/* ============================================================================
   APPUI — the "this is an application, not a web page" layer for the APP FLEET.

   Loaded by the admin/portal shells, AFTER app.css:
     app/Views/layouts/app.php · portal.php · bare.php · docs.php
   — and by the public-website layouts of eight apps (`body.site`, see SCOPING).

   ── WHY A SEPARATE SHEET (READ BEFORE EDITING) ────────────────────────────
   app.css is NOT shared across the fleet. Measured 2026-09-18 over 112 app
   directories: 29 carry the skeleton's byte-identical canonical block, 13 are
   base+appended, and 70 have an EDITED base (older skeleton generations, plus
   the de-branding sweeps that renamed classes inside it — taskora's `.skeleton`
   became `.employee-task-reports`). So there is no version of "overwrite
   app.css everywhere" that is safe.

   This file sidesteps all of that. It only OVERRIDES classes the canonical
   block already defines, by cascade order, so:
     · every app inherits the new look with ZERO view edits;
     · a diverged app.css is irrelevant — we never touch it;
     · the blast radius is exactly the shells that link it;
     · it reverts by deleting one <link>, with nothing to untangle.

   ── THE RULE FOR EDITING THIS FILE ────────────────────────────────────────
   Never rename or redefine a class app.css owns — OVERRIDE it. Views across
   112 apps use these names and we cannot grep them all with confidence.
   Everything new is namespaced `.ap-*` and is strictly opt-in.

   ── SCOPING ───────────────────────────────────────────────────────────────
   Scoped to `body.admin` / `body.public`, which every app layout already sets.
   That is the equivalent of the platform's `body.has-shell` — and it means the
   marketing landing (`ld-*`, no body class) is deliberately untouched.

   ⚠️ A THIRD SCOPE, `body.site`, gets the PHONE-SAFETY rules only. Eight apps
   render a public WEBSITE with `<body class="site">` (layouts/public/_layout.php
   in astrology-booking, car-rental-booking, ngo-donor-fund-management,
   temple-donation-management, live-quiz-platform, medical-equipment-rental,
   pg-hostel-booking; layouts/site.php in ca-business-compliance-platform). They
   load this file, and for months not one rule in it matched them: every
   donate/booking/contact field was 15px, so iOS Safari zoomed the page on focus
   — measured 2026-09-26 on 55 of 98 public pages. `body.site` is added ONLY to
   rules that stop a phone from breaking (row wrap, field-row collapse, file
   input, the 16px floor, an over-long button) and NEVER to the look (radius,
   shadow, padding): those sites are designed pages with their own style, and
   the owner did not ask for them to be restyled. Do not "simplify" this by
   adding `public` to those layouts' <body>: that would pull the whole app look
   onto eight marketing sites. Search `body.site` to see every rule it reaches.
   (`sb-site` — site-builder / online-store — does NOT load this file at all.)

   ⚠️ DO NOT ADD `overscroll-behavior-y: none`. app.css sets
   `body{overflow-x:hidden}`, which makes BODY a scroll container whose content
   exactly fits while <html> is what actually scrolls. Forbidding the gesture
   from chaining body -> html freezes the page for wheel and touch, while
   scrollHeight checks and window.scrollTo() both still pass. (Cost the platform
   a day on 2026-09-12; same body rule exists here.)
   ============================================================================ */

body.admin, body.public {
    /* ── Motion. One spring, one decel curve. An app responds; it does not
       animate at you. Anything over ~280ms reads as "the page is loading". */
    --e-out:    cubic-bezier(.32, .72, 0, 1);
    --e-spring: cubic-bezier(.34, 1.36, .64, 1);
    --t-fast: .14s;
    --t-base: .22s;

    /* ── Native-app radii. Phone UI is rounder than web UI; this single step up
       (10/14/18 -> 12/16/20) is most of the difference between "a page of
       boxes" and "an app screen". Overrides app.css's :root values. */
    --radius-sm: 12px;
    --radius:    16px;
    --radius-lg: 20px;
    --r-xl:      24px;

    /* ── Elevation. Tinted, not grey — a neutral drop shadow on a warm surface
       reads as dirt. These are the only shadows this layer uses. */
    --el-1: 0 1px 2px rgba(16,24,40,.05), 0 1px 3px rgba(16,24,40,.04);
    --el-2: 0 2px 4px -1px rgba(16,24,40,.06), 0 6px 16px -6px rgba(16,24,40,.10);
    --el-3: 0 8px 24px -8px rgba(16,24,40,.16), 0 2px 6px -2px rgba(16,24,40,.06);
    --shadow-sm: var(--el-1);
    --shadow:    var(--el-2);
    --shadow-lg: var(--el-3);

    /* ── Functional tints, DELIBERATELY INDEPENDENT OF THE BRAND COLOUR.
       Every app is white-label: --sky/--blue is whatever palette the buyer
       picked, so tinting a dozen metric icons with it produces a dozen squares
       of the same colour. These carry MEANING instead (money is emerald, danger
       is rose) and stay constant, so an icon's colour becomes a landmark.
       The brand colour still owns every primary affordance: buttons, active
       nav, focus rings, links. Contrast-checked against #fff and --bg-2. */
    --t-indigo:  #4338ca; --tb-indigo:  #eef2ff;
    --t-violet:  #6d28d9; --tb-violet:  #f3e8ff;
    --t-rose:    #be123c; --tb-rose:    #ffe4e6;
    --t-orange:  #c2410c; --tb-orange:  #ffedd5;
    --t-amber:   #92400e; --tb-amber:   #fef3c7;
    --t-emerald: #047857; --tb-emerald: #d1fae5;
    --t-teal:    #0f766e; --tb-teal:    #ccfbf1;
    --t-sky:     #0369a1; --tb-sky:     #e0f2fe;
    --t-slate:   #475569; --tb-slate:   #f1f5f9;

    /* Canvas a hair cooler than app.css's --bg so white cards lift off it. */
    --app-canvas: #f6f7f9;
}

/* ============================================================================
   1 · GLOBAL APP BEHAVIOURS + PRESS FEEDBACK
   A web page reacts on mouseup; an app reacts the instant your finger lands.
   ============================================================================ */
body.admin, body.public { -webkit-tap-highlight-color: transparent; }
body.admin a, body.admin button, body.admin .icon-btn,
body.public a, body.public button, body.public .icon-btn { touch-action: manipulation; }

