
  
  @font-face { font-family:'Rubik'; font-style:normal; font-weight:400; font-display:block; src:url(/fonts/rubik-he.woff2) format('woff2'); unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F; }
  @font-face { font-family:'Rubik'; font-style:normal; font-weight:400; font-display:block; src:url(/fonts/rubik-la.woff2) format('woff2'); unicode-range: U+0000-00FF, U+2000-206F, U+20AC, U+2122; }
  @font-face { font-family:'Rubik'; font-style:normal; font-weight:500; font-display:block; src:url(/fonts/rubik-he.woff2) format('woff2'); unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F; }
  @font-face { font-family:'Rubik'; font-style:normal; font-weight:500; font-display:block; src:url(/fonts/rubik-la.woff2) format('woff2'); unicode-range: U+0000-00FF, U+2000-206F, U+20AC, U+2122; }
  @font-face { font-family:'Rubik'; font-style:normal; font-weight:600; font-display:block; src:url(/fonts/rubik-he.woff2) format('woff2'); unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F; }
  @font-face { font-family:'Rubik'; font-style:normal; font-weight:600; font-display:block; src:url(/fonts/rubik-la.woff2) format('woff2'); unicode-range: U+0000-00FF, U+2000-206F, U+20AC, U+2122; }
  @font-face { font-family:'Rubik'; font-style:normal; font-weight:700; font-display:block; src:url(/fonts/rubik-he.woff2) format('woff2'); unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F; }
  @font-face { font-family:'Rubik'; font-style:normal; font-weight:700; font-display:block; src:url(/fonts/rubik-la.woff2) format('woff2'); unicode-range: U+0000-00FF, U+2000-206F, U+20AC, U+2122; }
  @font-face { font-family:'Frank Ruhl Libre'; font-style:normal; font-weight:800; font-display:block; src:url(/fonts/frl800-he.woff2) format('woff2'); unicode-range: U+0307-0308, U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F; }
  @font-face { font-family:'Frank Ruhl Libre'; font-style:normal; font-weight:800; font-display:block; src:url(/fonts/frl800-la.woff2) format('woff2'); unicode-range: U+0000-00FF, U+2000-206F, U+20AC, U+2122; }

  :root {
    /* tells the browser itself (not just this stylesheet) which mode is
       active, so native chrome it draws on top of these tokens - text
       selection highlight, autofill, scrollbars, and any un-styled native
       control - switches to matching light/dark artwork instead of always
       drawing light-mode-colored chrome on top of a dark card. */
    color-scheme: light;
    --bg: #d9e5df;
    --card: #ffffff;
    --card-glass: #1c3a34;
    --bg-muted: #ccdcd4;
    --text: #142320;
    --muted: #42544f;
    --accent: #4a3fc4;
    --accent-hover: #3b30a3;
    --accent-soft: #e9e6fa;
    --active: #1f9d55;
    --disabled: #8fa09b;
    --deleted: #d64545;
    --border: #c2d5cc;
    --border-hover: #93aea5;

    --gold-400: #ecbb5e;
    --gold-500: #e5a83c;
    --gold-600: #d29127;
    --gold-soft: #f6e9cf;
    --gold-text: #8a5e12;
    --gold-ink: #221604;
    --ink-700: #234943;
    --ink-800: #1c3a34;
    --ink-900: #16302a;
    --ink-950: #0e1f1b;
    --side-text: #cdd9d6;
    /* 2026-08-20 fix (visual-consistency audit, P2): #8fa39e on --ink-700
       (the sidebar gradient's own lightest, top-most stop, .sidebar STYLE
       further down) measured 3.75:1 - fails WCAG AA's 4.5:1 floor for the
       small (11px) uppercase nav-group-label text that uses this color.
       Lightened ~20% toward white (same fix applied to the clay/cloud
       palettes below, which had the identical failure at their own
       ink-700/side-muted pairing) - now 4.68:1, comfortably clear. */
    --side-muted: #a5b5b1;
    --side-line: rgba(255,255,255,.09);

    --space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px;
    --space-5: 20px; --space-6: 24px; --space-7: 32px; --space-8: 48px; --space-9: 64px;
    --radius-xs: 4px; --radius-sm: 8px; --radius-md: 12px; --radius-pill: 999px;

    --shadow-xs: 0 1px 2px rgba(13,30,28,.07);
    --shadow-sm: 0 1px 3px rgba(13,30,28,.08), 0 6px 18px rgba(13,30,28,.05);
    --shadow-md: 0 8px 24px rgba(13,30,28,.12), 0 20px 48px rgba(13,30,28,.13);
    --shadow-focus: 0 0 0 3px rgba(74,63,196,.3);
    --shadow-gold: 0 10px 26px rgba(229,168,60,.36);

    --ease: cubic-bezier(.22,1,.36,1);
    /* 2026-08-20 fix (visual-consistency audit, P2): this had been
       identical to --ease (no overshoot at all) since the token's very
       first commit - DESIGN.md's own Elevation & Depth section (written by
       describing what this file's CSS actually does) calls the modal
       entrance that uses this token "the system's bounce ease - the one
       deliberate elastic-motion moment in the whole app," a promise the
       flat curve never delivered. Reused verbatim from the FAB's own
       satellite-bubble "back" overshoot (.fab-wrap .fab-menu-item, STYLE
       further down) - the same "something arriving and settling into
       place" feeling, already tuned and proven elsewhere in this file,
       rather than inventing a new curve. */
    --ease-bounce: cubic-bezier(.34, 1.56, .64, 1);
    --duration-fast: 150ms; --duration-base: 200ms; --duration-slow: 320ms;
    /* Dedicated to modal CLOSING only (.modal-overlay.is-closing, see
       closeFormModal) - Chaim, 2026-08-11: tap-outside/Escape closed too
       fast to actually see. Deliberately separate from --duration-base/
       --duration-slow above, which many unrelated rules still share and
       which OPENING still uses unchanged - only closing needed to slow down. */
    --duration-modal-close: 360ms; --duration-modal-close-slow: 500ms;

    --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    --font-display: 'Frank Ruhl Libre', Georgia, serif;
    --text-xs: 12px; --text-sm: 14px; --text-base: 15px; --text-lg: 17px;
    --text-xl: 19px; --text-2xl: 23px; --text-3xl: 30px;
    --weight-regular: 400; --weight-medium: 500; --weight-semibold: 600; --weight-bold: 700;
    --leading-tight: 1.25; --leading-normal: 1.55;
  }

  /* Dark mode - set by THEME_STATE_SCRIPT (data-theme on <html>, applied
     before first paint), toggled from the three-dots menu or followed
     automatically from the phone's own appearance setting. Everything
     else in this file reads colors through the tokens above, so this one
     block is the entire theme - no component rule below needs to know
     which mode is active. --gold-500/--gold-600/--gold-ink and the header/
     sidebar's own --ink-800/900/950 + --side-* tokens are deliberately NOT
     overridden here: the sidebar/header are already permanently dark in
     both modes, and the solid-gold-chip pairs (gold-500 bg + gold-ink
     text) are self-contained regardless of page theme. --gold-soft/
     --gold-text ARE overridden, though, since gold-text is also used as
     plain text straight on a card (the dashboard's "peak clicks" number) -
     without a dark-specific pair here that reads as near-invisible
     dark-brown-on-dark-green instead of a legible gold. Every color below
     was checked against WCAG contrast (4.5:1 for text-sized use) against
     both --card and --accent-soft/--gold-soft as applicable. */
  :root[data-theme="dark"] {
    color-scheme: dark;
    --bg: #0c1815;
    --card: #16302a;
    --card-glass: #1c3a34;
    --bg-muted: #1f3d36;
    --text: #e8f0ec;
    --muted: #9fb3ac;
    --accent: #8b7ff0;
    --accent-hover: #a89bff;
    --accent-soft: #211c3f;
    --active: #3ecf7e;
    --disabled: #5c6f68;
    --deleted: #ff6b6b;
    --border: #2a453d;
    --border-hover: #3c5c52;

    --gold-soft: #33270f;
    --gold-text: #ecbb5e;

    --shadow-xs: 0 1px 2px rgba(0,0,0,.35);
    --shadow-sm: 0 1px 3px rgba(0,0,0,.4), 0 6px 18px rgba(0,0,0,.3);
    --shadow-md: 0 8px 24px rgba(0,0,0,.45), 0 20px 48px rgba(0,0,0,.4);
    --shadow-focus: 0 0 0 3px rgba(139,127,240,.35);
    --shadow-gold: 0 10px 26px rgba(229,168,60,.3);
  }

  /* ============================================================
     4 additional palettes (2026-08-12) - "ירוק וזהב" above (the bare :root/
     [data-theme="dark"] pair) is now just ONE of 5 named options, picked
     from the three-dots menu's "צבעי מערכת" (see BULK_SELECT_SCRIPT's
     setPaletteChoice + PALETTE_STATE_SCRIPT's pre-paint data-palette stamp).
     Same override contract as the green dark-mode block right above:
     the LIGHT block for each palette sets every color token; that
     palette's OWN [data-theme="dark"] block overrides only the same subset
     green's dark block does (bg, card, bg-muted, text, muted, the accent
     trio, active, disabled, deleted, the border pair, gold-soft, gold-text,
     shadows) - gold-400, 500, 600, gold-ink, and the header/sidebar's
     ink-700/800/900/950 + side-* tokens
     stay whatever the light block set, exactly like green, since that
     chrome is permanently dark in both modes regardless of palette.
     Specificity note: :root[data-palette="x"] and :root[data-palette="x"]
     [data-theme="dark"] both beat the bare :root/:root[data-theme="dark"]
     pair above regardless of source order (equal-or-higher specificity),
     so data-palette="green" (or no data-palette at all, e.g. localStorage
     cleared) correctly falls through to that original pair unchanged.
     shadow-xs/sm/md are intentionally identical, black-based values across
     all 4 new palettes (not individually tinted like green's own light-mode
     shadow) - a neutral shadow reads fine against any of these grounds, and
     deriving a tasteful per-palette tint for a barely-perceptible detail
     wasn't worth the added surface. ============================================================ */

  :root[data-palette="clay"] {
    color-scheme: light;
    --bg: #e7dfd2; --card: #fdfbf8; --card-glass: #3c2d21; --bg-muted: #ddd2c0;
    --text: #2b2016; --muted: #6b5c4a;
    --accent: #bd5c37; --accent-hover: #9c4527; --accent-soft: #f1ddc9;
    --active: #4c8c5a; --disabled: #a8937c; --deleted: #b3452f;
    --border: #d3c6b2; --border-hover: #b8a688;
    --gold-400: #d98f6a; --gold-500: #bd5c37; --gold-600: #9c4527;
    --gold-soft: #f1ddc9; --gold-text: #8a4022; --gold-ink: #2c150a;
    --ink-700: #4a3828; --ink-800: #3c2d21; --ink-900: #2c2018; --ink-950: #1c130c;
    /* 2026-08-20 fix (visual-consistency audit, P2): same WCAG contrast
       fix as the default (green) palette above - #a8937c on this
       palette's own --ink-700 measured 3.78:1, under the 4.5:1 floor.
       Lightened the same ~20% toward white; now 4.86:1. */
    --side-text: #ddd0bf; --side-muted: #b9a996;
    --shadow-xs: 0 1px 2px rgba(0,0,0,.12);
    --shadow-sm: 0 1px 3px rgba(0,0,0,.1), 0 6px 18px rgba(0,0,0,.08);
    --shadow-md: 0 8px 24px rgba(0,0,0,.14), 0 20px 48px rgba(0,0,0,.12);
    --shadow-focus: 0 0 0 3px rgba(189,92,55,.3);
    --shadow-gold: 0 10px 26px rgba(189,92,55,.32);
  }
  :root[data-palette="clay"][data-theme="dark"] {
    color-scheme: dark;
    --bg: #1c150f; --card: #2a2018; --card-glass: #3c2d21; --bg-muted: #362a1e;
    --text: #ece2d4; --muted: #b3a08c;
    --accent: #d98f6a; --accent-hover: #e2a37d; --accent-soft: #3a2415;
    --active: #6db67d; --disabled: #6b5c4a; --deleted: #d97a5c;
    --border: #3d3022; --border-hover: #52402c;
    --gold-soft: #3a2415; --gold-text: #e2a37d;
    --shadow-xs: 0 1px 2px rgba(0,0,0,.35);
    --shadow-sm: 0 1px 3px rgba(0,0,0,.4), 0 6px 18px rgba(0,0,0,.3);
    --shadow-md: 0 8px 24px rgba(0,0,0,.45), 0 20px 48px rgba(0,0,0,.4);
    --shadow-focus: 0 0 0 3px rgba(217,143,106,.35);
    --shadow-gold: 0 10px 26px rgba(189,92,55,.28);
  }

  :root[data-palette="jewel"] {
    color-scheme: light;
    --bg: #e6e9ef; --card: #ffffff; --card-glass: #10192b; --bg-muted: #d7dce6;
    --text: #17202e; --muted: #566579;
    --accent: #4f74ab; --accent-hover: #3c5c8c; --accent-soft: #dde6f3;
    --active: #379672; --disabled: #8896ab; --deleted: #b0473f;
    --border: #c8d0dc; --border-hover: #a9b4c6;
    --gold-400: #7fa0d6; --gold-500: #4f74ab; --gold-600: #3c5c8c;
    --gold-soft: #dde6f3; --gold-text: #2f4c78; --gold-ink: #0c1622;
    --ink-700: #16233d; --ink-800: #10192b; --ink-900: #0b1220; --ink-950: #060a12;
    --side-text: #cdd6e4; --side-muted: #8896ab;
    --shadow-xs: 0 1px 2px rgba(0,0,0,.12);
    --shadow-sm: 0 1px 3px rgba(0,0,0,.1), 0 6px 18px rgba(0,0,0,.08);
    --shadow-md: 0 8px 24px rgba(0,0,0,.14), 0 20px 48px rgba(0,0,0,.12);
    --shadow-focus: 0 0 0 3px rgba(79,116,171,.3);
    --shadow-gold: 0 10px 26px rgba(79,116,171,.32);
  }
  :root[data-palette="jewel"][data-theme="dark"] {
    color-scheme: dark;
    --bg: #0a0f1a; --card: #131b2c; --card-glass: #10192b; --bg-muted: #1a2438;
    --text: #e3e8f2; --muted: #94a3ba;
    --accent: #7fa0d6; --accent-hover: #8fb0e0; --accent-soft: #1c2c46;
    --active: #4db38a; --disabled: #566579; --deleted: #e0685f;
    --border: #24304a; --border-hover: #324263;
    --gold-soft: #1c2c46; --gold-text: #8fb0e0;
    --shadow-xs: 0 1px 2px rgba(0,0,0,.35);
    --shadow-sm: 0 1px 3px rgba(0,0,0,.4), 0 6px 18px rgba(0,0,0,.3);
    --shadow-md: 0 8px 24px rgba(0,0,0,.45), 0 20px 48px rgba(0,0,0,.4);
    --shadow-focus: 0 0 0 3px rgba(127,160,214,.35);
    --shadow-gold: 0 10px 26px rgba(79,116,171,.28);
  }

  :root[data-palette="cloud"] {
    color-scheme: light;
    --bg: #edeeec; --card: #ffffff; --card-glass: #2c313a; --bg-muted: #e0e2df;
    --text: #262a2e; --muted: #666e73;
    --accent: #6f74c4; --accent-hover: #565aa3; --accent-soft: #e6e6f5;
    --active: #4d9b74; --disabled: #9aa1ac; --deleted: #b0524a;
    --border: #d6d9d5; --border-hover: #bcc1bc;
    --gold-400: #9a9ee0; --gold-500: #6f74c4; --gold-600: #565aa3;
    --gold-soft: #e6e6f5; --gold-text: #4a4e93; --gold-ink: #14152b;
    --ink-700: #363c47; --ink-800: #2c313a; --ink-900: #20242b; --ink-950: #16191e;
    /* 2026-08-20 fix (visual-consistency audit, P2): same WCAG contrast
       fix as the default (green) palette above - #9aa1ac on this
       palette's own --ink-700 measured 4.26:1, just under the 4.5:1 floor.
       Lightened the same ~20% toward white; now 5.31:1. */
    --side-text: #d6d9e0; --side-muted: #aeb4bd;
    --shadow-xs: 0 1px 2px rgba(0,0,0,.12);
    --shadow-sm: 0 1px 3px rgba(0,0,0,.1), 0 6px 18px rgba(0,0,0,.08);
    --shadow-md: 0 8px 24px rgba(0,0,0,.14), 0 20px 48px rgba(0,0,0,.12);
    --shadow-focus: 0 0 0 3px rgba(111,116,196,.3);
    --shadow-gold: 0 10px 26px rgba(111,116,196,.32);
  }
  :root[data-palette="cloud"][data-theme="dark"] {
    color-scheme: dark;
    --bg: #131417; --card: #1c1e22; --card-glass: #2c313a; --bg-muted: #24272c;
    --text: #e5e6e8; --muted: #9ba0a6;
    --accent: #9a9ee0; --accent-hover: #a6abe8; --accent-soft: #23243d;
    --active: #59b389; --disabled: #666e73; --deleted: #d97b72;
    --border: #2d3036; --border-hover: #3c4048;
    --gold-soft: #23243d; --gold-text: #a6abe8;
    --shadow-xs: 0 1px 2px rgba(0,0,0,.35);
    --shadow-sm: 0 1px 3px rgba(0,0,0,.4), 0 6px 18px rgba(0,0,0,.3);
    --shadow-md: 0 8px 24px rgba(0,0,0,.45), 0 20px 48px rgba(0,0,0,.4);
    --shadow-focus: 0 0 0 3px rgba(154,158,224,.35);
    --shadow-gold: 0 10px 26px rgba(111,116,196,.28);
  }

  :root[data-palette="glow"] {
    color-scheme: light;
    --bg: #f1eef7; --card: #ffffff; --card-glass: #1c1728; --bg-muted: #e5e0f0;
    --text: #1f1a2b; --muted: #6b6280;
    --accent: #8438e0; --accent-hover: #6d29bd; --accent-soft: #ece2fb;
    --active: #1f9d6e; --disabled: #9c8fc0; --deleted: #c23e6b;
    --border: #ddd6ea; --border-hover: #c4b9dc;
    --gold-400: #b98af5; --gold-500: #8438e0; --gold-600: #6d29bd;
    --gold-soft: #ece2fb; --gold-text: #6d34b3; --gold-ink: #180a2e;
    --ink-700: #251f36; --ink-800: #1c1728; --ink-900: #120e1a; --ink-950: #0b0813;
    --side-text: #d9d2ec; --side-muted: #9c8fc0;
    --shadow-xs: 0 1px 2px rgba(0,0,0,.12);
    --shadow-sm: 0 1px 3px rgba(0,0,0,.1), 0 6px 18px rgba(0,0,0,.08);
    --shadow-md: 0 8px 24px rgba(0,0,0,.14), 0 20px 48px rgba(0,0,0,.12);
    --shadow-focus: 0 0 0 3px rgba(132,56,224,.3);
    --shadow-gold: 0 10px 26px rgba(132,56,224,.32);
  }
  :root[data-palette="glow"][data-theme="dark"] {
    color-scheme: dark;
    --bg: #0a0910; --card: #17141f; --card-glass: #1c1728; --bg-muted: #201c2b;
    --text: #ede9f5; --muted: #a99fc0;
    --accent: #b98af5; --accent-hover: #c9a6ff; --accent-soft: #2a1f42;
    --active: #34d399; --disabled: #6b6280; --deleted: #ff6b93;
    --border: #2c2740; --border-hover: #3c3555;
    --gold-soft: #2a1f42; --gold-text: #c9a6ff;
    --shadow-xs: 0 1px 2px rgba(0,0,0,.35);
    --shadow-sm: 0 1px 3px rgba(0,0,0,.4), 0 6px 18px rgba(0,0,0,.3);
    --shadow-md: 0 8px 24px rgba(0,0,0,.45), 0 20px 48px rgba(0,0,0,.4);
    --shadow-focus: 0 0 0 3px rgba(185,138,245,.4);
    --shadow-gold: 0 10px 26px rgba(132,56,224,.32);
  }

  /* Chrome's default tap-highlight box is a plain rectangle regardless of
     the tapped element's actual shape (a round button got a square blue
     flash) and doesn't match this app's own transparent/scale-based press
     feedback used everywhere - turned off globally, no exceptions, per
     explicit request, rather than chasing it selector-by-selector (which
     is how .nav-item ended up with its own copy of this same rule earlier -
     now redundant and removed, this covers it too). */
  * { -webkit-tap-highlight-color: transparent; box-sizing: border-box; }
  /* A long-press anywhere (a row, a title, a button) is this app's OWN
     gesture (opens multi-select, drag-to-reorder, etc.) - without this, the
     phone's own text-selection kicks in on the same long-press, throwing up
     the native copy/share/select-all bubble ON TOP of (and instead of) the
     app's own UI, which reads exactly like a browser page and not an app.
     Off everywhere, per explicit request, with the only two carve-outs where
     selecting text is actually the point: real form fields (typing/editing
     needs it) and anything the app explicitly marks selectable. */
  * { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
  input, textarea, [contenteditable], .allow-select { -webkit-user-select: text; user-select: text; -webkit-touch-callout: default; }
  /* reserves the scrollbar's width permanently, so a row expanding/collapsing
     (which can push the page past/under the scroll threshold) never shifts
     the whole layout sideways by the scrollbar's width */
  html { scrollbar-gutter: stable; }
  /* Chaim (2026-08-12): this used to unconditionally kill Chrome's native
     rubber-band bounce/pull-to-refresh everywhere (they're the same
     mechanism), compensated for with a custom rebuilt-from-touch-events
     PTR - explicit instruction to drop that trade entirely and restore the
     real native gesture instead, so this rule (and the custom indicator
     it existed to make room for) are just gone. */
  /* Chaim (2026-08-12), second pass: "עדיין לא מצליח לרענן במשיכה, לא עושה
     כלום בכלל" - removing overscroll-behavior-y:none alone (the round
     right before this one) wasn't the whole fix. This html{overflow-y:
     hidden} default (only ever switched to auto once OVERSCROLL_GUARD_
     SCRIPT confirms there's real content below the fold) was STILL there,
     and unlike overscroll-behavior, overflow:hidden removes the page from
     being a scrollable region at all - no scroll boundary for native pull-
     to-refresh to attach to in the first place, on any page short enough
     to still read as "not overflowing" (trash included, and plausibly
     others depending on how much is on screen). Always allowing real
     overflow now - what this used to protect against (a short page's few
     px of real "give", from .container's own permanent FAB/tab-bar
     clearance padding, letting a drag tuck a row behind the sticky header
     and leave it there) is handled the other way around in
     OVERSCROLL_GUARD_SCRIPT now instead: let the browser's own bounce/
     scroll happen (needed for native PTR to work at all), then snap back
     to the top once it actually settles, but only on a page that doesn't
     have real content to justify staying scrolled. */
  html { overflow-y: auto; }
  body {
    margin: 0;
    font-family: 'Rubik', system-ui, -apple-system, "Segoe UI", Arial, sans-serif;
    font-size: var(--text-base);
    line-height: var(--leading-normal);
    background: var(--bg);
    color: var(--text);
  }
  h1, h2, h3, h4 { margin: 0 0 var(--space-3); font-weight: var(--weight-semibold); line-height: var(--leading-tight); }
  h1, h2 { font-weight: 800; letter-spacing: -.01em; }
  /* 2026-08-20 fix (visual-consistency audit, P2): was Instrument Violet -
     DESIGN.md's own Don't list already says not to use that accent for
     anything but a focus ring, and Chaim separately flagged the exact same
     off-brand-purple smell on the upload progress ring (2026-08-18, "נראה
     אתר של בונה מתחיל" - see that rule's own comment) - the default link
     color is a much bigger, more visible instance of the same problem.
     --gold-text is this system's own readable-on-light gold, already used
     everywhere else text needs to carry the brand color. */
  a { color: var(--gold-text); text-decoration: none; transition: color var(--duration-fast) var(--ease); }
  a:hover { text-decoration: underline; }

  @media (prefers-reduced-motion: reduce) {
    *, *::before, *::after {
      animation-duration: .001ms !important; animation-iteration-count: 1 !important;
      transition-duration: .001ms !important; transition-delay: 0s !important;
    }
  }

  /* The gold nav-pill glides (not jumps) to wherever you clicked, across the
     full page navigation - cross-document View Transitions. The root pair is
     muted so the page itself swaps instantly; only the pill morphs. A prior
     "site jumps" report turned out to be the scrollbar appearing/disappearing
     (fixed via scrollbar-gutter above), not this - safe to keep both.
     2026-08-30: scoped to desktop only. On mobile the ONLY view-transitioned
     element (.nav-pill) already has view-transition-name:none (real navs
     there are the rare cold-load back-arrow case; tab switching is all
     same-document soft-nav, which this at-rule never applies to anyway), so
     opting in bought nothing on the phone - but the cross-document snapshot
     it forces on every real navigation can flash a frame on a real device
     mid-swap, which is exactly the "כל הדף קופץ ומהבהב" Chaim reported
     going back from Settings to the links list. */
  @media (min-width: 781px) {
    @view-transition { navigation: auto; }
    ::view-transition-old(root), ::view-transition-new(root) { animation: none; }
    .nav-pill { view-transition-name: nav-active; }
    ::view-transition-group(nav-active) {
      animation-duration: var(--duration-slow);
      animation-timing-function: var(--ease);
    }
  }
  @media (prefers-reduced-motion: reduce) {
    ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none !important; }
  }

  /* ---------- layout shells ---------- */
  .app-shell { display: flex; min-height: 100vh; align-items: stretch; }
  .sidebar {
    width: 248px; flex: none; display: flex; flex-direction: column;
    background: linear-gradient(180deg, var(--ink-700), var(--ink-800) 55%, var(--ink-900));
    border-left: 1px solid rgba(0,0,0,.25);
    padding: var(--space-6) var(--space-4);
    /* pinned to the viewport while the page content scrolls past it - the
       sidebar itself scrolls internally on the rare page taller than 100vh */
    position: sticky; top: 0; align-self: flex-start; height: 100vh; overflow-y: auto;
    /* Deliberate exception to the transform/opacity-only rule: a sidebar
       collapse exists to reflow the content next to it, so width is the
       honest property here. User-initiated, once per click. */
    transition: width var(--duration-base) var(--ease);
  }
  .brand { display: flex; align-items: center; padding: 0 var(--space-2) var(--space-5); border-bottom: 1px solid var(--side-line); margin-bottom: var(--space-3); }
  .sidebar-toggle {
    flex: none; width: 38px; height: 38px; border-radius: var(--radius-sm); border: 1px solid var(--side-line);
    background: rgba(255,255,255,.06); color: #fff; display: flex; align-items: center; justify-content: center; cursor: pointer;
    transition: background-color var(--duration-fast) var(--ease);
  }
  .sidebar-toggle:hover { background: rgba(255,255,255,.14); color: #fff; }
  .sidebar-nav { display: flex; flex-direction: column; gap: var(--space-2); }
  .nav-group-label {
    font-size: 11px; font-weight: var(--weight-semibold); color: var(--side-muted); text-transform: uppercase;
    letter-spacing: .06em; padding: var(--space-4) var(--space-3) var(--space-1); white-space: nowrap; overflow: hidden;
  }
  .nav-group-label:first-child { padding-top: 0; }
  .nav-item {
    position: relative; z-index: 0;
    display: flex; align-items: center; gap: var(--space-3); color: var(--side-text);
    padding: 13px var(--space-4); border-radius: var(--radius-pill); font-size: var(--text-lg); font-weight: var(--weight-medium);
    transition: background-color var(--duration-fast) var(--ease), color var(--duration-fast) var(--ease), transform var(--duration-fast) var(--ease);
  }
  .nav-item svg { width: 21px; height: 21px; }
  .nav-item:hover { background: var(--side-line); color: #fff; text-decoration: none; }
  .nav-item.current { color: var(--gold-ink); }
  .nav-item svg { flex: none; }
  /* no position set here on purpose - on desktop the pill (inset:0, a
     positioned descendant) sees through this static wrapper and resolves
     against the next positioned ancestor up, .nav-item, covering the full
     icon+label row exactly as before. Mobile gives this element its own
     position:relative, becoming the pill's containing block instead, so the
     highlight shrinks to just the icon there - see that override below. */
  .nav-icon-wrap { display: inline-flex; align-items: center; justify-content: center; }
  /* 2026-08-26, Chaim (picked "B" from a real comparison against WhatsApp's
     own bottom nav): the 4 real destination tabs' icons are outline when
     NOT current, filled solid when they ARE - both variants always render
     (see navItem()'s own comment), this just picks one. Riding on the same
     .nav-item.current class the pill/label color already key off, so the
     swap needs no new JS anywhere - correct from first paint, and correct
     after a soft-nav tab switch the instant the existing code flips
     .current, same as everything else that already depends on it.
     display:flex on BOTH (not inline/block - a plain <span> defaults to
     inline, and the 2026-08-26 follow-up bug this fixes was exactly that
     default) - same family as .switch/.click-check's own documented fix
     elsewhere in this file: an inline box sits on the parent line's text
     baseline, so the font's descender gap hangs below it and pushes the
     visible icon up from the pill's true center. Chaim caught this the
     hard way (real screenshot, pixel-measured: the icon sat ~11px above
     center, not off to a side - horizontal centering was already exact). */
  .nav-icon-outline, .nav-icon-filled { display: flex; }
  .nav-icon-filled { display: none; }
  .nav-item.current .nav-icon-outline { display: none; }
  .nav-item.current .nav-icon-filled { display: flex; }
  /* pill = flat top/bottom, fully-round ends (a squashed ellipse) */
  .nav-pill {
    position: absolute; inset: 0; z-index: -1;
    background: var(--gold-500); border-radius: var(--radius-pill);
    box-shadow: 0 4px 14px rgba(229,168,60,.32);
  }
  /* mobile-only reveal: grows from the icon's exact center rather than the
     cross-document glide the desktop sidebar pill uses (View Transitions
     morphs a snapshot between the old and new pill's rects - a different
     mechanism than a plain keyframe animation, and running both on the same
     element risks fighting each other, so view-transition-name is turned
     off for this pill on mobile instead of layering the two). Decelerate
     curve: starts fast, eases off gently right at the end of the expansion. */
  @keyframes nav-pill-grow {
    from { transform: scale(.4); }
    to { transform: scale(1); }
  }
  .sidebar-footer { margin-top: auto; padding-top: var(--space-5); border-top: 1px solid var(--side-line); }
  .sidebar-user { display: flex; align-items: center; gap: var(--space-2); padding: 0 var(--space-3); margin-bottom: var(--space-2); }
  .sidebar-avatar {
    width: 26px; height: 26px; border-radius: 50%; background: var(--gold-soft); color: var(--gold-text); flex: none;
    display: flex; align-items: center; justify-content: center; font-size: var(--text-xs); font-weight: var(--weight-bold);
  }
  .sidebar-username { font-size: var(--text-sm); color: var(--side-text); white-space: nowrap; overflow: hidden; }
  .sidebar-logout-form { margin: 0; }
  .sidebar-logout {
    display: flex; align-items: center; gap: var(--space-3); width: 100%; text-align: right;
    background: none; border: none; color: var(--side-text); font: inherit; font-weight: var(--weight-medium);
    padding: var(--space-3); border-radius: var(--radius-sm); cursor: pointer;
    transition: background-color var(--duration-fast) var(--ease), color var(--duration-fast) var(--ease);
  }
  .sidebar-logout:hover { background: rgba(214,69,69,.16); color: #ff9d9d; }
  /* mobile-only "more" button (API Tokens + logout) - desktop already shows
     both directly in the sidebar, so this stays hidden there. */
  .mobile-more-wrap { display: none; position: relative; }

  /* sidebar collapse toggle - desktop only, mobile always uses the bottom tab bar layout instead */
  @media (min-width: 781px) {
    html.sidebar-collapsed .sidebar { width: 76px; padding-left: var(--space-2); padding-right: var(--space-2); }
    html.sidebar-collapsed .brand { justify-content: center; padding-left: 0; padding-right: 0; }
    html.sidebar-collapsed .nav-group-label { display: none; }
    html.sidebar-collapsed .nav-item { justify-content: center; }
    html.sidebar-collapsed .nav-item span { display: none; }
    html.sidebar-collapsed .sidebar-user { justify-content: center; padding: 0; }
    html.sidebar-collapsed .sidebar-username { display: none; }
    html.sidebar-collapsed .sidebar-logout { justify-content: center; }
    html.sidebar-collapsed .sidebar-logout span { display: none; }
  }

  .main { flex: 1; min-width: 0; }
  .container { max-width: none; padding: var(--space-7) var(--space-6); }
  .center-shell {
    flex: 1; min-height: 100vh; width: 100%; display: flex; align-items: center; justify-content: center; padding: var(--space-6);
    background:
      radial-gradient(60% 55% at 85% 12%, rgba(229,168,60,.10), transparent 60%),
      radial-gradient(55% 60% at 12% 90%, rgba(74,63,196,.12), transparent 60%),
      var(--bg);
  }
  .container-narrow { width: 100%; max-width: 400px; }

  /* Friend-facing upload page (friendUploadPage) - the .panel card gives it
     the same "this is really part of the app" weight loginPage already has,
     not a bare unbranded screen (see the function's own comment for why
     that matters more than looking "minimal"). Three views inside one
     .panel, only one ever visible at a time (plain [hidden], no animation
     between them - swap is instant, matching how every tab transition in
     this app already works).
     2026-08-18, Chaim (real-device screenshot): "נראה אתר של בונה מתחיל" -
     this whole family of pages (here, uploadSavePage, the admin create-
     flow's own progress view) was just a plain white .panel floating on the
     page background, the one part of the app that never got the real
     gold/petrol identity every other surface has (login, the QR modal, the
     sidebar). padding:0/overflow:hidden here, same idiom .qr-modal-card
     already established, so the new .upload-hero-band below can bleed a
     real petrol gradient strip to the card's own rounded corners instead of
     sitting inside its padding like ordinary content. Approved via a real
     before/after mockup shown to Chaim first, not guessed. */
  .upload-panel { padding: 0; overflow: hidden; }
  .upload-hero-band {
    background: linear-gradient(180deg, var(--ink-700), var(--ink-800) 60%, var(--ink-900));
    padding: var(--space-6) var(--space-5) var(--space-8);
    text-align: center; color: var(--side-text);
  }
  .upload-hero-band-icon { color: var(--gold-400); margin-bottom: var(--space-2); }
  /* 2026-08-20 fix (visual-consistency audit, P2): was set in --font-display
     (Frank Ruhl Libre) - a heading, not a live-status number, so it broke
     DESIGN.md's own Readout Rule ("never a heading... if it's not a count,
     it's not in this font"). Dropped the override; inherits the page's
     normal Heebo at the same weight/size, same as every other heading. */
  .upload-hero-band-title { font-size: var(--text-2xl); font-weight: 700; color: #fff; margin: 0; }
  .upload-hero-band-message { color: var(--side-text); background: rgba(255,255,255,.08); border-radius: var(--radius-sm); padding: var(--space-2) var(--space-3); margin: var(--space-2) 0 0; font-size: var(--text-sm); }
  /* Pulled up under the hero band's own bottom padding, same "content
     overlaps the gradient's lower edge" idea .after-body used in the
     mockup - keeps the transition from feeling like two stacked, unrelated
     blocks. */
  .upload-panel-body { background: var(--card); border-radius: var(--radius-md) var(--radius-md) 0 0; margin-top: calc(-1 * var(--space-5)); position: relative; padding: var(--space-6) var(--space-5) var(--space-5); }
  .upload-hero { display: flex; flex-direction: column; align-items: center; text-align: center; gap: var(--space-2); }
  /* Author styles always beat the UA [hidden]{display:none} rule at equal
     specificity (origin ordering, not selector weight) - without this,
     setting the hidden PROPERTY in JS (or the attribute server-side) would
     do nothing and this view would stay visibly stacked on top of whichever
     one is supposed to show instead, exactly like .form-error[hidden]
     already guards against above. */
  .upload-hero[hidden] { display: none; }
  .upload-hero-reassure { color: var(--muted); font-size: var(--text-sm); margin: 0 0 var(--space-4); }
  /* The ring+summary's own visual weight (Chaim's approved mockup wrapped
     this pair in a gold-tinted block, not bare on white) - kept the ring
     itself centered at its existing size/ids so FRIEND_UPLOAD_SCRIPT's own
     positioning math (stroke-dashoffset etc.) needs no changes at all, only
     this wrapper is new. */
  .upload-progress-ring-block { background: var(--gold-soft); border-radius: var(--radius-md); padding: var(--space-4) var(--space-3); margin-bottom: var(--space-4); }
  /* 2026-08-19, Chaim (round 2 of this same flow's design): "כל המידע
     והאפשרויות ממש בחלק העליון... בפס אחד" - replaces the block above for
     both this modal and friendUploadPage: ring, plain-facts summary, and
     both action buttons all live in one persistent row instead of two
     separate blocks (one of which used to only appear once the batch
     finished - itself part of his separate "קופצת" complaint on this same
     surface, since inserting a whole new block IS a layout jump). */
  .upload-progress-strip { display: flex; align-items: center; gap: var(--space-3); }
  /* .upload-progress-ring-wrap.upload-progress-ring-wrap-sm, not a bare
     -sm selector (2026-08-20 fix, audit RH2): the real markup always
     carries both classes together (friendUploadPage's strip and the admin
     create-modal's strip), and a bare single-class -sm selector ties in
     specificity with .upload-progress-ring-wrap below - which is declared
     LATER and so wins the 132px/132px size on that tie, blowing this
     "compact strip" ring up to full size on every load. Same pitfall/fix
     this file already documents for .icon-btn.header-search-btn - a
     compound selector wins regardless of source order. */
  .upload-progress-ring-wrap.upload-progress-ring-wrap-sm { width: 52px; height: 52px; margin: 0; flex: none; }
  .upload-progress-ring-wrap-sm .upload-progress-ring-label { font-size: 14px; transition: opacity 200ms var(--ease); }
  .upload-progress-strip-summary { flex: 1 1 0; min-width: 0; font-size: var(--text-sm); color: var(--muted); }
  .upload-progress-strip-btn {
    flex: none; width: 38px; height: 38px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
    border: none; cursor: pointer; padding: 0; -webkit-tap-highlight-color: transparent;
  }
  .upload-progress-strip-btn svg { width: 18px; height: 18px; }
  .upload-progress-add-btn { background: var(--gold-soft); color: var(--gold-text); }
  .upload-progress-finish-btn { background: var(--gold-500); color: var(--gold-ink); }
  .upload-progress-finish-btn:disabled { background: var(--bg-muted); color: var(--disabled); cursor: default; }
  .upload-progress-divider { height: 1px; background: var(--border); margin: var(--space-3) 0; }
  /* The ring's own "draws a checkmark in" morph (Chaim: "המאה יתחלף בהנפשה
     לוי שמתחיל להצטייר... עד שהוא מסיים לצייר את הוי בשלמותו") - hidden and
     undrawn until morphRingToCheck runs; stroke-dasharray/dashoffset are set
     inline by that function (real path lengths via getTotalLength(), not a
     guess), this only owns the icon's static positioning/color and the
     opacity reveal. */
  .upload-progress-ring-check { position: absolute; inset: 0; margin: auto; width: 60%; height: 60%; color: var(--active); opacity: 0; pointer-events: none; }
  .upload-progress-ring-check.is-visible { opacity: 1; transition: opacity 200ms var(--ease); }
  .upload-progress-ring-check path, .upload-progress-ring-check polyline { fill: none; stroke: currentColor; stroke-width: 2.5; }
  .upload-progress-ring-check polyline { stroke-linecap: round; stroke-linejoin: round; }
  .upload-hero-btn {
    display: flex; align-items: center; gap: var(--space-2); border: none; border-radius: var(--radius-pill);
    background: var(--gold-500); color: var(--gold-ink); box-shadow: var(--shadow-gold);
    padding: var(--space-4) var(--space-7); font: inherit; font-size: var(--text-lg); font-weight: var(--weight-bold); cursor: pointer;
  }
  .upload-hero-btn:hover, .upload-hero-btn:active { background: var(--gold-600); }

  .upload-progress-summary { text-align: center; color: var(--gold-text); font-size: var(--text-sm); margin-bottom: 0; }
  .upload-progress-list { display: flex; flex-direction: column; gap: var(--space-3); max-height: 50vh; overflow-y: auto; }
  /* 2026-08-18, Chaim (reference screenshot): one shared circular ring for
     the WHOLE batch's aggregate progress, at the bottom of the grid - not a
     bar per file anymore (those are gone below, replaced by
     upload-thumb-grid's checkmark-on-done tiles, same shape as the admin
     side's real thumbnails - see uploadFileThumbHtml's own comment).
     r=RING_RADIUS (client/generators.js - 42), circumference =
     RING_CIRCUMFERENCE there ~= 263.9 - the dashoffset JS sets is always
     "263.9 * (1 - pct/100)". CSS can't import that JS constant, so this is
     the one place the number is still spelled out directly rather than
     shared (2026-08-27, Task 7.2 - see RING_RADIUS's own comment for the
     3 client-JS call sites that DO share it). rotate(-90deg) starts the
     fill at 12 o'clock rather than SVG's default 3 o'clock, matching the
     reference image. */
  .upload-progress-ring-wrap { position: relative; width: 132px; height: 132px; margin: 0 auto var(--space-2); }
  .upload-progress-ring { width: 100%; height: 100%; transform: rotate(-90deg); }
  .upload-progress-ring-track { fill: none; stroke: var(--gold-soft); stroke-width: 8; }
  .upload-progress-ring-fill {
    /* 2026-08-18, Chaim (real-device screenshot): "נראה אתר של בונה מתחיל"
       - --accent (a generic purple-blue) was never this app's own primary
       color - --gold-500 is, used by every real primary CTA (.btn-hero,
       .upload-hero-btn) and the whole gold/petrol identity (login, QR
       modal, sidebar). A secondary-colored ring reads as unbranded next to
       all of that, exactly his complaint. */
    fill: none; stroke: var(--gold-600); stroke-width: 8; stroke-linecap: round;
    stroke-dasharray: 263.9; stroke-dashoffset: 263.9;
    transition: stroke-dashoffset var(--duration-fast) linear;
  }
  .upload-progress-ring-label {
    position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
    /* Same treatment the dashboard's own big numbers (.now .big, .mile .n)
       already use for a "hero number" - matching that, not a plain
       system-font number, is what ties this into the rest of the app's own
       typographic identity instead of reading as a generic add-on screen. */
    font-family: var(--font-display); font-weight: 700; font-size: 26px;
    color: var(--gold-ink); font-variant-numeric: tabular-nums;
  }
  /* A failed tile's retry action - same full-tile-overlay idea as
     .upload-thumb-play, just interactive (a real button, not decorative).
     2026-08-19, Chaim: "למה יש את הסימן קריאה הזה... זה לא קשור" - a real
     bug, not a design complaint: this was visible on EVERY tile regardless
     of the JS-set hidden attribute, because display:flex on a plain class
     selector beats the browser's own hidden-attribute default of
     display:none (author CSS always wins over the user-agent stylesheet,
     regardless of specificity) - the element's .hidden = true was firing
     correctly the whole time, the CSS just never actually respected it.
     The rule below (higher specificity, same origin) restores the real,
     always-intended behavior: invisible unless a tile is genuinely
     failed. */
  .upload-thumb-retry {
    position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
    background: rgba(17,24,39,.55); color: #fff; border: none; cursor: pointer; padding: 0;
  }
  .upload-thumb-retry[hidden] { display: none; }
  .upload-thumb.is-uploading { opacity: .7; }
  /* 2026-08-19, Chaim (round 2 of this same indicator): "בתוך העיגול החושב
     ניהיה וי ירוק... חוץ מזה תוריד השאר" - ONE center indicator now, not a
     spinner PLUS a separate corner checkmark badge: the same small circle
     that spins while queued/uploading morphs in place into a solid green
     circle with a checkmark the moment the file is actually done. The
     spinning ring and the check are two children of the same wrapper,
     cross-faded via the is-done class - simpler than the big aggregate
     ring's own SVG stroke-draw morph (that one's meant to be a genuine
     "reveal" centerpiece; this is a tiny 26px per-tile status dot, a plain
     fade+scale reads just as clearly at that size without the extra
     weight). */
  .upload-thumb-status {
    position: absolute; inset: 0; margin: auto; width: 26px; height: 26px;
  }
  .upload-thumb-status[hidden] { display: none; }
  .upload-thumb-status-spin {
    position: absolute; inset: 0; border-radius: 50%;
    border: 3px solid rgba(255,255,255,.4); border-top-color: #fff;
    animation: upload-thumb-spin .8s linear infinite;
    opacity: 1; transition: opacity var(--duration-fast);
  }
  .upload-thumb-status-check {
    position: absolute; inset: 0; border-radius: 50%; background: var(--active); color: #fff;
    display: flex; align-items: center; justify-content: center;
    opacity: 0; transform: scale(.6); transition: opacity var(--duration-fast), transform var(--duration-fast);
  }
  .upload-thumb-status-check svg { width: 16px; height: 16px; }
  .upload-thumb-status.is-done .upload-thumb-status-spin { opacity: 0; }
  .upload-thumb-status.is-done .upload-thumb-status-check { opacity: 1; transform: scale(1); }
  @keyframes upload-thumb-spin { to { transform: rotate(360deg); } }
  @media (prefers-reduced-motion: reduce) {
    /* 2026-08-20 fix (Round 2 audit, RL20): removed a dead
       .upload-thumb-status-spin{animation-duration:2.4s} override that
       could never actually apply - the universal *, *::before, *::after
       rule above already forces animation-duration:.001ms !important for
       everything under this same media query, and !important always wins
       regardless of source order. Net user-facing effect was already
       harmless (the element still freezes correctly, just via the
       universal rule instead of this one), just dead weight - pre-existing
       from before this same duplicated pattern got renamed here, not
       introduced by that rename. */
    .upload-thumb-status-check { transition: none; }
  }
  /* Two real actions once a batch finishes (Chaim: "עדיין תיהיה האפשרות
     להעלות קבצים נוספים... או סיום ואישור שליחה") - replaces the old
     single "אפשר לסגור את הדף" hint, which only ever fires once the
     friend explicitly taps the second button now (see
     UPLOAD_SUCCESS_CONFIRMED_SCRIPT's own comment). */
  .upload-success-actions { display: flex; flex-direction: column; gap: var(--space-2); width: 100%; margin-top: var(--space-3); }
  .upload-progress-row { display: flex; align-items: center; gap: var(--space-3); }
  .upload-row-thumb { width: 44px; height: 44px; border-radius: var(--radius-sm); object-fit: cover; flex: none; background: var(--bg-muted); }
  .upload-row-thumb-file { display: flex; align-items: center; justify-content: center; color: var(--muted); }
  .upload-row-body { flex: 1; min-width: 0; }
  .upload-row-name { font-size: var(--text-sm); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: var(--space-1); }
  .upload-row-bar-track { height: 4px; border-radius: var(--radius-pill); background: var(--bg-muted); overflow: hidden; }
  /* 2026-08-20 fix (visual-consistency audit, P2): gold, not Instrument
     Violet - the exact same fix, for the exact same reason, Chaim already
     asked for on this row's sibling indicator (the aggregate progress
     ring, see its own 2026-08-18 comment above) - a per-file bar is the
     same "in progress" status, so it should read the same way. */
  .upload-row-bar-fill { height: 100%; width: 0%; background: var(--gold-500); border-radius: var(--radius-pill); transition: width var(--duration-fast) var(--ease); }
  .upload-progress-row.is-done .upload-row-bar-fill { background: var(--active); width: 100% !important; }
  .upload-progress-row.is-failed .upload-row-bar-fill { background: var(--deleted); }
  .upload-row-state { flex: none; display: flex; align-items: center; gap: var(--space-2); }
  .upload-row-state svg { display: block; }
  .upload-progress-row.is-done .upload-row-state { color: var(--active); }
  .upload-progress-row.is-failed .upload-row-state { color: var(--deleted); }
  .upload-row-retry {
    border: 1px solid var(--deleted); color: var(--deleted); background: none; border-radius: var(--radius-sm);
    padding: var(--space-1) var(--space-2); font: inherit; font-size: var(--text-xs); cursor: pointer;
  }

  .upload-success-view { display: flex; flex-direction: column; align-items: center; text-align: center; gap: var(--space-2); }
  .upload-success-view[hidden] { display: none; } /* same [hidden]-vs-author-display fix as .upload-hero above */
  .upload-success-icon { color: var(--active); }
  /* Same font-display treatment as the ring's own label - part of the same
     2026-08-18 fix, tying every "hero" number/headline on this whole family
     of pages into the rest of the app's own identity instead of plain
     system-font text. */
  /* 2026-08-20 fix (visual-consistency audit, P2): same Readout Rule
     violation as .upload-hero-band-title above - this wraps plain text
     ("תודה! 🎉", "ההורדה התחילה"), never a number. Dropped the
     --font-display override. */
  .upload-success-title { font-size: var(--text-2xl); font-weight: 700; margin: 0; }
  .upload-success-hint { color: var(--muted); font-size: var(--text-sm); margin: 0; }

  .breadcrumb-back { margin-bottom: var(--space-2); font-size: var(--text-sm); }
  .breadcrumb-back a { color: var(--muted); }
  /* 2026-08-20 fix (visual-consistency audit, P2): gold, not violet - see
     the base "a" rule's own comment above for the full reasoning; every
     link-hover state in this file follows the same rule now. */
  .breadcrumb-back a:hover { color: var(--gold-text); }
  .page-header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); margin-bottom: var(--space-6); flex-wrap: wrap; }
  .page-header h1 { font-weight: 800; font-size: calc(var(--text-3xl) * 1.5); letter-spacing: -.01em; margin: 0; }
  .page-header-actions { display: flex; align-items: center; gap: var(--space-2); flex: none; }
  /* Chaim (2026-08-11): /trash and /tokens replaced their old separate
     breadcrumb-back line with this arrow living right in the title itself -
     first in DOM order, which in this RTL layout puts it at the true right
     edge, immediately before the title text (his own words: "מצד ימין של
     הכותרת"). Sized relative to em so it tracks the title's own font-size
     wherever it's used (mainHeaderHtml's bigger mobile size included, see
     the h1/.selection-count rule further down) rather than a fixed px value
     that would drift out of proportion there. Chaim, 2026-08-12: was too
     small ("צריך להיות פרופורציונלי לטקסט שם") - now matches the title's
     own size exactly (1em) instead of a fraction of it. */
  .header-back-link {
    display: inline-flex; align-items: center; justify-content: center;
    margin-inline-end: var(--space-2);
    /* Chaim, 2026-08-12: "שיהיה בצבע צהוב של האתר" - the site's own gold
       accent, permanently, not just on hover - matches every other header
       icon (search/bell/plus) at rest. */
    color: var(--gold-500); text-decoration: none; vertical-align: middle;
  }
  .header-back-link svg { width: 1em; height: 1em; }
  .header-back-link:hover { color: var(--gold-400); }
  /* ---------- create: inline error + success modal + confetti ----------
     The error sits right above the URL box inside the still-open modal, so a
     duplicate link never bounces you to another page to tell you so. */
  .form-error {
    background: var(--deleted-soft, rgba(224, 87, 74, .12));
    color: var(--deleted); border: 1px solid var(--deleted);
    border-radius: var(--radius-sm); padding: var(--space-2) var(--space-3);
    margin-bottom: var(--space-2); font-size: var(--text-sm); font-weight: var(--weight-semibold);
  }
  .form-error[hidden] { display: none; }
  /* the "this URL already exists as a shortcut, link to it instead?" prompt -
     gold, not red, since it's a helpful suggestion, not an error (the actual
     rejection only happens if they ignore it and submit anyway). */
  .target-suggestion[hidden] { display: none; }
  .target-suggestion-btn {
    display: block; width: 100%; text-align: right; margin-top: var(--space-2);
    background: var(--gold-soft); color: var(--gold-text); border: 1px solid var(--gold-500);
    border-radius: var(--radius-sm); padding: var(--space-2) var(--space-3);
    font: inherit; font-size: var(--text-sm); font-weight: var(--weight-semibold); cursor: pointer;
  }
  .target-suggestion-btn:hover { background: var(--gold-500); color: var(--gold-ink); }
  /* the Google-style live-search dropdown under the target-URL box - a plain
     card list (not gold, this isn't a warning/suggestion about existing data,
     just search results), each a two-line button (title + the real URL). */
  .url-search-results[hidden] { display: none; }
  .url-search-results {
    display: flex; flex-direction: column; gap: 1px; margin-top: var(--space-2);
    max-height: 220px; overflow-y: auto; overflow-x: hidden;
    border: 1px solid var(--border); border-radius: var(--radius-sm);
  }
  .url-search-result {
    display: flex; flex-direction: column; gap: 2px; text-align: right;
    background: var(--card); border: none; border-bottom: 1px solid var(--border);
    padding: var(--space-2) var(--space-3); font: inherit; cursor: pointer;
  }
  .url-search-result:last-child { border-bottom: none; }
  .url-search-result:hover { background: var(--bg-muted); }
  .url-search-loading { color: var(--muted); font-size: var(--text-sm); padding: var(--space-2) var(--space-3); }
  .url-search-result-title { font-size: var(--text-sm); font-weight: var(--weight-semibold); color: var(--text); }
  .url-search-result-url {
    font-size: var(--text-xs); color: var(--muted); direction: ltr; text-align: right;
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  }
  .success-card { text-align: center; }
  .success-card h3 { font-size: var(--text-xl); margin-bottom: var(--space-4); }
  .success-qr {
    width: 200px; height: 200px; margin: 0 auto var(--space-4);
    background: #fff; border-radius: var(--radius-md); padding: var(--space-3);
    box-shadow: var(--shadow-sm); display: flex; align-items: center; justify-content: center;
  }
  .success-qr svg { width: 100%; height: 100%; display: block; }
  .success-url-row {
    display: flex; align-items: center; justify-content: center; gap: var(--space-2);
    background: var(--bg-muted); border-radius: var(--radius-pill);
    padding: var(--space-2) var(--space-3); margin-bottom: var(--space-4);
  }
  .success-url {
    font-size: var(--text-sm); font-weight: var(--weight-semibold); color: var(--text);
    direction: ltr; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
  }
  .success-url-row .icon-btn.copied { color: var(--active); }
  .success-actions { display: flex; gap: var(--space-2); margin-bottom: var(--space-2); }
  .success-actions .btn { flex: 1; display: flex; align-items: center; justify-content: center; gap: var(--space-2); }
  .success-actions .btn svg { width: 18px; height: 18px; }
  .success-done { width: 100%; }
  .confetti-wrap { position: fixed; inset: 0; pointer-events: none; z-index: 200; overflow: hidden; }
  .confetti-wrap i {
    position: absolute; top: -20px; border-radius: 2px;
    animation-name: confetti-fall; animation-timing-function: linear; animation-fill-mode: forwards;
  }
  @keyframes confetti-fall {
    0% { transform: translateY(0) rotate(0deg); opacity: 1; }
    100% { transform: translateY(105vh) rotate(720deg); opacity: .85; }
  }
  @media (prefers-reduced-motion: reduce) { .confetti-wrap { display: none; } }

  /* ---------- header selection bar (shown while multi-selecting) ----------
     Both areas cross-slide between their normal and selecting variants (in
     from / back toward the three-dots at the left edge) instead of a hard
     display swap, so the enter AND the exit both animate.
     - Title: the title and count share ONE grid cell (same size), so the count
       reads as the title just changing its text - exact same place and size.
     - Actions: the normal search/bell pair stays in flow and sizes the box, so
       the title keeps its FULL width in normal mode; the wider selection icons
       are overlaid absolutely (extending toward the centre, into the room freed
       once the title shrinks to the short "N נבחרו" count). */
  .header-title-stack { display: grid; min-width: 0; }
  .header-title-stack > * { grid-area: 1 / 1; align-self: center; }
  .header-actions-stack { position: relative; display: flex; align-items: center; }
  .header-normal-actions { display: flex; align-items: center; gap: var(--space-2); }
  .selection-actions {
    position: absolute; left: 0; top: 0; bottom: 0;
    /* equal-width buttons + one shared gap = identical spacing between every
       icon, matching the search/bell pair; a little air between them (Chaim
       first wanted them tighter, then a touch roomier - this is the middle). */
    display: flex; align-items: center; gap: var(--space-2);
  }
  .selection-count { font-weight: 800; font-size: calc(var(--text-3xl) * 1.5); letter-spacing: -.01em; white-space: nowrap; }
  /* Plain crossfade, no movement at all - picked live over two alternatives
     (an interactive 3-way demo, not guessed) after the shared-axis slide
     read as the outgoing and incoming pairs "mixing/passing through each
     other" mid-transition. Chaim's own words: the old content should
     "evaporate" in place, not slide anywhere, and the new content simply
     appears in its place - so transform is dropped entirely, opacity only. */
  .page-title, .selection-count, .header-normal-actions, .selection-actions {
    transition: opacity 280ms ease;
  }
  .selection-count, .selection-actions { opacity: 0; pointer-events: none; }
  .page-title, .header-normal-actions { opacity: 1; }
  .main.is-selecting .page-title, .main.is-selecting .header-normal-actions { opacity: 0; pointer-events: none; }
  .main.is-selecting .selection-count, .main.is-selecting .selection-actions { opacity: 1; pointer-events: auto; }
  /* .fab-wrap's own is-selecting hide (animated, not this display:none) now
     lives with .fab-wrap itself, further down (STYLE) - kept there instead
     of here so it sits right next to .tracking-tabs, the button it's
     synchronized with. */
  /* bare glyphs, same as the search/bell/three-dots - gold on the dark header
     so they're clearly visible without a heavy circle; the delete stays red. */
  .selection-actions .icon-btn {
    width: 40px; height: 44px; border-radius: 0;
    background: transparent; color: var(--gold-500); box-shadow: none;
  }
  .selection-actions .icon-btn svg { width: 27px; height: 27px; }
  .selection-actions .icon-btn.danger { color: var(--deleted); }
  .selection-actions .icon-btn:active { transform: scale(.9); }
  /* always in the DOM now (one persistent header, no more per-page
     showResetNotif render flag) but still recipients/upload-only -
     "renew notifications" is meaningless for /admin's plain links, which
     never have any. Hidden by default, restored to .icon-btn's own
     inline-flex only while .main carries .page-recipients or .page-upload
     (2026-08-18: uploadSelectionActionsHtml, added the same day, renders
     the exact same icon-reset-notif button for the uploads page's own
     long-press selection bar - missing here the same way the dashboard
     bell button was missing its own .is-escaped rule earlier this round,
     caught by the same E2E-first pattern, not a guess). 3-class selectors
     (matching .icon-btn's own .selection-actions .icon-btn prefix) so this
     reliably beats the plain .icon-btn base rule's display:inline-flex
     regardless of source order - a bare 1-class .icon-reset-notif rule TIES
     that base rule's specificity and LOSES the tie (the base rule sits
     later in this file), which is exactly how this first shipped: hidden
     on paper, but showing on every page in practice. */
  .selection-actions .icon-btn.icon-reset-notif { display: none; }
  .main.page-recipients .selection-actions .icon-btn.icon-reset-notif,
  .main.page-upload .selection-actions .icon-btn.icon-reset-notif { display: inline-flex; }
  /* the exact same gold circle as the FAB, just living inline in the header.
     .icon-btn's own width/height/svg-size rules are defined later in this
     file, so a bare .header-search-btn selector (same specificity, earlier)
     would lose that fight - combined .icon-btn.header-search-btn selectors
     guarantee this wins regardless of source order. */
  /* just the gold glyph now, no filled circle behind it - matching the bare
     three-dots "more" button, so the header reads lighter and less crowded
     (Chaim: the circles "filled up the whole top block too much"). The 56px
     box is still the tap target; only the visible pill/shadow are gone. */
  .icon-btn.header-search-btn {
    width: 56px; height: 56px; border-radius: 0;
    border: none; background: transparent; color: var(--gold-500); box-shadow: none;
  }
  .icon-btn.header-search-btn svg { width: 30px; height: 30px; }
  .icon-btn.header-search-btn:hover { background: transparent; color: var(--gold-400); }
  .icon-btn.header-search-btn:active { transform: scale(.9); }
  /* Chaim (2026-08-12): /tokens' create button moved from a floating FAB
     at the bottom into the header itself, left side - "אתה לא חייב שזה
     יהיה כפתור של פלוס כי זה מעל הבלוק העליון... שים פשוט את סמל הפלוס".
     Exact same treatment as the search/bell glyphs it now sits beside
     (bare gold icon, no filled circle), not the FAB's own filled-pill look
     - it lives in .page-header-actions now, not .fab-wrap. */
  .icon-btn.header-plus-btn {
    width: 56px; height: 56px; border-radius: 0;
    border: none; background: transparent; color: var(--gold-500); box-shadow: none;
  }
  .icon-btn.header-plus-btn svg { width: 30px; height: 30px; }
  .icon-btn.header-plus-btn:hover { background: transparent; color: var(--gold-400); }
  .icon-btn.header-plus-btn:active { transform: scale(.9); }

  /* ---------- notification bell (page header, next to search) ---------- */
  /* the bell always renders now (mainHeaderHtml is one persistent header
     shared by all 3 swipe-nav pages - see there), but it's still
     dashboard-only content: hidden by default, shown only while .main
     carries .page-dashboard, exactly like .tracking-tabs does for its own
     recipients-only pair below. */
  .notif-bell-wrap { display: none; position: relative; }
  .main.page-dashboard .notif-bell-wrap { display: block; }
  /* Real bug, 2026-08-18 (Chaim: "כפתור ההתראה בדשבורד לא מביא שום חלון") -
     escapeToPopover (BULK_SELECT_SCRIPT) moves .notif-bell-wrap OUT of
     .main.page-dashboard and into the sibling #popover-root the instant the
     bell is tapped (the same escape every popover here uses, to clear the
     header's own stacking context - see that function's own comment). The
     ABOVE rule's visibility depends entirely on being a descendant of
     .main.page-dashboard, so the moment it escapes it stops matching that
     selector and falls back to the bare display:none default - the dropdown
     was opening correctly (hidden attribute genuinely removed, confirmed by
     tracing it directly) but rendering at 0x0 because its own parent had
     gone display:none out from under it. .is-escaped is added by the exact
     same escapeToPopover call, right before the reparent, so it's a precise,
     always-in-sync signal for "still open, wherever it currently lives." */
  .notif-bell-wrap.is-escaped { display: block; }
  .icon-btn.header-bell-btn {
    width: 56px; height: 56px; border-radius: 0; position: relative;
    border: none; background: transparent; color: var(--gold-500); box-shadow: none;
  }
  .icon-btn.header-bell-btn svg { width: 30px; height: 30px; }
  .icon-btn.header-bell-btn:hover { background: transparent; color: var(--gold-400); }
  .icon-btn.header-bell-btn:active { transform: scale(.9); }
  /* now that the bell is a bare glyph (no circle), the badge sits at the
     glyph's upper-right inside the tap-target box rather than straddling a
     circle edge that no longer exists. */
  .notif-dot {
    position: absolute; top: 7px; right: 9px;
    width: 15px; height: 15px;
    border-radius: 50%; background: var(--deleted); border: 2px solid var(--ink-800);
  }
  .notif-dropdown {
    position: absolute; top: calc(100% + var(--space-2)); left: 0; z-index: 90;
    width: 320px; max-width: calc(100vw - var(--space-8)); max-height: 60vh; overflow-y: auto;
    background: var(--card); border: 1px solid var(--border); border-radius: var(--radius-md);
    box-shadow: var(--shadow-md); padding: var(--space-4);
  }
  .notif-dropdown h4 { margin: 0 0 var(--space-2); font-size: var(--text-sm); color: var(--muted); font-weight: var(--weight-semibold); }
  .push-enable-btn {
    display: flex; align-items: center; gap: var(--space-2); width: 100%;
    background: var(--gold-soft); color: var(--gold-text); border: 1px solid var(--gold-500);
    border-radius: var(--radius-sm); padding: var(--space-2) var(--space-3); margin-bottom: var(--space-3);
    font: inherit; font-size: var(--text-sm); font-weight: var(--weight-semibold); cursor: pointer;
    transition: background-color var(--duration-fast) var(--ease);
  }
  .push-enable-btn:hover { background: var(--gold-500); color: var(--gold-ink); }
  .push-enable-btn svg { flex: none; width: 17px; height: 17px; }
  .notif-list .activity-list { gap: 2px; }
  /* .activity-title's base rule (below) truncates to one line with an
     ellipsis, right for the dashboard's dense recent-activity feed but wrong
     here - a notification is exactly the content someone opened this
     dropdown to read in full, so let it wrap across as many lines as it
     needs instead of hiding part of it. */
  .notif-list .activity-title { white-space: normal; overflow: visible; text-overflow: clip; }
  /* with the title now free to wrap, align the dot/timestamp to the first
     line instead of dead-center of a now-taller, multi-line row. */
  .notif-list .activity-row { align-items: flex-start; }
  .notif-list .activity-dot { margin-top: 6px; }

  /* the create button now lives inline in the header, next to search/bell -
     .fab-wrap is its positioning anchor for the two-choice popup below it. */
  .fab-wrap { position: relative; }
  /* 2026-08-20 fix (visual-consistency audit, P1): trackingTabsHtml() renders
     .tracking-tabs unconditionally into every page's markup (see layout(),
     it's a sibling of .container inside <main>, "always in the DOM" by
     design - STYLE's own comment on the rule below says so). Its entire
     hidden/shown behavior (fixed position, opacity/transform toggle) used to
     live ONLY inside the mobile @media block, so on desktop - where no rule
     targeted it at all - it fell back to a plain div's native display:block
     and rendered fully visible, in normal document flow, on every single
     page (dashboard, admin, trash, settings...), not just /recipients on
     mobile where it's meant to live. Desktop already shows both tracking
     tables at once (.tracking-tab-panel has no hide rule outside mobile
     either - intentional, there's room), so this floating pill switcher has
     no desktop job at all: hidden here, unconditionally; the mobile block's
     own display:flex (higher up in source but only active under the media
     query) still switches it back on exactly as before. */
  .tracking-tabs { display: none; }
  .fab {
    width: 56px; height: 56px; border-radius: var(--radius-pill); border: none;
    background: var(--gold-500); color: var(--gold-ink); cursor: pointer;
    /* flex, not inline-flex: as an inline box inside .fab-wrap it sat on a text
       baseline, so a descender gap hung underneath it and the button floated a
       couple of px above the bottom offset the wrap was actually placed at. */
    display: flex; align-items: center; justify-content: center;
    position: relative; /* anchors the two absolutely-positioned .fab-icon spans */
    box-shadow: var(--shadow-gold);
    transition: background-color var(--duration-fast) var(--ease), transform var(--duration-fast) var(--ease-bounce);
  }
  .fab svg { width: 26px; height: 26px; }
  .fab:hover { background: var(--gold-600); }
  .fab:active { transform: scale(.94); }
  .fab:focus-visible { outline: none; box-shadow: var(--shadow-focus); }
  /* the + used to rotate into an x while its menu was open - Chaim asked for
     it to stay put instead, always the same plain +, regardless of whether
     the create-menu is open. Deliberate removal, not an oversight - don't
     reintroduce the rotation without asking first. */

  /* Reorder mode: the FAB IS the save action, not a separate button - its
     own + fades/rotates out and a v fades/rotates in, in exactly the same
     spot (both icons always sit stacked via position:absolute, never
     re-rendered - only opacity/transform/color change). onclick itself is
     also re-routed while this is on (see toggleFabMenu, FAB_MENU_SCRIPT). */
  .fab-icon {
    position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
    transition: opacity var(--duration-base) var(--ease), transform var(--duration-base) var(--ease);
  }
  .fab-icon-plus { opacity: 1; transform: scale(1) rotate(0deg); }
  .fab-icon-check { opacity: 0; transform: scale(.4) rotate(-45deg); }
  .main.is-reordering .fab-icon-plus { opacity: 0; transform: scale(.4) rotate(45deg); }
  .main.is-reordering .fab-icon-check { opacity: 1; transform: scale(1) rotate(0deg); }

  /* 2026-08-30, Chaim, explicit and blunt ("תוריד לגמרי את הפמפום הזה...
     תעבור על הקוד כולו ותוודא שאין את הפמפום הזה של הכפתורים בכלל"): the
     continuous "breathing" scale animation (the old @keyframes gentle-pulse,
     a 1.06<->1.13 scale loop) is GONE from every button it was on - the two
     long primary CTAs (.btn.btn-hero, .upload-hero-btn), the FAB's
     hover/press feedback and its reorder-save wait state, and the radial
     create-menu items. Matches his standing "no pumping at all" preference
     (see .mobile-rows long-press, which lost its own pulse for the same
     reason). Reorder mode still reads clearly from the FAB's +<->v icon
     morph (.main.is-reordering above); the CTAs keep their static ambient
     glow (box-shadow), just no motion. */

  /* the FAB's two-choice popup - now opens downward under the button like
     any other header dropdown, since the button itself no longer floats at
     the bottom of the screen. */
  .fab-menu {
    position: absolute; top: calc(100% + var(--space-2)); left: 0; z-index: 90;
    display: flex; flex-direction: column; gap: 8px;
    opacity: 0; visibility: hidden; transform: translateY(-8px) scale(.96);
    transition: opacity var(--duration-base) var(--ease), transform var(--duration-base) var(--ease), visibility 0s linear var(--duration-base);
  }
  .fab-menu.is-open {
    opacity: 1; visibility: visible; transform: none;
    transition: opacity var(--duration-base) var(--ease), transform var(--duration-base) var(--ease);
  }
  /* desktop: the wrapper div each button now sits in (see fabMenuHtml, added
     for the mobile radial layout below) drops out of the box model entirely
     here, so .fab-menu's own flex-column treats each <button> exactly as if
     it were still a direct child - this list looks and behaves identically
     to before the wrapper existed. */
  .fab-menu-item { display: contents; }
  .fab-menu button {
    display: flex; align-items: center; gap: var(--space-2); white-space: nowrap;
    background: var(--card); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius-pill);
    padding: 11px 18px; font: inherit; font-size: var(--text-sm); font-weight: var(--weight-medium); cursor: pointer;
    box-shadow: var(--shadow-md);
  }
  .fab-menu button:hover { background: var(--bg-muted); }
  /* 2026-08-20 fix (visual-consistency audit, P2): gold, not violet -
     same reasoning as the base "a" rule above. */
  /* 2026-08-28: the FAB menu items now use the filled Material Symbols
     (matching the bottom nav) - size them explicitly so link/user/upload
     all read at the same weight (they were 17/17/32 before, visibly
     mismatched). The mobile .fab-wrap rule below already sets 22px. */
  .fab-menu button svg { flex: none; width: 22px; height: 22px; color: var(--gold-text); }

  /* ---------- surfaces ---------- */
  .panel { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius-md); box-shadow: var(--shadow-md); padding: var(--space-8); }
  .panel h2 { font-size: var(--text-2xl); margin-bottom: var(--space-5); }
  .card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius-md); box-shadow: var(--shadow-sm); padding: var(--space-5); }
  .card h3 { font-size: var(--text-2xl); font-weight: var(--weight-bold); color: var(--text); margin: 0 0 var(--space-2); }
  .card-stack { display: flex; flex-direction: column; gap: var(--space-5); }
  .card-success { border-color: var(--active); }
  .card-w-sm { max-width: 420px; margin: 0 auto; }
  .card-w-md { max-width: 640px; margin: 0 auto; }
  .card-w-lg { max-width: 760px; margin: 0 auto; }

  /* ---------- upload links (שיתוף קבצים) ---------- */
  .upload-links-list { display: flex; flex-direction: column; gap: var(--space-3); }
  .upload-link-row { padding: 0; overflow: hidden; }
  .upload-link-row-head {
    width: 100%; display: flex; align-items: center; gap: var(--space-3); padding: var(--space-4) var(--space-5);
    background: none; border: none; font: inherit; text-align: right; cursor: pointer; color: var(--text);
  }
  /* 2026-08-20 fix (visual-consistency audit, P2): this was the "receive"
     badge's violet, sitting right next to its own "send" sibling below
     which was ALREADY gold - now both use the exact same gold-soft/
     gold-text pair; the direction icon inside each (not shown here) is
     what actually tells send/receive apart, not the badge color. */
  .upload-direction-badge {
    flex: none; width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
    background: var(--gold-soft); color: var(--gold-text);
  }
  .upload-direction-badge svg { width: 16px; height: 16px; }
  .upload-direction-badge.upload-direction-send { background: var(--gold-soft); color: var(--gold-text); }
  /* 2026-08-18, Chaim: "את הלוגו של העלאה תוריד לגובה של הכיתוב, שזה לא
     יתפוס מקום בגובה" - the 32px circle above was sized for
     uploadLinkRowHtml's OWN bigger, more spacious head row (untouched -
     that's the trash page's still-current display, "untouched on purpose"
     per this project's own established precedent) - reused as-is inside
     uploadTableRowHtml's compact row-title-cell, it was taller than the
     title/subtitle text next to it and dragged the whole row's height up
     with it (measured: 86px vs a regular link row's 52px). Scoped
     specifically to THAT context so the trash page's own badge is
     untouched - small enough to sit inline with the text, still keeps its
     own tinted circle (send/receive stays a real color cue at a glance, not
     just icon shape) just no longer taller than the line it sits on. */
  .mobile-rows .row-title-cell .upload-direction-badge { width: 20px; height: 20px; }
  .mobile-rows .row-title-cell .upload-direction-badge svg { width: 12px; height: 12px; }
  /* Puts the badge and title on the SAME line, inside row-title-main's own
     column flex (title stacked above subtitle) - min-width:0 on both this
     and row-title-btn so the title still truncates with an ellipsis instead
     of pushing the row wider than the card. */
  .row-title-line { display: flex; align-items: center; gap: var(--space-2); min-width: 0; }
  .row-title-line .row-title-btn { min-width: 0; }
  .upload-link-title { flex: 1; min-width: 0; font-weight: var(--weight-semibold); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  .upload-row-chevron { flex: none; color: var(--muted); transition: transform var(--duration-fast) var(--ease); }
  .upload-link-row-head[aria-expanded="true"] .upload-row-chevron { transform: rotate(180deg); }
  /* Live-activity badge in the row HEAD - visible even while the row body
     is collapsed, so "something is happening right now" (§3/§9 of the
     PRODUCT plan's live-tracking ask) doesn't require Chaim to already have
     this specific row open to notice it. Driven entirely by
     LIVE_UPDATES_SCRIPT's 'upload-progress' SSE listener, not server-
     rendered - always starts hidden. */
  .upload-live-badge { flex: none; display: flex; align-items: center; gap: var(--space-1); font-size: var(--text-xs); color: var(--active); font-weight: var(--weight-semibold); }
  .upload-live-badge[hidden] { display: none; } /* same [hidden]-vs-author-display fix as .upload-hero above */
  .upload-live-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--active); animation: row-pulse-fade 1.2s ease-in-out infinite; }
  @media (prefers-reduced-motion: reduce) { .upload-live-dot { animation: none; } }
  /* The actual live progress bar, inside the (possibly still collapsed)
     row body - reuses the exact same .upload-row-bar-track/-fill markup
     language as the friend-facing upload page's own per-file rows, per
     Chaim's explicit "everything must look consistent" requirement. */
  .upload-live-progress { display: flex; align-items: center; gap: var(--space-3); }
  .upload-live-progress[hidden] { display: none; } /* same [hidden]-vs-author-display fix as .upload-hero above */
  .upload-live-progress-pct { flex: none; font-size: var(--text-xs); color: var(--muted); }
  .upload-link-row-body { padding: 0 var(--space-5) var(--space-5); display: flex; flex-direction: column; gap: var(--space-3); }
  .upload-link-row-body[hidden] { display: none; } /* same [hidden]-vs-author-display fix as .upload-hero above - see toggleUploadLinkRow */
  .upload-quota-gauge { height: 6px; border-radius: var(--radius-pill); background: var(--bg-muted); overflow: hidden; }
  .upload-quota-fill { height: 100%; background: var(--gold-500); border-radius: var(--radius-pill); transition: width var(--duration-base) var(--ease); }
  .upload-quota-label { font-size: var(--text-xs); color: var(--muted); margin-top: calc(-1 * var(--space-2)); }
  .upload-file-list { display: flex; flex-direction: column; gap: var(--space-2); }
  .upload-file-list-empty { color: var(--muted); font-size: var(--text-sm); margin: 0; }
  .upload-file-row { display: flex; align-items: center; gap: var(--space-2); font-size: var(--text-sm); }
  .upload-file-icon { flex: none; color: var(--muted); display: flex; }
  .upload-file-name { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  .upload-file-size { flex: none; color: var(--muted); font-size: var(--text-xs); }
  .upload-file-row.is-downloaded { opacity: .6; }
  .upload-file-done-badge { flex: none; color: var(--active); display: flex; }
  /* 2026-08-18, Chaim: "שיראו את הקובץ... ולא סתם שמות" - real image/video
     previews instead of a filename row, 4 to a line (revised same day from
     an initial 3, "ותעשה ארבעה קבצים"). A percentage grid (not a fixed
     pixel size) so the tiles scale with whatever width the card/panel
     actually has - narrower on a phone, bigger on desktop - rather than a
     size picked for one breakpoint only. */
  .upload-thumb-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--space-2); margin-bottom: var(--space-2); }
  /* 2026-08-18, Chaim: "יש מצב שהחבר ישלח לי מאתיים קבצים" - a batch this
     size must never grow the ring/summary/buttons above it out of reach;
     this scrolls independently once it gets tall instead of stretching the
     whole page. :has(:empty) hides the wrapper purely in CSS before any
     tile actually exists - no JS coordination with startBatch()'s own
     un-hiding needed, one less place for the two to drift apart. */
  .upload-thumb-grid-scroll { max-height: 46vh; overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; }
  .upload-thumb-grid-scroll:has(.upload-thumb-grid:empty) { display: none; }
  .upload-thumb {
    position: relative; aspect-ratio: 1; border-radius: var(--radius-sm); overflow: hidden;
    background: var(--bg-muted); border: 1px solid var(--border);
    /* 2026-08-28 (Chaim: "20 קבצים והכל תקוע... שיראו הכל ושיעבוד חלק"):
       every file gets a tile now, no cap - so an off-screen tile must cost
       nothing. content-visibility skips its layout/paint/decode entirely
       until it scrolls near, and lets the browser drop that work again once
       it scrolls away, so a 30- or 200-image grid never holds more than a
       screenful of decoded bitmaps at once. contain-intrinsic-size keeps
       the scrollbar honest while a tile is skipped. */
    content-visibility: auto;
    contain-intrinsic-size: auto 88px;
  }
  .upload-thumb-media { width: 100%; height: 100%; object-fit: cover; display: block; }
  .upload-thumb.is-zoomable { cursor: zoom-in; }
  /* 2026-08-19: a document tile opens in a new tab, not a zoom overlay -
     same "this tile is clickable" cue, honest cursor for what actually
     happens. */
  .upload-thumb.is-clickable { cursor: pointer; }
  .upload-thumb-play {
    position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
    color: #fff; text-shadow: 0 1px 6px rgba(0,0,0,.5); pointer-events: none;
  }
  /* Same-day follow-up: a real PDF/document preview turns out impractical
     without a real rendering engine (tried embedding via <iframe>/<embed> -
     inconsistent across browsers, often just "couldn't load plugin" - this
     app stays dependency-free, no headless-render library either) - a
     clearly designed icon + short type label ("PDF", "ZIP"...) instead,
     same tile shape as a real thumbnail so the grid still reads as one
     consistent thing, matching Chaim's own explicit fallback: "מה שלא
     [ניתן] שרק יראו את שם סוג הקובץ". */
  .upload-thumb-icon { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; color: var(--muted); }
  .upload-thumb-icon svg { width: 40%; height: 40%; }
  .upload-thumb-type-label {
    position: absolute; inset-inline: 0; bottom: 6px; text-align: center;
    font-size: 10px; font-weight: var(--weight-semibold); color: var(--muted); letter-spacing: .02em;
  }
  .upload-thumb.is-downloaded { opacity: .55; }
  .upload-thumb.is-downloaded .upload-thumb-icon { color: var(--active); }
  .upload-thumb-done-badge {
    position: absolute; bottom: 4px; inset-inline-end: 4px; color: var(--active);
    background: var(--card); border-radius: 50%; display: flex; padding: 1px;
  }
  /* Opposite corner from the done-badge, always visible (not hover-only -
     this is a touch-first app, nothing to hover) - dark + white so it reads
     against any thumbnail, light or dark photo alike. */
  .upload-thumb-remove {
    position: absolute; top: 4px; inset-inline-start: 4px; width: 20px; height: 20px; border-radius: 50%;
    display: flex; align-items: center; justify-content: center; background: rgba(17,24,39,.65); color: #fff;
    border: none; cursor: pointer; padding: 0; -webkit-tap-highlight-color: transparent;
  }
  .upload-thumb-remove svg { width: 10px; height: 10px; }
  .upload-link-actions { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; padding-top: var(--space-2); border-top: 1px solid var(--border); }
  /* The add-files action is the primary action of an expanded send link.
     It used to inherit .btn.secondary, which made it look like a generic
     utility control even though it is the one action this card is asking for.
     Keep the shared .btn sizing/interaction, but give this action the same
     semantic accent treatment as the rest of the site's primary CTAs. */
  .btn.upload-add-files-btn {
    background: var(--gold-500); color: var(--gold-ink); border-color: transparent;
    border-radius: var(--radius-pill); box-shadow: var(--shadow-gold);
    min-width: 176px;
  }
  .btn.upload-add-files-btn:hover { background: var(--gold-600); border-color: transparent; box-shadow: var(--shadow-gold); }
  .btn.upload-add-files-btn:focus-visible { border-color: transparent; }
  .upload-add-files-btn svg { width: 22px; height: 22px; flex: none; }
  .upload-direction-choices { display: flex; flex-direction: column; gap: var(--space-3); margin: var(--space-4) 0; }
  .upload-direction-choice {
    display: flex; flex-direction: column; align-items: center; text-align: center; gap: var(--space-1);
    padding: var(--space-5); border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--bg-muted);
    cursor: pointer; font: inherit; color: var(--text);
  }
  /* 2026-08-20 fix (visual-consistency audit, P2): gold, not violet -
     same reasoning as the base "a" rule above. */
  .upload-direction-choice:hover { border-color: var(--gold-500); background: var(--gold-soft); }
  .upload-direction-choice svg { width: 30px; height: 30px; color: var(--gold-text); }
  .upload-direction-choice-title { font-weight: var(--weight-semibold); }
  .upload-direction-choice-hint { font-size: var(--text-sm); color: var(--muted); }

  /* ---------- upload save page (Web Share) ---------- */
  .upload-save-preview { width: 100%; max-height: 40vh; overflow-y: auto; margin: var(--space-4) 0; text-align: right; }
  .upload-save-docs { width: 100%; margin-top: var(--space-5); }
  .upload-save-docs h2 { font-size: var(--text-base); margin: 0 0 var(--space-3); }
  a.upload-file-row { text-decoration: none; color: inherit; padding: var(--space-2); border-radius: var(--radius-sm); }
  a.upload-file-row:hover { background: var(--bg-muted); }

  .quick-create-card { margin-bottom: var(--space-6); }
  .sr-only {
    position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden;
    clip: rect(0,0,0,0); white-space: nowrap; border: 0;
  }
  .quick-create-input-wrap { position: relative; display: flex; align-items: center; }
  /* the magnifier mirrors the clear-X exactly: same 20px size, same distance
     from its edge (X sits in a 38px box at 8px, so its icon center is 27px in) */
  .quick-create-input-wrap > svg {
    position: absolute; right: calc(var(--space-2) + 9px); width: 20px; height: 20px;
    color: var(--muted); pointer-events: none;
  }
  .quick-create-input-wrap input { padding-right: 46px; padding-left: 46px; text-align: center; }
  .search-clear {
    position: absolute; left: var(--space-2);
    display: none; align-items: center; justify-content: center;
    width: 38px; height: 38px; padding: 0; border: none; background: transparent;
    border-radius: var(--radius-pill); color: var(--muted); cursor: pointer;
    transition: background-color var(--duration-fast) var(--ease), color var(--duration-fast) var(--ease);
  }
  .search-clear svg { width: 20px; height: 20px; }
  .search-clear:hover { background: var(--bg-muted); color: var(--text); }
  .search-clear:focus-visible { outline: none; box-shadow: var(--shadow-focus); }
  .quick-create-input-wrap.has-text .search-clear { display: inline-flex; }

  /* ---------- live search results ---------- */
  .search-results { display: flex; flex-direction: column; gap: var(--space-2); margin-top: var(--space-3); }
  .search-results:empty { display: none; }
  .search-result {
    display: flex; align-items: center; gap: var(--space-3); width: 100%;
    padding: var(--space-1) var(--space-3); border-radius: var(--radius-sm);
    border: 1px solid var(--border); background: var(--card); cursor: pointer;
    font: inherit; color: var(--text); text-align: right;
    transition: background-color var(--duration-fast) var(--ease), border-color var(--duration-fast) var(--ease);
  }
  .search-result:hover { background: var(--bg-muted); border-color: var(--border-hover); }
  .search-result:focus-visible { outline: none; box-shadow: var(--shadow-focus); }
  .search-result-name { font-weight: var(--weight-semibold); min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  /* desktop only - the actual short link, clickable, next to the copy button */
  .search-result-url {
    display: none; flex: 1; min-width: 0; direction: ltr; text-align: left; color: var(--muted);
    font-size: var(--text-sm); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  }
  /* 2026-08-20 fix (visual-consistency audit, P2): gold, not violet -
     same reasoning as the base "a" rule above. */
  .search-result-url:hover { color: var(--gold-text); text-decoration: underline; }
  @media (min-width: 781px) { .search-result-url { display: inline-block; } }
  .search-result .icon-btn { margin-inline-start: auto; flex: none; }
  .search-empty { color: var(--muted); font-size: var(--text-sm); padding: var(--space-2) var(--space-3); }
  /* 2026-08-20 fix (visual-consistency audit, P2): was violet, which would
     now be visually IDENTICAL to .badge.kind-tracking's gold just below -
     these 3 "kind" badges (regular/tracking/upload) need to stay tellable
     apart in a mixed search-results list, and gold is reserved for
     tracking (this app's own featured link type). Regular links get the
     same plain neutral treatment .badge.kind-upload already uses further
     down - "not tracking" is the only thing this badge needs to say. */
  .badge.kind-regular { background: var(--bg-muted); color: var(--muted); }
  .badge.kind-tracking { background: var(--gold-soft); color: var(--gold-text); }
  .badge.kind-upload { background: var(--bg-muted); color: var(--muted); }

  @keyframes row-highlight { 0%, 55% { background-color: rgba(74,63,196,.16); } 100% { background-color: transparent; } }
  tr.row-highlight { animation: row-highlight 2000ms var(--ease); }

  /* ---------- summary ---------- */
  .summary-row { display: flex; gap: var(--space-4); flex-wrap: wrap; margin-bottom: var(--space-6); }
  .summary-card { flex: 1; min-width: 150px; transition: background-color var(--duration-slow) var(--ease); }
  .summary-card .num { display: block; font-size: var(--text-3xl); font-weight: var(--weight-bold); font-variant-numeric: tabular-nums; }
  .summary-card .label { display: block; color: var(--muted); font-size: var(--text-sm); margin-top: var(--space-1); }
  .summary-card.gold .num { color: var(--gold-text); }
  .summary-card.gold { border-color: var(--gold-500); background: var(--gold-soft); }
  /* grid items default to min-width:auto (won't shrink below their content's
     natural size) - without min-width:0 a single long unwrapped activity
     title could force this whole grid, and the page with it, wider than the
     phone's screen instead of ellipsizing. */
  .dashboard-split { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-5); }
  .dashboard-split > .dash-card, .summary-row > .card { min-width: 0; }

  /* ---------- dashboard: hover feedback ---------- */
  .summary-row .summary-card, .dash-card {
    transition: transform var(--duration-fast) var(--ease), box-shadow var(--duration-fast) var(--ease);
  }
  .summary-row .summary-card:hover, .dash-card:hover { transform: translateY(-3px); box-shadow: var(--shadow-md); }
  .activity-row:hover { transform: translateX(-2px); }

  /* ---------- dashboard: clicks chart ---------- */
  .chart-wrap { position: relative; }
  .chart-peak { font-size: var(--text-xs); color: var(--muted); margin-bottom: var(--space-2); }
  .chart-peak strong { color: var(--gold-text); font-variant-numeric: tabular-nums; }
  /* direction:ltr on the plot row and the x-axis row specifically - the SVG's
     own coordinate system always runs left-to-right regardless of page
     direction (day 1 at x=0, today at x=w), so without forcing ltr here an
     RTL flex row would lay these HTML labels out mirrored against it. */
  .chart-plot { display: flex; align-items: stretch; gap: var(--space-2); direction: ltr; }
  .chart-y-axis { position: relative; width: 34px; flex: none; }
  .chart-y-label {
    position: absolute; right: 0; transform: translateY(-50%);
    font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap;
  }
  .dash-chart-svg { cursor: crosshair; flex: 1; min-width: 0; }
  .chart-x-axis {
    display: flex; justify-content: space-between; direction: ltr;
    margin-top: var(--space-2); padding-left: calc(34px + var(--space-2));
  }
  .chart-x-label { font-size: 11px; color: var(--muted); white-space: nowrap; }
  .chart-dot { fill: var(--gold-600); stroke: #fff; stroke-width: 2; transition: opacity .15s var(--ease); }
  /* 2026-08-20 fix (visual-consistency audit, P2): matches .chart-dot just
     above (already gold) - was the one violet leftover in an otherwise
     all-gold chart. */
  .chart-crosshair { stroke: var(--gold-600); stroke-width: 1; stroke-dasharray: 3 3; transition: opacity .15s var(--ease); }
  .chart-tooltip {
    position: absolute; top: 0; left: 0; pointer-events: none; opacity: 0; transition: opacity .15s var(--ease);
    background: var(--ink-900); color: #fff; padding: 7px 11px; border-radius: var(--radius-sm); font-size: var(--text-xs);
    white-space: nowrap; transform: translateY(-100%); box-shadow: var(--shadow-sm); z-index: 5;
  }
  .chart-tooltip strong { display: block; font-size: var(--text-sm); font-variant-numeric: tabular-nums; margin-bottom: 1px; }
  .chart-tooltip span { color: var(--side-muted); }
  @media (prefers-reduced-motion: reduce) {
    .summary-row .summary-card, .dash-card { animation: none; }
  }

  /* ---------- dashboard: activity + notifications feeds ---------- */
  .activity-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
  .activity-row {
    display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2) var(--space-2);
    border-radius: var(--radius-sm);
    transition: background-color var(--duration-fast) var(--ease), transform var(--duration-fast) var(--ease);
  }
  a.activity-row { color: inherit; }
  a.activity-row:hover { background: var(--bg-muted); text-decoration: none; }
  .activity-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--gold-500); flex: none; }
  /* 2026-08-20 fix (visual-consistency audit, P2): was violet - the base
     dot right above is already gold, and this modifier only ever appears
     in the bell dropdown's own excited "עדכון חם!!" milestone/new-visitor
     rows (notificationRowHtml, a different list from the plain activity
     feed the base dot belongs to - the two are never shown side by side),
     so there was never a real "tell these two apart" need here, just a
     leftover off-brand color. */
  .activity-dot.notify { background: var(--gold-500); }
  .activity-body { flex: 1; min-width: 0; }
  .activity-title { font-size: var(--text-sm); font-weight: var(--weight-semibold); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  .activity-meta { font-size: var(--text-xs); color: var(--muted); }
  .activity-time { font-size: var(--text-xs); color: var(--muted); font-variant-numeric: tabular-nums; flex: none; }

  /* ---------- mobile dashboard (2026-08-11 redesign) ----------
     Phone-only (see the @media(max-width:780px) toggle further down, next
     to this app's other phone-only redesigns per CLAUDE.md) - desktop keeps
     the summary/chart/activity dashboard above completely unchanged, so
     none of the rules below need a media guard themselves: their shared
     ancestor .dash-mobile is display:none until that breakpoint hides
     .dash-desktop and shows this instead. */
  .dash-mobile { display: none; }

  .dash-story { font-size: var(--text-sm); line-height: var(--leading-normal); color: var(--muted); margin: 0 0 var(--space-4); }
  .dash-story b { color: var(--text); font-weight: var(--weight-semibold); }

  .now {
    background: var(--ink-800); color: #fff; border-radius: var(--radius-md);
    padding: var(--space-5) var(--space-5) var(--space-4);
    display: flex; align-items: stretch; gap: var(--space-4);
    box-shadow: var(--shadow-sm); overflow: hidden; margin-bottom: var(--space-4);
  }
  .now .num-side { flex: 1; min-width: 0; }
  .now .live { display: inline-flex; align-items: center; gap: 6px; font-size: var(--text-xs); color: var(--side-muted); font-weight: var(--weight-medium); }
  .now .live i { width: 7px; height: 7px; border-radius: var(--radius-pill); background: var(--gold-400); display: inline-block; animation: dash-pulse 2.2s ease-out infinite; }
  @keyframes dash-pulse { 0% { box-shadow: 0 0 0 0 rgba(236,187,94,.55); } 70% { box-shadow: 0 0 0 9px rgba(236,187,94,0); } 100% { box-shadow: 0 0 0 0 rgba(236,187,94,0); } }
  /* --font-display ('Frank Ruhl Libre', self-hosted at weight 800, see the
     @font-face rules below) is declared in :root but was never actually
     applied anywhere before this redesign - the brand's own "Heebo body +
     Frank Ruhl Libre display" personality (see PRODUCT.md) finally shows up
     on the big live number here, the milestone tile's number, and the
     waiting-list initials. */
  .now .big { font-family: var(--font-display); font-weight: 800; font-size: 44px; line-height: 1; color: var(--gold-400); font-variant-numeric: tabular-nums; margin-top: var(--space-1); }
  .now .big small { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--side-text); margin-inline-start: var(--space-2); }
  .now .vs { display: inline-flex; align-items: center; gap: 5px; margin-top: var(--space-2); font-size: var(--text-xs); font-weight: var(--weight-semibold); padding: 3px var(--space-3); border-radius: var(--radius-pill); }
  .now .vs svg { width: 12px; height: 12px; }
  .now .vs.up { background: rgba(62,207,126,.14); color: #7ee0a8; }
  /* Reuses the exact same up-arrow icon as .vs.up, flipped - one icon, two
     meanings, instead of drawing a near-duplicate down-arrow. */
  .now .vs.down { background: rgba(255,107,107,.14); color: var(--deleted); }
  .now .vs.down svg { transform: scaleY(-1); }
  .now .vs.even { background: rgba(255,255,255,.08); color: var(--side-text); }
  .now .hours { display: flex; align-items: flex-end; gap: 3px; height: 60px; align-self: flex-end; padding-bottom: var(--space-1); flex: none; }
  .now .hours b { width: 6px; border-radius: 3px 3px 1px 1px; background: rgba(205,217,214,.28); display: block; }
  .now .hours b.on { background: var(--gold-500); }
  .now .hours b.cur { background: var(--gold-400); animation: dash-hourglow 2.2s ease-out infinite; }
  @keyframes dash-hourglow { 0%, 100% { opacity: 1; } 50% { opacity: .55; } }

  .dash-mosaic { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-3); }
  .tile { min-width: 0; padding: var(--space-4); position: relative; }
  .tile h3 { font-size: var(--text-sm); font-weight: var(--weight-bold); margin: 0 0 var(--space-3); display: flex; align-items: center; gap: 7px; }
  .tile h3 svg { flex: none; }
  .tile.span2 { grid-column: 1 / -1; }
  .tile.t-gold { background: var(--gold-soft); }
  .tile.t-dark { background: var(--ink-900); color: #fff; }
  .tile.t-dark h3 { color: #fff; }

  .tile-badge { font-size: 11px; font-weight: var(--weight-bold); background: var(--bg); color: var(--muted); border-radius: var(--radius-pill); padding: 2px var(--space-2); }
  .tile-badge.gold { background: var(--gold-500); color: var(--gold-ink); }

  .tile-empty { text-align: center; padding: var(--space-4) var(--space-2); }
  .tile-empty-icon { width: 40px; height: 40px; margin: 0 auto var(--space-2); border-radius: var(--radius-pill); background: var(--bg); color: var(--muted); display: flex; align-items: center; justify-content: center; }
  .tile-empty-icon svg { width: 18px; height: 18px; }
  .tile-empty-icon.dark { background: rgba(255,255,255,.08); color: var(--gold-400); }
  .tile-empty p { margin: 0; font-size: 11px; color: var(--muted); line-height: var(--leading-normal); }
  .tile-empty.dark p { color: var(--side-text); }

  /* ---------- mobile dashboard: waiting-for-click tile ---------- */
  .wait { display: flex; flex-direction: column; gap: var(--space-2); }
  .wait .p { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; }
  /* 2026-08-20 fix (visual-consistency audit, P2): held a recipient's name
     initial (a letter), never a number - so --font-display
     didn't belong here either, same Readout Rule violation as the two
     upload-title rules above. */
  .wait .coin {
    width: 32px; height: 32px; border-radius: var(--radius-pill); background: var(--ink-800); color: var(--gold-400);
    display: flex; align-items: center; justify-content: center; font-weight: 800;
    font-size: var(--text-sm); flex: none;
  }
  .wait .who { min-width: 0; flex: 1; }
  .wait .who b { display: block; font-size: var(--text-sm); font-weight: var(--weight-semibold); line-height: 1.25; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  .wait .who span { display: block; font-size: 11px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  .wait .age { font-size: 11px; font-weight: var(--weight-bold); color: var(--muted); background: var(--bg); border-radius: var(--radius-pill); padding: 2px var(--space-2); flex: none; }
  .wait .p.urgent .age { background: var(--gold-500); color: var(--gold-ink); }
  .wait .p.urgent .coin { outline: 2px solid var(--gold-500); outline-offset: 2px; }
  /* copy/QR quick actions on the single most-overdue row only, borrowed from
     the "אנשים קודם" runner-up concept - width:100% forces its own line
     under the coin/name/age row above it, inside the same flex-wrap parent. */
  .urgent-actions { display: flex; gap: var(--space-2); width: 100%; margin-top: 2px; }
  .urgent-actions .icon-btn {
    flex: 1; width: auto; height: auto; padding: 7px 0; border-radius: var(--radius-sm);
    display: flex; align-items: center; justify-content: center; gap: 5px;
    background: var(--bg); font-size: 11px; font-weight: var(--weight-semibold); color: var(--text);
  }
  .urgent-actions .icon-btn svg { width: 13px; height: 13px; }
  .urgent-actions .icon-btn:hover { background: var(--bg-muted); }

  /* ---------- mobile dashboard: hot-link tile ---------- */
  /* 2026-08-20 fix (visual-consistency audit, P2): a link/recipient's real
     name (text), never a number - same Readout Rule violation as the 3
     fixes above it in this file. */
  .hot .name { font-weight: 800; font-size: var(--text-lg); line-height: 1.2; margin: 2px 0 var(--space-2); }
  .hot .share { font-size: 11px; color: var(--gold-text); font-weight: var(--weight-semibold); }
  .hot .bar { height: 8px; border-radius: var(--radius-pill); background: var(--border); margin: var(--space-2) 0 var(--space-3); overflow: hidden; }
  /* --share (a custom property, never an animated width) sets the FINAL
     width once; .grow (added by DASH_LIVE_SCRIPT a tick after paint, on
     both first load and every live refresh) is what actually animates, via
     transform:scaleX - width itself never transitions, so this never trips
     the layout-thrash the design hook flagged on an earlier pass of this
     exact tile. */
  .hot .bar i { display: block; height: 100%; width: var(--share, 0%); border-radius: var(--radius-pill); background: var(--gold-500); transform: scaleX(0); transform-origin: right; transition: transform .9s var(--ease); }
  .hot .bar i.grow { transform: scaleX(1); }
  .hot .second { display: flex; justify-content: space-between; font-size: 11px; color: var(--gold-text); border-top: 1px dashed var(--gold-500); padding-top: var(--space-2); opacity: .8; }
  .hot .second b { font-variant-numeric: tabular-nums; }

  /* ---------- mobile dashboard: week tile ---------- */
  .week { display: flex; align-items: flex-end; gap: var(--space-2); height: 70px; padding-top: var(--space-1); }
  .week .d { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 4px; height: 100%; justify-content: flex-end; }
  .week .d b { width: 100%; max-width: 26px; border-radius: 6px 6px 2px 2px; background: var(--bg-muted); display: block; }
  .week .d.today b { background: var(--gold-500); }
  .week .d span { font-size: 10px; color: var(--muted); font-weight: var(--weight-medium); }
  .week .d.today span { color: var(--gold-text); font-weight: var(--weight-bold); }
  .week .d em { font-style: normal; font-size: 10px; color: var(--muted); font-variant-numeric: tabular-nums; height: 13px; }
  .week .d.today em { color: var(--gold-text); font-weight: var(--weight-bold); }
  .week-foot { font-size: 11px; color: var(--muted); margin-top: var(--space-2); }

  /* ---------- mobile dashboard: files tile ---------- */
  .files { display: flex; flex-direction: column; gap: var(--space-2); }
  .files .f { display: flex; align-items: center; gap: var(--space-2); }
  .files .ficon { width: 30px; height: 30px; border-radius: var(--radius-sm); background: var(--bg); display: flex; align-items: center; justify-content: center; flex: none; color: var(--ink-800); }
  .files .fi { min-width: 0; flex: 1; }
  .files .fi b { display: block; font-size: 12px; font-weight: var(--weight-semibold); line-height: 1.3; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  .files .fi span { font-size: 10.5px; color: var(--muted); }
  .files .fsize { font-size: 10.5px; color: var(--muted); flex: none; }

  /* ---------- mobile dashboard: milestone tile ---------- */
  .mile { text-align: center; padding-top: var(--space-1); }
  .mile .laurel { color: var(--gold-500); opacity: .9; display: block; }
  .mile .laurel svg { width: 24px; height: 24px; }
  .mile .n { font-family: var(--font-display); font-weight: 800; font-size: 34px; color: var(--gold-400); line-height: 1; }
  .mile p { margin: 6px 0 0; font-size: 11px; color: var(--side-text); line-height: var(--leading-normal); }
  .mile p b { color: #fff; font-weight: var(--weight-semibold); }

  .dash-digest { text-align: center; font-size: 11px; color: var(--muted); margin: var(--space-4) var(--space-2) 0; }

  /* One shared background pulse for "a live update just landed here" -
     reuses row-highlight (defined above for table rows) on a plain .tile
     too, same keyframe, a touch shorter. */
  .dash-tile-flash { animation: row-highlight 1400ms var(--ease); border-radius: inherit; }

  /* 2026-08-19, Chaim: "הדשבורד קופץ כשהוא נפתח, תסדר את זה שהוא ישאר
     חלק לגמרי" - real, measured (Playwright bounding-box tracking, not
     guessed) - .rise's own translateY(14px) is exactly the 14px shift every
     dashboard tile showed over its first ~500ms: getBoundingClientRect
     includes transform, so each tile really was rendering 14px below its
     resting position and visibly rising into place, staggered by each one's
     own animation-delay. Not a bug, a deliberate entrance effect - but not
     what he wants here. Content now appears fully in place immediately, no
     motion at all, matching "completely smooth" literally. */
  .dash-mobile .rise { opacity: 1; transform: none; }
  @media (prefers-reduced-motion: reduce) {
    .now .live i, .now .hours b.cur { animation: none; }
    .hot .bar i { transition: none; }
  }

  /* ---------- table ---------- */
  .table-wrap { overflow-x: auto; }
  /* ---------- table header menu (hamburger: multi-select / reorder / delete) ---------- */
  .table-menu-wrap { display: flex; align-items: center; gap: var(--space-2); }
  /* the dropdown anchors to this - just the hamburger - not the wider
     .table-menu-wrap, so it always opens flush under the hamburger itself
     regardless of whether the reorder-mode save button is also showing. */
  .table-menu-anchor { position: relative; }
  /* [hidden] must win over this rule's own display, or the dropdown/action
     button render permanently regardless of the hidden attribute (a real bug:
     an unconditional display:flex here has equal specificity to the UA
     stylesheet's own hidden-attribute rule, and author CSS always wins ties -
     so the menu looked like a stray white box always sitting open, and
     "closing" it via JS silently did nothing visible). Gating display behind
     :not([hidden]) instead means the attribute is what actually controls it. */
  /* narrow, edge-to-edge items (no inset padding on the dropdown itself, so
     each item's own background/hover fills the full width) separated by a
     real divider line rather than just a gap, with a bolder, slightly
     tighter label than a default menu item. */
  .table-menu-dropdown:not([hidden]) { display: flex; flex-direction: column; }
  .table-menu-dropdown {
    position: absolute; top: calc(100% + var(--space-2)); left: 0; z-index: 90;
    width: 170px; background: var(--card); border: 1px solid var(--border);
    border-radius: var(--radius-md); box-shadow: var(--shadow-md); overflow: hidden;
  }
  /* blurs/dims the site behind the menu while it's open, closing it on tap
     like a lightweight modal - same strength as the real .modal-overlay
     (rgba(17,24,39,.45) + blur(6px)), for one consistent "blurred behind a
     panel" feel everywhere on the site. Shared by all three header popovers
     (table hamburger menu, the + button's create menu, the notification
     bell) - whichever is open toggles the same backdrop.
     z-index (65) sits *above* .page-header's sticky z-index (50):
     position:sticky+z-index creates a stacking context, so anything nested
     inside it - including its own trigger button - would otherwise be
     capped at the header's z-index and stay stuck above/below the backdrop
     as one indivisible unit, no matter its own nominal z-index. Since the
     requirement is "blur literally everything except the one active button
     and its popup" (not the whole bar it lives in), the open trigger+popup
     pair is instead *moved* out of its bar into #popover-root (a true
     top-level sibling of this backdrop, see escapeToPopover/unescapeFromPopover
     in BULK_SELECT_SCRIPT) and re-anchored to its exact former screen
     position - the only way to let it sit above the backdrop while its own
     bar sits below it, since a parent's z-index otherwise always wins over
     a child's. (The table hamburger menu uses the same escape mechanism
     even though .table-title-bar itself stopped being sticky/z-indexed -
     harmless to keep using it there too, and one shared code path is
     simpler than a special case.) */
  .table-menu-backdrop {
    position: fixed; inset: 0; z-index: 65;
    background: rgba(17,24,39,.45);
    backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
    opacity: 0; pointer-events: none;
    transition: opacity var(--duration-base) var(--ease);
    /* Chaim (2026-08-11): while blurred, the page behind should feel dead,
       not swipeable - pointer-events:auto above only ever blocked TAPS from
       reaching through, never a touch-drag/scroll gesture starting ON this
       backdrop (a real, separate browser behavior - a Element being
       click-blocked doesn't imply it's scroll-blocked). touch-action:none
       stops that gesture from ever starting a scroll at all. Scoped to just
       this element, not the page/body, so it does nothing to any OTHER
       scrollable area - in particular .modal (the actual sheet content,
       a totally separate element sitting on top of .modal-overlay below)
       keeps its own default touch-action, so scrolling a long form's own
       fields is untouched. */
    touch-action: none;
  }
  .table-menu-backdrop.is-visible { opacity: 1; pointer-events: auto; }
  /* the temporary home + state for a trigger+popup pair pulled out of a
     blurred sticky bar while its own menu is open - see the comment above. */
  .is-escaped { position: fixed; z-index: 80; }
  .table-menu-dropdown form { margin: 0; }
  /* Chaim (2026-08-12): "הקווים עושים את זה טיפה מיושן" - a border-bottom
     under every single row read as dated; dropped entirely rather than just
     lightened. His own worry (no lines -> items blur together) is answered
     by tightening the padding instead (a denser, more deliberate list reads
     as organized on its own, no ruled lines needed) plus ONE real divider
     - not per-item - right before the logout button below (border-top +
     a bit of extra margin there specifically), the one universal grouping
     convention actual native app menus use: everything routine stays as
     one unbroken list, the exit/destructive action sits visually apart at
     the bottom. */
  .table-menu-item {
    display: block; width: 100%; text-align: right; background: none; border: none;
    padding: var(--space-2) var(--space-4); font: inherit;
    /* Chaim (2026-08-13): "תגדיל טיפה את גודל הטקסט בתפריט של שלוש הנקודות" -
       was --text-sm (14px), one real step up to --text-base (15px) per his
       own "טיפה" (a little). */
    font-size: var(--text-base); font-weight: var(--weight-semibold); color: var(--text); cursor: pointer;
  }
  .table-menu-item:hover { background: var(--bg-muted); }
  /* :active is a real, zero-JS guarantee of the same tint for as long as a
     finger/pointer is actually down - a tap used to act instantly with no
     visual sign it even registered, this (plus the ripple itself now, see
     RIPPLE_SCRIPT/.ripple-circle) is what fixed that on touch devices,
     which never trigger :hover. */
  .table-menu-item:active { background: var(--bg-muted); }
  .table-menu-item { transition: background-color 220ms var(--ease); }

  /* ---------- /settings: top-level list (2026-08-13) ----------
     Chaim's own reference screenshot - a real Settings screen from another
     app - bold title, a muted one-line description underneath, icon at the
     row's own end (RTL: icon first in DOM = right edge, same convention as
     .table-menu-item's icon-then-label order elsewhere in this file). */
  .settings-list { display: flex; flex-direction: column; }
  .settings-row {
    display: flex; align-items: center; gap: var(--space-4);
    padding: var(--space-4); text-decoration: none; color: inherit;
    /* 2026-08-13: logout moved here from the dropdown - it's a real form
       submit button, so this also resets a button's own default chrome
       (harmless on the anchor rows). The border reset comes FIRST, then the
       divider below it - 2026-08-30, Chaim: "קו מפריד בין אופציה לאופציה
       כמו שיש בלינקים". The old order reset the border AFTER setting
       border-bottom, silently wiping it - the settings rows never actually
       showed dividers. */
    width: 100%; text-align: right; background: none; border: none; font: inherit; cursor: pointer;
    border-bottom: 1px solid var(--border);
  }
  .settings-row:last-child { border-bottom: none; }
  .settings-row:active { background: var(--bg-muted); }
  .settings-row-icon { flex: none; width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; color: var(--gold-500); }
  .settings-row-icon svg { width: 26px; height: 26px; }
  .settings-row-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1 1 auto; }
  .settings-row-title { font-size: var(--text-base); font-weight: var(--weight-semibold); color: var(--text); line-height: 1.3; }
  .settings-row-subtitle { font-size: var(--text-sm); color: var(--muted); line-height: 1.35; }

  /* Notifications settings list: icon + two-line label/description + the
     on/off switch. Same .settings-row chrome (edge-to-edge press highlight,
     divider between rows) as every other settings row and as the link
     list. The switch keeps to its own width; the label column takes the
     rest. */
  .notif-pref-row { align-items: center; gap: var(--space-3); padding-block: var(--space-3); min-height: 68px; box-sizing: border-box; }
  /* 2026-08-30, Chaim: every notification row must be the exact same height
     ("הכל צריך להיות סימטרי") - so the label + description are each clamped
     to a single line (ellipsis if a translation ever overflows), never
     wrapping to a 3rd/4th line the way the longer ones did. */
  .notif-pref-row .settings-row-title,
  .notif-pref-row .settings-row-subtitle { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  .notif-pref-switch { flex: none; display: flex; }
  .notif-pref-custom-tag {
    align-self: flex-start; margin-top: 3px;
    font-size: 10px; font-weight: var(--weight-semibold);
    color: var(--gold-text); background: var(--gold-soft);
    padding: 1px 7px; border-radius: var(--radius-pill);
  }
  .notif-pref-custom-tag[hidden] { display: none; }

  /* the notification text editor's placeholder chips + inline error */
  .notif-editor-chips { display: flex; flex-wrap: wrap; gap: var(--space-2); margin: var(--space-2) 0 var(--space-3); }
  .notif-editor-chip {
    font: inherit; font-size: var(--text-xs); cursor: pointer;
    background: var(--bg-muted); color: var(--text);
    border: 1px solid var(--border); border-radius: var(--radius-pill);
    padding: 5px 11px;
  }
  .notif-editor-chip:active { background: var(--border); }
  .notif-editor-err { color: var(--deleted); font-size: var(--text-sm); margin: 0 0 var(--space-2); }
  #notif-editor-modal textarea { width: 100%; padding: var(--space-3); border: 1px solid var(--border); border-radius: var(--radius-sm); font-family: inherit; font-size: var(--text-base); background: var(--card); color: var(--text); resize: vertical; }
  /* logout specifically - same "flag the destructive/exit action" red the
     ⋮ dropdown's own .table-menu-item.danger already used, so it doesn't
     just blend in with the 4 ordinary settings rows above it. */
  .settings-row.danger .settings-row-icon, .settings-row.danger .settings-row-title { color: var(--deleted); }

  /* /settings/theme and /settings/colors: the exact .table-menu-item rows
     the ⋮ menu's old theme/palette sub-views used, just given real page-
     width room to breathe instead of the dropdown's own max-width:220px
     (that constraint lives on .mobile-more-dropdown itself, never on
     .table-menu-item - so reusing the same class here needed no override,
     only this flex/icon-sizing rule the dropdown-scoped version already had
     under its own selector, mirrored here for this container instead). */
  .settings-choice-list { padding: var(--space-2) 0; }
  .settings-choice-list .table-menu-item { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-4); font-size: var(--text-base); }
  .settings-choice-list .table-menu-item svg { flex: none; width: 20px; height: 20px; }

  /* 2026-08-20 fix (visual-consistency audit, P2): a real button - exactly
     what gold is for ("every real call-to-action", DESIGN.md). Was violet
     with white text; text goes to gold-ink (not white - gold is too light
     for white text to stay readable, per DESIGN.md's own Colors section),
     same pairing every other gold surface in this file already uses. */
  .table-action-btn {
    background: var(--gold-500); color: var(--gold-ink); border: none; border-radius: var(--radius-sm);
    padding: var(--space-2) var(--space-4); font: inherit; font-size: var(--text-sm);
    font-weight: var(--weight-semibold); cursor: pointer;
  }
  .table-action-btn:hover { background: var(--gold-600); }
  /* the one grouping divider left in this menu, see the base rule's own
     comment above - every .table-menu-item.danger happens to already be a
     "leave/destructive, sits apart from the routine list above it" action
     (logout here; the same class elsewhere in this file is bulk-delete),
     so hanging the separator off a class that already carries that exact
     meaning is more robust than a position-based :last-child/:nth-child
     rule that would silently stop working the moment an item is added
     below it. */
  .table-menu-item.danger { color: var(--deleted); border-top: 1px solid var(--border); margin-top: var(--space-1); padding-top: var(--space-3); }
  /* the currently-applied filter/sort option, inside the filter dropdown -
     same visual language as the old standalone .summary-filter.is-on chips
     this replaced (tinted background, accent text).
     2026-08-20 fix (visual-consistency audit, P2): that "accent" is gold
     now, not violet - "the currently active choice" is exactly the kind of
     status the One Gold Rule reserves gold for. */
  .table-menu-item.is-on { background: var(--gold-soft); color: var(--gold-text); }
  /* 2026-08-20 fix (visual-consistency audit, P1): .mm-swatch/.mm-palette-item/
     .mm-check are shared with the real /settings/colors page (settingsColorsPage
     reuses this exact markup+ids) - this trio used to live only inside the
     mobile @media block below despite that, which meant on desktop the swatch
     dot had no width/height/radius at all (invisible) and the "currently
     applied" checkmark had no hide/show rule so EVERY row showed a checkmark,
     not just the active one. Hoisted here, next to the base .table-menu-item
     rules this component already reuses, so /settings/colors looks and
     behaves identically on desktop and mobile - the mobile block below no
     longer needs its own copy. */
  .mm-swatch { display: block; width: 14px; height: 14px; border-radius: 999px; flex: none; box-shadow: inset 0 0 0 1px rgba(0,0,0,.12); }
  .mm-palette-item { position: relative; }
  .mm-palette-item .mm-check { display: none; margin-inline-start: auto; color: var(--active); flex: none; }
  .mm-palette-item.is-on .mm-check { display: flex; }
  /* the hamburger and filter buttons are a matched pair, both enlarged from
     the base .icon-btn size per explicit request - easier to tap and to
     notice as the list's own controls, not just another small icon. */
  .table-menu-btn { width: 44px; height: 44px; }
  .table-menu-btn svg { width: 24px; height: 24px; }
  /* the table's name lives inside the same card as the table (a title-bar
     section, not a separate tab floating above it) - genuinely one rounded
     body. padding:0 on the card itself since the title bar and table now
     manage their own; the title bar rounds only its own top corners to
     match the card's, so the two read as continuous with no seam. */
  .table-card { padding: 0; }
  .table-title-bar {
    display: flex; align-items: center; justify-content: space-between;
    padding: var(--space-4) var(--space-5); background: var(--bg-muted);
    border-bottom: 1px solid var(--border);
    border-radius: var(--radius-md) var(--radius-md) 0 0;
    cursor: pointer;
  }
  .table-title-bar h3 { margin: 0; font-weight: 800; font-size: var(--text-lg); color: var(--text); }
  /* the whole table body's own show/hide, independent of any single row's
     accordion - same grid-template-rows trick as the row-detail panels, so
     it always unfurls to the table's real height instead of a guessed one. */
  .table-collapse { display: grid; grid-template-rows: 1fr; transition: grid-template-rows 420ms var(--ease); }
  .table-collapse.is-collapsed { grid-template-rows: 0fr; }
  .table-collapse-inner { overflow: hidden; min-height: 0; }
  /* .table-wrap itself has no padding (it's reused inside row-detail panels
     too, which manage their own) - this replaces the padding the outer
     .card used to provide back when the title bar was a sibling of .card,
     not a nested child of it. */
  .table-card .table-wrap { padding: var(--space-4) var(--space-5) var(--space-5); }
  table { width: 100%; border-collapse: collapse; }
  th, td { text-align: right; padding: var(--space-3) var(--space-2); border-bottom: 1px solid var(--border); font-size: var(--text-base); font-variant-numeric: tabular-nums; }
  th { color: var(--muted); font-weight: var(--weight-semibold); font-size: var(--text-xl); }
  tbody tr { transition: background-color var(--duration-fast) var(--ease); }
  tbody tr:hover { background: var(--bg); }
  /* checkboxes are opt-in, not ambient - hidden until "בחירה מרובה" turns
     multi-select mode on for that table (the header's own empty first <th>
     hides in step, so table-layout:fixed columns stay aligned). */
  .checkbox-cell { width: 1%; display: none; }
  /* custom round checkbox with a Checkmark Animation (WhatsApp-style selection,
     per Chaim's agent): the circle fills with the accent colour and the tick
     scales in from nothing with a slight overshoot ("pop") in ~260ms with
     easing - never a hard-cut appearance. */
  .checkbox-cell input[type="checkbox"] {
    appearance: none; -webkit-appearance: none; -moz-appearance: none;
    width: 22px; height: 22px; border-radius: 50%; margin: 0; flex: none;
    border: 2px solid var(--muted); background: var(--card);
    position: relative; cursor: pointer;
    transition: background-color 200ms var(--ease), border-color 200ms var(--ease);
  }
  /* 2026-08-20 fix (visual-consistency audit, P2): checked = the active/
     selected state, exactly what gold is for. Was violet with a white
     check-mark; the mark itself goes to gold-ink to match (white-on-gold
     is the same low-contrast pairing DESIGN.md's Colors section already
     rules out for gold text, and a thin check-mark glyph has the same
     legibility need). */
  .checkbox-cell input[type="checkbox"]:checked { background: var(--gold-500); border-color: var(--gold-500); }
  .checkbox-cell input[type="checkbox"]::after {
    content: '';
    /* measured pixel-by-pixel against the 22x22 circle (a border-right/
       border-bottom box rotated 45deg doesn't visually centre on its own
       raw geometric box centre - Chaim caught it sitting ~1.5px right and
       ~1.75px down from true centre; left/top nudged by exactly that,
       verified after the fact with the same pixel measurement, not just by
       eye). */
    position: absolute; left: 5.25px; top: 1.3px;
    width: 5px; height: 10px; border: solid var(--gold-ink); border-width: 0 2px 2px 0;
    transform: rotate(45deg) scale(0); transform-origin: center;
    transition: transform 260ms cubic-bezier(.2, .8, .3, 1.5);
  }
  .checkbox-cell input[type="checkbox"]:checked::after { transform: rotate(45deg) scale(1); }
  .checkbox-cell input[type="checkbox"]:focus-visible { outline: none; box-shadow: var(--shadow-focus); }
  .mobile-rows thead th:first-child { display: none; }
  table.multi-select-mode .checkbox-cell { display: table-cell; }
  table.multi-select-mode.mobile-rows thead th:first-child { display: table-cell; }
  .row-toggle-cell { display: none; }
  .drag-handle { display: none; cursor: grab; touch-action: none; color: var(--muted); align-items: center; justify-content: center; }
  .drag-handle:active { cursor: grabbing; }
  .drag-handle svg { pointer-events: none; }
  table.reorder-mode td.row-toggle-cell { display: table-cell !important; }
  table.reorder-mode .row-toggle-cell .drag-handle { display: flex; }
  tr.dragging { opacity: .65; box-shadow: var(--shadow-md); position: relative; z-index: 5; background: var(--card); }

  /* ---------- switch (on/off, replaces the old power-icon toggle) ---------- */
  /* display:FLEX, not inline-flex. As an inline box it sat on the parent line's
     text baseline, so the font's descender gap hung below it and pushed the
     visible switch ~3px ABOVE the row's true centre - the "things are slightly
     off-centre" Chaim kept seeing. Same root cause as the three-dots icon.
     Its wrapping <form> is flex too, so it adds no line box of its own. */
  .switch { border: none; background: transparent; padding: 0; cursor: pointer; display: flex; flex: none; }
  .row-status-switch form { display: flex; align-items: center; margin: 0; }
  .switch-track {
    display: block; width: 40px; height: 23px; border-radius: var(--radius-pill);
    background: #c3cfcb; position: relative; transition: background-color var(--duration-base) var(--ease);
  }
  .switch-knob {
    position: absolute; top: 3px; right: 3px; width: 17px; height: 17px; border-radius: 50%;
    background: #fff; box-shadow: 0 1px 3px rgba(13,30,28,.35); transition: transform var(--duration-base) var(--ease);
  }
  .switch.is-on .switch-track { background: var(--active); }
  .switch.is-on .switch-knob { transform: translateX(-17px); }
  .switch:focus-visible { outline: none; box-shadow: var(--shadow-focus); border-radius: var(--radius-pill); }

  /* ---------- row title (visual hint - the whole row is the real click target) ---------- */
  tbody tr[data-link-id] { cursor: pointer; }
  /* desktop columns are fixed-width now (see table-layout:fixed rules below),
     so a long title needs to truncate instead of overflowing into the next
     cell - previously this only happened on mobile, where the card layout
     already forced it. */
  .row-title-btn {
    font-weight: var(--weight-semibold); color: var(--text);
    display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  }
  /* 2026-08-20 fix (visual-consistency audit, P2): "this row is currently
     open" is an active-state signal, same family as .table-menu-item.is-on
     above - gold, not violet. gold-text (not gold-600) since this sits on
     the plain card background as text, same as every other gold-text use. */
  tr.row-expanded .row-title-btn { color: var(--gold-text); }
  /* title cell is a flex row everywhere now (was mobile-only) so the status
     switch has somewhere sane to sit on desktop too, flush to the trailing
     edge - it's the row's only status control now, not a mobile-only extra
     alongside a separate always-visible desktop column. */
  /* the flex layout lives on this inner wrapper, not the <td> itself -
     forcing display:flex directly on a table-cell element knocks it out of
     table-layout:fixed's column-width algorithm entirely (its computed
     display stops being table-cell), which let the title/switch spill out
     past the column's real width on desktop instead of truncating inside
     it. The wrapper gets the exact same flex layout with none of that. */
  .row-title-flex { display: flex; align-items: center; gap: var(--space-3); min-width: 0; }
  /* NO margin-inline-start:auto here - that used to push ONLY the status
     switch off to the trailing edge on its own, leaving the copy/QR buttons
     stranded back near the name (each control in a different place, the
     "messy" look Chaim reported). Instead the name (.row-title-main, flex:1)
     grows to fill and pushes the whole button cluster - copy, QR, and status
     together - to the trailing edge as one group, aligned in a clean column
     down every row. This matches how the tracking rows already behaved. */
  .row-status-switch { flex: none; }
  /* the click-count subtitle / quick actions are mobile-card-only - invisible
     on desktop, where the row stays a plain table row with its own
     always-visible קישור/נלחץ? columns */
  /* flex:1 (all viewports now, not just mobile) so the name grows and pushes
     the button cluster to the trailing edge - on desktop too, where the
     status switch lives in this same title cell. Without the grow here, once
     the switch's own auto-margin was removed above it would have slid back
     next to the name on desktop. */
  .row-title-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1 1 0; }
  /* the click-count subtitle now shows on desktop too (under the name) - it's
     the only place the total-clicks number appears there, since the old
     "קליקים" number column became the "ציר זמן" chart column. The status chip
     and quick-actions stay mobile-card-only (on desktop the status switch has
     its own column-cell spot and copy/QR live in the קישור cell). */
  .row-title-sub { font-size: var(--text-xs); color: var(--muted); }
  .row-quick-actions { display: none; }
  /* 2026-08-25, Chaim: קישורי מעקב's own MOBILE card row should match every
     other row's left side exactly - the on/off switch flush to the
     trailing edge with copy/QR beside it (.row-actions-cluster groups
     those three so justify-content:space-between below treats them as one
     unit, not three separately-spread items) - PLUS the "נלחץ?"/when badge
     shown right in the row itself instead of only in the expanded panel.
     .row-clicked-chip stays display:none here and is only re-enabled inside
     the mobile media query below (same pattern as .row-quick-actions just
     above) - desktop's row-title-cell column is far too narrow (~100px) to
     fit a potentially-long "נלחץ · 25/08/2026, 21:33" string alongside the
     name AND the switch (verified: Playwright showed the switch overflowing
     the column by 100+px, landing at a different x per row, when this was
     briefly left unscoped) - desktop keeps its own dedicated נלחץ? column
     instead (see trackingTableHead/trackingRowHtml's own tracking-status-
     col). */
  /* flex-shrink:3 (not the default 1, and not flex:none) - a full
     "נלחץ · <date>" string can easily be too wide to fit next to the name
     AND the switch on a narrow phone; this lets the chip actually shrink
     under pressure instead of overflowing the card, with .row-clicked-chip
     .badge below turning that shrink into a clean ellipsis truncation
     instead of a wrapped/clipped mess. The name (.row-title-main, its own
     default shrink-factor of 1) is the row's real identifying info, so it
     should give up space to the badge, not the other way around - shrink:3
     here means the chip gives up roughly 3px for the name's every 1px once
     both are being squeezed, keeping the name readable longer. */
  .row-clicked-chip { flex: 0 3 auto; min-width: 0; display: none; }
  .row-clicked-chip .badge { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .row-actions-cluster { display: flex; align-items: center; gap: var(--space-3); flex: none; }

  /* ---------- clicks sparkline ---------- */
  .spark { width: 56px; height: 20px; flex: none; vertical-align: middle; margin-inline-start: var(--space-2); }
  .spark path { fill: none; stroke: var(--gold-600); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; }

  /* ---------- last-click cell: tap to expand a short list of recent clicks ---------- */
  .clicks-history-cell { cursor: pointer; }
  /* 2026-08-25, Chaim: "השורה של קליק אחרון... כאילו נצמדת למעלה. כשאני
     פותח את המידע, הוא כאילו נפתח באמצע ואז פתאום הוא נצמד למעלה" - the
     shared .mobile-extra rule (align-items: center, meant for a simple
     one-line label:value cell) was vertically centering this cell's two
     children (.clicks-history-value + .clicks-history-list) inside its
     own already-tall, still-growing height while the list's max-height
     animated open - so the content visibly sat centered mid-transition,
     then snapped to the top once both heights settled and the "extra"
     centered space vanished. This cell's real content, ONCE OPEN, is a
     top-flowing multi-line block (a value line, then a whole list) -
     top-aligning it from the very first frame removes the jump entirely.
     Scoped to .is-open specifically (2026-08-25 follow-up, live-site
     screenshot): the first version applied this unconditionally, which
     also flipped the CLOSED, single-line state (just the value, e.g.
     "—" or a date - no list, nothing animating) from centered to
     top-aligned, leaving it sitting visibly high instead of vertically
     centered like every other plain .mobile-extra cell. Closed state
     keeps the shared rule's normal centering; only the actually-
     animating open state needs the override. */
  .mobile-rows td.clicks-history-cell.is-open { align-items: flex-start; }
  .clicks-history-value { font-variant-numeric: tabular-nums; }
  /* 2026-08-25 (Task 8, Chaim: "it doesn't open nicely, it opens and then
     something jumps"): a hard display:none/block toggle can't transition at
     all, so the list used to pop in/out in a single frame instead of growing
     smoothly - same family of bug as the .table-wrap/.mobile-extra rows
     nearby, which already learned this lesson (max-height/opacity, never
     display, for anything that needs to animate open/closed). */
  .clicks-history-list {
    max-height: 0; opacity: 0; overflow: hidden;
    transition: max-height var(--duration-slow) var(--ease), opacity var(--duration-slow) var(--ease);
  }
  .clicks-history-cell.is-open .clicks-history-list { max-height: 1500px; opacity: 1; }
  /* 2026-08-25 (post-Task-8 review fix): linkHistoryPage (/links/:id/history,
     "היסטוריה מלאה") reuses this same .clicks-history-list class for its
     always-visible list, but that page has no .clicks-history-cell ancestor
     anywhere in its markup - it can never match .is-open above, so the base
     rule's max-height:0/opacity:0/overflow:hidden clipped the whole page's
     click list to nothing. Before Task 8 this was a plain display:none, and
     the page's own inline style="display:block" fully overrode it (inline
     beats a class selector); Task 8 switched the base rule to
     max-height/opacity for the accordion's animation, properties an inline
     display:block never touches, silently breaking this unrelated page. Own
     opt-out class instead of fighting inline-style specificity property by
     property. */
  .clicks-history-list.full-page { max-height: none; opacity: 1; overflow: visible; }
  /* flex + space-between so .clicks-history-when/.clicks-history-who (the
     timestamp and the "who clicked" name) sit apart instead of butted
     together with just a collapsed whitespace between them. flex-wrap:wrap
     (not the mobile card's plain single line) matters here because this
     same rule is shared with the desktop <table> view too, where the
     קליק-אחרון column is much narrower than a mobile card - without wrap,
     a strict single-line flex row clipped/overlapped the "who" span there
     (caught by screenshotting desktop, not just mobile) where the old plain
     inline text used to wrap the two onto their own separate lines. With
     wrap, mobile's wide card still shows them on one space-between line
     (they comfortably fit), and desktop's narrow column falls back to the
     same two-line wrap it already had. */
  .clicks-history-row {
    display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: var(--space-2);
    font-size: var(--text-sm); color: var(--muted); padding: 4px 0;
    border-top: 1px solid var(--border); font-variant-numeric: tabular-nums;
  }
  .clicks-history-row:first-child { border-top: none; }
  /* the timestamp reads as the primary column (full text color); the "who"
     name stays muted - a visual hierarchy, not just two identical gray spans. */
  .clicks-history-when { color: var(--text); }
  .clicks-history-who { color: var(--muted); }
  .clicks-history-empty { font-size: var(--text-sm); color: var(--muted); padding: 4px 0; }
  .clicks-history-full-link { display: block; text-align: center; font-size: var(--text-sm); color: var(--active); padding: var(--space-2) 0; }

  .cell-ellipsis { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  @media (min-width: 781px) {
    /* checkbox (40px) + actions (200px, snug around its icon buttons) are
       reserved as fixed pixels; column 3 (the URL) gets a plain percentage
       of the table's own width; every other column is left with no explicit
       width at all, so table-layout:fixed's own "remaining columns evenly
       divide whatever's left" rule (CSS2.1 §17.5.2) does the rest - no hand
       -balanced arithmetic needed, and it can never overflow/gap by
       construction.
       2026-08-20 fix (visual-consistency audit, P1): this used to be
       calc((100% - 240px) * <fraction>) per column, all fractions summing to
       1.0 - looked correct on paper, but VERIFIED (Playwright, real measured
       getBoundingClientRect widths, not just reasoning about the CSS spec)
       that Chromium's table-layout:fixed algorithm does NOT resolve a
       mixed-unit calc() (percentage combined with a pixel subtraction) into
       a definite column width - it silently falls back to 0/auto for that
       column instead, regardless of which fraction was written. So EVERY
       fraction here, old (0.32) and an earlier rebalance attempt (0.44)
       alike, was actually being ignored the whole time - all 7 percentage
       columns were already landing on a flat equal split of the leftover
       space, which is what caused the URL column's real bug (it only had
       ~104px, most of it eaten by its 2 icon buttons, so its text wrapped
       one character per line). A calc()-free plain percentage (40%,
       tested in isolation) resolves correctly, hence the switch below. */
    .tracking-table { table-layout: fixed; }
    .tracking-table th:nth-child(1), .tracking-table td:nth-child(1) { width: 40px; }
    .tracking-table th:nth-child(3), .tracking-table td:nth-child(3) { width: 40%; }
    /* were nth-child(9)/(10) - shifted to 8/9 (2026-08-25) when the סטטוס
       column was removed from trackingTableHead (see that function's own
       comment; נלחץ? stayed, so only one column dropped out) - actions is
       now the 8th column, row-toggle-cell the 9th. */
    .tracking-table th:nth-child(8), .tracking-table td:nth-child(8) { width: 200px; }
    .tracking-table th:nth-child(9), .tracking-table td:nth-child(9) { width: 0; }

    /* #links-table (regular links) was table-layout:auto - the browser sizes
       each column from its content's natural width, and since that has no
       hard cap, a wide combination of columns can add up to more than the
       card's own width, making the table (and its border-bottom lines)
       silently extend past the visible edge instead of stopping at 100%.
       table-layout:fixed with explicit widths guarantees the 9 columns
       always sum to exactly 100%, same fix as .tracking-table above.
       2026-08-20 fix (visual-consistency audit, P1 family follow-up): this
       had the SAME calc((100% - 240px) * fraction) bug .tracking-table's
       own width rules just got fixed for - verified (Playwright) that this
       table had the exact same "URL column collapses to a shared equal
       split, text wraps one character per line" symptom, for the same
       underlying reason (calc() mixing % and px never resolves for table
       column widths under table-layout:fixed). Same fix: a bare percentage
       for the URL column, the rest left auto to divide what's left. */
    #links-table { table-layout: fixed; }
    #links-table th:nth-child(1), #links-table td:nth-child(1) { width: 40px; }
    #links-table th:nth-child(3), #links-table td:nth-child(3) { width: 40%; }
    #links-table th:nth-child(7), #links-table td:nth-child(7) { width: 200px; }
    #links-table th:nth-child(8), #links-table td:nth-child(8) { width: 0; }

    /* the click count reads better as a centered number in its own column
       than pinned to the default text-align:right - both #links-table and
       .tracking-table happen to put קליקים at column 4 despite otherwise
       differing column order, so one nth-child rule covers both. */
    .mobile-rows th:nth-child(4), .mobile-rows td:nth-child(4) { text-align: center; }
  }

  .empty-state { padding: var(--space-8) var(--space-4); text-align: center; }
  /* 2026-08-26 fix (Chaim, real screenshot: a table with 3 real rows was
     ALSO showing "עדיין אין לינקים" underneath them): same root cause as
     .upload-thumb-retry's own documented bug earlier in this file - an
     author-stylesheet display declaration always beats the browser's own
     default hidden-attribute display:none UA rule, regardless of selector
     specificity. The mobile centering rule further down sets display:flex
     unconditionally on .empty-state, which silently broke the hidden
     attribute emptyState() itself already renders whenever there IS real
     data (see that function's own hidden param) - the table's own
     equivalent (.is-empty-hidden) already carries an !important for
     exactly this reason; this needed the same fix. */
  .empty-state[hidden] { display: none !important; }
  /* The table itself is always rendered now, even with zero rows (see the
     comment above linkedRows/standaloneRows/tableOrEmpty in render.js) so a
     live SSE update always has somewhere to insert its first row - this class
     is how it stays visually hidden until that happens. !important because a
     later mobile rule (.table-wrap table { display: block }) outranks a bare
     [hidden] attribute's specificity and would otherwise show an empty,
     headerless table. */
  .is-empty-hidden { display: none !important; }
  /* same bare-[hidden]-loses-to-a-later-display-rule story as .is-empty-hidden
     right above (here it's .card-stack's own display:flex that wins the
     cascade tie) - HEADER_SEARCH_SCRIPT's showResultsInPage/restorePageContent
     use this to hide the real page content behind search results. */
  .header-search-hidden-content { display: none !important; }
  /* flex, not inline-flex - same baseline-gap family as .switch/.fab: alone
     on its centered text-align:center line, an inline box picks
     up a descender gap below it that pushes it visibly high, widening the
     gap to the title beyond the intended margin-bottom. margin-inline:auto
     centers the now-block box horizontally in its place. */
  /* 2026-08-26, Chaim (real mockups compared against WhatsApp's own empty
     state, "option A" picked): a real brand moment, not a small muted-gray
     dot - bigger, tinted with the app's actual --active green rather than
     --bg/--muted. color-mix (not a fixed rgba(31,157,85,.13)) blends against
     --card at request time, so this stays correct automatically in dark
     mode too instead of needing its own dark-mode override. */
  .empty-state-icon {
    display: flex; align-items: center; justify-content: center; width: 88px; height: 88px;
    border-radius: var(--radius-pill); background: color-mix(in srgb, var(--active) 14%, var(--card));
    color: var(--active); margin: 0 auto var(--space-4);
  }
  .empty-state-icon svg { width: 32px; height: 32px; }
  .empty-state-title { font-weight: var(--weight-semibold); margin: 0 0 var(--space-1); }
  .empty-state-hint { color: var(--muted); font-size: var(--text-sm); margin: 0; }
  /* 2026-08-26 follow-up, Chaim: the plain-pill fix above (just border-
     radius) wasn't enough - "לא אותו דבר... לא הכי בשל", specifically
     compared against .tracking-tab-btn (the מקושרים/עצמאיים pills on
     /recipients). Cloning that button's own box model/typography/shadow
     directly (padding, font-size/weight, box-shadow) instead of leaning on
     generic .btn values that happen to differ in all of those - in its
     .is-active (selected/filled) treatment specifically, since this is
     always the one prominent action here, never a toggle between two
     states the way the real tab buttons are. Still a compound selector
     (.btn.empty-state-cta) for the same cascade-specificity reason as
     before - a plain .empty-state-cta alone silently loses to .btn's own
     same-specificity rule later in this file. */
  .btn.empty-state-cta {
    margin-top: var(--space-5); padding: var(--space-3) var(--space-4); border-radius: var(--radius-pill);
    border: 1px solid var(--gold-500); background: var(--gold-500); color: var(--gold-ink);
    font-size: var(--text-sm); font-weight: var(--weight-semibold); box-shadow: var(--shadow-md);
  }
  .btn.empty-state-cta:hover { background: var(--gold-600); border-color: var(--gold-600); box-shadow: var(--shadow-md); transform: none; }

  /* ---------- trash (סל מיחזור) ---------- */
  /* Chaim (2026-08-11, after a first custom-card attempt): the list itself
     must be the exact real row markup/table (see trashPage) - everything
     below is only the small, additive part that markup doesn't already
     have: marking every real row/card as a dimmed, inactive "not a live
     thing right now" item (same opacity+grayscale language .badge.disabled
     already uses elsewhere), undone the instant it's checked so a selected
     item still reads clearly against its dimmed neighbours.
     2026-08-17: the old .trash-sub caption line + its own inline
     .trash-select-all-btn text button are gone entirely (Chaim: drop the
     caption, select-all belongs only in the shared header spot like the
     links page) - see .trash-restore-cell above and TRASH_SELECT_SCRIPT's
     own comment. */
  #trash-links-table tbody tr[data-link-id] { opacity: .6; filter: grayscale(30%); transition: opacity var(--duration-fast) var(--ease), filter var(--duration-fast) var(--ease); }
  #trash-links-table tbody tr[data-link-id]:has(input[name="ids"]:checked),
  #trash-links-table tbody tr[data-link-id].row-pressing { opacity: 1; filter: none; }
  /* Editing/toggling/resetting a deleted item doesn't apply (restore is the
     one real action) - hidden/inert everywhere, not just on mobile, unlike
     the real table's own accordion-only mobile hiding (.mobile-rows td.
     actions), since desktop never accordion-gates .actions at all. */
  #trash-links-table td.actions { display: none !important; }
  /* Chaim (2026-08-12): strip the row down further - "לא מעניין אותי האם זה
     נלחץ והאם זה פעיל..." (doesn't care whether it was clicked, whether
     it's active, copy/QR) - none of those apply to a deleted item, and
     unlike the earlier round (dimmed+inert but still visible), he now wants
     them fully gone, not just disabled. What's left: name+click-count on
     the right (row-title-main, untouched), the kind badge pulled out of its
     normal spot inside row-title-main and pinned to the row's own left edge
     instead ("בצד ימין... ובצד שמאל") - real markup throughout, just two
     scoped CSS moves, no change to the shared row functions themselves. */
  #trash-links-table .row-status-switch,
  #trash-links-table .row-clicked-chip,
  #trash-links-table .tracking-status-col,
  #trash-links-table .row-quick-actions,
  #trash-links-table .folder-tag { display: none !important; }
  #trash-links-table .row-title-flex { position: relative; }
  #trash-links-table .search-kind-badge { position: absolute; left: 0; top: 50%; transform: translateY(-50%); }
  /* the upload card's own real checkbox-less head - this table's checkbox
     cell (real markup, every other row already has one) stands in for it. */
  .trash-upload-cell { padding: 0 !important; border-bottom: none !important; flex: 1; min-width: 0; }
  .trash-upload-cell .upload-link-row { margin: 0; box-shadow: none; }
  /* same "doesn't care if it's active" reasoning as the switch above -
     scoped to .active/.disabled specifically (not .badge generally), so
     the kind badge (also a .badge, different classes) stays untouched. */
  .trash-upload-cell .badge.active, .trash-upload-cell .badge.disabled { display: none; }

  /* ---------- badges ---------- */
  .badge { display: inline-block; padding: 3px 11px; border-radius: var(--radius-pill); font-size: var(--text-xs); font-weight: var(--weight-semibold); }
  .badge.active { background: #e0f2e5; color: #17693a; }
  .badge.disabled { background: var(--bg-muted); color: var(--muted); }
  /* "נלחץ" - the friend opened the link but hasn't finished (see
     uploadStatusBadge): an in-progress amber, between disabled-grey and
     active-green. */
  .badge.progress { background: var(--gold-soft); color: var(--gold-text); }

  /* The upload row's collapsed subtitle now carries the 3-state status
     badge (Chaim, 2026-08-28) beside the "N קבצים · size" text - keep them
     on one line, wrapping only if the row is genuinely too narrow. */
  .row-upload-subline { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; }
  .row-upload-subline .upload-status-badge { padding: 2px 8px; font-size: 10px; }

  /* Tracking row sub line (2026-08-30, Chaim: the clicked badge + its
     timestamp overlapped the recipient name and clipped its own date). The
     clicked pill + full "30/08/2026, 07:23" now sit on this line, under the
     name at full width. Desktop keeps its dedicated נלחץ? column, so
     .tracking-clicked-meta is hidden there (the count reads exactly as
     before); the mobile media query re-enables it. */
  .tracking-row-sub { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 6px; }
  .tracking-clicked-meta { display: none; align-items: baseline; gap: 5px; min-width: 0; }
  .row-tag { display: inline-block; padding: 1px 8px; border-radius: var(--radius-pill); font-size: 11px; font-weight: var(--weight-semibold); line-height: 1.6; white-space: nowrap; }
  .row-tag-clicked { background: #e0f2e5; color: #17693a; }
  .row-tag-idle { background: var(--bg-muted); color: var(--muted); }
  /* isolate so the LTR "30/08/2026, 04:51" doesn't get its comma/digits
     reshuffled by the surrounding RTL line. */
  .row-sub-when { color: var(--muted); white-space: nowrap; direction: ltr; unicode-bidi: isolate; }

  /* ---------- forms ---------- */
  label { display: block; font-size: var(--text-sm); color: var(--muted); margin: var(--space-4) 0 var(--space-1); }
  input[type=text], input[type=url], input[type=password], select {
    width: 100%; padding: var(--space-3); border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: var(--text-base);
    font-family: inherit; background: var(--card); color: var(--text);
    transition: border-color var(--duration-fast) var(--ease), box-shadow var(--duration-fast) var(--ease);
  }
  input[type=text]:hover, input[type=url]:hover, input[type=password]:hover, select:hover {
    border-color: var(--border-hover);
  }
  input[type=text]:focus, input[type=url]:focus, input[type=password]:focus, select:focus {
    outline: none; border-color: var(--accent); box-shadow: var(--shadow-focus);
  }
  /* same reasoning as .modal-actions just below: flex-end anchors the
     submit+cancel row to the LEFT edge in this RTL layout (Chaim: every
     create/edit form's buttons currently cluster on the right, move them
     left), and the pill radius is scoped to just this row rather than a
     global .btn change. */
  .form-actions { display: flex; gap: var(--space-2); margin-top: var(--space-5); justify-content: flex-end; }
  .form-actions .btn { border-radius: var(--radius-pill); }
  /* Link creation specifically (Chaim, 2026-08-11): no cancel button at all
     - Escape and tapping outside the modal already close it (see
     activeModalCancel/openFormModal), so nothing is actually lost - just ONE
     big, centered, "alive" call to action instead of a submit+cancel pair.
     Reuses the exact same gold-fill / wide / glowing language
     .upload-hero-btn already established for "the one thing to press here",
     scaled up further per Chaim's explicit "roughly double the current
     size" - not a new visual idiom invented from scratch. */
  .form-actions-single { justify-content: center; }
  /* .btn.btn-hero, not bare .btn-hero: the markup is class="btn btn-hero",
     and a bare .btn-hero selector has the EXACT SAME specificity as .btn
     itself (one class each) - .btn's own box-shadow (the inset white top-
     highlight every plain .btn gets) happened to sit LATER in this
     stylesheet and was silently winning the tie, which was the "white
     stripe across the top" bug Chaim caught. Chaining to .btn.btn-hero
     gives it real, unambiguous higher specificity, correct regardless of
     source order.
     CORRECTED again (Chaim, same day): the FIRST width fix set an explicit
     font-size/padding bump (roughly double .btn's own) AND width:100% on
     top of that already-enlarged sizing - stacked together that read as
     genuinely oversized/disproportionate ("ענק בלי פרופורציה"), not what
     he'd actually approved. He'd approved the SIZE (font/padding) of the
     very first hero version - the only axis he ever asked to double was
     WIDTH. So: font-size/padding are gone entirely now - this button is
     the EXACT same height/text-size as every other plain .btn (nothing
     redeclared, it just inherits .btn's own values), and only min-width
     makes it wider - a fixed, roughly-double-a-natural-pill's-width value,
     not width:100% (which stretched it to the ENTIRE form's width,
     compounding the oversized feel). What's left making it "hero" at all:
     the stronger ambient glow (box-shadow, replacing .btn's subtle inset
     highlight) and the wider min-width. The continuous "breathing" pulse
     this used to also carry was removed 2026-08-30 at Chaim's explicit
     request ("תוריד לגמרי את הפמפום הזה... בכלל") - static now, no motion. */
  .btn.btn-hero {
    min-width: 200px;
    box-shadow: var(--shadow-gold);
  }
  .btn.btn-hero:hover, .btn.btn-hero:active { background: var(--gold-600); }
  .error { background: #ffe0e1; color: #ad2b31; padding: var(--space-3) var(--space-4); border-radius: var(--radius-sm); margin-bottom: var(--space-4); font-size: var(--text-base); }

  /* .folder-tabs/.folder-tab-group/.folder-tab* (the old tab-bar UI) were
     removed as dead code in this pass - the last markup that ever used them
     (the folder-browsing tabs, then the active/disabled filter chips that
     briefly reused the same class) is gone from both the folders-removal
     round and the filter/sort-dropdown round; found via a proactive grep
     for every :hover rule turning up one with zero matching markup anywhere
     in the file. .folder-tag (the small per-row badge showing which folder
     a link belongs to) is unrelated and still in real use - kept as-is. */
  .folder-tag {
    display: inline-block; font-size: var(--text-xs); color: var(--muted); background: var(--bg);
    border: 1px solid var(--border); border-radius: var(--radius-xs); padding: 1px 8px; margin-top: 3px;
  }

  /* ---------- ripple / press feedback (RIPPLE_SCRIPT) ----------
     Chaim, 2026-08-12: exact Material Design 3 ripple spec from a reference
     video - "כדי להגיע לרמת דיוק של 100%... ללא מקום לפרשנות". Applied to
     essentially every real button site-wide (see RIPPLE_SCRIPT's own
     SELECTOR) - explicitly excluding the REAL bottom tab bar (dashboard/
     links/recipients/upload - his own words, "חוץ מהכפתורים של הכרטסיות
     למטה" - that row already has its own established sliding-pill feel, a
     different interaction entirely) and .switch (a toggle has its own
     sliding-knob animation, not a momentary press). 2026-08-13: durations
     bumped a little per Chaim's own follow-up ("תאריך אותו עוד טיפה") -
     was 225ms expand / 150ms fade, now 280ms / 180ms.

     .ripple-clip (not overflow:hidden on the button itself) is what
     actually clips the circle to the button's shape - several of these
     buttons (.fab, .btn-hero) have their own real box-shadow, and
     overflow:hidden directly on the button would silently clip that too.
     A separate absolutely-positioned inset:0 child bears the clipping
     instead, leaving the button element itself untouched. border-radius:
     inherit so it matches the specific button's own shape (circle, pill,
     or a softer rect) without needing a second lookup per class.

     opacity is a static value, never itself transitioned on entry (the
     same instant-appear/animated-disappear trick this file already uses
     elsewhere for JS-driven state classes - transform's own transition on
     the base .ripple-circle rule is what makes the EXPANSION visible;
     is-releasing only adds a transition for opacity, scoped to the
     fade-OUT alone). background: currentColor rather than a
     fixed rgba(0,0,0,.12) - his own spec listed both as valid, currentColor
     is the one that actually stays visible against this app's dark
     header/sidebar (gold or light text there) as well as light cards
     elsewhere, and adapts automatically in dark mode too. */
  .ripple-clip {
    position: absolute; inset: 0; overflow: hidden;
    border-radius: inherit; pointer-events: none;
  }
  .ripple-circle {
    position: absolute; border-radius: 50%;
    background: currentColor; opacity: .12;
    transform: scale(0);
    transition: transform 280ms cubic-bezier(.2,0,0,1);
  }
  .ripple-circle.is-expanding { transform: scale(1); }
  .ripple-circle.is-releasing {
    opacity: 0;
    transition: transform 280ms cubic-bezier(.2,0,0,1), opacity 180ms ease-out;
  }

  /* ---------- buttons ---------- */
  .btn {
    display: inline-flex; align-items: center; justify-content: center; gap: 6px;
    padding: 10px 18px; border-radius: var(--radius-sm); border: 1px solid transparent;
    background: var(--gold-500); color: var(--gold-ink); font-size: var(--text-base); font-weight: var(--weight-bold);
    font-family: inherit; cursor: pointer;
    box-shadow: inset 0 1px 0 rgba(255,255,255,.3);
    transition: background-color var(--duration-fast) var(--ease), border-color var(--duration-fast) var(--ease),
                box-shadow var(--duration-fast) var(--ease), transform var(--duration-fast) var(--ease);
  }
  .btn:hover { background: var(--gold-600); transform: translateY(-1px); box-shadow: var(--shadow-gold); }
  .btn:active { transform: translateY(0) scale(.98); }
  .btn:focus-visible { outline: none; box-shadow: var(--shadow-focus); }
  .btn:disabled, .icon-btn:disabled { opacity: .55; cursor: default; pointer-events: none; transform: none; }
  .btn.secondary { background: var(--card); color: var(--text); border-color: var(--border); box-shadow: none; }
  .btn.secondary:hover { background: var(--bg-muted); border-color: var(--border-hover); box-shadow: none; }
  /* Solid fill, not an outline (Chaim, 2026-08-11: "לא רק עם מסגרת אדומה
     אלא עם מילוי אדום כולו") - the one real use site is the delete-
     confirmation dialog's own "אישור" button (.icon-btn.danger, a
     different, separately-styled class for small inline trash icons
     elsewhere, is untouched - a small icon getting a full red fill would
     read very differently than this one deliberate, high-stakes button). */
  .btn.danger { background: var(--deleted); color: #fff; border-color: transparent; box-shadow: none; }
  .btn.danger:hover { background: #b93838; border-color: transparent; color: #fff; box-shadow: none; }
  .btn.block { width: 100%; }

  /* NOT display:flex on the <td> itself - forcing flex on a table cell drops
     its computed display from table-cell, which pulls it out of the table's
     border-collapse so its bottom border no longer lines up with the rest of
     the row's separator line (it looked "cut" under the action icons). Same
     trap the .row-title-cell comment above describes. Kept a plain table cell;
     the buttons flow inline and get their spacing from a margin between
     siblings, which works identically once the mobile rule re-flexes it. */
  .actions { white-space: nowrap; }
  .actions form { display: inline; }
  .actions > * + * { margin-inline-start: var(--space-2); }

  .link-cell { display: flex; align-items: center; gap: var(--space-2); min-width: 0; }
  /* wrap instead of ellipsize - the whole link should always be readable,
     never cut off; the row simply grows taller to fit it */
  .link-cell .link-text {
    white-space: normal; word-break: break-all; max-width: 100%; min-width: 0; flex: 1;
    font-family: inherit; font-size: var(--text-sm); font-weight: var(--weight-medium); line-height: var(--leading-snug, 1.35);
  }
  .icon-btn {
    display: inline-flex; align-items: center; justify-content: center;
    width: 38px; height: 38px; flex: none; padding: 0;
    border-radius: var(--radius-sm); border: none; background: transparent;
    color: var(--muted); cursor: pointer;
    transition: background-color var(--duration-fast) var(--ease), color var(--duration-fast) var(--ease),
                transform var(--duration-fast) var(--ease-bounce);
  }
  .icon-btn svg { width: 19px; height: 19px; }
  .icon-btn:hover { background: var(--bg-muted); color: var(--text); transform: scale(1.18); }
  .icon-btn:active { transform: scale(.94); }
  .icon-btn:focus-visible { outline: none; box-shadow: var(--shadow-focus); }
  .icon-btn.danger:hover { color: #ad2b31; background: #ffe0e1; }
  .icon-btn.copied { color: var(--active); animation: icon-pop var(--duration-slow) var(--ease); }
  .icon-btn-sm { width: 22px; height: 22px; }
  .icon-btn-ghost { color: var(--muted); }
  @keyframes icon-pop { 0% { transform: scale(1); } 45% { transform: scale(1.18); } 100% { transform: scale(1); } }

  /* the same focus ring used by .btn/.icon-btn, for the rest of the interactive chrome */
  .nav-item:focus-visible, .sidebar-toggle:focus-visible, .sidebar-logout:focus-visible {
    outline: none; box-shadow: var(--shadow-focus);
  }

  /* touch screens have no real hover - a tap would leave the "hover" state stuck
     on (enlarged icon, tinted background) until the next tap somewhere else */
  @media (hover: none) {
    .btn:hover { transform: none; }
    .icon-btn:hover { transform: none; background: transparent; color: var(--muted); }
    .icon-btn.danger:hover { color: var(--muted); background: transparent; }
    .fab:hover { transform: none; background: var(--gold-500); }
    /* .nav-item:hover was missing from this list - on the mobile bottom tab
       bar a tap was leaving its full-row background (icon + label together,
       rounded via .nav-item's own border-radius:pill - reads as "a circle
       around the whole button") stuck on until the next tap elsewhere, on
       top of (and separate from) the browser's own tap-highlight box and
       this app's own icon-scoped :active effect - three different things
       that all needed their own fix. Only background is reset here, not
       color - the hover rule's color:#fff would otherwise fight
       .nav-item.current's gold-ink icon color at equal specificity. */
    .nav-item:hover { background: none; }
    /* same stuck-hover class of bug as .nav-item above: tbody tr:hover's
       background (var(--bg), a light sage green) was never reset here -
       tapping a row to expand its details left that green tint stuck on
       until the next tap elsewhere, reading as "it animates green on click
       but never turns off" even though it was never meant to persist at
       all - :hover simply has no real "off" trigger on a touchscreen. */
    tbody tr:hover { background: none; }
    /* Proactive audit (asked for explicitly, not from a specific report):
       every one of these has the exact same stuck-hover shape as the three
       above, found by grepping every :hover rule in this file and checking
       it against this block. Two different reasons a rule can be missing
       from here even when it looks similar to an already-fixed one:
       (a) it's a variant selector *more specific* than the base rule this
       block already resets - .icon-btn.header-search-btn:hover (2 classes)
       beats .icon-btn:hover (1 class) at equal media-query status, so
       resetting only the base class silently does nothing for the variant;
       (b) it changes a *different property* than what's already reset. The
       worst instance used to be a shared "gentle-pulse" :hover animation on
       the FAB / search / bell (an infinite looping breathe a single stray
       tap could start with no way to stop it on a touchscreen) - that whole
       animation was removed from the app on 2026-08-30 (see its old spot
       near .fab-icon above), so there is nothing left to reset here. */
    .icon-btn.header-search-btn:hover { background: transparent; color: var(--gold-400); }
    .icon-btn.header-bell-btn:hover { background: transparent; color: var(--gold-400); }
    .icon-btn.header-plus-btn:hover { background: transparent; color: var(--gold-400); }
    .push-enable-btn:hover { background: var(--gold-soft); color: var(--gold-text); }
    .summary-row .summary-card:hover, .dash-card:hover { transform: none; box-shadow: var(--shadow-sm); }
    .table-menu-item:hover { background: none; }
    /* 2026-08-20 fix (visual-consistency audit, P2): matches the button's
       own resting color (now gold, see the base rule) - this is a mobile
       no-op-hover reset like its neighbors above, not a real hover shade. */
    .table-action-btn:hover { background: var(--gold-500); }
    .search-clear:hover { background: transparent; color: var(--muted); }
    .search-result:hover { background: none; border-color: var(--border); }
    .fab-menu button:hover { background: var(--card); }
    .activity-row:hover { transform: none; }
    a.activity-row:hover { background: none; }
    a:hover { text-decoration: none; }
  }

  /* ---------- misc text utilities ---------- */
  .text-muted { color: var(--muted); }
  .text-sm { font-size: var(--text-sm); }
  .text-xs { font-size: var(--text-xs); }
  .text-center { text-align: center; }

  /* ---------- QR + code block ---------- */
  /* the QR modal, restyled to actually look like part of this app - a petrol
     hero strip (the same identity gradient used on the sidebar/login, see
     .sidebar's own background) instead of a bare generic dialog, the code
     itself sitting in a real white card (not just a loose inline svg), and
     the action row centered/wrapping instead of a plain left-aligned pair. */
  .qr-modal-card { padding: 0; overflow: hidden; }
  .qr-modal-hero {
    background: linear-gradient(180deg, var(--ink-700), var(--ink-800) 55%, var(--ink-900));
    padding: var(--space-5);
  }
  .qr-modal-hero h3 { margin: 0; color: var(--side-text); font-size: var(--text-lg); font-weight: var(--weight-bold); }
  .qr-frame {
    display: flex; justify-content: center; margin: var(--space-5) var(--space-5) 0;
    padding: var(--space-5); background: #fff; border: 1px solid var(--border); border-radius: var(--radius-md);
  }
  .qr-frame svg { display: block; width: min(70vw, 260px); height: auto; }
  .qr-modal-actions { justify-content: center; flex-wrap: wrap; margin: var(--space-5); }
  .qr-modal-actions .btn span { white-space: nowrap; }
  .code-block {
    display: block; margin-top: var(--space-2); font-family: var(--font-mono); font-size: var(--text-sm);
    background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-xs);
    padding: var(--space-3) var(--space-4); word-break: break-all;
  }

  /* ---------- image lightbox (2026-08-18) ---------- */
  /* Near-opaque black, not the shared modal tint - a photo needs a true dark
     ground to actually read at full size, the same reason every photo app's
     own viewer goes black, not tinted-blur-over-the-page-behind like every
     OTHER modal in this file. padding:0 so the image can genuinely fill the
     screen edge-to-edge, not sit inside a card. */
  .lightbox-overlay { background: rgba(0,0,0,.94); backdrop-filter: none; -webkit-backdrop-filter: none; padding: 0; }
  .lightbox-viewport {
    width: 100%; height: 100%; overflow: hidden; display: flex; align-items: center; justify-content: center;
    touch-action: none; /* LIGHTBOX_SCRIPT owns pinch/pan/double-tap itself - see its own comment on why native pinch isn't available here either */
  }
  .lightbox-img, .lightbox-video {
    max-width: 100%; max-height: 100%; touch-action: none; user-select: none; -webkit-user-drag: none;
    /* 2026-08-19, Chaim: pinch/double-tap now pivot around wherever the
       gesture actually happens (lightboxSetOriginFromPoint, LIGHTBOX_SCRIPT)
       - this is just the sane default before any gesture has set it. */
    transform-origin: 50% 50%; will-change: transform;
  }
  /* 2026-08-19: video gets its own NATIVE controls (play, seek, fullscreen)
     - unlike the image it never runs the hand-rolled pinch/pan gesture code
     (LIGHTBOX_SCRIPT's pointerdown guards this out), so touch-action must
     stay auto here or a touch on the seek bar would get eaten by the
     viewport's own touch-action:none instead of reaching the scrubber. */
  .lightbox-video { touch-action: auto; }
  /* No entrance transition of its own (unlike every .form-modal) - a photo
     viewer opening should feel instant, not slide/fade like a form sheet;
     tap-outside (the dark ground itself) or the X closes it, no drag-to-
     dismiss either - a stray pinch/pan gesture must never accidentally
     start closing the very view it's trying to zoom. */
  .lightbox-close, .lightbox-download {
    position: absolute; top: var(--space-4); z-index: 1; width: 40px; height: 40px; border-radius: 50%;
    display: flex; align-items: center; justify-content: center; background: rgba(255,255,255,.12); color: #fff;
    border: none; cursor: pointer; -webkit-tap-highlight-color: transparent;
  }
  .lightbox-close { left: var(--space-4); }
  .lightbox-close svg { width: 18px; height: 18px; }
  .lightbox-download { right: var(--space-4); }
  .lightbox-download svg { width: 20px; height: 20px; }

  /* ---------- modal ---------- */
  .modal-overlay {
    position: fixed; inset: 0; background: rgba(17,24,39,.45);
    backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
    display: flex; align-items: center; justify-content: center; padding: var(--space-4);
    opacity: 0; pointer-events: none; z-index: 100;
    /* background-color/backdrop-filter added alongside opacity (2026-08-11)
       so MODAL_DRAG_SCRIPT's springBack() - clearing its own inline
       overrides on those two properties after a drag that didn't commit -
       can hand off to a real CSS transition back to full strength, the
       same "clear inline, let the stylesheet's own rule animate it" pattern
       already used for .modal's transform. Deliberately NOT touching this
       element's own opacity during a drag at all (see MODAL_DRAG_SCRIPT's
       own comment) - .modal, a CHILD of this element, would fade out right
       along with it if opacity ever dropped mid-drag, which is exactly the
       "the sheet itself fades and the site shows through it" bug Chaim
       caught; background-color's alpha channel and backdrop-filter's blur
       radius don't have that problem - they only affect how THIS element's
       own backdrop renders, never a child's separate opaque background. */
    transition: opacity var(--duration-base) var(--ease), background-color var(--duration-base) var(--ease), backdrop-filter var(--duration-base) var(--ease);
    /* same fix, same reasoning as .table-menu-backdrop above: pointer-events
       blocks taps reaching the page behind, not a touch-drag/scroll gesture
       starting on this overlay itself - Chaim wants the blurred page to
       feel completely dead while a modal's open. Only this outer overlay,
       not .modal (the card sitting inside it) - a long form still scrolls
       normally within itself. */
    touch-action: none;
  }
  .modal-overlay.is-open { opacity: 1; pointer-events: auto; }
  /* Slower, dedicated durations while actually closing (see --duration-
     modal-close/-slow above and closeFormModal) - higher specificity than
     the plain .modal-overlay/.modal rules above via the extra .is-closing
     class, so it only overrides the CLOSING transition, opening (is-open
     being added) is untouched and still uses the normal, snappier tokens. */
  .modal-overlay.is-closing {
    transition: opacity var(--duration-modal-close) var(--ease), background-color var(--duration-modal-close) var(--ease), backdrop-filter var(--duration-modal-close) var(--ease);
  }
  .modal-overlay.is-closing .modal { transition: transform var(--duration-modal-close-slow) var(--ease-bounce); }
  .modal {
    background: var(--card); border-radius: var(--radius-md); box-shadow: var(--shadow-md);
    padding: var(--space-6); max-width: 380px; width: 100%;
    transform: translateY(8px) scale(.98); transition: transform var(--duration-slow) var(--ease-bounce);
  }
  .modal-overlay.is-open .modal { transform: translateY(0) scale(1); }
  .modal p { margin: 0; font-size: var(--text-base); line-height: var(--leading-normal); }
  /* flex-end, not flex-start: in this RTL layout flex-end is the LEFT edge -
     Chaim wants confirm/cancel pairs anchored there, not clustered on the
     right (his examples: the delete-confirmation dialog, every create/edit
     form's submit+cancel row). Pill-shaped specifically HERE (not a global
     .btn change, which would also reshape unrelated buttons like the login
     submit) to match the same fully-rounded-sides, flat-top/bottom look as
     the gold nav-pill in the bottom bar - "the site's own design, not just
     generic buttons." */
  .modal-actions { display: flex; gap: var(--space-2); justify-content: flex-end; margin-top: var(--space-6); }
  .modal-actions .btn { border-radius: var(--radius-pill); }
  /* the floating create-link window: same width and feel as the big panels */
  .modal-form { max-width: 760px; max-height: calc(100vh - 2 * var(--space-6)); overflow-y: auto; padding: var(--space-7); }
  .modal-form h3 { font-size: var(--text-2xl); font-weight: var(--weight-bold); margin: 0 0 var(--space-3); }
  /* search opens near the top so results grow downward, command-palette style */
  .search-modal { align-items: flex-start; }
  .search-modal .modal { margin-top: 10vh; }

  @media (max-width: 780px) {
    .app-shell { flex-direction: column; }

    /* Chaim (2026-08-14): approved via a live interactive demo - real
       horizontal slide when switching between the 4 bottom tabs (mobile
       only, matching how the demo itself was framed and how every other
       swipe-nav-adjacent change in this file is scoped - see SWIPE_NAV_SCRIPT
       for the JS side). Only ever applied for the brief transition window
       (swapTo adds/removes .is-transitioning itself) - .swipe-content has no
       rules at all outside of it, so nothing here touches normal layout.
       CSS Grid stacking (both layers sharing grid-area 1/1) rather than
       position:absolute - lets the row auto-size to the TALLER of the two
       pages (they're rarely the same height) with no manual measuring. */
    .swipe-content.is-transitioning { display: grid; overflow: hidden; }
    /* 2026-08-14, same day: Chaim also wanted entering/leaving /settings and
       its own sub-pages (/settings/theme, /settings/colors) - and, by the
       same logic, /trash and /tokens, which share the exact same underlying
       swapSecondary() - to get the same real push/pop slide, not just the 4
       main tabs. swapSecondary swaps the WHOLE .container (it has no
       .swipe-content split of its own - each secondary page renders its own
       header, unlike the 4 tabs' shared one - see swapSecondary's own
       comment), so .container is the thing that needs .is-transitioning
       here, not .swipe-content. Shares the exact same .swipe-layer grid-area
       rule as above - genuinely the same technique either way. */
    /* Chaim: "בדף ההגדרות כל דף שנכנסים הבלוק העליון לא נמצא צמוד למעלה
       ורק אחרי שנייה הוא נמצא" - a real, locally-reproduced bug (measured
       .page-header's own getBoundingClientRect() through the transition,
       not guessed): .page-header's OWN rule (further down in this file)
       reads flush-to-the-edge via a negative margin sized to exactly
       cancel .container's own padding (margin: calc(-1 * var(--space-5))
       calc(-1 * var(--space-4)) ... - see its own comment for the full
       "full-bleed" reasoning) - that cancellation depends on .page-header
       being close enough to .container for the negative margin to collapse
       against its padding correctly. Once .page-header sits one level
       deeper inside a .swipe-layer that's a CSS GRID ITEM (not a plain
       block-flow child), that collapsing no longer happens the same way -
       a real, well-documented CSS Grid behavior difference, not a fluke -
       so the header rendered ~20px (.container's own top padding) too low
       for the whole transition, snapping up the instant it ended and the
       extra .swipe-layer wrapper was removed. Fixed by moving .container's
       own padding onto EACH layer individually instead (and zeroing it on
       .container itself while transitioning) - the exact same total
       spacing, just applied one level closer to where .page-header's own
       math actually expects to find it, so nothing has to rely on
       cross-grid-boundary margin collapsing at all. */
    .container.is-transitioning { display: grid; overflow: hidden; padding: 0; }
    .swipe-content.is-transitioning > .swipe-layer,
    .container.is-transitioning > .swipe-layer { grid-area: 1 / 1; min-width: 0; }
    .container.is-transitioning > .swipe-layer {
      /* must match .container's own real padding (below) exactly, or content
         jumps the instant a swipe transition ends */
      padding: var(--space-5) var(--space-4) calc(var(--app-bar-chrome-h) + var(--space-4) + env(safe-area-inset-bottom, 0px));
    }

    /* Chaim (2026-08-11): a page reached via its own breadcrumb-back
       (/trash, /tokens) isn't one of the 4 main tabs the bottom bar
       switches between, and its own FAB target ("+") doesn't apply there
       either - layout()'s hideBottomBar/hideFab flags add these two
       classes to opt a specific page out of either, independently. */
    .app-shell.hide-bottom-bar .sidebar { display: none; }

    /* bottom tab bar - same .sidebar-nav/.nav-item markup, just repositioned.
       safe-area inset keeps it clear of the iPhone home indicator. Must undo
       the desktop sticky-sidebar rule's height:100vh/overflow-y/align-self -
       without this reset the bar stayed 100vh tall (a fixed box anchored to
       the bottom edge, extending the full screen height upward), stretching
       every nav item across the whole screen instead of a slim strip. */
    .sidebar {
      position: fixed; left: 0; right: 0; bottom: 0; top: auto;
      /* z-index 62: originally raised above the table title bar's old
         sticky z-index of 60 (a sticky title bar could otherwise render
         over/hide this permanent navigation near the bottom of a short
         table) - that title bar isn't sticky anymore, but 62 is still
         correct to keep: safely above ordinary content, still below the
         backdrop's 65 so this bar blurs along with everything else while a
         popup is open, same as the top header. */
      z-index: 62;
      width: 100%; height: auto; align-self: stretch; overflow-y: visible;
      flex-direction: row; align-items: stretch;
      border-left: none; border-top: 1px solid var(--border); border-bottom: none;
      padding: var(--space-1) var(--space-2) calc(var(--space-1) + env(safe-area-inset-bottom, 0px));
      /* 2026-08-30: the bottom bar's visible chrome is the same height as the
         top bar's (--app-bar-chrome-h); each then extends into its own OS
         safe-area inset (this one the bottom / gesture inset). */
      min-height: calc(var(--app-bar-chrome-h) + env(safe-area-inset-bottom, 0px));
      box-sizing: border-box;
      box-shadow: 0 -2px 10px rgba(15,23,42,.08);
    }
    .brand { display: none; }
    .nav-group-label { display: none; }
    /* flex-basis is set later (.sidebar-footer comment below) once the 3-vs-4
       tab question is settled for this layout - only direction/gap belong here. */
    .sidebar-nav { flex-direction: row; gap: 0; }
    .nav-item {
      flex: 1; min-width: 0; flex-direction: column; justify-content: center; align-items: center;
      gap: 2px; padding: var(--space-2) var(--space-1); font-size: 13px; text-align: center;
      /* a dedicated, faster duration here (not the shared --duration-base,
         which plenty of unrelated rules also use) - Chaim: switching tabs
         felt like the "pressed" state lingered too long before settling. */
      transition: transform 140ms var(--ease);
    }
    /* a label that wraps to 2 lines makes the whole bar taller than the FAB's
       fixed clearance above it expects - keep every tab exactly one line,
       shrinking to an ellipsis rather than wrapping if it doesn't fit. */
    .nav-item span {
      white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%;
    }
    /* the gold highlight should hug just the icon here (a WhatsApp-style
       pill), not stretch down over the label text below it - giving this
       element its own position:relative (it has none on desktop) makes it
       the pill's containing block instead of .nav-item, without touching
       the pill's markup at all. Padding is what turns the tight icon box
       into a wide pill shape rather than a circle. */
    /* horizontal padding widened per explicit request - roughly as wide as
       the longer tab labels ("קיצורי לינקים") rather than just hugging the
       icon tightly; height unchanged (that part was already right). This is
       an estimate without a live device to measure exact rendered text
       width against - flagged as such, easy to tune further either way. */
    .nav-icon-wrap {
      position: relative; padding: 4px 24px; border-radius: var(--radius-pill);
      transition: background-color var(--duration-fast) var(--ease), transform var(--duration-fast) var(--ease);
    }
    /* the pill reveal itself, not the icon-wrap: see the .nav-pill keyframes
       above for why view-transition-name is turned off here specifically. */
    .nav-pill {
      view-transition-name: none;
      transform-origin: center center;
      /* faster per Chaim's ask - was 280ms, read as slow/laggy when
         tapping between tabs. */
      animation: nav-pill-grow 160ms cubic-bezier(.2, 0, 0, 1);
    }
    /* the tap-highlight removed on .nav-item above still needs *some* press
       feedback to replace it - the icon's own background tint (:active,
       below) covers that. The whole-tab SIZE change is deliberately not tied
       to :active anymore (Chaim: a tap used to shrink the tab and immediately
       grow it back the instant the finger lifted - "kind of shrinks the icon
       and text and grows it back" - he wants it to shrink and STAY shrunk for
       as long as that tab is the current one, only growing back once a
       DIFFERENT tab becomes current). .nav-item.current below owns the scale
       now, animated by the transition on .nav-item itself - transform on the
       parent doesn't affect sibling layout, so shrinking the current tab (or
       growing back whichever tab stops being current) never shifts its
       neighbours. */
    .nav-item:active .nav-icon-wrap { background: rgba(0,0,0,.12); }
    /* was .92 - Chaim: reads as "too pressed/sunken in", too big a jump from
       its neighbors. Still a real, visible size difference from the other
       (unscaled) tabs, just a gentler one. */
    .nav-item.current { transform: scale(.96); }
    /* .nav-item.current sets color:var(--gold-ink) for contrast against the
       gold pill - correct for the icon (still sitting on that pill), wrong
       for the label below it (sitting on the plain dark bar, same as every
       other tab) now that the pill no longer stretches down to cover it. */
    .nav-item.current > span:not(.nav-icon-wrap) { color: var(--side-text); }
    /* API Tokens and logout no longer live on the bottom bar at all - the
       "more" button moved up to the top-left corner of the header (see
       .mobile-more-wrap below), so the bottom bar is just the 3 real tabs
       now. display:contents drops .sidebar-footer's own box from layout
       entirely (so it no longer reserves a 4th slot in .sidebar-nav's row)
       while letting its fixed-position child still render normally. */
    .sidebar-footer { display: contents; }
    .sidebar-user { display: none; }
    .nav-item-desktop-only { display: none; }
    .sidebar-logout-form { display: none; }
    /* the 3 remaining tabs get the bottom bar's full width to themselves now */
    .sidebar-nav { flex: 1 1 0; }
    /* a small standalone corner button now, not a 4th bottom tab - floats at
       the top-left, independent of scrolling (position:fixed is relative to
       the viewport even nested inside .sidebar, which is itself fixed, since
       .sidebar sets no transform/filter that would change that). Left/top
       match the container's own edge padding so it lines up with the
       header's gold buttons, which sit at that same margin. */
    .mobile-more-wrap {
      /* JS (alignMoreDots) copies the bell button's live top+height onto this
         box; align-items:center then lands the dots on the bell's exact centre.
         The top:0/height:header-h here is only the pre-JS fallback. width:
         30px explicit (2026-08-13, matches .nav-item's own width below) -
         needed now that BOTH inner buttons are position:absolute (see
         .more-trigger-btn/.select-all-header-btn further down), so nothing
         is left in normal flow to size this box the way the single old
         trigger button used to.

         opacity:0 + a transition on top/opacity (2026-08-13, Chaim: "כשאני
         מרענן את הדף... הוא כאילו לשניה נמצא למעלה ואז חוזר למקום, קופץ") -
         the real cause traced to the Hebrew webfont itself: it arrives
         slightly after first paint (see alignMoreDots's own comment,
         document.fonts.ready), and swapping it in changes the header's real
         rendered height, which measurably moves the bell/search buttons -
         genuinely reproducible only over a real network with real font
         latency, never in a fast local test. Rather than chase the font-
         loading timing itself, the wrap simply stays invisible until
         alignMoreDots() has positioned it for real at least once (see its
         own .is-positioned toggle) - nothing is ever visibly PAINTED at the
         wrong spot to jump FROM in the first place.
         2026-08-30: the top transition is GONE. alignMoreDots legitimately
         re-runs several times in a row after a nav (panel-activated, a fresh
         ResizeObserver's initial callback, the 300ms safety timeout, the
         webfont swap) - with a transition on the top property, each of those
         became a visible glide, reading as the dots "קופצים ומהבהבים" every time
         Chaim opened the menu or came back from Settings. Snapping straight
         to the right spot each time is invisible (the value barely changes
         between calls); a transition only made the noise visible. opacity
         still transitions, for the one-time .is-positioned reveal. */
      display: flex; align-items: center; position: fixed;
      top: 0; height: var(--mobile-sticky-header-h); width: 30px; left: var(--space-1); z-index: 60;
      opacity: 0; transition: opacity var(--duration-fast) var(--ease);
    }
    .mobile-more-wrap.is-positioned { opacity: 1; }
    /* just three gold dots now - no white circle behind them, matching the
       gold of the search/bell buttons it sits beside. Since there's no button
       box to keep clear of the edge, the dots hug the screen edge (left
       var(--space-2)) and the tap target is a slim 40px column, its icon
       pulled to the edge side (justify-content:flex-start) so the visible
       dots sit close to the edge and the header's search/bell can come in
       close on the other side rather than leaving a big empty gap. */
    .mobile-more-wrap > .nav-item {
      width: 30px; height: 44px; padding: 0;
      /* flex-direction:row is CRUCIAL: the base .nav-item is flex-direction:
         column (sidebar tabs stack icon over label), which this inherited - so
         justify-content:flex-start was pinning the dots to the TOP of the
         button (10px high) even though the button box itself was perfectly
         centered on the bell. Row makes flex-start = left edge (dots hug the
         screen edge as intended) and align-items:center = vertically centred. */
      display: flex; flex-direction: row; align-items: center; justify-content: flex-start;
      background: transparent; border: none; box-shadow: none;
      color: var(--gold-500); cursor: pointer; font-family: inherit;
    }
    .mobile-more-wrap > .nav-item svg { width: 24px; height: 24px; }
    /* the ⋮ trigger and the select-all replacement (2026-08-13) stack
       exactly on top of each other, inset:0 within the wrap's own now-
       explicit box, and crossfade via .is-selecting - same opacity-only
       transition convention as .header-normal-actions/.selection-actions
       (STYLE, further up) for the header's own search/bell↔selection-icons
       swap, applied here to this second, independently-positioned trigger. */
    .more-trigger-btn, .select-all-header-btn { position: absolute; inset: 0; transition: opacity 280ms ease; }
    .select-all-header-btn { opacity: 0; pointer-events: none; }
    /* .is-selecting toggled on .mobile-more-wrap ITSELF (not .main - see
       updateSelectionBar's own comment on why) so this is a plain descendant
       selector, not reaching across the sidebar/main sibling boundary. */
    .mobile-more-wrap.is-selecting .more-trigger-btn { opacity: 0; pointer-events: none; }
    .mobile-more-wrap.is-selecting .select-all-header-btn { opacity: 1; pointer-events: auto; }
    /* Chaim (2026-08-13), immediate correction: the first version's gold-
       pill "is-on" fill (matching .nav-item.current's own treatment) was a
       NEW visual effect this button alone had - "חייב להיות... באותה אפקט
       שיראה ממש אחיד עם כל הסמלים" (must look genuinely uniform with all the
       [other] icons) - the delete/reset-clicks/edit icons beside it in the
       selection bar never get any background/fill treatment at all, they're
       always just plain bare glyphs. Dropped entirely - this button now
       stays exactly as bare as its siblings in every state; the old
       dropdown-row version this replaced never visually changed its icon
       either (only its text label did), so a static icon here is actually
       matching PRECEDENT too, not a downgrade. title/aria-label (JS,
       BULK_SELECT_SCRIPT) still carry the real select-vs-deselect state for
       anyone using a screen reader.
       2026-08-17: that title/aria-label-only signal turned out to not be
       enough for a sighted user either (Chaim: nothing VISIBLE hinted this
       same checkmark was about to deselect everything) - the icon itself
       now crossfades to an X once everything's checked, via the same plain
       opacity+scale+rotate technique fabWrapHtml's own .fab-icon-plus/
       .fab-icon-check pair already uses for its reorder-mode swap. Still
       exactly the bare-glyph constraint above - no fill/pill/background
       reappears, only which line-art glyph is showing changes. */
    .select-all-header-btn .nav-icon-wrap { position: relative; width: 24px; height: 24px; }
    .select-all-header-btn .sa-icon {
      position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
      transition: opacity var(--duration-base) var(--ease), transform var(--duration-base) var(--ease);
    }
    .select-all-header-btn .sa-icon-check { opacity: 1; transform: scale(1) rotate(0deg); }
    .select-all-header-btn .sa-icon-x { opacity: 0; transform: scale(.4) rotate(-45deg); }
    .select-all-header-btn.all-selected .sa-icon-check { opacity: 0; transform: scale(.4) rotate(45deg); }
    .select-all-header-btn.all-selected .sa-icon-x { opacity: 1; transform: scale(1) rotate(0deg); }
    /* flex-center the dots inside their wrapper and make the svg a block box:
       as an INLINE svg (default) it sat on the text baseline, leaving a
       descender gap below it that shoved the visible dots UP within the button
       - the real reason they read as "too high" no matter how perfectly the
       button box itself was centered on the bell (the bell/search svgs are
       direct flex children of their buttons, so they never had this gap). */
    .mobile-more-wrap .nav-icon-wrap { display: flex; align-items: center; justify-content: center; padding: 0; }
    .mobile-more-wrap svg { display: block; }
    /* opens downward now (the button lives at the TOP of the screen, not the
       bottom anymore) - anchored to the wrap's own left edge. Chaim
       (2026-08-11): "יותר רחב ממה שצריך" - a fixed 170px was wider than any
       real label here needs; max-content sizes it to whatever's actually
       inside (bounded so a short label like "תצוגה" doesn't look absurdly
       narrow, and nothing can blow past a sane max either). Same open/close
       idea as the .modal-overlay pattern elsewhere in this file (a resting
       CSS state plus one class toggle, no [hidden] attribute in the mix at
       all) rather than the instant show/hide this used to have - "שייצא
       כתריס גלילה מהיר למעלה וייסגר גם באותו הדרך רק הפוך": scaleY from a
       fixed top edge (transform-origin) reads exactly as a blind unrolling
       down and rolling back up, the SAME transition running both ways. */
    /* 2026-08-26, Chaim: "תוריד את האייקונים לגמרי... תצמצם את הרווח...
       בדיוק ברוחב שצריך לכיתוב עצמו... קצת יותר רחב מהכיתוב, כמו
       באינטרנט כולו" - the default menu's 3 items (+ the filter view's own
       back button) dropped their leading icons entirely (see the markup
       itself - plain <span>text</span> now, no svg), and min-width came
       down from 150px (a 2026-08-11 fix for a DIFFERENT "too wide"
       complaint, back when icons were still part of the width max-content
       had to accommodate) to 100px - low enough it no longer constrains
       any of these real labels (max-content + .table-menu-item's own
       existing padding is what actually sizes this now, giving exactly
       "a little wider than the text" without any extra rule needed), kept
       only as a sane floor rather than removed outright. */
    .mobile-more-dropdown {
      position: absolute; top: calc(100% + var(--space-2)); left: 0; z-index: 90;
      width: max-content; min-width: 100px; max-width: 220px;
      background: var(--card); border: 1px solid var(--border);
      border-radius: var(--radius-md); box-shadow: var(--shadow-md); overflow: hidden;
      display: flex; flex-direction: column;
      transform-origin: top; transform: scaleY(0); opacity: 0; pointer-events: none;
      transition: transform var(--duration-base) var(--ease), opacity var(--duration-base) var(--ease);
    }
    .mobile-more-dropdown.is-open { transform: scaleY(1); opacity: 1; pointer-events: auto; }
    @media (prefers-reduced-motion: reduce) { .mobile-more-dropdown { transition-duration: .01ms; } }
    /* 2026-08-25, Chaim: "בצד ימין יש טיפה רווח, בעוד שבצד שמאל יש הרבה
       רווח" - measured directly (real /admin page, real dropdown): the
       icon+text pair was flex-packed toward the RTL start (right, 16px -
       just the rule's own padding) with all the row's unused width
       (~29px) left over on the far side (left) instead of split evenly,
       since justify-content defaulted to flex-start. Centering the
       content splits that leftover space equally on both sides of the
       icon+text group, so the left gap now matches the right gap exactly -
       same fix for every row here, icon+text or plain text alike. Still
       needed for the theme/palette rows below (swatch+label+checkmark
       genuinely need flex to lay out horizontally) - see the next comment
       for why the now icon-less default/filter rows override this back
       out instead of reusing it. */
    .mobile-more-dropdown .table-menu-item { display: flex; align-items: center; justify-content: center; gap: var(--space-2); }
    .mobile-more-dropdown .table-menu-item svg { flex: none; width: 17px; height: 17px; }
    /* 2026-08-26 follow-up, Chaim (real screenshot of WhatsApp's own ⋮ menu,
       pixel-measured against it directly): now that the default/filter
       rows have no icon left to center (see the markup - plain
       <span>text</span> now), go back to real right-aligned text - and
       match WhatsApp's own asymmetry while at it: text there sits a small,
       tight distance from the menu's right edge (measured: ~57px in their
       screenshot, a fixed gap regardless of row length) while even their
       OWN longest row ("רשימות תפוצה עסקיות") still leaves real breathing
       room on the left (~97px, roughly 1.7x the right gap) - the menu's
       width isn't "shrink-wrapped exactly to content" the way removing the
       icons alone would produce, it carries a bit of genuine reserved
       space past the text on the trailing (left, RTL) side. --space-4
       (16px, the row's own existing right-side padding) x1.5 = --space-6
       (24px) on the left is the closest match available on this file's own
       documented spacing scale (DESIGN.md: never introduce a value outside
       it) to that same ~1.7x ratio. More specific than the shared rule
       above (3 classes vs 2) so it doesn't need to fight it on source
       order - .more-theme/.more-palette (unmentioned here) keep the flex
       layout untouched. */
    .mobile-more-dropdown .more-default .table-menu-item,
    .mobile-more-dropdown .more-filter .table-menu-item {
      display: block; text-align: right; padding-inline-start: var(--space-4); padding-inline-end: var(--space-6);
    }
    /* 2026-08-13: updateMobileMenuListActions (BULK_SELECT_SCRIPT) toggles
       this instead of the [hidden] attribute - the rule right above already
       sets display:flex at equal specificity, which would otherwise beat a
       plain [hidden] silently (the same known bug family this file has
       already hit more than once). */
    .mm-list-action.is-mm-list-hidden { display: none !important; }
    /* the menu shows ONE view at a time: the default action list, or the
       filter/sort view (swapped in by tapping "הצג לפי") - theme/palette had
       their own sub-views here too until 2026-08-13, moved out to real pages
       (/settings/theme, /settings/colors) alongside "הגדרות" replacing all 4
       of the old settings-ish rows with one link to /settings. */
    /* Chaim (2026-08-17): "תגדיל טיפה את הרווח בין שורה לשורה בתפריט... מידי
       צמוד" - the rows themselves already had breathing room via their own
       padding (.table-menu-item), just none BETWEEN adjacent ones - a small
       gap on this flex column (their real shared parent) is the direct,
       minimal fix, without growing each row's own height/click target.
       Same-day follow-up: "תרחיב עוד" - space-1 (4px) read as still too
       tight, bumped to space-2 (8px). */
    .more-section { flex-direction: column; gap: var(--space-2); }
    .more-default { display: flex; }
    .more-filter { display: none; }
    .mobile-more-dropdown.showing-filter .more-default { display: none; }
    .mobile-more-dropdown.showing-filter .more-filter { display: flex; }
    .more-back { color: var(--muted); }
    /* .mm-swatch/.mm-palette-item/.mm-check moved to the base rules (STYLE,
       up top, next to .table-menu-item.is-on) 2026-08-20 - they're shared
       with the real /settings/colors page and need to look right on desktop
       too, not just inside this mobile-only dropdown. See that base comment
       for the full "why this was wrong here" explanation. */
    /* the per-table hamburger + filter buttons are desktop-only now - on
       mobile every one of their actions moved into the three-dots menu above
       (per Chaim's request). Their dropdowns/forms/state elements stay in the
       DOM (just hidden) so all the existing functions keep working unchanged;
       the three-dots items simply drive them for the active table. */
    .table-menu-wrap { display: none; }
    /* extra room under the list while reordering so the FAB (now doubling as
       the save button - see .fab-icon/.main.is-reordering in STYLE) never
       sits on top of the last row, which you still need to be able to see
       and drag. */
    .mobile-rows.reorder-mode { margin-bottom: calc(56px + var(--space-4)); }
    /* enlarged per explicit request - matches the "more" dots button's own
       24px (.mobile-more-wrap > .nav-item svg below) for visual consistency
       across every icon in the bar. This is the rule that actually wins
       (same specificity as .sidebar-nav > .nav-item svg, but later in
       source) - kept as the one true definition rather than two rules
       fighting over it. */
    .sidebar-nav .nav-icon-wrap svg { width: 24px; height: 24px; }
    /* tighter side padding = more usable width on a phone; bottom clears the tab bar */
    .container { padding: var(--space-5) var(--space-4) calc(var(--app-bar-chrome-h) + var(--space-4) + env(safe-area-inset-bottom, 0px)); }
    .panel { padding: var(--space-6) var(--space-5); }

    /* title + create button always share one line, same spot on every page.
       the room for the fixed three-dots button is added to the sticky
       .page-header rule's own padding further down - NOT here - because
       that rule uses the padding shorthand, which comes later in source
       order and would otherwise wipe out a padding-left set here (exactly
       the bug that left the dots sitting on top of the bell). */
    .page-header { flex-wrap: nowrap; }
    .page-header h1, .page-header .selection-count { font-size: calc(var(--text-xl) * 1.5); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .page-header .btn { flex: none; }
    /* the 3 top header buttons (plus/search/bell) scaled down closer to the
       bottom tab bar's own compact proportions, per explicit request - was
       56px/26px icon (desktop keeps that size; there's no bottom bar there
       to compare against). This is a judgment call on exactly how much
       smaller reads as "matching," not a measured target - easy to tune
       further either way. */
    .fab { width: 48px; height: 48px; }
    /* narrower than the FAB now they're bare glyphs (no circle to fill) - less
       dead padding around each icon pulls the bell/search visually closer to
       each other and to the dots, per Chaim's "tighten the gaps" request. */
    .icon-btn.header-search-btn, .icon-btn.header-bell-btn, .icon-btn.header-plus-btn { width: 40px; height: 44px; }
    /* 2026-08-25, Chaim (API Tokens page screenshot): "שהפלוס יהיה בדיוק
       באמצע מעל הסטטוס הירוק" - measured directly (Playwright bounding
       boxes, real /tokens page): the plus button's own center sat 10px to
       the inline-start (right) of the toggle-switch column's center below
       it (button x:42-82 vs switch x:32-72, both width 40, same 390px
       viewport) - a real, measured 10px gap, not a guess. Only this one
       header button needs the nudge (search/bell never sit above a
       switch column on any page that uses them), so it's scoped here
       rather than touching the shared 3-button rule above. */
    .icon-btn.header-plus-btn { margin-inline-end: -10px; }
    .fab svg { width: 22px; height: 22px; }
    /* bare glyphs need to read a touch larger than they did inside a filled
       circle to keep the same visual weight beside the title. */
    .icon-btn.header-search-btn svg, .icon-btn.header-bell-btn svg, .icon-btn.header-plus-btn svg { width: 27px; height: 27px; }
    /* the FAB floats free of the header now, hovering just above the bottom
       tab bar instead - left/top margins match the header's own edge
       padding (same var(--space-4)) so it lines up with the bell button
       directly above it; the bottom offset reuses the exact clearance
       .container already reserves for the tab bar's real height, plus a
       small gap, so it always sits just clear of it regardless of device
       safe-area. */
    .fab-wrap {
      position: fixed; left: var(--space-4);
      /* the gap above the bottom bar matches the side gap to the screen edge
         (both var(--space-4)) so the FAB sits symmetrically in its corner -
         bottom = the bar's real total height (--app-bar-chrome-h + the
         bottom safe-area inset, same as .sidebar's own min-height) + that
         same space-4. */
      bottom: calc(var(--app-bar-chrome-h) + env(safe-area-inset-bottom, 0px) + var(--space-4));
      z-index: 60;
      /* visible by default; only .main.is-selecting below ever hides it -
         same opacity/transform/duration as .tracking-tabs (STYLE, further
         down) so the two move as ONE synchronized pair whenever selection
         toggles on /recipients (Chaim: "all 3 buttons need to be
         synchronized with each other"), not two different animations (or
         one animated, one an instant display:none, which is what this was
         until now) that merely happen to fire at the same moment. */
      opacity: 1; transform: none;
      transition: opacity var(--duration-base) var(--ease), transform var(--duration-base) var(--ease);
    }
    /* Selecting a row hides the FAB - same reasoning as it always has
       ("if I want to edit/select something, the + should get out of the
       way"), just animated now instead of an instant display:none, so it
       reads as one continuous motion instead of a jump-cut. Applies on
       EVERY page (admin's FAB is the only one of the 3 buttons it has, and
       gets the exact same treatment there too). */
    .main.is-selecting .fab-wrap { opacity: 0; transform: translateY(24px); pointer-events: none; }

    /* Chaim: the three create buttons should pop OUT of the + itself like
       bubbles, fanned across a quarter-circle - not stack as a list above
       it. Direction is deliberate: up and to the right only, since the FAB
       already sits in the screen's own bottom-left corner (.fab-wrap above)
       - left runs off the screen edge and down runs under the tab bar, so
       neither direction ever had room to begin with. Radius/angles below:
       three stops 45° apart, sweeping from straight up (item 1) to
       straight out to the side (item 3) - see fabMenuHtml's own --dx/--dy
       per item, spent here, not recalculated.
       .fab-menu itself stops being the thing that animates (no more
       translateY/scale/visibility toggle from the base rule above) - it's
       just repositioned to exactly overlay the FAB's own box, and each
       .fab-menu-item inside is what actually moves, independently, so its
       nth-child stagger below reads as three separate bubbles rather than
       one panel. */
    .fab-wrap .fab-menu {
      top: auto; left: auto; right: auto; bottom: auto; inset: 0;
      display: block;
      opacity: 1; visibility: visible; transform: none;
      transition: none;
      pointer-events: none;
    }
    .fab-wrap .fab-menu-item {
      display: flex; align-items: center; justify-content: center;
      position: absolute; inset: 0;
      transform: translate(0, 0) scale(0); opacity: 0; pointer-events: none;
      /* 320ms read as "already out there" a beat after tapping, not as
         actually traveling from the + - Chaim wanted the trip itself
         visible, both out and back, so this is slower now (480ms). Still
         the same "back" overshoot (1.56 past 1) for the bubble-pop
         settle - just given enough time to actually be seen happening. */
      transition: transform 480ms cubic-bezier(.34, 1.56, .64, 1), opacity 260ms var(--ease);
    }
    .fab-wrap .fab-menu.is-open .fab-menu-item {
      transform: translate(var(--dx), var(--dy)) scale(1); opacity: 1; pointer-events: auto;
    }
    /* each bubble pops a beat after the last (and, symmetrically, closes a
       beat after the last on the way back in) - reads as one organic
       unfurl/gather rather than three items snapping in perfect unison. */
    .fab-wrap .fab-menu-item:nth-child(1) { transition-delay: 0ms; }
    .fab-wrap .fab-menu-item:nth-child(2) { transition-delay: 50ms; }
    .fab-wrap .fab-menu-item:nth-child(3) { transition-delay: 100ms; }

    /* white, not gold - Chaim: all four (the FAB + its 3 satellites) being
       the same yellow didn't read well visually. White + a neutral icon
       color keeps the FAB itself as the one gold anchor the bubbles
       visibly came from.
       2026-08-20 fix (visual-consistency audit, P2): the icon color here
       used to reuse --accent (violet) specifically because the desktop
       dropdown's own icons used it too at the time this comment was
       written - now that those icons are gold (.fab-menu button svg,
       STYLE above), copying them here would undo Chaim's own explicit
       "not gold" call for this exact spot. --text (the app's plain
       neutral icon/body color) keeps his original intent - a real
       non-gold color, not a leftover off-brand one. */
    .fab-wrap .fab-menu button {
      width: 48px; height: 48px; padding: 0; border-radius: 50%;
      background: var(--card); color: var(--text); border: 1px solid var(--border);
      box-shadow: var(--shadow-md);
      display: flex; align-items: center; justify-content: center;
    }
    .fab-wrap .fab-menu button:hover { background: var(--bg-muted); }
    .fab-wrap .fab-menu button svg { width: 22px; height: 22px; color: inherit; }
    /* icon-only on mobile, same reasoning .header-search-btn's own
       title/aria-label already rely on - title/aria-label still carry the
       real name for anyone using a screen reader or a long-press tooltip. */
    .fab-wrap .fab-menu button span { display: none; }
    /* the radial menu items used to keep "breathing" (gentle-pulse) the
       whole time the menu was open - removed 2026-08-30 along with every
       other button pulse in the app, at Chaim's explicit request ("תוריד
       לגמרי את הפמפום הזה... בכלל"). The pop-to-position entrance transform
       on .fab-menu-item is enough on its own to say "these just appeared". */
    @media (prefers-reduced-motion: reduce) {
      .fab-wrap .fab-menu-item { transition-duration: .01ms !important; }
    }
    /* while its create-menu is open, the FAB is pulled into #popover-root and
       re-anchored via inline top/right (escapeToPopover) so it can sit ABOVE
       the blur backdrop (z-index 65) - the whole reason it escapes. But the
       .fab-wrap rule above still forces left/bottom (which fight the top/right
       re-anchor) and z-index:60 (which, being later in source order, was
       overriding .is-escaped's own z-index:80 and leaving the FAB + its two
       buttons stuck below the blur - unreadable and untappable, exactly the
       bug). This 2-class rule wins and neutralizes all three. */
    .fab-wrap.is-escaped { left: auto; bottom: auto; z-index: 80; }

    /* two floating pill tabs standing in for "two screens" on /recipients -
       they sit just above the bottom tab bar, and the FAB rises to sit above
       THEM. Lives outside .container (see trackingTabsHtml) so it's always in
       the DOM on every page - hidden via opacity/transform (not display:none),
       and now FULLY symmetric: arriving at /recipients, leaving it, and the
       selection-mode hide/show (.is-selecting, mid-visit) all cross-fade+
       slide the exact same way, exactly like the header selection bar above
       and .fab-wrap (STYLE, above) - the 3 buttons this pair + the FAB make
       up move as one synchronized set. An earlier version made LEAVING
       /recipients instant, specifically to dodge a bug where this pill would
       visibly linger over the newly-arrived page while SWIPE_NAV_SCRIPT's
       old sliding transition was still catching up - now that every
       transition there is one instant DOM swap (Chaim's ask, see
       SWIPE_NAV_SCRIPT above), that risk is gone: nothing is ever mid-slide
       for this pill to outlive, so it's safe to animate every direction
       the same way again. */
    .tracking-tabs {
      display: flex; gap: var(--space-2);
      position: fixed;
      /* share the FAB's row: the FAB stays put in the bottom-left corner
         (exactly like /admin - Chaim: it must not move at all), and the two
         tabs fill the space from just to its right (its var(--space-4) inset +
         48px FAB width + a gap) out to the right screen edge. bottom nudged so
         the ~44px pills sit exactly vertically centred on the 48px FAB -
         measured (not guessed): a first attempt at +2px still measured
         1.84px too high, so it's +0.16px now, checked again after. */
      left: calc(var(--space-4) + 48px + var(--space-3));
      right: var(--space-4);
      bottom: calc(var(--app-bar-chrome-h) + env(safe-area-inset-bottom, 0px) + var(--space-4) + 0.16px);
      z-index: 60;
      /* hidden by default (any page that isn't /recipients): slid down below
         its resting spot and faded out, pointer-events off so it can't be
         tapped while invisible. Same opacity/transform/duration as
         .fab-wrap's own hidden state above, on purpose. */
      opacity: 0; transform: translateY(24px); pointer-events: none;
      transition: opacity var(--duration-base) var(--ease), transform var(--duration-base) var(--ease);
    }
    /* arrived at /recipients (fresh load, or a swipe/tap commit - both just
       flip this class on .main) - rise up from below and fade in. A fresh
       direct load of /recipients ships this class already on the
       server-rendered markup, so nothing actually transitions then - only a
       client-side navigation or a deselect (below) ever plays this. */
    .main.page-recipients .tracking-tabs { opacity: 1; transform: none; pointer-events: auto; }
    /* ...but not while a link is checked: the tab buttons would switch which
       TABLE is showing out from under an in-progress selection (see
       switchTrackingTab's own cancel-the-other-tab's-selection logic above),
       so they duck back down (together with the FAB - .fab-wrap above) for
       as long as .is-selecting is on, then slide back up the moment the
       selection is cleared (that reveal reuses the rule above). Transition
       repeated here (identical to the base rule's) only because 3 classes
       are needed to reliably beat the 2-class rule above regardless of
       source order - the actual timing is the same either way. */
    .main.page-recipients.is-selecting .tracking-tabs {
      opacity: 0; transform: translateY(24px); pointer-events: none;
      transition: opacity var(--duration-base) var(--ease), transform var(--duration-base) var(--ease);
    }
    /* Chaim (2026-08-12): the exact same duck-down-and-fade already applies
       during reorder mode too - "כמו כשאני לוחץ לחיצה ארוכה... שני הכפתורים
       יורדים למטה". The FAB itself stays (updateReorderSaveBtn already
       cross-fades its own + into a v via .main.is-reordering .fab-icon-*,
       untouched here) - only the two tab buttons duck away, same reasoning
       as during selection: switching tabs mid-reorder would swap which
       table is being dragged out from under the gesture. Reappears the
       instant reorder mode ends (saved via the v, or exited any other way)
       by falling back to the base .main.page-recipients rule above. */
    .main.page-recipients.is-reordering .tracking-tabs {
      opacity: 0; transform: translateY(24px); pointer-events: none;
      transition: opacity var(--duration-base) var(--ease), transform var(--duration-base) var(--ease);
    }
    .tracking-tab-btn {
      flex: 1; padding: var(--space-3) var(--space-4); border-radius: var(--radius-pill);
      border: 1px solid var(--border); background: var(--card); color: var(--text);
      font: inherit; font-size: var(--text-sm); font-weight: var(--weight-semibold); cursor: pointer;
      box-shadow: var(--shadow-md);
      transition: background-color var(--duration-fast) var(--ease), color var(--duration-fast) var(--ease), border-color var(--duration-fast) var(--ease), transform var(--duration-fast) var(--ease);
    }
    .tracking-tab-btn.is-active { background: var(--gold-500); color: var(--gold-ink); border-color: var(--gold-500); }
    .tracking-tab-btn:active { transform: scale(.97); }
    .tracking-tab-btn:focus-visible { outline: none; box-shadow: var(--shadow-focus); }
    /* both tables always render server-side (SSE keeps the hidden one live
       too) - this is a pure visibility toggle, not a re-render. The default
       (linked) panel ships WITH .is-active-panel already on it from the
       server, so the linked table shows the instant the page loads with no
       tap and no JS needed - the earlier version keyed the default off
       [data-tracking-panel="linked"], which the :not(.is-active-panel)
       !important rule then overrode on first load, hiding BOTH tables until
       something was tapped. */
    .tracking-tab-panel { display: none; }
    .tracking-tab-panel.is-active-panel { display: block; }
    /* room so the visible table's last row isn't hidden behind the floating
       tab+FAB row above the bottom bar (they share one row now, ~48px tall).
       .tracking-tabs is no longer a sibling of .card-stack (see
       trackingTabsHtml/layout), so this keys off the page itself instead -
       .card-stack only needs the extra room while /recipients is active. */
    .main.page-recipients .card-stack { padding-bottom: calc(48px + var(--space-4)); }

    /* The FAB is NOT repositioned on /recipients anymore - it uses the base
       bottom-left .fab-wrap spot, identical to /admin, and the two tabs sit to
       its right on the same row (see .tracking-tabs above). No raise, no slide:
       Chaim wants the plus button to stay put. */

    /* 2026-08-30, Chaim: the top bar should read as a mirror of the bottom
       tab bar - same visible chrome height, each then extending into its own
       OS safe-area inset.
       --app-bar-chrome-h = the visible bar height, safe-areas EXCLUDED,
                            identical top and bottom.
       --mobile-sticky-header-h = the top-bar box = chrome + the TOP inset.
       (A brief attempt to make the two TOTALS pixel-identical via
       max(inset-top, inset-bottom) was reverted 2026-08-30: it tied the
       sticky top header's height to the BOTTOM inset, which on a real phone
       shifts as the browser chrome / gesture area animates on scroll, and
       that repainted the page - "כל הדף מהבהב". Each bar back on its own
       stable inset; a small top/bottom difference is worth a rock-steady
       layout.) */
    :root {
      /* the bottom tab bar's natural content height (icon + label + its
         paddings) - the top bar is pinned to exactly this so the two match. */
      --app-bar-chrome-h: 79px;
      --mobile-sticky-header-h: calc(env(safe-area-inset-top, 0px) + var(--app-bar-chrome-h));
    }
    .page-header {
      /* z-index 50, not higher: needs to stay under .table-menu-backdrop's
         65 so the header blurs along with the rest of the page when a table
         menu is open, while still comfortably above ordinary scrolling
         content (z-index:auto). */
      position: sticky; top: 0; z-index: 50;
      /* full-bleed: .page-header lives inside .container, which carries its
         own var(--space-4) side padding AND var(--space-5) top padding - a
         negative margin of the same amounts cancels both, letting the
         header's background reach the true viewport edges on all sides
         (including flush against the very top, not just left/right).
         Padding is even on all 4 sides (not matched back to the cancelled
         amounts) so the buttons/title sit visually centered in the colored
         block itself - once the block is an actual visible background
         rather than invisible space, centering the content in *it* reads
         right, even though the content sits a little higher than its exact
         old pixel position from before this block existed. Without this
         background the header had no visible background of its own at all
         (var(--bg), identical to the page behind it) - what actually read
         as "a card that doesn't reach the edges" was only the 3 header
         buttons' own gold box-shadows overlapping into a soft halo, bounded
         to just their own cluster, never a real full-width block to begin
         with. */
      margin: calc(-1 * var(--space-5)) calc(-1 * var(--space-4)) var(--space-4);
      /* padding-top = the top status-bar inset (the dark bg still reaches
         y=0 behind it - see the margin above), padding-bottom = 0. Combined
         with min-height (= that inset + --app-bar-chrome-h) and
         align-items:center, the title + buttons sit centred in the
         --app-bar-chrome-h zone just below the status bar - "בין סוף שורת
         ההתראות לבין סוף הבלוק" (Chaim, 2026-08-30). Left padding clears the
         fixed three-dots button in this same corner. */
      padding: env(safe-area-inset-top, 0px) var(--space-4) 0 calc(30px + var(--space-3));
      /* the exact same petrol gradient the bottom tab bar uses, per explicit
         request that the two read as one consistent surface (top and bottom
         chrome sharing an identity, the way the header + table title bar
         used to share --bg-muted) - but flipped top-to-bottom (to top,
         instead of the bottom bar's own 180deg/to bottom) so the lighter
         #234943 tone sits at *this* block's bottom edge, same as the bottom
         bar's lighter tone sits at *its* top edge - both bars go lighter
         toward the content in the middle of the screen and darker toward
         the true top/bottom edges, a mirrored pair rather than two copies
         running the same direction. No border-radius even though cards
         normally have one: this block is flush against the true top and
         both true side edges, so rounding could only ever show on its
         bottom corners, which would look odd sitting flush against a flat
         top/sides anyway. */
      background: linear-gradient(to top, var(--ink-700), var(--ink-800) 55%, var(--ink-900));
      border-bottom: 1px solid var(--side-line);
      min-height: var(--mobile-sticky-header-h); box-sizing: border-box;
      box-shadow: var(--shadow-sm);
    }
    /* dark petrol background now, needs the sidebar's own light ink instead
       of the page's normal dark text color to stay readable. */
    .page-header h1, .page-header .selection-count { color: var(--side-text); }

    /* the in-header search pill (see HEADER_SEARCH_SCRIPT / mainHeaderHtml) -
       position:fixed, sized/positioned entirely by JS at open time off the
       real search button's own live rect, so this block only owns the look,
       never the math. Same "only width transitions, on a bouncy
       cubic-bezier" mechanism as the reference video Chaim sent - just a
       faster duration per his own follow-up ("too slow"). */
    .header-search-overlay {
      position: fixed; z-index: 70; /* above .page-header's 50 and .table-menu-backdrop's 65 */
      display: flex; flex-direction: column;
      /* Chaim (2026-08-11): the bouncy cubic-bezier this used to share with
         the header-fade (its control points go outside 0-1 on purpose, an
         actual overshoot curve) read as the search icon visibly sliding
         PAST its final spot and correcting back - "זזה שמאלה ורק אז ימינה"
         on open, the mirror on close. var(--ease) (a plain ease-out, no
         overshoot) plus a shorter duration - opening/closing should feel
         immediate, not floaty. */
      transition: left var(--duration-base) var(--ease), width var(--duration-base) var(--ease);
    }
    .header-search-overlay[hidden] { display: none; }
    .header-search-pill {
      flex: none; width: 100%; height: 100%;
      background: var(--ink-800); border: 1px solid rgba(255,255,255,.14);
      border-radius: var(--radius-pill);
      display: flex; align-items: center; overflow: hidden;
    }
    .header-search-pill-icon {
      flex: none; width: 38px; height: 38px;
      display: flex; align-items: center; justify-content: center; color: var(--gold-400);
    }
    .header-search-pill-icon svg { width: 18px; height: 18px; }
    /* type=search (not text) is what actually gets mobile keyboards to swap
       their enter key for a dedicated search/magnifying-glass action key -
       Chaim noticed this was missing and correctly diagnosed why (it wasn't
       "really" a search field). enterkeyhint=search reinforces the same
       intent for browsers that key off that attribute instead. Kept :focus
       neutralized below regardless (a leftover safety net - no rule in this
       stylesheet currently targets input[type=search] specifically, but
       nothing stops one from being added later and reaching this element
       the same way input[type=text]:focus once did). */
    #header-search-input {
      flex: 1 1 auto; min-width: 0; background: none; border: none; outline: none;
      font: inherit; font-size: var(--text-lg); color: var(--side-text); padding: 0;
      -webkit-appearance: none; appearance: none;
    }
    #header-search-input:focus { border: none; box-shadow: none; }
    #header-search-input::placeholder { color: var(--side-muted); }
    /* type=search adds its own native "clear" control in WebKit - redundant
       and visually doubled up with our own .header-search-clear-btn (the X
       already in the pill), so it's suppressed here rather than left to
       show alongside it. */
    #header-search-input::-webkit-search-cancel-button,
    #header-search-input::-webkit-search-decoration {
      -webkit-appearance: none; appearance: none; display: none;
    }
    .header-search-clear-btn {
      flex: none; width: 38px; height: 38px;
      display: flex; align-items: center; justify-content: center;
      background: none; border: none; color: var(--side-muted); cursor: pointer;
    }
    .header-search-clear-btn svg { width: 16px; height: 16px; }
    /* results replace the real page content in place now (see
       showResultsInPage in HEADER_SEARCH_SCRIPT) - .search-results already
       has the list layout (gap, flex column); this just gives it the same
       breathing room the page's other content starts with. */
    .header-search-page-results { margin-top: var(--space-2); }

    /* title, bell AND the fixed three-dots "more" button all fade together
       with the SAME duration/easing as the pill's own left/width growth
       (not a separate quicker fade) - Chaim: everything should read as one
       motion, not the dots popping back before the title's caught up. Not
       display:none - none of them need to leave layout, the fixed-position
       pill already paints over them while it's actually covering that
       spot. .mobile-more-wrap lives outside .page-header (see
       alignMoreDots' comment), hence the body-level class rather than
       hanging this off .page-header like the other two. */
    body.header-search-active .header-title-stack,
    body.header-search-active .notif-bell-wrap,
    body.header-search-active .mobile-more-wrap {
      opacity: 0; pointer-events: none;
      transition: opacity .4s cubic-bezier(.8, -.4, .2, 1.4);
    }
    @media (prefers-reduced-motion: reduce) {
      .header-search-overlay, .header-title-stack, .notif-bell-wrap, .header-search-results {
        transition-duration: .01ms !important;
      }
    }
    /* Table title bars used to stick under the page header while scrolling
       (rounds nine through twenty-three chased that seam-free, blur-correct,
       z-index-safe) - Chaim then asked for the opposite: he wants the title
       to stay exactly where it naturally sits on the card and scroll away
       normally like everything else, not detach and re-anchor near the
       header. No position:sticky here now - this is a deliberate reversal,
       not an oversight, so don't reintroduce it without asking first. */

    /* the bell sits mid-header, not at a screen edge (search and the FAB are
       on either side of it) - anchoring the dropdown's own left edge to the
       bell's left edge and growing rightward from there pushed most of it
       past the right edge of the phone, off-screen. Centering it on the
       viewport instead of the button guarantees it's always fully visible
       regardless of exactly where the bell lands in the header. */
    .notif-dropdown {
      position: fixed; top: calc(var(--mobile-sticky-header-h) + var(--space-2));
      left: 50%; right: auto; transform: translateX(-50%);
      width: calc(100vw - var(--space-8)); max-width: 360px;
    }

    /* creation forms (link/tracking-link create, folder create) become bottom
       sheets - anchored to the screen edge with a drag handle, the native
       mobile pattern - instead of a small centered popup. the search modal
       (also a .form-modal) is excluded on purpose: it stays a top-anchored
       command palette. */
    .form-modal:not(.search-modal) { align-items: flex-end; padding: 0; }
    .form-modal:not(.search-modal) .modal {
      max-width: none; width: 100%; border-radius: 20px 20px 0 0;
      padding: var(--space-5) var(--space-5) calc(var(--space-5) + env(safe-area-inset-bottom, 0px));
      max-height: 88vh; overflow-y: auto; transform: translateY(100%) translateZ(0);
      /* slower than the desktop popup's fade/scale on purpose - a sheet this
         size needs a visible, unhurried rise (and matching fall on close) to
         read as "sliding into place" rather than just appearing. */
      transition: transform 550ms var(--ease);
      /* permanent, not toggled per-drag from MODAL_DRAG_SCRIPT: promoting to
         a GPU layer costs one real frame the first time the browser does it,
         so flipping will-change on right as the finger starts moving (or
         off right as it's released, only to pay the cost again next open)
         is exactly what showed up as a jump at the start of every single
         drag. Declaring it here means the layer exists from the moment the
         sheet becomes part of the DOM, before any touch has happened.
         translateZ(0) + backface-visibility below are the older, blunter
         version of the same request - will-change is a hint the engine can
         ignore/deprioritize, translateZ(0) forces an actual 3D layer on
         every engine that's ever shipped, belt-and-suspenders for exactly
         the taller/heavier sheets (link/tracking, with the folder select and
         the live URL-search box) that kept jumping when the short upload
         sheet already felt smooth on the same phone. */
      will-change: transform;
      backface-visibility: hidden;
    }
    .form-modal:not(.search-modal).is-open .modal { transform: translateY(0) translateZ(0); }
    /* the visual bar is 40x4px, but the actual touch target is much taller
       (padding, not a bigger bar) so it's easy to grab on a real phone
       without needing pixel-perfect accuracy - see MODAL_DRAG_SCRIPT, which
       drives the actual drag-to-dismiss this handle exists for. */
    .form-modal:not(.search-modal) .modal-grab-handle {
      display: flex; align-items: center; justify-content: center;
      margin: calc(-1 * var(--space-3)) calc(-1 * var(--space-5)) var(--space-2);
      padding: var(--space-3) 0; touch-action: none; cursor: grab;
    }
    .form-modal:not(.search-modal) .modal-grab-handle::before {
      content: ''; width: 40px; height: 4px; border-radius: var(--radius-pill); background: var(--border);
    }
    .form-modal:not(.search-modal) .modal-grab-handle:active::before { background: var(--border-hover); }

    /* 16px is the iOS Safari threshold below which focusing a field force-zooms the page */
    input[type=text], input[type=url], input[type=password], select { font-size: 16px; }

    /* dashboard summary: 2x2 grid of smaller tiles instead of a tall column
       - dead weight now that .dash-desktop is hidden below, kept as-is
       rather than pruned so this diff stays about the mobile view, not a
       cleanup of the desktop rules sitting next to it. */
    .summary-row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-3); }
    .summary-row .card { padding: var(--space-3) var(--space-4); }
    .summary-card .num { font-size: var(--text-2xl); }
    .dashboard-split { grid-template-columns: 1fr; }

    /* the actual swap: 2026-08-11 dashboard redesign, phone-only (see the
       big comment above .dash-mobile's own rules) - desktop's original
       summary/chart/activity dashboard hides here in favor of the live
       mosaic. */
    .dash-desktop { display: none; }
    .dash-mobile { display: block; }

    /* tables -> full-width flat list (Chaim: remove the whole table/card look).
       The per-table title bar is dropped (the header title, and on /recipients
       the מקושרים/עצמאיים tabs, already name each list); the card loses all its
       chrome and breaks out to the true screen edges by the container's own
       side padding, so the rows below run edge to edge. */
    .table-title-bar { display: none; }
    .card.table-card { background: transparent; border: none; border-radius: 0; box-shadow: none; }
    .table-card { margin-inline: calc(-1 * var(--space-4)); }
    .table-card .table-wrap { padding: 0; }
    .card-stack { gap: 0; }

    /* 2026-08-30, Chaim: a settings row's press highlight (.settings-row:
       active) and its divider must run from the true screen edge to the
       other - "בדיוק כמו בשורות הלינק שזה מגיע מצד לצד". Same mechanism as
       .table-card right above: break the whole list out past .container's
       own var(--space-4) side padding; each .settings-row / .table-menu-item
       keeps its own var(--space-4) inner padding, so icon+text stay lined up
       with the rest of the page while the row's background fills the width.
       Applies to every settings list - the main list, the logout block, and
       the theme/colours choice lists. */
    .settings-list,
    .settings-choice-list { margin-inline: calc(-1 * var(--space-4)); }
    /* the per-row actions strip in the expanded card is gone - those actions
       (edit / reset / delete) now live in the top selection bar reached by a
       long-press, so repeating them here is redundant. !important because the
       later .mobile-rows td.actions rule sets display:flex for its layout. */
    .mobile-rows td.actions { display: none !important; }
    /* 2026-08-26, Chaim: "שיהיה ממוקם יפה באמצע ולא למעלה כמו שעכשיו" - the
       empty-state block used to just sit at the top of the page with a wall
       of blank background below it (the card chrome around it is already
       gone here, see above). dvh (not vh) is the dynamic viewport unit - a
       mobile browser's own address bar showing/hiding doesn't leave this
       guessing wrong the way a fixed vh number can. Subtracting a rough
       header+bottom-bar allowance rather than using the full 100dvh keeps
       this from ever demanding MORE height than genuinely free and forcing
       an unwanted scrollbar on a short phone. .empty-state is always its
       own independently hidden/shown element (see emptyState()'s own
       hidden param) - this never affects a real table's own rows. */
    .empty-state {
      display: flex; flex-direction: column; align-items: center; justify-content: center;
      min-height: calc(100dvh - 220px);
    }

    /* tables -> cards */
    .table-wrap table { display: block; }
    .table-wrap thead { display: none; }
    .table-wrap tbody { display: block; }
    .table-wrap tr {
      display: block; background: var(--card); border: 1px solid var(--border);
      border-radius: var(--radius-md); box-shadow: var(--shadow-xs);
      padding: var(--space-3) var(--space-4); margin-bottom: var(--space-3);
    }
    .table-wrap tbody tr:last-child { margin-bottom: 0; }
    .table-wrap td {
      display: flex; justify-content: space-between; align-items: center; gap: var(--space-3);
      padding: var(--space-2) 0; border-bottom: 1px solid var(--border); text-align: left;
    }
    .table-wrap td:last-child { border-bottom: none; }
    /* field labels a notch larger than before - the card's "column headers" */
    .table-wrap td::before {
      content: attr(data-label); font-weight: var(--weight-semibold); color: var(--muted);
      font-size: var(--text-sm); flex-shrink: 0;
    }
    .table-wrap td:not([data-label])::before { content: none; }
    .table-wrap td.actions { justify-content: flex-end; }
    .table-wrap td.checkbox-cell { justify-content: flex-start; }
    /* label+value stay on one line; the history list wraps onto its own
       full-width line below when opened (flex-basis:100% forces the wrap
       regardless of how short the label+value pair is on their own) */
    .table-wrap td.clicks-history-cell { flex-wrap: wrap; }
    .table-wrap td.clicks-history-cell .clicks-history-list { flex-basis: 100%; margin-top: var(--space-2); }

    /* long titles still ellipsize inside the card row so the card never widens;
       the link itself wraps in full (see .link-cell .link-text above) */
    .table-wrap td .cell-ellipsis { min-width: 0; max-width: 55vw; }

    /* roomier touch targets */
    .checkbox-cell input[type="checkbox"] { width: 22px; height: 22px; }

    /* collapsed-by-default extra details, opened by tapping the row itself.
       max-height (not display:none/flex) is what makes this actually
       animate open AND closed - display can't be transitioned at all, which
       is exactly why the old version looked fine opening (a fade-in over an
       already-full-size box) but visibly snapped shut on close (opacity
       faded out over 320ms while the box stayed full height the whole time,
       then display:none yanked it flat in a single frame once the JS timer
       finally caught up). One transition, both directions, no JS timer. */
    .table-wrap td.mobile-extra, .table-wrap td.actions {
      max-height: 0; opacity: 0; overflow: hidden;
      transition: max-height var(--duration-slow) var(--ease), opacity var(--duration-slow) var(--ease);
    }
    .table-wrap tr.row-expanded td.mobile-extra, .table-wrap tr.row-expanded td.actions {
      max-height: 72px; opacity: 1;
    }
    .table-wrap td.row-toggle-cell { display: none; }

    /* ---- compact link rows (the 3 link tables only) - real app cards, not a
       shrunk table: colored avatar tile, title + click-count subtitle, and a
       read-only status pill, all visible collapsed. checkbox on the left edge,
       card content on the right edge. expanding reveals the full labeled panel. */
    .mobile-rows tr {
      display: flex; flex-wrap: wrap; align-items: center; column-gap: var(--space-3);
      /* flat full-width list row (Chaim: kill the table look): no card chrome,
         a hairline separator between rows, content inset by the container's own
         side padding (var(--space-4), matched by .table-card's negative margin
         below so rows reach the true screen edges), shorter than before. */
      padding: var(--space-1) var(--space-4); margin-bottom: 0;
      background: transparent; border: none; border-radius: 0; box-shadow: none;
      border-bottom: 1px solid rgba(0, 0, 0, .28);
    }
    /* Chaim (2026-08-11): the desktop-width table's own hover tint (tbody
       tr:hover, line ~5276) doesn't reach in here - .mobile-rows tr's
       collapsed-card styling just above overrides background outright, and
       this whole ruleset only applies under max-width:780px to begin with,
       which the desktop rule was never written to reach either way. His
       actual case: a real mouse, over a real desktop Chrome window simply
       narrowed down to phone width - same real-hover situation the desktop
       rule already covers above 780px, so this reuses that exact tint.
       Gated on the (hover: hover) feature query (a real pointer that CAN
       hover) rather than relying on the separate (hover: none) reset block
       further down to cancel it out on touch devices afterward - this rule
       is MORE specific than that reset's plain two-selector rule (an extra
       class plus an attribute selector here), so on an actual phone (both
       max-width:780px AND hover:none true at once) it would otherwise win
       the specificity fight and bring back the exact "stuck hover" bug that
       reset block was written to kill. Never being active there in the
       first place, via its own feature query, is the correct fix - not a
       second patch chasing the first. */
    @media (hover: hover) {
      .mobile-rows tbody tr[data-link-id]:hover { background-color: var(--bg-muted); }
    }
    /* transparent, not none: dropping the border outright made the last row 1px
       shorter than every other one. Keep the box identical, just hide the line. */
    .mobile-rows tbody tr:last-child { border-bottom-color: transparent; }
    .mobile-rows td { border-bottom: none; padding: var(--space-1) 0; }
    /* order:0 puts the checkbox FIRST in the RTL row - i.e. hard against the
       right edge, ahead of the name - so names sit flush against the right
       edge normally, and ONLY shift left to make room once a long-press
       enters multi-select mode (Chaim: this shift is the expected, wanted
       WhatsApp-style behaviour - names must NOT sit permanently shifted with
       an empty gap reserved at rest, which an earlier attempt at fixing a
       DIFFERENT jump - see stripEntranceAnim's removal, the actual cause -
       had also (unnecessarily) introduced). */
    .mobile-rows td.checkbox-cell { order: 0; width: auto; flex: none; display: none; align-items: center; }
    .mobile-rows.multi-select-mode td.checkbox-cell { display: flex; }
    /* trash's own per-row restore button (2026-08-17) - same right-hand
       order:0 edge as checkbox-cell above, but the exact INVERSE visibility:
       shown while just browsing, hidden the moment multi-select turns the
       real checkbox on there instead - the two never fight over the spot
       because they're never both visible at once. */
    .mobile-rows td.trash-restore-cell { order: 0; width: auto; flex: none; display: flex; align-items: center; }
    .mobile-rows.multi-select-mode td.trash-restore-cell { display: none; }
    /* on mobile the drag handle does NOT live in its own wrapped cell below
       the row (which pushed the row taller and looked messy) - it takes the
       exact trailing-edge spot the copy/QR/status buttons had, in the same
       title row, so the row keeps its exact height and position. The buttons
       fade out and the handle fades in, in place (see .row-drag-mobile). */
    .mobile-rows.reorder-mode td.row-toggle-cell { display: none !important; }
    .row-drag-mobile { display: none; }
    .mobile-rows.reorder-mode .row-drag-mobile {
      display: flex; align-items: center; justify-content: center; flex: none;
      width: 40px; height: 26px; color: var(--muted);
      animation: drag-handle-in var(--duration-base) var(--ease) both;
    }
    .mobile-rows.reorder-mode .row-drag-mobile svg { width: 22px; height: 22px; }
    @keyframes drag-handle-in { from { opacity: 0; transform: scale(.7); } to { opacity: 1; transform: none; } }
    /* the sibling(s) a dragged row displaces slide into their new spot
       (see the FLIP logic in the pointermove handler) instead of snapping -
       scoped to reorder-mode and excludes .dragging so the actively-dragged
       row itself (which tracks the pointer 1:1 via direct transform writes,
       no easing) is never subject to this transition's delay. */
    .mobile-rows.reorder-mode tbody tr[data-link-id]:not(.dragging) {
      /* the background/box-shadow/radius parts are here too so that RELEASING a
         row (it loses .dragging and lands in this rule) animates the gold
         "picked up" look back OUT, rather than snapping. */
      transition: transform var(--duration-base) var(--ease),
                  background-color var(--duration-base) var(--ease),
                  box-shadow var(--duration-base) var(--ease),
                  border-radius var(--duration-base) var(--ease);
    }
    /* ===== the picked-up row (Chaim's choice B) =====
       Gold tint + gold outline, flat (no shadow, keeps the list's flat style),
       and it ANIMATES into that look the moment you grab it and back out when
       you let go. The outline is an INSET box-shadow, not a real border, so it
       costs no layout - a 2px border would grow the row and shove the whole
       list 4px as soon as you touched it.
       transform is deliberately NOT transitioned here: the drag writes the
       row's transform directly on every pointermove, and easing it would make
       the row lag behind the finger. */
    .mobile-rows.reorder-mode tbody tr[data-link-id].dragging {
      transition: background-color var(--duration-base) var(--ease),
                  box-shadow var(--duration-base) var(--ease),
                  border-radius var(--duration-base) var(--ease);
      background-color: var(--gold-soft);
      box-shadow: inset 0 0 0 2px var(--gold-500);
      border-radius: var(--radius-md);
      border-bottom-color: transparent;
      opacity: 1;
      animation: none;
    }
    /* a quiet "waiting for you" breathe while a row can be reordered - via
       opacity (not scale) because reorder rows already animate transform for
       the FLIP slide-into-place effect above, and an active scale animation
       would win over that transition and break it. Multi-select rows do NOT
       pulse anymore (Chaim: no pumping at all on long-press) - the press tint +
       scale-in checkmark + persistent selected highlight are the feedback. */
    @keyframes row-pulse-fade { 0%, 100% { opacity: 1; } 50% { opacity: .72; } }
    /* background-color transition still needed below for the PERSISTENT
       selected-row highlight (:has(input:checked), further down) - unrelated
       to touch feedback, a real separate feature, kept as-is. The .select-tap
       and .row-pressing flat-tint rules that USED to live here (a quick
       colour flash on tap, and an immediate press tint) are gone as of
       2026-08-13 - both fired ALONGSIDE the row's own new ripple (the whole
       row is a real ripple target now, RIPPLE_SCRIPT) for the exact same tap,
       two different-looking effects stacked on one touch - the "two shadows"
       bug Chaim reported. Ripple alone carries ALL of this now: it already
       shows instantly on pointerdown (not just on release) and stays fully
       visible for as long as a finger is held down, covering both the quick-
       flash and the long-press-hold cases these 2 rules used to handle
       separately. row-pressing's CLASS itself (not this rule) is still
       toggled by the same JS as before - #trash-links-table's own opacity
       rule further down still keys off it, for an unrelated reason (keeping
       a dimmed trash row undimmed while actively pressed). */
    .mobile-rows tbody tr[data-link-id] { transition: background-color var(--duration-fast) var(--ease); }
    /* a SELECTED row stays gently tinted the whole time it's checked (the
       persistent WhatsApp-style highlight), fading in via the transition above.
       :has() keys it straight off the checkbox state - no JS bookkeeping. */
    /* 2026-08-20 fix (visual-consistency audit, P2): gold-soft, not
       accent-soft (violet) - matches the checkbox itself, which this same
       fix pass already moved to gold above (checkbox-cell input rules). */
    .mobile-rows tbody tr[data-link-id]:has(input[name="ids"]:checked) { background-color: var(--gold-soft); }
    /* the round checkboxes pop into existence when multi-select turns on,
       instead of blinking in - a small scale/fade so the appearance itself
       reads as a deliberate micro-interaction. */
    @keyframes checkbox-pop-in { from { transform: scale(0); opacity: 0; } to { transform: scale(1); opacity: 1; } }
    .mobile-rows.multi-select-mode td.checkbox-cell input[type="checkbox"] { animation: checkbox-pop-in 240ms var(--ease) both; }
    .mobile-rows.reorder-mode tbody tr[data-link-id]:not(.dragging) {
      animation: row-pulse-fade 1.6s ease-in-out infinite;
    }
    /* reorder mode is for dragging, not acting - the switch/copy/QR buttons
       are real controls a stray tap could trigger mid-drag, and they add
       visual noise while every row's only job is to be picked up and moved.
       Only the name/title stays, restored the instant reorder mode ends. */
    .mobile-rows.reorder-mode .row-status-switch,
    .mobile-rows.reorder-mode .row-clicked-chip,
    .mobile-rows.reorder-mode .row-quick-actions { display: none !important; }
    .mobile-rows td.row-title-cell {
      order: 1; display: flex; align-items: center; gap: var(--space-3);
      flex: 1 1 0; min-width: 0; text-align: right;
    }
    /* row-title-flex is the td's only child - it must grow to fill the whole
       cell so that the name inside it (.row-title-main, flex:1) has real free
       space to expand into and push the button cluster to the trailing edge.
       Without this it would shrink-wrap its content and the cluster would sit
       right next to the name instead. */
    .mobile-rows .row-title-flex { flex: 1 1 0; min-width: 0; }
    .mobile-rows .row-title-sub {
      display: block; font-size: var(--text-xs); color: var(--muted); font-weight: var(--weight-regular);
    }
    .mobile-rows .row-title-main .folder-tag { margin-top: 2px; }
    .mobile-rows .row-quick-actions { display: flex; gap: 2px; flex: none; }
    /* a consistent, deliberately compact icon-button size for this row's
       own copy/QR pair - smaller than the default .icon-btn so the cluster
       (copy, QR, switch) doesn't crowd the row. */
    .mobile-rows .row-quick-actions .icon-btn { width: 26px; height: 26px; }
    .mobile-rows .row-quick-actions .icon-btn svg { width: 15px; height: 15px; }
    /* The clicked status on mobile (2026-08-30, Chaim: "לא רואים את כל מה
       שכתוב בבלוק הירוק... וגם זה חוסם את השם של הלינק עצמו"). The old
       inline .row-clicked-chip had to share one horizontal line with the
       name AND the switch cluster - a clicked badge carrying a full
       "· 30/08/2026, 07:23" never fit, so it clipped itself and covered the
       name. It's on the row's own SUB line now (trackingRowSubHtml): the
       name keeps the whole top line to itself, the green pill + full
       timestamp + click count sit underneath at full width, wrapping if
       they must. */
    .row-clicked-chip { display: none; }
    .tracking-title-flex .row-title-main { flex: 1 1 0; min-width: 0; }
    .tracking-clicked-meta { display: inline-flex; }
    .tracking-clicks-count::before { content: "·"; margin-inline-end: 6px; color: var(--muted); }
    /* the desktop-only נלחץ? column (see trackingRowHtml/trackingTableHead) -
       row-clicked-chip covers this on mobile instead, right in the card.
       !important: same reason as .mobile-rows td.actions above - the
       generic .table-wrap td { display:flex } card-row rule (a plain
       class+element selector) otherwise outranks a bare .tracking-status-
       col class selector on specificity alone, regardless of source order,
       and this cell isn't .mobile-extra so the accordion collapse rules
       don't hide it either - confirmed via a real measurement (Playwright)
       that without !important this rendered as a full extra 112px-wide
       flex row, stealing that width straight from the name/count column
       next to it. */
    .tracking-status-col { display: none !important; }
    /* the collapsed card already shows copy/QR (title cell) directly, so the
       copy/QR inside the expanded קישור row would just repeat them - hide the
       link-cell's copy/QR on mobile for BOTH tables (desktop is the only
       place they show; there the title-cell quick-actions are hidden instead,
       so the link cell is the sole home for copy/QR). Likewise the count
       number lives in the collapsed card's subtitle on mobile, so the copy in
       the ציר-זמן cell is hidden there. */
    .mobile-rows .link-cell .icon-btn { display: none; }
    /* keep the URL to a single ellipsized line in the expanded card so every
       info row is the same height - a long link that wrapped to 2-3 lines was
       the one row that stuck out taller than the rest. The full URL is still
       available via its title tooltip and the collapsed card's copy button. */
    .mobile-rows .link-cell .link-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    /* the נלחץ? cell is a normal, uniform-height info row now (it used to be
       force-collapsed to max-height:0, but the expanded padding still leaked
       ~17px of a half-shown badge - the "cramped" row Chaim pointed at). */
    /* the row itself already toggles open/closed on tap - this explicit
       close button duplicated that, so it's dropped for tracking rows. */
    .tracking-table.mobile-rows tr.row-expanded td.row-toggle-cell { display: none; }
    .mobile-rows td.row-title-cell::before { content: none; }
    /* display:flex is unconditional now (not gated by row-expanded) so
       max-height/opacity (see the shared .table-wrap rule above) are the
       only things animating open/closed - a display swap can't transition
       at all, which was the root cause of the old snap-shut-on-close bug.
       border-top fades via color, not width, so it never adds a chunky
       width transition of its own; padding is included too, so the row
       genuinely settles to zero height when closed, not just a padded
       sliver. */
    .mobile-rows td.mobile-extra {
      order: 4; display: flex; width: 100%; justify-content: space-between; align-items: center;
      gap: var(--space-3); padding: 0;
      /* border-top is NOT set here - the later unconditional-card rule below
         always wins (same specificity, later source) and fixes it to
         var(--border), so a border-color transition here would never run. */
      transition: max-height var(--duration-slow) var(--ease), opacity var(--duration-slow) var(--ease),
                  padding var(--duration-slow) var(--ease), margin var(--duration-slow) var(--ease);
    }
    .mobile-rows td.row-toggle-cell { order: 6; }
    /* every expanded info row is the SAME fixed height so the list reads as an
       even table (Chaim: some rows were really cramped - the נלחץ? cell was
       half-collapsed at 17px while the status toggle row ran to 46px). The
       shared max-height:72px still animates the open (cell grows 0->44 as
       max-height allows, then holds at 44); content is centred in that height.
       The clicks-history cell opts out when its list is open (below). */

    /* ===== expanded detail = ONE white card (Chaim's choice B) =====
       The info rows share a single white, rounded, inset panel - separated
       inside it by the same hairlines they had before - so it reads as the
       link's detail panel, not as more list rows. The cells are separate <td>s,
       so the "card" is assembled from them: every cell carries the white bg +
       side borders; the FIRST (the only .mobile-extra not preceded by another)
       rounds the top; the LAST (:has() - nothing after it) rounds the bottom.

       CRUCIAL - all of this, INCLUDING height:44px, is applied UNCONDITIONALLY,
       not only under .row-expanded. That's what makes the close an exact mirror
       of the open (Chaim: "the close isn't quite the reverse"):
         - if the white card/borders only existed while expanded, removing the
           class would strip them in a single frame and you'd watch a bare,
           transparent strip roll up instead of the card;
         - if height:44px only existed while expanded, the cell would SNAP from
           44px down to its content height (~22px) the instant the class went,
           and only then animate - so the close began with a jump.
       While collapsed these are all invisible anyway: max-height:0 (border-box)
       clamps the whole box, borders included, to zero height. */
    .mobile-rows td.mobile-extra {
      height: 44px; padding-block: 0;
      background: var(--card);
      width: calc(100% - 2 * var(--space-3)) !important;
      margin-inline: var(--space-3);
      padding-inline: var(--space-3) !important;
      border-inline: 1px solid var(--border);
      border-top: 1px solid var(--border);
    }
    .mobile-rows td:not(.mobile-extra) + td.mobile-extra {
      border-top: none;
      border-radius: var(--radius-md) var(--radius-md) 0 0;
    }
    .mobile-rows td.mobile-extra:not(:has(+ td.mobile-extra)) {
      border-bottom: 1px solid var(--border);
      border-radius: 0 0 var(--radius-md) var(--radius-md);
    }
    /* only the OUTER gaps depend on being open - and they're transitioned (see
       the margin in the transition list below) so they ease shut with the card
       rather than snapping. */
    .mobile-rows tr.row-expanded td:not(.mobile-extra) + td.mobile-extra { margin-top: var(--space-2); }
    .mobile-rows tr.row-expanded td.mobile-extra:not(:has(+ td.mobile-extra)) { margin-bottom: var(--space-2); }

    /* ROLLING SHUTTER, and its exact reverse.
       Open : row i waits i * STEP        -> unrolls top-to-bottom.
       Close: row i waits (n-1-i) * STEP  -> rolls up bottom-to-top.
       Same STEP (45ms) both ways, and --n (the row's real number of info rows,
       set inline on the <tr>) makes the last row the one that starts closing
       first - the previous version used a different step AND a hard-coded
       count, which is why the close didn't mirror the open. */
    .mobile-rows tr.row-expanded td.mobile-extra { transition-delay: calc(var(--i, 0) * 45ms); }
    .mobile-rows tr:not(.row-expanded) td.mobile-extra { transition-delay: calc((var(--n, 4) - 1 - var(--i, 0)) * 45ms); }
    /* the one cell whose real content can genuinely exceed the shared 72px
       cap - its own click-history list opens inside it (see
       toggleClicksHistory) and can run to several lines. */
    /* tall enough to show the full recent-clicks list (up to 20 rows) without
       clipping the bottom ones - the old 400px cut off longer histories. */
    .mobile-rows tr.row-expanded td.clicks-history-cell.is-open { height: auto; max-height: 1500px; }
    /* 2026-08-25 (Task 8, Chaim: "there's some ugly gap between the first
       item in the list and the rest"). The generic .table-wrap rule above
       (line ~9945) already gives this cell flex-wrap:wrap plus flex-basis
       100% on the list, so the list DOES drop onto its own full-width line -
       that part was never actually broken. The real bug, found by measuring
       the real rendered gaps with getComputedStyle/getBoundingClientRect
       (not just reading the CSS): td.mobile-extra's own gap (var(--space-3),
       12px) already separates the wrapped label/value line from the list's
       line, and the list ALSO carried its own top margin (var(--space-2),
       8px) on top of that - the two stack to a 20px gap before the first
       row, next to every other row's much smaller ~9px hairline rhythm
       (4px padding + 1px border + 4px padding). That mismatch, not a
       missing gap, is what reads as "ugly" - re-declared here (later in
       source, same specificity, so it wins) to drop the redundant margin
       and let the container's own gap be the only source of that first
       line-break, landing much closer to the rest of the list's rhythm. */
    .mobile-rows td.clicks-history-cell .clicks-history-list { margin-top: 0; }
    /* uploadTableRowHtml's detail cell (2026-08-17): a progress bar + quota
       gauge + a growing file list is a whole stacked PANEL, not one single-
       line "label: value" row like every other info cell here - the base
       td.mobile-extra rule is display:flex with no direction (row, meant for
       exactly one inline value); without this override its children laid
       out side-by-side instead of stacked, and clipped at the shared fixed
       44px height. Same escape hatch as clicks-history-cell just above. */
    .mobile-rows tr.row-expanded td.upload-detail-cell {
      height: auto; max-height: 900px;
      flex-direction: column; align-items: stretch; justify-content: flex-start;
      gap: var(--space-2);
      padding-block: var(--space-3) !important;
    }
    /* the links table's own .mobile-rows td.actions {display:none!important}
       (see the "tables -> cards" block above) is a deliberate call for THAT
       table - edit/reset/delete moved into the long-press multi-select bar
       there, so repeating them inline would be redundant. Uploads keep
       exactly ONE thing inline here now (2026-08-18: reset-notification/
       delete moved to their own long-press bar too - see
       uploadSelectionActionsHtml - status moved to the row-quick-actions
       switch) - the single direction-specific primary action (add-files/
       save) - same !important weapon, higher specificity (3 classes vs 2)
       to win the tie. order:5 (between mobile-extra's 4 and row-toggle-
       cell's 6) because .actions never had its own order rule before (it
       was always display:none on mobile until now), so without this it
       fell back to the initial order:0 and rendered up in the checkbox-
       cell/trash-restore-cell slot instead of after the detail panel
       where it visually belongs. */
    .mobile-rows td.actions.upload-actions-cell { display: flex !important; order: 5; width: 100%; }
    /* full-width and centered - one real, clear button ("כפתור יפה וברור"),
       not a cluster of small icons anymore. */
    .upload-actions-cell .btn { width: 100%; justify-content: center; }
  }
