/* Fawatery UI theme
   -----------------
   This file is the stable visual contract shared by the application,
   authentication, legal, admin, and marketing pages. Keep design tokens and
   reusable primitives here; keep page layout and feature-specific selectors
   in style.css or marketing.css. See UI_STYLE_GUIDE.md before adding styles. */

/* The two faces, self-hosted. They were a Google Fonts @import at the top
   of style.css — a third-party stylesheet fetched before anything could
   paint, on every cold start, and gone the moment the device was offline.
   Variable woff2, per script, so a page loads only the subsets it uses;
   `swap` so the text is readable before the font lands. The URLs carry
   the ?v= stamp like every other static, and the service worker precaches
   whatever this file names (sw.js: fontRefs). Cairo and Outfit are OFL. */
@font-face {
  font-family: 'Cairo';
  font-style: normal;
  font-weight: 400 900;
  font-display: swap;
  src: url('/static/fonts/cairo-arabic.woff2?v=20260915b') format('woff2');
  unicode-range: U+0600-06FF, U+0750-077F, U+0870-088E, U+0890-0891, U+0897-08E1, U+08E3-08FF, U+200C-200E, U+2010-2011, U+204F, U+2E41, U+FB50-FDFF, U+FE70-FE74, U+FE76-FEFC, U+102E0-102FB, U+10E60-10E7E, U+10EC2-10EC4, U+10EFC-10EFF, U+1EE00-1EE03, U+1EE05-1EE1F, U+1EE21-1EE22, U+1EE24, U+1EE27, U+1EE29-1EE32, U+1EE34-1EE37, U+1EE39, U+1EE3B, U+1EE42, U+1EE47, U+1EE49, U+1EE4B, U+1EE4D-1EE4F, U+1EE51-1EE52, U+1EE54, U+1EE57, U+1EE59, U+1EE5B, U+1EE5D, U+1EE5F, U+1EE61-1EE62, U+1EE64, U+1EE67-1EE6A, U+1EE6C-1EE72, U+1EE74-1EE77, U+1EE79-1EE7C, U+1EE7E, U+1EE80-1EE89, U+1EE8B-1EE9B, U+1EEA1-1EEA3, U+1EEA5-1EEA9, U+1EEAB-1EEBB, U+1EEF0-1EEF1;
}
@font-face {
  font-family: 'Cairo';
  font-style: normal;
  font-weight: 400 900;
  font-display: swap;
  src: url('/static/fonts/cairo-latin-ext.woff2?v=20260915b') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: 'Cairo';
  font-style: normal;
  font-weight: 400 900;
  font-display: swap;
  src: url('/static/fonts/cairo-latin.woff2?v=20260915b') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Outfit';
  font-style: normal;
  font-weight: 300 700;
  font-display: swap;
  src: url('/static/fonts/outfit-latin-ext.woff2?v=20260915b') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: 'Outfit';
  font-style: normal;
  font-weight: 300 700;
  font-display: swap;
  src: url('/static/fonts/outfit-latin.woff2?v=20260915b') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

:root {
  color-scheme: dark;

  /* Typography */
  --font-app: 'Cairo', 'Outfit', sans-serif;
  --font-system: system-ui, -apple-system, 'Segoe UI', Tahoma, sans-serif;
  /* Every figure on a receipt is monospace, and that is structural rather
     than stylistic: equal advances are what make a column of amounts line up
     under each other. The widget states the corollary — the currency rides
     BESIDE the number in the text face, because inside a monospace run a
     currency code opens a full character cell of gap. */
  --font-mono: ui-monospace, 'SF Mono', SFMono-Regular, 'Roboto Mono', Menlo, monospace;
  --font-size-xs: 0.75rem;
  --font-size-sm: 0.85rem;
  --font-size-md: 0.9rem;
  --font-size-base: 1rem;
  --font-size-lg: 1.2rem;
  --font-weight-medium: 500;
  --font-weight-semibold: 600;
  --font-weight-bold: 700;

  /* Palette: use semantic aliases below in component rules. */
  --color-slate-950: #020617;
  --color-slate-900: #0f172a;
  --color-slate-800: #1e293b;
  --color-slate-200: #e2e8f0;
  --color-slate-50: #f8fafc;
  --color-sky-600: #0284c7;
  --color-sky-400: #38bdf8;
  --color-green-400: #4ade80;
  --color-red-400: #f87171;
  --color-amber-400: #fbbf24;
  --color-purple-400: #c084fc;

  /* Fintech Emerald & Sage Tokens (Growth, Security, Saving) */
  --color-emerald-950: #062419;
  --color-emerald-900: #0d4a2e;
  --color-emerald-800: #0f5132;
  --color-emerald-600: #059669;
  --color-emerald-500: #10b981;
  --color-emerald-400: #34d399;
  --color-mint-400: #34d399;
  --color-mint-300: #6ee7b7;
  --color-badge-chip: rgba(12, 58, 92, 0.95);
  --color-featured-soft: rgba(12, 58, 92, 0.25);
  /* 047 D6: the ground under green text that stays emerald-400 in BOTH themes.
     Dark keeps the sage tint it always had; light goes to a solid dark chip,
     because emerald-400 on a light tint is 1.78:1 — the failure this pair
     exists to make unrepresentable. Text on the PAGE ground uses
     --color-accent-on-page instead, which darkens rather than chipping. */
  --color-accent-chip: rgba(56, 189, 248, 0.12);
  --color-accent-chip-border: rgba(56, 189, 248, 0.28);
  /* Ground for the brand mark's chip. Dark in BOTH themes on purpose: the
     mark is a bright sky glyph, and it needs a dark plate to sit on the way
     --color-badge-chip does for the hero badge. 6.47:1 here. */
  --color-brand-chip: #082f49;
  --glow-accent: 0 4px 24px -2px rgba(56, 189, 248, 0.35);
  --glow-brand: 0 4px 20px -2px rgba(56, 189, 248, 0.35);
  /* The hover rung. One glow token per hue collapses :hover onto its own base
     and silently deletes the elevation lift — the shadow must still move. */
  --glow-accent-strong: 0 12px 38px -4px rgba(56, 189, 248, 0.55);
  --glow-brand-strong: 0 8px 28px -2px rgba(56, 189, 248, 0.55);
  /* The raised nav +. Its own pair rather than --glow-brand because that one
     carries a -2px spread, which pulls the halo inside the 54px circle and
     reads as a smaller button; this button is the only element whose glow is
     part of its silhouette rather than an elevation cue. */
  --glow-nav-add: 0 4px 18px rgba(56, 189, 248, 0.45);
  /* .btn-danger:hover's shadow, which was a raw red and so painted the same
     weight on a white page as on a slate one. Mirrors --glow-brand. */
  --glow-danger: 0 4px 12px -2px rgba(248, 113, 113, 0.30);

  /* The elevation banner's ground. Its own pair rather than
     --color-accent-soft, which was once defined TWICE in each theme block —
     beside danger/warning as violet, and again by the marketing palette as
     sky — so last-wins resolved it violet in dark and SKY IN LIGHT, and a
     banner reusing it would have changed hue with the theme. The duplicate
     is gone and that token is now violet in both, but this pair stays its
     own: the banner's ground is not the accent tint's meaning, and merging
     them would make one edit move two unrelated surfaces. */
  --color-elevated-soft: rgba(192, 132, 252, 0.12);
  --color-elevated-border: rgba(192, 132, 252, 0.30);


  /* Semantic colors and surfaces */
  --color-page: var(--color-slate-950);
  /* The card fill was rgba(30, 41, 59, 0.6), which composites to (24, 34, 52)
     over the page gradient's LIGHTEST point — 1.12:1 and ΔL* 5.2 against the
     ground it is meant to sit above, so the 8%-white hairline was carrying the
     whole separation on its own and box-shadow, the other separator, is
     invisible on a near-black page. 047 had already reached this conclusion
     for the marketing card (--color-surface-raised, "nothing on the page read
     as being above anything else") and only marketing got the fix; the app
     cards never moved. Raised to 1.26:1 / ΔL* 9.5 at the page top and 1.37 at
     the bottom — this token composited over the page and nothing else, which
     is what test_the_dark_page_ground_is_not_the_card_colour measures. A card
     that carries its own gradient reads higher on screen (the month-health
     hero measured 1.68 against 1.47); the floor is set on the bare token, so
     no component's own paint can lend it a pass.

     Still translucent, deliberately: the app's frosted-glass look comes from
     backdrop-filter showing through this fill, and 0.85 keeps a trace of it
     while 034's opaque #periodSheet override (a list of choices must not have
     the acts' own figures reading through it) stays the exception it was
     written as. Light redeclares this token, so nothing here reaches it. */
  --color-surface: rgba(33, 46, 70, 0.85);
  --color-surface-solid: var(--color-slate-900);
  --color-surface-input: rgba(15, 23, 42, 0.8);
  /* 058: raised with --color-surface-subtle below, and it has to move with
     it. .quick-chip RESTS on subtle and hovers to this, so lifting the
     resting rung alone leaves 1.02:1 between the two states — a hover that
     does nothing. 1.36:1 against a card now, and 1.16 above the inset,
     both up from 1.20/1.10. */
  --color-surface-hover: rgba(255, 255, 255, 0.10);
  /* 045: an inset one step quieter than --color-surface — an unselected
     row inside an already-translucent card, where --color-surface would
     read as the same plane as its parent. The only handoff colour the
     theme did not already carry.

     058: at 0.03 it did read as the same plane — 1.09:1 against the card
     it sits in, where 1.20 is the floor the card itself had to clear
     against the page. So the ~20 sites that use it (the item rows, the
     insets inside .settings-group, .item-row2-flat, the goal cards) were
     separated by their border alone, or by nothing where they carry none —
     the same defect the card had against the page before it was raised,
     one level further in. 0.055 puts the step at 1.18:1. The nesting is
     page < card < inset < hover and every rung is measured, because the
     one thing that cannot be checked by looking is whether a rung that
     moved still clears the one under it. */
  --color-surface-subtle: rgba(255, 255, 255, 0.055);
  --color-surface-band: rgba(15, 23, 42, 0.6);
  --color-btn-face: var(--color-surface);
  /* The row's own value, NOT --color-surface-solid, which it used to borrow.
     That token is opaque so an object reads on the page AND on a card, and
     its dark value is --color-slate-900 — which is exactly --color-page-top,
     a token that exists to be INVISIBLE against the top of the page
     gradient. So the row inherited a colour chosen to disappear: measured
     1.00:1 where the transactions list begins, falling to 1.13:1 at the
     bottom of the gradient. It has to clear the page at BOTH ends and still
     sit under the card it is also hosted in (Overview recents): 1.09:1 over
     page-top, 1.23:1 over page-bottom, 1.16:1 under the card. The boundary
     itself is carried by .item-row2's border; this only stops the fill from
     colliding outright. */
  --color-surface-row: #161f33;
  --color-stripe-down: rgba(0, 0, 0, 0.45);

  /* 047: an object that must read on the page ground AND on a card. Every
     marketing card family was --color-surface, which is 0.6 alpha over a page
     that is a gradient — so the card's own colour moved with the ground under
     it, and nothing on the page read as being above anything else. Opaque
     removes the dependency: 1.29 against the page's lightest point, with the
     border and --shadow-md doing the rest. Kept just under --color-receipt so
     the ladder stays page < card < paper. */
  --color-surface-raised: #202c47;

  /* 047: an ambient spotlight behind the hero — the soft pool of brand hue
     that gives the headline a ground to sit on instead of a flat sheet.
     A radial-gradient, never filter: blur() — a gradient paints in the same
     layer, and this market runs low-end Android, where 038 deliberately took
     a compositing layer OFF every button. */
  --glow-ambient: rgba(56, 189, 248, 0.13);
  --color-surface-footer: rgba(2, 6, 23, 0.7);
  /* 0.08/0.14 gave a card edge of 1.28:1 against its own fill. On a lighter
     card the same alpha reads weaker still, so both step up together — the
     border is the separator that actually survives on a dark ground. */
  --color-border: rgba(255, 255, 255, 0.12);
  --color-border-strong: rgba(255, 255, 255, 0.20);
  --color-text: var(--color-slate-50);
  --color-text-muted: #a8b5cc;
  --color-brand: var(--color-sky-400);
  --color-brand-hover: #0ea5e9;
  --color-accent-on-page: var(--color-sky-400);
  --color-success: var(--color-green-400);
  --color-danger: var(--color-red-400);
  --color-warning: var(--color-amber-400);
  --color-accent: var(--color-purple-400);
  --color-on-brand: var(--color-slate-900);
  --color-on-strong: var(--color-slate-900);

  /* Third-party sign-in buttons. A distinct family on purpose: rendering
     "Continue with Google" in the brand fill made it indistinguishable from
     the form's own submit, so the two competing actions on an auth page read
     as one. These are Google's own published button colours (light and dark
     variants), and Apple's guidance permits the same neutral treatment — one
     surface for both providers, with each logo carrying the identity, rather
     than two one-off button families. */
  --color-provider-face: #131314;
  --color-provider-face-hover: #1e1f20;
  --color-provider-border: #8e918f;
  --color-provider-text: #e3e3e3;
  --gradient-page: linear-gradient(135deg, var(--color-slate-900) 0%, var(--color-slate-950) 100%);
  --gradient-final: linear-gradient(135deg, rgba(13, 74, 46, 0.35) 0%, rgba(15, 23, 42, 0.7) 100%);
  /* 046: the receipt. Not new colours — these are the shipped Android widget
     palette (mobile/.../res/values/colors.xml, w_receipt / w_ink / w_rule),
     promoted here so the marketing pages and the home-screen widgets are the
     same surface by construction rather than by two people agreeing. 027
     modelled that family on a till receipt because the product is named for
     one; the web is where the metaphor can finally be drawn in full.

     --color-ink is deliberately NOT --color-text: thermal print is never
     pure white, and the half-step down is what stops a dark card reading as
     a UI panel instead of paper.

     The GROUND is the one value that could not be shared. The widget's strip
     is #0e1520 because it lies on a user's wallpaper, which is arbitrary and
     usually lighter; ours lies on our own page, which is #0f172a → #020617,
     and at that range the widget's value is within a couple of percent of the
     background — the torn edge and the perforation both disappeared into it.
     So the ground goes UP rather than down: paper is lighter than the surface
     it lies on. It also has to go further up than an ordinary card does —
     every other card in this app is separated by its border, and a receipt
     has a torn edge instead, so the fill is the only separation it gets. What stays shared is the ink, the rule and the stamp, which
     is what makes the two read as one language. */
  --color-receipt: #1e2b3f;
  --color-ink: #eef3f8;
  --color-rule: #31445a;

  /* Sticky page-top chrome. The page gradient runs slate-900 → slate-950, so
     a bar pinned to the TOP sits where the page is LIGHTEST — painting it
     --color-page (the gradient's darkest end) draws a near-black band across
     the one spot it can never match. 034 hit this on the Insights period bar;
     the sub-page headers hit it again. One token now, so the dashboard navbar
     and every sub-page header are the same surface by construction. */
  --color-surface-nav: rgba(15, 23, 42, 0.75);
  --color-page-top: var(--color-slate-900);

  /* Translucent semantic fills */
  --color-brand-soft: rgba(56, 189, 248, 0.12);
  --color-brand-border: rgba(56, 189, 248, 0.3);
  --color-stamp: #f69910;
  --color-success-soft: rgba(74, 222, 128, 0.15);
  --color-success-border: rgba(74, 222, 128, 0.3);
  --color-danger-soft: rgba(248, 113, 113, 0.15);
  --color-danger-border: rgba(248, 113, 113, 0.3);
  /* Danger text on a danger TINT rather than on the card. --color-danger is
     graded against the card, and sliding its own 15% wash between the two is
     the same double-mute an opacity is: red-400 measures 4.31:1 there, at
     12px/700, which takes no large-text exemption. Thinning the wash DOES
     reach it — 0.12 alpha measures 4.51 and 0.10 measures 4.65 — and that
     is the wrong lever anyway: the tint is what tells a danger badge from
     a muted one at a glance, and buying 0.2 of contrast by nearly halving
     it spends the badge's only other signal. The ink is the value that was
     wrong on this ground, so the ink is what moves; the wash keeps the
     0.15 every other *-soft in the set uses. Light needs no second value
     — #991b1b is already 7.22:1 on its own tint — but declares one so a
     component never has to ask which theme it is in. */
  --color-danger-ink: #fca5a5;
  --color-warning-soft: rgba(251, 191, 36, 0.15);
  --color-warning-border: rgba(251, 191, 36, 0.3);
  --color-accent-soft: rgba(192, 132, 252, 0.12);
  --color-accent-border: rgba(192, 132, 252, 0.3);
  --color-muted-soft: rgba(148, 163, 184, 0.15);
  --color-muted-border: rgba(148, 163, 184, 0.3);

  /* Spacing: the 4px-based scale is the default for new components. */
  --space-0: 0;
  --space-1: 0.25rem;
  --space-2: 0.5rem;
  --space-3: 0.75rem;
  --space-4: 1rem;
  --space-5: 1.25rem;
  --space-6: 1.5rem;
  --space-8: 2rem;
  --space-10: 2.5rem;
  --space-12: 3rem;
  --space-16: 4rem;

  /* Shape */
  --radius-xs: 6px;
  --radius-sm: 8px;
  --radius-md: 10px;
  --radius-lg: 12px;
  --radius-xl: 14px;
  --radius-2xl: 16px;
  --radius-3xl: 20px;
  /* The shape of a top-level content card — the month-health hero, the
     analytics section, an Insights act. Larger than --radius-xl, which is
     for the small stat tiles that sit inside them. */
  --radius-card: 18px;
  --radius-dialog: 20px;
  /* One reading measure for the whole app. The Insights tab has been capped
     at this width since 034 "so the dashboard reads like an app screen
     rather than a stretched table"; the Overview never was, so at desktop
     width its cards stretched to 1400px and a two-column row became a label
     marooned at one margin and a figure at the other. Same reasoning, one
     number, both tabs.

     FLUID, not fixed. A flat 720px was that same reasoning taken one step
     too far: on a 1440px monitor the whole app sat in a phone-width column
     with half the screen empty either side, which reads as a mobile layout
     centred on a desktop rather than as a desktop layout. The marooned-label
     defect is a 1400px problem, not a 960px one — a two-column hero row
     still reads as a pair at 960. So 720 up to a 1161px window (today's
     value, byte for byte, on every laptop that was already at the cap), ~847
     on a 1366 laptop, 960 from 1548 up and never wider. */
  --measure-content: clamp(720px, 62vw, 960px);
  /* The one surface that WANTS width: the purchases table. Eight columns at
     `td { padding: 1rem }` need ~1150px before `.table-wrap`'s overflow-x
     stops firing, so the dashboard container takes this measure while the
     Transactions tab is active (style.css, beside the container rule) and the
     reading measure everywhere else. */
  --measure-wide: 1200px;
  --radius-pill: 999px;

  /* Controls and interaction */
  --control-height-sm: 36px;
  --control-height: 44px;
  --control-height-lg: 48px;
  --focus-ring: 0 0 0 3px rgba(56, 189, 248, 0.2);
  --focus-ring-danger: 0 0 0 3px rgba(248, 113, 113, 0.2);
  --transition-fast: 150ms ease;
  --transition-base: 200ms ease;
  --transition-slow: 300ms ease;

  /* Elevation */
  --shadow-sm: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
  --shadow-md: 0 10px 15px -3px rgba(0, 0, 0, 0.2), 0 4px 6px -2px rgba(0, 0, 0, 0.1);
  --shadow-lg: 0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 10px 10px -5px rgba(0, 0, 0, 0.2);
  --shadow-dialog: 0 25px 50px rgba(0, 0, 0, 0.5);
  --shadow-receipt: 0 16px 30px rgba(2, 6, 23, 0.8);
  --shadow-dock: 0 -8px 24px rgba(2, 6, 23, 0.7);
  --shadow-card: none;

  /* Backward-compatible names. Existing feature styles may use these while
     migration continues, but new styles should use the semantic names. */
  --bg-gradient: var(--gradient-page);
  --bg: var(--color-surface-solid);
  --card-bg: var(--color-surface);
  --border: var(--color-border);
  --text: var(--color-text);
  --muted: var(--color-text-muted);
  --primary: var(--color-brand);
  --primary-hover: var(--color-brand-hover);
  --green: var(--color-success);
  --red: var(--color-danger);
  --amber: var(--color-warning);
  --purple: var(--color-accent);

  /* Popover, scrim, track & skeleton tokens */
  --color-surface-popover: rgba(15, 23, 42, 0.95);
  --color-scrim: rgba(2, 6, 23, 0.75);
  --color-track: rgba(255, 255, 255, 0.09);
  /* A track that is also an AFFORDANCE. --color-track is tuned for a bar the
     eye reads by its fill; the chart carousel's dots are the only thing
     telling you there are more charts, and at 0.09/0.08 they measured 1.18:1
     in light — invisible, against the 3:1 that non-text UI needs. Its own
     token because --color-track backs three meters that should NOT get
     heavier. */
  --color-track-strong: rgba(255, 255, 255, 0.38);
  --spinner-track: rgba(255, 255, 255, 0.25);
  --skeleton-base: rgba(255, 255, 255, 0.05);
  --skeleton-shine: rgba(255, 255, 255, 0.12);
  /* Disabled/de-emphasised text. A COLOUR rather than an opacity: fading text
     toward the page costs far more contrast on a light ground than a dark one
     (muted at 0.55 opacity measures 3.51:1 dark but 2.09:1 light), so the two
     themes have to state the value independently. Sits deliberately dimmer
     than --color-text-muted in both: 4.63:1 here against 9.75:1. */
  --color-text-disabled: #6b7a91;
  --gradient-title: linear-gradient(135deg, #f8fafc 0%, #a8b5cc 100%);

  /* Undefined tokens promoted to root contract (Error 4) */
  --surface-2: rgba(255, 255, 255, 0.035);
  --navbar-bg: #0f172a;
  --imp: #64748b;
  --imp-essential: #22c55e;
  --imp-important: #3b82f6;
  --imp-optional: #f59e0b;
  --imp-luxury: #ef4444;

  /* Category color palette */
  --cat-groceries: #22c55e;
  --cat-household: #3b82f6;
  --cat-rent: #0ea5e9;
  --cat-electronics: #a855f7;
  --cat-clothing: #ec4899;
  --cat-health: #ef4444;
  --cat-transport: #f59e0b;
  --cat-education: #06b6d4;
  --cat-entertainment: #f97316;
  --cat-dining: #eab308;
  --cat-personal-care: #8b5cf6;
  --cat-gifts: #f43f5e;
  --cat-donations: #10b981;
  --cat-utilities: #6366f1;
  --cat-lending: #94a3b8;
  --cat-debt: #f472b6;
  --cat-family-support: #14b8a6;
  --cat-other: #64748b;
}

/* 047/048: Light mode palette (opt-in per-surface override; :root stays dark) --
   The public marketing pages and app support light mode while :root stays dark.
   All semantic tokens re-map here so components compose correctly on a light ground. */
:root[data-theme="light"] {
  color-scheme: light;

  /* Text and surfaces */
  /* 058: the dark card was raised to 1.26:1 against the page's lightest
     point and light was left where it was drawn — #ffffff on #f1f5f9 is
     1.10:1 at the top stop, and the top of the page is where the hero and
     the first card live. The card cannot move (it is #ffffff, and the
     receipt is white paper), so the GROUND does: 1.17 at the top, 1.27 at
     the bottom, which is the ladder dark already has rather than a heavier
     one. --color-page-top must move with the top stop — it exists to be
     invisible against it (see the Insights period bar) — and --color-page
     is the html ground behind the gradient, so it tracks the pair. */
  --color-page: #e6ebf3;
  --gradient-page: linear-gradient(180deg, #e9eef5 0%, #dde5ef 100%);
  --color-surface: #ffffff;
  --color-surface-solid: #ffffff;
  --color-surface-input: #f8fafc;
  --color-surface-nav: rgba(255, 255, 255, 0.85);
  /* 058: the light halves of the two dark rungs. Subtle was 1.10:1 against
     its card; hover had to follow it or .quick-chip's hover would have
     landed LIGHTER than its resting state — an inverted hover, which is
     worse than a weak one. 1.17 and 1.25 against the card, 1.08 between
     them (1.04 before). */
  --color-surface-hover: rgba(15, 23, 42, 0.11);
  --color-surface-subtle: rgba(15, 23, 42, 0.075);
  --color-surface-band: rgba(241, 245, 249, 0.8);
  --color-surface-raised: #ffffff;
  --glow-ambient: rgba(56, 189, 248, 0.16);
  --color-surface-footer: #ffffff;
  --color-border: rgba(15, 23, 42, 0.12);
  --color-border-strong: rgba(15, 23, 42, 0.20);
  --color-text: #0f172a;
  /* Slate-500 (#64748b) is the obvious counterpart to the dark theme's muted
     and it does not survive here: 4.55:1 on the page, but 4.34:1 on an inset
     and 4.4:1 on a card, so the same token passed or failed depending on which
     surface it landed on. This clears 4.5:1 on every ground the app puts muted
     text on, including at the 0.9 opacity the insight glance applies. */
  --color-text-muted: #586576;

  /* Brand, accent & semantic colors. sky-700 rather than sky-600: the CTA
     paints --color-on-brand (#fff) on this fill, and sky-600 is 4.10:1 under
     a 16.8px bold label — short of AA. sky-700 is 5.93:1, and 5.3:1 as a
     body-size link on the page ground. */
  --color-brand: #0369a1;
  --color-brand-hover: #075985;
  --color-brand-soft: rgba(3, 105, 161, 0.08);
  --color-brand-border: rgba(3, 105, 161, 0.25);
  --color-stamp: #a8560a;
  --color-accent-on-page: #0c4a6e;
  --color-on-brand: #ffffff;
  --color-on-strong: #ffffff;

  /* Google's light-theme button spec (#747775 outline on white). */
  --color-provider-face: #ffffff;
  --color-provider-face-hover: #f4f6f8;
  --color-provider-border: #747775;
  --color-provider-text: #1f1f1f;

  /* Semantic status color overrides (Amendment 5: semantic layer override; raw -400 intact) */
  --color-success: #166534;
  --color-danger: #991b1b;
  --color-warning: #854d0e;
  --color-accent: #6b21a8;

  /* Translucent status fills */
  --color-success-soft: rgba(22, 101, 52, 0.08);
  --color-success-border: rgba(22, 101, 52, 0.22);
  --color-danger-soft: rgba(153, 27, 27, 0.08);
  --color-danger-border: rgba(153, 27, 27, 0.22);
  --color-danger-ink: var(--color-danger);
  --color-warning-soft: rgba(133, 77, 14, 0.08);
  --color-warning-border: rgba(133, 77, 14, 0.22);
  --color-accent-soft: rgba(107, 33, 168, 0.08);
  --color-accent-border: rgba(107, 33, 168, 0.22);
  --color-muted-soft: rgba(148, 163, 184, 0.12);
  --color-muted-border: rgba(148, 163, 184, 0.22);

  /* Focus rings */
  --focus-ring: 0 0 0 3px rgba(3, 105, 161, 0.25);
  --focus-ring-danger: 0 0 0 3px rgba(153, 27, 27, 0.25);

  /* Emerald & Sage */
  --color-badge-chip: #0c3a5c;
  --color-featured-soft: rgba(56, 189, 248, 0.08);
  /* Solid, not a tint: sky-400 rides this ground unchanged (5.52:1). */
  --color-accent-chip: #0c3a5c;
  --color-accent-chip-border: var(--color-sky-600);
  /* Solid, not a tint — sky-400 rides this ground unchanged (5.52:1), the
     same trick --color-badge-chip uses on the light theme. */
  --color-brand-chip: #0c3a5c;
  --glow-accent: 0 4px 20px -2px rgba(56, 189, 248, 0.16);
  --glow-brand: 0 4px 20px -2px rgba(3, 105, 161, 0.16);
  --glow-accent-strong: 0 10px 28px -4px rgba(56, 189, 248, 0.28);
  --glow-brand-strong: 0 8px 24px -2px rgba(3, 105, 161, 0.28);
  --glow-nav-add: 0 4px 18px rgba(3, 105, 161, 0.22);
  --glow-danger: 0 4px 12px -2px rgba(153, 27, 27, 0.18);

  --color-elevated-soft: rgba(107, 33, 168, 0.08);
  --color-elevated-border: rgba(107, 33, 168, 0.22);


  /* The receipt: white thermal paper on light slate */
  --color-receipt: #ffffff;
  --color-ink: #0f172a;
  --color-rule: #e2e8f0;

  /* Elevation on light ground */
  --shadow-sm: 0 2px 8px -2px rgba(15, 23, 42, 0.08);
  --shadow-md: 0 4px 16px -2px rgba(15, 23, 42, 0.12);
  --shadow-lg: 0 12px 28px -4px rgba(15, 23, 42, 0.12);
  --shadow-dialog: 0 20px 40px rgba(15, 23, 42, 0.16);
  --shadow-receipt: 0 16px 30px rgba(15, 23, 42, 0.08);
  --shadow-dock: 0 -6px 20px -2px rgba(15, 23, 42, 0.14);
  --gradient-final: linear-gradient(135deg, rgba(236, 253, 245, 0.8) 0%, rgba(241, 245, 249, 0.8) 100%);

  /* Popover, scrim, track & skeleton tokens in light mode */
  --color-surface-popover: #ffffff;
  --color-scrim: rgba(15, 23, 42, 0.4);
  --color-track: rgba(15, 23, 42, 0.08);
  --color-track-strong: rgba(15, 23, 42, 0.50);
  --spinner-track: rgba(15, 23, 42, 0.15);
  --skeleton-base: rgba(15, 23, 42, 0.06);
  --skeleton-shine: rgba(15, 23, 42, 0.12);
  /* 3.25:1, against --color-text-muted's 4.55:1 — dimmer than muted, which is
     the relationship that has to hold, while staying well clear of the 1.68:1
     an opacity of 0.4 produced here. */
  --color-text-disabled: #828c9c;
  --gradient-title: linear-gradient(135deg, #0f172a 0%, #334155 100%);

  /* Undefined tokens in light mode */
  --surface-2: rgba(15, 23, 42, 0.045);
  --navbar-bg: #ffffff;
  --imp: #64748b;

  /* Category colors in light mode (>= 3:1 on light surfaces) */
  --cat-groceries: #15803d;
  --cat-household: #1d4ed8;
  --cat-rent: #0284c7;
  --cat-electronics: #7e22ce;
  --cat-clothing: #be185d;
  --cat-health: #b91c1c;
  --cat-transport: #b45309;
  --cat-education: #0e7490;
  --cat-entertainment: #c2410c;
  --cat-dining: #a16207;
  --cat-personal-care: #6d28d9;
  --cat-gifts: #be123c;
  --cat-donations: #047857;
  --cat-utilities: #4338ca;
  --cat-lending: #475569;
  --cat-debt: #9d174d;
  --cat-family-support: #0f766e;
  --cat-other: #64748b;

  /* Backward-compatible aliases */
  --bg-gradient: var(--gradient-page);
  --bg: var(--color-surface-solid);
  --card-bg: var(--color-surface);
  --border: var(--color-border);
  --text: var(--color-text);
  --muted: var(--color-text-muted);
  --primary: var(--color-brand);
  --primary-hover: var(--color-brand-hover);

  --color-page-top: #e9eef5;
  --shadow-card: var(--shadow-sm);
  --color-btn-face: #eef2f7;
  --color-stripe-down: rgba(15, 23, 42, 0.35);
  --color-surface-row: #f8fafc;
  --imp-essential: #15803d;
  --imp-important: #1d4ed8;
  --imp-optional: #b45309;
  --imp-luxury: #b91c1c;
}

/* `hidden` means NOT RENDERED, and it has to outrank every component ------
   The user agent's `[hidden] { display: none }` lives in the UA origin, which
   any author-origin `display` beats regardless of specificity — so a rule as
   ordinary as `.btn { display: flex }` silently un-hides an element the code
   correctly marked hidden. This has now been patched four times one component
   at a time (`tr` in 014, `.csel-menu`, `.coach-focus-context`, and 034's
   custom-range pickers), and it shipped a FIFTH time as a Delete button
   offered on built-in categories, which cannot be deleted: `settings.js` sets
   `deleteCategoryBtn.hidden = !cat.custom` and the button stayed on screen.
   One author-origin rule ends the class. `!important` is the point rather
   than a shortcut — nothing may out-declare "this element is not rendered",
   so any future component is covered without knowing this rule exists. */
[hidden] { display: none !important; }

/* Buttons -----------------------------------------------------------------
   Base .btn is the neutral/secondary treatment for backward compatibility.
   New call sites should choose an explicit variant whenever meaning matters. */
.btn {
  min-height: var(--control-height);
  padding: 0.65rem 1.2rem;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  background: var(--color-btn-face);
  color: var(--color-text);
  font: inherit;
  font-size: var(--font-size-md);
  font-weight: var(--font-weight-semibold);
  line-height: 1.2;
  text-decoration: none;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.4rem;
  /* No backdrop-filter. It was invisible where it was not harmful — every
     .btn sits on a card that is ALREADY blurred, and the solid variants
     (.btn-primary, .btn-toggle.active) paint an opaque fill straight over
     whatever it produced. What it did buy was a compositing layer on every
     button in the app, and on iOS 26 a paint bug: the label was trimmed
     symmetrically at both ends and a long-press repaint (the selection
     highlight) restored it. Selection changes paint, not layout, so the
     clipping was never a sizing problem — it was this layer. */
  transition: background-color var(--transition-base), border-color var(--transition-base),
    color var(--transition-base), box-shadow var(--transition-base), transform var(--transition-base);
}

/* Neutral, because the preamble above says base .btn IS the neutral
   treatment — and this rule used to contradict it, painting the brand fill,
   the brand border and on-brand text on every button that names no variant.
   All 21 of those are Cancel, Close or Decline, so at the moment the pointer
   landed on Cancel it became pixel-identical to the Save beside it, on the
   change-password, delete-account, unlink and merge-decline sheets. "One
   filled primary per dialog" is the app's own law and hover is part of the
   action. The brand fill belongs to .btn-primary, which has its own rule. */
.btn:hover {
  background: var(--color-surface-hover);
  border-color: var(--color-border-strong);
  color: var(--color-text);
}

.btn:focus-visible {
  outline: 2px solid var(--color-brand);
  outline-offset: 2px;
}

.btn:disabled,
.btn[aria-disabled="true"] {
  opacity: 0.55;
  cursor: not-allowed;
  pointer-events: none;
}

.btn-primary {
  background: var(--color-brand);
  border-color: var(--color-brand);
  color: var(--color-on-brand);
}

.btn-primary:hover {
  background: var(--color-brand-hover);
  border-color: var(--color-brand-hover);
  color: var(--color-on-strong);
}

.btn-secondary {
  background: var(--color-btn-face);
  border-color: var(--color-border);
  color: var(--color-text);
}

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

.btn-danger {
  background: transparent;
  border-color: var(--color-danger-border);
  color: var(--color-danger);
}

.btn-danger:hover {
  background: var(--color-danger);
  border-color: var(--color-danger);
  color: var(--color-on-strong);
}

.btn-sm {
  min-height: var(--control-height-sm);
  padding: 0.45rem 0.8rem;
  border-radius: var(--radius-sm);
  font-size: 0.8rem;
}

.btn-lg { min-height: var(--control-height-lg); padding: 0.85rem 1.5rem; font-size: var(--font-size-base); }
.btn-full { width: 100%; }
.btn-icon { width: var(--control-height); padding: 0; }
.btn-toggle { background: transparent; }
.btn-toggle.active { background: var(--color-accent); border-color: var(--color-accent); color: var(--color-on-strong); }

/* Form controls ---------------------------------------------------------- */
.form-control,
.form-group input,
.modal-input,
.modal select,
.modal-select,
.add-input,
.add-select {
  border: 1px solid var(--color-border-strong);
  border-radius: var(--radius-md);
  background: var(--color-surface-input);
  color: var(--color-text);
  font-family: inherit;
  transition: border-color var(--transition-base), background-color var(--transition-base), box-shadow var(--transition-base);
}

/* .form-control began metrics-free ("each composition supplies its own") and
   that rule kept failing its consumers: admin and settings each re-supplied
   the same three numbers, then the goals page forgot and shipped fields at
   the browser's own 27px/13.3px default — under the 44px tap target every
   other control uses, and under the 16px at which iOS zooms the page on
   focus. Third consumer, third outcome: the metrics live HERE now, and a
   composition that truly needs different ones overrides (admin's search
   field already does). Scoped to .form-control alone — the other selectors
   in the shared block above carry their own metrics in style.css. */
.form-control {
  width: 100%;
  min-height: var(--control-height);
  padding: 0.6rem 0.75rem;
  font-size: 16px;
}

.form-control:focus,
.form-group input:focus,
.modal-input:focus,
.modal select:focus,
.modal-select:focus,
.add-input:focus,
.add-select:focus {
  border-color: var(--color-brand);
  box-shadow: var(--focus-ring);
  outline: none;
}

.form-control[aria-invalid="true"],
.form-control.error,
.form-group input.error {
  border-color: var(--color-danger);
  box-shadow: var(--focus-ring-danger);
}

/* Numeric entry -----------------------------------------------------------
   Amount, price, quantity and count fields are type="text" + inputmode, not
   type="number" — see the note above applyNumericNormalization() in app.js:
   type="number" silently swallows an Arabic-Indic keystroke before any event
   fires, so an Arabic keyboard could not type into this app at all.

   They carry dir="ltr" so a decimal point and a minus sign edit at the end
   the reader expects. That makes `start` resolve LEFT, which would leave the
   one number in an Arabic form against the wrong margin — so alignment is
   restored to the PAGE's start edge here. Scoped to the three shared control
   families deliberately: the quantity stepper and the add sheet's hero price
   are centred by their own compositions and must stay that way. */
[dir="rtl"] .form-control[dir="ltr"],
[dir="rtl"] .modal-input[dir="ltr"],
[dir="rtl"] .add-input[dir="ltr"] { text-align: right; }

input[inputmode="decimal"],
input[inputmode="numeric"],
input[inputmode="tel"] { font-variant-numeric: tabular-nums; }

.field-label {
  display: block;
  margin-bottom: 0.4rem;
  color: var(--color-text-muted);
  font-size: var(--font-size-sm);
  font-weight: var(--font-weight-semibold);
}

.field-help { color: var(--color-text-muted); font-size: var(--font-size-sm); line-height: 1.6; }
.field-group { margin-bottom: var(--space-4); }
.field-group-lg { margin-bottom: var(--space-5); }
.field-label-spaced { margin-top: var(--space-3); }

.modal-copy {
  margin-bottom: var(--space-5);
  color: var(--color-text-muted);
  font-size: var(--font-size-md);
  line-height: 1.6;
}

/* Surfaces and feedback -------------------------------------------------- */
.ui-card {
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-xl);
  padding: var(--space-4);
}

.alert {
  padding: var(--space-4);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-lg);
  color: var(--color-text);
  font-size: var(--font-size-md);
}

.alert-info { background: var(--color-brand-soft); border-color: var(--color-brand-border); }
.alert-success { background: var(--color-success-soft); border-color: var(--color-success-border); color: var(--color-success); }
.alert-warning { background: var(--color-warning-soft); border-color: var(--color-warning-border); color: var(--color-warning); }
.alert-danger { background: var(--color-danger-soft); border-color: var(--color-danger-border); }

.badge {
  display: inline-flex;
  align-items: center;
  min-height: 24px;
  padding: 0.3rem 0.6rem;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-xs);
  font-size: var(--font-size-xs);
  font-weight: var(--font-weight-bold);
  line-height: 1;
}

[dir="ltr"] .badge {
  letter-spacing: 0.5px;
}

.badge-success { background: var(--color-success-soft); border-color: var(--color-success-border); color: var(--color-success); }
.badge-info { background: var(--color-brand-soft); border-color: var(--color-brand-border); color: var(--color-brand); }
.badge-warning { background: var(--color-warning-soft); border-color: var(--color-warning-border); color: var(--color-warning); }
.badge-danger { background: var(--color-danger-soft); border-color: var(--color-danger-border); color: var(--color-danger-ink); }
.badge-muted { background: var(--color-muted-soft); border-color: var(--color-muted-border); color: var(--color-text-muted); }

/* Small, intentional layout helpers. Visibility remains owned by JS and the
   HTML hidden attribute; do not use utility classes as application state. */
.cluster { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-3); }
.stack { display: flex; flex-direction: column; gap: var(--space-4); }
.text-muted { color: var(--color-text-muted); }
.text-danger { color: var(--color-danger); }
.text-center { text-align: center; }

