/* ============================================================================
 *  Luuqo interaction FX — smooth page transitions, sidebar-pill slide,
 *  button loading states, confirm modal. Loaded by every shell + login page.
 * ========================================================================== */

/* ── Cross-page content fade guard ───────────────────────────────────────────
 *  This is a plain multi-page app. We keep <main> visible by default because
 *  CSS keyframe fades can get paused/stuck by embedded browsers and leave the
 *  entire page at opacity:0. Sidebar/menu/modal motion lives below; page content
 *  should never depend on an animation finishing before it can be read. */
/* The end state MUST be `transform: none`, not `translateY(0)`. With
   `animation-fill-mode: both` the final keyframe sticks, and ANY non-none
   transform makes <main> a containing block for `position: fixed` descendants —
   which trapped every module's modals inside <main>'s box, so the backdrop
   stopped at the sidebar and the chrome painted over the dialog. `none`
   interpolates as identity, so the slide-in looks exactly the same. */
@keyframes lk-page-enter { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: none; } }
@keyframes lk-fade-in { from { opacity: 0; } to { opacity: 1; } }
/* Subtle fade-in on each page load — the only page transition (no loading
   overlay). Kept short so it never feels like a wait.
   OPACITY ONLY (lk-fade-in), never a transform: with animation-fill-mode `both`
   a transform PERSISTS on <main>, and any non-none transform makes the element a
   containing block for position:fixed — which anchored every modal/overlay to the
   content area instead of the viewport (shifted dialogs, backdrops that only
   covered part of the screen, clipped dropdowns). */
main.lk-page-enter {
    animation: lk-fade-in .16s ease both;
}

/* ── Cross-document page transitions (browser-native, safe) ──────────────────
 *  Opt into the View Transitions API so full-page navigations cross-fade
 *  instead of hard-cutting. The browser snapshots old+new and animates the
 *  swap itself — unlike a CSS keyframe this can NEVER leave content stuck at
 *  opacity:0, and browsers without support just navigate instantly. This is
 *  what gives the "smooth fade when I go to another page" the founder wanted. */
@view-transition { navigation: auto; }
::view-transition-old(root),
::view-transition-new(root) { animation-duration: .22s; }
@media (prefers-reduced-motion: reduce) {
    @view-transition { navigation: none; }
}

/* ── Button loading state (luuqo.js swaps the label for a spinner) ── */
.lk-spin {
    display: inline-block; width: 14px; height: 14px; vertical-align: -2px;
    border: 2px solid currentColor; border-top-color: transparent;
    border-radius: 50%; animation: lk-spin .6s linear infinite;
}
@keyframes lk-spin { to { transform: rotate(360deg); } }
button[disabled], .btn[disabled] { cursor: not-allowed; opacity: .65; }

/* ── Photo-tile loading state (luuqo.js injects .lk-tile-loader while a chosen
 *    image is read → cropped → applied). Dark translucent veil + white spinner
 *    over the little upload tile; theme-neutral so it works on any surface. ── */
.lk-tile-loader {
    position: absolute; inset: 0; z-index: 5;
    display: flex; align-items: center; justify-content: center;
    background: rgba(15, 23, 42, .55);
    border-radius: inherit;
    -webkit-backdrop-filter: blur(1px); backdrop-filter: blur(1px);
}
.lk-tile-spin {
    display: inline-block; width: 26px; height: 26px;
    border: 3px solid rgba(255, 255, 255, .35); border-top-color: #ffffff;
    border-radius: 50%; animation: lk-spin .6s linear infinite;
}
@media (prefers-reduced-motion: reduce) { .lk-tile-spin { animation: none; } }

/* ── Confirm modal ── */
/* z-index 10020 = the CONFIRM rung, and it sits ABOVE the 10000 DIALOG rung on
   purpose. This is the [data-lk-confirm] dialog luuqo.js appends to <body>, and
   it is the one layer that is almost always raised FROM another dialog: the
   Upgrade button lives inside the plan modal (.lk-dialog-backdrop, 10000), the
   Revoke button inside an lk_overlay_start() dialog (.lk-overlay, 10000). At
   9999 it lost to both. Measured before the change, with the plan modal open and
   the confirm raised: document.elementFromPoint at the confirm's OWN centre
   returned the plan modal's text, so Cancel and Upgrade could not be clicked at
   all and Escape was the only way out — not a cosmetic bug.

   It stays BELOW the 10050 TOAST and 10100 LOCK rungs (see .pos-modal in
   modules/pos-system/page.php): a toast must remain readable over anything, and
   nothing may float above the register's PIN gate. Tour (10300) and welcome
   (10500) are likewise untouched. */
.lk-modal-overlay {
    position: fixed; inset: 0; z-index: 10020;
    background: rgba(15, 23, 42, .45); backdrop-filter: blur(2px);
    display: flex; align-items: center; justify-content: center;
    opacity: 0; transition: opacity .18s ease;
}
.lk-modal-overlay.is-open { opacity: 1; }
.lk-modal {
    background: #fff; color: #0f172a; border-radius: 18px;
    max-width: 420px; width: calc(100% - 40px); padding: 24px;
    box-shadow: 0 24px 60px rgba(15, 23, 42, .28);
    transform: translateY(14px) scale(.97); opacity: 0;
    transition: transform .24s cubic-bezier(.16, 1, .3, 1), opacity .24s ease;
    font-family: Inter, system-ui, sans-serif;
    /* IT MUST NEVER GROW PAST THE SCREEN. The overlay centres this box and had no
       height cap, so a long message (an inactive-item confirm naming a negative
       balance, or one naming four packages built from the item) made the box
       taller than the viewport: measured 404px tall in a 262px-high landscape
       phone viewport, top at -71px, with Cancel and the confirm button BELOW the
       fold and nothing to scroll — a dialog the user could neither accept nor
       dismiss. Capped and scrollable, the buttons are always reachable.
       dvh second so browsers that have it use the visible height (a mobile
       toolbar makes 100vh taller than what you can see); older ones keep vh. */
    max-height: calc(100vh - 2rem);
    max-height: calc(100dvh - 2rem);
    overflow-y: auto;
    overscroll-behavior: contain;    /* don't scroll the page behind at the ends */
}
.lk-modal-overlay.is-open .lk-modal { transform: none; opacity: 1; }
.lk-modal-ico { width: 44px; height: 44px; border-radius: 12px; display: flex; align-items: center; justify-content: center; background: #fef2f2; color: #dc2626; font-size: 18px; margin-bottom: 14px; }
.lk-modal h3 { font-weight: 700; font-size: 17px; margin: 0 0 6px; }
.lk-modal p { color: #475569; font-size: 14px; line-height: 1.5; margin: 0 0 20px; }
.lk-modal-actions { display: flex; gap: 10px; justify-content: flex-end; }
.lk-modal-busy { pointer-events: none; opacity: .75; }

/* ── Image crop modal ── */
.lk-crop-overlay { overflow-y: auto; }
.lk-crop-modal { max-width: 380px; margin: auto; text-align: left; }
.lk-crop-hint { color: #64748b; font-size: 12px; margin: 0 0 12px; }
.lk-crop-stage {
    position: relative; overflow: hidden; border-radius: 14px;
    background: #0f172a; margin: 0 auto 16px; touch-action: none;
    cursor: grab; box-shadow: inset 0 0 0 1px rgba(255,255,255,.12);
}
.lk-crop-stage:active { cursor: grabbing; }
.lk-crop-img { position: absolute; top: 0; left: 0; max-width: none; user-select: none; -webkit-user-drag: none; pointer-events: none; }
.lk-crop-zoom { width: 100%; margin: 0 0 20px; accent-color: #4f46e5; cursor: pointer; }

/* Shared dialog contract for app modules.
 * - Backdrop/card z-index and blur live here, not inside individual modules.
 * - Alpine modals should add x-transition on the backdrop/card.
 * - Plain JS fallbacks can add lk-dialog-pre-enter before showing, then remove
 *   it on the next frame; add lk-dialog-closing before hiding.
 * - Page scroll is locked by window.lkSetModalOpen(true/false). */
html.lk-modal-open,
body.lk-modal-open {
    overflow: hidden !important;
    overscroll-behavior: contain;
}
.lk-dialog-backdrop {
    position: fixed;
    inset: 0;
    z-index: 10000;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 1rem;
    background: rgba(15, 23, 42, .58);
    backdrop-filter: blur(7px) saturate(.88);
    opacity: 1;
    transition: opacity .18s ease-out, backdrop-filter .18s ease-out;
    will-change: opacity, backdrop-filter;
}
/* Repeated class is intentional: it beats the generic .card transition rule
 * without using !important, so dialog enter/leave transforms stay predictable. */
.lk-dialog-card.lk-dialog-card {
    max-height: calc(100dvh - 2rem);
    overflow: auto;
    transform-origin: center;
    opacity: 1;
    transform: translate3d(0, 0, 0) scale(1);
    transition:
        transform .24s cubic-bezier(.16, 1, .3, 1),
        opacity .24s ease-out;
    will-change: opacity, transform;
}

/* .lk-dialog-foot — a dialog's action bar, flush to the card and always in reach.
 *
 * Two problems, one rule.
 *
 * FLUSH. A footer inside a padded form was reaching for `-mx-5 -mb-5` to cancel
 * that padding, and the frozen build emits no negative-margin utilities at all,
 * so its `border-t` hairline floated ~20px in from BOTH card edges with a 20px
 * dead strip under the buttons — the classic unfinished look, on the dialog that
 * grants and revokes staff permissions. modules/item-master/page.php solves the
 * same problem structurally (card `p-0`, padded children) and that is still the
 * right shape for a NEW modal; this exists for the ones already built around a
 * padded form, where the choice is a real declaration here or dead classes there.
 *
 * IN REACH. .lk-dialog-card is itself the scrollport (max-height + overflow:auto
 * above), so `position: sticky` resolves against the card and pins the bar to the
 * card's bottom edge — which is why Save no longer sits below a 24rem permission
 * list. Only from 640px up: on a phone the card is nearly the whole screen and a
 * pinned bar costs more height than it saves.
 *
 * The background must be painted or the content scrolls through it. --app-surface
 * is the correct token in every theme: `.card` is declared `background:
 * var(--app-surface)`, and Midnight's card override is scoped
 * `.card:not(.lk-dialog-card)`, so a dialog card keeps that variable throughout. */
.lk-dialog-foot {
    margin-left: -1.25rem;
    margin-right: -1.25rem;
    margin-bottom: -1.25rem;
    padding: 1rem 1.25rem;
}
@media (min-width: 640px) {
    .lk-dialog-foot {
        position: sticky;
        bottom: 0;
        z-index: 1;
        background: var(--app-surface);
        border-bottom-left-radius: inherit;
        border-bottom-right-radius: inherit;
    }
}
.lk-dialog-backdrop.lk-dialog-pre-enter {
    opacity: 0;
    backdrop-filter: blur(0) saturate(1);
}
.lk-dialog-backdrop.lk-dialog-pre-enter .lk-dialog-card {
    opacity: 0;
    transform: translate3d(0, 18px, 0) scale(.965);
}
.lk-dialog-backdrop.lk-dialog-closing {
    pointer-events: none;
    opacity: 0;
    backdrop-filter: blur(0) saturate(1);
    transition-duration: .14s;
}
.lk-dialog-backdrop.lk-dialog-closing .lk-dialog-card {
    opacity: 0;
    transform: translate3d(0, 10px, 0) scale(.985);
    transition-duration: .14s;
}
.card.lk-dialog-card:hover,
.admin-card.lk-dialog-card:hover {
    transform: translate3d(0, 0, 0) scale(1);
}
/* flex-start, not center, because `align-items: center` CLIPS the top of a
   dialog taller than the viewport inside a scrolling container — you can never
   scroll back up to it. The cost is that a SHORT dialog then sits pinned to the
   top: "Manage your plan" with two plans floated high while the confirm dialog
   next to it looked centred, which is what gave it away.

   `margin: auto` on the card fixes both at once. On a flex item it absorbs the
   free space on BOTH axes, so a dialog that fits is centred, and one that does
   not has no free space to absorb and simply scrolls from its top edge. */
.lk-dialog-scroll {
    align-items: flex-start;
    overflow-y: auto;
}
.lk-dialog-scroll > .lk-dialog-card { margin: auto; }
@supports not (height: 100dvh) {
    .lk-dialog-card { max-height: calc(100vh - 2rem); }
}

/* Shared on/off switch used by module settings pages and generated module
 * scaffolds. Keep this independent from Tailwind dynamic classes: Alpine
 * :class values are not always present in the built CSS, so the knob position
 * must be controlled by plain CSS tied to the real checkbox state. */
.lk-switch {
    position: relative;
    display: inline-flex;
    align-items: center;
    width: 2.75rem;
    height: 1.5rem;
    flex: 0 0 auto;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
}
.lk-switch input {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
}
.lk-switch-track {
    position: absolute;
    inset: 0;
    border-radius: 999px;
    background: #cbd5e1;
    border: 1px solid #94a3b8;
    box-shadow: inset 0 1px 2px rgba(15, 23, 42, .08);
    transition: background-color .18s ease, box-shadow .18s ease;
}
.lk-switch-knob {
    position: absolute;
    top: .1875rem;
    left: .1875rem;
    width: 1.25rem;
    height: 1.25rem;
    border-radius: 999px;
    background: #fff;
    box-shadow:
        0 1px 2px rgba(15, 23, 42, .16),
        0 4px 10px rgba(15, 23, 42, .10);
    transform: translateX(0);
    transition: transform .18s cubic-bezier(.16, 1, .3, 1);
}
.lk-switch input:checked + .lk-switch-track {
    background: #10b981;
    border-color: #059669;
    box-shadow: inset 0 0 0 1px rgba(5, 150, 105, .18);
}
.lk-switch input:checked + .lk-switch-track + .lk-switch-knob {
    transform: translateX(1.1875rem);
}
.lk-switch input:focus-visible + .lk-switch-track {
    outline: 3px solid rgba(16, 185, 129, .25);
    outline-offset: 3px;
}
.lk-switch input:disabled + .lk-switch-track,
.lk-switch input:disabled + .lk-switch-track + .lk-switch-knob {
    opacity: .55;
    cursor: not-allowed;
}

/* Backward-compatible safety net for any older generated switch markup that
 * still uses Tailwind peer classes. This prevents the exact mobile symptom
 * where the track turns green but the white knob stays on the left because
 * the dynamic translate class was not included in the compiled CSS. */
.peer:checked ~ .peer-checked\:translate-x-5 {
    transform: translateX(1.25rem) !important;
}
.peer:checked ~ .peer-checked\:translate-x-full {
    transform: translateX(100%) !important;
}
.peer:checked ~ .peer-checked\:after\:translate-x-full::after {
    transform: translateX(100%) !important;
}

/* ── Tailwind classes the (stale) compiled build is MISSING — supplied here so
 *    existing markup works without a rebuild (no build toolchain in this repo).
 *    Each verified absent from public/build/assets/app-*.css. Same trap as the
 *    peer-checked translate above. */
/* non-responsive arbitrary utilities the build missed */
.text-\[5px\] { font-size: 5px; }
.text-\[12px\] { font-size: 12px; line-height: 1rem; }
.min-h-\[60vh\] { min-height: 60vh; }
.tracking-\[0\.24em\] { letter-spacing: 0.24em; }
.tracking-\[0\.22em\] { letter-spacing: 0.22em; }
@media (min-width: 640px) {
    .sm\:max-w-\[220px\] { max-width: 220px; }
    .sm\:grid-cols-\[minmax\(0\,1fr\)_minmax\(0\,1fr\)_auto\] { grid-template-columns: minmax(0,1fr) minmax(0,1fr) auto; }
}
@media (min-width: 1024px) {
    .lg\:p-5 { padding: 1.25rem; }
    .lg\:text-right { text-align: right; }
    .lg\:justify-between { justify-content: space-between; }
    .lg\:grid-cols-\[minmax\(0\,1fr\)_360px\] { grid-template-columns: minmax(0,1fr) 360px; }
}
@media (min-width: 1280px) {
    .xl\:grid-cols-\[minmax\(0\,1fr\)_360px\] { grid-template-columns: minmax(0,1fr) 360px; }
    .xl\:grid-cols-\[360px_minmax\(0\,1fr\)\] { grid-template-columns: 360px minmax(0,1fr); }
}
@media (min-width: 1536px) {
    .\32 xl\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0,1fr)); }
}

/* ── Generic hover-lift (opt-in via .lk-lift) ── */
@media (hover: hover) {
    .lk-lift { transition: transform .18s ease, box-shadow .2s ease; will-change: transform; }
    .lk-lift:hover { transform: translateY(-3px); }
}

/* ── Respect reduced-motion ── */
@media (prefers-reduced-motion: reduce) {
    main.lk-page-enter { animation: none; }
    .lk-spin { animation: none; }
    /* .lk-overlay belongs here as much as the legacy names: app/overlay.php
       emits x-transition.opacity on it, and a user who asked for less motion was
       still getting the fade. */
    .lk-modal, .lk-modal-overlay, .lk-overlay { transition: none; }
    .lk-dialog-backdrop,
    .lk-dialog-card,
    .lk-dialog-backdrop.lk-dialog-closing,
    .lk-dialog-backdrop.lk-dialog-closing .lk-dialog-card {
        transition: none;
    }
}

/* ============================================================================
 *  Tasteful BEAUTIFY pass — ADDITIVE polish only (shadow / border / hover).
 *  Never changes layout, size, spacing, or theme colors. Shared by the client
 *  app (.card) and admin (.admin-card), which use an identical glass base.
 *  The hover-lift / glow live behind @media (hover: hover) and are stripped
 *  under prefers-reduced-motion so nothing moves for motion-sensitive users.
 * ========================================================================== */

/* Cards: smooth the existing layered shadow so it can animate on hover. The
 * base box-shadow stays exactly as defined in app.css / header_admin.php —
 * we only add a transition + a 1px hairline ring that reads as crisper edges. */
.card,
.admin-card {
    transition: box-shadow .26s cubic-bezier(.16, 1, .3, 1),
                transform   .26s cubic-bezier(.16, 1, .3, 1),
                border-color .26s ease;
}

@media (hover: hover) {
    /* Gentle lift: rise 2px and deepen the SAME navy shadow language used by
     * the base, keeping the inner top highlight so the glass look holds. */
    .card:hover,
    .admin-card:hover {
        transform: translateY(-2px);
        box-shadow:
            0 18px 40px -16px rgba(30, 41, 99, .28),
            0 6px 14px -6px rgba(30, 41, 99, .12),
            inset 0 1px 0 rgba(255, 255, 255, .7);
        border-color: rgba(255, 255, 255, .9);
    }
}

/* Stat tiles: a soft ring + faint glow gives the icon chip presence without
 * touching its size or background colour. */
.stat-icon {
    box-shadow:
        0 1px 2px rgba(30, 41, 99, .08),
        inset 0 0 0 1px rgba(255, 255, 255, .55);
    transition: box-shadow .26s cubic-bezier(.16, 1, .3, 1),
                transform .26s cubic-bezier(.16, 1, .3, 1);
}
@media (hover: hover) {
    .card:hover .stat-icon,
    .admin-card:hover .stat-icon {
        box-shadow:
            0 6px 16px -6px rgba(79, 70, 229, .35),
            inset 0 0 0 1px rgba(255, 255, 255, .65);
    }
}

/* Primary button: refined resting shadow + a soft indigo glow on hover. The
 * background colour transition is owned by app.css; we only add the glow, so
 * no theme colour is overridden. */
.btn-primary {
    box-shadow:
        0 1px 2px rgba(30, 41, 99, .14),
        0 4px 12px -4px rgba(79, 70, 229, .35);
    transition: background .15s, transform .05s, box-shadow .22s ease;
}
@media (hover: hover) {
    .btn-primary:hover {
        box-shadow:
            0 2px 4px rgba(30, 41, 99, .16),
            0 8px 22px -6px rgba(79, 70, 229, .5);
    }
}
.btn-primary:active {
    box-shadow:
        0 1px 2px rgba(30, 41, 99, .16),
        0 2px 6px -2px rgba(79, 70, 229, .3);
}

/* Badges + pills: crisper edges via a hairline inset + a whisper of shadow.
 * Colours come from the badge-* modifiers — untouched here. */
.badge,
.pill {
    box-shadow:
        inset 0 0 0 1px rgba(15, 23, 42, .06),
        0 1px 1px rgba(15, 23, 42, .04);
}

/* Tables: subtle row hover. .tbl already hover-tints rows in app.css; we add a
 * smooth transition there and extend the same treatment to admin Tailwind
 * tables (table.min-w-full), which previously had no row feedback. */
.tbl tbody tr {
    transition: background .16s ease;
}
table.min-w-full tbody tr {
    transition: background .16s ease;
}
table.min-w-full tbody tr:hover {
    background: rgba(15, 23, 42, .025);
}

/* Motion-sensitive users: keep the polish (shadows, borders) but remove every
 * transform/transition so nothing shifts or animates. */
@media (prefers-reduced-motion: reduce) {
    .card,
    .admin-card,
    .stat-icon,
    .btn-primary,
    .tbl tbody tr,
    table.min-w-full tbody tr {
        transition: none;
    }
    .card:hover,
    .admin-card:hover {
        transform: none;
    }
}

/* ============================================================================
 *  CLIENT SIDEBAR — it_skrich-style nav motion (ported to the Luuqo app shell).
 *   - hover: link nudges translateX(3px); its icon scales 1.1.
 *   - a SINGLE JS-driven sliding pill (.lk-nav-indicator) provides the active
 *     highlight and physically slides between items (luuqo.js).
 *   - a subtle bouncy entrance for the nav links on first paint.
 *  Easing mirrors it_skrich: cubic-bezier(.4,0,.2,1) for the slide,
 *  cubic-bezier(.34,1.56,.64,1) for the bouncy bits.
 * ========================================================================== */

/* Hover motion — only where a real pointer exists, so touch taps don't "stick"
 * in a nudged state. Transition list mirrors it_skrich's sidebar-link. */
@media (hover: hover) {
    aside .lk-nav-link {
        transition: opacity .18s ease,
                    color .2s ease,
                    background-color .2s ease,
                    transform .2s ease;
        will-change: transform;
    }
    aside .lk-nav-link:hover {
        transform: translateX(3px);
    }
    /* Icon gets its own transition + a gentle scale on hover (it_skrich). */
    aside .lk-nav-link i {
        transition: transform .2s ease;
    }
    aside .lk-nav-link:hover i {
        transform: scale(1.1);
    }
    /* The settings gear sits inside the row but must not scale with the icon. */
    aside .lk-nav-link ~ a i { transform: none; }
}

/* Sliding active indicator — absolutely positioned pill that sits on the active
 * link and SLIDES between items. Reuses the existing active-pill look
 * (.lk-nav-indicator): same gradient + inset accent bar + glow. It lives once,
 * directly inside the sidebar <nav>, and is moved by transform (GPU). */
.lk-nav-indicator {
    position: absolute;
    top: 0;
    left: .75rem;                 /* match .lk-nav-link side margin */
    right: .75rem;
    height: 0;                    /* set from the active link's height in JS */
    border-radius: .875rem;       /* match .lk-nav-link radius */
    background: linear-gradient(#ffffff2e 0%, #ffffff1a 100%);
    box-shadow: inset 3px 0 0 var(--app-accent),
                inset 0 1px 0 #ffffff40,
                0 8px 20px -8px #00000059,
                0 2px 4px -2px #0003;
    -webkit-backdrop-filter: blur(10px) saturate(140%);
    backdrop-filter: blur(10px) saturate(140%);
    opacity: 0;                   /* hidden until JS positions it */
    z-index: 0;                   /* under the link's content (links are z-index:0 + relative) */
    pointer-events: none;
    will-change: transform;
    transform: translateY(0);
    transition: transform .22s cubic-bezier(.4, 0, .2, 1),
                height .22s cubic-bezier(.4, 0, .2, 1),
                opacity .16s ease;
}
.lk-nav-indicator.is-on { opacity: 1; }
/* Collapsed sidebar: the pill spans the narrower rail (links use .5rem margin). */
aside[data-collapsed="true"] .lk-nav-indicator { left: .5rem; right: .5rem; }

/* NOTE: the nav links intentionally have NO per-load entrance animation.
 * This is a plain MPA — a keyframe entrance would replay on EVERY page change,
 * making the whole sidebar visibly re-animate (flicker/jank) on each navigation.
 * it_skrich's sidebar persists silently for the same reason. The hover motion
 * above + the sliding active pill give the nav all the life it needs. */

/* Motion-sensitive users: no slide, no scale, no entrance — the pill still
 * marks the active item, it just appears in place (snap, no transition). */
@media (prefers-reduced-motion: reduce) {
    aside .lk-nav-link,
    aside .lk-nav-link:hover,
    aside .lk-nav-link i,
    aside .lk-nav-link:hover i {
        transform: none;
    }
    aside nav .lk-nav-link { animation: none; }
    .lk-nav-indicator { transition: none; }
}

/* ── Module sidebar dropdown (developer-declared manifest 'nav') ── */
.lk-modgroup { position: relative; }
.lk-modtoggle {
    width: 100%; background: transparent; border: 0; cursor: pointer;
    color: inherit; text-align: left;
    /* font-FAMILY only — the `font:` shorthand would reset weight/size to the
       body's, making this one nav row render lighter than its siblings. The
       .lk-nav-link class already sets the correct .8125rem / 500. */
    font-family: inherit;
}
.lk-modtoggle-active { background: rgba(255, 255, 255, .07); }
/* Module dropdown chevron. Styled here (not inline) because Alpine's :style
 * string binding REPLACES the whole style attribute on toggle — an inline
 * margin/font-size would be wiped at runtime. margin-right adds clear breathing
 * room from the sidebar's right edge; .is-open (Alpine :class) drives rotate. */
.lk-mod-chev {
    flex-shrink: 0;
    font-size: 10px;
    margin-right: 16px;
    margin-left: 6px;
    opacity: .8;
    transition: transform .2s ease;
}
.lk-mod-chev.is-open { transform: rotate(180deg); }
.lk-submenu { display: flex; flex-direction: column; margin: 2px 0 4px; }
.lk-subnav-link {
    display: flex; align-items: center; gap: .55rem;
    margin: 1px .75rem; padding: .4rem .75rem .4rem 2.45rem;
    border-radius: .75rem; font-size: .8125rem; line-height: 1.2;
    color: inherit; opacity: .72; position: relative; z-index: 1;
    transition: opacity .15s ease, background-color .15s ease, transform .2s ease;
}
.lk-subnav-link:hover { opacity: 1; background: rgba(255, 255, 255, .08); transform: translateX(3px); }
.lk-subnav-link.is-active { opacity: 1; background: rgba(255, 255, 255, .13); font-weight: 600; }
.lk-subnav-link.is-active::before {
    content: ''; position: absolute; left: 1.45rem; top: 50%; transform: translateY(-50%);
    width: 5px; height: 5px; border-radius: 50%; background: var(--app-accent, currentColor);
}
.lk-subnav-link i { width: 1.1rem; text-align: center; font-size: .8rem; opacity: .85; flex-shrink: 0; }
@media (prefers-reduced-motion: reduce) {
    .lk-subnav-link, .lk-subnav-link:hover { transition: none; transform: none; }
}

/* ============================================================================
 *  ELDER-FRIENDLY / LOW-TECH-USER PASS — additive accessibility polish scoped
 *  to the CLIENT app (.theme-root). Goal: comfortable touch targets, clearly
 *  visible focus, easier-to-read body copy and bolder form labels for users
 *  who are not tech-savvy ("senang orang tua / tak celik IT nak guna") —
 *  WITHOUT moving layout or overriding the compiled Tailwind look:
 *   - every override is wrapped in :where() (zero specificity), so any
 *     existing/compiled style that targets the same property always wins;
 *   - all sizing lives behind @media (pointer: coarse), so desktop layout
 *     is byte-for-byte untouched;
 *   - NO font-size bumps on body/main, NO padding/margin changes.
 * ========================================================================== */

/* 1 ── Touch targets: on touch devices give tappable things a comfortable
 *      ~44px minimum hit area (WCAG 2.5.8 / Apple HIG). min-height only —
 *      widths, padding and flow stay exactly as authored. */
@media (pointer: coarse) {
    /* Exclude compact buttons (.btn-sm) and icon-only buttons (.btn-ghost —
     * the topbar hamburger/bell) from the bump: forcing those to 44px made
     * them look bloated ("terlalu gemuk") on phones. Full-size buttons,
     * nav rows and form submits keep the comfortable hit area. */
    :where(.theme-root .btn:not(.btn-sm):not(.btn-ghost),
           .theme-root button[type="submit"]:not(.btn-sm):not(.btn-ghost),
           .theme-root .lk-nav-link) {
        min-height: 2.75rem;
    }
    :where(.theme-root input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="hidden"]):not([type="color"]):not([type="file"]),
           .theme-root select,
           .theme-root textarea) {
        min-height: 2.75rem;
    }
}

