@charset "UTF-8";
/* ==========================================================================
   LifeWheel — Design System
   --------------------------------------------------------------------------
   This is the ONLY global stylesheet. It replaced four files (style.css,
   dashboard.css, pro.css and the previous app.css) that each redefined the
   same custom properties and fought over the cascade — the reason spacing,
   radii and colours never quite matched between pages.

   Layer order below is deliberate; keep additions in the right section:
     1  Reset
     2  Design tokens
     3  Base typography
     4  App shell & navigation   (sidebar / topbar / drawer / bottom bar)
     5  Page primitives          (page head, tabs, toolbar, sections)
     6  Surfaces                 (panels, cards, stat tiles)
     7  Buttons
     8  Forms
     9  Badges, chips, pills
    10  Tables (card-mode on phones)
    11  Lists & media rows
    12  Feedback                 (alerts, toasts, modals, empty, skeleton)
    13  Feature modules          (wallet, subscriptions, work, today...)
    14  Utilities
    15  Legacy class shims
    16  Print & reduced motion

   Written RTL-first. Sizing uses logical properties so an LTR flip is a
   one-line change on <html dir>.
   ========================================================================== */

/* ==========================================================================
   1. Reset
   ========================================================================== */
*,
*::before,
*::after { box-sizing: border-box; }

* { margin: 0; padding: 0; }

html {
    -webkit-text-size-adjust: 100%;
    text-size-adjust: 100%;
    scroll-behavior: smooth;
    /* Anchor jumps must clear the sticky mobile top bar. */
    scroll-padding-top: 90px;
}

body { min-height: 100svh; }

/* LTR mirrors every direction-carrying animation. See --dir above. */
html[dir="ltr"] { --dir: -1; }

/* 🎨 Pre-paint ground.
   The theme class lands on <body>, which does not exist until the parser
   reaches it. Painting <html> from the base tokens — and from the dark ones
   the moment header.php's boot script has added `pre-dark` — means the first
   frame is already the right colour instead of the browser's default white.
   This is why the boot script sets a class on <html> rather than waiting. */