/* Page entry — the app's one navigation motion ---------------------------
   Sub-pages open from the service worker's cache, so the shell paints in a
   frame or two. That is fast enough to read as a hard cut: the screen simply
   swaps. These give the arrival a direction — content rises into place while
   the chrome fades, which is the same upward idiom the bottom sheets have
   used since 009, so a page and a sheet feel like the same product. */
@keyframes fw-enter-rise {
  from { opacity: 0; transform: translateY(10px); }
  to { opacity: 1; transform: none; }
}
@keyframes fw-enter-fade {
  from { opacity: 0; }
  to { opacity: 1; }
}

/* Meter fill — a bar draws to its value on the FIRST paint of a screen.
   scaleX rather than width so the compositor carries the 900ms on the
   low-end Android this market runs; the delay lets the block the bar sits
   in finish rising first. body.fw-settled (app.js) switches it off once the
   first load has settled: a refresh is a diff (061), and a bar collapsing
   to zero and refilling on every purchase would be a repaint. There is no
   logical keyword for transform-origin, so RTL is overridden the way
   marketing.css already does for its own meter. */
@keyframes fw-meter-fill { from { transform: scaleX(0); } }
.fw-meter-fill {
  animation: fw-meter-fill 900ms cubic-bezier(0.22, 0.61, 0.36, 1) 250ms both;
  transform-origin: left;
}
[dir="rtl"] .fw-meter-fill { transform-origin: right; }
.fw-settled .fw-meter-fill { animation: none; }

