/* ============================================================================
   Carpe Money — shared brand layer  (design-system foundation)
   ----------------------------------------------------------------------------
   Ported from the Carpe Money Design System handoff (claude.ai/design). Loaded
   LAST in each template's <head> (after the page's own inline <style>) so these
   tokens, the parchment field, base type, and the component classes win over the
   older inline rules without each template having to delete them first.

   Layers, top to bottom:
     1. Colour tokens        — base palette (light + dark) + semantic aliases
     2. Typography tokens     — one family (Plus Jakarta Sans), scale + tracking
     3. Spacing / shape       — spacing, radii, borders, 3-step shadows, motion
     4. Base field + lockup   — the aged-scroll parchment, brand wordmark, label
     5. Components (.cm-*)     — opt-in: card, button, input, badge, meter, …
     6. Legacy retrofit       — keeps NOT-yet-migrated screens looking right

   Palette is "Tyrian-forward": Tyrian purple is the brand; emerald is money/
   positive; laurel green is the heritage accent; on an aged-parchment field.
   Light parchment is the DEFAULT; dark espresso via :root[data-theme="dark"].
   ========================================================================== */

/* ── 1 · Colour — base palette ───────────────────────────────────────────── */
:root,
:root[data-theme="light"] {
  --bg:        #fbf6ea;   /* aged parchment            */
  --surface:   #fdfcf4;   /* card (warm ivory)         */
  --surface2:  #f6efde;   /* inputs / secondary fill   */
  --accent:    #6e1f5a;   /* Tyrian purple (brand)     */
  --accent2:   #581548;   /* darker Tyrian (hover)     */
  --glow:      #9c4783;   /* lighter Tyrian (focus)    */
  --good:      #2f7d52;   /* emerald (positive/money)  */
  --laurel:      #557a36; /* laurel green (heritage accent) */
  --laurel-deep: #3f5c28; /* deep laurel (text on tint)     */
  --text:      #382616;   /* ink                       */
  --muted:     #6f5a45;   /* secondary ink (Tyrian-tinted readability) */
  --danger:    #b23a2b;   /* Pompeian red              */
  --auburn:    #8a4130;   /* burnt auburn — browner than Pompeian --danger, so the bar reads distinct from the figure */
  --amber:     #a67c3c;   /* gold                      */
  /* A CAUTION colour rated for BODY TEXT, which --amber is not: #a67c3c on the parchment ground is
     3.66:1, under AA, and the one line it carries is a "this total is incomplete" caveat that must
     not be the thing a member misses. Auburn measures 7.09:1 here. The two swap by theme — auburn
     is only 4.64:1 on the dark surface where amber reaches 6.94 — so this cannot be a single
     colour, which is why it is its own token rather than a re-use. [js-review] */
  --warn-text: #8a4130;
  --tex-ink:   #6f3a20;   /* parchment grain ink       */
  --frame:     rgba(166,124,60,0.50);   /* gold frame line */
  --border:    rgba(110,31,90,0.22);    /* Tyrian-tinted hairline */
  color-scheme: light;
}

:root[data-theme="dark"] {
  --bg:        #1c1410;   /* deep espresso parchment   */
  --surface:   #261a13;
  --surface2:  #32231a;
  --accent:    #c071a8;   /* lifted Tyrian             */
  --accent2:   #9c4783;
  --glow:      #d98fc2;
  --good:      #4faf78;   /* lifted emerald            */
  --laurel:      #9cbb6a; /* lifted laurel green       */
  --laurel-deep: #b9d089;
  --text:      #ecdcc4;   /* warm ivory ink            */
  --muted:     #bda286;
  --danger:    #df5a44;
  --auburn:    #c26a52;   /* lifted for dark */
  --amber:     #c89a4e;
  --warn-text: #c89a4e;   /* amber, not auburn: 6.94:1 here vs auburn's 4.64 — see the light block */
  --tex-ink:   #d9a877;
  --frame:     rgba(196,150,74,0.42);
  --border:    rgba(192,113,168,0.26);
  color-scheme: dark;
}

/* ── 1 · Colour — semantic aliases (theme-agnostic role names) ────────────── */
:root {
  /* Surfaces & field */
  --surface-page:    var(--bg);
  --surface-card:    var(--surface);
  --surface-sunken:  var(--surface2);

  /* Text roles */
  --text-body:       var(--text);
  --text-muted:      var(--muted);
  --text-on-accent:  #f6eef2;        /* ivory on Tyrian fills */
  --text-heading:    var(--accent);
  --text-label:      var(--accent);

  /* Brand roles */
  --brand:           var(--accent);
  --brand-hover:     var(--accent2);
  --brand-focus:     var(--glow);
  --brand-laurel:    var(--laurel);
  --creed:           var(--danger);

  /* Money / status roles */
  --money-positive:  var(--good);
  --money-negative:  var(--danger);
  --status-warning:  var(--amber);
  --status-info:     var(--accent);

  /* Lines & focus */
  --border-hairline: var(--border);
  --border-strong:   color-mix(in srgb, var(--accent) 40%, var(--border));
  --ring-focus:      color-mix(in srgb, var(--glow) 65%, transparent);
  --frame-gold:      var(--frame);

  /* ── 2 · Typography ────────────────────────────────────────────────────── */
  /* The trailing emoji families are a FALLBACK TAIL, not a style choice: they are only ever
     consulted for codepoints Plus Jakarta Sans has no glyph for. Members do put emoji in rule and
     category names, and without an explicit tail the glyph came from whatever the host's font
     config happened to pick — a colour emoji on iOS, a monochrome outline on a stock Linux box
     (which is how QA saw it as a "mono glyph"). Naming them makes it deterministic everywhere
     instead of a property of the device. [#97] */
  --font-sans:    'Plus Jakarta Sans', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif,
                  'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji';
  --font-display: 'Plus Jakarta Sans', system-ui, sans-serif,
                  'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji';
  --font-mono:    'Plus Jakarta Sans', ui-monospace, 'SF Mono', Menlo, monospace,
                  'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji';

  --weight-regular: 400;
  --weight-medium: 500;
  --weight-semibold: 600;
  --weight-bold: 700;
  --weight-black: 800;

  --text-2xs: 0.6875rem;   /* 11px */
  --text-xs:  0.75rem;     /* 12px */
  --text-sm:  0.875rem;    /* 14px */
  --text-base: 1rem;       /* 16px */
  --text-md:  1.125rem;    /* 18px */
  --text-lg:  1.375rem;    /* 22px */
  --text-xl:  1.75rem;     /* 28px */
  --text-2xl: 2.25rem;     /* 36px */
  --text-3xl: 3rem;        /* 48px */
  --text-4xl: 4rem;        /* 64px */

  --leading-tight: 1.1;
  --leading-snug: 1.3;
  --leading-normal: 1.55;

  --tracking-tight: -0.02em;
  --tracking-normal: 0;
  --tracking-label: 0.16em;
  --tracking-word: 0.14em;
  --tracking-creed: 0.3em;

  /* ── 3 · Spacing / radii / borders / shadows / motion / layout ─────────── */
  --space-0: 0;
  --space-1: 0.25rem;   /* 4px  */
  --space-2: 0.5rem;    /* 8px  */
  --space-3: 0.75rem;   /* 12px */
  --space-4: 1rem;      /* 16px */
  --space-5: 1.5rem;    /* 24px */
  --space-6: 2rem;      /* 32px */
  --space-7: 3rem;      /* 48px */
  --space-8: 4rem;      /* 64px */

  --radius-xs: 6px;
  --radius-sm: 10px;
  --radius-md: 14px;
  --radius-lg: 20px;
  --radius-pill: 999px;

  --border-width: 1px;
  --border-accent-width: 3px;
  --hairline: var(--border-width) solid var(--border-hairline);

  --shadow-card:  0 1px 2px color-mix(in srgb, var(--tex-ink) 18%, transparent),
                  0 6px 20px color-mix(in srgb, var(--accent) 12%, transparent);
  --shadow-raised: 0 2px 4px color-mix(in srgb, var(--tex-ink) 18%, transparent),
                   0 12px 32px color-mix(in srgb, var(--accent) 18%, transparent);
  --shadow-pop:   0 18px 48px color-mix(in srgb, var(--accent) 24%, transparent);
  --shadow-inset: inset 0 1px 0 color-mix(in srgb, #ffffff 40%, transparent);

  --ring-width: 3px;
  --ring: 0 0 0 var(--ring-width) var(--ring-focus);

  --ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
  --ease-out:      cubic-bezier(0.16, 1, 0.3, 1);
  --ease-bounce:   cubic-bezier(0.34, 1.56, 0.64, 1);
  --duration-fast:  120ms;
  --duration-base:  200ms;
  --duration-slow:  320ms;
  --duration-bounce: 360ms;

  --app-max:   1180px;
  --phone-max: 420px;
  --tap-min:   44px;
}

/* ── 4 · Base field — the aged-scroll parchment ──────────────────────────────
   Four layers, back to front: aged-edge vignette · paper-fiber noise (inline
   SVG feTurbulence) · grain-ink mottling · base parchment colour. The field is
   calm; cards float above it with modern, springy elevation. */
body {
  margin: 0;
  background-color: var(--surface-page);
  font-family: var(--font-sans);
  color: var(--text-body);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}
/* Parchment field on a real position:fixed layer — NOT background-attachment:fixed,
   which iOS/mobile mispositions to the layout viewport and renders as a shadowy band
   down one edge. inset:0 sizes it to the visual viewport, behind all content, so the
   field stays calm as content scrolls. Edge vignette + right-side cloud intentionally
   omitted (no edge darkening). */
body::before {
  content: "";
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  background-color: var(--surface-page);
  background-image:
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='p'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.55' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.42 0 0 0 0 0.25 0 0 0 0 0.12 0 0 0 0.16 0'/%3E%3C/filter%3E%3Crect width='240' height='240' filter='url(%23p)'/%3E%3C/svg%3E"),
    radial-gradient(ellipse 90% 70% at 18% 12%,  color-mix(in srgb, var(--tex-ink) 14%, transparent), transparent 70%),
    radial-gradient(ellipse 100% 80% at 50% 105%, color-mix(in srgb, var(--tex-ink) 15%, transparent), transparent 72%),
    radial-gradient(ellipse 70% 60% at 30% 70%,  color-mix(in srgb, var(--tex-ink) 9%, transparent),  transparent 66%);
  background-repeat: repeat, no-repeat, no-repeat, no-repeat;
  background-size: 240px 240px, cover, cover, cover;
}

/* A detached scroll panel (when the field isn't on <body> — phone frames). */
.cm-parchment {
  background-color: var(--surface-page);
  background-image:
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='p'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.55' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.42 0 0 0 0 0.25 0 0 0 0 0.12 0 0 0 0.16 0'/%3E%3C/filter%3E%3Crect width='240' height='240' filter='url(%23p)'/%3E%3C/svg%3E"),
    radial-gradient(ellipse 90% 70% at 18% 12%,  color-mix(in srgb, var(--tex-ink) 14%, transparent), transparent 70%),
    radial-gradient(ellipse 100% 80% at 50% 105%, color-mix(in srgb, var(--tex-ink) 15%, transparent), transparent 72%),
    radial-gradient(ellipse 70% 60% at 30% 70%,  color-mix(in srgb, var(--tex-ink) 9%, transparent),  transparent 66%);
  background-repeat: repeat, no-repeat, no-repeat, no-repeat;
  background-size: 240px 240px, cover, cover, cover;
  color: var(--text-body);
}

/* ── 4 · Brand lockup — wordmark + creed ─────────────────────────────────── */
.brand-text { display: flex; flex-direction: column; line-height: 1; }
.brand-word {
  font-family: var(--font-display);
  font-weight: var(--weight-black);
  letter-spacing: var(--tracking-word);
  color: var(--brand);
  line-height: 1;
}
.brand-creed {
  font-size: var(--text-2xs);
  letter-spacing: var(--tracking-creed);
  text-transform: uppercase;
  color: var(--creed);
  margin-top: 4px;
}

/* Reusable uppercase Tyrian micro-label. */
.cm-label {
  font-size: var(--text-2xs);
  font-weight: var(--weight-semibold);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  color: var(--text-label);
}

/* Tabular numerals — align money columns. */
.cm-tnum { font-variant-numeric: tabular-nums; }

/* ── 5 · Components (opt-in .cm-* classes) ───────────────────────────────── */

/* Icon host: a Lucide <i data-lucide> becomes an <svg> inheriting currentColor. */
.cm-icon { display: inline-flex; align-items: center; justify-content: center; flex: none; line-height: 0; }
.cm-icon svg, i[data-lucide] { stroke-width: 2; }

/* Card — the signature surface: ivory fill, hairline, 3px Tyrian top rule, soft
   purple lift. .quiet drops the top rule; .interactive springs up on hover. */
.cm-card {
  background: var(--surface-card);
  border: 1px solid var(--border-hairline);
  border-top: var(--border-accent-width) solid var(--brand);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-card);
  padding: var(--space-5);
}
/* A RULE CARD IS NOT A SECTION. The 3px Tyrian rail is the app's "this is a section" mark — and
   with one card per rule it was appearing 16 times on a scroll, which means it signified nothing,
   and made "Run a check" and the settings toggles read as rules too. Rule cards are .quiet: a
   hairline top border. The rail goes back to marking the ONE section card per screen, so it starts
   saying "I'm NOT a rule" again. [taste review, third pass — its straight recommendation] */
.cm-card.quiet { border-top: 1px solid var(--border-hairline); }
.cm-card.interactive {
  cursor: pointer;
  transition: box-shadow var(--duration-base) var(--ease-standard),
              transform var(--duration-bounce) var(--ease-bounce);
}
.cm-card.interactive:hover { box-shadow: var(--shadow-raised); transform: translateY(-3px) scale(1.004); }

/* Card header — Tyrian title + optional trailing action. */
.cm-card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--space-4); }
.cm-card-head h2 { margin: 0; font-size: var(--text-lg); font-weight: var(--weight-bold); color: var(--text-heading); }