html { background: var(--bg-color); }
html.pre-dark { background: #0c0d11; }

img, svg, video, canvas { display: block; max-width: 100%; height: auto; }
button, input, select, textarea { font: inherit; color: inherit; }
button { background: none; border: 0; cursor: pointer; }
a { color: inherit; text-decoration: none; }
ul, ol { list-style: none; }
table { border-collapse: collapse; width: 100%; }
summary { cursor: pointer; list-style: none; }
summary::-webkit-details-marker { display: none; }
[hidden] { display: none !important; }

/* ==========================================================================
   2. Design tokens  →  MOVED TO tokens.css
   ==========================================================================
   The 114 `:root` tokens and the 32 `body.dark-mode` overrides that used to
   live here now live in `public/assets/css/tokens.css`, loaded immediately
   before this file by every <head> that loads app.css.

   They moved because the auth pages never load app.css and were carrying a
   hand-copied duplicate of the same palette. One source, two cascades.

   Nothing else changed: same names, same values, same order.
   ========================================================================== */

/* ==========================================================================
   3. Base typography
   ========================================================================== */
body {
    font-family: var(--font-sans);
    font-size: var(--text-base);
    line-height: 1.65;
    color: var(--text-main);
    background: var(--bg-color);
    background-image: var(--bg-gradient);
    background-attachment: fixed;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    overflow-x: hidden;
    transition: background-color var(--transition), color var(--transition);
}

h1, h2, h3, h4, h5 { line-height: 1.3; font-weight: var(--weight-heavy); text-wrap: balance; }
h1 { font-size: var(--text-xl); }
h2 { font-size: var(--text-lg); }
h3 { font-size: var(--text-md); }
h4 { font-size: var(--text-base); }
p { text-wrap: pretty; }
small { font-size: var(--text-xs); }
strong, b { font-weight: var(--weight-bold); }

::selection { background: var(--primary-soft); color: var(--text-main); }

/* Accessible keyboard focus — visible on every interactive element.
   The colour is the --focus-ring token (dark-safe, see tokens.css). */
:focus-visible {
    outline: var(--focus-ring);
    outline-offset: 2px;
    border-radius: 6px;
}
:focus:not(:focus-visible) { outline: none; }

/* Scrollbars */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
    background: var(--border-strong);
    border: 2px solid transparent;
    border-radius: var(--radius-pill);
    background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover { background: var(--primary); background-clip: padding-box; }
* { scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }

/* Numeric text lines up in tables and money columns. */
.num, .money, td.num, .stat-value {
    font-variant-numeric: tabular-nums;
    font-feature-settings: 'tnum' 1;
}

/* Signed money on an RTL page.
   A "+" or "−" is a bidi-neutral character, so inside an Arabic paragraph the
   browser pushes it to the far side of the number: «+5,000» is painted as
   «5,000+». Numbers themselves are always read left-to-right, so the sign has
   to be isolated in its own LTR run to sit where a reader expects it. */
.row-amount, .amount, .money-signed, .fm-row strong, .forecast-worst strong,
.stat-value, .balance-figure .balance-value {
    direction: ltr;
    unicode-bidi: isolate;
}
/* isolate keeps the sign glued to its digits; the block itself still sits on
   whichever side the RTL layout put it, so nothing moves. */

/* ==========================================================================
   4. App shell & navigation
   --------------------------------------------------------------------------
   Desktop  (>=1280px) : fixed sidebar + fluid content
   Laptop   (1024-1279): icon rail that expands on hover/focus
   Tablet   (768-1023) : top bar + off-canvas drawer
   Phone    (<768px)   : top bar + drawer + bottom tab bar
   ========================================================================== */
.dashboard-wrapper {
    display: grid;
    grid-template-columns: var(--sidebar-w) minmax(0, 1fr);
    gap: var(--space-5);
    width: 100%;
    max-width: var(--content-max);
    margin-inline: auto;
    padding: var(--space-5);
    align-items: start;
}

/* -- Sidebar --------------------------------------------------------- */
/* 🧊 THE SHELL IS NOT MADE OF GLASS ANY MORE — LifeWheel V2.
   --------------------------------------------------------------------------
   The sidebar used to carry `.glass-panel` in its markup, which meant the
   single most-looked-at surface in the product was:

       background: linear-gradient(155deg, …)     a gradient
       backdrop-filter: blur(16px)                glassmorphism
       box-shadow: …, inset 0 1px 0 …             an inset highlight

   — three of the first things V2's visual direction rules out, on the frame
   every other screen hangs off. It now carries only `.sidebar`, and this rule
   is what it gets instead: one flat surface, one hairline, one small shadow.

   ⚠️ `.glass-panel` ITSELF IS UNCHANGED and is deliberately left alone. It is
   used by 41 views, most of them pre-V2 pages; restyling it here would
   silently redesign Financial, Work, Habits and a dozen legacy screens from
   inside a navigation change. The V2 surfaces (Home, My Systems, Explore, and
   now the shell) have each stopped USING it instead. What remains on
   `.glass-panel` is therefore an accurate map of what has not been through V2
   yet — see the analysis document.
   -------------------------------------------------------------------------- */
.sidebar {
    position: sticky;
    top: var(--space-5);
    display: flex;
    flex-direction: column;
    justify-content: space-between;
    width: 100%;
    max-height: calc(100svh - (var(--space-5) * 2));
    padding: var(--space-5) var(--space-3) var(--space-4);
    border: 1px solid var(--border);
    border-radius: var(--radius-xl);
    background: var(--surface);
    box-shadow: var(--shadow-xs);
    overflow-y: auto;
    overscroll-behavior: contain;
}

.sidebar-body { min-width: 0; }

.brand-logo {
    display: flex;
    align-items: center;
    gap: 10px;
    margin: 0 4px var(--space-5);
    padding: 4px;
    color: var(--text-main);
}
.brand-mark {
    display: grid;
    place-items: center;
    flex: none;
    width: 38px;
    height: 38px;
    border-radius: 12px;
    color: #fff;
    background: var(--primary-ink);
    box-shadow: var(--shadow-xs);
}
.brand-logo strong { display: block; font-size: 0.95rem; line-height: 1.15; }
.brand-logo small {
    display: block;
    margin-top: 2px;
    color: var(--text-faint);
    font-size: 0.56rem;
    letter-spacing: 0.14em;
}

/* The sidebar's search "field" — a button dressed as an input, because that
   is the shape people aim at when they want to search. It opens the overlay;
   nothing is ever typed into this element itself. */
.sidebar-search {
    display: flex;
    align-items: center;
    gap: 8px;
    width: 100%;
    /* The most-tapped control in the app, and it measured 42px — two short
       of the floor the rest of the project holds to. `min-height` rather
       than more padding so the box does not grow on a laptop, where it was
       already fine. */
    min-height: var(--tap);
    margin: var(--space-4) 0 var(--space-3);
    padding: 9px var(--space-3);
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    background: var(--surface-2);
    color: var(--text-faint);
    font-size: var(--text-sm);
    text-align: start;
    cursor: pointer;
    transition: border-color 160ms ease, color 160ms ease;
}
.sidebar-search:hover { border-color: var(--primary); color: var(--text-muted); }
.sidebar-search > span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sidebar-search kbd {
    flex: none;
    padding: 1px 6px;
    border: 1px solid var(--border-strong);
    border-radius: 5px;
    background: var(--surface);
    font-family: inherit;
    font-size: .68rem;
    letter-spacing: .02em;
}

.topbar-search {
    display: inline-grid;
    place-items: center;
    flex: none;
    width: var(--tap);
    height: var(--tap);
    margin-inline-start: auto;
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    background: var(--surface);
    color: var(--text-main);
}
.topbar-search:hover { border-color: var(--primary); color: var(--primary-text); }

.nav-menu { display: grid; gap: 2px; }

.nav-section-label {
    margin: var(--space-4) 10px 6px;
    color: var(--text-faint);
    font-size: 0.64rem;
    font-weight: var(--weight-heavy);
    letter-spacing: 0.07em;
    text-transform: uppercase;
}
.nav-section-label:first-child { margin-top: 0; }

/* ==========================================================================
   🧩 Navigation hierarchy — PLATFORM vs SYSTEMS vs TOOLS
   --------------------------------------------------------------------------
   Three ranks, and the type has to carry them because the rows themselves are
   identical. Before this every heading looked the same, so «My systems» read
   as one section among six instead of as the product's spine.

     PLATFORM   Home · My systems · Explore        ← what LifeWheel is
     SYSTEMS    the user's active systems          ← their actual workspace
     TOOLS      everything that predates the model ← still there, ranked below

   Only the labels change weight; the rows keep their existing size, spacing
   and 44px target, so nothing about reachability moves.
   ========================================================================== */

/* The two headings that matter read as headings, not as faint captions. */
.nav-label-platform,
.nav-label-systems {
    color: var(--text-muted);
    font-size: 0.68rem;
    letter-spacing: 0.05em;
}

/* ⚠️ A rule, not a divider element: a separator that exists only in CSS
   cannot end up in the accessibility tree as a meaningless list item. */
.nav-label-systems,
.nav-label-tools {
    padding-block-start: var(--space-4);
    border-block-start: 1px solid var(--border);
}

/* «More tools» is the demotion. Quieter than the two above it, and its own
   sub-headings are quieter again — the old section names survive as
   second-level labels rather than as peers of the platform. */
.nav-label-tools {
    color: var(--text-faint);
    font-size: 0.62rem;
}
.nav-label-sub {
    margin-block-start: var(--space-3);
    color: var(--text-faint);
    font-size: 0.6rem;
    font-weight: var(--weight-bold);
    letter-spacing: 0.04em;
    /* ↔️ Logical, so the indent follows the document direction in Arabic. */
    padding-inline-start: 4px;
    opacity: .85;
}

.nav-item {
    position: relative;
    display: flex;
    align-items: center;
    gap: 11px;
    min-height: var(--tap);
    padding: 9px 12px;
    border: 1px solid transparent;
    border-radius: var(--radius-sm);
    color: var(--text-muted);
    font-size: var(--text-sm);
    font-weight: var(--weight-semibold);
    transition: background var(--transition), color var(--transition), border-color var(--transition);
}
.nav-item > i {
    flex: none;
    width: 20px;
    text-align: center;
    font-size: 0.95rem;
    color: var(--text-faint);
    transition: color var(--transition);
}
.nav-item > span {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.nav-item:hover {
    color: var(--text-main);
    background: var(--surface-2);
    border-color: var(--border);
}
.nav-item:hover > i { color: var(--text-muted); }
/* 📍 الصفحة اللي إنت فيها.
   -----------------------------------------------------------------------
   ⚠️ الشريط الجانبي بقى `::before` بـ `inset-inline-start`، مش
   `box-shadow: inset 3px 0 0`.

   `box-shadow` مالهاش نسخة منطقية: `inset 3px 0 0` معناها «٣ بكسل من
   الشمال» في الاتجاهين، فالعربي كان بيطلع الشريط على الناحية الغلط —
   بعيد عن حافة القايمة بدل ما يكون عليها. ومكانش فيه أي تجاوز
   `[dir=rtl]` في الملف كله يصلّحها.

   والخلفية بقت لون واحد بدل `linear-gradient(100deg, …)`: التدرّج
   بزاوية ثابتة بيتلاشى ناحية الشمال في الاتجاهين كمان، فكان بيبان
   مقلوب في العربي. واللون الواحد أهدى — وده المطلوب في قايمة بتتقري
   عشرين مرة في اليوم. */
.nav-item.active {
    color: var(--text-main);
    background: var(--primary-soft);
    border-color: color-mix(in srgb, var(--primary) 26%, transparent);
    font-weight: var(--weight-bold);
}
.nav-item.active::before {
    content: "";
    position: absolute;
    inset-block: 6px;
    inset-inline-start: 0;
    width: 3px;
    border-radius: 0 var(--radius-xs, 3px) var(--radius-xs, 3px) 0;
    background: var(--primary);
}
[dir="rtl"] .nav-item.active::before {
    border-radius: var(--radius-xs, 3px) 0 0 var(--radius-xs, 3px);
}
.nav-item.active > i { color: var(--primary-text); }

/* 🔒 A destination this account cannot open yet.
   -----------------------------------------------------------------------
   Deliberately still a link, and deliberately still legible. It is dimmed
   enough to read as unavailable and not so far dimmed that the person cannot
   see what the product contains — that list is the reason to subscribe. The
   padlock and the title attribute carry the reason, so arriving on /billing
   is an answer to a tap rather than a surprise. */
.nav-item.is-locked {
    opacity: 0.55;
    cursor: pointer;
}
.nav-item.is-locked:hover {
    opacity: 0.8;
    background: var(--surface-2);
}
.nav-item .nav-lock,
.app-tabbar .tab-lock {
    margin-inline-start: auto;
    flex: none;
    width: 13px;
    height: 13px;
    color: var(--text-faint);
}
/* In the tab bar the padlock rides on the icon instead of taking a column. */
.app-tabbar a.is-locked { opacity: 0.55; position: relative; }
.app-tabbar a.is-locked .tab-lock {
    position: absolute;
    inset-block-start: 3px;
    inset-inline-end: calc(50% - 19px);
    margin: 0;
    width: 11px;
    height: 11px;
}

/* Small "new"/count badge at the end of a nav row. */
.nav-item em,
.nav-item .nav-count {
    margin-inline-start: auto;
    flex: none;
    padding: 2px 7px;
    border-radius: var(--radius-pill);
    background: var(--primary-soft);
    color: color-mix(in srgb, var(--primary) 70%, var(--text-main));
    font-size: 0.6rem;
    font-style: normal;
    font-weight: var(--weight-heavy);
}
.nav-item .nav-count.is-due { background: var(--danger-soft); color: color-mix(in srgb, var(--danger) 78%, var(--text-main)); }
/* «فيه رد» — good news, so green. Red is reserved here for the things that
   cost money if they are ignored, and spending it on an answered ticket
   would make both of them mean less. */
.nav-item .nav-count.is-reply { background: var(--success-soft); color: color-mix(in srgb, var(--success) 78%, var(--text-main)); }

.sidebar-foot { display: grid; gap: 8px; margin-top: var(--space-4); }
.sidebar-quote {
    margin: var(--space-4) 6px 0;
    padding-top: var(--space-3);
    border-top: 1px solid var(--border);
    color: var(--text-faint);
    font-size: 0.7rem;
    line-height: 1.8;
}
.sidebar-quote i { margin-inline-end: 5px; color: var(--primary-text); }

/* -- Main column ----------------------------------------------------- */
.app-main { min-width: 0; }
.main-content {
    display: flex;
    flex-direction: column;
    gap: var(--space-5);
    min-width: 0;
}

/* 📱 The ONLY place safe-area-inset is read.
   `standalone` (what the manifest asks for) keeps the constants above at 0,
   because the viewport is already inset. `fullscreen` genuinely covers the
   system bars, so there the real numbers are wanted. */
@media (display-mode: fullscreen) {
    :root {
        --safe-top: env(safe-area-inset-top, 0px);
        --safe-bottom: env(safe-area-inset-bottom, 0px);
    }
}

/* -- Mobile top bar --------------------------------------------------- */
.app-topbar {
    display: none;
    position: sticky;
    top: 0;
    z-index: 60;
    align-items: center;
    gap: var(--space-3);
    min-height: var(--topbar-h);
    padding: 10px var(--space-4);
    padding-top: max(10px, var(--safe-top));
    /* 🧊 Opaque, not translucent-plus-blur. A full-width `backdrop-filter`
       pinned to the top of a phone screen repaints on every scroll frame, and
       what it bought was a smear of the content underneath. A solid bar with a
       hairline says «this is fixed chrome» more clearly and costs nothing. */
    background: var(--bg-color);
    border-bottom: 1px solid var(--border);
}
.app-topbar .brand-mini {
    display: flex;
    align-items: center;
    gap: 8px;
    min-width: 0;                 /* the bar is fixed-width; the name yields */
    font-weight: var(--weight-heavy);
    font-size: var(--text-base);
}
.app-topbar .brand-word { white-space: nowrap; }
/* On a phone the bar carries five controls — menu, brand, search, account,
   bell. The wordmark is the only thing in it that is not a control, so it is
   the one that goes; the logo mark still says which app this is.
   It is hidden rather than truncated on purpose: "Life Wheel" clipped to
   "…eel" reads as a rendering bug, not as a shortened name. */
@media (max-width: 479px) {
    .app-topbar .brand-word { display: none; }
}
.app-topbar .topbar-spacer { margin-inline-start: auto; }

.nav-toggle {
    display: inline-grid;
    place-items: center;
    flex: none;
    width: var(--tap);
    height: var(--tap);
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    background: var(--surface);
    color: var(--text-main);
}
.nav-toggle:hover { border-color: var(--border-strong); }

/* -- Drawer scrim ----------------------------------------------------- */
.nav-scrim {
    display: none;
    position: fixed;
    inset: 0;
    z-index: 70;
    background: var(--overlay);
    -webkit-backdrop-filter: blur(3px);
    backdrop-filter: blur(3px);
}
body.nav-open .nav-scrim { display: block; animation: fade-in 180ms ease; }
/* ⚠️ NOT `overflow: hidden` any more. iOS Safari ignores that for touch
   scrolling, so the page scrolled behind the open drawer. syncScrollLock in
   ui.js takes <body> out of flow instead, keyed off this same class. */

/* -- Mobile bottom tab bar -------------------------------------------- */
.app-tabbar {
    display: none;
    position: fixed;
    inset-inline: 0;
    bottom: 0;
    z-index: 65;
    padding: 6px 8px;
    padding-bottom: max(6px, var(--safe-bottom));
    /* 🧊 Same reasoning as `.app-topbar` above — and this one sits over the
       scrolling list for the whole session, not just at the top of it. */
    background: var(--bg-color);
    border-top: 1px solid var(--border);
}
.app-tabbar ul {
    display: grid;
    grid-auto-flow: column;
    grid-auto-columns: 1fr;
    gap: 2px;
}
.app-tabbar a,
.app-tabbar button {
    display: grid;
    justify-items: center;
    gap: 3px;
    width: 100%;
    padding: 6px 2px;
    min-height: 50px;
    border-radius: var(--radius-sm);
    color: var(--text-faint);
    /* Phase 14: was 0.62rem (8.96px at the 360 floor — below every other
       label on the screen). 11px keeps five columns on 360 without a wrap
       and is the one deliberate exception under the 12px floor, being a
       tab caption under an icon rather than content. */
    font-size: 11px;
    font-weight: var(--weight-bold);
    line-height: 1.2;
    text-align: center;
}
/* The reserved column the quick-actions button sits over (footer.php). */
.app-tabbar-slot { min-height: 50px; }
.app-tabbar a i,
.app-tabbar button i { font-size: 1.05rem; }
.app-tabbar a.active { color: var(--primary-text); background: var(--primary-soft); }

/* ♿ Skip link (Phase 14) — first focusable element, hidden until focused.
   Sits above the top bar and the drawer so it is never behind either. */
.skip-link {
    position: fixed;
    inset-block-start: var(--space-3);
    inset-inline-start: var(--space-3);
    z-index: 90;
    padding: var(--space-2) var(--space-4);
    border-radius: var(--radius-sm);
    background: var(--surface);
    color: var(--text-main);
    font-size: var(--text-sm);
    font-weight: var(--weight-semibold);
    box-shadow: var(--shadow-md);
    transform: translateY(-200%);
    opacity: 0;
    pointer-events: none;
}
.skip-link:focus-visible {
    transform: none;
    opacity: 1;
    pointer-events: auto;
    outline: var(--focus-ring);
    outline-offset: 2px;
}
/* The target of the skip: focusable by script/fragment, never a visible ring
   of its own — it is a region, not a control. */
main[tabindex="-1"]:focus { outline: none; }

/* Phase 14 — where the quick-actions button still floats in the corner
   (768–1023 circle, ≥1024 pill), the page reserves the button's height at
   its end so the last row's action is never under it at the end of a
   scroll. Mid-scroll it is a floating button like any other; on phones it
   is docked into the tab bar instead (see quick-add.php). */
@media (min-width: 768px) {
    .app-main { padding-block-end: calc(54px + var(--space-5) * 2); }
}
body.nav-open .app-tabbar button[data-nav-toggle] { color: var(--primary-text); background: var(--primary-soft); }

/* ==========================================================================
   🎛️ V2 APP SHELL — THE STATE CONTRACT
   --------------------------------------------------------------------------
   Phase 1 left four items to be closed against the real shell rather than by
   restyling 161 views. Measured first, at 390px, before writing anything:

     topbar   8 controls  · all 44px
     tabbar   4 controls  · all 50px
     nav-item 27 controls · all 44px
     account  6 controls  · 44–48px
     bell 44px · quick action 54px
     → 0 shell controls under the 44px floor

   So item E was already met inside the shell; the failures found in Phase 1
   (`.sys-move-btn`, 26×18px) are My Systems CONTENT and belong to Phase 3.

   What was actually missing was not size but ARTICULATION: the four states
   existed in three different places and one of them did not exist at all.
   This block is the single place a future V2 platform surface can read.

     FOCUS        `:focus-visible` — 2px `--primary` outline, 2px offset.
                  Defined once, globally, above. Not repeated per component:
                  a second focus rule is how two components drift apart.

     ACTIVE       `.active` + `[aria-current="page"]`. Both, deliberately —
                  see below.

     UNAVAILABLE  `.is-locked` — opacity PLUS a lock glyph PLUS the reason in
                  `title`/`aria-label`. Three channels, so it survives
                  greyscale and reaches a screen reader.

     DISABLED     `:disabled` / `[aria-disabled="true"]` — was MISSING for
                  shell controls. Added below.
   ========================================================================== */

/* ⚠️ `aria-current` styled ALONGSIDE `.active`, not instead of it.
   ---------------------------------------------------------------------------
   The shell sets both on the current row. Styling only the class lets the two
   drift: a row could carry `.active` and no `aria-current`, look current to
   the eye and be invisible as current to a screen reader — which is exactly
   the bug the class-only rule cannot catch. Binding the paint to the ATTRIBUTE
   as well means anything that looks current is announced as current.

   `:where()` keeps specificity at zero so this adds a hook without competing
   with the existing `.nav-item.active` rules above. */
.nav-menu :where([aria-current="page"]).nav-item,
.app-tabbar :where([aria-current="page"]) {
    color: var(--primary-text);
}

/* -- Disabled ---------------------------------------------------------------
   The state that had no rule. Distinct from `.is-locked`: locked means "your
   membership does not reach this, and here is why"; disabled means "this
   control cannot act right now". Locked stays tappable and routes to billing;
   disabled does not respond at all.

   ⚠️ Not opacity alone — `cursor` and the removal of every hover affordance
   carry it too, so the state is legible without relying on a colour shift a
   dark theme might swallow. */
.app-tabbar a[aria-disabled="true"],
.app-tabbar button:disabled,
.nav-menu .nav-item[aria-disabled="true"],
.app-topbar button:disabled,
.account-menu button:disabled {
    opacity: 0.45;
    cursor: not-allowed;
    pointer-events: none;
}

/* -- Target size: the density contract --------------------------------------
   ⚠️ Measured across six viewports before writing this, and the measurement
   changed the conclusion.

   The shell reports 0 controls under 44px at 360, 390, 1024, 1280 and 1440,
   and at 768 it reported six. That looked like a gap, and the first fix here
   forced `.account-popover` rows to `--control-md` at every width.

   That fix was WRONG and was reverted. There is already a
   `@media (max-width: 767px)` block near the end of this file that raises
   `.btn-sm`, `.tabs button`, `.chip`, `.account-popover button` and
   `.brand-mini` to `--tap`. In other words the shell already distinguishes
   TOUCH from POINTER, and does it correctly:

     ≤767px   every control is at least `--tap` — the finger is the input
     ≥768px   menu rows sit at 40px and the wordmark at 30px — the intentional
              exceptions §8 allows, because a mouse is not a thumb

   40px clears WCAG 2.5.8 (24px AA) comfortably; 44px is the AAA figure and is
   applied exactly where it matters. Flattening that to one number would have
   removed a deliberate decision and made every desktop menu taller for no
   accessibility gain.

   Recorded here because the next person will measure 40px at 1280, reach for
   the same "fix", and should find this note first.
   ------------------------------------------------------------------------- */

/* -- Reduced motion ---------------------------------------------------------
   ♿ The shell's transitions are affordances, not decoration, so they are
   shortened rather than removed — an instant state change reads as a glitch. */
@media (prefers-reduced-motion: reduce) {
    .nav-item,
    .app-tabbar a,
    .app-tabbar button { transition-duration: 1ms; }
}

/* -- Account menu ------------------------------------------------------ */
.account-dock { position: fixed; z-index: 80; top: var(--space-5); inset-inline-start: var(--space-5); }
.account-menu { position: relative; }
.account-menu summary {
    display: flex;
    align-items: center;
    gap: 6px;
    padding: 4px;
    border: 1px solid var(--border);
    border-radius: var(--radius-pill);
    background: color-mix(in srgb, var(--surface-strong) 90%, transparent);
    box-shadow: var(--shadow-sm);
    transition: box-shadow var(--transition), border-color var(--transition);
}
.account-menu summary:hover,
.account-menu[open] summary { border-color: var(--primary-text); box-shadow: 0 0 0 4px var(--primary-soft); }
.account-avatar {
    display: grid;
    place-items: center;
    flex: none;
    width: 38px;
    height: 38px;
    border-radius: 50%;
    color: #fff;
    background: var(--primary-ink);
    box-shadow: var(--shadow-xs);
    font-size: 0.76rem;
    font-weight: var(--weight-black);
}
.account-avatar-sm { width: 34px; height: 34px; font-size: 0.68rem; }
.account-caret { width: 22px; color: var(--text-muted); font-size: 0.62rem; text-align: center; transition: transform var(--transition); }
.account-menu[open] .account-caret { transform: rotate(180deg); }
.account-popover {
    position: absolute;
    top: calc(100% + 10px);
    inset-inline-start: 0;
    width: 232px;
    padding: 8px;
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-md);
    /* 🧊 Was `blur(18px)` behind a 97%-opaque surface — three percent of a
       blur, which is a compositor layer for something nobody can see. */
    background: var(--surface-strong);
    box-shadow: var(--shadow-lg);
}
.account-popover-head {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 8px 8px 12px;
    margin-bottom: 4px;
    border-bottom: 1px solid var(--border);
}
.account-popover-head strong,
.account-popover-head small { display: block; max-width: 145px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.account-popover-head strong { font-size: 0.8rem; }
.account-popover-head small { margin-top: 2px; color: var(--text-faint); font-size: 0.67rem; }
.account-popover a,
.account-popover button {
    display: flex;
    align-items: center;
    gap: 10px;
    width: 100%;
    min-height: 40px;
    padding: 9px 10px;
    border-radius: 10px;
    color: var(--text-muted);
    font-size: 0.8rem;
    font-weight: var(--weight-bold);
    text-align: start;
}
.account-popover a:hover,
.account-popover button:hover { color: var(--text-main); background: var(--surface-2); }
.account-popover form { margin-top: 4px; padding-top: 4px; border-top: 1px solid var(--border); }
.account-popover button.is-danger { color: var(--danger); }
.account-popover button.is-danger:hover { color: var(--danger); background: var(--danger-soft); }

/* -- 🔔 The nudge bell ------------------------------------------------- *
   Same shape as the account chip so the two read as one control cluster.
   Everything it shows is derived from the database on this request, so the
   badge is a live count, not a queue of things you already dealt with.      */
.nudge-dock { position: fixed; z-index: 80; top: var(--space-5); inset-inline-start: calc(var(--space-5) + 76px); }
.nudge-menu { position: relative; }
.nudge-menu > summary {
    display: grid;
    place-items: center;
    position: relative;
    width: 44px;
    height: var(--control-md);
    border: 1px solid var(--border);
    border-radius: 50%;
    background: color-mix(in srgb, var(--surface-strong) 90%, transparent);
    box-shadow: var(--shadow-sm);
    color: var(--text-muted);
    cursor: pointer;
    list-style: none;
    transition: box-shadow var(--transition), border-color var(--transition), color var(--transition);
}
.nudge-menu > summary::-webkit-details-marker { display: none; }
.nudge-menu > summary:hover,
.nudge-menu[open] > summary { color: var(--text-main); border-color: var(--primary-text); box-shadow: 0 0 0 4px var(--primary-soft); }

.nudge-count {
    position: absolute;
    top: -3px;
    inset-inline-end: -3px;
    min-width: 19px;
    height: 19px;
    padding: 0 5px;
    border-radius: var(--radius-pill);
    background: var(--warning);
    color: #1a1205;
    font-size: 0.63rem;
    font-weight: var(--weight-black);
    line-height: 19px;
    text-align: center;
}
.nudge-count.is-urgent { background: var(--danger); color: #fff; }

.nudge-popover {
    position: absolute;
    top: calc(100% + 10px);
    inset-inline-start: 0;
    width: min(340px, calc(100vw - 2 * var(--space-4)));
    max-height: min(70vh, 520px);
    overflow-y: auto;
    padding: 8px;
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-md);
    /* 🧊 Was `blur(18px)` behind a 97%-opaque surface — three percent of a
       blur, which is a compositor layer for something nobody can see. */
    background: var(--surface-strong);
    box-shadow: var(--shadow-lg);
}
.nudge-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 8px 8px 12px;
    margin-bottom: 4px;
    border-bottom: 1px solid var(--border);
    font-size: 0.8rem;
}

.nudge-list { display: grid; gap: 4px; }
.nudge-item {
    display: flex;
    align-items: flex-start;
    gap: 10px;
    padding: 10px;
    border-radius: 10px;
    border-inline-start: 3px solid var(--nudge-tone, var(--info));
    background: var(--surface-2);
}
.nudge-item.is-danger  { --nudge-tone: var(--danger); }
.nudge-item.is-warning { --nudge-tone: var(--warning); }
.nudge-item.is-info    { --nudge-tone: var(--info); }
.nudge-icon { flex: none; margin-top: 1px; color: var(--nudge-tone); font-size: 0.9rem; }
.nudge-body { min-width: 0; display: grid; gap: 6px; }
.nudge-text { color: var(--text-main); font-size: 0.78rem; font-weight: var(--weight-semibold); line-height: 1.6; }
.nudge-action {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    min-height: 30px;
    color: var(--nudge-tone);
    font-size: 0.72rem;
    font-weight: var(--weight-heavy);
}
.nudge-action:hover { text-decoration: underline; }

.nudge-clear {
    display: grid;
    justify-items: center;
    gap: 4px;
    padding: var(--space-5) var(--space-4);
    text-align: center;
}
.nudge-clear i { color: var(--success); font-size: 1.6rem; margin-bottom: 4px; }
.nudge-clear strong { font-size: 0.85rem; }
.nudge-clear span { color: var(--text-faint); font-size: 0.72rem; }

/* -- The rotating flash ------------------------------------------------ *
   One reminder, on its own, at most every few hours — and a different one
   each time, so the fourth item eventually gets seen instead of the first
   one shouting forever. Sits under the dock cluster, lets itself out.     */
.nudge-flash {
    position: fixed;
    z-index: 79;
    top: calc(var(--space-5) + 58px);
    inset-inline-start: var(--space-5);
    display: flex;
    align-items: flex-start;
    gap: 10px;
    width: min(370px, calc(100vw - 2 * var(--space-4)));
    padding: 12px 14px;
    border: 1px solid var(--border-strong);
    border-inline-start: 3px solid var(--nudge-tone, var(--info));
    border-radius: var(--radius-md);
    /* 🧊 Was `blur(18px)` behind a 97%-opaque surface — three percent of a
       blur, which is a compositor layer for something nobody can see. */
    background: var(--surface-strong);
    box-shadow: var(--shadow-lg);
    opacity: 0;
    transform: translateY(-10px);
    transition: opacity 220ms ease, transform 220ms ease;
}
.nudge-flash.is-in { opacity: 1; transform: translateY(0); }
.nudge-flash.is-danger  { --nudge-tone: var(--danger); }
.nudge-flash.is-warning { --nudge-tone: var(--warning); }
.nudge-flash.is-info    { --nudge-tone: var(--info); }

.nudge-flash-icon { flex: none; margin-top: 2px; color: var(--nudge-tone); }
.nudge-flash-body { min-width: 0; display: grid; gap: 6px; }
.nudge-flash-body > span { color: var(--text-main); font-size: 0.78rem; font-weight: var(--weight-semibold); line-height: 1.6; }
.nudge-flash-body > a {
    display: inline-flex;
    align-items: center;
    min-height: 30px;
    color: var(--nudge-tone);
    font-size: 0.72rem;
    font-weight: var(--weight-heavy);
}
.nudge-flash-body > a:hover { text-decoration: underline; }
.nudge-flash > button {
    flex: none;
    width: 30px;
    height: 30px;
    margin-inline-start: auto;
    border: 0;
    border-radius: 8px;
    background: transparent;
    color: var(--text-faint);
    cursor: pointer;
}
.nudge-flash > button:hover { color: var(--text-main); background: var(--surface-2); }

@media (max-width: 1023px) {
    /* Below the top bar, full width, clear of everything else. */
    .nudge-flash {
        top: calc(var(--space-3) + 58px);
        inset-inline: var(--space-3);
        width: auto;
    }
}

@media (prefers-reduced-motion: reduce) {
    .nudge-flash { transition: none; transform: none; }
}

.nudge-dock--mobile { display: none; }
@media (max-width: 1023px) {
    .nudge-dock--desktop { display: none; }
    .nudge-dock--mobile { display: block; position: static; }
    /* Anchor the panel to the bar, not the button, so it never hangs off the
       edge of a 360px screen. */
    .nudge-dock--mobile .nudge-popover {
        position: fixed;
        top: calc(var(--space-3) + 58px);
        /* Both insets, so the panel stretches across the bar. An earlier
           version added `left: auto; width: auto`, which cancelled the inline
           inset and let the panel shrink-to-fit, resolving to a negative left
           and pushing the whole page 12px sideways on a 360px screen.

           ⚠️ That bug was amplified by `.app-topbar` carrying a
           `backdrop-filter`, which made it the containing block for its fixed
           descendants. V2 removed that filter, so the containing block is now
           the viewport — which for THIS panel is the same box, because the bar
           is itself fixed at `top: 0; inset-inline: 0`. Both insets are still
           written out rather than relied upon implicitly. */
        inset-inline: var(--space-3);
    }
}

/* A closed <details> normally skips its contents, but a position:fixed child
   escapes that and keeps its box — which is how a panel nobody had opened was
   still able to widen the document. Take it out of layout explicitly. */
.nudge-menu:not([open]) .nudge-popover { display: none; }

/* Only one of the two docks is ever visible: the desktop one in the user bar,
   or the copy that sits inside the mobile top bar. */
.account-dock--mobile { display: none; }
@media (max-width: 1023px) {
    .account-dock--desktop { display: none; }
    .account-dock--mobile { display: block; margin-inline-start: auto; }
}

/* -- Desktop user bar --------------------------------------------------- *
 *
 * 🖥️ The account chip and the notification bell, in normal flow at the top of
 * the content column.
 *
 * They were `position: fixed` at the viewport's inline-start corner. That
 * corner is not part of the app: the shell is centred at --content-max, so on
 * a wide monitor the pair floated by itself out in the page margin; and
 * between 1024 and 1279px, where the sidebar collapses to an icon rail, they
 * were drawn straight over the brand mark. The reservation that went with it
 * (`padding-inline-end: 136px` on every page's first block) was on the
 * opposite side from the dock, so it protected nothing and cost 136px of the
 * hero on every single page.
 *
 * Hidden below 1024px, where the same two controls already live in the mobile
 * top bar.                                                                  */
.app-userbar { display: none; }

@media (min-width: 1024px) {
    .app-userbar {
        display: flex;
        align-items: center;
        justify-content: flex-end;
        gap: var(--space-2);
        margin-block-end: var(--space-3);
    }
    /* In the bar they are ordinary boxes; the fixed insets on the base rules
       stop applying with them. */
    .app-userbar .account-dock,
    .app-userbar .nudge-dock { position: static; }
    /* Both popovers hang from a control at the inline-END of the row, so they
       open inward instead of off the edge of the column. */
    .app-userbar .account-popover,
    .app-userbar .nudge-popover { inset-inline-start: auto; inset-inline-end: 0; }
}

/* ==========================================================================
   5. Page primitives
   ========================================================================== */
.page-head {
    display: flex;
    flex-wrap: wrap;
    align-items: flex-end;
    justify-content: space-between;
    gap: var(--space-4);
}
.page-head .page-head-text { min-width: 0; }
.page-head .eyebrow,
.eyebrow {
    display: block;
    /* Phase 17 — the accent AS INK (tokens.css): identical to --accent on
       light, lifted on dark, where the raw accent measured 3.5:1 (and 2.5:1
       for the default blue) under a 12px eyebrow. */
    color: var(--primary-text);
    font-size: var(--text-xs);
    font-weight: var(--weight-heavy);
    letter-spacing: 0.05em;
}
.page-head h1,
.page-head .page-head-h1 { margin-top: 4px; font-size: var(--text-xl); }
/* Phase 16 — `.page-head-h1` is the same heading at <h2> rank, for a page
   head that sits INSIDE a System Shell whose <h1> is the system name. */
.page-head p.sub,
.page-head .hero-copy { margin-top: 6px; color: var(--text-muted); font-size: var(--text-sm); max-width: 62ch; }
.page-head-actions { display: flex; flex-wrap: wrap; gap: var(--space-2); }
/* A "back to ..." link in the eyebrow is navigation, not prose: give it a real
   touch target instead of leaving it 13px tall. */
.page-head .eyebrow .text-link,
.page-hero .eyebrow .text-link {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    min-height: 40px;
}

/* Hero panel used at the top of feature pages. */
.page-hero {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-5);
    padding: var(--space-5);
    border-radius: var(--radius-xl);
}