@media (prefers-reduced-motion: reduce) {
  .btn { transition-duration: 0.01ms; }
  /* `both` fill means these hold their FROM state; a disabled animation must
     not leave the page at opacity 0 (or a meter at scaleX(0) for the length
     of its delay). Zeroing the duration AND the delay keeps the element
     rendered and skips the motion. */
  [class*="fw-enter"],
  .fw-meter-fill,
  .settings-header,
  .settings-page > * { animation-duration: 0.01ms !important; animation-delay: 0ms !important; }
}

/* 049 — the busy state of any button or row action.
 *
 * It is a CLASS and a pseudo-element rather than written markup because the
 * bilingual pattern makes the label untouchable: a template button is an .ar
 * span and an .en span that setLang() toggles, and any innerHTML/textContent
 * write collapses both into one string in one language, permanently. Drawing
 * the spinner from outside the content model is what lets the label survive.
 *
 * `currentColor` on three sides and a transparent fourth, so it reads on the
 * primary, danger and secondary fills without a per-variant rule. Reduced
 * motion slows it rather than stopping it — a still ring is not a spinner, it
 * is a decoration nobody can tell is running. */
.is-busy { pointer-events: none; }

.is-busy::before {
  content: "";
  display: inline-block;
  width: 0.9em;
  height: 0.9em;
  margin-inline-end: 0.5em;
  vertical-align: -0.1em;
  border: 2px solid currentColor;
  border-top-color: transparent;
  border-radius: 50%;
  animation: fw-busy-spin 0.7s linear infinite;
  flex: none;
}