/* Button — Tyrian primary, parchment-outline secondary, ghost, danger, success.
   Calm hover (darken), gentle press (down 1px). */
.cm-btn {
  display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2);
  height: 44px; min-width: 44px; padding: 0 18px;
  font-family: var(--font-sans); font-size: var(--text-sm); font-weight: var(--weight-bold);
  line-height: 1; letter-spacing: 0.01em;
  border-radius: var(--radius-sm); border: 1px solid transparent; cursor: pointer;
  white-space: nowrap; text-decoration: none;   /* so an <a class="cm-btn"> reads as a button */
  transition: background var(--duration-fast) var(--ease-standard),
              border-color var(--duration-fast) var(--ease-standard),
              transform var(--duration-fast) var(--ease-standard);
}
.cm-btn.sm { height: 36px; padding: 0 14px; }
.cm-btn.xs { height: 28px; min-width: 0; padding: 0 10px; font-size: var(--text-xs); }
.cm-btn.lg { height: 52px; padding: 0 24px; font-size: var(--text-base); border-radius: var(--radius-md); }
.cm-btn.block { width: 100%; }
.cm-btn.primary   { background: var(--brand); color: var(--text-on-accent); }
.cm-btn.primary:hover   { background: var(--brand-hover); }
.cm-btn.secondary { background: var(--surface-card); color: var(--brand); border-color: var(--border-strong); }
.cm-btn.secondary:hover { border-color: var(--brand); }
.cm-btn.ghost     { background: transparent; color: var(--brand); }
.cm-btn.ghost:hover     { background: color-mix(in srgb, var(--brand) 10%, transparent); }
.cm-btn.danger    { background: var(--danger); color: #fff; }
.cm-btn.danger:hover    { background: color-mix(in srgb, var(--danger) 84%, #000); }
/* ghost + danger = a transparent button with danger-red text (not a solid red fill) —
   .danger alone would win the cascade and make a "ghost danger" button solid. */
.cm-btn.ghost.danger       { background: transparent; color: var(--danger); }
.cm-btn.ghost.danger:hover { background: color-mix(in srgb, var(--danger) 10%, transparent); }
.cm-btn.success   { background: var(--good); color: #fff; }
.cm-btn.success:hover   { background: color-mix(in srgb, var(--good) 86%, #000); }
.cm-btn:active:not(:disabled) { transform: translateY(1px); }
.cm-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.cm-btn[hidden] { display: none; }   /* class display:inline-flex would otherwise beat the UA [hidden] rule */

/* Input — labelled field on the sunken parchment fill, Tyrian focus ring. */
.cm-field { display: block; }
.cm-field > .cm-label { display: block; margin-bottom: var(--space-2); }
.cm-inputwrap {
  display: flex; align-items: center; gap: var(--space-2);
  background: var(--surface-sunken);
  border: 1px solid var(--border-hairline);
  border-radius: var(--radius-sm);
  padding: 0 14px; height: 46px;
  transition: border-color var(--duration-fast) var(--ease-standard),
              box-shadow var(--duration-fast) var(--ease-standard);
}
.cm-inputwrap:focus-within { border-color: var(--brand); box-shadow: var(--ring); }
.cm-inputwrap.error { border-color: var(--danger); }
.cm-inputwrap > .cm-icon, .cm-inputwrap > i[data-lucide] { color: var(--muted); }
.cm-inputwrap > input {
  flex: 1; min-width: 0; height: 100%;
  border: none; outline: none; background: transparent;
  font-family: var(--font-sans); font-size: var(--text-sm); color: var(--text-body);
}
.cm-inputwrap > input::placeholder { color: var(--muted); }
.cm-hint { display: block; margin-top: var(--space-2); font-size: var(--text-xs); color: var(--muted); }
.cm-hint.error { color: var(--danger); }

/* Divider with a centred label ("or"). */
.cm-divider {
  display: flex; align-items: center; gap: 12px;
  color: var(--muted); font-size: var(--text-2xs);
  text-transform: uppercase; letter-spacing: 0.1em;
}
.cm-divider::before, .cm-divider::after { content: ''; flex: 1; height: 1px; background: var(--border-hairline); }
.cm-divider[hidden] { display: none; }

/* Badge — compact tinted status / money pill. */
.cm-badge {
  display: inline-flex; align-items: center; gap: var(--space-1);
  height: 22px; padding: 0 10px; border-radius: var(--radius-pill);
  font-size: var(--text-2xs); font-weight: var(--weight-semibold);
  letter-spacing: 0.02em; line-height: 1; white-space: nowrap;
}
.cm-badge.positive { background: color-mix(in srgb, var(--good) 14%, transparent);   color: var(--good); }
.cm-badge.negative { background: color-mix(in srgb, var(--danger) 14%, transparent); color: var(--danger); }
.cm-badge.warning  { background: color-mix(in srgb, var(--amber) 14%, transparent);  color: var(--amber); }
.cm-badge.brand    { background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent); }
.cm-badge.laurel   { background: color-mix(in srgb, var(--laurel) 14%, transparent); color: var(--laurel-deep); }
.cm-badge.neutral  { background: color-mix(in srgb, var(--muted) 14%, transparent);  color: var(--muted); }
.cm-badge.solid          { color: #fff; }
.cm-badge.solid.positive { background: var(--good); }
.cm-badge.solid.negative { background: var(--danger); }
.cm-badge.solid.warning  { background: var(--amber); }
.cm-badge.solid.brand    { background: var(--accent); }

/* Spend-vs-limit meter — emerald → gold (.near) → Pompeian red (.over). */
.cm-meter { height: 8px; border-radius: var(--radius-pill); background: var(--surface-sunken); overflow: hidden; }
/* Bar walk: TYRIAN resting -> GOLD (.near, >=80%) -> AUBURN (.over, #8b3a2b — a burnt classical
   red, NOT the figure's Pompeian --danger). statusColor() is gone; JS-built meters compute the same
   walk explicitly per renderer. Income meters are NOT this — they pass their own var(--good). */
.cm-meter > span {
  display: block; height: 100%; border-radius: var(--radius-pill); background: var(--accent);
  transition: width var(--duration-bounce) var(--ease-bounce), background var(--duration-base) var(--ease-standard);
}
.cm-meter.near > span { background: var(--amber); }
.cm-meter.over > span { background: var(--auburn); }   /* bar critical = burnt auburn, not the figure's Pompeian red */
/* Diagonal amber "zebra" hatch for the remaining-recurring segment on an alert meter
   (the still-to-post recurring — pending, not yet spent). Kept as a var so it can be set
   INLINE on the ghost segment: the hand-rolled `.cm-meter > span` rule (higher specificity
   than a bare class) would otherwise override a class-based background. Theme-aware — it
   re-resolves var(--amber) per theme. */
:root { --cm-recur-hatch: repeating-linear-gradient(135deg,
          color-mix(in srgb, var(--amber) 60%, transparent) 0 3px,
          color-mix(in srgb, var(--amber) 20%, transparent) 3px 6px); }

/* Logo coin tile — gold frame line + soft lift on parchment. */
.cm-coin { border-radius: var(--radius-md); box-shadow: var(--shadow-card); border: 1px solid var(--frame-gold); display: block; }

/* TrustNote — the security voice: modern, plain, emerald. Never decorated. */
.cm-trustnote {
  display: inline-flex; align-items: center; gap: var(--space-2);
  font-size: var(--text-xs); line-height: 1.5; color: var(--text-muted);
}
.cm-trustnote > .cm-icon, .cm-trustnote > i[data-lucide] { color: var(--good); }
.cm-trustnote.panel {
  display: flex; align-items: flex-start; gap: var(--space-3); padding: var(--space-4);
  background: color-mix(in srgb, var(--good) 8%, var(--surface-card));
  border: 1px solid color-mix(in srgb, var(--good) 30%, transparent);
  border-radius: var(--radius-sm);
  font-size: var(--text-sm); line-height: 1.55; color: var(--text-body);
}

/* Text link in the brand voice. */
.cm-link { color: var(--brand); text-decoration: none; font-weight: var(--weight-semibold); }
.cm-link:hover { text-decoration: underline; }
/* Screen-reader-only text. Used to append "(opens in a new tab)" to external links —
   sighted users get the context from the new tab itself, so it must not take layout. */
.cm-visually-hidden {
  position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
  overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0;
}

/* Laurel host — the parametric SVG is injected by carpe-laurel.js. */
.cm-laurel { display: block; color: var(--brand-laurel); }
.cm-laurel svg { display: block; }

/* ============================================================================
   6 · Legacy retrofit — the small set of older inline class names still in use.
   index.html is fully migrated to .cm-*; these remaining hooks serve admin.html
   (.card) and a few shared inline bits in index.html (main h2, label,
   .tx-table th, .wiz-inst, .cat-section-title, header/nav rails, .acct-row hover).
   ========================================================================== */
main h2 { color: var(--accent); }

label, .cat-section-title, .wiz-inst, .tx-table th, .drawer-section > .lbl {
  color: var(--accent);
}

.card {
  border-top: 3px solid var(--accent);
  box-shadow: var(--shadow-card);
}

header, nav.tabs {
  border-bottom-color: color-mix(in srgb, var(--accent) 40%, var(--border));
}
.acct-row:hover, #pwa-install-btn:hover { border-color: var(--accent); }

/* ============================================================================
   7 · Phase-2 mobile Dashboard — "Ledger Lines" (Design 2).
   ADD-ONLY component classes for renderDashboardV2() + cm-viz.js. Token-based;
   the mobile-only LAYOUT rules are gated under @media (max-width:760px), (max-height:540px
   and max-width:1024px) [landscape phones] so the desktop dashboard is untouched. Faithful to the prototype's sizes/weights/
   spacing/states (explorations/mobile-home/shared.jsx + Design2.jsx).
   ========================================================================== */

/* Header eye / hide-balances toggle (mobile only — desktop has no eye control). */
.cm-iconbtn {
  width: 38px; height: 38px; flex: none;
  display: none;                       /* shown only on mobile (media query below) */
  align-items: center; justify-content: center;
  border-radius: var(--radius-sm);
  border: 1px solid var(--border-hairline);
  background: var(--surface-card); color: var(--accent);
  cursor: pointer;
  transition: background var(--duration-fast) var(--ease-standard),
              border-color var(--duration-fast) var(--ease-standard);
}
.cm-iconbtn:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); }
.cm-iconbtn:active { transform: translateY(1px); }

/* Solid Tyrian icon/initials chip — bank marks + category glyphs read crisp. */
.cm-avatar {
  flex: none; display: inline-flex; align-items: center; justify-content: center;
  border-radius: var(--radius-sm);
  background: var(--brand); color: var(--text-on-accent);
  font-family: var(--font-sans); font-weight: var(--weight-black); letter-spacing: .01em;
  box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 22%, transparent),
              0 1px 2px color-mix(in srgb, var(--accent) 35%, transparent);
  border: 1px solid color-mix(in srgb, #000 12%, var(--brand));
}
.cm-avatar.round { border-radius: var(--radius-pill); }

/* Dashboard V2 host: only rendered into on mobile; section stack + padding. */
.cm-dashv2 { display: none; flex-direction: column; gap: 16px; padding: 4px 16px 20px; }
/* Loading skeleton (replaces raw "Loading…" text on the mobile dashboard host). */
.cm-skel { border-radius: 12px; position: relative; overflow: hidden;
  background: color-mix(in srgb, var(--text-body) 6%, transparent); }
.cm-skel::after { content: ''; position: absolute; inset: 0; transform: translateX(-100%);
  background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--text-body) 8%, transparent), transparent);
  animation: cm-shimmer 1.3s infinite; }
@keyframes cm-shimmer { 100% { transform: translateX(100%); } }
.cm-skel-hero { height: 220px; }
/* Shimmer-row skeleton — YouTube-style placeholder shown while a panel/list hydrates
   (replaces the raw "Loading…" text). Reuses .cm-skel for the box + shimmer. */
.cm-skel-list { display: flex; flex-direction: column; gap: 14px; padding: 6px 0; }
.cm-skel-row { display: flex; align-items: center; gap: 12px; }
.cm-skel-av { width: 40px; height: 40px; border-radius: 12px; flex: none; }
.cm-skel-lines { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
.cm-skel-l1 { height: 12px; width: 62%; }
.cm-skel-l2 { height: 10px; width: 38%; }
@media (prefers-reduced-motion: reduce) { .cm-skel::after { animation: none; } }

/* Spending hero card. */
.cm-hero { position: relative; overflow: hidden; }
/* Watermark laurel: a faint flourish on the right of the hero's upper band — below the
   top-right "$ left · save rate" badge and above the chart, so it collides with neither. */
.cm-hero .cm-hero-laurel {
  position: absolute; right: 8px; top: 44px; pointer-events: none; opacity: .05;
}
.cm-hero-amt {
  font-size: 36px; font-weight: var(--weight-black); letter-spacing: -.035em;
  color: var(--text-body); line-height: 1; white-space: nowrap;
}
.cm-hero-of { font-size: var(--text-sm); color: var(--muted); font-weight: var(--weight-semibold); }
.cm-hero-axis { display: flex; justify-content: space-between; font-size: var(--text-xs); color: var(--muted); font-weight: var(--weight-semibold); }

/* Month-by-month column chart (CMViz.monthlyColumns) — HTML columns, not SVG, so
   the bars stay square at any width. Columns share the row equally and shrink
   rather than scroll, so a 12-month range still fits a 360px phone.

   There is deliberately NO column "track". A filled track is scaffolding drawn with
   the same weight as the data, and on a sparse series it inverts the picture: ten
   empty months render as ten tall slabs while the three real values disappear. A
   single baseline rule carries the axis instead. */
.cm-mcols { display: flex; align-items: flex-end; justify-content: center; gap: 4px; width: 100%; }
/* Capped so a 2–3 month range draws columns, not slabs; uncapped they would each
   take a third of the card and stop reading as a time series. */
.cm-mcol { flex: 1 1 0; min-width: 0; max-width: 64px; display: flex; flex-direction: column; gap: 6px; }
.cm-mcol-track {
  position: relative; display: flex; align-items: flex-end; justify-content: center;
  border-bottom: 1px solid var(--border);
}
/* min-height gives a MEASURED zero (spent, none of it recurring) a visible stub. A
   fill element is only emitted for months that have data, so this can't resurrect a
   no-data month — and without it a real 0% would render as blank, i.e. the most
   informative case would be the least visible one. */
.cm-mcol-fill {
  display: block; width: 100%; min-height: 2px;
  border-radius: var(--radius-sm) var(--radius-sm) 0 0;
  transition: height var(--duration-bounce) var(--ease-bounce);
}
/* "No data" month: a muted dot ON the baseline. It must never be mistakable for a
   measured zero — a real 0% draws no bar at all, which is the honest picture, so the
   dot is what says "nothing was measured here" rather than "we measured nothing". */
.cm-mcol-empty {
  display: block; width: 4px; height: 4px; border-radius: 50%;
  background: var(--muted); opacity: .35; margin-bottom: -2px;
}
/* Axis captions are thinned to ~6; let a caption overflow its own narrow column
   rather than clip mid-glyph ("Oct" is wider than a 13-column slot). */
.cm-mcol-lab {
  font-size: var(--text-xs); color: var(--muted); font-weight: var(--weight-semibold);
  text-align: center; line-height: 1; white-space: nowrap; min-height: 1em;
}
/* Balances hidden: blur the columns like every other figure surface. */
.cm-mcols.is-hidden { filter: blur(7px); opacity: .5; pointer-events: none; }
@media (prefers-reduced-motion: reduce) { .cm-mcol-fill { transition: none; } }
/* Hero month-pivot nav — [‹] Month YYYY [›]; steps the hero to a past month. */
.cm-hero-nav { display: flex; align-items: center; justify-content: space-between; margin: 2px 0 2px; }
.cm-hero-nav-label { font-size: var(--text-sm); font-weight: var(--weight-bold); color: var(--text-body); letter-spacing: -.01em; }
.cm-hero-nav-btn {
  display: inline-flex; align-items: center; justify-content: center;
  width: 44px; height: 44px; border-radius: var(--radius-pill);
  background: none; border: none; color: var(--accent); cursor: pointer;
  transition: background .14s ease; -webkit-tap-highlight-color: transparent;
}
/* Hover only on hover-capable devices — otherwise a mobile TAP leaves the fill "stuck" so the two
   arrows look asymmetric (one active). Keyboard focus gets a ring via :focus-visible, not a fill. */
@media (hover: hover) { .cm-hero-nav-btn:hover:not(:disabled) { background: color-mix(in srgb, var(--accent) 10%, transparent); } }
.cm-hero-nav-btn:focus { outline: none; }
.cm-hero-nav-btn:focus-visible { outline: 2px solid var(--ring-focus); outline-offset: 2px; }
.cm-hero-nav-btn:disabled { opacity: .3; cursor: default; color: var(--muted); }
/* While a month-pivot's >90d backfill runs, dim the hero + freeze its arrows instead of the
   full-screen boot splash (which an in-card control shouldn't trigger). */
.cm-hero-pivoting .cm-hero-chart { opacity: .4; transition: opacity .15s ease; }
.cm-hero-pivoting .cm-hero-nav-btn { pointer-events: none; }
.cm-hero-pacekey { display: inline-flex; align-items: center; gap: 5px; }
.cm-hero-pacekey > span { width: 14px; height: 0; border-top: 1.5px dashed var(--muted); }
/* Hero line-graph legend — one row of [swatch · name · total]. Four CSS vars
   trade text size against breathing room so it stays one line. Spending swatch =
   purple (--accent, → --danger over budget), income = green (--good), budget =
   a DASHED rule matching the chart's reference line. */
.cm-hero-legend {
  --leg-font: 12px;       /* legend text size */
  --leg-gap: 4px;         /* space between the three legend entries */
  --leg-swatch: 13px;     /* swatch width */
  --leg-swatch-gap: 5px;  /* space between a swatch and its label */
  display: flex; flex-wrap: wrap; align-items: center;
  justify-content: space-between; gap: 4px var(--leg-gap); margin-top: 8px;
}
.cm-hero-leg {
  display: inline-flex; align-items: center; gap: var(--leg-swatch-gap);
  font-size: var(--leg-font); color: var(--muted); font-weight: var(--weight-semibold);
  white-space: nowrap; margin: 0; width: auto;
}
.cm-hero-leg b { color: var(--text-body); font-weight: var(--weight-bold); margin-left: 2px; }
.cm-hero-sw { width: var(--leg-swatch); height: 3px; border-radius: var(--radius-pill); flex: none; }
.cm-hero-sw.spend       { background: var(--accent); }
.cm-hero-sw.spend.over  { background: var(--danger); }
.cm-hero-sw.income      { background: var(--good); }
/* Segmented control (e.g. the recurring editor's Starts with / Contains / Exactly match toggle).
   Full-width flex so 2–3 options share the row evenly and never overflow a narrow modal. */
.cm-segment {
  display: flex; padding: 3px; gap: 3px; border-radius: var(--radius-pill);
  background: var(--surface-sunken); border: 1px solid var(--border-hairline);
}
.cm-segment-opt {
  appearance: none; border: none; background: none; cursor: pointer; flex: 1;
  padding: 6px 10px; border-radius: var(--radius-pill); font: inherit; font-size: 13px;
  white-space: nowrap; text-align: center;
  font-weight: var(--weight-semibold); color: var(--muted); transition: background .14s ease, color .14s ease;
}
.cm-segment-opt.is-active { background: var(--surface-card); color: var(--accent); box-shadow: var(--shadow-card); }

/* Merchant autocomplete dropdown (recurring editor) — a floating listbox under the input. */
.cm-ac-menu {
  position: absolute; left: 0; right: 0; top: calc(100% + 3px); z-index: 30;
  background: var(--surface-card); border: 1px solid var(--border);
  border-radius: 10px; box-shadow: var(--shadow-card); max-height: 224px; overflow-y: auto;
}
.cm-ac-item {
  display: flex; align-items: center; justify-content: space-between; gap: 10px;
  padding: 9px 12px; cursor: pointer; font-size: 14px; color: var(--text-body);
}
.cm-ac-item:hover, .cm-ac-item.is-active { background: color-mix(in srgb, var(--accent) 10%, transparent); }
.cm-ac-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cm-ac-count { flex: none; font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }

/* Amount suggestions (recurring editor) — one-tap chips under the Amount field, offering the
   amounts this rule's own criteria actually match in the user's charges.  Chips, not a dropdown
   like the merchant field above: a type-ahead menu hanging off a number input is undiscoverable
   (you'd have to focus it to learn it exists), and there are only ever 1–3 real answers. */
.cm-sug { margin-top: 10px; }
.cm-sug-label {
  font-size: 11px; font-weight: var(--weight-semibold); letter-spacing: .04em;
  text-transform: uppercase; color: var(--muted); margin-bottom: 6px;
}
.cm-sug-chips { display: flex; flex-wrap: wrap; gap: 8px; }
.cm-sug-chip {
  display: flex; flex-direction: column; align-items: flex-start; justify-content: center; gap: 1px;
  /* min-height, not padding alone: the two-line stack lands within a pixel or two of the 44px
     touch minimum, and which side of it depends on the platform's default line-height. */
  min-height: 44px; padding: 7px 11px; text-align: left; cursor: pointer;
  background: var(--surface-card); border: 1px solid var(--border); border-radius: 10px;
  transition: border-color .14s ease, background .14s ease;
}
.cm-sug-chip:hover, .cm-sug-chip:focus-visible {
  border-color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, transparent);
}
.cm-sug-amt {
  font-size: 14px; font-weight: var(--weight-semibold); color: var(--text-body);
  font-variant-numeric: tabular-nums;
}
.cm-sug-why { font-size: 10px; color: var(--muted); letter-spacing: .02em; }

/* ── Touch focus-zoom ──────────────────────────────────────────────────────────────────────
   WebKit zooms the whole viewport whenever you focus a text control whose font-size is under
   16px, and leaves you to pinch back out. It is not a "Safari quirk" you can ignore: Apple
   requires every iOS browser to use WebKit, so this is Chrome/Firefox/Edge on an iPhone too —
   and the Capacitor build is a WKWebView, so it is the native app as well.

   Fixed by MEETING the 16px threshold. NOT by `user-scalable=no` / `maximum-scale=1`, which
   also stop the zoom — by disabling pinch-zoom outright, taking magnification away from anyone
   who needs it. That trade is never worth it.

   Gated on POINTER, not a width breakpoint: an iPad in landscape is wider than any mobile
   breakpoint we have and still zooms. Touch is the real discriminator here, not width.

   `any-pointer` as well as `pointer`, because the two failure modes are not symmetric. An iPad
   with a trackpad attached can report its PRIMARY pointer as fine while still being a WebKit
   touchscreen that zooms — a false negative there silently resurrects the exact bug. A false
   positive (a touch-capable laptop getting 16px fields) costs a slightly larger control on a
   device that was never going to zoom, which is no worse and arguably a better touch target.
   Desktop with no touch at all keeps its 14px.

   Non-text controls are excluded — they never trigger the zoom, and forcing a size on them
   would fight the switch/checkbox sizing. */
@media (pointer: coarse), (any-pointer: coarse) {
  input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="button"]):not([type="submit"]):not([type="reset"]),
  select,
  textarea { font-size: 16px; }
}
/* Shown only for a merchant with no fixed price (a utility, a coffee shop) — states the spread so
   a later "Overcharged" badge reads as "this bill moves", not as a bug. */