/* -- Tabs -------------------------------------------------------------- */
.tabs {
    display: flex;
    gap: 4px;
    padding: 4px;
    border: 1px solid var(--border);
    border-radius: var(--radius-pill);
    background: var(--surface-2);
    overflow-x: auto;
    scrollbar-width: none;
    -webkit-overflow-scrolling: touch;
}
.tabs::-webkit-scrollbar { display: none; }
.tabs a,
.tabs button {
    display: inline-flex;
    align-items: center;
    gap: 7px;
    flex: 0 0 auto;
    min-height: 38px;
    padding: 8px 15px;
    border-radius: var(--radius-pill);
    color: var(--text-muted);
    font-size: var(--text-sm);
    font-weight: var(--weight-bold);
    white-space: nowrap;
    transition: background var(--transition), color var(--transition);
}
.tabs a:hover, .tabs button:hover { color: var(--text-main); background: var(--surface-2); }
.tabs a.active, .tabs button.active,
.tabs a[aria-selected="true"], .tabs button[aria-selected="true"] {
    color: var(--text-on-primary);
    /* ♿ ink, not accent — the active tab's label is white on this fill. */
    background: var(--primary-ink);
    box-shadow: var(--shadow-xs);
}
.tabs .active .tab-count,
.tabs [aria-selected="true"] .tab-count {
    background: rgba(0, 0, 0, 0.24);
    color: var(--text-on-primary);
}
.tabs .tab-count {
    padding: 1px 7px;
    border-radius: var(--radius-pill);
    /* ♿ Two different grounds, two rules. On an INACTIVE tab the pill sits on
       the page surface and the digit is muted text; the old
       rgba(255,255,255,.16) made a pale pill that measured 3.48:1 in light
       mode. On the ACTIVE tab the pill sits on the accent fill under a white
       digit, where a pale pill measured 4.47:1. So: a surface pill by
       default, and a darkened one inside the active tab. */
    background: var(--surface-3);
    color: var(--text-muted);
    font-size: 0.66rem;
    font-weight: var(--weight-heavy);
}

.tab-panel[hidden] { display: none; }

/* Segmented control (compact two/three-way switch inside forms). */
.segmented {
    display: inline-flex;
    padding: 3px;
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    background: var(--surface-2);
}
.segmented label {
    position: relative;
    flex: 1;
    min-width: 0;
}
.segmented input { position: absolute; opacity: 0; inset: 0; cursor: pointer; }
.segmented span {
    display: flex;
    align-items: center;
    justify-content: center;
    /* 44, not 36. The `<input>` is `position:absolute; inset:0` over its
       label, so the label's height IS the hit area — and every segmented
       control in the project (mood, energy, in/out, the filters) was a
       36px target. `flex` rather than `block` so the label centres inside
       the taller box instead of sitting at the top of it. */
    min-height: var(--tap);
    padding: 8px 14px;
    border-radius: var(--radius-xs);
    color: var(--text-muted);
    font-size: var(--text-sm);
    font-weight: var(--weight-bold);
    text-align: center;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    transition: background var(--transition), color var(--transition);
}
.segmented input:checked + span { color: var(--text-main); background: var(--surface-3); box-shadow: var(--shadow-xs); }
.segmented input:focus-visible + span { outline: var(--focus-ring); outline-offset: 2px; }
/* ♿ The money colours are tuned to sit on the page, not on their own pale
   tint: «خارج» selected measured 4.37:1 inside the wallet dialog. Same mix
   toward the body colour that the badges use. */
.segmented.is-money input[value="in"]:checked + span { color: color-mix(in srgb, var(--money-in) 80%, var(--text-main)); background: var(--success-soft); }
.segmented.is-money input[value="out"]:checked + span { color: color-mix(in srgb, var(--money-out) 80%, var(--text-main)); background: var(--danger-soft); }

/* -- Toolbar (search + filters above a list) --------------------------- */
.toolbar {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-2);
}
.toolbar > form { display: contents; }
.search-box { position: relative; flex: 1 1 220px; min-width: 0; }
.search-box i {
    position: absolute;
    inset-inline-start: 14px;
    top: 50%;
    transform: translateY(-50%);
    color: var(--text-faint);
    pointer-events: none;
}
.search-box input { padding-inline-start: 40px; }

/* -- Section heading inside a panel ------------------------------------ */
.section-heading {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-3);
    margin-bottom: var(--space-4);
}
.section-heading h2, .section-heading h3 { display: flex; align-items: center; gap: 9px; }
.section-heading h2 i, .section-heading h3 i { color: var(--primary-text); }
.section-heading span.count { color: var(--text-faint); font-size: var(--text-sm); font-weight: var(--weight-bold); }
/* A link sitting in a section heading is a real destination, not prose —
   give it a touch-sized target. */
.section-heading .text-link {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    min-height: var(--control-sm);
}

/* ==========================================================================
   6. Surfaces
   ========================================================================== */
.glass-panel,
.panel {
    padding: var(--space-5);
    border: 1px solid var(--glass-border);
    border-radius: var(--radius-xl);
    background: linear-gradient(155deg, var(--glass-bg), color-mix(in srgb, var(--glass-bg) 72%, transparent));
    -webkit-backdrop-filter: blur(16px);
    backdrop-filter: blur(16px);
    box-shadow: var(--shadow-md), inset 0 1px 0 var(--glass-highlight);
}

.card {
    padding: var(--space-4);
    border: 1px solid var(--border);
    border-radius: var(--radius-md);
    background: var(--surface);
    box-shadow: var(--shadow-xs);
}
.card-hover { transition: transform var(--transition), border-color var(--transition), box-shadow var(--transition); }
.card-hover:hover { transform: translateY(-3px); border-color: var(--border-strong); box-shadow: var(--shadow-md); }

/* Auto-fitting grids — the workhorse of every page layout.
   `minmax(min(<floor>, 100%), 1fr)` keeps a single narrow column from
   overflowing on a 320px phone. */
.grid-auto { display: grid; gap: var(--space-4); grid-template-columns: repeat(auto-fit, minmax(min(260px, 100%), 1fr)); }
.grid-auto-sm { display: grid; gap: var(--space-3); grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr)); }
.grid-auto-lg { display: grid; gap: var(--space-5); grid-template-columns: repeat(auto-fit, minmax(min(340px, 100%), 1fr)); }
.bento-grid { display: grid; gap: var(--space-4); grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr)); }

/* Sidebar-plus-content split that collapses to one column when narrow. */
.split-layout { display: grid; gap: var(--space-5); grid-template-columns: minmax(0, 1fr); }
@media (min-width: 1080px) {
    .split-layout { grid-template-columns: minmax(0, 1fr) 350px; align-items: start; }
    .split-layout.reverse { grid-template-columns: 350px minmax(0, 1fr); }
    .split-layout > .split-aside { position: sticky; top: var(--space-5); }
}

/* -- Stat tiles -------------------------------------------------------- */
.stats-grid { display: grid; gap: var(--space-3); grid-template-columns: repeat(auto-fit, minmax(min(168px, 100%), 1fr)); }

.stat-card {
    position: relative;
    display: flex;
    flex-direction: column;
    gap: 4px;
    padding: var(--space-4);
    border: 1px solid var(--border);
    border-radius: var(--radius-md);
    background: var(--surface);
    overflow: hidden;
}
.stat-card::after {
    content: '';
    position: absolute;
    inset-block-start: 0;
    inset-inline: 0;
    height: 3px;
    background: var(--stat-accent, var(--primary));
    opacity: 0.85;
}
.stat-card .stat-label {
    display: flex;
    align-items: center;
    gap: 7px;
    color: var(--text-muted);
    font-size: var(--text-xs);
    font-weight: var(--weight-bold);
}
.stat-card .stat-label i { color: var(--stat-accent, var(--primary)); }
.stat-value { font-size: var(--text-num); font-weight: var(--weight-heavy); line-height: 1.15; letter-spacing: -0.02em; }
.stat-card .stat-hint { color: var(--text-faint); font-size: var(--text-xs); }
.stat-card.is-in { --stat-accent: var(--money-in); }
.stat-card.is-out { --stat-accent: var(--money-out); }
.stat-card.is-warning { --stat-accent: var(--warning); }
.stat-card.is-danger { --stat-accent: var(--danger); }
.stat-card.is-success { --stat-accent: var(--success); }

/* ==========================================================================
   7. Buttons
   ========================================================================== */
/* ==========================================================================
   🔘 THE BUTTON TAXONOMY  (Phase 13)
   --------------------------------------------------------------------------
   Six roles, two sizes, one modifier — and nothing else a view should need:

     .btn-primary    the one action a screen exists for (accent fill)
     .btn-secondary  an ordinary action (surface + hairline)
     .btn-quiet      a low-emphasis action: cancel, close, «see all»
                     (text only, surface on hover)
     .btn-danger     destructive; always behind a confirmation
     .btn-icon       a square 44×44 (36×36 with .btn-sm) holding one glyph;
                     MUST carry aria-label
     .btn-link       an action that reads as text inside prose or a row
                     (a <button>, never an <a> — navigation stays a link)
     .btn-sm / .btn-lg   36px / 52px against the 44px base
     .btn-block      full width

   Aliases kept for the existing markup — same rules, same selectors, so a
   view can be migrated one class at a time and nothing shifts meanwhile:
     .btn-ghost   ≡ .btn-quiet   (149 uses at the time of writing)
     .btn-brand   ≡ .btn-primary (3)
     .btn-success ≡ .btn-primary (5) — a green button was a STATUS painted
                    on an action; status is what badges are for (§15).
   No success / warning / info / brand button variants.
   ========================================================================== */