@keyframes fw-busy-spin { to { transform: rotate(360deg); } }

@media (prefers-reduced-motion: reduce) {
  .is-busy::before { animation-duration: 2.4s; }
}


/* ═══════════════════════════════════════════════════════════════════════════
   THE APP FAMILY'S SHARED BASE — scoped to html.fw-app
   ─────────────────────────────────────────────────────────────────────────
   Rules the application pages (style.css) and the session-less public pages
   (public.css) BOTH need: the reset, the html/body base, the app-only :root
   variables, the toast, the status line, the skip link, the touch/hover
   feedback for buttons. They lived at the top of style.css; public.css split
   off that sheet and would otherwise have had to copy them.

   Scoped with :where(.fw-app), never a bare class, on purpose: :where() adds
   ZERO specificity, so every rule keeps exactly the weight it had in
   style.css and the cascade against both sheets is unchanged — while the
   marketing pages, which load theme.css but carry no fw-app class on <html>,
   are untouched by a reset and a body ground that were never theirs. An app
   or public document declares itself with class="fw-app" on <html>; a
   template that forgets loses this whole block, which the suite pins.
   ═══════════════════════════════════════════════════════════════════════════ */

/* ─── Shared application styles (all non-marketing pages) ─────────
   Design tokens and reusable UI primitives live in theme.css. This file is
   for application/page layout and feature-specific composition. */