.cm-sug-note { margin-top: 6px; font-size: 11px; color: var(--muted); line-height: 1.4; }

/* Recurring swatch — a gold block matching the chart's gold FILL band: the recurring
   dollars actually charged so far this month (constant colour; never over-budget red).
   The chart also draws a dashed amber "committed /mo" reference line, but the legend folds
   that into this Recurring leg's text ("$X of $Y/mo · $Z to go") — no separate swatch. */
.cm-hero-sw.recurring,
.cm-hero-sw.recurring.over { height: 9px; border-radius: 2px; background: var(--amber); opacity: 0.85; }
/* Budget swatch = dashed rule (matches the chart's dashed budget reference line). */
.cm-hero-sw.budget {
  height: 0; background: none; border-radius: 0;
  border-top: 2px dashed var(--muted); opacity: 0.7;
}

/* Extra-income pill (income beyond estimate raised the budget). */
.cm-pill-good {
  display: inline-flex; align-items: center; gap: 5px;
  font-size: 11px; font-weight: var(--weight-bold); color: var(--good);
  background: color-mix(in srgb, var(--good) 14%, transparent);
  padding: 3px 9px; border-radius: var(--radius-pill); white-space: nowrap;
}

/* (StatTile / Money-in-out tiles removed — income + spending now live in the
   hero line-graph legend above.) */

/* Emphasis block (Alert rules / Subscriptions): conditional 3px top-rule +
   breached red-tint border + raised shadow. .breached deepens both. */
.cm-block {
  background: var(--surface-card);
  border: 1px solid var(--border-hairline);
  border-top: 3px solid var(--brand);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-card);
  padding: 16px 16px 8px;
}
.cm-block.raised { box-shadow: var(--shadow-raised); }
/* Resting rail is the BRAND, not emerald — a green rail said "fine" in a colour that isn't ours,
   on every healthy block (and on "Run a check", which has no status at all). warn/bad keep
   their rails: those mean something. [design directive 2026-07-14] */
.cm-block.tone-good    { border-top-color: var(--accent); }
.cm-block-head { display: flex; align-items: center; gap: 12px; }
.cm-block-head h2 { margin: 0; font-size: 17px; font-weight: var(--weight-bold); color: var(--accent); }
.cm-block-chip {
  width: 40px; height: 40px; flex: none; border-radius: var(--radius-sm);
  display: inline-flex; align-items: center; justify-content: center;
  color: var(--text-on-accent);
  box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 26%, transparent);
}
.cm-block-counts { display: flex; gap: 14px; margin-top: 12px; padding-bottom: 2px; }
.cm-block-counts .ct { display: flex; align-items: center; gap: 6px; }
.cm-block-counts .dot { width: 8px; height: 8px; border-radius: 3px; flex: none; }
.cm-block-counts .n { font-size: var(--text-sm); font-weight: var(--weight-black); color: var(--text-body); }
.cm-block-counts .lbl { font-size: var(--text-xs); color: var(--muted); font-weight: var(--weight-semibold); }
.cm-block-rows { border-top: 1px solid var(--border-hairline); margin-top: 8px; }
.cm-block-action {
  width: 100%; margin: 4px 0 8px; height: 40px; border-radius: var(--radius-md);
  border: 1px solid var(--border-strong); background: transparent; color: var(--accent);
  font-family: var(--font-sans); font-size: var(--text-sm); font-weight: var(--weight-bold);
  cursor: pointer; display: inline-flex; align-items: center; justify-content: center; gap: 8px;
}
.cm-block-action:hover { background: color-mix(in srgb, var(--accent) 8%, transparent); }

/* Collapsible dashboard cards (Alert rules + Recurring): the summary (head + counts)
   stays visible; the rule rows collapse. The title is a separate deep-link to its tab. */
details.cm-block > summary { list-style: none; cursor: pointer; }
details.cm-block > summary::-webkit-details-marker { display: none; }
details.cm-block[open] .cm-stat-chev { transform: rotate(180deg); }
.cm-block-link { cursor: pointer; display: inline-flex; align-items: center; gap: 2px; }
.cm-block-link:hover { text-decoration: underline; text-underline-offset: 2px; }
.cm-block-link i[data-lucide] { color: var(--accent); }