.btn,
.btn-primary,
.btn-secondary,
.btn-ghost,
.btn-quiet,
.btn-danger,
.btn-success,
.btn-link {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 8px;
    min-height: var(--tap);
    padding: 11px 18px;
    border: 1px solid transparent;
    border-radius: var(--radius-sm);
    font-size: var(--text-sm);
    font-weight: var(--weight-bold);
    line-height: 1.1;
    white-space: nowrap;
    cursor: pointer;
    -webkit-user-select: none;
    user-select: none;
    transition: transform var(--transition), box-shadow var(--transition),
                background var(--transition), border-color var(--transition), color var(--transition);
}
.btn:active, .btn-primary:active, .btn-secondary:active, .btn-quiet:active,
.btn-danger:active, .btn-success:active { transform: translateY(1px); }

.btn-primary, .btn-brand, .btn-success {
    color: var(--text-on-primary);
    /* ♿ --primary-ink, not --primary: this fill sits under a white label.
       See the token block for the measurements. */
    /* §9: flat, not a gradient, and no coloured glow under it. A solid fill
       with a 1px contact shadow is what a primary action looks like in a
       product; the 20px accent-tinted blur it replaces is the single most
       recognisable "generated" tell in the old dashboard. */
    background: var(--primary-ink);
    box-shadow: var(--shadow-xs);
}
.btn-primary:hover, .btn-brand:hover, .btn-success:hover { background: var(--primary-strong); box-shadow: var(--shadow-sm); }

.btn-secondary {
    color: var(--text-main);
    background: var(--surface-2);
    border-color: var(--border);
}
.btn-secondary:hover { background: var(--surface-3); border-color: var(--border-strong); }

.btn-ghost, .btn-quiet { color: var(--text-muted); background: transparent; }
.btn-ghost:hover, .btn-quiet:hover { color: var(--text-main); background: var(--surface-2); }

/* A text action. Keeps the 44px floor for the finger (padding, not
   height, is what disappears), the accent-as-ink colour, and an underline
   on hover so it reads as actionable without a box. `.is-danger` for the
   one destructive text action a list row sometimes carries. */
.btn-link {
    padding-inline: var(--space-2);
    background: transparent;
    color: var(--primary-text);
    font-weight: var(--weight-semibold);
    text-underline-offset: 3px;
}
.btn-link:hover { text-decoration: underline; background: transparent; }
.btn-link.is-danger { color: var(--danger); }

/* ♿ #ef4444 gave a white label 3.76:1; #dc2626 -> #b91c1c is 4.83 -> 7.06. */
.btn-danger { color: #fff; background: var(--danger); box-shadow: var(--shadow-xs); }
.btn-danger:hover { filter: brightness(0.92); box-shadow: var(--shadow-sm); }
/* Phase 17 — on the dark theme --danger is lifted to be readable AS TEXT
   (#ff8095), so a white label on it measured 2.4:1. The label takes the page
   ground there (≈8:1), the same rule the done-tick uses on --money-in; the
   two counter pills that share this fill follow. */
body.dark-mode .btn-danger,
body.dark-mode .nudge-count.is-urgent,
body.dark-mode .nc-unread-pill { color: var(--bg-color); }

/* (.btn-success is an alias of .btn-primary — see the taxonomy note.) */

.btn-sm { min-height: var(--control-sm); padding: 7px 13px; font-size: var(--text-xs); }
.btn-lg { min-height: var(--control-lg); padding: 14px 26px; font-size: var(--text-md); }
.btn-icon { width: var(--tap); min-height: var(--tap); padding: 0; }
.btn-icon.btn-sm { width: var(--control-sm); min-height: var(--control-sm); }
.btn-block { width: 100%; }

.btn:disabled, .btn-primary:disabled, .btn-secondary:disabled, .btn-quiet:disabled,
.btn-danger:disabled, .btn-success:disabled, .btn-link:disabled,
.btn[aria-disabled="true"] { opacity: 0.5; cursor: not-allowed; transform: none; box-shadow: none; }

/* -- Busy ----------------------------------------------------------------
   The state a submit button is in between the click and the next page.
   `initSubmitGuard` (ui.js) sets `disabled`, `aria-busy="true"` and this
   class on every submit control of a `data-once` form, and pins the
   button's width first — so nothing under the finger moves. The label
   stays (dimmed), a spinner drawn from currentColor sits before it, and
   the opacity is deliberately NOT the disabled .5: busy is «working»,
   not «unavailable». Works on any button, .btn or not. */
.is-busy,
[aria-busy="true"].btn {
    position: relative;
    cursor: progress;
    opacity: 0.85;
    pointer-events: none;
    transform: none;
}
/* The spinner sits INSIDE the inline-start padding (18px on .btn, 13px on
   .btn-sm — a 1em ring at 4px fits both), absolutely positioned, so the
   box keeps its exact width and the label keeps its place. Measured: a
   flex-child spinner grew every variant by 22px. */
.btn.is-busy::before,
button.is-busy::before {
    content: "";
    position: absolute;
    inset-block-start: 50%;
    inset-inline-start: 4px;
    margin-block-start: -0.5em;
    inline-size: 1em;
    block-size: 1em;
    border: 2px solid currentColor;
    border-inline-end-color: transparent;
    border-radius: var(--radius-pill);
    animation: lw-spin 0.8s linear infinite;
}
/* A square icon button has no padding to hide in: the glyph steps aside. */
.btn-icon.is-busy::before { inset-inline-start: 50%; margin-inline-start: -0.5em; }
.btn-icon.is-busy > * { visibility: hidden; }
/* A text action has no gutter either; busy there is the cursor and the dim. */
.btn-link.is-busy::before { display: none; }
@keyframes lw-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
    .btn.is-busy::before, button.is-busy::before { animation-duration: 2s; }
}

.btn-group { display: flex; flex-wrap: wrap; gap: var(--space-2); }

/* Quiet inline destructive link (used inside list rows). */
.danger-link {
    color: var(--danger);
    font-size: var(--text-xs);
    font-weight: var(--weight-bold);
    cursor: pointer;
}
.danger-link:hover { text-decoration: underline; }

.text-link { color: var(--primary-text); /* Phase 17 — accent as ink, see .eyebrow */ font-weight: var(--weight-bold); }
.text-link:hover { color: var(--text-main); text-decoration: underline; }

/* ==========================================================================
   8. Forms
   ========================================================================== */
.form-control,
.input,
select.form-control,
textarea.form-control {
    display: block;
    width: 100%;
    min-height: var(--tap);
    padding: 11px 14px;
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    background: color-mix(in srgb, var(--bg-color) 55%, transparent);
    color: var(--text-main);
    /* 16px on phones stops iOS Safari zooming the page on focus. */
    font-size: max(16px, var(--text-sm));
    transition: border-color var(--transition), box-shadow var(--transition), background var(--transition);
}
@media (min-width: 768px) {
    .form-control, .input, select.form-control, textarea.form-control { font-size: var(--text-sm); }
}
textarea.form-control { min-height: 108px; resize: vertical; line-height: 1.7; }
.form-control::placeholder { color: var(--text-faint); }
.form-control:hover { border-color: var(--border-strong); }
.form-control:focus {
    border-color: var(--focus);
    background: color-mix(in srgb, var(--bg-color) 38%, transparent);
    box-shadow: 0 0 0 4px var(--primary-soft);
    outline: none;
}
select.form-control {
    appearance: none;
    padding-inline-end: 38px;
    background-image: linear-gradient(45deg, transparent 50%, var(--text-muted) 50%),
                      linear-gradient(135deg, var(--text-muted) 50%, transparent 50%);
    background-position: left 18px top 50%, left 13px top 50%;
    background-size: 5px 5px, 5px 5px;
    background-repeat: no-repeat;
}
/* Phase 17 — the chevron follows the reading direction: `padding-inline-end`
   reserves the END side, so in an LTR document the glyph belongs on the right. */
[dir="ltr"] select.form-control {
    background-position: right 18px top 50%, right 13px top 50%;
}
select.form-control option { background: var(--surface-strong); color: var(--text-main); }
input[type="color"].form-control { padding: 5px; min-height: var(--control-md); cursor: pointer; }
input[type="date"].form-control::-webkit-calendar-picker-indicator { filter: invert(0.6); cursor: pointer; }
body.light-mode input[type="date"].form-control::-webkit-calendar-picker-indicator { filter: none; }

.form-group { margin-bottom: var(--space-4); text-align: start; }
.form-group:last-child { margin-bottom: 0; }
.form-group label,
.field-label {
    display: block;
    margin-bottom: 7px;
    color: var(--text-muted);
    font-size: var(--text-sm);
    font-weight: var(--weight-semibold);
}
.form-group:focus-within label { color: var(--primary-text); }
/* `display:block` so the hint works on a <small> or <span> as well as a <p>.
   Seven views carried their own identical copy of this rule (with a 4px
   offset instead of 6px, so hints sat closer to the field on those pages
   than on every other one). The copies are gone; this is the definition. */
.form-hint { display: block; margin-top: 6px; color: var(--text-faint); font-size: var(--text-xs); }
.text-danger, .field-error { display: block; margin-top: 6px; color: var(--danger); font-size: var(--text-xs); font-weight: var(--weight-semibold); }
.form-control[aria-invalid="true"] { border-color: var(--danger); box-shadow: 0 0 0 4px var(--danger-soft); }

/* Responsive form rows: fields sit side by side when there is room. */
.form-row { display: grid; gap: var(--space-3); grid-template-columns: repeat(auto-fit, minmax(min(190px, 100%), 1fr)); }
.form-row .form-group { margin-bottom: 0; }
.form-actions { display: flex; flex-wrap: wrap; gap: var(--space-2); margin-top: var(--space-4); }

/* An optional block of a form, folded away until it is wanted. */
.more-fields > summary {
    cursor: pointer;
    padding: 10px 0;
    color: var(--text-muted);
    font-size: var(--text-sm);
    font-weight: var(--weight-bold);
}

/* Checkbox / switch row */
.check-row {
    display: flex;
    align-items: flex-start;
    gap: 10px;
    padding: 10px 12px;
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    background: var(--surface-2);
    cursor: pointer;
}
.check-row input[type="checkbox"] { flex: none; width: 18px; height: 18px; margin-top: 3px; accent-color: var(--primary); cursor: pointer; }
.check-row .check-text { min-width: 0; }
.check-row strong { display: block; font-size: var(--text-sm); }
.check-row small { display: block; color: var(--text-faint); }
.check-row:hover { border-color: var(--border-strong); }

/* Amount input with a currency suffix. */
.amount-field { position: relative; }
.amount-field input { padding-inline-end: 54px; }
.amount-field .currency {
    position: absolute;
    inset-inline-end: 14px;
    top: 50%;
    transform: translateY(-50%);
    color: var(--text-faint);
    font-size: var(--text-xs);
    font-weight: var(--weight-bold);
    pointer-events: none;
}

/* ==========================================================================
   9. Badges, chips, pills
   ========================================================================== */
.badge {
    display: inline-flex;
    align-items: center;
    gap: 5px;
    padding: 4px 10px;
    border: 1px solid var(--border);
    border-radius: var(--radius-pill);
    background: var(--surface-2);
    color: var(--text-muted);
    /* Phase 13 — never under 12px: --text-xs dips to 11.6px between 768 and
       1120px, and a badge is content, not decoration (§16). */
    font-size: max(12px, var(--text-xs));
    font-weight: var(--weight-bold);
    line-height: 1.5;
    white-space: nowrap;
}
/* ♿ The semantic colours are picked to sit on the page, not on their own
   pale tint: measured in a browser, «متأخر» came out at 3.80:1 and «مدفوع»
   at 4.49:1 on a light page. Mixing a little of the body colour in pulls
   each one toward the theme it is in — darker on a light page, lighter on a
   dark one — which lifts all four over 4.5 without changing the hue or the
   tint behind them. Same trick .badge-primary was already using. */
.badge-success { background: var(--success-soft); color: color-mix(in srgb, var(--success) 80%, var(--text-main)); border-color: transparent; }
.badge-warning { background: var(--warning-soft); color: color-mix(in srgb, var(--warning) 80%, var(--text-main)); border-color: transparent; }
.badge-danger  { background: var(--danger-soft);  color: color-mix(in srgb, var(--danger)  80%, var(--text-main)); border-color: transparent; }
.badge-info    { background: var(--info-soft);    color: color-mix(in srgb, var(--info)    80%, var(--text-main)); border-color: transparent; }
/* Phase 13: --primary-text, not --primary — the accent-as-ink token is what
   clears 4.5:1 on the dark tint (measured 4.17:1 before). */
.badge-primary { background: var(--primary-soft); color: color-mix(in srgb, var(--primary-text) 80%, var(--text-main)); border-color: transparent; }
.badge-muted,
.badge-neutral { background: var(--surface-2);    color: var(--text-faint); }
/* Phase 13 — the two catalogue states every system header, library card and
   onboarding row already paints by hand (`sysx-tag.is-beta`, `sys-tag`,
   `ob-tag`): the same two tints, now with one name. */
.badge-beta { background: var(--warning-soft); color: color-mix(in srgb, var(--warning) 80%, var(--text-main)); border-color: transparent; }
.badge-soon { background: var(--surface-3);    color: var(--text-faint); border-color: transparent; }

.chip-row { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.chip {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    min-height: 34px;
    padding: 6px 13px;
    border: 1px solid var(--border);
    border-radius: var(--radius-pill);
    background: var(--surface-2);
    color: var(--text-muted);
    font-size: var(--text-xs);
    font-weight: var(--weight-bold);
    transition: background var(--transition), color var(--transition), border-color var(--transition);
}
.chip:hover { color: var(--text-main); border-color: var(--border-strong); }
.chip.active { color: var(--text-on-primary); background: var(--primary-ink); border-color: transparent; }
/* ♿ A count or a hint INSIDE the active chip is sitting on the accent fill,
   not on the page. `.faint` is a page-surface grey — measured on /work it came
   out at 1.53:1 against the chip. On this one surface it takes the same
   colour as the label; it is already secondary by being smaller. */
.chip.active .faint,
.chip.active .muted,
.chip.active .num { color: var(--text-on-primary); }
.chip .dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; }

/* Progress bar */
.progress { height: 8px; border-radius: var(--radius-pill); background: var(--surface-2); overflow: hidden; }
.progress > span {
    display: block;
    height: 100%;
    border-radius: var(--radius-pill);
    background: linear-gradient(90deg, var(--primary), var(--secondary));
    transition: width 420ms ease;
}
.progress.is-success > span { background: var(--success); }
.progress.is-danger > span { background: var(--danger); }

/* ==========================================================================
   10. Tables
   --------------------------------------------------------------------------
   Wide tables scroll inside .table-wrap on tablets. On phones they switch to
   a stacked "card" layout driven by each cell's data-label attribute, so no
   horizontal scrolling is ever required to read a row.
   ========================================================================== */
.table-wrap {
    border: 1px solid var(--border);
    border-radius: var(--radius-md);
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
}
.table { width: 100%; font-size: var(--text-sm); }
.table th,
.table td { padding: 12px 14px; text-align: start; border-bottom: 1px solid var(--border); vertical-align: middle; }
.table thead th {
    position: sticky;
    top: 0;
    z-index: 1;
    background: var(--surface-strong);
    color: var(--text-muted);
    font-size: var(--text-xs);
    font-weight: var(--weight-heavy);
    white-space: nowrap;
}
.table tbody tr { transition: background 150ms ease; }
.table tbody tr:hover { background: var(--surface-2); }
.table tbody tr:last-child td { border-bottom: 0; }
.table td.num, .table th.num { text-align: end; font-variant-numeric: tabular-nums; }
.table .row-actions { display: flex; gap: 6px; justify-content: flex-end; }

@media (max-width: 767px) {
    .table-responsive { border: 0; overflow: visible; }
    .table-responsive .table,
    .table-responsive .table tbody,
    .table-responsive .table tr,
    .table-responsive .table td { display: block; width: 100%; }
    .table-responsive .table thead { display: none; }
    .table-responsive .table tr {
        margin-bottom: var(--space-3);
        padding: var(--space-3);
        border: 1px solid var(--border);
        border-radius: var(--radius-md);
        background: var(--surface);
    }
    .table-responsive .table td {
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: var(--space-3);
        padding: 7px 0;
        border-bottom: 1px dashed var(--border);
        text-align: end;
    }
    .table-responsive .table td:last-child { border-bottom: 0; padding-bottom: 0; }
    .table-responsive .table td::before {
        content: attr(data-label);
        flex: none;
        color: var(--text-faint);
        font-size: var(--text-xs);
        font-weight: var(--weight-bold);
        text-align: start;
    }
    .table-responsive .table td:not([data-label]) { justify-content: flex-end; }
    .table-responsive .table .row-actions { justify-content: flex-end; width: 100%; }
}