:root:where(.fw-app) {
  /* Fluid child-row hierarchy for purchase groups. Logical spacing below
     makes the same value work from the right in RTL and the left in LTR. */
  --purchase-child-indent: clamp(0.625rem, 2vw, 1.5rem);

  /* The top bar's own height, without the notch inset it adds on top. It was
     a 60px literal repeated in four places — the bar itself, the two sticky
     date-group headers that must sit flush under it, and the pull-refresh
     indicator that emerges from beneath it — so trimming the bar left three
     of them anchored to a bar that had moved.

     48px matches the bottom nav's own total, which is what makes the two
     bars read as one system: the top holds a 36px logo and the bottom a
     44px button, so both sit on ~6px of air rather than the top carrying 8
     and the bottom 4.5. Move this and the bottom bar's min-height together
     or the asymmetry comes straight back. */
  --nav-height: 48px;
}

:where(.fw-app), :where(.fw-app) * { margin: 0; padding: 0; box-sizing: border-box; }

/* Mobile-first: default base font-size 16px, prevents iOS zoom on input focus */
html:where(.fw-app) {
  font-size: 16px;
  -webkit-text-size-adjust: 100%;
  text-size-adjust: 100%;
  scroll-behavior: smooth;
  /* clip (not hidden): hidden still allows scrollLeft to be set
     programmatically (e.g. by scrollIntoView bubbling up from a nested
     scroller); clip makes the axis truly unscrollable by any means. */
  overflow-x: clip;
  /* 067: pan-x pan-y, not manipulation. `manipulation` is pan-x pan-y
     PLUS pinch-zoom, so it only ever removed double-tap zoom; the app's
     pages are not zoomable now (the owner's decision, reversing 001's
     FR-014), and pinch is the half this rule had left open on Chromium.
     WebKit ignores touch-action for pinch, which is what the viewport
     meta on the data-page templates and the gesturestart guard in app.js
     are for. The receipt viewer draws its own zoom (attachReceiptZoom). */
  touch-action: pan-x pan-y;
  background: var(--color-page);
  /* Must live on the same element as overflow-x above, not on body: Chrome
     fails to propagate body's overflow to the viewport once html has a
     non-default overflow-x, and if overscroll-behavior is non-'auto' on
     body in that state, wheel/touch scrolling breaks completely (page
     becomes entirely unscrollable, both by trackpad and touch). */
  overscroll-behavior-y: none;
  overscroll-behavior-x: none;
}

