/* ============================================================================
 *  Luuko 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 (luuko.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 (luuko.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 ── */
.lk-modal-overlay {
    position: fixed; inset: 0; z-index: 9999;
    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-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);
}
.lk-dialog-scroll {
    align-items: flex-start;
    overflow-y: 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-modal, .lk-modal-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 Luuko 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 (luuko.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.
 *       Force every real form control to 16px on phones — 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: 16px !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);
    }
}

/* 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 Luuko 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;
    }
}

/* ============================================================================
 *  ONBOARDING — one-shot WELCOME overlay (header_app.php, fresh login only)
 *  + FIRST-RUN spotlight tour (luuko-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
 *    luuko-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.
 * luuko-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 + luuko.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; } }
/* '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; }
}

/* ── DRILL MOTION — a direction for list → detail (public/assets/js/luuko-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 luuko-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. luuko-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; }
.\!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 luuko-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 (luuko-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 luuko-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 Luuko 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; }
.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 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 (luuko.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;
}
.lk-manage-stack > * { width: 100%; }
.lk-manage-stack .btn { width: 100%; justify-content: center; }
@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 luuko-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). "luuko-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. ── */
.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 — luuko.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. ── */