/* ==========================================================================
   11. Lists & media rows
   ========================================================================== */
.list { display: grid; gap: var(--space-2); }

.list-row {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-3);
    padding: var(--space-3) var(--space-4);
    border: 1px solid var(--border);
    border-radius: var(--radius-md);
    background: var(--surface);
    transition: border-color var(--transition), background var(--transition);
}
.list-row:hover { border-color: var(--border-strong); }
.list-row .row-main { flex: 1 1 200px; min-width: 0; }
.list-row .row-title { display: block; font-weight: var(--weight-bold); font-size: var(--text-sm); }
.list-row .row-type-icon { color: var(--text-faint); font-size: 0.85em; margin-inline-end: 4px; }
.list-row .row-note {
    display: block;
    margin-top: 3px;
    color: var(--text-faint);
    font-size: var(--text-xs);
    overflow-wrap: anywhere;
}
.list-row .row-meta {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 8px;
    margin-top: 3px;
    color: var(--text-faint);
    font-size: var(--text-xs);
}
.list-row .row-amount { flex: none; font-size: var(--text-md); font-weight: var(--weight-heavy); font-variant-numeric: tabular-nums; }
.list-row .row-amount.is-in { color: var(--money-in); }
.list-row .row-amount.is-out { color: var(--money-out); }
.list-row .row-actions { display: flex; flex: none; gap: 6px; margin-inline-start: auto; }

/* Coloured leading icon for a row. */
.row-icon {
    display: grid;
    place-items: center;
    flex: none;
    width: 42px;
    height: 42px;
    border-radius: var(--radius-sm);
    background: var(--row-accent-soft, var(--primary-soft));
    color: var(--row-accent, var(--primary));
    font-size: 1rem;
}

@media (max-width: 560px) {
    .list-row { padding: var(--space-3); }
    .list-row .row-actions { width: 100%; margin-inline-start: 0; justify-content: flex-end; }
}

/* ==========================================================================
   12. Feedback
   ========================================================================== */
.alert {
    display: flex;
    align-items: flex-start;
    gap: 11px;
    padding: 13px 16px;
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    background: var(--surface);
    font-size: var(--text-sm);
    font-weight: var(--weight-semibold);
}
.alert i { margin-top: 3px; flex: none; }
.alert-success { background: var(--success-soft); border-color: color-mix(in srgb, var(--success) 38%, transparent); }
.alert-success i { color: var(--success); }
.alert-error, .alert-danger { background: var(--danger-soft); border-color: color-mix(in srgb, var(--danger) 38%, transparent); }
.alert-error i, .alert-danger i { color: var(--danger); }
.alert-info { background: var(--info-soft); border-color: color-mix(in srgb, var(--info) 38%, transparent); }
.alert-info i { color: var(--info); }
.alert-warning { background: var(--warning-soft); border-color: color-mix(in srgb, var(--warning) 38%, transparent); }
.alert-warning i { color: var(--warning); }

.toast-stack {
    position: fixed;
    z-index: 9999;
    inset-inline-start: var(--space-4);
    inset-block-end: calc(var(--space-4) + var(--safe-bottom));
    display: grid;
    gap: 10px;
    max-width: min(360px, calc(100vw - 32px));
}
.toast {
    display: flex;
    align-items: center;
    gap: 11px;
    padding: 13px 17px;
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    background: var(--surface-strong);
    box-shadow: var(--shadow-lg);
    font-size: var(--text-sm);
    font-weight: var(--weight-semibold);
    /* 🧊 A `backdrop-filter` behind a fully opaque background blurs nothing.
       It was measurably a no-op and is removed rather than tuned. */
    animation: toast-in 320ms var(--ease-spring) both;
}
.toast i { flex: none; }
.toast-success i { color: var(--success); }
.toast-error i { color: var(--danger); }
.toast-info i { color: var(--info); }
.toast-warning i { color: var(--warning); }

@media (max-width: 767px) {
    /* Keep toasts clear of the bottom tab bar. */
    .toast-stack { inset-block-end: calc(var(--tabbar-h) + 12px + var(--safe-bottom)); inset-inline: var(--space-3); max-width: none; }
}

/* -- Modals ------------------------------------------------------------- */
.modal-overlay {
    position: fixed;
    inset: 0;
    z-index: 10000;
    display: none;
    align-items: center;
    justify-content: center;
    padding: var(--space-4);
    background: var(--overlay);
    -webkit-backdrop-filter: blur(4px);
    backdrop-filter: blur(4px);
    overflow-y: auto;
    overscroll-behavior: contain;
}
.modal-overlay.open { display: flex; animation: fade-in 180ms ease; }
.modal {
    width: 100%;
    max-width: 430px;
    margin: auto;
    padding: var(--space-5);
    border: 1px solid var(--border);
    border-radius: var(--radius-lg);
    background: var(--surface-strong);
    box-shadow: var(--shadow-lg);
    text-align: center;
    animation: modal-pop 240ms var(--ease-spring);
}
.modal h3 { margin-bottom: 8px; }
.modal p { margin-bottom: var(--space-4); color: var(--text-muted); }
.modal-actions { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; }

/* Wider form dialog (add/edit panels). */
.dialog {
    width: 100%;
    max-width: 560px;
    margin: auto;
    padding: var(--space-5);
    border: 1px solid var(--border);
    border-radius: var(--radius-lg);
    background: var(--surface-strong);
    box-shadow: var(--shadow-lg);
    text-align: start;
    animation: modal-pop 240ms var(--ease-spring);
    /* A dialog taller than the screen used to lose its top and be
       unscrollable: the overlay is a flex container, and content that
       overflows a flex container's START edge cannot be reached by scrolling
       it — a very old flexbox behaviour. The dialog scrolls itself instead,
       so the header and its close button are always on screen.
       `dvh` first with a `vh` fallback, because on a phone the address bar
       makes `vh` taller than the space you actually have. */
    max-height: calc(100vh - 2 * var(--space-4));
    max-height: calc(100dvh - 2 * var(--space-4));
    /* Last, and the one that actually applies: 100% of .modal-overlay, which
       ui.js sizes to the VISUAL viewport while it is open. `dvh` above is the
       no-JS fallback and is identical whenever there is no keyboard up. */
    max-height: 100%;
    overflow-y: auto;
    overscroll-behavior: contain;
}
.dialog-head {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: var(--space-3);
    margin-bottom: var(--space-4);
    padding-bottom: var(--space-3);
    border-bottom: 1px solid var(--border);
}
.dialog-head p { margin-top: 4px; color: var(--text-muted); font-size: var(--text-xs); }
.dialog-close {
    display: grid;
    place-items: center;
    flex: none;
    width: 36px;
    height: 36px;
    border-radius: var(--radius-xs);
    color: var(--text-muted);
}
.dialog-close:hover { background: var(--surface-2); color: var(--text-main); }

@media (max-width: 767px) {
    /* ♿ 36px is a desktop size. On a phone this is the control that closes the
       dialog — measured in every one of them at 36×36, under the 44px floor
       the rest of the app holds to. */
    .dialog-close { width: var(--tap); height: var(--tap); }
}

@media (max-width: 560px) {
    /* Dialogs become bottom sheets on phones — thumb-reachable. */
    .modal-overlay { align-items: flex-end; padding: 0; }
    .dialog, .modal {
        max-width: none;
        /* ⚠️ `margin: auto` on the base rule is what centres a dialog on a
           desktop. Auto margins BEAT `align-items` in flexbox, so on a phone
           the "bottom sheet" was actually still centred — it floated with a
           gap under it, and at some heights that gap put its last pixel past
           the fold. Zero the block margins and it sits on the bottom edge,
           which is what a sheet is. */
        margin-block: 0;
        border-radius: var(--radius-xl) var(--radius-xl) 0 0;
        animation: sheet-up 260ms var(--ease-spring);
        /* A sheet stops short of the top so the page behind it stays visible
           — that strip is what tells you this is a sheet and not a new page. */
        max-height: 88vh;
        max-height: 88dvh;
        max-height: 88%;
    }

    /* ⌨️ With a keyboard up there is no page behind to leave a strip of —
       the keyboard is covering it. Give the sheet every pixel of what is
       left, so the fields and the action row both fit in it instead of the
       row being pushed past the bottom edge. */
    .modal-overlay.kb-open .dialog,
    .modal-overlay.kb-open .modal {
        max-height: 100%;
        border-radius: 0;
        animation: none;
    }
}

/* -- The phone sheet: head / body / foot -------------------------------- *
 *
 * ⚠️ What was wrong
 * -----------------
 * A dialog was one long scrolling box: title, then fields, then the actions,
 * all in the same scroll. That is fine when the content is short and it is
 * exactly wrong when it is not. Measured with Quick Add open:
 *
 *      screen      sheet box     content     hidden below     «حفظ» at
 *      360×640     561px         837px        276px           y=847
 *      375×667     585px         838px        253px           y=849
 *      320×568     498px         837px        339px           y=842
 *
 * So the primary action of the sheet sat ~250–340px past the bottom of the
 * visible box. You opened it, saw fields, and had to scroll to the end of a
 * long form to find out how to save — and the title and the close button
 * scrolled away with everything else, so while you were down there nothing
 * told you which sheet you were even in.
 *
 * What it is now
 * --------------
 * The title sticks to the top and the actions stick to the bottom; only the
 * fields move. It is the same three-part shape the job wizard already uses —
 * which is why the wizard measured "footer visible" at every size while the
 * dialogs did not.
 *
 * Both bars carry an opaque background (they are scrolled UNDER, so a
 * translucent one would show the fields through them) and bleed to the sheet's
 * edges with a negative inline margin, cancelling the dialog's own padding.
 *
 * Placed after the 560px block on purpose: it has to win the padding.        */
@media (max-width: 767px) {
    .dialog { padding-block: 0; }

    .dialog > .dialog-head {
        position: sticky;
        inset-block-start: 0;
        z-index: 2;
        margin-inline: calc(var(--space-5) * -1);
        margin-bottom: var(--space-4);
        padding: var(--space-5) var(--space-5) var(--space-3);
        background: var(--surface-strong);
    }

    /* Descendant, not child: most of these sit inside the dialog's <form>.
       Sticky still resolves against .dialog, which is the scroll container. */
    .dialog .form-actions,
    .dialog .modal-actions {
        position: sticky;
        inset-block-end: 0;
        z-index: 2;
        margin-block-start: var(--space-4);
        margin-inline: calc(var(--space-5) * -1);
        margin-bottom: 0;
        padding: var(--space-3) var(--space-5) calc(var(--space-4) + var(--safe-bottom));
        border-top: 1px solid var(--border);
        background: var(--surface-strong);
    }

    /* The action row is the floor of the sheet: full-width buttons, primary
       first under the thumb. */
    .dialog .form-actions .btn,
    .dialog .form-actions button[type="submit"],
    .dialog .modal-actions .btn { flex: 1 1 auto; }

    /* ⌨️ With a keyboard up the action row stops being sticky and is pinned
       to the bottom of the overlay instead — which ui.js has by then sized to
       the visible region, so this is the bottom of what the person can see.
       Sticky could not guarantee that: it pins inside the row's own <form>,
       and a form that starts below the fold takes its sticky child down with
       it (measured: Quick Add at 375x667 with a 300px keyboard put the row
       41px under it). See syncActionBar in ui.js for the full reasoning.

       The safe-area inset goes too: the keyboard is already covering the home
       indicator, so reserving for it spends ~34px there is none of. */
    .modal-overlay.kb-open .dialog .form-actions,
    .modal-overlay.kb-open .dialog .modal-actions {
        position: absolute;
        inset-inline: 0;
        inset-block-end: 0;
        z-index: 3;
        margin-inline: 0;
        margin-block-start: 0;
        padding-bottom: var(--space-3);
    }

    /* …and the sheet gets that height back as padding, so the last field can
       still be scrolled clear of the pinned row. ui.js measures the row and
       publishes --kb-actions-h; the 76px is only the fallback for the frame
       before it does. */
    .modal-overlay.kb-open .dialog {
        padding-bottom: var(--kb-actions-h, 76px);
    }

    /* A focused field must never come to rest flush against the sticky header
       or hidden under the sticky action row. */
    .dialog .form-group,
    .dialog .form-row,
    .dialog .field { scroll-margin-block: var(--space-6) var(--space-6); }
}

/* A short phone (an SE, or any phone in a browser with chrome top and bottom)
   has ~570px to give. Let the sheet take a little more of it. */
@media (max-width: 767px) and (max-height: 700px) {
    .dialog, .modal { max-height: 92vh; max-height: 92dvh; max-height: 92%; }
    .modal-overlay.kb-open .dialog,
    .modal-overlay.kb-open .modal { max-height: 100%; }
}

/* -- Empty & loading ---------------------------------------------------- */
.empty-state {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 10px;
    padding: var(--space-7) var(--space-4);
    color: var(--text-muted);
    text-align: center;
}
.empty-state .empty-icon {
    display: grid;
    place-items: center;
    width: 68px;
    height: 68px;
    margin-bottom: 4px;
    border-radius: 50%;
    background: var(--primary-soft);
    color: var(--primary-text);
    font-size: 1.7rem;
}
/* 🎨 An empty state that MEANS something.
   --------------------------------------------------------------------------
   «You are all caught up» is a result, not an absence, and it was being drawn
   with the same neutral glyph as «you have no clients yet». Home said it with
   two inline `style=` attributes; this makes it a property of the shared
   component instead, so the next surface that needs it does not invent a
   third copy.

   One rule reads the pair, four rules set it — rather than four copies of the
   icon rule. `has-tone` is what switches it on, so the default stays
   untouched for the states that should stay neutral. */
.empty-state.has-tone .empty-icon {
    background: var(--es-soft);
    color: var(--es-ink);
}
.empty-state.is-success { --es-soft: var(--success-soft); --es-ink: var(--success); }
.empty-state.is-warning { --es-soft: var(--warning-soft); --es-ink: var(--warning); }
.empty-state.is-danger  { --es-soft: var(--danger-soft);  --es-ink: var(--danger); }
.empty-state.is-info    { --es-soft: var(--info-soft);    --es-ink: var(--info); }

.empty-state h3 { color: var(--text-main); font-size: var(--text-md); }
.empty-state p { max-width: 44ch; font-size: var(--text-sm); }

/* The catalogue draws with lw_icon(), which returns inline SVG rather than a
   Font Awesome glyph. Without an explicit size an SVG with no width/height
   falls back to the replaced-element default (300x150) and blows the circle
   apart — the same class of bug the marketing icons hit. */
.empty-state .empty-icon svg {
    inline-size: 28px;
    block-size: 28px;
    stroke: currentColor;
    fill: none;
}

/* -- Page-scale variant -------------------------------------------------
   An empty SECTION inside a populated screen should stay quiet; a whole
   screen with nothing on it is the primary content and needs the weight.
   Same component, two scales — not two components. */
.empty-state.is-page {
    gap: var(--space-3);
    padding: var(--space-7) var(--space-4);
}
.empty-state.is-page .empty-icon { inline-size: 76px; block-size: 76px; }
.empty-state.is-page .empty-icon svg { inline-size: 32px; block-size: 32px; }
.empty-state.is-page h3 { font-size: var(--text-lg); font-weight: var(--weight-semibold); }
.empty-state.is-page p { max-width: 46ch; font-size: var(--text-base); line-height: 1.6; }
.empty-state .empty-actions { display: flex; flex-wrap: wrap; gap: var(--space-2); justify-content: center; }

.skeleton { position: relative; overflow: hidden; background: var(--surface-2); border-radius: var(--radius-sm); }
.skeleton::after {
    content: '';
    position: absolute;
    inset: 0;
    background: linear-gradient(90deg, transparent, var(--glass-highlight), transparent);
    transform: translateX(-100%);
    animation: skeleton-shimmer 1.4s infinite;
}

@keyframes skeleton-shimmer { 100% { transform: translateX(100%); } }
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes modal-pop { from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: none; } }
@keyframes sheet-up { from { opacity: 0; transform: translateY(28px); } to { opacity: 1; transform: none; } }
@keyframes toast-in { from { opacity: 0; transform: translateX(calc(28px * var(--dir))) scale(0.96); } to { opacity: 1; transform: none; } }

/* ==========================================================================
   13. Feature modules
   ========================================================================== */