:where(.fw-app) body {
  font-family: var(--font-app);
  background: var(--bg-gradient);
  color: var(--text);
  min-height: 100vh;
  min-height: 100dvh;
  -webkit-tap-highlight-color: transparent;
  -webkit-font-smoothing: antialiased;
  /* clip, never hidden: `hidden` makes the body a scroll container, and a
     sticky element sticks to its nearest SCROLLING ancestor — so .navbar and
     .settings-header, both `position: sticky`, scrolled away with the page
     (measured: -390px after a 390px scroll, on sub-pages that carry no
     bottom nav, leaving no way back on screen). html already clips this
     axis above, for the same reason its comment gives. */
  overflow-x: clip;

  /* NO padding-top. The inset belongs to whichever element paints the top of
     the page, not to the body — because .navbar and .settings-header are
     `position: sticky; top: 0`, and a sticky element cannot rise above its
     containing block. Body padding moves that block down by the notch, so the
     header parked 47px from the top of an iPhone with the page gradient
     painted above it, at every scroll position, on every page. Measured on
     /settings: content began at 175px where the header's own inset already
     put it at 128.

     The bottom inset stays: nothing sticks to the bottom of the body, so it
     costs no band, and it keeps content off the home indicator on surfaces
     with no bottom nav of their own. */
  padding: 0 env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
}