/* 1b ── iOS Safari auto-zooms the whole page when a focused field is < 16px.
 *       Android/Chromium (Chrome, Samsung Internet, Edge, ...) has no such
 *       quirk, so it gets the same smaller field text as the rest of the
 *       page; only real iOS browsers (Safari AND every other iOS browser —
 *       Apple mandates WebKit under the hood for all of them) are held at
 *       the 16px floor, via @supports (-webkit-touch-callout: none), a
 *       feature no non-Apple engine implements. Covers the design-system
 *       field classes AND the hand-styled `border-app` filter/search
 *       fields. !important beats utility text-sm; the auth OTP (.otp) and
 *       checkboxes/radios have neither class so they're untouched. */
@media (max-width: 640px) {
    .input, .select, .textarea,
    .form-input, .form-select, .form-textarea,
    input.border-app, select.border-app, textarea.border-app {
        font-size: .8125rem !important;
    }
    /* Checkboxes / radios: slightly larger + tinted with the active theme
     * accent so they're easier to see and tap. scale() is paint-only (does
     * not affect layout flow), so nothing around them shifts. */
    :where(.theme-root input[type="checkbox"],
           .theme-root input[type="radio"]) {
        accent-color: var(--app-accent);
        transform: scale(1.15);
    }
}
@supports (-webkit-touch-callout: none) {
    @media (max-width: 640px) {
        .input, .select, .textarea,
        .form-input, .form-select, .form-textarea,
        input.border-app, select.border-app, textarea.border-app {
            font-size: 16px !important;
        }
    }
}

/* 2 ── Focus visibility: a clear outline for keyboard users. :focus-visible
 *      only (no ring on mouse clicks), and zero specificity via :where(), so
 *      wherever the compiled CSS already draws its own focus ring or sets
 *      outline-none, that styling wins untouched (no double rings). */
:where(.theme-root a,
       .theme-root button,
       .theme-root [role="button"],
       .theme-root input,
       .theme-root select,
       .theme-root textarea):where(:focus-visible) {
    outline: 2px solid color-mix(in srgb, currentColor 60%, transparent);
    outline-offset: 2px;
}

/* Form fields should use the Luuqo accent for focus, not browser-default
 * orange/blue rings. This is intentionally stronger than the low-specificity
 * accessibility fallback above because .input is a framework component. */
.theme-root :is(.input, .textarea, .select):is(:focus, .lk-field-focused) {
    outline: none !important;
    border-color: var(--app-accent) !important;
    box-shadow:
        0 0 0 3px rgba(124, 58, 237, .18),
        0 1px 2px rgba(15, 23, 42, .05),
        inset 0 1px 0 rgba(255, 255, 255, .75);
}
@supports (color: color-mix(in srgb, red, white)) {
    .theme-root :is(.input, .textarea, .select):is(:focus, .lk-field-focused) {
        box-shadow:
            0 0 0 3px color-mix(in srgb, var(--app-accent) 20%, transparent),
            0 1px 2px rgba(15, 23, 42, .05),
            inset 0 1px 0 rgba(255, 255, 255, .75);
    }
}

/* 3 ── Readability: smoother glyph rendering + a touch more breathing room
 *      in body copy. Deliberately NO font-size changes (layout-safe). */
.theme-root main {
    -webkit-font-smoothing: antialiased;
    font-size: 100%; /* intentional no-op (inherits) — documents that sizes are NOT bumped */
}
:where(.theme-root main p) {
    line-height: 1.55;
    letter-spacing: normal;
}

/* 4 ── Form labels: slightly bolder so fields are easier to scan. Zero
 *      specificity — any explicit font-weight on a label wins. */
:where(.theme-root label) {
    font-weight: 600;
}

/* 5 ── Loading affordance: inside buttons, draw the busy spinner a little
 *      larger so "the system is working" is unmistakable for slow readers. */
:where(.theme-root .btn .lk-spin,
       .theme-root button[type="submit"] .lk-spin) {
    width: 16px;
    height: 16px;
}

/* ============================================================================
 *  FRAMEWORK CLARITY PACK — 10 small global UI fixes.
 *  Purpose: make client + admin screens easier to scan, less flat, and safer on
 *  mobile without asking module developers to style the same primitives again.
 * ========================================================================== */

/* 1. Body safety: prevent accidental page-wide horizontal drift. Inner tables
 * still scroll inside their own overflow containers. */
html,
body {
    max-width: 100%;
    overflow-x: clip;
}
@supports not (overflow: clip) {
    html,
    body {
        overflow-x: hidden;
    }
}

/* 2. Topbar depth: sticky bars need a bottom edge so content does not visually
 * merge with the controls. Client pages deepen it only after scroll. */
#lk-content > header,
#lk-admin-content > header,
.sf-topbar {
    box-shadow:
        0 1px 0 rgba(15, 23, 42, .05),
        0 10px 24px -22px rgba(15, 23, 42, .28);
    transition: box-shadow .22s ease, background-color .22s ease;
}
#lk-content > header.lk-topbar-scrolled,
#lk-admin-content > header {
    box-shadow:
        0 1px 0 rgba(15, 23, 42, .08),
        0 14px 32px -22px rgba(15, 23, 42, .38);
}

/* 3. Card base clarity: the previous hover looked nice, but resting cards were
 * sometimes too close to the white canvas. This gives every panel a readable
 * edge before hover. */