/* -- Money figures ------------------------------------------------------ */
.amount { font-variant-numeric: tabular-nums; font-weight: var(--weight-heavy); }
.amount.is-in { color: var(--money-in); }
.amount.is-out { color: var(--money-out); }
.amount .cur { margin-inline-start: 3px; font-size: 0.72em; font-weight: var(--weight-bold); opacity: 0.72; }

/* -- Wallet balance hero ------------------------------------------------ */
.wallet-hero {
    display: grid;
    gap: var(--space-5);
    grid-template-columns: minmax(0, 1fr);
    align-items: center;
    padding: var(--space-5);
    border-radius: var(--radius-xl);
}
@media (min-width: 900px) {
    .wallet-hero { grid-template-columns: minmax(260px, 340px) minmax(0, 1fr); }
}
.balance-figure { display: grid; gap: 6px; }
.balance-figure .balance-label { color: var(--text-muted); font-size: var(--text-sm); font-weight: var(--weight-bold); }
.balance-figure .balance-value {
    font-size: clamp(2rem, 1.3rem + 3.2vw, 3.2rem);
    font-weight: var(--weight-black);
    line-height: 1.05;
    letter-spacing: -0.03em;
    font-variant-numeric: tabular-nums;
}
.balance-figure .balance-value.is-negative { color: var(--money-out); }
.balance-figure .balance-sub { color: var(--text-faint); font-size: var(--text-xs); }
.balance-actions { display: flex; flex-wrap: wrap; gap: var(--space-2); margin-top: var(--space-3); }

/* -- Mini bar chart (pure CSS, no chart library needed) ------------------ */
.mini-chart {
    display: flex;
    align-items: flex-end;
    gap: clamp(4px, 1.2vw, 12px);
    min-height: 132px;
    padding-top: var(--space-3);
}
.mini-chart .bar-group {
    display: flex;
    flex: 1 1 0;
    flex-direction: column;
    align-items: center;
    gap: 6px;
    min-width: 0;
}
.mini-chart .bars { display: flex; align-items: flex-end; gap: 3px; width: 100%; height: 96px; }
.mini-chart .bar {
    flex: 1;
    min-height: 3px;
    border-radius: 4px 4px 0 0;
    background: var(--money-in);
    opacity: 0.9;
    transition: height 420ms ease;
}
.mini-chart .bar.out { background: var(--money-out); }
.mini-chart .bar-label {
    color: var(--text-faint);
    font-size: 0.6rem;
    font-weight: var(--weight-bold);
    white-space: nowrap;
}
.chart-legend { display: flex; flex-wrap: wrap; gap: var(--space-3); margin-top: var(--space-3); font-size: var(--text-xs); color: var(--text-muted); }
.chart-legend span { display: inline-flex; align-items: center; gap: 6px; }
.chart-legend i { width: 10px; height: 10px; border-radius: 3px; }

/* -- Category breakdown bars -------------------------------------------- */
.breakdown { display: grid; gap: var(--space-3); }
.breakdown-row { display: grid; gap: 6px; }
.breakdown-row .breakdown-head { display: flex; justify-content: space-between; gap: var(--space-3); font-size: var(--text-xs); }
.breakdown-row .breakdown-head strong { font-weight: var(--weight-bold); }
.breakdown-row .breakdown-head span { color: var(--text-muted); font-variant-numeric: tabular-nums; }

/* -- Subscription cards -------------------------------------------------- */
.sub-grid { display: grid; gap: var(--space-4); grid-template-columns: repeat(auto-fit, minmax(min(290px, 100%), 1fr)); }

.sub-card {
    display: flex;
    flex-direction: column;
    gap: var(--space-3);
    padding: var(--space-4);
    border: 1px solid var(--border);
    border-inline-start: 4px solid var(--sub-color, var(--primary));
    border-radius: var(--radius-md);
    background: var(--surface);
    transition: transform var(--transition), box-shadow var(--transition), border-color var(--transition);
}
.sub-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); }
.sub-card.is-due { border-color: color-mix(in srgb, var(--danger) 45%, transparent); background: color-mix(in srgb, var(--danger-soft) 45%, var(--surface)); }
.sub-card.is-soon { border-color: color-mix(in srgb, var(--warning) 40%, transparent); }
.sub-card.is-paused, .sub-card.is-closed { opacity: 0.62; }

.sub-card-head { display: flex; align-items: flex-start; gap: var(--space-3); }
.sub-card-head .row-icon { --row-accent: var(--sub-color, var(--primary)); background: color-mix(in srgb, var(--sub-color, var(--primary)) 16%, transparent); }
.sub-card-head .sub-title { min-width: 0; flex: 1; }
.sub-card-head h3 { font-size: var(--text-md); overflow-wrap: anywhere; }
.sub-card-head .sub-cycle { color: var(--text-faint); font-size: var(--text-xs); }

.sub-price { display: flex; align-items: baseline; gap: 6px; }
.sub-price .amount { font-size: var(--text-lg); }
.sub-price .per { color: var(--text-faint); font-size: var(--text-xs); }

.sub-meta { display: grid; gap: 6px; font-size: var(--text-xs); color: var(--text-muted); }
.sub-meta div { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); }
.sub-meta strong { font-variant-numeric: tabular-nums; }

.sub-actions { display: flex; flex-wrap: wrap; gap: var(--space-2); margin-top: auto; padding-top: var(--space-2); }
.sub-actions .btn-renew { flex: 1 1 140px; }

/* Countdown pill on a subscription card. */
.due-pill {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    padding: 4px 10px;
    border-radius: var(--radius-pill);
    font-size: var(--text-xs);
    font-weight: var(--weight-heavy);
    white-space: nowrap;
}
.due-pill.is-due { background: var(--danger-soft); color: var(--danger); }
.due-pill.is-soon { background: var(--warning-soft); color: var(--warning); }
.due-pill.is-ok { background: var(--success-soft); color: var(--success); }
.due-pill.is-paused, .due-pill.is-closed { background: var(--surface-2); color: var(--text-faint); }

/* -- Provenance chip on an automatic wallet row -------------------------- */
.source-chip {
    display: inline-flex;
    align-items: center;
    gap: 5px;
    padding: 2px 8px;
    border-radius: var(--radius-pill);
    background: var(--surface-3);
    color: var(--text-faint);
    font-size: 0.64rem;
    font-weight: var(--weight-bold);
}
.source-chip i { font-size: 0.6rem; }

/* -- Work / job cards ---------------------------------------------------- */
.job-grid { display: grid; gap: var(--space-4); grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr)); }
.job-card { display: flex; flex-direction: column; gap: var(--space-3); }
.job-card .job-money { display: flex; flex-wrap: wrap; gap: var(--space-3); }
.job-card .job-money div { flex: 1 1 90px; }
.job-card .job-money span { display: block; color: var(--text-faint); font-size: var(--text-xs); }
.job-card .job-money strong { font-size: var(--text-sm); font-variant-numeric: tabular-nums; }

/* Status dots reused across work + subscriptions. */
.status-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; }
.status-active { background: var(--success); }
.status-on_hold, .status-paused { background: var(--warning); }
.status-completed { background: var(--info); }
.status-cancelled { background: var(--text-faint); }

/* --------------------------------------------------------------------------
   🧩 Workspace systems grid  (Phase 4)
   --------------------------------------------------------------------------
   The dashboard's centre of gravity: one card per system the person has
   switched on, in their order. Replaced the Phase 2 strip AND the four
   hardcoded stat cards.

   Tokens only — this is the same design system as everything above it.
   ↔️ Logical properties throughout; nothing here is direction-carrying.
   -------------------------------------------------------------------------- */
.ws-systems { margin-block-end: var(--space-5); }
.ws-manage {
    /* ↔️ Inline END of the heading row in both directions. */
    margin-inline-start: auto;
    font-size: var(--text-sm);
    font-weight: var(--weight-semibold);
    color: var(--primary-text);
}
.ws-manage:hover { text-decoration: underline; text-underline-offset: 4px; }

/* -- The grid --------------------------------------------------------- */
/* 📱 MOBILE FIRST — two up, not one.
   -------------------------------------------------------------------------
   This grid was written desktop-first (`auto-fit minmax(210px)`) and then
   collapsed to ONE column under 620px. Measured on a 390px viewport with six
   systems: six 87px cards, 667px of grid, 31% of a 2176px page — a third of
   the workspace spent listing six names.

   Two columns is the mobile base case. Each card is a name, an icon and one
   number; that fits in ~170px and halves the scroll. The desktop rule is now
   the override, which is the right way round when the phone is the primary
   target (§1, §19).

   `minmax(0, 1fr)` and not `auto`: a long Arabic system name would otherwise
   force its track wider than the viewport and the whole page would scroll
   sideways. */
.ws-grid {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: var(--space-2);
}
/* ⚠️ EXPLICIT COLUMN COUNTS, NOT `auto-fit`.
   --------------------------------------------------------------------------
   `auto-fit` maximises the column count for whatever minimum it is given, and
   that produced two opposite faults from one rule. Measured on a real account:

     minmax(210px)  →  FIVE cards across a 1210px column, so a sixth card was
                       orphaned alone on its own row (5 + 1), and every card
                       was squeezed to the narrowest it ever needs to be on the
                       widest screen we have.
     minmax(260px)  →  fixed the orphan (4 + 2) but collapsed the 768px tablet
                       to TWO columns, where three had fitted comfortably.

   No single minimum satisfies both, because the two widths want different
   answers. So the answer is stated per breakpoint instead of derived from a
   track size — which is also the more honest description of the intent: this
   grid holds a handful of cards carrying a name and one number, and how many
   belong on a row is a design decision, not an arithmetic outcome.

       phone      2   the established two-up rhythm
       ≥620       3   tablet and the narrow desktop shell
       ≥1024      4   the wide shell, cards ~294px

   With the counts people actually have (2–8 systems) this leaves no row
   holding a single orphaned card. */
@media (min-width: 620px) {
    .ws-grid {
        grid-template-columns: repeat(3, minmax(0, 1fr));
        gap: var(--space-3);
    }
}
@media (min-width: 1024px) {
    .ws-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}

.ws-card {
    display: flex;
    flex-direction: column;
    gap: 6px;
    /* Tighter on a phone, roomier from tablet up — see the min-width block. */
    padding: var(--space-3);
    border: 1px solid var(--border);
    border-radius: var(--radius-md);
    background: var(--surface);
    color: var(--text-main);
    /* 👆 A whole card is the tap target, so it carries the 44px floor itself
       rather than relying on its contents to be big enough. */
    min-block-size: 92px;
    transition: border-color var(--transition), box-shadow var(--transition);
}
@media (min-width: 620px) {
    .ws-card { gap: var(--space-2); padding: var(--space-4); min-block-size: 118px; }
}
a.ws-card:hover { border-color: var(--border-strong); box-shadow: var(--shadow-sm); }
/* An active system with no screen: not a link, so it must not look like one. */
.ws-card.is-flat { background: transparent; border-style: dashed; }

/* ♿ Tone is a HINT, never the only signal — every card also carries its
   number and its hint sentence in words. The tint is a top border rather
   than a fill so the card's ground stays the same everywhere and the text
   contrast never changes with state. */
.ws-card.is-ok     { border-block-start: 2px solid var(--success); }
.ws-card.is-warn   { border-block-start: 2px solid var(--warning); }
.ws-card.is-danger { border-block-start: 2px solid var(--danger); }

.ws-card-head { display: flex; align-items: center; gap: var(--space-2); }
.ws-card-ico {
    display: grid;
    place-items: center;
    flex: none;
    inline-size: 28px;
    block-size: 28px;
    border-radius: var(--radius-xs);
    background: var(--primary-soft);
    color: var(--primary-text);
}
.ws-card.is-flat .ws-card-ico { background: var(--surface-3); color: var(--text-faint); }
.ws-card-ico svg { inline-size: 15px; block-size: 15px; }
.ws-card-name {
    font-size: var(--text-sm);
    font-weight: var(--weight-semibold);
    min-inline-size: 0;
    /* 📱 Wraps to two lines on a narrow card instead of being clipped.
       «العمل والعملاء» and «Work & Clients» both truncate at 170px, and a
       card whose title reads «العمل وال…» has failed at the one job it has.
       Two lines, then ellipsis. */
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
    line-height: 1.3;
}
.ws-tag {
    margin-inline-start: auto;
    flex: none;
    padding: 1px 6px;
    border-radius: var(--radius-pill);
    background: var(--warning-soft);
    color: var(--warning);
    font-size: var(--text-xs);
    font-weight: var(--weight-semibold);
}

.ws-card-value {
    font-size: 1.25rem;
    font-weight: var(--weight-semibold);
    line-height: 1.15;
    letter-spacing: -0.02em;
    /* ♿/§31 Tabular figures — a column of cards whose digits are different
       widths reads as a column of different type sizes. */
    font-variant-numeric: tabular-nums;
    /* ↔️ THE ALIGNMENT IS `align-self`, NOT `text-align` — and that is a fix.
       ---------------------------------------------------------------------
       This element carries `dir="ltr"` in the markup so that a value like
       «2 / 3» keeps its order inside an Arabic page. That attribute also
       makes every direction-relative property on it resolve against LTR —
       so `text-align: start` meant LEFT even in Arabic, and the number sat
       on the wrong edge of the card. Same trap as the count pill in
       marketing.css: logical properties answer to the ELEMENT's direction,
       not its parent's.

       `align-self` is resolved by the FLEX CONTAINER instead — the card,
       which has no `dir` override — so it follows the document and the
       number lands on the reading edge in both languages. */
    align-self: start;
}
@media (min-width: 620px) { .ws-card-value { font-size: 1.45rem; } }
.ws-card-unit { font-size: 0.62em; font-weight: var(--weight-medium); color: var(--text-faint); margin-inline-start: 4px; }
.ws-card-hint { font-size: var(--text-xs); color: var(--text-faint); line-height: 1.5; }

.ws-card-bar {
    display: block;
    block-size: 4px;
    border-radius: 2px;
    background: var(--surface-3);
    overflow: hidden;
}
.ws-card-bar > span {
    display: block;
    block-size: 100%;
    background: var(--primary);
    border-radius: 2px;
}
.ws-card.is-ok .ws-card-bar > span { background: var(--success); }

/* «Start here» / «No screen yet» — the card's line when there is no number. */
.ws-card-cta {
    margin-block-start: auto;
    font-size: var(--text-sm);
    font-weight: var(--weight-semibold);
    color: var(--primary-text);
}
.ws-card-cta.is-muted { color: var(--text-faint); font-weight: var(--weight-medium); }

.ws-quick-none { font-size: var(--text-sm); color: var(--text-faint); }

/* -- Sidebar active systems (Phase 4) --------------------------------- */
/* Active-system rows carry an inline SVG rather than a Font Awesome glyph,
   so they need the sizing the `i` selector gives every other nav icon. */
.nav-item .nav-sys-ico { inline-size: 17px; block-size: 17px; flex: none; }

@media (max-width: 330px) {
    /* Only genuinely tiny screens fall back to one column.
       ⚠️ This threshold was 380px and that was too eager: 360px is one of
       the most common real phone widths there is (iPhone SE, a large slice
       of Android), and it was getting the one-up layout the whole mobile
       pass exists to remove. Verified two-up at 360px — a card is ~160px,
       and the name wraps to two lines rather than truncating. */
    .ws-grid { grid-template-columns: 1fr; }
}

/* --------------------------------------------------------------------------
   🖼️ Inline catalogue icons inside the app shell
   --------------------------------------------------------------------------
   ⚠️ `lw_icon()` defaults its class to `m-ico`, which is SIZED IN
   marketing.css — a stylesheet the signed-in app never loads. Every call
   inside the app therefore renders an SVG with no intrinsic size, and an
   unsized <svg> expands to fill its container. Caught in the system
   switcher, where the «go» arrow rendered about 200px tall and pushed the
   whole list off the sheet.

   The component rules (`.ws-card-ico svg`, `.sheet-ico svg`, …) each set a
   size and so were accidentally immune. This is the floor underneath them,
   so the next `lw_icon()` call in an app view cannot reintroduce the bug.

   ↔️ `.m-arrow` is the mirrored-glyph class from the same helper — «forward»
   is left in Arabic. Object glyphs are never mirrored; see lw_icon(). */
.m-ico { inline-size: 18px; block-size: 18px; flex: none; }
[dir="rtl"] .m-arrow { transform: scaleX(-1); }

/* 📱 Shared visibility utilities.
   `.only-wide` hides a control on phones; `.only-narrow` is its opposite.
   Mobile is the base state in both — the element is hidden by default and
   revealed at the breakpoint — which keeps the phone the thing being
   designed rather than the thing being patched. */