/* Prominent rule row inside the alert block (solid status chip + tall meter). */
/* ── ROW-IN-A-CARD (the DASHBOARD block only) ────────────────────────────────────────────────
   These rules are SCOPED to .cm-block-rows on purpose. Alerts and Categories used to be rows
   inside one shared card; they are now ONE CARD PER RULE (matching Recurring), and on a card
   every one of these is wrong:
     padding:12px 0   -> dead space inside the card's own 24px
     border-bottom    -> a stray hairline just above the card's own border
     :last-child      -> with one row per card EVERY row is last-child, so it silently swallowed
                         the bug above instead of showing it
     .is-alt margins  -> the -16px is tuned to a shared card's 16px side padding; on a card it
                         bleeds the stripe 16px OUTSIDE the border, as a band over the page
   The dashboard alert block IS still rows-in-a-card (.cm-block-rows, 16px side padding), so the
   rules stay — scoped to it. Deleting them outright would silently un-stripe the dashboard.
   The rule cards keep the bare .cm-rule-row class as a TEST HOOK; it now matches no CSS. */
.cm-block-rows .cm-rule-row { padding: 12px 0; border-bottom: 1px solid var(--border-hairline); }
.cm-block-rows .cm-rule-row:last-child { border-bottom: none; }
.cm-block-rows .cm-rule-row.is-alt {
  background: color-mix(in srgb, var(--text-body) 3.5%, transparent);
  margin-left: -16px; margin-right: -16px; padding-left: 16px; padding-right: 16px;
}

/* ── THE RULE CARD (Alerts / Categories / Recurring — one shape) ─────────────────────────────
   The alert card's summary is a COLUMN: the face row, then the meter. Recurring's meter grades a
   charge that already landed and is genuinely secondary; an alert's meter IS the rule, so burying
   "how close am I to the limit" behind a tap would be a regression. */
.cm-rule-facerow { display: flex; align-items: center; gap: 12px; width: 100%; min-width: 0; }
.cm-rule-trailing { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; }

/* The expanded rule's detail list — a plain key/value column, not a table. */
.cm-rule-detail { margin: 0 0 4px; display: flex; flex-direction: column; gap: 6px; }
.cm-rule-drow { display: flex; gap: 10px; align-items: baseline; font-size: var(--text-xs); }
.cm-rule-drow dt { flex: 0 0 92px; color: var(--muted); }
.cm-rule-drow dd { margin: 0; min-width: 0; color: var(--text-body); overflow-wrap: anywhere; }
.cm-rule-drow dd b { color: var(--accent); }

/* The CATEGORY card's face line WRAPS. Everywhere else a rule's scope is a short fixed phrase
   ("This month · Food and Drink"), so nowrap+ellipsis is right. A category rule's is a built
   sentence — merchant + operator + quoted text + account + amount — and it was being guillotined
   mid-word ("Merchant is exactly · Everyday Checkin…"), hiding the one thing that identifies the
   rule. Let it take the two lines it needs. [user, 2026-07-14] */
.cm-crulev2 .cm-rule-scope {
  white-space: normal; text-overflow: clip; line-height: 1.35;
  /* Degrades fine without line-clamp support: the full text simply shows on 2+ lines, nothing lost. */
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}

/* Card rhythm comes from the CONTAINER, not per-card margins — #alertsv2 is already a flex column
   with a gap, so an inline margin-bottom would stack on top of it. Give the plain-div containers
   the same treatment rather than sprinkling margins. */
#rules-list, #crule-list, #crule-list-m { display: flex; flex-direction: column; gap: 12px; }

.cm-rule-top { display: flex; align-items: center; gap: 10px; }
.cm-rule-chip {
  width: 32px; height: 32px; flex: none; border-radius: var(--radius-sm);
  display: inline-flex; align-items: center; justify-content: center;
  box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 26%, transparent);
  background: var(--accent); color: var(--text-on-accent);   /* never status-tinted */
}
/* weight-medium (not semibold) so the amount — the actually-actionable figure — reads as the
   one clear "loudest" thing on the row, not tied with the name. */
/* The desktop rule chip. Same object as .cm-rule-chip (mobile, 32px), one size up — the three
   renderers that used it each hand-rolled their own 36px span, and drifted: the alert one tinted
   itself by STATUS (green→red) and the recurring one used a 14% wash that read as pale lilac next
   to the category rules' solid purple. Same object, three skins. Now: always solid Tyrian, in every
   rule family, on every surface. The icon does the identifying; the chip never encodes status.
   [design directive 2026-07-14] */
/* The creed's separators are LAUREL, not middots — the brand motif, doing a job. Green on purpose:
   .cm-laurel already sets color:var(--brand-laurel) (olive), and that element-level rule beats the
   creed's inherited Pompeian red, so the leaves stay green inside red text with no extra work.
   `sprig` is the only horizontal laurel variant — wreath/branch are vertical and would tower over a
   7px line. .cm-laurel is display:block, so inline use MUST override it. The right leaf is mirrored
   so the pair faces inward. [design directive 2026-07-14 (c)] */
.brand-leaf {
  display: inline-block; vertical-align: middle; margin: 0 .45em;
  position: relative; top: -1px;   /* optical centring against uppercase letterforms */
}
/* Sized 34px (not 18) with a heavier stroke, because at 18px the leaves fall BELOW perceptual
   resolution and the sprig reads as green fuzz — as chevrons, or as dirt on the screen. Detail you
   cannot resolve isn't craft, it's noise. Rendered a size ladder and looked at it at 1:1: the leaves
   only separate around 34px. It overhangs the 7px creed line slightly, which is what a flourish is
   supposed to do. [taste review, after-pass] */
.brand-leaf-r { transform: scaleX(-1); }