/* ─── A long press on an app surface is a press, not a selection ─────────
   iOS painted the selection highlight and raised the callout/magnifier over
   a control the finger was still resting on, and Android offered "Save
   image" on a spending chart — neither of which any native app does, and
   the loudest remaining "this is a web page" tell in the packaged APK. The
   .bottom-nav lock at the foot of this file recorded the finding this
   generalises: iOS ignores the unprefixed `user-select`, so -webkit- is the
   declaration doing the work here. That rule is left in place rather than
   folded in — deleting it would mean re-proving .bottom-nav never renders
   outside a body[data-page], for no benefit.

   Keyed on body[data-page], NOT on body.login-page: terms, privacy and
   delete_account carry that class too, and they are documents holding the
   support address a user must be able to copy. admin.html carries a bare
   <body> for the same reason, so #errTraceback and #errPayload stay
   copyable with no exemption rule to maintain.

   No touch-action in this block, deliberately. Four places own that
   property at their own level — .swipe-content (pan-y), .csel-opt
   (manipulation), .bottom-nav-add (none, which is what makes the voice
   hold hold) and the @media (hover: none) block — and html{touch-action:
   pan-x pan-y} above already kills double-tap and pinch zoom on every
   page. A blanket here would outrank whichever of the four lost the
   cascade, and the loss is a dead gesture, not a visible defect. */
:where(.fw-app) body[data-page], :where(.fw-app) body[data-page] * {
  -webkit-user-select: none;
  -moz-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
}

/* Fields are the exception and it has to be stated: `none` inherited into an
   input takes the caret, the double-tap word select and — the one that
   actually strands a user — the Paste callout, which is how a long password
   reaches these forms at all. `text`, never `auto`: per CSS-UI-4 `auto` on a
   descendant of a `none` ancestor COMPUTES to none, so the obvious-looking
   exemption is a no-op that reads correct in the diff and ships broken.

   .selectable is the opt-out for anything later that turns out to be prose
   living on an app page — a reference number, an error code. Nothing uses it
   yet; it exists so the answer to "this needs to be copyable" is one class
   rather than another edit to this block. */
:where(.fw-app) body[data-page] input, :where(.fw-app) body[data-page] textarea, :where(.fw-app) body[data-page] select, :where(.fw-app) body[data-page] .selectable, :where(.fw-app) body[data-page] .selectable * {
  -webkit-user-select: text;
  -moz-user-select: text;
  user-select: text;
  -webkit-touch-callout: default;
}

@keyframes fadeInScale {
  from { opacity: 0; transform: scale(0.95) translateY(10px); }
  to { opacity: 1; transform: scale(1) translateY(0); }
}

@keyframes slideInRight {
  from { opacity: 0; transform: translateX(100%); }
  to { opacity: 1; transform: translateX(0); }
}

@keyframes slideInLeft {
  from { opacity: 0; transform: translateX(-100%); }
  to { opacity: 1; transform: translateX(0); }
}

/* Toast Notifications */
:where(.fw-app) .toast-container {
  position: fixed;
  bottom: 24px;
  left: 24px;
  z-index: 10000;
  display: flex;
  flex-direction: column;
  gap: 10px;
}

:where(.fw-app)[dir="rtl"] .toast-container, :where(.fw-app) [dir="rtl"] .toast-container {
  left: auto;
  right: 24px;
}

:where(.fw-app) .toast {
  /* NOT a raw slate: --color-text flips with the theme and this plate did
     not, so in light mode every toast in the app was near-black text in a
     near-black box — 1.42:1, measured. This token is the floating-surface
     one, opaque white in light and the same 0.95 slate in dark, so the pair
     now moves together: 17.85:1 light, 17.06:1 dark. */
  background: var(--color-surface-popover);
  backdrop-filter: blur(16px);
  border: 1px solid var(--border);
  color: var(--text);
  padding: 12px 20px;
  border-radius: 12px;
  box-shadow: var(--shadow-lg);
  font-size: 0.95rem;
  font-weight: 600;
  display: flex;
  align-items: center;
  gap: 10px;
  transition: all 0.3s ease;
  animation: slideInRight 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}

:where(.fw-app)[dir="rtl"] .toast, :where(.fw-app) [dir="rtl"] .toast {
  animation: slideInLeft 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}

:where(.fw-app) .toast.hide {
  opacity: 0;
  transform: translateY(20px);
}

:where(.fw-app) .toast-success { border-left: 4px solid var(--green); }

:where(.fw-app) .toast-error { border-left: 4px solid var(--red); }

:where(.fw-app) .toast-info { border-left: 4px solid var(--primary); }

:where(.fw-app)[dir="rtl"] .toast-success, :where(.fw-app) [dir="rtl"] .toast-success { border-left: none; border-right: 4px solid var(--green); }

:where(.fw-app)[dir="rtl"] .toast-error, :where(.fw-app) [dir="rtl"] .toast-error { border-left: none; border-right: 4px solid var(--red); }

:where(.fw-app)[dir="rtl"] .toast-info, :where(.fw-app) [dir="rtl"] .toast-info { border-left: none; border-right: 4px solid var(--primary); }

:where(.fw-app) .note { font-size: 0.85rem; color: var(--muted); margin-top: 1.5rem; line-height: 1.6; }

/* 44px, because these are not incidental links in prose: «نسيت كلمة المرور؟»
   and «العودة لتسجيل الدخول» are the only recovery route on the page and sit
   alone on their own line. Measured 26px tall. inline-flex keeps them inline
   inside the sentence forms (/signup's «لديك حساب بالفعل؟ …») while still
   carrying the target; the legal pages' links are genuinely mid-sentence and
   are left alone, which is the exception WCAG 2.5.8 names. */
:where(.fw-app) .note a {
  color: var(--primary); font-weight: 600; text-decoration: none; transition: color 0.3s;
  display: inline-flex; align-items: center; min-height: var(--control-height);
}