.card:not(.lk-dialog-card),
.admin-card:not(.lk-dialog-card) {
    border-color: color-mix(in srgb, var(--app-border, #e2e8f0) 78%, white);
    box-shadow:
        0 14px 34px -22px rgba(30, 41, 99, .34),
        0 4px 12px -10px rgba(30, 41, 99, .16),
        inset 0 1px 0 rgba(255, 255, 255, .7);
}

/* 4. Muted text contrast: keep it muted, but not washed out on mobile screens. */
.theme-root .text-muted-app {
    color: color-mix(in srgb, var(--app-text-muted, #64748b) 84%, var(--app-text, #0f172a) 16%);
}

/* 5. Buttons should read as physical controls even when "soft". */
.btn,
.btn-primary,
.btn-soft {
    align-items: center;
    justify-content: center;
    min-width: 0;
    text-decoration: none;
}
.btn-soft {
    border: 1px solid rgba(148, 163, 184, .22);
    box-shadow:
        0 1px 2px rgba(15, 23, 42, .06),
        inset 0 1px 0 rgba(255, 255, 255, .72);
    transition: background .15s ease, border-color .15s ease, box-shadow .18s ease, transform .05s ease;
}
@media (hover: hover) {
    .btn-soft:hover {
        border-color: rgba(148, 163, 184, .34);
        box-shadow:
            0 4px 12px -8px rgba(15, 23, 42, .22),
            inset 0 1px 0 rgba(255, 255, 255, .78);
    }
}
.btn-soft:active {
    transform: translateY(1px);
}

/* 6. Forms: crisper fields, clearer placeholders, consistent admin/client
 * focus ring. */
.input,
.textarea,
.select,
.form-input,
.form-select,
.form-textarea {
    border-color: rgba(148, 163, 184, .40);
    /* Softer, deeper lift so fields stand out clearly (easier on older eyes). */
    box-shadow:
        0 2px 6px rgba(15, 23, 42, .08),
        0 1px 2px rgba(15, 23, 42, .05),
        inset 0 1px 0 rgba(255, 255, 255, .7);
}
/* Slimmer fields: trim vertical padding so search/inputs are thinner and save
 * space, while keeping the same readable font size + comfortable side padding.
 * Base component ships .625rem 10px top/bottom → .45rem 7px. textarea keeps
 * more room for multi-line comfort. Overrides the compiled component (loaded
 * before this file). */
.input,
.select,
.form-input,
.form-select {
    padding-top: .45rem;
    padding-bottom: .45rem;
}
.textarea,
.form-textarea {
    padding-top: .55rem;
    padding-bottom: .55rem;
}
/* A PLACEHOLDER STILL HAS TO BE READABLE. #94a3b8 on the cream field surface
   measures 2.37:1 — WCAG AA asks 4.5:1 for text — and it failed the same way in
   blossom, forest and lagoon (midnight passed). That matters more than usual here:
   Company Settings and My Profile are READING surfaces until you press Edit, so a
   hint is often the only thing visible in a field, and an owner checking his SSM
   number should not have to squint at it. Derived from the theme's own ink at 68%
   so it lands ~5.5:1 on the light themes and stays correct on the dark one, while
   still reading as quieter than a real value. */
.input::placeholder,
.textarea::placeholder,
.form-input::placeholder,
.form-textarea::placeholder {
    color: #475569;                    /* no-color-mix fallback: slate-600 */
    opacity: 1;                        /* Firefox dims placeholders by default */
}
@supports (color: color-mix(in srgb, red, white)) {
    .input::placeholder,
    .textarea::placeholder,
    .form-input::placeholder,
    .form-textarea::placeholder {
        color: color-mix(in srgb, var(--app-text, #0f172a) 68%, transparent);
    }
}
.form-input:focus,
.form-select:focus,
.form-textarea:focus {
    outline: none;
    border-color: #6366f1;
    box-shadow:
        0 0 0 3px rgba(99, 102, 241, .18),
        0 1px 2px rgba(15, 23, 42, .05),
        inset 0 1px 0 rgba(255, 255, 255, .75);
}

/* 7. Tables: reduce "floating text" feel and make row movement visible. */
.tbl,
table.min-w-full {
    border-collapse: separate;
    border-spacing: 0;
}
.tbl thead tr,
table.min-w-full thead tr {
    background: rgba(15, 23, 42, .028);
}
.tbl tbody tr,
table.min-w-full tbody tr {
    transition: background-color .16s ease, box-shadow .16s ease;
}
@media (hover: hover) {
    .tbl tbody tr:hover,
    table.min-w-full tbody tr:hover {
        background: rgba(15, 23, 42, .035);
        box-shadow: inset 3px 0 0 color-mix(in srgb, var(--app-accent, #6366f1) 55%, transparent);
    }
}

/* 8. Horizontal-scroll areas: smoother mobile scroll and visible scrollbars
 * without forcing the whole page wider. */
.overflow-x-auto,
.overflow-auto {
    max-width: 100%;
    -webkit-overflow-scrolling: touch;
    scrollbar-gutter: stable;
}
.overflow-x-auto::-webkit-scrollbar,
.overflow-auto::-webkit-scrollbar {
    height: 8px;
    width: 8px;
}
.overflow-x-auto::-webkit-scrollbar-thumb,
.overflow-auto::-webkit-scrollbar-thumb {
    background: rgba(100, 116, 139, .34);
    border-radius: 999px;
}

/* 9. Dialogs: a stronger edge + shadow makes modal layers obvious even on busy
 * module screens. */
.lk-dialog-card.lk-dialog-card {
    border: 1px solid rgba(255, 255, 255, .76);
    box-shadow:
        0 32px 90px -32px rgba(2, 6, 23, .55),
        0 10px 28px -18px rgba(2, 6, 23, .28),
        inset 0 1px 0 rgba(255, 255, 255, .72);
}
.lk-dialog-backdrop {
    background: rgba(15, 23, 42, .62);
}

/* 10. Small-screen text/button safety. Long labels wrap neatly instead of
 * overflowing the card edge. */
@media (max-width: 520px) {
    .btn,
    .btn-primary,
    .btn-soft {
        white-space: normal;
        text-align: center;
    }
    .btn i,
    .btn-primary i,
    .btn-soft i {
        flex-shrink: 0;
    }
    .page-header h1,
    #lk-content header h1,
    #lk-admin-content header h1 {
        overflow-wrap: anywhere;
    }
    /* The page's OWN heading (.page-header, used by every module and every
       /app page — chrome.php, dashboard.php, settings.php, and 20+ others)
       had no top margin of its own and ran at the same 24px it uses on a
       wide desktop, so it read as flush against the sticky topbar above it
       and oversized for a 360-390px phone. */
    .page-header {
        margin-top: .3125rem;
    }
    .page-header h1 {
        font-size: 1.25rem;
    }
    .subtitle {
        font-size: .8125rem;
    }
    /* Every module's form fields (.input/.select/.textarea — the shared trio
       every module builds forms from) carried full desktop padding and a
       12px radius, which reads chunky/"tebal" on a narrow phone even though
       the border itself is already 1px. Trims the box, not the border.
       Font-size is NOT touched here — section 1b below pins it to 16px on
       phones !important, on purpose (iOS Safari auto-zooms the whole page
       when a focused field is smaller than that), so an override here would
       be silently dead weight at best and a re-introduced zoom bug at worst. */
    .input,
    .select,
    .textarea {
        padding: .25rem .5rem;
        border-radius: .5rem;
        /* section 1 (@media pointer: coarse, above) floors every field at
           2.75rem/44px on purpose — WCAG 2.5.8 / Apple HIG's touch-target
           minimum — via a zero-specificity :where() so it never fights
           anything. That is exactly why the padding trim above changed
           nothing visible: min-height still won regardless. A real selector
           (this one) outranks :where() with no !important needed, so this
           knowingly moves phones below that guideline on explicit request —
           still comfortably tappable at full row width, just shorter.
           1.875rem/30px is close to the natural floor: font-size is pinned
           to 16px (section 1b, iOS zoom guard) so its own line-height is
           already ~19-20px — much less padding than this and the box stops
           shrinking further and just clips the text instead. */
        min-height: 1.875rem;
    }
}

/* ============================================================================
 *  ONBOARDING — one-shot WELCOME overlay (header_app.php, fresh login only)
 *  + FIRST-RUN spotlight tour (luuqo-tour.js). Theme-aware via the --app-*
 *  custom properties every theme defines; every var() carries a light-theme
 *  fallback so the pieces degrade gracefully outside .theme-root.
 *  z-order: tour 10300-10302 < welcome 10500 (welcome always wins; the tour
 *  waits for it to finish anyway).
 * ========================================================================== */

/* ── Welcome overlay — frosted full-screen veil over the app shell ── */
.lk-welcome-overlay {
    position: fixed;
    inset: 0;
    z-index: 10500;
    display: flex;
    align-items: center;
    justify-content: center;
    background: color-mix(in srgb, var(--app-bg, #f8fafc) 88%, transparent);
    -webkit-backdrop-filter: blur(16px) saturate(130%);
    backdrop-filter: blur(16px) saturate(130%);
    cursor: pointer;                       /* whole screen = "tap to skip" */
    animation: lk-welcome-fade-in .4s ease both;
}
.lk-welcome-overlay.is-leaving {
    animation: lk-welcome-fade-out .45s ease both;
    pointer-events: none;
}
.lk-welcome-card {
    text-align: center;
    padding: 24px;
    animation: lk-welcome-pop .65s cubic-bezier(.16, 1, .3, 1) both;
}
.lk-welcome-overlay.is-leaving .lk-welcome-card {
    animation: lk-welcome-card-out .4s ease both;
}
.lk-welcome-logo {
    width: 76px;
    height: 76px;
    margin: 0 auto 20px;
    border-radius: 20px;
    object-fit: contain;
    box-shadow: 0 18px 44px -14px color-mix(in srgb, var(--app-accent, #14b8a6) 45%, transparent),
                0 4px 12px -4px rgba(15, 23, 42, .18);
}
/* Elder-friendly: big, bold, high-contrast greeting. */
.lk-welcome-title {
    font-size: clamp(1.5rem, 4.5vw, 2.1rem);
    font-weight: 800;
    letter-spacing: -.02em;
    line-height: 1.25;
    color: var(--app-text, #0f172a);
}
.lk-welcome-sub {
    margin-top: .5rem;
    font-size: 1.05rem;
    font-weight: 500;
    color: var(--app-text-muted, #64748b);
}
.lk-welcome-wave {
    display: inline-block;
    transform-origin: 70% 70%;
    animation: lk-welcome-wave 1.2s ease .4s 1 both;
}
@keyframes lk-welcome-fade-in  { from { opacity: 0; } to { opacity: 1; } }
@keyframes lk-welcome-fade-out { from { opacity: 1; } to { opacity: 0; } }
@keyframes lk-welcome-pop {
    from { opacity: 0; transform: scale(.88) translateY(12px); }
    to   { opacity: 1; transform: none; }
}
@keyframes lk-welcome-card-out {
    to { opacity: 0; transform: scale(.97) translateY(-6px); }
}
@keyframes lk-welcome-wave {
    0%, 100% { transform: rotate(0); }
    20%      { transform: rotate(16deg); }
    40%      { transform: rotate(-8deg); }
    60%      { transform: rotate(12deg); }
    80%      { transform: rotate(-4deg); }
}

/* ── Tour: transparent click-catcher (blocks the page under the tour) ── */
.lk-tour-backdrop {
    position: fixed;
    inset: 0;
    z-index: 10300;
    background: transparent;
}

/* ── Tour: spotlight — the huge box-shadow IS the dark overlay; the box
 *    itself is the transparent cut-out hugging the target. Moved/resized by
 *    luuqo-tour.js via inline top/left/width/height; the transition makes it
 *    glide between steps. ── */
.lk-tour-spot {
    position: fixed;
    z-index: 10301;
    border-radius: 14px;
    box-shadow: 0 0 0 9999px rgba(2, 6, 23, .68),
                0 0 0 2px color-mix(in srgb, var(--app-accent, #14b8a6) 80%, transparent),
                0 0 24px 2px color-mix(in srgb, var(--app-accent, #14b8a6) 35%, transparent);
    pointer-events: none;
    opacity: 0;
    transition: top .32s cubic-bezier(.4, 0, .2, 1),
                left .32s cubic-bezier(.4, 0, .2, 1),
                width .32s cubic-bezier(.4, 0, .2, 1),
                height .32s cubic-bezier(.4, 0, .2, 1),
                opacity .25s ease;
}
.lk-tour-spot.is-on { opacity: 1; }

/* ── Tour: tooltip card — elder-friendly sizes (16px+ body, 44px buttons). ── */
.lk-tour-card {
    position: fixed;
    z-index: 10302;
    width: min(340px, calc(100vw - 24px));
    background: var(--app-surface, #ffffff);
    color: var(--app-text, #0f172a);
    border: 1px solid var(--app-border, #e2e8f0);
    border-radius: 18px;
    padding: 20px;
    box-shadow: 0 24px 60px rgba(2, 6, 23, .38);
    opacity: 0;
    transform: translateY(8px);
    transition: top .32s cubic-bezier(.4, 0, .2, 1),
                left .32s cubic-bezier(.4, 0, .2, 1),
                opacity .25s ease,
                transform .25s ease;
}
.lk-tour-card.is-on { opacity: 1; transform: none; }
.lk-tour-step {
    font-size: 12px;
    font-weight: 700;
    letter-spacing: .08em;
    text-transform: uppercase;
    color: var(--app-accent, #0d9488);
    margin-bottom: 6px;
}
.lk-tour-title {
    font-size: 18px;
    font-weight: 800;
    margin: 0 0 6px;
}
.lk-tour-body {
    font-size: 16px;
    line-height: 1.55;
    color: var(--app-text-muted, #475569);
    margin: 0 0 14px;
}
.lk-tour-dots {
    display: flex;
    gap: 6px;
    margin-bottom: 14px;
}
.lk-tour-dot {
    width: 8px;
    height: 8px;
    border-radius: 999px;
    background: color-mix(in srgb, var(--app-text-muted, #94a3b8) 35%, transparent);
    transition: background .2s ease, width .2s ease;
}
.lk-tour-dot.is-on {
    width: 22px;
    background: var(--app-accent, #14b8a6);
}
.lk-tour-dot.is-past {
    background: color-mix(in srgb, var(--app-accent, #14b8a6) 55%, transparent);
}
.lk-tour-actions {
    display: flex;
    gap: 10px;
    justify-content: flex-end;
    flex-wrap: wrap;
}
.lk-tour-btn {
    min-height: 44px;
    padding: 10px 18px;
    border-radius: 12px;
    font-size: 16px;
    font-weight: 700;
    font-family: inherit;
    cursor: pointer;
    border: 1px solid transparent;
    transition: background .15s ease, box-shadow .2s ease, transform .05s ease;
}
.lk-tour-btn:active { transform: scale(.98); }
.lk-tour-btn-ghost {
    background: transparent;
    color: var(--app-text-muted, #475569);
    border-color: var(--app-border, #e2e8f0);
}
.lk-tour-btn-ghost:hover {
    background: var(--app-surface-2, #f1f5f9);
}
.lk-tour-btn-primary {
    background: var(--app-accent, #0d9488);
    color: var(--app-on-accent, #ffffff);
    box-shadow: 0 6px 16px -6px color-mix(in srgb, var(--app-accent, #0d9488) 60%, transparent);
}
.lk-tour-btn-primary:hover {
    background: var(--app-accent-hover, var(--app-accent, #0f766e));
}
/* "Back" (steps 2..n) is pushed to the far left of the right-aligned action row,
 * so the two buttons a user reaches for by position — Skip and Next — never move
 * when it appears, and Back cannot be hit while aiming for Next. */
.lk-tour-btn-back { margin-right: auto; }

/* Small screens: dock the card to the bottom — thumb reach, never clipped.
 * luuqo-tour.js clears the inline top/left it set on bigger screens. */
@media (max-width: 639.98px) {
    .lk-tour-card.is-docked {
        top: auto;
        left: 12px;
        right: 12px;
        bottom: calc(12px + env(safe-area-inset-bottom, 0px));
        width: auto;
    }
}

/* Motion-sensitive users: the welcome overlay is removed instantly by its
 * inline script; these rules also strip every animation/transition so nothing
 * moves even for the single frame before that script runs. */
@media (prefers-reduced-motion: reduce) {
    .lk-welcome-overlay,
    .lk-welcome-overlay.is-leaving,
    .lk-welcome-card,
    .lk-welcome-overlay.is-leaving .lk-welcome-card,
    .lk-welcome-wave {
        animation: none;
    }
    .lk-tour-spot,
    .lk-tour-card,
    .lk-tour-dot,
    .lk-tour-btn {
        transition: none;
    }
    .lk-tour-card { transform: none; }
}

/* ──────────────────────────────────────────────────────────────────────────
 * RESTORED UTILITIES — classes used by post-Jun-4 UI that are absent from the
 * frozen Tailwind build (this repo has no rebuild toolchain). Each selector
 * only affects elements already using that class, so this block is additive
 * and safe. Theme-var-based where a var exists. (Interface audit 2026-07-07.)
 * ────────────────────────────────────────────────────────────────────────── */
/* opacity-tint fills (slash-opacity variants) */
.bg-white\/65{background-color:rgba(255,255,255,.65)}
.bg-white\/70{background-color:rgba(255,255,255,.7)}
.bg-white\/90{background-color:rgba(255,255,255,.9)}
.bg-gray-50\/70{background-color:rgba(249,250,251,.7)}
.bg-gray-100\/60{background-color:rgba(243,244,246,.6)}
.bg-violet-50\/55{background-color:rgba(245,243,255,.55)}
.bg-red-50\/55{background-color:rgba(254,242,242,.55)}
.bg-emerald-50\/40{background-color:rgba(236,253,245,.4)}
.bg-emerald-50\/45{background-color:rgba(236,253,245,.45)}
.bg-emerald-50\/60{background-color:rgba(236,253,245,.6)}
.bg-indigo-50\/45{background-color:rgba(238,242,255,.45)}
.bg-amber-50\/60{background-color:rgba(255,251,235,.6)}
.bg-amber-50\/70{background-color:rgba(255,251,235,.7)}
.bg-sky-50\/70{background-color:rgba(240,249,255,.7)}
.bg-violet-50{background-color:#f5f3ff}
.bg-fuchsia-600{background-color:#c026d3}
.bg-fuchsia-700{background-color:#a21caf}
/* theme-accent tints (--app-accent-soft doesn't exist → mix the accent) */
.bg-accent-soft\/40{background-color:color-mix(in oklab, var(--app-accent) 16%, transparent)}
.bg-accent-app\/15{background-color:color-mix(in oklab, var(--app-accent) 15%, transparent)}
/* hover fills */
.hover\:bg-violet-100:hover{background-color:#ede9fe}
.hover\:bg-cyan-100:hover{background-color:#cffafe}
/* tint borders */
.border-red-100{border-color:#fee2e2}
.border-violet-100{border-color:#ede9fe}
.border-cyan-100{border-color:#cffafe}
.border-sky-200{border-color:#bae6fd}
/* hover border → accent (notification bell, POS tiles, payment buttons) */
.hover\:border-accent-app:hover{border-color:var(--app-accent)}
/* rings — use OUTLINE (a separate property) so a .card's box-shadow (specificity
 * 0,2,0 via .card:not(...)) can't swallow the selection ring. Outline follows
 * border-radius in modern browsers; -offset keeps it inside the card edge. */
.ring-2{outline:2px solid var(--tw-ring-color,currentcolor);outline-offset:-2px}
.ring-accent-app{--tw-ring-color:var(--app-accent)}
.ring-accent-app\/30{--tw-ring-color:color-mix(in oklab, var(--app-accent) 30%, transparent)}
.ring-slate-900{--tw-ring-color:var(--color-slate-900,#0f172a)}
/* owner role card uses ring-1 (build box-shadow ring, also swallowed by .card) */
.card.ring-1.ring-accent-app\/30{outline:1px solid color-mix(in oklab, var(--app-accent) 34%, transparent);outline-offset:-1px}
/* accent text (Sandbox section-heading icons) */
.text-fuchsia-500{color:var(--color-fuchsia-500,#d946ef)}
.text-violet-500{color:var(--color-violet-500,#8b5cf6)}
/* gradient stops — mirror the build's --tw-gradient-stops pattern */
.from-sky-500{--tw-gradient-from:var(--color-sky-500,#0ea5e9);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}
.from-slate-600{--tw-gradient-from:var(--color-slate-600,#475569);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}
.to-violet-700{--tw-gradient-to:var(--color-violet-700,#6d28d9);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}
.to-purple-700{--tw-gradient-to:var(--color-purple-700,#7e22ce);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}
.to-emerald-700{--tw-gradient-to:var(--color-emerald-700,#047857);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}
.to-orange-700{--tw-gradient-to:var(--color-orange-700,#c2410c);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}
.to-slate-800{--tw-gradient-to:var(--color-slate-800,#1e293b);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}
/* misc layout / effects */
.shadow-inner{box-shadow:inset 0 2px 4px 0 rgba(0,0,0,.05)}
.min-h-9{min-height:2.25rem}
.hover\:brightness-\[\.98\]:hover{filter:brightness(.98)}
@media (min-width:1024px){
  .lg\:sticky{position:sticky}
  .lg\:top-6{top:1.5rem}
  .lg\:grid-cols-8{grid-template-columns:repeat(8,minmax(0,1fr))}
}

/* ── .lk-table — the ONE shared table standard (see memory: feedback-table-design).
 *    Colourful themed header + zebra rows + visible grid + hover, tuned for
 *    elderly / poor-eyesight users. Theme-aware via --app-accent (color-mix), with
 *    solid indigo fallbacks. Add class="lk-table" to any <table>. */
.lk-table { width: 100%; border-collapse: collapse; }
.lk-table thead th {
    background: #eef2ff;                                   /* fallback */
    color: #4338ca;                                        /* fallback */
    font-weight: 700;
    letter-spacing: .02em;
    border-bottom: 2px solid #c7d2fe;                      /* fallback */
    border-right: 1px solid rgba(148, 163, 184, .20);
}
.lk-table tbody td {
    border-bottom: 1px solid var(--lk-border, #e5e8ef);
    border-right: 1px solid rgba(148, 163, 184, .14);
}
.lk-table th:last-child, .lk-table td:last-child { border-right: 0; }
.lk-table tbody tr:nth-child(even) { background: #f7f8fc; }          /* zebra fallback */
.lk-table tbody tr:hover { background: #eef2ff; }                    /* hover fallback */
@supports (background: color-mix(in srgb, red, white)) {
    .lk-table thead th {
        background: linear-gradient(180deg,
            color-mix(in srgb, var(--app-accent, #6366f1) 16%, var(--app-surface, #fff)),
            color-mix(in srgb, var(--app-accent, #6366f1) 9%,  var(--app-surface, #fff)));
        color: color-mix(in srgb, var(--app-accent, #6366f1) 76%, var(--text-app, #0f172a));
        border-bottom-color: color-mix(in srgb, var(--app-accent, #6366f1) 34%, var(--lk-border, #e2e8f0));
    }
    .lk-table tbody tr:nth-child(even) { background: color-mix(in srgb, var(--app-accent, #6366f1) 5%, var(--app-surface, #fff)); }
    .lk-table tbody tr:hover           { background: color-mix(in srgb, var(--app-accent, #6366f1) 11%, var(--app-surface, #fff)); }
}
@media (prefers-reduced-motion: no-preference) {
    .lk-table tbody tr { transition: background-color .12s ease; }
}

/* ── .lk-grid — DATA-GRID variant of .lk-table for large / wide lists (item
 *    catalogues, stock balance, movement ledgers). Keeps it a REAL table at
 *    every screen size: thin rows, EVERY column always visible (no mobile
 *    card-collapse or column-hiding), a sticky header, an optional frozen first
 *    column, and horizontal scroll on narrow screens. Built for thousands of
 *    rows × many branch columns.
 *      <div class="lk-grid-wrap">
 *        <table class="lk-table lk-grid lk-grid-freeze"> … </table>
 *      </div>
 *    A frozen sticky column needs border-collapse:SEPARATE (collapsed borders
 *    vanish under sticky cells) + an opaque per-row background (else scrolled
 *    columns bleed under it) — both handled here. */
.lk-grid-wrap {
    overflow: auto;                       /* both axes */
    max-height: 68vh;
    max-width: 100%;
    -webkit-overflow-scrolling: touch;
    scrollbar-gutter: stable;
}
.lk-table.lk-grid {
    border-collapse: separate;
    border-spacing: 0;
    width: auto;                          /* natural column widths → scroll when wider than the wrap */
    min-width: 100%;                      /* …but fill the wrap when narrower */
}
/* Cells: thin single-line by default so the grid scrolls sideways instead of
 * squishing. `:not([colspan])` leaves multi-column empty-state cells (the "no
 * rows yet" message) with their own padding + wrapping + centering. */
/* ── GRID DENSITY (one dial for every .lk-grid table). Compact profile: smaller
 *    font + tighter rows so more items fit per screen (POS-style). To make it
 *    airier for older eyes again, bump --lk-grid-fs (e.g. .8125rem) + the
 *    padding below. */
.lk-table.lk-grid { --lk-grid-fs: .75rem; }   /* 12px data cells */
.lk-table.lk-grid th:not([colspan]),
.lk-table.lk-grid td:not([colspan]) {
    padding: 6px 12px;
    white-space: nowrap;
    font-size: var(--lk-grid-fs);
}
.lk-table.lk-grid thead th {
    position: sticky;
    top: 0;
    z-index: 3;
}
.lk-table.lk-grid thead th { font-size: .6875rem; }              /* ~11px header labels */
.lk-table.lk-grid tbody td { line-height: 1.3; }
.lk-table.lk-grid tbody td .text-\[10px\] { font-size: .625rem; }   /* SKU / sub-lines ~10px */
/* First column = identity (Item / When / Ref): wrap (incl. long unbroken codes)
 * but cap width so it can't blow out the grid; every other column stays single-
 * line. Excludes colspan empty-state cells. */
.lk-table.lk-grid th:first-child:not([colspan]),
.lk-table.lk-grid td:first-child:not([colspan]) {
    white-space: normal;
    overflow-wrap: anywhere;
    min-width: 9rem;
    max-width: 15rem;
}
/* Free-text columns (note / remarks / reason) opt BACK into wrapping within a
 * capped width, so a long note doesn't stretch the grid to an absurd width.
 * Declared after the nowrap rule so it wins at equal specificity. */
.lk-table.lk-grid td.lk-wrap,
.lk-table.lk-grid th.lk-wrap {
    white-space: normal;
    overflow-wrap: anywhere;
    min-width: 10rem;
    max-width: 20rem;
}
/* Frozen first column (opt-in: add class lk-grid-freeze). Its cells need their
 * OWN opaque background reproducing the row zebra + hover, or scrolled columns
 * show through the "transparent" sticky cell. Excludes colspan empty-state
 * cells (they must stay a plain full-width centred message, not a sticky cell).
 *
 * background-COLOR, NEVER the `background` shorthand, on these BODY cells.
 * Their selectors are far more specific than any row-state class a page can add
 * (0,4,1 / 0,5,3 vs a bare `.some-row > td`), so a shorthand here silently
 * resets background-IMAGE on the pinned cell and every row marker painted with
 * a gradient disappears from the one column that is always on screen. That is
 * exactly how .im-row-off's amber marker went missing on all three Item Master
 * grids. The thead rules below DO keep the shorthand — they really do paint a
 * gradient band, and a header row never carries a row-state class. */
.lk-table.lk-grid-freeze th:first-child:not([colspan]),
.lk-table.lk-grid-freeze td:first-child:not([colspan]) {
    position: sticky;
    left: 0;
    z-index: 2;
    background-color: var(--app-surface, #fff);           /* opaque base (odd rows) */
    /* Edge + a soft drop: when the grid is scrolled, the columns sliding UNDER the
       pinned first column can leave a half-covered word next to it, which reads as
       a rendering bug. A visible lip makes it obvious the column is pinned. */
    box-shadow: 1px 0 0 rgba(148, 163, 184, .28), 6px 0 8px -6px rgba(15, 23, 42, .18);
}
.lk-table.lk-grid-freeze tbody tr:nth-child(even) td:first-child:not([colspan]) { background-color: #f7f8fc; }  /* zebra fallback (matches .lk-table) */
.lk-table.lk-grid-freeze tbody tr:hover          td:first-child:not([colspan]) { background-color: #eef2ff; }  /* hover fallback (matches .lk-table) */
/* Corner cell (header row ∩ frozen column): sticky BOTH axes, above everything.
 * The selector needs :not([colspan]) so its specificity (0,4,2) BEATS the
 * generic frozen-column rule above (0,4,1) — without it that rule's z-index:2
 * and surface background win, the corner drops BELOW the other header cells
 * (z-index:3) and scrolled headers paint over the pinned column. Z-index
 * ladder: corner th 4 > other thead th 3 > frozen td 2 > normal td auto. */
.lk-table.lk-grid-freeze thead th:first-child:not([colspan]) {
    position: sticky;
    top: 0;
    left: 0;
    z-index: 4;
    background: #eef2ff;                                  /* opaque, matches .lk-table thead fallback */
}
@supports (background: color-mix(in srgb, red, white)) {
    .lk-table.lk-grid-freeze tbody tr:nth-child(even) td:first-child:not([colspan]) { background-color: color-mix(in srgb, var(--app-accent, #6366f1) 5%, var(--app-surface, #fff)); }
    .lk-table.lk-grid-freeze tbody tr:hover          td:first-child:not([colspan]) { background-color: color-mix(in srgb, var(--app-accent, #6366f1) 11%, var(--app-surface, #fff)); }
    .lk-table.lk-grid-freeze thead th:first-child:not([colspan]) {
        background: linear-gradient(180deg,
            color-mix(in srgb, var(--app-accent, #6366f1) 16%, var(--app-surface, #fff)),
            color-mix(in srgb, var(--app-accent, #6366f1) 9%,  var(--app-surface, #fff)));
    }
}

/* ── .im-filt* — multi-select filter dropdown (Categories / Branches) on Item
 *    Master lists. DESKTOP: floating panel under the button. MOBILE (<640px):
 *    the panel drops into normal flow as a FULL-WIDTH row under the filter bar,
 *    so it can never be clipped by the card's overflow or run off the viewport
 *    edge. Positioning lives here in CSS — NEVER via an Alpine :style string
 *    binding (that wipes the whole style attribute). Centralised here so the two
 *    Item Master pages don't diverge. */
.im-filt { position: relative; }
.im-filt-btn { display: inline-flex; align-items: center; gap: .4rem; }
.im-filt-pop {
    position: absolute;
    top: calc(100% + 6px);
    left: 0;
    z-index: 40;
    width: 15rem;
    max-width: min(15rem, calc(100vw - 24px));
    padding: 8px;
    box-shadow: 0 12px 34px rgba(15, 23, 42, .18);
}
.im-filt-pop.right { left: auto; right: 0; }
.im-filt-list { max-height: 15rem; overflow: auto; display: flex; flex-direction: column; gap: 1px; }
.im-filt-opt { display: flex; align-items: center; gap: .55rem; padding: .42rem .5rem; border-radius: .45rem; font-size: .82rem; cursor: pointer; color: var(--text-app, #1e293b); }
.im-filt-opt:hover { background: var(--lk-surface-2, #f1f5f9); }
.im-filt-opt input { width: 1rem; height: 1rem; flex-shrink: 0; accent-color: var(--app-accent, #7c3aed); }
.im-filt-foot { display: flex; gap: .4rem; padding-top: .5rem; margin-top: .35rem; border-top: 1px solid var(--lk-border, #e2e8f0); }
.im-filt-cnt { display: inline-flex; align-items: center; justify-content: center; min-width: 1.1rem; height: 1.1rem; padding: 0 .3rem; border-radius: 999px; background: var(--app-accent, #7c3aed); color: #fff; font-size: 10px; font-weight: 700; }
@media (max-width: 639.98px) {
    .im-filt { display: contents; }                        /* button + panel become direct flex children */
    .im-filt-pop,
    .im-filt-pop.right {
        position: static;
        order: 90;                                         /* wrap below the filter buttons */
        flex-basis: 100%;
        width: 100%;
        max-width: 100%;
        left: auto; right: auto;
        margin-top: 2px;
        box-shadow: 0 8px 22px rgba(15, 23, 42, .12);
    }
}

/* ── Async content skeletons (app/async.php + luuqo.js §5). Shown in a
 *    .lk-async-slot while the page's data is fetched, so the shell is instant
 *    and the content area animates a placeholder until the data lands. */
.lk-async-slot { display: block; }
.lk-sk {
    display: inline-block; border-radius: 6px; height: .8rem; vertical-align: middle;
    background: #eef1f6;
    background-image: linear-gradient(90deg, rgba(148,163,184,0) 0, rgba(255,255,255,.65) 45%, rgba(148,163,184,0) 90%);
    background-size: 220% 100%; background-repeat: no-repeat;
    animation: lk-sk-shimmer 1.25s ease-in-out infinite;
}
@supports (background: color-mix(in srgb, red, white)) {
    .lk-sk { background-color: color-mix(in srgb, var(--app-accent, #6366f1) 9%, var(--app-surface, #fff)); }
}
@keyframes lk-sk-shimmer { 0% { background-position: 120% 0; } 100% { background-position: -120% 0; } }
.lk-sk-dot { width: 1.7rem; height: 1.7rem; border-radius: 9px; }
.lk-sk-head { height: 2.5rem; background: var(--lk-surface-2, #f1f5f9); border-bottom: 2px solid var(--lk-border, #e5e8ef); }
.lk-sk-row { display: flex; align-items: center; gap: .8rem; padding: .72rem 1rem; border-bottom: 1px solid var(--lk-border, #eef1f6); }
.lk-async-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem; }
.lk-sk-card { padding: 1.1rem; border: 1px solid var(--lk-border, #e5e8ef); border-radius: 1rem; display: flex; flex-direction: column; gap: .7rem; background: var(--app-surface, #fff); }
/* 'stats' skeleton — stat-card row + content panels (dashboard / billing): the
   boxes show instantly, so it reads as "layout ready, data loading via AJAX". */
.lk-sk-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 1rem; margin-bottom: 1.1rem; }
.lk-sk-stat { display: flex; flex-direction: column; gap: .75rem; padding: 1.3rem; border: 1px solid var(--lk-border, #e5e8ef); border-radius: 1rem; background: var(--app-surface, #fff); }
.lk-sk-ico { width: 2.6rem; height: 2.6rem; border-radius: .85rem; }
.lk-sk-panels { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
.lk-sk-panel { display: flex; flex-direction: column; gap: .7rem; padding: 1.3rem; min-height: 210px; border: 1px solid var(--lk-border, #e5e8ef); border-radius: 1rem; background: var(--app-surface, #fff); }
@media (max-width: 800px) { .lk-sk-panels { grid-template-columns: 1fr; } }
/* 'figures' skeleton — the /admin landing page: a hero band, a six-tile figure
   strip and two accordion heads. Every number below is measured off the real
   content at 1280px rather than guessed: card 123px (a 55px band plus a 46px tile
   row inside .625rem of padding), a 12px space-y-3 gap, then an 83px two-up
   accordion row. The breakpoints mirror the content's own
   `grid-cols-2 sm:grid-cols-3 lg:grid-cols-6` and `lg:grid-cols-2`, so the wait
   re-flows exactly where the content re-flows. */
.lk-sk-figcard { border: 1px solid var(--lk-border, #e5e8ef); border-radius: 1rem; background: var(--app-surface, #fff); overflow: hidden; margin-bottom: .75rem; }
.lk-sk-figband { display: flex; align-items: center; gap: .75rem; height: 55px; padding: .5rem .75rem; background: var(--lk-surface-2, #f1f5f9); }
.lk-sk-figbandico { width: 30px; height: 30px; border-radius: .5rem; flex: none; }
.lk-sk-figrow { display: grid; grid-template-columns: repeat(2, 1fr); gap: .5rem; padding: .625rem; }
.lk-sk-fig { display: flex; align-items: center; gap: .5rem; height: 46px; padding: 0 .5rem; border: 1px solid var(--lk-border, #e5e8ef); border-radius: .6rem; }
.lk-sk-figico { width: 26px; height: 26px; border-radius: .45rem; flex: none; }
.lk-sk-figtext { display: flex; flex-direction: column; gap: .28rem; min-width: 0; }
.lk-sk-accs { display: grid; grid-template-columns: 1fr; gap: .75rem; }
.lk-sk-acc { display: flex; align-items: flex-start; gap: .55rem; height: 83px; padding: .5rem .75rem; border: 1px solid var(--lk-border, #e5e8ef); border-radius: 1rem; background: var(--app-surface, #fff); }
.lk-sk-acctile { width: 24px; height: 24px; border-radius: .45rem; flex: none; }
@media (min-width: 640px) { .lk-sk-figrow { grid-template-columns: repeat(3, 1fr); } }
@media (min-width: 1024px) {
    .lk-sk-figrow { grid-template-columns: repeat(6, 1fr); }
    .lk-sk-accs { grid-template-columns: 1fr 1fr; }
}
/* 'form' skeleton — a card with a title bar and label+input pairs. Every stock
   movement screen (Stock In / Out / Adjustment / Stock Take / Transfer) is a FORM
   and used to wait behind the eight-row TABLE skeleton: ~430px of grey rows that
   collapsed into one card with four inputs, so a single page load jumped twice.
   The pair rhythm and the 2.1rem field height are the real .input/.select box, so
   the swap fills the boxes in instead of re-laying the page out.
   lk_async_fields() emits the BODY on its own, to drop inside a card whose title
   bar a frame has already painted for real. */
/* Measured against the real card title row the frame paints (`w-full flex …
   px-4 py-3` around a `text-sm` label): .75rem+1.25rem+.75rem = 2.75rem tall.
   Matching it to the pixel is the whole point — the pjax skeleton and the
   hard-load frame have to reserve the same box or the swap nudges. */
.lk-sk-form-head { display: flex; align-items: center; min-height: 2.75rem; padding: .75rem 1rem; }
/* THE COLUMN COUNT IS COPIED FROM THE REAL FORMS, NOT CHOSEN.
   Every form this skeleton stands in front of is `grid grid-cols-1 sm:grid-cols-2
   gap-4` (stock-in.php:570, stock-out.php:522, adjustment.php:298), i.e. ONE column
   below Tailwind's sm breakpoint and TWO from 640px up — never three.
   This said `repeat(auto-fit, minmax(min(100%, 15rem), 1fr))`, which at the app's
   944px content column resolves to THREE columns. Measured in Chrome at 1280x900
   with the frozen bundle + this file, the pending frame beside the hydrated body:
   .lk-sk-form-body computed `274px 274px 274px` against the real form's
   `419px 419px`, so Adjustment's 3 fields fitted on ONE skeleton row and TWO real
   rows — card 185px pending -> 323px real, +138px (+75%) on hydration, on a page
   whose whole point is that nothing moves when the data lands.
   1fr / 1fr 1fr at the same 640px breakpoint and the same 1rem gap is the same
   grid the form itself will lay out, so the field boxes are simply filled in. */
.lk-sk-form-body {
    display: grid; grid-template-columns: 1fr;
    gap: 1rem; padding: 1rem; border-top: 1px solid var(--lk-border, #eef1f6);
}
@media (min-width: 640px) { .lk-sk-form-body { grid-template-columns: 1fr 1fr; padding: 1.25rem; } }
.lk-sk-field { display: flex; flex-direction: column; gap: .45rem; min-width: 0; }
.lk-sk-field-full { grid-column: 1 / -1; }
.lk-sk-input { height: 2.1rem; width: 100%; border-radius: .6rem; }
.lk-sk-btn { height: 2rem; width: 9.5rem; border-radius: .6rem; }
.lk-async-plain { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .7rem; min-height: 38vh; color: var(--text-app, #1e293b); font-size: .85rem; font-weight: 600; }
.lk-async-spin {
    width: 2.1rem; height: 2.1rem; border-radius: 50%;
    border: 3px solid rgba(124, 58, 237, .2); border-top-color: var(--app-accent, #7c3aed);
    animation: lk-async-spin .7s linear infinite;
}
@supports (border-top-color: color-mix(in srgb, red, white)) {
    .lk-async-spin { border-color: color-mix(in srgb, var(--app-accent, #7c3aed) 20%, transparent); border-top-color: var(--app-accent, #7c3aed); }
}
@keyframes lk-async-spin { to { transform: rotate(360deg); } }
.lk-async-error { padding: 2.5rem 1rem; text-align: center; color: var(--text-muted, #64748b); font-size: .85rem; display: flex; flex-direction: column; align-items: center; gap: .7rem; }

/* ── FRAME-FIRST placeholders (app/async.php: lk_async_dash / lk_async_rows) ──
   A page now paints its real chrome on the FIRST request and fills in the data on
   hydration. Two things stand in for data that has not arrived, and BOTH must
   occupy the same box the real value will, or the page jumps when it lands. */

/* An unfetched figure. Same font-size/weight/line-height as the digits that
   replace it (the stat cards set those on the parent), so only the glyph changes.
   Deliberately NOT faint: an elderly or low-vision user has to be able to see that
   there IS a value pending, not think the card is empty or broken. */
.lk-async-dash {
    font-variant-numeric: tabular-nums;
    color: var(--text-muted, #64748b);
    opacity: .75;
    animation: lk-async-dash-pulse 1.5s ease-in-out infinite;
}
@keyframes lk-async-dash-pulse { 0%, 100% { opacity: .75; } 50% { opacity: .35; } }
@media (prefers-reduced-motion: reduce) { .lk-async-dash { animation: none; } }

/* "Loading…" in the middle of the table, inside the tbody, spanning the real
   columns — so the header the user is already reading does not move. Height is
   deliberately generous: it reserves roughly the space a first page of rows takes,
   which keeps the surrounding card from resizing twice. */
.lk-async-rows > td {
    padding: 3.2rem 1rem !important;
    text-align: center;
    color: var(--text-muted, #64748b);
    font-size: .85rem;
    font-weight: 600;
}
.lk-async-rows > td > .lk-async-spin {
    display: block;
    margin: 0 auto .7rem;
    width: 1.8rem; height: 1.8rem; border-width: 2.5px;
}
/* PHONE: keep that "Loading…" ON SCREEN. The cell spans every column and the rule
   above centres its content, so on a horizontally scrolling grid the spinner and
   the word sit at half the TABLE's width — off to the right of the viewport,
   leaving an apparently empty table that reads as broken. (Same defect the empty
   state was moved out of the table to avoid.) Below 640px, pin the content to the
   visible edge and put the spinner beside the label.
   HOISTED HERE from page-local <style> blocks in modules/item-master/page.php and
   stock-balance.php — both carried an identical copy, and both said in a comment
   that it belonged here so every frame-first page inherits it. It now does. */
@media (max-width: 639px) {
    .lk-grid-wrap tr.lk-async-rows > td { text-align: left; }
    .lk-grid-wrap tr.lk-async-rows > td > .lk-async-spin {
        display: inline-block; margin: 0 .55rem 0 0; vertical-align: -.35rem;
    }
}
/* Fade the real content in when it lands. */
/* Opacity only — a transform here would persist (fill-mode both) and turn the
   slot into a containing block for position:fixed, trapping every modal that an
   async partial renders inside the content area. See main.lk-page-enter above. */
.lk-async-slot.lk-async-done { animation: lk-fade-in .16s ease both; }
@media (prefers-reduced-motion: reduce) {
    .lk-sk, .lk-async-spin { animation: none; }
    .lk-async-slot.lk-async-done { animation: none; }
}
/* ── IN-PLACE SKELETON (luuqo.js §6b soft swap, §6c page snapshot, §6d tabs) ───
 *  The page keeps its REAL layout while its data reloads: the cards and rows stay
 *  exactly where they are and only the DATA turns into placeholders (luuqo.js wraps
 *  each text run in .lk-sk-t). Nothing inside is clickable or readable, so a stale
 *  value can never be acted on.
 *
 *  CLEAN SHAPES, NOT DEBRIS (owner and his boss, 2026-10-01: "skeleton hancur").
 *  The first version tinted every pill with the accent (peach on the orange
 *  themes), drew icons as shimmer circles and left buttons in their own colour,
 *  so a primary button became "a purple block holding a dot and a pill". Now:
 *  one NEUTRAL grey (taken from the text colour, so it is right on every theme),
 *  every text run one soft bar, every button / chip / tab / badge ONE block of its
 *  own size with nothing drawn inside it, inputs one bar, icons not drawn at all. */
.lk-stale, .lk-stale * {
    pointer-events: none !important;
    user-select: none !important; -webkit-user-select: none !important;
    color: transparent !important; -webkit-text-fill-color: transparent !important;
    text-shadow: none !important; caret-color: transparent !important;
}
.lk-stale { --lk-sk-base: #e8ebf0; --lk-sk-shine: rgba(255, 255, 255, .38); }
@supports (background: color-mix(in srgb, red, white)) {
    .lk-stale { --lk-sk-base: color-mix(in srgb, var(--app-text, #1e293b) 9%, var(--app-surface, #fff)); }
}
/* Rules from the design systems that publish theirs (eBay Playbook, Primer,
   Polaris, Atlassian, MUI; researched 2026-10-01): text is one bar per run, 4px
   corners; a BUTTON is one solid block of its own size; badges, status chips and
   icons are LEFT OUT (blank space reads cleaner than more shapes); inputs keep
   their real border and simply show no value; table column headers stay real.
   The sweep is slow and soft, and only starts after a second: most loads finish
   while it is still a calm, static grey. */
.lk-stale .lk-sk-t,
.lk-stale img,
.lk-stale progress,
.lk-stale .btn,
.lk-stale button,
.lk-stale [role="tab"] {
    background-color: var(--lk-sk-base) !important;
    background-image: linear-gradient(90deg, rgba(255, 255, 255, 0) 0, var(--lk-sk-shine) 50%, rgba(255, 255, 255, 0) 100%) !important;
    background-size: 200% 100% !important; background-repeat: no-repeat !important;
    background-position: 120% 0;
    border-color: transparent !important; box-shadow: none !important;
    animation: lk-sk-shimmer 1.5s ease-in-out 1s infinite;
}
/* A button is ONE block: nothing inside it is drawn (no dot, no inner bar). */
.lk-stale .btn *, .lk-stale button *, .lk-stale [role="tab"] * { visibility: hidden !important; }
/* Left out entirely: icons, badges, status chips. */
.lk-stale i[class*="fa-"], .lk-stale .badge, .lk-stale span[class*="rounded-full"] { visibility: hidden !important; }
/* Inputs stay real fields: border kept, value hidden, no fill. */
.lk-stale input:not([type="checkbox"]):not([type="radio"]):not([type="hidden"]),
.lk-stale select, .lk-stale textarea { background-image: none !important; }
/* Column headers stay real: they are the list's frame, not its data. */
.lk-stale thead, .lk-stale thead *, .lk-stale th, .lk-stale th * {
    color: var(--app-text-muted, #64748b) !important; -webkit-text-fill-color: var(--app-text-muted, #64748b) !important;
}
.lk-stale .lk-sk-t { border-radius: 4px; -webkit-box-decoration-break: clone; box-decoration-break: clone; }
.lk-stale img { object-position: -99999px -99999px !important; border-radius: .5rem; }
.lk-stale svg, .lk-stale canvas, .lk-stale video { opacity: 0 !important; }
.lk-stale input::placeholder, .lk-stale textarea::placeholder { color: transparent !important; }
/* §6e: the first ~450 ms of any swap only DIM what is leaving (taps paused), with a
   2px bar under the top bar; the bar shows only if the wait passes 120 ms. */
.lk-dim { opacity: .55 !important; transition: opacity .15s ease; }
#lk-progress { position: fixed; left: 0; right: 0; height: 2px; z-index: 45; pointer-events: none; overflow: hidden; opacity: 0; }
#lk-progress.is-on { opacity: 1; transition: opacity .1s linear .12s; }
#lk-progress::before {
    content: ''; position: absolute; top: 0; bottom: 0; left: -40%; width: 40%;
    background: linear-gradient(90deg, rgba(0, 0, 0, 0), var(--app-accent, #6366f1), rgba(0, 0, 0, 0));
    animation: lk-progress 1.1s ease-in-out infinite;
}
@keyframes lk-progress { from { transform: translateX(0); } to { transform: translateX(350%); } }
/* The page snapshot (§6c) lays out exactly like the content it copied. */
.lk-snap { display: contents; }
/* The data region fades in when its fresh content lands; nothing else moves. */
@keyframes lk-soft-in { from { opacity: .35; } to { opacity: 1; } }
.lk-soft-in { animation: lk-soft-in .2s ease-out; }
@media (prefers-reduced-motion: reduce) {
    .lk-stale .lk-sk-t, .lk-stale img, .lk-stale progress,
    .lk-stale .btn, .lk-stale button, .lk-stale [role="tab"] { animation: none; }
    #lk-progress::before { animation: none; left: 0; width: 100%; opacity: .5; }
    .lk-dim { transition: none; }
    .lk-soft-in { animation: none; }
}


/* ── DRILL MOTION — a direction for list → detail (public/assets/js/luuqo-drill.js)
 *
 *  Opening ONE ITEM out of a list of 83 used to look exactly like switching a
 *  settings tab: the same flat .16s fade above. So a DRILL now carries a
 *  direction — going deeper slides the new content in FROM THE RIGHT, coming
 *  back up slides it in FROM THE LEFT — while a sidebar link, a module tab, a
 *  filter or a page of results keeps that fade completely untouched. A motion
 *  that fires on every navigation tells the user nothing; this one only fires
 *  where there is a direction to report.
 *
 *  THIS REPLACES THE FADE FOR A DRILL, it does not stack on top of it: the same
 *  element (.lk-async-slot) already runs lk-fade-in, so a second animation would
 *  fight it for the opacity channel. The keyframes below therefore carry the
 *  fade INSIDE the slide — one animation, one element, no conflict — and the
 *  three-class selector guarantees it wins over .lk-async-done regardless of
 *  where a future edit lands in this file.
 *
 *  THE END STATE IS `transform: none`, AND THERE IS NO FILL-MODE. A transform
 *  that sticks turns the slot into a containing block for position:fixed, which
 *  is what once trapped every module's modal inside the content area (see
 *  main.lk-page-enter at the top of this file). With no fill-mode the transform
 *  is gone the instant the animation ends, and luuqo-drill.js additionally strips
 *  the class on animationend with a timeout behind it.
 *
 *  Horizontal overflow: translating right pushes past the viewport edge, which
 *  is already contained by `html, body { overflow-x: clip }` in the clarity pack
 *  above — so no scrollbar appears and nothing here needs its own clip, which
 *  would have cut off in-content dropdowns. Nothing sets overflow on the slot,
 *  so sticky table headers and the sticky freeze column keep resolving against
 *  their own scroll containers.
 *
 *  Travel is a share of the viewport with a floor and a ceiling, so 375px and a
 *  wide desktop both read as the same gesture rather than 40px looking like a
 *  shove on a phone and a twitch on a monitor. */
:root { --lk-drill-x: clamp(1.5rem, 6vw, 3rem); }
@keyframes lk-drill-from-right {
    from { opacity: 0; transform: translate3d(var(--lk-drill-x), 0, 0); }
    to   { opacity: 1; transform: none; }
}
@keyframes lk-drill-from-left {
    from { opacity: 0; transform: translate3d(calc(-1 * var(--lk-drill-x)), 0, 0); }
    to   { opacity: 1; transform: none; }
}
/* Decelerating curve: the content arrives quickly and settles, so the direction
   is unmistakable in the first 100ms without the whole page feeling slow. */
.lk-async-slot.lk-drill-in,
.lk-async-slot.lk-async-done.lk-drill-in {
    animation: lk-drill-from-right .28s cubic-bezier(.22, .61, .36, 1);
}
.lk-async-slot.lk-async-done.lk-drill-out,
.lk-async-slot.lk-drill-out {
    animation: lk-drill-from-left .28s cubic-bezier(.22, .61, .36, 1);
}
/* Motion-sensitive users get the established fade and nothing else — several of
   these users are elderly, and a sliding page is a real accessibility problem,
   not a preference. luuqo-drill.js also declines to add the class at all when
   the query matches; this rule is the guarantee for when JS cannot ask. */
@media (prefers-reduced-motion: reduce) {
    .lk-async-slot.lk-drill-in,
    .lk-async-slot.lk-drill-out,
    .lk-async-slot.lk-async-done.lk-drill-in,
    .lk-async-slot.lk-async-done.lk-drill-out {
        animation: lk-fade-in .16s ease both;
    }
}

/* ── App top bar background (RESTORED) ──────────────────────────────────────
   includes/header_app.php styles the sticky top bar with `bg-surface/85`, but
   the frozen Tailwind build contains NO opacity-modified surface utility — so
   the bar rendered with NO background at all and page content scrolled visibly
   underneath it (only `backdrop-blur-xl` softened the bleed-through; obvious on
   mobile where the search field showed through the bar). Supply the class here
   so the existing markup works unchanged, keeping the intended translucency. */
.bg-surface\/85 {
    background-color: color-mix(in srgb, var(--app-surface, #ffffff) 85%, transparent);
}
@supports not (background-color: color-mix(in srgb, #fff 85%, transparent)) {
    .bg-surface\/85 { background-color: var(--app-surface, #ffffff); }
}

/* ── Scrollbars, app-wide (it.skrichgroup.com house style) ───────────────────
   §8 above only styled `.overflow-x-auto` / `.overflow-auto`, so every VERTICAL
   scroll area — the sidebar nav, the role-editor permission box, the branch
   switcher — fell back to the raw platform scrollbar: a wide grey track with
   ▲▼ arrow buttons that looks nothing like the rest of the app. Style them
   globally instead, accent-tinted like IT Valoobuy, and kill the arrows. */
/* Firefox ONLY. The standard `scrollbar-width`/`scrollbar-color` properties
   DISABLE every ::-webkit-scrollbar-* rule in Chrome, which then falls back to
   its own thin scrollbar — and that one still draws ▲▼ arrow buttons. So gate
   them behind a feature query Chrome fails and Firefox passes. */
@supports not selector(::-webkit-scrollbar) {
    * {
        scrollbar-width: thin;
        scrollbar-color: color-mix(in srgb, var(--app-accent, #6366f1) 34%, transparent) transparent;
    }
    #lk-sidebar,
    #lk-sidebar * { scrollbar-color: rgba(255, 255, 255, .34) transparent; }
}
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
    background: color-mix(in srgb, var(--app-accent, #6366f1) 30%, transparent);
    border-radius: 999px;
    transition: background .18s ease;
}
::-webkit-scrollbar-thumb:hover {
    background: color-mix(in srgb, var(--app-accent, #6366f1) 62%, transparent);
}
/* Arrow buttons and the dead corner square are pure platform chrome. */
::-webkit-scrollbar-button { display: none; width: 0; height: 0; }
::-webkit-scrollbar-corner { background: transparent; }

/* The sidebar sits on a saturated accent panel, where an accent-tinted thumb
   disappears — use a translucent white one there instead. (The Firefox
   `scrollbar-color` counterpart lives inside the @supports block above; setting
   it here too would re-disable the webkit rules in Chrome.) */
#lk-sidebar ::-webkit-scrollbar-thumb,
#lk-sidebar::-webkit-scrollbar-thumb {
    background: rgba(255, 255, 255, .3);
}
#lk-sidebar ::-webkit-scrollbar-thumb:hover,
#lk-sidebar::-webkit-scrollbar-thumb:hover {
    background: rgba(255, 255, 255, .58);
}

/* Older engines without color-mix still need a visible thumb. */
@supports not (background: color-mix(in srgb, #fff 30%, transparent)) {
    ::-webkit-scrollbar-thumb { background: rgba(100, 116, 139, .34); }
    ::-webkit-scrollbar-thumb:hover { background: rgba(100, 116, 139, .6); }
}

/* ── max-h-80 (RESTORED) ────────────────────────────────────────────────────
   The frozen build ships `.max-h-96` but NOT `.max-h-80`, so the branch-switcher
   list never capped its height and grew as long as the branch count. */
.max-h-80 { max-height: 20rem; }

/* ── UTILITIES THE FROZEN BUILD NEVER EMITTED (RESTORED) ────────────────────
   A UI-consistency audit found ~50 class usages across the module pages that
   produce NO CSS at all, so the markup looks correct and silently does nothing.
   Two families, both confirmed absent with
     grep -o '\.CLASS{[^}]*}' public/build/assets/app-*.css

   1. EVERY `!`-important variant. The build contains not one of them, so every
      destructive control written as `btn btn-soft !text-rose-700` rendered in
      plain neutral grey — Reject sat beside Approve looking identical, and the
      in/out colour coding on the Stock In / Stock Out shortcuts was dead too.
      Values below are copied from each plain utility's own rule in the bundle,
      so they carry the real theme variables rather than invented colours.

   2. Arbitrary-value widths. `min-w-[10rem]` on a search field and
      `max-w-[14rem]` on a filter select emitted nothing, so filter selects
      stretched to full width in a stacked column on desktop, and on a phone the
      search box was the first thing to collapse to its content box.

   Restored here rather than rewritten in ~20 files: one edit, the existing
   markup keeps working, and any `!` override an author intended still wins. */
.\!p-0 { padding: calc(var(--spacing) * 0) !important; }
.\!p-2 { padding: calc(var(--spacing) * 2) !important; }
.\!p-2\.5 { padding: .625rem !important; }
.\!px-1 { padding-inline: calc(var(--spacing) * 1) !important; }
.\!px-2 { padding-inline: calc(var(--spacing) * 2) !important; }
.\!py-1 { padding-block: calc(var(--spacing) * 1) !important; }
.\!py-1\.5 { padding-block: calc(var(--spacing) * 1.5) !important; }
.\!pl-9 { padding-left: 2.25rem !important; }
.\!rounded-lg { border-radius: var(--radius-lg) !important; }
.\!text-xs {
    font-size: var(--text-xs) !important;
    line-height: var(--tw-leading, var(--text-xs--line-height)) !important;
}
.\!text-muted-app { color: var(--app-text-muted) !important; }
.\!text-rose-700 { color: var(--color-rose-700) !important; }
.\!text-red-700 { color: var(--color-red-700) !important; }
.\!text-emerald-700 { color: var(--color-emerald-700) !important; }
.\!text-amber-700 { color: var(--color-amber-700) !important; }
.\!text-cyan-700 { color: var(--color-cyan-700) !important; }
.\!bg-amber-50 { background-color: var(--color-amber-50) !important; }
.\!w-auto { width: auto !important; }
.\!hidden { display: none !important; }
.\!justify-start { justify-content: flex-start !important; }

/* Plain utilities the build also skipped (Tailwind's own scale, unit .25rem). */
.p-2\.5 { padding: .625rem; }
.pl-9 { padding-left: 2.25rem; }
.w-44 { width: 11rem; }
.normal-case { text-transform: none; }
.max-w-\[12rem\] { max-width: 12rem; }
.max-w-\[14rem\] { max-width: 14rem; }
.max-w-\[16rem\] { max-width: 16rem; }
.min-w-\[10rem\] { min-width: 10rem; }
.min-w-\[12rem\] { min-width: 12rem; }
.min-w-\[15rem\] { min-width: 15rem; }

/* ══════════════════════════════════════════════════════════════════════════════
   UI-CONSISTENCY CONVERGENCE — shared foundation (2026-08-04)
   One block, appended last, for the six /app module pages that were each
   hand-rolling a fix for the same missing framework piece. Every colour below is
   either an existing --app-* theme variable or copied verbatim from a rule
   earlier in THIS file — nothing new is invented.
   ══════════════════════════════════════════════════════════════════════════════ */

/* ── 1. MORE UTILITIES THE FROZEN BUILD NEVER EMITTED ───────────────────────
   Proven absent in public/build/assets/app-DQzN9urV.css (which is Tailwind
   v4.3.0 and writes its breakpoints as `@media (width>=40rem)`):
     .pb-4{      -> 0 matches
     .sm\:px-5   -> 0 matches
     .sm\:pb-5   -> 0 matches
   WHAT WAS BROKEN: the collapsible form body on Stock In (stock-in.php:447),
   Stock Out (stock-out.php:291) and Adjustment (adjustment.php:203) is written
   `class="px-4 pb-4 sm:px-5 sm:pb-5 …"`. With all three classes dead the body
   computed `padding: 16px 16px 0px 16px` — no bottom padding at all, so on every
   one of those pages the last row of fields ran into the card edge and was
   clipped by the card's own 16px corner radius.
   640px is the same breakpoint the bundle uses for `sm:` (40rem at the default
   root font-size, which the app never overrides). */
.pb-4 { padding-bottom: 1rem; }
@media (min-width: 640px) {
    .sm\:px-5 { padding-inline: 1.25rem; }
    .sm\:pb-5 { padding-bottom: 1.25rem; }
}

/* ── 2. .lk-empty-ico — the empty-state icon ─────────────────────────────────
   Font Awesome is loaded from the CDN UNLAYERED, and its `.fas,.fa-solid{…}`
   rule sets display:inline-block. Tailwind's `.block` lives inside
   `@layer utilities`, and an unlayered rule always beats a layered one, so
   `<i class="fas fa-box-open mb-2 block">` never became a block: almost every
   "no rows yet" state on the module pages rendered as one cramped inline line
   (icon jammed against the sentence) instead of icon-above-copy. This rule is
   deliberately unlayered too, and luuqo-fx.css loads AFTER Font Awesome
   (includes/header_app.php:359 then :363), so it wins. Modules swap the dead
   `block mb-2` pair onto this one class. */
.lk-empty-ico {
    display: block;
    margin-bottom: .5rem;
}

/* ── 3a. GRID CONTRAST — vertical rules were far fainter than horizontal ─────
   `.lk-table tbody td` (see :1171) draws its horizontal at
   `1px solid var(--lk-border, #e5e8ef)` but its vertical at
   `rgba(148,163,184,.14)`, and `.lk-table thead th` (:1163) uses
   `rgba(148,163,184,.20)`. On the light themes that is already a visible
   mismatch; on Midnight (--app-surface: oklch(20% .065 250)) the #e5e8ef
   horizontal reads as a bright line while a .14-alpha slate vertical is
   effectively invisible — the table stops being a GRID and becomes a striped
   list, which breaks the house rule that low-vision users get visible column
   lines. The verticals are raised to the horizontal's OWN value, not a new
   colour, so the two can never drift apart per theme again.
   `--lk-border` is undefined anywhere in the app, so both sides resolve to
   #e5e8ef today; they are kept as one shared token on purpose — migrating only
   the vertical to --app-border would desynchronise the pair on dark themes.
   `.lk-table th:last-child/.lk-table td:last-child` (:1175, specificity 0,2,1)
   still beats this rule (0,1,2), so the outer edge stays borderless. */
.lk-table thead th,
.lk-table tbody td {
    border-right: 1px solid var(--lk-border, #e5e8ef);
}

/* ── 3b. GRID HEADER LABELS — the intended 11px never applied ────────────────
   `.lk-table.lk-grid thead th { font-size:.6875rem }` at :1237 has specificity
   (0,2,2) and loses to `.lk-table.lk-grid th:not([colspan])` at :1226 (0,3,1 —
   :not([colspan]) counts as an attribute selector), so every .lk-grid header
   label rendered at the 12px data-cell size instead of the 11px it asks for and
   the header row read as just another data row. Adding the same
   `:not([colspan])` takes this to (0,3,2) and it applies. */
.lk-table.lk-grid thead th:not([colspan]) { font-size: .6875rem; }

/* ── 4. .lk-grid tfoot — grand-total rows ────────────────────────────────────
   The totals rows in Stock Valuation (page.php:581) and the Requisition report
   (report.php:311) sit in a <tfoot> that had NO styling of its own: no
   background, so rows scrolled underneath it through the transparent cells; no
   border; not pinned, so on a long list the grand total scrolled out of sight
   and the number the user came for was unreachable without hitting the bottom.
   Pinned to the bottom of .lk-grid-wrap (which caps at max-height:68vh) with an
   opaque surface and the header's own 2px accent border mirrored on top, so the
   header and the total row bookend the grid.
   Z-INDEX LADDER (extends the one documented at :1277): thead corner 4 >
   thead th 3 = tfoot td 3 > frozen tbody td 2 > normal td auto, and the tfoot's
   own frozen corner takes 5 so scrolled total cells cannot paint over the
   pinned label — the same failure the thead corner comment describes. */
.lk-table.lk-grid tfoot td,
.lk-table.lk-grid tfoot th {
    position: sticky;
    bottom: 0;
    z-index: 3;
    background: var(--app-surface, #fff);
    border-top: 2px solid #c7d2fe;                    /* fallback, matches .lk-table thead */
    font-weight: 700;
}
/* Padding needs the doubled class (the trick documented at :130) to reach (0,4,2):
   `.lk-table.lk-grid td:not([colspan])` at :1226 is (0,3,1) and otherwise wins on
   the plain cells only, which left the total row with 6px on its label and 8px on
   its colspan filler cell — a visibly uneven row height across one total. */
.lk-table.lk-grid.lk-grid tfoot td,
.lk-table.lk-grid.lk-grid tfoot th { padding: 8px 12px; }
.lk-table.lk-grid tfoot td:not(:last-child),
.lk-table.lk-grid tfoot th:not(:last-child) {
    border-right: 1px solid var(--lk-border, #e5e8ef);
}
/* Corner cell (total row ∩ frozen column). Needs :not([colspan]) + both class
   names so its specificity (0,4,2) beats the generic frozen-column rule at
   :1265 (0,4,1), which would otherwise hold it at z-index 2. */
.lk-table.lk-grid-freeze tfoot td:first-child:not([colspan]),
.lk-table.lk-grid-freeze tfoot th:first-child:not([colspan]) {
    left: 0;
    z-index: 5;
}
@supports (background: color-mix(in srgb, red, white)) {
    .lk-table.lk-grid tfoot td,
    .lk-table.lk-grid tfoot th {
        border-top-color: color-mix(in srgb, var(--app-accent, #6366f1) 34%, var(--lk-border, #e2e8f0));
    }
}

/* ── 5. CONFIRM MODAL — was hardcoded light, so it fought the theme ──────────
   `.lk-modal` (:77) shipped `background:#fff; color:#0f172a` and `.lk-modal p`
   (:88) `color:#475569`, while the Cancel button inside it is `.btn-soft`, which
   the frozen build defines as `background:var(--app-surface-2); color:
   var(--app-text)`. On Midnight that produced a hardcoded white card carrying a
   dark button with near-white label text on it — the Cancel action was the
   least readable thing in a dialog whose whole job is a clear yes/no, and every
   destructive confirmation in the app goes through it. Moved onto the same
   --app-* variables the buttons already use, keeping the old literals as the
   no-variable fallback. The 1px border is added because the card's only
   separation from the backdrop was `box-shadow: … rgba(15,23,42,.28)`, which is
   invisible against a dark backdrop over a dark page.
   `.lk-modal-ico`'s pale-red tile is derived from the live surface for the same
   reason; the red itself is the existing rule's own #dc2626. */
.lk-modal {
    background: var(--app-surface, #fff);
    color: var(--app-text, #0f172a);
    border: 1px solid var(--app-border, #e5e8ef);
}
.lk-modal p { color: var(--app-text-muted, #475569); }
@supports (background: color-mix(in srgb, red, white)) {
    .lk-modal-ico {
        background: color-mix(in srgb, #dc2626 14%, var(--app-surface, #fff));
        color: color-mix(in srgb, #dc2626 76%, var(--app-text, #0f172a));
    }
}

/* ══════════════════════════════════════════════════════════════════════════════
   ACCESSIBILITY + FROZEN-BUILD PASS (2026-08-06)
   Six defects that all trace back to a name that resolves to nothing: four CSS
   custom properties that are referenced but never declared, and nine Tailwind
   utilities the frozen build purged. Every value below is either an existing
   --app-* theme variable or copied verbatim from the same utility's own rule in
   public/build/assets/app-DQzN9urV.css — nothing is invented.
   ══════════════════════════════════════════════════════════════════════════════ */

/* ── A. THE MISSING TOKENS ───────────────────────────────────────────────────
   `--lk-surface-2`, `--lk-border`, `--text-app` and `--text-muted` are read at
   17 call sites in this file (and by modules/item-master) but declared NOWHERE
   in the app — only `--app-*` exists. Every one of those sites therefore fell
   back to its hardcoded LIGHT literal regardless of theme, so on Midnight:
     • .lk-sk-head painted an rgb(241,245,249) bar across an oklch(20%) card —
       a white flash on EVERY module page load, the most-seen frame in the app;
     • .lk-async-plain drew "Loading…" in rgb(30,41,59) on that dark card, i.e.
       the one word explaining the wait was unreadable;
     • .im-filt-opt (the Category / Branch filter lists on Item Master and Stock
       Balance) drew 18 dark-navy option labels on a dark-navy panel — legible
       only while hovered, and on touch there is no hover at all.
   Declaring the four as aliases fixes all 17 sites at once and keeps the
   deliberate horizontal/vertical grid-line PAIRING documented at :1595 intact,
   because both sides still read one shared token.
   --lk-border is the one alias that is NOT a straight --app-border pass-through:
   Midnight's --app-border is oklch(33% .055 250/.55) over an oklch(20%) surface,
   which is far too faint to read as a column rule, and low-vision users are
   required to get a visible GRID. Light themes keep today's exact #e5e8ef (zero
   visual change); Midnight gets the same idea inverted — a light hairline. */
:root {
    --lk-border: #e5e8ef;
    --lk-surface-2: var(--app-surface-2, #f1f5f9);
    --text-app: var(--app-text, #0f172a);
    --text-muted: var(--app-text-muted, #64748b);
}
/* Dark theme: flip the grid hairline so it stays VISIBLE instead of staying
   bright. Declared after :root (equal specificity, so the later one wins). */
[data-theme="midnight"] {
    --lk-border: rgba(203, 213, 225, .34);
}

/* ── B. UTILITIES THE FROZEN BUILD PURGED ────────────────────────────────────
   Each proven absent from app-DQzN9urV.css by escaped-selector match. Declared
   here (luuqo-fx.css loads last) so the existing markup works unchanged. */

/* B1. Widths. The top-bar branch switcher asks for max-w-[9rem] sm:max-w-[12rem]
   and its panel for w-64; with all three dead a realistic outlet name ("Talia
   Cafe — Samarahan" already exists in KAFE001) grew the button to 358px on a
   375px screen, pushing the notification bell and the page title off-viewport,
   and the panel shrink-wrapped to its content instead of 256px. The label span
   already carries `truncate`, so clamping the button yields an ellipsis. */
.w-56 { width: calc(var(--spacing) * 56); }
.w-64 { width: calc(var(--spacing) * 64); }
.max-w-\[9rem\] { max-width: 9rem; }
@media (min-width: 640px) {
    .sm\:max-w-\[12rem\] { max-width: 12rem; }
}

/* B2. /app/support's two-column layout. The wrapper asks for a named-track grid
   and the compose panel for `lg:sticky lg:top-20`; the arbitrary grid value and
   `lg:top-20` were both absent, so the page computed ONE 1048px track — the
   ticket list and the form stacked, giving a 1048px-wide single-line Subject
   field — and `position:sticky` with `top:auto` never stuck. */
@media (min-width: 1024px) {
    .lg\:grid-cols-\[minmax\(0\,1fr\)_minmax\(320px\,420px\)\] {
        grid-template-columns: minmax(0, 1fr) minmax(320px, 420px);
    }
    .lg\:top-20 { top: calc(var(--spacing) * 20); }
}

/* B3. The `file:` variant of the two accent utilities. Company Settings styles
   its Logo and Wallpaper pickers with file:border-0 (which DID apply, stripping
   the browser's own button chrome) plus file:bg-accent-soft / file:text-accent-app
   (which did not) — so the net effect of the styling was to make "Choose File"
   LESS visible than an unstyled input: bare text, no border, no background, on
   the two controls that set the tenant's own branding. Values copied from
   .bg-accent-soft and .text-accent-app in the bundle, color-mix included. */
.file\:bg-accent-soft::file-selector-button { background-color: var(--app-accent); }
@supports (color: color-mix(in lab, red, red)) {
    .file\:bg-accent-soft::file-selector-button {
        background-color: color-mix(in oklch, var(--app-accent) 14%, transparent);
    }
}
.file\:text-accent-app::file-selector-button { color: var(--app-accent); }

/* B4. Hover feedback on the tab strips (Company Settings, My Profile, and all
   three Requisition pages) and on the dashboard / store card titles. All five
   strips render inactive tabs as `text-muted-app hover:text-app hover:border-app`
   and neither hover utility exists, so an inactive tab was pixel-identical
   hovered and un-hovered — muted grey text with no border and no reaction reads
   as a LABEL, not a control, which is exactly the cue a non-IT user relies on.
   The requisition files carry a comment keeping the classes as a "forward bet"
   on a future Tailwind rebuild; the build is frozen, so the bet is settled here.
   `.group:hover` has no emitted rules at all in the bundle, hence the explicit
   descendant selector for group-hover. */
.hover\:text-app:hover { color: var(--app-text); }
.hover\:border-app:hover { border-color: var(--app-border); }
.group:hover .group-hover\:text-accent-app { color: var(--app-accent); }

/* ── C. TOUCH TARGETS ON THE PRIMARY NAVIGATION ──────────────────────────────
   Gated to phone/tablet widths and coarse pointers so desktop density is
   byte-for-byte unchanged. This EXTENDS the elder-friendly pass at :555, which
   deliberately excludes .btn-ghost from its 44px bump because full-size ghost
   buttons looked bloated — the fix here grows only the HIT AREA of the topbar
   hamburger, whose icon stays 12px. */
@media (max-width: 1023.98px), (pointer: coarse) {
    /* The hamburger is the ONLY route to navigation on a phone and measured
       26 x 34 px — well under the 44 x 44 minimum that exists for imprecise
       touch. It is a transparent ghost button, so a larger box is invisible
       until pressed. */
    .lk-topbar-btn {
        min-width: 2.75rem;
        min-height: 2.75rem;
    }
    /* Module sub-nav rows (Manage Item, Stock Balance, Stock In/Out/Adjustment/
       Stock Take, POS, KDS, Form/Review/Report, Settings) were 28px tall, so
       adjacent destinations sat about 28px apart and a user aiming for one
       landed on its neighbour. Height and type size only; the indent, the active
       dot and the hover transform are untouched. */
    .lk-subnav-link {
        min-height: 2.75rem;
        padding-top: .55rem;
        padding-bottom: .55rem;
        font-size: .875rem;
    }
    /* Filter checkboxes measured 15 x 15 px — essentially untappable. Grow the
       control and give its label a full-height row so the WORD is a target too.
       Real specificity (not :where()) because these inputs carry no size utility
       of their own; on touch, bigger is the house rule. */
    .theme-root input[type="checkbox"],
    .theme-root input[type="radio"] {
        width: 1.35rem;
        height: 1.35rem;
    }
    .theme-root label:has(> input[type="checkbox"]),
    .theme-root label:has(> input[type="radio"]) {
        min-height: 2.75rem;
    }
    .im-filt-opt { min-height: 2.75rem; font-size: .875rem; }
}

/* ============================================================================
 *  D. THE DEPTH SCALE  ("sakit mata" fix — flat page, nothing for the eye to hold)
 * ============================================================================
 *  MEASURED PROBLEM (real rendered pixels, KAFE001 /app/item-master +
 *  /app/item-master/stock-balance, Chrome, 1440 and 375):
 *
 *      theme      page bg   card bg   page-vs-card contrast
 *      luuko      #fdfdfe   #fefefe   1.008   <- the DEFAULT theme. One plane.
 *      lagoon     #effbfc   #fefefe   1.048
 *      blossom    #fdf6f7   #fefefe   1.057
 *      forest     #f1f5f8   #fefefe   1.087
 *      midnight   #00091c   #0e233d   1.256
 *
 *  On the default theme --app-bg and --app-surface are BOTH #fff, so a "raised
 *  white card" was mathematically indistinguishable from the page behind it. The
 *  card's shadow was 0 1px 2px at ~4% alpha, i.e. invisible at arm's length.
 *  Midnight's table header measured 1.027 against its card — no header band at
 *  all. That is the whole complaint: every element on ONE plane, so the eye has
 *  no edge to rest on and a 250-number grid becomes exhausting to read.
 *
 *  THE FIX = four deliberate levels, driven by ONE small token set, never by
 *  ad-hoc per-component shadows:
 *
 *      L1  page      a real step greyer than the card
 *      L2  card      stays the theme's surface, now genuinely lifted
 *      L3  thead     its own tone + a shadow BENEATH it, so the sticky header
 *                    reads as floating above the rows instead of merging in
 *      L4  rows      definite separators + zebra + a hover that reads as a lift
 *
 *  EVERY value is derived with color-mix() from the theme's OWN --app-* tokens,
 *  so all five shipped themes (and any future one) get depth in their own hue —
 *  no per-theme colour literals to maintain. Plain fallbacks are declared first
 *  for engines without color-mix, exactly as the rest of this file does.
 *
 *  DARK THEMES ARE INVERTED ON PURPOSE. A dark drop-shadow on a dark page makes
 *  a card look DIRTY, not raised, so on Midnight depth comes from a light RIM
 *  plus an inner top highlight, and its "raised" tones go LIGHTER rather than
 *  darker. Midnight is the only dark theme shipped today; a future dark theme
 *  needs its selector added to the [data-theme="midnight"] blocks below (they
 *  are the only theme-specific code in this section — everything else is
 *  generic and a new LIGHT theme is picked up automatically).
 * ========================================================================== */

/* ── D1. Tokens ──────────────────────────────────────────────────────────────
   --lk-depth-ink is the shadow COLOUR: the theme accent folded into a deep
   navy. Pure-black shadows read muddy; a shadow carrying a little of the
   theme's own hue is what makes the surface look expensive. It is used only at
   low alpha with a large blur — "brighter and softer", not heavier. */
:root {
    --lk-depth-ink: #16203a;                    /* fallback: deep navy, no tint */
    /* The colour the CARD paints. Light themes: identical to --app-surface, so
       nothing changes. Dark themes raise it (see D7) — on a near-black theme the
       page has no room left to go darker, so elevation has to come from a
       LIGHTER card. Every row/header tone below mixes into THIS, not into
       --app-surface, so the whole ladder moves together and the frozen column
       can never drift away from the card it sits on. */
    --lk-card-surface: var(--app-surface, #fff);
    --lk-row-zebra: #f6f7fb;
    --lk-row-hover: #eaeefb;
    --lk-thead-1: #f2f5ff;
    --lk-thead-2: #e9edfb;
}
@supports (background: color-mix(in srgb, red, white)) {
    :root {
        --lk-depth-ink: color-mix(in srgb, var(--app-accent, #6366f1) 24%, #0a1020);

        /* L1 — page one step greyer. Derived by folding the theme's own TEXT
           colour into its surface, so each theme greys toward its own hue
           instead of toward a foreign neutral. --app-bg has exactly one
           consumer (.bg-app on <body>) plus the welcome overlay's frosted
           veil, both of which want this value, so overriding the token is
           safer than restyling <body> and keeps everything derived consistent.
           :root here is the same element as [data-theme=…] (both are <html>),
           and luuqo-fx.css loads last, so this wins at equal specificity.
           NOTE this only works while the theme's text is DARKER than its
           surface. Midnight opts out in D7. */
        --app-bg: color-mix(in srgb, var(--app-text, #0f172a) 7%, var(--app-surface, #fff));

        /* L4 — separators. The old #e5e8ef hairline was too timid to read as a
           GRID for a low-vision user; fold more of the text colour in. */
        --lk-border: color-mix(in srgb, var(--app-text, #0f172a) 18%, var(--app-surface, #fff));

        --lk-row-zebra: color-mix(in srgb, var(--app-accent, #6366f1) 7%, var(--lk-card-surface));
        --lk-row-hover: color-mix(in srgb, var(--app-accent, #6366f1) 17%, var(--lk-card-surface));

        /* L3 — header band, a clear step darker than the card it sits on. */
        --lk-thead-1: color-mix(in srgb, var(--app-accent, #6366f1) 24%, var(--lk-card-surface));
        --lk-thead-2: color-mix(in srgb, var(--app-accent, #6366f1) 15%, var(--lk-card-surface));
    }
}

/* ── D2. L2 — the card actually lifts ────────────────────────────────────────
   Large blur, low alpha, accent-tinted, plus a 1px tinted ring for a crisp
   edge and an inset top highlight for the glass read. This REPLACES the old
   0 1px 2px @ 4% (invisible) and the hover shadow at :322.

   .admin-card is deliberately NOT included anywhere in this section. It is used
   only under pages/admin/, every one of which loads includes/header_admin.php —
   and that file declares .admin-card in an inline <style> AFTER this stylesheet
   (link at :156, style at :161). Equal specificity, later in document order, so
   the inline block wins and any .admin-card rule here would be dead code that
   reads as if it worked. The admin console already has its own working depth (a
   frosted translucent panel over a blue canvas) and was not part of the
   complaint. Restyling it means editing header_admin.php. */
.card:not(.lk-dialog-card) {
    box-shadow:
        0 0 0 1px rgba(22, 32, 58, .07),
        0 1px 2px rgba(22, 32, 58, .06),
        0 10px 26px -12px rgba(22, 32, 58, .16),
        0 24px 54px -28px rgba(22, 32, 58, .20),
        inset 0 1px 0 rgba(255, 255, 255, .9);
}
@supports (background: color-mix(in srgb, red, white)) {
    .card:not(.lk-dialog-card) {
        box-shadow:
            0 0 0 1px color-mix(in srgb, var(--lk-depth-ink) 8%, transparent),
            0 1px 2px color-mix(in srgb, var(--lk-depth-ink) 7%, transparent),
            0 10px 26px -12px color-mix(in srgb, var(--lk-depth-ink) 18%, transparent),
            0 24px 54px -28px color-mix(in srgb, var(--lk-depth-ink) 22%, transparent),
            inset 0 1px 0 rgba(255, 255, 255, .9);
    }
}

@media (hover: hover) {
    /* Deepen the SAME shadow language on hover (the 2px lift from :324 still
       applies — this only restyles the shadow it animates to). */
    .card:not(.lk-dialog-card):hover {
        box-shadow:
            0 0 0 1px rgba(22, 32, 58, .10),
            0 2px 4px rgba(22, 32, 58, .07),
            0 16px 34px -14px rgba(22, 32, 58, .22),
            0 34px 68px -32px rgba(22, 32, 58, .26),
            inset 0 1px 0 rgba(255, 255, 255, .95);
    }
    @supports (background: color-mix(in srgb, red, white)) {
        .card:not(.lk-dialog-card):hover {
            box-shadow:
                0 0 0 1px color-mix(in srgb, var(--lk-depth-ink) 11%, transparent),
                0 2px 4px color-mix(in srgb, var(--lk-depth-ink) 8%, transparent),
                0 16px 34px -14px color-mix(in srgb, var(--lk-depth-ink) 24%, transparent),
                0 34px 68px -32px color-mix(in srgb, var(--lk-depth-ink) 28%, transparent),
                inset 0 1px 0 rgba(255, 255, 255, .95);
        }
    }
    /* …but a card wrapping a DATA GRID must not jump when the pointer crosses
       it. The hover-lift at :324 is fine on a small dashboard tile; on a card
       holding hundreds of rows it shoves the whole table 2px while someone is
       mid-row, which is the opposite of restful. House rule: nothing moves
       while a person is reading. The shadow still deepens, so the card is
       still visibly the focused surface. */
    @supports selector(:has(*)) {
        .card:has(.lk-grid-wrap):hover,
        .card:has(> table.lk-table):hover,
        .card:has(.lk-table.lk-grid):hover {
            transform: none;
        }
    }
}

/* ── D3. L3 — the table header becomes a band that floats above the rows ─────
   Its own tone, a 2px accent rule, and a soft shadow cast DOWNWARD onto the
   first rows. Because .lk-grid thead th is position:sticky, that shadow is
   what makes the pinned header read as held ABOVE the data while scrolling
   rather than dissolved into it. */
.lk-table thead th {
    background: var(--lk-thead-1);
    box-shadow: 0 6px 10px -6px rgba(22, 32, 58, .28);
}
@supports (background: color-mix(in srgb, red, white)) {
    .lk-table thead th {
        background: linear-gradient(180deg, var(--lk-thead-1), var(--lk-thead-2));
        /* Header LABEL contrast: the old mix (accent 76% + text) measured 4.10:1
           on the default theme — below the 4.5:1 AA floor, at 11px bold, on the
           densest financial screen in the product. Pulling the mix toward the
           theme's text colour keeps the accent identity and clears AA on every
           theme. */
        color: color-mix(in srgb, var(--app-accent, #6366f1) 34%, var(--app-text, #0f172a));
        border-bottom-color: color-mix(in srgb, var(--app-accent, #6366f1) 48%, var(--lk-border, #e2e8f0));
        box-shadow: 0 6px 10px -6px color-mix(in srgb, var(--lk-depth-ink) 30%, transparent);
    }
}

/* ── D4. L4 — definite separators, zebra, and a hover that reads as a lift ── */
.lk-table tbody tr:nth-child(even) { background: var(--lk-row-zebra); }
.lk-table tbody tr:hover           { background: var(--lk-row-hover); }
/* "Lift" WITHOUT transform: a transform on a <tr> would make it a containing
   block and break both the sticky header and the frozen first column. A top
   highlight + a bottom shade on each cell gives the same raised read, costs no
   layout, and cannot disturb the scroll rig. */
.lk-table tbody tr:hover > td {
    box-shadow:
        inset 0 1px 0 rgba(255, 255, 255, .55),
        inset 0 -1px 0 rgba(22, 32, 58, .13);
}
@supports (background: color-mix(in srgb, red, white)) {
    .lk-table tbody tr:hover > td {
        box-shadow:
            inset 0 1px 0 rgba(255, 255, 255, .55),
            inset 0 -1px 0 color-mix(in srgb, var(--lk-depth-ink) 16%, transparent);
    }
}

/* ── D5. Keep the frozen column and the sticky corner in the same language ───
   These selectors are MORE specific than D3/D4 (:not([colspan]) + the .lk-grid-
   freeze class), so without re-declaring here the pinned column would keep the
   OLD zebra/hover tones and visibly diverge from the rest of the row, and the
   corner cell would lose the new header band. Each keeps its existing lip
   shadow — that lip is what tells the user the column is pinned — and gains
   the new depth on top of it. */
/* Odd (unstriped) pinned cell must repaint the CARD surface, not --app-surface:
   on a dark theme the card is raised above --app-surface (D7), and the pinned
   column is opaque by necessity, so leaving it on the raw token would print a
   visibly darker stripe down the frozen column. */
/* background-COLOR only — see the note at the D3 freeze block: the shorthand
   here wipes background-image on the pinned cell, which is where row markers
   like .im-row-off live. */
.lk-table.lk-grid-freeze td:first-child:not([colspan]) { background-color: var(--lk-card-surface); }
.lk-table.lk-grid-freeze tbody tr:nth-child(even) td:first-child:not([colspan]) { background-color: var(--lk-row-zebra); }
.lk-table.lk-grid-freeze tbody tr:hover          td:first-child:not([colspan]) { background-color: var(--lk-row-hover); }
.lk-table.lk-grid-freeze thead th:first-child:not([colspan]) {
    background: var(--lk-thead-1);
    box-shadow:
        1px 0 0 rgba(148, 163, 184, .28),
        6px 0 8px -6px rgba(15, 23, 42, .18),
        0 6px 10px -6px rgba(22, 32, 58, .28);
}
@supports (background: color-mix(in srgb, red, white)) {
    .lk-table.lk-grid-freeze thead th:first-child:not([colspan]) {
        background: linear-gradient(180deg, var(--lk-thead-1), var(--lk-thead-2));
        box-shadow:
            1px 0 0 color-mix(in srgb, var(--lk-depth-ink) 26%, transparent),
            6px 0 8px -6px color-mix(in srgb, var(--lk-depth-ink) 30%, transparent),
            0 6px 10px -6px color-mix(in srgb, var(--lk-depth-ink) 30%, transparent);
    }
    /* Hovered pinned cell: keep the pin lip AND pick up the row's lift. */
    .lk-table.lk-grid-freeze tbody tr:hover td:first-child:not([colspan]) {
        box-shadow:
            1px 0 0 color-mix(in srgb, var(--lk-depth-ink) 26%, transparent),
            6px 0 8px -6px color-mix(in srgb, var(--lk-depth-ink) 24%, transparent),
            inset 0 1px 0 rgba(255, 255, 255, .55),
            inset 0 -1px 0 color-mix(in srgb, var(--lk-depth-ink) 16%, transparent);
    }
}

/* ── D6. Figures line up in columns ──────────────────────────────────────────
   Verified in-browser: every number cell in the two dense grids ALREADY had
   text-right + tabular-nums (0 of 50 and 0 of 250 wrong). The gap was outside
   the tables — the big KPI tile figures ("83", "3", "17" at 20px) computed
   font-variant-numeric:normal, so proportional digits made the headline
   numbers on every module land at ragged widths. Scoped to cards/tables/grids
   rather than the whole body so button, topbar and sidebar metrics keep their
   current advance widths and nothing reflows. */
.theme-root .card,
.theme-root .lk-table,
.theme-root .lk-grid-wrap {
    font-variant-numeric: tabular-nums;
}

/* ── D7. MIDNIGHT (and any future DARK theme) ────────────────────────────────
   Inverted on purpose: raised surfaces go LIGHTER, and the card is separated by
   a light rim + inner top highlight instead of a drop shadow. A dark shadow
   here would just look like grime on the card. */
[data-theme="midnight"] {
    /* D1's generic L1 rule MUST be overridden here: it folds --app-text into
       the surface, and Midnight's text is near-white, so the generic formula
       would make the page LIGHTER than the card — depth inverted.
       Darkening the page alone is not the answer either. Midnight's surface is
       oklch(20%) (#001632, relative luminance 0.0075), already close to the
       black floor, so pushing the page down buys almost nothing perceptually:
       measured, a 46%-black page moved page-vs-card 1.099 → 1.092, i.e. very
       slightly WORSE. Depth here therefore comes from BOTH ends — a deeper page
       AND a raised card (see the @supports block below for the split and the
       measured result). Raising the card is also the honest physical model: a
       lit surface facing the viewer is brighter than the void behind it. */
    --app-bg: #00060f;
    --lk-border: rgba(203, 213, 225, .40);
    --lk-card-surface: #061c36;
    --lk-row-zebra: #0d2440;
    --lk-row-hover: #16304c;
    --lk-thead-1: #17304a;
    --lk-thead-2: #0e2540;
}
@supports (background: color-mix(in srgb, red, white)) {
    [data-theme="midnight"] {
        /* Depth on Midnight is split between BOTH surfaces rather than taken
           from one, because that is the only split that raises the card without
           costing the reader any contrast (see the note on --app-text below):
             · page  → 60% black over the surface (deep, but not pure black)
             · card  → +3% white over the surface
           Measured result: page-vs-card 1.099 → 1.183, which finally matches
           the light themes (~1.19) instead of trailing them. */
        --app-bg: color-mix(in srgb, #000 60%, var(--app-surface));
        --lk-card-surface: color-mix(in srgb, #fff 3%, var(--app-surface));
        /* Header band lighter STILL than the raised card, with a touch of the
           amber accent so it stays the theme's own header. */
        --lk-thead-1: color-mix(in srgb, #fff 11%, var(--lk-card-surface));
        --lk-thead-2: color-mix(in srgb, #fff 5%, var(--lk-card-surface));
        --lk-row-zebra: color-mix(in srgb, #fff 5%, var(--lk-card-surface));
        --lk-row-hover: color-mix(in srgb, #fff 11%, var(--lk-card-surface));
    }
}
/* Raising the card lightens what body text sits on, which would DROP text
   contrast — measured 16.38 → 13.55 at a 7% lift, and no text colour can
   recover 16.38 once the card is that light (pure white caps at 14.5). Contrast
   is not allowed to regress for a low-vision reader, so the lift is kept small
   (3%) and the text inside cards is brightened to compensate. Net 16.80:1,
   i.e. slightly BETTER than before while the card is now genuinely raised.
   Not pure #fff: on a near-black panel pure white haloes for astigmatic
   readers, and #fdfeff measures the same to two significant figures.
   Scoped to .card so the sidebar and topbar keep their tuned tones.
   BOTH declarations are required. Retargeting the variable alone reached only
   15.14:1, because `text-app` sits on <body>: `color: var(--app-text)` is
   resolved THERE, against the theme value, and every table cell without its own
   colour class simply inherits that computed colour — the card-scoped variable
   never entered the chain. Re-declaring `color` on the card puts the brighter
   value back into inheritance for unclassed cells, while the variable covers
   the cells that DO carry .text-app / color-mix(). */
[data-theme="midnight"] .card {
    --app-text: #fdfeff;
    color: var(--app-text);
}
/* Paint the raised surface. Scoped to the dark theme so light themes keep their
   existing .card background declaration untouched. */
[data-theme="midnight"] .card:not(.lk-dialog-card) {
    background: var(--lk-card-surface);
    box-shadow:
        inset 0 1px 0 rgba(255, 255, 255, .10),   /* top rim = the light source */
        0 0 0 1px rgba(255, 255, 255, .09),       /* light rim, not a dark halo */
        0 12px 30px -16px rgba(0, 0, 0, .70),
        0 28px 60px -34px rgba(0, 0, 0, .60);
}
@media (hover: hover) {
    [data-theme="midnight"] .card:not(.lk-dialog-card):hover {
        box-shadow:
            inset 0 1px 0 rgba(255, 255, 255, .16),
            0 0 0 1px rgba(255, 255, 255, .15),
            0 18px 40px -18px rgba(0, 0, 0, .75),
            0 38px 74px -36px rgba(0, 0, 0, .65);
    }
}
/* Header shadow on dark: a dark cast under a light band still reads correctly
   (the band is the raised thing), but it needs to be deeper to show at all. */
[data-theme="midnight"] .lk-table thead th {
    box-shadow:
        inset 0 1px 0 rgba(255, 255, 255, .07),
        0 6px 12px -6px rgba(0, 0, 0, .65);
}
[data-theme="midnight"] .lk-table tbody tr:hover > td {
    box-shadow:
        inset 0 1px 0 rgba(255, 255, 255, .09),
        inset 0 -1px 0 rgba(0, 0, 0, .40);
}
@supports (background: color-mix(in srgb, red, white)) {
    [data-theme="midnight"] .lk-table.lk-grid-freeze thead th:first-child:not([colspan]) {
        box-shadow:
            1px 0 0 rgba(255, 255, 255, .14),
            6px 0 8px -6px rgba(0, 0, 0, .60),
            inset 0 1px 0 rgba(255, 255, 255, .07),
            0 6px 12px -6px rgba(0, 0, 0, .65);
    }
    [data-theme="midnight"] .lk-table.lk-grid-freeze tbody tr:hover td:first-child:not([colspan]) {
        box-shadow:
            1px 0 0 rgba(255, 255, 255, .14),
            6px 0 8px -6px rgba(0, 0, 0, .55),
            inset 0 1px 0 rgba(255, 255, 255, .09),
            inset 0 -1px 0 rgba(0, 0, 0, .40);
    }
}

/* ── D8. Reduced motion ──────────────────────────────────────────────────────
   The depth itself is static, so it is fully available to motion-sensitive
   users; only the ANIMATION of it is dropped. The row-background transition at
   :1194 is already gated. This kills the card lift + shadow tween so the
   surface changes state instantly instead of gliding. */
@media (prefers-reduced-motion: reduce) {
    .card,
    .card:hover {
        transition: none;
        transform: none;
    }
    .lk-table tbody tr { transition: none; }
}

/* ============================================================================
 *  SECTION E — MODULE ICON TILE + PLAN PICKER
 *  ---------------------------------------------------------------------------
 *  E1. .lk-mi — THE module icon tile, emitted by lk_module_icon() in
 *      app/module_ui.php and by nothing else. Five surfaces used to draw this
 *      themselves (sidebar gradient / Store pale chip / Store hero white square
 *      / Billing flat indigo / admin flat cyan) and the same five modules read
 *      as five unrelated products.
 *
 *      WHY IT IS A COMPONENT AND NOT TAILWIND UTILITIES: the tile needs an
 *      exact px size at six steps, an absolutely-positioned image layer over a
 *      glyph layer, and a rim that flips on dark themes. The compiled Tailwind
 *      build in this repo is FROZEN, and the arbitrary values that would be
 *      needed emit no CSS at all — the old tile asked for an 11px glyph and got
 *      nothing, so its glyph size was silently inherited from whatever it sat
 *      inside. That is part of why one tile looked different in five places.
 *
 *      SELF-CONTAINED ON PURPOSE: a saturated gradient with a white glyph and a
 *      light rim. Its contrast does not depend on the surface behind it, so it
 *      holds in all five themes (default, Midnight, Blossom, Forest, Lagoon)
 *      with no per-theme override — which the pale tinted chips could never do.
 *      The two gradient stops arrive as --lk-mi-a / --lk-mi-b from
 *      LK_MODULE_TINT, the one place a module colour is decided.
 * ========================================================================== */

.lk-mi {
    position: relative;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: 0 0 auto;
    overflow: hidden;
    line-height: 1;
    color: #fff;
    background-image: linear-gradient(135deg, var(--lk-mi-a, #64748b), var(--lk-mi-b, #334155));
    box-shadow:
        inset 0 1px 0 rgba(255, 255, 255, .22),   /* top rim = the light source */
        0 0 0 1px rgba(255, 255, 255, .16),       /* light rim, never a dark halo */
        0 1px 2px rgba(15, 23, 42, .20),
        0 4px 10px -5px rgba(15, 23, 42, .26);
}
/* The glyph is ALWAYS in the DOM, underneath the image. When an uploaded icon
   404s (a deleted Supabase object, an unreachable bucket) its inline onerror
   removes the img and this is simply revealed — no blank square and no
   broken-image glyph, which is what a bare img would have left behind.

   THE SHADOW IS LEGIBILITY, NOT DECORATION. White on the lighter gradient stop
   measures 2.54:1 (emerald-500) and 2.74:1 (amber-500) — fine on a good monitor,
   thin for the elderly / low-vision users this product is built for. Deepening
   the tile instead was measured and REJECTED: it moves the tile toward the
   category colour field it sits on, so the icon stopped reading as an object
   (3.65:1 → 2.90:1). See lk_module_tile_stops() in app/module_ui.php. A shadow
   sharpens the glyph's edge against its own tile at every size and leaves the
   module's colour exactly where the owner approved it. */
.lk-mi > i {
    position: relative;
    z-index: 0;
    text-shadow: 0 1px 2px rgba(2, 6, 23, .45);
}
.lk-mi > img {
    position: absolute;
    inset: 0;
    z-index: 1;
    width: 100%;
    height: 100%;
    object-fit: cover;
    /* A transparent PNG must read as a logo, not as a hole in the gradient. */
    background: #fff;
}

/* Sizes. These are the sizes actually used: sm = sidebar row AND billing row
   (identical on purpose — "do these two match" should be answerable at a
   glance), md = dashboard widget + admin list, lg = Store card, xl/2xl = hero. */
.lk-mi-xs  { width: 22px; height: 22px; border-radius: 7px;  font-size: 10px; }
.lk-mi-sm  { width: 28px; height: 28px; border-radius: 9px;  font-size: 13px; }
.lk-mi-md  { width: 36px; height: 36px; border-radius: 11px; font-size: 16px; }
.lk-mi-lg  { width: 48px; height: 48px; border-radius: 14px; font-size: 21px; }
.lk-mi-xl  { width: 64px; height: 64px; border-radius: 18px; font-size: 27px; }
.lk-mi-2xl { width: 80px; height: 80px; border-radius: 22px; font-size: 34px; }

/* fa-fw forces 1.25em of width, which off-centres a glyph in a fixed box. */
.lk-mi > i.fa-fw { width: auto; }

/* Dark theme: depth from a brighter rim, not a heavier shadow (a dark cast on a
   dark page reads as dirt). Mirrors section D7's treatment of .card. */
[data-theme="midnight"] .lk-mi {
    box-shadow:
        inset 0 1px 0 rgba(255, 255, 255, .26),
        0 0 0 1px rgba(255, 255, 255, .22),
        0 6px 14px -8px rgba(0, 0, 0, .70);
}

/* The sidebar scales its glyph on hover (section B). The tile must scale as ONE
   object — scaling the glyph inside a fixed, overflow-hidden box just clipped
   it. Gated behind hover: like the rule it corrects. */
@media (hover: hover) {
    aside .lk-nav-link:hover .lk-mi > i,
    aside .lk-modtoggle:hover .lk-mi > i { transform: none; }
    aside .lk-mi { transition: transform .2s ease; }
    aside .lk-nav-link:hover .lk-mi,
    aside .lk-modtoggle:hover .lk-mi { transform: scale(1.08); }
}
@media (prefers-reduced-motion: reduce) {
    aside .lk-mi,
    aside .lk-nav-link:hover .lk-mi,
    aside .lk-modtoggle:hover .lk-mi { transform: none; transition: none; }
}

/* ── E2. The plan picker ─────────────────────────────────────────────────────
   ONE modal, rendered by lk_module_plan_modal(), opened from the Store's
   "Subscribe" and from Billing's "Manage plan". It reuses the shared dialog
   contract (.lk-dialog-backdrop / .lk-dialog-card) so the scrim, blur, z-index
   and enter/leave transition are the same as every other Luuqo dialog.

   DEPTH: the modal card already carries a full .card shadow. The plan cards
   INSIDE it therefore get a border and a tint only — stacking a second full
   shadow reads as embossed, which is worse than flat (section D). */

.lk-planmodal {
    width: 100%;
    padding: 1.25rem;
}
@media (min-width: 640px) { .lk-planmodal { padding: 1.75rem; } }
/* Width follows the number of plan cards so one plan is not a lonely card in a
   wide empty dialog and four are not squeezed into two rows. */
.lk-planmodal-w1 { max-width: 30rem; }
.lk-planmodal-w2 { max-width: 44rem; }
.lk-planmodal-w3 { max-width: 58rem; }
.lk-planmodal-w4 { max-width: 74rem; }

.lk-planmodal-head {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: .75rem;
    margin-bottom: 1.25rem;
}
.lk-planmodal-title {
    font-size: 1.25rem;
    font-weight: 700;
    line-height: 1.2;
    color: var(--app-text, #0f172a);
}
.lk-planmodal-sub {
    margin-top: .15rem;
    font-size: .8125rem;
    color: var(--app-text-muted, #64748b);
}
.lk-planmodal-promo { margin-bottom: 1.25rem; }
/* The held code's effect ON THE CARD. Compact by the house rule (10-11px), and
   deliberately quieter than the headline price: it explains that number, it does
   not compete with it. */
.lk-plancard-promo { margin-top: .2rem; font-size: 11px; min-height: 1.1rem; }
.lk-plancard-promo-on { color: #047857; font-weight: 700; }
.lk-plancard-promo-on .fas { font-size: 10px; }
.lk-plancard-promo-was { font-weight: 400; opacity: .5; text-decoration: line-through; font-size: 10px; }
.lk-plancard-promo-off { opacity: .5; font-size: 10px; }
.lk-plancard-promo-1st { font-weight: 400; opacity: .7; font-size: 10px; }
[data-theme="midnight"] .lk-plancard-promo-on { color: #34d399; }
/* A HELD PROMO CODE, shown in place of the empty field. Lives here rather than
   in a page <style> because the plan modal is emitted by app/module_ui.php and
   rendered on several pages - a per-page block would leave it unstyled on
   whichever page nobody remembered. Compact by the house rule: 10px chips. */
.lk-promo-held {
    display: flex; align-items: center; gap: .4rem; flex-wrap: wrap;
    padding: .4rem .55rem; border-radius: .45rem;
    border: 1px solid color-mix(in oklch, var(--app-accent) 40%, transparent);
    background: color-mix(in oklch, var(--app-accent) 9%, transparent);
    font-size: 11px;
}
.lk-promo-held > .fas { color: var(--app-accent); font-size: 11px; }
.lk-promo-held-code {
    font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
    font-weight: 700; letter-spacing: .06em; font-size: 11px;
}
.lk-promo-held-lbl { opacity: .75; }
.lk-promo-held-x {
    margin-left: auto; font-size: 10px; font-weight: 600; text-decoration: underline;
    background: none; border: 0; padding: 0; cursor: pointer; color: inherit; opacity: .8;
}
.lk-promo-held-x:hover { opacity: 1; }

/* THE MONTHLY / ANNUAL PILL.
   Rendered by lk_module_plan_modal() ONLY when at least one tier in the modal
   actually has a yearly price — which is the minority of modules. For everything
   else none of this markup exists: no greyed half, no empty tab, no disabled
   control inviting a click, which is the rule the whole annual-billing design is
   built around.

   IT IS ONE CONTROL, NOT TWO BUTTONS SIDE BY SIDE. The container owns the rim,
   the radius and the padding; the halves carry none of their own, so the selected
   fill sits INSIDE the rim with a 4px inset all round instead of butting against
   it. That inset is the whole reason it reads as a segmented control.

   THE SIZES ARE HERE AND NOT IN TAILWIND. The served build is JIT-purged, so a
   utility combination no other page uses emits no CSS at all and the pill would
   render as two naked buttons — the house rule, and the defect it exists for. */
.lk-ivtoggle-wrap {
    margin: 0 auto 1.25rem;
    text-align: center;
}
/* A GRID, NOT A FLEX ROW, so the two halves are the SAME WIDTH.
   Flexbox sizes a shrink-to-fit container from the SUM of its items, and
   min-width:auto then clamps each item back up to its own text, so `flex: 1 1 0`
   still measured 88.6px "Monthly" against 80.2px "Annual" and the selected fill
   both slid AND shrank ~8px as it moved — a segmented control that changes shape
   reads as a rendering fault. An inline-grid's fr tracks are intrinsically sized
   from the WIDEST track, so both halves come out at 88.6px with nothing clipped
   (measured at 1280, 1024 and 768). grid-auto-flow: column keeps it agnostic
   about how many halves the markup emits. */
.lk-ivtoggle {
    display: inline-grid;
    grid-auto-flow: column;
    grid-auto-columns: 1fr;
    align-items: stretch;
    gap: .25rem;
    max-width: 100%;
    padding: .25rem;
    border-radius: 999px;
    border: 1px solid var(--lk-border, var(--app-border, rgba(15, 23, 42, .14)));
    background: var(--lk-row-zebra, rgba(15, 23, 42, .05));
}
.lk-ivtoggle-opt {
    appearance: none;
    margin: 0;
    border: 0;
    /* 2.25rem on a MOUSE, and the coarse-pointer rule below raises it to the
       house 2.75rem floor on glass.
       The comment that used to sit here claimed "2.25rem + the container's
       padding clears the 40px touch target" and was wrong twice. The floor is
       2.75rem/44px (§1 of the elder-friendly pass, WCAG 2.5.8), not 40px; and
       the container's padding is NOT hit area — document.elementFromPoint 1px
       and 3px inside the pill's rim both answered .lk-ivtoggle, which carries no
       @click, so a tap landing in that 5px ring changed nothing at all. Measured
       at 375x812 with (pointer: coarse): halves 36px tall beside 44px Subscribe
       buttons in the same dialog — the one control on the screen that decides
       monthly-or-yearly was the one control the floor did not reach. It could
       not: §1's floor is written with :where() (zero specificity) and selects
       .btn / button[type="submit"] / .lk-nav-link, so it loses to this rule's
       own min-height AND never matched a bare <button type="button"> anyway. */
    min-height: 2.25rem;
    padding: .45rem 1.15rem;
    border-radius: 999px;
    font-size: .8125rem;
    font-weight: 700;
    line-height: 1.2;
    white-space: nowrap;
    color: var(--app-text-muted, #64748b);
    background: transparent;
    cursor: pointer;
    transition: background-color .15s ease, color .15s ease;
}
/* HOVER IS A MOUSE STATE. Chrome leaves :hover stuck on the last element a
   finger touched, so on a tablet the half you just tapped AWAY from would keep
   full-strength text and read as selected too. Same guard, same reason, as the
   eleven other hover rules in this file. */
@media (hover: hover) {
    .lk-ivtoggle-opt:hover { color: var(--app-text, #0f172a); }
}
/* THE SELECTED HALF IS THE PRIMARY, PRESSED — not the accent.
   It shipped as #fff on var(--app-accent), which measured 3.19:1 on the default
   theme and 2.33:1 on Midnight against AA's 4.5:1 floor for 13px/700 text: the
   lowest-contrast text in the dialog, on the control that decides whether the
   client pays RM 199.90 a month or RM 2,399.00 a year. It also made the pill
   amber above violet Subscribe buttons — two different "this is the chosen
   thing" colours 60px apart on one card.
   --app-primary-hover is the house's own primary-when-pressed, which is what a
   selected segment IS, and --app-on-primary is the text colour the themes
   publish for it (Midnight's is deliberately dark). Measured white-on-fill
   across all five shipped themes: 7.10 / 14.65 / 5.69 / 6.56 / 5.63 — every one
   over 4.5:1, and the same violet family as the .btn-primary below it. */
.lk-ivtoggle-opt.is-on,
.lk-ivtoggle-opt.is-on:hover {
    color: var(--app-on-primary, #fff);
    background: var(--app-primary-hover, #6d28d9);
}
@media (pointer: coarse) {
    /* THE HOUSE 44px FLOOR, reached by a real selector this time — §1's list is
       :where()-wrapped (zero specificity) and would have lost to the 2.25rem
       above it even if .lk-ivtoggle-opt were added to it. */
    .lk-ivtoggle-opt { min-height: 2.75rem; }
}
/* ONE SENTENCE, NOT A PER-PLAN BADGE. The per-plan figure is .lk-plancard-save,
   on the card, where the money decision is actually taken.
   AND IT IS DARKER THAN IT SHIPPED: #059669 measured 3.77:1 on the white modal
   body at 12px/600, under AA's 4.5:1, on the one sentence that tells a client
   annual billing is cheaper. #047857 is the same green one step down and
   measures 5.48:1. The Midnight override below stays as it is — on that theme
   the note sits on a dark surface, where the LIGHTER green is the one that
   passes (#34d399 measures 9.41:1 there). */
.lk-ivtoggle-note {
    margin-top: .5rem;
    font-size: .75rem;
    font-weight: 600;
    color: #047857;
}
[data-theme="midnight"] .lk-ivtoggle-note { color: #34d399; }
/* On a phone the pill takes the card's width rather than overflowing it — the
   halves are already equal by then (see the grid above), so this only drops the
   inline sizing and tightens the padding. */
@media (max-width: 400px) {
    .lk-ivtoggle { display: grid; }
    .lk-ivtoggle-opt { padding: .45rem .5rem; }
}
@media (prefers-reduced-motion: reduce) {
    .lk-ivtoggle-opt { transition: none; }
}

/* "RM 39.98 saved", under the yearly price on a card. lk_interval_saving() is
   display maths and never prices a charge — see app/pricing.php. */
.lk-plancard-save {
    margin-top: .2rem;
    font-size: .75rem;
    font-weight: 600;
    color: #059669;
}
[data-theme="midnight"] .lk-plancard-save { color: #34d399; }
/* A tier the owner never priced yearly, while the pill is on Annual. It states
   the fact in words: "RM —" reads as a broken page and "RM 0.00" reads as free.
   IT IS A TAG BESIDE THE MONTHLY FIGURE NOW, not a replacement for the whole
   price row — a card that keeps a live Subscribe button has to keep showing the
   amount that button charges — so it is sized as a chip and not as a second
   price. .lk-plancard-price is flex/baseline/wrap, so it sits on the line and
   drops below on a narrow card. */
.lk-plancard-only {
    font-size: .6875rem;
    font-weight: 700;
    padding: .05rem .4rem;
    border-radius: 999px;
    border: 1px solid var(--lk-border, var(--app-border, rgba(15, 23, 42, .14)));
    color: var(--app-text, #0f172a);
    /* ONE STEP UP from the card, not the card's own tone: .lk-plancard is
       already painted with --lk-row-zebra, so tinting the tag with the same
       token made its padding and radius invisible and left the words floating
       beside the price as if they were part of it. */
    background: var(--app-surface, #fff);
}
.lk-planmodal-foot {
    margin-top: 1.25rem;
    text-align: center;
    font-size: .6875rem;
    color: var(--app-text-muted, #64748b);
}
.lk-planmodal-foot i { margin-right: .25rem; }
.lk-planmodal-extra {
    margin-top: 1rem;
    padding-top: 1rem;
    border-top: 1px solid var(--lk-border, var(--app-border, rgba(15, 23, 42, .14)));
    text-align: center;
}

.lk-plangrid {
    display: grid;
    gap: 1rem;
    grid-template-columns: minmax(0, 1fr);
    align-items: stretch;
}
.lk-plangrid-1 { max-width: 24rem; margin-left: auto; margin-right: auto; }
@media (min-width: 640px) {
    .lk-plangrid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
    .lk-plangrid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
    .lk-plangrid-4 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (min-width: 1024px) {
    .lk-plangrid-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}

.lk-plancard {
    display: flex;
    flex-direction: column;
    padding: 1.1rem;
    border-radius: 1rem;
    border: 1px solid var(--lk-border, var(--app-border, rgba(15, 23, 42, .14)));
    /* ONE LEVEL BELOW the modal card it sits in (section D): the zebra tone is
       already derived per-theme as exactly that step, so a nested card reads as
       recessed instead of as a second raised slab. No second shadow — two full
       shadows stacked read as embossed, which is worse than flat. */
    background: var(--lk-row-zebra, var(--lk-card-surface, var(--app-surface, #fff)));
}
/* THE TENANT'S OWN PLAN. Accent ring + more accent in the fill, so the card they
   already pay for is the one the eye lands on first. */
.lk-plancard.is-current {
    border-color: var(--app-accent, #6366f1);
    border-width: 2px;
    padding: calc(1.1rem - 1px);
}
@supports (background: color-mix(in srgb, red, white)) {
    .lk-plancard.is-current {
        background: color-mix(in srgb, var(--app-accent, #6366f1) 15%, var(--lk-card-surface, #fff));
    }
}
.lk-plancard-top {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: .5rem;
}
.lk-plancard-name {
    font-size: 1.0625rem;
    font-weight: 700;
    color: var(--app-text, #0f172a);
}
.lk-plancard-badge {
    display: inline-flex;
    align-items: center;
    gap: .3rem;
    padding: .15rem .5rem;
    border-radius: 999px;
    font-size: .625rem;
    font-weight: 800;
    text-transform: uppercase;
    letter-spacing: .06em;
    color: #fff;
    background: var(--app-accent, #6366f1);
    white-space: nowrap;
}
.lk-plancard-badge.is-soon { background: #a16207; }
.lk-plancard-price {
    display: flex;
    align-items: baseline;
    gap: .35rem;
    margin-top: .5rem;
    flex-wrap: wrap;
}
.lk-plancard-amount {
    font-size: 1.5rem;
    font-weight: 700;
    color: var(--app-text, #0f172a);
}
.lk-plancard-per {
    font-size: .75rem;
    color: var(--app-text-muted, #64748b);
}
.lk-plancard-desc {
    margin-top: .75rem;
    font-size: .8125rem;
    line-height: 1.5;
    color: var(--app-text-muted, #64748b);
}
.lk-plancard-limits {
    margin-top: .75rem;
    display: flex;
    flex-direction: column;
    gap: .3rem;
}
.lk-plancard-limits li {
    display: flex;
    align-items: flex-start;
    gap: .45rem;
    font-size: .8125rem;
    line-height: 1.4;
    color: var(--app-text, #0f172a);
}
.lk-plancard-limits li i {
    margin-top: .2rem;
    font-size: .6875rem;
    color: #059669;
}
.lk-plancard-limits li.is-off { color: var(--app-text-muted, #64748b); }
.lk-plancard-limits li.is-off i { color: var(--app-text-muted, #94a3b8); }
.lk-plancard-note,
.lk-plancard-fitmsg {
    margin-top: .65rem;
    font-size: .6875rem;
    line-height: 1.45;
    color: var(--app-text-muted, #64748b);
}
.lk-plancard-fitmsg { color: #92400e; }
/* SAY WHAT IS OVER THE LINE, on the card, before anything is clicked. */
.lk-plancard-warn {
    display: flex;
    gap: .5rem;
    margin-top: .75rem;
    padding: .6rem .7rem;
    border-radius: .7rem;
    font-size: .75rem;
    line-height: 1.45;
    color: #7c4a03;
    background: rgba(245, 158, 11, .14);
    border: 1px solid rgba(245, 158, 11, .45);
}
.lk-plancard-warn i { margin-top: .15rem; flex: 0 0 auto; }
[data-theme="midnight"] .lk-plancard-warn {
    color: #fde68a;
    background: rgba(245, 158, 11, .16);
    border-color: rgba(245, 158, 11, .52);
}
[data-theme="midnight"] .lk-plancard-fitmsg { color: #fcd34d; }

/* Push the control to the bottom so cards of different heights line up. */
.lk-plancard-cta { margin-top: auto; padding-top: 1.1rem; }
/* THE FOUR-COLUMN GRID IS THE CROWDED ONE, and Task 6 gave it a wider label to
   carry: the annual CTA holds the bigger figure plus a character more ("/yr").
   .btn is white-space: nowrap with overflow: visible, so a label wider than the
   button does not clip or wrap — it inks straight across the rounded rim onto
   the card, on the control that commits the money.
   Measured at 1024px, four tiers (grid 221.5px, button 184.3px border box,
   16px padding each side, content box 150px): at 13px the widest real label
   "Subscribe — RM 2,399.00/yr" inks 171.8px, which clears the rim by 6.3px, but
   "Subscribe — USD 24,999.00/yr" inks ~186px and crosses it. One step down the
   scale takes the real label to 159.2px (12.6px inside the rim) and the
   franchise one to 9.1px inside it, measured — with every label still on ONE
   line, which is what a price should be.
   Bounded above because 4 columns only exist from 1024px (see .lk-plangrid-4)
   and by ~1150px the cards are wide enough to carry the full-size label: at
   1280px the same button is 230px and the franchise label clears by 22px. */
@media (min-width: 1024px) and (max-width: 1150px) {
    .lk-plangrid-4 .lk-plancard-cta .btn { font-size: .75rem; }
}

/* THE CURRENT PLAN IS NOT A BUTTON. Not a disabled button either — a dashed,
   unfilled block with no hover, no focus ring and no pointer, because a control
   that looks pressable and does nothing is the exact complaint this answers. */
.lk-plan-current {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: .45rem;
    width: 100%;
    min-height: 2.5rem;
    padding: .55rem .85rem;
    border-radius: .75rem;
    font-size: .8125rem;
    font-weight: 700;
    color: var(--app-text, #0f172a);
    background: transparent;
    border: 1px dashed var(--app-accent, #6366f1);
    cursor: default;
    user-select: none;
}
.lk-plan-current i { color: var(--app-accent, #6366f1); }
/* Not-buyable states keep the disabled button (they ARE controls that would act
   if the owner switched them on) but read clearly as unavailable. */
.lk-plan-dead { opacity: .55; cursor: not-allowed; }
/* A downgrade is deliberately the quieter control of the two. */
.lk-plan-down { font-weight: 600; }

/* ── E3. Billing's one Manage-plan button ────────────────────────────────────
   Neutral, not primary: it opens a dialog, it does not commit money. */
.lk-manage-btn { white-space: nowrap; }
.lk-manage-lock {
    display: inline-flex;
    align-items: center;
    gap: .35rem;
    padding: .3rem .6rem;
    border-radius: .5rem;
    font-size: .6875rem;
    font-weight: 700;
    color: var(--app-text-muted, #64748b);
    background: var(--lk-row-zebra, rgba(15, 23, 42, .05));
    white-space: nowrap;
}

/* ── E4. Save affordance, touch targets and the two filter inputs ─────────────
   Fixes from the post-batch audit. Everything here is a shared rule rather than a
   per-page tweak, because each defect was measured on more than one page. */

/* NOTHING MOVES WHILE A SAVE IS IN FLIGHT.
   Company Settings greys every button of the submitting form IN PLACE (luuqo.js
   §1). My Profile could not use that — its header buttons sit OUTSIDE the form
   they submit — so it hid Cancel instead, which pulled the primary button 27px
   sideways out from under the pointer that had just pressed it. Profile now keeps
   Cancel and applies this class, which is §1's own treatment expressed once. */
.lk-btn-locked { opacity: .55 !important; pointer-events: none; }

/* And the label swap must not resize the button either: "Save changes" is wider
   than "Saving…", so without a reserved width the control shrinks mid-save. Both
   pages' Save buttons carry this. */
.lk-btn-save-fixed { min-width: 8.25rem; justify-content: center; }

/* THE TILE'S OWN SIZE TOKEN MUST WIN.
   The frozen bundle carries `.lk-nav-link i.fa-fw { font-size:.95rem }` at
   specificity 0,2,1, which outranks `.lk-mi-sm` (0,1,0) — so the same lk-mi-sm
   tile rendered a 15.2px glyph in the sidebar and 13px in Billing, i.e. the one
   place a user compares module icons was the one place they did not match. Same
   0,2,1 as the bundle rule and later in source order, next to the width override
   that already fights the other half of it. */
.lk-mi > i.fa-fw { font-size: inherit; }

/* TOUCH TARGETS ON A PHONE. An icon-only kebab measured 33x26px — under the 24px
   WCAG 2.2 floor on one axis and nowhere near the 44px platform norm — and it is
   the ONLY route to Change role / Reset password / Suspend. Grown below 640px
   only, so the desktop grid keeps its compact row height. */
@media (max-width: 639px) {
    .btn.btn-sm.lk-kebab {
        min-width: 44px;
        min-height: 44px;
    }
    /* The filter popup's option rows: the checkbox glyph stays 25px (changing it
       would break the popup's alignment), the LABEL grows, which is what the
       finger actually hits. */
    .im-filt-opt {
        padding-top: .55rem;
        padding-bottom: .55rem;
    }
}

/* The two filter inputs on Billing are not .input components, so they got neither
   the framework's accent focus ring nor the low-specificity :focus-visible
   fallback — they carried focus:outline-none (which the frozen build DOES emit)
   plus focus:border-indigo-400 (which it does NOT), so tabbing into either
   produced no visible change at all: a WCAG 2.4.7 failure on a control set aimed
   at keyboard users. One class, used by both. */
.lk-filter-input {
    width: 100%;
}
@media (min-width: 640px) {
    .lk-filter-input { width: 11rem; }
}
.lk-filter-input:focus,
.lk-filter-input:focus-visible {
    outline: 2px solid var(--app-accent, #6366f1);
    outline-offset: 1px;
    border-color: var(--app-accent, #6366f1);
}

/* A CARD-HEADER TITLE BLOCK THAT WRAPS INSTEAD OF COLLAPSING.
   `flex-1 min-w-0` on a title beside a fixed-width control means the TITLE gives
   up its width — at 375px Billing's "Your modules & subscriptions" fell to a 41px
   column, four wrapped lines with an 11-line description of one word each, while
   the row's own flex-wrap never fired because nothing forced it. Taking the full
   basis below 640px forces the wrap; from 640px up it behaves exactly like flex-1.
   NOT `basis-full sm:basis-auto`: the frozen build emits NO CSS for either
   (verified against public/build/assets/app-DQzN9urV.css), which is the trap this
   class exists to avoid. */
.lk-card-hdr-title {
    flex: 1 1 100%;
    min-width: 0;
}
@media (min-width: 640px) {
    .lk-card-hdr-title { flex: 1 1 0%; }
}

/* THE PRIMARY ACTION MUST NOT LIVE OFF-SCREEN.
   Billing's grid is 477px wide inside its horizontal scroller, so at 375px every
   Manage-plan button sat 8px past the viewport edge — invisible on arrival, with
   the frozen first column making the table look complete rather than truncated.
   The house data-grid is right for DATA; it is the wrong container for the page's
   one action. Below 640px the action renders inside the frozen name cell instead
   (full width, always in view) and the Manage column's copy is hidden. */
.lk-manage-stack {
    display: flex;
    margin-top: .5rem;
}
/* NOT full-width. A 100%-wide button under the module name turned every row
   into a slab and pushed the grid tall enough that two rows filled a phone
   screen. It reads as what it is — a small action belonging to the name above
   it — when it hugs its own label and sits left, under the text it acts on. */
.lk-manage-stack > * { width: auto; max-width: 100%; }
.lk-manage-stack .btn { width: auto; justify-content: flex-start; padding-left: .6rem; padding-right: .7rem; }
/* The other half of .lk-manage-stack. Below 640px the row's action moves INTO
   the frozen name cell, and the Manage column must then go away ENTIRELY —
   hiding only its contents leaves a fourth column still holding width, and on a
   ~477px grid that is enough to push "Current plan" off the edge until all you
   read is the tail of the word. Applied to the th AND every matching td, so the
   table keeps a consistent column count. */
@media (max-width: 639.98px) {
    .lk-col-manage { display: none; }
}
@media (min-width: 640px) {
    .lk-manage-stack { display: none; }
}

/* ── F. ONE WORD FOR "INACTIVE" — the item status badge and its row ───────────
   A client asked "how do I make an item inactive?" and gave up looking, because
   the row action said "Hide item", the filter said "Inactive" and the column is
   is_active: three names for one state. The whole app now says INACTIVE, and
   this is what that word looks like.

   Two rules drive the styling:

   1) TEXT FIRST, COLOUR SECOND. The badge carries the literal word "Inactive",
      so the state survives a monochrome screen, a colour-blind reader and a
      printout. The amber is confirmation, never the message.

   2) NEVER LOWER THE CONTRAST OF A ROW SOMEBODY IS TRYING TO READ. The list
      used to mark an inactive item with `opacity-55` on the whole <tr>, which
      is the one thing you must not do for a reader with poor eyesight — it
      dimmed the name, the code, the category and the price together, so the
      row was hardest to read exactly when the user had gone looking for it.
      .im-row-off replaces the dimming with a solid amber left edge and a faint
      tint: an added signal instead of a subtracted one. Every character in the
      row keeps its full contrast.

   Both classes live HERE and not in the page's own <style> because the item
   detail view (?item=…) shows the same badge, and one copy is what stops the
   list and the detail page drifting into two different-looking "Inactive"s.
   (The frozen Tailwind build has no rule for either name — checked.) */
.im-badge-off {
    display: inline-block;
    font-size: 10px;
    font-weight: 700;
    line-height: 1.5;
    letter-spacing: .02em;
    padding: .05rem .4rem;
    border-radius: 9999px;
    /* Explicit light-theme values, not a theme variable: this badge means the
       same thing on all four themes, and amber-on-amber is legible on each. */
    color: #92400e;                     /* amber-800 — 5.9:1 on the fill below */
    background: #fde68a;                /* amber-200 */
    border: 1px solid #f59e0b;          /* amber-500 — a visible edge, not a wash */
    vertical-align: .06em;
    white-space: nowrap;
}
.im-row-off > td {
    background-image: linear-gradient(rgba(245, 158, 11, .055), rgba(245, 158, 11, .055));
}
/* The left edge is painted in the BACKGROUND layer of the first cell, not with
   box-shadow. All three grids that use .im-row-off are .lk-grid-freeze, and the
   pinned first cell already owns its box-shadow — the pin "lip" that tells the
   user the column is frozen, re-declared again on hover (D5) and again for dark
   (D7). box-shadow does not merge across rules: whichever rule wins REPLACES
   the list, so a marker shadow here either lost to the lip (it did: 0,2,1 vs
   0,4,1) or would have deleted the lip. A hard-stop gradient cannot collide
   with any of that, sits BEHIND the text so no character loses contrast, and
   survives zebra + hover because those now only set background-color.
   Declared after the all-cells tint above so it wins for the first cell. */
.im-row-off > td:first-child {
    background-image: linear-gradient(90deg,
        #f59e0b 0, #f59e0b .22rem,
        rgba(245, 158, 11, .055) .22rem, rgba(245, 158, 11, .055) 100%);
}

/* MIDNIGHT / any dark theme: same word, same meaning, contrast re-struck for a
   dark surface (the light chip would glare and the tint would vanish). */
[data-theme="midnight"] .im-badge-off {
    color: #fde68a;                     /* amber-200 text … */
    background: #78350f;                /* … on amber-900 */
    border-color: #b45309;              /* amber-700 */
}
[data-theme="midnight"] .im-row-off > td {
    background-image: linear-gradient(rgba(245, 158, 11, .085), rgba(245, 158, 11, .085));
}
/* Must be re-stated for dark: the rule above matches td:first-child too and at
   equal specificity (0,2,1) source order would let it erase the left edge. */
[data-theme="midnight"] .im-row-off > td:first-child {
    background-image: linear-gradient(90deg,
        #f59e0b 0, #f59e0b .22rem,
        rgba(245, 158, 11, .085) .22rem, rgba(245, 158, 11, .085) 100%);
}

/* ── F2. The item form's Status chooser (Active / Inactive) ───────────────────
   Two radios rendered as pickable cards. Why not a toggle switch: a switch shows
   you a position, not a word, so you have to already know which way "on" points
   — and half of these users are non-IT or reading a small phone screen. Two
   labelled options each carry their own word plus a line of plain English about
   what it means, and the whole card is the hit target (44px+ tall).
   Arbitrary bracket values and "!"-variants emit nothing in the frozen Tailwind
   build, so the layout is written out here in full. */
.im-stat-opt {
    display: flex;
    align-items: flex-start;
    gap: .5rem;
    flex: 1 1 12rem;
    min-width: 0;
    padding: .5rem .6rem;
    min-height: 2.9rem;
    border: 1px solid var(--app-border);
    border-radius: .5rem;
    background: var(--app-surface);
    cursor: pointer;
    transition: border-color .15s ease, box-shadow .15s ease, background-color .15s ease;
}
.im-stat-opt:hover { border-color: var(--app-accent); }
.im-stat-opt > input[type="radio"] {
    margin-top: .15rem;
    flex: none;
    width: 1rem;
    height: 1rem;
    accent-color: var(--app-accent);
    cursor: pointer;
}
.im-stat-opt > span { display: block; min-width: 0; }
.im-stat-ttl {
    display: block;
    font-size: 12px;
    font-weight: 700;
    color: var(--app-text);
    line-height: 1.3;
}
.im-stat-sub {
    display: block;
    font-size: 10.5px;
    line-height: 1.35;
    color: var(--app-text-muted);
    margin-top: .1rem;
}
/* THE CHOSEN one is marked by a real border + ring, not by colour alone: the
   radio dot is the primary signal and this only reinforces it. */
.im-stat-sel {
    border-color: var(--app-accent);
    background: var(--app-surface-2);
    box-shadow: 0 0 0 2px color-mix(in srgb, var(--app-accent) 28%, transparent);
}
@media (prefers-reduced-motion: reduce) {
    .im-stat-opt { transition: none; }
}

/* ═══════════════════════════════════════════════════════════════════════════
   SECTION Z — MODULE OVERLAYS MUST CLEAR THE APP CHROME
   ═══════════════════════════════════════════════════════════════════════════
   REPORTED SYMPTOM: the POS "Close shift" dialog dimmed the content area but
   left the sidebar and the top bar at full brightness, so the modal looked
   half-covered.

   IT WAS NOT A SIZING BUG. Measured in the browser, the backdrop was already
   position:fixed at 0,0 1272x720 — the full viewport. The failure was PAINT
   ORDER, proven by a hit test: document.elementFromPoint(100, 300), a point
   well inside the dialog's own box, returned A.lk-nav-link — the sidebar.

   WHAT THE RULES BELOW DO NOT FIX, MEASURED RATHER THAN ASSUMED. Three
   candidate causes were tested on the real page; only the third holds:
     raise z-index 50 -> 10000 ........ FAILED, chrome still won every probe
     remove .pos-wrap overflow:hidden .. FAILED, so it is not a clip either
     move the overlay to <body> ....... FIXED ALL FOUR PROBES

   THE CAUSE IS THE OVERLAY'S POSITION IN THE TREE, NOT ITS Z-INDEX. The
   sidebar's path is  ASIDE < BODY. The dialog's is
     DIV.pos-modal < DIV.pos-wrap < .lk-async-slot < MAIN < .lk-sidebar-collapsing < BODY
   so the dialog is judged as part of that <main> branch and cannot be lifted
   over a BODY-level sibling from inside it, whatever number it carries. A
   body-level fixed probe at z-index 2147483647 won the same point instantly,
   which is what proved the point was reachable and the dialog simply was not
   competing at that level.

   SO THE REAL REPAIR IS x-teleport="body" ON EVERY MODULE OVERLAY, which is
   exactly why requisitions.php and store-show.php never had this bug.

   THESE RULES ARE STILL WORTH KEEPING, but only as the second half: once an
   overlay IS at body level it competes directly with the chrome, and at the old
   z-index 50 it would TIE with the sidebar (also 50) and lose on DOM order.
   They set the shared layer so no module has to guess a number again. On their
   own they fix nothing — do not read the presence of this block as the fix.

   Ordering, highest last, so a toast is still readable over a dialog and the
   PIN lock covers everything:
     sidebar/chrome  50 .. 30   (unchanged, owned by the shell)
     module overlays  10000     (matches .lk-dialog-backdrop)
     toasts           10050
     full-screen lock 10100
   Kept in luuqo-fx.css because it loads LAST, so it settles the layer for every
   module without each one having to remember.

   THE SELECTORS ARE DOUBLED ON PURPOSE (.pos-modal.pos-modal). "luuqo-fx loads
   last" is only true against the Tailwind build: a module's own rules live in a
   <style> block inside its async partial, which is injected into <body> and so
   appears LATER in the cascade than this <link> in <head>. At equal specificity
   the later rule wins, and a single .pos-modal here lost to the module's own
   .pos-modal — measured: z-index still computed 50 after the first attempt.
   Repeating the class raises specificity enough to win without !important, the
   same technique already used by .lk-dialog-card.lk-dialog-card above. */
/* ── THE framework overlay. app/overlay.php emits this markup and always wraps
      it in <template x-teleport="body">, which is the half that actually fixes
      the bug. New dialogs use lk_overlay_start()/lk_overlay_end() and inherit
      all of it; the per-module classes below are the legacy names, kept on the
      same layer so nothing regresses while they are migrated. ── */
/* ── PURGE-PROOF SHIMS for variants the build does not emit ──────────────────
   tests/ci-guards.php fails a `sm:` / `lg:` / `hover:` class that is in neither
   the purged build nor this file, because such a class emits NO CSS at all: the
   styling reads as present in the markup and does nothing on screen. Its own
   advice is "use a class the build already carries" — these ARE those classes.

   Named for what they do, so the next reader is not hunting a Tailwind variant
   that was never generated. EXTEND THIS LIST rather than reaching for a dead
   variant again; every entry below replaced one that had been sitting in
   tests/tailwind-dead-baseline.txt doing nothing. */
.lk-hop { transition: opacity .15s ease; }
.lk-hop:hover { opacity: .7; }
.lk-hop-white25:hover { background: rgba(255, 255, 255, .25); }
.lk-focus-sky:focus { border-color: #38bdf8; }          /* sky-400 */
@media (min-width: 640px) {
    .lk-sm-gap-4       { gap: 1rem; }
    .lk-sm-text-right  { text-align: right; }
    .lk-sm-justify-end { justify-content: flex-end; }
    .lk-sm-pt-6        { padding-top: 1.5rem; }
}
@media (min-width: 1024px) {
    .lk-lg-p-6 { padding: 1.5rem; }
}

.lk-overlay {
    position: fixed;
    inset: 0;
    z-index: 10000;
    isolation: auto;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 16px;
    background: rgba(15, 23, 42, .55);
    -webkit-backdrop-filter: blur(2px);
    backdrop-filter: blur(2px);
    overflow-y: auto;              /* a tall dialog on a short screen must scroll,
                                      not have its confirm button cut off */
}
.lk-overlay-card {
    width: 100%;
    display: flex;
    flex-direction: column;
    gap: 14px;
    padding: 20px;
    margin: auto;                  /* centres, and keeps top/bottom reachable when
                                      the dialog is taller than the viewport */
}
/* A dialog must not inherit .card:hover's lift — on a touch POS it reads as jitter. */
.lk-overlay-card:hover { transform: none; }
.lk-overlay-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 10px;
}
.lk-overlay-title {
    font-size: 16px;
    font-weight: 700;
    color: var(--app-text);
    line-height: 1.3;
}
.lk-overlay-x {
    width: 34px;
    height: 34px;
    border-radius: 9px;
    display: flex;
    align-items: center;
    justify-content: center;
    border: none;
    background: none;
    cursor: pointer;
    flex-shrink: 0;
}
.lk-overlay-x:hover { background: var(--app-surface-2); }
@media (max-width: 639px) {
    /* 44px minimum touch target — the close control is often the only way out. */
    .lk-overlay-x { width: 44px; height: 44px; }
    .lk-overlay-card { padding: 16px; }
}
/* ── The shared CONFIRM card, used INSIDE the framework overlay ──────────────
   .lk-modal (above, line ~77) ships `opacity: 0` plus a translate, and is only
   ever revealed by `.lk-modal-overlay.is-open .lk-modal` — a parent that no
   longer exists once a confirm is rendered by lk_overlay_start(). Migrating POS
   Settings' confirm onto the helper therefore produced an INVISIBLE dialog:
   measured on /app/pos-system/settings, the backdrop dimmed correctly and the
   card was in the DOM at the right size with computed opacity 0. The overlay's
   own x-transition.opacity owns the fade now, so the card is simply shown.
   Doubled class + descendant selector so it outranks both `.lk-modal` and
   `.lk-modal-overlay.is-open .lk-modal`, and only ever applies inside
   .lk-overlay — luuqo.js's own openConfirm() keeps its animation untouched. */
.lk-overlay .lk-modal.lk-modal {
    transform: none;
    opacity: 1;
}

/* ── THE FOUR LEGACY PINS ARE GONE, AND HERE IS WHY THAT IS SAFE ─────────────
   This block used to end with four doubled-class z-index pins:
       .pos-modal.pos-modal, .req-modal.req-modal   z-index 10000
       .pos-toast.pos-toast                         z-index 10050
       .pos-gate.pos-gate                           z-index 10100
   They were scaffolding for the migration, not the fix, and every one of them
   was checked before deletion rather than assumed dead:

   .req-modal   NO LONGER EXISTS IN THE REPO. The last two markup references
                (modules/requisition/form.php and the 301-redirected twin
                modules/item-master/requisitions.php) are now lk_overlay_start()
                calls, and their private .req-sheet/.req-x rules — which were
                byte-for-byte .lk-overlay-card and .lk-overlay-x — went with them.
                Grepped: not one occurrence outside comments and a stale worktree.

   .pos-modal / .pos-gate / .pos-toast
                still exist in modules/pos-system/page.php, and they DECLARE THEIR
                OWN z-index in that file's <style> — 10000, 10100 and 10050, the
                same three numbers. The pins were duplicates. They existed because
                the module's rule once said z-index:50 and, since a module's
                <style> is injected into <body> and therefore wins at equal
                specificity, a single .pos-modal here lost to it; the doubled class
                was how this file won that fight. With the module agreeing on the
                number there is nothing left to win.

   MEASURED AFTER DELETING THEM, at 1280x800 with the tenant sidebar at z-index 50
   and the top bar at 30, forcing each dialog visible and hit-testing
   document.elementFromPoint at four chrome points (inside the sidebar, the sidebar
   user card, the top bar) plus one content point:
       /app/pos-system ......... 9 dialogs + the PIN gate, 5/5 probes in-dialog
       /app/item-master ........ 11 dialogs, 5/5 in-dialog
       /app/requisition/form ... 5/5    /app/stock-card ... 5/5
       /app/settings ........... 5/5    /app/pos-system/{sales,settings} ... 5/5
   and lkOverlayTripwire() reported ZERO offenders on every one of those pages.
   The layer that does the work is .lk-overlay (10000) above, plus the teleport to
   <body> that app/overlay.php always emits — which is the half that actually fixes
   the bug, as the measurements at the top of this section say. ── */

/* ── Purge-proof shims, batch 2 (2026-09-01) ──────────────────────────────
   Same contract as the .lk-sm-* block above: the frozen Tailwind build emits
   no CSS for these variants, so a `sm:ml-auto` in a page silently does
   nothing. Each of these replaces one that was found dead in an admin page.
   Extend this list rather than reaching for a variant the build never had. */
.lk-hop-emerald { transition: color .15s ease; }
.lk-hop-emerald:hover { color: #059669; }        /* emerald-600 */
.lk-maxh-32 { max-height: 8rem; }                /* was max-h-32: uncapped */
@media (min-width: 640px) {
    .lk-sm-ml-auto     { margin-left: auto; }
    .lk-sm-inline-flex { display: inline-flex; }
}
@media (min-width: 1024px) {
    .lk-lg-ml-auto { margin-left: auto; }
}
.lk-hop-violet { transition: background-color .15s ease; }
.lk-hop-violet:hover { background-color: #6d28d9; }   /* violet-700 */
.lk-hop-grey   { transition: background-color .15s ease; }
.lk-hop-grey:hover   { background-color: #e5e7eb; }   /* gray-200 */

/* ══════════════════════════════════════════════════════════════════════════
   B5. UTILITIES THE FROZEN BUILD PURGED, and what each absence was costing.

   Every one below was verified absent from BOTH public/build/assets/app-*.css
   and this file, by tokenising each stylesheet on { and } and matching the
   selector exactly — not by substring, which also matches the .dark: variant
   and has produced false clearances here before.

   · font-extrabold — 11 files. The build's preflight sets h1-h6 to
     font-weight:inherit, so every cover title using it rendered at 400,
     including a printed receipt's grand total and the public homestay
     storefront's name.
   · border-l — 23 sites in 12 files; both store-show plan tables had no
     column rules at all.
   · grid-cols-3 — only 1, 2 and 4 are in the build.
   · bg-black/80 and bg-black/55 — only /5 /10 /15 /40 /50 /60 exist. The
     homestay photo lightbox is `fixed inset-0 z-50 bg-black/80`: a FULL-SCREEN
     overlay with NO BACKDROP, so a guest tapping a photo got it floating over
     the live booking page. That is the one genuinely broken thing in here.
   · text-sidebar/50,/70,/75 and border-sidebar/50 — 7 sites in
     includes/header_app.php, so the client sidebar — the most-viewed surface
     in the product — painted every category heading, the SSM line, the role
     line and the module cog at one flat weight.
   · text-app/30,/40 — the "not included" crosses on a plan table read as
     loudly as the ticks.

   The colour-mix forms mirror how the build itself derives its own /NN
   variants; the flat fallbacks above keep something sensible where
   color-mix is unsupported.
   ══════════════════════════════════════════════════════════════════════════ */
.font-extrabold { font-weight: 800; }
.border-l { border-left-width: 1px; border-left-style: var(--tw-border-style, solid); }
.grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.bg-black\/80 { background-color: rgba(0, 0, 0, .8); }
.bg-black\/55 { background-color: rgba(0, 0, 0, .55); }
.text-sidebar\/50,
.text-sidebar\/70,
.text-sidebar\/75 { color: var(--app-sidebar-text); }
.border-sidebar\/50 { border-color: var(--app-sidebar-text); }
.text-app\/30,
.text-app\/40 { color: var(--app-text); }
@supports (color: color-mix(in lab, red, red)) {
    .text-sidebar\/50 { color: color-mix(in oklch, var(--app-sidebar-text) 50%, transparent); }
    .text-sidebar\/70 { color: color-mix(in oklch, var(--app-sidebar-text) 70%, transparent); }
    .text-sidebar\/75 { color: color-mix(in oklch, var(--app-sidebar-text) 75%, transparent); }
    .border-sidebar\/50 { border-color: color-mix(in oklch, var(--app-sidebar-text) 30%, transparent); }
    .text-app\/30 { color: color-mix(in oklch, var(--app-text) 30%, transparent); }
    .text-app\/40 { color: color-mix(in oklch, var(--app-text) 40%, transparent); }
}

/* ══════════════════════════════════════════════════════════════════════════
   B6. THE SAME PROBLEM, IN THE SHAPE THE GUARD WAS NOT LOOKING AT.

   tests/ci-guards.php has judged Tailwind classes against the frozen build
   since 2026-09-01, and it passed this whole list. By design: it only judged
   tokens carrying a VARIANT PREFIX (hover:, sm:) or a BRACKET, on the theory
   that a plain `p-4` is too likely to be some other kind of string to risk a
   false alarm on. Twenty-four plain utilities were dead behind that decision.
   Four of them were visible defects on the public homestay storefront:

   · border-rose-400 — three error banners (`border-l-4 border-rose-400`) drew
     their 4px accent bar in the inherited colour while the success banner
     beside them drew green, so the one message a guest must not miss was the
     one that looked unstyled.
   · border-rose-200 — "we could not check the price just now" got a 1px
     border in the same inherited colour.
   · border-sky-400 — the policy editor's info callout, same story.
   · my-4 — the photo lightbox panel, so its sticky title bar sat 16px from
     the top of the viewport instead of 32px.

   The guard is widened in the same commit: two families — a colour utility on
   a named Tailwind hue and scale, and a spacing utility on the numeric scale —
   are now judged too. Those two shapes are never anything but Tailwind, which
   is what makes them safe to block on.

   Written the way the v4 build writes them, against the build's own theme
   variables, so each one is byte-for-byte the rule that was purged rather
   than an approximation of it.
   ══════════════════════════════════════════════════════════════════════════ */

/* Colour ─ the palette variables are all emitted by the build; only these
   particular utility rules were purged. */
.border-rose-200 { border-color: var(--color-rose-200); }
.border-rose-400 { border-color: var(--color-rose-400); }
.border-sky-400 { border-color: var(--color-sky-400); }
.border-amber-300 { border-color: var(--color-amber-300); }
.bg-slate-700 { background-color: var(--color-slate-700); }

/* Spacing ─ --spacing is .25rem, and calc() keeps these tracking the theme
   the way the build's own utilities do. */
.p-10 { padding: calc(var(--spacing) * 10); }
.p-0\.5 { padding: calc(var(--spacing) * .5); }
.pl-2\.5 { padding-left: calc(var(--spacing) * 2.5); }
.pb-3 { padding-bottom: calc(var(--spacing) * 3); }
.pr-0 { padding-right: calc(var(--spacing) * 0); }
.my-4 { margin-block: calc(var(--spacing) * 4); }
.my-0\.5 { margin-block: calc(var(--spacing) * .5); }
.mb-0 { margin-bottom: calc(var(--spacing) * 0); }
.mb-0\.5 { margin-bottom: calc(var(--spacing) * .5); }
.mb-2\.5 { margin-bottom: calc(var(--spacing) * 2.5); }
.-mx-1 { margin-inline: calc(var(--spacing) * -1); }
.h-0 { height: calc(var(--spacing) * 0); }
.w-48 { width: calc(var(--spacing) * 48); }
.min-w-40 { min-width: calc(var(--spacing) * 40); }
.w-40 { width: calc(var(--spacing) * 40); }

/* space-y carries the reverse flag the build sets, so a later space-y-reverse
   on the same element keeps working. :where() to match the build's zero
   specificity — a plain selector here would outrank the real utilities. */
:where(.space-y-2\.5 > :not(:last-child)) {
    --tw-space-y-reverse: 0;
    margin-block-start: calc(calc(var(--spacing) * 2.5) * var(--tw-space-y-reverse));
    margin-block-end: calc(calc(var(--spacing) * 2.5) * calc(1 - var(--tw-space-y-reverse)));
}

/* And three the widened guard found only because the same commit taught its
   selector match to treat a BACKSLASH as a boundary. Each of these was
   shadowed by a fractional sibling the build does carry — `.top-1\/2`,
   `.left-1\/3`, `.bg-emerald-400\/20` — so a substring-ish match cleared them
   while the flat utility was never emitted at all:

   · top-1 / left-1 — the numbered badge on each photo in the homestay gallery
     editor is `absolute top-1 left-1`. With both offsets dead it fell back to
     auto and sat at its static position instead of inside the thumbnail.
   · bg-emerald-400 — the admin header's active-users dot, `w-1.5 h-1.5
     rounded-full bg-emerald-400`. A 6px circle with no background is nothing
     at all, so the count sat next to an invisible indicator. */
.top-1 { top: calc(var(--spacing) * 1); }
.left-1 { left: calc(var(--spacing) * 1); }
.bg-emerald-400 { background-color: var(--color-emerald-400); }

/* ── A SCHEDULED PLAN CHANGE, and the way out of it ─────────────────────────────
   Stated above the plan grid so it is the first thing read on opening the dialog:
   the client pressed a button days ago and will not remember the wording.

   Its own CSS rather than Tailwind utilities because the served Tailwind build is
   JIT-PURGED against the templates that existed when it was compiled — a class
   first used in a PHP string emits no rule at all and fails silently, which is
   how a banner ends up as unstyled text on the one screen that moves money. */
.lk-plan-pending {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: center;
    gap: .5rem .75rem;
    margin: 0 auto 1rem;
    padding: .625rem .875rem;
    border-radius: .75rem;
    border: 1px solid var(--lk-border, var(--app-border, rgba(15, 23, 42, .14)));
    background: var(--lk-row-zebra, rgba(15, 23, 42, .05));
    text-align: center;
}
.lk-plan-pending-txt {
    margin: 0;
    font-size: .8125rem;
    line-height: 1.45;
    color: var(--app-text, #0f172a);
}
.lk-plan-pending-txt i {
    margin-right: .375rem;
    opacity: .75;
}
.lk-plan-pending-form {
    margin: 0;
    flex: 0 0 auto;
}
/* The deferred upgrade sits UNDER the immediate one, in its own form (an element
   has one name, and `interval` already uses it on each submit button). The gap is
   what stops the two reading as one control with a stray second line. */
.lk-plan-later {
    margin: .375rem 0 0;
}
@media (max-width: 480px) {
    /* Stack, and let the cancel go full width — at 375px the sentence wraps to
       two lines and a button beside it would be squeezed under 44px. */
    .lk-plan-pending { flex-direction: column; }
    .lk-plan-pending-form,
    .lk-plan-pending-form .btn { width: 100%; }
}
/* The card the subscription is SCHEDULED to move to: same inert treatment as the
   current plan, distinguished only by the clock — it is a statement, not an
   offer, and must not read as a control that failed to work. */
.lk-plan-scheduled {
    border-style: dashed;
}

/* ── PLAN-CARD CTAs MAY WRAP ────────────────────────────────────────────────────
   .btn is `white-space: nowrap` with `overflow: visible`, which is right for the
   short labels it was written for and wrong here. These buttons carry a whole
   consequence — "Naik taraf sekarang — bayar beza RM 644.21", "Naik taraf pada
   15 Mac 2027 — tiada bayaran sekarang" — and at desktop the card is 270px, so a
   264px label in a 232px box did not clip, it SPILLED OUT of the button and over
   the card beside it. Measured, not guessed: scrollWidth 256 against clientWidth
   230 on a four-column grid at 1280.

   Wrapping instead of shortening, because the words are the feature: the timing
   and the figure are exactly what the client has to read before pressing. At
   375px these already wrapped (the grid is one column and the button is 256px),
   which is why the fault was invisible on the width I checked first.

   height:auto with a min-height keeps a single-line label the same size it has
   always been, so every other button on these cards is untouched. */
.lk-plancard-cta .btn,
.lk-plan-later .btn {
    white-space: normal;
    height: auto;
    min-height: 2.375rem;
    padding-top: .45rem;
    padding-bottom: .45rem;
    line-height: 1.25;
    text-align: center;
}

/* The logout overlay sits on every page, invisible (opacity 0), with its
   spinner and three dots animating forever, so the page repainted every frame
   for nothing. On a weak POS WebView that is the one never-ending animation on
   the register. Paused until the overlay is actually shown; the fade-in is a
   transition on the overlay itself and is untouched. */
.lk-logout-overlay:not(.is-visible),
.lk-logout-overlay:not(.is-visible) * {
    animation-play-state: paused;
}


/* ============================================================================
 *  COMPACT ON PHONES (luuqo.js §6f sets html.lk-compact; it is OFF on
 *  photoluqoo, dentaluuqo and buku-jualan-gerai, which stay exactly as built)
 * ============================================================================
 *  Owner, 2026-10-01: "dalam mobile dia tak besar ... compact n kemas n cantik,
 *  taknak diorang sakit mata". Numbers from Material 3 / Apple HIG / Polaris /
 *  WCAG 2.5.8: KPI tiles 2-3 a row on a phone; numbers ~17px, labels 11px at
 *  least; buttons keep their label on ONE line; card padding 16px; nothing
 *  smaller than 11px. */
@media (max-width: 639px) {
    /* Stat tiles: 2-3 a row (4 tiles become 2 x 2), a compact card each. */
    html.lk-compact .lk-statgrid {
        display: grid !important;
        grid-template-columns: repeat(var(--lk-n, 3), minmax(0, 1fr)) !important;
        gap: .5rem !important;
    }
    html.lk-compact .lk-statgrid > * { min-width: 0; }
    /* Three a row on six columns (luuqo.js lkStatCols): a short last row fills the width. */
    html.lk-compact .lk-statgrid.lk-statgrid-6 { grid-template-columns: repeat(6, minmax(0, 1fr)) !important; }
    html.lk-compact .lk-statgrid.lk-statgrid-6 > * { grid-column: span 2; }
    html.lk-compact .lk-statgrid.lk-statgrid-6 > .lk-s3 { grid-column: span 3; }
    html.lk-compact .lk-statgrid > .lk-s6 { grid-column: 1 / -1; }
    html.lk-compact .lk-statgrid .lk-stat-tile { padding: .75rem !important; min-height: 4.5rem; }
    html.lk-compact .lk-statgrid .lk-stat-tile .mb-3, html.lk-compact .lk-statgrid .lk-stat-tile .mb-2 { margin-bottom: .35rem !important; }
    html.lk-compact .lk-statgrid .lk-mi { width: 1.75rem !important; height: 1.75rem !important; min-width: 1.75rem; border-radius: .55rem !important; }
    html.lk-compact .lk-statgrid .lk-mi i { font-size: .8rem !important; }
    html.lk-compact .lk-statgrid .lk-mi img { width: 100% !important; height: 100% !important; }
    html.lk-compact .lk-statgrid > * * { min-width: 0; }
    html.lk-compact .lk-statgrid > * > * { gap: .35rem; }
    html.lk-compact .lk-statgrid .text-4xl, html.lk-compact .lk-statgrid .text-3xl,
    html.lk-compact .lk-statgrid .text-2xl, html.lk-compact .lk-statgrid .text-xl,
    html.lk-compact .lk-statgrid .text-lg, html.lk-compact .lk-statgrid .text-base {
        font-size: 1.125rem !important; line-height: 1.25 !important;
        overflow-wrap: anywhere; font-variant-numeric: tabular-nums;
    }
    html.lk-compact .lk-statgrid-2 .text-4xl, html.lk-compact .lk-statgrid-2 .text-3xl,
    html.lk-compact .lk-statgrid-2 .text-2xl, html.lk-compact .lk-statgrid-2 .text-xl {
        font-size: 1.25rem !important;
    }
    /* .lk-stat-val is the NUMBER itself, tagged by luuqo.js whatever sized it: a
       module's own class (homestay's .bhub-kpival at 26px, POS History's 22px)
       gets the same phone size as a Tailwind one, so every KPI reads alike.
       A value that would still break over two lines comes down a notch
       (lkStatFit measures it), before the grid falls back to two a row. */
    html.lk-compact .lk-statgrid .lk-stat-val { font-size: 1.125rem !important; line-height: 1.25 !important; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
    html.lk-compact .lk-statgrid-2 .lk-stat-val { font-size: 1.25rem !important; }
    html.lk-compact .lk-statgrid.lk-statgrid-tight .lk-stat-val { font-size: 1rem !important; }
    html.lk-compact .lk-statgrid-2.lk-statgrid-tight .lk-stat-val { font-size: 1.0625rem !important; }
    /* Label over number: when one label takes two lines and its neighbour one,
       the numbers sat at two heights. The number goes to the foot of the tile,
       so a row of numbers lines up. */
    html.lk-compact .lk-statgrid .lk-stat-end { display: flex !important; flex-direction: column; }
    html.lk-compact .lk-statgrid .lk-stat-end > .lk-stat-val { margin-top: auto; }
    /* Labels never break inside a word ("OUTSTANDIN / G"): a touch smaller and
       tighter instead; only the NUMBER may wrap anywhere. */
    html.lk-compact .lk-statgrid .uppercase, html.lk-compact .lk-statgrid .tracking-wide,
    html.lk-compact .lk-statgrid .text-xs, html.lk-compact .lk-statgrid .text-\[10px\], html.lk-compact .lk-statgrid .text-\[11px\] {
        font-size: 11px !important; letter-spacing: .015em !important; line-height: 1.3 !important;
        overflow-wrap: normal !important; word-break: normal !important; hyphens: manual;
    }
    html.lk-compact .lk-statgrid-2 .text-xs:not(.uppercase):not(.tracking-wide) { font-size: 12px !important; }
    /* A tile's explanation stays readable (11px) and stops at three lines. */
    html.lk-compact .lk-statgrid .text-xs:not(.uppercase):not(.tracking-wide),
    html.lk-compact .lk-statgrid .text-sm:not(.uppercase):not(.tracking-wide) {
        font-size: 11px !important; letter-spacing: 0 !important; line-height: 1.35 !important;
        display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden;
    }
    html.lk-compact .lk-statgrid .lk-stat-ico { display: none !important; }
    /* Two a row: an odd last tile takes the whole row rather than sitting alone. */
    html.lk-compact .lk-statgrid-2 > :last-child:nth-child(odd) { grid-column: 1 / -1; }

    /* A button keeps its label on one line ("New / invoice" in two lines was the
       look the owner flagged); the row it sits in wraps instead. */
    html.lk-compact .lk-async-slot .btn { white-space: nowrap; }

    /* Readable floor: nothing under 11px; banner-size headings come down. */
    html.lk-compact .lk-async-slot .text-\[9px\] { font-size: 10px !important; }
    html.lk-compact .lk-async-slot .text-\[10px\] { font-size: 11px !important; }
    html.lk-compact .lk-async-slot .text-4xl { font-size: 1.625rem !important; line-height: 1.2 !important; }
    html.lk-compact .lk-async-slot .text-3xl { font-size: 1.375rem !important; line-height: 1.25 !important; }
    html.lk-compact .lk-async-slot h1.text-2xl, html.lk-compact .lk-async-slot h2.text-2xl { font-size: 1.25rem !important; line-height: 1.3 !important; }
    /* Text a module's own CSS shrinks below 11px for phones (a filter label at
       .625rem, a launcher label at 10px under 420px) is lifted by luuqo.js
       lkTextFloor(), which tags it; the rule lives here so it only bites on a phone. */
    html.lk-compact .lk-async-slot .lk-fs11 { font-size: 11px !important; }
    html.lk-compact .lk-async-slot .lk-fs11.lk-lh { line-height: 1.25 !important; }

    /* Filter chip bar: one row that scrolls, chips 32px, a fade at the edge. */
    html.lk-compact .lk-chiprow {
        flex-wrap: nowrap !important; overflow-x: auto; overflow-y: hidden;
        -webkit-overflow-scrolling: touch; scrollbar-width: none; overscroll-behavior-x: contain;
        padding-right: 24px; scroll-padding-left: 16px;
        -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent);
        mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent);
    }
    html.lk-compact .lk-chiprow::-webkit-scrollbar { display: none; }
    html.lk-compact .lk-chiprow > a, html.lk-compact .lk-chiprow > button { flex: 0 0 auto; white-space: nowrap; }

    /* A form dialog is a BOTTOM SHEET on a phone (M3): full width, rounded top,
       never taller than the screen less 72px, its own scroll, a grab handle. The
       short confirm card (.lk-modal) stays centred. */
    html.lk-compact .lk-overlay { align-items: flex-end; padding: 72px 0 0; }
    html.lk-compact .lk-overlay-card {
        max-width: 100% !important; margin: auto 0 0 !important;
        border-radius: 16px 16px 0 0 !important; border-bottom-width: 0 !important;
        max-height: calc(100vh - 72px); max-height: calc(100dvh - 72px); overflow-y: auto;
        padding: 12px 16px calc(16px + env(safe-area-inset-bottom, 0px)) !important;
        animation: lk-sheet-in .22s cubic-bezier(.16, 1, .3, 1);
    }
    html.lk-compact .lk-overlay-card::before {
        content: ''; align-self: center; flex: 0 0 auto; width: 36px; height: 4px; border-radius: 999px;
        background: rgba(100, 116, 139, .35); margin-bottom: 2px;
    }
    @keyframes lk-sheet-in { from { transform: translateY(28px); opacity: .5; } to { transform: none; opacity: 1; } }

    /* The 'stats' loading skeleton matches the 2-up tiles, so nothing jumps. */
    html.lk-compact .lk-sk-stats { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; gap: .5rem !important; }

    /* Cards breathe less on a phone: 16-20px, not 24-32. */
    html.lk-compact .lk-async-slot .card.p-8 { padding: 1.25rem !important; }
    html.lk-compact .lk-async-slot .card.p-6 { padding: 1rem !important; }
}

/* The on/off switch is a 44x24 pill, not a 44x44 circle. The touch rule above
   that gives a checkbox's label a 44px row also caught .lk-switch (its input IS
   a checkbox), and the track (inset:0) stretched into a circle on every phone. */
@media (max-width: 1023.98px), (pointer: coarse) {
    .theme-root label.lk-switch { min-height: 0 !important; height: 1.5rem; display: inline-flex; }
}
@media (prefers-reduced-motion: reduce) {
    html.lk-compact .lk-overlay-card { animation: none !important; }
}