.cm-rule-chip-lg {
  width: 36px; height: 36px; flex: none; border-radius: var(--radius-sm);
  display: inline-flex; align-items: center; justify-content: center;
  background: var(--accent); color: var(--text-on-accent);
}
.cm-rule-name { font-size: var(--text-sm); font-weight: var(--weight-bold); color: var(--text-body);
  min-width: 0; flex: 1 1 auto;
  /* Two-line clamp instead of a single-line ellipsis so a long name ("Dining & groceries") isn't
     cut to "Dining & gro…" when a wide amount steals width. (QA fix: rule-name truncation) */
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
/* Same two-line clamp as .cm-rule-name: the single-line ellipsis was cutting the scope on
   EVERY card at 390px (taste 2026-07-25) — a scope you can't read isn't a scope. */
.cm-rule-scope { font-size: var(--text-xs); color: var(--muted); margin-top: 2px;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.cm-rule-amt { font-size: var(--text-sm); font-weight: var(--weight-bold); flex: none; white-space: nowrap; }
.cm-rule-amt .of { color: var(--muted); font-weight: var(--weight-medium); }
.cm-rule-note { font-size: var(--text-xs); font-weight: var(--weight-semibold); margin-top: 6px; }
/* Narrow phones (iPhone SE/mini class, ≤400px): the name+amount pair no longer fit at full size
   on one line without the name mid-word-truncating — step both down and shrink the chip so the
   name gets more room. */
@media (max-width: 400px) {
  .cm-rule-chip { width: 28px; height: 28px; }
  .cm-rule-name { font-size: 13px; }
  .cm-rule-amt { font-size: 13px; }
}

/* Subscription validation row (smaller chip). */
.cm-recurring-row { padding: 12px 0; border-bottom: 1px solid var(--border-hairline); }
.cm-recurring-row:last-child { border-bottom: none; }
.cm-sub-chip { width: 34px; height: 34px; }

/* Net-worth strip (demoted summary). */
.cm-nw { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.cm-nw-val { font-size: 22px; font-weight: var(--weight-black); letter-spacing: -.01em; color: var(--text-body); margin-top: 3px; }
.cm-nw-spark { width: 92px; flex: none; opacity: .9; }

/* Transaction + account rows (Tyrian-chip ledger lines). */
.cm-tx-row { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3) var(--space-2); border-bottom: 1px solid var(--border-hairline); }
.cm-tx-row:last-child { border-bottom: none; }
/* Tappable ledger lines (Activity) — open the txn detail/recategorize drawer.
   Subtle Tyrian-tinted hover/active so the row reads as a button, plus a clear
   keyboard-focus ring (rows get role="button" tabindex="0"). */
.cm-tx-row.tappable { cursor: pointer; border-radius: var(--radius-sm);
  transition: background var(--duration-fast) var(--ease-standard); }
.cm-tx-row.tappable:hover { background: color-mix(in srgb, var(--accent) 7%, transparent); }
.cm-tx-row.tappable:active { background: color-mix(in srgb, var(--accent) 12%, transparent); }
.cm-tx-row.tappable:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.cm-tx-icon { width: 38px; height: 38px; }
/* Category icon inside a drill-down row's merchant cell — the same Tyrian .cm-avatar
   treatment as Activity (cm-tx-icon), sized down to sit inline in the compact table row. */
.cm-drill-cat { width: 26px; height: 26px; margin-right: 8px; vertical-align: middle; }
.cm-drill-cat svg { width: 15px; height: 15px; }
.cm-tx-name { font-size: var(--text-sm); font-weight: var(--weight-semibold); color: var(--text-body); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cm-tx-meta { font-size: var(--text-xs); color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* Raw bank descriptor line in the Activity ledger — rendered only when it says more than the
   cleaned merchant (txnRawDesc), e.g. "GUSTO, NET 750250" under a merchant of "Gusto". */
.cm-tx-desc { font-size: var(--text-xs); color: var(--muted); opacity: .85; white-space: nowrap;
              overflow: hidden; text-overflow: ellipsis; }
.cm-tx-amt { font-size: var(--text-sm); font-weight: var(--weight-bold); white-space: nowrap; flex: none; }
.cm-tx-amt.in { color: var(--good); }
.cm-tx-amt.out { color: var(--text-body); }
.cm-tx-amt.pending { color: var(--amber); }

.cm-acct-row {
  display: flex; align-items: center; gap: var(--space-4); padding: var(--space-4);
  background: var(--surface-card); border: 1px solid var(--border-hairline);
  border-radius: var(--radius-md); cursor: pointer; width: 100%; text-align: left;
  font: inherit; color: inherit;
  transition: border-color var(--duration-fast) var(--ease-standard);
}
.cm-acct-row:hover { border-color: var(--brand); }
.cm-acct-name { font-size: var(--text-sm); font-weight: var(--weight-bold); color: var(--text-body); }
.cm-acct-sub { font-size: var(--text-xs); color: var(--muted); }
.cm-acct-bal { font-size: var(--text-md); font-weight: var(--weight-bold); color: var(--text-body); }

.cm-secthead { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.cm-secthead h2 { margin: 0; font-size: 17px; font-weight: var(--weight-bold); color: var(--accent); }

/* ── DualLine hero chart — overlaid haloed end-dots + "budget" chip + area fills
   are inline-styled in cm-viz.js so they stay correct under the SVG's fluid
   stretch (preserveAspectRatio="none"). The wrapper is position:relative; this
   keeps any future overlay clipping consistent with the card. ───────────────── */
.cm-hero .cm-dualline { position: relative; }

/* W1: the hero day/month drilldown reuses #drill-backdrop but its header "View
   Transactions →" button replays the last Stats query (wrong dataset). When the
   backdrop is opened from the hero (data-source='hero'), hide that header button
   entirely — the modal body's own list is the correct content. */
#drill-backdrop[data-source='hero'] .modal-head button.btn { display: none; }

/* Month-ledger day-group sticky headers (monthDrilldown — newest day first). */
.tx-table .drill-dayhead td {
  position: sticky; top: 0; z-index: 1; background: var(--surface-card);
  font-size: 10px; font-weight: var(--weight-bold); letter-spacing: .06em;
  text-transform: uppercase; color: var(--muted);
  border-bottom: 1px solid var(--border-hairline); padding: 10px 6px 4px;
}

/* Spotlight scrub day-detail strip (centred; fills on scrub/pin; empty otherwise).
   Reserves a min-height so revealing the detail doesn't shift the chart/legend. */
.cm-hero-daystrip {
  min-height: 30px; display: flex; align-items: center;
  gap: 10px; flex-wrap: wrap; margin: 6px 0 2px;
}
.cm-hero-day-info { display: flex; align-items: baseline; gap: 10px 14px; flex-wrap: wrap; min-width: 0; }
.cm-hero-day-date { font-size: var(--text-sm); font-weight: var(--weight-bold); color: var(--text-body); }
.cm-hero-day-metric { font-size: var(--text-xs); color: var(--muted); }
.cm-hero-day-metric b { font-weight: var(--weight-bold); color: var(--text-body); }
.cm-hero-day-metric.out b { color: var(--text-body); }
.cm-hero-day-metric.in b { color: var(--good); }
.cm-hero-day-see { flex: none; font-size: 12px; gap: 5px; padding: 0 9px; height: 30px; min-height: 0; }

/* Hero header row 2 — "of $budget" + the View-transactions slot. Min-height is
   reserved so the button appearing/disappearing never reflows the chart down. */
.cm-hero-row2 {
  display: flex; align-items: center; justify-content: space-between;
  gap: 10px; min-height: 30px; margin-top: 2px;
}
.cm-hero-row2-slot { flex: none; }
.cm-hero-badge { font-size: 12px; flex: none; margin-left: auto; }  /* stays right-aligned even when it wraps to its own line */

/* ── Spotlight scrub: marker line + riding dots + callout + pulse ─────────────── */
.cm-dl-scrub { user-select: none; -webkit-user-select: none; touch-action: none; }
/* Series dim transition while a finger is down (set via inline opacity). */
.cm-dl-series { transition: opacity .18s; }
/* Full-bleed push-and-drag surface. */
.cm-dl-surf {
  position: absolute; inset: 0; touch-action: none; cursor: grab; outline: none;
}
.cm-dl-surf:active { cursor: grabbing; }
.cm-dl-surf:focus-visible { box-shadow: inset 0 0 0 2px var(--ring-focus); border-radius: 8px; }
/* Riding dots (HTML overlay, % positioned → stay circular under the SVG stretch). */
.cm-dl-ride {
  position: absolute; transform: translate(-50%, -50%); border-radius: 50%;
  border: 2px solid var(--surface-card); pointer-events: none;
}
/* Haptic-style pulse ring fired on each day-cross (re-keyed by node replacement). */
.cm-ride-pulse {
  position: absolute; inset: 0; border-radius: 50%;
  animation: cmRidePulse 460ms cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
@keyframes cmRidePulse {
  0%   { transform: scale(1);   opacity: .7; }
  100% { transform: scale(2.6); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .cm-ride-pulse { animation: none; opacity: 0; }
}
/* Value callout floating above the spending dot — running Spending + Income through this day. */
.cm-dl-callout {
  position: absolute; transform: translate(-50%, calc(-100% - 14px));
  background: var(--accent); color: var(--text-on-accent);
  padding: 6px 10px; border-radius: 8px; white-space: nowrap; text-align: center;
  line-height: 1.2; pointer-events: none;
  box-shadow: 0 4px 14px color-mix(in srgb, var(--accent) 32%, transparent);
}
.cm-dl-callout-row { display: flex; align-items: baseline; justify-content: space-between; gap: 14px; font-size: 11px; }
.cm-dl-callout-row b { font-weight: var(--weight-black); letter-spacing: -.01em; }
.cm-dl-callout-k { opacity: .82; font-weight: var(--weight-semibold); }
.cm-dl-callout-sub { font-size: 9px; font-weight: var(--weight-semibold); opacity: .85; letter-spacing: .02em; margin-top: 2px; }

/* Month-drilldown breakdown header — the Spending/Income/Recurring/Budget legend that used to
   sit on the hero face, now stacked above the txn table inside #drill-body. */
.cm-drill-breakdown { display: flex; flex-direction: column; gap: 9px; padding: 14px 16px; border-bottom: 1px solid var(--border); }
.cm-drill-bd-row { display: flex; align-items: baseline; justify-content: space-between; gap: 14px; font-size: var(--text-sm); color: var(--muted); }
.cm-drill-bd-row b { color: var(--text-body); font-weight: var(--weight-bold); }
.cm-drill-bd-row b.over { color: var(--danger); }
.cm-drill-bd-of { color: var(--muted); font-weight: var(--weight-semibold); }

/* ── Net-worth Spotlight scrub (Stats, MONTHLY) ───────────────────────────────
   The Stats analog of the hero scrub: a prominent hero-style card with one
   net-worth line scrubbed by MONTH. DISTINCT cm-nw-* ids/classes from the hero's
   cm-dl-* (so the two never collide). Reuses the hero's cmRidePulse keyframes +
   the slider focus ring + amt-btn styles. */
.cm-nw-hero { position: relative; overflow: hidden; }
.cm-nw-hero .cm-nw-laurel {
  position: absolute; right: -36px; top: -22px; pointer-events: none; opacity: .06;
}
.cm-nw-amt {
  font-size: 36px; font-weight: var(--weight-black); letter-spacing: -.035em;
  color: var(--text-body); line-height: 1; white-space: nowrap;
}
.cm-nw-badge { font-size: 12px; flex: none; }
.cm-nw-sub { color: var(--muted); }
.cm-nw-chart { position: relative; }
/* The scrub wrap inherits the same no-select / touch-action lock as the hero. */
.cm-nw-scrub { user-select: none; -webkit-user-select: none; touch-action: none; }
/* Series dim transition while a finger is down (set via inline opacity). */
.cm-nw-series { transition: opacity .18s; }
/* Full-bleed push-and-drag surface (mirror .cm-dl-surf). */
.cm-nw-surf {
  position: absolute; inset: 0; touch-action: none; cursor: grab; outline: none;
}
.cm-nw-surf:active { cursor: grabbing; }
.cm-nw-surf:focus-visible { box-shadow: inset 0 0 0 2px var(--ring-focus); border-radius: 8px; }
/* Home net-worth card acting as a link to the Accounts chart (renderNetWorthV2). */
.cm-nw-link { cursor: pointer; }
.cm-nw-link:focus-visible { outline: 2px solid var(--ring-focus); outline-offset: 2px; border-radius: var(--radius-md); }
/* Single riding dot (HTML overlay, % positioned → stays circular under stretch). */
.cm-nw-ride {
  position: absolute; transform: translate(-50%, -50%); border-radius: 50%;
  border: 2px solid var(--surface-card); pointer-events: none;
}
/* (reuses .cm-ride-pulse + @keyframes cmRidePulse from the hero scrub above.) */
/* Value callout floating above the dot — that month's net worth. */
.cm-nw-callout {
  position: absolute; transform: translate(-50%, calc(-100% - 14px));
  background: var(--accent); color: var(--text-on-accent);
  padding: 5px 9px; border-radius: 8px; white-space: nowrap; text-align: center;
  line-height: 1.15; pointer-events: none;
  box-shadow: 0 4px 14px color-mix(in srgb, var(--accent) 32%, transparent);
}
.cm-nw-callout-amt { font-size: 14px; font-weight: var(--weight-black); letter-spacing: -.01em; }
.cm-nw-callout-sub { font-size: 9px; font-weight: var(--weight-semibold); opacity: .85; letter-spacing: .02em; }
/* Callout extra rows — the Assets + Liabilities values under the net-worth headline. */
.cm-nw-callout-row {
  display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
  font-size: 10px; font-weight: var(--weight-semibold); opacity: .92; margin-top: 2px;
}
.cm-nw-callout-row b { font-weight: var(--weight-bold); }
.cm-nw-callout-k { opacity: .8; }
/* Three-series legend swatches (token colors only). */
.cm-hero-sw.cm-nw-sw-net    { background: var(--accent); }
.cm-hero-sw.cm-nw-sw-net.over { background: var(--danger); }
.cm-hero-sw.cm-nw-sw-assets { background: var(--good); }
.cm-hero-sw.cm-nw-sw-liab   { background: var(--amber); }
/* A side riding dot (Assets/Liabilities) — thinner ring than the primary net-worth dot. */
.cm-nw-ride.side { border-width: 1.5px; }
/* Month-strip Assets/Liabilities value accents. */
.cm-nw-month-assets b { color: var(--good); }
.cm-nw-month-liab b   { color: var(--amber); }
/* Month strip (fills on scrub/pin; empty otherwise; min-height reserved so the
   chart/axis don't shift when it appears). */
.cm-nw-monthstrip {
  min-height: 30px; display: flex; align-items: center;
  gap: 10px; flex-wrap: wrap; margin: 6px 0 2px;
}
.cm-nw-month-info { display: flex; align-items: baseline; gap: 10px 14px; flex-wrap: wrap; min-width: 0; }
.cm-nw-month-date { font-size: var(--text-sm); font-weight: var(--weight-bold); color: var(--text-body); }
.cm-nw-month-val { font-size: var(--text-xs); color: var(--muted); }
.cm-nw-month-val b { font-weight: var(--weight-bold); color: var(--text-body); }
.cm-nw-month-delta { font-size: var(--text-xs); color: var(--muted); }
.cm-nw-month-delta b { font-weight: var(--weight-bold); }
.cm-nw-month-delta.up b { color: var(--good); }
.cm-nw-month-delta.down b { color: var(--danger); }
/* Row 2 — the "View transactions" slot (min-height reserved, no reflow). */
.cm-nw-row2 { display: flex; align-items: center; justify-content: center; min-height: 30px; margin-top: 2px; }
.cm-nw-row2-slot { flex: none; }
.cm-nw-month-see { flex: none; font-size: 12px; gap: 5px; padding: 0 9px; height: 30px; min-height: 0; }
/* The NW drilldown reuses #drill-backdrop; like the hero source, hide the header's
   "View Transactions →" replay button (it would replay the wrong Stats query). */
#drill-backdrop[data-source='networth'] .modal-head button.btn { display: none; }

/* Clickable hero total — drills into the full month-to-date ledger sheet. */
.cm-hero-amt-btn {
  display: inline-flex; align-items: center; gap: 2px;
  background: none; border: none; padding: 2px 4px; margin: -2px -4px;
  border-radius: 8px; cursor: pointer; color: inherit;
  transition: background .14s var(--ease-standard);
}
.cm-hero-amt-btn:hover { background: color-mix(in srgb, var(--accent) 8%, transparent); }
.cm-hero-amt-btn:active { transform: translateY(1px); }
.cm-hero-amt-btn:focus-visible { outline: 2px solid var(--ring-focus); outline-offset: 2px; }
.cm-hero-amt-btn .cm-icon, .cm-hero-amt-btn svg { transition: transform .14s var(--ease-standard); }
.cm-hero-amt-btn:hover .cm-icon, .cm-hero-amt-btn:hover svg { transform: translateX(2px); }

/* ── Activity screen V2 (mobile Activity tab) ──────────────────────────────── */
.cm-activityv2 { display: none; flex-direction: column; gap: 14px; }
/* The history-floor note renders on every Activity paint and is blank unless the chosen range
   reaches past the oldest transaction we hold — which is the uncommon case. Blank is NOT free
   in a flex column: the node still contributes the 14px gap above it plus its own margin and
   padding, so without this it parked ~40px of dead space under the ledger on the ordinary
   render. :empty collapses it until there is actually something to say. */
#act-floor-note:empty { display: none; }
.cm-act-head { display: flex; align-items: center; justify-content: space-between; }
.cm-act-head h1, .cm-secthead h1 {
  margin: 0; font-size: 22px; font-weight: var(--weight-black);
  letter-spacing: -.01em; color: var(--text-body);
  /* Never break the title mid-word at narrow widths — keep it whole + let the filter-note (flex:1)
     absorb the shrink and ellipsize instead ("Activity", not "Activi/ty"). */
  white-space: nowrap; flex: 0 0 auto;
}
/* ── Page title — ONE composition for every panel and screen title ──────────────────
   A small laurel branch to the LEFT of the title, everywhere (operator directive,
   2026-08-01). Only Dashboard and Alerts carried one before, at data-size 40 and 42.
   data-size is the branch's WIDTH against a 120×200 viewBox, so those rendered 67px and
   70px TALL against a ~26px title line and stretched the title row to nearly three times
   the text — the inflation the directive calls out.

   So the rendered height is pinned HERE rather than trusted to a per-call-site data-size:
   no future call site can inflate a title row by asking for a big laurel, and the aspect
   ratio still comes from the viewBox. The height is under the 22px title's line box, which
   is what keeps the row exactly as tall as it was before the laurel existed.

   26px against the 22px title's 28px line box: sized by eye against the rendered thing, not
   picked as a round number. It leaves 2px of headroom, so the row measures 28px with the laurel
   exactly as it did without one, and the branch still stands the full height of the word beside
   it. 22px was the first try and read as a smudge rather than a laurel.

   data-stroke is still the call site's job and does need saying: stroke width is in the SVG's
   own user units, so it does NOT scale with the box. At 13px wide the default 2 resolves to
   about a fifth of a pixel, the spine disappears and the leaves come off their branch. Every
   title host passes 12. */
.cm-title-laurel { flex: none; align-self: center; opacity: .9; }
.cm-title-laurel svg { height: 26px; width: auto; }
/* Laurel + heading as one unit, for the heads that are not already flex rows (and for
   .panel-head, where the laurel must join the title on the left rather than become a third
   space-between item that lands in the middle of the row).
   .cm-acct-head and .cm-prof-head-row are the same thing under older names — folded in here so
   there is one title-row behaviour instead of three. .cm-acct-head's own `justify-content:
   space-between` is deliberately NOT carried over: it only ever holds the title, so with a
   laurel beside it space-between would push the two to opposite ends of the screen. */
/* flex:0 0 auto matters inside .cm-secthead/.cm-act-head: those are space-between rows, so
   without the wrapper the laurel would become its own flex item and get flung to the far end
   of the row from the title it belongs to (visible on Categories, which has nothing else in
   the row, and on Activity whenever the filter-note is absent). Ignored where .cm-title-row
   is a plain block. */
.cm-title-row, .cm-acct-head, .cm-prof-head-row {
  display: flex; align-items: center; gap: 9px; min-width: 0; flex: 0 0 auto;
}
.cm-title-row > h1, .cm-title-row > h2 { margin: 0; }

/* (.cm-act-search reuses .cm-iconbtn, shown via the mobile media-query rule.) */
/* Sort + search sit together on the right. position/z-index so the sort popover paints ABOVE the
   ledger cards below it rather than behind them. */
.cm-act-head { position: relative; z-index: 2; gap: 8px; }
.cm-act-actions { display: flex; align-items: center; gap: 6px; flex: 0 0 auto; }
/* Match the 36px dir button (and roughly the search button) — the default .cm-btn.xs is 28px,
   which left the Sort pill floating short next to its own arrow. */
.cm-act-actions .cm-btn.xs { height: 36px; }

/* Spent / Earned 2-card summary. */
.cm-act-summary { display: flex; gap: 10px; }
.cm-act-stat { flex: 1; min-width: 0; padding: 12px 14px; }
.cm-act-stat .cm-label { font-size: 10px; }
.cm-act-amt { font-size: 18px; font-weight: var(--weight-black); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* Very narrow phones (≤360px): step the KPI value + label down so a 6-figure value doesn't clip to
   "$2…" and labels ("SAVE RATE") don't wrap mid-word in the 3-up summary. */
@media (max-width: 360px) {
  .cm-act-amt { font-size: 15px; }
  .cm-act-stat { padding: 11px 10px; }
  .cm-act-stat .cm-label { letter-spacing: 0; white-space: nowrap; }
}
.cm-nw-hidden { height: 80px; display: flex; align-items: center; justify-content: center;
  color: var(--muted); font-size: 11px; letter-spacing: .08em; text-transform: uppercase; border-radius: 10px;
  background: repeating-linear-gradient(45deg, color-mix(in srgb, var(--text-body) 5%, transparent) 0 7px, transparent 7px 14px); }
/* On-track confirmation — a quiet on-theme green check that replaces the old "On track" pills. */
/* The all-clear check — GREEN again. Badges are the app's ONE dynamically-coloured element per card
   (user, 2026-07-14), so they are the place colour is allowed to move. I had turned this Tyrian while
   purging green; that was the wrong element to purge. [one-dynamic-element rule] */
.cm-ok { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px;
  border-radius: 999px; flex: none; color: var(--good);
  background: color-mix(in srgb, var(--good) 15%, transparent); }

/* CompactIncome strip (relocated from the dashboard hero). */
/* The CompactIncome strip and its fill bar were removed — received-vs-estimate now rides as
   subtext under the Earned figure in the Activity summary. Its .cm-ci-* rules went with it;
   nothing in the markup referenced them any more. */
/* A monthlyColumns bar that drills in. Must reset the UA button chrome or the columns
   inherit borders/background and stop looking like a chart. */
button.cm-mcol { appearance: none; border: 0; background: none; padding: 0; font: inherit;
                 color: inherit; cursor: pointer; }
button.cm-mcol:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }
button.cm-mcol.is-clickable:hover .cm-mcol-fill { filter: brightness(1.08); }

/* "$10k expected" under the Earned figure. Deliberately WRAPS, unlike its sibling .cm-act-amt,
   which clips to an ellipsis: the word carries the meaning here, so "$25.5k expec…" would throw
   away the only thing that distinguishes this from a second earned figure. It was nowrap while it
   held a bare "$10k"; adding the word broke that — measured at 320px, "$25.5k expected" needs 75px
   of the 69.3px the card gives it, and no font step fixes it (even 9px still needs 75px). Wrapping
   costs 13px of card height and all three summary cards stretch together, so the row stays level.
   The figure keeps nowrap so the wrap can only land at the space, never inside "$123.4k"; overflow
   is the floor if some future value can't fit even alone. */
.cm-act-sub {
  font-size: 10.5px; font-weight: 600; color: var(--muted);
  margin-top: 2px; overflow: hidden;
}
.cm-act-sub .cm-tnum { white-space: nowrap; }

/* Filter rail (pill segmented control; active = surface-card + shadow). */
.cm-act-rail {
  display: flex; gap: 6px; padding: 4px;
  background: var(--surface-sunken); border-radius: var(--radius-pill);
}
.cm-act-pill {
  flex: 1; min-width: 0; padding: 7px 4px; border: none; cursor: pointer;
  border-radius: var(--radius-pill); font: inherit;
  font-size: var(--text-xs); font-weight: var(--weight-bold); white-space: nowrap;
  background: transparent; color: var(--muted);
  transition: background var(--duration-fast) var(--ease-standard);
}
.cm-act-pill.on { background: var(--surface-card); color: var(--accent); box-shadow: var(--shadow-card); }

/* Date-grouped ledger. */
.cm-act-ledger { display: flex; flex-direction: column; gap: 16px; }
.cm-act-group { margin-bottom: 6px; }

/* Filter/search button — a prominent Tyrian-FILLED magnifying glass, standardized
   across the Stats + Activity headers so the filter affordance reads clearly. */
.cm-act-search {
  position: relative;
  background: var(--accent); border-color: var(--accent); color: var(--text-on-accent);
}
.cm-act-search:hover {
  background: color-mix(in srgb, var(--accent) 86%, #000);
  border-color: color-mix(in srgb, var(--accent) 86%, #000);
}
.cm-act-search.has-filters::after {
  content: ""; position: absolute; top: 5px; right: 5px;
  width: 9px; height: 9px; border-radius: 50%;
  background: var(--amber); border: 2px solid var(--accent);   /* gold "active filters" dot, pops on the Tyrian fill */
}

/* ── Wave-1 mobile screens (Profile / Accounts) — host stacks + section heads.
   Hosts are display:none by default; the mobile @media gate (below) flips them to
   flex and hides the desktop siblings. Composed from the existing .cm-* inventory;
   only the page-specific layout glue lives here. ───────────────────────────── */
.cm-profilev2, .cm-accountsv2, .cm-alertsv2, .cm-statsv2, .cm-categoriesv2, .cm-settingsv2 { display: none; flex-direction: column; gap: 14px; }
.cm-prof-head-row h1, .cm-acct-head h1 {
  margin: 0; font-size: 22px; font-weight: var(--weight-black);
  letter-spacing: -.01em; color: var(--text-body);
}

/* ── Wave-2 mobile screens (Alerts / Stats) ──────────────────────────────── */
/* Alerts: per-rule action strip under the meter (toggle + Edit/Delete). */
.cm-rule-actions { display: flex; align-items: center; gap: 6px; margin-top: 12px; }
.cm-rule-actions .switch { font-size: var(--text-xs); }

/* Stats: condensed stat row (tappable → drilldown), full-width like a ledger line. */
.cm-stat-row {
  display: flex; align-items: center; gap: 12px; width: 100%; text-align: left;
  padding: 11px 0; border-bottom: 1px solid var(--border-hairline);
  background: transparent; border-left: none; border-right: none; border-top: none;
  font: inherit; color: inherit; cursor: pointer;
}
.cm-stat-row:last-child { border-bottom: none; }
.cm-stat-row:active { background: color-mix(in srgb, var(--accent) 7%, transparent); }

/* Stats: collapsible section (native <details>; chevron rotates when open). */
.cm-stat-sect { padding: 0; overflow: hidden; }
.cm-stat-summary {
  display: flex; align-items: center; justify-content: space-between; gap: 8px;
  padding: 14px 16px; cursor: pointer; list-style: none; user-select: none;
}
.cm-stat-summary::-webkit-details-marker { display: none; }
.cm-stat-secttitle { font-size: 16px; font-weight: var(--weight-bold); color: var(--accent);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cm-stat-chev { color: var(--muted); flex: none; transition: transform var(--duration-fast) var(--ease-standard); }
.cm-stat-sect[open] .cm-stat-chev { transform: rotate(180deg); }
.cm-stat-body { padding: 0 16px 14px; }
/* (.cm-acct-head's flex row now comes from the shared .cm-title-row rule above — this used to
   restate it with justify-content:space-between, which would fling the laurel and the title to
   opposite edges.) */

/* Home accounts card (TASK3): the by-type totals chips live INSIDE the <summary>
   so they show on the COLLAPSED card (two types per line on mobile); the per-type
   subheading inside the body is just the group label (the total is the chip). */
/* Strip ABOVE the accounts card: vault state (left) + Add account (right).
   Outside <details> on purpose — inside a <summary> every click toggles the card.

   SPACING IS LOAD-BEARING (taste review 2026-07-31). It was 18px above / 8px below, which
   rendered near-equidistant and made the strip read as an orphan row floating on parchment
   rather than as this card's header. Proximity is the only thing saying "these belong
   together", so the gap below must be clearly tighter than the gap above.

   PADDING IS ALIGNMENT, not decoration: 16px matches the card's own inner padding, so the
   chip's glyph sits on the same vertical rail as the card's building icon. Aligning to the
   card's OUTER edge (the old `padding: 0 2px`) put them 18px apart and only the right edge
   lined up, by coincidence. */
.cm-home-acct-strip {
  display: flex; align-items: center; justify-content: space-between; gap: 10px;
  /* padding-left 7 + the chip's own 10 = 17, which is exactly the card icon's inset from the
     card's left edge (measured: card at x=32, its icon at x=49). Both chip STATES must therefore
     carry the same horizontal padding, or the rail would shift when it flips. */
  margin: 26px 0 9px; padding: 0 7px; min-height: 28px;
}
.cm-home-acct-strip .cm-home-addacct { margin: 0; flex: none; }

/* Vault chip — quiet by default. It is reassurance, not a badge to admire:
   muted text, no fill, no border when unlocked. The LOCKED state is the only one
   that earns colour, because it is the only one with something to do. */
.cm-vault-chip {
  display: inline-flex; align-items: center; gap: 5px; min-width: 0;
  font-size: var(--text-xs); font-weight: var(--weight-semibold);
  letter-spacing: .01em; border-radius: 999px; padding: 4px 10px;
  background: none; border: none; color: var(--muted);
  white-space: nowrap;   /* "Locked" wrapped to two lines once the rail inset was added */
}
.cm-vault-chip.is-open { cursor: default; }
.cm-vault-chip.is-open i { color: var(--laurel, #557a36); }
/* The locked chip is a real control, so it needs a real tap target. The visual pill stays
   small; ::before extends the hit area to >=44px tall without changing how it looks. */
.cm-vault-chip.is-locked {
  cursor: pointer; color: var(--amber); padding: 4px 10px;
  background: color-mix(in srgb, var(--amber) 10%, transparent);
  position: relative;
}
.cm-vault-chip.is-locked::before {
  content: ''; position: absolute; left: 0; right: 0;
  top: 50%; transform: translateY(-50%); height: 44px;
}
.cm-vault-chip.is-locked:hover { background: color-mix(in srgb, var(--amber) 16%, transparent); }

/* Add-account chooser rows — inside the shared bottom sheet (.cm-choice-sheet), so they
   inherit its chassis and only need row styling. Two-line rows because each option needs a
   sentence to distinguish it; .cm-more-item's one-liners would not carry that. */
.cm-choice-item {
  display: flex; align-items: center; gap: 12px; width: 100%;
  padding: 14px 6px; margin: 0; background: none; border: none; cursor: pointer;
  text-align: left; border-bottom: 1px solid color-mix(in srgb, var(--border) 55%, transparent);
}
.cm-choice-item:last-child { border-bottom: none; }
.cm-choice-item .ic {
  width: 38px; height: 38px; flex: none; border-radius: 10px;
  display: flex; align-items: center; justify-content: center;
  background: var(--accent); color: #fff;
}
.cm-choice-txt { flex: 1; min-width: 0; }
.cm-choice-txt .t { display: block; font-size: var(--text-sm); font-weight: var(--weight-bold); color: var(--text-body); }
.cm-choice-txt .s { display: block; font-size: var(--text-xs); color: var(--muted); margin-top: 2px; line-height: 1.4; }
.cm-choice-chev { color: var(--muted); flex: none; }

/* Desktop form (a centred dialog) lives in index.html's own <style>, scoped to
   min-width:761px — it has to, because that block is linked AFTER this file and hides
   .cm-more-sheet on desktop, so a rule here could not win. Below 761px the chooser is the
   bottom sheet, inheriting .cm-more-sheet's chassis untouched. */

.cm-home-acct-summary { flex-direction: column; align-items: stretch; gap: 12px; }
.cm-home-acct-toprow { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.cm-home-acct-chips {
  display: grid; grid-template-columns: 1fr 1fr; gap: 8px 14px;
  padding: 0;   /* now sits inside the already-padded <summary> */
}
.cm-home-acct-chip {
  display: flex; flex-direction: column; gap: 1px; min-width: 0;
  padding: 8px 10px; border-radius: var(--radius-sm);
  background: var(--surface-sunken);
}
.cm-home-acct-chip .k {
  font-size: var(--text-xs); color: var(--muted); font-weight: var(--weight-semibold);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.cm-home-acct-chip .v { font-size: var(--text-sm); font-weight: var(--weight-bold); color: var(--text-body); }
.cm-home-acct-chip .v.neg { color: var(--amber); }
.cm-home-acct-grouph {
  display: flex; align-items: baseline; justify-content: space-between; gap: 8px;
  margin: 4px 0 2px; padding-top: 6px;
  font-size: var(--text-xs); font-weight: var(--weight-bold); letter-spacing: .02em;
  text-transform: uppercase; color: var(--muted);
}
.cm-home-acct-grouph:first-child { padding-top: 0; }
.cm-home-acct-grouph .cm-tnum { color: var(--text-body); text-transform: none; letter-spacing: 0; }
.cm-home-acct-grouph .cm-tnum.neg { color: var(--amber); }

/* Profile header card. */
.cm-prof-head { display: flex; align-items: center; gap: 12px; }
.cm-prof-name {
  font-size: var(--text-md); font-weight: var(--weight-bold); color: var(--text-body);
  /* Truncate a long email cleanly instead of breaking mid-domain ("gmail.co / m"). (QA fix: email wrap) */
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%;
}
/* Stacked rows (members / pending / accounts) inside a card. */
.cm-prof-rows { display: flex; flex-direction: column; gap: 10px; }

/* Accounts net-position card + group-by control. */
.cm-acct-net { position: relative; overflow: hidden; }
.cm-acct-net-val {
  font-size: var(--text-2xl); font-weight: var(--weight-black); letter-spacing: -.02em;
  line-height: 1.1; margin: 6px 0 12px; position: relative; color: var(--text-body);
}
.cm-acct-net-badges { display: flex; flex-wrap: wrap; gap: 8px; position: relative; }
/* The "this total is incomplete" caveat, bound to the Net position figure rather than floating
   below the card — a member reading a confident $0.00 for a bank that merely needs reconnecting
   is the failure this exists to prevent. Amber, not red: the reconnect banner owns the alarm;
   this is the figure qualifying itself. */
.cm-acct-partial-note {
  display: flex; align-items: flex-start; gap: 7px; position: relative;
  margin: 2px 0 10px; font-size: var(--text-sm); line-height: 1.45; color: var(--warn-text);
}
.cm-acct-partial-note svg { flex: 0 0 auto; margin-top: 2px; }
/* min-width:0 + wrap: the span is a flex item (default min-width:auto) inside a card with
   overflow:hidden, so a long unbreakable bank name would clip SILENTLY with no ellipsis. Same fix
   the ops feed carries. [js-review] */
.cm-acct-partial-note > span { min-width: 0; overflow-wrap: anywhere; }
.cm-acct-net-partial {
  font-weight: 600; color: var(--warn-text); letter-spacing: 0; text-transform: none;
}
/* An inline "Reconnect" inside the caveat sentence — the member needs a way back on the one screen
   that may have no other Reconnect affordance, and a full .cm-btn mid-sentence reads as a banner. */
.cm-linkish {
  background: none; border: 0; margin: -2px 0; font: inherit; color: inherit; font-weight: 700;
  text-decoration: underline; text-underline-offset: 2px; cursor: pointer;
  display: inline-block;
  /* Vertical padding with a negative margin: this is the primary affordance for fixing a WRONG
     MONEY FIGURE, and at 14px the bare text is ~19px tall against the 44px floor this app states
     in five other places. The negative margin keeps it on the sentence's baseline. */
  padding: 2px 3px;
}
.cm-linkish:hover { opacity: .8; }
/* The disabled state matters more here than on a normal button: reconnectBank holds it disabled
   for the whole external ASWebAuthenticationSession on the native rail, and without this it stays
   fully lit with cursor:pointer — the silent dead tap this codebase refuses elsewhere. */
.cm-linkish:disabled { opacity: .5; cursor: not-allowed; text-decoration: none; }
/* Several broken banks get real tap targets on their own line rather than underlined words
   mid-sentence, which are unhittable on a phone. */
.cm-acct-partial-cta { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 10px; position: relative; }
/* .cm-btn.xs is 28px — SMALLER than the inline link it replaces, on the case this treatment calls
   the most severe. 44px is the floor .cm-btn's own base sets and that five other rules restate;
   two in-file precedents already scope an override this way (.cm-act-actions, .cm-suggest-actions). */
.cm-acct-partial-cta .cm-btn { min-height: 44px; }
/* .cm-btn is white-space:nowrap, so a long nickname on its own wrapped line is shrunk to the line
   width and its label clipped SILENTLY by the card's overflow:hidden — no ellipsis, no scroll.
   renameBank collects nicknames through a bare prompt() with no maxlength. */
.cm-acct-partial-cta .cm-btn { max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
/* ghost inherits a transparent border, so on parchment the row reads as three purple labels rather
   than controls — and it lost the underline in the move off .cm-linkish. Give it an edge. */
.cm-acct-partial-cta .cm-btn.ghost { border-color: var(--border-strong, var(--border)); }
.cm-linkish:focus-visible { outline: 2px solid var(--glow); outline-offset: 2px; border-radius: 3px; }
.cm-acct-groupby { display: flex; align-items: center; gap: 10px; }
.cm-acct-groupby select { flex: 1; }
.cm-acct-group { display: flex; flex-direction: column; }
.cm-acct-grouptotal { font-size: var(--text-sm); font-weight: var(--weight-bold); color: var(--text-body); }
.cm-acct-grouptotal.neg { color: var(--amber); }
.cm-acct-excluded { opacity: .62; }

/* ── Wave-3 mobile screen (Categories) ───────────────────────────────────── */
/* Each category is a full-width card: label on top, stacked toggles, then (for
   custom categories) a Rename/Delete action group. Composed from .cm-card + .tgl. */
/* Denser list treatment: strip the heavy per-item card chrome so ~20 categories read as a
   scannable list of hairline-divided rows, not a wall of identical cards. (QA fix #7) */
.cm-catv2 { padding: 12px 2px; border: none; border-radius: 0; box-shadow: none;
  background: transparent; margin: 0; border-bottom: 1px solid var(--border); }
/* Grouped-list treatment (taste 2026-07-25): the hairline rows keep their density but live
   INSIDE one .cm-card per section, restoring the card language everything around them uses.
   Deliberate double-wrap: each row still carries .cm-card in markup, and .cm-catv2 above
   strips its chrome — don't "simplify" either half or the rows regrow borders/shadows. */
.cm-catv2-group { padding: 4px 16px; }

/* Recurring list group/sort controls — ONE compact row above #recurring-list. Selects shrink
   (min-width:0) so the direction button never wraps to its own line at 390px; the global
   input/select rule would otherwise stretch them to 100%. */
.cm-recur-controls { display: flex; align-items: center; gap: 8px; margin: 12px 0 10px; }
.cm-recur-controls select { width: auto; flex: 1 1 auto; min-width: 0; padding: 7px 10px; font-size: 13px; }
.cm-recur-controls .cm-btn { flex: 0 0 auto; }
.cm-recur-controls .cm-label { flex: 0 0 auto; white-space: nowrap; }   /* "GROUP" must not wrap to "GROU P" */
.cm-recur-controls[hidden] { display: none; }   /* display:flex would defeat the UA [hidden] rule */
.cm-catv2-group .cm-catv2:last-child { border-bottom: none; }
/* The global form `label { text-transform: uppercase }` rule catches these toggle <label>s and
   made every row shout "IS INCOME?" — they're sentence-case settings rows, not micro-labels. */
.cm-catv2-tgl { text-transform: none; letter-spacing: 0; }
.cm-catv2-head { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.cm-catv2-name { font-size: var(--text-md); font-weight: var(--weight-bold); color: var(--text-body); }
.cm-catv2-tags { display: inline-flex; flex-wrap: wrap; gap: 6px; }
/* Toggles STACKED (not horizontal): label on the left, switch on the right. */
.cm-catv2-toggles { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; }
.cm-catv2-tgl { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; }
.cm-catv2-tgl-lbl { flex: 1; min-width: 0; font-size: var(--text-sm); color: var(--text-body); }
/* A locked category's toggles are non-interactive — dim them to read as disabled. */
.cm-catv2-tgl input:disabled ~ .tgl-track,
.cm-catv2-tgl:has(input:disabled) { opacity: .72; cursor: default; }
.cm-catv2-actions { display: flex; gap: 8px; margin-top: 14px; }
.cm-catv2-actions .cm-btn { flex: 1; }
/* The auto-rule toggle row inside the collapsible Categorization-rules card. */
.cm-catv2-autorule { display: flex; align-items: center; gap: 12px;
  border: 1px solid var(--border); border-radius: var(--radius-md); padding: 11px 13px; }
/* Mobile categorization-rule rows: summary on top, action strip below. */
.cm-crulev2-top { display: flex; align-items: flex-start; gap: 8px; min-width: 0; }
.cm-crulev2-desc { font-size: var(--text-sm); color: var(--text-body); min-width: 0; line-height: 1.4; }

/* ── Suggested-recurring: compact trigger + reformatted modal rows ──────────── */
/* Slim one-line banner/button rendered into #recurring-suggestions; opens the modal. */
.cm-suggest-trigger {
  display: flex; align-items: center; justify-content: space-between; gap: 12px;
  width: 100%; margin-bottom: 16px; padding: 11px 14px; cursor: pointer;
  border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border-hairline));
  border-radius: var(--radius-md); font: inherit; text-align: left;
  background: color-mix(in srgb, var(--accent) 7%, var(--surface-card));
  color: var(--text-body);
  transition: background var(--duration-fast) var(--ease-standard);
}
.cm-suggest-trigger:hover { background: color-mix(in srgb, var(--accent) 12%, var(--surface-card)); }
.cm-suggest-trig-l { display: inline-flex; align-items: center; gap: 8px; min-width: 0;
  font-size: var(--text-sm); font-weight: var(--weight-semibold); }
.cm-suggest-trig-l i[data-lucide] { color: var(--accent); flex: none; }
.cm-suggest-trig-r { display: inline-flex; align-items: center; gap: 4px; flex: none;
  font-size: var(--text-xs); font-weight: var(--weight-bold); color: var(--accent); }

/* Modal rows: merchant gets the width; actions ~a third, comfortably tappable. */
.cm-suggest-row {
  display: flex; align-items: center; gap: 12px;
  padding: 12px 0; border-top: 1px solid var(--border-hairline);
  cursor: pointer; border-radius: 8px;
}
.cm-suggest-row:hover { background: color-mix(in srgb, var(--accent) 5%, transparent); }
.cm-suggest-row:first-child { border-top: none; }
.cm-suggest-chip {
  width: 36px; height: 36px; flex: none; border-radius: var(--radius-sm);
  display: inline-flex; align-items: center; justify-content: center;
  background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--accent);
}
.cm-suggest-main { flex: 1 1 auto; min-width: 0; }
.cm-suggest-name {
  font-weight: var(--weight-bold); font-size: 15px;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* Tonal "Add" — clearly actionable but not a solid block, so a list of them doesn't read as a wall. */
.cm-suggest-actions .cm-btn.cm-suggest-add {
  background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--accent); border: none;
}
.cm-suggest-actions .cm-btn.cm-suggest-add:hover { background: color-mix(in srgb, var(--accent) 20%, transparent); }
.cm-suggest-meta { font-size: var(--text-xs); color: var(--muted); margin-top: 2px;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cm-suggest-actions { display: flex; align-items: center; gap: 6px; flex: 0 0 auto; }
.cm-suggest-actions .cm-btn.sm { min-height: 36px; }
/* Higher specificity than .cm-btn.ghost (which sets --brand) so the dismiss × stays a quiet
   grey and doesn't compete with the tonal Add. */
.cm-suggest-actions .cm-btn.cm-suggest-dismiss {
  width: 36px; padding: 0; font-size: 18px; line-height: 1; color: var(--muted);
}

/* Bank-link launch overlay — hidden by default (DESKTOP never shows it). The full
   fixed-overlay styling lives inside the mobile @media block below; here we only
   ensure the element is inert on desktop (mirrors .cm-bnav/.cm-sheet-scrim base). */
.cm-link-ov { display: none; }
/* ...EXCEPT in its error state. The working scrim is deliberately mobile-only (desktop gets
   Plaid's own drop-in, so a full-screen wait would be noise) — but the FAILURE path in
   connectBank() is reachable ONLY on desktop, so leaving this display:none meant a failed bank
   link there rendered nothing at all. Centred card rather than the mobile full-bleed scrim.
   [js-review Blocker on f23989f] */
.cm-link-ov.is-error {
  /* 1150, not 1200: #checkout-sheet reserves 1200 with a comment saying it hosts a PAYMENT form
     and must top everything. Not reachable together today (billingCanSync() gates before any
     overlay code), but squatting on a number this codebase treats as reserved is how that stops
     being true quietly. [js-review Minor on 250f302] */
  display: flex; position: fixed; inset: 0; z-index: 1150;
  flex-direction: column; align-items: center; justify-content: center; gap: 10px;
  padding: 28px 22px; text-align: center;
  /* OPAQUE, not a tint. The old 46%-ink scrim was written for desktop; on mobile the @media
     block's parchment-blur base showed through it and the white text below sat on beige with a
     laurel behind — QA could not read the middle of the failure message (#175 M4, screenshot
     05_s3f_connect_fail_ux.png). A solid parchment ground with ink text is the brand's own
     surface treatment and reads identically on every viewport and in the boot race where
     backdrop-filter hasn't composited yet. */
  background: var(--bg, #fbf6ea);
  opacity: 1; pointer-events: auto;
  /* A short window must never put Retry/Close out of reach — there is no other way out of a
     pointer-events:auto full-screen scrim. [js-review Major on 250f302] */
  overflow-y: auto;
}
.cm-link-ov.is-error .cm-link-ov-title {
  font-family: var(--font-display, inherit); font-size: 20px; font-weight: 700;
  color: var(--text-heading, var(--ink, #221c10)); max-width: 30ch;
}
.cm-link-ov.is-error .cm-link-ov-sub {
  font-size: 14.5px; line-height: 1.5; max-width: 42ch;
  color: var(--text-muted, #6b6353);
}
/* The laurel's position:absolute lives ONLY inside the mobile @media block, so on the desktop
   surface this rule set newly exposes it rendered as a 220x220 in-flow flex child ahead of the
   title — a giant wreath squeezed above the headline rather than a watermark behind it. Scoped
   here so the error card looks deliberate on every viewport. [js-review Major on 250f302]
   Opacity .08, down from .18: on the now-opaque parchment ground the wreath sits DIRECTLY
   behind the message text, and at .18 it read as strokes through the words (#175 M4). */
.cm-link-ov.is-error .cm-link-ov-laurel {
  position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
  opacity: .08; pointer-events: none; max-width: 62vmin; height: auto;
}
.cm-link-ov.is-error .cm-btn { margin-top: 4px; }

/* [hidden] needs an explicit escape hatch whenever an author `display` rule would outrank the
   UA's — the same gotcha already fixed on .cm-btn, .cm-divider, .cm-recur-controls,
   .cm-sortctl-menu and #cm-offline. Without it, hiding the trust note in the error state was a
   silent no-op and "We never see your bank login" sat under the failure message.
   [js-review Minor on f23989f] */
.cm-trustnote[hidden] { display: none; }

/* ── Drilldown modal: pin the header on DESKTOP too ────────────────────────────────────────
   The desktop modal had no max-height and let the BACKDROP scroll, with .modal-head relying on
   position:sticky (index.html:407). On a long list the sheet grew taller than the viewport
   (measured 2355px for 36 rows), so rows scrolled up into the backdrop's 48px padding strip and
   rendered ABOVE the stuck header — the title read as floating mid-list over the transactions.
   Mirror what the mobile bottom sheet already does: cap the sheet, make it a flex column, and let
   ONLY .modal-body scroll, so no row can ever paint above the head. The mobile #drill-backdrop
   block further down overrides these (same specificity, later in source). */
#drill-backdrop .modal {
  max-height: calc(100vh - 96px);   /* the backdrop's 48px top + 48px bottom padding */
  display: flex;
  flex-direction: column;
  overflow: hidden;                 /* the body scrolls, not the sheet */
}
#drill-backdrop .modal-head { flex: 0 0 auto; }   /* genuinely pinned, not sticky */
/* min-height:0 is load-bearing — without it a flex child won't shrink below its content and the
   body would never scroll (the sheet would overflow instead). */
#drill-backdrop .modal-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
#drill-backdrop #drill-apply-foot { flex: 0 0 auto; }   /* apply bar stays put below the list */

/* ── Mobile drill Sort control ─────────────────────────────────────────────────────────────
   The sortable <thead> is hidden under 640px, so phones sort through this header control.
   Gated on the SAME 640px query that hides the thead — deliberately NOT the 760px isMobile(),
   which would show both affordances at once in the 641–760px band. */
/* The head must own a stacking context ABOVE the scrolling body, or the sort popover paints behind
   the transaction rows: .modal-body carries `-webkit-overflow-scrolling: touch`, which promotes it
   to its own compositing layer on iOS and lifts it over an unpositioned (static) header. */
#drill-backdrop .modal-head { position: relative; z-index: 2; }

/* Shared mobile sort control — two mounts: #drill-sort (every drilldown) and #activity-sort
   (the mobile Activity head). Visible by default; the DRILL copy is the one that has to hide on
   desktop, because there the sortable <thead> takes over. The Activity mount needs no gate — it
   lives inside .cm-activityv2, which is itself mobile-only. */
.cm-sortctl { display: inline-flex; position: relative; align-items: center; gap: 6px; flex: 0 0 auto; }
#drill-sort { display: none; }
@media (max-width: 640px), ((max-height: 540px) and (max-width: 1024px)) {
  #drill-sort { display: inline-flex; }
}
/* touch-action:manipulation kills iOS's double-tap-to-zoom on these controls (tapping Sort was
   zooming the whole page). */
.cm-sortctl, .cm-sortctl button { touch-action: manipulation; }
.cm-sortctl-dir { width: 36px; height: 36px; }
.cm-sortctl-menu {
  position: absolute; top: calc(100% + 6px); right: 0; z-index: 30; min-width: 164px;
  display: flex; flex-direction: column; gap: 2px; padding: 5px;
  background: var(--surface-card); border: 1px solid var(--border);
  border-radius: var(--radius-md); box-shadow: var(--shadow-card);
}
.cm-sortctl-menu[hidden] { display: none; }
.cm-sortctl-menu button {
  appearance: none; border: 0; background: none; text-align: left; cursor: pointer;
  padding: 11px 12px; border-radius: 8px; font: inherit;
  /* 16px: anything smaller and iOS auto-zooms the page when the control takes focus. */
  font-size: 16px; line-height: 1.2; color: var(--text-body);
}
.cm-sortctl-menu button.is-active {
  background: var(--surface-sunken); color: var(--accent); font-weight: var(--weight-bold);
}
/* With the Sort control present, the title + Sort + "View Transactions →" + close cannot share one
   header row on a 375px phone: the flex row squeezed the TITLE to a 3-line sliver that collided
   with the Sort button (it never "overflowed", so a width check misses it — verified visually).
   The hero and net-worth drills already hide this button (see the [data-source] rules above); do
   the same for the remaining sources on mobile, where the drilldown IS the transaction list. */
@media (max-width: 640px), ((max-height: 540px) and (max-width: 1024px)) {
  #drill-backdrop .modal-head button.btn { display: none; }
}

/* ── Mobile gate: show the V2 dashboard + eye toggle, hide the desktop one ──
   A landscape phone (≈844×390) is WIDER than 760px but SHORT — also treat short
   viewports as mobile (OR with max-height) so rotating keeps the mobile UI. Keep
   this list in lockstep with the index.html shell media query + the JS matchMedia
   gates (ACTIVITY_MQ / DASHV2_MQ). */
@media (max-width: 760px), ((max-height: 540px) and (max-width: 1024px)) {
  #panel-dashboard.active .cm-dashv2 { display: flex; }
  /* When the V2 host is active, hide the desktop dashboard cards on mobile.
     !important so it also overrides inline display styles (e.g. the heading row). */
  #panel-dashboard.active.v2 > :not(.cm-dashv2) { display: none !important; }
  .cm-iconbtn { display: inline-flex; }

  /* Activity: show the V2 screen, hide the desktop filter bar + table. */
  #panel-transactions.active .cm-activityv2 { display: flex; }
  #panel-transactions.active.v2 > :not(.cm-activityv2) { display: none !important; }

  /* Profile (Wave 1): show the V2 screen, hide the desktop cards. */
  #panel-profile.active .cm-profilev2 { display: flex; }
  #panel-profile.active.v2 > :not(.cm-profilev2) { display: none !important; }

  /* Accounts (Wave 1): show the V2 screen, hide the desktop net-position + list. */
  #panel-accounts.active .cm-accountsv2 { display: flex; }
  #panel-accounts.active.v2 > :not(.cm-accountsv2) { display: none !important; }

  /* Alerts (Wave 2): show the V2 screen, hide the desktop head + wizard + list. */
  #panel-alerts.active .cm-alertsv2 { display: flex; }
  #panel-alerts.active.v2 > :not(.cm-alertsv2) { display: none !important; }

  /* Stats (Wave 2): show the V2 screen, hide the desktop range-bar + body. */
  #panel-stats.active .cm-statsv2 { display: flex; }
  #panel-stats.active.v2 > :not(.cm-statsv2) { display: none !important; }

  /* Categories (Wave 3): show the V2 screen, hide the desktop cards + rule editor. */
  #panel-categories.active .cm-categoriesv2 { display: flex; }
  #panel-categories.active.v2 > :not(.cm-categoriesv2) { display: none !important; }

  /* Settings (Wave 4): show the V2 screen, hide the desktop title/cards. The desktop
     cards are MOVED inside #settingsv2 on mobile, so they show as the collapsibles'
     bodies; the hide rule targets only the panel's *direct* children that aren't the
     host (the moved cards are now descendants of the host, not direct children). */
  #panel-settings.active .cm-settingsv2 { display: flex; }
  #panel-settings.active.v2 > :not(.cm-settingsv2) { display: none !important; }

  /* A desktop card moved into a collapsible section body sheds its own card chrome
     (border / radius / shadow / padding) so it sits flush — the surrounding <details>
     .cm-card already supplies the surface. The inner section padding still applies. */
  .cm-setv2-slot > .cm-card {
    border: none; border-radius: 0; box-shadow: none; background: transparent;
    padding: 0; margin: 0;
  }
  .cm-setv2-slot { padding-top: 10px; }   /* breathing room below the <summary> hairline */
  .cm-setv2-signout { margin-top: 4px; color: var(--danger); }
  .cm-setv2-signout i[data-lucide] { color: var(--danger); }

  /* The Security / Appearance / Monthly-budget / Notifications cards each lead with their own
     seal+heading row; inside a collapsible that title is already in the <summary>, so hide the
     redundant inner one (mobile-only; the desktop card is untouched). Linked-accounts leads with
     a .panel-head (title row + Connect button) — hide just the title row there so the button
     survives (a bare div:first-child rule would hide the whole panel-head, button included). */
  #setv2-slot-security > .cm-card > div:first-child,
  #setv2-slot-appearance > .cm-card > div:first-child,
  #setv2-slot-budget > .cm-card > div:first-child,
  #setv2-slot-notifications > .cm-card > div:first-child { display: none; }
  #setv2-slot-banks > .cm-card > .panel-head > .modal-head-row { display: none; }

  /* ── Bank-link launch overlay (Wave-4) ──────────────────────────────────────
     A branded, full-screen scrim shown the instant the user taps Connect-a-bank,
     covering the gap while the Plaid Link SDK downloads + initialises. It is
     DISMISSED the moment Plaid's own iframe opens (and on exit/error/success).
     This is a WRAPPER around the vendor flow only — it never touches tokens. The
     copy is the security voice (plain, emerald, no laurel inside the trust note;
     the laurel here is a decorative brand watermark only). Mobile-only by virtue
     of living inside this @media block; the JS also gates on isMobile(). */
  .cm-link-ov {
    position: fixed; inset: 0; z-index: 90;   /* above the bottom-nav (60) + sheets (75) */
    display: flex; flex-direction: column; align-items: center; justify-content: center;
    gap: 18px; padding: 28px calc(28px + env(safe-area-inset-left, 0px)) calc(28px + env(safe-area-inset-bottom, 0px));
    text-align: center;
    background: color-mix(in srgb, var(--bg) 94%, transparent);
    -webkit-backdrop-filter: blur(10px) saturate(1.2); backdrop-filter: blur(10px) saturate(1.2);
    opacity: 0; pointer-events: none; transition: opacity var(--duration-base, .26s) var(--ease-standard, ease);
  }
  .cm-link-ov.open { opacity: 1; pointer-events: auto; }
  .cm-link-ov-laurel {     /* decorative brand watermark behind the spinner */
    position: absolute; top: 50%; left: 50%; transform: translate(-50%, -64%);
    color: var(--brand-laurel); opacity: .10; pointer-events: none;
  }
  .cm-link-ov-spin {
    position: relative; width: 46px; height: 46px; border-radius: 50%;
    border: 3px solid color-mix(in srgb, var(--accent) 22%, transparent);
    border-top-color: var(--accent); animation: cm-link-spin .9s linear infinite;
  }
  @keyframes cm-link-spin { to { transform: rotate(360deg); } }
  .cm-link-ov-title {
    position: relative; font-size: var(--text-md); font-weight: var(--weight-bold);
    color: var(--text-heading, var(--text-body)); max-width: 22ch;
  }
  .cm-link-ov-sub { position: relative; font-size: var(--text-sm); color: var(--text-muted); max-width: 28ch; }
  /* Trust note — the security voice; plain + emerald, never laurel-decorated. */
  .cm-link-ov .cm-trustnote { position: relative; justify-content: center; max-width: 30ch; }
  .cm-link-ov-cancel { position: relative; margin-top: 4px; }
  @media (prefers-reduced-motion: reduce) {
    .cm-link-ov-spin { animation-duration: 2.4s; }
    .cm-link-ov { transition: none; }
  }

  /* ── Drill-down → vertical drawer (bottom sheet) ───────────────────────────
     The shared drill-down (#drill-backdrop) — Stats by-category/merchant/account
     + top-purchases, the hero day/month ledgers, and the net-worth per-month /
     full-range drilldowns — becomes a bottom sheet on mobile: scrim behind, the
     sheet anchored to the viewport bottom, slides UP, rounded top corners, a
     Tyrian top rule, body scrolls inside. Scoped to #drill-backdrop only, so the
     OTHER .modal-backdrop sheets (rule-editor / crule-editor / stats-filter /
     activity-filter / recur-suggest) keep the existing full-screen mobile modal
     defined in templates/index.html. These rules OVERRIDE that full-screen block
     for #drill-backdrop specifically. */
  #drill-backdrop {
    padding: 0;
    align-items: flex-end;          /* anchor the sheet to the bottom */
    /* Above the bottom nav (z 60), the sheet-scrim (70) + the More sheet (75) — else the
       fixed bottom nav overlays the sheet and clips its last row. */
    z-index: 90;
    background: color-mix(in srgb, #1c1410 52%, transparent);   /* calm dark scrim */
  }
  #drill-backdrop .modal {
    max-width: none;
    min-height: 0;                  /* undo the full-screen min-height:100vh */
    max-height: 85vh;
    display: flex;
    flex-direction: column;
    border: none;
    border-top: 3px solid var(--accent);          /* Tyrian top rule */
    border-radius: 20px 20px 0 0;                 /* rounded TOP corners */
    box-shadow: 0 -16px 44px rgba(26, 16, 20, .28);
    transform: translateY(100%);                  /* parked below the viewport */
    transition: transform var(--duration-base, .26s) var(--ease-standard, ease);
    padding-bottom: env(safe-area-inset-bottom, 0px);
    overflow: hidden;               /* the body scrolls, not the sheet */
  }
  #drill-backdrop.show .modal { transform: translateY(0); }   /* slide up */
  /* The backdrop toggles display:none→flex, so the slide needs an explicit
     starting frame to animate from on open. @starting-style supplies it on the
     browsers that ship it (recent WebKit/Chromium = the mobile targets); older
     engines simply snap into place at the bottom — still a bottom sheet. */
  @starting-style {
    #drill-backdrop.show .modal { transform: translateY(100%); }
  }
  #drill-backdrop .modal-head {
    border-radius: 20px 20px 0 0;
    flex: 0 0 auto;                 /* pinned head stays put while the body scrolls */
  }
  #drill-backdrop .modal-body {
    flex: 1 1 auto;
    overflow-y: auto;
    -webkit-overflow-scrolling: touch;
  }
  @media (prefers-reduced-motion: reduce) {
    #drill-backdrop .modal { transition: none; }
  }
}

/* Inline button spinner. `.cm-spin` was previously styled ONLY under `#cm-busy`, so reusing the
   class on a button rendered an invisible empty span. This is the generic form; the higher-
   specificity `#cm-busy .cm-spin` rule still wins inside the busy overlay. @keyframes cm-spin
   lives in index.html's <style> — keyframe names are global, so it resolves from here. */
.cm-spin {
  display: inline-block; width: 13px; height: 13px; vertical-align: -2px;
  border: 2px solid color-mix(in srgb, currentColor 30%, transparent);
  border-top-color: currentColor; border-radius: 50%;
  animation: cm-spin 0.7s linear infinite;
}
@media (prefers-reduced-motion: reduce) { .cm-spin { animation-duration: 2s; } }

/* ── Offline pill ──────────────────────────────────────────────────────────
   Carpe Money computes from IndexedDB, so offline is a normal state, not an error:
   the figures on screen stay correct as of the last sync. This says so quietly and
   gets out of the way. pointer-events:none is load-bearing — the pill floats over
   the header, and an invisible tap-eater there would break the account switcher. */
/* Anchored to the BOTTOM, not the top: at the top it landed squarely on the brand wordmark
   and the account avatar. Above the bottom nav it reads as a status toast and covers nothing.
   Offsets: 66px nav + safe-area on mobile (<=760px, the .cm-bnav breakpoint); on desktop there
   is no nav, so it just sits above the bottom edge. */
#cm-offline {
  position: fixed; z-index: 88;          /* over sheets (75) + bottom-nav (60), under the link overlay (90) */
  bottom: 18px;
  left: 50%; transform: translateX(-50%);
  display: flex; align-items: center; gap: 7px;
  max-width: calc(100vw - 24px);
  padding: 6px 12px; border-radius: 999px;
  background: var(--surface-card); color: var(--text-body);
  border: 1px solid var(--border);
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.14);
  font-size: 12.5px; font-weight: 600; white-space: nowrap;
  pointer-events: none;
  animation: cm-offline-in 0.22s ease-out;
}
#cm-offline[hidden] { display: none; }   /* [hidden] loses to display:flex without this */
#cm-offline i { color: var(--muted); flex: none; }
@media (max-width: 760px) { #cm-offline { bottom: calc(78px + env(safe-area-inset-bottom, 0px)); } }
@keyframes cm-offline-in { from { opacity: 0; transform: translate(-50%, 8px); }
                             to { opacity: 1; transform: translate(-50%, 0); } }
@media (prefers-reduced-motion: reduce) { #cm-offline { animation: none; } }