.only-wide { display: none !important; }
@media (min-width: 768px) {
    .only-wide { display: inline-flex !important; }
    .only-narrow { display: none !important; }
}

/* --------------------------------------------------------------------------
   📱 Bottom sheet + system switcher  (mobile-first)
   --------------------------------------------------------------------------
   ONE component, two presentations: docked to the bottom edge on a phone,
   an ordinary centred dialog from 768px up. Mobile is the base case here
   and the desktop form is the override — which is the right way round for
   a control whose primary home is a thumb.

   It reuses `.modal-overlay` / `.dialog` from the feedback layer, so focus
   trapping, Escape, the scrim and the scroll lock are ui.js's already.
   -------------------------------------------------------------------------- */
.sheet-overlay { align-items: flex-end; }

.dialog.sheet {
    inline-size: 100%;
    max-inline-size: 100%;
    /* Never taller than most of the screen: a sheet that fills the viewport
       is a page, and the person loses the sense that they can dismiss it. */
    max-block-size: 82svh;
    display: flex;
    flex-direction: column;
    border-start-start-radius: var(--radius-xl);
    border-start-end-radius: var(--radius-xl);
    border-end-start-radius: 0;
    border-end-end-radius: 0;
    /* Clears the home indicator on a phone without a fixed magic number. */
    padding-block-end: max(var(--space-3), var(--safe-bottom));
}

.sheet-list {
    overflow-y: auto;
    -webkit-overflow-scrolling: touch;
    margin-block: var(--space-2);
}

.sheet-row {
    display: flex;
    align-items: center;
    gap: var(--space-3);
    /* 👆 THUMB TARGET. 52px, comfortably over the 44px floor, because this
       is a list somebody scrolls and taps one-handed while walking. */
    min-block-size: 52px;
    padding: var(--space-2) var(--space-3);
    border-radius: var(--radius-sm);
    color: var(--text-main);
    font-size: var(--text-md);
    font-weight: var(--weight-medium);
}
a.sheet-row:active { background: var(--surface-2); }
a.sheet-row:hover { background: var(--surface-2); }
.sheet-row.is-flat { color: var(--text-faint); }

.sheet-ico {
    display: grid;
    place-items: center;
    flex: none;
    inline-size: 34px;
    block-size: 34px;
    border-radius: var(--radius-sm);
    background: var(--primary-soft);
    color: var(--primary-text);
}
.sheet-row.is-flat .sheet-ico { background: var(--surface-3); color: var(--text-faint); }
.sheet-ico svg { inline-size: 17px; block-size: 17px; }
.sheet-name { min-inline-size: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ↔️ Both sit at the inline END; neither element overrides `dir`. */
.sheet-go { margin-inline-start: auto; color: var(--text-faint); flex: none; inline-size: 16px; block-size: 16px; }
.sheet-soon { margin-inline-start: auto; flex: none; font-size: var(--text-xs); }

.sheet-empty { padding: var(--space-5) var(--space-3); text-align: center; display: grid; gap: var(--space-3); justify-items: center; }
.sheet-empty p { color: var(--text-muted); font-size: var(--text-base); }

.sheet-foot {
    display: flex;
    gap: var(--space-4);
    padding-block-start: var(--space-3);
    border-block-start: 1px solid var(--border);
}
.sheet-manage { font-size: var(--text-sm); font-weight: var(--weight-semibold); color: var(--primary-text); min-block-size: var(--tap); display: inline-flex; align-items: center; }

/* From tablet up the sheet stops being a sheet. */
@media (min-width: 768px) {
    .sheet-overlay { align-items: center; }
    .dialog.sheet {
        max-inline-size: 460px;
        max-block-size: 74vh;
        border-radius: var(--radius-lg);
        padding-block-end: var(--space-4);
    }
}

/* ==========================================================================
   14. Utilities
   ========================================================================== */
.stack { display: grid; gap: var(--space-4); }
.stack-sm { display: grid; gap: var(--space-2); }
.row { display: flex; flex-wrap: wrap; gap: var(--space-3); }
.row-tight { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.between { justify-content: space-between; }
.center { align-items: center; }
.middle { display: flex; align-items: center; gap: var(--space-2); }
.push-end { margin-inline-start: auto; }
.grow { flex: 1 1 auto; min-width: 0; }
.wrap-anywhere { overflow-wrap: anywhere; }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.muted { color: var(--text-muted); }
.faint { color: var(--text-faint); }
.small { font-size: var(--text-xs); }
.text-end { text-align: end; }
.text-center { text-align: center; }
.mt-0 { margin-top: 0; }
.mt-3 { margin-top: var(--space-3); }
.mt-4 { margin-top: var(--space-4); }
.mb-0 { margin-bottom: 0; }
.mb-3 { margin-bottom: var(--space-3); }
.mb-4 { margin-bottom: var(--space-4); }
.full { width: 100%; }
/* Visually hidden, still read aloud.
 *
 * ⚠️ The `inset` line is the whole point and must not be removed.
 *
 * Without it this is `position: absolute` with no offsets, which means the
 * element renders at its STATIC position — wherever it would have been in
 * the flow. That is invisible and harmless in a normal block. Inside a
 * horizontally scrollable container it is neither: the static position sits
 * out past the viewport, and a 1px absolutely-positioned element out there
 * drags the whole document sideways with it.
 *
 * Measured on `/admin/plans` at 360px before the fix: the page's scroll
 * width was 522px, it scrolled into 162px of empty space, and the save
 * button went out of reach. From one `<label class="sr-only">` inside a
 * table (§12 #34).
 *
 * `inset: 0` pins it to its containing block instead. It costs nothing —
 * the element is 1px and clipped either way — and it makes the helper safe
 * to use anywhere, which is what a helper has to be.
 */
.sr-only {
    position: absolute;
    /* Logical, not `inset: 0 auto auto 0` — this app is RTL and the house
       rule is no physical `left`/`right` anywhere. */
    inset-block-start: 0;
    inset-inline-start: 0;
    width: 1px; height: 1px;
    padding: 0; margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
}
.divider { height: 1px; background: var(--border); border: 0; margin: var(--space-4) 0; }

/* ==========================================================================
   15. Legacy class shims
   Older views still use these hooks; map them onto the new system rather
   than leaving them unstyled.
   ========================================================================== */
.dashboard-hero { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-4); padding: var(--space-5); }
.dashboard-hero h1 { font-size: var(--text-xl); }
.module-layout { display: grid; gap: var(--space-5); grid-template-columns: minmax(0, 1fr); }
@media (min-width: 980px) { .module-layout { grid-template-columns: 330px minmax(0, 1fr); align-items: start; } }
.module-form { display: grid; gap: var(--space-3); }
.module-form form { display: grid; gap: var(--space-3); }
.module-list { display: grid; gap: var(--space-2); align-content: start; }
.module-row {
    display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-3);
    padding: var(--space-3) var(--space-4);
    border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface);
}
.module-row p { color: var(--text-muted); font-size: var(--text-sm); }
.module-row small { color: var(--text-faint); font-size: var(--text-xs); }
.tool-empty { padding: var(--space-5); border: 1px dashed var(--border-strong); border-radius: var(--radius-md); color: var(--text-muted); text-align: center; }
.auth-wrapper { display: grid; place-items: center; min-height: 100svh; padding: var(--space-4); }
.auth-card { width: 100%; max-width: 440px; padding: var(--space-6) var(--space-5); text-align: center; }
.auth-card h1 { font-size: var(--text-2xl); }
.auth-card > p { margin-bottom: var(--space-5); color: var(--text-muted); }
.page-ready { opacity: 1; }

/* ==========================================================================
   16. Responsive shell breakpoints
   ========================================================================== */

/* Large desktop: give the content more room. */
@media (min-width: 1600px) {
    :root { --sidebar-w: 272px; }
}

/* Laptop / small desktop: sidebar becomes an icon rail that expands. */
@media (min-width: 1024px) and (max-width: 1279px) {
    .dashboard-wrapper { grid-template-columns: var(--sidebar-rail-w) minmax(0, 1fr); }
    /* ⚠️ `overflow-y: auto` مش `overflow: visible`.
       -------------------------------------------------------------------
       الشريط الرفيع فيه ٢٠ عنصر بـ 56px + عناوين الأقسام ≈ 1340px. على
       لابتوب 1024×768 ده بيطلع بره الشاشة بحوالي 570px — يعني «الإعدادات»
       و«الدعم» مايتوصلش ليهم خالص، ومفيش تمرير لأن `overflow: visible`
       بيلغيه.

       والـ `visible` مكانش بيخدم حاجة: الشارات `position: absolute` جوّه
       العنصر نفسه (inset 4px/6px) فمابتخرجش برّه، والتوسّع عند الهوفر
       بيكبّر عرض `.sidebar` نفسه — مابيفيضش منه. والظل بيترسم برّه صندوق
       العنصر بغضّ النظر عن overflow بتاعه هو.

       فالتمرير رجع، والتوسّع لسه شغّال. */
    .sidebar { padding-inline: 10px; overflow-y: auto; }
    .sidebar .brand-logo { justify-content: center; margin-inline: 0; }
    .sidebar .brand-logo > span:not(.brand-mark) { display: none; }
    .nav-section-label { text-align: center; margin-inline: 0; font-size: 0.55rem; }
    /* Icon + a small label, stacked. Relying on the icon alone would leave the
       rail completely blank whenever the icon font fails to load. */
    .nav-item {
        flex-direction: column;
        justify-content: center;
        gap: 3px;
        padding: 8px 4px;
        min-height: 56px;
        text-align: center;
    }
    .nav-item > span {
        display: block;
        width: 100%;
        font-size: 0.58rem;
        line-height: 1.25;
        font-weight: var(--weight-bold);
    }
    .nav-item > i { width: auto; font-size: 1.05rem; }
    .nav-item em, .nav-item .nav-count {
        position: absolute;
        inset-block-start: 4px;
        inset-inline-end: 6px;
        margin: 0;
        padding: 1px 5px;
        font-size: 0.52rem;
    }
    .sidebar-quote { display: none; }

    /* Expand on hover or keyboard focus, floating over the content. */
    .sidebar:hover,
    .sidebar:focus-within {
        width: var(--sidebar-w);
        z-index: 90;
        box-shadow: var(--shadow-lg);
    }
    .sidebar:hover .nav-item > span,
    .sidebar:focus-within .nav-item > span { font-size: var(--text-sm); text-align: start; }
    .sidebar:hover .nav-item,
    .sidebar:focus-within .nav-item {
        flex-direction: row;
        justify-content: flex-start;
        gap: 11px;
        min-height: var(--tap);
        padding: 9px 12px;
        text-align: start;
    }
    .sidebar:hover .brand-logo > span:not(.brand-mark),
    .sidebar:focus-within .brand-logo > span:not(.brand-mark) { display: block; }
    .sidebar:hover .nav-item em, .sidebar:focus-within .nav-item em,
    .sidebar:hover .nav-item .nav-count, .sidebar:focus-within .nav-item .nav-count {
        position: static;
        margin-inline-start: auto;
        padding: 2px 7px;
        font-size: 0.6rem;
    }
}

/* ⛔️ REMOVED: the 136px reservation on every page's first block.
   It existed because the account chip and the bell were fixed over a corner
   of the viewport. They are in the user bar now (see .app-userbar), in flow
   and above the content, so there is nothing to keep clear of — and every
   page gets 136px of its hero back. */

/* Tablet & phone: off-canvas drawer + top bar. */
@media (max-width: 1023px) {
    .dashboard-wrapper {
        display: block;
        padding: 0 0 var(--space-5);
    }
    .app-topbar { display: flex; }
    /* Phase 17 — the shorthand here used to cancel the ≥768 end-of-page
       reservation for the corner FAB (declared earlier, so it lost): at
       768–1023 the last row's action sat under the button. The reservation
       is restated; the ≤767 tab-bar rule below still overrides it there. */
    .app-main { padding: var(--space-4); padding-block-end: calc(54px + var(--space-5) * 2); }
    .main-content { gap: var(--space-4); }

    /* 👍 The brand link is a real destination — it is the Home link, and
       between 768 and 1023px it is the ONLY always-visible one, because the
       tab bar does not appear until 767px. It measured 112×30 in a bar whose
       menu, search and bell are all 44×44.

       ⚠️ It belongs HERE and not in the 767px touch-target block, because the
       gap it closes is exactly 768–1023: the range where the shell is already
       the touch shell (hamburger, drawer, fixed top bar) but the phone floor
       has stopped applying. Found in the V2 visual QA gate. */
    .brand-mini { min-height: var(--tap); }

    /* The drawer opens from the same edge the menu button sits on: the INLINE
       START edge, which is the right in RTL and the left in LTR. (`inset-inline-end`
       here would anchor it to the opposite side of the button — the drawer would
       fly in from the left while the button sat on the right.) */
    .sidebar {
        position: fixed;
        z-index: 75;
        inset-block: 0;
        inset-inline-start: 0;
        width: min(300px, 86vw);
        max-height: none;
        height: 100dvh;
        padding: var(--space-5) var(--space-3);
        padding-top: max(var(--space-5), var(--safe-top));
        border-radius: 0;
        /* The visible edge is the one facing the content. */
        border-inline-end: 1px solid var(--border-strong);
        /* A side panel needs a sideways shadow to read as elevated — the shared
           --shadow-lg only casts downward, which left it looking pasted on. */
        box-shadow: -14px 0 34px rgba(14, 15, 19, 0.12);
        /* Transforms are not direction-aware: +X is always screen-right, so the
           closed position has to be written per direction. RTL parks it to the
           right of the viewport, LTR to the left. */
        transform: translateX(105%);
        visibility: hidden;
        transition: transform 280ms var(--ease-spring), visibility 280ms;
    }
    [dir="ltr"] .sidebar {
        transform: translateX(-105%);
        box-shadow: 14px 0 34px rgba(14, 15, 19, 0.12);
    }
    body.nav-open .sidebar { transform: none; visibility: visible; }

    .account-dock { position: static; }
    .account-popover { inset-inline-start: auto; inset-inline-end: 0; }

    /* The account chip moves into the top bar on small screens. */
    .app-topbar .account-dock { margin-inline-start: auto; }
}

/* Phone: add the bottom tab bar and shrink the shell further. */
@media (max-width: 767px) {
    .app-tabbar { display: block; }
    /* Phase 14 — one control per job. While the tab bar is showing, its
       «القائمة» tab is the menu toggle; the top-bar hamburger opened the
       very same drawer two thumb-lengths away, so it steps out here and
       comes back only where the bar is gone (768–1023, and landscape
       phones below). */
    .app-topbar .nav-toggle { display: none; }
    .app-main { padding: var(--space-3); padding-bottom: calc(var(--tabbar-h) + var(--space-5)); }
    .glass-panel, .panel { padding: var(--space-4); border-radius: var(--radius-lg); }
    .page-hero, .dashboard-hero, .wallet-hero { padding: var(--space-4); }
    .stats-grid { grid-template-columns: repeat(auto-fit, minmax(min(140px, 47%), 1fr)); }
    .page-head { align-items: flex-start; }
    .page-head-actions { width: 100%; }
    .page-head-actions .btn,
    .page-head-actions .btn-primary,
    .page-head-actions .btn-secondary { flex: 1 1 auto; }
}

/* --------------------------------------------------------------------------
   👍 Touch targets — the 44px floor, on phones only
   --------------------------------------------------------------------------
   `--tap` is 44px and the base `.btn` already respects it. The *small*
   variants deliberately do not: on a desktop, a 36px button next to a table
   row is correct density, and a thumb is not involved.

   On a phone the same 36px control is a miss waiting to happen. So rather
   than shrinking the desktop or duplicating every component into a "mobile"
   twin, the small variants simply lose their smallness below 768px. The
   padding and font size stay — only the box grows to something a thumb can
   actually land on.

   Everything below was measured, not guessed: 171 sub-44px controls across
   14 pages at 390px, collapsing into these few shared components.
   -------------------------------------------------------------------------- */
@media (max-width: 767px) {
    /* The single biggest offender — .btn-sm is on goals, today, the wallet,
       the dashboard and every admin table. */
    .btn-sm { min-height: var(--tap); }
    .btn-icon.btn-sm { width: var(--tap); min-height: var(--tap); }

    /* Section tabs (الشغلانات / العملاء / الحسابات …) and filter chips are
       the primary way people move around inside a page on a phone. */
    .tabs a,
    .tabs button { min-height: var(--tap); }
    .chip { min-height: var(--tap); padding-inline: 15px; }

    /* The account popover: five rows, each one a destination or an action
       that logs you out. None of them deserve to be 40px. */
    .account-popover a,
    .account-popover button { min-height: var(--tap); }

    /* The wordmark is hidden under 480px, which left the logo as a 30px
       link. The negative margin keeps it looking where it was. */
    .app-topbar .brand-mini {
        min-height: var(--tap);
        padding-inline: 7px;
        margin-inline-start: -7px;
    }
}

