/* ============================================================================
   Universal Till — base stylesheet.
   Design tokens live in :root so themes (built-in or plugin) can re-skin the
   POS by overriding variables and/or the pos-container grid areas.
   ========================================================================== */

:root {
  --brand: #1e293b;        /* nav / headings accent */
  --accent: #2563eb;       /* primary action */
  --accent-contrast: #ffffff;
  --bg: #f1f5f9;           /* page background */
  --surface: #ffffff;      /* cards, tables */
  --surface-2: #f8fafc;    /* subtle wells inside cards */
  --border: #e2e8f0;
  --text: #0f172a;
  --muted: #64748b;
  /* ut-docs#305: --border alone is ~1.2:1 against --surface -- fine for
     decorative dividers/cards (WCAG 1.4.11 doesn't cover those), but a form
     control's own boundary is a UI component the spec DOES cover, and needs
     3:1. --control-border reuses --muted (already >=3:1 against every
     curated theme's white --surface) rather than a one-off literal, so it
     tracks --muted automatically if a theme ever retunes it. */
  --control-border: var(--muted);
  /* ut-docs#797: the focus-state border of a form control is a UI component
     boundary WCAG 1.4.11 covers, so it needs >=3:1 against --surface too.
     --accent clears that in default (5.17:1) and slate (6.70:1), so those keep
     using it via this token's default; amber/fresh/monarch override
     --focus-border with a darker same-hue shade (their raw --accent is only
     ~2.5-2.8:1) rather than darkening --accent itself, which paints ~15 other
     non-boundary spots (buttons, prices, tabs) that 1.4.11 doesn't govern. */
  --focus-border: var(--accent);
  --success: #16a34a;
  --danger: #dc2626;
  --warning: #d97706;
  /* ut-docs#2192: semantic-tint BACKGROUND colours, split out from
     --success/--danger/--warning themselves (deliberately — a theme that
     retunes the ink/border colour above shouldn't silently move every tint
     background too). Every existing tint rule below now reads one of
     these three instead of a literal rgba(), so a theme can override the
     wash without touching the ink. Defaults are exactly the literal
     values every call site used before this token existed, so the four
     themes that never override these (amber/fresh/monarch/slate) and
     dark.css (overrides --danger only, not the tint) render pixel-
     identical to before; monochrome.css overrides all three to a neutral
     grayscale wash instead. */
  --success-tint: rgba(22, 163, 74, .12);
  --danger-tint: rgba(220, 38, 38, .1);
  --warning-tint: rgba(217, 119, 6, .12);
  /* ut-docs#2207: same split as the tint BACKGROUNDS above, one step later
     — the notice/tag/block BORDER colours (.pos-notice.success/.error,
     .notice-block-warn/-success) were still a literal rgba() at .35 alpha,
     so Monochrome's tint fix left a colourless wash with a colored outline.
     Defaults are exactly the literal values every call site used before
     this token existed, so the four themes that never override these
     render pixel-identical to before; monochrome.css overrides all three
     to a neutral grayscale border instead, same alpha, same reasoning as
     its tint override just above. */
  --success-tint-border: rgba(22, 163, 74, .35);
  --danger-tint-border: rgba(220, 38, 38, .35);
  --warning-tint-border: rgba(217, 119, 6, .35);
  --radius: 10px;
  --radius-lg: 14px;
  --shadow: 0 1px 2px rgba(15, 23, 42, .06), 0 1px 3px rgba(15, 23, 42, .08);
  /* ut-docs#2308: sell-screen basket/products divider geometry. Read by
     BOTH sides of the feature so the numbers only live here: app.css's own
     `.pos-container` grid-template-columns (the real, foolproof clamp —
     the grid simply refuses to size either track past what these allow,
     regardless of what JS or a persisted setting asks for) and the drag
     handler's own live clamp (index.html, via getComputedStyle at drag
     time) that keeps the divider from visibly fighting that CSS floor
     mid-drag. Also mirrored (for the value the settings API accepts
     before it's ever persisted) in internal/pages/common/state.go's
     MinBasketPanelWidthRem/MaxBasketPanelWidthRem — keep all three in
     sync by hand if any of this ever changes. Never overridden by a
     built-in theme (web/public/themes/*.css) — this is layout, not
     palette — but nothing stops a theme plugin from doing so, same as any
     other custom property here. */
  --pos-basket-min-w: 22rem;      /* matches ut-docs#213's basket-column floor */
  --pos-basket-max-w: 60rem;      /* generous sanity ceiling; real day-to-day limit is whatever leaves --pos-products-min-w free */
  --pos-products-min-w: 16.5rem;  /* two 7rem .grid tiles + .5rem gap + .products' own .7rem/.8rem padding, plus a small margin */
  --pos-divider-w: 1.5rem;        /* >=24px hit area even though the visible grip is thinner (ut-docs#2308 AC2) */
  /* ut-docs#2873: the ONE popup backdrop colour -- every modal dialog's
     native ::backdrop and the shared #ut-scrim behind every non-modal
     popup (base.html) read it. A theme retunes it here (dark.css does). */
  --ut-scrim: rgba(15, 23, 42, .45);
}

/* ---------- Base ---------- */
* { box-sizing: border-box; }
/* Fluid root size: 17px at a 1024px-wide till up to 20px at 1920px, so
   text/touch-targets (almost everything else is rem-based) scale with the
   viewport instead of the fixed 10-inch-only baseline from #144
   (ut-docs#161). Two-point linear fit anchored at (1024px, 17px) and
   (1920px, 20px): slope = 3/896 ≈ .00335px per viewport px.
   min() with a matching vh-based curve so a WIDE-but-SHORT till (e.g.
   1920x800) doesn't inflate every rem past the tender panel's fixed
   vertical budget (independent review of ut-docs#161 caught this live:
   width-only growth clipped the Cash/Card/Gift Card buttons on exactly
   that class of screen) — height, not just width, bounds the growth.
   Composed with --ui-scale (base.html, login.html, setup.html — from the
   operator's Settings > UI scale choice, default 1) so that existing
   manual scale-up still layers on top of the automatic viewport fit
   instead of replacing it. --ui-scale is still unset on the remaining
   standalone kiosk screens (self_order.html, self_order_shop.html,
   order_tracking.html — ut-docs#1126 found and fixed login/setup, ut-docs#1175
   tracks these three) that still set an inline px font-size directly —
   that inline value always wins over this rule there, so the "1" fallback
   below is inert for them, not load-bearing. */
:root { --fluid-fs: clamp(17px, min(13.57px + 0.335vw, 13.25px + 0.625vh), 20px); }
html { font-size: calc(var(--ui-scale, 1) * var(--fluid-fs)); }
body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif,
    "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", "Segoe UI Symbol";
  background: var(--bg);
  color: var(--text);
  line-height: 1.45;
  -webkit-font-smoothing: antialiased;
}
h1 { font-size: 1.45rem; font-weight: 700; letter-spacing: -0.01em; margin: 0; }
h2 { font-size: 1.02rem; font-weight: 600; margin: 0 0 .6rem 0; color: var(--brand);
     text-transform: uppercase; letter-spacing: .04em; }
h3 { font-size: 1rem; font-weight: 600; margin: .2rem 0; }
a { color: var(--accent); }
/* Column layout so the sale screen fills the viewport exactly below the
   nav, whatever height the nav ends up (one row or two). */
body { display: flex; flex-direction: column; min-height: 100dvh; margin: 0; }
.container { max-width: 1500px; margin: 0 auto; padding: 1rem 1.25rem 2rem; width: 100%; }
/* Sale screen fills the full viewport width — no dead side gutters on wide
   till displays (ut-docs#161). Admin/reports/settings pages keep the
   1500px reading-width cap above. */
body.sale-screen .container { max-width: none; }
/* ADR-0098: #ut-page (the boosted page region: rail + notices + main) is
   the body's flex child now; it is itself the same flex column, so main's
   own rules below read exactly as they did when main was body's child. */
#ut-page { display: flex; flex-direction: column; flex: 1 0 auto; min-height: 0; }
/* Main grows to fill the viewport so the status bar never floats mid-screen
   on short pages (menu, settings, …). */
#ut-page > main.container { flex: 1 0 auto; }
/* Sale screen gets a DEFINITE height (not just min-height) so the grid's
   1fr rows resolve and the basket scrolls internally instead of growing
   and pushing the tender panel off-screen. */
body.sale-screen { height: 100dvh; overflow: hidden; }
body.sale-screen #ut-page { flex: 1 1 auto; }
body.sale-screen #ut-page > main.container { flex: 1 1 auto; min-height: 0; display: flex;
  flex-direction: column; padding-top: .6rem; padding-bottom: 0; }
/* ---------- Touch: the till is an operator surface, not a document ----------
   ut-docs#1021 — root-caused on the physical Pi 5 till (WebKitGTK 2.52,
   labwc/Wayland), where the full input stack was verified healthy layer by
   layer (libinput: touch caps OK; compositor: native wl_touch confirmed with
   a raw client; plain pages: kinetic touch panning works in the SAME shell).
   The reported "cannot scroll anywhere, dragging highlights text" is a
   WebKit gesture-arbitration fall-through: when a touch drag has nothing to
   pan in its direction — most commonly a downward drag while already at the
   top of the page, which is many operators' first gesture — WebKit falls
   back to treating the drag as a mouse selection drag. Once a selection
   exists, subsequent drags EXTEND it instead of panning, so the whole page
   feels unscrollable (reproduced deterministically with synthetic evdev
   gestures: up-drag pans, down-drag-at-top selects).
   The cure is the one ut-docs#1170 already applied to .btn, widened to what
   a POS actually is: nothing outside editable fields is ever meant to be
   text-selected. With no selection to fall into, an unpannable drag simply
   does nothing (standard touch-UI feel) and panning is unaffected —
   verified on the same device: down-drag-at-top selects nothing, up-drag
   still pans. Editable/copyable content opts back in below; -webkit- prefix
   because WebKitGTK is the engine this was proven on. */
body { -webkit-user-select: none; user-select: none; }
/* Editing surfaces always keep native selection — caret/selection inside a
   field is part of typing, on every input kind. */
input, textarea, [contenteditable] {
  -webkit-user-select: text; user-select: text;
}
/* Copyable ID-shaped values (<code>/<pre>: store/device IDs, audit JSON,
   translation keys, enrol codes) stay selectable ONLY where a precise
   pointer exists (independent review finding): on the touch till a <code>
   chip at the top of /settings would otherwise re-open the exact
   selection-drag fall-through this block exists to close — the canonical
   down-drag-at-top starting on it. A till has no clipboard destination
   anyway; desktops keep copyability. */
@media (pointer: fine) {
  code, pre { -webkit-user-select: text; user-select: text; }
}
/* The customer order-tracking page is an anonymous customer's OWN PHONE,
   not an operator surface (order_tracking.html's own header, ut-docs#527)
   — a customer may legitimately select/copy their receipt number. The
   operator-surface rationale above deliberately does not apply there
   (independent review finding). */
body.tracking-screen { -webkit-user-select: text; user-select: text; }

.muted { color: var(--muted); }
.empty { text-align: center; color: var(--muted); padding: .75rem 0; }
.tag {
  display: inline-block; padding: .15rem .55rem; border-radius: 999px;
  font-size: .75rem; font-weight: 600; background: var(--surface-2);
  color: var(--muted); border: 1px solid var(--border);
}
/* A tag flagging something that needs attention (e.g. a failing bug-report
   upload, ut-docs#637) — same warn tint .sync-banner below uses, reused
   here rather than inventing a second warning color. */
.tag.warn { background: var(--warning-tint); color: var(--warning); border-color: var(--warning); }
/* A tag for a healthy live state (the Tills roster's "Linked", ut-docs#2742)
   — the success tint; the word itself carries the meaning. */
.tag.ok { background: var(--success-tint); color: var(--success); border-color: var(--success); }

/* ---------- Navigation: left icon rail (>480px), top bar (<=480px) ----------
   2026-08-30 (ut-docs#1332): converted from an always-horizontal top bar,
   after repeated product-owner feedback comparing directly against a
   competitor POS (SumUp) — a full-width row at the top of EVERY page (plus,
   on the sale screen specifically, a second row for New Sale/Inventory/
   Deposit refund) cost a fixed slice of vertical height nowhere else in the
   app needed, on the screen operators spend the most time looking at.

   Default (>480px, real tablet/kiosk/desktop widths): `.nav` is a fixed
   left rail, full viewport height, `--rail-width` wide. `.nav-primary`
   (Till/Menu/Inventory/+ the sale-screen-only New-Sale/Deposit-refund) sits
   at the top; `.nav-right` (help/bugreport/sync/fiscal/session chips) is
   pushed to the bottom via `margin-block-start: auto` in the same column.
   Logical properties throughout (`inset-inline-start`, `padding-inline-
   start`) so the rail sits on the RIGHT for RTL locales (fa/ar) with no
   separate rule — "left or right" was the product owner's own framing.

   <=480px (phone width): reverts to the previous horizontal top bar
   wholesale, including its own hard-fought wrapping-to-two-rows behavior
   (ut-docs#413) — a permanent ~8.5rem-wide side rail would eat too much of
   a 360px screen, and that width class already has its own settled,
   independently-tested layout not worth re-litigating here. */
:root { --rail-width: 4.5rem; }
/* ut-docs#2364: the <=480px top-bar fallback (see the big media block
   below) wraps to a variable number of rows depending on content/locale
   -- currently 3 at 360px -- so anything anchored below it needs the
   REAL rendered height, not a guessed rem value (a guessed 4.6rem, tuned
   for an old 2-row bar, is exactly what caused this bug). Same pattern as
   public/osk.js's --osk-reserved-height: a small IIFE in app.js measures
   .nav's real getBoundingClientRect().height and overwrites this via
   documentElement.style.setProperty. This fallback is a safe pre-JS
   guess (roughly the measured 3-row height) so there's no flash of
   overlap before that first measurement lands. */
:root { --topbar-h: 12rem; }
.nav {
  background: var(--brand); color: #fff;
  padding: .6rem .5rem;
  display: flex; flex-direction: column; align-items: stretch; gap: .4rem;
  position: fixed; inset-inline-start: 0; top: 0; bottom: 0;
  inline-size: var(--rail-width);
  overflow-y: auto;
  z-index: 100;
  box-shadow: 1px 0 4px rgba(0,0,0,.25);
}
/* Rail content pushes the page's own content (and the footer statusbar)
   over by the rail's width — .nav itself is `position: fixed`, so it's
   unaffected by this padding on its own ancestor. */
body { padding-inline-start: var(--rail-width); }
/* ...but only pages that actually RENDER the rail should pay for it. The
   rule above is on bare `body`, while `.nav` comes from base.html — and
   login.html / setup.html / order_tracking.html are standalone pages that
   load app.css WITHOUT base.html, so they have no rail at all. Independent
   review (2026-08-31) measured `padding-inline-start: 80.36px` with no
   `.nav` in the DOM on /login and /setup at 1280x800 and 1024x600: since
   `body.login-screen` centres its card with `justify-content: center`, the
   card was centred inside a content box shifted 4.5rem inward, i.e. drawn
   ~40px off true centre on the first screen a new shop owner ever sees.
   (self_order*.html escape this only by accident — their own `padding`
   shorthand happens to reset it — so they are not listed here.) */
body.login-screen, body.tracking-screen { padding-inline-start: 0; }
.statusbar { padding-inline-start: calc(var(--rail-width) + 1rem); }

/* ============================================================================
   ut-docs#2223 — page & menu transitions "like an app".

   Cross-document CSS View Transitions, as a progressive enhancement only:
   an engine without support (`@view-transition` unknown) keeps today's
   instant full-document swap. Every real target has it — pilot tablet
   Android WebView 151/Chrome 153, Pi 5 `unitill-desktop` = WebKitGTK 2.52
   (probed on the device; NOT the Chromium 152 that merely sits installed
   there), Windows shell WebView2 153.

   The OPT-IN (`@view-transition { navigation: auto }`) lives ONLY in
   base.html's inline <head> block, not here, for two reasons found on
   real targets: (1) Chrome on the pilot tablet read the incoming page's
   opt-in before this 244K stylesheet had arrived and aborted every
   transition INTO a till page ("ViewTransition opt-in disabled"); (2) the
   pages that load this file WITHOUT base.html (login, setup, self-order,
   order tracking) have none of the script's guards -- reduced-motion
   skip, watchdog, settled promises -- so an opt-in here gave them
   transitions that surfaced as uncaught "Transition was skipped" errors
   and, in headless Chromium, never revealed at all. The rules below are
   inert on a page that has not opted in.

   WHAT MOVES (ADR-0122, superseding ADR-0118 §2's push — ut-docs#2939,
   #2942): the ROOT snapshot — the whole document MINUS the two named
   groups below — as an iOS zoom from the tapped element, over
   --ut-motion-ms (400ms Full / 200ms Balanced, eased
   cubic-bezier(.32,.72,0,1): fast start, long soft landing). FORWARD: the
   incoming page grows out of the tapped tile/link/button — uniform scale
   --ut-zoom-s (source width / viewport width, clamped .1–1) about its
   centre, translated so that centre starts on the source's centre
   (--ut-zoom-x/--ut-zoom-y, viewport px), from opacity .4 (never blank) —
   while the outgoing page recedes underneath (scales to .92, drops 3%,
   dims to .55) on the black backdrop. BACK: the page being left shrinks
   into the box it grew from and fades out ON TOP; the previous page
   comes back up from the receded state underneath. The three properties
   are set on <html> by base.html's shell script (htmx:beforeTransition
   for the boosted hop, pagereveal for a full load) from the origin
   recorder / the sessionStorage back store; with no origin they default
   to the bottom centre below. Custom properties on <html> are consumed
   only by these keyframes, so setting them changes no layout and the
   ADR-0118 §1 snapshot-before-swap still holds. The same rules drive the
   boosted same-document transition and the cross-document fallback, on
   every engine.
   `.nav` (rail) and `.statusbar` are named view-transition groups pinned
   to "no animation, always drawn from the new page, above the zooming
   root", so the rail reads as fixed furniture (ut-docs#1830).

   Deliberately NOT named: <main>, or anything else that hosts a
   `position: fixed` dialog. A `view-transition-name` makes its element a
   stacking context with layout containment, which turns it into the
   containing block for every fixed-position descendant — the payment
   overlay, #hold-modal, #elevation-modal, .item-form-modal
   all live inside <main> and every one of them stopped being reachable
   the moment <main> carried a name (found by e2e bugreport-panel.spec.ts,
   confirmed by toggling the single declaration; the property has this
   effect with NO transition running). The rail and statusbar host no
   fixed descendants (checked), which is why they can be named.

   Direction (forward vs back) is decided by the inline `pagereveal`
   script in base.html's <head> (must run before first paint, so it can't
   live in app.js, which is `defer`) and, for a boosted hop, by the shell's
   htmx:beforeSwap — it sets `data-nav-dir` on <html> and, where
   supported, `vt.types.add('back')` so `:active-view-transition-type(back)`
   flips the animation; the `html[data-nav-dir="pop"]` selectors are the
   same flip via a plain attribute, belt-and-braces for an engine with
   View Transitions but not the newer `types` API. NB: written with NO
   descendant combinator — every ::view-transition-* pseudo originates on
   <html> itself, so `:root ::view-transition-new(x)` can never match.
   RTL needs nothing: the zoom has no side, it starts from the tapped
   element's own (already mirrored) box — no left/right literal anywhere. */
:root {
  --ut-motion-ms: 400ms; --ut-motion-ease: cubic-bezier(.32,.72,0,1);
  --ut-zoom-x: 50vw; --ut-zoom-y: 100vh; --ut-zoom-s: .3;
}
.nav        { view-transition-name: ut-rail; }
.statusbar  { view-transition-name: ut-statusbar; }
/* Fixed furniture: no cross-fade at all, new image simply covers the old.
   `mix-blend-mode: normal` matters as much as `animation: none`: the UA
   stylesheet blends old+new with `plus-lighter` (right for a cross-fade
   whose opacities sum to 1, wrong for two opaque images -- they ADD and
   the bar flashes brighter); and the OLD image is hidden outright (the
   images are `inline-size: 100%; block-size: auto`, so at phone width --
   sticky top bar -- an old bar taller than the new one would otherwise
   peek out below it for the whole transition). */
::view-transition-old(ut-rail), ::view-transition-new(ut-rail),
::view-transition-old(ut-statusbar), ::view-transition-new(ut-statusbar) { animation: none; mix-blend-mode: normal; }
::view-transition-old(ut-rail), ::view-transition-old(ut-statusbar) { opacity: 0; }
/* `.nav` precedes <main> in DOM order, so without an explicit stacking
   order the zooming root snapshot paints OVER the rail/statusbar for the
   400ms it animates -- confirmed live, this is the fix. */
::view-transition-group(ut-rail), ::view-transition-group(ut-statusbar) { animation-duration: 0s; z-index: 2; }
/* Never block input (ADR-0118 §3, kept by ADR-0122): the transition
   overlay sits on top of the real, already-swapped DOM for the whole
   400ms; without this a tap during the motion hits the overlay
   (retargeted to <html>) and is lost. */
::view-transition { pointer-events: none; }
/* Content = the root pair (ADR-0122). Uniform scale + translate and
   opacity only (compositor-only, no layout, no clipping mask — not
   compositor-only on every engine we ship). `mix-blend-mode: normal`: the
   UA blends the pair with plus-lighter (right for a cross-fade whose
   opacities sum to 1), but here an opaque page grows OVER a dimmed one —
   additive blending would flash the overlap brighter, worst on dark
   themes. The dim reads against the black ::view-transition backdrop
   (ADR-0118 §2, kept), which is only ever visible around the receded
   (scaled) page. */
::view-transition { background: #000; }
::view-transition-old(root), ::view-transition-new(root) {
  mix-blend-mode: normal;
  animation-duration: var(--ut-motion-ms);
  animation-timing-function: var(--ut-motion-ease);
  animation-fill-mode: both;
  transform-origin: 50% 50%;
}
/* Forward: old recedes underneath, new grows out of the origin on top (DOM
   order of the pair already paints new above old). `translate(... - 50%)`
   is relative to the snapshot image's own box, so the image's centre lands
   exactly on the source centre at scale --ut-zoom-s. */
::view-transition-old(root) { animation-name: ut-page-recede; }
::view-transition-new(root) { animation-name: ut-page-zoom-in; }
@keyframes ut-page-zoom-in  { from { transform: translate(calc(var(--ut-zoom-x) - 50%), calc(var(--ut-zoom-y) - 50%)) scale(var(--ut-zoom-s)); opacity: .4; } to { transform: none; opacity: 1; } }
@keyframes ut-page-recede   { from { transform: none; opacity: 1; } to { transform: translateY(3%) scale(.92); opacity: .55; } }
/* Back: the page being left (old) shrinks into the box it came from and
   fades out ON TOP; the previous page (new) comes back up from the receded
   state underneath it. */
:root:active-view-transition-type(back)::view-transition-old(root) { animation-name: ut-page-zoom-out; z-index: 1; }
:root:active-view-transition-type(back)::view-transition-new(root) { animation-name: ut-page-return; }
html[data-nav-dir="pop"]::view-transition-old(root) { animation-name: ut-page-zoom-out; z-index: 1; }
html[data-nav-dir="pop"]::view-transition-new(root) { animation-name: ut-page-return; }
@keyframes ut-page-zoom-out { from { transform: none; opacity: 1; } to { transform: translate(calc(var(--ut-zoom-x) - 50%), calc(var(--ut-zoom-y) - 50%)) scale(var(--ut-zoom-s)); opacity: 0; } }
@keyframes ut-page-return   { from { transform: translateY(3%) scale(.92); opacity: .55; } to { transform: none; opacity: 1; } }

/* In-page htmx swaps ease instead of blinking -- zero latency: no swap
   delay, no settle-timing dependency (ut-docs#239's defaultSettleDelay:0
   stays untouched). app.js's htmx:afterSettle listener (re)applies this
   class to the swapped-in element for OPERATOR-caused swaps only; starts
   from .55 (not 0) so a cashier scanning fast is never left waiting for
   content that's already there. */
.ut-swap-fx { animation: ut-swap-in 150ms ease-out both; }
@keyframes ut-swap-in { from { opacity: .55; } to { opacity: 1; } }

/* ADR-0122 §4 (ut-docs#2943): the rail-driven tree panes (#items-panel,
   #admin-panel, #manual-panel) zoom out of the tapped tree item -- a
   TRANSIENT Web Animations transform in app.js (zoomPane), never a CSS
   rule here: a persistent transform on a pane would make it the containing
   block of its position:fixed dialogs (#2338's hazard, which
   TestAppCSSNamesOnlyTheFixedRailAndStatusbar still guards). Panes and
   popups use the shorter small-zoom duration (ADR-0122 §1). The
   CLOSE alone uses --ut-zoom-close-ms, not this one (ADR-0123, #2987): the
   open and close eased on the same fast duration left the popup 2/3 shrunk
   and 2/3 transparent after 60ms, so the shrink needed a longer duration of
   its own -- the open is unchanged. */
:root { --ut-zoom-small-ms: 300ms; --ut-zoom-close-ms: 400ms; }

/* ADR-0122 §5 (ut-docs#2944): every popup (<dialog>, sheet, picker) grows
   from the tapped element and shrinks back into it -- base.html's ONE
   shared popup motion, a transient Web Animations transform, never a CSS
   rule here. After the native close (the page already takes input) the
   SAME element is painted again for at most --ut-zoom-close-ms (ADR-0123,
   #2987 -- its own, slower duration/ease than the open) with
   data-ut-closing, inert and aria-hidden while it shrinks: fixed at the box
   it last had open (the custom properties base.html sets), in its open
   display type, no pointer events, above the rail (100) and the on-screen
   keyboard (#osk, 1000). !important because it must beat every popup's own
   closed-state rule (the UA's dialog:not([open]), .record-dialog:not([open]),
   .bugreport-panel) and id-keyed frame (#hold-modal, #pfand-modal, ...).
   `transform` is deliberately NOT important: the shrink animates it (an
   important declaration would beat the animation); this only drops the
   popup's own transform (e.g. .shrinkage-sheet's centring), which the
   recorded box already includes. Logical properties, so RTL needs nothing
   more (base.html measures the inline-start offset per direction). */
html [data-ut-closing] {
  display: var(--ut-closing-display, block) !important;
  position: fixed !important;
  inset: auto !important;
  inset-block-start: var(--ut-closing-t) !important;
  inset-inline-start: var(--ut-closing-is) !important;
  inline-size: var(--ut-closing-w) !important;
  block-size: var(--ut-closing-h) !important;
  min-inline-size: 0 !important; min-block-size: 0 !important;
  max-inline-size: none !important; max-block-size: none !important;
  margin: 0 !important;
  box-sizing: border-box !important;
  overflow: hidden !important;
  pointer-events: none !important;
  z-index: 1500 !important;
  translate: none !important; rotate: none !important; scale: none !important;
  transform: none;
  transition: none !important;
}

/* Reduced motion: ONE block, kills everything -- the new view-transition
   rules above AND every pre-existing `transition:`/`animation:` rule
   already in this file (.menu-tile, .tab/.tab-active, etc.), via the
   wildcard rather than an enumerated list, so a future motion rule added
   anywhere in app.css is automatically covered too. */
@media (prefers-reduced-motion: reduce) {
  /* (the `@view-transition { navigation: none }` half of this lives next
     to the opt-in in base.html) */
  ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none !important; }
  *, *::before, *::after { transition-duration: 0s !important; animation-duration: 0s !important; scroll-behavior: auto !important; }
  /* Progress spinners are essential motion (WCAG 2.3.3 exempts them):
     frozen at 0s they read as "hung". Keep them turning. */
  #refresh-indicator, .animate-spin { animation-duration: 1s !important; }
  /* ADR-0122 §6 backstop: base.html never re-shows a closing popup under
     reduced motion; should one ever be marked, it is not painted. */
  html:root [data-ut-closing] { display: none !important; }
}

/* ADR-0119 (ut-docs#2859): the per-till visual effects level. base.html
   renders exactly one fx-full | fx-balanced | fx-light class on <html>
   (Auto is resolved on the server, never rendered). Only paint and motion
   change between levels -- never layout, sizes, touch targets or theme
   colours. prefers-reduced-motion (above) stays the floor at every level.
   Theme/layout plugins gate their own costly effects on the same class
   (html:not(.fx-light), .fx-balanced); the Light wildcard below covers
   motion, shadow, blur and filter even for a plugin that ignores it.

   LIGHT ("super light", for a Raspberry Pi or a weak/old machine): no
   animation or transition at all -- the same wildcard shape as the
   reduced-motion block, and JS (UT.motionOff) never even creates the View
   Transition; no shadow, blur or filter; flat colours instead of the
   gradients below (explicit selectors, never a universal
   background-image: none -- icons can be background images); press
   feedback is a colour change, not a scale. */
html.fx-light::view-transition-group(*), html.fx-light::view-transition-old(*), html.fx-light::view-transition-new(*) { animation: none !important; }
html.fx-light *, html.fx-light *::before, html.fx-light *::after {
  transition-duration: 0s !important; transition-delay: 0s !important;
  animation: none !important;
  scroll-behavior: auto !important;
  box-shadow: none !important; text-shadow: none !important;
  filter: none !important;
  -webkit-backdrop-filter: none !important; backdrop-filter: none !important;
}
/* ADR-0122 §6 backstop: no closing popup is ever re-shown under Light. */
html.fx-light [data-ut-closing] { display: none !important; }
/* Essential motion stays (WCAG 2.3.3, as under reduced motion). */
html.fx-light #refresh-indicator, html.fx-light .animate-spin { animation: spin 1s linear infinite !important; }
/* Function stays: these state rings are drawn with an inset/spread
   box-shadow (a border, not a shadow), so they come back after the
   wildcard -- selected category, pressed picker tile, checked colour, the
   suggested icon, the caps-lock key, a row being dragged. */
html.fx-light .osk-caps { box-shadow: inset 0 0 0 2px var(--accent-contrast) !important; }
html.fx-light .category-overflow-tile.active { box-shadow: 0 0 0 2px var(--cat-color, var(--accent)) inset !important; }
html.fx-light .picker-tile[aria-pressed="true"] { box-shadow: inset 0 0 0 2px var(--accent) !important; }
/* Its own rule: an engine without :has() drops only this one. */
html.fx-light .designer-cat-colors .item-color-tile:has(input:checked) { box-shadow: inset 0 0 0 2px var(--accent) !important; }
html.fx-light .builtin-icon-tile.builtin-icon-suggested { box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px var(--accent) !important; }
html.fx-light .category-row.is-dragging > td { box-shadow: inset 0 2px 0 var(--accent), inset 0 -2px 0 var(--accent) !important; }
html.fx-light .designer-cat.is-dragging { box-shadow: inset 0 0 0 1px var(--accent) !important; }
/* Gradients and textures -> flat. */
html.fx-light .pos-container > .products { background-image: none; }
/* (The item editor's tab-bar scroll fades, ut-docs#2024, stay: they are the
   only cue that tabs sit off-screen -- an affordance, not decoration.) */
html.fx-light .busy-bar { background: var(--accent, #2563eb); }
html.fx-light .trust-official { background: #fbbf24; }
/* Press feedback: a colour change only, no scale. */
html.fx-light .btn-tile:not(.tile-colored):active,
html.fx-light .category-tile:active,
html.fx-light .category-overflow-tile:active,
html.fx-light .picker-tile:active { transform: none; background-color: var(--surface-2); }
/* A coloured tile keeps its white label legible: darken its own colour. */
html.fx-light .btn-tile.tile-colored:active { transform: none; background: color-mix(in srgb, var(--tile-color) 80%, #000); }
html.fx-light .designer-cat.is-dragging { transform: none; }
/* No lift on the dragged tile (the drag's own inline translate still wins). */
html.fx-light .jiggle-mode .tile-cell > .btn-tile.dragging { transform: none; }

/* BALANCED: the short opacity swap fade (.ut-swap-fx 150ms) stays; the
   tree-pane and popup zooms (ADR-0122) are cut to 200ms; the ADR-0122 page
   zoom stays but is cut to ADR-0097's original 200ms budget (ADR-0122 amends
   ADR-0119 §3); no backdrop blur;
   one small shadow instead of large or layered ones; no drag lift or
   jiggle (the one decorative looping animation). `filter` itself is left
   alone: core uses it only for the hidden-tile grayscale and hover
   brightness, neither of them a blur. */
html.fx-balanced { --ut-motion-ms: 200ms; --ut-zoom-small-ms: 200ms; --ut-zoom-close-ms: 240ms; --shadow: 0 1px 2px rgba(15, 23, 42, .08); }
html.fx-balanced *, html.fx-balanced *::before, html.fx-balanced *::after {
  -webkit-backdrop-filter: none !important; backdrop-filter: none !important;
}
html.fx-balanced .nav,
html.fx-balanced #osk,
html.fx-balanced .toast,
html.fx-balanced .payment-overlay,
html.fx-balanced .login-card,
html.fx-balanced .bugreport-panel,
html.fx-balanced .btn-tile:hover,
html.fx-balanced .category-tile:hover,
html.fx-balanced .category-overflow-tile:hover { box-shadow: 0 1px 3px rgba(15, 23, 42, .18); }
html.fx-balanced .jiggle-mode .tile-cell > .btn-tile { animation: none; }
html.fx-balanced .jiggle-mode .tile-cell > .btn-tile.dragging { transform: none; box-shadow: 0 1px 3px rgba(15, 23, 42, .18); }
html.fx-balanced .designer-cat.is-dragging { transform: none; box-shadow: inset 0 0 0 1px var(--accent); }
/* ============================================================================ */

/* ut-docs#2873: every till popup gets a full-screen dimmed backdrop that
   blocks taps behind it. Two families, one colour (--ut-scrim, :root):
   - a showModal() dialog: its native ::backdrop (top layer; the rest of
     the page is already inert). Since ut-docs#2097 that is only the
     self-order kiosk's #selforder-modal -- every till dialog is non-modal
     (guard-no-showmodal.sh). The fallback is for an engine whose
     ::backdrop does not inherit custom properties yet.
   - a non-modal .show() dialog (the #1385 on-screen-keyboard family, z
     500/550) or a UT.popupOpened() panel: the ONE shared #ut-scrim
     (base.html), shown by base.html's popup hooks while any such popup is
     open. z 450: above the page and the nav rail (100), below every
     non-modal popup (>=500), the on-screen keyboard (#osk, 1000) and
     toasts. No blur, no fade (ADR-0119: the cheapest possible paint).
   The status bar stays reachable (CLAUDE.md "Offline-first", ut-docs#1999):
   while the scrim is up it is lifted to 460 -- above the scrim, still
   below every popup. So does Lock: the nav rail is lifted to 460 too,
   dimmed by its own ::after and click-through (pointer-events: none, so
   a tap on it lands on the scrim) -- except the Lock form, which stays
   live (ut-docs#1999/#2873; exit-to-OS lives in Settings, not the rail;
   self-order kiosk pages have no rail). Tap rule: a tap on the scrim does nothing unless the
   topmost popup opts in with data-ut-scrim-dismiss; a popup the cashier
   keeps working beside opts out with data-ut-no-scrim. */
/* An engine whose ::backdrop does not inherit custom properties
   (Chromium < 122, WebKit < 17.4) gets the light fallback in every theme. */
dialog::backdrop { background: var(--ut-scrim, rgba(15, 23, 42, .45)); }
#ut-scrim { position: fixed; inset: 0; z-index: 450; background: var(--ut-scrim); touch-action: none; }
#ut-scrim[hidden] { display: none; }
html.ut-scrim-on .statusbar { z-index: 460; }
/* The docked payment panel opts out (data-ut-no-scrim) so the cashier works
   beside it -- but a popup opened FROM it (its Hold button) must block it
   too: while the scrim is up it drops just under it (review, ut-docs#2873).
   Its own content holds no dialog, so nothing nested is pushed under. */
html.ut-scrim-on #payment-overlay[data-ut-no-scrim] { z-index: 449; }
html.ut-scrim-on .nav { z-index: 460; pointer-events: none; }
html.ut-scrim-on .nav::after { content: ""; position: absolute; inset: 0; background: var(--ut-scrim); pointer-events: none; }
html.ut-scrim-on .nav .session-lock { position: relative; z-index: 1; pointer-events: auto; }

/* ut-docs#298: .nav's background is always var(--brand) — dark in every
   shipped theme (fresh/slate/amber/monarch all set it near-black/navy) — so
   the light-glyph mark (unitill-logo-light.svg, transparent background)
   sits directly on it with no backing plate. */
.logo { display: flex; flex-direction: column; align-items: center; gap: .3rem;
        padding: .3rem .35rem .5rem; }
/* rem, not px: the logo must follow --ui-scale / the fluid baseline like
   everything else (ut-docs#213 — it was the nav's last fixed-px sizing,
   illegibly small on a 10-inch high-density till). */
.logo img { height: 1.6rem; display: block; }
/* App name: visible in the horizontal phone-width bar (space for it there),
   visually-hidden in the rail — SumUp's own reference rail carries no
   brand text either, and the logo mark alone (always on screen) already
   carries the brand at this width. NOT display:none: independent review
   (2026-08-30) caught that hiding it outright leaves the brand name
   announced by nothing in the nav for assistive tech — same
   .visually-hidden pattern this file already uses for basket.html's
   item-count label, just applied unconditionally by default now instead
   of only below 480px. */
.app-name {
  font-size: 1.1rem; font-weight: 700; white-space: nowrap;
  position: absolute; width: 1px; height: 1px; margin: -1px;
  padding: 0; overflow: hidden; clip-path: inset(50%); border: 0;
}
.nav-primary { display: flex; flex-direction: column; gap: .3rem; }
.nav-right { display: flex; flex-direction: column; align-items: stretch; gap: .3rem;
             margin-block-start: auto; padding-block-start: .4rem;
             border-block-start: 1px solid rgba(255,255,255,.15); }
.nav-toggle {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: .15rem; background: rgba(255,255,255,.12);
  color: #fff; border: 0; text-decoration: none;
  border-radius: 10px; padding: .5rem; min-height: 48px; min-width: 48px;
  font-weight: 600; cursor: pointer; text-align: center;
}
.nav-toggle:hover, .nav-toggle:active { background: rgba(255,255,255,.22); }
/* ut-docs#3000: instant tap feedback (base.html's shell script). .ut-pressed
   is set synchronously on pointerdown -- a touch :active can lag or never
   show on a quick tap -- and .ut-nav-pending holds the tapped item while its
   boosted page loads. Colour only, no transform/animation, so it is the
   same under Light effects and reduced motion (ADR-0119). */
.nav-toggle.ut-pressed, .nav-toggle.ut-nav-pending { background: rgba(255,255,255,.22); }
/* 2026-09-02 (ut-docs#1423, seen on the tablet next to the icon-size
   report): the two rail items that are <button>s (bug-report toggle, Lock)
   rendered ~48px wide against ~60px for the <a> tiles — a <button>
   shrink-wraps its content even as a stretch flex item / inside a block
   <form>, so it needs an explicit inline-size to match. Reset to auto in
   the <=480px top bar, where every control sits in a row. */
.nav button.nav-toggle { inline-size: 100%; }
/* 2026-08-30 (product owner, live on the tablet): fixed box, centered
   content -- a fixed-size flex box normalizes each icon's footprint.
   2026-09-02 (ut-docs#1423): the icons themselves are now inline SVG
   ({{ icon }}, internal/httpx/icons.go), not emoji. Two rounds of
   per-glyph emoji font-size tuning (#1332, #1348's `.ico-boost`) each
   passed on desktop Chromium and each still left 🔒/🐞 visibly smaller on
   the real tablet, because every platform's colour-emoji font pads each
   glyph differently. One vector set, one CSS size, identical everywhere;
   `.ico-boost` is gone with it. font-size is kept only so a stray text
   node can never render huge. */
.nav-toggle-ico {
  display: inline-flex; align-items: center; justify-content: center;
  inline-size: 1.6rem; block-size: 1.6rem;
  font-size: 1.25rem; line-height: 1;
  /* ut-docs#1539: anchors .nav-badge below (an alert dot). ut-docs#1729
     narrowed what lights it on the primary's sync chip: a pending pairing
     request or a quarantined entry — states a human resolves — and no
     longer a merely stale/offline satellite, which nothing can clear.
     No other .nav-toggle-ico has a positioned descendant, so this costs
     them nothing. */
  position: relative;
}
.nav-toggle-ico svg { inline-size: 1.5rem; block-size: 1.5rem; display: block; }
/* 2026-08-30 (product owner, live on the tablet): icon-only in the rail,
   like SumUp's own reference rail — the label doesn't need to be visible
   when the icon alone reads fine at this size, and dropping it lets the
   rail itself be much narrower (was 8.5rem to fit visible labels, now
   4.5rem). NOT display:none/removed from the DOM: the same
   .visually-hidden treatment .app-name already uses above keeps each
   label in the accessible name (screen readers read it exactly as if it
   were visible) at zero layout cost, and the <=480px block below restores
   it fully visible for the top-bar fallback, which still has the width
   for it. */
.nav-toggle-label {
  position: absolute; width: 1px; height: 1px; margin: -1px;
  padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
}
/* ut-docs#3352 / ADR-0137: the shell Back (nav.html) points the way the
   page reads — its arrow mirrors in RTL, like .products-strip-back's. */
html[dir="rtl"] .nav-back .nav-toggle-ico svg { transform: scaleX(-1); }
/* An action, not a destination: no tile behind it, so it doesn't read as a
   fifth rail page (tap/hover feedback still tints it). */
.nav-back { background: transparent; }
/* ut-docs#1539: a small alert indicator on a rail icon (offline/queued
   replica, quarantined sync entries, a stale roster, a degraded fiscal
   signer) — replaces the old .sync-chip.warn/.fiscal-chip.warn
   whole-button recolouring the product owner explicitly rejected ("if it
   is a menu button it should be correct"). The 1px border is --brand (the
   rail's true background, ut-docs#298) — one layer further out than the
   .nav-toggle it actually sits against (a translucent
   rgba(255,255,255,.12) tint over --brand), so the ring reads slightly
   darker than its immediate surround rather than an exact match; still
   enough separation to read as sitting ON the icon rather than merging
   into it, without adding a second custom color for a 1px ring alone. */
.nav-badge { position: absolute; inset-block-start: -.1rem; inset-inline-end: -.1rem;
  inline-size: .55rem; block-size: .55rem; border-radius: 50%;
  background: var(--warning); border: 1px solid var(--brand); }

@media (max-width: 480px) {
  /* Reverts the rail back to the previous horizontal top bar wholesale —
     see this block's own top-level comment for why. */
  .nav {
    position: sticky; inset-inline-start: auto; top: 0; bottom: auto;
    inline-size: auto; flex-direction: row; flex-wrap: wrap; row-gap: .4rem;
    justify-content: space-between; align-items: center; gap: 1rem;
    padding: .55rem 1.25rem; box-shadow: 0 1px 4px rgba(0,0,0,.25);
    /* The rail's own `overflow-y: auto` (default rule) turned out to break
       this sticky bar's own height calculation for its wrapped rows --
       confirmed live: .nav-right rendered a real ~10px BELOW .nav's own
       bottom edge instead of inside it once .nav-primary's wrap pushed
       .nav-right onto its own row, making the right-most chip (bugreport
       toggle, same place the real Lock button sits with a session) fail
       its own hit-test despite passing every plain bounding-box check.
       position: sticky + a non-visible overflow on the same element is a
       known interaction to avoid; visible is exactly what the previous,
       working top-bar CSS always had here. */
    overflow-y: visible;
  }
  body { padding-inline-start: 0; }
  .statusbar { padding-inline-start: 1rem; }
  .logo { flex-direction: row; gap: .55rem; padding: .15rem .35rem; }
  .logo img { height: 2.4rem; }
  .app-name { position: static; width: auto; height: auto; margin: 0;
    padding: 0; overflow: visible; clip-path: none; }
  .nav-primary { flex-direction: row; flex-wrap: wrap; gap: .4rem; }
  .nav-right { flex-direction: row; flex-wrap: wrap; align-items: center;
    gap: .5rem; margin-inline-start: .5rem; margin-block-start: 0;
    padding-block-start: 0; border-block-start: 0; }
  .nav-toggle { flex-direction: row; gap: .4rem; border-radius: 10px;
    padding: .6rem 1.1rem; min-height: 46px; font-size: 1rem; }
  .nav-toggle-ico { inline-size: auto; block-size: auto; font-size: 1rem; }
  .nav button.nav-toggle { inline-size: auto; }
  /* Undoes the rail's visually-hidden treatment (default rule, above) --
     the top-bar fallback has room for a real visible label, same as it
     always did before the rail existed. */
  .nav-toggle-label {
    position: static; width: auto; height: auto; margin: 0;
    padding: 0; overflow: visible; clip-path: none; white-space: nowrap;
    font-size: 1rem;
  }
}

/* Menu page: the tiles ARE the navigation — drop the redundant admin link
   from the session chip; keep logo, ☰, operator and Lock. */
body.menu-screen .session-admin-link { display: none; }

/* Menu launcher page: a grid of large touch tiles. */
.menu-grid {
  display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
  gap: 1.1rem; margin-top: 1rem;
}
/* ADR-0088: a layout plugin's group heading — spans the whole grid row
   (grid-column, not a left/right offset, so RTL needs nothing extra). */
.menu-group {
  grid-column: 1 / -1; margin: .4rem 0 -.5rem; font-size: 1rem; font-weight: 600;
  color: var(--muted); text-align: start;
}
.menu-tile {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: .7rem; min-height: 160px; padding: 1.4rem;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius-lg); box-shadow: var(--shadow);
  color: var(--text); text-decoration: none; text-align: center;
  /* ut-docs#1334 (review): .menu-tile is now used on a <button> (the deposit-
     refund tile) as well as on <a>. A <button> does NOT inherit body's font,
     so without this it rendered its label in the UA default (measured:
     font-family "Arial", vs the app stack every sibling tile gets). Every
     .menu-ico is a drawn SVG glyph now (ut-docs#1845 removed the last
     emoji fallback here), so this no longer matters for the icon itself,
     but the label font still needs it. Same normalisation .btn-tile/.nav-toggle
     already apply for the same reason; a no-op for the <a> tiles, which
     inherit these values already. */
  font: inherit; cursor: pointer;
  transition: transform .06s ease, border-color .15s ease;
}
.menu-tile:hover, .menu-tile:active { border-color: var(--brand); transform: translateY(-2px); }
.menu-tile.ut-pressed, .menu-tile.ut-nav-pending { border-color: var(--brand); }
.menu-ico { font-size: 2.9rem; line-height: 1; }
/* ut-docs#1720/#1845: every tile icon is a drawn glyph now (menu_page.go's
   iconSVGFor), never an emoji. Sized in absolute units rather than in em,
   for the same reason ut-docs#1423 moved the rail off emoji at all: an
   emoji's drawn size inside its em-square differs per platform font, so
   matching it via font-size would drift between the tablet and desktop
   Chromium exactly as the old per-glyph size tuning did. .menu-tile already
   centres its children. */
.menu-ico svg { inline-size: 2.9rem; block-size: 2.9rem; display: block; }
.menu-label { font-size: 1.2rem; font-weight: 600; }
.btn-touch { min-height: 46px; display: inline-flex; align-items: center; }

/* Small inline icon inside a text button/link (ut-docs#1845) — the phone-
   width sale-screen shortcuts (index.html's .kiosk-actions) that duplicate
   nav.html's rail actions, sized to the surrounding text via em rather than
   .menu-ico's fixed box (there's no separate label span to anchor to here,
   the icon sits inline with its own text). */
/* No vertical-align here (a flex item ignores it, and every .btn/.a.btn is
   display:inline-flex already — this class only needs to size its own
   svg child); the flex gap on .btn/.menu-lang-label supplies the spacing
   next to the label text once the whitespace text node between the spans
   collapses. */
.btn-ico { display: inline-flex; }
.btn-ico svg { inline-size: 1em; block-size: 1em; display: block; }

/* Language switcher on the touch menu page — big, obvious buttons so a
   Turkish/Persian operator can find their language at a glance. */
.menu-lang { margin-top: 1.6rem; display: flex; flex-wrap: wrap; align-items: center; gap: .75rem; }
.menu-lang-label { font-weight: 600; color: var(--muted); display: inline-flex; align-items: center; gap: .35rem; }
.menu-lang-ico svg { inline-size: 1.1em; block-size: 1.1em; display: block; }
.menu-lang-btns { display: flex; flex-wrap: wrap; gap: .5rem; }
/* ut-docs#3086: Settings → Languages shown to staff. Whole label is the
   touch target; wraps for long native names and many languages. */
.staff-lang-list { display: flex; flex-wrap: wrap; gap: .5rem 1rem; margin-block: .5rem; border: 0; padding: 0; min-inline-size: 0; }
.staff-lang-opt { display: inline-flex; align-items: center; gap: .5rem; min-block-size: 44px; padding-inline: .25rem; cursor: pointer; }
.staff-lang-opt input { inline-size: 1.25rem; block-size: 1.25rem; }
/* ut-docs#1125: no text-transform/letter-spacing here. Both were written for
   the old two-letter code labels ("EN", "FA"); now that the label is the
   language's own native name they actively damage it — uppercase turns
   "Türkçe" into the shouted "TÜRKÇE", and letter-spacing on Arabic-script
   names ("العربية", "فارسی") pulls apart glyphs that must stay cursively
   joined, which reads as broken text to a native speaker. */
.menu-lang-btn { min-width: 64px; justify-content: center; font-weight: 700; }

/* Sale screen is touch-first and the busiest screen in the product: the
   manager links (Users/Promotions/Translations) live in the ☰ menu already,
   so drop them from the rail here too, keeping it shorter. The Lock
   button/operator name themselves no longer need their own sizing override
   now every .nav-toggle (rail or top-bar fallback) already gets a uniform
   size everywhere -- a sale-screen-only exception here would make the rail
   look inconsistent between pages instead of it being one predictable
   strip of chrome. */
body.sale-screen .session-admin-link { display: none; }

/* ---------- Reports ---------- */
.report-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 1rem; }
.kpi { text-align: center; padding: 1rem; }
.kpi-label { font-size: .78rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
.kpi-value { font-size: 1.6rem; font-weight: 700; color: var(--brand); margin-top: .15rem; }
/* ut-docs#421: which report tab is open had no visual cue at all. Repeats
   the :hover pairing (not just .active alone) because .btn.secondary:hover
   below shares this rule's specificity and comes later in the cascade —
   without it, hovering the active tab (a real mouse, not just a touch/kiosk
   tap) would silently fall back to the plain unselected-hover gray. */
.report-tabs .btn.active,
.report-tabs .btn.active:hover { background: var(--accent); color: var(--accent-contrast); border-color: var(--accent); }

/* ---------- Back-office pages (catalog / inventory) ---------- */
.page-head { display: flex; align-items: center; justify-content: space-between;
             gap: 1rem; margin: .25rem 0 1rem; }
/* ut-docs#2092: page-scoped override for .page-head usages whose action
   row is more than a title + one small control (currently just catalog.html)
   — lets the row drop to its own line at narrow widths instead of both
   flex children being squeezed onto one, never-wrapping line. */
.page-head-wrap { flex-wrap: wrap; }
.page-head input[type="search"] { min-width: 260px; }
/* ut-docs#2165: keeps a .page-head's search box and its category-filter
   trigger together as ONE space-between child. Without it .page-head's own
   justify-content spreads every direct child evenly, putting a full 1rem+
   gap between a box and the control the manual calls "beside" it. Logical
   gap only — nothing directional to mirror under RTL. */
.page-head-search-group { display: flex; align-items: center; gap: .5rem; min-inline-size: 0; }

/* ---------- Plugin content pages (FAQ etc.) ---------- */
.plugin-content-search { display: block; min-width: 260px; max-width: 100%;
                          margin: 0 0 1rem; }
/* ut-docs#2892: a plugin's static content/index.html renders in an
   empty-sandbox iframe. With no scripts it can't report its own height, so
   it fills the viewport below the page title and scrolls inside. The frame
   document pads itself (plugin_page.go), so the card has no padding here. */
.plugin-page-frame { display: block; inline-size: 100%; padding: 0;
                     block-size: calc(100dvh - 9rem); min-block-size: 18rem;
                     margin-block-start: .6rem; }
/* ut-docs#1901: the add/edit-item form moved into a full-screen <dialog>
   (#item-form-modal below), so the list no longer shares this row with a
   permanent 360px side panel — single column at every width now, not just
   under the old 900px breakpoint. */
.catalog-layout { display: grid; grid-template-columns: minmax(0, 1fr);
                  gap: 1rem; align-items: start; }
/* The items list scrolls INSIDE its card (sticky header) instead of making
   the page kilometres tall — the detail panel below stays one glance away. */
.catalog-list #catalog-table { max-block-size: min(34rem, 62vh); overflow-y: auto; }
.catalog-list #catalog-table thead th { position: sticky; inset-block-start: 0;
                                        background: var(--surface); z-index: 1; }
.catalog-row { cursor: pointer; }
.catalog-row:hover td { background: var(--surface-2); }
.catalog-name { font-weight: 600; }

/* ---------- Tills / pairing (ut-docs#1548) ---------- */
/* Same escape-hatch pattern .users-list .table / .settings-grid .card .table
   already use — with even ONE pending request, the pending-pairings table
   pushed the WHOLE PAGE past the viewport, forcing the operator to scroll
   sideways to reach Approve. This rule is the load-bearing half of that
   fix; re-measured during independent review with it removed and one
   seeded request: 97px over at 1024x600, 117px at 1280x800 (the PIN-dedup
   markup change alone does NOT fix it — it was still 97px over).
   e2e/tests/tills-pairing-layout-1548.spec.ts seeds a real pending request
   precisely so it fails on that, rather than measuring an empty list. */
.pairing-table { display: block; overflow-x: auto; }
/* ut-docs#2781: the roster gained a Role column and a per-till Change role
   button; with the link/version columns and a long (tr/ar) label it can
   outgrow a 1024px kiosk the same way — same escape hatch, scrolling the
   table instead of the page. */
.tills-roster { display: block; overflow-x: auto; }
/* "Join as" choice on the join forms (tills.html, setup.html). */
.join-role { border: 0; padding: 0; margin-block: .6rem; }
.join-role label { display: block; margin-block: .3rem; }
.join-role small { display: block; margin-inline-start: 1.6rem; }
/* Narrow the manager-PIN box from the ~245px an `input`'s default size=20
   plus this sheet's padding gives it, so the device/code columns keep more
   of the row. NOT about the placeholder: an <input> never grows to fit its
   placeholder text, so the (untrusted, unbounded) device name embedded in
   it was never what widened the row. Cosmetic on its own — the rule above
   is what stops the page overflowing — but it means the table needs its
   internal scroll far less often. max-width keeps the 12rem from itself
   overflowing a narrower kiosk viewport. */
.pairing-table input[type="password"] { width: 12rem; max-width: 100%; }
/* Generic required-field affordance: `required` alone is invisible until
   submit (ut-docs#1548) — a placeholder that looks like a filled value
   (e.g. "Till 2") reads as already-answered, which is what produced every
   "pairing failed" report in ut-docs#1540. Toggled by a page-local script
   on the field's `invalid`/`input` events, not by :invalid alone, which
   would paint the border red before the operator has touched anything.
   Logical margin so the message hangs under the field in RTL too. */
.field-invalid { border-color: var(--danger); }
.field-error-msg { color: var(--danger); font-size: .85rem;
                   margin-block: .2rem 0; margin-inline: 0; }
.journal-row { cursor: pointer; }
.journal-row:hover td { background: var(--surface-2); }
/* .catalog-form-head: see the .item-form-modal block below (ut-docs#1956
   made it the dialog's pinned action bar). */

/* Store claim code (Settings): big, readable off the till screen. */
.claim-code-box { display: flex; flex-direction: column; gap: .3rem; }
.claim-code { font-family: ui-monospace, monospace; font-size: 1.9rem; font-weight: 700;
              letter-spacing: .15em; }

/* On-screen keyboard (touch tills / kiosk — osk.js). Fixed to the bottom,
   big touch targets; body gets padding so the focused field stays visible. */
#osk { position: fixed; inset-inline: 0; inset-block-end: 0; z-index: 1000;
       display: none; padding: .45rem .5rem calc(.45rem + env(safe-area-inset-bottom, 0px));
       background: var(--surface-2); border-block-start: 1px solid var(--border);
       box-shadow: 0 -4px 16px rgba(0,0,0,.12); user-select: none; touch-action: manipulation; }
#osk.osk-open { display: block; }
/* ut-docs#1998: the reserved space used to be a hardcoded 15.5rem, which
   drifted ~1.45rem short of #osk's real rendered height (measured 288px at
   this build's 17px root font-size) — silently masked by an unrelated
   container's own bottom padding rather than actually correct. `--osk-
   reserved-height` is written by osk.js's show() from #osk's OWN
   getBoundingClientRect().height every time it opens, so this tracks the
   real keyboard regardless of root font-size, safe-area-inset, or any
   future change to the keyboard's own layout — a hardcoded number (even a
   corrected one) would just drift again the next time any of those change.
   The static fallback below only matters if JS somehow never ran. */
body.osk-padded { padding-block-end: var(--osk-reserved-height, 17rem); }
.osk-row { display: flex; justify-content: center; gap: .3rem; margin-block-end: .3rem; }
.osk-key { flex: 1 1 0; max-inline-size: 4.2rem; min-block-size: 2.9rem;
           font-size: 1.15rem; border: 1px solid var(--border); border-radius: .5rem;
           background: var(--surface); color: inherit; cursor: pointer; }
.osk-key:active { background: var(--accent); color: var(--accent-contrast); }
.osk-fn { max-inline-size: 6rem; font-size: .95rem; background: var(--surface-2); }
.osk-space { flex: 5 1 0; max-inline-size: 22rem; }
.osk-on { background: var(--accent); color: var(--accent-contrast); }
/* ut-docs#1835: caps latched (double-tap Shift) needs to read as visibly
   different from the one-shot .osk-on above — an operator glancing at the
   key mid-word needs to tell "still armed for one letter" from "stays on
   until I tap again" without waiting to see what the next letter does.
   --warning vs --accent is a clear hue difference on every theme except
   `amber` (whose own --accent IS an orange, close to --warning's amber) —
   checked all four shipped theme plugins (Tester step). The inset ring
   below is theme-invariant (drawn in --accent-contrast, white on every
   theme, never redefined) so the two states stay tellable apart by SHAPE
   even where their hues nearly collide, and color is never the only
   signal (osk-on has no ring at all). */
.osk-caps { background: var(--warning); color: var(--accent-contrast);
            box-shadow: inset 0 0 0 2px var(--accent-contrast); }
.osk-toggle { flex: 0 0 auto; font-size: 1.2rem; line-height: 1; }

/* Touch tills: no pointer arrow parked over the sale screen (cursor.js —
   a real mouse movement removes the class, the next touch restores it). */
html.cursor-hidden, html.cursor-hidden * { cursor: none !important; }

/* Catalog detail panel: full-width under the list — item info + barcode
   chips on one side, the variants list (editable grid) as the main area. */
/* ut-docs#1956: no top margin — it now sits inside the item form's
   Variants tab panel (.catalog-form-body carries the padding), not as a
   page-level card under the list. */
.catalog-detail { margin-block-start: 0; }
.catalog-detail-grid { display: grid; grid-template-columns: 280px minmax(0, 1fr);
                       gap: 1.25rem; align-items: start; }
@media (max-width: 900px) { .catalog-detail-grid { grid-template-columns: minmax(0, 1fr); } }
/* ut-docs#1900 (found on the option-set panel's 360px driven run): the
   variants column is a grid item, and a grid item's automatic minimum size
   is its min-content — which .vg-cols' min-inline-size: 54rem sets at
   ~918px. So the column ignored its minmax(0, 1fr) track and overflowed
   the card at every viewport below that (the COST column clipped even at
   the 1024 kiosk width), and .variant-grid's own overflow-x: auto never
   got to scroll because its parent was already wide enough. min-inline-
   size: 0 lets the column shrink to its track; the grid then scrolls
   inside it as that rule intended, and everything above the grid (the
   apply/generate block) wraps at the real viewport width. The 1fr track
   in the ≤900px rule above is minmax(0, 1fr) for the same reason (plain
   1fr means minmax(auto, 1fr), which re-grows the track to min-content). */
.catalog-detail-variants { min-inline-size: 0; }
.catalog-detail-title { display: flex; align-items: center; gap: .6rem; margin-block-end: .6rem; }
.catalog-detail-title h3 { margin: 0; }
.catalog-detail-title .thumb { inline-size: 44px; block-size: 44px; object-fit: cover;
                               border-radius: 8px; border: 1px solid var(--border); }
.catalog-detail-label { font-size: .75rem; font-weight: 600; text-transform: uppercase;
                        letter-spacing: .04em; color: var(--muted); margin-block-end: .4rem; }

/* ---------- Item customization admin (ADR-0020) ---------- */
.catalog-detail-modifiers { margin-block-start: 1rem; }
/* ut-docs#2379 review finding: this button sits right after the inherited
   section above it (modifier_group_admin.html) with no rule of its own —
   same 1rem convention as its siblings above, not a new value. */
.catalog-detail-modifiers-empty-cta { margin-block-start: 1rem; }
.catalog-routing-override { margin-block: .2rem; }
.modifier-admin-group { border: 1px solid var(--border); border-radius: var(--radius);
                         padding: .6rem .75rem; margin-block-end: .6rem; }
.modifier-admin-group.vg-inactive { opacity: .55; }
.modifier-admin-group-row, .modifier-admin-option-row {
  display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; margin-block-end: .35rem;
}
/* Same reason as `.set-row` (ut-docs#300): the Min/Max labels here wrap a
   number input, so the global stacking rule would make them two-line and leave
   this compact wrap-row unevenly tall next to its one-line neighbours. */
.modifier-admin-group-row label,
.modifier-admin-option-row label { flex-direction: row; align-items: center; }
.modifier-admin-group-row input[type="text"] { flex: 1 1 10rem; min-inline-size: 8rem; }
.modifier-admin-option-row { padding-inline-start: 1rem; }
.modifier-admin-option-row input[type="text"] { flex: 1 1 8rem; min-inline-size: 6rem; }
.modifier-admin-option-row.vg-inactive { opacity: .55; }
.modifier-admin-options { margin-block-start: .35rem; }
.modifier-admin-group-new { border: 1px dashed var(--border); border-radius: var(--radius);
                             padding: .6rem .75rem; }
/* ut-docs#2330: item-editor attach-only multi-select — a checkbox list, not
   a native <select multiple> (touch browsers handle multi-select poorly).
   .modifier-admin-options-list reuses .modifier-inherited-options' own
   flex-basis so the two read-only rows (own groups here, inherited groups
   further down this file) size their comma-separated option text the same
   way. .modifier-attach-option gets the full stack's own column layout so
   each checkbox row is a full-width tap target, not just the checkbox
   itself — same 44px floor as .held-chip/.modifier-option elsewhere in this
   file, kiosk included (this row is plain flex, not an <input>/<select>, so
   body.kiosk's own min-height rules for those two elements don't reach it). */
/* Independent-review finding: this rule was missing entirely, so the name
   span fell back to flex's own 0 1 auto default next to
   .modifier-admin-options-list's 2 1 12rem sibling above — shrinking to
   content, losing weight, and not lining up with .modifier-inherited-name
   directly below it in the DOM despite the doc comment above claiming
   parity. Same values as that class. */
.modifier-admin-group-name { flex: 1 1 10rem; min-inline-size: 8rem; font-weight: 600; }
.modifier-admin-options-list { flex: 2 1 12rem; font-size: .85rem; }
.modifier-admin-attach-multi { flex-direction: column; align-items: stretch; }
/* Reset the browser's own default fieldset/legend chrome (border, inset
   padding) — this is a layout grouping for screen readers, not a visual
   frame; `.catalog-detail-label`'s own rule already gives the legend its
   text styling. */
.modifier-attach-fieldset { border: 0; margin: 0; padding: 0; min-inline-size: 0; }
.modifier-attach-fieldset legend { padding: 0; }
.modifier-attach-option { display: flex; flex-direction: row; align-items: center; gap: .5rem;
                           min-height: 44px; padding-inline: .25rem; }
.modifier-attach-option input[type="checkbox"] { inline-size: 1.25rem; block-size: 1.25rem;
                                                  flex: none; }
/* ---------- /modifiers: one card per shop-wide group (ADR-0101, ut-docs#2399) ---------- */
/* The page is a plain stack of cards (ut-docs#1899's review: no empty
   editor column at kiosk width); each card is the group's CRUD form, its
   option rows, and an "Assigned to" block of category checkboxes + item
   chips. Logical properties only (RTL). Touch targets: every control a
   finger lands on — the category checkbox labels, the item chips' remove
   button, the "Add item" row — is 2.75rem tall under body.kiosk (the
   documented 1024x600 floor), same bar .modifier-attach-option sets just
   above; outside kiosk they follow the surrounding form's own rhythm. Chips
   wrap, so a group assigned to twenty items stacks instead of overflowing
   at 360px. */
.modifiers-cards { display: flex; flex-direction: column; gap: .75rem; max-inline-size: 44rem; }
/* ut-docs#2399 (Tester): the create card and the group cards share one
   column width, so the page reads as a single list rather than a
   full-width form above a narrower stack. */
.modifiers-new-group-card { max-inline-size: 44rem; }
/* ut-docs#2406 (independent review): the unassigned-cleanup toolbar had no
   rules of its own, so it rendered as a full-width block card above the
   44rem stack — the very "full-width form above a narrower stack" the
   comment above fixed for the create card — and its three inline children
   collapsed to a single space between them, reading as one run-on
   sentence ("Show only unassigned 2 unassigned group(s)") at the 1024x600
   kiosk floor. Flex + gap + the shared column width; logical properties
   only, so RTL needs no extra CSS. The filter label gets the same 2.75rem
   touch target every other tap target on this page has under body.kiosk. */
.modifiers-unassigned-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: .75rem; max-inline-size: 44rem; }
.modifiers-unassigned-toolbar .btn { margin-inline-start: auto; }
.modifiers-filter-unassigned { display: inline-flex; align-items: center; gap: .4rem; }
body.kiosk .modifiers-filter-unassigned { min-block-size: 2.75rem; }
.modifier-card-footer { display: flex; justify-content: flex-end; margin-block-start: .6rem; padding-block-start: .6rem; border-block-start: 1px solid var(--border); }
.modifier-card.vg-inactive { opacity: .7; }
.modifier-card.vg-inactive .modifier-assign { opacity: .85; }
.modifier-delete-btn { margin-inline-start: auto; }
.modifier-assign { margin-block-start: .75rem; padding-block-start: .6rem; border-block-start: 1px solid var(--border); }
.modifier-assign-fieldset { border: 0; margin: 0 0 .5rem; padding: 0; min-inline-size: 0; }
.modifier-assign-fieldset legend { padding: 0; font-size: .8rem; margin-block-end: .25rem; }
.modifier-assign-row { display: flex; flex-wrap: wrap; gap: .4rem .5rem; align-items: center; }
/* A category checkbox rendered as a pill — the whole label is the tap
   target, not just the 1.25rem box inside it. */
.modifier-assign-cat { display: inline-flex; flex-direction: row; align-items: center; gap: .4rem;
                       padding-block: .3rem; padding-inline: .6rem; cursor: pointer;
                       font-size: .85rem; font-weight: 500; color: var(--text); }
.modifier-assign-cat input[type="checkbox"] { inline-size: 1.15rem; block-size: 1.15rem; flex: none; margin: 0; }
.modifier-assign-cat:has(input:checked) { background: var(--success-tint); border-color: var(--accent); }
.modifier-assign-cat.vg-inactive { opacity: .55; }
/* An assigned item: its name (a deep link into the catalog) plus a compact
   remove control on the trailing edge. */
.modifier-assign-item { display: inline-flex; flex-direction: row; align-items: center; gap: .35rem;
                        padding-block: .25rem; padding-inline: .6rem .3rem; font-size: .85rem; font-weight: 500; }
.modifier-assign-item a { color: inherit; }
.modifier-assign-item.vg-inactive { opacity: .55; }
.modifier-chip-remove { border: 0; background: transparent; color: var(--muted); font-size: 1.1rem; line-height: 1;
                        inline-size: 1.75rem; block-size: 1.75rem; border-radius: 999px; cursor: pointer;
                        display: inline-flex; align-items: center; justify-content: center; padding: 0; }
.modifier-chip-remove:hover, .modifier-chip-remove:focus-visible { background: var(--surface-2); color: var(--text); }
.modifier-assign-add-item { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; margin-block-start: .5rem; }
.modifier-assign-add-item .modifiers-item-search { flex: 1 1 12rem; min-inline-size: 8rem; }
body.kiosk .modifier-assign-cat,
body.kiosk .modifier-assign-item { min-block-size: 2.75rem; }
body.kiosk .modifier-chip-remove { inline-size: 2.75rem; block-size: 2.75rem; }
body.kiosk .modifier-assign-add-item .btn,
body.kiosk .modifier-assign-add-item .modifiers-item-search { min-block-size: 2.75rem; }
/* ---------- Option sets (ut-docs#1900) ---------- */
/* The item panel's apply/generate block, above the variant grid. Flex-wrap
   so the checkbox row folds cleanly at 360px; logical properties only. */
.option-set-pick { border: 1px dashed var(--border); border-radius: var(--radius);
                   padding: .6rem .75rem; margin-block-end: .6rem; }
.option-set-pick-row { display: flex; flex-wrap: wrap; gap: .5rem .75rem; align-items: center;
                       margin-block-end: .35rem; }
.option-set-pick-row:last-child { margin-block-end: 0; }
.option-set-pick-label { font-size: .75rem; font-weight: 600; text-transform: uppercase;
                         letter-spacing: .04em; color: var(--muted); }
/* Same reason as .modifier-admin-group-row label: the global label rule
   stacks label text above its control; these wrap a checkbox inline. */
.option-set-pick-box { display: inline-flex; flex-direction: row; align-items: center; gap: .3rem; }
.option-set-pick-box:has(input:disabled) { opacity: .5; }
.option-set-pick-hint { font-size: .8rem; flex: 1 1 14rem; min-inline-size: 0; }
.option-set-pick-msg { margin: .35rem 0 0; font-size: .9rem; font-weight: 600; }
/* The shop-wide screen: a plain stack, like /modifiers. */
.option-sets-list { display: flex; flex-direction: column; gap: .75rem; max-inline-size: 36rem; }
.option-set-card .chip-add { margin-block-start: .45rem; }
/* Values are words ("Small"), not codes — override .chip's monospace. */
.option-set-chip { font-family: inherit; font-size: .85rem; }
.option-set-new { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; margin-block-start: 0; }
.option-set-new input[type="text"] { flex: 1 1 12rem; inline-size: auto; }
/* ut-docs#3319: rename / per-value edit, up, down, remove / used-by /
   activate + delete. Logical properties only (fa/ar RTL). Reorder is
   buttons, not drag (kiosk scroll gesture). */
.option-set-head { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; justify-content: space-between; }
.option-set-rename { display: flex; flex-wrap: wrap; gap: .35rem; align-items: center; flex: 1 1 14rem; min-inline-size: 0; }
.option-set-rename input[type="text"] { flex: 1 1 10rem; min-inline-size: 0; font-weight: 600; }
.option-set-status { white-space: nowrap; }
.option-set-error { margin: .4rem 0; padding: .35rem .55rem; border: 1px solid var(--warning-tint-border);
                    border-radius: var(--radius); background: var(--warning-tint); color: var(--text); font-weight: 600; }
.option-set-values { list-style: none; margin: .2rem 0 0; padding: 0; }
/* Two classes: .chip (declared below) would otherwise win back its
   monospace over .option-set-chip's override on source order alone. */
.option-set-values .option-set-chip { gap: .1rem; padding-block: .05rem; padding-inline: .45rem .2rem; font-family: inherit; }
.option-set-value-edit { display: inline-flex; margin: 0; }
/* Width follows the text: the size attribute (set from the value's length)
   for any engine, field-sizing where supported; bounded either way. */
.option-set-value-edit input[type="text"] { border: 0; background: transparent; padding: .1rem; font: inherit; color: inherit;
                                            inline-size: auto; field-sizing: content; min-inline-size: 2ch; max-inline-size: 14rem; }
.option-set-value-edit input[type="text"]:focus-visible { outline: 2px solid var(--focus, var(--accent)); outline-offset: 1px; }
.chip .option-set-chip-btn { inline-size: 1.75rem; block-size: 1.75rem; border-radius: 999px; font-size: .95rem;
                             display: inline-flex; align-items: center; justify-content: center; }
.chip .option-set-chip-btn:hover:not(:disabled),
.chip .option-set-chip-btn:focus-visible { background: var(--surface); color: var(--text); }
.chip .option-set-chip-btn:disabled { opacity: .35; cursor: default; }
.option-set-usedby { margin-block-start: .6rem; }
.option-set-usedby-item { text-decoration: none; color: inherit; }
.option-set-usedby-hint { margin: .35rem 0 0; font-size: .85rem; }
.option-set-card-footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .5rem; margin-block-start: .6rem;
                          padding-block-start: .6rem; border-block-start: 1px solid var(--border); }
body.kiosk .chip .option-set-chip-btn { inline-size: 2.75rem; block-size: 2.75rem; }
body.kiosk .option-set-value-edit input[type="text"],
body.kiosk .option-set-rename input[type="text"],
body.kiosk .option-set-card-footer .btn { min-block-size: 2.75rem; }

.chip-row { display: flex; flex-wrap: wrap; gap: .35rem; align-items: center; min-block-size: 1.6rem; }
.chip { display: inline-flex; align-items: center; gap: .35rem; padding: .12rem .55rem;
        border: 1px solid var(--border); border-radius: 999px; background: var(--surface-2);
        font-family: ui-monospace, monospace; font-size: .78rem; white-space: nowrap; }
.chip button { border: 0; background: none; cursor: pointer; padding: 0; line-height: 1;
               color: var(--muted); font-size: .78rem; }
.chip button:hover { color: #b91c1c; }
/* ut-docs#2648: the running-out-stock chip ("⚠ N item(s) predicted to run
   out…") reused .chip + this class, but .chip itself is the monospace CODE
   chip (variant/option-set values) and chip-warn had no rule of its own —
   inside an h1/h2 it also inherited the global heading rule's
   text-transform: uppercase + letter-spacing. Values here are words, not
   codes, and must not inherit either; a sentence may wrap on a phone.
   Text stays var(--text): --warning on its own tint is only ~2.6:1 in the
   light themes (the ⚠ and the tint carry the warning). */
.chip-warn { font-family: inherit; text-transform: none; letter-spacing: normal;
             font-weight: 600; background: var(--warning-tint); color: var(--text);
             border-color: var(--warning-tint-border); vertical-align: middle;
             white-space: normal; }
a.chip-warn { text-decoration: none; }
.chip-add { display: inline-flex; gap: .35rem; align-items: center; margin-block-start: .45rem;
            flex-wrap: wrap; }
.chip-add input[type="text"] { inline-size: 10rem; min-inline-size: 0; padding: .3rem .5rem; }
.chip-add-primary { display: inline-flex; gap: .3rem; align-items: center; font-size: .8rem;
                    color: var(--muted); }

/* Variants grid: one shared column template so header and rows always align. */
.vg-cols { display: grid; gap: .6rem; align-items: center;
           grid-template-columns: 3.4rem minmax(8rem, 1.3fr) minmax(6rem, 1fr) 6.5rem 6.5rem
                                  minmax(12rem, 1.7fr) 3.5rem 5.5rem; }
.vg-head { font-size: .75rem; font-weight: 600; text-transform: uppercase;
           letter-spacing: .04em; color: var(--muted); padding-block: .3rem; }
.vg-row { padding-block: .5rem; border-block-start: 1px solid var(--border); }
.vg-row input[type="text"], .vg-row input[type="number"] { inline-size: 100%; min-inline-size: 0;
                                                           padding: .35rem .5rem; }
.vg-row .chip-add { margin-block-start: 0; }
.vg-row .chip-add input[type="text"] { inline-size: 7.5rem; }
.vg-inactive > input, .vg-inactive > .chip-row { opacity: .55; }
.vg-new { background: var(--surface-2); border-radius: 8px; padding-inline: .5rem; }
/* Raised to 1300px (was 900): the 17px base (#144) widens .vg-cols'
   fixed-rem sum past a 1280px 10-inch screen, so arm the scroll earlier. */
@media (max-width: 1300px) {
  .variant-grid { overflow-x: auto; }
  .vg-cols { min-inline-size: 54rem; }
  /* ut-docs#1967: the commit action (Save / Save Variant) is .vg-cols' own
     rightmost column — inside the horizontal scroller armed just above, so
     it can sit off-screen while the *page* has no overflow at all (only the
     grid does), which is exactly why this survived a page-level overflow
     assertion. Stick that one column to the scroll container's trailing
     edge instead of trying to fit the whole row: reachable without ever
     scrolling, and — inset-inline-end being logical — correct under RTL
     too, where it resolves to left:0 and pins to the visual trailing edge.
     Deliberately scoped to the same breakpoint that arms the scroller:
     above it there is no overflow to stick against and nothing to cover,
     so no rule should apply at all.
     Only the header's trailing cell takes a background — it is an empty
     <div>, so without one the columns scrolling beneath it show through.
     The other two rows' trailing cell IS the .btn itself, which already
     paints its own opaque background over exactly that same box; setting
     `background` on them instead *overrides* .btn's accent fill (this
     selector outranks a bare `.btn`) and leaves white label text on a
     near-white button — which is how the add-variant row's "Save Variant"
     was rendered illegible before review. */
  .vg-cols > :last-child { position: sticky; inset-inline-end: 0; z-index: 1; }
  .vg-head > :last-child { background: var(--surface); }
}

/* ut-docs#1956 (independent review, F4): scoped to :not(.catalog-detail *).
   This rule predates #1956 and was correct while .catalog-form contained
   only the item's own fields — but #1956 moved catalog_variants.html INSIDE
   .catalog-form (it used to render at page level, under the list), and this
   selector's 0,1,1 specificity then beat .chip-add-primary's 0,1,0 and every
   bare <label> in that panel. Measured effect: every checkbox in the
   variants/modifiers panel put its box on the line ABOVE its own text and
   doubled in height (21px -> 44px) — "Primary", "Plain code", and each
   modifier group's Required/Active. The exclusion, not a narrower positive
   selector, because the panel arrives by htmx outerHTML swap and its inner
   structure is not this file's to track.
   ut-docs#1997: also excludes .field-checks and .field-checks label — same
   cascade collision, one level down. :not(.catalog-detail label)'s own
   specificity (0,1,1) makes the WHOLE selector (0,2,2), which beats
   .field-checks label's plain (0,1,1) below regardless of source order —
   so every checkbox this rule reaches (both a <label class="field-checks">
   itself, e.g. "Plain code"/"Set as primary", AND a bare <label> nested
   inside a <div class="field-checks"> container, e.g. isWeighed/
   stockUntracked/isActive on this same Details tab) was rendering box-
   above-text, not just the two most visually dramatic cases the bug report
   measured. Confirmed live: label:has(#item-active)'s computed
   flex-direction was "column" before this fix. */
/* ut-docs#2211: also excludes .modifier-attach-option — the attach picker's
   checkbox rows now render inside the item editor's own Modifiers tab
   (inside .catalog-form), and this rule's column layout stacked each box
   above its group name, centred, instead of the row
   .modifier-attach-option defines. */
.catalog-form label:not(.catalog-detail label):not(.field-checks):not(.field-checks label):not(.modifier-attach-option) {
  display: flex; flex-direction: column; gap: .2rem;
  font-size: .82rem; margin-bottom: .55rem;
}
/* Same ut-docs#1956 fix from the flex side: a flex item's min-width is
   `auto` (its content size) too, so the stretched control inside each
   column-flex label also needs an explicit zero minimum to follow its
   .field-pair column down at phone width. */
.catalog-form label:not(.catalog-detail label):not(.field-checks):not(.field-checks label):not(.modifier-attach-option) > input,
.catalog-form label:not(.catalog-detail label):not(.field-checks):not(.field-checks label):not(.modifier-attach-option) > select { min-inline-size: 0; }
/* minmax(0, 1fr), not bare 1fr (ut-docs#1956): 1fr is minmax(auto, 1fr),
   and a text <input>'s `auto` minimum is its intrinsic ~20ch width — at
   360px that held the SKU column open 18px past the full-screen item
   form's body. Zero minimum lets a column shrink below an input's default
   width; nothing that already fit changes. */
.field-pair { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: .5rem; }
/* Same [hidden]-override precedent as .field-checks below (and .btn/
   .suggest-strip/.pos-notice elsewhere): an author rule's own `display`
   always beats the UA `[hidden]` style regardless of specificity, so a
   `hidden`-attributed .field-pair would stay visibly grid-displayed. Not
   yet hit in shipped markup (ut-docs#1472's viewfinder deliberately keeps
   `hidden` on an unstyled wrapper instead), but guarded here so the next
   edit that puts `hidden` directly on a .field-pair doesn't fail silently. */
.field-pair[hidden] { display: none; }
.field-checks { display: flex; flex-wrap: wrap; gap: .5rem 1rem; margin: .3rem 0 .7rem; } /* wraps: a phone row of checkboxes (ut-docs#3297) */
/* ut-docs#1997: `.field-checks` is used two ways in this codebase — a
   <div> wrapping several plain <label> children (the rule above; the
   descendant selector here), AND a class on a single standalone <label>
   itself (catalog.html's "Plain code"/"Set as primary" — .field-checks
   label's own descendant selector never matched those, since the label
   IS .field-checks, not inside it). Both need the same row layout, so
   `label.field-checks` gets its own `display: flex` here rather than
   relying on the shrink-to-fit inline default it would otherwise fall
   back to once excluded from the blanket column rule above. Kept as a
   separate rule from `.field-checks label` (not folded into one
   selector list) only for `margin`: a descendant label sits inside the
   .field-checks DIV's own `.3rem 0 .7rem` margin above, so margin: 0
   there is correct, but a standalone label.field-checks has no such
   wrapper — same bottom spacing every other .catalog-form field gets. */
.field-checks label { display: flex; flex-direction: row; align-items: center; gap: .4rem; margin: 0; }
label.field-checks { display: flex; flex-direction: row; align-items: center; gap: .4rem; margin: 0 0 .55rem; }
/* Same [hidden]-override precedent as .btn/.suggest-strip/.pos-notice above:
   a class selector's own `display` always beats the UA `[hidden]` style, so
   without this a `hidden`-attributed .field-checks (tax_codes.html's
   isActive checkbox, meant to show only in edit mode) stays visibly
   flex-displayed on page load. Found live while testing ut-docs#259. */
.field-checks[hidden] { display: none; }
/* ut-docs#1901: the add/edit-item form is a <dialog> (#item-form-modal,
   opened via .show() from catalog.html) instead of a permanently-visible
   360px sticky side panel. Opened via .show(), NOT showModal() — same fix,
   same reasoning (ut-docs#1385) as #hold-modal/#pfand-modal/
   #elevation-modal/#table-add-modal/.payment-overlay above: showModal()'s
   top-layer/inert-outside behaviour makes the custom on-screen keyboard
   (#osk, appended to <body>, never re-parented into whichever dialog is
   open) completely unreachable, and this form has more OSK-triggering
   fields (barcode, name, price, SKU, description...) than any of those
   five. `position: fixed` replaces the native top-layer positioning
   .show() no longer provides; `z-index: 500` matches the same five for the
   identical reason, staying below #osk's `z-index: 1000` so the keyboard
   still renders on top of whichever field it's typing into here. No
   ::backdrop rule — a .show() dialog never paints one.

   ut-docs#1956 (product owner: "the forms are so confusing"): genuinely
   FULL-SCREEN now — `inset: 0`, no max-size, no border/radius — not the
   previous 94vw × 92vh floating card. Laid out as a flex column so the
   action bar is pinned WITHOUT position: sticky (sticky inside an
   overflow:auto dialog is exactly the arrangement that used to let the
   head scroll away):
     .item-form-modal        fixed, full-bleed, flex column, overflow hidden
       .catalog-form         flex: 1, min-block-size: 0, flex column
         .catalog-form-head  flex: none — title + delete | + New / Close /
                             Save, the notice row, then the tab strip
         .catalog-form-body  flex: 1, min-block-size: 0, overflow-y: auto —
                             the ONE scroll container, holding the panels
   All padding lives on the head and body, none on the dialog itself, so
   the pinned head's bottom rule runs edge to edge.
   `background` is explicit (independent review of #1901, N1) — same as
   .payment-overlay — rather than relying on the UA's dialog `canvas`
   default showing through. */
.item-form-modal { position: fixed; inset: 0; margin: 0; z-index: 500;
                    inline-size: 100%; max-inline-size: none;
                    block-size: 100%; max-block-size: none;
                    border: 0; border-radius: 0; box-shadow: none; padding: 0;
                    display: flex; flex-direction: column; overflow: hidden;
                    background: var(--surface); color: var(--text); }
/* Out-specifies the display:flex above (0,2,0 vs 0,1,0), so a closed
   dialog still renders nothing — verified, not assumed (the UA's own
   dialog:not([open]) rule would otherwise lose to the author rule). */
.item-form-modal:not([open]) { display: none; }
.item-form-modal .catalog-form { flex: 1 1 auto; min-block-size: 0; display: flex; flex-direction: column; }
.catalog-form-head { flex: none; display: flex; flex-direction: column; gap: .4rem;
                     padding: .6rem 1.5rem 0; border-block-end: 1px solid var(--border);
                     background: var(--surface); }
/* ut-docs#2211: the item's modifiers render inline in the item editor's
   Modifiers tab, inside .catalog-form-body — the one scroll container — so
   #item-modifiers-list needs no scroll/flex rules of its own (the nested
   dialog it replaced did, ut-docs#2330). */
/* One row at 1024×600 (the kiosk floor), wrapping into two at phone width:
   the title+delete group and the action group each keep their own
   children on one line; only the two GROUPS wrap relative to each other. */
.catalog-form-head-row { display: flex; flex-wrap: wrap; align-items: center;
                         justify-content: space-between; gap: .5rem .75rem; }
.catalog-form-head-title { display: flex; align-items: center; gap: .5rem;
                           flex: 1 1 12rem; min-inline-size: 0; }
.catalog-form-head-title h3 { margin: 0; min-inline-size: 0; overflow: hidden;
                              text-overflow: ellipsis; white-space: nowrap; }
.catalog-form-head-actions { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center;
                             margin-inline-start: auto; }
/* Icon-only delete (catalog.delete): a .btn.secondary in the danger colour,
   square, same 3rem touch floor as its neighbours; the Lucide glyph is
   sized by .btn-ico (above). Visible focus ring, since the label is
   aria-label/title only. */
.catalog-form-delete { flex: none; color: var(--danger); min-inline-size: 3rem; padding-inline: .65rem; }
.catalog-form-delete:hover { color: var(--danger); }
.catalog-form-delete:focus-visible { outline: 2px solid var(--focus, var(--accent)); outline-offset: 2px; }
.catalog-form-delete .btn-ico svg { inline-size: 1.3rem; block-size: 1.3rem; }
/* The notice row keeps its height when empty so a notice appearing never
   shoves the tab strip (and the whole body) down a line. */
.catalog-form-head #item-form-msg { min-block-size: 1.3rem; }
.catalog-form-head .tab-bar { border-block-end: 0; }
.catalog-form-body { flex: 1 1 auto; min-block-size: 0; overflow-y: auto; overflow-x: hidden;
                     padding: 1rem 1.5rem 1.5rem; }
/* The generic .tab-panel (below) is its own flex scroll box for the tender;
   here the BODY is the one scroll container, so panels are plain blocks —
   one scrollbar, never two nested ones. */
.catalog-form-body .tab-panel { display: block; min-block-size: 0; overflow: visible; flex: none; }
.catalog-form-body .tab-panel .item-form-needs-save { margin: 0; }
/* Create mode (the item does not exist yet, so nothing item-bound can
   work): each panel shows its "save this item first" hint and hides its
   controls. Edit mode: the reverse. Toggled by setMode() in catalog.html. */
.catalog-form.is-create .item-form-needs-item { display: none; }
.catalog-form:not(.is-create) .item-form-needs-save { display: none; }
@media (max-width: 700px) {
  .catalog-form-head { padding-inline: 1rem; }
  .catalog-form-body { padding-inline: 1rem; }
  /* ut-docs#2000: at 360px the pinned head was measured at 42% of the
     viewport (312px of 740) — three button rows (title+delete, then
     +New/Close/Save wrapping across two) plus a two-row tab strip. Two
     independent collapses bring that down without touching the kiosk-floor
     (1024x600) layout, which stays outside this query entirely:
     1. Close/Save go icon-only, same visual language as the delete
        button beside them (.catalog-form-delete, above) — the .btn-label
        span is visually hidden (same technique as the global
        .visually-hidden utility; not that class itself, since it must
        apply only under this query) while the button's own aria-label/
        title (always present, see catalog.html) still carry the
        accessible name. (+New stays text-only at every width — its own
        label already bakes in a "+", so it needs no icon and no
        collapsing; independent review finding F2.)
     2. The tab strip stops wrapping and becomes a single horizontally
        scrollable row instead — scoped to THIS tab-bar only
        (.catalog-form-head .tab-bar), not the generic .tab-bar component
        tills.html/index.html also use, which keep their own wrap
        behaviour. The roving-tabindex arrow-key handler (catalog.html)
        already moves focus tab-to-tab; browsers scroll a focused element
        into view automatically, so keyboard reachability is unaffected.
     Independent review (F3/F4) also found two focus-visibility gaps this
     query introduces and this block now closes: .catalog-form-delete
     already has its own :focus-visible ring (above) because its label is
     aria-label/title only, same as Close/Save become right here — but
     nothing gave THEM the matching ring; and .tab-bar's new
     overflow-y: hidden (needed so a horizontal scrollbar doesn't add
     height) was clipping the active tab's border-bottom indicator and
     the roving-tabindex focus ring both top and bottom, since .tab uses
     a negative margin-bottom to overlap the bar's own border. */
  .catalog-form-head-actions .btn .btn-label {
    position: absolute; inline-size: 1px; block-size: 1px; margin: -1px;
    padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
  }
  .catalog-form-head-actions .btn { min-inline-size: 3rem; padding-inline: .65rem; }
  .catalog-form-head-actions .btn .btn-ico svg { inline-size: 1.3rem; block-size: 1.3rem; }
  .catalog-form-head-actions .btn:focus-visible {
    outline: 2px solid var(--focus, var(--accent)); outline-offset: 2px;
  }
  .catalog-form-head .tab-bar { flex-wrap: nowrap; overflow-x: auto; overflow-y: hidden; padding-block: 2px; }
  .catalog-form-head .tab-bar .tab:focus-visible {
    outline: 2px solid var(--focus, var(--accent)); outline-offset: -2px;
  }
  /* ut-docs#2024: at 360px the five tabs need ~540px of min-content in
     ~328px available, so one or two sit entirely off-screen with nothing
     visible saying they're there (independent review of #2000, finding
     F5). A pure-CSS `background-attachment: local`/`scroll` "scroll
     shadow" (the classic no-JS technique) was tried first and rejected —
     empirically verified backwards for this element under `dir="rtl"`
     (the fade showed at the wrong scroll position entirely, not merely
     on the unmirrored side): `local` attachment's 0%/100% positions tie
     to the scrollable content's own coordinate origin, and that origin
     sits at a different physical edge than the visible "nothing more to
     scroll" edge once `direction: rtl` flips which edge is the reading
     start. So instead: catalog.html's `tabBarFade()` does the show/hide
     with real `scrollLeft`/`scrollWidth` arithmetic (`Math.abs()`, so it
     is correct for both the LTR 0→+max and the RTL 0→−max scroll-origin
     conventions without a sign-specific branch) and toggles two classes
     below; only the fade's own cosmetic gradient *direction* — not
     whether/when it shows — needs a `dir="rtl"` override, since
     `background-image` gradients have no reliable logical direction
     keyword across engines. `position: sticky` pseudo-elements, flex
     children of .tab-bar itself (the scrolling element), are what keep
     each fade pinned to its edge through the scroll without an extra
     wrapper element; `inset-inline-start`/`-end` place them so the
     browser's own bidi resolution decides which physical edge each one
     is on, matching the show/hide classes' left work above without this
     file needing to reason about it too. */
  .catalog-form-head .tab-bar::before,
  .catalog-form-head .tab-bar::after {
    content: ""; position: sticky; flex: none; pointer-events: none;
    inline-size: 1.1rem; block-size: 100%; opacity: 0;
    transition: opacity .15s ease;
  }
  .catalog-form-head .tab-bar::before {
    order: -1; inset-inline-start: 0;
    background: linear-gradient(to right, var(--surface), transparent);
  }
  .catalog-form-head .tab-bar::after {
    order: 999; inset-inline-end: 0;
    background: linear-gradient(to left, var(--surface), transparent);
  }
  html[dir="rtl"] .catalog-form-head .tab-bar::before {
    background: linear-gradient(to left, var(--surface), transparent);
  }
  html[dir="rtl"] .catalog-form-head .tab-bar::after {
    background: linear-gradient(to right, var(--surface), transparent);
  }
  .catalog-form-head .tab-bar.tab-bar--fade-start::before,
  .catalog-form-head .tab-bar.tab-bar--fade-end::after {
    opacity: 1;
  }
}
/* ut-docs#1901, same fix as body.osk-padded .payment-overlay above: the
   OSK is `position: fixed` + `z-index: 1000`, spanning the bottom of the
   viewport regardless of this dialog's own box — without this, whichever
   field/button sits in that band is genuinely covered, not merely close to
   an edge, the exact failure #1385 found for the payment overlay. ut-docs#1956:
   the dialog is full-bleed and anchored to the top edge (inset-block-start:
   0 wins the over-constrained vertical box), so shortening its block-size
   keeps the whole form — pinned head and scrolling body alike — clear of
   the keyboard. ut-docs#1998: reads `--osk-reserved-height` (see the
   body.osk-padded rule above) instead of the old hardcoded 15.5rem, which
   had drifted short of the keyboard's real height. */
body.osk-padded .item-form-modal { block-size: calc(100% - var(--osk-reserved-height, 17rem)); }
.catalog-extra { margin-top: .9rem; border-top: 1px solid var(--border); padding-top: .6rem; }
.catalog-extra summary { font-weight: 600; cursor: pointer; color: var(--muted);
                         font-size: .85rem; }
.catalog-extra form { margin-top: .6rem; }
.stock-low { color: var(--danger); font-weight: 700; }

/* ---------- Kiosk header row ---------- */
/* 2026-08-30: margin-bottom trimmed 1rem->.6rem now this row no longer
   follows its own <h1> (moved to the nav bar, nav.html) — it was sized
   for the gap under a heading, not under nothing. */
.kiosk-header {
  display: flex; align-items: center; justify-content: space-between;
  gap: 1rem; margin: .25rem 0 .6rem;
}
/* 2026-08-30 (nav rail, ut-docs#1332): New Sale/Inventory/Deposit refund
   normally live in the shared nav rail now (nav.html) -- .kiosk-header
   only still renders here as the <=480px phone-width fallback (see
   index.html's own comment for why cramming them into the rail's own
   phone-width bar instead overflowed the sale screen). Hidden by default;
   the media query below is the ONLY place that shows it. */
.phone-fallback-only { display: none; }
@media (max-width: 480px) {
  .phone-fallback-only { display: flex; }
}
/* The rail's own copies of Inventory/New Sale/Deposit refund (nav.html)
   are the mirror image: shown by default, hidden at <=480px where
   .phone-fallback-only above takes over instead. */
.nav-rail-only { display: flex; }
@media (max-width: 480px) {
  .nav-rail-only { display: none; }
}
.kiosk-actions { display: flex; align-items: center; gap: .5rem; }
/* .btn.compact (2026-08-30, product owner): a smaller secondary-action
   size for buttons that don't need the full checkout-grade footprint —
   New Sale/Inventory/Deposit refund here, available anywhere else a
   dense row of lower-stakes actions needs it. min-height 2.5rem/40px
   clears WCAG 2.5.8's 24x24 CSS px floor with real margin. Deliberately
   NOT applied to anything money/tax-path (Cash, Card, Payment, tender
   actions, the dine-in/takeaway switch — that one determines VAT, see
   .order-type-switch's own comment) — those keep the base .btn size. */
.btn.compact { min-height: 2.5rem; padding: .45rem .8rem; font-size: .9rem; }
/* ut-docs#413: neither `.kiosk-header` nor `.kiosk-actions` wraps, so at a
   real phone width (confirmed live at 360px) `.kiosk-actions` alone needs
   ~411px for its three buttons (New Sale / Inventory / Deposit refund) in
   one un-wrapped row. Flexbox's response to that squeeze isn't a clean
   shrink — each button's own automatic minimum width is its LONGEST WORD
   (buttons don't set white-space: nowrap), so "New Sale" and "Deposit
   refund" each measured a rendered height of two text lines instead of
   one, i.e. exactly the "blank-looking, no legible single-line label"
   symptom reported — while the row as a whole still overflowed the
   viewport (the same invisible-off-canvas mechanism as `.nav` above,
   `body.sale-screen` hides it rather than scrolling to it). Wrapping both
   levels lets each button render at its own full single-line width and
   move to a new row instead of being squeezed below it — this is also
   the underlying cause the ticket flagged as the money-path "Deposit
   refund" trigger itself being unreachable/mis-rendered. */
@media (max-width: 480px) {
  .kiosk-header { flex-wrap: wrap; row-gap: .5rem; }
  .kiosk-actions { flex-wrap: wrap; row-gap: .5rem; }
}
/* ---------- POS sale screen ----------
   One-screen till layout like real POS systems: the transaction column
   (basket with a pinned total + the tender actions) NEVER moves; the product
   grid fills the rest; each panel scrolls internally — the page itself does
   not scroll. The journal lives on its own page (/journal), not on the sale
   screen. Panels carry named grid areas so a theme can still reposition them
   by overriding grid-template-areas/-columns. */
.pos-container {
  display: grid;
  /* 22rem floor, not 20.625: with classic (non-overlay) scrollbars and
     wider font metrics (Linux/CI, some kiosk builds), the narrower column
     wrapped item names to 3 lines and pushed the remove button past the
     panel edge — tall rows broke the >=4-visible-lines AC (ut-docs#213). */
  /* ut-docs#2308: a THIRD track (--pos-divider-w) carries the draggable
     basket/products divider between the two original tracks. The basket
     track's own max grew a live-settable custom property,
     `--basket-col-width` (unset by default -> falls back to the original
     26.25rem, exactly the pre-#2308 rendering) -- set inline on this
     element by index_page.go/index.html from the persisted
     display.basket_panel_width_rem setting, and live-updated by the drag
     handler's own container.style.setProperty during a drag. Both outer
     tracks keep their own minmax() floor regardless of what that property
     says, so the grid itself is the ultimate clamp (see the --pos-*
     tokens' own comment on :root above) -- a runaway/corrupt custom
     property value can shrink a pane down to its floor, never past it. */
  grid-template-columns: minmax(var(--pos-basket-min-w), var(--basket-col-width, 26.25rem)) var(--pos-divider-w) minmax(var(--pos-products-min-w), 1fr);
  /* ut-docs#213: the basket is a full-height first-class column (the
     operator must see what's rung up — the old stacked basket/tender
     split left ~1 visible line at 1280x800). The tender panel sits under
     the products grid. Products keeps its 8rem floor (.basket-scroll's
     and .tab-panel's 6rem floors guard the same invisible-not-clipped
     class); tender deliberately has NO grid-level floor of its own — a
     first attempt gave it one to match products, which raised their
     combined minimum from 8rem to 16rem and, at 1920x800 + a 2x manual
     ui-scale, was enough to push the page past 100dvh and trigger the
     sticky `.statusbar`'s bottom pin over the still-unscrolled Cash/Card
     row (caught live by e2e/tests/tender-panel-reachable.spec.ts's own
     high-ui-scale case). `minmax(0, 2fr)` keeps tender free to compress
     under pressure exactly as far as plain `auto` always could (down to
     whatever its own content needs, ultimately `.tab-panel`'s 6rem floor
     for the Pay tab specifically) — this rule only changes how the
     LEFTOVER space is split once nothing is under pressure.
     ut-docs#1231: products was previously `1fr` against tender's plain
     `auto`, and an `auto` (non-flexible) row is maximized to its own
     max-content size BEFORE an `fr` row gets any leftover space at all,
     so tender (scan row + held sales + tab bar + full pay grid + footer)
     silently claimed roughly half the screen regardless of need, leaving
     products squeezed near its bare 8rem floor — confirmed live on the
     1280x800 reference till: the very first product tile rendered clipped
     mid-tile, no visible scroll affordance. Making tender's max a real
     flex factor puts both rows on the same ratio-based distribution, so
     products — the screen's primary purpose — gets the dominant share at
     any viewport height instead of "whatever tender didn't want".
     2026-08-30: retuned again, 4fr:3fr -> 5fr:2fr — the original 4:3
     tuning (see docs/code-reviews/2026-08-28-sale-screen-products-
     dominant-space-1231.md) was measured against a tender column that
     doesn't exist anymore: a fixed pay-grid needing Cash/Card on screen
     at all times. ut-docs#1252 moved that grid into its own overlay, and
     a later compactness pass (docs/code-reviews/2026-08-30-sale-screen-
     compact-ui.md) shrank the default view down to a scan row + two
     small buttons — measured live, tender's real content need at
     1024x600 is ~118px, and 5:2's ~122px floor share is the tightest
     split that still clears it (verified via
     e2e/tests/tender-panel-reachable.spec.ts's held-sales-matrix case —
     4:1 was tried and clipped the Payment button, confirmed by that same
     test failing). Pushing further toward products past 5:2 also wasn't
     worth it on its own terms: at 3:1 products only gained 21px (293px
     -> 314px) for a huge ratio swing — the remaining shortfall at
     1024x600 (products needs ~476px for one full "heading + search +
     tabs + category label + one tile row", the whole right column is
     only ~427px total) is a genuine floor-height constraint no ratio
     split can close without starving tender below its own real need.
     Products scrolling internally at the 1024x600 floor for a catalog
     with more than a handful of items in
     one category is accepted, same "never invisible, always reachable
     via scroll" fallback this file already uses for tender/basket/tab-
     panel — not a new pathology. Every payment button (inside
     the overlay now, not this row) stays reachable via its own dialog,
     never this grid's concern anymore.
     Every payment button stays reachable via tender's own internal
     scroll exactly as before (e2e/tests/tender-panel-reachable.spec.ts).
     A flexible tender row does mean its rendered height now tracks
     `.pos-container`'s own available height (plain `auto` never did),
     which moves the scan-row/"Add"-button boundary whenever that height
     changes — most visibly `body.osk-padded`'s reservation (ut-docs#1998:
     now `--osk-reserved-height`, previously a hardcoded 15.5rem) while
     the on-screen keyboard opens/closes. That surfaced a real, PRE-
     EXISTING race in osk.js: closing the keyboard (focusin's hide())
     reflows the page synchronously, and a reflow landing between a
     click's mousedown and mouseup means the browser never synthesizes the
     click at all — confirmed live, tapping the scan-row's "Add" button
     while the OSK was open. Main never surfaced it because products
     (pinned at its floor, the bug fixed here) meant tender's position
     never moved regardless of osk-padded — latent, not absent. Fixed at
     the source in osk.js (focusin's hide() deferred one tick, same
     pattern its focusout sibling already used), not by keeping this
     layout static — every other cause of `.pos-container` resizing
     (window resize, ui-scale, basket receipt-view toggling) reflows
     tender the same way and was never guarded against before either. */
  /* ut-docs#2702: the tender panel is now two fixed rows (thin scan row +
     one action row, no held-sales strip, no quick-pay row), so it no
     longer needs a flexible share at all -- `auto` sizes it to exactly its
     content (~6.5rem) and products takes everything else. The ratio
     history above is kept for context; every one of those retunings was
     chasing tender's content footprint, which is now small and constant. */
  grid-template-rows: minmax(8rem, 1fr) auto;
  grid-template-areas:
    "basket divider products"
    "basket divider tender";
  /* ut-docs#2308: split from a single `gap` shorthand -- the divider
     column now provides its own visual separation between basket and
     products/tender (it's a real, wide-enough-to-see grid track, not a
     hairline), so a column-gap on top would double it up. row-gap is
     unchanged (still separates products from tender, the same .8rem as
     before this feature). */
  row-gap: .8rem;
  column-gap: 0;
  align-items: stretch;
  /* Fill whatever space is left below the nav + kiosk header — robust to
     the nav wrapping to two rows (a fixed calc() assumed one row and
     pushed the tender panel off-screen). */
  flex: 1 1 auto;
  min-height: 0;
}
/* ut-docs#2702: the short-viewport `@media (max-height: 710px)` tender-
   share override (ut-docs#1336's quick-pay row budget, and before it the
   700px one) is gone with the rows it budgeted for — tender is content-
   sized now (`auto` above). History: git log -p on this file. */
.pos-container > .basket { grid-area: basket; }
.pos-container > .products { grid-area: products; }
.pos-container > .journal { grid-area: journal; display: none; } /* own page */
.pos-container > .tender { grid-area: tender; }

/* ut-docs#2308: the basket/products divider. Fills its whole grid track
   (align-items: stretch on .pos-container above) so the hit area is the
   full height of the sale screen, not just a short handle in the middle --
   a touch-and-hold can land anywhere along it. cursor: col-resize signals
   the affordance to a mouse/trackpad user; touch-action: none stops the
   browser's own pan/scroll gesture from competing with the JS drag
   (index.html) once a hold has armed it. The grip itself
   (.pos-divider-grip) is deliberately narrower than the >=24px hit area
   (ut-docs#2308 AC2) -- both its resting and held/hover colour come from
   the existing --border/--accent theme tokens, never a hardcoded colour,
   so a monochrome or dark theme (web/public/themes/*.css) repaints it
   automatically along with everything else. */
.pos-container > .pos-divider {
  grid-area: divider;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: col-resize;
  touch-action: none;
  min-height: 0;
}
.pos-divider-grip {
  inline-size: .3rem;
  block-size: 3rem;
  border-radius: var(--radius-lg);
  background: var(--border);
  pointer-events: none; /* the grip is decorative -- .pos-divider itself is the hit target */
  transition: background-color .12s ease;
}
.pos-container > .pos-divider:hover .pos-divider-grip,
.pos-container > .pos-divider.dragging .pos-divider-grip {
  background: var(--accent);
}

/* Panels are cards; each scrolls internally so nothing gets pushed around. */
.pos-container > .basket,
.pos-container > .products,
.pos-container > .journal,
.pos-container > .tender {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  padding: .7rem .8rem;
  box-shadow: var(--shadow);
  min-height: 0;
  overflow-y: auto;
}

/* ut-docs#1313: .products has no visible scroll affordance, and many kiosk
   browsers hide native scrollbars entirely -- a cashier gets no on-screen
   cue that the catalog continues below the fold. Pure-CSS "scroll shadow"
   (no JS, no scroll listener/IntersectionObserver): two pairs of layered
   backgrounds, one pinned to the scrolling content (`background-attachment:
   local`) that paints an opaque cover flush over the edge tile as it scrolls
   past, and one pinned to the panel's own box (`background-attachment:
   scroll`) that paints the shadow underneath it. The cover only uncovers the
   shadow once there's actual scrolled-past distance for it to reveal, so the
   cue is self-hiding at each extreme (no shadow at the very top, none at the
   very bottom once everything's been seen) with nothing to compute in JS.
   Visible on first paint before any scroll gesture, identical in kiosk and
   windowed mode (both already give this panel `overflow-y: auto` above),
   and doesn't depend on hover -- this is a touch-only surface (see
   ux-guidelines.md's "no hover-only affordance" rule). Overrides this
   block's own `background` for `.products` specifically (same selector
   specificity as the shared rule above, so source order is what makes this
   win -- keep it directly below that block if either ever moves).
   `rgba(255,255,255,0)` was used as "transparent" rather than a bare
   `transparent` keyword so Safari's gradient-interpolation quirk doesn't
   fade through gray; the alpha channel makes the RGB value irrelevant.
   Originally coupled to `--surface`'s then-only value (`#ffffff`).
   Reviewed 2026-08-31 (ut-docs#1313): the moment a theme overrides
   `--surface` to something dark, this cover gradient would fade toward
   WHITE-transparent instead of matching-color-transparent -- a visible
   white halo at the scroll edges, worse than no shadow at all -- deferred
   then because no dark theme existed yet to verify the fix against.
   **ut-docs#2176 is that dark theme, and the fix below is verified
   against it**: `color-mix(in srgb, var(--surface), transparent 100%)`
   keeps the same anti-gray-fade property (it's still an explicit alpha-0
   value, never the bare `transparent` keyword) while tracking whichever
   theme is active. Re: the ADR-0028 webkit2gtk-4.1 version check this
   comment previously asked for: `color-mix()` is a long-shipped, widely
   supported CSS Color 4 feature (Safari/WebKit since ~16.2, essentially
   baseline across engines by now) -- not independently confirmed against
   this product's actual installed webkit2gtk-4.1 build (no such hardware
   in this session), so if a Linux desktop build ever shows the halo
   again, check the runtime WebKitGTK version first before assuming this
   fix regressed. `.products` carries this recipe (`.tender-scroll`'s copy,
   ut-docs#2128, left with that wrapper in ut-docs#2702) -- `.basket`'s own
   pinned-footer/tab-panel scroll pattern is different and out of scope
   here, though the same two-gradient-pair recipe would apply if a future
   card asks for it there too. */
.pos-container > .products {
  background-color: var(--surface);
  background-image:
    linear-gradient(var(--surface) 30%, color-mix(in srgb, var(--surface), transparent 100%)),
    linear-gradient(color-mix(in srgb, var(--surface), transparent 100%), var(--surface) 70%),
    radial-gradient(farthest-side at 50% 0, rgba(0, 0, 0, .18), rgba(0, 0, 0, 0)),
    radial-gradient(farthest-side at 50% 100%, rgba(0, 0, 0, .18), rgba(0, 0, 0, 0));
  background-repeat: no-repeat;
  background-position: 0 0, 0 100%, 0 0, 0 100%;
  background-size: 100% 24px, 100% 24px, 100% 10px, 100% 10px;
  background-attachment: local, local, scroll, scroll;
}

/* Basket: lines scroll, totals pinned at the bottom of the panel. At a high
   ui_scale on a short viewport, the toggle row + pinned totals can outgrow
   the grid row's allotted height entirely, squeezing basket-scroll's flex
   basis to 0 -- invisible, not just clipped (confirmed live 2026-07-29: a
   scanned item existed in the DOM and the total updated, but the row
   itself had zero rendered height, nothing to scroll to). overflow-y: auto
   here is the fallback once basket-scroll's own min-height (below) can't
   be satisfied -- the panel becomes genuinely scrollable instead of
   silently hiding sale contents. */
.pos-container > .basket { display: flex; flex-direction: column; overflow-y: auto; }
/* Tender: normally the panel itself doesn't need to scroll — only the
   active tab body does (.tab-panel's own overflow-y: auto) — because its
   content is sized to fit. But at a high manual UI scale stacked on top of
   the fluid viewport fit (ut-docs#161 independent review, live-confirmed:
   Cash/Card/Gift Card/Hold Sale/New Customer clipped and unreachable on a
   1920x1200 kiosk at scale 2.0), the panel's total content can still
   outgrow its grid row. Same fallback as .basket above (shared overflow-y:
   auto from the block that ends at line 333) instead of .tender's own
   overflow:hidden — every payment button stays reachable by scrolling
   instead of silently disappearing past a hard clip. */
/* After a sale the receipt swaps into the basket slot; it keeps the grid area
   and scrolls internally instead of bleeding over other panels. */
.pos-container > .basket.receipt-view { display: block; overflow-y: auto; }
/* overflow-x is deliberately explicit here, not left unset (ut-docs#391):
   per the CSS overflow computed-value rule, an unset 'visible' axis next
   to a non-'visible' 'overflow-y' silently computes to 'auto' too -- so
   the moment the basket table's content ever again outgrows the panel,
   this becomes horizontally scrollable and defaults to showing the LEFT
   edge, clipping the remove button (right-most column) off-screen. Kept
   as 'auto' rather than 'hidden' so if that ever happens again it fails
   as "scroll to see the rest" rather than "silently gone" -- but the row-
   width budget above (.qty-input/.disc-input/.basket td) is what actually
   keeps this from triggering at any supported resolution; this line is a
   documented fallback, not the fix. */
.basket-scroll { flex: 1; min-height: 6rem; overflow-y: auto; overflow-x: auto; }
.basket .totals { flex-shrink: 0; }

@media (max-width: 900px) {
  .pos-container {
    grid-template-columns: 1fr;
    /* ut-docs#2702: products (last) is the one flexible row, so the
       stacked tier gives the tender panel's freed height to the grid too,
       instead of `auto`'s align-content stretch padding every row. */
    grid-template-rows: auto auto minmax(8rem, 1fr);
    grid-template-areas: "basket" "tender" "products";
    height: auto;
  }
  .pos-container > .basket { max-height: 45dvh; }
  /* ut-docs#2308 AC3: below the same breakpoint .pos-container itself
     stacks at, the divider is inactive -- not just visually hidden, never
     rendered as interactive. `display: none` alone is sufficient (a
     display:none element receives no pointer events at all), but the drag
     handler's own isActive() check (index.html) re-confirms this at
     pointerdown time too, belt-and-braces against a theme overriding this
     rule. There's also no "divider" cell in the single-column
     grid-template-areas above for it to occupy even if it weren't hidden. */
  .pos-container > .pos-divider { display: none; }
}
/* ut-docs#413: at the tablet tier above, `grid-template-rows: auto` is a
   single track token applied to all three implicit rows (basket/tender/
   products) alike, and none of them is a flexible (fr) track. Confirmed
   live at 360x640: with no flexible track to absorb it, the grid's default
   align-content (normal, which resolves to stretch for a grid container)
   divided `.pos-container`'s own flex-allocated height into three EQUAL
   ~129px rows regardless of content — nowhere near enough for the basket's
   table + totals, so the totals row rendered below the fold of the
   basket's own `overflow-y: auto` clip (an elementFromPoint probe at the
   totals row's own coordinates hit the TENDER panel's tab-bar underneath
   it, not the totals row itself — reported live as "the Subtotal row gets
   obscured"). Giving tender the only flexible track absorbs all leftover
   space itself during grid track sizing, which resolves before
   align-content ever gets a say — the same trick the desktop grid above
   already relies on (`minmax(8rem, 1fr) minmax(0, auto)`), just adapted
   from two rows to three. `.products` gets an explicit cap too (a large
   catalog's tile grid has no other height ceiling at this tier) and the
   whole container keeps its overflow-y: auto — same "never invisible,
   always reachable via scroll" fallback `.basket-scroll`/`.tab-panel`
   already use elsewhere in this file, for a phone short enough that even
   the three floors above don't all fit at once. Deliberately its own
   block, not an edit to the 900px block above: this tier's fix is scoped
   to phones, not the wider tablet range this bug also affected
   (ut-docs#416). Since ut-docs#2702 the 900px block's rows are
   `auto auto minmax(8rem, 1fr)` -- the same flexible-last-track shape -- so
   that tier is fixed too; regression test:
   e2e/tests/tablet-tier-totals-416.spec.ts. */
@media (max-width: 480px) {
  .pos-container {
    grid-template-rows: minmax(min-content, 45dvh) minmax(min-content, 1fr) minmax(min-content, 26dvh);
    overflow-y: auto;
  }
  .pos-container > .products { max-height: 26dvh; }
}

/* ---------- Cards & forms ---------- */
.card {
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius); padding: .8rem .9rem; box-shadow: var(--shadow);
}
.form-row { display: grid; grid-template-columns: 1fr .6fr .6fr 1fr auto; gap: .5rem;
            align-items: center; margin-bottom: .75rem; }
input, select, textarea {
  font: inherit; color: var(--text);
  padding: .5rem .65rem; border-radius: 8px; border: 1px solid var(--control-border);
  background: var(--surface);
}
/* ut-docs#3350: iOS WebKit zooms the page in when a field under 16px takes
   focus. At the phone tier (portrait) every typed-into field is at least
   16px. :where() keeps this at element specificity, so a class rule that
   sizes a field larger (the setup wizard's 1.05rem) still wins; 1em is the
   parent's size, so a larger inherited size wins too. The basket's
   qty/discount fields are sized below 16px by class, so they're named.
   The iOS app shell also locks zoom in any orientation (TillWebView.swift);
   browsers keep pinch-zoom. */
@media (max-width: 480px) {
  input:where(:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=color])),
  select, textarea, input.qty-input, input.disc-input { font-size: max(16px, 1em); }
}
input:focus, select:focus, textarea:focus {
  outline: 2px solid rgba(37, 99, 235, .35); outline-offset: 1px; border-color: var(--focus-border);
}
.form-row input { padding: .55rem; }
label { font-size: .85rem; color: var(--text); }
/* ut-docs#300: a <label> that wraps its own control stacks -- the control sits
   on its own line below the label text, at full width. This used to be opt-in
   per form (.catalog-form, .split-tender-form, .translations-controls and
   .setup-card each declare it separately) and any form that never got such a
   rule silently inherited the bare inline default above. That is how the
   deposit-refund payout dialog shipped with "Manager PIN" sitting to the RIGHT
   of the amount input and its own input wrapped onto the next line, and the
   change-Manager-PIN form had it too. Making stacking the default means a new
   form can no longer reintroduce the bug by forgetting an opt-in class.

   checkbox/radio are excluded IN THE SELECTOR, deliberately, rather than left
   to specificity: the ADR-0020 item-customization options are
   `label.modifier-option` wrapping a checkbox/radio and must stay a horizontal
   row, as must the ~12 plain toggle rows in catalog/settings/receipt-designer.
   Excluding them structurally means no later rule ordering or specificity
   change can flip them into a column.

   The `:where()` is load-bearing, not decoration. `:has()` takes the
   specificity of its most specific argument, so a bare
   `label:has(> input:not([type=checkbox]):not([type=radio]))` would score
   (0,2,2) -- each `:not([attr])` contributes (0,1,0) -- and would then OUTRANK
   every scoped rule below (`.setup-card label`, `.translations-controls
   label`, … all (0,1,1)), quietly changing forms that already looked right,
   while the sibling `label:has(> select)` branch at (0,0,2) did not. `:where()`
   contributes zero, so all three branches sit at (0,0,2), uniformly below the
   scoped rules. Keep it.

   Specificity does NOT rescue a container that sets `display:flex` and
   `align-items` but never `flex-direction`: the cascade is per-property, so
   this rule's `flex-direction: column` applies to such a label unopposed
   whatever the container's selector weight. `.set-row` and the modifier-admin
   rows below therefore declare `flex-direction: row` explicitly. That is the
   same hazard ut-docs#300 flagged for `.modifier-option`, and the independent
   review found it live on three manager-facing Settings rows.

   `:has()` needs Chromium >= 105 / Safari >= 15.4 / Firefox >= 121. The
   Chromium kiosk and current Android WebView are well past that; on anything
   older the rule is dropped wholesale and the form degrades to the old inline
   layout rather than to something worse. */
label:has(> input:where(:not([type="checkbox"]):not([type="radio"]))),
label:has(> select),
label:has(> textarea) { display: flex; flex-direction: column; gap: .2rem; }
/* Same [hidden]-override precedent as .btn/.field-pair/.field-checks: the
   author `display` above beats the UA `[hidden]{display:none}` rule whatever
   the specificity, so a `hidden`-attributed label containing an input stays
   fully visible. Found by review on ut-docs#1537's update form, whose PIN row
   is meant to appear only once the server has actually asked for a PIN — it
   was showing to everyone, and the feature only "worked" because an empty PIN
   still takes the session path. */
label[hidden] { display: none; }
/* Vertical rhythm for forms whose fields are direct children (the payout
   dialog, change-PIN): stacked labels would otherwise butt straight together. */
.stacked-form { display: flex; flex-direction: column; gap: .6rem; }
/* A submit button that is a direct child stays content-width, as it was
   before the form became a flex column (align-items defaults to stretch). */
.stacked-form > button { align-self: start; }

/* ---------- Buttons ---------- */
/* user-select/touch-action here are UNCONDITIONAL (ut-docs#1170) — the only
   place they previously existed for .btn was gated behind `body.kiosk`
   (below), which never applies on a windowed desktop-shell install
   (ut-docs#1021's own confirmed hardware finding: kiosk service inactive,
   display.window_mode=normal on the reporting till). A tap on any .btn on
   that class of device could be swallowed as a text-selection drag instead
   of dispatching its click — confirmed live for the Designer's search-result
   "add" button (web/ui/partials/buttons_admin.html), whose hx-post silently
   never fired. Buttons are never meant to be text-selected, so this is safe
   everywhere, not just in kiosk mode. */
.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: .4rem;
  background: var(--accent); color: var(--accent-contrast);
  border: 1px solid transparent; padding: .7rem 1.15rem; border-radius: 9px;
  cursor: pointer; font: inherit; font-size: 1rem; font-weight: 600; min-height: 3rem;
  text-decoration: none; line-height: 1.2;
  transition: background .12s ease, transform .05s ease;
  user-select: none; touch-action: manipulation;
}
/* display:inline-flex above would defeat the hidden attribute's UA rule —
   hidden buttons (osk-toggle, ai-identify-open) must actually disappear. */
.btn[hidden] { display: none; }
.btn:hover { background: #1d4ed8; }
.btn:active { transform: translateY(1px); }
.btn:disabled { opacity: .55; cursor: default; transform: none; }
.btn.primary { background: var(--accent); }
.btn.secondary { background: var(--surface); color: var(--text); border-color: var(--border); }
.btn.secondary:hover { background: var(--surface-2); border-color: #cbd5e1; }
.btn.danger { background: var(--danger); }
.btn.danger:hover { background: #b91c1c; }
a.btn, a.btn:hover { text-decoration: none; }
.btn-actions { display: flex; gap: .25rem; justify-content: center; }
.btn-actions .btn { padding: .4rem .6rem; font-size: .85rem; min-height: 0; }

/* ---------- Sale-screen category tabs + search (ut-docs#418) ----------
   .products-finder is the Alpine scope shared by the search input, the
   tab bar and the grid below it — all three filters (active tab, search
   query) apply client-side to the already-rendered tile set, no server
   round trip, so there is nothing for a second filter to silently drop
   (the class of bug the self-order kiosk's own search+category
   combination has, ut-docs#419). */
.products-finder { display: flex; flex-direction: column; gap: .6rem; }
/* ut-docs#2173 (closes ut-docs#993): replaces the old two-row
   .products-header (title + always-expanded search + compact "+" link,
   2026-08-30) stacked above .tab-bar. At 1024x600 with 12 categories that
   tab bar alone (flex-wrap: wrap, unbounded) ran 247px over 5 rows with
   ZERO product tiles visible on the kiosk floor — the whole point of this
   screen. .products-strip is now the ONE row both live in: the category
   tab bar (still .tab-bar, still a real WAI-ARIA tablist — see the generic
   .tab-bar rule far below, which this section deliberately never touches;
   it's shared with the tender Pay/Split bar and the catalog item-form
   strip) plus the search/edit icon buttons pinned at its trailing end.
   justify-content: flex-end matters for the $flat/single-real-category
   branches (buttons.html) where no .tab-bar renders at all — the icons
   then sit at the trailing end instead of drifting to the leading one. */
.products-strip { display: flex; align-items: center; justify-content: flex-end; gap: .25rem; }
/* ut-docs#2173 review finding F1: these three rules serve the EMPTY-CATALOG
   branch of buttons.html ({{ if not .Groups }}), which still renders its own
   .products-header/.products-add and is deliberately untouched by the strip
   work above — it has no tab bar and no search to collapse, and its visible
   <h2> is the only context a brand-new shop gets. Deleting them along with
   the old two-row header regressed that screen from one ~42px flex row to a
   77.8px stacked block (measured at 1024x600), i.e. the exact opposite of
   this card's goal, on the first screen a new merchant ever sees. The old
   `.products-header input[type="search"]` rule is NOT restored: that branch
   has no search input, so it really was dead. */
.products-header { display: flex; align-items: center; gap: .6rem; margin-bottom: .6rem; }
.products-header h2 { margin: 0; white-space: nowrap; }
.products-add { flex: 0 0 auto; }
/* Padding-matched to .products-finder .tab-bar's own rendered height so
   swapping the tab bar out for this input (search mode) never changes
   .products-strip's own height — the "same row, same height" requirement
   ut-docs#2173 was written against.

   The +3px is not a fudge factor and does NOT depend on the font-size: both
   elements are `font: inherit` and both carry the same .5rem vertical
   padding, which cancels. The 6px difference is entirely px constants —
   the bar's padding-block (2+2), plus the active tab's border-block-start
   (3) and border-bottom (3), minus its margin-bottom (-2), minus the
   input's own top+bottom border (1+1) = 6 (ut-docs#2214 dropped the bar's
   own border-block-end, which used to add a further +2 here — re-measure
   this constant again if that border ever comes back). Half on each side
   is this +3px. So it holds at any font-size or rem scale, and what it IS
   brittle to is a change in any of those BORDER widths, or .tab's vertical
   padding diverging from the base input's. Change either and re-measure.
   e2e/tests/sale-screen-search-strip-2173.spec.ts pins the invariant
   (|Δheight| < 1px between rest and search mode), so a drift fails there
   rather than silently on a till. */
.products-strip > input[type="search"] { flex: 1 1 auto; min-inline-size: 0; padding-block: calc(.5rem + 3px); }
/* The strip's tab bar: unlike the generic .tab-bar (flex-wrap: wrap, below)
   this one is ALWAYS single-row, at every width — the sale screen never
   had room to spare for a second row, so unlike .catalog-form-head
   .tab-bar (which only goes nowrap under its own max-width: 700px query,
   ut-docs#2000/#2024) this is not inside any media query at all.
   ut-docs#2307 (product owner: too many categories used to scroll — "I
   prefer to have a ... button at the right"): overflow-x: auto/scrolling
   is GONE. buttons.html's own applyCategoryOverflow() now hides whichever
   trailing category tabs don't fit this row and reveals the trailing
   '...' button (.tab-more below) instead — overflow: hidden here only
   clips the brief instant (well under one frame, never painted) that
   method's own measurement pass can leave a not-yet-hidden tab spilling
   past this box's edge while it reads that tab's natural width. */
.products-finder .tab-bar {
  flex: 1 1 auto; min-inline-size: 0;
  flex-wrap: nowrap; overflow: hidden;
  padding-block: 2px; margin-bottom: 0;
  /* ut-docs#2214 (product owner, on-device): the generic .tab-bar's
     border-block-end sits directly above the category heading/quick-button
     grid below it, reading as redundant chrome once the heading itself
     already separates the groups — same reasoning .catalog-form-head
     .tab-bar (ut-docs#2024, below) already applied to its own tab bar. */
  border-block-end: 0;
}
/* overflow: hidden above (unchanged reasoning from when this was
   overflow-y: hidden only, ut-docs#2173/#2000) still clips both the
   active tab's border-bottom indicator and its focus ring, since .tab
   uses a negative margin-bottom to overlap the bar's own border. */
.products-finder .tab-bar .tab:focus-visible {
  outline: 2px solid var(--focus, var(--accent)); outline-offset: -2px;
}
/* ut-docs#2307 (independent review): buttons.html's applyCategoryOverflow()
   adds this class for the duration of ONE synchronous measurement pass and
   removes it again before anything can paint. .tab is flex: 1 1 0% (the
   generic .tab-bar rule below), so a visible tab's rendered width is its
   stretched share of the row, not the width it actually needs — and that
   share depends on how many siblings that same method hid on its last
   pass, which made the measurement feed on its own output and left a
   widened strip permanently showing fewer tabs than fit (1600px -> 620px
   -> 1600px showed 2 where a fresh load showed 4). The fix itself is that
   the method now measures with every tab un-hidden; this rule is
   belt-and-braces on top, so the pass is a content-width measurement
   unconditionally rather than only on the branch that reaches it. */
.products-finder .tab-bar.ut-measuring .tab { flex: 0 0 auto; }
/* ut-docs#2307: the trailing "more categories" button — see
   buttons.html's own comment above it for the full behaviour
   (applyCategoryOverflow() shows/hides it, never CSS alone). flex: 0 0
   auto (not .tab's own flex: 1, below in this file's generic .tab-bar
   rule) — a fixed-footprint icon button must never compete with the real
   tabs for the row's free space; that fixed footprint is exactly what
   lets applyCategoryOverflow() treat its width as a constant once
   measured. margin-inline-start (never margin-left) keeps a small gap
   from the last tab correct under RTL too.
   :not([hidden]) on the layout rule matters (found live, real bug): a
   plain class selector's specificity beats the UA stylesheet's own
   `[hidden] { display: none }`, so an un-guarded `display: inline-flex`
   here would make applyCategoryOverflow()'s own `.hidden = true` a no-op
   — the button stayed visibly on screen even while its `hidden` attribute
   was genuinely set (a shop with everything already fitting saw a dead
   "..." button that did nothing new, requirement 5's own hidden-entirely
   case). */
.products-finder .tab-bar .tab-more:not([hidden]) {
  flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center;
  padding: .4rem .5rem; margin-inline-start: .15rem; align-self: center;
  background: none; border: none; border-radius: var(--radius);
  color: var(--muted); cursor: pointer;
}
.products-finder .tab-bar .tab-more:hover,
.products-finder .tab-bar .tab-more[aria-expanded="true"] {
  background: var(--surface-2); color: var(--text);
}
.products-finder .tab-bar .tab-more:focus-visible {
  outline: 2px solid var(--focus, var(--accent)); outline-offset: -2px;
}
/* ut-docs#2173 — the back arrow is the first direction-dependent icon in
   this registry (internal/httpx/icons.go): every other rail glyph reads
   the same in LTR and RTL, but "back" must point startward, so it's
   mirrored here instead of drawn twice. */
html[dir="rtl"] .products-strip-back .btn-ico svg { transform: scaleX(-1); }
.products-finder input[type="search"] { width: 100%; }
/* Unlike .tender's tab-panel (app.css, "Tender" section below), .products
   itself already scrolls (.pos-container > .products has its own
   overflow-y: auto) — no nested floor/scroll needed here, just layout. */
.products-tab-panel { flex: 1; display: flex; flex-direction: column; gap: .5rem; }

/* ---------- Category groups ----------
   Each section's --cat-color custom property is set inline per group (an
   explicit per-category color, or a deterministic auto-color — see
   ui.BuildCategoryGroups); the border/heading here just consume it. */
.category-groups { display: flex; flex-direction: column; gap: 1rem; }
.category-group { display: flex; flex-direction: column; gap: .5rem; }
.category-group .category-group { margin-inline-start: 1rem; }
.category-header {
  /* ut-docs#2214 (product owner, on-device): 1rem read heavier than a
     grouping label needs to be — matches the .85rem this file already uses
     for secondary/label text elsewhere (e.g. .field-error-msg,
     .option-set-chip) rather than inventing a new size. */
  margin: 0; padding-inline-start: .5rem; font-size: .85rem; font-weight: 700;
  border-inline-start: 4px solid var(--cat-color, var(--accent));
}

/* ---------- Category tiles (ut-docs#2283, compacted by ut-docs#2500) ----------
   .category-tile reuses the .grid it sits in (buttons.html) — same
   auto-fill/7rem column floor the product-tile grid already uses, so the
   two densities stay visually consistent. Same --cat-color-accent/
   border-inline-start recipe as .btn-tile (ut-docs#1325) — one colour per
   category, carried on the tile itself.

   Height: ut-docs#2283 once set min-height: 9.5rem (152px) so a name-only
   tile would never read lighter than the ~140px item tile beside it. The
   product owner reversed that on the real tablet (ut-docs#2500,
   2026-09-24): ~170px tiles fit only three rows at 1280x800. The tile is
   now compact — about an item tile's height or less, image on top, name
   (2-line clamp) and count below — the 1.75rem image and .9rem name are
   sized so even an image + two-line-name tile stays within an item
   tile's height — with min-height 3.5rem (56px) keeping
   it above the 44px touch floor even for a one-line, imageless tile; the
   content sets the rest. e2e/tests/category-image-2500.spec.ts measures
   it against a real item tile at 1024x600 and 360x800. */
/* ut-docs#2307: .category-overflow-tile (the category-strip overflow
   sheet's own tiles) shares this whole ruleset by SELECTOR, not by class
   name on the markup — it deliberately does NOT also carry the literal
   `category-tile` class the way an earlier draft did. That draft made
   sell-screen-categories-tab-2283.spec.ts's own `.category-tile` locator
   start resolving to this sheet's tiles instead of the real Categories-tab
   picker's (this dialog sits earlier in the DOM) — same class, different
   click behaviour, a real collision, not just cosmetic. */
.category-tile, .category-overflow-tile {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: .15rem; padding: .4rem .4rem; min-height: 3.5rem; border: 1px solid var(--border);
  border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow);
  font: inherit; color: var(--text); cursor: pointer; text-align: center;
  transition: border-color .12s ease, box-shadow .12s ease, transform .05s ease;
  user-select: none; touch-action: manipulation; -webkit-touch-callout: none;
  border-inline-start: 4px solid var(--cat-color, var(--accent));
}
.category-tile:hover, .category-overflow-tile:hover { border-color: #cbd5e1; box-shadow: 0 3px 8px rgba(15, 23, 42, .12); }
.category-tile:active, .category-overflow-tile:active { transform: scale(.97); }
/* ut-docs#2500: the category's image (built-in icon or uploaded photo),
   above the name. contain, not cover — a built-in icon is a glyph on a
   transparent field and must never be cropped. */
.category-tile-img { inline-size: 1.75rem; block-size: 1.75rem; object-fit: contain; flex: none; }
/* ut-docs#2500: the strip tab's small inline-start image. A fixed box, so
   applyCategoryOverflow()'s width measurement is right before it loads. */
.tab-cat-img { inline-size: 1.25rem; block-size: 1.25rem; object-fit: contain; margin-inline-end: .3rem; vertical-align: middle; }
.category-tile-name {
  font-weight: 600; font-size: .9rem; line-height: 1.2;
  overflow-wrap: anywhere;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
/* Wider than the base .modifier-modal (28rem/90vw) — this holds a full
   item grid, not a handful of buttons — same "widen the dialog, not the
   content" call .barcode-backfill-modal made for its own wide table.
   position: fixed + z-index (not the dialog UA stylesheet's own absolute/
   margin:auto centering) -- same explicit-geometry recipe as #hold-modal/
   #pfand-modal/#elevation-modal/#table-add-modal/.category-overflow-dialog
   above: this dialog is opened with .show(), never .showModal() (the till's
   own on-screen keyboard must stay reachable for the popup's search box --
   buttons.html's own comment), so it gets none of showModal()'s free
   top-layer stacking. Tester finding (ut-docs#2499): without this it fell
   back to the UA default (position: absolute, z-index: auto) and rendered
   BEHIND ordinary page content once the layout collapsed to one column --
   confirmed live at 360x800, the sell screen's own "Add items to pay"
   button painted on top of an item tile inside the open popup. z-index:
   500 matches that same family. */
/* ut-docs#3073 (product owner + pilot café owner): FULL SCREEN, for room
   to find the item -- everything from the nav rail's inline-end edge to the
   viewport's, top to bottom. The rail itself is never covered, so its
   status chips stay visible and Lock stays live (the scrim's .nav lift
   above; CLAUDE.md "Offline-first", ut-docs#1999/#2873). Logical insets, so
   under RTL it sits beside the rail on the right. The heading stays on top,
   the item list scrolls (flex: 1 -- no more 60vh cap) and Close is pinned
   at the bottom. The <=480px top-bar layout and the on-screen keyboard's
   reservation are handled further down (their own blocks). block-/inline-
   size: auto override the UA dialog's fit-content so both insets stretch it;
   doubled with .modifier-modal (its frame, further down) so its 28rem/90vw
   cap -- same specificity, later in the file -- can't win. */
.modifier-modal.category-items-modal {
  position: fixed; inset-block: 0; inset-inline: var(--rail-width) 0; margin: 0; z-index: 500;
  inline-size: auto; max-inline-size: none; block-size: auto; max-block-size: none;
  box-sizing: border-box; border-radius: 0; border-block: 0; border-inline-end: 0;
}
.modifier-modal.category-items-modal[open] { display: flex; flex-direction: column; }
.category-items-modal-body { flex: 1 1 auto; min-block-size: 0; overflow-y: auto; margin-block: .5rem; }
/* Same reservation as body.osk-padded .payment-overlay: the popup's search
   box drives the on-screen keyboard, which must not cover Close. */
body.osk-padded .modifier-modal.category-items-modal { inset-block-end: var(--osk-reserved-height, 17rem); }
/* <=480px: the rail is a top bar (see the nav block above), so the popup
   starts under it instead of beside it; --topbar-h is its measured height,
   plus the same .5rem .bugreport-panel adds (the bar sits that far down --
   measured at 360x800: without it the popup covered its bottom 8px). */
@media (max-width: 480px) {
  .modifier-modal.category-items-modal { inset-block-start: calc(var(--topbar-h) + .5rem); inset-inline: 0; border-inline-start: 0; }
}
/* ut-docs#2499: the category_tabs mode's tile grid and popup, and the
   all_filter_chips mode's chip row (buttons.html). The tile's item count
   shares .category-overflow-count's look (below) — same small muted line,
   different number (items, not quick buttons). The popup's own search box
   sits above its grid; each tile is wrapped in a display: contents cell
   (same idiom as .tile-cell at rest) so the popup's x-show filter hides a
   whole cell without leaving an empty grid slot. The chip row reuses the
   shared .filter-chips pattern (touch floor, wrap-never-clip, checkmark +
   aria-pressed, focus ring — see that block further down); the only
   override is a slightly shorter chip so a 12+ category row at 1024x600
   wraps to fewer rows while staying at/above the 44px (2.75rem) floor. */
.category-tile-count { font-size: .78rem; color: var(--muted); }
.category-items-search { inline-size: 100%; margin-block-end: .5rem; }
.category-items-cell { display: contents; }
.products-cat-chips { margin-block-end: .35rem; }
.products-cat-chips .chip { min-block-size: 2.75rem; padding-inline: .8rem; }

/* ut-docs#2307: the category strip's trailing "..." sheet — every
   category, once, as a large touch target (product owner's own card
   text: "shows all the categories to select"). Reuses .category-tile's
   own frame/name-clamp above (so the density/look matches the Categories
   tab's own tiles exactly) plus .category-filter-dialog-head's title-row
   layout (category_filter_popover.html) for the heading + close button,
   rather than a third near-duplicate of either. */
/* position: fixed (not the dialog UA stylesheet's own absolute/margin:auto
   centering) -- same explicit-geometry recipe as #hold-modal/#pfand-modal/
   #elevation-modal/#table-add-modal/.category-filter-dialog above: this
   dialog is nested inside .products-finder rather than sitting at the page
   root the way #category-items-modal does, so it must not depend on every
   ancestor between it and the viewport staying position:static for its own
   centering to land correctly. z-index: 500 matches that same family. */
.category-overflow-dialog {
  position: fixed; inset-block-start: 8vh; inset-inline: 0; margin-inline: auto; z-index: 500;
  max-width: 30rem; width: 92vw;
}
.category-overflow-grid { max-height: 60vh; overflow-y: auto; margin-block: .5rem; }
.category-overflow-count { font-size: .78rem; color: var(--muted); }
/* The active category — a ring, not the tab's own underline (this tile
   has no shared baseline to underline against), same --cat-color
   convention .tab-bar .tab.active (below) already uses so the accent
   colour is the category's own rather than a generic one. */
.category-overflow-tile.active {
  border-color: var(--cat-color, var(--accent));
  box-shadow: 0 0 0 2px var(--cat-color, var(--accent)) inset;
}

/* ---------- Product tiles ----------
   The whole tile is one touch target: image, name, price. */
/* 2026-08-30 (product owner, comparing against a competitor POS — "sumup
   can show a lot of product there but we have only place for 4"):
   column floor 8.8rem->7rem, tile min-height 8.5rem->6.5rem, thumb
   4.25rem->3.25rem (below). A near-identical density change was tried
   once already today and reverted before shipping — that attempt's own
   comment wrongly asserted .tile-name already had a 2-line clamp when it
   didn't, and a real German name overflowed the narrower column
   (independent review caught it). Fixed properly this time: .tile-name
   below actually gets display:-webkit-box/-webkit-line-clamp:2 plus
   overflow-wrap, verified against a realistic long name before shipping,
   not asserted without checking. */
.grid { display: grid; gap: .5rem; grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr)); }
/* ut-docs#2319: the All grid's "load more" button (buttons.html's
   all-more-button) sits inside the same .grid as the product tiles it
   loads more of — span the full row instead of sizing to one tile column.
   Its touch target comes from the `.compact` class already on the element
   (2.5rem/40px, the app's own established deliberate secondary-action
   floor — see .order-type-option's comment above for the product-owner
   precedent), not from a rule here; a `min-height` here would be dead
   weight, since `.btn.compact`'s two-class selector always outranks this
   single-class one. */
.all-more-btn { grid-column: 1 / -1; }
/* user-select/touch-action unconditional here too (ut-docs#1170) — same
   reasoning as .btn above, previously gated behind body.kiosk only. */
.btn-tile {
  display: flex; flex-direction: column; align-items: stretch; gap: .25rem;
  padding: .45rem .4rem .5rem; min-height: 6.5rem; border: 1px solid var(--border);
  border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow);
  font: inherit; color: var(--text); cursor: pointer; text-align: center;
  transition: border-color .12s ease, box-shadow .12s ease, transform .05s ease;
  user-select: none; touch-action: manipulation;
  /* ut-docs#2285 (kept by ut-docs#2339's utTileJiggle, app.js, which owns
     the same hold): a long-press must never pop the platform's own "save
     image"/text-selection callout menu partway through the hold —
     iOS/WebKit's -webkit-touch-callout is the one piece of that
     user-select already covers on other engines does not. */
  -webkit-touch-callout: none;
}
/* Same [hidden]-override precedent as .btn/.field-pair/.field-checks/label
   above: this class's own `display: flex` always beats the UA
   `[hidden]{display:none}` rule regardless of specificity, so a
   `hidden`-attributed .btn-tile (ut-docs#1951: the catalog grid's own
   client-side search, filterRows(), sets .catalog-row.hidden to filter
   cards) stayed visibly flex-displayed — found live running the e2e suite
   against a real browser, not just template assertions. */
.btn-tile[hidden] { display: none; }
/* Category-color accent (ut-docs#1325), same var(--cat-color, var(--accent))
   fallback pattern .category-header/.tab already use — --cat-color inherits
   from whichever ancestor sets it (.category-group, or the tab-panel wrapper
   in buttons.html), so no per-tile data plumbing is needed here. Overrides
   just the inline-start side of the uniform 1px .btn-tile border above
   (source order, same technique used everywhere else this file layers a
   colored accent onto a bordered element); padding-inline-start is trimmed
   by the same 3px the border grew by (1px -> 4px) so the extra width doesn't
   visibly nudge the centered tile content off-axis. Not the tile's only
   identifying signal — name/price/thumbnail and the category header/tab
   above it all still show — so a pale admin-set category color merely
   weakens this one accent, it never makes an item unreadable or
   unidentifiable (WCAG 1.4.1: color is not used as the sole means of
   conveying the category). */
.btn-tile { border-inline-start: 4px solid var(--cat-color, var(--accent)); padding-inline-start: calc(.4rem - 3px); }
.btn-tile:hover { border-color: #cbd5e1; box-shadow: 0 3px 8px rgba(15, 23, 42, .12); }
.btn-tile:active { transform: scale(.97); }
/* ut-docs#1901: solid tile-color background for a photo-less item — set
   via --tile-color (product-tile, buttons.html), inline per tile, only
   when the item has no image AND a color. Every catalogtypes.ItemColors()
   swatch is a deliberately dark/muted tone (the "Slate" direction), so a
   single fixed white foreground reads on all of them without per-swatch
   contrast logic — same reasoning as .picker-tile[aria-pressed] not
   needing one either. */
.btn-tile.tile-colored { background: var(--tile-color); }
/* .muted joins the white foreground (independent-review fix, ut-docs#1951):
   the catalog card grid puts a .muted barcodes/variants caption INSIDE the
   tile, where the sale-screen tile only ever had a name and a price. Left on
   var(--muted) (#64748b) it sits mid-grey on the tile colour — measured live
   at 1.06:1 against amber (#b45309) and 1.60:1 against stone (#57534e), i.e.
   effectively invisible, against WCAG AA's 4.5:1 for 13px text. #fff is the
   same single fixed foreground the name/price already rely on, and clears
   4.5:1 on every swatch in the palette (amber, the lightest, is 5.05:1). */
.btn-tile.tile-colored .tile-name,
.btn-tile.tile-colored .tile-price,
.btn-tile.tile-colored .muted { color: #fff; }
.tile-name {
  font-weight: 600; font-size: .9rem; line-height: 1.2; white-space: normal;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
  overflow: hidden; overflow-wrap: anywhere;
}
.tile-price { font-size: .9rem; color: var(--accent); font-weight: 700;
              font-variant-numeric: tabular-nums; }
.thumb { width: 100%; height: 3.25rem; object-fit: contain;
         border-radius: 7px; background: var(--surface-2); }
.thumb.small { width: 40px; height: 40px; aspect-ratio: auto; margin-inline-end: .5rem;
               vertical-align: middle; }

/* ---------- Basket ---------- */
/* Screen-reader-only text (aria-label is ignored on generic spans). */
.visually-hidden { position: absolute; width: 1px; height: 1px; margin: -1px;
  padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
/* Always-visible total-quantity badge next to the basket title (ut-docs#213). */
.basket-count { display: inline-flex; align-items: center; justify-content: center;
  min-width: 1.7em; padding: .08em .5em; margin-inline-start: .45rem;
  border-radius: 999px; background: var(--accent); color: var(--accent-contrast, #fff);
  font-size: .72em; font-weight: 700; font-variant-numeric: tabular-nums;
  vertical-align: middle; line-height: 1.4; }
/* A DIV inside the item <td>, not a class on the <td> itself (ut-docs#391):
   a table-cell with `display: flex` specified directly on it fails CSS
   fixup — the browser wraps it in an anonymous table-cell box instead,
   which is what actually participates in table-layout: fixed's column-
   width algorithm (.basket table above). A `width`/`nth-child` rule
   aimed at the original <td> silently stopped reaching the real column
   box (caught via a live DOM probe, not visually — the column still
   *rendered*, just squeezed the name near 0 width, invisible without
   inspecting). basket.html wraps this in its own <div class="line-item">
   now so the <td> stays a genuine table-cell. max-width matters for
   real now, not just decorative: the item column is deliberately left
   with no declared table width (.basket table's comment above) so it
   can grow to fill whatever the other columns don't need — this caps
   how far that growth goes at the panel's roomy default width. */
/* 10.4rem = the item column's real share at the panel's 26.25rem default
   (26.25 - 4.3 qty - 4 price - 4 total - 2.8 remove - .7 cell padding)
   since ut-docs#1314 slimmed the qty column; was 9.875rem for the old
   8rem side-by-side qty+discount budget. */
.line-item { max-width: 10.4rem; display: flex; align-items: center; gap: .4rem; }
.line-thumb {
  flex: 0 0 auto; width: 2rem; height: 2rem; object-fit: cover;
  border-radius: 6px; border: 1px solid var(--border);
}
.line-thumb-empty { background: var(--surface); display: inline-block; }
.line-text { min-width: 0; }
/* Clamp to 2 lines so row height has a ceiling on every font stack — a
   3-line wrap (seen on Linux/CI metrics) broke the >=4-visible-lines AC.
   Full names still print on receipts; the row shows an ellipsis. */
.line-name { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
  overflow: hidden; font-weight: 600; font-size: .88rem; line-height: 1.2;
  /* ut-docs#1314: long unbreakable words (German compound product names,
     e.g. "Doppelrahmfrischkäse") must wrap into the clamp, not clip
     mid-character past the box's hidden overflow edge. */
  overflow-wrap: anywhere; }
.line-item small { font-size: .68rem; }
.line-modifiers { list-style: none; margin: .15rem 0 0; padding: 0; }
.line-modifiers li { font-size: .72rem; line-height: 1.3; }
/* ut-docs#1314, phone tier (ut-docs#413's breakpoint): at 360px the whole
   basket table is ~288px while the declared qty/price/total/remove
   columns take ~257px, leaving the ITEM column ~32px — which the 2rem
   thumbnail alone (itself clipped) used to consume entirely, rendering
   the item NAME at literal 0px width (measured live 2026-08-30; before
   #1314's column-budget change the item column got ~0px, so the name was
   invisible on phones either way). The name is what the operator needs;
   drop the photo at this tier so the name owns the column.
   Re-measured at review (2026-08-30), 360x800, before -> after: item
   column 0 -> 31.6px, name 0 -> 19.8px, and — not previously reported —
   .basket-scroll's HORIZONTAL overflow 32px -> 0 (16px -> 0 under
   body.kiosk). So this tier is strictly better than it was, including for
   ut-docs#391's own no-horizontal-scroll invariant. It is still not GOOD:
   ~20px is ~2 characters. Making the phone tier genuinely readable means
   re-budgeting the PRICE/TOTAL/remove columns or giving this tier its own
   row layout, which is deliberately out of scope for ut-docs#1314 and is
   filed as its own card — do not "fix" it by widening the item column
   here, that is exactly attempt 3's regression. */
@media (max-width: 480px) {
  .line-thumb { display: none; }
}

/* ---------- Item customization (ADR-0020) ---------- */
.modifier-modal {
  border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow);
  padding: 1.25rem; max-width: 28rem; width: 90vw;
}
.modifier-modal::backdrop { background: var(--ut-scrim, rgba(15, 23, 42, .45)); }
.modifier-modal:not([open]) { display: none; }
/* ut-docs#1356: the barcode-backfill dialog reuses .modifier-modal's frame
   but holds a 3-column data table (name/SKU/derived barcode), which the
   base 28rem max-width clips on any realistic SKU length -- confirmed by
   screenshot in both LTR and RTL. Wider dialog, not a narrower table: the
   table's own min-width (catalog_barcode_backfill.html) plus this class's
   larger cap keep the common case scroll-free; the table wrapper's
   overflow:auto is the remaining safety net for a still-longer row. */
.barcode-backfill-modal { max-width: 38rem; }
/* ---------- Sell-screen jiggle edit mode (ut-docs#2339) ----------
   Replaces the ut-docs#2285 per-tile long-press sheet (.tile-sheet, gone)
   with an iOS-springboard-style edit mode over the WHOLE grid: app.js's
   utTileJiggle adds .jiggle-mode to #buttons-grid after a ~500ms hold (or
   a right-click) on a tile; every tile then wobbles in place, grows three
   corner badges (edit / delete / hide, ut-docs#2541), and can be dragged to
   reorder. Nothing overlays the screen, so the nav rail's status/lock/exit
   stay reachable
   throughout (universal-till/CLAUDE.md's standing rule).

   .tile-cell (buttons.html's product-tile) wraps each tile + its badges.
   At rest it is display: contents, so the tile <button> is still the grid
   item and the resting render is pixel-identical to the pre-#2339 grid;
   the badges are display: none. In edit mode the wrapper becomes the real,
   positioned grid cell the badges anchor to, and the tile fills it. Alpine
   toggles x-show on the wrapper via an inline display:none, which beats
   `contents` and `block` alike, so search-hiding keeps working in both
   states.

   Logical properties only (inset-inline-start/-end, never left/right):
   the edit badge sits at the inline-START corner and remove at the
   inline-END corner, so an RTL locale (fa/ar) mirrors them for free.

   Touch-target floor (ut-docs#161): the visible badge is a 22px dot, but
   the badge ELEMENT is a 44x44 CSS px hit area centred on that dot, the
   same floor every other tappable thing here honours. The hit box hangs
   8px past the tile's corner; adjacent tiles' boxes overlap only inside
   the .5rem grid gap, where no dot is drawn, so a finger on a dot always
   hits that dot's own badge.

   touch-action: none on the tile ONLY in edit mode (the tables.html
   pattern, ut-docs#1025): outside it the tile keeps `manipulation` so a
   vertical finger-pan starting on a tile still scrolls the products
   panel. Inside it the drag owns the gesture -- pointerdown's
   preventDefault + setPointerCapture -- and this just stops the browser
   from ALSO panning underneath it. */
.tile-cell { display: contents; }
.tile-badges { display: none; }
.jiggle-mode .tile-cell { display: block; position: relative; min-inline-size: 0; }
.jiggle-mode .tile-cell > .btn-tile { inline-size: 100%; block-size: 100%; touch-action: none; cursor: grab; }
/* ut-docs#2534: the all_filter_chips All grid with no category chip is
   edit-only (no drag -- app.js's canMove), so its tiles keep the finger-pan
   and a plain pointer; under a chip app.js's syncAllGridMovable sets
   .all-grid-movable (the fragment's data-all-filter marker is there) and
   the rule above applies. A class, not :has(), so it holds on every engine. */
.jiggle-mode #buttons-grid-all:not(.all-grid-movable) > .tile-cell > .btn-tile { touch-action: manipulation; cursor: pointer; }
.jiggle-mode .tile-badges { display: contents; }
.tile-badge {
  position: absolute; inset-block-start: -8px; z-index: 2;
  inline-size: 44px; block-size: 44px; padding: 0; margin: 0; border: 0;
  background: transparent; display: flex; align-items: center; justify-content: center;
  cursor: pointer; color: var(--text); font: inherit; text-decoration: none;
  -webkit-tap-highlight-color: transparent;
}
.tile-badge-edit { inset-inline-start: -8px; }
.tile-badge-remove { inset-inline-end: -8px; }
/* ut-docs#2541: the Hide badge sits at the inline-END BOTTOM corner --
   `inset-block-start: -8px` above is overridden with `auto` so this one
   anchors off `inset-block-end` instead (the shared `.tile-badge` rule sets
   block-start, not block-end, so both must be set explicitly here or the
   two would stack at the same corner). Logical properties only, same as
   every other badge in this file, so it mirrors under RTL for free. It sits
   a full 44px hit-area's worth below the top-corner badges (top corner's
   own -8px offset + its 44px box), so the two never overlap even on the
   smallest tile this grid ever renders (checked at 1024x600 and 360px). */
.tile-badge-hide { inset-block-start: auto; inset-block-end: -8px; inset-inline-end: -8px; }
/* ut-docs#2465: Move to category -- the fourth corner, inline-START BOTTOM
   (mirrors .tile-badge-hide), so no two badges ever share a corner. Shown
   only when app.js's utTileJiggle marked the grid .recat-enabled (a strip
   with category tabs for a catalog_management session); otherwise there is
   nowhere to move to. The arrow in the folder points inline-end, so it
   flips under RTL. */
.tile-badge-move { inset-block-start: auto; inset-block-end: -8px; inset-inline-start: -8px; display: none; }
.recat-enabled .tile-badge-move { display: flex; }
[dir="rtl"] .tile-badge-move svg { transform: scaleX(-1); }
/* A category tab the dragged tile would move to on release: an inset
   outline in the accent colour, a tinted fill and a down-arrow marker --
   shape and outline, never colour alone. Inset, so it isn't clipped by the
   tab bar's own overflow. */
.products-finder .tab-bar .tab.tile-drop-target {
  outline: 3px dashed var(--accent); outline-offset: -3px;
  background: color-mix(in srgb, var(--accent) 14%, var(--surface));
  color: var(--text);
}
/* The marker is a masked shape with empty content, so it never joins the
   tab's accessible name (a text glyph there would be read out). */
.products-finder .tab-bar .tab.tile-drop-target::after {
  content: ""; display: inline-block; vertical-align: middle;
  inline-size: .9em; block-size: .9em; margin-inline-start: .35rem;
  background: var(--accent);
  -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 4v14'/%3E%3Cpath d='m6 12 6 6 6-6'/%3E%3C/svg%3E") center / contain no-repeat;
  mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 4v14'/%3E%3Cpath d='m6 12 6 6 6-6'/%3E%3C/svg%3E") center / contain no-repeat;
}
/* The Move to category dialog reuses the overflow sheet's frame/tiles;
   its own bits are the hint line and the Cancel row. */
.tile-move-dialog h3 { margin: 0; }
.tile-move-hint { margin-block: .35rem 0; color: var(--muted); font-size: .9rem; }
.tile-move-option[hidden] { display: none; }
.tile-move-actions { display: flex; justify-content: flex-end; }
.tile-move-actions .btn { min-block-size: 44px; }
.tile-badge-dot {
  position: relative;
  display: flex; align-items: center; justify-content: center;
  inline-size: 22px; block-size: 22px; border-radius: 50%;
  background: var(--surface); border: 1px solid var(--border);
  box-shadow: 0 1px 4px rgba(15, 23, 42, .25);
}
.tile-badge-dot svg { inline-size: 13px; block-size: 13px; display: block; }
.tile-badge-remove .tile-badge-dot { color: var(--danger); }
.tile-badge:focus-visible .tile-badge-dot { outline: 2px solid var(--accent); outline-offset: 2px; }
/* ut-docs#2361: the jiggle-mode lock affordance -- a small notification-dot
   overlay at the badge-dot's outer corner for a non-granted (cashier)
   session, visible BEFORE they tap into the real catalog_management
   elevation prompt (ut-docs#2312). Positioned relative to the PARENT
   .tile-badge-dot (given position: relative just above for exactly this),
   not the outer 44px .tile-badge hit box, so it sits at the visible dot's
   own corner regardless of the hit box's own padding. Sized and colored
   like a status dot, not a third badge -- this lock span ALSO carries the
   base `.tile-badge-dot` class (buttons.html), so `.tile-badge-remove
   .tile-badge-dot { color: var(--danger) }` above does set `color` on it
   too, by descendant-selector match, not just on the trash icon's own
   dot. It still renders in var(--warning) below because color is only
   ever INHERITED onto a child that has no matching rule of its own --
   `.tile-badge-lock svg` is a rule that directly targets the svg
   element, and a direct match always wins over a value merely inherited
   from an ancestor, regardless of the ancestor rule's own specificity. */
.tile-badge-lock {
  position: absolute; inset-block-end: -3px; inset-inline-end: -3px;
  inline-size: 14px; block-size: 14px; box-shadow: 0 1px 3px rgba(15, 23, 42, .3);
}
.tile-badge-lock svg { inline-size: 8px; block-size: 8px; color: var(--warning); }
/* The wobble: rotate-only (compositor, no layout), ~1.2deg either way,
   alternating, infinite while the mode is on. The per-nth-child delay/
   duration nudges are what make it read as the organic iOS shake rather
   than a row of tiles ticking in lock-step. No prefers-reduced-motion
   block of its own: the ONE global reduced-motion rule earlier in this
   file zeroes animation-duration on everything, and a 0s infinite
   animation with no fill-mode has no effect at all -- the tiles simply sit
   still, badges and drag still fully usable. */
@keyframes ut-jiggle { from { transform: rotate(-1.2deg); } to { transform: rotate(1.2deg); } }
.jiggle-mode .tile-cell > .btn-tile { animation: ut-jiggle .25s ease-in-out infinite alternate; }
.jiggle-mode .tile-cell:nth-child(2n) > .btn-tile { animation-delay: -.13s; }
.jiggle-mode .tile-cell:nth-child(3n) > .btn-tile { animation-duration: .29s; }
.jiggle-mode .tile-cell:nth-child(5n) > .btn-tile { animation-delay: -.07s; animation-duration: .23s; }
/* The tile under the finger: animation off (an animation's transform
   would override the inline translate the drag writes), no transition lag
   on that translate, lifted above its siblings and slightly enlarged --
   the iOS "picked up" affordance. Siblings shuffle via a short transform
   transition on their CELLS (app.js's FLIP step -- the tile itself is
   already animating transform, which would override a FLIP set on it) so
   the reflow reads as tiles sliding out of the way rather than snapping. */
.jiggle-mode .tile-cell > .btn-tile.dragging {
  animation: none; transition: none; cursor: grabbing;
  z-index: 10; position: relative; transform: scale(1.06);
  box-shadow: 0 8px 20px rgba(15, 23, 42, .3);
}
.jiggle-mode .tile-cell.shuffling { transition: transform .15s ease; }
/* The dragged tile's OWN badges: hidden for the duration of the drag.
   They anchor to the .tile-cell, which stays put while the tile itself
   rides an inline translate under the finger — so without this they
   detach and hover over the empty slot the tile just left (measured live
   during independent review, 2026-09-17: tile moved 25px, badges moved 0).
   A general-sibling selector, not :has(): the badges span always follows
   the tile inside .tile-cell (buttons.html's product-tile), so this needs
   no extra class and no :has() support from WebKitGTK. Only the dragged
   tile's badges go — every other tile keeps showing its own. */
.jiggle-mode .tile-cell > .btn-tile.dragging ~ .tile-badges { display: none; }
/* Done bar: sticky at the top of .products' own scroll so Done is always
   in reach however far the drag went. Same [hidden]-override precedent as
   .btn/.btn-tile[hidden]: display: flex would otherwise beat the UA's
   [hidden]{display:none}. */
.jiggle-bar {
  display: flex; align-items: center; justify-content: space-between; gap: .5rem;
  position: sticky; inset-block-start: 0; z-index: 3;
  padding: .3rem .4rem; border-radius: var(--radius);
  background: var(--surface-2); border: 1px solid var(--border);
}
.jiggle-bar[hidden] { display: none; }
.jiggle-bar-title { font-size: .85rem; font-weight: 600; color: var(--muted); min-inline-size: 0; }
/* ---------- Hidden tiles, edit-mode search add (ut-docs#2698) ----------
   A HIDDEN quick button (buttons.html's product-tile, .tile--hidden) stays
   in the rendered grid in its own spot, so edit mode can show it greyed
   with an Unhide badge. On the sale screen it also carries
   .tile--rest-hidden: display:none at rest, so the resting sell screen
   looks exactly as if it weren't there, and a normal grid cell again once
   #buttons-grid is in .jiggle-mode (same `block` the other cells get). The
   Designer never adds .tile--rest-hidden -- it always shows them greyed.

   Greyed, never by colour alone (WCAG 1.4.1): the tile's surface goes to
   the neutral well colour with a dashed border and the category accent
   desaturated; its picture and price fade (opacity + grayscale); and an
   eye-off glyph sits in the tile. The NAME keeps var(--text) on
   var(--surface-2) -- the same pair every other well in this file uses --
   so the label stays well above 4.5:1 in both themes; opacity is never put
   on the label or on the whole tile for exactly that reason. A colour
   swatch tile loses its swatch (its white label would otherwise sit on a
   greyed swatch). */
.tile-cell.tile--rest-hidden { display: none; }
.jiggle-mode .tile-cell.tile--rest-hidden { display: block; }
.tile--hidden > .btn-tile,
.tile--hidden > .btn-tile.tile-colored {
  background: var(--surface-2); border-style: dashed; box-shadow: none;
  border-inline-start-color: var(--border);
}
.tile--hidden > .btn-tile .thumb,
.tile--hidden > .btn-tile .tile-price { opacity: .55; filter: grayscale(1); }
.tile--hidden > .btn-tile .tile-name,
.tile--hidden > .btn-tile.tile-colored .tile-name,
.tile--hidden > .btn-tile.tile-colored .tile-price { color: var(--text); }
.tile-hidden-mark { display: flex; justify-content: center; color: var(--muted); }
.tile-hidden-mark svg { inline-size: 18px; block-size: 18px; }
/* The Unhide badge takes the Hide badge's corner (.tile-badge-hide) and its
   44x44 hit area (.tile-badge); an accent dot so it reads as the way back. */
.tile-badge-unhide .tile-badge-dot { background: var(--accent); border-color: var(--accent); color: var(--accent-contrast, #fff); }
/* A category whose tiles are ALL hidden: its strip tab and overflow tile are
   out of sight at rest and come back while the grid is in jiggle mode
   (app.js's utTileJiggle sets .jiggle-active on .products-finder, the
   tabs' ancestor -- #buttons-grid, which carries .jiggle-mode, is their
   sibling). The "no quick buttons yet" line under such a category is the
   at-rest message only: in jiggle mode and on the Designer the greyed tiles
   show instead. */
.tab.tab-hidden-only, .category-overflow-tile.tab-hidden-only { display: none; }
.jiggle-active .tab.tab-hidden-only:not([hidden]) { display: inline-block; }
.jiggle-active .category-overflow-tile.tab-hidden-only { display: flex; }
.jiggle-mode .category-empty-state--hidden-only,
.products--edit .category-empty-state--hidden-only { display: none; }
/* Sell-screen search in edit mode: a result that isn't a visible quick
   button carries "Add to quick buttons". At rest the wrapper is
   display: contents and the action display:none, so the result is the same
   sellable tile it always was; in jiggle mode the pair stacks in one grid
   cell. The action is a plain .btn (>= the 44px floor). */
.search-result-cell { display: contents; }
.search-add-quick { display: none; }
.jiggle-active .search-result-cell { display: flex; flex-direction: column; gap: .35rem; min-inline-size: 0; }
.jiggle-active .search-add-quick { display: inline-flex; justify-content: center; min-block-size: 44px; white-space: normal; }
/* ut-docs#2095: #tax-codes-modal/#import-modal are .item-form-modal, not
   .modifier-modal (independent review, F1/F2 -- both dialogs hold real
   text inputs, so they need the same OSK-reachable .show()/no-top-layer
   treatment #item-form-modal itself uses, not showModal()'s inert-outside
   behaviour) -- see catalog.html's own comment on the two dialogs for the
   full reasoning. .item-form-modal is already full-bleed (inset: 0, no
   max-width), so unlike .barcode-backfill-modal/.modifier-modal above
   there is no dialog width to widen here. What's still needed is a
   horizontal-scroll safety net on the tax-codes table's own wrapper
   (#tax-codes-table, tax_codes_table.html) at narrow viewports -- measured
   at 360x800 the Active/Edit/Deactivate columns clip without it.
   ut-docs#2113 (independent review of this same card flagged the gap,
   fixed as its own follow-up): the STANDALONE /catalog/tax-codes page
   renders the identical #tax-codes-table partial outside this dialog
   entirely, and was measured overflowing WORSE at 360px with no rule
   reaching it at all (overflow-x: visible, ~183px of table hanging off
   the document, the whole page scrolling horizontally to 521px in a
   360px viewport). #tax-codes-table is a single unique id per page render
   -- whether reached via this dialog or the standalone page -- so
   unscoping the selector below covers both call sites with one rule
   instead of duplicating it under `.catalog-list` too. */
#tax-codes-table { overflow-x: auto; }
/* Hold Sale naming (ut-docs#46) is opened non-modal (dialog.show(), not
   showModal()): a showModal() dialog makes everything outside it inert,
   including the custom on-screen keyboard (#osk, appended to <body>, not
   inside the dialog) that kiosk Pis depend on -- there's no OS keyboard to
   fall back to there. Anchored near the top rather than vertically
   centered so it never sits under the OSK's fixed bottom panel (ut-docs#1998:
   height now tracks `--osk-reserved-height`, previously a hardcoded 15.5rem)
   while it's open. */
/* ut-docs#1542: #hold-modal can now open WHILE #payment-overlay is also
   open (the overlay's own in-body Hold Sale duplicate, index.html, added
   for that card so Hold Sale is reachable at all at narrow desktop
   viewports). At the same z-index (500, shared with #pfand-modal/
   #elevation-modal/#table-add-modal below) equal stacking falls back to
   DOM order, and .payment-overlay is declared LATER in index.html than
   this dialog -- so without this bump, #hold-modal painted BEHIND the
   still-open overlay at any viewport narrow enough for their centered/
   right-anchored boxes to overlap (confirmed live via screenshot at
   1024x600: the modal's own Hold Sale submit button was entirely hidden
   under the Tender panel). 550 is the minimum bump that clears
   .payment-overlay's 500 while staying below #osk's 1000, so the on-screen
   keyboard (#hold-label-input can open it) still renders on top of this,
   same reasoning .payment-overlay's own z-index comment above gives. */
#hold-modal { position: fixed; inset-block-start: 8vh; inset-inline: 0; margin-inline: auto; z-index: 550; }
#hold-label-input { width: 100%; margin-block: .5rem .75rem; }
/* ut-docs#2097 (product owner, 2026-09-28): the till's former showModal()
   dialogs. showModal() made everything outside the dialog inert -- the
   status bar and the rail's Lock included (CLAUDE.md "Offline-first",
   ut-docs#1999) -- and "unclickable counts as blocked, no exception for
   small dialogs". They open with .show() now, like #hold-modal above, so
   this is the explicit frame the top layer used to give them: fixed,
   centred in both axes as the UA's `dialog:modal` did (none of them holds
   a text field, so the #hold-modal family's 8vh anchor clear of the
   on-screen keyboard is not needed), and capped short of the viewport so
   a tall one never paints over the status bar (<= 2 rows) at the bottom.
   Logical properties only (RTL). #ut-scrim (z 450, base.html) blocks the
   page behind them; the status bar (460) and Lock stay above it.
   Stacking (was top-layer open order): #category-items-modal (500) can
   open #modifier-modal, and #order-type-prompt-modal can gate a tile tap
   or a scan while #modifier-modal is open (ut-docs#2371: never under it),
   so the modifier is 560 (over #category-items-modal's 500 and
   #hold-modal's 550) and the prompt 570 (over the modifier) -- all below
   #osk (1000). The self-order kiosk's
   #selforder-modal stays showModal() (kiosk mode must NOT reach status/
   lock/exit) and keeps .modifier-modal::backdrop. */
#modifier-modal, #order-type-prompt-modal, #table-modal, #barcode-backfill-modal, #sku-backfill-modal, #table-qr-modal {
  position: fixed; inset: 0; margin: auto; block-size: fit-content;
  max-block-size: calc(100dvh - 8rem); overflow-y: auto; z-index: 500;
}
#modifier-modal { z-index: 560; }
#order-type-prompt-modal { z-index: 570; }
/* Keep the payout dialog out of the native top layer: the custom on-screen
   keyboard is appended to <body> and must remain clickable while it is open. */
#pfand-modal { position: fixed; inset-block-start: 8vh; inset-inline: 0; margin-inline: auto; z-index: 500; }
/* Manager-override-elevation dialog (ut-docs#557): same non-modal treatment
   as #hold-modal/#pfand-modal above, and for the same reason — its PIN
   field is type="password", which web/public/osk.js's wantsOSK() opens the
   on-screen keyboard for, and showModal()'s inert-outside-the-dialog
   behaviour would make that keyboard unreachable on kiosk hardware with no
   physical keyboard. */
#elevation-modal { position: fixed; inset-block-start: 8vh; inset-inline: 0; margin-inline: auto; z-index: 500; }
.elevation-summary { font-weight: 600; margin-block: .4rem .6rem; }
/* Satellite-role confirm dialog (ut-docs#2781): same non-modal treatment
   as #elevation-modal above (cloned from it, minus the PIN field) — it
   needs the identical fixed position + z-index, which .modifier-modal
   alone does not provide (ut-docs#2873's scrim sweep caught the gap). */
#confirm-modal { position: fixed; inset-block-start: 8vh; inset-inline: 0; margin-inline: auto; z-index: 500; }
/* Tap-to-add table dialog (ut-docs#1025): same non-modal treatment as
   #hold-modal/#pfand-modal/#elevation-modal above and for the identical
   reason — its label field is what a kiosk operator types the new table's
   name into, and showModal()'s native top-layer/inert-outside behaviour
   both hid the custom OSK and (observed in review) diverged the dialog's
   painted position from its own measured geometry under dir="rtl". This
   explicit fixed position replaces reliance on the browser's native
   top-layer centering, which fixes both. */
#table-add-modal { position: fixed; inset-block-start: 8vh; inset-inline: 0; margin-inline: auto; z-index: 500; }
/* Category-filter popover (ut-docs#2165) — same non-modal .show() family
   as #hold-modal/#pfand-modal/#elevation-modal/#table-add-modal above and
   for the identical reason (the custom on-screen keyboard must stay
   reachable), just class-scoped instead of id-scoped since this dialog
   renders twice on the page (catalog.html AND inventory.html each ship
   their own instance with a different controlID). inset-inline (not
   left/right) so the centered anchor mirrors correctly under dir="rtl"
   with no separate rule. */
.category-filter-control { position: relative; display: inline-flex; }
.category-filter-dialog {
  position: fixed; inset-block-start: 8vh; inset-inline: 0; margin-inline: auto; z-index: 500;
  max-width: 22rem;
}
.category-filter-dialog-head {
  display: flex; align-items: center; justify-content: space-between; gap: .75rem;
  margin-block-end: .5rem;
}
.category-filter-dialog-head h3 { margin: 0; font-size: 1rem; }
/* The chip row's own default top margin/wrapping is fine at full page
   width but wants a scroll boundary once it's confined to this dialog's
   22rem cap on a shop with many top-level categories. */
.category-filter-dialog .category-filter { max-height: 50vh; overflow-y: auto; }
/* The trigger's active-filter indicator: presence/absence (a real hidden
   attribute JS toggles, category-filter.js's paintTriggerState), not a
   colour swap — same WCAG 1.4.11 reasoning category_filter.html's own
   chip-check already documents for the chips themselves. --surface as the
   ring colour (not --brand, unlike .nav-badge above) since this button
   sits on a page card, not the dark nav rail. */
.category-filter-badge {
  position: absolute; inset-block-start: -.1rem; inset-inline-end: -.1rem;
  inline-size: .6rem; block-size: .6rem; border-radius: 50%;
  background: var(--accent); border: 1px solid var(--surface);
}
/* Anchors the badge above — plain .btn-ico has no positioned ancestor
   (unlike .nav-toggle-ico, which app.css gives one for the same reason),
   so this is scoped to just the one trigger that needs it. */
.category-filter-trigger .btn-ico { position: relative; }
.modifier-group { border: 1px solid var(--border); border-radius: var(--radius);
                   padding: .6rem .75rem; margin-block-end: .75rem; }
.modifier-group legend { font-weight: 600; padding-inline: .25rem; }
.modifier-required { color: var(--danger, #dc2626); }
.modifier-option { display: flex; align-items: center; gap: .5rem; padding: .35rem 0;
                    min-height: 44px; }
.modifier-price { margin-inline-start: auto; color: var(--accent); font-variant-numeric: tabular-nums; }
.modifier-actions { display: flex; justify-content: flex-end; gap: .5rem; margin-block-start: .5rem; }
/* ut-docs#413: `#pfand-modal`/`#hold-modal` already size themselves safely
   at 90vw (confirmed live at 360px — the deposit-refund dialog itself,
   its amount/manager-PIN inputs and its Cancel/Pay Out buttons all measure
   fully on-screen with no CSS change needed here). This is a defensive-
   only addition for locale/scale combinations this session couldn't drive
   live (a longer fa/ar button label, or a higher ui_scale stacked on a
   360px phone): without wrapping, two unshrinkable buttons could still
   squeeze past the modal's own narrow content width the same way the
   nav/kiosk-header buttons did. Wrapping costs nothing when they already
   fit (today, in English, at 1x scale) and only ever helps otherwise. */
@media (max-width: 480px) {
  .modifier-actions { flex-wrap: wrap; }
}
/* qty ABOVE line-discount (ut-docs#1314) — a deliberate reversal of the
   ut-docs#213-era rule that kept them side by side. History, both ways:
   - Pre-#213, a stacked pair nearly doubled every row's height and was
     the main reason only ~1 line item fit in the OLD basket panel —
     which was half a column, stacked above the tender panel.
   - Post-#213 the basket owns the full left column, so row height has
     real headroom — and the side-by-side pair's 8rem column reservation
     became the dominant width cost: it left the ITEM column ~2.6-2.9rem
     of text space (~45-47px measured live at 1024x600 and 1280x800),
     clamping "Cheddar Cheese 400g" to "Chedd Che…" (ut-docs#1314).
   Stacking frees ~3.7rem of width for the item column (see the column
   budget at .basket table below). Measured live at 1280x800 after this
   change (with the input line-height/padding shave below): rows are
   ~77-79px — the same ~79px they already were, because pre-change row
   height was set by 2-line-clamped NAMES + SKU while post-change it's
   set by the stacked inputs — and the ut-docs#213 ">=4 without
   scrolling" AC passes with a 12.9px margin (pre-change margin: ~11px).
   Known, accepted trade-off, RE-MEASURED at review (2026-08-30) because
   the first pass under-reported it — quote these numbers, not "3 instead
   of 4", if there is ever a 5th attempt. Under body.kiosk the two stacked
   inputs keep their 2.1rem touch min-height, so nothing shaves kiosk rows
   the way the shave below shaves normal ones, and kiosk rows grow
   73.6->90.2px / 77.3->94.7px. Fully-visible basket lines, before -> after,
   six lines rung up:
     - NON-kiosk: 1280x800 4->4, 1024x600 1->1, 901x600 1->1, 360x800 1->1.
       No row-count regression at ANY viewport — the shave below exactly
       absorbs the stacking.
     - body.kiosk: 1280x800 4->3, and at the DOCUMENTED KIOSK FLOOR
       1024x600 (and 901x600) 2->1. The floor case is the worse half and
       is the one the first pass omitted.
   Accepted because the alternative is worse for the same operator: before
   this change those kiosk rows were legible-count-2 but name-width 44.8px,
   i.e. two lines of "Chedd Che…" the operator cannot actually verify;
   after, it is one line of a fully readable name (107.7px) plus a scroll.
   Nothing is blocked either way — .basket-scroll scrolls, .totals is
   pinned, and the .basket-count badge carries the quantity. Recovering the
   kiosk row count without re-starving the name needs a phone/kiosk-tier
   row layout, which is deliberately NOT in this card.
   align-items: flex-end = inline-end in a column
   flex, so the pair hugs the cell's end edge in LTR and RTL alike. */
/* ut-docs#2217: .25rem -> .15rem. Three stacked children now (qty-input,
   the +/- stepper row, disc-input), not two -- an unchanged .25rem gap
   would apply TWICE, adding .25rem (~4.5px) more height than before this
   card ever asked for. Trimmed to .15rem, which was enough (with no other
   change) to clear sale-screen-213.spec.ts's ">=4 rows visible" AC again
   after the stepper row's own real height pushed row height right to that
   AC's edge -- measured live: 4 rows needed <=508.91px of the panel's
   543.59px box, and at .25rem this design summed to 511.5px, 2.59px over
   (row 4's bottom edge past the box by exactly that amount); .15rem
   brings it to a genuinely passing margin, not a razor-thin one. */
.line-inputs { display: flex; flex-direction: column; align-items: flex-end; gap: .15rem; }
/* line-height 1.2 (matching .line-name's own) + .25rem vertical padding:
   with the ut-docs#1314 stacked pair, the INPUTS are what set row height
   (they outgrow the 2-line-clamped item text), and the inherited 1.45
   body line-height + .3rem padding made rows 87.8px at 1280x800 — the
   4th row overshot .basket-scroll's box by ~25px, breaking ut-docs#213's
   ">=4 lines visible" AC (measured live, 2026-08-30). These two shaves
   bring rows back to ~77-79px (4 rows fit again, 12.9px margin,
   measured live). Touch height on
   kiosk hardware is untouched: body.kiosk's 2.1rem min-height (below)
   still wins there — verified at review, byte-identical before/after
   (37.5px at 1280x800, 35.7px at 1024x600).
   But read "kiosk hardware" literally, NOT as "no touch target moved":
   OUTSIDE body.kiosk these two inputs do shrink, 34.7->29.1px at 1280x800
   and 33.1->27.8px at 1024x600 (measured at review). That matters because
   ut-docs#1021 confirmed a REAL deployed till running window_mode=normal
   with the kiosk service inactive — i.e. a touchscreen that never gets
   body.kiosk (the same finding that made ut-docs#1170 un-gate .btn's
   touch protections from body.kiosk). Accepted here rather than fixed:
   these inputs were ALREADY under this product's 44px touch-target
   minimum before this change (33-35px), so the shave lowers a floor that
   was never met rather than breaking one that was, and it stays above
   WCAG 2.2 AA 2.5.8's 24px (2.5.5, cited near here elsewhere in this file,
   is the DIFFERENT 44px AAA "Enhanced" criterion — not this floor; fixed
   at both those other spots as part of ut-docs#1340 below, since that card
   IS the WCAG-citation question and left them newly inconsistent
   otherwise). Raising basket qty/discount to a real 44px in non-kiosk mode
   is its own card — it cannot be done by padding alone without
   re-breaking ut-docs#213's ">=4 rows" AC that the shave exists to
   protect.

   ut-docs#1340 investigated raising it anyway and REJECTED it — closed
   decision, not an open question. The decisive number: two inputs at a
   real 44px each, plus the `.25rem` gap between them (tracks
   `--ui-scale`/`--fluid-fs`'s `clamp(17px,...,20px)`, so ~4.25-5px at the
   default scale, not a flat 4px), need ~92-98px of vertical content —
   inside rows currently 77-79px with only 12.9px of measured margin (the
   ut-docs#1314 review's own number). That kills every SYMMETRIC sizing
   variant at once, not just padding: widening the gap instead of the
   inputs costs the identical vertical budget, and an invisible enlarged
   hit-area (a <label> overlay, out of flow, so it doesn't touch row
   height) still can't reach 44px symmetrically without the two overlays
   overlapping by ~11-12px in that shared gap — a real misclick risk
   between qty and discount, worse than the current undersized-but-
   unambiguous targets. (A third stacked child, `.line-order-type-phone`,
   used to join this column below 480px, which is why the ut-docs#2217
   overlay was flagged as needing to account for it too, not just the
   qty/disc pair -- removed by ut-docs#2282/#2309, so that's now moot;
   left as historical context for why this paragraph reads the way it does.)
   An ASYMMETRIC overlay — qty's hit-area growing into the row's own
   `.55rem` cell padding upward, disc's growing into it downward, never
   into the shared gap or the next row — reaches ~39px each without
   touching row height at all. Deliberately NOT built here: it is a real
   interaction change (hit-testing inside a <td>'s padding under
   `vertical-align: middle`, the phone-breakpoint third child above) that
   wants its own tests, and ut-docs#2217 is already redesigning this
   control (dedicated +/- steppers) — one coordinated interaction
   redesign, not two uncoordinated ones.
   Current sizes already clear the real WCAG AA floor (2.5.8, 24x24px);
   44px is this product's own stricter internal aspiration, not a
   compliance requirement — and at the supported `ui_scale 2` (these
   inputs are rem-sized, so they track `--ui-scale` directly) they are
   already ~56px, over 44, so the gap only bites at the default scale.
   No new device re-verification: nothing here changes a measured
   dimension, so ut-docs#1021/#1314's existing on-device measurements
   still stand — this card's decision is about the trade-off between
   sizing variants, not a new number to confirm on hardware.
   Decision: keep this size at the default scale. The real fix is
   ut-docs#2217 (dedicated +/- steppers, each their own non-overlapping
   44px target, in BOTH kiosk and non-kiosk — kiosk's own 2.1rem/37.5px
   floor is also under 44px); this compact input stays for the rarer
   "type an exact number" path and is out of scope there. */
.qty-input { width: 3.4rem; padding: .25rem .3rem; font-size: .85rem; line-height: 1.2; }
.disc-input { width: 3.4rem; padding: .25rem .3rem; font-size: .8rem; line-height: 1.2; }
.btn-x { padding: .3rem .4rem; font-size: .8rem; min-height: 0; }
/* Shrinkage reason sheet (ut-docs#1465, G41): the toggle button is
   .btn-x itself (same touch target as the plain remove button it
   replaces for a non-zero-value line — the card's own hard requirement).
   The sheet itself is a native <dialog> (.modifier-modal base, same as
   #hold-modal/#pfand-modal/#elevation-modal) opened via .show() (never
   .showModal(), same reasoning as elevation_prompt.html).
   CORRECTION (Tester finding, ut-docs#1465 review): .show() does NOT get
   the browser's top-layer/margin:auto centering — that is a .showModal()
   -only behavior — so unlike #hold-modal/#pfand-modal/#elevation-modal
   (each a top-level element near <body>), THIS dialog is nested inside
   each basket line row, and without an explicit position here it renders
   at that row's own in-flow position — measured overflowing past the
   bottom of a 360px-tall viewport on a line near the end of a long
   basket, with no scroll affordance surfaced. `position: fixed` +
   centering below takes it out of flow and out of `.basket-scroll`'s
   overflow:auto entirely (fixed is viewport-relative; nothing in this
   dialog's ancestor chain sets `transform`, which is the only thing that
   would re-trap a fixed-position descendant) — this is what the comment
   above originally claimed happened for free and didn't. inset-inline
   (not left/right) keeps this RTL-correct with no separate ruleset. */
/* z-index 500 (ut-docs#2873): the non-modal popup family's layer, above
   the shared popup scrim (450) -- without it the sheet painted under it. */
.shrinkage-sheet,
.age-check-sheet {
  position: fixed; inset-block-start: 50%; inset-inline: 0; z-index: 500;
  transform: translateY(-50%); margin-inline: auto;
  max-height: min(80vh, 32rem); overflow-y: auto;
}
/* ut-docs#3340: the age-restricted line's persistent ID-check badge and its
   sheet. Reuses the shrinkage sheet's fixed, centred frame above; the badge
   is a compact pill under the line name, at least 44px tall to stay a real
   touch target. State shows as TEXT ("Needs ID check" / "ID checked" / "ID
   refused"), colour is only a second signal, from the existing tokens. */
.age-check { display: block; margin-block-start: .2rem; }
.age-check-toggle {
  display: inline-flex; align-items: center; gap: .3rem;
  min-block-size: 44px; padding-block: .2rem; padding-inline: .6rem;
  border: 1px solid var(--warning); border-radius: 999px;
  background: transparent; color: inherit; font: inherit; font-size: .8rem;
  cursor: pointer; text-align: start;
}
.age-check-toggle.is-accepted { border-color: var(--success); color: var(--success); }
.age-check-toggle.is-refused { border-color: var(--danger); color: var(--danger); }
.age-check-toggle .btn-ico svg { inline-size: 1rem; block-size: 1rem; }
.age-check-sheet-prompt { margin: 0 0 .3rem; font-size: .95rem; font-weight: 600; }
.age-check-sheet-item { margin: 0 0 .5rem; }
.age-check-refused-hint { margin: 0 0 .5rem; color: var(--danger); font-weight: 600; }
.age-check-sheet-actions { display: flex; flex-direction: column; gap: .5rem; }
.age-check-sheet-actions .btn { white-space: nowrap; }
.shrinkage-remove { display: inline-block; }
.shrinkage-sheet-prompt { margin: 0 0 .3rem; font-size: .95rem; font-weight: 600; }
.shrinkage-sheet-actions { display: flex; flex-direction: column; gap: .5rem; }
.shrinkage-sheet-actions .btn { white-space: nowrap; }
/* ut-docs#2217: the +/- stepper is its OWN row, the same 3.4rem width as
   .qty-input/.disc-input above and below it — NOT flanking .qty-input
   horizontally. Three width attempts were tried in that flanking shape
   (square 2.6rem buttons; 1.8rem buttons; 1.4rem buttons + a shrunk
   qty-input) and every one of them, even the narrowest, still grew the
   qty column's reserved rem budget past 4.3rem and re-clipped a real
   19-26 char product name below what basket-item-name-width-1314.spec.ts
   requires — measured directly: at a mere 5.6rem column (barely over
   4.3rem), .line-name's available width was still only 90px, and the
   test needs ~113px (extrapolated from measured scrollHeight-vs-width
   pairs at three column widths). The ITEM column has essentially NO
   horizontal slack left for this table to give, full stop — not "not
   quite enough", genuinely none, confirmed by the FULL e2e suite (the
   three specs an earlier pass ran directly don't cover this file at
   all). But the qty/disc column was ALREADY the taller of the two
   siblings sharing this row (measured: .line-inputs 78.9px vs .line-item
   63.6px) before this card touched anything — so it has real, unused
   vertical room a horizontal-only design never spent. A row of two
   buttons AT the existing 3.4rem width, splitting it via flex:1, uses
   that vertical room instead and costs the qty column's reserved WIDTH
   nothing — table-layout:fixed's column width stays exactly 4.3rem,
   unchanged from before this card, verified by re-running
   basket-item-name-width-1314/1338, basket-no-horizontal-scroll-391 and
   sale-screen-213 all green. Height (2.6rem, 44.2px at --fluid-fs's
   measured floor of 17px) clears 44px at every supported viewport; each
   button's own width (flex:1 of 3.4rem minus one gap, ~1.65rem/~29px)
   clears WCAG 2.5.8's real 24px floor with room to spare, and this
   codebase already treats its touch-target floor as height-only
   elsewhere anyway (.btn-touch sets only min-height, no min-width). */
.qty-stepper { display: flex; width: 3.4rem; gap: .2rem; }
.qty-step-btn { flex: 1; min-height: 2.6rem; padding: 0; font-size: 1.1rem; line-height: 1; border-radius: 6px; }

.order-type-row { display: flex; align-items: center; gap: .5rem; margin-bottom: .6rem; }
/* 2026-08-31 (product owner, live on the tablet, ut-docs#1379): a compact
   two-segment control replacing the single sliding switch this row
   briefly had (2026-08-30) — the switch's one-label-that-changes-on-tap
   turned out to read as "tap this to switch TO the named state" rather
   than "the named state is active now", which is exactly backwards for a
   control that decides a fiscal receipt's VAT rate
   (internal/pos/service.go, tax_codes.takeaway_rate_basis_points -- the
   German §12 UStG dine-in/takeaway split). Both segments are always
   visible and labeled; the active one is filled, not inferred from a
   knob position.
   Two same-day follow-ups after this first landed, both from the product
   owner watching it live: (1) "too big" -- dropped from the switch's old
   3rem/48px floor to the app's own established `.btn.compact` floor
   (2.5rem/40px, still a real deliberate touch target, above WCAG 2.2 AA
   2.5.8's 24px). (2) reference screenshot supplied showing a small self-
   contained widget: `flex: 1` was still stretching the group to fill the
   WHOLE row. Both the group and each option are now `flex: 0 0 auto` --
   sized to their own icon+label content, not to the row. `.order-type-row`
   has no `justify-content` override, so the group hugs the row's start
   and the (also content-sized) table button sits right next to it with
   the row's own `gap`; leftover row width is simply empty, which is
   correct for two small controls, not a layout bug. */
.order-type-toggle-group {
  flex: 0 0 auto; display: flex; border: 1px solid var(--border);
  border-radius: 999px; overflow: hidden;
}
.order-type-option {
  flex: 0 0 auto; display: flex; align-items: center; justify-content: center;
  gap: .3rem; min-height: 2.5rem; padding: .35rem .7rem; border: none;
  background: var(--surface); color: var(--text); font-weight: 600;
  font-size: .8rem; line-height: 1.15; cursor: pointer;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  transition: background .15s ease, color .15s ease;
}
.order-type-option.is-active { background: var(--accent); color: #fff; }
/* ut-docs#2282/#2309: the per-line dine-in/takeaway segments (ADR-0073
   Decision 2) and the mixed-basket status chip that used to live here were
   REMOVED with the cashier-facing per-line control (product-owner scope
   change, outcome 1) -- see basket.html's own comment on the two bulk
   segments above for why "mixed" itself is still a value worth handling
   there, just no longer with a dedicated status chip. */
@media (max-width: 480px) {
  /* The bulk segments must wrap, never push the basket wider than the
     phone (ut-docs#391's no-horizontal-scroll invariant). */
  .order-type-row { flex-wrap: wrap; }
}
/* Table-assignment button + dialog (ut-docs#820, ADR-0054) — collapsed
   from an always-expanded inline strip into this button next to the
   order-type switch, 2026-08-30. Sized to MATCH .order-type-option (not
   independently re-derived) so the two controls this row shares read as
   one visual weight -- shrunk alongside it, same 2026-08-31 ut-docs#1379
   follow-up: still a real, deliberate touch target (`.btn.compact`'s own
   2.5rem/40px floor), no longer inflated to the switch's old 3rem shape.
   The actual min-height comes from the `compact` class on the button
   itself (table_picker.html), not from a rule here.
   NOTE the `flex: 1` here does nothing: this button's parent is
   table_picker.html's own `<span id="table-picker">` wrapper, and THAT span
   (not the button) is the flex item of `.order-type-row`. Kept only because
   it is harmless and the wrapper may later become `display: contents`;
   measured today the switch takes the row's remaining space and this button
   is content-sized, which is the layout we want anyway. */
.table-picker-trigger { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.table-current { display: block; font-weight: 600; margin-block-end: .5rem; }
.table-picker-options { display: flex; flex-wrap: wrap; gap: .4rem; margin-block-start: .5rem; }
.table-picker-empty { display: block; }

/* ut-docs#391 (3rd recurrence — see .basket-scroll below for two more
   prior symptomatic fixes this replaces). table-layout: auto (the
   default) sizes columns from content and only shrinks once the total
   exceeds the container — but the shrink redistributes across ALL
   columns including ones with no room to give.
   THREE failed attempts before this one, all worth recording so nobody
   re-walks them:
   1. A straight PERCENTAGE budget (33/31/14/14/8) under table-layout:
      fixed. Deterministic against auto-layout's redistribution problem,
      but wrong differently: independent review measured it failing at
      ui_scale 2 (offered in Settings, guarded as supported by
      ui-scale-basket.spec.ts) — negative margins up to -42px, the
      remove button clipped in half again — and passing the one
      viewport the e2e spec covered by only 0.48px. Root cause:
      qty-input/disc-input/.btn-x are REM-sized (track --ui-scale
      exactly), but a PERCENTAGE column is a share of the panel's
      rendered px width, which does not scale in lockstep with rem
      content once the panel hits its own 22rem/26.25rem clamp
      (.pos-container above) at a viewport/scale combination where the
      two stop moving together.
   2. Reserving qty+discount and remove as explicit REM (matching their
      real content, invariant to scale) and splitting the rest via
      calc((100% - reserved) * ratio) on item/price/total. The REM
      columns came out exactly right — but table-layout: fixed's
      column-sizing algorithm does not evaluate calc() the way normal
      box sizing does: instead of the intended 55:22.5:22.5 ratio, the
      browser silently fell back to splitting the remainder EQUALLY
      across the three calc()'d columns, squeezing item to a third of
      its intended share. Table scrollWidth still matched clientWidth
      (no horizontal overflow — the specific thing this bug is about),
      so the e2e spec's geometry assertions never caught it; only a
      targeted live DOM probe (declared vs. rendered column widths) did.
   3. Same REM reservations, but item/price/total as plain PERCENTAGES
      (not calc()) calibrated for the WORST case (the panel's 22rem
      floor, where the REM columns eat the largest share). Correctly
      scale-invariant this time — but a FIXED percentage of the table's
      width can't also be generous at the panel's roomy 26.25rem default
      width, so calibrating for the worst case starved the item column
      everywhere else: regressed ut-docs#213's own ">=4 basket lines
      visible without scrolling at 1280x800" (narrower item column ->
      more names hit their 2-line clamp -> taller rows -> fewer fit).
   The fix that actually holds: reserve qty+discount (still 4.3rem, even
   after ut-docs#2217 added a +/- stepper row into the same column — see
   .qty-stepper's own comment for why the qty/disc column's reserved WIDTH
   didn't need to change at all: three earlier attempts that flanked
   .qty-input with buttons horizontally each needed a wider column, and
   each one, even the narrowest, re-clipped a real product name below
   what basket-item-name-width-1314.spec.ts requires — this table's ITEM
   column turned out to have no horizontal slack left to give, full stop.
   The shipped design instead stacks the stepper as a same-width third row
   below .qty-input, spending vertical room this column already had
   unused rather than new horizontal room it didn't have. Original
   ut-docs#1314 sizing unchanged: one 3.4rem-wide qty-input + .35rem*2 cell
   padding + .2rem headroom = 4.3rem; was 8rem for the pre-#1314
   side-by-side qty/discount pair: two 3.4rem inputs + .25rem gap + cell
   padding), price (4rem) and total
   (4rem) as explicit REM sized to their real content — including the
   PRICE/TOTAL header labels themselves, which need more room than the
   data amounts do — and remove
   (2.8rem: .btn-x + its padding + cell padding, with headroom for
   "wider font metrics (Linux/CI, some kiosk builds)" — .pos-container's
   own comment above documents this exact hazard). ITEM alone is left
   with NO declared width. table-layout: fixed's own algorithm (not
   calc(), not a guessed ratio) gives the one column with no width its
   own dedicated behaviour: 100% of whatever is left after the declared
   columns are honoured — unambiguous with exactly one such column
   (attempt 2's equal-split trap only happens with MULTIPLE unspecified/
   calc()'d columns competing for the same leftover space). So item
   grows freely up to its own max-width (10.4rem, below) at the panel's
   normal 26.25rem width — recovering attempt 3's regression — while
   still only ever shrinking, never overflowing, at the tightest floor+
   scale corner. Verified live — scrollWidth/clientWidth, remove-button
   bbox, each column's actual rendered width against its declared one
   (the exact thing attempt 2 didn't check), AND the >=4-visible-lines
   AC at 1280x800 (the exact thing attempt 3 broke) — across ui_scale
   1/1.75/2 at 901px/1024px/1280px width, LTR and RTL. */
.basket table { width: 100%; border-collapse: collapse; background: var(--surface);
                border-radius: var(--radius); table-layout: fixed; }
.basket th { font-size: .75rem; text-transform: uppercase; letter-spacing: .04em;
             color: var(--muted); padding: .5rem .6rem; border-bottom: 2px solid var(--border);
             text-align: start; }
.basket td { padding: .55rem .6rem; border-bottom: 1px solid var(--border); text-align: start; }
.basket th:nth-child(n+2), .basket td:nth-child(n+2) { text-align: end; }
.basket th:nth-child(2), .basket td:nth-child(2) { width: 4.3rem; }
.basket th:nth-child(3), .basket td:nth-child(3) { width: 4rem; }
.basket th:nth-child(4), .basket td:nth-child(4) { width: 4rem; }
.basket th:nth-child(5), .basket td:nth-child(5) { width: 2.8rem; }
.basket tr:last-child td { border-bottom: none; }
/* 2026-08-30 (product owner, comparing against a competitor POS): two
   columns instead of one long stack — Subtotal/Discount/Tax pair up
   (flowing left-to-right, top-to-bottom — plain divs land in grid cells
   in source order), roughly halving this block's height on the common
   2-3 row case. .total keeps its own full-width row below them
   (grid-column: 1 / -1) — it's the one line that should stay visually
   distinct and easy to find regardless of how many detail rows precede
   it. .totals-customer (basket.html) gets the same full-width treatment
   — independent review measured a real CRM customer name paired against
   a money figure in a half-width cell wrapping to 2 lines and dragging
   the whole row taller with it; a name is the one field here with no
   predictable length the way every money amount has. */
.basket .totals {
  margin-top: .5rem; display: grid; grid-template-columns: 1fr 1fr;
  gap: .2rem .8rem; justify-items: end;
  background: var(--surface-2); border: 1px solid var(--border);
  border-radius: var(--radius); padding: .5rem .7rem; font-variant-numeric: tabular-nums;
  font-size: .9rem;
}
.basket .totals-customer { grid-column: 1 / -1; }
.basket .total { grid-column: 1 / -1; font-weight: 700; font-size: 1.3rem; color: var(--brand); }

/* "Customers also buy" strip — compact chips pinned under the totals;
   scrolls sideways instead of growing so the fixed sale-screen grid holds. */
.suggest-strip { flex-shrink: 0; display: flex; gap: .35rem; align-items: center;
                 overflow-x: auto; margin-top: .45rem; padding-block: .1rem; }
.suggest-strip[hidden] { display: none; }
.suggest-title { font-size: .78rem; font-weight: 700; text-transform: uppercase;
                 letter-spacing: .04em; color: var(--muted); white-space: nowrap; }
.suggest-chip { display: inline-flex; align-items: center; gap: .35rem;
                padding: .3rem .55rem; font: inherit; font-size: .8rem; cursor: pointer;
                border: 1px dashed var(--brand); border-radius: var(--radius);
                color: var(--brand); background: var(--surface); white-space: nowrap; }
.suggest-chip:hover { background: var(--brand); color: #fff; }
.suggest-thumb { width: 22px; height: 22px; object-fit: cover; border-radius: 4px; }
.suggest-price { font-variant-numeric: tabular-nums; opacity: .8; }
/* Tighter than the card-level default above (this pair wins, later in the
   cascade at equal specificity) -- part of the ut-docs#391 column-width
   budget (see .basket table above). */
.basket td { padding: .4rem .35rem; }
.basket th { padding: .4rem .35rem; }
/* Safety net, not the primary mechanism: price/total's percentages
   (.basket table above) fit ordinary amounts on one line at every
   supported viewport/ui_scale (verified live) -- but at the tightest
   floor+scale corner, or a pathological locale-grouped/very-large
   amount, the column can still be narrower than the text. table-layout:
   fixed can't grow a column to fit, so without somewhere to give it
   would overflow into its neighbour instead; let it wrap onto a second
   line there rather than that. Covers the header cells too -- at that
   same extreme corner "PRICE"/"TOTAL" are single unbreakable words that
   would otherwise overflow their th into each other, unreadable in a
   different way than the data cells' amounts but the same root cause. */
.basket td:nth-child(3), .basket td:nth-child(4),
.basket th:nth-child(3), .basket th:nth-child(4) { overflow-wrap: anywhere; }

/* ut-docs#1338, phone tier (<=480px): a genuinely different row layout
   instead of a 5th column-budget shave (".basket table"'s own comment
   above already carries four attempts at that shape). The reserved-rem
   budget above is what starves ITEM at 360px in the first place -- ~288px
   total minus the four reserved columns leaves ~20px, ~2 characters, for
   the one thing the operator most needs to read. So this tier stops
   relying on that budget instead of re-shaving it: each line becomes a
   2-row grid -- the name gets the FULL card width on its own row, and
   qty/price/total/remove share a second row below, sized from the SAME
   reserved rem widths declared above (single source, never re-tuned
   separately for this tier -- a change to those values still applies
   here with nothing to keep in sync). table/thead/tbody/tr/td lose their
   table-ish display so the browser's table column algorithm (the thing
   starving item) never runs at this width; grid-template-areas replaces
   it with an explicit shape instead. The header row gets the identical
   grid too -- its own th:nth-child(1) ("Item") lands alone on row 1, and
   Qty/Price/Total line up directly above each line's own second row --
   so the two money figures per line stay labelled, not two adjacent
   numbers the operator has to infer which is which.

   Two details in the track list are load-bearing (both from independent
   review, both measured live at 360/414/480px x ui_scale 1/1.5/2):

   1. The LEADING `1fr` spacer is what makes "full card width" literally
      true. Four rigid rem tracks sum to a rigid 15.1rem (16rem once the
      `.3rem` gaps this block originally carried are counted), and a
      `name` area spanning only those four is capped at that sum no
      matter how wide the card is -- measured 272.0px of 288.3px at 360px,
      and 272.0px of 408.3px (33% of the row unused) at 480px, i.e. the
      name was still being capped by the very reserved-rem budget this
      block exists to stop relying on. The spacer absorbs the remainder
      instead, the name area spans all FIVE tracks, and the numeric
      columns stay flush to the inline-END -- matching both
      `th/td:nth-child(n+2) { text-align: end }` above and where those
      columns sit in the >=481px table.
   2. There is deliberately NO `column-gap`. `.basket td`'s own
      `.35rem` inline padding already separates the four; a gap is pure
      ADDED width, and above ui_scale 1 the rem tracks already exceed a
      phone's width (a pre-existing gap: at 360px the baseline table
      overflows .basket-scroll by 132px at scale 1.5 and 294px at
      scale 2 -- ut-docs#391's matrix only covers 1024/901px, both above
      this breakpoint). A `.3rem` column-gap made that strictly worse by
      exactly its own width -- measured +23px at scale 1.5 and +31px at
      scale 2, at every phone width -- which is the one way this block
      could have regressed ut-docs#391's invariant. Without it the
      overflow is byte-for-byte the pre-existing number, so this tier is
      no worse than before at any scale while the name goes from 0px
      (literally invisible above scale 1) to 234-312px. Do not re-add a
      gap here; widen the cell padding if the four ever need more air. */
@media (max-width: 480px) {
  .basket table, .basket thead, .basket tbody { display: block; }
  .basket tr {
    display: grid;
    grid-template-columns: 1fr 4.3rem 4rem 4rem 2.8rem;
    grid-template-areas: "name name  name  name  name"
                         ".    qty   price total remove";
    border-bottom: 1px solid var(--border);
  }
  /* ut-docs#2217: the +/- stepper pair is deliberately NOT shown at this
     tier. Unlike an earlier (abandoned) design, the shipped stepper costs
     the qty column's reserved WIDTH nothing at any width — see
     .qty-stepper's own comment above — so there's no column-budget
     collision here any more (the >480px path also reserves 4.3rem now,
     matching this tier's own grid-template-columns "qty" track exactly,
     with no override needed). What's still a genuine fit problem at THIS
     tier specifically is HEIGHT: qty/disc/stepper share a single packed
     grid row here with price/total/remove (unlike the >480px table, where
     item gets its own row so a taller qty/disc column doesn't touch it),
     and this tier's own column-budget history above (+132px overflow at
     scale 1.5, +294px at scale 2, even before this card) says that packed
     row has no headroom to spare either. .qty-input stays the only qty
     control here, exactly as it already was; filed as its own follow-up
     rather than solved by guesswork under this card. */
  .qty-step-btn { display: none; }
  /* tbody, not a bare :last-child: `display: block` on thead makes its
     single header row a :last-child too, so the un-scoped rule silently
     removed the header/body separator entirely (measured: thead tr
     border-bottom-width 0px). The 2px width keeps the header rule
     visually distinct from the 1px between lines, exactly as
     `.basket th`'s own 2px does at every other width. */
  .basket thead tr { border-bottom-width: 2px; }
  .basket tbody tr:last-child { border-bottom: none; }
  .basket th, .basket td { display: block; border-bottom: none; }
  .basket th:nth-child(1), .basket td:nth-child(1) { grid-area: name; }
  .basket th:nth-child(2), .basket td:nth-child(2) { grid-area: qty; }
  .basket th:nth-child(3), .basket td:nth-child(3) { grid-area: price; }
  .basket th:nth-child(4), .basket td:nth-child(4) { grid-area: total; }
  .basket th:nth-child(5), .basket td:nth-child(5) { grid-area: remove; }
  /* .line-item's 10.4rem cap (above) is a ceiling sized for the wider
     table layout's leftover-space column; at this tier item owns a
     dedicated full-width row instead, so the cap only shrinks the one
     thing this fix exists to widen. */
  .line-item { max-width: none; }
}

/* ut-docs#3050: upright tablets (a 10in tablet held upright is ~800x1280).
   The stacked `max-width: 900px` tier gives the basket the full width, so the
   narrow landscape column's reason for stacking qty / -+ / discount in three
   rows (no horizontal slack for the item name, ut-docs#2217) can go. What
   decides the line layout is the basket's own width in rem, so it's a
   container query: its rem is the root font size, which carries --ui-scale,
   so a scaled-up till drops to a roomier shape by itself (a viewport query
   would overflow at scale 1.5-2). min-height instead of orientation: the
   Android keyboard shrinks the viewport, and orientation would flip the
   layout mid-typing. The short stacked landscape windows in this range
   (900x600, 768x600) get the shorter lines too; landscape phones (height
   < 481px, ut-docs#2918) and the <=480px tier (ut-docs#3059) are out. A
   browser without container queries keeps the stacked tier as it was.
   Only this element may be a container (TestAppCSSContainerOnlyOnTheBasket).
   Regression test: portrait-tablet-sale-3050.spec.ts. */
@media (min-width: 481px) and (max-width: 900px) and (min-height: 481px) {
  .pos-container > .basket { container: basket / inline-size; }
}
/* Upright only (landscape in this width range is never this tall): products
   get the height back. A long basket scrolls inside .basket-scroll. */
@media (min-width: 481px) and (max-width: 900px) and (min-height: 900px) {
  .pos-container > .basket { max-height: 38dvh; }
}
/* Tier 1 -- room for everything on one row (800px at scale 1). Tier 2 --
   the name gets its own row above the controls (600px at scale 1, 800px at
   scale 1.25), the same table-to-grid shape the <=480px tier uses. Below
   tier 2 (800px at scale 1.5-2) the stacked tier stays as it was. */
@container basket (min-width: 24rem) {
  .line-item { max-width: none; }
  .line-inputs { flex-direction: row; align-items: center; justify-content: flex-end; gap: .3rem; }
  .qty-stepper { width: auto; gap: .3rem; }
  .qty-step-btn { flex: 0 0 auto; min-width: 46px; min-height: 46px; }
  /* The steppers beside these are 46px, so match them. body.kiosk's own
     2.1rem floor comes later in this file, so kiosk needs one more class
     to outrank it. */
  .qty-input, .disc-input,
  body.kiosk .line-inputs .qty-input, body.kiosk .line-inputs .disc-input { min-height: 46px; }
  /* qty + disc inputs, two 46px steppers, three gaps, the cell's padding. */
  .basket th:nth-child(2), .basket td:nth-child(2) { width: calc(7.9rem + 92px); }
}
@container basket (min-width: 38rem) {
  .basket td { padding-block: .35rem; }
}
@container basket (min-width: 24rem) and (max-width: 37.99rem) {
  .basket table, .basket thead, .basket tbody { display: block; }
  .basket tr {
    display: grid;
    grid-template-columns: 1fr auto 4rem 4rem 2.8rem;
    grid-template-areas: "name name  name  name  name"
                         ".    qty   price total remove";
    border-bottom: 1px solid var(--border);
  }
  /* The header needs no name row of its own: "Item" sits in the spacer
     cell left of Qty/Price/Total, saving a row for the products. */
  .basket thead tr { border-bottom-width: 2px; grid-template-areas: "name qty price total remove"; }
  .basket tbody tr:last-child { border-bottom: none; }
  /* Tighter than tier 1's .35rem: this line already spends two rows. */
  .basket th, .basket td { display: block; border-bottom: none; padding-block: .2rem; }
  .basket th:nth-child(1), .basket td:nth-child(1) { grid-area: name; }
  .basket th:nth-child(2), .basket td:nth-child(2) { grid-area: qty; width: auto; }
  .basket th:nth-child(3), .basket td:nth-child(3) { grid-area: price; }
  .basket th:nth-child(4), .basket td:nth-child(4) { grid-area: total; }
  .basket th:nth-child(5), .basket td:nth-child(5) { grid-area: remove; }
}
/* ---------- Sale-screen notices (ut-docs#213) ----------
   THE notification surface for the sale screen — one component, two slots:
   server-rendered inside #basket (partials/basket.html) and the client-side
   #pos-alert (pages/index.html). Errors persist until dismissed; info and
   success auto-expire (app.js). In-flow, never a centered overlay, so it
   can't occlude the pay buttons mid-sale. Pattern doc:
   docs/sale-screen-notifications.md. */
.pos-notice { display: flex; align-items: center; gap: .6rem;
  padding: .55rem .8rem; border-radius: var(--radius); margin-block-end: .5rem;
  font-weight: 600; font-size: .92rem; border: 1px solid transparent;
  opacity: 1; transition: opacity .25s ease; flex-shrink: 0; }
.pos-notice.hide { opacity: 0; }
.pos-notice[hidden] { display: none; }
.pos-notice .notice-text { flex: 1; min-width: 0; overflow-wrap: anywhere; }
/* Text is --text, not --accent (ut-docs#3169): a theme whose accent is green
   put green on this blue tint at ~2:1. --text meets 4.5:1 on every theme's
   surface, and the translucent tint keeps that true. */
.pos-notice.info { background: rgba(37, 99, 235, .1); color: var(--text);
  border-color: rgba(37, 99, 235, .3); }
.pos-notice.success { background: var(--success-tint); color: var(--success);
  border-color: var(--success-tint-border); }
.pos-notice.error { background: var(--danger-tint); color: var(--danger);
  border-color: var(--danger-tint-border); }
/* Touch-target floor (ut-docs#2721 UX finding): the ✕ used to measure
   ~28x22px. It is now a real 44x44 box (min-height/min-width in px, so it
   holds at every --fluid-fs/--ui-scale), glyph centred. The negative block
   margin (-11px each side, 22px net) keeps its contribution to the row's
   height at the old 22px, so every notice that shares this class
   (.pos-notice on the sale screen, .pairing-notice/.join-notice in
   base.html) keeps its height: the extra hit area hangs into the notice's
   own .55rem block padding, where nothing else is tappable. Fixed here,
   once, not per notice. */
.notice-dismiss { flex: 0 0 auto; background: transparent; border: none;
  color: inherit; font: inherit; font-size: 1rem; cursor: pointer;
  padding: 0 .4rem; border-radius: 6px; line-height: 1;
  min-height: 44px; min-width: 44px; margin-block: -11px;
  display: inline-flex; align-items: center; justify-content: center; }
.notice-dismiss:hover { background: rgba(15, 23, 42, .08); }
#pos-alert { margin-block-end: .6rem; }

/* ---------- Toasts (legacy overlay — self-order screens only; the sale
   screen uses .pos-notice above) ---------- */
.toast { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
  padding: .9rem 1.2rem; border-radius: 10px; box-shadow: 0 8px 24px rgba(0,0,0,.18);
  z-index: 2000; background: #0f172a; color: #e2e8f0; min-width: 240px; text-align: center;
  opacity: 1; transition: opacity .25s ease; }
.toast.error { background: #7f1d1d; color: #fecaca; border: 1px solid #fecdd3; }
.toast.success { background: #14532d; color: #dcfce7; border: 1px solid #bbf7d0; }
.toast.info { background: #0f172a; color: #e2e8f0; border: 1px solid #1f2937; }
.toast.hide { opacity: 0; }

/* ---------- Journal ---------- */
.journal-filters { display: flex; flex-wrap: wrap; gap: .75rem; align-items: end;
                    margin-block-end: .75rem; }
.journal-filters label { display: flex; flex-direction: column; gap: .2rem;
                          font-size: .78rem; color: var(--muted); }
.journal-till-status { margin-block-end: .5rem; font-size: .8rem; }
.journal-truncated-notice { margin-block-end: .5rem; font-size: .8rem; }
/* B2 (ut-docs#550 review): an honest "can't show that here" notice for an
   explicit cross-till ask on a replica -- same warn tint .tag.warn /
   .sync-banner already use, so it reads as a real notice, not muted status text. */
.journal-replica-notice { margin-block-end: .75rem; padding: .5rem .75rem;
                           border-radius: var(--radius); font-size: .85rem;
                           background: var(--warning-tint); color: var(--warning); }
.journal table { width: 100%; border-collapse: collapse; background: var(--surface);
                 border-radius: var(--radius); overflow: hidden; }
.journal th { font-size: .72rem; text-transform: uppercase; letter-spacing: .04em;
              color: var(--muted); padding: .45rem .5rem; border-bottom: 2px solid var(--border);
              text-align: start; }
.journal td { padding: .45rem .5rem; border-bottom: 1px solid var(--border);
              text-align: start; font-size: .88rem; }
.journal td:nth-child(2), .journal td:nth-child(3), .journal td:nth-child(4) { white-space: nowrap; }
.journal .empty { text-align: center; color: var(--muted); }

/* ---------- Tender ----------
   ut-docs#2702: two content-sized rows — the thin scan row and ONE action
   row (Pay + Hold / New sale / Open orders icons). Pay/Split tabs, the
   pay-grid and the overlay footer render inside .payment-overlay (a
   <dialog>, ut-docs#1252, styled further down this section). Nothing in
   this column grows with shop data any more (the held-sales strip left in
   ut-docs#2702), so it neither scrolls nor needs a grid-ratio budget. */
[x-cloak] { display: none !important; }
.tender { display: flex; flex-direction: column; gap: .45rem; }
/* ut-docs#2702: .tender-scroll (the scrolling wrapper for scan row + held
   strip, ut-docs#2128's scroll shadows, ut-docs#2142's "keep the strip
   here" note) is gone: the strip left the sale screen, and the two
   remaining rows are fixed-height, so nothing here scrolls any more. The
   ut-docs#2142 note's suggested future move -- a parked-count badge on the
   Open orders button -- is what replaced the strip. */
.scan-row { display: flex; gap: .4rem; align-items: center; flex-shrink: 0; }
/* 2026-08-30 (product owner): the barcode field inherited the global
   input padding (.5rem .65rem, sized for a normal form field on an
   admin page) — smaller here, since this row is tapped/scanned dozens
   of times a shift and doesn't need that much padding to stay usable.
   ut-docs#2702: thinner still — a fixed 2.5rem field ("barcode box can be
   thinner smaller with its belongings"); body.kiosk's 2.4rem input floor
   stays below it, so kiosk is never smaller than this. */
.scan-row input { padding: .3rem .55rem; block-size: 2.5rem; min-height: 2.5rem; }
.scan-row input[name="code"] { flex: 1; min-width: 0; }
.scan-row input[name="qty"] { width: 3.4rem; text-align: center; }
/* ut-docs#2702: the scan row's and the action row's icon-only buttons.
   A 2.75rem (44px) square — the WCAG 2.5.5/2.5.8 touch floor this file
   already uses for .held-chip/.modifier-option — with a 1.25rem glyph.
   The label lives in a .visually-hidden span + title (index.html). */
.tender-icon-btn { flex: 0 0 auto; inline-size: 2.75rem; min-inline-size: 2.75rem;
                   min-height: 2.75rem; block-size: 2.75rem; padding: 0; position: relative; }
.tender-icon-btn .btn-ico svg { inline-size: 1.25rem; block-size: 1.25rem; }
/* Count badge on the Open orders icon (ut-docs#2702): parked orders at a
   glance, replacing the held-sales strip. Sits on the button's block-start/
   inline-end corner (logical insets — mirrors under RTL). Not colour alone:
   it carries the number itself. [hidden] needs its own rule because the
   display below would otherwise out-rank the UA's hidden styling. */
.count-badge { position: absolute; inset-block-start: -.35rem; inset-inline-end: -.35rem;
               min-inline-size: 1.25rem; block-size: 1.25rem; padding-inline: .3rem;
               display: inline-flex; align-items: center; justify-content: center;
               border-radius: 999px; background: var(--danger); color: #fff;
               font-size: .72rem; font-weight: 700; line-height: 1;
               box-shadow: 0 0 0 2px var(--surface); pointer-events: none; }
.count-badge[hidden] { display: none; }
/* ut-docs#413: this input's own `min-width: 0` above deliberately lets it
   shrink below its placeholder's content width (unlike the nav/kiosk
   buttons elsewhere in this file, which is exactly what made them squeeze
   below their own label) — a real, live reported symptom ("Barco" instead
   of "Barcode") at a narrow enough width/locale/scale combination. Native
   inputs clip an overflowing value or placeholder outright, mid-character,
   with no ellipsis unless asked for; this makes any such truncation
   deliberate instead. */
@media (max-width: 480px) {
  .scan-row input[name="code"] { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
}

.held-strip { display: flex; flex-wrap: wrap; gap: .35rem; align-items: center; }
.held-strip:empty { display: none; }
.held-title { font-size: .78rem; font-weight: 700; text-transform: uppercase;
              letter-spacing: .04em; color: var(--muted); }
/* ut-docs#2218: min-height:0 overrode .btn's 3rem floor down to a
   measured ~26-27px hit target -- under this codebase's 44px touch-target
   floor (see .modifier-option/.catalog-detail-title .thumb elsewhere in
   this file). Raised to a real 44px, same as those, WITHOUT touching
   padding/font-size, so the chip keeps its compact look; .btn's own
   align-items:center centers the (unchanged) content in the taller box.
   Unlike ut-docs#1340 (basket qty/disc, rejected: a fixed ">=4 rows
   visible" AC), .held-chip lives in .held-strip (flex-wrap) inside
   .tender-scroll (free-scrolling, no row-count AC) -- nothing here
   depends on this chip's height staying small. */
.held-chip { padding: .35rem .6rem; font-size: .8rem; min-height: 44px;
             border: 1px dashed var(--accent); color: var(--accent); background: var(--surface); }
.held-chip:hover { background: var(--accent); color: #fff; }

/* flex-wrap: wrap (ut-docs#424) — a shop with 12+ top-level categories
   (or a narrow viewport) previously had nowhere for overflow tabs to go
   but shrink, and flex:1 + flex-basis:0 below still floors each tab at
   its own min-content width (browsers' default min-width:auto) while
   shrinking — but for a multi-word label, min-content is only the widest
   WORD, not the whole label, so a tab could still narrow enough to wrap
   its own text across multiple lines before the row ran out of tabs to
   shrink. white-space: nowrap on .tab-bar .tab (below) raises that floor
   to the label's full width, so a tab that doesn't fit its row moves to
   the next one instead of wrapping internally. A row that fits without
   wrapping (e.g. the tender's fixed 2-tab Pay/Split bar) renders exactly
   as before, since flex:1 still evenly fills a single line when nothing
   overflows. */
.tab-bar { display: flex; flex-wrap: wrap; gap: .25rem; border-bottom: 2px solid var(--border); }
.tab-bar .tab {
  flex: 1; padding: .5rem .6rem; font: inherit; font-weight: 600; text-align: center;
  white-space: nowrap;
  background: none; border: none; border-bottom: 3px solid transparent;
  /* border-block-start: always-on color accent from --cat-color when a
     tab carries one (sale-screen category tabs, ut-docs#418) — falls
     back to transparent, so the tender's plain Pay/Split tabs (no
     --cat-color set) render exactly as before. */
  border-block-start: 3px solid var(--cat-color, transparent);
  color: var(--muted); cursor: pointer; margin-bottom: -2px;
}
.tab-bar .tab.active { color: var(--cat-color, var(--accent)); border-bottom-color: var(--cat-color, var(--accent)); }
/* min-height: 6rem (not 0) mirrors .basket-scroll's own floor above --
   the independent review of ut-docs#161 measured .tab-panel collapsing to
   a real, hit-testable 0 clientHeight (Cash/Card/Gift Card rendered
   nowhere, not merely clipped) once .tender itself became scrollable:
   same "flex:1, min-height:0 inside a now-scrollable ancestor" collapse
   .basket-scroll's own comment (line ~336) already documents and floors
   against. The floor plus .tender's outer overflow-y: auto together mean
   the tab body can shrink, but never past a size where its own buttons
   become genuinely unreachable — past that floor, the OUTER panel scrolls
   instead. */
.tab-panel { flex: 1; min-height: 6rem; overflow-y: auto; display: flex;
             flex-direction: column; gap: .5rem; }

.pay-grid { display: grid; grid-template-columns: 1fr 1fr; gap: .5rem; }
.pay-btn { min-height: 4.2rem; font-size: 1.15rem; font-weight: 700; }
.pay-btn.pay-plugin { background: var(--surface); color: var(--accent);
                      border: 2px dashed var(--accent); }
.pay-btn.pay-plugin:hover { background: var(--accent); color: #fff; }

.tender-footer { display: grid; grid-template-columns: 1fr 1fr; gap: .5rem; margin-top: auto; }
/* Only New Customer lives in the overlay's own footer now (Hold Sale moved
   to .tender-default-footer below, independent review) — a lone button in
   a 2-column grid would only fill half the width. */
.tender-footer.single { grid-template-columns: 1fr; }

/* ut-docs#1252: the Pay/Split tabs, pay-grid and New-Customer footer live
   in the <dialog> opened by .payment-trigger; Hold Sale stays in this row,
   NOT in the dialog (independent review) — a "step away from checkout"
   action must not cost a trip through Payment. flex-shrink: 0 so the row is
   never squeezed. Full history of this row's sizing: git log -p. */
.tender-default-footer { display: flex; flex-direction: row; gap: .4rem; flex-shrink: 0; align-items: center; }
/* ut-docs#2702: ONE action row — Pay takes every pixel the three 44px icon
   buttons (.tender-icon-btn above) leave. Pay is the one primary, text-
   labelled action here; it keeps the 2.75rem (44px) touch floor the icons
   use, so the row is one even height. min-width: 0 + ellipsis: a long
   "Pay £1,234.56" in a long locale shrinks inside its own box instead of
   pushing the icons off the row. */
.tender-default-footer > .payment-trigger { flex: 1 1 auto; min-width: 0; min-height: 2.75rem;
  block-size: 2.75rem; padding: 0 .8rem; font-size: 1rem; font-weight: 700; line-height: 2.75rem;
  /* block (not .btn's inline-flex): text-overflow only applies to a block
     container's own text, never to a flex container's anonymous item. */
  display: block; text-align: center;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* ut-docs#2702: the preferred payment method (payments.default_method)
   leads the payment panel's grid and spans its whole first row — the
   quick-pay button's one-tap job, now one tap after Pay. */
.pay-grid > .pay-default { grid-column: 1 / -1; }

/* The popup body. Scrolls internally rather than growing the dialog past the
   viewport -- a till with a dozen parked tables must still show its Close
   button. */
.parked-orders-modal { min-width: min(30rem, 92vw); }
/* ut-docs#3582: each row now carries Cancel order beside Move table, so the
   popup gets more room than .modifier-modal's 28rem -- at the 1024x600
   kiosk floor a labelled table order otherwise wrapped to three lines. */
#parked-orders-modal.parked-orders-modal { max-width: 42rem; }
/* ut-docs#2702 review: a non-modal show() dialog is positioned in flow by the
   UA sheet, so at 360px the sale screen's positioned tender controls painted
   over its Close button -- worse once a row's Move table options open. Same
   fixed + z-index frame as #pfand-modal/#elevation-modal (below #hold-modal's
   550 and #osk's 1000), with its own scroll so Close stays reachable. */
#parked-orders-modal { position: fixed; inset-block-start: 8vh; inset-inline: 0; margin-inline: auto; z-index: 500; max-block-size: 84vh; overflow-y: auto; }
.parked-orders-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .5rem; max-height: 60vh; overflow-y: auto; }
/* ut-docs#3582: one fixed column grid per row -- the order | the Move table
   slot | the 46px Cancel order target -- placed explicitly, so Cancel sits
   in the same column on every row whether or not that row can move (a row
   without Move table gets an invisible same-size placeholder whenever any
   row in the list has one, parked_orders.html). Before this the row was a
   wrapping flexbox and Move table / the trash button landed wherever the
   leftover width let them. */
.parked-orders-list > li { display: grid; grid-template-columns: minmax(0, 1fr) auto 46px; gap: .5rem; align-items: stretch; }
.parked-orders-list > li > .parked-order,
.parked-orders-list > li > .parked-order-form { grid-column: 1; grid-row: 1; min-inline-size: 0; }
.parked-orders-list > li > .parked-move,
.parked-orders-list > li > .parked-move-placeholder { grid-column: 2; grid-row: 1; }
.parked-orders-list > li > .parked-order-cancel,
.parked-orders-list > li > .parked-order-cancel-spacer { grid-column: 3; grid-row: 1; }
/* ut-docs#2702 review: Move table on each popup row (ut-docs#820). Closed,
   it is a compact button beside the row; open, it takes a row of its own so
   the free-table options wrap under the order they move. Every target keeps
   the 46px floor (.btn-touch); logical properties only (RTL). */
/* Open, the free-table options need the full width: the whole <details>
   takes a second row of ITS OWN <li> (so it can only read as this order's),
   and Cancel keeps its column on row 1. */
.parked-orders-list > li > .parked-move[open] { grid-column: 1 / -1; grid-row: 2; }
/* ut-docs#3622: with .parked-move[open] vacating column 2's row-1 track,
   that track's auto width (per-<li> grid, not shared across rows) can
   collapse toward 0, and the order button's own minmax(0, 1fr) column
   grows to absorb the freed space -- widening right up to where the
   toggle sat closed. A second tap meant to close the panel then lands on
   the order button instead and resumes it. Rather than chase exact track
   sizing, make the order's own resume target inert while ITS row's panel
   is open: a tap anywhere it may have shifted to does nothing, never
   resumes. (An engine without :has() just keeps the pre-#3622 behaviour.) */
.parked-orders-list > li:has(> .parked-move[open]) > .parked-order,
.parked-orders-list > li:has(> .parked-move[open]) > .parked-order-form { pointer-events: none; }
.parked-move-placeholder { visibility: hidden; min-height: 46px; display: inline-flex; align-items: center; }
.parked-move > summary { list-style: none; min-height: 46px; display: inline-flex; align-items: center; cursor: pointer; }
.parked-move > summary::-webkit-details-marker { display: none; }
.parked-move-options { display: flex; flex-wrap: wrap; gap: .5rem; margin-block-start: .5rem; }
.parked-move-option { min-height: 46px; min-width: 46px; }
/* Whole-row target at the product's documented 46px floor (.btn-touch): the
   cashier taps this with a finger, and the label alone is a few characters
   wide. text-align/margin-inline keep it correct under RTL. */
.parked-orders-list > li > .parked-order { min-height: 46px; display: flex; align-items: center; gap: .5rem; text-align: start; }
/* The order's label stays on one line (ellipsized, full text still in the
   accessible name and on /open-orders) -- a label wrapping to three lines
   made each row a different height at 1024x600. */
.parked-order-label { flex: 1 1 auto; min-inline-size: 5rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.parked-order-label .open-order-items { white-space: normal; }
/* The label keeps a 5rem floor (it is how the cashier recognises the
   order, same reasoning as /open-orders' own label floor); under pressure
   the merchant-named table chip is what truncates. */
.parked-order .held-chip-table { flex: 0 1 auto; min-inline-size: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@media (max-width: 40rem) {
  /* Three columns cannot share 360-390px: the order takes the row's whole
     first line and Move table + Cancel sit on a second line, still in their
     own fixed columns (Cancel always last), with a divider between orders
     so that line reads as part of the order above it. */
  .parked-orders-list > li { padding-block-end: .5rem; border-block-end: 1px solid var(--border); }
  .parked-orders-list > li:last-child { border-block-end: none; padding-block-end: 0; }
  .parked-orders-list > li > .parked-order,
  .parked-orders-list > li > .parked-order-form { grid-column: 1 / -1; }
  .parked-orders-list > li > .parked-move,
  .parked-orders-list > li > .parked-move-placeholder,
  .parked-orders-list > li > .parked-order-cancel,
  .parked-orders-list > li > .parked-order-cancel-spacer { grid-row: 2; }
  .parked-orders-list > li > .parked-move[open] { grid-row: 3; }
}
.parked-order-meta { margin-inline-start: auto; opacity: .8; white-space: nowrap; }
/* .held-chip-table has never had a rule of its own (it rendered as bare text
   on the strip too). It now also appears here, so give it the quiet chip
   look its name promises rather than leaving it indistinguishable from the
   order label beside it. */
.held-chip-table { font-size: .85em; opacity: .75; border: 1px solid currentColor; border-radius: 999px; padding: 0 .45em; }

/* ut-docs#2138: /open-orders' own rows are real buttons, not <tr> click
   handlers (ut-docs#826's accessibility gate: tap-only with no keyboard
   equivalent fails it) -- each body row is a single <td colspan> holding a
   <form>+<button>, so the WHOLE row is one focusable control at the
   product's 46px touch floor (the .btn-touch value; .btn-touch itself is
   display:inline-flex, which a full-width row button cannot take).
   Because the row is one control rather than five real per-field <td>s,
   the header CANNOT stay a table row: `flex` on a <th> is inert while its
   <tr> is still display:table-row, so the <th>s were laid out by the table
   algorithm while the values were laid out by the button's flex box, and
   the two disagreed by 100-356px at EVERY viewport measured (360x740,
   1024x600, 1280x800) -- headers pointing at the wrong column on a list
   that carries money (ut-docs#2138 review). The header row is therefore
   the same flex box as the button, over the same classes and the same
   gap/padding, which is the only way the two can line up.
   The block display on the table parts is also what lets the button's
   width:100% fill the row: .users-list .table's `display:block` escape
   hatch shrink-to-fits the internal table box, which left ~25% of each
   visible row non-tappable at 1024x600 and 1280x800 (measured). */
#open-orders-table, #open-orders-table thead,
#open-orders-table tbody, #open-orders-table tr { display: block; }
/* overflow-x, not just width:100% -- ut-docs#2138 review follow-up: with a
   long cashier-typed label (up to 64 runes, maxHoldLabelRunes) AND a long
   merchant-named table sharing one row at the 360px phone floor, the four
   never-truncated fields' own min-inline-size floors alone can exceed the
   viewport. Before this rule the label (which had NO floor) simply
   shrank to 0 and vanished, silently -- worse than the truncation this
   card exists to avoid. A horizontal scroll on the table itself (matching
   `.list-scroll`'s documented role for the same class of overflow
   elsewhere in this pattern) makes the rare long-label+long-table
   combination reachable by a swipe instead of invisible. */
#open-orders-table { width: 100%; overflow-x: auto; }
#open-orders-table thead tr {
  display: flex; align-items: center; gap: .5rem;
  padding: .5rem .6rem; border-bottom: 2px solid var(--border);
}
/* display:block on the <td> too, not just the <tr>: a table-cell under a
   blockified row gets wrapped in an ANONYMOUS table box, which shrink-to-
   fits, and the button's width:100% then resolves against that instead of
   the row -- the row button measured 412px inside a 915px list. */
#open-orders-table td { display: block; }
#open-orders-table th, #open-orders-table td { padding: 0; border-bottom: none; }
#open-orders-table td > form { margin: 0; }
.open-order-row-btn {
  width: 100%; min-height: 46px; display: flex; align-items: center; gap: .5rem;
  padding: .5rem .6rem; text-align: start; background: none; border: none;
  border-bottom: 1px solid var(--border); color: inherit; font: inherit; cursor: pointer;
}
#open-orders-table tbody tr:last-child .open-order-row-btn { border-bottom: none; }
.open-order-row-btn:hover, .open-order-row-btn:focus-visible { background: var(--surface-2); }
/* Two of the five fields are unbounded free text -- the cashier-typed
   order label and the merchant-named table -- so those two shrink and
   ellipsize. The other three (item count, money total, age) are the ones
   that must NEVER be truncated: "short in English" is not "short
   everywhere" (Persian digits/currency and the localised "N min" phrasing
   measure wider), and silently clipping a money amount on a till is the
   worst outcome here. They take their natural width and never ellipsize.
   Every column carries a rem min-inline-size shared by the <th> and the
   value, so header and value start at the same edge for ordinary content
   and only the genuinely over-long string widens its own column -- rem,
   not px, so it stays inside the fluid 1024->1920 type scale. */
.open-order-cell-label {
  /* min-inline-size: 3rem, not 0 -- the label is the row's PRIMARY
     identifying text (which order this even is), so it must stay visible
     rather than shrink to nothing under pressure; #open-orders-table's own
     overflow-x is the escape hatch once even that floor cannot fit. */
  flex: 1 1 5rem; min-inline-size: 3rem;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.open-order-cell-table {
  flex: 0 1 auto; min-inline-size: 4rem;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.open-order-cell-lines { flex: 0 0 auto; min-inline-size: 3rem; white-space: nowrap; }
.open-order-cell-total { flex: 0 0 auto; min-inline-size: 5rem; white-space: nowrap; }
.open-order-cell-age { flex: 0 0 auto; min-inline-size: 6rem; white-space: nowrap; }

/* ut-docs#2703 (reopened): Open orders' two tabs -- On hold / Pay at the
   counter -- on the page (links, aria-current) and in the sale screen's
   popup (buttons). The shared .tab-bar look (catalog item dialog, tender
   Pay/Split), plus: the 46px touch floor; <a> tabs lose link styling; the
   count reads as a quiet pill beside the label (never the absolutely
   positioned .count-badge, which belongs to the sale-screen icon button).
   Labels wrap onto a second row rather than shrink when a long locale
   ("Kasada ödenecek", "پرداخت در صندوق") meets a 360px screen, via
   .tab-bar's own flex-wrap. */
.open-orders-tabs { margin-block-end: .75rem; }
.open-orders-tabs .tab { min-height: 46px; display: inline-flex; align-items: center; justify-content: center; gap: .4rem; text-decoration: none; }
.open-orders-tabs .tab-count { min-inline-size: 1.5rem; padding-inline: .4rem; border-radius: 999px;
  background: var(--surface-2); color: inherit; font-size: .85em; line-height: 1.5; }
.open-orders-tabs .tab.active .tab-count { background: var(--accent); color: #fff; }
/* A legacy counter order's "what was ordered" line under its label: it
   wraps (never ellipsized -- it is how the cashier recognises the order). */
/* "Priced when opened" stands in a legacy row's total cell: let it wrap
   in a narrow block rather than widen the never-truncated money column
   past a 360px phone (the money rule above is about amounts, not this). */
.open-order-cell-total .open-order-price-later { display: inline-block; max-inline-size: 6rem; white-space: normal; line-height: 1.2; }
.open-order-items { display: block; white-space: normal; overflow-wrap: break-word; font-weight: 400; font-size: .85em; opacity: .8; }
/* ut-docs#2703 review: at phone width the five columns left the order
   cell a few letters wide ("Avocad/o/Lachs"). Below 40rem each row wraps:
   the order (label + items) takes the full first line, the numbers follow
   on the second; the column headers no longer line up, so they go. */
@media (max-width: 40rem) {
  #open-orders-table thead { display: none; }
  .open-order-row-btn { flex-wrap: wrap; row-gap: .25rem; }
  .open-order-row-btn .open-order-cell-label { flex: 1 1 100%; white-space: normal; }
  .open-order-row-btn .open-order-cell-table:empty { display: none; }
}
/* ut-docs#3582: Cancel order beside each held row, on the page and in the
   popup -- an icon-only square at the 46px touch floor in every mode (never
   smaller in kiosk than on desktop, ut-docs#161). On the page the <td>
   (overriding its display:block above, which existed only to stretch the
   one row button) lays the resume button and Cancel side by side; the
   header's actions cell is 46px minus the header row's own .5rem gap, so
   the five data columns still start where their headers do (the row
   button's inline padding sits inside it, the header's on the row). A
   legacy row's spacer keeps the same width. Logical properties only (RTL). */
#open-orders-table td { display: flex; align-items: stretch; }
#open-orders-table td > form { flex: 1 1 auto; min-inline-size: 0; }
.open-order-cancel, .open-order-cancel-spacer {
  flex: 0 0 46px; min-block-size: 46px; min-inline-size: 46px; margin: 0;
  border-bottom: 1px solid var(--border);
}
.open-order-cancel { display: inline-flex; align-items: center; justify-content: center; padding: 0; border-radius: 0; }
#open-orders-table tbody tr:last-child .open-order-cancel,
#open-orders-table tbody tr:last-child .open-order-cancel-spacer { border-bottom: none; }
.open-order-cell-actions { flex: 0 0 calc(46px - .5rem); }
.parked-order-cancel { min-block-size: 46px; min-inline-size: 46px; display: inline-flex; align-items: center; justify-content: center; padding: 0; }
.open-orders-hint:empty, .parked-orders-hint:empty { display: none; }
.parked-order-form { display: flex; margin: 0; }
.parked-order-form > .parked-order { flex: 1; min-height: 46px; display: flex; align-items: center; gap: .5rem; text-align: start; }

/* The dialog itself: a slide-in panel anchored to the inline-end edge
   (right in LTR, left in RTL — logical properties per this repo's RTL
   rule) rather than the UA's default centered box, and sized so it never
   covers .pos-container's basket column (a separate grid area next to
   .tender, untouched by this dialog).
   ut-docs#1385: opened via .show(), not showModal() (index.html's
   payment-trigger button) — showModal()'s top-layer/inert-outside
   behaviour made the custom on-screen keyboard untappable while this was
   open, the same bug already fixed for #hold-modal/#pfand-modal/
   #elevation-modal/#table-add-modal below. `z-index: 500` matches those
   four (needed now — .show() no longer gets the browser's native top-layer
   stacking) and stays below #osk's `z-index: 1000` so the keyboard still
   renders above whichever field it's typing into here. No ::backdrop rule
   any more: a .show() dialog never paints one, and the basket staying
   visible/undimmed while paying was always the intent (the light tint this
   replaced only ever softened, never replicated, the near-opaque default
   #modifier-modal's own ::backdrop used while it was still showModal()'d
   — ut-docs#2097 made it non-modal too, over the shared #ut-scrim)
   — dropping it is a continuation of that, not a regression, and
   matches the other four non-modal dialogs (#hold-modal/#pfand-modal/
   #elevation-modal/#table-add-modal), none of which replicate dimming
   either. */
.payment-overlay {
  position: fixed;
  inset-block: 0;
  inset-inline: auto 0;
  margin: 0;
  width: min(26rem, 92vw);
  max-width: 92vw;
  height: 100dvh;
  max-height: 100dvh;
  border: none;
  border-start-start-radius: var(--radius-lg);
  border-end-start-radius: var(--radius-lg);
  padding: 0;
  z-index: 500;
  /* Symmetric (no horizontal offset), deliberately: a directional
     box-shadow (e.g. -8px 0) would need to flip sign under RTL, where this
     panel anchors to the LEFT instead of the right — independent review
     caught the first draft's `-8px 0` falling off-canvas under RTL, the
     one physical-direction value left in an otherwise all-logical block. */
  box-shadow: 0 0 24px rgba(0, 0, 0, .25);
  background: var(--surface);
}
/* ut-docs#1385 (independent review): body.osk-padded reserves space at the
   bottom of <body> for the on-screen keyboard (line ~511 above), but that's
   a BODY padding -- it does nothing for .payment-overlay, which is
   position: fixed and sized/positioned relative to the viewport, not to
   body's padding box. Without this, the OSK (z-index 1000) simply draws
   on top of whatever the overlay's own fixed 100dvh height put underneath
   it -- confirmed live at 1024x600: #split-tender-submit sat at
   y 436-487, #osk spanned y 312-600, so the button was genuinely,
   unreachably covered, not merely close to an edge. Higher specificity
   (2 classes vs. .payment-overlay's 1) overrides height/max-height with
   the SAME two properties the base rule sets, deliberately not
   `block-size` -- kept in the same physical property to avoid any
   logical/physical-property cascade-interaction subtlety. Shrinking the
   dialog's own box is what lets #split-tender-card's EXISTING
   `flex: 1; overflow-y: auto` (below) do the rest: less box height means
   its content now genuinely overflows, so the panel scrolls internally to
   reach Complete/Clear -- the same mechanism .tab-panel's own 6rem-floor
   comment already relies on, just newly triggered because there is
   finally a reason to. ut-docs#1998: reads `--osk-reserved-height` (same
   custom property as body.osk-padded above, written by osk.js's show()
   from #osk's own measured height) instead of a hardcoded 15.5rem that had
   drifted ~1.45rem short of the keyboard's real rendered height. */
body.osk-padded .payment-overlay {
  height: calc(100dvh - var(--osk-reserved-height, 17rem));
  max-height: calc(100dvh - var(--osk-reserved-height, 17rem));
}
.payment-overlay-body {
  display: flex; flex-direction: column; height: 100%; min-height: 0;
  padding: .8rem .9rem; gap: .55rem; overflow-y: auto;
}
.payment-overlay-header { display: flex; align-items: center; justify-content: space-between; gap: .5rem; }
.payment-overlay-header h3 { margin: 0; }
.payment-overlay-close { min-height: 2.2rem; min-width: 2.2rem; padding: 0; font-size: 1rem; line-height: 1; }
/* Tablet-and-narrower (independent review, ut-docs#1252): the first draft
   keyed this to 480px, matching the phone-only stacking tier further down
   this file. But .pos-container itself stops being a side-by-side grid at
   900px (see ".pos-container { grid-template-columns... }" media query
   below) -- between 481-900px the layout is ALREADY stacked (basket above
   tender above products) while this rule still rendered the overlay as a
   fixed-width side panel, landing it on top of the stacked basket instead
   of beside it (measured live: 58% of the basket covered at 768px, an
   iPad-portrait width). Matching .pos-container's own 900px breakpoint is
   what actually keeps the "basket stays visible" promise at every width
   where a side panel and a visible basket can't both fit — below it,
   nothing can show basket + overlay side by side anyway (same reasoning
   as the phone tier, just at .pos-container's real stacking point, not an
   arbitrary narrower one). */
@media (max-width: 900px) {
  .payment-overlay { inset-inline: 0; width: 100%; max-width: 100%; border-radius: 0; }
}

.offline-toggle { display: flex; align-items: center; gap: .5rem; font-weight: 600;
                  font-size: .85rem; color: var(--muted); padding: .2rem .1rem; }
.offline-toggle input { accent-color: var(--brand); }
.split-grid { display: grid; grid-template-columns: 1fr 1fr; gap: .4rem .5rem; }
.split-tender-form label { display: flex; flex-direction: column; font-size: .8rem;
                           gap: .2rem; margin-bottom: 0; }
.split-tender-form input, .split-tender-form select { padding: .45rem; border-radius: 6px;
                                                      border: 1px solid var(--control-border); }
/* ut-docs#305: the rule above and the base `input:focus, select:focus,
   textarea:focus` rule (near the top of this file) are equal specificity
   (one class + one element); this file's cascade order puts the rule above
   LATER, so its resting `border` shorthand silently out-ranked the generic
   focus rule's `border-color` and split-tender-form fields never got a
   visible focus swap at all -- pre-existing, not introduced by this fix,
   but caught by the new contrast test's focus-distinguishability check and
   fixed alongside it since it's the same file/selector. */
.split-tender-form input:focus, .split-tender-form select:focus {
  border-color: var(--focus-border);
}
.split-controls { display: grid; grid-template-columns: 1fr 1fr; gap: .4rem; }
/* ut-docs#1832: voucher-id field + Check balance button share one full-width
   row of the split grid; the explicit [hidden] rule is needed because the
   display:flex here would otherwise out-rank the UA's hidden styling. */
.split-voucher-field { grid-column: 1 / -1; display: flex; gap: .4rem; align-items: flex-end; }
.split-voucher-field[hidden] { display: none; }
.split-voucher-field label { flex: 1; min-width: 0; }
.split-voucher-field .btn { flex: 0 0 auto; }
/* ut-docs#1037: the single-purpose Redeem row (value/holder label + the
   Redeem button) sits on its own full-width row under the voucher-id field,
   same [hidden] override reasoning as .split-voucher-field. Logical
   properties only (RTL). */
.split-voucher-redeem { grid-column: 1 / -1; display: flex; gap: .4rem; align-items: center; }
.split-voucher-redeem[hidden] { display: none; }
.split-voucher-redeem-info { flex: 1; min-width: 0; font-weight: 600; overflow-wrap: anywhere; }
.split-voucher-redeem .btn { flex: 0 0 auto; }
/* ut-docs#1037: voucher purpose radios. A fieldset of two <label>s rather
   than a segmented toggle so a long option ("Bestimmter Artikel (jetzt
   versteuert)") wraps instead of truncating; each label is a flex row so
   the radio stays at the start of a wrapped line. */
.split-issue-purpose { grid-column: 1 / -1; border: 1px solid var(--control-border); border-radius: 6px;
                       padding: .35rem .5rem; margin: 0; display: flex; flex-wrap: wrap; gap: .3rem 1rem; }
.split-issue-purpose legend { font-size: .8rem; color: var(--muted); padding-inline: .2rem; }
.split-issue-purpose label { display: flex; align-items: flex-start; gap: .35rem; flex: 1 1 12rem; min-width: 0;
                             margin: 0; font-size: .9rem; }
.split-issue-purpose label span { overflow-wrap: anywhere; }
.split-issue-purpose input[type="radio"] { flex: 0 0 auto; margin-block-start: .2rem; }
.split-issue-vat-field { grid-column: 1 / -1; }
.split-issue-vat-field[hidden] { display: none; }
.split-issue-voucher { border-top: 1px dashed var(--border); padding-top: .4rem; }
/* ux fix (ut-docs#1832 gate): a plain <summary> defaults to list-item
   text with a tiny UA disclosure triangle — easy to miss on a touchscreen
   next to this panel's 54px pill buttons. .split-issue-voucher-summary
   layers on top of the existing .btn.secondary class (same control every
   other secondary action here already uses) so it reads as tappable; the
   list-item styling is suppressed and a chevron re-added via ::after
   (rotated when [open]) so open/closed state is still visible without the
   invisible-until-you-look-closely UA marker. */
.split-issue-voucher-summary { width: 100%; justify-content: space-between; list-style: none; }
.split-issue-voucher-summary::-webkit-details-marker { display: none; }
.split-issue-voucher-summary::marker { content: ''; }
.split-issue-voucher-summary::after {
  content: '▾'; font-size: .8em; transition: transform .12s ease;
}
.split-issue-voucher[open] > .split-issue-voucher-summary::after { transform: rotate(180deg); }
.split-issue-voucher-hint { font-size: .8rem; color: var(--muted); margin: .4rem 0 .4rem; }
.split-issue-voucher .split-tender-form { margin-bottom: .4rem; }
.pill-code { font-family: monospace; overflow-wrap: anywhere; }
.split-tender-payments { margin: .25rem 0; display: flex; flex-direction: column; gap: .4rem; }
.split-tender-payments.empty { color: var(--muted); font-size: .9rem; }
.payment-pill { display: flex; justify-content: space-between; align-items: center;
                border: 1px solid var(--border); border-radius: 8px; padding: .45rem .6rem;
                background: var(--surface); }
.payment-pill strong { font-size: .9rem; }
.payment-pill span { font-weight: 600; }
.payment-pill button { background: none; border: none; color: var(--danger); cursor: pointer; }
.split-tender-status { min-height: 1.2rem; font-size: .85rem; margin-bottom: .5rem; }
.split-tender-status.error { color: var(--danger); }
.split-tender-status.success { color: #15803d; }
.split-tender-status.info { color: #1d4ed8; }
.tender-actions { grid-template-columns: repeat(2, minmax(140px, 1fr)); }

/* ut-docs#413: every money-path action grid feeding the tender panel
   (Cash/Card, Hold Sale/New customer, split-tender's own field + action
   grids) is a plain `1fr 1fr` two-column layout — safe at kiosk/tablet
   widths, but exactly the shape that squeezes a button below its own
   label at a real phone width (360px content column here is ~290px after
   panel padding; two 140px-floor columns plus their gap already only
   just fits at 1x scale in English, with no margin for a longer locale
   string or a higher ui_scale). Collapsed to one column instead of
   measuring individual button widths, which would be fragile against
   locale/scale variance. `.tender-actions` itself isn't applied by any
   template today (confirmed: no hit for the class under web/ui) — it's
   updated here anyway for parity with how this was originally scoped, but
   `.pay-grid`/`.tender-footer`/`.split-grid`/`.split-controls` below are
   the classes actually live on the sale screen. */
@media (max-width: 480px) {
  .pay-grid,
  .tender-footer,
  .split-grid,
  .split-controls,
  .tender-actions {
    grid-template-columns: 1fr;
  }
}

/* ---------- Plugin buttons group (products panel) ---------- */
.plugin-buttons { margin-top: .8rem; padding-top: .7rem; border-top: 1px dashed var(--border); }
.plugin-buttons h3 { font-size: .78rem; text-transform: uppercase; letter-spacing: .04em;
                     color: var(--muted); margin: 0 0 .45rem; }

/* ---------- Tabs ---------- */
.tabs { display: flex; gap: 0; margin-bottom: 1rem; }
.tab, .tab-active { transition: all .2s ease; }
.tab:hover { color: var(--accent); border-bottom-color: #93c5fd !important; }
.tab-active:hover { border-bottom-color: #2563eb !important; }

/* ---------- HTMX loading indicators ---------- */
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator, .htmx-request.htmx-indicator { display: inline-block; }
#refresh-indicator { margin-inline-start: .5rem; font-size: 1rem; animation: spin 1s linear infinite; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.animate-spin { animation: spin 1s linear infinite; display: inline-block; }
svg.animate-spin { flex-shrink: 0; width: 1.25rem; height: 1.25rem; }

/* ---------- Generic tables (settings, inventory, catalog, shifts…) ---------- */
table.table, .card table {
  width: 100%; border-collapse: collapse; background: var(--surface);
  border-radius: var(--radius); overflow: hidden;
}
table.table th, .card table th {
  font-size: .75rem; text-transform: uppercase; letter-spacing: .04em;
  color: var(--muted); padding: .5rem .6rem; border-bottom: 2px solid var(--border);
  text-align: start;
}
table.table td, .card table td {
  padding: .5rem .6rem; border-bottom: 1px solid var(--border); text-align: start;
}
table.table tr:last-child td, .card table tr:last-child td { border-bottom: none; }

/* Catalog import: a warned row (created, but with something the operator
   should check — a dropped barcode, stock not carried, …) must read as
   visually distinct from BOTH a clean row and a skipped/failed one, not
   just readable in its own status text (ut-docs#303 — three warnings sat
   unnoticed among 209 rows that all rendered identically). Tint + icon,
   not colour alone, so it doesn't rely on colour perception. The icon uses
   the --warning var directly; the tint reads the separate --warning-tint
   token (ut-docs#2192) instead of a literal rgba — still decoupled from
   --warning itself (a future --warning change won't silently move it),
   but now a theme can override the wash on its own, which monochrome does.
   Legible on every theme this ships, light or dark: dark.css doesn't
   override --warning-tint, and dark's own #2176 review already confirmed
   the inherited amber has adequate contrast there. Logical margin, not
   left/right, for RTL. */
tr.row-warn td { background: var(--warning-tint); }
.row-warn-icon { color: var(--warning); margin-inline-end: .3em; }
/* Block-level warning notice (ut-docs#970 review, F6) — .row-warn above is
   scoped to `tr.row-warn td` only, so a non-table warning block (e.g. the
   catalogue-import currency-confirmation prompt) rendered with just
   `class="row-warn"` got NO background/border at all, reading as plain body
   text. Same amber palette as the row variant, block-shaped instead. */
.notice-block-warn { background: var(--warning-tint); border: 1px solid var(--warning-tint-border);
  border-radius: var(--radius); padding: .7rem .9rem; margin-block-end: .6rem; }
/* Block-level success notice (ut-docs#1171): same shape as .notice-block-warn
   above, --success palette instead of --warning — the catalogue import's
   post-commit summary needs to read as unmistakably "done", distinct at a
   glance from the (amber) preview/warning state, not just a plain <p>. */
.notice-block-success { background: var(--success-tint); border: 1px solid var(--success-tint-border);
  border-radius: var(--radius); padding: .7rem .9rem; margin-block-end: .6rem; }

/* ut-docs#3709: /import?welcome=1 — the three ways a new shop starts
   (import a file / load sample data / skip). A grid of option cards on wide
   screens, stacked on a phone; each card's action sits at its foot so the
   buttons line up across the row. Logical properties only (RTL). */
.import-welcome { margin-block-end: 1rem; }
.import-welcome h2 { margin-block: 0 .3rem; }
.import-welcome-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
  gap: .75rem; margin-block-start: .6rem; }
.import-welcome-option { display: flex; flex-direction: column; gap: .5rem;
  border: 1px solid var(--border); border-radius: var(--radius); padding: .8rem .9rem; }
.import-welcome-option h3 { margin: 0; }
.import-welcome-option p { margin: 0; flex: 1; }
.import-welcome-actions, .import-sample-actions { display: flex; flex-wrap: wrap; gap: .4rem; }
.import-welcome-actions .btn, .import-sample-actions .btn { flex: 1 1 auto; }

/* ---------- Kiosk mode (dedicated till: UT_KIOSK=1) ----------
   Touch-first: bigger targets, no text selection or tap highlights, no
   accidental pinch zoom. Pair with a fullscreen browser (chromium --kiosk);
   see packaging/linux/unitill-kiosk-setup.sh. */
body.kiosk { font-size: .9375rem; touch-action: manipulation; }
body.kiosk.sale-screen { overflow: hidden; }
body.kiosk .container { padding: .6rem .8rem 0; }
body.kiosk .kiosk-header { margin: 0 0 .6rem; }
body.kiosk .pos-container { height: calc(100dvh - 6.6rem); }
body.kiosk * { -webkit-tap-highlight-color: transparent; }
body.kiosk .btn, body.kiosk .nav-toggle, body.kiosk .btn-tile,
body.kiosk h1, body.kiosk h2, body.kiosk label,
body.kiosk .basket th, body.kiosk .journal th {
  -webkit-user-select: none; user-select: none;
}
/* .btn/.btn-tile touch targets: kiosk must never be SMALLER than non-kiosk
   (real screens, real fingers) — previously 2.75rem/5.4rem here shrank
   below the 3rem/8.5rem base rules and fell under a 48px effective floor
   at common till widths (ut-docs#161). Kiosk now inherits the base sizes. */
body.kiosk input, body.kiosk select { min-height: 2.4rem; }
body.kiosk .qty-input, body.kiosk .disc-input { min-height: 2.1rem; }
body.kiosk .basket .total { font-size: 1.45rem; }

/* ── Operator PIN login (docs: architecture/pos-auth.md) ─────────────────
   Full-screen keypad; touch-first like the sale screen. */
body.login-screen {
  display: flex; align-items: center; justify-content: center;
  min-height: 100dvh; margin: 0; background: var(--bg);
}
.login-card {
  background: var(--surface); border: 1px solid var(--border);
  border-radius: 12px; padding: 2rem 2.4rem; width: min(24rem, 92vw);
  text-align: center; box-shadow: 0 8px 30px rgba(0,0,0,.12);
}
/* ut-docs#298: unlike .nav (always dark var(--brand)), --surface here is
   white in every shipped theme (default/fresh/slate/amber/monarch), so
   this keeps the CANONICAL dark mark, not the light-glyph variant — a
   light glyph here would vanish against the white card, trading the
   reported "white patch on dark chrome" defect for the same patch
   inverted (dark patch on a white card), on surfaces that had no defect
   to begin with (independent review, ut-docs#298). No backing plate
   needed: the dark mark on a white card already reads cleanly with no
   background at all. Constrain height only: the mark is portrait
   (aspect ~0.73) and fixing both dimensions would distort it. */
.login-logo, .selforder-logo {
  display: block; margin-inline: auto; background: transparent;
}
.login-logo { height: 5rem; margin-bottom: .5rem; }
.selforder-logo { height: 6rem; margin-bottom: 1rem; }
.login-tagline { margin: 0 0 1.4rem; color: var(--muted, #64748b); font-size: .85rem; }
.login-card h1 { font-size: 1.2rem; margin: 0 0 1rem; }
.login-footer {
  margin-top: 1.6rem; padding-top: 1rem; border-top: 1px solid var(--border);
  color: var(--muted, #94a3b8); font-size: .72rem; letter-spacing: .02em;
}
.login-error { color: var(--danger, #c0392b); font-weight: 600; min-height: 1.3rem; }
/* ut-docs#2982: inline-actions.js's save-error step — a refused Settings
   save's reason, right after the form that made the request. */
.save-error-inline { margin-block: .5rem 0; text-align: start; }
.pin-dots { display: flex; gap: .45rem; justify-content: center; margin-bottom: 1.1rem; }
.pin-dot {
  width: .8rem; height: .8rem; border-radius: 50%;
  border: 1px solid var(--border); background: transparent;
}
.pin-dot.filled { background: var(--brand); border-color: var(--brand); }
.pin-pad { display: grid; grid-template-columns: repeat(3, 1fr); gap: .55rem; }
.pin-key { min-height: 3.4rem; font-size: 1.25rem; }
.pin-clear { font-size: .95rem; }
.login-setup { display: flex; flex-direction: column; gap: .8rem; text-align: start; }
.login-setup input { width: 100%; }

/* Multi-till sync chip in the nav (ADR-0011 D4) and the fiscalisation
   status chip (ut-docs#685). ut-docs#1539: both used to be their own
   tinted pill (.ok green / .warn amber background+text, hardcoded here)
   — the ONE/TWO rail items ut-docs#1423 missed migrating to a plain
   .nav-toggle. That pill design is what #405 had to work around (a
   `.sync-chip a { color: inherit }` specificity fight against every
   shipped theme's own `.nav a { color: … }` — see the git history on this
   comment if that workaround is ever needed again for a similar case).
   Migrating to .nav-toggle removes the whole problem at the root: every
   rail item, chips included, now shares one plain white color with no
   per-state override, so there is nothing left for a theme's `.nav a`
   rule to fight over. State now shows as a small .nav-badge dot (above)
   on the icon instead of recolouring the button — per the product owner's
   own rule ("if it is a menu button it should be correct"), a permanent
   navigation control shouldn't change color depending on transient state.
   `.sync-chip`/`.fiscal-chip` themselves are now bare wrappers (htmx
   swap target + the state-class hook sync_admin_test.go's/
   fiscal_chip_test.go's TestSyncChip_*/TestFiscalChip_* assert on,
   "sync-chip ok"/"sync-chip warn"/"fiscal-chip ok"/"fiscal-chip warn") —
   no box styling of their own, nothing to define here. */
.sync-banner { background: var(--warning-tint); color: var(--warning); border-radius: .5rem;
  padding: .6rem .9rem; margin-block-end: .8rem; }
.sync-banner a { color: inherit; font-weight: 700; text-decoration: underline; }

/* ut-docs#1551: nav-level dismissible notice for a pending pairing
   request -- mounted in base.html as a sibling of <nav>/<main> (not
   inside any one page's content), so it renders identically on every
   manager-facing page rather than only where a page opts in the way
   .sync-banner above does. Deliberately its own class, not .pos-notice
   reused: that one is explicitly scoped to the sale screen's in-flow
   basket/#pos-alert surface (docs/sale-screen-notifications.md) and
   auto-expires info/success notices after 2.5s -- this notice must
   persist (dismissed only by the operator, or by the request resolving)
   and must look identical on every page, not just inside a basket. The
   info tint matches .pos-notice.info's own palette so the two notice
   surfaces still read as one visual language. Reuses .notice-dismiss
   (below) for the button. */
.pairing-notice { display: flex; align-items: center; gap: .6rem;
  padding: .55rem 1rem; font-weight: 600; font-size: .92rem;
  background: rgba(37, 99, 235, .1); color: var(--accent);
  border-block-end: 1px solid rgba(37, 99, 235, .3); }
.pairing-notice[hidden] { display: none; }
.pairing-notice-text { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.pairing-notice a { color: inherit; font-weight: 700; text-decoration: underline; }
/* ut-docs#2721: the standalone till's "join this shop's main till" notice
   reuses .pairing-notice's look; its inline name + Link form wraps under
   the text on a narrow screen, and pair-start's progress shows below. */
.join-notice { flex-wrap: wrap; }
.join-notice-form { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; margin: 0; }
.join-notice-form input[type="text"] { width: auto; min-width: 8rem; margin: 0; }
.join-notice-label { margin: 0; font-weight: 600; color: inherit; }
/* The candidate's name (a <bdi>, join_notice.go) moves to the next line
   as one unit instead of breaking at its hyphens inside fa/ar copy
   ("test-" / "2721-main"); it still breaks if it alone is wider than the
   line, so a long name can't overflow a 360px screen. */
.join-notice-name { display: inline-block; max-width: 100%; overflow-wrap: anywhere; }
.join-notice-status:not(:empty) { padding: .55rem 1rem; border-block-end: 1px solid rgba(37, 99, 235, .3); }

/* Operator chip + lock button in the nav: a column, in the rail and in
   the phone ☰ drawer alike (ut-docs#3297 removed the old top bar's
   one-row override). */
.session-chip { display: flex; flex-direction: column; align-items: stretch; gap: .3rem; }
.session-user { font-weight: 600; }
.session-lock { display: block; margin: 0; }
.btn-lock { padding: .5rem .3rem; }

/* Users admin page */
.users-layout { display: grid; grid-template-columns: 2fr 1fr; gap: 1rem; align-items: start; }
/* ut-docs#1173: a grid item's default min-inline-size is auto, i.e. it won't
   shrink below its content's own intrinsic width -- a row with several
   inline-editable fields (tables.html's rename/zone/seats/shape + two
   buttons per row is the widest of this class's 8 consumers) can be wider
   than the 2fr track ever allocates it, which blows the WHOLE grid out
   past the viewport instead of just that one card scrolling. Same fix,
   same reasoning, as the already-reviewed .settings-grid .card /
   .settings-grid .card .table pair just below (ut-docs#251) -- let the
   table scroll inside its own card rather than stretch the page. */
.users-list { min-inline-size: 0; }
.users-list .table { display: block; overflow-x: auto; }
/* ut-docs#3359 (replaces ut-docs#3297's "scroll sideways inside its own
   box" rule, which the product owner rejected on an iPhone: "we shouldn't
   scroll to the left and right"): on a phone every data table is a stacked
   card list. web/public/table-cards.js marks each `main table` with a
   <thead> as `.t-cards` (not the basket, not data-cards="off", not a table
   nested in another's cell) and gives every body cell its column header
   as data-label; these rules do the rest, so above 480px nothing changes.
   Per card: the first labelled cell is the title (no label), each other
   cell is "LABEL value" (the label a fixed share of the line, the value
   beside it, or under it when it is a block such as a form), the
   header-less row-actions cell sits at the card's end, and a row with more
   than four labelled cells keeps the rest behind a More/Less toggle. The
   wrappers that only existed to scroll a wide table sideways stop
   scrolling at this tier. */
.t-cards-more { display: none; }
@media (max-width: 480px) {
  table.t-cards, table.t-cards > :is(tbody, tfoot) { display: block; inline-size: 100%; }
  /* overflow: the generic table rules (and #tax-codes-table / .users-list /
     .settings-grid / .pairing-table / #open-orders-table) clip or scroll the
     table; a card list must do neither. The id selectors keep this ahead of
     those rules' own specificity. */
  table.t-cards, .users-list table.t-cards, .settings-grid .card table.t-cards,
  #tax-codes-table table.t-cards, table.t-cards#open-orders-table, .card table.t-cards.perm-table {
    overflow: visible; background: transparent; border-radius: 0; max-inline-size: 100%;
  }
  :is(.list-scroll, .perm-scroll, #tax-codes-table):has(> table.t-cards) { overflow-x: visible; }
  /* Visually hidden, still announced (the labels repeat it per card). */
  table.t-cards > thead {
    display: block; position: absolute; inline-size: 1px; block-size: 1px; margin: -1px;
    padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
  }
  table.t-cards tr.t-cards-row {
    display: block; position: relative; margin-block-end: .6rem; padding: .55rem .8rem;
    background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
  }
  table.t-cards tr:not(.t-cards-row) { display: block; }
  table.t-cards tr[hidden] { display: none; }
  table.t-cards tr > :is(td, th) {
    display: block; padding: .25rem 0; border: 0; min-inline-size: 0; max-inline-size: none;
    text-align: start !important; /* beats inline style="text-align:end" money cells */
    text-transform: none; letter-spacing: normal; font-size: inherit;
    overflow-wrap: anywhere; position: static;
  }
  table.t-cards tr:not(.t-cards-row) > :is(td, th) { padding: .4rem .2rem; }
  table.t-cards tr > .t-cards-title { font-weight: 700; padding-block-end: .35rem; color: var(--text); }
  table.t-cards tr > [data-label]:not(.t-cards-title)::before {
    content: attr(data-label);
    display: inline-block; inline-size: 40%; margin-inline-end: .5rem; vertical-align: top;
    font-size: .75rem; line-height: 1.6; font-weight: 600; text-transform: uppercase;
    letter-spacing: .04em; color: var(--muted);
  }
  table.t-cards tr > .t-cards-actions:empty { display: none; }
  table.t-cards tr > .t-cards-actions { display: flex; flex-wrap: wrap; gap: .5rem; justify-content: flex-end; }
  /* ut-docs#3357: a labelled actions CELL (a real column header, e.g.
     /orders' "Update") renders its buttons through the page's own
     .btn-actions div, not .t-cards-actions (that class only reaches a
     HEADER-LESS cell) -- so it never inherited the flex-wrap this card
     tier needs. .btn-actions's base rule (`display:flex; gap:.25rem;
     justify-content:center`) is shared with other, non-card contexts and
     must stay nowrap there; only override it inside a card. Found by
     measuring a real order's row at 360px: four one-tap status buttons
     (Preparing/Ready/Collected/Cancel order) summed past the card's
     content width and spilled ~21px outside the card border on both
     sides -- unreadable in exactly the way this ticket exists to fix. */
  table.t-cards .btn-actions { flex-wrap: wrap; justify-content: flex-start; }
  table.t-cards tr.t-cards-row:not(.t-cards-open) > .t-cards-extra { display: none; }
  table.t-cards tr > .t-cards-more { display: block; padding-block-start: .4rem; }
  /* Buttons keep their words whole (the cell's anywhere-wrap is for data). */
  table.t-cards :is(button, .btn) { overflow-wrap: normal; }
  .t-cards-toggle { min-block-size: 2.75rem; }
  .t-cards-toggle::before { content: attr(data-caption); }
  main :is(input:not([type="checkbox"]):not([type="radio"]), select, textarea) { max-inline-size: 100%; box-sizing: border-box; }
}
.users-actions { display: flex; gap: .5rem; flex-wrap: wrap; }
.users-inline { display: inline-flex; gap: .35rem; margin: 0; }
.users-inline input { width: 7.5rem; }
/* ut-docs#3324: a custom role's label is the shop's own text (up to 40
   characters); cap the role picker so a long one can't push the row's
   Change role button out of the card. */
.users-inline select.users-role-pick { max-inline-size: 9rem; }
/* Rename-field inputs (locations/registers "name" text input) hold longer,
   free-text names than the other .users-inline inputs (PIN, printer
   address, search, the country_settings checkbox) — a fixed 7.5rem clips
   mid-word with no indication more text exists (ut-docs#898). Widen just
   this variant and add an ellipsis fallback for anything longer than
   realistic name lengths still allow; .users-inline's other consumers
   (kitchen stations, tables, promotions, country_settings, users.html's
   PIN field) are unaffected since they don't carry this class. Fixed
   width, not min/max-width — nothing here ever needs to shrink below or
   grow past it. (A `flex-wrap: wrap` on .users-inline itself was tried as
   a narrow-viewport defence and reverted: it changes the container's
   intrinsic-sizing algorithm enough to make the unrelated role-select +
   "Change role" button on /users wrap onto two lines even at full desktop
   width, a real regression on a page this fix has no business touching —
   see the ut-docs#898 code-review record.) */
.users-inline input.rename-input { width: 13rem; text-overflow: ellipsis; overflow: hidden; }
.users-form form { display: flex; flex-direction: column; gap: .7rem; }
@media (max-width: 900px) { .users-layout { grid-template-columns: 1fr; } }

/* Permissions matrix (/users/permissions, ut-docs#3132). Above the phone
   tier (at ≤480px it is a card list, ut-docs#3359 below) the grid scrolls
   sideways inside its own card, never the page; the action column stays
   pinned at the card's inline-start edge while the role columns scroll
   under it, so a checkbox is never read against the wrong action. The
   table's generic `overflow: hidden` (table.table above) would make the
   table itself the scroll container and defeat `position: sticky`, so it
   is `clip` here (same rounded-corner clipping, no scroll container). */
.perm-card { min-inline-size: 0; }
.perm-scroll { overflow-x: auto; max-inline-size: 100%; }
.card table.perm-table { overflow: clip; }
.perm-table .perm-action {
  position: sticky; inset-inline-start: 0; z-index: 1;
  background: var(--surface);
  min-inline-size: 12rem;
  border-inline-end: 1px solid var(--border);
}
.perm-table tbody th.perm-action {
  text-transform: none; letter-spacing: normal; font-size: inherit;
  font-weight: 400; color: var(--text); padding: .5rem .6rem;
  border-bottom: 1px solid var(--border); vertical-align: top;
}
.perm-table tbody tr:last-child th.perm-action { border-bottom: none; }
.perm-label { display: block; font-weight: 600; }
.perm-desc, .perm-unlocks { display: block; font-size: .8rem; line-height: 1.35; margin-block-start: .15rem; }
.perm-unlocks { font-weight: 600; }
.perm-table .perm-cell { text-align: center; min-inline-size: 4.5rem; }
/* ut-docs#3324: a custom role's column (ADR-0128 §6) says, under its
   label, where it is edited; its cells are disabled. */
.perm-cloud-note { display: block; font-size: .75rem; font-weight: 400; text-transform: none; letter-spacing: normal; }
.perm-table .perm-group-head { background: var(--surface-2); padding-block: .4rem; }
.perm-group-label { position: sticky; inset-inline-start: .6rem; }
/* ut-docs#3359: on a phone the matrix is a card list like every other
   table (table-cards.js) — one card per action, its description under the
   title, then one "ROLE ☐" line per role — so nothing pins or scrolls. */
@media (max-width: 480px) {
  .perm-table.t-cards .perm-action, .perm-table.t-cards .perm-cell {
    position: static; min-inline-size: 0; max-inline-size: none; border: 0; background: none;
  }
  .perm-table.t-cards tbody th.perm-action { padding: 0 0 .35rem; }
  .perm-table.t-cards .perm-group-head { background: none; padding: .6rem .2rem .3rem; font-weight: 700; }
  .perm-table.t-cards .perm-group-label { position: static; }
  .perm-table.t-cards .perm-cell input[type="checkbox"] { inline-size: 1.4rem; block-size: 1.4rem; vertical-align: middle; }
}

/* Fiscal register (ut-docs#665) -- one block per business location inside
   .users-list; without this, consecutive locations' tables run straight
   into each other with no visual break. */
.fiscal-register-group { margin-block-end: 1.5rem; }
.fiscal-register-group:last-child { margin-block-end: 0; }
/* ut-docs#2420: the per-location address-edit form (.users-inline, three
   unstyled text inputs + a submit button) has no intrinsic ability to
   shrink -- its three ~7.5rem inputs plus labels plus button sum to
   ~523px, and inline-flex boxes don't wrap by default, so at the 360px
   floor it overflows the whole document by ~184px once its group also
   renders a table (an empty group with no rows never reaches this,
   nothing else on the page changes). Scoped to THIS page's group rather
   than the shared .users-inline class itself: a bare `flex-wrap: wrap` on
   .users-inline was already tried and reverted (see the comment on
   `.users-inline input.rename-input` above, ut-docs#898) because it makes
   the unrelated /users role-select + "Change role" row wrap even at full
   desktop width -- that page never carries .fiscal-register-group, so
   this selector can't touch it. */
@media (max-width: 480px) {
  .fiscal-register-group .users-inline { flex-wrap: wrap; }
  /* ut-docs#3653: the same overflow inside a phone card's actions cell.
     That cell is flex-end, so a non-wrapping inline edit form wider than
     the card (promotions: type, value, description, two dates, Save
     ≈ 715px) spilled past the card's inline-START edge and its first
     controls were cut off by the table's overflow:hidden. /kitchen-stations
     had the same defect. table-cards.js puts .t-cards on tables at every
     width, but this block is ≤480px only, so the desktop /users row
     (#898) is untouched. */
  table.t-cards .t-cards-actions .users-inline {
    flex-wrap: wrap; justify-content: flex-end; min-inline-size: 0; max-inline-size: 100%;
  }
  /* Once wrapped, let the fields fill their line, and keep a date wide
     enough to show its year (7.5rem cut it off at "31/12/202").
     min-inline-size, not width: it outranks the templates' inline
     style="width:…" without !important. */
  table.t-cards .t-cards-actions .users-inline :is(input, select) { flex: 1 1 auto; }
  table.t-cards .t-cards-actions .users-inline input[type="date"] { min-inline-size: 9.75rem; }
}

/* Camera identify overlay (opened on demand from the scan row) */
.ai-identify-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.55);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 60;
}
.ai-identify-overlay[hidden] { display: none; }
.ai-identify-panel {
  background: var(--surface, #fff);
  border-radius: 12px;
  padding: 1rem;
  inline-size: min(30rem, 92vw);
  max-block-size: 90dvh;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
}
.ai-identify-panel h3 { margin: 0; }
/* Shared by all three camera previews (AI identify + ut-docs#548 barcode
   scan + ut-docs#1472 catalog photo viewfinder) — a class, not an ID,
   since multiple distinct <video> elements use it. */
.camera-overlay-video {
  inline-size: 100%;
  border-radius: 8px;
  background: #000;
  aspect-ratio: 4 / 3;
  object-fit: cover;
}
/* ut-docs#3133: the catalog photo viewfinder sits in the full-width item
   dialog (#1956), where 100% at 4:3 was ~1230x920 on a 1280x800 tablet —
   taller than the screen, top under the tab bar. A compact preview; the
   AI-identify and barcode panels keep the shared sizing above. */
#image-viewfinder .camera-overlay-video {
  display: block;
  inline-size: min(100%, 20rem);
}
/* ut-docs#3133: the item's current (or just-taken) picture on the Item
   image tab. */
.item-image-current {
  display: block;
  max-inline-size: 8rem;
  max-block-size: 8rem;
  object-fit: contain;
  margin-block: 0.5rem;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--surface);
}
.item-image-current[hidden] { display: none; }
.ai-identify-results { display: flex; flex-direction: column; gap: 0.5rem; }
.ai-identify-results .ai-match {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  justify-content: flex-start;
  text-align: start;
}
.ai-identify-results .ai-match img {
  inline-size: 2.5rem;
  block-size: 2.5rem;
  object-fit: cover;
  border-radius: 6px;
}
.ai-identify-status { min-block-size: 1.2rem; color: var(--muted, #667); }
.ai-identify-controls { display: flex; gap: 0.5rem; justify-content: flex-end; }

/* Beginner guide (/help) — large readable steps, two-column on wide screens */
.help-intro { color: var(--muted); font-size: 1.05rem; max-inline-size: 44rem; }
.help-guide { display: grid; grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); gap: .8rem; }
.help-steps { margin: 0; padding-inline-start: 1.4rem; font-size: 1.05rem; }
.help-steps li { margin-block-end: .5rem; line-height: 1.55; }
.help-section-title { margin-block: 1.4rem .3rem; }

/* The manual (/help): topic tree + search on the inline-start side, the topic
   on the other. Logical properties only, so RTL locales mirror for free.
   On a narrow screen (a 1024×600 Pi in portrait, a phone) the two panes
   stack — the tree first, since that is how you find anything. */
.manual { display: grid; grid-template-columns: minmax(14rem, 18rem) minmax(0, 1fr);
          gap: 1.2rem; align-items: start; }
@media (max-width: 52rem) { .manual { grid-template-columns: 1fr; } }

.manual-nav { position: sticky; inset-block-start: .5rem; max-block-size: calc(100vh - 5rem);
              overflow-y: auto; padding-inline-end: .4rem; }
/* ut-docs#3356: stacked on a phone the tree filled the screen and stayed
   pinned (sticky + max-block-size), so a tapped topic swapped in below it,
   out of sight — same class of bug as .items-rail-wrap (#3297). Unpin the
   nav; while the panel shows a topic hide the whole nav (the back link
   restores it); while it shows search results hide only the tree, keeping
   the search box. Only topic articles carry data-topic. */
.manual-back { display: none; }
@media (max-width: 52rem) {
  .manual-nav { position: static; max-block-size: none; overflow: visible; }
  /* Hiding the tree shifts content the browser had anchored on and it
     re-scrolls the page past the topic heading after our scroll-to-top
     (app.js); opt the manual out of scroll anchoring. */
  .manual { overflow-anchor: none; }
  .manual:has(#manual-topic[data-topic]) .manual-nav { display: none; }
  .manual:has(.manual-results) .manual-tree { display: none; }
  .manual-back { display: inline-flex; align-items: center; gap: .35rem;
                 min-block-size: 44px; padding-inline: .6rem; margin-block-end: .4rem;
                 border-radius: .45rem; text-decoration: none; color: inherit; }
  .manual-back:hover { background: var(--panel, rgba(127,127,127,.12)); }
  /* No RTL flip for the chevron: U+2039 is bidi-mirrored, so an RTL line
     already draws it pointing inline-start (a scaleX(-1) would undo that). */
}
.manual-search input { inline-size: 100%; padding: .55rem .7rem; font-size: 1rem;
                       min-block-size: 44px; border: 1px solid var(--border); border-radius: .5rem;
                       background: var(--panel, #fff); color: inherit; }
.manual-tree ul { list-style: none; margin: 0 0 .9rem; padding: 0; }
.manual-tree li { margin-block-end: .15rem; }
.manual-section { font-size: .8rem; text-transform: uppercase; letter-spacing: .04em;
                  color: var(--muted); margin-block: 1rem .35rem; }
.manual-link { display: block; padding: .5rem .6rem; border-radius: .45rem;
               text-decoration: none; color: inherit; line-height: 1.35; min-block-size: 44px; }
.manual-link:hover { background: var(--panel, rgba(127,127,127,.12)); }
.manual-link.is-current { background: var(--accent, #2b6cb0); color: #fff; font-weight: 600; }

.manual-panel { min-inline-size: 0; }
.manual-topic { max-inline-size: 48rem; line-height: 1.6; }
.manual-topic h1 { margin-block: 0 .6rem; }
.manual-topic h2 { margin-block: 1.6rem .4rem; font-size: 1.2rem; }
.manual-topic ol, .manual-topic ul { padding-inline-start: 1.4rem; }
.manual-topic li { margin-block-end: .45rem; }
/* Screenshots: full width of the reading column, never overflowing it. */
.manual-topic img { max-inline-size: 100%; height: auto; border: 1px solid var(--border);
                    border-radius: .5rem; margin-block: .6rem; display: block; }
.manual-untranslated { background: var(--panel, rgba(127,127,127,.12)); border-inline-start: 3px solid var(--accent, #2b6cb0);
                       padding: .5rem .7rem; border-radius: .3rem; color: var(--muted); }
.manual-result-list { list-style: none; margin: 0; padding: 0; }
.manual-result-list li { margin-block-end: 1rem; }
.manual-snippet { color: var(--muted); margin-block: .2rem 0; }

/* /items (ut-docs#1950): a narrow left rail of compact section rows, the
   selected section's own content on the other side — same grid/breakpoint
   shape as .manual above (14-18rem rail, the rest to the panel), reused
   deliberately rather than re-derived. Logical properties only, so RTL
   locales mirror for free.
   Below the breakpoint the two panes stack AND a rail tap falls back to a
   real navigation instead of the wide layout's in-panel htmx swap — a
   deliberate, stated difference from .manual (whose stacked layout keeps
   swapping in place): see items.html's own script for how that fallback is
   implemented, and its comment for why this exact 52rem value is reused
   rather than a new one. */
.items-layout { display: grid; grid-template-columns: minmax(14rem, 18rem) minmax(0, 1fr);
                gap: 1.2rem; align-items: start; }
@media (max-width: 52rem) { .items-layout { grid-template-columns: 1fr; } }

.items-rail-wrap { position: sticky; inset-block-start: .5rem; }
/* ut-docs#3297: stacked, the section list sits above the panel; pinned,
   it stayed on screen and the catalog scrolled up under it. */
@media (max-width: 52rem) { .items-rail-wrap { position: static; } }
.items-rail-list { display: flex; flex-direction: column; gap: .2rem; }
/* Compact row target block-size: ~3.4rem (two text lines + padding) — still
   well under the old card tiles' ~6rem+, and keeps a >=44px touch target
   (reference/ux-guidelines.md's touch-ergonomics rule; #144's fluid-scale
   fix, not any single other rule's declared min-height) even with no
   explicit min-block-size below: its own two lines of content plus
   padding-block already clear 44px at every viewport in the fluid-scale
   range. The subtitle is a REAL second line (independent-review fix,
   ut-docs#1950: an earlier draft hid it in a title="" tooltip, which
   ux-guidelines.md rules out — no mouse on a touchscreen till). Each line
   ellipses independently instead of wrapping, via min-inline-size: 0 on the
   flex text column (a flex item's automatic min-inline-size is its
   min-content otherwise, which defeats text-overflow: ellipsis on a nowrap
   child — same class of bug ut-docs#1900 already found on the catalog
   variants grid). */
.items-row { display: flex; align-items: center; gap: .5rem;
             padding-block: .4rem; padding-inline: .7rem; border-radius: var(--radius);
             text-decoration: none; color: inherit; }
.items-text { display: flex; flex-direction: column; min-inline-size: 0; flex: 1 1 auto; }
.items-name, .items-subtitle {
  display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.items-name { line-height: 1.3; }
.items-subtitle { font-size: .82rem; color: var(--muted); line-height: 1.25; }
.items-row.is-current .items-subtitle { color: inherit; opacity: .85; }
.items-row:hover { background: var(--surface-2); }
.items-row.is-current { background: var(--accent); color: var(--accent-contrast); font-weight: 600; }
.items-row.is-disabled { color: var(--muted); justify-content: space-between; }
.items-panel { min-inline-size: 0; }

/* /admin tree (ut-docs#2008): reuses .items-row/.items-text/.items-name
   verbatim (web/ui/partials/admin_tree.html) -- same RTL-safe logical
   properties, same hover/focus treatment. Each row here is name-only (no
   subtitle -- see this card's scope), so the two-line block-size the
   comment above .items-row explains does not apply; min-block-size holds
   the row at the product's own documented touch-target floor (.btn-touch,
   above: "the product's own 46px touch-target floor") -- these are
   primary navigation rows on a touchscreen till, not a secondary control,
   so they get the full floor, not a smaller one (found in review: this
   was 44px, 2px under spec). */
.admin-tree .items-row { min-block-size: 46px; }
.admin-group { margin-block-start: 1.4rem; }
.admin-group:first-child { margin-block-start: 0; }
.admin-group h2 { font-size: 1rem; font-weight: 600; color: var(--muted); margin-block-end: .3rem; }
.admin-tree-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .2rem; }

/* Contextual "?" — sits next to a page heading and opens that page's topic.
   Deliberately small and low-contrast: it must be findable when wanted and
   invisible when not, and never a mis-tap target during a sale. */
.help-hint { display: inline-flex; align-items: center; justify-content: center;
             inline-size: 1.6rem; block-size: 1.6rem; margin-inline-start: .4rem;
             border: 1px solid var(--border); border-radius: 50%; text-decoration: none;
             color: var(--muted); font-size: .95rem; line-height: 1; vertical-align: middle; }
.help-hint:hover, .help-hint:focus { color: inherit; border-color: var(--focus-border, #2b6cb0); }

/* Translation editor (/translations) */
.translations-controls { display: flex; gap: 1rem; align-items: end; margin-block-end: .8rem; flex-wrap: wrap; }
.translations-controls label { display: flex; flex-direction: column; gap: .25rem; font-size: .85rem; color: var(--muted); }
.translations-grid { width: 100%; border-collapse: collapse; }
.translations-grid th { font-size: .75rem; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); text-align: start; padding: .4rem .5rem; border-bottom: 1px solid var(--border); }
.translations-grid td { padding: .4rem .5rem; border-bottom: 1px solid var(--border); vertical-align: top; }
.translations-key code { font-size: .8rem; }
.translations-ref { color: var(--muted); max-inline-size: 18rem; }
.translations-edit { display: flex; gap: .4rem; }
.translations-edit input[type="text"] { min-inline-size: 16rem; }
/* ut-docs#3359: in the phone card the field takes the line beside its
   Save button instead of forcing 16rem past the screen edge. */
@media (max-width: 480px) {
  .translations-edit input[type="text"] { min-inline-size: 0; flex: 1 1 0; }
}
/* ut-docs#2014: the infinite-scroll sentinel's loading text and its
   terminal end-of-list replacement, both on the colspan <td> (not the
   <tr> — padding on a table-row box is ignored by every browser, and
   text-align/font-style would inherit down but the padding wouldn't, so
   both classes have to live on the cell itself). .muted (color only) sets
   no alignment of its own; text-align: center here is what actually
   centers these two rows — deliberately NOT applied to the plain
   "no matching texts" empty-state row above, which stays start-aligned
   like a normal data row (review finding, ut-docs#2014: an earlier draft
   of this comment claimed the opposite of what the rule below does). */
.translations-loading,
.translations-end { text-align: center; padding-block: .6rem; font-style: italic; }

/* First-boot setup wizard */
.setup-card {
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg);
  padding: 1.6rem; inline-size: min(26rem, 92vw); margin: 8dvh auto 0;
  display: flex; flex-direction: column; gap: .9rem; text-align: center;
}
.setup-card h1 { font-size: 1.3rem; margin: 0; }
.setup-card input[type="text"], .setup-card input[type="password"], .setup-card select {
  inline-size: 100%; padding: .6rem .7rem; font-size: 1.05rem;
}
.setup-card label { display: block; text-align: start; margin-block-end: .6rem; }
.setup-dots { display: flex; gap: .4rem; justify-content: center; }
.setup-dot { inline-size: .6rem; block-size: .6rem; border-radius: 50%; background: var(--border); }
.setup-dot.active { background: var(--accent); }
.setup-langs { display: flex; gap: .5rem; justify-content: center; flex-wrap: wrap; }
/* ut-docs#1180: the wizard's optional tax-plugin install prompt (step 3,
   Germany today). A subtle well inside the step so it reads as an aside to
   the business-details form rather than another field of it — the shipped
   markup carried no rule at all and ran straight into the TSE inputs.
   Existing :root tokens only (no new hex/px colour), fully logical
   properties so the RTL locales lay out unmodified, and rem sizing so it
   stays inside the fluid touch-target scaling. The button goes full-width:
   "Install" is three letters in English and a good deal longer in de/tr. */
.setup-tax-plugin-prompt {
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding-block: .7rem; padding-inline: .8rem;
  margin-block: .7rem;
}
.setup-tax-plugin-prompt h2 { font-size: 1.05rem; margin-block: 0 .3rem; }
.setup-tax-plugin-prompt p { margin-block: 0 .5rem; }
.setup-tax-plugin-prompt .btn { inline-size: 100%; }
/* ut-docs#1165: step 1's "a newer version exists — update before continuing
   setup?" prompt. Same well styling as .setup-tax-plugin-prompt just above
   (an aside inside the step, not another form field), reused rather than a
   near-duplicate rule. */
.setup-update-banner {
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding-block: .7rem; padding-inline: .8rem;
  margin-block-end: .7rem;
}
.setup-update-banner p { margin-block: 0 .5rem; }
.setup-update-banner .btn { inline-size: 100%; }
.setup-nav { display: flex; gap: .6rem; justify-content: center; margin-block-start: .4rem; flex-wrap: wrap; }

/* Touch-first tile picker (setup wizard country/shop-type, ut-docs#1095) —
   replaces a native <select> with large tappable tiles. Borrows the
   grid/press feel of .btn-tile above (the sale-screen product tiles) but
   sized down for a single line of text instead of image+name+price.
   --control-border/--focus-border (see :root's own comments on both) are
   the same tokens every real form control on this page uses, since this
   button IS a form control now, not decoration. */
.picker-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(7.5rem, 1fr)); gap: .5rem; margin-block-end: .4rem; }
.picker-tile {
  display: flex; align-items: center; justify-content: center;
  min-block-size: 3.2rem; padding: .6rem .5rem; border: 1px solid var(--control-border);
  border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow);
  font: inherit; font-size: .95rem; font-weight: 600; color: var(--text);
  cursor: pointer; text-align: center; line-height: 1.25;
  transition: border-color .12s ease, background .12s ease, transform .05s ease;
}
.picker-tile:hover { border-color: #cbd5e1; }
.picker-tile:active { transform: scale(.97); }
.picker-tile:focus-visible { outline: 2px solid rgba(37, 99, 235, .35); outline-offset: 1px; border-color: var(--focus-border); }
.picker-tile[aria-pressed="true"] {
  border-color: var(--accent); background: var(--surface-2); box-shadow: inset 0 0 0 2px var(--accent);
}
[x-cloak] { display: none !important; }

/* Catalog built-in image picker (ut-docs#1844): a smaller .picker-grid of
   icon tiles inside the "Item image" details panel, icon-above-label
   (review finding F6: a touchscreen till can't rely on the hover-only
   `title` tooltip .picker-tile's base text-only layout assumes, so each
   tile also carries a real visible label). .builtin-icon-suggested marks
   PlaceholderIcon's own keyword-match suggestion. JS applies "pressed" and
   "suggested" mutually exclusively (see setBuiltinIconTiles in
   catalog.html) — a real choice always outranks a suggestion — but review
   finding F4 also lets "none" show pressed AND another tile show
   suggested at once (two independent facts: "this is the current — empty
   — state" and "here's what picking one now would suggest"), so both
   classes CAN legitimately coexist on different tiles at once; they just
   never coexist on the SAME tile. */
.builtin-icon-section .picker-grid { grid-template-columns: repeat(auto-fill, minmax(4.6rem, 1fr)); }
.builtin-icon-tile {
  flex-direction: column; gap: .3rem;
  min-block-size: 3.6rem; padding: .5rem .4rem;
  font-size: .78rem; font-weight: 500;
}
.builtin-icon-tile img { display: block; }
/* ut-docs#2506: ~70 icons in sections. The grid scrolls inside its own
   box (touch-scrollable, no nested page jump) so the dialog's Save stays
   reachable at the 1024×600 floor; the section headings span the whole
   row; the search box sits above at full width. */
.builtin-icon-grid {
  grid-template-columns: repeat(auto-fill, minmax(4.6rem, 1fr));
  max-block-size: 18rem; overflow-y: auto; overscroll-behavior: contain;
  padding: .15rem;
}
.builtin-icon-group-label {
  grid-column: 1 / -1; margin-block-start: .35rem;
  font-size: .8rem; font-weight: 600; color: var(--muted); text-align: start;
}
.builtin-icon-group-label[hidden], .builtin-icon-tile[hidden] { display: none; }
.builtin-icon-search { display: block; inline-size: 100%; margin-block: .35rem; }
/* Review finding F5: .picker-tile:focus-visible's own outline (above) and
   a plain `outline` here have identical specificity, so whichever rule
   comes later in source wins regardless of focus state — a suggested tile
   that receives keyboard focus would show only the dashed suggestion ring
   with no distinct focus indicator. box-shadow is a different property
   entirely, so the two rules can never fight over the same declaration:
   tabbing onto a suggested tile shows BOTH the dashed suggestion ring and
   :focus-visible's own outline. */
.builtin-icon-tile.builtin-icon-suggested { box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px var(--accent); border-style: dashed; }

/* Item color swatch picker (ut-docs#1901): same .picker-grid/.picker-tile
   pattern and aria-pressed convention as the built-in icon grid just
   above, one small dot + translated name per tile rather than an image.
   --swatch (set inline per tile, catalog.html) feeds .swatch-dot's
   background — the one place a raw hex from catalogtypes.ItemColors()
   reaches CSS on the PICKER side; the tile's own selected/pressed state
   still comes from .picker-tile[aria-pressed="true"], unchanged. */
.item-color-grid { grid-template-columns: repeat(auto-fill, minmax(6rem, 1fr)); }
.item-color-tile { flex-direction: column; gap: .3rem; min-block-size: 3.6rem; padding: .5rem .4rem; font-size: .78rem; font-weight: 500; }
.swatch-dot { display: block; inline-size: 1.15rem; block-size: 1.15rem; border-radius: 50%;
              background: var(--swatch, transparent); border: 1px solid rgba(15, 23, 42, .18); }
/* Category editor pickers (ut-docs#2284): the colour grid reuses the item
   swatch picker above unchanged; these only space the dialog's three
   sections (the base .field-checks rows wrap since ut-docs#3297).
   .category-swatch is the same dot drawn inline beside a list
   row's name, so the list shows the colour without opening the row.
   Logical properties only — the dialog renders RTL for fa/ar/he. */
.category-dialog-section { margin-block-start: .9rem; }
.category-dialog-label { font-size: .82rem; margin-block-end: .25rem; }
.category-dialog-help { font-size: .82rem; margin-block: 0 .4rem; }
/* ut-docs#2500: the category dialog's image picker — the current uploaded
   photo's preview line, and breathing room between the upload buttons and
   the built-in icon grid. [hidden] must win over the flex display. */
.category-image-current { display: flex; align-items: center; gap: .5rem; margin-block-end: .4rem; }
.category-image-current[hidden] { display: none; }
.category-image-current img { inline-size: 2.5rem; block-size: 2.5rem; object-fit: contain; border-radius: var(--radius); }
#category-icon-grid { margin-block-start: .4rem; }
/* Item editor: inherited-group rows in the nested customization dialog and
   the kitchen-routing section under the Variants tab (ut-docs#2284). The
   inherited row reuses .modifier-admin-group-row's flex layout, with the
   name given the flex share a text input would otherwise take. */
.modifier-inherited-name { flex: 1 1 10rem; min-inline-size: 8rem; font-weight: 600; }
.modifier-inherited-options { flex: 2 1 12rem; font-size: .85rem; }
.catalog-inherited-help, .catalog-routing-help, .catalog-routing-inherited { font-size: .85rem; margin-block: .2rem; }
.catalog-detail-routing { margin-block-start: 1rem; }
.catalog-routing-form { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem 1rem; }
.catalog-routing-form .field-checks { margin: 0; }
.category-swatch { display: inline-block; vertical-align: middle; inline-size: .85rem; block-size: .85rem; margin-inline-end: .15rem; }
/* Catalog card grid (ut-docs#1951): the empty-state placeholder lives
   inside the same .grid as the cards (so a beforeend OOB insert has one
   stable parent whether the catalog is empty or not — see
   catalog_row.html) and must span every column, not sit as a single
   card-width cell. */
.catalog-empty { grid-column: 1 / -1; }
/* Independent-review fix: unlike the sale-screen's product tiles
   (buttons.html), a catalog card can carry a Barcodes and/or Variants
   caption (.muted) between the name and the price, so its content height
   varies card to card. Without this, .tile-price sits directly under
   whatever text came before it, so a card with a caption shows its price
   lower than a caption-less neighbour in the same grid row — visible in
   the shipped screenshot's second row. margin-block-start:auto on a
   .btn-tile flex child (column direction) pushes it, and it alone, to the
   bottom, so every card's price lines up regardless of caption length.
   Scoped to .catalog-row only — the sale-screen grid has no such
   variable-height content and doesn't need this. */
.catalog-row .tile-price { margin-block-start: auto; }

/* Bottom status bar: always-visible version + live connectivity. Bookends the
   dark top nav; part of the body flex column so it sits at the bottom. */
.statusbar {
  flex: 0 0 auto;
  /* Pinned to the bottom of the screen: flex-grow on main handles short
     pages, sticky keeps it visible when a page scrolls. */
  position: sticky; bottom: 0; z-index: 90;
  display: flex; align-items: center; gap: 1rem;
  /* ut-docs#3050: an upright tablet (600px) with the Update chip showing
     overflowed to 712px and scrolled the whole page sideways. Wrap to a
     second row instead; at 1024px and up everything still fits one row. */
  flex-wrap: wrap; row-gap: .2rem;
  padding: .28rem 1rem; font-size: .78rem;
  background: #0f172a; color: #94a3b8;
  border-top: 1px solid rgba(255,255,255,.08);
}
.statusbar .sb-item { display: inline-flex; align-items: center; gap: .45rem; white-space: nowrap; }
.statusbar .sb-ver { margin-inline-start: auto; font-variant-numeric: tabular-nums; }
.sb-dot { width: .6rem; height: .6rem; border-radius: 50%; background: #22c55e;
  box-shadow: 0 0 0 3px rgba(34,197,94,.15); }
.sb-conn.is-offline { color: #fca5a5; }
.sb-conn.is-offline .sb-dot { background: #ef4444; box-shadow: 0 0 0 3px rgba(239,68,68,.15); }

/* "Update available" chip in the status bar (shown only when a newer release
   is detected — best-effort, offline-tolerant). */
.statusbar .sb-update {
  color: #052e16; background: #4ade80; text-decoration: none;
  padding: .05rem .5rem; border-radius: 999px; font-weight: 700;
  margin-inline-start: .5rem;
}
/* Only the actionable pill (the <a>/<button> variants) hovers — the plain
   <span> fallback shown when no self-update path is available (ut-docs#159,
   a unix kiosk with no browser to send a link to) isn't clickable, and
   shouldn't invite a click it can't act on. */
.statusbar :is(a, button).sb-update:hover { background: #22c55e; }
/* ut-docs#2733: the stuck-auto-update chip is a <span> whose remedy text is
   the link (arrow-up already leads to /plugins) — keep the chip's own dark
   ink instead of the status bar's link colour (green on green). */
.statusbar .sb-update a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
.statusbar .sb-update a:hover { color: #000; }
/* Its link IS the remedy, so it may wrap rather than clip at phone width
   (.sb-item is nowrap); min-width: 0 lets the flex item shrink to wrap. */
.statusbar .sb-update.is-stuck { white-space: normal; min-width: 0; border-radius: .75rem; }
/* Quiet hint shown until the till is connected to the marketplace —
   registration is OPTIONAL (ADR-0015), so no alarm colors, no urgency. */
.statusbar .sb-enrol {
  color: var(--muted, #6b7280); background: transparent;
  border: 1px solid var(--border, #d1d5db); text-decoration: none;
  padding: .05rem .5rem; border-radius: 999px; font-weight: 500;
  margin-inline-start: .5rem;
}
.statusbar .sb-enrol:hover { color: var(--ink, #111827); border-color: var(--muted, #6b7280); }
/* Underpowered-PSU chip (ut-docs#1232) — reuses the shared --warning token
   (already used for `.tag.warn`/`.row-warn-icon` elsewhere) rather than
   inventing a second warning color. Persistent like sb-update, not a toast:
   it stays until the till is rebooted on a proper supply. Dark ink on the
   amber fill (not light, unlike sb-update's dark-on-green) — #fff7ed on
   #d97706 measured ~3:1, failing WCAG AA at this chip's ~12.5px bold text
   (needs 4.5:1, not the 3:1 "large text" bar); #451a03 measures ~4.7:1
   (independent review finding). A real <a> (see markup), so it hovers like
   sb-enrol/sb-update rather than sitting inert. */
.statusbar .sb-power {
  color: #451a03; background: var(--warning, #d97706);
  text-decoration: none;
  padding: .05rem .5rem; border-radius: 999px; font-weight: 700;
  margin-inline-start: .5rem;
}
.statusbar a.sb-power:hover { filter: brightness(0.92); }
/* Main till not reachable (ut-docs#2722) — the same amber warning pill as
   sb-power: the till still sells, but sync and plugin updates wait. Allowed
   to wrap (unlike other .sb-item) because the sentence carries a date and
   the longest locales would otherwise push the bar wider than a tablet. */
.statusbar .sb-main-till {
  color: #451a03; background: var(--warning, #d97706);
  text-decoration: none; white-space: normal;
  padding: .05rem .5rem; border-radius: 999px; font-weight: 700;
  margin-inline-start: .5rem;
}
.statusbar a.sb-main-till:hover { filter: brightness(0.92); }
#sb-main-till-mount:empty { display: none; }
/* ut-docs#2569 (ADR-0060 §6): "Subscription not confirmed" / "Subscription
   ended" — the same amber pill as sb-main-till (the till still sells; only
   paid cloud features are paused). Unlike sb-main-till it never wraps: on a
   1024x600 kiosk bar it shares the row with the connection light and the
   update/main-till chips, so the text ellipsizes instead (the full label
   stays the accessible name, the reason is on the Settings card). Logical
   properties only. */
#sb-subscription-mount:empty { display: none; }
#sb-subscription-mount { min-inline-size: 0; display: inline-flex; }
.statusbar .sb-subscription {
  color: #451a03; background: var(--warning, #d97706);
  text-decoration: none; white-space: nowrap; min-inline-size: 0;
  max-inline-size: 16rem;
  padding: .05rem .5rem; min-block-size: 2.25rem; align-items: center; border-radius: 999px; font-weight: 700;
  margin-inline-start: .5rem;
}
.statusbar .sb-subscription-text { overflow: hidden; text-overflow: ellipsis; min-inline-size: 0; }
.statusbar a.sb-subscription:hover { filter: brightness(0.92); }
.subscription-paused-list { margin-block: .3rem; padding-inline-start: 1.25rem; }
.subscription-banner p { margin-block: .25rem; }
/* ADR-0116 D6 (ut-docs#3524): the cloud refused this till's credential 3
   times in a row (cloud_auth_chip.html). Same amber pill as sb-main-till:
   the till still sells offline, but the cloud waits for pairing. Wraps for
   the same reason (a sentence, long in some locales). */
.statusbar .sb-cloud-auth {
  color: #451a03; background: var(--warning, #d97706);
  text-decoration: none; white-space: normal;
  padding: .05rem .5rem; border-radius: 999px; font-weight: 700;
  margin-inline-start: .5rem;
}
.statusbar a.sb-cloud-auth[href]:hover { filter: brightness(0.92); }
#sb-cloud-auth-mount:empty { display: none; }
/* ut-docs#574 (ADR-0147): the cloud refused the archived-report upload
   (report_archive_chip.html). Same amber pill as sb-cloud-auth: the till
   still sells and keeps every report; the upload waits for an active
   subscription. Wraps for the same reason (a sentence in some locales). */
.statusbar .sb-report-archive {
  color: #451a03; background: var(--warning, #d97706);
  text-decoration: none; white-space: normal;
  padding: .05rem .5rem; border-radius: 999px; font-weight: 700;
  margin-inline-start: .5rem;
}
.statusbar a.sb-report-archive[href]:hover { filter: brightness(0.92); }
#sb-report-archive-mount:empty { display: none; }
/* ut-docs#2742: the replica's one main-till light (main_till_status.html).
   Words carry the state; the dot only repeats it — filled green while
   linked, a hollow ring while polling (sync works, just not instantly).
   The unreachable state keeps sb-main-till's amber pill above. A real
   link to /tills, so it gets a comfortable hit area on a touchscreen. */
.statusbar .sb-link { text-decoration: none; min-block-size: 1.75rem; }
.statusbar a.sb-link:hover .sb-link-text { text-decoration: underline; }
.statusbar .sb-link.is-linked { color: #bbf7d0; }
.statusbar .sb-link.is-polling { color: #cbd5e1; }
.statusbar .sb-link.is-polling .sb-dot {
  background: transparent; box-shadow: none; border: 2px solid #94a3b8;
  box-sizing: border-box;
}
/* A till with a main till: the browser's "Online" light would only say a
   network interface is up — hidden next to the chip, shown only as
   "No internet" when the network is really down. */
.statusbar.has-link .sb-conn:not(.is-offline) { display: none; }
/* Installed-plugin update chip (ut-docs#1953) — same green "there's
   something new" pill as sb-update above (not sb-power's amber: a pending
   plugin update is a notice, not a hardware-degrading warning), just its
   own class so it can be targeted/tested independently. */
.statusbar .sb-plugin-update {
  color: #052e16; background: #4ade80; text-decoration: none;
  padding: .05rem .5rem; border-radius: 999px; font-weight: 700;
  margin-inline-start: .5rem;
}
.statusbar a.sb-plugin-update:hover { background: #22c55e; }
/* Language-pack-specific update chip (ut-docs#2299) — same green pill as
   sb-plugin-update above; a separate class only so it can render alongside
   it (a joined till can have both a pending language pack AND some other
   pending plugin type at once) and be targeted/tested independently. */
.statusbar .sb-language-pack-update {
  color: #052e16; background: #4ade80; text-decoration: none;
  padding: .05rem .5rem; border-radius: 999px; font-weight: 700;
  margin-inline-start: .5rem;
}
.statusbar a.sb-language-pack-update:hover { background: #22c55e; }
/* After-update "what's new" chip (ut-docs#3091) — same green notice pill as
   sb-update; the link and its × dismiss sit inside one pill. */
.statusbar .sb-release-notes {
  color: #052e16; background: #4ade80;
  padding-block: .05rem; padding-inline: .5rem .25rem; border-radius: 999px; font-weight: 700;
  margin-inline-start: .5rem; gap: .25rem;
}
.statusbar .sb-release-notes a { color: inherit; text-decoration: none; display: inline-flex; align-items: center; gap: .45rem; }
.statusbar .sb-release-notes a:hover { text-decoration: underline; }
.statusbar .sb-release-notes-dismiss {
  display: inline-flex; align-items: center; justify-content: center;
  min-inline-size: 2.25rem; min-block-size: 2.25rem; padding: 0;
  border: 0; border-radius: 999px; background: transparent; color: inherit; cursor: pointer;
}
.statusbar .sb-release-notes-dismiss:hover { background: #22c55e; }
/* Settings → About's release notes (ut-docs#3091). */
.release-note { border-block-start: 1px solid var(--border, #d1d5db); padding-block: .5rem; }
.release-note h4 { margin-block: .25rem; }
.release-note h5 { margin-block: .5rem .25rem; font-size: .95rem; }
.release-note ul { margin-block: .25rem; padding-inline-start: 1.25rem; }
/* ut-docs#413: confirmed live at 360px — `.statusbar`'s row (conn chip +
   the optional enrol/update pills + the version, none of it wrapping)
   needs more width than a phone viewport has. Wrapping the bar itself
   handles most of that; the update pill's own text is also the longest,
   most locale-length-sensitive string on the bar ("Update available
   v0.2.65 — In-app update isn't available here" reported truncated
   mid-word) and the enrol pill has the same shape, so both get a deliberate
   ellipsis cap too rather than relying on wrap alone for an outlier string
   longer than one full row.
   Placed AFTER .sb-update/.sb-enrol's own base rules above (independent
   review, 2026-08-07) — not just after the tender/pos-container blocks
   further up this file, like every other phone-tier block here. Equal
   specificity to those base rules means source order decides the winner;
   this only worked when it sat earlier because neither base rule declared
   `overflow`/`text-overflow`/`max-inline-size` yet — a future edit adding
   any of those to the base rule would have silently overridden this block
   with nothing to warn about it. */
@media (max-width: 480px) {
  .statusbar { flex-wrap: wrap; row-gap: .2rem; }
  .statusbar .sb-update,
  .statusbar .sb-enrol,
  .statusbar .sb-power,
  .statusbar .sb-link,
  .statusbar .sb-plugin-update,
  /* ut-docs#2299: sb-language-pack-update's own text ("Language pack
     update available") is longer than sb-plugin-update's, and its
     ar/fa/tr translations are comparable in length — same overflow risk
     this block already exists to cap, so it gets the same treatment. */
  .statusbar .sb-language-pack-update {
    overflow: hidden; text-overflow: ellipsis; max-inline-size: 70vw;
  }
  /* ut-docs#3091: the what's-new pill holds a link AND its × — clipping the
     pill itself cut the × off at 360px (tester, 2026-09-28), leaving no way
     to dismiss. Cap the pill, ellipsise only the link's text, keep the ×. */
  .statusbar .sb-release-notes { max-inline-size: 70vw; min-inline-size: 0; }
  .statusbar .sb-release-notes a { min-inline-size: 0; flex: 0 1 auto; }
  .statusbar .sb-release-notes-text { overflow: hidden; text-overflow: ellipsis; min-inline-size: 0; }
  .statusbar .sb-release-notes-dismiss { flex: none; }
}

/* Claim-by-phone QR under the claim code (kiosk/desktop shells can't open a browser) */
.claim-qr { margin-top: .6rem; }
/* ut-docs#2176 (dark-theme audit): #fff here is deliberate, not a theme gap
   — a QR code needs a plain white quiet zone to stay reliably scannable
   regardless of theme, so this one intentionally does NOT follow --surface. */
.claim-qr img { border-radius: .5rem; background: #fff; padding: .4rem; border: 1px solid var(--border); }

/* Busiest days/hours bars on the reports page */
.busy-table td { padding-block: .25rem; }
.busy-bar { block-size: .9rem; min-inline-size: 2px; border-radius: 4px;
  background: linear-gradient(90deg, var(--accent, #2563eb), #60a5fa); }

/* Plugin trust badges (ADR-0006 surface): official = gold, verified = green,
   unverified = amber warning. */
.trust-badge { font-size: .72rem; font-weight: 700; border-radius: 999px;
  padding: .12rem .55rem; white-space: nowrap; }
.trust-official { background: linear-gradient(135deg, #fbbf24, #f59e0b); color: #451a03; }
.trust-verified { background: #dcfce7; color: #166534; }
.trust-unverified { background: #fef3c7; color: #92400e; }

/* Paid-listing badge (ut-docs#673): flags a plugin that needs an entitlement
   before it can be installed. Boolean only, no price shown — the catalog
   data model doesn't carry an amount yet (ut-docs#700). */
.paid-badge { font-size: .72rem; font-weight: 700; border-radius: 999px;
  padding: .12rem .55rem; white-space: nowrap; background: #ede9fe; color: #5b21b6; }

/* Sample-data badge on catalogue rows (ut-docs#539): marks items inserted by
   the opt-in demo seed so a shop can tell them from its own products. */
.sample-badge { font-size: .68rem; font-weight: 700; border-radius: 999px;
  padding: .12rem .5rem; white-space: nowrap; background: #e0e7ff; color: #3730a3;
  margin-inline-start: .35rem; vertical-align: middle; }

/* Reorder suggestion beside the days-left warning */
.order-hint { display:inline-block; margin-inline-start:.45rem; font-size:.78rem;
  background:#eff6ff; color:#1d4ed8; border-radius:999px; padding:.1rem .5rem; font-weight:600; }


/* Variant image cell: thumb + hidden-file camera upload */
.vg-img { display: flex; align-items: center; gap: .25rem; }
/* ut-docs#2176 (dark-theme audit): was a hardcoded #fff — a stranded white
   square around every variant thumbnail once a dark theme is selected.
   var(--surface-2) is the same token .thumb (catalog tiles' own photo
   backdrop, just above in this file) already uses for the identical job. */
.vg-img img, .vg-img .thumb-ph { inline-size: 2.2rem; block-size: 2.2rem; object-fit: cover; border-radius: .4rem; background: var(--surface-2); }
.vg-img-upload label { cursor: pointer; opacity: .6; }
.vg-img-upload label:hover { opacity: 1; }
.vg-img-upload input[type="file"] { display: none; }

/* Checkout fee hints (manual least-cost routing) */
.pay-btn .fee-hint { display:block; font-size:.72rem; font-weight:500; opacity:.75; }

/* Settings page: responsive card grid + aligned form rows (field feedback:
   "pages like settings are so unaligned and messy"). */
/* Masonry-style packing: CSS columns let short cards stack tightly instead
   of every grid row growing to its tallest card (field feedback: "random
   spaces between boxes"). */
.settings-grid { columns: 24rem; column-gap: 1rem; }
.settings-grid .card { margin: 0 0 1rem; min-inline-size: 0; overflow: hidden;
                       break-inside: avoid; }
/* Wide tables (backups list) scroll INSIDE their card, never past its edge. */
.settings-grid .card .table { display: block; overflow-x: auto; }
/* Cards whose content is inherently wide (backup file table, the raw
   settings key/value table) span the full grid width instead of
   overflowing their single-column card — column-span is the multi-column-
   native way to do this; switching .settings-grid itself to CSS Grid would
   undo the masonry packing every other card relies on. */
.settings-grid .card.settings-wide { column-span: all; }

/* ut-docs#1960: Settings as a two-pane master-detail — the section list (+ a
   search box over individual settings) on the inline-start side, the selected
   section's card on the other. Same grid technique as .manual above (help):
   grid-template-columns with logical properties only, so RTL locales (fa/ar)
   mirror the whole shell with zero extra rules. The cards themselves are
   untouched server-rendered .card blocks; settings.html's own script shows
   one and adds .settings-section-hidden to the rest. */
.settings-shell { display: grid; grid-template-columns: minmax(14rem, 18rem) minmax(0, 1fr);
                  gap: 1.2rem; align-items: start; }
.settings-nav { position: sticky; inset-block-start: .5rem; max-block-size: calc(100vh - 5rem);
                overflow-y: auto; padding-inline-end: .4rem; }
.settings-search { margin-block-end: .6rem; }
.settings-search input { inline-size: 100%; padding: .55rem .7rem; font-size: 1rem;
                         min-block-size: 44px; border: 1px solid var(--control-border);
                         border-radius: var(--radius); background: var(--surface); color: inherit; }
.settings-tree { list-style: none; margin: 0; padding: 0; }
.settings-tree li { margin-block-end: .15rem; }
/* ADR-0088, ut-docs#1913: a layout plugin's settings-sidebar group heading —
   same role as .menu-group, restated for a <ul> sidebar instead of a grid. */
.settings-tree-group { margin: .6rem 0 .15rem; padding-inline: .6rem;
                       font-size: .8rem; font-weight: 600; text-transform: uppercase;
                       letter-spacing: .02em; color: var(--muted); text-align: start; }
.settings-tree-group:first-child { margin-block-start: 0; }
/* Same shape as .manual-link: a full-width, touch-floor-height row. */
.settings-tree-link { display: block; padding: .5rem .6rem; border-radius: .45rem;
                      text-decoration: none; color: inherit; line-height: 1.35;
                      min-block-size: 44px; }
.settings-tree-link:hover, .settings-tree-link:focus-visible { background: var(--surface-2); }
.settings-tree-link.is-current { background: var(--accent); color: var(--accent-contrast);
                                 font-weight: 600; }
/* A search hit: the control's own label, then the section it lives in. */
.settings-hit-text, .settings-hit-section { display: block; }
.settings-hit-section { font-size: .8rem; color: var(--muted); margin-block-start: .1rem; }
.settings-tree-link.is-current .settings-hit-section { color: inherit; opacity: .85; }
.settings-noresults { margin: 0; padding: .5rem .6rem; }
.settings-panel { min-inline-size: 0; }
/* One card at a time: the masonry columns .settings-grid uses for the
   all-at-once layout would squeeze the lone visible card into a 24rem
   column — inside the shell it flows as a single column instead. A narrow
   card stays a comfortable reading width (same 48rem cap as .manual-topic);
   the inherently wide ones (backup table, raw settings table) keep the
   full panel. */
.settings-shell .settings-grid { columns: auto; }
.settings-shell .settings-grid .card { max-inline-size: 48rem; }
.settings-shell .settings-grid .card.settings-wide { max-inline-size: none; }
.settings-section-hidden { display: none; }
/* The matched control after picking a search result — a brief ring in the
   accent colour so the eye lands on it. settings.html's revealHit() removes
   this class after ~1.8s (a timed removal, not a CSS-driven fade — outline
   isn't a smoothly animatable shorthand, so it disappears outright). */
.settings-hit-target { outline: 3px solid var(--accent); outline-offset: 3px;
                       border-radius: .3rem; }
/* Phone width (the same 40rem breakpoint .settings-grid collapses at, below):
   list-first. Without a selection only the section list shows; a tap (or a
   real deep link, which settings.html's script treats as a selection) swaps
   in the panel with a back control. Above this width .has-selection has no
   effect at all — both panes are always side by side. */
.settings-back { display: none; margin-block-end: .8rem; }
/* ut-docs#3090: the landing grid of category tiles, and the sidebar header
   (a way back to it + the open category's name). The shell's own
   display:grid would beat the UA [hidden] rule, hence the explicit guard. */
.settings-shell[hidden] { display: none; }
.settings-home-search { max-inline-size: 32rem; }
.settings-cat-grid { margin-top: .6rem; }
.settings-cat-tile { min-height: 150px; gap: .45rem; }
.settings-cat-desc { font-size: .9rem; color: var(--muted); line-height: 1.35; }
.settings-home-link { display: flex; align-items: center; min-block-size: 44px;
                      padding-inline: .6rem; margin-block-end: .2rem; border-radius: .45rem;
                      color: inherit; text-decoration: none; font-weight: 600; }
.settings-home-link[hidden] { display: none; }
/* The arrow points back toward the grid in both directions (RTL mirrors). */
.settings-home-link::before { content: "\2190"; margin-inline-end: .4em; }
[dir="rtl"] .settings-home-link::before { content: "\2192"; }
.settings-cat-title:focus { outline: none; }
.settings-cat-title:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
.settings-home-link:hover, .settings-home-link:focus-visible { background: var(--surface-2); }
.settings-cat-title { margin: .2rem 0 .6rem; padding-inline: .6rem; font-size: 1.15rem; text-align: start; }
@media (max-width: 40rem) {
  .settings-shell { grid-template-columns: 1fr; }
  .settings-nav { position: static; max-block-size: none; padding-inline-end: 0; }
  #settings-shell:not(.has-selection) .settings-panel { display: none; }
  #settings-shell.has-selection .settings-nav { display: none; }
  #settings-shell.has-selection .settings-back { display: inline-flex; }
}
.settings-grid h2 { margin-block: 0 .6rem; font-size: 1.05rem; }
.settings-grid form select, .settings-grid form input[type="text"],
.settings-grid form input[type="number"], .settings-grid form input[type="password"] {
  min-block-size: 2.3rem; }
/* Payments card fee rows: a fixed grid so a long provider name (e.g.
   "Card (Stripe)") never wraps the percent/fixed inputs onto a second
   line — it ellipsizes instead, which is the correct tradeoff at this
   card's normal column width. Independent review (2026-08-01) caught two
   real bugs in the first draft: the number-input track was too narrow for
   its own field's max value (100.00%) at ANY viewport/UI-scale, since the
   rem-based width and rem-based font scale together — and the fixed track
   widths clipped the Save button under .card's overflow:hidden on narrow
   viewports with no way to reach it. Fixed here: wider input track + the
   same reduced-padding treatment .vg-row already uses for number inputs
   (app.css, variants grid), and an overflow-x:auto escape hatch on the row
   itself — same pattern this file already uses for wide tables inside a
   card (.settings-grid .card .table, just above). */
.fee-row {
  display: grid;
  grid-template-columns: minmax(6rem, 1fr) 4.8rem auto 4.8rem auto;
  align-items: center;
  gap: .4rem;
  margin-block-end: .4rem;
  overflow-x: auto;
}
.fee-row .fee-name {
  min-inline-size: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.fee-row input[type="number"], .fee-row input[type="text"] {
  /* ut-docs#1275: percent/fixed switched from type="number" to type="text"
     (osk.js decimal-corruption fix) -- without type="text" here too, this
     rule silently stopped matching them and the 2026-08-01 review's own
     fix (the comment above) regressed: the safety margin against clipping
     at "100.00" shrank ~60% at ui_scale 1, invisible to make docs-shots
     since /settings has no docs-shot. */
  inline-size: 4.8rem; min-inline-size: 4.8rem;
  padding: .35rem .4rem;
}
.fee-row .fee-msg { grid-column: 1 / -1; }
/* `flex-direction: row` is explicit and REQUIRED, not redundant (ut-docs#300):
   three of these are `<label class="set-row">` wrapping a <select>, which the
   global stacking rule above matches. Without an explicit direction here that
   rule's `flex-direction: column` wins by default — the cascade is per-property
   — and `align-items: center` then centres the label and its select on separate
   lines, turning each one-line Settings row into a 134px centred stack. */
.set-row { display: flex; flex-direction: row; gap: .5rem; align-items: center; flex-wrap: wrap; }
/* ut-docs#2859: a select sized to a long option (the effects level's
   "Light: no animations, flat look (fastest)") must never outgrow its card
   at 360px — it shrinks and the native control truncates the label. */
.set-row select { max-inline-size: 100%; min-inline-size: 0; }
.set-tight { margin-block-start: .35rem; }
.set-below { margin-block-end: .6rem; }
.set-gap { margin-block-start: .8rem; }
.set-gap-s { margin-block-start: .45rem; }
.set-divider { margin-block-start: .8rem; border-block-start: 1px solid var(--border);
               padding-block-start: .6rem; }
/* ut-docs#2981: a Settings region that writes a shop-wide key sits in a
   set-lock fieldset — no frame of its own; `disabled` while this additional
   till cannot reach its main till, with the note on top (warning tint, text
   in var(--text) for contrast, as .chip-warn). */
/* display: contents: a fieldset is its own block formatting context, so
   the margins around it stopped collapsing and some cards grew 6-11px on a
   main till (#2981 Tester); disabled still reaches its controls. */
.set-lock { display: contents; border: 0; margin: 0; padding: 0; min-inline-size: 0; }
.set-lock-note { margin-block: .4rem; padding-block: .4rem; padding-inline: .6rem;
                 background: var(--warning-tint); color: var(--text);
                 border: 1px solid var(--warning-tint-border); border-radius: 6px; }

/* ut-docs#1840: the "Remove sample data" kept-items list (Settings → Data)
   — one row per item RemoveDemoCatalogue couldn't remove, with its real
   reason and (for an edited item) inline resolution buttons. Logical
   properties throughout so it lays out correctly in the RTL locales
   (fa/ar) with no extra rule. */
.demo-kept-list { list-style: none; margin: .4rem 0 0; padding: 0; display: flex;
                  flex-direction: column; gap: .5rem; }
.demo-kept-item { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem;
                  padding-block: .3rem; border-block-end: 1px solid var(--border); }
.demo-kept-item:last-child { border-block-end: none; }

/* Self-order kiosk exit (ADR-0020, ut-docs#208): a discreet PIN-gated way
   back to till settings, reachable from every kiosk screen — the start
   screen, the browse/cart screen (in the header's own flex row, never
   fixed-over-content: an earlier fixed-corner version sat directly on top
   of the Back button, a real independent-review finding), and inside each
   of the three modals self_order_shop.html swaps into #selforder-modal
   (modifier picker, payment picker, confirmation) — a native <dialog> in
   its open (top-layer) state makes everything OUTSIDE it inert, so a
   corner control living only in the page body is unreachable during
   checkout; each dialog partial gets its own copy instead. Plain <a
   href="/login?next=kiosk">, not htmx — a real full-page navigation reuses
   the existing PIN login flow as-is rather than a new in-kiosk auth check;
   invalid PIN rejection and session creation are that flow's own job.
   Sized to this repo's own touch-target floor (.btn's 3rem), same as every
   other kiosk control (app.css "kiosk must never be SMALLER than
   non-kiosk" rule) — discreet via muted color/opacity, never via a
   sub-floor hit target. */
.selforder-exit { display: inline-flex; align-items: center; justify-content: center;
  min-inline-size: 3rem; min-block-size: 3rem; border-radius: 999px; background: var(--surface, #fff);
  border: 1px solid var(--border, #d1d5db); color: var(--muted, #6b7280);
  font-size: 1.1rem; text-decoration: none; opacity: .55; flex: none; }
.selforder-exit:hover, .selforder-exit:focus-visible { opacity: 1; }
/* Only the plain start screen (no header, nothing else near a corner) uses
   a fixed corner placement — inset-inline-start/inset-block-start (never
   left/top) so it stays correct under RTL locales. */
.selforder-screen .selforder-exit { position: fixed; inset-block-start: .75rem;
  inset-inline-start: .75rem; z-index: 10; }
@media (max-width: 40rem) { .settings-grid { columns: 1; } }

/* ---------- Bug-report panel (ut-docs#346) ---------- */
/* NON-modal floating capture panel: no backdrop element exists, no focus
   trap, no inert — the page underneath keeps every click/key/scroll it
   gets today. Anchored just below the sticky nav at the inline END so it
   overlays the sale screen's products region only: the basket is the full-
   height start column and the tender sits at the bottom of the end column,
   and the bounded max-block-size stops the panel ever growing down into
   the tender on short viewports. Logical properties only (inset-inline-end,
   not right) so RTL locales mirror it with no extra CSS. z-index below the
   nav's 100 keeps the nav chips clickable above it. */
.bugreport-panel {
  display: none;
  position: fixed;
  /* 2026-08-30 (nav rail, ut-docs#1332): was 4.6rem unconditionally, tuned
     to sit just below the old horizontal top bar. The rail no longer
     occupies any top space by default, so 1rem matches the panel's own
     1rem inline inset instead of leaving a dead gap; the <=480px block
     below restores the old 4.6rem for the top-bar fallback, which still
     needs the clearance. */
  inset-block-start: 1rem;
  inset-inline-end: 1rem;
  inline-size: min(26rem, calc(100vw - 2rem));
  /* The sale screen's tender panel is content-sized and bottom-anchored in
     the end column (~25-30rem tall at 1x scale on short viewports), so a
     plain "under 100vh" cap still landed the panel on top of the barcode
     input and pay buttons at 1280x720 (driven by
     e2e/tests/bugreport-panel.spec.ts). Reserve a 32rem band above the
     viewport bottom instead: on short screens the panel shrinks (its own
     overflow-y takes over), on tall ones 55vh wins. The 12rem floor keeps
     it usable on extreme viewports at the cost of some overlap there. */
  max-block-size: max(12rem, min(55vh, calc(100vh - 33rem)));
  overflow-y: auto;
  z-index: 90;
  background: var(--surface, #fff);
  border: 1px solid var(--border, #e2e8f0);
  border-radius: .6rem;
  box-shadow: 0 8px 28px rgba(0,0,0,.28);
  padding: .85rem;
  text-align: start;
}
.bugreport-panel.open { display: block; }
/* ut-docs#395: draggable so the operator can shift the panel off whatever
   it's covering. touch-action: none stops the kiosk touchscreen's own
   scroll/pan gesture from competing with the drag; user-select: none stops
   the title text highlighting mid-drag. The ✕ opts back into normal touch/
   selection behaviour — it's a tap target, not part of the drag surface. */
.bugreport-head { display: flex; align-items: center; justify-content: space-between; gap: .5rem;
  cursor: move; touch-action: none; user-select: none; }
.bugreport-head h2 { margin: 0; font-size: 1rem; flex: 1 1 auto; }
/* ut-docs#2342: Discard sits in the head next to ✕ — small, secondary, and
   it must not steal the drag handle's width from the title on the 26rem
   panel ("Verwerfen"/"Descartar" are the long ones). */
.bugreport-head .bugreport-discard { flex: 0 0 auto; padding-block: .25rem; padding-inline: .6rem; font-size: .85rem; min-block-size: 2.1rem; }
.bugreport-close { background: none; border: 0; cursor: pointer; font-size: 1.1rem;
  color: var(--muted, #64748b); padding: .25rem .5rem; border-radius: .4rem;
  touch-action: auto; user-select: auto; }
.bugreport-close:hover, .bugreport-close:focus-visible { background: var(--surface-2, #f8fafc); color: inherit; }
.bugreport-nav-note { font-weight: 600; }
/* Send row: the note field and its Send button share one line. Measured,
   not guessed (independent review, 2026-08-06) — clearing both the basket
   column and the tender panel leaves ~13rem of band on a 1280x720 /
   1024x600 till, and stacking these two blocks put Send below the panel's
   own scroll line on every till-sized viewport. */
.bugreport-send { display: flex; align-items: flex-start; gap: .6rem; }
.bugreport-send textarea { flex: 1 1 auto; min-inline-size: 0; }
.bugreport-send .btn { flex: 0 0 auto; }
/* The two optional recorders share a row instead of stacking, halving what
   the operator has to scroll past to reach them. Collapses to one column
   where the panel itself is near its narrow floor. */
.bugreport-capture { display: grid; grid-template-columns: 1fr 1fr; gap: .6rem; align-items: start; }
.bugreport-capture p { margin: 0 0 .35rem; }
@media (max-width: 30rem) { .bugreport-capture { grid-template-columns: 1fr; } }
/* Screenshot thumbnails (ut-docs#347): a wrapping strip so any number of
   captures stays inside the panel's own scroll rather than growing the
   fixed-position band. Own full-width row (see the comment on the markup
   above) — not a third .bugreport-capture column — because the panel's
   26rem inline-size leaves no room for a third column's label + button. */
.bugreport-thumbs { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .5rem; }
.bugreport-thumb { position: relative; display: inline-block; inline-size: 4.5rem; block-size: 4.5rem; }
.bugreport-thumb img { inline-size: 100%; block-size: 100%; object-fit: cover; border-radius: .3rem; border: 1px solid var(--border, #e2e8f0); }
.bugreport-thumb-remove { position: absolute; inset-block-start: -.4rem; inset-inline-end: -.4rem;
  inline-size: 1.3rem; block-size: 1.3rem; border-radius: 50%; border: 1px solid var(--border, #e2e8f0);
  background: var(--surface, #fff); color: inherit; cursor: pointer; font-size: .7rem; line-height: 1; padding: 0; }
.bugreport-thumb-remove:hover, .bugreport-thumb-remove:focus-visible { background: var(--surface-2, #f8fafc); }
/* ut-docs#3353, phone tier: a full-screen sheet, not a floating card. The
   product owner, on an iPhone: "bug report doesn't have a mobile design".
   A 26rem card with a drag handle on a 390px screen is a card you can't
   move out of the way, and its head ate the vertical swipe.
   - Edge to edge under the top bar (a fixed 56px row at this tier, so
     --phone-top-h, not the measured --topbar-h whose 12rem default would
     paint a server-opened /report-issue sheet 192px down first), down to
     the bottom edge. This replaces ut-docs#2364's offset rule. z-index is
     BELOW the nav's 60 so ☰ (Lock, exit and status in its drawer) and Back
     stay tappable over it (CLAUDE.md: status/lock/exit reachable on every
     surface); the bar never overlaps it because the sheet starts under it.
   - !important on the insets: a position dragged on a wider layout is
     restored as inline left/top (ut-docs#2342) and must not move the sheet.
   - The head stays pinned while the sheet scrolls, so ✕ and Discard are
     always one tap away; it is no drag handle here (the panel's own
     pointerdown bails out at this width), so it gives touch back to
     scrolling.
   - Every control is a >=44px full-width row; the note gets its own line
     with Send under it; the padding clears the home indicator and the
     page's status row (--phone-sb-h, measured by app.js). On the sale
     screen that row is fixed above the basket bar at z-index 39, under the
     sheet: while the sheet is open it drops to the bottom edge and rises
     above it, so an offline/main-till problem stays visible. */
@media (max-width: 480px) {
  .bugreport-panel {
    inset-block: var(--phone-top-h, 56px) 0 !important;
    inset-inline: 0 !important;
    inline-size: auto; max-block-size: none;
    z-index: 59;
    border: 0; border-radius: 0; box-shadow: none;
    padding: 0 1rem calc(1rem + var(--phone-sb-h, 0px) + env(safe-area-inset-bottom));
    overscroll-behavior: contain;
  }
  .bugreport-head {
    position: sticky; inset-block-start: 0; z-index: 1;
    margin-inline: -1rem; padding-block: .5rem; padding-inline: 1rem .5rem;
    background: var(--surface, #fff); border-block-end: 1px solid var(--border, #e2e8f0);
    cursor: auto; touch-action: auto; user-select: auto;
  }
  .bugreport-head h2 { font-size: 1.1rem; }
  .bugreport-head .bugreport-discard, .bugreport-close { min-block-size: 44px; min-inline-size: 44px; }
  .bugreport-panel > .set-gap-s { margin-block-start: .75rem; }
  .bugreport-send { flex-direction: column; align-items: stretch; }
  .bugreport-send textarea { min-block-size: 6rem; }
  .bugreport-panel .btn:not(.bugreport-discard) { inline-size: 100%; min-block-size: 48px; justify-content: center; }
  .bugreport-thumb { inline-size: 5.5rem; block-size: 5.5rem; }
  .bugreport-thumb-remove { inline-size: 2.75rem; block-size: 2.75rem; font-size: .9rem;
    inset-block-start: -.6rem; inset-inline-end: -.6rem; }
  /* :has() carries an id's specificity, so the drawer-foot (z 61) and
     scrim (z 460) lifts are excluded here rather than out-ranked. */
  html:not(.ut-scrim-on) body.sale-screen:not(.nav-drawer-open):has(#bugreport-panel.open) .statusbar { bottom: 0; z-index: 60; }
  .bugreport-panel a[href="/my-reports"] { display: inline-flex; align-items: center; min-block-size: 44px; }
}

/* ADR-0088 / ut-docs#1904: the "Open" link on the Hidden menu tiles page.
   It sits on its own line under the destination name — inline, it read as
   part of the label ("Tables & floor plan Open"), the two-labels-in-one-row
   defect ut-docs#300 shipped once already. Block + margin-block-start, not
   a float or a left/right margin, so RTL needs no separate rule. */
.menulayout-dest { display: block; }
.menulayout-goto { display: inline-block; margin-block-start: 0.25rem; font-size: 0.875rem; }

/* ==========================================================================
   ut-docs#2010 — app-wide list/edit standard: list header + record dialog.
   Reference: ut-docs/reference/list-and-dialog-pattern.md; reference
   implementation web/ui/pages/categories.html; behaviour
   web/public/record-dialog.js. Generalises the .item-form-modal /
   .catalog-form* rules above (ut-docs#1901/#1956) into page-agnostic class
   names. Those older rules are deliberately left untouched — catalog.html
   adopts these in a follow-up, not here. This is the THEME SEAM: a
   ut-plugin-theme-* re-skins the whole pattern by overriding :root tokens
   and these class names; replacing the structure is an ADR-0088 layout
   plugin's job. Tokens and rem only; logical properties only (RTL is a real
   target). Additive block, appended at the end of the file on purpose.
   ========================================================================== */

/* --- List header: search grows, the icon-only New button keeps its square
   at the end of the row; wraps at phone width. --- */
.list-header { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem .75rem;
               margin-block-end: .75rem; }
.list-header-search { position: relative; flex: 1 1 12rem; min-inline-size: 0;
                      display: flex; align-items: center; }
.list-header-ico { position: absolute; inset-inline-start: .75rem; display: inline-flex;
                   color: var(--muted); pointer-events: none; }
.list-header-ico svg { inline-size: 1.1rem; block-size: 1.1rem; }
.list-header-search input[type="search"] { inline-size: 100%; min-inline-size: 0;
                                           padding-inline-start: 2.4rem; min-block-size: 3rem; }

/* --- Icon-only button (New / Close / Edit / reorder / deactivate): square,
   the same 3rem touch floor as every .btn, the Lucide glyph sized up from
   .btn-ico's 1em, and an explicit focus ring because the accessible name is
   aria-label/title only (WCAG 1.4.11 — --focus-border is >=3:1 against
   --surface in every curated theme, see :root). --- */
.btn-icon { flex: none; min-inline-size: 3rem; padding-inline: .65rem; }
.btn-icon .btn-ico svg { inline-size: 1.3rem; block-size: 1.3rem; }
.btn-icon:focus-visible { outline: 2px solid var(--focus-border, var(--accent)); outline-offset: 2px; }
.btn-icon-danger, .btn-icon-danger:hover { color: var(--danger); }
/* --- The list: a card whose wide table scrolls inside it (same escape
   hatch as .users-list / .settings-grid .card .table) and tap-to-edit rows
   that read as tappable. Attribute selectors, so no page has to carry an
   extra class for the behaviour it already declares. --- */
.list-card { min-inline-size: 0; }
/* The sideways scroll lives on a WRAPPER, not on the table (ut-docs#2010,
   Tester pass on the pilot tablet). `.users-list .table`'s older
   `display: block` trick above turns the <table> into a block box: the box
   obeys `table.table`'s `width: 100%`, but the internal table boxes still
   shrink-to-fit and sit at the start edge, so the rows painted to 495px
   inside a 1155px card and the merchant saw a half-empty card. Keeping the
   element a real table and moving overflow onto .list-scroll fills the card
   and still scrolls sideways when the columns genuinely do not fit (360px).
   Pinned by the (f) case in e2e/tests/categories-record-dialog-2010.spec.ts.
   At ≤480px the table is a card list instead and this stops scrolling
   (ut-docs#3359, the `table.t-cards` block). */
.list-card .list-scroll { overflow-x: auto; }
/* nowrap on purpose: at 360px a wrapping actions cell stacked its three icon
   buttons vertically, tripling the row height and clipping them at the card
   edge (seen in this card's own 360px screenshot). One row, and the table
   scrolls sideways inside the card instead. */
.list-actions { display: flex; gap: .5rem; flex-wrap: nowrap; justify-content: flex-end; }
.list-empty-row td { text-align: center; color: var(--muted); }
[data-record-open] { cursor: pointer; }
[data-record-open]:hover > td { background: var(--surface-2); }

/* --- Record dialog: full-bleed, .show()n (never showModal — the OSK
   reason, see .item-form-modal above), a flex column of exactly two parts:
   a pinned head (ONE row of chrome, nothing more — ut-docs#2000 found
   catalog's taller head eating 42% of a 360px screen) and the ONE scroll
   container, whose first child is the in-dialog message region
   (ut-docs#2020, .record-dialog-msg below) — collapsed (:empty) and
   reserving no space until a refused mutation actually has something to
   say. `position: fixed`
   replaces the top-layer positioning .show() does not give; z-index 500
   stays under #osk's 1000. No ::backdrop rule — a .show() dialog never
   paints one. --- */
.record-dialog { position: fixed; inset: 0; margin: 0; z-index: 500;
                 inline-size: 100%; max-inline-size: none;
                 block-size: 100%; max-block-size: none;
                 border: 0; border-radius: 0; box-shadow: none; padding: 0;
                 display: flex; flex-direction: column; overflow: hidden;
                 background: var(--surface); color: var(--text); }
/* Out-specifies the display:flex above so a closed dialog renders nothing. */
.record-dialog:not([open]) { display: none; }
.record-dialog-inner { flex: 1 1 auto; min-block-size: 0; display: flex; flex-direction: column; }
.record-dialog-head { flex: none; padding: .6rem 1.5rem .5rem;
                      border-block-end: 1px solid var(--border); background: var(--surface); }
/* One row at the 1024×600 kiosk floor; the title group and the action group
   each keep their own children on one line and only the two GROUPS wrap
   relative to each other at phone width. */
.record-dialog-head-row { display: flex; flex-wrap: wrap; align-items: center;
                          justify-content: space-between; gap: .5rem .75rem; }
.record-dialog-head-title { display: flex; align-items: center; gap: .5rem;
                            flex: 1 1 12rem; min-inline-size: 0; }
.record-dialog-head-title h3 { margin: 0; min-inline-size: 0; overflow: hidden;
                               text-overflow: ellipsis; white-space: nowrap; }
.record-dialog-destructive { display: flex; align-items: center; gap: .5rem; }
.record-dialog-destructive form { margin: 0; display: inline-flex; }
/* The display rules above would defeat the hidden attribute's UA rule (same
   trap .btn[hidden] guards against): record-dialog.js hides the whole slot
   in create mode and each data-record-when form that does not match the
   opened row — caught by the e2e spec's first run, not assumed. */
.record-dialog-destructive[hidden], .record-dialog-destructive form[hidden],
.record-dialog [data-record-when][hidden] { display: none; }
.record-dialog-head-actions { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center;
                              margin-inline-start: auto; }
/* ut-docs#2099 (coding-standards.md §10, ut-docs#1999's other half): status/
   lock/exit-to-OS, withheld outright in self-order kiosk mode (see
   record_dialog.html — the whole row is absent then, not just hidden).
   Deliberately its own thin line, NOT full-height .btn/.btn-icon controls
   (those are 3rem/48px touch targets, sized for a primary action) — this
   is a secondary affordance and the ut-docs#2000 head-height budget has no
   room to repeat that size a third time in the same pinned head. Measured
   height cost at 360px is recorded in
   ut-docs/reference/list-and-dialog-pattern.md's "The nav rail is
   covered" section. The connection chip reuses .sb-dot / .is-offline
   as-is (both unscoped in this file already, e.g. via base.html's #sb-conn)
   — only this row's own layout/sizing is new. */
.record-dialog-status-row { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem;
                            margin-block-start: .35rem; padding-block-start: .35rem;
                            border-block-start: 1px solid var(--border);
                            font-size: .78rem; color: var(--muted, #6b7280); }
.record-dialog-conn { display: inline-flex; align-items: center; gap: .4rem;
                      margin-inline-end: auto; white-space: nowrap; }
/* ut-docs#2099 review finding N1: .sb-conn.is-offline's #fca5a5 (app.css
   above) was tuned for the statusbar's own dark #0f172a ground — ~1.9:1
   against this dialog's white --surface, a WCAG 1.4.3 failure on exactly
   the state (offline) an offline-first till most needs legible. Scoped
   override to the existing --danger token (~4.8:1 on white) rather than a
   new literal; .sb-dot's own background/glow is unaffected. */
.record-dialog-status-row .sb-conn.is-offline { color: var(--danger); }
.record-dialog-status-row .btn { min-block-size: 1.9rem; padding: .2rem .5rem; }
.record-dialog-status-row .btn-icon { min-inline-size: 1.9rem; padding-inline: .4rem; }
.record-dialog-status-row .btn-icon .btn-ico svg { inline-size: 1.05rem; block-size: 1.05rem; }
.record-dialog-lock { margin: 0; display: inline-flex; }
.record-dialog-body { flex: 1 1 auto; min-block-size: 0; overflow-y: auto;
                      padding: 1rem 1.5rem 1.5rem; }
/* ut-docs#2020: :empty, not [hidden] — this element is the aria-live
   region itself (record_dialog.html), and its content is swapped via
   hx-swap="innerHTML" so the NODE stays put and only its text changes
   (replacing the node, outerHTML-style, would generally stop assistive
   tech from announcing it). Genuinely empty (record-dialog.js's open()
   clears it with textContent = '', and the template renders it with no
   whitespace) reads as :empty and collapses to nothing; a translated
   message written into it — by the server's own refusal response, or by
   record-dialog.js's dialogFailureFallback for a network/plain-text
   failure the server response never covered — makes it non-empty and
   this rule takes over. */
.record-dialog-msg:empty { display: none; }
.record-dialog-msg { color: var(--danger); font-weight: 600;
                      margin-block: 0 .7rem; max-inline-size: 40rem; }
.record-dialog-body form { display: flex; flex-direction: column; gap: .7rem; max-inline-size: 40rem; }
/* The same ut-docs#1956 zero-minimum fix .catalog-form already carries for
   its own .field-pair inputs (see that rule's comment above), extended to a
   .field-pair form that sits inside a dialog body instead: ut-docs#2011
   moved inventory.html's receive/adjust form in here, and it is not inside
   a .catalog-form, so that rule's selector never reaches it.

   DEFENSIVE ONLY — read this before "fixing" a phantom bug on top of it.
   Corrected during ut-docs#2011's review: this rule changes nothing in the
   markup shipped today, and its removal was measured (360x740, live, full
   e2e till) to leave every box in the dialog byte-identical in geometry.
   Today's fields all wrap their control in a <label>, which ut-docs#300's
   global `label:has(> input)` rule already makes a column flex container --
   and a flex item's automatic minimum size applies in the MAIN axis only,
   so in a column container `min-width: auto` is already 0 and the input
   already shrinks with its grid track. What this rule covers is the case
   that rule does not: a control placed directly in a .field-pair (or in a
   non-label wrapper), where the grid item keeps a min-content minimum and
   a text input's ~20ch intrinsic width would hold the column open past a
   360px dialog's edge. Kept as a guard-rail, not as a live fix; the
   viewport assertion in inventory-stock-dialog-2011.spec.ts measures the
   dialog body's real overflow and would catch that case if it appeared. */
.record-dialog-body .field-pair input,
.record-dialog-body .field-pair select { min-inline-size: 0; }
@media (max-width: 700px) {
  .record-dialog-head { padding-inline: 1rem; }
  .record-dialog-body { padding-inline: 1rem; }
}
/* OSK reservation, same fix as body.osk-padded .item-form-modal above: the
   keyboard is fixed at the bottom of the viewport regardless of this
   dialog's box, so the dialog shortens to stay clear of it. osk.js sets
   --osk-reserved-height from the keyboard's real height (universal-till#1027
   / ut-docs#1998); the fallback is live whenever that measures <= 0
   (pre-layout) and MUST track whatever the other reservations use —
   body.osk-padded, .item-form-modal and .payment-overlay are all 17rem
   since #1027 (15.5rem under-reserved, which is the bug #1998 fixed). */
body.osk-padded .record-dialog { block-size: calc(100% - var(--osk-reserved-height, 17rem)); }

/* ut-docs#2186 (UX review finding 2, refined per review B-comment nit): a
   page script disables every field in a fields form for a row-tap open in
   a genuinely read-only view (today: only fiscal-register -- no update
   endpoint exists, so nothing there is really an active input any more).
   The browser/theme's default `:disabled` styling is a dim, low-contrast
   grey, right for "this control isn't available right now", wrong for "this
   is the TSE serial/certification ID the operator is here to READ for their
   ELSTER filing" -- compliance data, not a control they're not meant to
   notice. Full-contrast text, no input chrome, tokens/rem only.
   `.record-dialog--readonly-fields` (toggled by the page script, not an
   id-selector on one dialog) so this stays a reusable "read-only field
   contrast" rule for whichever screen's own script opts in next, keeping
   the theme/plugin seam this pattern's own reference doc calls for --
   nothing here applies unless a page's own script adds the class, so no
   other record_dialog screen is affected today. */
.record-dialog--readonly-fields .record-dialog-body input:disabled,
.record-dialog--readonly-fields .record-dialog-body select:disabled {
  opacity: 1;
  color: var(--text);
  -webkit-text-fill-color: var(--text);
  background: var(--surface-2);
  border-color: var(--border);
  cursor: default;
}

/* ut-docs#2186 (review finding B6): the list header's search only ever
   toggles each .entry-row's own `hidden` -- right for a flat table, but
   this screen groups rows under a per-location heading + its own inline
   address form, neither of which is a row. Filtering every row inside a
   group out used to leave that group's heading/address-form/table-header
   chrome stranded on screen above the shared "no results" line -- several
   screens' worth of empty chrome at the 360px floor. `:has()` reacts to the
   SAME `hidden` attribute record-dialog.js already sets, live, with no
   extra JS: a group collapses the instant its last visible row is filtered
   out and reappears the instant one matches again. */
.fiscal-register-group:not(:has(.entry-row:not([hidden]))) {
  display: none;
}

/* ==========================================================================
   ut-docs#2119 — category filter chip row, shared by /catalog and
   /inventory (web/ui/partials/category_filter.html, web/public/
   category-filter.js). Existing :root tokens only, logical properties
   only (RTL is a real target — UX gate finding 5: `gap` alone drives the
   spacing, never a per-chip margin-inline-*, so flex-wrap mirrors
   correctly under dir="rtl" for free).

   Deliberately scoped under .category-filter rather than styling the bare
   .chip class: .chip already means something else here (the small,
   monospace, removable-tag pill used by barcode/option-set-value chips
   and the "N running out"/"pending plugin" badges, all far smaller than a
   touch target) — restyling it globally would regress every one of those.
   The filter row's buttons keep the "chip" class name the design calls
   for (and so they still read as chips visually — a rounded pill, not a
   rectangular button), but every rule below is scoped to only apply
   inside .category-filter.

   ut-docs#2167: /country-settings' scope-filter chips (its two "show
   mine"/"show all" chips, web/ui/pages/country_settings.html) need this
   exact same touch-floor/focus-ring/pressed-checkmark treatment, so every
   selector below carries a `.filter-chips` twin instead of duplicating
   the block — `.filter-chips` is the shared/generic name, `.category-filter`
   is kept only because it's still /catalog's and /inventory's own existing
   call site's class. Add any FUTURE chip-row consumer under `.filter-chips`
   directly; there's no need to keep inventing a new `.category-filter`-style
   name per page. */
.category-filter, .filter-chips { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center;
                    margin-block-end: .75rem; }
/* Real 3rem touch floor (same as .btn-icon), content-sized (no fixed
   width/max-width, white-space normal) so a long localized category name
   (German runs 30-50% longer, UX gate finding 2) never truncates —
   instead the row wraps (flex-wrap above), never scrolls. */
.category-filter .chip, .filter-chips .chip { display: inline-flex; align-items: center; gap: .35rem;
                          min-block-size: 3rem; padding-inline: 1rem; padding-block: .3rem;
                          border-radius: var(--radius); border: 1px solid var(--border);
                          background: var(--surface); color: var(--text);
                          font: inherit; font-size: .95rem; line-height: 1.25;
                          white-space: normal; text-align: start;
                          inline-size: auto; max-inline-size: none;
                          cursor: pointer; touch-action: manipulation; }
.category-filter .chip:hover, .filter-chips .chip:hover { background: var(--surface-2); }
/* Explicit focus ring (WCAG 1.4.11) — same treatment as .btn-icon's own,
   since the chip's accessible state (pressed/not) is otherwise carried
   only by aria-pressed + the checkmark below (UX gate finding 7). */
.category-filter .chip:focus-visible, .filter-chips .chip:focus-visible { outline: 2px solid var(--focus-border, var(--accent));
                                        outline-offset: 2px; }
/* Pressed state: colour AND a checkmark, never colour alone (WCAG
   non-colour-alone, UX gate finding 3) — a shop owner must be able to
   tell at a glance that a category filter from an earlier session is
   still narrowing what they see. */
.category-filter .chip[aria-pressed="true"], .filter-chips .chip[aria-pressed="true"] { background: var(--accent);
                                               color: var(--accent-contrast);
                                               border-color: var(--accent); }
.category-filter .chip-check, .filter-chips .chip-check { display: none; flex: none; inline-size: 1rem; block-size: 1rem; }
.category-filter .chip-check svg, .filter-chips .chip-check svg { inline-size: 1rem; block-size: 1rem; }
.category-filter .chip[aria-pressed="true"] .chip-check, .filter-chips .chip[aria-pressed="true"] .chip-check { display: inline-flex; }
/* ut-docs#1976: /reports' date-range chips are links (plain GET navigation,
   no JS), so their "selected" state is aria-current rather than
   aria-pressed — same pressed look, same checkmark (never colour alone). */
.filter-chips a.chip { text-decoration: none; }
.filter-chips .chip[aria-current="true"] { background: var(--accent); color: var(--accent-contrast);
                                          border-color: var(--accent); }
.filter-chips .chip[aria-current="true"] .chip-check { display: inline-flex; }
.report-window { display: flex; flex-direction: column; gap: .5rem; margin-block-end: .75rem; }
.report-window .filter-chips { margin-block-end: 0; }

/* ut-docs#2174: Quick Buttons Designer — the live replica of the sale
   screen's product panel plus its category-management section
   (buttons.html's "designer-categories", edit mode only). The replica itself
   reuses every .products-finder/.tab-bar/.grid/.tile-cell rule unchanged
   (same markup, same jiggle mode); these rules only lay out the page
   sections around it and the management list beneath it. Logical
   properties only — /designer renders RTL for fa/ar. */
.designer { display: flex; flex-direction: column; gap: 1.25rem; }
.designer-preview > h2 { margin-block-end: .15rem; }
.designer-preview > .muted { margin-block: 0 .6rem; font-size: .85rem; }
.designer-categories { margin-block-start: 1rem; padding-block-start: .85rem; border-block-start: 1px solid var(--border); display: flex; flex-direction: column; gap: .6rem; }
.designer-categories-head { display: flex; flex-direction: column; gap: .2rem; }
.designer-categories-head h3 { margin: 0; }
.designer-categories-help { margin: 0; font-size: .85rem; }
.designer-cat-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .45rem; }
/* ut-docs#3074: a reorder moves rows in place (no re-render), and the page
   must stay exactly where it was. Browser scroll anchoring would otherwise
   pick the moved row (usually the one in view) as its anchor and scroll the
   page after it -- measured: Move later scrolled the Designer by one row
   height. Opting the list out keeps the viewport still while the row moves. */
.designer-cat-list { overflow-anchor: none; }
.designer-cat {
  display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .3rem .6rem; align-items: center;
  padding: .45rem .65rem; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface);
}
/* ut-docs#2699: the row body (picture + name + counts) is the edit toggle —
   a real button, reset to look like the row it is; the deactivate button is
   the only other control on the line, so the row stays one line at 360px
   (the ut-docs#2174 narrow-width stacking rule is gone with the three
   buttons that needed it). */
.designer-cat-open {
  display: flex; align-items: center; gap: .6rem; min-inline-size: 0; min-block-size: 3rem;
  padding: 0; border: 0; background: none; color: inherit; font: inherit; text-align: start; cursor: pointer;
  border-radius: var(--radius);
}
.designer-cat-open:focus-visible { outline: 2px solid var(--focus, var(--accent)); outline-offset: 2px; }
.designer-cat-text { display: flex; flex-direction: column; min-inline-size: 0; }
.designer-cat-name { font-weight: 600; overflow-wrap: anywhere; }
.designer-cat-inactive .designer-cat-name { color: var(--muted); }
.designer-cat-meta { font-size: .8rem; }
.designer-cat-move { display: flex; flex-wrap: wrap; gap: .5rem; }
/* Everything below the row's first line spans the full width. */
.designer-cat-blocked, .designer-cat-msg, .designer-cat-form { grid-column: 1 / -1; }
.designer-cat-blocked { margin: 0; font-size: .8rem; }
.designer-cat-msg { margin: 0; font-size: .85rem; }
.designer-cat-msg:empty { display: none; }
.designer-cat-msg .error { color: var(--danger); }
.designer-cat-none { padding: .45rem .65rem; }
/* ut-docs#2541: "Hidden from sell screen" section -- same block rhythm as
   .designer-categories just above (border-block-start rule, gap), a
   simpler flat row (name + one Unhide button, no swatch/move/edit) since
   there's nothing else to arrange here. */
.designer-hidden { margin-block-start: 1rem; padding-block-start: .85rem; border-block-start: 1px solid var(--border); display: flex; flex-direction: column; gap: .6rem; }
.designer-hidden-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .45rem; }
.designer-hidden-item {
  display: flex; align-items: center; justify-content: space-between; gap: .6rem;
  padding: .45rem .65rem; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface);
}
.designer-hidden-name { font-weight: 600; overflow-wrap: anywhere; }
/* ut-docs#2614: natural width, start-aligned (the head is a column flexbox). */
.designer-hidden-unhide-all { align-self: flex-start; }
.designer-cat-form { display: flex; flex-direction: column; gap: .55rem; margin-block-start: .35rem; padding-block-start: .55rem; border-block-start: 1px dashed var(--border); }
.designer-cat-field { display: flex; flex-direction: column; gap: .25rem; font-size: .85rem; }
.designer-cat-field input[type="text"] { inline-size: min(100%, 22rem); }
.designer-cat-form-actions { display: flex; flex-wrap: wrap; gap: .5rem; }
/* The colour picker: the same .item-color-tile swatches catalog.html uses,
   as <label>s around a native radio (native arrow-key roving focus, no
   picker script). The radio stays visible so selection is never colour-
   only; :has() is a progressive enhancement that mirrors the pressed look
   of .picker-tile[aria-pressed="true"] on browsers that support it. */
.designer-cat-colors { display: flex; flex-wrap: wrap; gap: .4rem; border: 0; padding: 0; margin: 0; min-inline-size: 0; }
.designer-cat-colors legend { font-size: .85rem; padding: 0; margin-block-end: .3rem; }
.designer-cat-colors .item-color-tile { flex-direction: row; gap: .4rem; min-block-size: 2.6rem; min-inline-size: 6rem; padding: .35rem .6rem; }
.designer-cat-colors .item-color-tile input[type="radio"] { margin: 0; flex: none; }
.designer-cat-colors .item-color-tile:has(input:checked) { border-color: var(--accent); background: var(--surface-2); box-shadow: inset 0 0 0 2px var(--accent); }
.designer-cat-colors .item-color-tile:has(input:focus-visible) { outline: 2px solid rgba(37, 99, 235, .35); outline-offset: 1px; }
.designer-cat-new { display: flex; flex-direction: column; gap: .5rem; align-items: flex-start; }
.designer-cat-new .designer-cat-form { inline-size: 100%; border-block-start: 0; padding-block-start: 0; }

/* ut-docs#2699: category rows' leading picture (image → icon → colour
   swatch → placeholder, chosen server-side) on /categories and in the
   Designer's category list, and the long-press drag-to-reorder states that
   web/public/list-reorder.js toggles. Logical properties only (fa/ar RTL). */
.cat-thumb {
  flex: none; display: inline-flex; align-items: center; justify-content: center;
  inline-size: 2.25rem; block-size: 2.25rem; border-radius: var(--radius); overflow: hidden;
  background: var(--surface-2); border: 1px solid var(--border); vertical-align: middle;
}
.cat-thumb img { inline-size: 100%; block-size: 100%; object-fit: contain; }
.cat-thumb-swatch { inline-size: 1.25rem; block-size: 1.25rem; border-radius: 50%; background: var(--swatch, transparent); border: 1px solid rgba(15, 23, 42, .18); }
.cat-thumb-placeholder { display: inline-flex; color: var(--muted); }
.cat-thumb-placeholder svg { inline-size: 1.1rem; block-size: 1.1rem; }
.category-row-open {
  display: inline-flex; align-items: center; gap: .6rem; min-block-size: 3rem; max-inline-size: 100%;
  padding: 0; border: 0; background: none; color: inherit; font: inherit; text-align: start; cursor: pointer;
  border-radius: var(--radius);
}
.category-row-open:focus-visible { outline: 2px solid var(--focus, var(--accent)); outline-offset: 2px; }
.category-row-name { overflow-wrap: break-word; }
/* The name column keeps room for the picture plus a short word at 360px;
   the table's own .list-scroll wrapper scrolls sideways if it must,
   rather than breaking names mid-word. */
.category-name-cell { min-inline-size: 9.5rem; }
.list-reorder-hint { font-size: .85rem; margin-block: 0 .5rem; }
.list-reorder-msg { margin-block: 0 .5rem; color: var(--danger); font-size: .9rem; }
.list-reorder-msg[hidden] { display: none; }
.category-move[hidden] { display: none; }
/* A reorderable row: no text-selection callout under a held finger, and
   vertical swipes still scroll the page until the hold arms the drag.
   touch-action does NOT apply to table rows (CSS spec: it applies to all
   elements except non-replaced inlines, table rows, row groups, columns
   and column groups), so on /categories the <tr>'s own declaration is
   inert — its cells carry pan-y instead. The Designer's <li> takes it
   directly. (The effective value is the intersection down the ancestor
   chain, so the buttons inside a cell inherit the restriction.) */
[data-reorder-id] { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; touch-action: pan-y; }
[data-reorder-id] > td { touch-action: pan-y; }
[data-reorder-id] input, [data-reorder-id] textarea { -webkit-user-select: text; user-select: text; }
.is-reordering, .is-reordering * { cursor: grabbing; }
.designer-cat.is-dragging {
  border-color: var(--accent); background: var(--surface-2);
  box-shadow: 0 6px 18px rgba(15, 23, 42, .18), inset 0 0 0 1px var(--accent);
}
.category-row.is-dragging > td { background: var(--surface-2); box-shadow: inset 0 2px 0 var(--accent), inset 0 -2px 0 var(--accent); }
@media (prefers-reduced-motion: no-preference) {
  .designer-cat { transition: box-shadow .12s ease, transform .12s ease; }
  .designer-cat.is-dragging { transform: scale(1.015); }
}

/* ── Phone sale screen (ut-docs#3059) ───────────────────────────────────
   Product owner, 2026-09-28, on an iPhone: the phone tier showed no product
   tile without scrolling (a wrapped 3-row nav, then the basket table and
   the tender block, then the grid). Owner-approved design (mock-up on the
   card, modelled on the SumUp/Square/Shopify phone apps):
   - the nav is ONE 56px row (☰ + logo); nav.html's #nav-drawer, which is
     `display: contents` on every wider layout, becomes an off-canvas panel;
   - item tiles are app icons: the item's picture, else .tile-initials on
     the item colour, in a square with rounded corners, 3 per row;
   - #basket collapses into a bottom bar (Dine in | Takeaway, and
     .basket-phonebar "Pay £x / n items") that opens a sheet
     (body.pos-sheet-open): the lines, totals and the tender panel, stacked.
   Tablet, desktop and Pi layouts are untouched: the rail is binding there
   (the phone is the owner's own exception). Toggles: app.js utNavDrawer /
   utPhoneSheet. Spec: e2e/tests/phone-sell-3059.spec.ts. */
.nav-drawer { display: contents; }
.nav-drawer-toggle, .nav-drawer-backdrop, .basket-phonebar,
.basket-sheet-close, .tile-initials { display: none; }
/* nav.html's alert chips (fiscal, diagnostic mode): a column at the rail's
   foot; an empty chip (no alert) takes no room. */
.nav-alerts { display: flex; flex-direction: column; gap: .3rem; }
.nav-alerts > span:empty { display: none; }

@media (max-width: 480px) {
  :root { --phone-top-h: 56px; }

  /* Top bar: ☰ + logo, one row. */
  .nav {
    flex-wrap: nowrap; justify-content: flex-start; align-items: center;
    gap: .35rem; row-gap: 0; padding: 0 .4rem;
    block-size: var(--phone-top-h); min-block-size: var(--phone-top-h);
    z-index: 60;
  }
  .nav-drawer-toggle {
    display: inline-flex; align-items: center; justify-content: center;
    inline-size: 48px; block-size: 48px; flex-shrink: 0;
    border: 0; border-radius: 10px; background: transparent; color: #fff;
    font: inherit; cursor: pointer; order: -1;
  }
  .nav-drawer-toggle .btn-ico svg { inline-size: 1.6rem; block-size: 1.6rem; }
  .nav-drawer-toggle:focus-visible { outline: 3px solid #fff; outline-offset: -3px; }
  /* ut-docs#3352 / ADR-0137: Back leads the bar (top-start, where iOS puts
     it), icon only like ☰; its label stays the accessible name. */
  .nav > .nav-back {
    order: -2; flex-shrink: 0; justify-content: center;
    inline-size: 48px; block-size: 48px; min-width: 48px; min-height: 48px;
    padding: 0; background: transparent;
  }
  .nav > .nav-back .nav-toggle-label {
    position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
    overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
  }
  .nav > .nav-back .nav-toggle-ico svg { inline-size: 1.6rem; block-size: 1.6rem; }
  .nav > .nav-back:focus-visible { outline: 3px solid #fff; outline-offset: -3px; }
  .nav .logo img { height: 2rem; }
  .nav-alerts { flex-direction: row; align-items: center; gap: .3rem; margin-inline-start: auto; min-inline-size: 0; }
  /* An active alert chip is icon + badge in the bar (its label stays the
     accessible name, visually hidden); the app name gives way to it. */
  .nav-alerts .nav-toggle { min-height: 44px; padding: .4rem .6rem; }
  .nav-alerts .nav-toggle-label {
    position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
    overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
  }
  /* The fiscal chip's "ok" state is information, not an alert: it isn't
     worth the bar on a phone (warn/err still show). */
  .nav-alerts .fiscal-chip.ok { display: none; }
  .nav:has(.nav-alerts .fiscal-chip:not(.ok)) .app-name,
  .nav:has(#diagnostics-chip:not(:empty)) .app-name { display: none; }

  /* Drawer: the same links and chips, as a column. inset-inline-start and
     the direction-aware transform keep it on the reading-start edge in RTL. */
  .nav-drawer {
    display: flex; flex-direction: column; gap: .75rem;
    position: fixed; inset-block: 0; inset-inline-start: 0;
    inline-size: min(20rem, 86vw); box-sizing: border-box;
    padding: .75rem .6rem calc(.75rem + env(safe-area-inset-bottom));
    background: var(--brand); color: #fff; overflow-y: auto; z-index: 2;
    box-shadow: 0 10px 40px rgba(0, 0, 0, .35);
    transform: translateX(-105%); visibility: hidden;
    transition: transform .2s ease, visibility 0s linear .2s;
  }
  [dir="rtl"] .nav-drawer { transform: translateX(105%); }
  body.nav-drawer-open .nav-drawer {
    transform: none; visibility: visible; transition: transform .2s ease;
  }
  .nav-drawer .nav-primary, .nav-drawer .nav-right {
    flex-direction: column; flex-wrap: nowrap; align-items: stretch;
    gap: .3rem; margin: 0;
  }
  .nav-drawer .nav-right { padding-block-start: .75rem; border-block-start: 1px solid rgba(255, 255, 255, .15); }
  .nav-drawer .nav-toggle,
  .nav .nav-drawer button.nav-toggle { justify-content: flex-start; inline-size: 100%; min-height: 48px; }
  .nav-drawer .nav-rail-only { display: flex; }
  /* ut-docs#3358: the manager links (Users/Promotions/Translations) are
     dropped from the sale/menu RAIL to keep it short, but the drawer is a
     scrollable column — an administrator needs them there on every screen.
     `body` matches the specificity of the body.sale-screen/menu-screen
     hide rules; this block comes later, so it wins. */
  body .nav-drawer .session-admin-link { display: flex; }
  /* ut-docs#3297: the Lock button, a row like every other drawer item. */
  .nav-drawer .btn-lock { padding: .5rem .75rem; }
  body.nav-drawer-open .nav-drawer-backdrop {
    display: block; position: fixed; inset: 0; z-index: 1;
    background: rgba(15, 23, 42, .45);
  }
  @media (prefers-reduced-motion: reduce) {
    .nav-drawer, body.nav-drawer-open .nav-drawer { transition: none; }
  }

  /* Sale screen: products fill; basket is a bottom bar; the rest waits in
     the sheet. */
  body.sale-screen:not(.pos-sheet-open) .kiosk-header { display: none; }
  /* The status bar (main-till link, network light, update/enrol chips).
     Product owner, 2026-09-29, on an iPhone: the dark row above the pay bar
     cost the grid a row for information that is fine one tap away -- it
     moves to the foot of the ☰ drawer (the approved mock-up's layout). A
     problem still shows on the sale screen itself (offline-first: offline
     state is never hidden): no internet, main till not reachable, a
     refused cloud credential (ADR-0116 D6) or an underpowered supply bring the row back just above the pay bar (or, in
     the open sheet, at the very bottom, with Pay pinned above it). So does
     an open dialog: the scrim covers ☰, and status must stay reachable
     (html.ut-scrim-on lifts the row above it, below). app.js re-measures
     it whenever it appears, wraps or goes. */
  body.sale-screen .statusbar {
    position: fixed; inset-inline: 0; bottom: var(--phone-bar-h, 8rem); z-index: 39;
    padding-block: .2rem; font-size: .8rem;
  }
  body.sale-screen.pos-sheet-open .statusbar { bottom: 0; }
  html:not(.ut-scrim-on) body.sale-screen:not(.nav-drawer-open) .statusbar:not(:has(.sb-conn.is-offline, .sb-main-till, .sb-cloud-auth, .sb-report-archive, .sb-power, .sb-update.is-stuck)) { display: none; }
  /* In the open drawer: its foot, one item per line, above the backdrop
     (the nav's stacking context is z-index 60). The drawer pads its end by
     the row's height so the last link scrolls clear of it. */
  body.sale-screen.nav-drawer-open .statusbar {
    inset-inline: 0 auto; bottom: 0; z-index: 61;
    inline-size: min(20rem, 86vw); box-sizing: border-box;
    flex-direction: column; align-items: flex-start; gap: .35rem;
    padding: .6rem .9rem calc(.6rem + env(safe-area-inset-bottom));
    background: var(--brand); color: #cbd5e1; font-size: .85rem;
    border-block-start: 1px solid rgba(255, 255, 255, .15);
  }
  body.sale-screen.nav-drawer-open .statusbar .sb-ver { margin-inline-start: 0; }
  body.sale-screen.nav-drawer-open .statusbar :is(.sb-update, .sb-enrol, .sb-power, .sb-main-till, .sb-subscription, .sb-cloud-auth, .sb-report-archive, .sb-plugin-update, .sb-language-pack-update, .sb-release-notes) { margin-inline-start: 0; }
  /* ut-docs#3358: every screen's status row paints over the open drawer's
     foot (sale screen: the rule above; elsewhere the page's sticky
     .statusbar), so the drawer reserves the row's measured open height
     (app.js utNavDrawer) and Lock always scrolls clear of it. */
  body.nav-drawer-open .nav-drawer {
    padding-block-end: calc(var(--phone-drawer-sb-h, var(--phone-sb-h, 0px)) + .75rem);
  }
  /* ...and, like everywhere else, it stays above #ut-scrim while a dialog
     is open (status stays reachable; base rule html.ut-scrim-on .statusbar). */
  html.ut-scrim-on body.sale-screen .statusbar { z-index: 460; }
  .pos-container {
    display: flex; flex-direction: column; overflow: hidden;
    flex: 1 1 auto; min-height: 0;
  }
  .pos-container > .pos-divider { display: none; }
  .pos-container > .products {
    order: 1; flex: 1 1 auto; min-height: 0; max-height: none;
    overflow-y: auto; margin: 0; border: 0; border-radius: 0; box-shadow: none;
    padding-block-end: calc(var(--phone-bar-h, 8rem) + var(--phone-sb-h, 0px) + .75rem);
  }
  body:not(.pos-sheet-open) .pos-container > .tender { display: none; }
  body:not(.pos-sheet-open) .pos-container > .basket {
    position: fixed; inset-inline: 0; bottom: 0; z-index: 40;
    display: flex; flex-direction: column; gap: .45rem;
    margin: 0; border-radius: 16px 16px 0 0; border: 0;
    padding: .55rem .75rem calc(.6rem + env(safe-area-inset-bottom));
    background: var(--surface); box-shadow: 0 -4px 16px rgba(15, 23, 42, .14);
    max-height: none; min-height: 0; overflow: visible;
  }
  body:not(.pos-sheet-open) .pos-container > .basket:not(.receipt-view) > :not(.order-type-row):not(.basket-phonebar):not(.pos-notice) { display: none; }
  /* Product owner, 2026-09-29: Dine in | Takeaway sits NEXT TO the pay
     bar, one row (the approved mock-up), not on a row of its own above
     it. The order-type row becomes a contents box so its toggle is the
     bar's sibling; the table button waits in the sheet, where the whole
     row comes back. A long locale's segment label wraps to two lines
     inside the 56px segment rather than squeezing Pay. */
  body:not(.pos-sheet-open) .pos-container > .basket:not(.receipt-view) {
    display: grid; grid-template-columns: minmax(0, auto) minmax(0, 1fr);
    align-items: stretch; column-gap: .5rem;
  }
  body:not(.pos-sheet-open) .pos-container > .basket:not(.receipt-view) > .pos-notice { grid-column: 1 / -1; }
  body:not(.pos-sheet-open) .pos-container > .basket > .order-type-row { display: contents; }
  body:not(.pos-sheet-open) .pos-container > .basket #table-picker,
  body:not(.pos-sheet-open) .pos-container > .basket .table-picker-trigger { display: none; }
  body:not(.pos-sheet-open) .basket .order-type-toggle-group {
    align-self: stretch; max-inline-size: min(13rem, 46vw); min-block-size: 56px;
    border-color: var(--muted); border-radius: 12px;
  }
  body:not(.pos-sheet-open) .basket .order-type-option {
    flex: 1 1 0; min-inline-size: 0; min-block-size: 56px; padding: .25rem .55rem;
    font-size: .9rem; white-space: normal; overflow-wrap: break-word; hyphens: manual; text-align: center;
  }
  body:not(.pos-sheet-open) .basket .order-type-option.is-active {
    background: color-mix(in srgb, var(--success) 72%, #000); color: #fff;
  }
  body:not(.pos-sheet-open) .basket .order-type-ico { display: none; }
  .basket-phonebar {
    display: flex; align-items: center; justify-content: space-between; gap: .75rem;
    inline-size: 100%; min-inline-size: 0; min-height: 56px; padding: .4rem .75rem .4rem .9rem;
    border: 0; border-radius: 12px; font: inherit; cursor: pointer;
    background: color-mix(in srgb, var(--success) 72%, #000); color: #fff; text-align: start;
  }
  .basket-phonebar.is-empty { background: var(--surface-2); color: var(--text); border: 1px solid var(--border); }
  .basket-phonebar-text { display: flex; flex-direction: column; line-height: 1.2; min-inline-size: 0; overflow-wrap: anywhere; }
  .basket-phonebar-pay { font-size: 1.15rem; font-weight: 700; }
  .basket-phonebar-count { font-size: .85rem; }
  .basket-phonebar.is-empty .basket-phonebar-pay { font-size: 1rem; font-weight: 650; }
  .basket-phonebar.is-empty .basket-phonebar-count { display: none; }
  .basket-phonebar .btn-ico svg { inline-size: 1.3rem; block-size: 1.3rem; }
  .basket-phonebar:focus-visible { outline: 3px solid var(--focus-border); outline-offset: 2px; }

  /* The open sheet: products out, basket and tender stacked, scrolling. */
  body.pos-sheet-open .pos-container { overflow-y: auto; }
  body.pos-sheet-open .pos-container > .products { display: none; }
  body.pos-sheet-open .basket-phonebar { display: none; }
  body.pos-sheet-open .pos-container > .basket {
    position: relative; flex: none; block-size: auto; min-block-size: 0; max-height: none; overflow: visible;
  }
  body.pos-sheet-open .pos-container > .basket .basket-scroll { flex: none; block-size: auto; max-height: none; overflow: visible; }
  /* A sticky box -- and a flex item with a z-index, even a static one --
     is its own stacking context, so a dialog opened inside the tender (the
     payment overlay, z-index 500) would be trapped under the top bar and
     the status row: unpin it and drop the z-index while one is open. */
  body.pos-sheet-open .pos-container > .tender:has(dialog[open]) { position: static; z-index: auto; }
  /* Pay stays in reach at the foot of the sheet while the lines scroll. */
  body.pos-sheet-open .pos-container > .tender {
    flex: none; position: sticky; bottom: var(--phone-sb-h, 0px); z-index: 2;
    box-shadow: 0 -4px 16px rgba(15, 23, 42, .14);
  }
  /* The sheet has the whole screen, so the ±1 steppers the packed phone
     row had to drop (ut-docs#1338) come back: owner-approved design, every
     line editable with thumb-sized − and +. */
  body.pos-sheet-open .basket .qty-step-btn {
    display: inline-flex; align-items: center; justify-content: center;
    min-inline-size: 44px; min-block-size: 44px; padding: 0; font-size: 1.3rem;
  }
  /* ...and the qty cell also takes the row's free start column (empty in
     the packed tier), so − and + sit inside it and never under the price.
     Same tracks as the packed tier, so the 15.1rem fixed budget of
     ut-docs#1338/#391 is unchanged; only the areas differ. */
  body.pos-sheet-open .basket tr {
    grid-template-areas: "name name name  name  name"
                         "qty  qty  price total remove";
  }
  /* ut-docs#3297 review: at 360px the basket is ~288px wide, too narrow
     for − qty + beside four fixed tracks (+ wrapped under −). The ✕ moves
     up beside the item name and price/total take their own width, so the
     second row is just the quantity controls, the price and the total. */
  body.pos-sheet-open .basket :is(thead, tbody) tr {
    grid-template-columns: minmax(0, 1fr) auto auto;
    grid-template-areas: "name  name  remove"
                         "qty   price total";
  }
  /* ut-docs#3297: [−] qty [+] on one row, the discount box after them
     (wrapping under them when the row is full), not a tower of qty /
     steppers / discount (the packed tier's column order). */
  body.pos-sheet-open .basket .line-inputs {
    flex-direction: row; flex-wrap: wrap; align-items: center; justify-content: flex-start; gap: .3rem;
  }
  body.pos-sheet-open .basket td:nth-child(2) { width: auto; padding-inline: .3rem; }
  body.pos-sheet-open .basket :is(td, th):is(:nth-child(3), :nth-child(4)) { width: auto; white-space: nowrap; }
  body.pos-sheet-open .basket .qty-stepper { display: contents; }
  body.pos-sheet-open .basket .qty-step-btn:first-child { order: 1; }
  body.pos-sheet-open .basket .qty-input { order: 2; inline-size: 2.8rem; min-block-size: 44px; text-align: center; }
  body.pos-sheet-open .basket .qty-step-btn:last-child { order: 3; }
  body.pos-sheet-open .basket .disc-input { order: 4; min-block-size: 44px; }
  body.pos-sheet-open .basket-sheet-close {
    display: inline-flex; align-items: center; justify-content: center;
    position: absolute; inset-block-start: .5rem; inset-inline-end: .5rem; z-index: 1;
    inline-size: 48px; block-size: 48px;
    border: 1px solid var(--border); border-radius: 12px;
    background: var(--surface); color: var(--text); cursor: pointer;
  }
  body.pos-sheet-open .basket-sheet-close .btn-ico svg { inline-size: 1.4rem; block-size: 1.4rem; }

  /* Categories: one white bar of 40px pills under the top bar, scrolling
     sideways, never wrapping, and staying put while the tiles scroll
     (product owner, 2026-09-29: the tall boxes with a check mark weren't
     the approved mock-up). The selected pill is filled -- a lightness
     change, not colour alone -- so it needs no check mark here. */
  .pos-container .products .filter-chips {
    flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none;
    position: sticky; top: -.7rem; z-index: 2; gap: .5rem;
    margin: 0 -.8rem .75rem; padding: .1rem .8rem .6rem;
    background: var(--surface); border-block-end: 1px solid var(--border);
  }
  .pos-container .products .filter-chips::-webkit-scrollbar { display: none; }
  .pos-container .products .filter-chips .chip {
    flex-shrink: 0; white-space: nowrap; position: relative;
    min-block-size: 40px; block-size: 40px; padding: 0 1rem; border-radius: 20px;
    font-size: .95rem; font-weight: 600;
  }
  /* 40px to the eye (the mock-up), 44px to the finger (the touch floor):
     the bar's own padding has the room. */
  .pos-container .products .filter-chips .chip::after {
    content: ""; position: absolute; inset-block: -2px; inset-inline: 0;
  }
  .pos-container .products .filter-chips .chip[aria-pressed="true"] {
    background: color-mix(in srgb, var(--success) 72%, #000);
    border-color: color-mix(in srgb, var(--success) 72%, #000); color: #fff;
  }
  .pos-container .products .filter-chips .chip-check { display: none; }

  /* Search and Edit sit in the top bar, at its end (the approved mock-up):
     same DOM (buttons.html's .products-strip, inside the finder's Alpine
     scope), fixed over the bar. The bar keeps their room free for its
     alert chips. While searching, the two hide (x-show) and the back
     button and search field take the strip's own row under the bar. */
  body.sale-screen .pos-container .products-strip :is(.products-strip-search, .products-strip-edit) {
    position: fixed; inset-block-start: calc((var(--phone-top-h) - 48px) / 2); z-index: 61;
    inline-size: 48px; min-inline-size: 48px; block-size: 48px; min-block-size: 48px; padding: 0;
    border: 0; border-radius: 10px; background: transparent; color: #fff; box-shadow: none;
  }
  body.sale-screen .pos-container .products-strip .products-strip-edit { inset-inline-end: .4rem; }
  body.sale-screen .pos-container .products-strip .products-strip-search { inset-inline-end: calc(.4rem + 48px + .15rem); }
  body.sale-screen .pos-container .products-strip:not(:has(.products-strip-edit)) .products-strip-search { inset-inline-end: .4rem; }
  body.sale-screen .pos-container .products-strip :is(.products-strip-search, .products-strip-edit) .btn-ico svg { inline-size: 1.45rem; block-size: 1.45rem; }
  body.sale-screen .pos-container .products-strip :is(.products-strip-search, .products-strip-edit):focus-visible { outline: 3px solid #fff; outline-offset: -3px; }
  body.sale-screen:has(.products-strip-edit) .nav { padding-inline-end: calc(.4rem + 96px + .15rem); }
  body.sale-screen:has(.products-strip-search):not(:has(.products-strip-edit)) .nav { padding-inline-end: calc(.4rem + 48px); }
  /* Under the drawer's backdrop and any dialog's scrim, like the bar. */
  body.sale-screen.nav-drawer-open .pos-container .products-strip :is(.products-strip-search, .products-strip-edit) { z-index: 59; }
  /* At rest, with the two icons lifted out and no tab bar, the strip is
     empty: it takes no room above the category pills. */
  body.sale-screen .pos-container .products-finder:not(:has(.products-strip .tab-bar)) { gap: 0; }
  body.sale-screen .pos-container .products-strip:has(> input[type="search"]:not([style*="none"])) { margin-block-end: .6rem; }

  /* App-icon tiles: 3 per row, the picture or the initials in a rounded
     square, name and price under it. */
  .pos-container .products .grid {
    grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1rem .75rem;
  }
  .pos-container .btn-tile,
  .pos-container .btn-tile.tile-colored {
    display: flex; flex-direction: column; align-items: center; justify-content: flex-start;
    gap: .3rem; min-height: 0; block-size: auto; padding: 0;
    background: transparent; border: 0; box-shadow: none; text-align: center;
  }
  .pos-container .btn-tile .thumb,
  .pos-container .btn-tile .tile-initials {
    display: flex; align-items: center; justify-content: center;
    inline-size: 100%; max-inline-size: 6.5rem; aspect-ratio: 1 / 1; block-size: auto;
    margin: 0; border-radius: 24%; object-fit: cover;
    box-shadow: 0 1px 2px rgba(15, 23, 42, .18), 0 4px 10px rgba(15, 23, 42, .12);
  }
  .pos-container .btn-tile .tile-initials {
    background: var(--tile-color, #475569); color: #fff;
    font-size: 2rem; font-weight: 700; letter-spacing: .02em; line-height: 1;
  }
  .pos-container .btn-tile .tile-name,
  .pos-container .btn-tile.tile-colored .tile-name {
    color: var(--text); font-size: .9rem; font-weight: 600; line-height: 1.2;
    display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
    overflow: hidden; overflow-wrap: anywhere;
  }
  .pos-container .btn-tile .tile-price,
  .pos-container .btn-tile.tile-colored .tile-price { color: var(--muted); font-size: .85rem; }
}

/* ---------- Guided tour (ut-docs#3710, web/public/tour.js) ----------
   .ut-tour-ring is the highlight AND the backdrop: a box exactly over the
   target whose huge spread shadow dims everything else (a centred step
   gives it no size, so the whole screen dims). Both it and the balloon sit
   above the page, nav rail and status bar (z 470/480) but under the
   on-screen keyboard (#osk, 1000); the tour never runs while a
   popup (>= 500) is open. The ring is visual only (pointer-events: none):
   the status bar, Lock and exit-to-OS stay reachable through it and a tap
   on the dimmed area never dismisses the tour. tour.js positions both from
   getBoundingClientRect (physical px, right in RTL too), so nothing here
   sets a horizontal inset; the balloon's own content uses logical
   properties. Reduced motion: the global prefers-reduced-motion block
   above already drops the fade. */
.ut-tour-ring {
  position: fixed; z-index: 470; pointer-events: none;
  border-radius: var(--radius);
  box-shadow: 0 0 0 3px var(--accent), 0 0 0 200vmax var(--ut-scrim);
}
.ut-tour-ring.is-centred { box-shadow: 0 0 0 200vmax var(--ut-scrim); }
.ut-tour-balloon {
  position: fixed; z-index: 480;
  inline-size: max-content; max-inline-size: min(340px, calc(100vw - 32px));
  border-radius: var(--radius-lg);
  background: var(--surface); color: var(--text);
  box-shadow: 0 10px 30px rgba(15, 23, 42, .28), 0 2px 6px rgba(15, 23, 42, .18);
  text-align: start;
  animation: ut-tour-in .15s ease-out;
}
@keyframes ut-tour-in { from { opacity: 0; } to { opacity: 1; } }
.ut-tour-arrow {
  position: absolute; inline-size: 14px; block-size: 14px;
  background: var(--surface); transform: rotate(45deg); pointer-events: none;
}
.ut-tour-arrow[hidden] { display: none; }
.ut-tour-content {
  position: relative; max-block-size: calc(100dvh - 16px); overflow-y: auto;
  padding: 1rem 1rem .85rem; border-radius: var(--radius-lg);
}
.ut-tour-title { margin: 0 0 .35rem; font-size: 1.05rem; font-weight: 700; color: var(--text); }
.ut-tour-body { margin: 0 0 .85rem; font-size: .95rem; line-height: 1.45; color: var(--text); overflow-wrap: anywhere; }
.ut-tour-foot { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; }
.ut-tour-counter { color: var(--muted); font-size: .85rem; font-variant-numeric: tabular-nums; margin-inline-end: auto; }
.ut-tour-actions { display: flex; flex-wrap: wrap; gap: .4rem; margin-inline-start: auto; }
.ut-tour-actions .btn { min-block-size: 44px; min-inline-size: 44px; padding-block: .5rem; padding-inline: .9rem; font-size: .95rem; }
.ut-tour-actions .btn[hidden] { display: none; }
.manual-tour { margin-block-end: 1rem; }

/* ---------- Plugin views (ADR-0121 §7, ut-docs#3160) ----------
   Core's own rendering of a plugin's view document
   (web/ui/partials/pluginview/). Logical properties only (RTL); actions
   keep the till's 46px touch floor (.btn-touch). Notices are inline and
   persist -- not .pos-notice, whose info level auto-expires. */
.plugin-view { display: flex; flex-direction: column; gap: 1rem; }
.plugin-view-title, .plugin-view-heading { margin: 0; }
.plugin-view-text { margin: 0; overflow-wrap: anywhere; }
.plugin-view-notice { padding: .55rem .8rem; border-radius: var(--radius);
  border: 1px solid transparent; font-weight: 600; overflow-wrap: anywhere; }
.plugin-view-notice.info { background: rgba(37, 99, 235, .1); color: var(--text);
  border-color: rgba(37, 99, 235, .3); }
.plugin-view-notice.warn { background: var(--warning-tint); color: var(--text);
  border-color: var(--warning-tint-border); }
.plugin-view-notice.error { background: var(--danger-tint); color: var(--danger);
  border-color: var(--danger-tint-border); }
.plugin-view-table-wrap { overflow-x: auto; }
.plugin-view-table th { text-align: start; }
.plugin-view-table .num { text-align: end; font-variant-numeric: tabular-nums; }
/* nowrap only as a real table: in phone card mode (table-cards.js) it would
   inherit into the ::before data-label and push a long German label
   ("GESAMTBETRAG") over its value. */
.plugin-view-table:not(.t-cards) .num { white-space: nowrap; }
.plugin-view-list { margin: 0; padding-block: .75rem; padding-inline: 2rem 1rem; }
.plugin-view-empty { text-align: center; }
.plugin-view-form { display: flex; flex-direction: column; gap: .6rem; max-inline-size: 36rem; }
.plugin-view-form > .btn, .plugin-view-action .btn { align-self: flex-start; }
/* A toggle is a whole-row target at the 46px floor (.btn-touch). nowrap:
   .field-checks' own flex-wrap would drop a long label (German) onto the
   line below its checkbox; the text wraps inside its own flex item instead. */
.plugin-view-form label.field-checks { flex-wrap: nowrap; min-block-size: 46px; margin: 0; cursor: pointer; }
.plugin-view-form label.field-checks > input[type="checkbox"] { flex: none; inline-size: 1.25rem; block-size: 1.25rem; margin: 0; }
.plugin-view-action { margin: 0; }
/* A running job's poll (ADR-0121 §8, ut-docs#3908): message, bar, and a
   plain refresh link at the 46px touch floor (the no-JS fallback). */
.plugin-view-poll { display: flex; flex-direction: column; gap: .6rem; max-inline-size: 36rem; }
.plugin-view-poll-msg { font-weight: 600; overflow-wrap: anywhere; }
.plugin-view-poll-bar { inline-size: 100%; block-size: 1rem; accent-color: var(--accent); }
.plugin-view-poll-refresh { align-self: flex-start; display: inline-flex; align-items: center;
  min-block-size: 46px; padding-inline: .25rem; }