body.admin .admin-sidebar, body.admin .admin-mobile-bar,
body.public .public-header, body.public .tabbar {
    -webkit-user-select: none; user-select: none;
}

body.admin .card, body.admin .stat, body.admin .kv, body.admin .chip,
body.admin .cat-tile, body.admin .btn, body.admin .icon-btn,
body.public .card, body.public .stat, body.public .chip, body.public .btn {
    transition: transform var(--t-fast) var(--e-out),
                box-shadow var(--t-fast) var(--e-out),
                border-color var(--t-fast), background-color var(--t-fast);
}
body.admin a.card:active, body.admin a.stat:active, body.admin .chip:active,
body.admin .cat-tile:active, body.public a.card:active, body.public .chip:active { transform: scale(.975); }
body.admin .btn:active, body.public .btn:active { transform: scale(.97); }

/* ============================================================================
   2 · NUMERALS — the headline fix.

   THE BUG (measured on billora, 375px, 2026-09-18): app.css sets
   `.stat .value { font-size:28px; overflow-wrap:anywhere }`. In a half-width
   card a real money string has nowhere to go, so `overflow-wrap:anywhere`
   breaks it MID-NUMBER: "Rs 67,626." on one line and "00" on the next. Three
   of six cards on the dashboard were broken that way. It reads as a rendering
   fault, and it gets worse as the customer's business grows.

   THE FIX is three things, none of which changes a displayed value:
     (a) tabular figures, so digits share one advance width and columns line up;
     (b) container-query sizing — the number shrinks to fit ITS OWN card rather
         than the viewport, so a long total degrades gracefully instead of
         wrapping. `cqi` falls back to the clamp floor where unsupported;
     (c) never break inside a number. `overflow-wrap:normal` undoes `anywhere`.
   appui.js then demotes a trailing ".00" to a small muted suffix, which buys
   back ~18% of the width for free. The digits themselves are never rewritten.
   ============================================================================ */
body.admin .stats > *, body.public .stats > * { container-type: inline-size; }

body.admin .stat .value, body.public .stat .value,
body.admin .kv .v, body.admin .stat-value, body.admin .ap-num {
    font-variant-numeric: tabular-nums;
    font-feature-settings: "tnum" 1, "cv11" 1;
    overflow-wrap: normal;
    word-break: normal;
    letter-spacing: -.03em;
}
body.admin .stat .value, body.public .stat .value {
    font-size: clamp(18px, 13cqi, 27px);
    line-height: 1.12;
    margin-top: 5px;
}
/* The decimal suffix appui.js splits off. Same colour family, 62% the size —
   present for anyone auditing a figure, silent for everyone scanning. */
body.admin .stat .value .ap-dec, body.public .stat .value .ap-dec,
body.admin .ap-num .ap-dec {
    font-size: .6em; font-weight: 700; color: var(--muted);
    letter-spacing: 0; margin-left: 1px;
}
body.admin .stat.accent .value .ap-dec { color: rgba(255,255,255,.7); }

/* Money and counts inside tables line up under each other. */
body.admin .table td, body.admin .table th,
body.public .table td, body.public .table th { font-variant-numeric: tabular-nums; }

/* ============================================================================
   3 · STAT / METRIC CARDS
   ============================================================================ */