:where(.fw-app) .note a:hover { text-decoration: underline; }

:where(.fw-app) .status { padding: 1rem; border-radius: 12px; margin-top: 1.25rem; font-size: 0.9rem; display: none; font-weight: 600; animation: fadeInScale 0.3s; }

:where(.fw-app) .status.show { display: block; }

:where(.fw-app) .status.err { background: var(--color-danger-soft); border: 1px solid var(--red); color: var(--red); }

:where(.fw-app) .modal-actions { display: flex; gap: 0.75rem; justify-content: flex-end; margin-top: 1.25rem; }

:where(.fw-app) .ac-item {
  padding: 0.5rem 0.8rem;
  cursor: pointer;
  display: flex;
  flex-direction: column;
  gap: 0.15rem;
  border-radius: 7px;
  transition: background 0.1s;
}

:where(.fw-app) .ac-item:hover, :where(.fw-app) .ac-item.active { background: var(--color-brand-soft); }

:where(.fw-app) .btn-full { padding: .85rem; margin-top: .5rem; text-align: center; }

@media (hover: hover) {
/* Geometry, not hue: a brand halo makes the same claim the brand fill
     did, on the same neutral Cancel buttons.

     The primary's glow has to be restated HERE rather than left to the base
     rule, and that is the whole point: .btn-primary declares no box-shadow
     anywhere in either stylesheet, so this rule was the only one it ever
     had. Neutralising it without the line below left the app's one filled
     action hovering to the same elevation as the Cancel beside it, and left
     --glow-brand-strong used by nothing but .fab. .premium-cta was the
     visible case — it carries --glow-brand at rest under a (0,1,0)
     selector, so a (0,2,0) .btn:hover made hovering the paywall's CTA
     REDUCE its shadow. Strong, not --glow-brand: the base is the resting
     glow, and a :hover naming the same one silently deletes the lift. */
:where(.fw-app) .btn:hover { transform: translateY(-2px); box-shadow: var(--shadow-sm); }

:where(.fw-app) .btn-primary:hover { box-shadow: var(--glow-brand-strong); }

}

@media (hover: none) {
/* Remove hover transforms on mobile */
:where(.fw-app) .card:hover, :where(.fw-app) .btn:hover, :where(.fw-app) .fab:hover, :where(.fw-app) .btn-danger:hover {
    transform: none !important;
    box-shadow: none !important;
  }

/* Active (tap) states.

     .item-line[role="button"] joins the list because it is a <div>, so it
     fell outside the button/a blanket and was the one row in the app that
     answered a press with nothing at all. The attribute is the semantic
     discriminator — the inert twin is .item-line.item-line-static, whose
     feedback comes from the stretched .row-open <button> over it — and
     :not(.item-line-static) would say the same thing by negation and start
     matching a third variant the day one appears.

     It goes on .item-line and NOT on its .swipe-content parent, which is
     measurable rather than a preference: --color-surface-hover is
     translucent (rgba(255,255,255,.06) dark, rgba(15,23,42,.065) light)
     while .swipe-content's background is opaque ON PURPOSE — it exists to
     hide .swipe-delete-bg, the red bar behind it — so a background-color
     there replaces that fill and bleeds red through every pressed row. A
     transform would compose against the translateX() the drag writes
     inline, and this transition would be dead anyway: the swipe's onStart
     writes style.transition='none' inline, which no rule here outranks. */
:where(.fw-app) .card:active, :where(.fw-app) .btn:active, :where(.fw-app) .nav-mobile-item:active, :where(.fw-app) .nav-tab:active, :where(.fw-app) .expand-row:active, :where(.fw-app) .month-nav button:active, :where(.fw-app) .nav-dropdown-item:active, :where(.fw-app) button:active, :where(.fw-app) a:active, :where(.fw-app) .item-line[role="button"]:active {
    background-color: var(--color-surface-hover) !important;
    transition: background 0.1s !important;
  }

/* Interactive touch areas */
:where(.fw-app) button, :where(.fw-app) a, :where(.fw-app) .nav-mobile-item, :where(.fw-app) .btn {
    touch-action: manipulation;
  }

/* Ensure min touch target height */
:where(.fw-app) .nav-mobile-item, :where(.fw-app) .btn, :where(.fw-app) .expand-row {
    min-height: 48px;
  }

}

@media (max-width: 768px) {
/* 5. BUTTON TAP ANIMATION */
:where(.fw-app) .btn, :where(.fw-app) .fab, :where(.fw-app) .nav-mobile-item, :where(.fw-app) button {
    transition: transform 150ms ease, box-shadow 150ms ease !important;
  }

:where(.fw-app) .btn:active, :where(.fw-app) .fab:active, :where(.fw-app) button:active {
    transform: scale(0.95) !important;
    transition: transform 150ms cubic-bezier(0.34, 1.56, 0.64, 1) !important;
  }

:where(.fw-app) .btn-primary:active, :where(.fw-app) .fab:active {
    box-shadow: var(--glow-brand-strong) !important;
  }

/* 11. TOAST ANIMATION */
:where(.fw-app) .toast-container {
    bottom: 24px;
    top: auto;
  }

@keyframes slideUpToast {
    from { transform: translateY(100%); opacity: 0; }
    to { transform: translateY(0); opacity: 1; }
  }

:where(.fw-app) .toast {
    animation: slideUpToast 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards !important;
  }

:where(.fw-app) .toast.hide {
    opacity: 0;
    transform: translateY(20px) !important;
  }

}

/* 7. NAVBAR BACKDROP (can be outside media query or inside) */
:where(.fw-app) body::after {
  content: '';
  position: fixed;
  inset: 0;
  background: var(--color-scrim);
  opacity: 0;
  pointer-events: none;
  transition: opacity 300ms;
  z-index: 150;
}

/* 049: `left: 0` and a physical corner radius are wrong in the language this
   app defaults to. Measured on all nine pages that carry the link: focused in
   Arabic it landed 147.9px from where the reading eye starts, at the far end
   of the viewport from the very first thing a keyboard user meets — and its
   one rounded corner pointed away from the page. Logical properties put it at
   the start edge in both directions with no second rule. */
:where(.fw-app) .skip-link {
  position: absolute;
  top: -40px;
  inset-inline-start: 0;
  /* Measured 42px focused — two short of the touch minimum. */
  display: inline-flex;
  align-items: center;
  min-height: var(--control-height);
  background: var(--color-surface-solid);
  color: var(--color-text);
  padding: 8px 16px;
  z-index: 10000;
  text-decoration: none;
  border-end-end-radius: 6px;
  font-size: 0.875rem;
}

:where(.fw-app) .skip-link:focus {
  top: 0;
}

/* Focus-visible styles for keyboard navigation.
   017 US4 (QW8, research D8): #3b82f6 was 4.85:1 contrast; var(--primary)
   (#38bdf8) is 8.33:1 — same color already used for every substitute this
   sweep left in place, so the ring reads as one consistent system. */
:where(.fw-app):focus-visible, :where(.fw-app) *:focus-visible {
  outline: 2px solid var(--primary);
  outline-offset: 2px;
}

@media (max-width: 768px) {
/* Toasts (incl. the undo toast) stack above the nav */
:where(.fw-app) .toast-container { bottom: calc(64px + env(safe-area-inset-bottom)); }

}

/* Undo action button inside toasts (showToast {actionLabel}) */
:where(.fw-app) .toast-action {
  margin-inline-start: 0.5rem;
  padding: 0.3rem 0.9rem;
  min-height: 44px;
  flex-shrink: 0;
  border-radius: 8px;
  border: 1px solid var(--color-brand-border);
  background: var(--color-brand-soft);
  color: var(--primary);
  font-weight: 700; font-size: 0.85rem; font-family: inherit;
  cursor: pointer;
}

:where(.fw-app) .toast-action:hover, :where(.fw-app) .toast-action:focus-visible { background: var(--color-brand-soft); }