/* Very small phones. */
@media (max-width: 400px) {
    :root { --space-5: 14px; }
    .stats-grid { grid-template-columns: 1fr 1fr; gap: var(--space-2); }
    .stat-card { padding: var(--space-3); }
    /* Phase 14: the 0.56rem (8.96px) step for ≤400px is gone — the 11px
       caption already fits five columns at 360 (measured: 0 clipped). */
    .btn, .btn-primary, .btn-secondary { padding-inline: 13px; }
}

/* Landscape phones: the bottom bar would eat too much height. */
@media (max-height: 480px) and (orientation: landscape) and (max-width: 1023px) {
    .app-tabbar { display: none; }
    .app-topbar .nav-toggle { display: inline-grid; }
    .app-main { padding-bottom: var(--space-4); }
}

/* ==========================================================================
   17. Print & reduced motion
   ========================================================================== */
@media print {
    .sidebar, .app-topbar, .app-tabbar, .account-dock,
    .toast-stack, .nav-scrim, .page-head-actions,
    .row-actions, .btn, .btn-primary, .btn-secondary, .btn-danger { display: none !important; }
    body { background: #fff; color: #000; }
    .glass-panel, .panel, .card, .stat-card {
        border: 1px solid #ccc !important;
        background: #fff !important;
        box-shadow: none !important;
        -webkit-backdrop-filter: none !important;
        backdrop-filter: none !important;
        break-inside: avoid;
    }
    .app-main { padding: 0; }
    .table-responsive .table thead { display: table-header-group; }
}

@media (prefers-reduced-motion: reduce) {
    html { scroll-behavior: auto; }
    *, *::before, *::after {
        animation-duration: 0.01ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.01ms !important;
    }
}

/* ==========================================================================
   📄 The pager
   ==========================================================================
   Under the lists that grow without a bound — the wallet ledger, the
   accounts ledger, the users table, the audit log. Everything else in this
   project is bounded by something real and does not get one.
   ========================================================================== */
.pager {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-3);
    margin-top: var(--space-4);
    padding-top: var(--space-3);
    border-top: 1px solid var(--border);
}
/* The count comes first in the markup and stays even when the arrows wrap
   away: it is the half that tells somebody the list is not everything. */
.pager-count { margin: 0; color: var(--text-muted); font-size: var(--text-xs); }
.pager-range { font-weight: var(--weight-heavy); color: var(--text-main); }
.pager-count [dir="ltr"] { unicode-bidi: isolate; font-variant-numeric: tabular-nums; }

.pager-links { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; }
.pager-nums { display: flex; align-items: center; gap: 4px; }

.pager-btn,
.pager-num {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 6px;
    /* Both dimensions: a page number is «7», and a 20px-wide target is a
       target you miss on a phone. */
    min-height: var(--tap);
    min-width: var(--tap);
    padding: 0 12px;
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    background: var(--surface-2);
    color: var(--text-muted);
    font-size: var(--text-sm);
    font-weight: var(--weight-bold);
    text-decoration: none;
    font-variant-numeric: tabular-nums;
}
.pager-btn:hover,
.pager-num:hover { border-color: var(--primary); color: var(--text-main); }

.pager-num.is-on {
    border-color: var(--primary-ink);
    /* ♿ solid fill under a white number — must be the ink, not the accent. */
    background: var(--primary-ink);
    color: var(--text-on-primary);
}
/* Rendered, not removed: the row keeps its shape at the ends of the list,
   so «التالي» does not jump sideways when you reach the last page. */
.pager-btn.is-off { opacity: .38; cursor: default; }
.pager-gap { padding: 0 2px; color: var(--text-faint); }

@media (max-width: 560px) {
    /* The numbers are the first thing to go: the arrows and the count are
       what somebody actually uses on a phone. */
    .pager { justify-content: center; }
    .pager-count { flex: 1 1 100%; text-align: center; }
    .pager-nums { display: none; }
    .pager-btn { flex: 1 1 auto; }
}

@media print {
    .pager { display: none !important; }
}

/* A `.text-link` that stands ALONE — not inside a sentence — is a button
   wearing a link's clothes, and it obeys the 44px floor like one. Opt-in
   with `.tap` rather than applied to every `.text-link`, because most of
   them genuinely are inline inside a paragraph, where a 44px line box
   would tear the paragraph apart. */
.text-link.tap {
    display: inline-flex;
    align-items: center;
    min-height: var(--tap);
}

/* ==========================================================================
   💳 The subscription card — top of the profile
   ==========================================================================
   Full width above the settings grid: «فاضلي كام؟» is the question people
   open their profile to answer.
   ========================================================================== */
.ms-card { padding: 0; overflow: hidden; margin-bottom: var(--space-4); }
/* The state is on the border rather than the fill: a card with a coloured
   background reads as an alert every time, including the two months when
   everything is fine. */
.ms-card.is-ok   { border-color: color-mix(in srgb, var(--success) 32%, var(--border)); }
.ms-card.is-warn { border-color: color-mix(in srgb, var(--warning) 45%, var(--border)); }
.ms-card.is-bad  { border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); }

.ms-main {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-4);
    padding: var(--space-4);
}

/* The number, and the number is the point. */
.ms-figure {
    flex: none;
    display: grid;
    place-items: center;
    gap: 2px;
    min-width: 104px;
    padding: var(--space-3) var(--space-4);
    border-radius: var(--radius-md);
    background: var(--surface-2);
    text-align: center;
}
.ms-value {
    font-size: clamp(1.9rem, 6vw, 2.4rem);
    font-weight: var(--weight-black);
    line-height: 1;
    font-variant-numeric: tabular-nums;
    /* An LTR numeral inside an RTL card. */
    direction: ltr;
    unicode-bidi: isolate;
}
.ms-label { color: var(--text-muted); font-size: var(--text-xs); font-weight: var(--weight-bold); }
.ms-card.is-ok   .ms-value { color: var(--success); }
.ms-card.is-warn .ms-value { color: var(--warning); }
.ms-card.is-bad  .ms-value { color: var(--danger); }

/* `min-width: 0` so a long note wraps instead of pushing the button off a
   360px row — the recurring flex trap in this project. */
.ms-body { flex: 1 1 240px; min-width: 0; }
.ms-head { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); }
.ms-head h3 { margin: 0; font-size: var(--text-md); }
.ms-note { margin: 6px 0 0; color: var(--text-muted); font-size: var(--text-sm); line-height: 1.7; }

.ms-bar {
    height: 6px;
    margin: var(--space-3) 0 0;
    border-radius: var(--radius-pill);
    background: var(--surface-2);
    overflow: hidden;
}
.ms-bar > span { display: block; height: 100%; border-radius: var(--radius-pill); background: var(--success); }
.ms-card.is-warn .ms-bar > span { background: var(--warning); }

.ms-facts {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-3) var(--space-5);
    margin: var(--space-3) 0 0;
}
.ms-facts dt { color: var(--text-faint); font-size: var(--text-xs); font-weight: var(--weight-bold); }
.ms-facts dd { margin: 2px 0 0; font-size: var(--text-sm); font-weight: var(--weight-bold); }
.ms-date { font-variant-numeric: tabular-nums; unicode-bidi: isolate; }

.ms-cta { flex: none; }

/* The offer, in its own strip under a divider so it reads as an aside
   rather than as part of the account's status. */
.ms-upgrade {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-3);
    padding: var(--space-3) var(--space-4);
    border-top: 1px solid var(--border);
    background: var(--surface-2);
}
.ms-up-text { flex: 1 1 220px; min-width: 0; display: grid; gap: 3px; }
.ms-up-text strong { font-size: var(--text-sm); color: var(--success); }
.ms-up-text strong [dir="ltr"] { unicode-bidi: isolate; }
.ms-up-text small { color: var(--text-muted); font-size: var(--text-xs); line-height: 1.7; }
/* `.btn-sm` is 36px, which is right inside a dense admin table and wrong
   here: this is the one thing the strip exists to be tapped. */
.ms-upgrade .btn { min-height: var(--tap); }

@media (max-width: 560px) {
    /* The figure goes full width and lies down: a 104px block beside wrapped
       text on a phone leaves both halves cramped. */
    .ms-figure {
        flex: 1 1 100%;
        display: flex;
        align-items: baseline;
        justify-content: center;
        gap: 8px;
        padding: var(--space-3);
    }
    .ms-value { font-size: 2rem; }
    .ms-cta { flex: 1 1 100%; }
    .ms-cta .btn { width: 100%; justify-content: center; }
    .ms-upgrade .btn { flex: 1 1 100%; justify-content: center; }
}

/* 🔔 «كل الإشعارات» — الطريق من الجرس لمركز الإشعارات (ترحيل 044). */
.nudge-all {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 8px;
    padding: 10px;
    margin-block-start: 6px;
    border-block-start: 1px solid var(--glass-border);
    font-size: .85rem;
    font-weight: var(--weight-semibold);
    color: var(--primary-text, var(--primary));
    text-decoration: none;
}
.nudge-all:hover { background: var(--primary-soft); }

/* ==========================================================================
   🧾 THE FIELD & ERROR CONTRACT  (Phase 12)
   --------------------------------------------------------------------------
   Rendered by lw_field() / lw_inline_notice() in app/Helpers/field.php.
   One field language for the whole product: label, native control, hint,
   required mark, error — laid out once, wired for assistive tech once.

   The control itself keeps whatever class the form already gives it
   (`.form-control`, `.mk-input`, `.fin-amount`…): this layer owns the
   wrapper, the label, the hint and the error, and the INVALID state is
   keyed on the `aria-invalid` attribute the helper sets — so any native
   control inherits it without a second class.

   Tokens only. Logical properties only. Spacing from --space-1..4; no
   6/10/14px half-steps (Phase 11 §16).
   ========================================================================== */
.lw-field {
    display: block;
    min-inline-size: 0;
    text-align: start;
}
/* `.lw-field .lw-field-label`, not `.lw-field-label`: an old form keeps
   `form-group` on the wrapper during the pilot, and `.form-group label`
   would otherwise out-rank this rule. */
.lw-field .lw-field-label {
    display: block;
    margin-block-end: var(--space-2);
    font-size: var(--text-sm);
    font-weight: var(--weight-semibold);
    line-height: var(--leading-snug);
    color: var(--text-main);
}
/* ♿ Required is a mark AND `required` on the control — never colour alone:
   the asterisk is a glyph, and it is aria-hidden because the semantics
   already come from the attribute. */
.lw-required {
    color: var(--danger);
    font-weight: var(--weight-bold);
}
.lw-optional {
    margin-inline-start: var(--space-1);
    font-size: var(--text-xs);
    font-weight: var(--weight-medium);
    color: var(--text-faint);
}
.lw-field-hint {
    display: block;
    margin-block-start: var(--space-1);
    font-size: var(--text-xs);
    line-height: var(--leading-normal);
    color: var(--text-faint);
}
/* The error: text first, a glyph for the colour-blind, danger for the rest.
   One step above the hint (--text-sm, never under 12px at any width — the
   xs step dips to 11.6px between 768 and 1120px) because an error is the
   one line under a field that must not be skimmed past. */
.lw-field-error {
    display: flex;
    align-items: flex-start;
    gap: var(--space-1);
    margin: var(--space-1) 0 0;
    font-size: var(--text-sm);
    line-height: var(--leading-normal);
    font-weight: var(--weight-semibold);
    color: var(--danger);
}
/* The glyph: a small circled «!» drawn here rather than an icon font, so it
   exists on the marketing pages (which load none) exactly as in the app. */
.lw-field-error::before {
    content: "!";
    flex: none;
    display: inline-grid;
    place-items: center;
    inline-size: 1.15em;
    block-size: 1.15em;
    margin-block-start: 0.15em;
    border: 1.5px solid currentColor;
    border-radius: var(--radius-pill);
    font-size: 0.85em;
    font-weight: var(--weight-heavy);
    line-height: 1;
}

/* -- Invalid ------------------------------------------------------------
   Keyed on the attribute, so it reaches .form-control, .mk-input, .fin-amount
   and any other native control alike. The halo is the same shape the focus
   halo already has on .form-control, in the danger tint, so the two states
   read as siblings rather than as two designs. */
.lw-field [aria-invalid="true"] {
    border-color: var(--danger);
    box-shadow: 0 0 0 4px var(--danger-soft);
}
/* ⚠️ Invalid must NOT swallow focus. `.form-control:focus` turns the outline
   off and relies on the accent border + halo — which the rule above just
   repainted red. So a focused invalid control gets the keyboard ring back
   (dark-safe --focus-ring, Phase 11) on top of its red border. */
.lw-field [aria-invalid="true"]:focus,
.lw-field [aria-invalid="true"]:focus-visible {
    border-color: var(--danger);
    box-shadow: 0 0 0 4px var(--danger-soft);
    outline: var(--focus-ring);
    outline-offset: 2px;
}
.lw-field.is-invalid .lw-field-label { color: var(--danger); }

/* -- Disabled / read-only ------------------------------------------------ */
.lw-field :disabled,
.lw-field [aria-disabled="true"] { opacity: .55; cursor: not-allowed; }
.lw-field [readonly]:not([type="radio"]):not([type="checkbox"]) {
    background: var(--surface-2);
    color: var(--text-muted);
}

/* ==========================================================================
   📌 THE INLINE NOTICE — a form-level message that stays
   --------------------------------------------------------------------------
   For the errors that belong to no single field, and for the summary of
   the ones that do. The toast may still fire; this is the copy that is
   still there when the toast is gone. One shape, four tones, the tone
   carried by an icon and a word as well as by colour.
   ========================================================================== */
.lw-inline-notice {
    display: flex;
    align-items: flex-start;
    gap: var(--space-3);
    margin-block-end: var(--space-4);
    padding: var(--space-3) var(--space-4);
    border: 1px solid var(--border-strong);
    border-inline-start-width: 3px;
    border-radius: var(--radius-md);
    background: var(--surface);
    font-size: var(--text-sm);
    line-height: var(--leading-normal);
    color: var(--text-main);
    text-align: start;
}
.lw-inline-notice-ico {
    flex: none;
    display: inline-grid;
    place-items: center;
    inline-size: 1.35em;
    block-size: 1.35em;
    margin-block-start: 0.1em;
    border: 1.5px solid currentColor;
    border-radius: var(--radius-pill);
    font-size: var(--text-sm);
    font-weight: var(--weight-heavy);
    line-height: 1;
}
.lw-inline-notice-ico::before { content: "!"; }
.lw-inline-notice.is-info    .lw-inline-notice-ico::before { content: "i"; }
.lw-inline-notice.is-success .lw-inline-notice-ico::before { content: "\2713"; }
.lw-inline-notice-body { min-inline-size: 0; flex: 1; }
.lw-inline-notice-title { margin: 0; font-weight: var(--weight-bold); }
.lw-inline-notice-text  { margin: 0; color: var(--text-muted); }
.lw-inline-notice-title + .lw-inline-notice-text { margin-block-start: var(--space-1); }
.lw-inline-notice-list  {
    margin: var(--space-2) 0 0;
    padding-inline-start: var(--space-4);
    color: var(--text-muted);
}
.lw-inline-notice-list li + li { margin-block-start: var(--space-1); }
.lw-inline-notice-list a { color: var(--primary-text); text-decoration: underline; text-underline-offset: 3px; }
.lw-inline-notice-actions {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-2);
    margin-block-start: var(--space-3);
}
.lw-inline-notice.is-danger  { border-inline-start-color: var(--danger);  background: var(--danger-soft); }
.lw-inline-notice.is-danger  .lw-inline-notice-ico { color: var(--danger); }
.lw-inline-notice.is-warning { border-inline-start-color: var(--warning); background: var(--warning-soft); }
.lw-inline-notice.is-warning .lw-inline-notice-ico { color: var(--warning); }
.lw-inline-notice.is-info    { border-inline-start-color: var(--info);    background: var(--info-soft); }
.lw-inline-notice.is-info    .lw-inline-notice-ico { color: var(--info); }
.lw-inline-notice.is-success { border-inline-start-color: var(--success); background: var(--success-soft); }
.lw-inline-notice.is-success .lw-inline-notice-ico { color: var(--success); }