body.admin .stats, body.public .stats {
    /* minmax(0,1fr) — NOT 1fr. A bare 1fr is minmax(auto,1fr), so a track can
       never shrink below its content and one long figure widens the whole row. */
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 10px;
}
@media (min-width: 720px) {
    body.admin .stats, body.public .stats { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
}
body.admin .stat, body.public .stat {
    border-radius: var(--radius);
    padding: 14px 15px;
    box-shadow: var(--el-1);
    min-width: 0;
    position: relative;
    overflow: hidden;
}
body.admin a.stat:hover, body.public a.stat:hover { box-shadow: var(--el-2); border-color: var(--line-2); }
body.admin .stat .label, body.public .stat .label {
    font-size: 12px; font-weight: 600; color: var(--muted);
    min-width: 0; line-height: 1.3;
}
/* A label is a label, not a headline — clip it rather than let two words of
   "Outstanding receivables" push the card's own number out of shape. */
body.admin .stat .label > span, body.public .stat .label > span {
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
}
body.admin .stat .label i, body.public .stat .label i { font-size: 15px; flex: none; opacity: .75; }
body.admin .stat .meta, body.public .stat .meta { font-size: 11.5px; margin-top: 3px; }
body.admin .stat.accent, body.public .stat.accent { box-shadow: 0 10px 24px -12px var(--blue); }

/* ⚠️ THE ACCENT CARD MUST BE RE-ASSERTED, NOT INHERITED.
   app.css says `.stat.accent .label{color:rgba(255,255,255,.85)}` at (0,0,3,0).
   The `body.admin .stat .label` rule above is (0,0,3,1) — one class heavier —
   so it silently won and painted a grey label onto the brand gradient. The
   first screenshot after the change caught it; no assertion would have. Any
   new `.stat .*` rule added above needs its accent twin added here. */
body.admin .stat.accent .label, body.public .stat.accent .label,
body.admin .stat.accent .label i, body.public .stat.accent .label i { color: rgba(255,255,255,.9); opacity: 1; }
body.admin .stat.accent .meta, body.public .stat.accent .meta { color: rgba(255,255,255,.78); }
body.admin .stat.accent .value, body.public .stat.accent .value { color: #fff; }

/* ============================================================================
   4 · CARDS, SURFACES, SECTIONS
   ============================================================================ */
body.admin { background: var(--app-canvas); }
@media (min-width: 1024px) { body.admin { background: var(--app-canvas); } }

body.admin .card, body.public .card {
    border-radius: var(--radius);
    box-shadow: var(--el-1);
    padding: 16px;
}
body.admin .card.lg, body.public .card.lg { border-radius: var(--radius-lg); padding: 22px; }
body.admin .card-title, body.public .card-title {
    font-size: 15px; font-weight: 800; letter-spacing: -.015em; margin-bottom: 12px;
}
body.admin .kv { border-radius: 14px; background: #fff; box-shadow: var(--el-1); border-color: var(--line); }
body.admin .kv .k { font-size: 11.5px; }
body.admin .kv .v { font-size: 15.5px; font-weight: 800; letter-spacing: -.02em; }
body.admin .section-title { font-size: 12px; letter-spacing: .05em; margin: 22px 0 9px; }
body.admin .divider { margin: 14px 0; }

/* ============================================================================
   5 · TABLES — DESKTOP
   The header row stays put while the body scrolls. On a 40-row invoice list
   that is the difference between reading a column and remembering it.
   ============================================================================ */
body.admin .table-wrap, body.public .table-wrap {
    border-radius: var(--radius); box-shadow: var(--el-1);
}
body.admin .table th, body.public .table th {
    font-size: 11px; letter-spacing: .05em; color: var(--muted-2);
    padding: 11px 14px; background: var(--bg-2);
    position: sticky; top: 0; z-index: 2;
}
body.admin .table td, body.public .table td { padding: 12px 14px; }
body.admin .table tr:last-child td, body.public .table tr:last-child td { border-bottom: none; }
body.admin .table tbody tr, body.public .table tbody tr { transition: background-color var(--t-fast); }
body.admin .table tr:hover td, body.public .table tr:hover td { background: var(--bg-2); }
/* `.r` is the canonical right-align class the list views already put on money
   columns — give it the alignment AND the figure treatment in one place. */
body.admin .table .r, body.public .table .r { text-align: right; font-variant-numeric: tabular-nums; }

/* ROW ACTIONS must sit on ONE line. Two 40px icon buttons plus the whitespace
   text node between them come to ~85px, and the action column is often sized
   at ~68-106px, so the second button wrapped under the first — measured on
   hr-payroll, business-card-crm, campus-placement and three others.
   ⚠️ TWO rules are needed, and the second is the one that is easy to miss:
   `app.css` sets `.icon-btn{display:grid}`, which is BLOCK-level, so where a
   view omits the inline `display:inline-grid` the buttons stack no matter what
   `white-space` says. campus-placement was still broken by the nowrap rule
   alone (its column stayed 68px) and only came right once the display was
   fixed too — another consequence of 70 of 112 apps carrying an edited base.
   Scoped with `:has(> .icon-btn)` so it reaches action cells only: a blanket
   `td:last-child` would stop long text wrapping and push tables wider. */
body.admin .table td:has(> .icon-btn), body.public .table td:has(> .icon-btn) { white-space: nowrap; }
body.admin .table td > .icon-btn, body.public .table td > .icon-btn { display: inline-grid; }

/* ============================================================================
   6 · TABLES — MOBILE (<=680px). The density fix.

   THE BUG: app.css turns each <td> into its own full-width row, so a 7-column
   invoice list renders seven stacked lines per record, each repeating an
   uppercase label. Measured on billora at 375px: ONE invoice filled 530px —
   1.5 records per screen, and the words DOCUMENT/TYPE/DATE/CUSTOMER/TOTAL/
   PAYMENT repeated once per row for all 40 rows. That is the "text is too
   much" the brief describes, and it is pure chrome.

   THE FIX: the row becomes a wrapping flex container and each cell becomes a
   compact field (quiet label above, value below), so short fields sit side by
   side instead of each claiming a line. The first cell is promoted to a title
   — safe fleet-wide, because the first column identifies the record in every
   list table there is. Labels stay in the DOM for screen readers; they just
   stop shouting.
   ============================================================================ */
@media (max-width: 680px) {
    /* ⚠️ `:has(.table-as-cards)` IS LOAD-BEARING, NOT A FLOURISH.
       Dropping the wrapper's border and clipping is right ONLY when the table
       inside has become cards — the cards are the frame, and `overflow:hidden`
       would crop their shadow. But `.table-wrap` is also the horizontal SCROLL
       CONTAINER (`overflow-x:auto`) for every table that stays a table, and
       plenty do: a view only becomes cards if its author added
       `table-as-cards`.

       The first version of this rule was unscoped, so on those pages the table
       lost its scroller and pushed the whole PAGE sideways — 375px viewport,
       603px document, every row's text squeezed into three lines. It shipped,
       and it was found by measuring `scrollWidth > clientWidth` on a live demo
       rather than by looking at a page that seemed fine.

       A browser without `:has()` keeps the original bordered, scrolling
       wrapper, which is the safe direction to fail in. */
    body.admin .table-wrap:has(.table-as-cards),
    body.public .table-wrap:has(.table-as-cards) {
        border: none; background: transparent; box-shadow: none; overflow: visible;
    }
    body.admin .table-as-cards tr, body.public .table-as-cards tr {
        display: flex; flex-wrap: wrap; align-items: flex-start;
        column-gap: 16px; row-gap: 8px;
        border: 1px solid var(--line); border-radius: var(--radius);
        background: #fff; box-shadow: var(--el-1);
        padding: 12px 14px; margin-bottom: 8px;
    }
    body.admin .table-as-cards td, body.public .table-as-cards td {
        display: flex; flex-direction: column; align-items: flex-start;
        width: auto; flex: 0 1 auto; min-width: 0; max-width: 100%;
        padding: 0; gap: 1px;
        font-size: 13.5px; font-weight: 600; color: var(--ink-2);
    }
    body.admin .table-as-cards td:before, body.public .table-as-cards td:before {
        content: attr(data-label);
        font-size: 10.5px; font-weight: 600; text-transform: uppercase;
        letter-spacing: .04em; color: var(--muted-2); line-height: 1.4;
    }
    /* A cell with no data-label would otherwise reserve an empty label line. */
    body.admin .table-as-cards td:not([data-label]):before,
    body.public .table-as-cards td:not([data-label]):before { display: none; }

    /* The record's identity: full width, no label, its own line. */
    body.admin .table-as-cards td:first-child, body.public .table-as-cards td:first-child {
        flex: 1 0 100%;
        font-size: 15.5px; font-weight: 700; color: var(--ink);
        letter-spacing: -.015em;
    }
    body.admin .table-as-cards td:first-child:before,
    body.public .table-as-cards td:first-child:before { display: none; }
    body.admin .table-as-cards td:first-child strong { font-weight: 700; }

    /* Row actions ride the title line's right edge instead of owning a line. */
    body.admin .table-as-cards td:last-child:not([data-label]),
    body.public .table-as-cards td:last-child:not([data-label]) {
        margin-left: auto; flex-direction: row; gap: 6px; align-self: center;
    }
    body.admin .table-as-cards td:empty, body.public .table-as-cards td:empty { display: none; }

    /* Right-aligned money keeps its emphasis; it just stops being right-aligned
       in a context where there is no column to align to. */
    body.admin .table-as-cards td.r, body.public .table-as-cards td.r {
        text-align: left; font-weight: 800; color: var(--ink); letter-spacing: -.02em;
    }
    body.admin .table-as-cards .badge, body.public .table-as-cards .badge { margin-top: 1px; }
}

/* ============================================================================
   6b · GRID TRACKS MUST BE ABLE TO SHRINK

   `1fr` means `minmax(auto, 1fr)`, and that `auto` floor is the item's
   min-content width. So a card holding a wide form or table sets the track's
   MINIMUM, and a one-column grid inside a 347px container can resolve to a
   407px track and push the page sideways.

   This was the last shared defect in the fleet — four of the final five
   overflows were exactly it, all on `.split`:
     fleet-management /admin/behavior      407px track in a 347px container
     photography-studio /admin/payments    436px
     solar-amc-management /admin/subsidy   407px
     physiotherapy-clinic /admin/referrals 382px
   and the fifth was `.kv-grid` at 210+225px inside 313px.

   ⭐ THE FIX IS ON THE ITEMS, NOT THE TRACKS. Overriding
   `grid-template-columns` would work (verified, 5/5) but would mean restating
   every breakpoint these utilities use — and app.css has diverged across 70 of
   112 apps, so an app that tuned its own breakpoints would silently inherit the
   skeleton's. `min-width:0` on the CHILD zeroes the automatic minimum that the
   `auto` floor is computed from, which fixes the same 5/5 while touching no
   column definition at all and needing no !important.

   It is a no-op wherever the content already fits: `1fr` and a shrinkable `1fr`
   resolve identically until the floor would have been exceeded, which is
   precisely the broken case.
   ============================================================================ */
body.admin .split > *, body.public .split > *,
body.admin .kv-grid > *, body.public .kv-grid > *,
body.admin .grid-3 > *, body.public .grid-3 > *,
body.admin .cat-grid > *, body.public .cat-grid > * { min-width: 0; }

/* A key/value tile's own text still has to be breakable, or the tile just
   overflows the (now correctly sized) track instead. "Committed (PO)" beside
   a long total was 226px in a 210px track. */
body.admin .kv, body.public .kv,
body.admin .kv .k, body.admin .kv .v { overflow-wrap: break-word; }

/* ============================================================================
   7 · ADMIN SHELL
   ============================================================================ */
body.admin .admin-sidebar { padding: 16px 12px; box-shadow: none; }

/* A CLOSED off-canvas sidebar must be hidden, not merely pushed off-screen.
   `translateX(-100%)` lands its right edge exactly on x=0, and on a non-integer
   device pixel ratio the boundary column still paints — a stray sliver of nav
   text was visible at the left edge of every phone screenshot. Hiding it also
   closes a real accessibility defect that predates this layer: the nav links
   were off-screen but still focusable, so keyboard and screen-reader users tab
   through the whole menu before reaching the page.
   `visibility` is delayed on close so the slide-out is still seen, and
   immediate on open. Desktop (>=1024px) is untouched — there the sidebar is
   `transform:none` and permanently visible. */
@media (max-width: 1023px) {
    body.admin .admin-sidebar {
        visibility: hidden;
        transition: transform var(--t-base) var(--e-out), visibility 0s linear var(--t-base);
    }
    body.admin .admin-sidebar.open {
        visibility: visible;
        transition: transform var(--t-base) var(--e-out), visibility 0s;
        box-shadow: var(--el-3);
    }
}
body.admin .admin-brand { padding: 4px 6px 14px; }
body.admin .admin-brand .logo { border-radius: 13px; box-shadow: var(--el-1); }
/* The brand text column MUST be allowed to shrink. It is a flex item, so its
   min-width defaults to `auto` = its min-content width, and the tagline below
   is `nowrap`, so that min-content IS the whole tagline. The column therefore
   refused to shrink, the ellipsis never fired, and a long tagline ran up to
   ~270px past the 260px sidebar into the main column — measured 2026-09-26 on
   74 of 114 demos, 27 of them far enough to cross the demo bar.
   `min-width:0` is the whole fix; the h1 clamp keeps a buyer's long business
   name from growing the block instead (`break-word` for one unbroken word).
   ⚠️ Do NOT answer this with a z-index on the demo bar: that draws the bar
   over the mobile nav drawer. Every layout's markup is `.logo` + a bare <div>
   (112/112 apps), which is what the selector below relies on. */
body.admin .admin-brand > div:not(.logo) { flex: 1 1 auto; min-width: 0; }
body.admin .admin-brand h1 {
    font-size: 14.5px; letter-spacing: -.02em;
    overflow: hidden; overflow-wrap: break-word;
    display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
}
body.admin .admin-brand p {
    font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
body.admin .admin-nav { gap: 2px; }
body.admin .admin-nav .nav-section { font-size: 10px; padding: 15px 12px 5px; }
body.admin .admin-nav a {
    padding: 9px 12px; border-radius: 11px; font-size: 14px; font-weight: 600;
    gap: 11px; min-width: 0;
}
body.admin .admin-nav a span {
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
}
body.admin .admin-nav a .icon { font-size: 19px; flex: none; }
/* Active item: a filled pill that carries the brand, plus a rail so the eye
   finds it without reading. */
body.admin .admin-nav a.active {
    background: var(--sky-soft); color: var(--blue); font-weight: 700;
    position: relative;
}
body.admin .admin-nav a.active:before {
    content: ""; position: absolute; left: -12px; top: 50%; transform: translateY(-50%);
    width: 3px; height: 20px; border-radius: 0 3px 3px 0; background: var(--blue);
}
body.admin .admin-nav a:active { transform: scale(.985); }
body.admin .admin-user-card { padding-top: 12px; }
body.admin .admin-user-card .name,
body.admin .admin-user-card .role { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* Mobile bar + desktop header. A hairline under a bar nothing has scrolled
   past is a web-page seam — appui.js adds .is-scrolled once the page moves. */
body.admin .admin-mobile-bar {
    background: rgba(255,255,255,.82);
    -webkit-backdrop-filter: saturate(180%) blur(20px);
    backdrop-filter: saturate(180%) blur(20px);
    border-bottom: 1px solid transparent;
    transition: border-color var(--t-base), box-shadow var(--t-base);
    padding: 10px 14px;
}
body.admin .admin-mobile-bar.is-scrolled { border-bottom-color: var(--line); box-shadow: var(--el-1); }
body.admin .admin-header h1 { font-size: 21px; letter-spacing: -.025em; }
body.admin .admin-header p { font-size: 13.5px; }

/* THE HEADER OVERLAP: `.admin-header .row.between` is a non-wrapping flex row,
   so on a phone the action buttons wrap INSIDE their own box and ride up over
   the page title. Let the row wrap and give the actions their own line. */
@media (max-width: 720px) {
    /* ⚠️ THE WRAP ENABLER AND THE FULL-WIDTH RULE MUST SHARE A SCOPE.
       The first version paired an UNSCOPED `.page-actions{width:100%}` with a
       `flex-wrap:wrap` scoped to `.admin-header .row.between`. Most apps use
       that wrapper so both applied and all was well — but photography-studio's
       `.admin-header` is ITSELF `display:flex; flex-wrap:nowrap` with no
       `.row.between` inside. There the width rule landed and the wrap enabler
       did not, so a 343px actions block sat beside the title in a row that
       could not wrap: 470px of content in a 375px viewport, on 10 of its 12
       screens. A rule that is only safe because of a second rule must not be
       able to apply without it. */
    body.admin .admin-header { padding: 16px 16px 6px; flex-wrap: wrap; }
    body.admin .admin-header > :first-child { min-width: 0; }
    body.admin .admin-header .row.between { flex-wrap: wrap; align-items: flex-start; gap: 10px; }
    body.admin .admin-header .row.between > :first-child { flex: 1 1 100%; min-width: 0; }
    body.admin .admin-header h1 { font-size: 19px; }
    body.admin .admin-header .page-actions { width: 100%; flex: 1 1 100%; }
    body.admin .page-actions .search { flex: 1 1 100%; }

    /* THE WHAT'S-NEW BELL IS A HEADER AFFORDANCE, NOT A PAGE ACTION (2026-10-05,
       owner). `.page-actions` gets a full-width line of its own on a phone (the
       rule right above), so on every screen whose header carries no buttons the
       lone bell hung UNDER the page title: ~50px of empty row with one icon
       stranded at the left edge. Measured on lab-management's demo at 375px --
       header 121px tall, bell at x=16. Pinned top-right the header is 71px and
       the bell sits level with the <h1>, which is where a phone puts a bell.
       A screen that DOES have actions keeps the line (that IS the overflow fix
       above) and now right-aligns it, so the row reads as deliberate.
       ⚠ The block the bell came from must COLLAPSE, never hide: `.wn-ov` -- the
       slide-in panel itself -- is a CHILD of `.page-actions`, so `display:none`
       would take the panel with it. `display:contents` drops only the wrapper's
       own box: the bell (absolute), the panel (fixed) and the widget's
       <style>/<script> all keep working and nothing claims a second line.
       Verified live: the panel still opens full-height at 345px wide.
       ⚠ That collapse must NOT give `.page-actions` a `position` -- it would
       become the bell's containing block and anchor `right:12px` to a 0-width
       box off the left edge of the screen.
       `:has()` is the test for "nothing in here but the widget"; where it is
       unsupported the rule simply does not apply and the old layout stands. */
    body.admin .admin-header { position: relative; }
    body.admin .admin-header .wn-bell { position: absolute; top: 10px; right: 12px; z-index: 2; }
    body.admin .admin-header:has(.wn-bell) :is(h1, h1 + p) { padding-right: 46px; }
    body.admin .admin-header .page-actions:not(:has(> :not(.wn-bell, .wn-ov, style, script))) { display: contents; }
    body.admin .admin-header .page-actions { justify-content: flex-end; }
    body.admin .admin-content { padding: 12px 14px calc(28px + env(safe-area-inset-bottom)); }

    /* A `.row` must wrap rather than run off the side.
       `.row` is `display:flex` with NO wrap, which is right on a desktop and
       wrong on a phone: the alternative to wrapping is overflowing. Measured on
       /admin/site across all six site-builder / online-store demos — a
       `<div class="row">` holding [Save] and [View my site] was 302px of space
       for 151px+ of buttons, so the second one sat at x=410 in a 375px
       viewport. Wrapping only engages when the content does not fit, so a row
       that already fits is untouched.
       ⚠️ `.ap-chips` deliberately still wins here: `.row.wrap.ap-chips` is three
       classes against this rule's two, so a chip strip keeps scrolling on one
       line instead of wrapping. Check that if you ever restructure either. */
    body.admin .row, body.public .row, body.site .row { flex-wrap: wrap; }

    /* A file input carries the browser's own "Choose file / no file chosen"
       widget, whose intrinsic width is ~371px — wider than the phone. As a flex
       item its default `min-width:auto` refuses to shrink below that, and it
       drags its form wider with it, so `max-width:100%` alone is circular: 100%
       of a parent the input itself has already stretched. It has to be allowed
       to shrink first. */
    body.admin form.row, body.public form.row, body.site form.row { min-width: 0; }
    body.admin input[type="file"], body.public input[type="file"],
    body.site input[type="file"] { min-width: 0; max-width: 100%; }

    /* `.field-row` must collapse on a narrow screen even when a view has set
       its columns inline.

       app.css already says `@media(max-width:520px){.field-row{grid-template-columns:1fr}}`
       — but AN INLINE STYLE OUTRANKS EVERY MEDIA QUERY, and 9 views across 5
       apps carry one (feedback-survey-platform's analytics filter bar is
       `style="grid-template-columns:2fr 1fr 1fr 1fr auto"`). Its five tracks
       measured 559px inside a 313px card: 639px of document in a 375px
       viewport, the worst single overflow in the fleet. `!important` is the
       only thing that beats an inline declaration — the same remedy the
       platform applied to 37 files on 2026-09-13.

       ⚠️ Measured, not assumed: this still overflowed at 600px, so the
       breakpoint is 720 and not app.css's 520 — a `max-width:520px` rule would
       have fixed the phone and left every tablet broken.

       The track is `minmax(min(100%,240px),1fr)` rather than a flat `1fr` so
       that a field row WITHOUT an inline override keeps exactly the layout
       app.css gives it: 1 column at 375px, 2 at 600px. The rule fixes the
       defect without restyling everyone else's forms. */
    body.admin .field-row, body.public .field-row, body.site .field-row {
        grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)) !important;
    }
}
@media (min-width: 1024px) {
    body.admin .admin-sidebar { border-right: 1px solid var(--line); }
    body.admin .admin-header { padding: 24px 30px 8px; }
    body.admin .admin-content { padding: 12px 30px 44px; }
}

/* ============================================================================
   8 · CONTROLS — buttons, inputs, chips, tabs, pagination
   ============================================================================ */
body.admin .btn, body.public .btn { border-radius: 12px; font-weight: 650; }
body.admin .btn-primary, body.public .btn-primary { box-shadow: 0 6px 14px -8px var(--blue); }
body.admin .btn-secondary, body.public .btn-secondary { box-shadow: var(--el-1); }
body.admin .icon-btn, body.public .icon-btn { border-radius: 11px; box-shadow: var(--el-1); }
body.admin .icon-btn:active, body.public .icon-btn:active { transform: scale(.94); }

body.admin input, body.admin select, body.admin textarea,
body.public input, body.public select, body.public textarea {
    border-radius: 12px; padding: 11px 13px;
    /* 16px is the iOS zoom threshold — anything under it makes Safari zoom the
       page on focus, which then leaves the user zoomed in on a form. */
    font-size: 16px;
}
@media (min-width: 1024px) {
    body.admin input, body.admin select, body.admin textarea { font-size: 14.5px; }
}

/* The public WEBSITE (`body.site`) gets the same 16px floor — but at
   specificity (0,0,1), ON PURPOSE, via `:where()`.

   The 15px on those sites comes from app.css's base
   `input,select,textarea{font-size:15px}`, which is (0,0,1). This rule ties
   it and wins because this file loads after app.css. It then LOSES to every
   control a site sized on purpose with a class, in either direction:
   live-quiz-platform's landing PIN field is `.ld-pin input{font-size:17px}`,
   and a (0,1,2) `body.site input` would have shrunk it to 16px. Measured on
   98 public pages: 40 stopped zooming, zero controls above 16px changed.

   The cost of that choice is that a class-sized control UNDER 16px is not
   rescued here. The one that exists (ca-business-compliance-platform's footer
   newsletter, `.nl-form input{font-size:14px}` in its own site.css) is fixed
   in that file. Probe with `_tools/gate/probe_site.js` after touching this.

   Button-type and choice inputs are excluded: they are not typed into, so they
   cannot trigger the zoom, and a bare <input type=submit> should keep its size.
   A browser without `:where()` drops the whole rule, i.e. keeps today's look. */
:where(body.site) input:where(:not([type="checkbox"], [type="radio"], [type="range"], [type="color"],
        [type="file"], [type="submit"], [type="button"], [type="reset"], [type="image"], [type="hidden"])),
:where(body.site) select,
:where(body.site) textarea { font-size: 16px; }

/* A button label longer than its container must wrap, not push the page.
   app.css makes `.btn` `white-space:nowrap`. On a website, labels carry data —
   astrology-booking's service page CTA is "Book <service name>", and "Book
   Kundali Milan (Marriage Matching)" came to a 384px button in a 335px column:
   the whole page panned sideways and the header was clipped (2026-09-26).
   Measured over every visible button on 98 public pages: 2 of 378 change, and
   both were already wider than their container. A label that fits renders
   exactly as before, because nothing wraps until it has to. `text-align`
   keeps a wrapped label centred like a one-line one (`.btn` centres with
   justify-content, which does not reach the lines inside a text run). */
body.site .btn { max-width: 100%; white-space: normal; text-align: center; }
body.admin .form-group label { font-size: 12.5px; color: var(--ink-2); }

body.admin .badge, body.public .badge { font-size: 11.5px; padding: 3px 9px; font-weight: 700; }
body.admin .chip, body.public .chip { border-radius: 999px; box-shadow: var(--el-1); }
body.admin .tabs a, body.public .tabs a { font-size: 13.5px; padding: 9px 13px; }

/* Tab strips scroll instead of wrapping.
   `.tabs` (canonical) already had `overflow-x:auto`; this only adds the
   momentum, snap and hidden scrollbar that make it feel like a tab strip
   rather than a box with a scrollbar in it.
   `.subtabs` is app-specific CSS in 7 apps and ships `flex-wrap:wrap`, so a
   long sub-navigation wraps to two or three rows on a phone and pushes the
   content it belongs to off the screen. Same treatment, ZERO markup change —
   this file loads after each app's own app.css, which is the whole point of
   the layer.
   ⚠️ NOT `.pill-row`. That one looks identical in the markup but holds
   `<span class="pill">` STATUS BADGES, not links — "Pending · 80G eligible ·
   Receipt #123". You read all of those at once; scrolling two of them out of
   sight would hide state, not save space. */
body.admin .tabs, body.public .tabs,
body.admin .subtabs, body.public .subtabs {
    flex-wrap: nowrap;
    overflow-x: auto;
    scroll-snap-type: x proximity;
    -webkit-overflow-scrolling: touch;
    scrollbar-width: none;
}
body.admin .tabs::-webkit-scrollbar, body.public .tabs::-webkit-scrollbar,
body.admin .subtabs::-webkit-scrollbar, body.public .subtabs::-webkit-scrollbar { display: none; }
body.admin .tabs > *, body.public .tabs > *,
body.admin .subtabs > *, body.public .subtabs > * {
    flex: none; scroll-snap-align: start; white-space: nowrap;
}
body.admin .pagination a, body.admin .pagination span { border-radius: 10px; box-shadow: var(--el-1); }
/* A long unbreakable string must not push the page sideways.

   THE DEFECT (measured live 2026-09-18, 11 apps): the Settings colour-palette
   picker renders each option's description inside `.cat-tile`. Several of the
   seeded descriptions end in a parenthetical disambiguation list —
   "(steel-blue clinical grey, distinct from
   teal/mint/enamel/sage/sprout/steel/slate/periwinkle)" — and that slash-joined
   run is ONE ~50-character word with no break opportunity in it. The text
   wrapped happily for four lines and then the last line ran 281px out of a
   152px tile, taking the whole document to 483px in a 375px viewport.

   `break-word` and not `anywhere`: it breaks a word only when the word cannot
   fit on a line of its own, so ordinary prose still wraps between words.

   ⚠️ Deliberately NOT applied to `.stat .value`, which sets
   `overflow-wrap:normal` a few sections up for the opposite reason — a figure
   must never break mid-number. Those two rules look contradictory and are not:
   prose that cannot fit should break, a number that cannot fit should shrink.
*/
body.admin .cat-tile, body.admin .cat-tile span,
body.public .cat-tile, body.public .cat-tile span {
    overflow-wrap: break-word;
    min-width: 0;
}

body.admin .empty { padding: 38px 20px; }
body.admin .empty i { font-size: 40px; }
body.admin .msg, body.public .msg { border-radius: 12px; box-shadow: var(--el-1); }
body.admin .avatar { box-shadow: var(--el-1); }

/* Toolbar: the search field is the point, so it gets the room on a phone. */
@media (max-width: 680px) {
    body.admin .toolbar { gap: 8px; }
    body.admin .toolbar .search { flex: 1 1 100%; }
}

/* ============================================================================
   9 · PUBLIC / CUSTOMER SHELL
   ============================================================================ */
body.public .tabbar {
    background: rgba(255,255,255,.88);
    -webkit-backdrop-filter: saturate(180%) blur(20px);
    backdrop-filter: saturate(180%) blur(20px);
}
body.public .tabbar a { font-size: 10.5px; gap: 2px; }
body.public .tabbar a i { font-size: 21px; }
body.public .tabbar a:active i { transform: scale(.88); transition: transform var(--t-fast) var(--e-out); }
body.public .public-header {
    -webkit-backdrop-filter: saturate(180%) blur(20px);
    backdrop-filter: saturate(180%) blur(20px);
}

/* ============================================================================
   10 · OPT-IN HELPERS (`.ap-*`). Nothing above depends on these; a view adopts
   one when it is next touched. Namespaced so they can never collide with the
   112 app-specific blocks appended to the various app.css copies.
   ============================================================================ */

/* A filter-chip strip that scrolls instead of wrapping to three lines.
   Put `.ap-chips` on the existing `.row.wrap` that holds the filter buttons.
   Measured on billora at 375px: seven filter buttons wrapped to 3 lines and ate
   129px — a sixth of the screen — before a single record was visible.

   ⚠️ ALL THREE SELECTORS ARE REQUIRED. The strip is almost always an existing
   `<div class="row wrap">`, and `.row.wrap` is (0,0,2,0) — it beats a lone
   `.ap-chips` at (0,0,1,0), so `flex-wrap:wrap` wins and the class appears to
   do nothing at all. It silently failed exactly that way the first time.
   Listing the real-world shapes is clearer than an !important. */
.ap-chips,
.row.ap-chips,
.row.wrap.ap-chips {
    display: flex; flex-wrap: nowrap; gap: 7px;
    overflow-x: auto; scroll-snap-type: x proximity;
    -webkit-overflow-scrolling: touch;
    scrollbar-width: none;
    margin: 0 -14px 14px; padding: 2px 14px 4px;
}
.ap-chips::-webkit-scrollbar { display: none; }
.ap-chips > * { flex: none; scroll-snap-align: start; }
@media (min-width: 1024px) {
    .ap-chips, .row.ap-chips, .row.wrap.ap-chips {
        flex-wrap: wrap; overflow: visible; margin-inline: 0; padding-inline: 0;
    }
}

/* Tinted icon chip for metric cards and list rows. */
.ap-ic {
    width: 32px; height: 32px; border-radius: 10px; flex: none;
    display: grid; place-items: center; font-size: 17px;
    background: var(--tb-slate); color: var(--t-slate);
}
.t-indigo  { background: var(--tb-indigo);  color: var(--t-indigo); }
.t-violet  { background: var(--tb-violet);  color: var(--t-violet); }
.t-rose    { background: var(--tb-rose);    color: var(--t-rose); }
.t-orange  { background: var(--tb-orange);  color: var(--t-orange); }
.t-amber   { background: var(--tb-amber);   color: var(--t-amber); }
.t-emerald { background: var(--tb-emerald); color: var(--t-emerald); }
.t-teal    { background: var(--tb-teal);    color: var(--t-teal); }
.t-sky     { background: var(--tb-sky);     color: var(--t-sky); }
.t-slate   { background: var(--tb-slate);   color: var(--t-slate); }

/* Trend chip. `flat` exists so an unchanged metric can say so rather than
   showing a misleading green arrow or nothing at all. */
.ap-delta {
    display: inline-flex; align-items: center; gap: 3px;
    padding: 2px 7px; border-radius: 999px;
    font-size: 11px; font-weight: 700; letter-spacing: 0;
    background: var(--tb-emerald); color: var(--t-emerald);
}
.ap-delta.down { background: var(--tb-rose); color: var(--t-rose); }
.ap-delta.flat { background: var(--gray-soft); color: var(--gray-badge); }
.ap-delta i { font-size: 12px; }

/* A section heading with an optional right-hand action. */
.ap-sec { display: flex; align-items: baseline; gap: 10px; margin: 22px 2px 10px; }
.ap-sec:first-child { margin-top: 2px; }
.ap-sec h2 { font-size: 15.5px; font-weight: 800; letter-spacing: -.02em; color: var(--ink); }
.ap-sec p { font-size: 12.5px; color: var(--muted); margin-top: 1px; }
.ap-sec .ap-sec-act { margin-left: auto; font-size: 12.5px; font-weight: 700; color: var(--blue); white-space: nowrap; }

/* ============================================================================
   10a · TODAY BAND · DUE LIST · NAV COUNTS — the automobile-dealership
   dashboard, made fleet-wide (owner, 2026-10-08: "make it for all the apps").

   A dashboard opens with a greeting + the date (controller title/subtitle),
   then `.kpi-today`: at most FOUR bold tiles for what needs action TODAY, each
   a link to the list behind it. The quieter totals stay in `.stats` below it,
   with no `.accent` (the band is the accent now). Then `.dash-row` cards
   holding `.list-line` rows: initials, name + one line, a badge, a time.

   Tones, in this order of use:
     k-night  dark   — the day's headline count
     k-brand  brand  — money or the main transaction (`k-red` is the same tile;
                       automobile-dealership shipped that name first)
     k-white  plain  — the next thing on the schedule
     k-amber  amber  — overdue / needs attention. Reserve it for that.

   A tile's number is `.v`; add `ap-num` for money so appui.js demotes ".00".
   ⚠️ Everything is scoped `body.admin`, and none of these names existed in any
   app before this section (checked across 112 apps on 2026-10-08).
   ============================================================================ */
body.admin .kpi-today { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-bottom: 12px; }
@media (min-width: 900px) {
    /* One row of however many tiles the role can see (2–4), all equal. */
    body.admin .kpi-today { grid-template-columns: none; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); }
}
body.admin .kpi-today > * {
    border-radius: 16px; padding: 16px 18px; min-width: 0;
    display: flex; flex-direction: column; gap: 2px;
    text-decoration: none; container-type: inline-size;
    box-shadow: var(--el-1);
}
body.admin .kpi-today > a:hover { box-shadow: var(--el-2); }
body.admin .kpi-today > a:active { transform: scale(.975); }
body.admin .kpi-today .l { font-size: 13px; font-weight: 600; opacity: .9; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
body.admin .kpi-today .v {
    font-size: clamp(20px, 15cqi, 28px); font-weight: 800; letter-spacing: -.03em; line-height: 1.15;
    font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
body.admin .kpi-today .s { font-size: 12.5px; opacity: .85; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
body.admin .kpi-today .k-night { background: #0b1220; color: #fff; }
body.admin .kpi-today .k-brand,
body.admin .kpi-today .k-red   { background: var(--blue); color: #fff; box-shadow: 0 10px 24px -12px var(--blue); }
body.admin .kpi-today .k-white { background: #fff; color: var(--ink); border: 1px solid var(--line); }
body.admin .kpi-today .k-amber { background: #fef3c7; color: #78350f; }
body.admin .kpi-today .k-night .ap-dec, body.admin .kpi-today .k-brand .ap-dec,
body.admin .kpi-today .k-red .ap-dec { color: rgba(255,255,255,.7); }
body.admin .kpi-today .k-amber .ap-dec { color: #92400e; }

body.admin .dash-row { display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 16px; }
body.admin .dash-row > .card { flex: 1 1 340px; min-width: 0; margin: 0; }
body.admin .dash-row > .card.wide { flex: 1.4 1 460px; }

body.admin .list-line {
    display: flex; align-items: center; gap: 12px; padding: 11px 0; min-width: 0;
    border-top: 1px solid var(--bg-2); color: var(--ink); text-decoration: none;
}
body.admin .list-line:first-of-type { border-top: 0; }
body.admin a.list-line:hover .t strong { color: var(--blue); }
body.admin .list-line .ini {
    width: 38px; height: 38px; border-radius: 999px; flex: none;
    display: grid; place-items: center; font-size: 13px; font-weight: 700;
    background: var(--bg-2); color: var(--ink-2);
}
body.admin .list-line .t { flex: 1; min-width: 0; display: flex; flex-direction: column; }
body.admin .list-line .t strong { font-size: 14.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
body.admin .list-line .t span { font-size: 13px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
body.admin .list-line .badge { flex: none; max-width: 40%; overflow: hidden; text-overflow: ellipsis; }
body.admin .list-line .when { font-size: 13px; color: var(--ink-2); white-space: nowrap; flex: none; font-variant-numeric: tabular-nums; }
body.admin .list-line .when.late { color: #b91c1c; font-weight: 700; }
body.admin .list-line .when.strong { font-weight: 700; color: var(--ink); }
body.admin .dash-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 6px; }
body.admin .dash-head .card-title { margin: 0; }
body.admin .dash-head a { font-size: 13px; font-weight: 600; white-space: nowrap; }

/* Sidebar counts: what is waiting in that list right now. Counts are the
   layout's to compute and must never break the page (wrap them in try). */
body.admin .admin-nav a .nav-count {
    margin-left: auto; flex: none; font-size: 11.5px; font-weight: 700; line-height: 1.6;
    padding: 1px 8px; border-radius: 999px; background: var(--blue); color: #fff;
}
body.admin .admin-nav a.active .nav-count { background: var(--blue-dark); }
body.admin .notif-btn { position: relative; }
body.admin .notif-btn .dot { position: absolute; top: 8px; right: 9px; width: 8px; height: 8px; border-radius: 999px; background: var(--blue); }
/* On a phone the What's-new bell is pinned top-right (§7) and an actions row holding only the bell
   collapses. The inbox icon is the same kind of header affordance, so it pins beside the bell and
   counts as "only header icons" — otherwise it sat alone on a row of its own under the title
   (seen on the 12 inbox apps at 375px, 2026-10-08). 40px icon + 8px gap from the bell. */
@media (max-width: 720px) {
    body.admin .admin-header .notif-btn { position: absolute; top: 10px; right: 12px; z-index: 2; }
    body.admin .admin-header:has(.wn-bell) .notif-btn { right: 60px; }
    body.admin .admin-header:has(.notif-btn) :is(h1, h1 + p) { padding-right: 48px; }
    body.admin .admin-header:has(.notif-btn):has(.wn-bell) :is(h1, h1 + p) { padding-right: 96px; }
    body.admin .admin-header .page-actions:not(:has(> :not(.wn-bell, .wn-ov, .notif-btn, style, script))) { display: contents; }
}

/* ============================================================================
   11 · MOTION PREFERENCES — last, so it wins over everything above.
   ============================================================================ */
@media (prefers-reduced-motion: reduce) {
    body.admin *, body.public * {
        animation-duration: .001ms !important;
        transition-duration: .001ms !important;
    }
    body.admin .btn:active, body.admin .icon-btn:active,
    body.admin a.card:active, body.admin a.stat:active,
    body.public .btn:active { transform: none; }
}

/* ============================================================================
   12 · PRINTABLE DOCUMENTS — the one DELIBERATELY UNSCOPED rule in this file.

   Everything else here is scoped to `body.admin` / `body.public`. The printable
   documents — statement, invoice, receipt — render through the `bare` layout,
   whose <body> carries NO class at all, so every rule above is inert on them.
   That is why they were the last customer-facing defect left: appui.css is
   loaded on those pages and simply never matched anything.

   THE DEFECT: /portal/statement is a raw 6-column <table> with no `.table-wrap`
   around it. Measured live as the CUSTOMER on 2026-09-18: a 406px table in a
   375px viewport, 433px of document — on six apps (the whole billing family).
   A shop's own customer opens their account statement on a phone and it runs
   off the side.

   ⚠️ `@media screen` IS LOAD-BEARING. These pages auto-print; a bare
   `max-width` query would follow the document onto paper and turn a printed
   statement into a clipped, scrollable box. Print keeps the real table.

   `.doc` is defined in the canonical block, so this is still an override of
   something app.css owns — the only difference is that it cannot be scoped by
   body class, because on this surface there is no body class to scope to.
   ============================================================================ */
@media screen and (max-width: 720px) {
    .doc table {
        display: block;
        overflow-x: auto;
        -webkit-overflow-scrolling: touch;
        max-width: 100%;
    }
}

@media print {
    body.admin .card, body.admin .stat, body.admin .table-wrap { box-shadow: none !important; }
    body.admin .table th { position: static; }
}
