  /* flip easter egg / cloud-pass extension — registers --light-glow-
     alpha as an animatable <number>, which plain CSS custom properties
     are NOT by default (they don't interpolate smoothly in animations/
     transitions without this — they'd just jump between values). Lets
     the cloud-pass effect (see body.cloud-pass-active further down)
     dim the actual light source itself, not just the glints it casts,
     while keeping this as part of body's own background (rather than
     a separate positioned element) — background always paints behind
     all page content unambiguously, avoiding any stacking-order risk
     a new element would introduce. */
  @property --light-glow-alpha {
    syntax: '<number>';
    inherits: true;
    initial-value: 0.26;
  }
  :root {
    /* Tells the browser which native form-control theme to use for
       checkboxes/scrollbars/etc, independent of accent-color. "light
       dark" = follow the OS by default; the two explicit overrides
       below take over once the toggle sets an explicit theme. */
    color-scheme: light dark;
    /* iOS Safari's own status bar/toolbar area is translucent and
       shows live page content through it — a fixed header that
       doesn't extend its own background/blur into that region (and
       whose content doesn't shift below it) shows raw, unblurred
       content peeking through at the very top on scroll, and/or sits
       partly under the notch. Requires viewport-fit=cover on the
       <meta name="viewport"> tag (see <head>) to be non-zero at all —
       without it this always resolves to 0px, silently. Falls back to
       0px explicitly (not left bare) so every consumer below works
       identically on non-notched devices and in browsers that don't
       support env() at all.
       This page already had SOME env(safe-area-inset-*) usage before
       this fix (.sidebar-flip-card's own top/bottom padding,
       .stack-nav/body's own bottom padding) — all of it was silently
       inert until now, since none of those could resolve to anything
       but 0 without viewport-fit=cover ever being set anywhere on
       this page. Fixing the meta tag fixes all of those at the same
       time as this page's own .top-frost-bar/header-actions/sidebar,
       not introducing a new pattern this page didn't already partly
       have. */
    --safe-top: env(safe-area-inset-top, 0px);
    /* How far iOS's own status-bar treatment bleeds PAST the safe-area
       inset in standalone mode.

       Carson's report, with a measured screenshot: even with every
       floating button already moved clear of env(safe-area-inset-top)
       (see the PWA / STANDALONE block at the very end of this
       stylesheet, which is what did that), the top third or so of the
       Log in, "i" and theme-toggle buttons still read as washed out
       and slightly smeared, permanently, at every scroll position.

       Cause: iOS does not stop its status-bar glass at the inset
       boundary. The inset is where the OPAQUE part ends; below it the
       treatment feathers out over another stretch of the page, and
       anything sitting in that stretch is composited through it.
       Measured off the screenshot (a 375pt-wide device, 50px inset):
       content only came back to full strength ~34px past the inset —
       about 16px below where the buttons currently start.

       This value is that overhang minus the 18px the buttons already
       sit below the inset: the EXTRA room the top of the page needs so
       nothing lands in the feathered region at all. 24px rather than
       the bare 16px measured, for margin — the exact reach isn't
       documented by Apple, and it costs nothing but a few px of top
       padding to stay well clear of it.

       This is the only number to touch when tuning: the floating
       buttons, the frost bar's own height, the credits panel and
       body's own top padding all add it, so they move together and
       every gap between them is preserved exactly as designed.

       0 outside standalone, deliberately: a Safari tab has no OS glass
       over it at all (env(safe-area-inset-top) is 0 there too), so
       every rule below that adds this variable is provably a no-op in
       tab mode. */
    --os-glass-bleed: 0px;
    --bg: #FAFAF9;
    --bg-rgb: 250, 250, 249;
    --surface: #FFFFFF;
    --surface-rgb: 255, 255, 255;
    --border: #E7E5E2;
    --text: #1C1C1E;
    --text-dim: #6B6B6E;
    --accent: #3E6C7A;
    --accent-rgb: 62, 108, 122;
    --accent-soft: #E4EEF0;
    /* Brand-logo theming, driven per frame by the theme animation in
       account.html rather than by a data-theme selector — see its
       COLOR_SPECS and the --logo-dim write in runTransition(). These
       resting values must match what the animation last wrote, since
       finish() clears the inline overrides and hands straight back to
       this cascade. 0 = full-color logos, 1 = muted dark treatment. */
    --logo-dim: 0;
    --abr-logo-fill: #000000;
    --light-rgb: 255, 205, 130;
    /* Base intensity for the light source glow in body{}'s own
       background — the cloud-pass effect animates this down slightly
       and back (see body.cloud-pass-active further down) to represent
       something briefly passing in front of the light itself, not
       just the glints it casts on glass elements elsewhere. */
    --light-glow-alpha: 0.26;
    --banner-danger-bg: #C1554B;
    --banner-danger-text: #FFFFFF;
    --banner-warning-bg: #D68A2E;
    --banner-warning-text: #FFFFFF;
    --banner-announce-bg: #3E9A6C;
    --banner-announce-text: #FFFFFF;
    --fog-rgb: 232, 221, 200;
    --fog-visible-mult: 0;
    --ok: #3E9A6C;
    --down: #C1554B;
    /* Darkened, "solid-fill-safe" variants of --ok/--down/--accent —
       Carson's report: white text on a hover-filled .admin-unban-btn
       was unreadable in light mode. Measured it rather than guessing
       at a fix: white-on-#3E9A6C comes out to 3.47:1, below the 4.5:1
       WCAG AA threshold this text size needs (it's bold but only
       12.5px, well under the "large text" size WCAG uses to allow a
       lower 3:1 threshold instead). Checked every other button using
       the same base-color-as-solid-fill pattern while at it, not just
       the one Carson happened to hit — --down and --accent both have
       the identical problem in at least one theme (--down in dark
       mode: 2.71:1; --accent in dark mode: 2.33:1), just not yet
       reported. These three are for exactly that role — a solid fill
       behind white text — not a wholesale replacement for --ok/--down/
       --accent's own existing use as borders, plain text, and badges,
       none of which have this problem. All six (three colors x two
       themes) verified at 5.6:1 or better with white text, not just
       barely clearing 4.5. */
    --ok-strong: #2f7451;
    --down-strong: #a44840;
    --accent-strong: #355c68;
    --maint: #7A6FC4;
    --pending: #C79A3E;
    --unknown: #B8B6B1;
    --radius: 16px;
    --shadow: rgba(0, 0, 0, 0.06);
  }
  @media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) {
      --bg: #0C0D0E; --bg-rgb: 12, 13, 14; --surface: #17181A; --surface-rgb: 23, 24, 26; --border: #2A2B2C;
      --text: #F2F2F0; --text-dim: #92928F; --accent: #7FB2C0; --accent-rgb: 127, 178, 192;
      --accent-soft: #1B2A2E; --light-rgb: 205, 222, 236; --fog-rgb: 125, 138, 152; --fog-visible-mult: 1; --ok: #58C08A; --down: #E0847B;
      --ok-strong: #306a4c; --down-strong: #864f4a; --accent-strong: #354b51;
      --logo-dim: 1; --abr-logo-fill: #FFFFFF;
      --maint: #A79EE0; --pending: #DDB466; --unknown: #4A4A48;
      --banner-danger-bg: #A8443C; --banner-danger-text: #FFF5F3; --banner-warning-bg: #B8722A; --banner-warning-text: #FFF8EE; --banner-announce-bg: #2E7A54; --banner-announce-text: #F0FFF5;
      --shadow: rgba(0,0,0,0.35);
    }
  }
  html[data-theme="dark"] {
    color-scheme: dark;
    --bg: #0C0D0E; --bg-rgb: 12, 13, 14; --surface: #17181A; --surface-rgb: 23, 24, 26; --border: #2A2B2C;
    --text: #F2F2F0; --text-dim: #92928F; --accent: #7FB2C0; --accent-rgb: 127, 178, 192;
    --accent-soft: #1B2A2E; --light-rgb: 205, 222, 236; --fog-rgb: 125, 138, 152; --fog-visible-mult: 1; --ok: #58C08A; --down: #E0847B;
    --ok-strong: #306a4c; --down-strong: #864f4a; --accent-strong: #354b51;
    --logo-dim: 1; --abr-logo-fill: #FFFFFF;
    --maint: #A79EE0; --pending: #DDB466; --unknown: #4A4A48;
    --banner-danger-bg: #A8443C; --banner-danger-text: #FFF5F3; --banner-warning-bg: #B8722A; --banner-warning-text: #FFF8EE; --banner-announce-bg: #2E7A54; --banner-announce-text: #F0FFF5;
    --shadow: rgba(0,0,0,0.35);
  }
  html[data-theme="light"] { color-scheme: light; }
  /* Suppresses ALL transitions site-wide while the JS-driven theme
     toggle animation (runTransition()) is actively running —
     confirmed via Safari Web Inspector profiling: applyColors()
     already writes a smooth, frame-accurate interpolated color value
     directly via root.style.setProperty() on every single animation
     frame, so the various transition: ...2400ms declarations
     throughout the page were fighting themselves — restarting a
     brand-new transition toward a new target on every frame, never
     once letting any single one of them actually finish. That's pure
     wasted overhead: the JS is already smooth on its own, the CSS
     layer on top was never doing anything visually useful during
     this specific window, only costing real style-recalculation
     time. Class added in runTransition() itself, removed in
     finish() — see those two functions' own comments for exactly
     where. */
  /* #theme-icon no longer needs an exclusion here at all — Carson's
     report: even with the icon correctly excluded from this rule (an
     earlier version of this comment covers that fix), the fade still
     wasn't visually smooth, just correctly timed. The likely cause
     isn't this rule at all: frame() below already does real,
     meaningful work every single animation frame (interpolating every
     theme color, recomputing every glint angle across the page) —
     requestAnimationFrame's own comment further down already
     acknowledges frames can get skipped under load. A separate,
     independent 150ms CSS transition competing for paint time against
     that is exactly the kind of thing that can start and finish
     without the browser ever getting a spare moment to render one of
     its own intermediate frames — reading as an instant snap even
     though the transition genuinely ran. The fix: the icon's own
     fade is now driven directly inside frame() itself (see
     runTransition()'s own copy of this reasoning), the same
     JS-computes-the-value-every-frame approach applyColors() already
     uses for every other color on the page — guaranteed the same
     render opportunity as everything else in this loop, not competing
     separately against it. With that, this element has nothing left
     that benefits from keeping its own CSS transition alive during
     the toggle, so it's back in the blanket rule below like
     everything else. */
  html.theme-animating,
  html.theme-animating *,
  html.theme-animating *::before,
  html.theme-animating *::after {
    transition: none !important;
  }
  * { box-sizing: border-box; }
  [hidden] { display: none !important; }
  body {
    margin: 0; min-height: 100vh; color: var(--text);
    /* The wave-band's full-bleed breakout (100vw + negative 50vw
       margins) can introduce a few px of horizontal overflow on
       browsers where 100vw includes the vertical scrollbar's width —
       same fix as the other two pages. */
    overflow-x: hidden;
    /* Carson's report: the page itself could be dragged left/right on
       a real touch device outside of any actual card-swipe gesture —
       a different problem from the overflow-x fix just above (that one
       stops a genuine horizontal SCROLLBAR from appearing; this is
       about Mobile Safari's own native drag/rubber-band response to a
       sideways touch, which can happen even with zero overflowing
       content). pan-y tells the browser to only ever recognize
       VERTICAL panning as a native touch gesture on this page — it
       doesn't touch JavaScript's own touchmove listeners at all (the
       settings-card stack's and widget stack's own swipe detection are
       both entirely JS-driven, reading touch coordinates directly
       rather than relying on any native browser panning), so this
       doesn't risk breaking either of those; it only removes the
       browser's OWN default horizontal-drag behavior everywhere else
       on the page, which is exactly what was being felt as unwanted
       sideways movement while trying to do something else entirely. */
    touch-action: pan-y;
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
    /* Bottom padding zeroed — same fix the home/request pages already
       needed: this 60px predates .wave-band existing on this page, and
       now that the wave band is the true last thing on the page, that
       padding just stacked empty space below it instead of the page
       ending flush with the wave's own bottom edge. .wave-band's own
       margin-top still gives it breathing room from the content above. */
    /* 48px -> 72px — Carson's report: the fixed nav bar and "Welcome,
       Carson" sat too close together, both at this tablet-ish width
       and on pure mobile — this padding is a flat value (not gated to
       any breakpoint), so a flat 48px meant it was too tight
       everywhere, not just one width range. Matches request.html's
       own body padding-top exactly (72px 0 0) — Carson's own explicit
       ask was to reuse that page's spacing rather than invent a new
       number here, so this is a direct copy of that value, not a
       fresh guess. */
    /* --body-pad-top is the single source of truth for this page's own
       top padding, and --banner-h (set by the JS from the banner's
       measured height, 0px when there is no active message) is added on
       top of it — that sum is what makes room for the site-wide banner,
       which is fixed to the top of the viewport and so takes up no space
       of its own. Every fixed element up there offsets from the same
       --banner-h, so the banner, the frost bar, the corner buttons and
       ordinary page content all move together, at every breakpoint and
       whether the banner is one line or five. Keeping the padding in one
       variable is also what stopped it and the banner drifting apart the
       way they used to (account.html's 82px breakpoint and both
       standalone/safe-area blocks each restated the numbers by hand, and
       one of them was wrong): any future change belongs here only. */
    --body-pad-top: 72px;
    padding: calc(var(--body-pad-top) + var(--banner-h, 0px)) clamp(20px, 3vw, 48px) 0;
    /* Now includes the fog layer too, matching the home/request pages
       exactly — this page has the dot wave now (see .wave-band near the
       end of #signed-in), so --fog-start has something real to anchor
       to. --fog-start itself is set by the fog-placement script below,
       same technique as the other two pages. */
    background:
      radial-gradient(640px 440px at var(--light-x, calc(100% - 24px)) var(--light-y, -70px), rgba(var(--light-rgb), var(--light-glow-alpha)), transparent 65%),
      linear-gradient(to bottom,
        transparent 0%,
        transparent var(--fog-start, 78%),
        rgba(var(--fog-rgb), calc(0.4 * var(--fog-visible-mult, 1))) calc(var(--fog-start, 78%) + 7%),
        rgba(var(--fog-rgb), calc(0.1 * var(--fog-visible-mult, 1))) 100%),
      var(--bg);
    transition: background-color 2400ms ease, color 0.3s ease;
  }

  /* ---------- Stars (dark mode only) ---------- */
  .stars {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    z-index: 0;
    pointer-events: none;
    opacity: 0;
    transition: opacity 2400ms ease;
    -webkit-mask-image: linear-gradient(to bottom, black 0%, black calc(var(--fog-start, 78%) - 10%), transparent var(--fog-start, 78%));
    mask-image: linear-gradient(to bottom, black 0%, black calc(var(--fog-start, 78%) - 10%), transparent var(--fog-start, 78%));
  }
  @media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) .stars { opacity: 1; }
  }
  html[data-theme="dark"] .stars { opacity: 1; }
  /* Same fix as .clouds' own copy of this comment, mirrored: .stars
     sits at opacity:0 by default (light mode) but every individual
     .star kept twinkling underneath regardless. No !important needed
     here specifically — .star's own inline style only ever sets
     animationDuration/animationDelay (longhand, via JS), never the
     full shorthand the way .cloud does, so a plain rule is enough to
     both pause it here and un-pause it in the dark-mode overrides
     just below, at the same specificity the opacity rules themselves
     already use. */
  .star { animation-play-state: paused; }
  @media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) .star { animation-play-state: running; }
  }
  html[data-theme="dark"] .star { animation-play-state: running; }

  /* ---------- Drifting clouds (light mode's own atmosphere) ----------
     Carson's ask: dark mode has stars and shooting stars giving it a
     living, calm quality; light mode had nothing above the fog line
     but a static glow. These are the day-mode counterpart — same
     "complementary crossfade" relationship as .stars has with dark
     mode, just inverted (visible in light, fades out toward dark).
     Same masking technique as .stars too, reusing --fog-start as-is
     so clouds fade out before reaching the horizon rather than
     drifting into the wave band.
     Pure CSS transform animation per cloud, GPU-composited, no JS
     render loop — cheaper than the dot-wave or stars canvas/DOM
     animations already running on this page, not more expensive. */
  .clouds {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    z-index: 0;
    pointer-events: none;
    overflow: hidden;
    opacity: 1;
    transition: opacity 2400ms ease;
    -webkit-mask-image: linear-gradient(to bottom, black 0%, black calc(var(--fog-start, 78%) - 10%), transparent var(--fog-start, 78%));
    mask-image: linear-gradient(to bottom, black 0%, black calc(var(--fog-start, 78%) - 10%), transparent var(--fog-start, 78%));
  }
  @media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) .clouds { opacity: 0; }
  }
  html[data-theme="dark"] .clouds { opacity: 0; }
  /* Carson's report, and a genuinely free fix: .clouds fading to
     opacity:0 in dark mode never actually stopped the 10 individual
     .cloud elements' own animation:...infinite — each kept drifting
     and re-blurring every frame regardless, just invisibly. Same for
     .stars in light mode below. !important needed here specifically
     — .cloud sets the FULL animation shorthand inline (duration,
     timing, iteration-count all in one), which implicitly includes
     animation-play-state:running baked into that same declaration;
     a plain unweighted rule here can't win against that. Matches the
     existing precedent one block down (.cloud { animation: none
     !important; } under reduced-motion) rather than inventing a new
     technique. Zero visual difference in either theme — this only
     stops paying full continuous animation/blur cost for elements
     nobody can currently see. */
  @media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) .cloud { animation-play-state: paused !important; }
  }
  html[data-theme="dark"] .cloud { animation-play-state: paused !important; }
  @media (prefers-reduced-motion: reduce) {
    .clouds { opacity: 1; }
    html[data-theme="dark"] .clouds { opacity: 0; }
    .cloud { animation: none !important; }
  }

  /* Each cloud is a small CLUSTER of overlapping "puff" shapes, not
     one smooth ellipse — Carson's own diagnosis after seeing the
     first version: a single blob reads as a smear, not a cumulus
     cloud, which needs an irregular, lumpy silhouette. .cloud is just
     the cluster's positioning/timing/blur shell (unstyled otherwise);
     .puff is where the actual lit/shadow gradients live, one pair per
     puff, positioned by the .cloud--a/b/c/d template rules further
     down. filter:blur on .cloud (not on each .puff individually)
     blurs the WHOLE cluster as one flattened, cohesive soft shape —
     that's a real CSS behavior, not a shortcut: a filter renders its
     entire subtree to an offscreen buffer first and blurs that
     combined result, so overlapping puffs merge into one soft
     silhouette rather than reading as several separately-blurred
     circles. Also cheaper than blurring each puff on its own (one
     blur operation per cloud, not per puff).
     --cloud-light-x-frac (set in JS, defaulting near the resting
     light's own side) shifts the highlight/shadow layers' focus
     together — one shared approximation of "which way the light is,"
     not true per-puff angle tracking, same reasoning as before.
     .puff's background is two layers, not one: a neutral white/pale
     base (the cloud's own material color, centered, independent of
     light direction) UNDER a warm/cool tint straight from
     rgba(var(--light-rgb)...) (the same variable already warming/
     cooling through the day/night toggle, so cloud warmth still
     follows the toggle for free). Carson's own "too saturated, too in
     your face" feedback on an earlier version, where the tint WAS the
     entire visible color with nothing neutral underneath — real
     clouds read as mostly white/pale gray with only a SUBTLE colored
     cast where the light or shadow falls, not fully tinted throughout
     their whole body. --cloud-lit-alpha's default and per-instance
     values were also lowered accordingly, now that the white base
     carries most of the cloud's actual visibility. --cloud-base-alpha
     (new) makes that white base itself vary by depth tier too, not
     just the tint — Carson's ask for the far tier to genuinely read
     as distant rather than just small: real atmospheric haze doesn't
     only desaturate a far object's own color, it thins out its whole
     presence, so a convincingly distant cloud should look thin and
     ghostly overall, not just a smaller solid one. */
  .cloud {
    position: absolute;
    filter: blur(var(--cloud-blur, 14px));
  }
  .puff {
    position: absolute;
    border-radius: 50%;
    background:
      radial-gradient(ellipse at calc(var(--cloud-light-x-frac, 0.7) * 100%) 30%,
        rgba(var(--light-rgb), var(--cloud-lit-alpha, 0.3)), transparent 58%),
      radial-gradient(ellipse at 48% 42%, rgba(255, 255, 255, var(--cloud-base-alpha, 0.52)), transparent 72%);
  }
  .puff::after {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: 50%;
    /* Fixed cool gray-blue rather than tracking --light-rgb itself —
       true to how shadows actually read outdoors: the lit side takes
       the sun/moon's own color, but a shaded side is mostly lit by
       scattered ambient sky light instead, which stays comparatively
       cool and neutral regardless of the direct light's own color.
       Alpha dropped from an earlier 0.24 to 0.12 — at the higher end
       of --cloud-blur (near clouds especially), each puff's own small
       highlight/shadow pair was reading as a distinct scattered dark
       spot rather than one soft shaded form — Carson's own "popcorn
       with cheese dust" description. Toned down rather than removed,
       since some shading is still what gives the cluster form at all. */
    background: radial-gradient(ellipse at calc(100% - var(--cloud-light-x-frac, 0.7) * 100%) 76%,
      rgba(118, 130, 146, 0.12), transparent 62%);
  }

  /* Four hand-placed puff arrangements — each .puff:nth-child(N) rule
     positions one puff as a percentage of its own .cloud's bounding
     box, so the same template can be reused at different overall
     sizes (see the HTML instances) and still keep its silhouette.
     Deliberately irregular/overlapping placements, not a neat grid —
     that's what reads as organic rather than a repeated stamp. Where
     two puffs' translucent gradients overlap, the combined opacity
     there is naturally higher than either alone — denser "core" areas
     and softer "edge" areas fall out of that layering for free,
     without needing to hand-tune per-puff opacity.
     Sizes scaled up ~35% from an earlier pass (offsets unchanged) —
     more overlap between adjacent puffs was the other half of the
     "popcorn" fix above: at the original, more separated sizing, each
     puff's own highlight/shadow pair stayed visually distinct even
     with the shadow toned down; heavier overlap merges them into
     fewer, larger unified bright/shaded regions instead of many small
     separate bumps. */
  .cloud--a .puff:nth-child(1) { left: 4%; bottom: 0%; width: 68%; height: 84%; }
  .cloud--a .puff:nth-child(2) { left: 42%; bottom: 4%; width: 76%; height: 89%; }
  .cloud--a .puff:nth-child(3) { left: 20%; bottom: 30%; width: 62%; height: 78%; }
  .cloud--a .puff:nth-child(4) { left: 50%; bottom: 46%; width: 46%; height: 59%; }

  .cloud--b .puff:nth-child(1) { left: 0%; bottom: 5%; width: 46%; height: 70%; }
  .cloud--b .puff:nth-child(2) { left: 22%; bottom: 0%; width: 57%; height: 76%; }
  .cloud--b .puff:nth-child(3) { left: 50%; bottom: 8%; width: 51%; height: 68%; }
  .cloud--b .puff:nth-child(4) { left: 68%; bottom: 2%; width: 46%; height: 62%; }
  .cloud--b .puff:nth-child(5) { left: 38%; bottom: 36%; width: 40%; height: 54%; }

  .cloud--c .puff:nth-child(1) { left: 6%; bottom: 0%; width: 73%; height: 86%; }
  .cloud--c .puff:nth-child(2) { left: 40%; bottom: 4%; width: 78%; height: 92%; }
  .cloud--c .puff:nth-child(3) { left: 32%; bottom: 34%; width: 54%; height: 65%; }

  /* Taller/narrower than the other three — a "building" cumulus tower
     rather than a rolling horizontal cluster, stacked upward through
     five progressively smaller puffs. Directly answers Carson's ask
     for taller, not just bigger, silhouettes among the set. */
  .cloud--d .puff:nth-child(1) { left: 18%; bottom: 0%; width: 78%; height: 62%; }
  .cloud--d .puff:nth-child(2) { left: 4%; bottom: 24%; width: 68%; height: 59%; }
  .cloud--d .puff:nth-child(3) { left: 44%; bottom: 28%; width: 65%; height: 57%; }
  .cloud--d .puff:nth-child(4) { left: 24%; bottom: 54%; width: 59%; height: 51%; }
  .cloud--d .puff:nth-child(5) { left: 36%; bottom: 74%; width: 40%; height: 36%; }

  .star {
    position: absolute;
    border-radius: 50%;
    background: #fff;
    animation-name: star-twinkle;
    animation-timing-function: ease-in-out;
    animation-iteration-count: infinite;
  }
  @keyframes star-twinkle {
    0%, 100% { opacity: 0.15; }
    50% { opacity: 0.85; }
  }
  @media (prefers-reduced-motion: reduce) {
    .star { animation: none; }
  }

  /* Drifts left-to-right across a bit more than the full viewport
     width, so a cloud is always fully off-screen at both ends of its
     cycle rather than popping in/out mid-frame. One shared keyframe —
     each cloud's own width/top/blur/opacity/timing (set inline below)
     is what makes four instances of it read as different clouds
     rather than four copies of the same one. */
  @keyframes cloud-drift {
    from { transform: translateX(-32vw); }
    to { transform: translateX(132vw); }
  }

  /* ---------- Shooting star (dark mode only, occasional) ----------
     Lives inside #stars as a plain sibling of the regular .star dots —
     same container, so it automatically inherits the SAME dark-mode
     opacity gating and fog-line mask (fading out before the wave band)
     as the rest of the starfield, no separate rules needed for either.
     A single element does the whole streak: this div is the bright
     head, its ::before is the fading tail extending behind it. Angle
     is a static per-instance rotation (--star-angle, set inline in JS,
     one of two ranges for a downward-left or downward-right diagonal)
     — combined with an ANIMATED translateX in the SAME transform value
     is what makes it travel in a straight diagonal line: rotate first
     establishes the direction, then translateX moves along that
     already-rotated axis, rather than needing separately-animated X
     and Y offsets to hit a diagonal. */
  .shooting-star {
    position: absolute;
    width: 2.5px;
    height: 2.5px;
    border-radius: 50%;
    background: #fff;
    box-shadow: 0 0 5px 1px rgba(255, 255, 255, 0.65);
    opacity: 0;
    animation-name: shooting-star-move;
    animation-timing-function: ease-out;
    animation-fill-mode: forwards;
  }
  .shooting-star::before {
    content: "";
    position: absolute;
    top: 50%;
    right: 100%;
    width: 90px;
    height: 1.5px;
    background: linear-gradient(to left, rgba(255, 255, 255, 0.8), transparent);
    transform: translateY(-50%);
  }
  @keyframes shooting-star-move {
    0% { transform: rotate(var(--star-angle, 35deg)) translateX(0); opacity: 0; }
    8% { opacity: 1; }
    75% { opacity: 0.85; }
    100% { transform: rotate(var(--star-angle, 35deg)) translateX(var(--star-travel, 380px)); opacity: 0; }
  }
  @media (prefers-reduced-motion: reduce) {
    .shooting-star { display: none; }
  }

  /* ---------- Interactive dot wave ----------
     Same element/behavior as the home and request pages: lives as the
     last thing inside #signed-in (see the HTML), full-bleed breakout
     to span the browser's full width regardless of .page-wrap's own
     max-width. */
  /* .wave-band, #bg-wave and their reduced-motion rule moved to
     /dot-wave.css on 4 Sep 2026, with the whole of their comment. This
     page and /admin link that file, so nothing about them changes.

     They moved for index.html and request.html, which had their own
     inline copies of the identical two rules because they do not load
     this file - and which could not be migrated onto dot-wave.js while
     the box it draws into was defined in a stylesheet they never see. */
  #loading, #signed-out { text-align: center; color: var(--text-dim); padding: 120px 0; }
  #signed-out .btn { margin-top: 16px; }

  /* a.btn / button.btn and their :hover moved to /buttons.css on
     4 Sep 2026, with every comment they carried. This page still gets
     them - it links that file - and so does /admin. /request now gets
     them too, which is the point: it was given a .btn in #138 and had no
     rule for it, because this file never reached that page.

     #signed-out .btn above stays here. It is this page's own placement of
     the gate link, not part of the button itself. */
  button.danger { color: var(--down); border-color: var(--down); }

  /* ---------- Page shell ---------- */

  /* Set to the EXACT sum of the sidebar's max width (320px) + gap's max
     width (34px) + the card stack's max width (940px) — zero slack at
     the cap. This matters for real symmetry (see .sidebar / .header-
     actions notes below): any extra slack here becomes leftover space
     that gets distributed unevenly between the two columns, which is
     exactly what caused the sidebar/stack drift reported earlier. */
  .page-wrap { max-width: 1294px; width: 100%; margin: 0 auto; }

  .account-header {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: 16px;
    margin-bottom: 28px;
    /* Reserve space on the right so the title text can never visually
       run underneath the fixed-position header-actions group (see
       below) — generous enough to cover the widest realistic rendering
       (all three buttons with full text labels + gaps) at any desktop
       width, and still harmless on mobile where those buttons collapse
       to small icon-only circles. */
    padding-right: 210px;
  }
  .header-text-wrap { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 1; }
  /* position:relative + z-index:1 is the FIX for Carson's report that
     the greeting and its subtitle looked washed out/hard to read,
     while no text on any card ever did. The cause wasn't the light
     source's glow (that's a body BACKGROUND layer — it paints behind
     everything and can't fade text) but .clouds: an absolutely
     positioned, z-index:0 layer of large, soft, blurred white cloud
     shapes drifting across the top of the page. A POSITIONED element
     with z-index:0 paints above every non-positioned, in-flow
     descendant, and this page's whole header chain (#signed-in >
     .page-wrap > .account-header > .header-text-wrap > here) is
     static all the way down — so clouds were literally drifting OVER
     the header glyphs, veiling them. Cards never showed it because
     each one carries its own position:relative, which already lifts
     them out of the clouds' reach; the free-floating header text was
     the only thing on the page that didn't.
     index.html and request.html never had the bug (Carson's own
     question — no, it isn't latent there) because both wrap their
     entire page content in <main style="position:relative;z-index:1">,
     which clears the same layer for everything inside it. This is
     that exact same one-line remedy, just applied at the tightest
     scope that works on this page rather than copied to a wrapper.
     Scope matters here and is why this sits on .header-title-block
     specifically rather than any ancestor: z-index:1 creates a
     STACKING CONTEXT, and every candidate above this element
     contains position:fixed children whose own z-index would get
     trapped inside it — .header-text-wrap holds .sidebar-trigger
     (z-index 83, which must stay clickable above its own open flyout
     at 82), .account-header also holds .header-actions, and
     .page-wrap/#signed-in hold .sidebar/.sidebar-backdrop (82/81)
     too. Trapping any of those at an effective level of 1 would drop
     them underneath .top-frost-bar (35) and the modals (70) — a real
     regression traded for a cosmetic fix. .header-title-block holds
     nothing but the <h1> and its <p class="sub">, so it can carry a
     stacking context with nothing to lose by it.
     Covers dark mode for free as well: .stars is the same
     absolutely-positioned z-index:0 layer as .clouds (they're a
     light/dark crossfade pair), so it had the identical problem
     waiting behind the theme toggle. */
  .header-title-block { min-width: 0; flex: 1; position: relative; z-index: 1; }
  /* Was forced to a single line with an ellipsis, which silently
     truncated longer greetings (e.g. full names) whenever the fixed
     210px reserved for header-actions left too little room — worst on
     narrow phones. Letting it wrap to a second line means the full
     greeting is always readable; nothing ever gets cut off with a "…". */
  .account-header h1 {
    font-size: 26px; font-weight: 650; letter-spacing: -0.02em; margin: 0 0 4px;
    overflow-wrap: break-word;
  }
  .account-header .sub { color: var(--text-dim); font-size: 13.5px; margin: 0; }

  /* Pinned to the viewport itself (not the page-wrap column), same
     pattern as the home page's own floating buttons — so Back to Home /
     Sign out / the theme toggle always sit in the browser's upper-right
     corner, regardless of window width, rather than scrolling away with
     the header or drifting inward as page-wrap's own width changes. */
  /* ---------- Site-wide announcement banner ----------
     See index.html for the full reasoning — same component, same
     mechanism. This page's set of fixed elements differs (.header-actions
     and the mobile .sidebar-trigger instead of separate Login/theme/
     credits buttons), but both read their top offset from the same
     --banner-h variable this sets. */
  .site-banner {
    /* Pinned to the top of the viewport rather than sitting in normal
       flow. In flow it scrolled away with the page while .top-frost-bar
       — fixed, and pushed down by top: var(--banner-h) to make room for
       it — stayed exactly where it was, so from the first scrolled pixel
       onward that reserved space showed as a transparent strip above the
       nav bar (Carson's screenshot). Pinned, the space the nav bar
       reserves is always filled by the banner that asked for it.
       left/right: 0 replaces the old width: 100vw full-bleed breakout:
       a fixed element already spans the viewport exactly, without
       100vw's habit of counting the scrollbar's width as page width.
       Room for it is still made the same way as before, just from the
       other end — body's padding-top adds --banner-h (see that rule)
       instead of this element's own margins doing it. That is also what
       makes dismissal clean: the JS sets --banner-h back to 0px, so the
       padding collapses and the nav bar sits flush against the top of
       the viewport again, with no leftover gap. */
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    /* Above .top-frost-bar (35) and ordinary page content, below
       .confirm-overlay (70), .header-actions (80) and the services
       flyout (.sidebar-backdrop 81 / .sidebar 82 / .sidebar-trigger 83)
       so an open dialog or flyout still covers the whole screen. */
    z-index: 40;
    /* No longer a flat background here — three tiers (danger/warning/
       announce), each with its own color, set via a class the JS
       applies based on the posted message's tier. Base rule keeps a
       sensible fallback (announce/green) in case that class is ever
       missing for some reason. */
    background: var(--banner-announce-bg);
    color: var(--banner-announce-text);
    transition: background-color 2400ms ease, color 2400ms ease;
  }
  .site-banner.tier-danger { background: var(--banner-danger-bg); color: var(--banner-danger-text); }
  .site-banner.tier-warning { background: var(--banner-warning-bg); color: var(--banner-warning-text); }
  .site-banner.tier-announce { background: var(--banner-announce-bg); color: var(--banner-announce-text); }
  .site-banner[hidden] { display: none; }
  .site-banner-inner {
    max-width: 940px;
    margin: 0 auto;
    padding: 10px 44px 10px 20px;
    display: flex;
    align-items: center;
    justify-content: center;
    position: relative;
    font-size: 13.5px;
    font-weight: 600;
    line-height: 1.5;
    text-align: center;
  }
  .site-banner-text b, .site-banner-text strong { font-weight: 800; }
  .site-banner-text u { text-decoration: underline; }
  .site-banner-text a { color: inherit; text-decoration: underline; }
  .site-banner-close {
    position: absolute;
    right: 12px;
    top: 50%;
    transform: translateY(-50%);
    width: 26px;
    height: 26px;
    border-radius: 50%;
    background: none;
    border: none;
    color: inherit;
    opacity: 0.85;
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    flex: none;
  }
  .site-banner-close:hover { opacity: 1; background: rgba(0, 0, 0, 0.12); }
  .site-banner-close svg { width: 15px; height: 15px; }
  /* Without this, [hidden] wouldn't actually hide the button — its own
     display:flex above has equal specificity to the browser's default
     [hidden]{display:none} and, being an author rule, would silently
     win. Danger/warning tiers rely on this to genuinely remove the
     close button, not just visually fade it. */
  .site-banner-close[hidden] { display: none; }

  .header-actions {
    position: fixed;
    /* + --safe-top / 2, not the full amount — Carson's follow-up
       report: adding the FULL --safe-top here made the gap above
       these icons visibly bigger than the gap below them inside
       .top-frost-bar. The bar's own height grew by the FULL
       --safe-top (so its background still reaches the true top of
       the screen), but that extra space was landing entirely ABOVE
       these icons and not being matched below them — worked through
       the actual numbers: bar spans [0, 74+safeTop], icon top was
       (18+safeTop), giving an 18px gap below but an (18+safeTop) gap
       above. Halving it here instead makes both gaps equal
       (18 + safeTop/2 each) — splits the extra room the notch needs
       evenly instead of piling all of it on one side. */
    top: calc(18px + var(--banner-h, 0px) + (var(--safe-top) / 2));
    right: 18px;
    /* Higher than .confirm-overlay (70) — without this, the theme
       toggle (and notif bell) were unreachable behind that overlay's
       dark tint whenever a confirm or share dialog was open. Same bug
       class the credits panel needed fixing for on the home page. */
    z-index: 80;
    display: flex;
    align-items: center;
    gap: 10px;
  }
  /* .btn-icon moved to /buttons.css, scoped there as
     .header-actions .btn-icon. It was bare here, which was safe only
     because every .btn-icon on this page and /admin sits in the row. */

  /* ---------- Notification bell ----------
     MOVED. Every .notif-* rule that used to live here now lives in
     /notifications.css, linked from this file's head, so index.html and
     request.html can show the same bell without a second and third copy of
     these rules drifting out of sync. The rules themselves were moved
     unchanged, comments and all — nothing was restyled as part of the move.

     Two rules elsewhere in THIS file still reference .notif-bell-btn and
     are deliberately left where they are, because they are this page's own
     concerns rather than the bell's: the mobile touch-target sizing it
     shares with .theme-toggle, and the cloud-pass glint suppression.

     Original header comment, kept because it documents a real product
     decision rather than a style:

       Real, functional feature for claldred.com's own native notification
       types — Feedback & Support replies, admin unban-request activity,
       and site-wide announcements. Per-SERVICE notifications
       (Nextcloud/Immich/etc. funneled in from each enrolled app) were the
       original stretch goal here — deliberately dropped, Carson's call:
       each service already has its own notification system, and funneling
       that into claldred.com too would just duplicate it rather than add
       anything.

     What HAS changed since that note: fetchNotifications() no longer
     returns a hard-coded empty array. There is a real backend now
     (functions/api/account/notifications.js), so the three native types
     are stored server-side and the bell renders the same list on all
     three pages. */

  /* ---------- Scroll-linked top frosted-glass bar ----------
     Same element/behavior as the home and request pages: a full-width
     bar behind the whole floating header-actions cluster (and the
     mobile sidebar-trigger, on the opposite corner), diffusing
     whatever scrolls underneath rather than leaving it sharp. Hidden
     at scroll position 0, fades in past a small threshold — see the
     matching JS below. */
  .top-frost-bar {
    position: fixed;
    top: var(--banner-h, 0px);
    left: 0;
    width: 100%;
    /* 74px + --safe-top — same fix as request.html/index.html's own
       copies of this comment: the bar's own blur/background stopped
       short of the true top of the screen, so Safari's own translucent
       status-bar/toolbar chrome (which shows live page content through
       it) revealed raw, unblurred content peeking through as things
       scrolled underneath instead of staying covered. top itself is
       untouched (still var(--banner-h, 0px), the true viewport top) —
       only height grows, so the bar's own background now reaches all
       the way up into that region too. */
    height: calc(74px + var(--safe-top) + var(--os-glass-bleed));
    /* Raised from 2 — with nothing but ordinary z-index:auto content
       between here and <body>, this SHOULD have been enough on paper,
       but real content (the card stack, its will-change/transform
       animation, etc.) was ending up above it in practice, which
       explained two symptoms as one bug: the bar looking like it
       activated far too late (it may have been correctly turning on,
       just hidden under opaque content on top of it) and cards later
       visibly overlapping it outright. 35 sits clearly above ordinary
       page content while staying below every floating button (the
       lowest of which, .sidebar-trigger, is 40) — those still need to
       render crisp on top of the blur, never blurred themselves. */
    z-index: 35;
    pointer-events: none;
    /* Genuinely everything else (all the previous rounds of fixes —
       viewport-fit=cover, --safe-top, this bar's own height) never
       actually touched Carson's original report, because it was never
       the real cause. Safari 26's "Liquid Glass" redesign scans
       position:fixed/sticky elements near the viewport edges and
       reads background-color/backdrop-filter DIRECTLY OFF THAT
       ELEMENT to compute its own status-bar/toolbar tint — completely
       undocumented by Apple, confirmed via outside research once
       "no change at all" ruled out everything already tried. This
       element WAS exactly that broken shape: a fixed element with
       both those properties on itself. Safari was tinting its own
       status bar FROM this bar's own styles instead of actually
       letting the frosted effect extend up through it — which is a
       different failure than what it looks like from a screenshot,
       and explains why nothing about height/position ever mattered.
       The fix: this element now holds NO background/backdrop-filter/
       box-shadow of its own at all — purely structural (position,
       size, z-index) — with all of that moved onto the new
       .top-frost-bar-glass child instead. Safari's own tinting scan
       explicitly ignores position:absolute children, so it no longer
       has anything here to read, and falls through to actually
       compositing real page content behind its own chrome — which is
       what was wanted the whole time. */
    background: none;
    /* Carson's report: two distinct frosted phases appeared, one after
       the other, before the bar finally settled — on both iPhone and
       Mac. The cause was this element's own opacity fade, not anything
       about the blur itself. An ancestor with opacity < 1 is a
       BACKDROP ROOT, so for the whole time this parent sat mid-fade,
       .top-frost-bar-glass's backdrop-filter had a different backdrop
       to sample than it has at opacity:1 — and WebKit resolves that
       root change on its own schedule rather than per-frame, so the
       tint and the blur landed on separate frames. That is exactly
       what reads as the nav loading in stages rather than one pane
       fading up.
       So there is no opacity on this element AT ALL in tab mode any
       more: it stays fully opaque and purely structural, and the fade
       moved onto .top-frost-bar-glass itself (and onto ::after below).
       opacity and backdrop-filter on the SAME element is the one
       arrangement that's well-defined — the backdrop is filtered
       first, then the whole result composites at that opacity, a true
       cross-fade between the sharp and the blurred backdrop with no
       root change to resolve mid-flight.
       The standalone/PWA branch further down still carries opacity
       here, and correctly so: in that mode this element IS the blur
       carrier (the child isn't rendered at all), so the two
       properties already sit together there and that path never had
       this bug to begin with. */
    opacity: 1;
  }
  .top-frost-bar-glass {
    position: absolute;
    inset: 0;
    background: rgba(var(--bg-rgb), 0.4);
    backdrop-filter: blur(14px) saturate(1.4);
    -webkit-backdrop-filter: blur(14px) saturate(1.4);
    /* Light-respecting shadow, small-button scale (7px reach) despite
       this being a full-width bar — Carson's ask: the bar itself
       should visibly separate from the content scrolling underneath
       it, the same "floating above the page" cue every other glass
       surface here already has. --frost-bar-shadow-x/-y set in
       setFrostBarShadow() (see the JS near the scroll-trigger IIFE
       below) directly on #top-frost-bar (the parent) — inherits down
       to this child via ordinary CSS custom-property inheritance, so
       moving the box-shadow itself onto this child didn't need any
       JS change alongside it. Already fades in/out for free with the
       parent's own opacity transition above — no separate visibility
       gating needed here either, since box-shadow disappears along
       with everything else at opacity:0. */
    box-shadow: var(--frost-bar-shadow-x, 0px) var(--frost-bar-shadow-y, 7px) 22px rgba(0, 0, 0, 0.20);
    /* 0.3s -> 2400ms — Carson's site-wide theme-transition audit
       caught this: this element's own background-color IS the
       theme-dependent frost fill (rgba(var(--bg-rgb), 0.4) above), so
       it needs the same 2400ms every other theme-dependent fill uses
       (see .theme-toggle's own reference timing), not the 0.3s
       reserved specifically for TEXT color elsewhere on this site.
       Own mistake from when this child was first split out for the
       Safari fix — never re-checked against the theme-timing
       standard at the time. */
    /* Scroll-linked, not a threshold-triggered fade — Carson's ask:
       clear at the top of the page, ramping continuously to fully
       frosted as the page scrolls down. --frost-p (0..1) is written
       on <html> by the scroll handler (see the frost IIFE further
       down) and driving opacity straight off it means the glass
       tracks the finger instead of "triggering" — there is no
       threshold moment left for anything to step at, which is the
       other half of what Carson was seeing.
       120ms is a smoothing tail, not the animation itself: iOS
       delivers scroll events in bursts during momentum scrolling, and
       without it those bursts show up as small jumps in what should
       be a continuous ramp.
       will-change keeps this layer promoted — and its backdrop buffer
       allocated — from load, so the first non-zero frame doesn't also
       have to pay for layer creation. That's a separate cost from the
       backdrop-root problem documented on the parent above, and it
       showed up the same way: as the effect arriving in pieces. */
    opacity: var(--frost-p, 0);
    will-change: opacity;
    transition: opacity 120ms linear, background-color 2400ms ease;
  }
  /* Carson's report: installed-as-PWA specifically (not a regular
     Safari tab) came back with a permanently blurred gradient sitting
     right where the nav buttons live, making them hard to read even
     though they stayed clickable. Standalone/PWA mode on iOS is a
     genuinely different rendering path from a tab — there's no
     Safari toolbar chrome for the Liquid Glass tinting-scan (the
     whole reason .top-frost-bar-glass exists as a separate child, see
     .top-frost-bar's own comment) to apply to at all in that mode.
     The split was solving a tab-mode-only problem; applying it
     unconditionally carried it into standalone mode too, where it
     wasn't needed and apparently caused its own new visual bug
     instead. This reverts to the ORIGINAL, pre-split approach —
     background/blur back directly on the fixed parent, exactly as it
     was before any of this — but ONLY inside this standalone-mode
     query, so tab-mode keeps the newer fix that Carson confirmed
     actually resolved his original report there. */
  @media (display-mode: standalone) {
    .top-frost-bar {
      background: rgba(var(--bg-rgb), 0.4);
      backdrop-filter: blur(14px) saturate(1.4);
      -webkit-backdrop-filter: blur(14px) saturate(1.4);
      box-shadow: var(--frost-bar-shadow-x, 0px) var(--frost-bar-shadow-y, 7px) 22px rgba(0, 0, 0, 0.20);
      /* opacity stays 0.3s (matches the scroll-triggered fade-in
         timing this whole element already uses) — background-color
         corrected to 2400ms, same theme-transition audit fix as
         .top-frost-bar-glass's own copy of this comment just above:
         this property IS the theme-dependent frost fill here, not a
         text color, so it needs the standard fill timing, not the
         0.3s this was copied from opacity's own value without
         re-checking against that standard. */
      /* Same scroll-linked driver as tab mode's .top-frost-bar-glass;
         the only difference is which element carries it, since here
         the blur is back on the fixed parent and the glass child
         isn't rendered at all. opacity and backdrop-filter were
         already on the same element in this mode, so it never had the
         backdrop-root staging the split path had — it just needed the
         same continuous ramp instead of a 0.3s threshold fade. */
      opacity: var(--frost-p, 0);
      will-change: opacity;
      transition: opacity 120ms linear, background-color 2400ms ease;
    }
    /* The glint is a child of the parent here, so it already fades
       with it — it must NOT multiply --frost-p in a second time on
       its own, which would square the ramp (0.5 * 0.5 = 0.25) and
       leave the edge trailing the pane it's supposed to be the edge
       of. The bare `html` prefix is load-bearing, not decoration:
       .top-frost-bar::after's own base rule is written BELOW this
       media query, so at equal specificity source order would hand it
       the win and this override would silently do nothing. The extra
       element selector settles it by specificity instead, which
       doesn't depend on where either rule sits in the file. */
    html .top-frost-bar::after {
      opacity: 1;
    }
    .top-frost-bar-glass {
      display: none;
    }
  }
  .top-frost-bar::after {
    content: "";
    position: absolute;
    left: 0;
    right: 0;
    bottom: 0;
    height: 2px;
    background: linear-gradient(to left,
      rgba(var(--light-rgb), 0.7) 0%,
      rgba(var(--light-rgb), 0.18) 40%,
      transparent 75%);
    pointer-events: none;
    /* Fades on the same driver as the glass it edges. This used to
       come for free from the parent's own opacity fade, which no
       longer exists in tab mode (see .top-frost-bar above) — same
       value, same 120ms tail, so the edge and the pane are always at
       identical strength rather than one arriving ahead of the
       other. */
    opacity: var(--frost-p, 0);
    transition: opacity 120ms linear;
  }
  @media (prefers-reduced-motion: reduce) {
    /* Only the smoothing tails go. The frost itself still tracks
       scroll position, which is direct manipulation rather than an
       animation playing on its own — exactly the kind of thing this
       setting is meant to leave alone. */
    .top-frost-bar, .top-frost-bar-glass, .top-frost-bar::after { transition: none; }
  }

  /* Flush with the glass — Carson's ask, and a correction from an
     inset shadow tried first: that read as the buttons sinking INTO
     the surface rather than sitting flush with it, so what's actually
     wanted is no shadow at all once the glass has arrived.
     This used to be a flat `box-shadow: none` under
     html.frost-scrolled, which meant every one of these buttons
     dropped its shadow in a single instant, at the 8px threshold,
     while the bar behind them was still running its own separate
     fade — two motions landing a beat apart, and a real part of what
     Carson saw as the nav appearing in phases. An earlier pass tried
     to paper over that by giving each button a 100ms box-shadow
     transition; that narrowed the gap without closing it, because the
     two were still independent animations of different durations
     started by the same instant.
     Now the shadow's ALPHA is interpolated off --frost-p directly, in
     each button's own box-shadow declaration below — the exact same
     number the glass itself fades on. There's nothing left here to
     toggle: at p=0 the shadow is at its full 0.20, at p=1 it's gone,
     and every value between is whatever the scroll position says. One
     driver, so the buttons and the bar cannot get out of step with
     each other.
     html.frost-scrolled itself still exists, but now only for the
     :active press treatments just below — a momentary inset shadow
     while a button is actually being pressed, which genuinely does
     need a yes/no answer about whether it's sitting on glass. */
  /* .header-actions .btn's own --frost-p-riding shadow moved to
     /buttons.css. */

  /* Press-in — see index.html's own copy of this comment for the full
     reasoning: an inset shadow only makes physical sense once flush
     against the glass (a real surrounding surface to sink into),
     which is why this is scoped to html.frost-scrolled specifically,
     not the raised default state too. See the filter:brightness rule
     further down for what the raised state's own press feedback is
     instead. Scoped to .theme-toggle alone on this page specifically
     — account.html has neither a .login-btn (there's nothing to log
     into once already signed in) nor a .credits-btn (no credits panel
     here), so those two simply don't exist for this treatment to
     apply to; .notif-bell-btn and .sidebar-trigger share the same
     frost-scrolled shadow-removal rule just above but weren't part of
     Carson's own ask, so they're deliberately left as they were. */
  html.frost-scrolled .theme-toggle:active {
    box-shadow: inset var(--btn-tr-shadow-x, 0px) calc(var(--btn-tr-shadow-y, 7px) * 0.5) 6px rgba(0, 0, 0, 0.3);
  }
  /* Press feedback for the RAISED default state — see index.html's own
     copy of this comment for the full reasoning (filter:brightness
     over opacity, and why this applies in BOTH states, layering on
     top of the frosted state's own inset shadow above rather than
     being replaced by it). */
  .theme-toggle:active {
    filter: brightness(0.85);
  }
  /* Carson's own follow-up: while flush against the frosted glass and
     actively pressed, the glint should vanish entirely — see
     index.html's own copy of this comment for the full reasoning.
     Only .theme-toggle on this page, same reason as the press-in rule
     just above: no .login-btn or .credits-btn exist here. */
  html.frost-scrolled .theme-toggle:active::before {
    opacity: 0;
  }
  @media (prefers-reduced-motion: reduce) {
    /* box-shadow/filter (the press feedback) are the only transitions
       left on this element post-split — background-color/border-color
       no longer live here at all. The fill layer's own 2400ms theme
       fade is untouched by this selector entirely, exactly as
       intended: disabling it under reduced motion would reintroduce
       the instant-snap bug this whole site-wide pass just fixed. */
    .theme-toggle {
      transition: none;
    }
    .theme-toggle::before {
      transition: none;
    }
  }

  .sidebar-trigger {
    display: none;
    width: 38px; height: 38px; flex: none;
    border-radius: 50%;
    background: var(--surface);
    border: 1px solid var(--border);
    color: var(--text);
    align-items: center; justify-content: center;
    cursor: pointer;
    /* Light-respecting shadow, same small-button scale as .theme-
       toggle/.notif-bell-btn — --btn-tl-shadow-x/-y set in
       setBtnGlintAngle(), own dedicated top-left reference point
       (this sits at the opposite corner from that cluster). */
    box-shadow: var(--btn-tl-shadow-x, 0px) var(--btn-tl-shadow-y, 7px) 22px rgba(0, 0, 0, calc(0.20 * (1 - var(--frost-p, 0))));
    /* Carson's report: the top-frost-bar's own appearance reads as a
       "stepping" animation rather than one smooth motion — this
       element genuinely had no transition of any kind before, so
       html.frost-scrolled's box-shadow:none rule (further down) was
       snapping this shadow away instantly while the bar itself was
       still mid-fade. Explains why the already-existing
       prefers-reduced-motion override for this element (further down)
       was a no-op until now — nothing was actually being disabled.
       Matches .theme-toggle/.notif-bell-btn's own 100ms.

       Superseded but kept for the history: that box-shadow:none rule
       no longer exists — this shadow's own alpha rides --frost-p
       continuously instead (see the "Flush with the glass" comment
       further up), so there's no snap left for this transition to
       smooth over. It stays on as a short tail on that continuous
       value, and for the :active press. */
    transition: box-shadow 100ms ease;
    /* Carson's report: an "indentation shadow" flashing on this button
       specifically the moment it's tapped to CLOSE the flyout — not
       anything in this file's own CSS (checked: this element has no
       :active rule of its own, and isn't part of the filter:brightness
       press-in system built for .theme-toggle/.login-btn/.credits-btn
       elsewhere). That points at Mobile Safari's own DEFAULT tap-
       highlight overlay instead — a built-in browser behavior, not
       something authored on this page, and the site has no existing
       global reset for it anywhere. It would read as more noticeable
       here specifically than on other buttons: while the flyout is
       open, .sidebar-trigger.open strips this button's own background
       down to nothing (see that rule's own comment), so there's no
       longer a solid circle for the browser's own highlight overlay to
       blend into — it was always there, just newly visible against
       the panel's own background instead of hidden inside this
       button's own fill. Carson's own framing: no reason to keep a
       "pressed" cue on a circle that no longer visually exists once
       the flyout's open. */
    -webkit-tap-highlight-color: transparent;
  }
  .sidebar-trigger svg { width: 18px; height: 18px; }

  /* ---------- Signed-in user chip ----------
     Written for THIS page, not shared with index.html/request.html, and
     that is deliberate. The chip there is its own fixed element at
     right:66px, positioned against .theme-toggle and carrying the PWA
     safe-area rule with it. Here it is one flex child inside the fixed
     .header-actions cluster, so it needs no position of its own and must
     not carry one. Same split .theme-toggle already has: one class name,
     two pages, deliberately different rules.

     What IS copied, on purpose, is the glass treatment -- background:none
     plus backdrop-filter here, visible fill and border on the -fill child,
     matching .theme-toggle immediately below so the two read as the same
     material sitting side by side. */
  .user-chip-wrap {
    /* The dropdown's containing block. Nothing else -- flex handles where
       the chip sits. */
    position: relative;
    flex: none;
  }
  .user-chip-wrap[hidden] { display: none; }

  .user-chip {
    position: relative;
    display: inline-flex;
    align-items: center;
    gap: 8px;
    height: 38px;
    padding: 0 10px;
    border-radius: 999px;
    background: none;
    backdrop-filter: blur(16px) saturate(1.5);
    -webkit-backdrop-filter: blur(16px) saturate(1.5);
    border: 1px solid transparent;
    color: var(--text);
    font-family: inherit;
    font-size: 13.5px;
    font-weight: 600;
    cursor: pointer;
    /* --own-shadow-x/-y, not --btn-tr-shadow-x/-y: setBtnGlintAngle() sets
       the former per-element by live measurement for the buttons inside
       .header-actions, because their x-position shifts with their
       siblings' widths. #user-chip-btn was added to that measurement in
       the same change as this rule. The alpha rides --frost-p exactly as
       .header-actions .btn does, so the chip fades with the frost bar
       rather than against it.

       Read the bare --shadow-x/-y until 4 Sep 2026. Same value either way
       on this page - the publisher wrote both names - but the prefixed
       one says what it means: measured onto THIS element, not inherited
       from whatever an ancestor happened to call the same thing. */
    box-shadow: var(--own-shadow-x, 0px) var(--own-shadow-y, 7px) 22px rgba(0, 0, 0, calc(0.20 * (1 - var(--frost-p, 0))));
    transition: box-shadow 100ms ease, filter 100ms ease;
  }
  .user-chip-fill {
    position: absolute;
    inset: 0;
    /* Negative z-index for the same reason .theme-toggle-fill documents
       just below: an absolutely positioned child paints above non-
       positioned siblings whatever the DOM order, so without this the
       fill covers the avatar and the name. */
    z-index: -1;
    pointer-events: none;
    border-radius: inherit;
    background: rgba(var(--surface-rgb), 0.6);
    border: 1px solid var(--border);
    transition: background-color 2400ms ease, border-color 2400ms ease;
  }
  .user-chip:hover .user-chip-fill { background: rgba(var(--accent-rgb), 0.35); }
  .user-chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
  .user-chip .avatar {
    width: 26px;
    height: 26px;
    flex: none;
    border-radius: 50%;
    overflow: hidden;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    background: var(--accent-soft);
    color: var(--accent);
    font-size: 11px;
    font-weight: 700;
    letter-spacing: 0.02em;
  }
  .user-chip .name {
    /* A long display name would push the chip across the header and shove
       the bell and theme toggle off the left edge of the fixed cluster. */
    max-width: 150px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .user-chip .chevron {
    width: 14px;
    height: 14px;
    flex: none;
    color: var(--text-dim);
    transition: transform 160ms ease;
  }
  .user-chip[aria-expanded="true"] .chevron { transform: rotate(180deg); }

  /* ---------- Glint parity for the rest of the header cluster ----------
     Carson's report: the bell and the theme toggle carry a glint border
     on this page; the Home button and the account chip do not. Both were
     genuinely missing it, for two different reasons.

     The chip is the clearer case. It already copies .theme-toggle's whole
     glass treatment deliberately (see this block's own header comment
     above) and matches on background, blur, transparent border, the -fill
     child and the shadow -- everything except this one layer.
     index.html's own chip HAS this rule. An oversight, not a decision.

     The Home button's half of this rule moved to /buttons.css on 4 Sep
     2026. It is a different material - a solid --surface pill with a
     visible --border, so its ring sits inboard of that border rather
     than replacing it - and the reasoning for that, including why it is
     deliberately not a glass conversion, went with it. The declarations
     below are unchanged; only the selector lost a line.

     --own-glint-angle, not the shared --btn-tr-glint-angle its
     neighbours use. That one is computed once for a fixed point at
     (innerWidth - 18, 18), which is right for the bell and the toggle
     and wrong for anything sitting further left in the flex row.
     Measured at a 560px viewport: corner 176.1deg, chip 189.1deg, Home
     button 239.7deg. setBtnGlintAngle() already measures the chip
     individually for --own-shadow-x/-y; the angle falls out of the same
     measurement.

     THIS READ THE BARE --glint-angle UNTIL 4 SEP 2026, under a page-local
     convention that an unprefixed custom property meant "measured per
     element". That was fine while this file was the only reader. It is
     not a convention a shared stylesheet can honour: index.html and
     request.html set --glint-angle on :root for the credits panel, custom
     properties inherit, and a shared rule reading the bare name picks up
     their value - which is #130, where the bell rendered at the credits
     panel's 260.4deg. notifications.css moved to --own-* in #131 and
     buttons.css was written that way in #139; the chip and the toggle are
     the last two, so the convention is retired rather than half-kept.
     setBtnGlintAngle() published both names from #131 precisely so this
     could happen, and now publishes one.

     The 225deg fallback is what /admin gets -- it links this file but
     carries none of the lighting subsystem, the same "reasonable
     default, not a broken one" that --own-shadow-x/-y already fall back
     to there. */
  .user-chip::before {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: inherit;
    padding: 1px;
    /* Sparkle: tight, bright hot-spot layer + background-blend-
       mode:screen on top of the original glint below — same
       technique as index.html's own copy of this. */
    background:
      linear-gradient(var(--own-glint-angle, 225deg),
        rgba(255, 255, 255, 1) 0%,
        rgba(255, 255, 255, 0.85) 1.5%,
        rgba(255, 255, 255, 0.3) 4%,
        transparent 9%),
      linear-gradient(var(--own-glint-angle, 225deg),
      rgba(255, 255, 255, 0.9) 0%,
      rgba(var(--light-rgb), 0.45) 22%,
      transparent 48%);
    background-blend-mode: screen, normal;
    -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    -webkit-mask-composite: xor;
    mask-composite: exclude;
    pointer-events: none;
  }
  /* .header-actions .btn { position: relative } moved to /buttons.css
     with the ring it exists to anchor. .btn-cancel and the other
     Account-card pills still set it here, for the same reason. */

  .user-dropdown {
    position: absolute;
    top: calc(100% + 8px);
    right: 0;
    min-width: 190px;
    padding: 6px;
    border-radius: 12px;
    background: var(--surface);
    border: 1px solid var(--border);
    box-shadow: 0 10px 30px rgba(0, 0, 0, 0.18);
    display: flex;
    flex-direction: column;
    opacity: 0;
    transform: translateY(-6px) scale(0.98);
    transform-origin: top right;
    pointer-events: none;
    transition: opacity 140ms ease, transform 140ms ease;
    /* Above .confirm-overlay, matching .header-actions' own z-index note
       -- otherwise the menu opens behind a dialog's tint. */
    z-index: 81;
  }
  .user-dropdown.open {
    opacity: 1;
    transform: translateY(0) scale(1);
    pointer-events: auto;
  }
  .user-dropdown a,
  .user-dropdown button {
    display: flex;
    align-items: center;
    gap: 9px;
    padding: 8px 10px;
    border: none;
    border-radius: 8px;
    background: none;
    color: var(--text);
    font-family: inherit;
    font-size: 13.5px;
    font-weight: 600;
    text-align: left;
    text-decoration: none;
    cursor: pointer;
  }
  .user-dropdown a:hover,
  .user-dropdown button:hover { background: var(--accent-soft); }
  .user-dropdown svg { width: 15px; height: 15px; flex: none; color: var(--text-dim); }
  .user-dropdown .divider { height: 1px; background: var(--border); margin: 5px 4px; }
  .user-dropdown .danger-item { color: var(--down); }
  .user-dropdown .danger-item svg { color: var(--down); }

  .theme-toggle {
    width: 38px; height: 38px; flex: none;
    border-radius: 50%;
    position: relative;
    /* background: none / border: transparent — Carson's site-wide ask
       after confirming the blur/color split looks identical to the
       combined approach, applied here even to the reference element
       itself for the original theme-transition audit. Visible fill/
       border moved to the new .theme-toggle-fill child below; box-
       shadow/filter stay here since neither is part of the 2400ms
       color transition, just the fast 100ms press feedback. border
       stays 1px transparent (border-box) to preserve this element's
       exact dimensions. */
    background: none;
    backdrop-filter: blur(16px) saturate(1.5);
    -webkit-backdrop-filter: blur(16px) saturate(1.5);
    border: 1px solid transparent;
    color: var(--text);
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    /* Own measured shadow, falling back to the shared top-right pair.
       setBtnGlintAngle() sets --own-shadow-x/-y on this element from its
       real centre; --btn-tr-shadow-x/-y is computed for the fixed point
       (innerWidth - 18, 18), which nothing on the page is actually at.
       The fallback is what keeps a page that never measures this button
       - /admin, which has no light position at all - rendering exactly
       as it did before. Same chain, same names, as .notif-bell-btn in
       notifications.css and .header-actions .btn in buttons.css, which
       both moved onto --own-* ahead of this one. */
    box-shadow: var(--own-shadow-x, var(--btn-tr-shadow-x, 0px)) var(--own-shadow-y, var(--btn-tr-shadow-y, 7px)) 22px rgba(0, 0, 0, calc(0.20 * (1 - var(--frost-p, 0))));
    /* background-color/border-color moved to .theme-toggle-fill along
       with the properties they animate — box-shadow/filter stay at
       their own 100ms press-feedback timing, unrelated to the color
       fade. */
    transition: box-shadow 100ms ease, filter 100ms ease;
  }
  .theme-toggle-fill {
    position: absolute;
    inset: 0;
    /* Negative, not left at auto/0 — a position:absolute element with
       default z-index still paints ABOVE normal-flow, non-positioned
       siblings (the icon svg) regardless of DOM order — see
       .stat-widget-fill's own copy of this same comment for the full
       reasoning. */
    z-index: -1;
    pointer-events: none;
    border-radius: inherit;
    background: rgba(var(--surface-rgb), 0.6);
    border: 1px solid var(--border);
    transition: background-color 2400ms ease, border-color 2400ms ease;
  }
  .theme-toggle::before {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: inherit;
    padding: 1px;
    /* Sparkle: tight, bright hot-spot layer + background-blend-
       mode:screen on top of the original glint below — same
       technique as index.html's own copy of this.

       --own-glint-angle first, the shared corner value only as a
       fallback. This element is not at that corner and never was: the
       shared value is the constant 176.1deg at every viewport, while this
       button's own angle is 241.2deg at a 949px one. See the loop in
       account.html's corner-glint script for the measurements and for
       what the mismatch looked like in the row. */
    background:
      linear-gradient(var(--own-glint-angle, var(--btn-tr-glint-angle, 180deg)),
        rgba(255, 255, 255, 1) 0%,
        rgba(255, 255, 255, 0.85) 1.5%,
        rgba(255, 255, 255, 0.3) 4%,
        transparent 9%),
      linear-gradient(var(--own-glint-angle, var(--btn-tr-glint-angle, 180deg)),
      rgba(255, 255, 255, 0.9) 0%,
      rgba(var(--light-rgb), 0.45) 22%,
      transparent 48%);
    background-blend-mode: screen, normal;
    -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    -webkit-mask-composite: xor;
    mask-composite: exclude;
    pointer-events: none;
    /* opacity transition added for the frosted-press rule further
       down — see index.html's own copy of this comment. */
    transition: opacity 100ms ease;
  }
  .theme-toggle svg { width: 17px; height: 17px; }
  .theme-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
  /* Icon fade, brought back after being removed for the mid-transition-
     timed swap — Carson's own ask: even with the swap correctly timed
     to the transition's own low-contrast window, a hard instant swap
     leaves no forgiveness if that timing is ever slightly off. A brief
     fade around the swap (see paintIcon's own animate=true path)
     softens exactly that risk, at the cost of nothing when the timing
     IS right — the fade's simply imperceptible against an already-
     near-invisible icon in either case. */
  #theme-icon { transition: opacity 150ms ease; }
  @media (prefers-reduced-motion: reduce) {
    #theme-icon { transition: none; }
  }

  .account-shell {
    display: flex;
    /* % of the flex container's own (already max-width-capped) rendered
       width — deliberately NOT vw. vw is relative to the raw viewport,
       which knows nothing about page-wrap's own 1294px cap: past that
       cap, vw-based sizing kept growing while the container itself had
       already stopped growing, silently creating leftover space that
       only showed up in a specific wide-desktop range. % ties this
       directly to the container's actual width, so it always tracks
       correctly whether or not that container has hit its cap. */
    gap: clamp(20px, 3%, 34px);
    align-items: flex-start;
  }

  /* ---------- Sidebar: enrolled services + live status ---------- */

  .sidebar {
    /* See .account-shell's gap comment above — same fix, same reasoning:
       % of the (capped) flex container, not vw of the raw viewport. */
    width: clamp(220px, 26%, 320px);
    flex: none;
    position: sticky;
    top: 24px;
    /* Matches .main-content's own full height — the stats widgets, nav
       dots, AND the settings card stack together, top to bottom, since
       that's the actual span .sidebar's top-aligned against (both start
       at the same y via .account-shell's align-items:flex-start).
       --sidebar-max-h is set by JS to that real measured height (see
       that script, near the glint-angle one).
       Explicit height, not max-height: max-height is only an upper
       bound — a flex column with less content than that bound simply
       renders shorter (shrink-to-fit), it doesn't stretch to meet it.
       With enough services enrolled the sidebar's own content usually
       exceeds this anyway, capping it at the same visual result, but
       "usually" left an opening for a still-visible mismatch depending
       on exactly how much content each column had — Carson's repeated
       screenshots confirmed that gap in practice. A definite height
       removes the ambiguity entirely: the sidebar is now always
       exactly this tall regardless of its own content, with
       .sidebar-footer's margin-top:auto still pinning Open Home Portal
       to the true bottom either way (extra room above it if the list
       is short, internal scroll on .sidebar-list if it's long). The
       clamp() here is now just the fallback for the brief window
       before the JS runs, not the real mechanism. */
    height: var(--sidebar-max-h, clamp(480px, 62vh, 820px));
    /* flip easter egg — .sidebar is now just a plain positioning shell
       (sticky here, a fixed slide-out drawer on mobile). All the
       actual glass styling (background, blur, border, the glint) lives
       on .sidebar-flip-card below, which is also the thing that
       rotates — .sidebar itself never gets a 3D transform, so none of
       its existing positioning logic needs to change. perspective
       here is what makes the child's rotation read as real depth
       (foreshortening as it turns) rather than a flat cutout spinning
       in place. */
    perspective: 1400px;
  }
  /* flip easter egg — double-click anywhere on the pane's own
     background (not a link/row, which would just navigate as normal
     on the first click) to flip the WHOLE pane — background, border,
     blur, glint, and content together, not just the content — around
     and see the back, mirrored, text and all. Deliberately does NOT
     set backface-visibility:hidden — leaving the back visible by
     default is exactly what makes the reversed/mirrored look happen
     for free, no extra transform trickery needed. preserve-3d here is
     required for .sidebar-edge-left/-right (the "thickness" strips)
     and .sidebar-flip-inner to rotate together with this element as
     one rigid body instead of flattening onto it. */
  .sidebar-flip-card {
    position: relative;
    width: 100%;
    height: 100%;
    /* Carson's ask, ported from request.html's .glass-card — same
       reasoning as .getting-started-flip-pane on index.html: this is
       always visible, never stacked with anything, not a flyout over
       unpredictable content. .sidebar-services-glass (the actual
       service-row list inside it) already has its own separate,
       denser blur(16px)/0.68, same "frosted inner content" pattern as
       .option/.credits-item elsewhere — untouched by this. */
    backdrop-filter: blur(2px) saturate(1.5);
    -webkit-backdrop-filter: blur(2px) saturate(1.5);
    border: 1px solid transparent;
    border-radius: var(--radius);
    padding: 18px;
    /* Light-respecting shadow, same technique as the other pages. One
       shared --sidebar-shadow-x/-y (computed in recompute() from this
       element's own live position, reusing the same reference point
       --sidebar-glint-angle already uses) covers both this desktop
       recipe AND the bigger mobile override below — same direction
       either way, just a bigger blur/alpha on mobile to match how
       much more this element visually dominates the screen there. */
    box-shadow: var(--sidebar-shadow-x, 0px) var(--sidebar-shadow-y, 7px) 22px rgba(0, 0, 0, 0.20);
    transform-style: preserve-3d;
    transform-origin: 50% 50%;
    /* transform/filter only now — background-color/border-color moved
       to .sidebar-flip-card-fill, Carson's site-wide ask after
       confirming the blur/color split looks identical to this
       combined approach. background: none and border: transparent
       (border-box, so a transparent 1px border here preserves this
       element's exact dimensions) — backdrop-filter/box-shadow stay,
       since neither is part of the 2400ms color transition, only the
       fill color and border color underneath are. */
    transition: transform 0.9s cubic-bezier(.65,.05,.36,1), filter 0.9s ease;
  }
  .sidebar-flip-card-fill {
    position: absolute;
    inset: 0;
    /* Negative, not left at auto/0 — a position:absolute element with
       default z-index still paints ABOVE normal-flow, non-positioned
       siblings (.sidebar-flip-inner etc.) regardless of DOM order —
       see .stat-widget-fill's own copy of this same comment for the
       full reasoning. */
    z-index: -1;
    pointer-events: none;
    border-radius: inherit;
    /* Same final value request.html's .glass-card-fill landed on
       after real testing. */
    background: rgba(var(--surface-rgb), 0.18);
    border: 1px solid var(--border);
    transition: background-color 2400ms ease, border-color 2400ms ease;
  }
  .sidebar-flip-card.flipped {
    transform: rotateY(180deg);
    /* Every button/link "on the other side" while flipped — can't be
       interacted with until it's flipped back around. */
    pointer-events: none;
    /* Subtle dim/desaturate — reads as looking through frosted glass
       from the wrong side rather than just a mirrored copy of the
       front. */
    filter: brightness(0.92) saturate(0.85);
  }
  @media (prefers-reduced-motion: reduce) {
    /* The whole feature is gated off under reduced motion in JS (the
       double-click listener is never even attached) — this is just a
       safety net in case .flipped somehow ends up applied anyway.
       Split across both elements now, matching where each property
       actually lives post-split: transform/filter (the flip itself)
       still needs disabling on the outer element, background-color/
       border-color (the theme fade, never the actual thing this
       safety net was ever about) still needs its own normal 2400ms
       timing on the fill layer regardless of reduced motion, since
       disabling that would reintroduce the exact background-color-
       snapping-instantly bug this whole site-wide pass just fixed. */
    .sidebar-flip-card { transition: none; }
  }
  .sidebar-flip-card::before {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: inherit;
    padding: 1.5px;
    /* Sparkle: tight, bright hot-spot layer + background-blend-
       mode:screen on top of the original glint below — same
       technique as index.html's own copy of this. */
    background:
      linear-gradient(var(--sidebar-glint-angle, 225deg),
        rgba(255, 255, 255, 1) 0%,
        rgba(255, 255, 255, 0.85) 1.5%,
        rgba(255, 255, 255, 0.3) 4%,
        transparent 9%),
      linear-gradient(var(--sidebar-glint-angle, 225deg),
      rgba(255, 255, 255, 0.95) 0%,
      rgba(var(--light-rgb), 0.55) 18%,
      rgba(var(--light-rgb), 0.12) 40%,
      transparent 62%);
    background-blend-mode: screen, normal;
    -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    -webkit-mask-composite: xor;
    mask-composite: exclude;
    pointer-events: none;
  }
  /* flip easter egg — respects the light source now: it's positioned
     at the same fixed screen location (upper right) regardless of
     which way the card is turned, so the glint and the haze both use
     --sidebar-glint-angle-flipped here, the mirror-corrected angle
     (see mirroredAngleFromLight() in the glint-angle script) rather
     than the plain --sidebar-glint-angle the front face uses. Without
     that correction the rotation's own left-right mirroring would put
     the bright edge on the wrong side — exactly what Carson noticed.
     Keyed off .flip-mid, NOT .flipped directly — a background gradient
     switches instantly the moment its class lands, with no way to
     animate smoothly between two different angles, so switching it the
     same instant the rotation STARTS made the glint visibly "teleport"
     to the wrong side before the pane had turned at all (Carson's
     second report). .flip-mid is added/removed by JS on a delay
     roughly matching the rotation's own midpoint (see
     toggleFlipPane()), landing right around when the pane is edge-on
     to the viewer and at its narrowest — the least visible moment for
     an instant swap to happen in, which is what actually hides it. */
  .sidebar-flip-card.flip-mid::before {
    /* Sparkle: tight, bright hot-spot layer + background-blend-
       mode:screen on top of the original glint below — same
       technique as index.html's own copy of this. */
    background:
      linear-gradient(var(--sidebar-glint-angle-flipped, 225deg),
        rgba(255, 255, 255, 1) 0%,
        rgba(255, 255, 255, 0.85) 1.5%,
        rgba(255, 255, 255, 0.3) 4%,
        transparent 9%),
      linear-gradient(var(--sidebar-glint-angle-flipped, 225deg),
      rgba(255, 255, 255, 0.95) 0%,
      rgba(var(--light-rgb), 0.55) 18%,
      rgba(var(--light-rgb), 0.12) 40%,
      transparent 62%);
    background-blend-mode: screen, normal;
  }
  .sidebar-flip-card.flip-mid .sidebar-services-glass::before {
    /* Sparkle: tight, bright hot-spot layer + background-blend-
       mode:screen on top of the original glint below — same
       technique as index.html's own copy of this. Retargeted from
       .sidebar-row (which no longer has its own ::before at all post-
       restructure) to .sidebar-services-glass, the new shared wrapper
       that now carries the one glint the whole services column has,
       same mid-flip mirroring this rule already provided per-row
       before. */
    background:
      linear-gradient(var(--sidebar-services-glint-angle-flipped, 225deg),
        rgba(255, 255, 255, 1) 0%,
        rgba(255, 255, 255, 0.85) 1.5%,
        rgba(255, 255, 255, 0.3) 4%,
        transparent 9%),
      linear-gradient(var(--sidebar-services-glint-angle-flipped, 225deg),
      rgba(255, 255, 255, 0.9) 0%,
      rgba(var(--light-rgb), 0.45) 22%,
      transparent 48%);
    background-blend-mode: screen, normal;
  }
  /* flip easter egg — atmospheric haze, visible only on the back (once
     flipped). Just the wash now — Carson didn't like either the
     radial-gradient droplets or the water-rivulet streaks tried
     earlier, wanted plain haze only. Same mirror-corrected angle as
     the glint above, so it diffuses from the same corner the glint is
     brightest at — light coming through the glass from the front,
     brightest nearest where it enters, fading out toward the far
     corner, rather than an angle unrelated to where the actual light
     is. Delayed opacity transition (the extra 0.35s before it starts)
     means it doesn't appear until the flip is mostly settled, rather
     than fogging up mid-turn while still edge-on to the viewer — reads
     more like "the glass has been sitting cold for a moment" than an
     instant effect tied to the rotation itself. */
  .sidebar-flip-card::after {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: inherit;
    pointer-events: none;
    opacity: 0;
    transition: opacity 0.6s ease 0.35s;
    background: linear-gradient(var(--sidebar-glint-angle-flipped, 225deg),
      rgba(255, 255, 255, 0.16) 0%,
      rgba(255, 255, 255, 0.08) 35%,
      rgba(255, 255, 255, 0.03) 65%,
      transparent 100%);
  }
  .sidebar-flip-card.flipped::after { opacity: 1; }
  /* flip easter egg — the pane's visible "thickness": two thin strips
     perpendicular to the main face, forming the left/right edges of a
     shallow box. Edge-on (barely visible) when the pane faces the
     viewer head-on; they widen into view as the whole thing turns,
     same as a real slab of glass would. rotateY(90deg)/rotateY(-90deg)
     with transform-origin pinned to their outer edge is what swings
     each strip from lying flat (in the same plane as the front face)
     to standing perpendicular, reaching back to translateZ(-14px) —
     the model here is a shallow open box: front face + two side walls,
     no separate back panel needed since the mirrored front IS the
     back, per the whole point of this effect. */
  .sidebar-edge-left, .sidebar-edge-right {
    position: absolute;
    top: 0;
    height: 100%;
    /* 17px, not 14px — same "sharp corners poking out" bug Carson
       caught on .stat-widget and .stack-card-flip-pane (see their own
       comments for the full explanation): this strip's own corner
       radius gets silently clamped to fit whatever width it's given,
       so at 14px against .sidebar-flip-card's own 16px radius
       (var(--radius)), the strip's corner rendered at a tighter ~14px
       curve than the parent's true 16px one — a real, if smaller (2px
       gap), mismatch than the other two had, but the same underlying
       cause. 17px clears 16px outright. */
    width: 17px;
    background: linear-gradient(to right, rgba(var(--surface-rgb), 0.42), rgba(var(--surface-rgb), 0.14));
    backdrop-filter: blur(20px) saturate(1.5);
    -webkit-backdrop-filter: blur(20px) saturate(1.5);
    border-top: 1px solid var(--border);
    border-bottom: 1px solid var(--border);
    pointer-events: none;
    /* Widening the strip (above) fixed the radius-clamping mismatch,
       but Carson's follow-up screenshot showed a faint sliver STILL
       visible on a completely resting (not flipping) widget card —
       see .flip-edge-left/-right's own copy of this comment for the
       full explanation (an off-axis viewing angle and/or backdrop-
       filter's own known quirks under 3D transforms, most likely,
       though there's no way to be fully certain without a live
       browser). Same fix here regardless of the exact cause: hidden
       at rest, only fading in once an actual flip is under way. */
    opacity: 0;
    transition: opacity 0.9s cubic-bezier(.65,.05,.36,1);
  }
  .flipped > .sidebar-edge-left, .flipped > .sidebar-edge-right {
    opacity: 1;
  }
  @media (prefers-reduced-motion: reduce) {
    .sidebar-edge-left, .sidebar-edge-right { transition: none; }
  }
  /* Only the OUTER-facing corner of each strip gets rounded — matching
     the parent's own corner radius on that side specifically, not a
     flat border-radius:inherit applied to all 4 corners (which would
     round the INNER corners too, corners that are never actually
     visible since they're hidden behind the main face at rest anyway).
     Without this, a plain rectangular strip's square corners poked out
     past the parent's own rounded corner — a visible notch exactly
     where the curve should be, worse the smaller the element (barely
     noticeable on the sidebar itself, glaring on the much smaller
     widgets — see .flip-edge-left/-right's own copy of this below).
     Browsers clamp an oversized radius down to fit the strip's own
     narrow width automatically — this used to be described here as a
     harmless fallback ("so this works even when..."), which undersold
     it: that clamping is exactly what was causing the visible mismatch
     in the first place (see the width:17px comment above), not a safe
     fallback. Both strips are wide enough now that the clamping no
     longer kicks in at all for either. */
  .sidebar-edge-left {
    left: 0;
    transform-origin: left center;
    transform: rotateY(90deg);
    border-left: 1px solid var(--border);
    border-top-left-radius: inherit;
    border-bottom-left-radius: inherit;
  }
  .sidebar-edge-right {
    right: 0;
    transform-origin: right center;
    transform: rotateY(-90deg);
    border-right: 1px solid var(--border);
    border-top-right-radius: inherit;
    border-bottom-right-radius: inherit;
  }
  /* ---------- Generic reusable flip-easter-egg pieces ----------
     Same idea as everything above, generalized so it doesn't need
     duplicating per element type — every standalone glass surface that
     gets this treatment (stat widgets, the 4 main settings cards, the
     carousel/card-stack nav arrows) adds .flip-pane to its own
     existing class list to pick up the rotation/haze/reduced-motion
     handling for free, keeping its own background/border/sizing rules
     untouched. --flip-thickness is overridden per target below since
     these vary a lot in size (a 30px round arrow needs a much thinner
     edge than a full settings card, if it gets one at all — round
     elements skip the edge strips entirely, see the arrows' own rule
     further down, since two flat rectangular strips don't read as
     "thickness" on a circle the way they do on a rectangle). */
  .flip-pane {
    /* Real bug Carson caught (2026-08-14): a flat rectangular strip's
       border-radius CANNOT exceed its own width without the browser
       silently clamping it down to fit — CSS scales corner radii
       proportionally when their sum exceeds the box's own dimension.
       At the default 14px thickness against most glass surfaces' own
       16px radius (var(--radius)), the strip's corner was rendering at
       a tighter ~14px curve than the parent's true 16px one, visible
       as a seam/sharp edge where the two don't quite align — more
       noticeable during the 2400ms theme-color crossfade since the
       color shift draws the eye right to that boundary. 17px keeps
       this comfortably above 16px so the corner renders at its full,
       unclamped radius. Elements needing a different thickness (much
       smaller or much larger than this default) override the variable
       below with their OWN radius in mind, not just an arbitrary size
       — see .stat-widget and .stack-card-flip-pane's own comments,
       both of which had this same mismatch even more severely. */
    --flip-thickness: 17px;
    transform-style: preserve-3d;
    transform-origin: 50% 50%;
    transition: background-color 2400ms ease, border-color 2400ms ease,
                transform 0.9s cubic-bezier(.65,.05,.36,1), filter 0.9s ease;
  }
  .flip-pane.flipped {
    transform: rotateY(180deg);
    /* pointer-events:none deliberately NOT here anymore — it caused a
       real bug for widgets/arrows specifically: unlike the sidebar and
       the 4 main cards (where the double-click listener lives on a
       separate OUTER element from the one that gets .flipped), widgets
       and arrows have the listener on this exact same element. Once
       flipped, pointer-events:none blocked ALL further pointer events
       on it — including the double-click needed to flip it back,
       leaving it stuck. Sidebar/cards re-declare pointer-events:none
       on their own more specific .flipped rules instead, where it's
       safe; arrows guard their own click handler with a .flipped check
       in JS instead, since they have nothing else to protect against
       (nothing else lives inside them) besides their own single click.
       Widgets need no equivalent — they have no interactive content
       inside to protect against at all. */
    filter: brightness(0.92) saturate(0.85);
  }
  @media (prefers-reduced-motion: reduce) {
    .flip-pane { transition: background-color 2400ms ease, border-color 2400ms ease; }
  }
  .flip-pane::after {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: inherit;
    pointer-events: none;
    opacity: 0;
    transition: opacity 0.6s ease 0.35s;
  }
  .flip-pane.flipped::after { opacity: 1; }
  .flip-edge-left, .flip-edge-right {
    position: absolute;
    top: 0;
    height: 100%;
    width: var(--flip-thickness, 14px);
    background: linear-gradient(to right, rgba(var(--surface-rgb), 0.42), rgba(var(--surface-rgb), 0.14));
    backdrop-filter: blur(16px) saturate(1.5);
    -webkit-backdrop-filter: blur(16px) saturate(1.5);
    border-top: 1px solid var(--border);
    border-bottom: 1px solid var(--border);
    pointer-events: none;
    /* Carson's screenshot showed this NOT resolved by fixing the
       corner-radius clamping alone (widening the strip so its own
       radius wouldn't get clamped, the earlier fix) — a real, faint
       sliver was still visible on a completely RESTING (not flipping)
       card. That points to a different cause: at rest these strips are
       rotated 90deg to sit edge-on, which only renders as truly
       invisible if the viewing angle is exactly perpendicular to
       them — but widgets share ONE perspective origin across their
       whole row rather than each getting its own dead-center one (an
       accepted trade-off, see .stat-widget's own comment on this), so
       anything off-center is viewed at a very slight angle, not a
       perfect one. Rather than keep chasing the exact rendering cause
       (that off-axis angle, and/or backdrop-filter's own known quirks
       when combined with 3D transforms — either is plausible, and
       there's no way to be certain which without a live browser),
       explicitly hiding these at rest sidesteps the question entirely:
       opacity 0 by default, fading in only once a flip is actually
       under way (see .flipped > .flip-edge-left/-right below) — same
       0.9s timing as the rotation itself, so the edge becomes visible
       as the object turns, not before. */
    opacity: 0;
    transition: opacity 0.9s cubic-bezier(.65,.05,.36,1);
  }
  .flipped > .flip-edge-left, .flipped > .flip-edge-right {
    opacity: 1;
  }
  @media (prefers-reduced-motion: reduce) {
    .flip-edge-left, .flip-edge-right { transition: none; }
  }
  .flip-edge-left {
    left: 0;
    transform-origin: left center;
    transform: rotateY(90deg);
    border-left: 1px solid var(--border);
    /* Only the outer-facing corner rounded, matching the parent's own
       radius on that side — same reasoning and technique as
       .sidebar-edge-left's own copy of this comment. Much more visible
       a fix on these smaller elements (widgets, arrows) than on the
       sidebar, since the strip's own width is closer to the parent's
       corner radius here — Carson's report was specifically about
       this showing up on widgets. */
    border-top-left-radius: inherit;
    border-bottom-left-radius: inherit;
  }
  .flip-edge-right {
    right: 0;
    transform-origin: right center;
    transform: rotateY(-90deg);
    border-right: 1px solid var(--border);
    border-top-right-radius: inherit;
    border-bottom-right-radius: inherit;
  }
  /* flip easter egg — plain flex content wrapper now, no longer the
     thing that rotates (that moved up to .sidebar-flip-card, its
     parent) — just holds the actual head/list/footer content. Safe
     home for overflow:auto (mobile's "scroll the whole panel as one
     unit" — see that media query) since, unlike .sidebar-flip-card,
     this element has no 3D children of its own that need to share its
     parent's preserve-3d space; overflow other than visible would
     otherwise flatten that, which is why it can't live on
     .sidebar-flip-card itself. */
  .sidebar-flip-inner {
    display: flex;
    flex-direction: column;
    width: 100%;
    height: 100%;
    position: relative;
  }
  .sidebar-head { display: flex; align-items: center; margin-bottom: 12px; flex: none; }
  .sidebar-head h3 { font-size: 13px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-dim); margin: 0; }

  /* Carson's ask, the biggest lever in the iPad performance
     investigation: every enrolled service used to run its own
     backdrop-filter independently (N separate blur operations, one
     per row, each also carrying its own 2400ms color transition) —
     this was the one thing left untouched by every earlier
     optimization, specifically because Carson didn't want to lose the
     "denser pane of glass on top of the panel's glass" distinction
     that per-row blur provided. This wrapper consolidates that down
     to ONE blur operation for the whole list — same density (0.68
     alpha, 16px blur) the rows themselves already used, so the visual
     relationship to .sidebar-flip-card's own glass is unchanged, just
     applied once instead of N times. .sidebar-list itself (nested
     inside, below) keeps its own scroll + fade-mask completely
     unchanged — this wrapper never scrolls or masks anything itself,
     it's purely the shared visual backdrop sitting behind it. */
  .sidebar-services-glass {
    position: relative;
    display: flex; flex-direction: column;
    flex: 1 1 auto; min-height: 0;
    background: rgba(var(--surface-rgb), 0.68);
    backdrop-filter: blur(16px) saturate(1.5);
    -webkit-backdrop-filter: blur(16px) saturate(1.5);
    border: 1px solid var(--border);
    border-radius: 12px;
    padding: 8px 6px;
    overflow: hidden;
    transition: background-color 2400ms ease, border-color 2400ms ease;
  }
  .sidebar-services-glass::before {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: inherit;
    padding: 1.5px;
    background:
      linear-gradient(var(--sidebar-services-glint-angle, 225deg),
        rgba(255, 255, 255, 1) 0%,
        rgba(255, 255, 255, 0.85) 1.5%,
        rgba(255, 255, 255, 0.3) 4%,
        transparent 9%),
      linear-gradient(var(--sidebar-services-glint-angle, 225deg),
      rgba(255, 255, 255, 0.9) 0%,
      rgba(var(--light-rgb), 0.45) 22%,
      transparent 48%);
    background-blend-mode: screen, normal;
    -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    -webkit-mask-composite: xor;
    mask-composite: exclude;
    pointer-events: none;
  }
  .sidebar-list {
    display: flex; flex-direction: column; gap: 2px;
    flex: 1 1 auto; min-height: 0; overflow-y: auto;
    /* 18px, not 6px — Carson kept seeing a "clipped" right edge on
       hover across three completely different hover mechanisms in a
       row (a stacked layer, an inset shadow, an outline ring), which
       means none of those were ever the actual cause — something
       constant across all three had to be. This 6px gutter, reserved
       for the scrollbar without shifting content when it appears/
       disappears, was the one thing never touched. A real system
       scrollbar often runs 15-17px wide (Windows/Linux; macOS's own
       overlay scrollbars can be thinner, but this can't assume that),
       which is wider than the 6px reserved for it — meaning the
       actual scrollbar track could be physically overlapping each
       row's own right edge, covering whatever's rendered there
       (the glint border, and any hover treatment) regardless of what
       that treatment actually was. 18px comfortably covers even a
       wide classic scrollbar with room to spare. Still works
       identically nested inside .sidebar-services-glass now — that
       wrapper's own overflow:hidden clips this same excess width at
       its own rounded edge, same as it always clipped at the old
       parent's edge before this restructure. */
    margin-right: -18px; padding-right: 18px;
    /* Fade mask — same mechanic as .card-body and the credits panel
       (index.html/request.html) — see account.html's own .card-body
       history (several failed shadow attempts) for why this landed on
       mask-image instead. --fade-top/--fade-bottom are set by JS,
       computed from actual scroll position — 0px at whichever edge
       genuinely has nothing more content past it. */
    --fade-top: 0px;
    --fade-bottom: 0px;
    mask-image: linear-gradient(to bottom, transparent, black var(--fade-top), black calc(100% - var(--fade-bottom)), transparent);
    -webkit-mask-image: linear-gradient(to bottom, transparent, black var(--fade-top), black calc(100% - var(--fade-bottom)), transparent);
  }
  .sidebar-group { margin-bottom: 4px; }
  .sidebar-group-label {
    font-size: 10.5px; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase;
    color: var(--text-dim); opacity: 0.75; margin: 12px 6px 4px;
  }
  .sidebar-group:first-child .sidebar-group-label { margin-top: 0; }

  /* ---------- Service brand logos ----------
     Shared by all three placements (sidebar rows, stat widgets, the
     Services card's checklist) — one rule rather than three separate
     copies, since the sizing/coloring logic is identical everywhere
     it's used. Height keyed to the current font-size (em, not a fixed
     px) so it automatically matches whatever text size it's sitting
     next to in each of the three different contexts, per Carson's own
     ask ("same size as the text"). Width left auto — these are real
     brand marks with their own native aspect ratios (some square,
     some wide, some tall), forcing them into a fixed square box would
     visibly stretch/squash several of them. vertical-align:middle
     works reasonably in both the flex contexts (sidebar rows, widgets)
     and the plain inline-text context (the Services card's <h4>) this
     needs to sit correctly in. */
  .service-logo {
    display: inline-block;
    height: 0.95em;
    width: auto;
    vertical-align: middle;
    margin-right: 6px;
    flex-shrink: 0;
    /* Dark mode: same slightly-muted treatment requested, not full
       brightness — these are full-color brand marks (unlike the
       site's own monochrome currentColor icons), so a plain CSS
       `color` change can't touch them; every fill is baked into each
       logo's own markup. filter:saturate()/brightness() is what can
       uniformly dial that back across 12 very different multi-color
       SVGs without needing per-logo custom work.

       Driven by --logo-dim (0 light, 1 dark) rather than by the
       dark-mode SELECTOR this used to use. The previous version of
       this rule paired those selectors with `transition: filter
       2400ms ease` and a comment claiming it shared the toggle's
       crossfade timing "so this doesn't snap while everything else
       eases". That was true when written and stopped being true when
       the toggle moved to per-frame JS: .theme-animating now sets
       transition: none !important site-wide for the animation's whole
       duration, and data-theme only flips in finish() at the very
       end, so the transition was suppressed at exactly the instant it
       would have mattered and the logos stepped straight to their
       dark values. Carson reported it as the logos snapping at the
       tail end while everything else eased.

       This is the same fix the status and banner colors above already
       got, and for the same stated reason: interpolate in step with
       the rest of the page rather than exempt this from
       .theme-animating wherever a snap happens to be visible.
       Endpoints unchanged — resolves to saturate(0.55)
       brightness(0.85) at --logo-dim: 1. No reduced-motion override
       any more: there is no transition left to disable, and killing
       one here is what would reintroduce the snap. */
    filter: saturate(calc(1 - 0.45 * var(--logo-dim, 0)))
            brightness(calc(1 - 0.15 * var(--logo-dim, 0)));
  }
  /* AudioBookRequest's own mark is single-color (unlike every other
     logo here, which are full multi-color brand marks) — same
     handling as request.html's own copy of this comment: its upstream
     SVG shipped its own light/dark logic, but only reacted to the
     SYSTEM's color-scheme preference, not this site's explicit
     data-theme toggle, so a user who'd toggled the SITE to dark while
     their OS stayed light would've seen an invisible black mark on a
     dark background. Stripped out at the source, driven here instead
     to match this site's actual dark-mode detection. */
  .abr-logo-path { fill: var(--abr-logo-fill, #000000); }

  .sidebar-row {
    position: relative;
    display: flex; align-items: center; gap: 10px;
    padding: 9px 10px; font-size: 13.5px; font-weight: 550;
    color: var(--text); text-decoration: none;
    /* No background/backdrop-filter/border of its own anymore —
       Carson's ask, the biggest lever in the iPad performance
       investigation: see .sidebar-services-glass's own comment (just
       above .sidebar-list) for the full reasoning. What used to be
       this row's own "denser pane of glass" now comes from the shared
       wrapper sitting behind the whole list instead — the divider
       below is what actually separates rows from each other now.
       Hover still deliberately touches NOTHING at this row's own edge
       — see :hover below, same "just a lift, no color/border/outline"
       reasoning as before, unaffected by this restructure. */
    transition: color 0.3s ease, border-color 2400ms ease, transform 0.15s ease;
    /* content-visibility still worth keeping even though each row is
       far cheaper to render now (no blur of its own left to skip) —
       still real layout/style/paint work saved for rows scrolled out
       of view within .sidebar-list, at zero cost to keep. */
    content-visibility: auto;
    contain-intrinsic-size: auto 40px;
  }
  /* Divider between rows within the same category group — what
     actually separates services now that individual rows carry no
     background/border of their own. border-color transitions at the
     same 2400ms as everything else (was missing entirely on the first
     mockup pass — Carson's report: snapping instantly while
     everything around it eased was what actually read as
     distracting, not the divider existing at all). */
  .sidebar-group .sidebar-row:not(:last-child) {
    border-bottom: 1px solid var(--border);
  }
  .sidebar-row:hover, .sidebar-row:focus-visible {
    /* Just the lift — no color, no border, no outline, no shadow.
       Browser's own default focus ring is left completely untouched
       here too, deliberately — not overridden with a custom outline
       the way the previous attempt did, since that's one of the three
       edge-based techniques already ruled out above. */
    transform: translateY(-1px);
  }
  .sidebar-footer { margin-top: auto; padding-top: 14px; flex: none; }
  .sidebar-divider { height: 1px; background: var(--border); margin: 0 0 14px; }
  /* Copied from index.html's own status legend (Carson's request,
     2026-08-11: users shouldn't have to visit the home page just to
     learn what the sidebar's colored dots mean) — same swatch colors
     (var(--ok)/--down/--maint/--pending) as .sidebar-dot below, so this
     legend and the dots it explains can never visually drift apart. */
  .sidebar-legend {
    display: flex; flex-wrap: wrap; gap: 10px 14px; margin: 0 0 14px;
  }
  .sidebar-legend .item {
    display: flex; align-items: center; gap: 5px;
    font-size: 11px; color: var(--text-dim);
  }
  .sidebar-legend .swatch { width: 7px; height: 7px; border-radius: 50%; flex: none; }
  .sidebar-legend .swatch.up { background: var(--ok); }
  .sidebar-legend .swatch.down { background: var(--down); }
  .sidebar-legend .swatch.maintenance { background: var(--maint); }
  .sidebar-legend .swatch.pending { background: var(--pending); }
  .sidebar-cta {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 8px;
    background: var(--text);
    color: var(--bg);
    text-decoration: none;
    font-size: 14px;
    font-weight: 600;
    padding: 12px 16px;
    border-radius: 999px;
    transition: transform 0.15s ease, opacity 0.15s ease, background-color 2400ms ease, color 2400ms ease;
  }
  .sidebar-cta:hover { transform: translateY(-1px); opacity: 0.92; }
  .sidebar-dot {
    width: 7px; height: 7px; border-radius: 50%; flex: none;
    background: var(--unknown); transition: background 0.3s ease;
  }
  .sidebar-dot.up { background: var(--ok); }
  .sidebar-dot.down { background: var(--down); }
  .sidebar-dot.maintenance { background: var(--maint); }
  .sidebar-dot.pending { background: var(--pending); }
  .sidebar-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .sidebar-empty { font-size: 13px; color: var(--text-dim); padding: 8px 6px; line-height: 1.5; }

  .sidebar-backdrop {
    display: none;
    /* Raised from 59 — Carson's report: the open flyout panel was
       rendering underneath .header-actions (the theme toggle/login/
       notif bell cluster, z-index:80, opposite corner) rather than
       above it, which looked sloppy given the panel is meant to read
       as its own floating layer over everything else on the page. 81,
       not just "80+1" arbitrarily — genuinely the next open slot in
       the existing scale: still safely below .row-context-menu (90)
       and #tutorial-block (200), both of which need to stay above the
       sidebar regardless (the tutorial specifically spotlights the
       sidebar itself at one point, so it has to render on top of it).
       .sidebar and .sidebar-trigger, just below, get the same
       treatment — same relative ordering these three already had
       (backdrop < panel < trigger, so the hamburger stays clickable
       on top of its own open panel), just all shifted above 80
       together rather than leaving only this one behind. */
    position: fixed; inset: 0; z-index: 81;
    background: rgba(0,0,0,0.32);
    opacity: 0; pointer-events: none;
    transition: opacity 0.25s ease;
  }
  .sidebar-backdrop.open { opacity: 1; pointer-events: auto; }

  @media (max-width: 780px) {
    /* Mirrors .header-actions' own fixed-to-viewport pattern (see that
       rule's comment for why: stays put in the browser's corner
       regardless of window width, instead of scrolling away with the
       header or drifting as page-wrap's width changes) — just the
       opposite corner. Moving it out of the normal header-text-wrap
       flow like this is also what lets the greeting naturally slide
       left into the space it used to occupy, with no extra rule needed
       for that part. */
    .sidebar-trigger {
      display: flex;
      position: fixed;
      /* + --safe-top / 2, not the full amount — same halving fix as
         .header-actions' own copy of this comment: the full amount
         made the gap above this icon inside .top-frost-bar visibly
         bigger than the gap below it; halving it splits the notch's
         extra room evenly between both sides instead. */
      top: calc(18px + var(--banner-h, 0px) + (var(--safe-top) / 2));
      left: 18px;
      /* Raised from 61 to 83, alongside .sidebar-backdrop (81) and
         .sidebar (82) just below — see .sidebar-backdrop's own comment
         for the full reasoning. Same relative gap above the panel
         (+2) it already had, so it still stays clickable on top of
         its own open flyout, just shifted up together with the other
         two rather than left behind under .header-actions. */
      z-index: 83;
      /* 38px -> 48px, ~25% — Carson's own ask, same reasoning and same
         target size as .header-actions' own buttons and .theme-toggle/
         .notif-bell-btn (see their own copy of this comment, in the
         max-width:600px block) — fine for a mouse, too small a touch
         target on an actual phone. Kept at this element's own existing
         780px breakpoint rather than moved to 600px, since that's
         where this button (and the flyout it opens) already becomes
         relevant in the first place — no reason to introduce a second,
         narrower threshold just for its size when its very existence
         is already gated at 780px. */
      width: 48px;
      height: 48px;
      /* Carson's own ask, and the JS half of it lives in openSidebar()/
         closeSidebar() — see .sidebar-trigger.open just below for what
         this transition is actually easing between. Declared here
         rather than only on the .open rule so BOTH directions (circle
         -> bare lines on open, bare lines -> circle on close) ease
         smoothly, matching every other visual state change on this
         page rather than snapping instantly. */
      transition: background-color 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease;
    }
    .sidebar-trigger svg {
      width: 23px;
      height: 23px;
    }
    /* Carson's follow-up report: at this same 780px breakpoint,
       .sidebar-trigger above grows to 48px the instant it appears, but
       Back to Home / Sign out / theme toggle / notif bell didn't grow
       until the narrower 600px breakpoint — leaving a visible size
       mismatch (the hamburger noticeably bigger than everything beside
       it) for the whole 600-780px range. Growing them here too, at the
       SAME breakpoint as the hamburger, keeps the whole cluster in
       sync instead of staggering it across two different thresholds.
       Back to Home's own height/padding/font at this breakpoint moved
       to /buttons.css, along with the 600px collapse it was written to
       stay out of the way of. */
    .theme-toggle, .notif-bell-btn {
      width: 48px;
      height: 48px;
    }
    .theme-toggle svg, .notif-bell-btn svg {
      width: 21px;
      height: 21px;
    }
    /* The "Notification panel, kept on-screen" rule that lived here -
       .header-actions .notif-bell-wrap { position: static } - moved to
       notifications.css on 4 Sep 2026, along with the whole of its
       comment. Nothing about it changed except which file it is in.

       It was written when account.html was the only page whose bell sat
       in a .header-actions row; index.html and request.html made theirs a
       fixed corner control instead and sidestepped the problem, which is
       why the rule was page-local and why its comment spent two
       paragraphs on the difference. Those two pages now use the same row,
       so they hit the same off-screen panel, and the fix has to reach
       them. Both pages load notifications.css and neither loads this
       file.

       Left as a note rather than silently removed because the next person
       to see a panel run off the left edge on /account will grep this
       file first - it is where the bug was reported and fixed. */
    /* .top-frost-bar's own height is otherwise a flat 74px+safe-top at
       every width (see that rule's own comment) — sized for the 38px
       default icon size. At this SAME 780px breakpoint, the buttons
       just above grow to 48px, but that height never followed them —
       the bar's own bottom edge stayed put while the buttons
       themselves grew and pushed further down into it, leaving
       noticeably less gap below them than above. This is the fix an
       earlier, incomplete comment here was describing before getting
       interrupted mid-edit and left as an orphaned fragment with no
       actual rule attached — replaced with the real rule this time.
       +10px (74->84) is exactly the button's own growth (38->48px)
       added onto the base formula, matching request.html/index.html's
       own copy of this same fix (at their own 600px breakpoint,
       where their icons grow the same way). */
    .top-frost-bar {
      height: calc(84px + var(--safe-top) + var(--os-glass-bleed));
    }
    /* body's own padding-top (72px, see that rule's own comment) was
       tuned to match the 38px default button size — it gives a
       comfortable 16px gap to "Welcome, Carson" at that size (72 -
       18 - 38 = 16), but at THIS breakpoint the buttons above just
       grew to 48px without body's own padding growing to match,
       shrinking that same gap down to a cramped 6px (72 - 18 - 48).
       +10px here (72->82) is exactly the button's own growth
       (38->48px) added onto the base value, restoring the same ~16px
       gap this page already has at desktop button size. Carson's own
       report specifically named this width range ("at the wider
       views") as still too tight even after the base padding fix —
       this is the missing piece that fix didn't cover, not a
       duplicate of it. */
    body {
      --body-pad-top: 82px;
    }
    /* Carson's own ask, same reasoning as .sidebar-trigger's own size
       bump just above — Carson's report specifically named the
       flyout's linked service rows too, not just the corner icon
       buttons.
       Second pass — the first round (padding only, 9px/10px ->
       11px/13px, deliberately leaving font-size/dot/gap untouched)
       wasn't enough by Carson's own follow-up report. This round
       grows the row more broadly rather than just padding again:
       font-size up (13.5px -> 15px), the gap between the status dot
       and the service name widened to match (10px -> 12px), and the
       dot itself grown too (7px -> 9px) so it doesn't read as
       disproportionately tiny against everything else around it now
       being larger. Padding pushed further as well (11px/13px ->
       15px/16px) rather than left where the first pass put it. */
    .sidebar-row {
      padding: 15px 16px;
      gap: 12px;
      font-size: 15px;
    }
    .sidebar-dot {
      width: 9px;
      height: 9px;
    }
    /* Carson's own ask, and a PROPORTIONAL match to .sidebar-row's own
       growth just above, not a copy of its exact numbers — the two
       started from different base sizes (12px/16px padding, 14px
       font-size, 8px gap here vs 9px/10px, 13.5px, 10px there), so
       matching .sidebar-row's own RATE of growth (padding +60-65%,
       font-size roughly +11%, gap +20-25%) keeps this button feeling
       consistently scaled with the rows above it rather than landing
       on an arbitrary size of its own. .sidebar-dot needs no separate
       rule here — it's the exact same shared class already grown just
       above, not scoped to .sidebar-row specifically, so this button's
       own dot already picks up that same 9px. */
    .sidebar-cta {
      padding: 20px 26px;
      gap: 10px;
      font-size: 16px;
    }
    @media (prefers-reduced-motion: reduce) {
      .sidebar-trigger { transition: none; }
    }
    /* Strips the circular button look entirely once the flyout is
       actually open — Carson's own report: sitting near the panel's
       own top-left corner, the button's OWN corner radius (a full
       circle, border-radius:50%) visibly clashed against the panel's
       different, rounded-rectangle one right next to it. Rather than
       try to match radii, removing the circle (background, border,
       shadow) entirely leaves just the bare three-line icon sitting
       directly on the panel's own glass surface — reads as part of
       the panel itself rather than a separate floating object next to
       it. color is deliberately untouched here — var(--text) (the
       icon's own stroke color, inherited via currentColor) already
       has the contrast it needs against the panel's own background,
       the same as it does against .sidebar-trigger's normal circular
       one. */
    .sidebar-trigger.open {
      background: none;
      border-color: transparent;
      box-shadow: none;
    }
    /* Reserve room on the left so the greeting text can't run underneath
       the now-fixed hamburger circle — same idea as .account-header's
       existing padding-right for the button row on the other side.
       54px -> 64px: grown by the same 10px .sidebar-trigger itself
       just grew (38px -> 48px, see that rule's own comment) — sized
       for a single icon instead of a whole row of them, so this needs
       to track that element's own width to keep the same relative
       clearance rather than drifting out of sync with it.
       Zeroed again at the narrower phone breakpoint below, where the
       header switches to a stacked (column) layout and the title no
       longer sits beside the icon horizontally at all. */
    .account-header { padding-left: 64px; }
    /* ---------- iOS Safari status-bar bleed fix ----------
       This rule used to read simply `.sidebar-backdrop { display: block; }`,
       and that single declaration was the cause of Carson's long-running
       report that scrolled text "comes out the top of the nav bar and stays
       visible" on THIS page while index.html and request.html were fine.

       As written, at phone widths this element was a position:fixed, inset:0
       box — top 0, 100% wide, full height — carrying
       background: rgba(0,0,0,0.32), PRESENT AND DISPLAYED AT ALL TIMES and
       hidden only by opacity:0.

       Safari 26 no longer tints its chrome from <meta name="theme-color">.
       It scans position:fixed / sticky elements near the viewport edges and
       reads background-color off them; the documented thresholds are within
       4px of an edge, at least 80% wide, at least 3px high. This element met
       every one of them, and an opacity:0 element still generates a box and
       still sits in the render tree. So Safari sampled a fully transparent
       full-screen overlay, concluded it had nothing solid to tint with, and
       fell through to compositing live page pixels behind the status bar.
       A display:none element generates no box at all and is skipped — which
       is why gating on .open fixes it.

       index.html and request.html never had the bug for exactly this reason:
       their equivalent full-viewport fixed overlays (.modal-overlay and
       .credits-overlay) both carry `[hidden] { display: none }` AND ship
       with the hidden attribute set in the markup, so they are genuinely
       display:none until opened. This page's backdrop had no such guard.

       It also explains why Edge on iOS always rendered this page correctly:
       Edge is WebKit, so engine and layout are identical and only the
       browser chrome differs. This was never a layout or compositing bug.

       Verified by A/B on-device: gated, no bleed; reverted to display:block,
       the bleed returns.

       The fade survives via allow-discrete + @starting-style, both supported
       in Safari 26: opening flips display to block immediately and animates
       opacity up from the @starting-style value; closing holds display:block
       for the 0.25s fade, then flips back to none. Engines without
       allow-discrete simply snap the scrim in and out — a fair degradation,
       and it is never left displayed-but-invisible, which is the state that
       caused this in the first place. */
    .sidebar-backdrop {
      display: none;
      transition: opacity 0.25s ease, display 0.25s allow-discrete;
    }
    .sidebar-backdrop.open {
      display: block;
    }
    @starting-style {
      .sidebar-backdrop.open { opacity: 0; }
    }
    /* Reworked to match .credits-panel's own floating-card treatment on
       the home/request pages (Carson's explicit reference) — inset from
       every edge rather than flush against the left one, with rounded
       corners and a real drop shadow so it genuinely reads as its own
       panel floating above the page, not an attached drawer. The open/
       close mechanics (backdrop click, .open class, JS below) are
       unchanged; this only touches how it looks. */
    .sidebar {
      position: fixed;
      /* NOT + var(--safe-top) here, deliberately, unlike
         .header-actions/.sidebar-trigger above (which now use HALF of
         it, see either's own comment for why even that had to be
         corrected once already) — .sidebar-flip-card's own padding-top
         (a few rules below) already bakes in the FULL
         env(safe-area-inset-top) itself, specifically so this panel's
         own CONTENT clears .sidebar-trigger's own (safe-top-aware)
         position. Adding any part of it again here too would
         double-count the same inset: once shifting the whole panel
         down, then again in the content's own padding on top of that
         — an unnecessary gap on a notched phone for no benefit, not a
         second real problem to fix. */
      top: calc(16px + var(--banner-h, 0px));
      left: 16px;
      bottom: 16px;
      width: min(300px, calc(100vw - 32px));
      max-width: none;
      /* Raised from 60 to 82 — see .sidebar-backdrop's own comment
         (just above this element in the source) for the full
         reasoning. */
      z-index: 82;
      /* transform + opacity together, matching .credits-panel exactly —
         a lone transform still let the (now off-canvas) panel remain
         faintly interactive/visible at the very edge of some viewports;
         opacity:0 + pointer-events:none when closed removes that
         entirely, same as the credits panel already does. */
      transform: translateX(-120%);
      opacity: 0;
      pointer-events: none;
      transition: transform 0.32s cubic-bezier(.22,.61,.36,1), opacity 0.25s ease;
      /* height was a definite value on desktop (synced to .main-content);
         top+bottom both being set here determines the box's height
         instead, so an explicit height would just fight that — same
         reasoning as the old min-height:100vh being wrong here too,
         just removed instead of overridden now that there's no bottom
         inset forcing a conflict. */
      height: auto;
      max-height: none;
    }
    .sidebar.open {
      transform: translateX(0);
      opacity: 1;
      pointer-events: auto;
    }
    /* flip easter egg — .sidebar-flip-card now carries what USED to be
       .sidebar's own mobile-specific box-shadow (stronger/darker than
       the desktop version) and padding (56px top to clear the fixed
       hamburger trigger, per the comments these values already had) —
       moved here since it's what actually has the padding/shadow now,
       not .sidebar itself. border-radius needed no override — it was
       already the same var(--radius) on both desktop and mobile. */
    .sidebar-flip-card {
      /* Same --sidebar-shadow-x/-y as the desktop rule above — see
         its own comment. Bigger blur/alpha here to match the full-
         screen drawer this becomes on mobile. */
      box-shadow: var(--sidebar-shadow-x, 0px) var(--sidebar-shadow-y, 7px) 52px rgba(0, 0, 0, 0.20);
      padding-top: max(56px, calc(56px + env(safe-area-inset-top) + var(--os-glass-bleed)));
      padding-bottom: max(18px, env(safe-area-inset-bottom));
      /* Carson's report, mobile only: the base rule's blur(2px)/0.18
         fill was chosen for the DESKTOP case, where this panel is a
         permanent part of the layout with nothing but the page
         background behind it — see that rule's own comment ("always
         visible, never stacked with anything, not a flyout over
         unpredictable content"). At this breakpoint every one of
         those assumptions stops holding: .sidebar above turns this
         into a fixed, inset, translateX-slid flyout that opens
         directly OVER arbitrary page content, which is exactly the
         case index.html/request.html's own left "i" flyout
         (.credits-panel-flip-pane) is tuned for — and both of those
         pages already tried the low-blur/low-alpha card recipe here
         and reverted it for the same reason Carson is reporting now
         ("too hard to read as a flyout sitting over arbitrary page
         content behind it, unlike the cards, which never have
         anything unpredictable underneath them").
         So these are .credits-panel-flip-pane's exact values, mapped
         across 1:1 rather than re-derived: blur 2px -> 20px here, and
         .sidebar-flip-card-fill's alpha 0.18 -> 0.5 just below (that
         pair is one recipe — the blur and the fill alpha only read
         correctly together, which is why neither moves alone). Scoped
         inside this media query so the desktop panel, where the
         original reasoning still fully applies, is untouched.
         .sidebar-services-glass (the inner service-row list) needs no
         rule here at all — it's already on blur(16px)/0.68, the exact
         same values .credits-item uses inside the credits panel, so
         the inner layer was consistent across all three pages
         already; only the outer pane was out of step. */
      backdrop-filter: blur(20px) saturate(1.5);
      -webkit-backdrop-filter: blur(20px) saturate(1.5);
    }
    /* The fill half of the recipe described just above — same 0.5 as
       .credits-panel-flip-pane-fill on index.html/request.html. The
       2400ms background-color/border-color transition, z-index:-1
       stacking and border all stay exactly as the base rule has them;
       this overrides the alpha and nothing else. */
    .sidebar-flip-card-fill {
      background: rgba(var(--surface-rgb), 0.5);
    }
    /* Reverted — Carson's report: this "whole panel scrolls as one
       unit" approach was quietly swallowing something real. With
       .sidebar-footer (Open Home Portal + the status legend) living
       INSIDE .sidebar-flip-inner, making that the scroll container
       meant the footer scrolled away with everything else instead of
       staying pinned at the bottom — the exact "always visible" pin
       .sidebar-footer's own margin-top:auto is supposed to guarantee.
       The desktop layout already gets this right by scrolling ONLY
       .sidebar-list internally (see that rule's own overflow-y:auto),
       leaving header and footer both outside the scrollable region —
       reverting to that same structure on mobile, not inventing a new
       one. The "nested/double scrollbar" concern this was originally
       written to avoid doesn't actually apply once .sidebar-flip-inner
       ISN'T scrolling at all (below) — there's only ever one scroll
       container active at a time, .sidebar-list itself, so there's
       nothing left to nest. */
    .sidebar-flip-inner {
      overflow-y: visible;
    }
    /* .sidebar-list scrolls again — see .sidebar-flip-inner's own
       comment just above for why. -webkit-overflow-scrolling:touch
       moved here from that element, since this is the one actually
       doing the scrolling on mobile now. margin-right/padding-right
       still zeroed (unlike the desktop base rule's own 18px scrollbar-
       gutter reservation) — mobile touch scrolling doesn't need that
       space reserved the way a persistent system scrollbar does.
       --fade-top/--fade-bottom and the mask-image itself need no
       separate mobile rule at all — they're already on this element's
       own base CSS (see that rule's own comment), and the JS that
       drives them (the "Sidebar scroll fade mask" IIFE) was already
       fully wired up and listening on this exact element the whole
       time. It just had nothing to compute — scrollHeight minus
       clientHeight was always ~0 while this stayed non-scrolling, so
       the fade silently existed but never had a scroll position worth
       reacting to. Restoring the scroll here is what actually turns
       it back on, not a separate fix of its own. */
    .sidebar-list {
      flex: 1 1 auto;
      min-height: 0;
      overflow-y: auto;
      -webkit-overflow-scrolling: touch;
      margin-right: 0;
      padding-right: 0;
    }
  }

  /* Combined condition, not a plain .sidebar{transition:none} in its own
     block — the mobile transform/opacity slide only exists inside the
     max-width:780px rule above; a standalone reduced-motion block would
     also wipe out the desktop .sidebar-flip-card's own unrelated
     2400ms color transition, which has nothing to do with motion. */
  @media (max-width: 780px) and (prefers-reduced-motion: reduce) {
    .sidebar { transition: none; }
  }

  /* ---------- Phone-width header compaction ----------
     Below this width, "Back to Home" and "Sign out" collapse from text
     pills into icon-only circles (matching the theme-toggle /
     sidebar-trigger visual language) so the header never overflows or
     wraps awkwardly. Above this width the buttons render exactly as
     before — this only changes anything on genuinely narrow screens.

     Also: the desktop layout reserves a fixed 210px on the right of
     .account-header for the button row's widest possible rendering
     (full text labels). That's massive overkill once the buttons have
     collapsed to four 38px icon circles here, and left so little room
     for the greeting that it was wrapping to 2-3 lines even after
     allowing wrapping at all (see .account-header h1 above). Rather
     than continue squeezing the greeting sideways next to the pinned
     icons, drop that horizontal reservation entirely on mobile and
     instead push the title block down below the icon row — it then
     gets the full screen width to itself, which is what actually lets
     a normal-length name render on one line. */
  @media (max-width: 600px) {
    /* The chip collapses the same way the buttons beside it do: name off,
       avatar only, grown to the same 48px touch target. The chevron goes
       too -- at avatar-only width it reads as decoration rather than an
       affordance, and the whole chip is the target anyway. */
    .header-actions .user-chip .name,
    .header-actions .user-chip .chevron { display: none; }
    .header-actions .user-chip {
      height: 48px;
      width: 48px;
      padding: 0;
      justify-content: center;
    }
    .header-actions .user-chip .avatar { width: 30px; height: 30px; font-size: 12px; }
    /* .header-actions .btn's collapse to a 48px icon-only circle here,
       and .btn-icon/.btn-label's swap that goes with it, moved to
       /buttons.css. */
    /* Same 25%-ish bump, same reasoning, for the other two floating
       corner buttons in this same cluster — .theme-toggle and
       .notif-bell-btn don't have their own existing mobile-only rule
       the way .header-actions .btn does (they're the same size at
       every width up to now), so this is a new override rather than
       an edit to something already here. */
    .theme-toggle, .notif-bell-btn {
      width: 48px; height: 48px;
    }
    .theme-toggle svg, .notif-bell-btn svg {
      width: 21px; height: 21px;
    }
    .account-header {
      padding-right: 0;
      /* The hamburger is fixed/out-of-flow at this width regardless (see
         the 780px block above), so the left reservation added there
         isn't needed once the header itself has already dropped to a
         stacked column layout below — the title block sits on its own
         line beneath both icon rows, not beside either one. */
      padding-left: 0;
      flex-direction: column;
      align-items: flex-start;
    }
    /* Fixed icon rows sit at top:18px and are 38px tall, so their bottom
       edge lands at ~56px from the viewport top. body's own top padding
       is a flat 48px (not reduced on mobile), which alone isn't quite
       enough clearance — push the title block down a bit further so it
       starts comfortably below the icons instead of grazing them. */
    .header-text-wrap { margin-top: 30px; }
  }

  /* NOTE: the quick-stats-row full-width override and the settings-card
     mobile height fix used to live here, but were silently dead on
     arrival: their base (desktop) equivalents — .stat-widget and
     .stack-wrap — are declared further down in this stylesheet, in the
     "Main content: card stack" section, and a later same-specificity
     rule always wins over an earlier one regardless of whether the
     earlier one is inside a media query. So the real desktop rules were
     silently overriding these on every reload, and neither fix ever
     actually took effect on a phone — confirmed by fetching the live
     deployed CSS and diffing rule order. Moved both (rebuilt, and the
     height fix reworked to be measurement-driven instead of a guessed
     constant) to the very end of the stylesheet, after every rule they
     need to beat, so this can't happen again. See that block for the
     real fix. */

  /* ---------- Main content: card stack ---------- */

  /* Reverted to align-items: center. The earlier flex-start attempt was
     the wrong fix for the wrong diagnosis — the real problem was
     .page-wrap being capped at a needlessly huge max-width (see above),
     which left so much leftover space that "centered within .main-content"
     drifted visually right of the sidebar. With .page-wrap trimmed down
     close to the actual content width, centering here now does what it
     looks like it should: the stack sits centered in the space between
     the sidebar's right edge and the header's right-side buttons. */
  .main-content { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center; }

  /* ---------- Quick-stats row (glanceable widgets) ----------
     Mirrors the home page's status-banner language: small, calm,
     numbers-first. Only ever shows widgets for services the user is
     actually enrolled in — same rule as the left sidebar. Sits below the
     card stack (see the placement comment just below), not beside it,
     so it doesn't visually compete with the sidebar's own navigational
     role.
     Used to be paginated (dots + arrows + sideways scroll) — abandoned
     entirely per Carson's own call, after a real, unwinnable fight: the
     shadow on each widget needed room to bleed past its own box, but
     the sliding-page mechanism needed overflow: hidden on this same
     row's container to clip pages cleanly as they slid past each
     other. Every fix that gave the shadow more room ate into that same
     container's clipping boundary, and vice versa — buffer padding
     could shrink the visible clipping gap but never eliminate the
     conflict, since the two features were structurally opposed on the
     same element. Removing pagination removes the opposing need for
     overflow: hidden entirely, so there's nothing left to clip a
     shadow anywhere in this row now — see .stats-widgets-grid below.
     Hidden via JS, not :empty — the nav/edit-button child is always
     present, even with zero widgets. */
  /* Sits BELOW the card stack at every width, matching document order.
     It used to be painted above the stack on desktop via order: -1,
     with a counterpart order: 0 in the mobile overrides undoing it —
     both removed 2026-08-27 (Carson's call, once the widgets went
     large): eight full-size cards above the stack pushed the thing
     people actually sign in for below the fold, and paging them was
     the alternative that got ruled out. Nothing here overrides the
     flex order any more, so this element paints where it sits in the
     document, which is also where a screen reader and the tab order
     have always found it. */
  .stats-carousel-wrap { width: 100%; max-width: 940px; margin: 18px 0 0; }
  .stats-carousel-wrap[hidden] { display: none; }

  /* Every enrolled widget renders directly in here — auto-fit/minmax
     wraps as many equal-width columns as fit (3 on a typical desktop
     width, same as the old pagination's own widest case), reflowing to
     fewer columns automatically as the viewport narrows, all without
     any JS recomputing a column count on resize the way
     getWidgetsPerPage() used to. No overflow: hidden anywhere on this
     element or its ancestors (see .stats-carousel-wrap's own comment
     for why that matters) — .stat-widget-wrap's own shadow, below,
     needs zero buffer math or partial-coverage compromises now; it
     just renders, same as the sidebar's or the main cards' own
     shadows already do. */
  .stats-widgets-grid {
    display: grid;
    /* 220px -> 300px: the large card carries a 44px art tile beside the
       value, plus a detail line under it. Below ~300px the detail line
       ellipsises almost immediately and the art crowds the number. At
       the 940px column this gives a clean 3-up; the old floor gave 4-up
       and the cards were too narrow to be worth the art. */
    grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
    gap: 12px;
    /* flip easter egg — belongs here, not on .stat-widget itself; see
       that rule's own comment for why perspective on the same element
       that rotates doesn't work. Was on the old .stats-page (one per
       page); this grid is the single shared row now, so it's the one
       and only place this needs to live. */
    perspective: 900px;
  }

  /* position:relative kept for consistency with the rest of this
     page's nav-row rules, though nothing here currently needs it — no
     absolutely-positioned sibling to anchor against anymore now that
     Edit Widgets is this row's only child.
     margin-top (new) — Carson's report: after removing widgets down
     to a single row, Edit Widgets sat crowded right against the
     bottom-most row's own shadow, with no breathing room at all (the
     grid above has zero margin-bottom of its own). 24px roughly
     matches .stack-wrap's own 22px gap to .stack-nav below the main
     settings cards, the same "space before a nav row" convention
     already used elsewhere on this page — not an arbitrary number. */
  .stats-nav {
    position: relative; display: flex; align-items: center; justify-content: flex-end;
    margin-top: 24px;
  }

  /* The long-press pencil, hidden everywhere by default — including
     desktop, where .stats-edit-btn is sitting right there and a hidden
     gesture would be a worse route to the same modal. It's revealed
     only at <=600px, only while its widget is armed; see the mobile
     overrides block at the end of this stylesheet and the "Widgets:
     long-press to edit" block in the script. */
  .stat-widget-edit-btn { display: none; }

  .stats-edit-btn {
    display: inline-flex; align-items: center; gap: 6px;
    position: relative;
    /* Carson's ask: same blur/color-split treatment as .stack-arrow
       right beside it (see its own comment for the full reasoning) —
       this button never had backdrop-filter at all before, just plain
       solid var(--surface). Visible fill/border moved to the new
       .stats-edit-btn-fill child below, inserted as the FIRST child —
       this element's own existing ::after (the hover-tint glow, also
       z-index:-1) stays correctly layered on top of the new fill
       rather than conflicting with it, since ::after is conceptually
       the last child and later DOM position wins among same-z-index
       siblings. */
    background: none;
    backdrop-filter: blur(2px) saturate(1.5);
    -webkit-backdrop-filter: blur(2px) saturate(1.5);
    border: 1px solid transparent;
    color: var(--text-dim);
    padding: 6px 12px; border-radius: 999px; font-size: 12px; font-weight: 650; cursor: pointer;
    /* Same light-respecting shadow as .stack-arrow — small-button
       scale (7px reach, 22px blur, fixed 0.20 alpha rather than the
       too-faint var(--shadow) — see .theme-toggle's own comment for
       the full reasoning). Shares the same --arrow-glint-angle-driven
       loop as .stack-arrow, so this comes for free alongside it, not
       a separate computation. */
    box-shadow: var(--shadow-x, 0px) var(--shadow-y, 7px) 22px rgba(0, 0, 0, 0.20);
    /* flip easter egg — background-color/border-color now live on
       .stats-edit-btn-fill (their own explicit transition there,
       matching .stack-arrow-fill), so this element's own list is just
       transform/filter — still declared explicitly rather than relying
       on .flip-pane's own cascade, same reasoning as before: two real
       bugs already caught here once by relying on implicit inheritance
       (see .stats-edit-btn.flipped below for the second one), not
       worth risking a third by changing that part of the approach. */
    transition: transform 0.9s cubic-bezier(.65,.05,.36,1), filter 0.9s ease;
  }
  .stats-edit-btn-fill {
    position: absolute;
    inset: 0;
    z-index: -1;
    pointer-events: none;
    border-radius: inherit;
    background: rgba(var(--surface-rgb), 0.18);
    border: 1px solid var(--border);
    transition: background-color 2400ms ease, border-color 2400ms ease;
  }
  /* flip easter egg — used to combine translateY(-50%) with the
     rotation here, since this button was once absolutely positioned
     (position:absolute; top:50%) to sit centered beside the old
     dots/arrows. Now that it's .stats-nav's only child, laid out via
     ordinary flex (justify-content:flex-end) rather than absolute
     positioning, there's no competing transform left to preserve —
     .flip-pane's own plain rotateY(180deg) rule works correctly on
     its own again, so there's no .stats-edit-btn.flipped override
     needed here at all anymore. */
  /* Same gradient-border glint as .stack-arrow, the arrow buttons on
     the settings-card stack right below — Carson's call: it's its own
     free-standing button outside any card, same as they are, so it
     should get the same treatment. Shares .stack-arrow's own ::before
     rule's exact styling (just a different selector list) rather than
     a separate copy. */
  .stats-edit-btn::before {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: inherit;
    padding: 1.5px;
    /* Sparkle: tight, bright hot-spot layer + background-blend-
       mode:screen on top of the original glint below — same
       technique as index.html's own copy of this. */
    background:
      linear-gradient(var(--arrow-glint-angle, 225deg),
        rgba(255, 255, 255, 1) 0%,
        rgba(255, 255, 255, 0.85) 1.5%,
        rgba(255, 255, 255, 0.3) 4%,
        transparent 9%),
      linear-gradient(var(--arrow-glint-angle, 225deg),
      rgba(255, 255, 255, 0.95) 0%,
      rgba(var(--light-rgb), 0.55) 18%,
      rgba(var(--light-rgb), 0.12) 40%,
      transparent 62%);
    background-blend-mode: screen, normal;
    -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    -webkit-mask-composite: xor;
    mask-composite: exclude;
    pointer-events: none;
  }
  /* flip easter egg — mirror-corrected glint on the back, same
     technique as the arrows right beside it. */
  .stats-edit-btn.flip-mid::before {
    /* Sparkle: tight, bright hot-spot layer + background-blend-
       mode:screen on top of the original glint below — same
       technique as index.html's own copy of this. */
    background:
      linear-gradient(var(--arrow-glint-angle-flipped, 225deg),
        rgba(255, 255, 255, 1) 0%,
        rgba(255, 255, 255, 0.85) 1.5%,
        rgba(255, 255, 255, 0.3) 4%,
        transparent 9%),
      linear-gradient(var(--arrow-glint-angle-flipped, 225deg),
      rgba(255, 255, 255, 0.95) 0%,
      rgba(var(--light-rgb), 0.55) 18%,
      rgba(var(--light-rgb), 0.12) 40%,
      transparent 62%);
    background-blend-mode: screen, normal;
  }
  .stats-edit-btn svg { width: 13px; height: 13px; }
  .stats-edit-btn::after {
    content: "";
    position: absolute; inset: -1px; border-radius: inherit; z-index: -1;
    background: var(--accent-soft); opacity: 0; transition: opacity 0.15s ease;
    pointer-events: none;
  }
  .stats-edit-btn:hover::after { opacity: 1; }
  /* flip easter egg — the haze that veils the back once flipped. This
     button's ::after is ALREADY used for a hover-tint glow (above),
     which needed different inset/z-index/background than the haze —
     .flip-pane's own generic ::after leaves background undeclared for
     exactly this kind of per-element override, but here the hover-tint
     rule ALSO redeclares inset/z-index (to sit slightly larger and
     behind the button, for the glow effect), which would otherwise
     silently win over the haze's own needed inset:0/normal stacking
     since both rules share the same specificity and this one comes
     later in the sheet. Explicit .flipped override needed to correct
     all three, not just add the missing background. */
  .stats-edit-btn.flipped::after {
    inset: 0;
    z-index: auto;
    background: linear-gradient(var(--arrow-glint-angle-flipped, 225deg),
      rgba(255, 255, 255, 0.16) 0%,
      rgba(255, 255, 255, 0.08) 35%,
      rgba(255, 255, 255, 0.03) 65%,
      transparent 100%);
    transition: opacity 0.6s ease 0.35s;
  }
  .stats-edit-btn:hover { color: var(--accent); }
  /* Collapses to icon-only on narrow viewports — same breakpoint and
     technique already used for the header's Back to Home/Sign out
     buttons — so the absolutely-positioned button has minimal width
     and can't collide with the centered dots/arrows next to it. */
  @media (max-width: 600px) {
    .stats-edit-label { display: none; }
    .stats-edit-btn { padding: 6px 9px; }
  }

  /* Carson's follow-up ask, revisiting the earlier "no shadow at all"
     decision: widgets were the one glass surface left without a real
     drop shadow, and it read as visibly inconsistent next to every
     other card on the page now that they're all light-aware. The
     original conflict (documented on .stat-widget below) was real —
     an outset shadow on an element that's BOTH inside an overflow:
     hidden ancestor (.stats-page-wrap, back when the widgets row was
     still paginated and needed it for horizontal paging — since
     removed entirely, see .stats-widgets-grid's own comment) AND has
     preserve-3d on itself (for the flip) hits a documented source of
     inconsistent, partial clipping across browsers. The fix isn't a
     bigger/safer shadow recipe — the same technique already exists
     elsewhere on this exact page: the sidebar's outer <aside> is a
     plain, non-rotating positioning shell, with a separate inner
     .sidebar-flip-card carrying the actual rotating pane, specifically
     so layout/positioning concerns never have to compete with 3D-
     transform ones on the same element. .stat-widget never got that
     split (a shared-parent-perspective model was used instead, see
     .stat-widget's own comment). This wrapper gives it one now:
     .stat-widget-wrap takes over the grid-item sizing (see
     .stats-widgets-grid's own grid-template-columns) and carries THIS
     shadow, while .stat-widget itself — still the actual rotating
     flip-pane, unchanged — no longer needs to. The wrapper itself
     never has preserve-3d or any transform of its own, so it can't
     hit the same clipping conflict — this is a structural fix, not
     another shadow-recipe guess (a category of fix that took four
     failed theories before landing on the inset shadow last time). */
  .stat-widget-wrap {
    position: relative;
    border-radius: 14px;
    box-shadow: var(--shadow-x, 0px) var(--shadow-y, 10px) 30px rgba(0, 0, 0, 0.18);
  }

  .stat-widget {
    display: flex;
    flex-direction: column;
    align-items: stretch;
    justify-content: center;
    gap: 12px;
    position: relative;
    /* Fills .stat-widget-wrap exactly (see its own comment just above
       for why the split exists) — same "fills its parent completely"
       relationship the sidebar's own inner flip-pane already has with
       its outer shell. */
    width: 100%;
    height: 100%;
    /* flip easter egg — perspective moved to .stats-widgets-grid (the
       shared row container, see that rule) — it was wrongly set here before,
       on the SAME element that also rotates, which does nothing for
       that element's own transform (perspective only affects an
       element's CHILDREN, never its own rotation) and was very likely
       the actual cause of the rotation snapping instantly instead of
       animating (Carson's report). A shared parent perspective does
       mean widgets toward the edges of a row rotate with a very slight
       asymmetric look rather than each having its own dead-center
       origin — an accepted, common trade-off rather than the larger
       restructure (a per-widget outer/inner wrapper split, like the
       sidebar and the 4 main cards both needed) that true per-widget
       perspective would require. --flip-thickness needs to be AT LEAST
       this widget's own 14px border-radius (see below), or the strip's
       corner gets clamped to a tighter curve than the widget's true
       one — border-radius can't exceed an element's own width without
       the browser scaling it down to fit, which is exactly what an
       under-sized strip does to its own corner. This was the actual
       bug behind Carson's "sharp corners poking out" report: the
       original 8px (chosen to look proportionate on such a small
       card) was well under the 14px radius it needed to match, so the
       clamped ~8px corner never lined up with the parent's real 14px
       curve — most visible during the 2400ms theme crossfade, where
       the color shift draws the eye right to that mismatched seam.
       15px is the minimum that avoids the clamping outright while
       staying as close as possible to the original's proportions. */
    --flip-thickness: 15px;
    /* Same glass treatment as .stack-card — "same opacity values as the
       main cards / cards in the home page's carousel" per the brief.
       Each widget gets its own live-computed glint angle (see the
       script) since, unlike the stack-cards, these sit side by side at
       different positions rather than sharing one box.
       background/border-color no longer live here — moved to the new
       .stat-widget-fill child below, Carson's site-wide ask after
       confirming the blur/color split technique looks identical to
       this combined approach (see the demo he actually tested). This
       element keeps backdrop-filter itself (static — its blur radius
       never animates, only the fill color underneath does) plus
       border-radius (so the blur still clips to the same rounded
       corner as before). border stays here too, but transparent —
       preserves the exact same box size (border-box includes it) so
       nothing about this element's own dimensions shifts; the VISIBLE
       border color moved to the fill layer along with the background
       it was always paired with. */
    background: none;
    /* Carson's ask, ported from request.html's .glass-card — same
       reasoning as .getting-started-flip-pane on index.html. Applies
       at this base/desktop level only: on desktop these sit spread
       out side by side in a grid with zero overlap, exactly the
       "nothing behind it" case. On mobile, though, they switch to a
       genuinely stacked, animated arrangement (kick/slide/fade
       between widgets, same as .stack-card) — see the mobile media
       query further down for the override that keeps THIS specific
       breakpoint at the original, more opaque values, same reasoning
       Carson gave for leaving .card-flip-pane alone on index.html. */
    backdrop-filter: blur(2px) saturate(1.5);
    -webkit-backdrop-filter: blur(2px) saturate(1.5);
    border: 1px solid transparent;
    border-radius: 14px;
    padding: 14px 16px;
    /* No outset shadow at all now — box-shadow, then filter:drop-
       shadow() as a replacement, both removed. Carson's own
       observation (widgets visibly lacked the drop shadow the main
       cards have, suggesting it was being trimmed by the carousel's
       own clipping) pointed at the real structural conflict: ANY
       shadow that extends outside an element's own box is
       fundamentally in tension with that element needing BOTH
       overflow:hidden on an ancestor (.stats-page-wrap, so the
       carousel clips properly while sliding) AND preserve-3d on
       itself (for the flip) — overflow:hidden clipping of 3D-
       transformed content is a documented source of inconsistent,
       PARTIAL clipping across browsers rather than a clean cut, which
       would leave exactly a stray, jagged, incompletely-clipped
       remnant of the shadow behind — matching what Carson kept
       describing far better than any of the previous four theories
       (edge strips, general compositing, the glint mask, box-shadow's
       own square-fallback) did on their own. Rather than try a fifth
       variant of "add some kind of shadow", removing it outright
       sidesteps the conflict entirely: nothing left that needs to
       extend past this element's own box at all. */
    /* Depth now comes from .stat-widget-wrap's own outset shadow
       instead (see its comment, just above .stat-widget in the
       stylesheet, for the full story of why a wrapper split was the
       actual fix). No shadow — inset OR outset — lives on this
       element anymore; it stays exactly what the reasoning above
       requires: nothing here that needs to render outside its own
       box. */
    /* Carson's follow-up screenshot showed a faint sharp corner STILL
       visible at REST, even with the edge strips now unconditionally
       hidden (opacity:0) until an actual flip starts — proving the
       edge strips were never the real cause here. This element is the
       smallest-radius (14px) thing on the page that combines
       backdrop-filter, border-radius, AND a 3D context
       (perspective/preserve-3d, for the flip) all together, sized to
       fill its parent (width/height:100%, from .stat-widget-wrap's
       own flex:1) rather than a fixed dimension — any of those can
       produce a sub-pixel seam where the blur and the rounded corner
       don't perfectly agree, and that kind of seam is far more
       visible on a small 14px radius than the page's larger ones
       (16px, 26px), which is very likely why nowhere else shows this.
       translateZ(0) forces this onto its
       own GPU compositing layer, a standard, low-risk fix for exactly
       this class of artifact — genuinely experimental, since there's
       no way to fully confirm the exact cause or that this resolves
       it without a live browser. */
    transform: translateZ(0);
    /* flip easter egg — transform/filter added here directly, not left
       to inherit from .flip-pane's own transition list. CSS transition
       is a single property, not merged across rules — since this rule
       is defined AFTER .flip-pane in the stylesheet with equal
       specificity (both single-class selectors), it was completely
       REPLACING .flip-pane's transition rather than adding to it,
       silently dropping the transform transition entirely. That's why
       the rotation was snapping instantly while the haze (a separate
       property, on a rule that was never overridden) kept animating
       correctly — Carson's report. box-shadow is no longer in this
       list — it was here for the old inset shadow's own var(--shadow),
       which changed value between themes and needed a transition to
       avoid snapping; now that the shadow lives on .stat-widget-wrap
       instead, using a fixed (not theme-dependent) color, there's
       nothing here that needs it. */
    /* transform/filter only now — background-color/border-color moved
       to .stat-widget-fill along with the properties they animate. */
    transition: transform 0.9s cubic-bezier(.65,.05,.36,1), filter 0.9s ease;
  }
  .stat-widget-fill {
    position: absolute;
    inset: 0;
    /* Negative, not left at auto/0 — a position:absolute element with
       default z-index still paints ABOVE normal-flow, non-positioned
       siblings (.stat-icon/.stat-text) regardless of DOM order, so
       without this the fill would sit on TOP of the widget's own
       content, not behind it. .stat-widget's own position:relative
       (already there) gives this its stacking context to be negative
       within. */
    z-index: -1;
    pointer-events: none;
    border-radius: 14px;
    /* Same final value request.html's .glass-card-fill landed on
       after real testing — desktop/base level only, see .stat-widget's
       own comment for the mobile override. */
    background: rgba(var(--surface-rgb), 0.18);
    border: 1px solid var(--border);
    transition: background-color 2400ms ease, border-color 2400ms ease;
  }
  .stat-widget::before {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: inherit;
    padding: 1.5px;
    /* Sparkle: tight, bright hot-spot layer + background-blend-
       mode:screen on top of the original glint below — same
       technique as index.html's own copy of this. */
    background:
      linear-gradient(var(--card-glint-angle, 225deg),
        rgba(255, 255, 255, 1) 0%,
        rgba(255, 255, 255, 0.85) 1.5%,
        rgba(255, 255, 255, 0.3) 4%,
        transparent 9%),
      linear-gradient(var(--card-glint-angle, 225deg),
      rgba(255, 255, 255, 0.95) 0%,
      rgba(var(--light-rgb), 0.55) 18%,
      rgba(var(--light-rgb), 0.12) 40%,
      transparent 62%);
    background-blend-mode: screen, normal;
    -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    -webkit-mask-composite: xor;
    mask-composite: exclude;
    pointer-events: none;
    /* Carson's screenshot let me actually SEE the artifact this time,
       not just reason about it — and it's specifically this glint's
       own ring, not the edge strips (already proven innocent — they're
       unconditionally hidden at rest now) or a general compositing
       issue (translateZ(0) on the PARENT didn't help, since the
       problem lives on the parent's own PSEUDO-element, a separate
       thing the parent's own compositing hint doesn't automatically
       extend to). The mask-composite ring technique needs two
       precisely-matched rounded-rect masks (the full box and the
       content-box inset by padding) to compute correctly at every
       point around the curve — at this element's small 14px radius,
       inside a 3D (perspective/preserve-3d) rendering context, that
       computation is very likely landing on a slightly different
       sub-pixel result than the parent's own actual rendered corner,
       visible as the kink right where the ring is brightest. Same
       GPU-layer-forcing idea as the parent's own translateZ(0), just
       applied directly to the element actually producing the visible
       seam this time. */
    transform: translateZ(0);
    will-change: transform;
  }
  /* flip easter egg — mirror-corrected glint + directional haze on the
     back, same reasoning and technique as the sidebar's own copy of
     this (see its comments for the full explanation of why the plain
     angle can't just be reused as-is once rotated). */
  .stat-widget.flip-mid::before {
    /* Sparkle: tight, bright hot-spot layer + background-blend-
       mode:screen on top of the original glint below — same
       technique as index.html's own copy of this. */
    background:
      linear-gradient(var(--card-glint-angle-flipped, 225deg),
        rgba(255, 255, 255, 1) 0%,
        rgba(255, 255, 255, 0.85) 1.5%,
        rgba(255, 255, 255, 0.3) 4%,
        transparent 9%),
      linear-gradient(var(--card-glint-angle-flipped, 225deg),
      rgba(255, 255, 255, 0.95) 0%,
      rgba(var(--light-rgb), 0.55) 18%,
      rgba(var(--light-rgb), 0.12) 40%,
      transparent 62%);
    background-blend-mode: screen, normal;
  }
  .stat-widget::after {
    background: linear-gradient(var(--card-glint-angle-flipped, 225deg),
      rgba(255, 255, 255, 0.16) 0%,
      rgba(255, 255, 255, 0.08) 35%,
      rgba(255, 255, 255, 0.03) 65%,
      transparent 100%);
  }
  .stat-icon {
    width: 36px; height: 36px; border-radius: 9px; flex: none;
    background: var(--accent-soft); color: var(--accent);
    display: flex; align-items: center; justify-content: center;
    transition: background-color 2400ms ease, color 2400ms ease;
  }
  .stat-icon svg { width: 18px; height: 18px; }
  .stat-text { min-width: 0; }
  .stat-app-name {
    font-size: 10.5px; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase;
    color: var(--accent); line-height: 1.3; margin-bottom: 1px;
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  }
  .stat-value { font-size: 17px; font-weight: 700; letter-spacing: -0.01em; line-height: 1.2; }
  .stat-label { font-size: 12px; color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

  /* ---------- Large widget internals ---------- */
  /* Header row: the service badge that used to sit inline beside the
     value now leads the card on its own line, so the art and the number
     get the full width underneath. */
  .stat-head { display: flex; align-items: center; gap: 9px; margin-bottom: 11px; }
  .stat-head .stat-icon { width: 28px; height: 28px; border-radius: 7px; }
  .stat-head .stat-icon svg { width: 14px; height: 14px; }
  .stat-head .stat-app-name { margin-bottom: 0; }

  .stat-main { display: flex; align-items: center; gap: 11px; min-width: 0; }

  /* 2:3 for posters, 1:1 for square covers and box art. Which one is
     decided by the service, not the payload — see .stat-art--square
     below. object-fit keeps a mis-sized upstream image from stretching. */
  .stat-art {
    width: 44px; aspect-ratio: 2 / 3; flex: none;
    border-radius: 5px; object-fit: cover; display: block;
    background: var(--accent-soft);
    box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
    transition: background-color 2400ms ease;
  }
  .stat-art--square { aspect-ratio: 1 / 1; }

  .stat-ring { width: 44px; height: 44px; flex: none; display: block; }
  .stat-ring circle { transition: stroke 2400ms ease; }

  /* Two-part line: the variable half (a title) takes what's left and
     ellipsises; the invariant half (a percentage, a state word) is
     flex: none and never truncates. The old single-nowrap-element
     version put the invariant half at the end of one string, which
     made it the FIRST thing lost — backwards, since it's short and
     it's the more useful half. Works unchanged for the cards that
     have no note; .stat-detail-main alone still ellipsises. */
  .stat-detail {
    margin-top: 11px; padding-top: 9px;
    border-top: 1px solid var(--border);
    font-size: 11.5px; color: var(--text);
    display: flex; align-items: baseline; gap: 6px; min-width: 0;
    transition: border-color 2400ms ease, color 2400ms ease;
  }
  .stat-detail-main {
    flex: 1 1 auto; min-width: 0;
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  }
  .stat-detail-note {
    flex: 0 0 auto; white-space: nowrap; color: var(--text-dim);
    transition: color 2400ms ease;
  }
  /* Separator only when there's something on both sides of it. */
  .stat-detail-main + .stat-detail-note::before {
    content: "\00b7"; margin-right: 6px;
  }

  /* A card with no art and no detail (Immich today) keeps the same
     footprint as its neighbours rather than sitting short in a stretched
     grid cell — the row height is set by the tallest card either way,
     and this is what stops the difference reading as a mistake. */
  .stat-widget > .stat-main:only-of-type { flex: 1; }

  @media (prefers-reduced-motion: reduce) {
    .stat-art, .stat-ring circle, .stat-detail { transition: none; }
  }

  .stack-wrap {
    position: relative;
    width: 100%;
    max-width: 940px;
    /* Deliberately more conservative than before (was 84vh, up to
       940px): at typical laptop/desktop window heights, the old values
       left too little room for the header, quick-stats row, and nav
       dots/arrows below the stack — pushing the whole page into a
       vertical scrollbar and visually cutting into the arrows. Each
       card already scrolls internally via .card-body's own overflow, so
       shrinking the ceiling here trades a little card height for
       guaranteeing the PAGE itself doesn't need to scroll on ordinary
       screens — exactly the tradeoff asked for. */
    height: clamp(480px, 62vh, 820px);
    margin: 0 0 22px;
  }

  .stack-card {
    position: absolute;
    inset: 0;
    /* flip easter egg — .stack-card is now just a positioning shell
       that owns the existing kick/zoom switch-animation transform
       (unchanged below) — the glass styling (background, blur, border,
       the glint) and the NEW flip rotation both moved to
       .stack-card-flip-pane, a child, since .stack-card's own
       transform is already spoken for by the switch animation and
       can't also carry a rotateY flip without the two fighting each
       other. perspective here is what makes that child's rotation
       read as real depth. */
    perspective: 1800px;
    transition: transform 0.9s cubic-bezier(.22,.61,.36,1), opacity 0.7s ease;
    /* will-change removed from here — Carson's ask, part of the iPad
       performance investigation: this was permanently promoting EVERY
       card in the stack to its own compositing layer at all times, not
       just the one or two actually mid-animation during a switch. Now
       set/cleared via JS in goToStackCard() itself, scoped to exactly
       outgoingCard/incomingCard for exactly the duration of their own
       switch animation — see that function's own comments for where.
       Purely an implementation detail; nothing about how any card
       looks, at rest or mid-transition, changes because of this. */
  }
  /* flip easter egg — the actual glass pane now; see .stack-card's own
     comment above for why this split happened. Adds .flip-pane via its
     class list (in the HTML, not repeated here) for the shared
     rotation/haze/reduced-motion handling — this rule only has what's
     specific to settings cards: the glass look itself, filling the
     parent exactly. Thickness bumped up from the shared default —
     these are the largest glass surfaces on the page, a thin edge
     would look undersized next to them. Needs to be AT LEAST this
     element's own 26px border-radius (below), same reasoning and same
     "sharp corners poking out" bug as .stat-widget's own copy of this
     comment — an under-sized strip gets its own corner radius clamped
     down to fit its own width, so 18px was actually the WORST mismatch
     on the page (an 8px gap against this element's 26px radius, more
     than .stat-widget's own 6px gap), even though it wasn't the one
     Carson happened to report — border-radius can't exceed an
     element's own width without the browser silently scaling it down.
     27px is the minimum that clears 26px outright. */
  .stack-card-flip-pane {
    --flip-thickness: 27px;
    width: 100%;
    height: 100%;
    display: flex;
    flex-direction: column;
    position: relative;
    /* background: none / border: transparent — Carson's site-wide ask
       after confirming the blur/color split looks identical to the
       combined approach. Deliberately overriding at this specific
       selector rather than touching .flip-pane's own shared base rule
       (used by several small, unsplit buttons too — .stack-arrow,
       .stats-edit-btn — that don't need this and shouldn't be
       affected by it). .flip-pane's own transition still applies here
       harmlessly (nothing left to transition on background/border-
       color at this element itself); the real fade now lives on
       .stack-card-flip-pane-fill below, its own explicit transition,
       not inherited. border stays 1px transparent (border-box)
       specifically to preserve this element's exact dimensions. */
    background: none;
    backdrop-filter: blur(16px) saturate(1.5);
    -webkit-backdrop-filter: blur(16px) saturate(1.5);
    border: 1px solid transparent;
    border-radius: 26px;
    /* Light-respecting shadow, same technique as the other pages.
       --stack-shadow-x/-y set in recompute() from #stack-wrap's own
       live position, same shared-across-all-cards reasoning as
       --stack-glint-angle above. Intermediate scale (10px reach, 30px
       blur) between the small buttons and the full panels, matching
       this element's own existing in-between weighting (10px/34px). */
    box-shadow: var(--stack-shadow-x, 0px) var(--stack-shadow-y, 10px) 30px rgba(0, 0, 0, 0.18);
    padding: clamp(28px, 3.2vw, 44px) clamp(26px, 3vw, 40px);
  }
  .stack-card-flip-pane-fill {
    position: absolute;
    inset: 0;
    /* Negative, not left at auto/0 — a position:absolute element with
       default z-index still paints ABOVE normal-flow, non-positioned
       siblings (.card-head/.card-body) regardless of DOM order — see
       .stat-widget-fill's own copy of this same comment for the full
       reasoning. */
    z-index: -1;
    pointer-events: none;
    border-radius: inherit;
    background: rgba(var(--surface-rgb), 0.68);
    border: 1px solid var(--border);
    transition: background-color 2400ms ease, border-color 2400ms ease;
  }
  /* flip easter egg — a slight scale-down while flipped, on top of the
     shared rotateY(180deg) — Carson's ask: since these 4 cards are a
     genuine stack (all sharing the exact same box), flipping one
     should reveal the card sitting underneath it, peeking out from
     behind (see triggerStackPeek() in JS, which is what actually makes
     the next card in the stack briefly visible, tracking this card's
     own rotation angle rather than a separate timer). */
  .stack-card-flip-pane.flipped {
    transform: rotateY(180deg) scale(0.94);
    /* Re-declared here explicitly, not inherited from the shared
       .flip-pane.flipped rule anymore (that rule dropped it — see its
       own comment for why). Safe here specifically because the
       double-click listener lives on the OUTER .stack-card, a separate
       element from this flip-pane, so this can't cause the same
       "stuck, can't click to undo" bug widgets/arrows had. Every form
       field/button/accordion inside genuinely needs to stop being
       interactive while "on the other side." */
    pointer-events: none;
  }
  .stack-card-flip-pane::before {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: inherit;
    padding: 1.5px;
    /* Sparkle: tight, bright hot-spot layer + background-blend-
       mode:screen on top of the original glint below — same
       technique as index.html's own copy of this. */
    background:
      linear-gradient(var(--stack-glint-angle, 225deg),
        rgba(255, 255, 255, 1) 0%,
        rgba(255, 255, 255, 0.85) 1.5%,
        rgba(255, 255, 255, 0.3) 4%,
        transparent 9%),
      linear-gradient(var(--stack-glint-angle, 225deg),
      rgba(255, 255, 255, 0.95) 0%,
      rgba(var(--light-rgb), 0.55) 18%,
      rgba(var(--light-rgb), 0.12) 40%,
      transparent 62%);
    background-blend-mode: screen, normal;
    -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    -webkit-mask-composite: xor;
    mask-composite: exclude;
    pointer-events: none;
  }
  /* flip easter egg — mirror-corrected glint + directional haze on the
     back, same technique as the sidebar's own copy of this. */
  .stack-card-flip-pane.flip-mid::before {
    /* Sparkle: tight, bright hot-spot layer + background-blend-
       mode:screen on top of the original glint below — same
       technique as index.html's own copy of this. */
    background:
      linear-gradient(var(--stack-glint-angle-flipped, 225deg),
        rgba(255, 255, 255, 1) 0%,
        rgba(255, 255, 255, 0.85) 1.5%,
        rgba(255, 255, 255, 0.3) 4%,
        transparent 9%),
      linear-gradient(var(--stack-glint-angle-flipped, 225deg),
      rgba(255, 255, 255, 0.95) 0%,
      rgba(var(--light-rgb), 0.55) 18%,
      rgba(var(--light-rgb), 0.12) 40%,
      transparent 62%);
    background-blend-mode: screen, normal;
  }
  .stack-card-flip-pane::after {
    background: linear-gradient(var(--stack-glint-angle-flipped, 225deg),
      rgba(255, 255, 255, 0.16) 0%,
      rgba(255, 255, 255, 0.08) 35%,
      rgba(255, 255, 255, 0.03) 65%,
      transparent 100%);
  }
  .stack-card:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
  /* flip easter egg — see triggerStackPeek() in JS for the full
     reasoning on why this needs a @keyframes animation rather than a
     plain show/hide transition. Sharing the rotation's own exact
     duration (0.9s) and easing curve (cubic-bezier(.65,.05,.36,1)) is
     what makes the 50% keyframe mark (this animation's opacity peak)
     line up with the actual moment the OTHER card's rotation reaches
     90°/edge-on — the two are playing in lockstep against the same
     eased-progress timeline, even though wall-clock time and rotation
     angle don't correspond linearly to each other under this easing.
     Transform stays constant throughout (only opacity actually pulses)
     — the slight scale-down/offset just needs to be there while
     visible at all, it doesn't need its own ramp. */
  @keyframes stack-peek {
    0% { opacity: 0; transform: scale(0.92) translateY(14px); }
    50% { opacity: 0.55; transform: scale(0.92) translateY(14px); }
    100% { opacity: 0; transform: scale(0.92) translateY(14px); }
  }
  .stack-card.peeking {
    /* transform is INSIDE the keyframes above, deliberately, even
       though it's the same constant value at every stop — properties
       animated by @keyframes get special precedence over an element's
       own inline style while the animation is actively running, but a
       plain property on this class rule (not inside the keyframes)
       would NOT get that same treatment. layoutStack() sets
       card.style.transform = "none" inline on every inactive card,
       which would otherwise silently cancel this out, since inline
       styles normally beat class-based CSS. */
    animation: stack-peek 0.9s cubic-bezier(.65,.05,.36,1);
  }
  @media (prefers-reduced-motion: reduce) {
    .stack-card.peeking { animation: none; opacity: 0; }
  }
  /* ---------- Ambient "cloud passing over the light source" ----------
     Carson's idea: an occasional, gradual dimming sweep across every
     glass surface's own glint, staggered by horizontal position (via
     --cloud-delay, set inline per-element in JS) so it reads as one
     "lazy cloud" actually drifting across everything — every glass
     element on the page participates now, not just a few, since a
     real cloud passing overhead would affect the whole scene at once
     rather than a chosen few things. Meant to be rare and easy to miss
     during active use, just a bit of life in an otherwise static
     interface when it's sitting idle. Targets ::before directly since
     a real animation (not a JS-driven opacity toggle) is what lets
     --cloud-delay's per-element stagger work cleanly via a single
     shared @keyframes. */
  /* Slower, gentler "lazy cloud" curve — the previous 1.6s dip-and-back
     read as too quick/abrupt for what's meant to be a lazy drift, not
     a blip. This eases down slowly, lingers dim for a stretch (like a
     genuinely slow-moving cloud actually blocking the light for a
     while, not just grazing past it), then eases back up just as
     slowly. Also a shallower minimum (0.35, not 0.15) — less of a
     flicker, more of a soft dim. */
  @keyframes glint-cloud-pass {
    0%, 100% { opacity: 1; }
    35%, 65% { opacity: 0.35; }
  }
  /* Dims the actual light source glow slightly during the same
     ambient event — Carson's ask: something passing in front of the
     light source is what would cause the reflected glints to dim in
     the first place, so the light itself dipping too (not just its
     reflections) sells that cause-and-effect more convincingly.
     Deliberately gentler than the glints' own dip (they go to 0.35,
     a ~65% reduction; this goes to roughly a ~40% reduction from its
     own 0.26 base) — Carson's own wording was "dim slightly", and
     this is a single, large, ambient glow rather than dozens of small
     reflective highlights, so a heavier dip here would likely read as
     the whole page darkening rather than a subtle atmospheric detail.
     No per-element stagger needed (unlike the glints) since there's
     only one light source, not many — fires immediately when
     cloud-pass-active is added to body, at the START of the whole
     cascade, roughly matching the idea that the cloud reaches the
     light source itself before its shadow ripples out to distant
     reflections. Same 4.5s duration as the glints, for one consistent
     "how long a pass takes" across the whole effect. */
  @keyframes light-glow-cloud-pass {
    0%, 100% { --light-glow-alpha: 0.26; }
    35%, 65% { --light-glow-alpha: 0.16; }
  }
  body.cloud-pass-active {
    animation-name: light-glow-cloud-pass;
    animation-duration: var(--cloud-pass-duration, 4.5s);
    animation-timing-function: ease-in-out;
  }
  @media (prefers-reduced-motion: reduce) {
    body.cloud-pass-active { animation: none; }
  }
  .cloud-pass-active .notif-bell-btn::before,
  .cloud-pass-active .theme-toggle::before,
  /* Added with the two rings up near .user-chip. Without these, the chip
     and the Home button would be the only glints on the page that do not
     dim when a cloud crosses the light. */
  .cloud-pass-active .user-chip::before,
  .cloud-pass-active .header-actions .btn::before,
  .cloud-pass-active .sidebar-flip-card::before,
  .cloud-pass-active .sidebar-services-glass::before,
  .cloud-pass-active .stack-arrow::before,
  .cloud-pass-active .stats-edit-btn::before,
  .cloud-pass-active .stat-widget::before,
  .cloud-pass-active .stack-card-flip-pane::before,
  .cloud-pass-active .confirm-modal-flip-pane::before,
  .cloud-pass-active .options::before,
  .cloud-pass-active .accordion-section::before,
  .cloud-pass-active [data-card="account"] .edit-toggle-btn::before,
  .cloud-pass-active [data-card="account"] .btn-save::before,
  .cloud-pass-active [data-card="account"] .btn-cancel::before,
  .cloud-pass-active #services-edit-btn::before,
  .cloud-pass-active .avatar-upload-btn::before,
  .cloud-pass-active #inbox-select-toggle-btn::before,
  .cloud-pass-active #attach-media-btn::before {
    animation-name: glint-cloud-pass;
    animation-duration: var(--cloud-pass-duration, 4.5s);
    animation-timing-function: ease-in-out;
    animation-delay: var(--cloud-delay, 0s);
  }
  @media (prefers-reduced-motion: reduce) {
    .cloud-pass-active .notif-bell-btn::before,
    .cloud-pass-active .theme-toggle::before,
    .cloud-pass-active .user-chip::before,
    .cloud-pass-active .header-actions .btn::before,
    .cloud-pass-active .sidebar-flip-card::before,
    .cloud-pass-active .sidebar-services-glass::before,
    .cloud-pass-active .stack-arrow::before,
    .cloud-pass-active .stats-edit-btn::before,
    .cloud-pass-active .stat-widget::before,
    .cloud-pass-active .stack-card-flip-pane::before,
    .cloud-pass-active .confirm-modal-flip-pane::before,
    .cloud-pass-active .options::before,
    .cloud-pass-active .accordion-section::before,
    .cloud-pass-active [data-card="account"] .edit-toggle-btn::before,
    .cloud-pass-active [data-card="account"] .btn-save::before,
    .cloud-pass-active [data-card="account"] .btn-cancel::before,
    .cloud-pass-active #services-edit-btn::before,
    .cloud-pass-active .avatar-upload-btn::before,
    .cloud-pass-active #inbox-select-toggle-btn::before,
    .cloud-pass-active #attach-media-btn::before {
      animation: none;
    }
  }
  @media (prefers-reduced-motion: reduce) {
    /* The kick/zoom card-switch sequence in goToStackCard() already
       branches to a plain opacity crossfade under reduced motion — but
       that JS branch clears its own inline transition overrides at the
       end and hands off to layoutStack(), which would otherwise fall
       back to this stylesheet's own 0.9s/0.7s default transition (not
       itself reduced-motion-aware). This closes that gap so the
       settling step can't silently animate slowly even when the active
       switch itself was already shortened. */
    .stack-card { transition: none; }
  }

  .card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; flex: none; }
  .card-head h2 { font-size: clamp(20px, 1.6vw + 13px, 27px); font-weight: 650; letter-spacing: -0.01em; margin: 0; }
  .card-sub { font-size: clamp(13px, 0.3vw + 12px, 15px); color: var(--text-dim); margin: -6px 0 18px; flex: none; }

  .card-body {
    flex: 1;
    min-height: 0;
    overflow-y: auto;
    /* Carson's report: nested scroll not responding to touch at all on
       real iOS (Edge for iOS too — Apple requires every iOS browser to
       use WebKit under the hood, so that rules out this being an
       artifact of any one specific app's own preview rendering, and
       points at something genuinely missing from this element's own
       CSS). This exact property is already relied on elsewhere in this
       same file for the identical class of problem — the mobile
       sidebar's own .sidebar-flip-inner scroll — just never carried
       over to this element. Modern iOS is supposed to treat plain
       overflow-y:auto as inherently touch-scrollable without this, but
       "supposed to" clearly wasn't holding up in practice here, nested
       as deep as this element sits inside .stack-card's own absolute
       positioning and .stack-card-flip-pane's flex layout — the same
       kind of complex nesting where this property has a real, documented
       history of mattering regardless of the OS version. */
    -webkit-overflow-scrolling: touch;
    display: flex;
    flex-direction: column;
    gap: clamp(12px, 1.2vw, 18px);
  }

  /* Fade mask, not a shadow — Carson's own redirect after several
     failed shadow attempts (all documented history, now removed):
     a small fade at the top/bottom of scrolled content instead of a
     hard cutoff. Genuinely simpler and more robust than anything the
     shadow route tried: mask-image fades the CONTENT itself (rows,
     backgrounds, everything) uniformly, so it never runs into the
     "opaque row background hides the effect" problem every shadow
     attempt kept hitting — there's nothing for a row's own background
     to sit "on top of" here. --fade-top/--fade-bottom are set by JS
     (below) per element, per scroll position: 0px at whichever edge
     genuinely has nothing more beyond it (so a card that fits without
     scrolling shows no fade at all, and neither edge fades once
     you've scrolled all the way to it), a real size at any edge that
     still has more content past it. Extended to the four nested lists
     inside these cards too (.admin-user-list, .admin-unban-list,
     .thread-list, .session-list) — each has its own independent
     scroll, same as .card-body's own, and the same cutoff applied
     there. */
  .card-body, .admin-user-list, .admin-unban-list, .thread-list, .session-list {
    --fade-top: 0px;
    --fade-bottom: 0px;
    mask-image: linear-gradient(to bottom, transparent, black var(--fade-top), black calc(100% - var(--fade-bottom)), transparent);
    -webkit-mask-image: linear-gradient(to bottom, transparent, black var(--fade-top), black calc(100% - var(--fade-bottom)), transparent);
  }

  .edit-toggle-btn {
    display: inline-flex;
    align-items: center;
    background: var(--surface);
    border: 1px solid var(--border);
    color: var(--accent);
    font-size: 13px;
    font-weight: 650;
    cursor: pointer;
    padding: 6px 14px;
    border-radius: 999px;
    position: relative;
    /* Pure opacity now — genuinely, not just in the comment (a real gap
       last round: this said "opacity-only" while the code right below
       it still animated max-height alongside it, which meant the
       button's own height was visibly changing WHILE it faded — a
       combined height+opacity animation on something this small reads
       as a wobble, not a clean fade, and was very likely the actual
       source of the "stuttery" feeling reported twice now). Starts
       visible (reveal-open baked into the initial HTML class list),
       unlike most other reveal-block elements which start closed. */
    opacity: 1;
    transition: transform 0.15s ease, opacity 0.18s ease,
                background-color 2400ms ease, border-color 2400ms ease, color 2400ms ease;
  }
  .edit-toggle-btn:not(.reveal-open) { opacity: 0; pointer-events: none; }
  .edit-toggle-btn[hidden] { display: none !important; }
  @media (prefers-reduced-motion: reduce) {
    .edit-toggle-btn { transition: transform 0.15s ease, opacity 0.1s ease, background-color 2400ms ease, border-color 2400ms ease, color 2400ms ease; }
  }
  .edit-toggle-btn:hover { transform: translateY(-1px); }

  /* ---------- Account Info card: avatar ---------- */
  .avatar-row {
    display: flex; align-items: center; gap: 16px;
    padding: 4px 0 18px 0; border-bottom: 1px solid var(--border);
  }
  /* --accent-strong, not --accent — same contrast fix as the pill-
     button harmonization pass. 22px bold technically counts as "large
     text" under WCAG (only needs 3:1, not 4.5:1) — light mode's own
     --accent already clears that on its own — but dark mode's --accent
     measures 2.33:1, below even that relaxed threshold. */
  .avatar-circle {
    width: 64px; height: 64px; border-radius: 50%; flex: none;
    background: var(--accent-strong); color: #fff;
    display: flex; align-items: center; justify-content: center;
    font-size: 22px; font-weight: 700; overflow: hidden;
    position: relative;
  }
  .avatar-circle img { width: 100%; height: 100%; object-fit: cover; display: block; }
  .avatar-circle.uploading::after {
    content: ""; position: absolute; inset: 0; border-radius: 50%;
    background: rgba(0,0,0,0.35);
  }
  .avatar-actions { display: flex; flex-direction: column; gap: 8px; }
  .avatar-btn-row { display: flex; gap: 8px; flex-wrap: wrap; }
  .avatar-upload-btn, .avatar-remove-btn {
    position: relative;
    padding: 8px 14px; border-radius: 999px; font-size: 13px; font-weight: 650; cursor: pointer;
    border: 1px solid var(--border); background: var(--surface); color: var(--text);
    /* Idle colors fade at the theme's 2400ms; the hover tint is a
       separate overlay (::after, below) on its own fast transition —
       same "background-color can't run at two different speeds for
       two different triggers" reasoning as the dual-purpose elements
       flagged in the site-wide theme-transition audit (.credits-btn,
       .dots button, and similar). */
    transition: background-color 2400ms ease, border-color 2400ms ease, color 2400ms ease, transform 0.15s ease;
  }
  .avatar-upload-btn::after, .avatar-remove-btn::after {
    content: "";
    position: absolute;
    inset: -1px; /* covers the border too, matching the old hover fill exactly */
    border-radius: inherit;
    z-index: -1;
    background: var(--accent-soft);
    opacity: 0;
    transition: opacity 0.15s ease;
    pointer-events: none;
  }
  .avatar-upload-btn:hover::after, .avatar-remove-btn:hover::after { opacity: 1; }
  /* Lift added — Carson's report: this button felt like it "didn't do
     anything" on hover next to buttons elsewhere on the page that do
     lift. It already had a color shift and a background-tint fade-in
     (right above); what it was missing was actual motion. */
  .avatar-upload-btn:hover { color: var(--accent); transform: translateY(-1px); }
  .avatar-remove-btn { color: var(--down); }
  .avatar-remove-btn:hover { color: var(--down); transform: translateY(-1px); }
  .avatar-remove-btn:disabled, .avatar-upload-btn:disabled { opacity: 0.5; cursor: default; }
  /* Glint parity with the sidebar — Upload Photo specifically, per
     Carson's request (not Remove Photo, which he didn't ask for). Uses
     ::before since ::after is already the hover-tint overlay here. */
  .avatar-upload-btn::before {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: inherit;
    padding: 1px;
    /* Sparkle: tight, bright hot-spot layer + background-blend-
       mode:screen on top of the original glint below — same
       technique as index.html's own copy of this. */
    background:
      linear-gradient(var(--section-glint-angle, 225deg),
        rgba(255, 255, 255, 1) 0%,
        rgba(255, 255, 255, 0.85) 1.5%,
        rgba(255, 255, 255, 0.3) 4%,
        transparent 9%),
      linear-gradient(var(--section-glint-angle, 225deg),
      rgba(255, 255, 255, 0.9) 0%,
      rgba(var(--light-rgb), 0.45) 22%,
      transparent 48%);
    background-blend-mode: screen, normal;
    -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    -webkit-mask-composite: xor;
    mask-composite: exclude;
    pointer-events: none;
  }
  .avatar-hint { font-size: 12px; color: var(--text-dim); }
  .avatar-error { font-size: 12.5px; color: var(--down); margin-top: 2px; }
  .avatar-error[hidden] { display: none; }

  /* ---------- Avatar position modal ----------
     Reuses .confirm-modal/.confirm-modal-flip-pane wholesale (same
     glass/flip/glint treatment as the confirm/share/widget-edit
     dialogs) — only the width override and this modal's own specific
     content (the drag stage) are declared here. */
  .avatar-position-modal { max-width: 360px; }
  .avatar-position-stage {
    position: relative;
    width: 260px;
    height: 260px;
    margin: 16px auto;
    border-radius: 12px;
    overflow: hidden;
    background: var(--bg);
    cursor: grab;
    /* Prevents the browser's own touch-scroll/pinch-zoom gestures from
       fighting the drag on mobile — without this, a finger-drag here
       would try to scroll the whole page at the same time. */
    touch-action: none;
  }
  .avatar-position-stage.dragging { cursor: grabbing; }
  .avatar-position-img {
    position: absolute;
    top: 0;
    left: 0;
    /* width/height set inline by JS once the image loads (the "cover"
       fit at scale 1, computed against the stage's own fixed 260px
       box) and never touched again — panning AND zooming are both
       handled purely via the transform (also inline, from JS:
       translate() for position, scale() for zoom) so neither forces a
       layout reflow, staying smooth through a fast pinch or scroll
       gesture. transform-origin:0 0 is what makes scale() grow the
       element from its own top-left corner rather than the default
       center — see applyTransform()'s own comment in the script for
       why that specific corner matters for the position math. user-
       select/drag disabled so a click-drag doesn't trigger the
       browser's own "drag this image" ghost or a text-selection
       cursor instead of the actual reposition gesture. */
    transform-origin: 0 0;
    user-select: none;
    -webkit-user-drag: none;
    pointer-events: none;
  }
  .avatar-position-mask {
    position: absolute;
    inset: 0;
    border-radius: 50%;
    /* The actual "circle open, outside darkened but translucent" effect
       — see this element's own comment in the HTML for why a box-shadow
       trick rather than a clip-path cutout. 0.55 alpha — dark enough to
       clearly separate the circle from its surroundings, translucent
       enough to still see roughly where the rest of the photo sits,
       matching Carson's own "still somewhat translucent" ask. */
    box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.55);
    pointer-events: none;
  }
  .avatar-position-error { font-size: 12.5px; color: var(--down); margin: 0 0 4px; }
  .avatar-position-error[hidden] { display: none; }

  .info-row { display: flex; flex-direction: column; gap: 6px; padding: clamp(10px, 1vw, 16px) 0; border-bottom: 1px solid var(--border); }
  .info-row:last-child { border-bottom: none; }
  .info-row label { font-size: 11.5px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-dim); }
  .info-display { font-size: clamp(15px, 0.4vw + 14px, 18px); font-weight: 550; }
  .info-input {
    font-size: clamp(15px, 0.4vw + 14px, 18px); font-family: inherit; padding: 10px 12px; border-radius: 9px;
    border: 1px solid var(--border); background: var(--bg); color: var(--text);
  }
  /* outline-offset (even at 1px) draws slightly outside the input's own
     box — and .card-body's overflow-y:auto implicitly forces
     overflow-x:auto too (CSS spec: can't have only one axis "visible"),
     so that offset ring was getting clipped/scrolled by the card body
     instead of rendering cleanly. box-shadow-as-focus-ring draws INSIDE
     the element's own bounds (via inset), so it can never be clipped by
     an ancestor's overflow, regardless of that ancestor's scroll axes.
     Carson's report, 2026-08-11: focused Name/Email inputs showed
     clipped/cut-off edges and another element peeking through beneath. */
  .info-input:focus-visible {
    outline: none;
    box-shadow: inset 0 0 0 2px var(--accent);
  }

  .card-actions { display: flex; gap: 10px; margin-top: 14px; flex: none; }
  /* ---------- Reusable reveal-block ----------
     Shared conditional-visibility treatment for save/cancel bars, edit-
     mode field blocks, save-notes, and the "currently posted" panel —
     anywhere something toggles between shown/hidden that used to just
     snap via the [hidden] attribute. Added alongside each element's own
     existing class (.card-actions, .admin-save-bar, .save-note, etc.)
     rather than replacing anything, and driven by revealBlock() in JS
     instead of direct .hidden assignment.
     max-height, not grid-template-rows: with this many different
     elements to cover, a numeric cap generous enough for the tallest of
     them (matching the accordion-content reveal's own reasoning) was
     far less structural risk than converting every one of them to a
     grid layout just for this. */
  .reveal-block {
    overflow: hidden;
    max-height: 0;
    opacity: 0;
    /* Tightened from 0.32s/0.22s — noticeably slower than
       animateFieldSwap's 0.16–0.22s crossfade, which is what was
       reading as "uneven"/"stuttery" between the two (Carson's report:
       Save/Cancel bars settling well after the fields they sit next to
       had already finished). Now much closer to the same pace. */
    transition: max-height 0.24s cubic-bezier(.22,.61,.36,1), opacity 0.2s ease, margin-top 0.24s cubic-bezier(.22,.61,.36,1);
  }
  .reveal-block.reveal-open {
    opacity: 1;
    /* This max-height:none is only a FALLBACK — for elements that
       start already-open via static HTML (password-display-note has
       "reveal-block reveal-open" baked into its initial class list, so
       nothing ever triggers the JS that would otherwise set an inline
       value for it). During an actual revealBlock() open/close, the
       inline style it sets always wins regardless of this rule's
       specificity, so there's no conflict — this only ever matters
       before JS has touched the element at all. Real transitions are
       measured to the element's own real height instead of a fixed
       generic cap (the previous 600px) — a fixed cap meant most of a
       collapse/reveal's transition duration was spent animating
       through max-height values with zero visual effect until right
       at the very end, which is what made these feel abrupt/juttery
       rather than smooth (confirmed on the Unban Requests Discard
       button specifically, but the same underlying cause applied
       everywhere this class is used, just less noticeably on content
       closer to 600px). */
    max-height: none;
  }
  /* Safety net, not decoration — a plain [hidden]{display:none} from the
     browser's own default stylesheet has the same specificity as an
     author rule, and several of the elements this class gets added to
     (.card-actions, .admin-save-bar) already set their own display
     property. Without this, [hidden] silently loses that tie and the
     "closed" element stays visible/interactive — the exact bug already
     hit once before with .site-banner-close. */
  .reveal-block[hidden] { display: none !important; }
  @media (prefers-reduced-motion: reduce) {
    .reveal-block { transition: opacity 0.15s ease; }
  }
  .btn-save {
    flex: 1; padding: 13px; border-radius: 999px; border: none;
    background: var(--text); color: var(--bg); font-size: 15px; font-weight: 650; cursor: pointer;
    position: relative;
    /* :active press-feedback added — Carson wasn't sure the Save
       Changes button animated at all, and the existing disabled-state
       fade (opacity 1→0.6) is genuinely subtle and often over almost
       as soon as it starts if the save resolves quickly. This gives an
       immediate, unambiguous "something happened" the instant it's
       clicked, independent of how fast the actual save call finishes. */
    transition: opacity 0.15s ease, transform 0.1s ease, background-color 2400ms ease, color 2400ms ease;
  }
  .btn-save:hover { opacity: 0.9; transform: translateY(-1px); }
  .btn-save:active:not(:disabled) { transform: scale(0.97); }
  .btn-save:disabled { opacity: 0.6; cursor: default; }
  .btn-cancel {
    padding: 13px 18px; border-radius: 999px; border: 1px solid var(--border);
    background: var(--surface); color: var(--text); font-size: 15px; font-weight: 600; cursor: pointer;
    position: relative;
    transition: transform 0.1s ease, background-color 2400ms ease, border-color 2400ms ease, color 2400ms ease;
  }
  /* Just the lift, no color shift — .btn-cancel is deliberately the
     neutral/safe choice (see the comment on .confirm-btn-yes/-no
     further down for the full "bias toward safe" reasoning this
     shares), so staying visually neutral on hover too, rather than
     shifting toward --accent the way .edit-toggle-btn does, is
     intentional here — not a gap, the harmonization goal was
     consistent MOTION across buttons, not identical color behavior
     regardless of what each button is actually for. */
  .btn-cancel:hover { transform: translateY(-1px); }
  .btn-cancel:active { transform: scale(0.97); }
  /* Glint parity with the sidebar — every pill button on the Account
     Info card (Edit/Change, Save Changes, Cancel — both the profile
     row's own pair and the password row's own pair) plus the Edit
     button on the Services card specifically, per Carson's request.
     Scoped to [data-card="account"] rather than the bare classes,
     since .btn-save/.btn-cancel are shared by every other card too and
     weren't asked for there. */
  [data-card="account"] .edit-toggle-btn::before,
  [data-card="account"] .btn-save::before,
  [data-card="account"] .btn-cancel::before,
  #services-edit-btn::before {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: inherit;
    padding: 1px;
    /* Sparkle: tight, bright hot-spot layer + background-blend-
       mode:screen on top of the original glint below — same
       technique as index.html's own copy of this. */
    background:
      linear-gradient(var(--section-glint-angle, 225deg),
        rgba(255, 255, 255, 1) 0%,
        rgba(255, 255, 255, 0.85) 1.5%,
        rgba(255, 255, 255, 0.3) 4%,
        transparent 9%),
      linear-gradient(var(--section-glint-angle, 225deg),
      rgba(255, 255, 255, 0.9) 0%,
      rgba(var(--light-rgb), 0.45) 22%,
      transparent 48%);
    background-blend-mode: screen, normal;
    -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    -webkit-mask-composite: xor;
    mask-composite: exclude;
    pointer-events: none;
  }

  /* Own tuned reveal rather than the plain .reveal-block treatment —
     text this short (usually one line) looks better sliding up into
     place than growing via a tall max-height cap meant for whole
     blocks. Still driven by the same revealBlock()/.reveal-open pair,
     just with save-note-specific values layered on top via a more
     specific selector. */
  .save-note {
    font-size: 12.5px; color: var(--ok); margin: 10px 0 0; flex: none;
    overflow: hidden;
    max-height: 0;
    opacity: 0;
    transform: translateY(-4px);
    transition: max-height 0.22s ease, opacity 0.22s ease, transform 0.22s cubic-bezier(.22,.61,.36,1);
  }
  .save-note.reveal-open {
    opacity: 1;
    transform: translateY(0);
    /* max-height no longer fixed at 60px — same fix as .reveal-block,
       same reasoning: revealBlock() measures this element's own real
       height and sets it inline instead. */
  }
  .save-note.error { color: var(--down); }
  .save-note[hidden] { display: none; }
  @media (prefers-reduced-motion: reduce) {
    .save-note { transition: opacity 0.15s ease; transform: none; }
  }

  /* ---------- Services card: grouped, icon + description rows ----------
     Replaces the old bare-checkbox-and-name list. Visually matches
     claldred.com/request's "Choose Your Services" section — same
     grouping by category, same icon-badge + name + description layout —
     so returning users recognize the pattern, and new users actually
     know what each app does before deciding whether to enable it.
     request.html's own .group/.option/.icon-badge rules were the direct
     source for these; kept the same class names on purpose. */
  .group { margin-bottom: 20px; }
  .group:last-child { margin-bottom: 0; }
  .group-title {
    font-size: 11.5px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase;
    color: var(--text-dim); margin: 0 0 8px;
  }
  .options {
    display: flex; flex-direction: column; gap: 1px;
    background: var(--border); border: 1px solid var(--border);
    border-radius: 14px; overflow: hidden;
    position: relative;
    transition: background-color 2400ms ease, border-color 2400ms ease;
  }
  /* Glint parity with the sidebar's own rows — covers every service
     group box (Plex+Seerr, Authentik, Audiobookshelf, Immich+
     Nextcloud, Mealie, RomM all share this exact .options class) plus
     the static "Core & Utilities" list (.options.core) in one shared
     rule, since they're all the same visual treatment. Angle computed
     per-element in recompute() like every other glass surface here. */
  .options::before {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: inherit;
    padding: 1px;
    /* Sparkle: tight, bright hot-spot layer + background-blend-
       mode:screen on top of the original glint below — same
       technique as index.html's own copy of this. */
    background:
      linear-gradient(var(--section-glint-angle, 225deg),
        rgba(255, 255, 255, 1) 0%,
        rgba(255, 255, 255, 0.85) 1.5%,
        rgba(255, 255, 255, 0.3) 4%,
        transparent 9%),
      linear-gradient(var(--section-glint-angle, 225deg),
      rgba(255, 255, 255, 0.9) 0%,
      rgba(var(--light-rgb), 0.45) 22%,
      transparent 48%);
    background-blend-mode: screen, normal;
    -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    -webkit-mask-composite: xor;
    mask-composite: exclude;
    pointer-events: none;
  }
  .option {
    display: flex; align-items: center; gap: 12px;
    background: var(--surface); padding: 13px 15px; cursor: pointer;
    transition: background-color 2400ms ease;
  }
  /* ---------- Services card: Plex & Seerr ----------
     Reverted to the exact same .group/.options/.option markup and
     styling as the svc-* grid below (per Carson's explicit ask,
     2026-08-11 — the earlier separate .plex-row treatment looked out of
     place next to it). Plex/Seerr are still functionally distinct (their
     own state machine + external API calls instead of a simple
     Authentik group PATCH), but that's handled in JS/endpoints, not by
     giving them different visual chrome. */
  .plex-status-note {
    font-size: 12.5px; color: var(--accent); background: var(--accent-soft);
    border-radius: 10px; padding: 9px 12px; margin: 8px 0 0; line-height: 1.4;
  }
  .plex-status-note.error { color: var(--down); background: transparent; border: 1px solid var(--down); }

  .option .icon-badge {
    width: 34px; height: 34px; border-radius: 9px; flex: none;
    background: var(--accent-soft); color: var(--accent);
    display: flex; align-items: center; justify-content: center;
    transition: background-color 2400ms ease, color 2400ms ease;
  }
  .option .icon-badge svg { width: 17px; height: 17px; }
  .option .info { flex: 1; min-width: 0; }
  .option .info h4 { margin: 0 0 2px; font-size: 14.5px; font-weight: 650; }
  .option .info p { margin: 0; font-size: 12.5px; color: var(--text-dim); line-height: 1.4; }
  /* The two notes the shared pair rule writes (ServiceRegistry.pairRules,
     1 Oct 2026), the same pair /request shows: why a service was ticked for
     you, and what it needs ticked first. Shown only while editing. */
  .option .info p.auto-tag,
  .option .info p.needs-tag { display: none; margin-top: 4px; font-size: 11px; font-weight: 600; }
  .option .info p.auto-tag { color: var(--accent); }
  .option .info p.auto-tag.show,
  .option .info p.needs-tag.show { display: block; }
  /* The phone app line's styles live in mobile-app.css, beside the script
     that draws it, since /request shows the same line (2 Oct 2026). */

  /* ---------------------------------------------------------------------
     Admin card, Services section: the estate view.

     Deliberately plain. This is a reference list read occasionally, not a
     surface anyone works in, and it sits in a card alongside controls that
     do things - so it should recede rather than compete. No cards, no
     hover states, no icons per row: 28 rows want to be scannable in one
     pass, and decoration at that count reads as noise.

     Every custom property below carries a fallback. This block renders in
     a card that may be reached before the theme has settled, and a missing
     token should degrade to something readable rather than to nothing.
     --------------------------------------------------------------------- */
  .estate-sub {
    margin: 0 0 12px;
    font-size: 12.5px;
    color: var(--text-dim);
  }

  /* Add-a-service form. Placed beside the estate styles deliberately -
     both render inside the admin card's accordion, so whatever context
     encloses those rules is the context these need too. */

  .addsvc-group { margin: 0 0 18px; }
  .addsvc-group:last-child { margin-bottom: 0; }

  .addsvc-group-title {
    margin: 0 0 6px;
    font-size: 11.5px;
    font-weight: 700;
    letter-spacing: 0.05em;
    text-transform: uppercase;
    color: var(--accent, var(--text-dim));
  }

  .addsvc-row {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr);
    align-items: center;
    gap: 2px 12px;
    padding: 7px 0;
    border-top: 1px solid rgba(128, 128, 128, 0.16);
  }

  /* NOT REDUNDANT, and the rule above is why. A relevant field is hidden
     by setting the `hidden` attribute, which hides through the user agent
     stylesheet's `[hidden] { display: none }` -- and an AUTHOR declaration
     beats a user agent one whatever the specificity. `.addsvc-row` setting
     `display: grid` therefore overrides it silently, and a row told to
     hide stays exactly where it was.

     This is the shape of bug a DOM test cannot see: a harness that does
     not load this stylesheet has only the user agent rule, so `hidden`
     works there and the reveal passes a click-through while failing in a
     browser. */
  .addsvc-row[hidden] { display: none; }

  /* The category field is a WRAPPER rather than an input: the picker, the
     box for a new name, and the sentence saying when the new group
     actually appears. The last two are hidden until "Add new" is chosen.

     Their hide is spelled out here rather than left to the user agent, for
     the reason the rule above gives. Nothing sets `display` on those two
     today, so the UA rule would in fact hold; this is what keeps that true
     when something later does. */
  .addsvc-cat {
    display: grid;
    gap: 6px;
    min-width: 0;
  }

  /* The licence dropdown (package E8a): the same stacked shape as the
     category picker, its typed box shown only for "Other…". */
  .addsvc-pick {
    display: grid;
    gap: 6px;
    min-width: 0;
  }
  .addsvc-pick-other[hidden] { display: none; }

  .addsvc-cat[hidden],
  .addsvc-cat-new[hidden],
  .addsvc-cat-note[hidden],
  /* The same rule, and the same reason it is needed: `[hidden]` is a UA
     rule that `display: grid` overrides, and three of these five are set
     hidden from script on every change of category. */
  .addsvc-cat-rename[hidden],
  .addsvc-cat-rename-open[hidden],
  .addsvc-cat-rename-out[hidden] { display: none; }

  /* The rename row, which is the one control in this card that edits the
     ESTATE rather than describing a service that does not exist yet. Boxed
     and inset so it does not read as another field of the form -- a control
     that looks like its neighbours invites a click meant for one of them,
     and this one opens a pull request against seven services' worth of
     records. Shut by default; two clicks to use. */
  .addsvc-cat-rename {
    display: grid;
    gap: 6px;
    min-width: 0;
    margin-top: 2px;
    padding: 8px 10px;
    border-radius: 8px;
    border: 1px solid rgba(128, 128, 128, 0.28);
    background: var(--input-bg, rgba(128, 128, 128, 0.08));
  }

  /* Full width inside the box, matching every other input in the card. */
  .addsvc-cat-rename .addsvc-input { width: 100%; }

  .addsvc-cat-rename-out {
    margin: 0;
    font-size: 12px;
  }

  /* What happened to the Homarr boards, in the same paragraph as the
     pull-request link and dimmed, so the link stays the thing you click.
     A span rather than its own block on purpose: the two are one answer to
     one action, and splitting them invites reading only the first. */
  .addsvc-cat-rename-boards { color: var(--text-dim); }

  .addsvc-label {
    font-size: 13px;
    font-weight: 600;
    overflow-wrap: anywhere;
  }

  /* Full width under both columns. The dotted path and the limits belong
     to the field, not to the label, and putting them beside the input
     would compete with the value being typed into it. */
  .addsvc-note {
    grid-column: 1 / -1;
    margin: 0;
    font-size: 11px;
    color: var(--text-dim);
    overflow-wrap: anywhere;
  }

  /* WHAT IS TYPED IS FULL-COLOUR TEXT, never inherited (Carson, 6 Oct
     2026: typed Name and Image "stay grey", so it was "hard to tell or
     remember if I edited those fields or not"). A box inside a caption --
     .addsvc-stack-field's dim label -- inherited that dim grey, so a typed
     value and an empty box's example looked the same. */
  .addsvc-input,
  .addsvc-list-item {
    width: 100%;
    min-width: 0;
    box-sizing: border-box;
    font: inherit;
    font-size: 13px;
    padding: 6px 8px;
    border-radius: 7px;
    border: 1px solid rgba(128, 128, 128, 0.32);
    background: var(--input-bg, rgba(128, 128, 128, 0.08));
    color: var(--text);
  }

  /* AN EMPTY BOX'S EXAMPLE reads as one: faint and slanted, and every
     example starts "e.g." (admin-add-words.js, admin-stack.js), so it is
     never mistaken for a value already set -- the "10" and "3" of an
     empty log rotation were, on the Nextcloud test. */
  .addsvc-input::placeholder,
  .addsvc-list-item::placeholder {
    color: var(--text-dim);
    opacity: 0.7;
    font-style: italic;
  }

  .addsvc-input[type="checkbox"] {
    width: 18px;
    height: 18px;
    padding: 0;
    justify-self: start;
  }

  /* Amber, not red. A conditional field is not an error - it is one that
     may or may not apply, and colouring it like a problem would train the
     eye to ignore actual problems. Same reasoning as the estate view's
     drift tags. */
  .addsvc-row-conditional .addsvc-label { color: var(--warn, #b7791f); }

  /* A list field. Rows stack in the input column and the Add button sits
     under them, so a list occupies the same grid cell a single input does
     and does not change the shape of the form around it. */
  .addsvc-list,
  .addsvc-list-rows {
    display: flex;
    flex-direction: column;
    gap: 6px;
    min-width: 0;
  }

  .addsvc-list-row {
    display: flex;
    align-items: center;
    gap: 6px;
  }

  .addsvc-list-item { flex: 1 1 auto; }

  .addsvc-list-remove {
    flex: 0 0 auto;
    width: 26px;
    height: 26px;
    padding: 0;
    font: inherit;
    font-size: 15px;
    line-height: 1;
    cursor: pointer;
    border-radius: 7px;
    border: 1px solid rgba(128, 128, 128, 0.32);
    background: var(--input-bg, rgba(128, 128, 128, 0.08));
    color: var(--text-dim);
  }

  /* The two rename buttons wear the list's own small-button look rather
     than one of their own: they are the same size of action in the same
     card, and a second set of values here is a second set to keep in step
     the next time this one is adjusted. */
  .addsvc-list-add,
  .addsvc-cat-rename-open,
  .addsvc-cat-rename-go {
    align-self: start;
    font: inherit;
    font-size: 12px;
    padding: 4px 10px;
    cursor: pointer;
    border-radius: 7px;
    border: 1px solid rgba(128, 128, 128, 0.32);
    background: var(--input-bg, rgba(128, 128, 128, 0.08));
    color: inherit;
  }

  /* Disabled AT the cap rather than hidden, so the reason the button
     stopped working is visible beside the "up to 8" the note already
     carries. A control that vanishes reads as a bug. */
  .addsvc-list-add[disabled],
  /* And the rename button once its pull request is open, for the same
     reason: the button that has done its job stays visible, greyed, rather
     than vanishing. A control that disappears reads as a bug -- and here it
     would also hide the link to the pull request sitting beside it. */
  .addsvc-cat-rename-go[disabled] {
    opacity: 0.5;
    cursor: default;
  }

  /* --warn, the same amber .addsvc-bad uses for a bad outcome, and NOT an
     invented --danger: the note under .retire-out in this file records that
     --bad is not a variable this stylesheet defines and that a fallback for
     a variable that does not exist renders as the fallback forever.

     It does not collide with the conditional label above, which is amber
     TEXT on a label. This is a border and a tint on the input itself, so
     the two say different things in different places -- "this field may
     not apply" against "this entry will be refused". */
  .addsvc-invalid {
    border-color: var(--warn, #b7791f);
    background: rgba(183, 121, 31, 0.08);
  }

  .addsvc-actions {
    display: flex;
    gap: 8px;
    margin: 14px 0 0;
  }

  /* `.addsvc-apply` JOINED THIS LIST ON 23 Sep 2026, and its absence was
     not a style choice. It was the only one of the five action buttons
     carrying none of this rule: no border, no padding, no radius and no
     `cursor: pointer`. Enabled, it neither looked nor behaved like its
     siblings, so the button that CREATES THINGS was the one a person could
     not tell was pressable. Carson found it by noticing the hover cursor
     never became a hand. */
  /* `.addsvc-synth-go` JOINED ON 24 Sep 2026, and it is in this list for
     the reason the paragraph above gives rather than for tidiness. It is
     the sixth button in this card and it lives outside `.addsvc-actions`,
     which is exactly the kind of difference that makes somebody write it
     its own rule -- and then discover, months later, that one button in
     the card does not look pressable. */
  .addsvc-build,
  .addsvc-preview,
  .addsvc-apply,
  .addsvc-propose,
  .addsvc-reset,
  .addsvc-synth-go,
  /* `.addsvc-logo-pick` and `.addsvc-logo-clear` JOINED ON 24 Sep 2026, and
     they are here rather than in a rule of their own for the reason the two
     paragraphs above give. They are the seventh and eighth buttons in this
     card and they also live outside `.addsvc-actions`, which is exactly the
     shape that produced a button nobody could tell was pressable. */
  .addsvc-logo-pick,
  .addsvc-logo-clear,
  /* `.addsvc-logo-browse` joined 7 Oct 2026 (E8 part 3b), the picker's own. */
  .addsvc-logo-browse,
  /* `.addsvc-icon-pick` and `.addsvc-icon-clear` JOINED ON 29 Sep 2026, the
     ninth and tenth, for the same reason as the two above them. */
  .addsvc-icon-pick,
  .addsvc-icon-clear,
  /* `.addsvc-stack-btn` JOINED ON 29 Sep 2026 (item 9, CREATE part 2),
     every add and remove button in the stack box, for the same reason:
     a button outside `.addsvc-actions` must still look pressable. */
  .addsvc-stack-btn {
    font: inherit;
    font-size: 13px;
    font-weight: 600;
    padding: 8px 14px;
    border-radius: 8px;
    border: 1px solid rgba(128, 128, 128, 0.32);
    background: transparent;
    color: inherit;
    cursor: pointer;
  }

  .addsvc-build { border-color: var(--accent, rgba(128, 128, 128, 0.5)); }
  .addsvc-build[disabled] { opacity: 0.55; cursor: default; }

  /* Deliberately quieter than Build, which shares the accent border. Build
     is safe and repeatable; this one creates a branch and a pull request,
     and a button styled as the primary action invites being pressed before
     the record underneath it has been read. */
  .addsvc-propose[disabled] { opacity: 0.55; cursor: default; }

  /* Same treatment, and the same reason it matters more here than anywhere
     else on the page: Apply is disabled until the estate has been checked,
     so the disabled state is the one it spends most of its life in and the
     difference between the two has to be visible. */
  .addsvc-apply[disabled] { opacity: 0.55; cursor: default; }

  /* Quiet for the same reason as Propose, and disabled for a different
     one: a check is safe and repeatable, but there is nothing to check
     until a record has been built. */
  .addsvc-preview[disabled] { opacity: 0.55; cursor: default; }

  .addsvc-propose-out {
    margin: 0 0 8px;
    font-size: 12.5px;
    font-weight: 600;
  }

  .addsvc-propose-out a { color: inherit; }

  .addsvc-result { margin: 14px 0 0; }

  .addsvc-verdict {
    margin: 0 0 8px;
    font-size: 12.5px;
    font-weight: 650;
  }

  .addsvc-ok { color: var(--ok, #2f855a); }
  .addsvc-bad { color: var(--warn, #b7791f); }

  .addsvc-errors {
    margin: 0 0 10px;
    padding-left: 18px;
    font-size: 12px;
    color: var(--text-dim);
  }

  .addsvc-errors li { margin: 2px 0; overflow-wrap: anywhere; }

  /* Scrolls inside itself. A record is 30-odd lines of JSON and the card
     is narrow; letting it set the card's width would push everything else
     off screen on a phone. */
  .addsvc-json {
    margin: 0;
    max-height: 320px;
    overflow: auto;
    padding: 10px;
    border-radius: 8px;
    background: rgba(128, 128, 128, 0.1);
    font-size: 11.5px;
    line-height: 1.45;
    white-space: pre;
    -webkit-overflow-scrolling: touch;
  }

  .addsvc-hint {
    margin: 8px 0 0;
    font-size: 11.5px;
    color: var(--text-dim);
  }

  /* DRAFT FROM A REPOSITORY -- Phase 5 item 10.

     ABOVE THE FIELDS, because that is the order it is used in: paste a
     URL, read what came back, then go through the same three steps a typed
     record goes through. Boxed rather than inline so it reads as a way IN
     to the form rather than as a field of it -- there is no `slug` here,
     and a plain input row above the first real field would look like one.

     Hidden in the markup. Only a probe that says this deployment can draft
     at all ever reveals it, so a deployment without the key shows no
     control instead of one that always fails. */
  /* THE LOGO BOX SHARES THIS FRAME rather than carrying a copy of it. The
     two are siblings in the same card -- a bordered, tinted block that is
     not a field -- and the day one of them changes shape they should both
     change or the card develops two kinds of box for no reason a reader can
     see. It sits BELOW the fields rather than above them; see admin.html. */
  .addsvc-synth,
  .addsvc-logo,
  .addsvc-icon,
  .addsvc-admingroup {
    margin: 0 0 16px;
    padding: 12px;
    border: 1px solid rgba(128, 128, 128, 0.28);
    border-radius: 10px;
    background: rgba(128, 128, 128, 0.06);
  }

  .addsvc-synth-label,
  .addsvc-logo-label,
  .addsvc-icon-label,
  .addsvc-admingroup-label {
    display: block;
    margin: 0 0 8px;
    font-size: 12px;
    font-weight: 600;
  }

  .addsvc-synth-row {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    gap: 8px;
    align-items: center;
  }

  /* PHASE 7 PACKAGE 5: the link to the compose file, under the repository's,
     and what was read from it -- a quieter box inside the drafting box, as
     the mockup Carson approved on 4 Oct 2026 drew it. */
  .addsvc-synth-second { margin-top: 12px; }

  /* The verdict names the repository's address, which at phone width ran
     past the box's edge (seen while checking this package's panel). */
  .addsvc-synth .addsvc-verdict { overflow-wrap: anywhere; }
  /* Create's wait (6 Oct 2026): a bar that fills over the longest a create
     can take, and the line saying to keep the page open. */
  .addsvc-progress {
    height: 6px; max-width: 520px; margin: 10px 0 4px;
    border-radius: 4px; overflow: hidden;
    background: color-mix(in srgb, currentColor 14%, transparent);
  }
  .addsvc-progress > i {
    display: block; height: 100%; width: 0;
    border-radius: 4px; background: currentColor; opacity: .55;
    transition: width 1s linear;
  }
  .addsvc-progress-note { margin: 4px 0 0; font-size: .85em; opacity: .7; }

  .addsvc-synth-read {
    margin: 12px 0 0;
    padding: 12px 14px;
    border: 1px solid rgba(128, 128, 128, 0.28);
    border-radius: 10px;
    overflow-wrap: anywhere;
  }

  .addsvc-synth-read-title {
    margin: 0 0 6px;
    font-size: 13px;
    font-weight: 600;
  }

  .addsvc-synth-read .addsvc-hint { margin: 8px 0 2px; }

  .addsvc-synth-read ul {
    margin: 2px 0 0 18px;
    padding: 0;
    font-size: 13px;
  }

  .addsvc-synth-read li { margin: 2px 0; }

  /* THE DRAFT'S REPORT IN HEADED SECTIONS (UX package B, Carson's mockup of
     6 Oct 2026): a line between sections, a bold summary with a count,
     the one for containers closed. The disclosure arrow is the browser's. */
  .addsvc-synth-sec + .addsvc-synth-sec {
    margin-top: 8px;
    padding-top: 8px;
    border-top: 1px solid rgba(128, 128, 128, 0.22);
  }
  .addsvc-synth-sum {
    cursor: pointer;
    font-size: 13px;
    font-weight: 600;
  }
  .addsvc-synth-count {
    display: inline-block;
    min-width: 1.4em;
    padding: 0 6px;
    border-radius: 9px;
    font-size: 11px;
    font-weight: 600;
    text-align: center;
    background: rgba(var(--accent-rgb, 127, 178, 192), 0.18);
  }
  .addsvc-synth-sec[data-section="todo"] .addsvc-synth-count {
    background: color-mix(in srgb, var(--warn, #e3b45c) 22%, transparent);
  }

  /* THE SAME TWO COLUMNS AS ITS SIBLING, plus one that only exists when
     Remove does. `grid-auto-flow: column` rather than a third declared
     track, because a declared track is still a track when its only item is
     `hidden`: the column measures 0px and the gap before it stays, so the
     button beside it stops sitting flush with the box. Measured in a
     browser, not reasoned about -- it was written the other way first. */
  .addsvc-logo-row {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    grid-auto-flow: column;
    grid-auto-columns: auto;
    gap: 8px;
    align-items: center;
  }

  /* THE ADMIN GROUP BOX: a tick, then two labelled lines that appear only
     while it is ticked. The tick sits on its sentence the way a field's
     checkbox sits in its row -- 18px, flush left -- and the two inputs
     take the box's full width, as the logo's URL box does. */
  .addsvc-admingroup-toggle {
    display: flex;
    align-items: center;
    gap: 8px;
    font-size: 13px;
    cursor: pointer;
  }

  .addsvc-admingroup-toggle .addsvc-input[type="checkbox"] { flex: none; margin: 0; }

  .addsvc-admingroup-fields {
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    gap: 4px;
    margin: 10px 0 0;
  }

  .addsvc-admingroup-field {
    font-size: 12px;
    color: var(--text-dim);
  }

  .addsvc-admingroup-field + .addsvc-input { margin-bottom: 6px; }

  /* THE PHONE APP BOX (2 Oct 2026): the admin group's frame, a question,
     then labelled lines that appear once there is an app to describe. */
  .addsvc-phoneapp {
    margin: 0 0 16px;
    padding: 12px;
    border: 1px solid rgba(128, 128, 128, 0.28);
    border-radius: 10px;
    background: rgba(128, 128, 128, 0.06);
  }

  .addsvc-phoneapp-label {
    display: block;
    margin: 0 0 8px;
    font-size: 12px;
    font-weight: 600;
  }

  .addsvc-phoneapp-field {
    display: block;
    margin: 0 0 4px;
    font-size: 12px;
    color: var(--text-dim);
  }

  .addsvc-phoneapp > .addsvc-input,
  .addsvc-phoneapp-fields > .addsvc-input { width: 100%; box-sizing: border-box; margin-bottom: 8px; }

  .addsvc-phoneapp-fields { margin: 4px 0 0; }

  .addsvc-phoneapp-toggle {
    display: flex;
    align-items: center;
    gap: 8px;
    font-size: 13px;
    cursor: pointer;
  }

  .addsvc-phoneapp-toggle .addsvc-input[type="checkbox"] { flex: none; margin: 0; width: auto; }

  .addsvc-phoneapp-verdict { white-space: pre-line; }

  /* THE STACK BOX: the admin group's frame, holding one card per
     container. A container card is the frame again, one step lighter, so
     where one container ends and the next begins reads at a glance. Each
     row is the inputs side by side on a wide screen and stacked on a
     phone -- the rows are too wide to share a line at 390px. */
  .addsvc-stack {
    margin: 0 0 16px;
    padding: 12px;
    border: 1px solid rgba(128, 128, 128, 0.28);
    border-radius: 10px;
    background: rgba(128, 128, 128, 0.06);
  }

  .addsvc-stack-label {
    display: block;
    margin: 0 0 8px;
    font-size: 12px;
    font-weight: 600;
  }

  .addsvc-stack-container {
    margin: 10px 0;
    padding: 10px;
    border: 1px solid rgba(128, 128, 128, 0.22);
    border-radius: 8px;
    background: rgba(128, 128, 128, 0.04);
  }

  .addsvc-stack-title,
  .addsvc-stack-sub {
    margin: 0;
    font-size: 12px;
    font-weight: 600;
  }

  .addsvc-stack-part { margin: 10px 0 0; }
  .addsvc-stack-part > .addsvc-hint { margin: 2px 0 6px; }

  .addsvc-stack-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
  }

  .addsvc-stack-row {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 6px;
    margin: 0 0 6px;
  }

  .addsvc-stack-row > .addsvc-input {
    flex: 1 1 160px;
    min-width: 0;
  }

  .addsvc-stack-row > select.addsvc-input { flex: 0 1 150px; }
  /* A shared folder's name is the NAS's, and as long as "/share/Browser
     Station"; its dropdown gets the room to show it. */
  .addsvc-stack-row-mount > select.addsvc-input { flex: 0 1 210px; }
  /* Its own address (Phase 7 package 2b): "No — reached through the NAS
     (usual)" is the longest choice in the box, and gets room to be read. */
  .addsvc-stack-row-lan > select.addsvc-input { flex: 0 1 270px; }
  .addsvc-stack-part > .addsvc-stack-lansaid { margin: 0 0 4px; }
  /* What Check or Find said, in the tones the rest of the card uses: the
     hint's own grey would otherwise win over them. */
  .addsvc-stack-lansaid.addsvc-ok { color: var(--ok, #2f855a); }
  .addsvc-stack-lansaid.addsvc-bad { color: var(--warn, #b7791f); }
  /* Config files (Phase 7 package 3): each file in a light box of its own,
     its text in a fixed-width face, because a config file's spacing is part
     of what it says. */
  .addsvc-stack-file {
    margin: 0 0 8px;
    padding: 10px;
    border: 1px solid rgba(128, 128, 128, 0.22);
    border-radius: 10px;
  }
  .addsvc-stack-file > .addsvc-stack-code {
    display: block;
    width: 100%;
    min-height: 128px;
    box-sizing: border-box;
    resize: vertical;
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    font-size: 12px;
    line-height: 1.45;
    white-space: pre;
    overflow-x: auto;
  }
  .addsvc-stack-file > .addsvc-stack-filemeta { margin: 4px 0 0; }

  .addsvc-stack-field {
    display: flex;
    flex-direction: column;
    gap: 2px;
    flex: 1 1 160px;
    min-width: 0;
    font-size: 12px;
    color: var(--text-dim);
  }

  .addsvc-stack-check {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    font-size: 13px;
    cursor: pointer;
    white-space: nowrap;
  }

  .addsvc-stack-check .addsvc-input[type="checkbox"] { flex: none; margin: 0; }

  .addsvc-stack-networks {
    display: flex;
    flex-wrap: wrap;
    gap: 14px;
  }

  .addsvc-stack-btn { padding: 6px 10px; font-size: 12px; }
  .addsvc-stack-btn[disabled] { opacity: 0.55; cursor: default; }

  /* MORE SETTINGS (Phase 7 package 1, 3 Oct 2026): one disclosure per
     container, closed until opened, under a dashed line so it reads as
     the container's own and as optional. The fields inside sit in a
     two- or four-column grid on a wide screen and one column on a phone. */
  .addsvc-stack-more {
    margin: 12px 0 0;
    padding: 10px 0 0;
    border-top: 1px dashed rgba(128, 128, 128, 0.32);
  }

  .addsvc-stack-more > summary {
    display: flex;
    align-items: center;
    gap: 8px;
    font-size: 13px;
    font-weight: 600;
    cursor: pointer;
    list-style: none;
  }

  .addsvc-stack-more > summary::-webkit-details-marker { display: none; }
  .addsvc-stack-more > summary::before { content: "\25B8"; color: var(--text-dim); }
  .addsvc-stack-more[open] > summary::before { content: "\25BE"; }
  .addsvc-stack-more > .addsvc-hint { margin: 6px 0 0; }

  .addsvc-stack-count {
    padding: 0 7px;
    border-radius: 999px;
    font-size: 11px;
    font-weight: 600;
    color: var(--text-dim);
    background: rgba(128, 128, 128, 0.14);
  }

  .addsvc-stack-grid {
    display: grid;
    gap: 8px 12px;
    margin: 0 0 6px;
  }

  .addsvc-stack-grid2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .addsvc-stack-grid4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }

  .addsvc-stack-help { font-size: 11.5px; }

  /* A SETTING BUILT FROM A SECRET (package E1, Carson's mockup of 7 Oct
     2026). The value box sits over a layer that repeats its text in
     transparent ink with each slot marked, so a slot is highlighted where
     it is typed; the box's own background is see-through, so the mark
     shows. The layer follows the box's sideways scroll (admin-stack.js). */
  .addsvc-slot-field {
    position: relative;
    display: block;
    flex: 1 1 160px;
    min-width: 0;
  }
  .addsvc-slot-back {
    position: absolute;
    inset: 0;
    box-sizing: border-box;
    padding: 6px 8px;
    border: 1px solid transparent;
    font: inherit;
    font-size: 13px;
    white-space: pre;
    overflow: hidden;
    color: transparent;
    pointer-events: none;
  }
  .addsvc-slot-field > .addsvc-input { position: relative; }
  .addsvc-slot {
    color: transparent;
    border-radius: 4px;
    background: rgba(var(--accent-rgb, 127, 178, 192), 0.32);
    box-shadow: 0 0 0 1px rgba(var(--accent-rgb, 127, 178, 192), 0.45);
  }
  .addsvc-stack-check.is-off { opacity: 0.5; }
  .addsvc-slot-said { margin: -2px 0 4px; color: var(--ok, #2f9e6b); }
  /* A PROJECT'S EXAMPLE VALUE (package E8d): outlined and said in amber,
     with "Use {web address}" beside it when that is the fix. */
  .addsvc-input.addsvc-example { border-color: var(--warn, #b7791f); }
  .addsvc-example-note { margin: -4px 0 12px; color: var(--warn, #b7791f); }
  .addsvc-example-note[hidden] { display: none; }
  /* Where Port came from (E8 part 2b): green when the image said, amber
     when it couldn't. */
  .addsvc-port-said[hidden] { display: none; }
  .addsvc-port-said.is-ok { color: var(--ok, #2f9e6b); }
  .addsvc-port-said.is-warn { color: var(--warn, #b7791f); }
  /* Who can reach it, for a container that can reach the whole NAS (E3). */
  .addsvc-reach-said { margin: 8px 0 0; font-size: 13px; }

  /* WHERE IT SHOWS (E8 part 3b, Carson's mockup of 7 Oct 2026). The line
     above the ticks saying the answers filled them, "set by you" on one he
     changed, the order's line, and the carousel lines' counts. */
  .addsvc-ticks-said { margin: 4px 0 10px; font-size: 13px; color: var(--ok, #2f9e6b); }
  .addsvc-ticks-said[hidden] { display: none; }
  .addsvc-tick-hand {
    margin-left: 8px;
    padding: 1px 7px;
    border-radius: 999px;
    border: 1px solid color-mix(in srgb, var(--accent) 50%, transparent);
    color: var(--accent);
    font-size: 11px;
    font-weight: 600;
    vertical-align: middle;
  }
  #admin-add-order-said { margin: 4px 0 0; font-size: 13px; }
  .addsvc-count { float: right; margin-left: 8px; font-size: 12px; font-weight: 400; color: var(--text-dim); }
  .addsvc-count.is-over { color: var(--warn, #b7791f); }

  /* THE LOGO: the picked one on the site's two backgrounds (index.html's
     --bg, and the dimming its dark theme gives every logo), the line under
     it, and the dashboardicons.com search. */
  .addsvc-logo-chosen { display: flex; align-items: center; gap: 14px; margin: 0 0 10px; }
  .addsvc-logo-chosen[hidden] { display: none; }
  .addsvc-logo-pair { display: flex; gap: 6px; flex: none; }
  .addsvc-logo-swatch {
    width: 54px;
    height: 54px;
    border-radius: 12px;
    display: grid;
    place-items: center;
    border: 1px solid rgba(128, 128, 128, 0.28);
  }
  .addsvc-logo-swatch img { width: 30px; height: 30px; object-fit: contain; }
  .addsvc-logo-swatch.is-light { background: #FAFAF9; }
  .addsvc-logo-swatch.is-dark { background: #0C0D0E; }
  .addsvc-logo-swatch.is-dark img { filter: saturate(0.55) brightness(0.85); }
  .addsvc-logo-name { margin: 0; font-size: 15px; font-weight: 700; overflow-wrap: anywhere; }
  .addsvc-logo-from { margin: 2px 0 0; font-size: 12.5px; color: var(--text-dim); }
  .addsvc-logo-acts { display: flex; flex-wrap: wrap; gap: 8px; }
  .addsvc-logo .addsvc-port-said { margin: 8px 0 0; font-size: 13px; }
  .addsvc-logo-panel {
    margin: 12px 0 0;
    padding: 12px 0 0;
    border-top: 1px solid rgba(128, 128, 128, 0.2);
  }
  .addsvc-logo-panel[hidden] { display: none; }
  .addsvc-logo-panel .addsvc-input { width: 100%; }
  .addsvc-logo-panel .addsvc-hint { margin: 8px 0 0; }
  .addsvc-logo-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
    gap: 8px;
    margin: 10px 0 0;
  }
  .addsvc-logo-option {
    font: inherit;
    font-size: 12px;
    padding: 10px 6px 8px;
    border: 1px solid rgba(128, 128, 128, 0.28);
    border-radius: 12px;
    background: transparent;
    color: inherit;
    cursor: pointer;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 6px;
    min-width: 0;
  }
  .addsvc-logo-option img { width: 30px; height: 30px; object-fit: contain; }
  .addsvc-logo-option span { max-width: 100%; overflow-wrap: anywhere; text-align: center; }
  .addsvc-logo-option[aria-pressed="true"] {
    border-color: var(--accent);
    background: var(--accent-soft);
  }
  .addsvc-logo-option-tone { color: var(--warn, #b7791f); font-size: 10.5px; line-height: 1.2; text-align: center; }
  .addsvc-logo-option-tone[hidden] { display: none; }
  .addsvc-logo-own { margin: 12px 0 0; font-size: 13px; }
  .addsvc-logo-own summary { cursor: pointer; font-weight: 600; }
  .addsvc-logo-own .addsvc-logo-row { margin-top: 8px; }
  .addsvc-logo > .addsvc-hint { margin-top: 10px; }

  /* ASK AI (E8 part 3c, Carson's mockup of 7 Oct 2026): the drafting box's
     look, at the foot of each step. The answer, then each suggestion with
     what the form says now, and a Use button. */
  .addsvc-ask { margin-top: 22px; }
  .addsvc-ask[hidden] { display: none; }
  .addsvc-ask-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 0 0 10px; }
  .addsvc-ask-head .addsvc-synth-label { margin: 0; flex: 1; font-size: 13px; }
  .addsvc-ask-busy { display: flex; align-items: center; gap: 8px; margin: 10px 0 0; font-size: 13px; color: var(--text-dim); }
  .addsvc-ask-busy::before {
    content: "";
    width: 12px;
    height: 12px;
    flex: none;
    border-radius: 50%;
    border: 2px solid rgba(128, 128, 128, 0.35);
    border-top-color: var(--accent);
    animation: addsvc-ask-spin 0.9s linear infinite;
  }
  .addsvc-ask-busy[hidden] { display: none; }
  @keyframes addsvc-ask-spin { to { transform: rotate(360deg); } }
  @media (prefers-reduced-motion: reduce) { .addsvc-ask-busy::before { animation: none; } }
  .addsvc-ask-out[hidden] { display: none; }
  .addsvc-ask-q { margin: 14px 0 4px; font-size: 13px; font-weight: 600; overflow-wrap: anywhere; }
  .addsvc-ask-a { margin: 0 0 8px; font-size: 13.5px; line-height: 1.5; white-space: pre-line; }
  .addsvc-ask-note { margin: 0 0 8px; font-size: 12.5px; color: var(--warn, #b7791f); }
  /* Which model answered, and what it cost (package E6). */
  .addsvc-ask-by { margin: 0 0 8px; font-size: 12px; color: var(--text-dim); }
  .addsvc-ask-sug-title { margin: 12px 0 6px; font-size: 12px; font-weight: 700; }
  .addsvc-ask-sug {
    display: grid;
    grid-template-columns: 9.5em minmax(0, 1fr) auto;
    gap: 4px 12px;
    align-items: center;
    padding: 8px 0;
    border-top: 1px solid rgba(128, 128, 128, 0.2);
    font-size: 13px;
  }
  .addsvc-ask-sug b { font-weight: 600; }
  .addsvc-ask-val { min-width: 0; overflow-wrap: anywhere; }
  .addsvc-ask-now { display: block; font-size: 12px; color: var(--warn, #b7791f); }
  .addsvc-ask-same { font-size: 12px; color: var(--ok, #2f9e6b); }
  .addsvc-ask-why { grid-column: 2 / 4; margin-top: -2px; font-size: 12px; color: var(--text-dim); }
  .addsvc-ask-acts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
  @media (max-width: 560px) {
    .addsvc-ask-sug { grid-template-columns: minmax(0, 1fr) auto; }
    .addsvc-ask-sug b { grid-column: 1 / 3; }
    .addsvc-ask-why { grid-column: 1 / 3; }
  }
  .addsvc-example-note .addsvc-stack-btn { margin-left: 4px; }
  .addsvc-slot-why { margin: 0 0 8px; }
  .addsvc-slot-menu {
    display: grid;
    width: min(460px, 100%);
    margin: 2px 0 8px;
    padding: 6px 0;
    border: 1px solid rgba(128, 128, 128, 0.32);
    border-radius: 10px;
    background: var(--panel-bg, rgba(128, 128, 128, 0.08));
  }
  .addsvc-slot-menu[hidden] { display: none; }
  .addsvc-slot-menu-title {
    margin: 0;
    padding: 2px 12px 4px;
    font-size: 11px;
    letter-spacing: 0.05em;
    text-transform: uppercase;
    color: var(--text-dim);
  }
  .addsvc-slot-item {
    padding: 6px 12px;
    border: 0;
    background: none;
    color: var(--text);
    font: inherit;
    font-size: 13px;
    text-align: left;
    cursor: pointer;
  }
  .addsvc-slot-item:hover,
  .addsvc-slot-item:focus-visible { background: rgba(var(--accent-rgb, 127, 178, 192), 0.16); }
  .addsvc-slot-from { color: var(--text-dim); }

  .addsvc-stack-measure {
    display: flex;
    gap: 6px;
    min-width: 0;
  }

  .addsvc-stack-measure > .addsvc-input { flex: 1 1 0; min-width: 0; }
  .addsvc-stack-measure > select.addsvc-input { flex: 0 1 92px; }

  .addsvc-stack-field-check > .addsvc-stack-check { min-height: 34px; }

  .addsvc-stack-field > .addsvc-input:disabled { opacity: 0.55; }

  /* EXTRA POWER (Phase 7 package 4a, 4 Oct 2026): a closed section per
     container, boxed in --warn's amber so it never reads as one more
     ordinary setting, with its count in amber once anything is set. The
     same amber frames the confirmations on Review & create. */
  .addsvc-stack-power {
    margin: 12px 0 0;
    padding: 10px 12px;
    border: 1px solid color-mix(in srgb, var(--warn, #b7791f) 55%, transparent);
    border-radius: 10px;
    background: color-mix(in srgb, var(--warn, #b7791f) 7%, transparent);
  }

  .addsvc-stack-power > summary {
    display: flex;
    align-items: center;
    gap: 8px;
    font-size: 13px;
    font-weight: 600;
    cursor: pointer;
    list-style: none;
  }

  .addsvc-stack-power > summary::-webkit-details-marker { display: none; }
  .addsvc-stack-power > summary::before { content: "\25B8"; color: var(--text-dim); }
  .addsvc-stack-power[open] > summary::before { content: "\25BE"; }
  .addsvc-stack-power > .addsvc-hint { margin: 6px 0 0; }

  /* Its ticks carry whole sentences, so they wrap rather than run off a
     phone's edge the way a one-word tick may. */
  .addsvc-stack-power .addsvc-stack-check {
    align-items: flex-start;
    white-space: normal;
  }

  .addsvc-stack-power .addsvc-stack-check .addsvc-input[type="checkbox"] { margin-top: 2px; }

  .addsvc-stack-powertag.is-set {
    color: var(--warn, #b7791f);
    background: color-mix(in srgb, var(--warn, #b7791f) 16%, transparent);
  }

  .addsvc-stack-caps {
    display: grid;
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: 4px 14px;
    margin: 0 0 6px;
  }

  .addsvc-stack-capwhat {
    margin: 0 0 2px 24px;
    font-size: 11.5px;
    color: var(--text-dim);
  }

  .addsvc-stack-capname {
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    font-size: 12.5px;
  }

  .addsvc-powers {
    margin: 0 0 12px;
    padding: 12px;
    border: 1px solid color-mix(in srgb, var(--warn, #b7791f) 55%, transparent);
    border-radius: 10px;
  }

  .addsvc-powers-title {
    margin: 0 0 4px;
    font-size: 13px;
    font-weight: 600;
  }

  .addsvc-powers .addsvc-power-line {
    display: flex;
    align-items: flex-start;
    margin: 6px 0 0;
    white-space: normal;
  }

  .addsvc-powers .addsvc-power-line .addsvc-input[type="checkbox"] { margin-top: 3px; }
  .addsvc-powers .addsvc-power-note { margin: 6px 0 0 24px; }
  /* Settings built from a secret (package E1): the same box, without ticks. */
  .addsvc-built { border-color: rgba(128, 128, 128, 0.32); }
  .addsvc-built .addsvc-built-line { margin: 4px 0 0; font-size: 13.5px; }
  .addsvc-built .addsvc-hint { margin-top: 8px; }

  /* NETWORKS (Phase 7 package 2, 3 Oct 2026): the two shared ones, then
     every other network on the NAS in a closed list of its own, each
     ticked one saying whose it is. */
  .addsvc-stack-netgroup {
    margin: 6px 0 4px;
    font-size: 11.5px;
    font-weight: 600;
    color: var(--text-dim);
  }

  .addsvc-stack-othernets { margin-top: 10px; }
  .addsvc-stack-netrow { margin: 4px 0 0; }
  .addsvc-stack-netrow > .addsvc-stack-netnote { margin: 2px 0 6px 24px; }

  /* ROOM TO READ (package E8e, Carson's mockup of 7 Oct 2026: the
     container setup was "clumped and daunting"). Spacing only: each
     container gets more room, each part of it a space and a faint line of
     its own, explanations a reading width, and a heading never touches the
     fields under it. After every other rule for the box, so it wins
     without !important; before the phone rules, so they still do. */
  .addsvc-stack { padding: 18px 20px; }
  .addsvc-stack-label { margin: 0 0 4px; font-size: 13px; }
  .addsvc-stack-container { margin: 16px 0; padding: 18px 20px 20px; border-radius: 12px; }
  .addsvc-stack-title { font-size: 14px; }
  .addsvc-stack-sub { font-size: 13px; }
  .addsvc-stack-head { margin: 0 0 14px; }
  .addsvc-stack-row { gap: 8px 10px; margin: 0 0 10px; }
  /* Name beside Image: tops lined up, not centred against "Runs as". */
  .addsvc-stack-row:has(> .addsvc-stack-field) { align-items: flex-start; gap: 12px 16px; }
  .addsvc-stack-field { gap: 5px; }
  .addsvc-stack-part { margin: 18px 0 0; padding: 16px 0 0; border-top: 1px solid rgba(128, 128, 128, 0.16); }
  .addsvc-stack-part > .addsvc-hint { margin: 4px 0 12px; max-width: 75ch; line-height: 1.5; }
  .addsvc-stack-part > .addsvc-slot-said,
  .addsvc-stack-part > .addsvc-slot-why { margin: -4px 0 12px; }
  .addsvc-stack-part > .addsvc-stack-btn { margin-top: 2px; }
  .addsvc-stack-grid { gap: 14px 16px; margin: 0 0 10px; }
  .addsvc-stack-more,
  .addsvc-stack-power { margin-top: 20px; }
  .addsvc-stack-more { padding-top: 14px; }
  .addsvc-stack-more > .addsvc-hint,
  .addsvc-stack-power > .addsvc-hint { margin: 8px 0 0; line-height: 1.5; }
  .addsvc-stack-networks { gap: 10px 18px; margin-top: 6px; }
  .addsvc-stack-sub + .addsvc-stack-grid,
  .addsvc-stack-sub + .addsvc-stack-row,
  .addsvc-stack-sub + .addsvc-stack-btn { margin-top: 10px; }
  .addsvc-stack-grid + .addsvc-stack-sub,
  .addsvc-stack-row + .addsvc-stack-sub { margin-top: 16px; }
  /* "No — reached through the NAS (usual)" read in full. */
  .addsvc-stack-row-lan > select.addsvc-input { flex: 0 1 320px; }

  @media (max-width: 760px) {
    .addsvc-stack-grid4,
    .addsvc-stack-caps { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  }

  @media (max-width: 560px) {
    .addsvc-stack-row > .addsvc-input,
    .addsvc-stack-row > select.addsvc-input,
    .addsvc-stack-row > .addsvc-slot-field,
    .addsvc-stack-field { flex-basis: 100%; }
    .addsvc-stack-grid2,
    .addsvc-stack-grid4,
    .addsvc-stack-caps { grid-template-columns: minmax(0, 1fr); }
    /* E8e's room, a little less of it on a phone. */
    .addsvc-stack,
    .addsvc-stack-container { padding: 14px; }
  }

  /* THE ICON ROW IS THE LOGO ROW, with the chosen icon where the URL box
     is. Same auto-flow, for the same reason: Remove is `hidden` until there
     is something to remove, and a declared third track would keep its gap. */
  .addsvc-icon-row {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    grid-auto-flow: column;
    grid-auto-columns: auto;
    gap: 8px;
    align-items: center;
  }

  .addsvc-icon-chosen {
    display: flex;
    align-items: center;
    gap: 10px;
    min-height: 34px;
    font-size: 13px;
    font-weight: 600;
  }

  .addsvc-icon-none { font-weight: 400; color: var(--text-dim); }

  /* THE HOMEPAGE'S OWN BADGE, at the same size and in the same colours as
     `.card .icon` in index.html, so what is picked here is what the
     carousel shows. A preview that looked better than the result would be
     a preview that lied. */
  .addsvc-icon-badge {
    width: 34px;
    height: 34px;
    flex: none;
    border-radius: 8px;
    background: var(--accent-soft);
    color: var(--accent);
    display: inline-flex;
    align-items: center;
    justify-content: center;
  }
  .addsvc-icon-badge svg { width: 18px; height: 18px; }

  .addsvc-icon-panel {
    margin: 10px 0 0;
    padding: 10px 0 0;
    border-top: 1px solid rgba(128, 128, 128, 0.2);
  }
  .addsvc-icon-panel .addsvc-input { width: 100%; }

  .addsvc-icon-group-title {
    margin: 12px 0 6px;
    font-size: 11px;
    font-weight: 700;
    letter-spacing: 0.05em;
    text-transform: uppercase;
    color: var(--text-dim);
  }

  .addsvc-icon-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(40px, 1fr));
    gap: 6px;
  }

  /* A button per icon, and a real one: focusable, pressable with a key,
     and announced by its label rather than by a drawing a screen reader
     cannot see. `aria-pressed` carries the choice, so the style follows
     the state rather than a second class that could disagree with it. */
  .addsvc-icon-option {
    height: 40px;
    padding: 0;
    border: 1px solid rgba(128, 128, 128, 0.28);
    border-radius: 8px;
    background: transparent;
    color: inherit;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
  }
  .addsvc-icon-option svg { width: 20px; height: 20px; }
  .addsvc-icon-option:hover { border-color: var(--accent); }
  .addsvc-icon-option[aria-pressed="true"] {
    border-color: var(--accent);
    background: var(--accent-soft);
    color: var(--accent);
  }
  .addsvc-icon-pick[disabled] { opacity: 0.55; cursor: default; }

  /* Quieter than Build, and on purpose. Build is safe and repeatable; this
     one spends money against a monthly cap, and a button styled as the
     primary action invites being pressed to see what happens. */
  .addsvc-synth-go[disabled] { opacity: 0.55; cursor: default; }

  @media (max-width: 520px) {
    .addsvc-row { grid-template-columns: minmax(0, 1fr); }
    .addsvc-synth-row { grid-template-columns: minmax(0, 1fr); }
    .addsvc-logo-row { grid-template-columns: minmax(0, 1fr); }
    .addsvc-icon-row { grid-template-columns: minmax(0, 1fr); }
  }

  .estate-group { margin: 0 0 16px; }
  .estate-group:last-child { margin-bottom: 0; }

  .estate-group-title {
    margin: 0;
    font-size: 11.5px;
    font-weight: 700;
    letter-spacing: 0.05em;
    text-transform: uppercase;
    color: var(--accent, var(--text-dim));
  }

  .estate-group-note {
    margin: 2px 0 6px;
    font-size: 11.5px;
    color: var(--text-dim);
  }

  /* Wraps rather than truncates. A hostname that runs off the edge of a
     narrow card is the one thing on this row you would have opened the
     view to read. */
  .estate-row {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: 3px 10px;
    padding: 7px 0;
    border-top: 1px solid rgba(128, 128, 128, 0.16);
  }

  .estate-main {
    display: flex;
    align-items: baseline;
    flex-wrap: wrap;
    gap: 2px 8px;
    min-width: 0;
    flex: 1 1 200px;
  }

  .estate-name { font-size: 13.5px; font-weight: 650; }

  .estate-host {
    font-size: 11.5px;
    color: var(--text-dim);
    overflow-wrap: anywhere;
  }

  /* A reconcile finding's own sentence, on its own line under the row.

     Full-width rather than beside the name because these are the register's
     notes and the checker's explanations - whole sentences, sometimes long
     ones, and the point of them is that somebody wrote down WHY. Truncating
     that to fit a row would remove the only part of the finding that is not
     recoverable from somewhere else. */
  .estate-why {
    flex: 1 1 100%;
    margin: 2px 0 0;
    font-size: 11.5px;
    line-height: 1.5;
    color: var(--text-dim);
    overflow-wrap: anywhere;
  }

  .estate-tags {
    display: flex;
    flex-wrap: wrap;
    gap: 4px;
  }

  /* ---------------------------------------------------------------------
     The Retire control and its plan panel (admin-retire.js).

     The button sits in the row's flex flow beside the tags; the panel is
     flex: 1 1 100% so it drops to its own full-width line under the row,
     the same technique .estate-why uses and for the same reason - this is
     prose and a list, not something that fits beside a hostname.
     --------------------------------------------------------------------- */
  .estate-retire {
    font-size: 10.5px;
    line-height: 1.7;
    padding: 0 9px;
    border: 1px solid rgba(128, 128, 128, 0.3);
    border-radius: 999px;
    background: transparent;
    color: var(--text-dim);
    cursor: pointer;
    white-space: nowrap;
    transition: border-color 0.15s, color 0.15s;
  }

  .estate-retire:hover { border-color: rgba(128, 128, 128, 0.55); color: var(--text); }
  .estate-retire[hidden] { display: none; }

  .retire-plan {
    flex: 1 1 100%;
    margin: 8px 0 4px;
    padding: 10px 12px;
    border-radius: 8px;
    background: rgba(128, 128, 128, 0.07);
    font-size: 11.5px;
    line-height: 1.55;
    color: var(--text-dim);
    overflow-wrap: anywhere;
  }

  /* THE PHONE APP EDITOR under a Services row (2 Oct 2026): the add
     card's box, full width beneath the row like Retire's panel, then its
     one button and what happened. */
  /* THE LOGS PANEL (Phase 7 package 6a, 4 Oct 2026), from the mockup
     Carson approved: under a row on Services, and in Create the service's
     result when a container didn't start. The lines on a dark ground in
     both themes, as a terminal shows them; masked lines amber, errors red,
     warnings softer. */
  .svc-logs {
    flex: 1 1 100%;
    margin: 10px 0 4px;
    padding: 12px 14px;
    border: 1px solid rgba(128, 128, 128, 0.28);
    border-radius: 10px;
    background: rgba(128, 128, 128, 0.05);
    min-width: 0;
  }
  .svc-logs-head { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 0 0 8px; }
  .svc-logs-title { font-size: 14px; }
  .svc-logs-pick {
    font: inherit;
    font-size: 12.5px;
    max-width: 100%;
    padding: 4px 8px;
    border-radius: 8px;
    border: 1px solid rgba(128, 128, 128, 0.35);
    background: transparent;
    color: inherit;
  }
  .svc-logs-state { font-size: 11.5px; color: var(--text-dim); }
  .svc-logs-state b { color: var(--ok, #2f855a); font-weight: 600; }
  .svc-logs-state b.is-bad { color: var(--warn, #b7791f); }
  .svc-logs-spacer { flex: 1; }
  .svc-logs-btn {
    font: inherit;
    font-size: 11px;
    font-weight: 600;
    line-height: 1.7;
    padding: 0 10px;
    border-radius: 8px;
    border: 1px solid rgba(128, 128, 128, 0.35);
    background: transparent;
    color: inherit;
    cursor: pointer;
  }
  .svc-logs-btn[disabled] { opacity: 0.55; cursor: default; }
  .svc-logs-more { margin: 0 0 6px; }
  .svc-logs-box {
    margin: 0;
    max-height: 320px;
    overflow: auto;
    padding: 10px 12px;
    border-radius: 8px;
    background: rgba(0, 0, 0, 0.82);
    color: #e6e6e6;
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    font-size: 11.5px;
    line-height: 1.5;
    white-space: pre-wrap;
    overflow-wrap: anywhere;
  }
  .svc-logs-box .is-masked { color: #f6c177; }
  .svc-logs-box .is-error { color: #ff8a80; }
  .svc-logs-box .is-warn { color: #ffd59e; }
  .svc-logs-out { margin: 6px 0 0; font-size: 11.5px; color: var(--text-dim); }
  .svc-logs-out:empty { display: none; }
  .svc-logs-out.is-bad { color: var(--warn, #b7791f); }
  .svc-logs-note { margin: 8px 0 0; font-size: 11px; color: var(--text-dim); }

  .addsvc-firststart {
    margin: 12px 0 0;
    padding: 14px;
    border-radius: 12px;
    border: 1px solid color-mix(in srgb, var(--warn, #b7791f) 55%, transparent);
    background: color-mix(in srgb, var(--warn, #b7791f) 7%, transparent);
  }
  .addsvc-firststart-title { margin: 0 0 6px; font-size: 13px; font-weight: 600; }
  .addsvc-firststart-lead { margin: 0 0 8px; font-size: 12.5px; }
  .addsvc-firststart .svc-logs { margin: 0; padding: 0; border: 0; background: none; }
  .addsvc-firststart .svc-logs-title { display: none; }

  .phone-edit { flex: 1 1 100%; margin: 8px 0 4px; }
  .phone-edit .addsvc-phoneapp { margin: 0 0 8px; }
  .phone-edit-actions { display: flex; gap: 8px; }
  .phone-edit-out { margin: 6px 0 0; font-size: 11px; color: var(--text-dim); }
  .phone-edit-out:empty { display: none; }
  .phone-edit-out.is-bad { color: var(--warn, #b7791f); }
  .phone-edit-out.is-ok a { color: var(--ok, #2f855a); font-weight: 600; }

  /* The row's button and the panel's, in Retire's and Activate's shapes. */
  .estate-phone-edit {
    font-size: 10.5px;
    line-height: 1.7;
    padding: 0 9px;
    border: 1px solid rgba(128, 128, 128, 0.3);
    border-radius: 999px;
    background: transparent;
    color: var(--text-dim);
    cursor: pointer;
    white-space: nowrap;
    transition: border-color 0.15s, color 0.15s;
  }
  .estate-phone-edit:hover { border-color: rgba(128, 128, 128, 0.55); color: var(--text); }
  .estate-phone-edit[hidden] { display: none; }

  /* The panel's Close is the same pill as its write (UX package C follow-up). */
  .phone-edit-go,
  .phone-edit-close {
    font-size: 11.5px;
    line-height: 2;
    padding: 0 12px;
    border: 1px solid rgba(128, 128, 128, 0.3);
    border-radius: 999px;
    background: transparent;
    color: var(--text);
    cursor: pointer;
  }
  .phone-edit-go[disabled] { opacity: 0.55; cursor: default; }

  .retire-plan-sum { margin: 0; font-weight: 650; color: var(--text); }
  .retire-plan-note { margin: 6px 0 0; }

  .retire-phase { margin: 10px 0 0; }
  .retire-phase-title { margin: 0; font-weight: 650; color: var(--text); }
  .retire-phase-why { margin: 2px 0 0; }

  .retire-steps { margin: 4px 0 0; padding-left: 18px; }
  .retire-step { margin: 2px 0 0; }

  /* The Docker name a step removes (UX package C): bold, fixed-width, on a
     faint tint, so each line's subject is the first thing read. */
  .retire-step-name {
    font-weight: 700;
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    font-size: 0.93em;
    color: var(--text);
    padding: 0 5px;
    border-radius: 5px;
    background: rgba(128, 160, 180, 0.16);
    border: 1px solid rgba(128, 160, 180, 0.35);
    overflow-wrap: anywhere;
  }

  /* Amber, same as .estate-tag.is-warn and for the same reason: this wants
     a decision, it is not an incident. Red here would be spent on every
     data path in the estate and mean nothing by the third read. */
  .retire-step-flag {
    margin-left: 6px;
    font-size: 10.5px;
    padding: 0 6px;
    border-radius: 999px;
    background: rgba(230, 160, 13, 0.16);
    color: var(--maint, #b8820a);
    white-space: nowrap;
  }

  @media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) .retire-step-flag { color: var(--maint, #e6a00d); }
  }
  :root[data-theme="dark"] .retire-step-flag { color: var(--maint, #e6a00d); }

  /* A renamed folder is kept, not gone: the calm colour, not the flag's. */
  .retire-step-flag.is-kept {
    background: rgba(62, 154, 108, 0.14);
    color: var(--ok, #2f855a);
  }
  @media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) .retire-step-flag.is-kept { color: var(--ok, #58C08A); }
  }
  :root[data-theme="dark"] .retire-step-flag.is-kept { color: var(--ok, #58C08A); }

  /* REMOVE IT RIGHT AWAY (package E10, Carson's mockup of 7 Oct 2026): a
     quiet box until ticked, then amber, like the warning it is. */
  .retire-now {
    margin: 14px 0 0;
    padding: 10px 14px;
    border: 1px solid rgba(128, 128, 128, 0.32);
    border-radius: 10px;
  }
  .retire-now.is-on {
    border-color: color-mix(in srgb, var(--warn, #b7791f) 60%, transparent);
    background: color-mix(in srgb, var(--warn, #b7791f) 8%, transparent);
  }
  .retire-now-label { display: flex; align-items: center; gap: 8px; font-weight: 600; cursor: pointer; }
  .retire-now-box { width: 18px; height: 18px; margin: 0; }
  .retire-now-hint { margin: 4px 0 0 26px; font-size: 12.5px; color: var(--text-dim); }
  .retire-actions { margin: 12px 0 0; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }

  /* The panel's Close is the same pill as its write (UX package C follow-up). */
  .retire-go,
  .retire-close {
    font-size: 11.5px;
    line-height: 2;
    padding: 0 12px;
    border: 1px solid rgba(128, 128, 128, 0.3);
    border-radius: 999px;
    background: transparent;
    color: var(--text);
    cursor: pointer;
  }

  .retire-go[disabled] { opacity: 0.55; cursor: default; }

  .retire-out { margin: 0; }
  /* Exactly what .addsvc-ok / .addsvc-bad use, twelve lines up this file.
     --bad is not a variable this stylesheet defines; --warn is. A fallback
     for a variable that does not exist renders as the fallback forever and
     stops following the theme, which is a difference nobody would see until
     the theme changed. */
  .retire-out.is-ok { color: var(--ok, #2f855a); }
  .retire-out.is-bad { color: var(--warn, #b7791f); }
  .retire-out a { color: inherit; }

  /* ---------------------------------------------------------------------
     The Activate control (admin-activate.js).

     SHARES .estate-retire's SHAPE RATHER THAN ITS RULES. Both are pill
     buttons in the same row flow and they must line up, so the geometry is
     duplicated rather than the two selectors being grouped -- grouping them
     would mean the next change to one silently moved the other, and these
     two buttons are going to diverge: Retire is the cautious one and
     Activate is the one that puts something on the public site.

     No panel, because activation has nothing to look up first. The output
     line drops to its own row the way .retire-plan does.
     --------------------------------------------------------------------- */
  .estate-activate {
    font-size: 10.5px;
    line-height: 1.7;
    padding: 0 9px;
    border: 1px solid rgba(128, 128, 128, 0.3);
    border-radius: 999px;
    background: transparent;
    color: var(--text-dim);
    cursor: pointer;
    white-space: nowrap;
    transition: border-color 0.15s, color 0.15s;
  }

  .estate-activate:hover { border-color: rgba(128, 128, 128, 0.55); color: var(--text); }
  .estate-activate[hidden] { display: none; }
  .estate-activate[disabled] { opacity: 0.55; cursor: default; }

  .activate-out { flex: 1 1 100%; margin: 6px 0 0; font-size: 11px; }
  /* Same two variables, same reasoning, as .retire-out above: --warn is
     defined by this stylesheet and --bad is not. */
  .activate-out.is-ok { color: var(--ok, #2f855a); }
  .activate-out.is-bad { color: var(--warn, #b7791f); }
  .activate-out a { color: inherit; }

  .estate-tag {
    font-size: 10.5px;
    line-height: 1.7;
    padding: 0 7px;
    border-radius: 999px;
    background: rgba(128, 128, 128, 0.14);
    color: var(--text-dim);
    white-space: nowrap;
  }

  /* Amber, not red. Nothing in this list is an incident - an unmonitored
     service or an unresolved drift finding is something to decide about
     eventually, and colouring it like an outage would train the eye to
     ignore the colour by the third read. */
  .estate-tag.is-warn {
    background: rgba(230, 160, 13, 0.16);
    color: var(--maint, #b8820a);
  }

  @media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) .estate-tag.is-warn { color: var(--maint, #e6a00d); }
  }
  :root[data-theme="dark"] .estate-tag.is-warn { color: var(--maint, #e6a00d); }
  /* Custom-built rather than left to the browser's native checkbox —
     same technique as request.html: a real, fully accessible <input>
     underneath (appearance: none only removes the OS's own painting,
     not its function), so it becomes an ordinary transitionable box.
     Idle colors fade at the theme's 2400ms; the checkmark's own
     opacity/scale is click-driven and stays fast (0.15s) — same box,
     two different triggers, two different speeds, on purpose. */
  .option input[type="checkbox"] {
    appearance: none;
    -webkit-appearance: none;
    position: relative;
    width: 19px;
    height: 19px;
    border-radius: 5px;
    border: 1.5px solid var(--border);
    background: var(--surface);
    flex: none;
    cursor: pointer;
    margin: 0;
    transition: background-color 2400ms ease, border-color 2400ms ease;
  }
  .option input[type="checkbox"]::after {
    content: "";
    position: absolute;
    left: 6px;
    top: 2px;
    width: 5px;
    height: 9px;
    border: solid var(--accent);
    border-width: 0 2px 2px 0;
    transform: rotate(45deg) scale(0.6);
    opacity: 0;
    transition: opacity 0.15s ease, transform 0.15s ease, border-color 2400ms ease;
  }
  .option input[type="checkbox"]:checked::after {
    opacity: 1;
    transform: rotate(45deg) scale(1);
  }
  .option input[type="checkbox"]:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
  }
  /* Just the checkbox grays out outside Edit mode — the row itself
     (icon/name/description) stays full-opacity and readable. Carson's
     revised preference, 2026-08-11: more standard than dimming the
     whole row, and looks better. */
  .option input[type="checkbox"]:disabled { cursor: not-allowed; opacity: 0.35; }

  /* Core &amp; Utilities: same visual language as the "Included With Every
     Account" block on request.html — a static checkmark instead of an
     interactive checkbox, since these are always-on and not something
     the user can toggle off. */
  .options.core .option { cursor: default; opacity: 0.7; }
  .options.core .option .check { margin-left: auto; color: var(--ok); flex: none; }
  .options.core .option .check svg { width: 18px; height: 18px; }
  .core-tag { font-size: 12px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-dim); margin: 18px 0 8px; }

  #feedback-text {
    /* Matched to the reply box's own textarea AND to both preview
       boxes' max-height — all three still share the exact same
       clamp(60px, 8vh, 100px). Growing the previews up to the
       textarea's old, taller clamp() would have been the more obvious
       fix, but that's exactly what was pushing the Send row off-screen
       before — so the textareas came down to meet the previews instead
       of the other way around. */
    height: clamp(60px, 8vh, 100px);
    resize: none;
    overflow-y: auto;
    font-family: inherit;
    font-size: 15px;
    padding: 14px;
    border-radius: 12px;
    border: 1px solid var(--border);
    background: var(--bg);
    color: var(--text);
    transition: background-color 2400ms ease, border-color 2400ms ease, color 2400ms ease;
  }
  /* No longer shares #feedback-text's tighter budget — that constraint
     existed specifically because the Feedback box's toolbar row eats
     into the same limited vertical space its Send button needs to stay
     visible in. Site Message dropped its own toolbar (see above), which
     freed up exactly that room. A native <textarea> can't be flex-
     centered the way a <div> can — see the rule below for how this
     actually centers a single line instead. */
  #sitemsg-text {
    /* Reworked — the previous attempt (line-height: 2.4 stretched way
       past normal reading proportions, paired with a very short height)
       rendered badly rather than centering anything, per Carson's
       screenshot: text visibly clipped, box looking barely 20px tall.
       This is the more reliable, standard way to center single-line
       text in a form control: keep line-height at a normal reading
       value (1.5) and calculate padding to match a specific fixed
       height, rather than distorting line-height itself. Fixed px
       height too, not vh-based, so the math holds predictably
       regardless of viewport size.
       Padding is intentionally asymmetric, not mathematically equal —
       equal top/bottom padding still rendered visibly low per Carson's
       screenshots, which tracks with a known font-metrics quirk: a
       font's own leading isn't evenly split above/below the baseline
       the way line-height math assumes. A first, modest correction
       (8px/20px) still wasn't enough — pushing much harder this time
       rather than repeating another small, hard-to-judge nudge; if
       this overshoots, that'll be obvious and easy to dial back from,
       unlike a change too subtle to tell apart from no change at all.
       Being upfront: I can't render this myself, so this is genuinely
       iterative on my end until it looks right on your actual screen. */
    height: 56px;
    line-height: 1.5;
    padding: 2px 14px 26px;
    resize: none;
    overflow-y: auto;
    font-family: inherit;
    font-size: 15px;
    border-radius: 12px;
    border: 1px solid var(--border);
    background: var(--bg);
    color: var(--text);
    transition: background-color 2400ms ease, border-color 2400ms ease, color 2400ms ease;
  }
  /* box-shadow-as-focus-ring, not outline: same fix already applied to
     .info-input above — this box's ancestor (.accordion-content) also
     uses overflow-y:auto, which implicitly forces overflow-x:auto too
     (an outline's offset ring draws OUTSIDE the box and gets clipped by
     that ancestor's scroll boundary). box-shadow's inset variant draws
     INSIDE the element's own bounds, so it can never be clipped
     regardless of any ancestor's overflow. Carson's report, 2026-08-12:
     the green focus ring was visibly cut off on the right/bottom edges. */
  #feedback-text:focus-visible, #sitemsg-text:focus-visible {
    outline: none;
    box-shadow: inset 0 0 0 2px var(--accent);
  }
  .feedback-preview-wrap { display: none; margin: 4px 0 12px; }
  .feedback-preview-label { font-size: 11px; font-weight: 700; letter-spacing: 0.03em; text-transform: uppercase; color: var(--text-dim); margin: 0 0 4px; }
  /* .thread-msg's own align-self:flex-end only does anything inside a
     flex-column parent (like the real .thread-messages history) — this
     wrap isn't one, so the bubble needs its own explicit push-right.
     max-height + overflow-y:auto is the fix for the actual reported
     bug: an unbounded preview grows with every line typed, pushing
     Send (and Attach Media) further down until they scroll off-screen.
     A flat 160px still left too little room within the card's own
     bounded height on shorter viewports — clamp() scales this down
     further exactly when that room is tightest, same reasoning as the
     card's own height already uses clamp() for. */
  .feedback-preview-wrap .thread-msg { margin-left: auto; max-height: clamp(60px, 8vh, 100px); overflow-y: auto; }
  .feedback-sender-note { font-size: 12px; color: var(--text-dim); margin: 8px 0 0; }

  /* Built with inline styles in a JS template string (see
     loadFeedbackThreads()'s renderThreadDetail-equivalent code) rather
     than a plain CSS rule, since it's generated per-thread — but
     transition isn't one of the properties set inline there, so this
     rule can still add it without conflicting with anything. Same
     2400ms fix as #feedback-text above. */
  .thread-reply-text {
    transition: background-color 2400ms ease, border-color 2400ms ease, color 2400ms ease;
  }

  /* ---------- Feedback card: media attachments ---------- */
  .attach-section { flex: none; }
  .attach-divider { height: 1px; background: var(--border); margin: 0 0 12px; }

  .attach-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
  .attach-btn {
    position: relative;
    display: inline-flex; align-items: center; gap: 7px;
    background: none; border: 1px solid var(--border); color: var(--text);
    padding: 8px 14px; border-radius: 999px; font-size: 13px; font-weight: 600; cursor: pointer;
    transition: border-color 2400ms ease, color 2400ms ease, transform 0.15s ease;
  }
  .attach-btn::after {
    content: "";
    position: absolute;
    inset: -1px;
    border-radius: inherit;
    z-index: -1;
    background: var(--accent-soft);
    opacity: 0;
    transition: opacity 0.15s ease;
    pointer-events: none;
  }
  .attach-btn:hover::after { opacity: 1; }
  .attach-btn:hover { color: var(--accent); transform: translateY(-1px); }
  .attach-btn svg { width: 15px; height: 15px; }
  /* Glint parity with the sidebar — Attach Media, per Carson's
     request. Uses ::before since ::after is already the hover-tint
     overlay here. */
  #attach-media-btn::before {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: inherit;
    padding: 1px;
    /* Sparkle: tight, bright hot-spot layer + background-blend-
       mode:screen on top of the original glint below — same
       technique as index.html's own copy of this. */
    background:
      linear-gradient(var(--section-glint-angle, 225deg),
        rgba(255, 255, 255, 1) 0%,
        rgba(255, 255, 255, 0.85) 1.5%,
        rgba(255, 255, 255, 0.3) 4%,
        transparent 9%),
      linear-gradient(var(--section-glint-angle, 225deg),
      rgba(255, 255, 255, 0.9) 0%,
      rgba(var(--light-rgb), 0.45) 22%,
      transparent 48%);
    background-blend-mode: screen, normal;
    -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    -webkit-mask-composite: xor;
    mask-composite: exclude;
    pointer-events: none;
  }
  .attach-hint { font-size: 12px; color: var(--text-dim); }

  .attach-chips {
    display: flex; flex-wrap: wrap; gap: 8px;
    margin-top: 12px;
  }
  .attach-chips:empty { margin-top: 0; }

  .attach-chip {
    position: relative;
    display: flex; align-items: center; gap: 8px;
    background: var(--bg); border: 1px solid var(--border); border-radius: 10px;
    padding: 6px 10px 6px 6px; font-size: 12.5px; font-weight: 550;
    max-width: 220px;
  }
  .attach-chip .thumb {
    width: 28px; height: 28px; border-radius: 6px; flex: none;
    background: var(--accent-soft); color: var(--accent);
    display: flex; align-items: center; justify-content: center; overflow: hidden;
  }
  .attach-chip .thumb img { width: 100%; height: 100%; object-fit: cover; }
  .attach-chip .thumb svg { width: 15px; height: 15px; }
  .attach-chip .file-name {
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  }
  .attach-chip .remove-btn {
    flex: none; width: 18px; height: 18px; border-radius: 50%;
    background: var(--border); color: var(--text); border: none;
    display: flex; align-items: center; justify-content: center; cursor: pointer;
    font-size: 12px; line-height: 1; padding: 0;
    transition: background-color 0.15s ease, color 0.15s ease;
  }
  /* --down-strong, not --down — same contrast fix as .admin-offboard-
     btn's own copy of this comment. No hover-lift here deliberately —
     unlike this page's standalone pill buttons, this is a tiny (18px)
     inline "remove" glyph attached to a chip, not a free-standing
     action in its own right; a lift reads oddly at this scale, same
     reasoning .admin-chip-remove's own equivalent button is exempt
     for. */
  .attach-chip .remove-btn:hover { background: var(--down-strong); color: #fff; }

  .attach-error {
    font-size: 12.5px; color: var(--down); margin: 10px 0 0;
  }
  .attach-error[hidden] { display: none; }

  /* ---------- Feedback card: Inbox / Send accordion ---------- */
  .accordion { display: flex; flex-direction: column; gap: 10px; flex: 1; min-height: 0; }
  .accordion-section {
    border: 1px solid var(--border); border-radius: 14px; background: var(--bg);
    display: flex; flex-direction: column; overflow: hidden; flex: none;
    position: relative;
    transition: background-color 2400ms ease, border-color 2400ms ease;
  }
  /* Glint parity with the sidebar's own rows — covers all 5 accordion
     boxes (Inbox, Send a Message, Users & Groups, Unban Requests, Site
     Message all share this exact class) in one shared rule. Angle
     computed per-element in recompute() like every other glass surface
     here. */
  .accordion-section::before {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: inherit;
    padding: 1px;
    /* Sparkle: tight, bright hot-spot layer + background-blend-
       mode:screen on top of the original glint below — same
       technique as index.html's own copy of this. */
    background:
      linear-gradient(var(--section-glint-angle, 225deg),
        rgba(255, 255, 255, 1) 0%,
        rgba(255, 255, 255, 0.85) 1.5%,
        rgba(255, 255, 255, 0.3) 4%,
        transparent 9%),
      linear-gradient(var(--section-glint-angle, 225deg),
      rgba(255, 255, 255, 0.9) 0%,
      rgba(var(--light-rgb), 0.45) 22%,
      transparent 48%);
    background-blend-mode: screen, normal;
    -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    -webkit-mask-composite: xor;
    mask-composite: exclude;
    pointer-events: none;
  }
  /* .accordion-section.open used to also get flex:1 here, so the open
     section stretch-filled whatever space .accordion (its parent, also
     flex:1) had available — made sense back when .accordion-content
     relied on that same flex chain for its own height. Now that content
     is sized by its own max-height reveal instead (below), that old
     rule just fought it: the section kept stretch-filling leftover
     space regardless of how tall its actual content was, which is
     exactly the "thicker than it should be" Carson reported on Users &
     Groups and Send a Message specifically — both have less natural
     content than Inbox's own thread list, so the empty stretched gap
     was more visible there. Removed; the section now sizes to its own
     header + content naturally, same as everything else on this page. */
  .accordion-header {
    display: flex; align-items: center; justify-content: space-between;
    width: 100%; padding: 14px 16px; background: none; border: none; cursor: pointer;
    font-family: inherit; text-align: left; flex: none;
  }
  .accordion-header-title {
    display: flex; align-items: center; gap: 9px; font-size: 15px; font-weight: 650; color: var(--text);
  }
  .accordion-header-title svg { width: 17px; height: 17px; color: var(--accent); flex: none; }
  /* --accent-strong, not --accent — same contrast fix as the pill-
     button harmonization pass (11px bold white text on --accent
     measures below 4.5:1, especially in dark mode). */
  .accordion-badge {
    font-size: 11px; font-weight: 700; color: #fff; background: var(--accent-strong);
    border-radius: 999px; min-width: 18px; height: 18px; padding: 0 5px;
    display: inline-flex; align-items: center; justify-content: center;
  }
  .accordion-badge[hidden] { display: none; }
  .accordion-chevron { width: 15px; height: 15px; color: var(--text-dim); flex: none; transition: transform 0.2s ease; }
  .accordion-header[aria-expanded="true"] .accordion-chevron { transform: rotate(180deg); }
  .accordion-content {
    padding: 0 16px 16px; display: flex; flex-direction: column; gap: 12px;
    overflow: hidden;
    max-height: 0;
    opacity: 0;
    transition: max-height 0.3s cubic-bezier(.22,.61,.36,1), opacity 0.24s ease;
  }
  .accordion-section.open .accordion-content {
    opacity: 1;
    /* No max-height OR overflow-y fallback here anymore — both used to
       race with setAccordionContentOpen()'s own JS sequence, in two
       separate but related ways. The max-height race: that function
       used to add .open BEFORE setting the inline max-height, which
       meant a since-removed max-height:none fallback here took effect
       the instant .open landed — snapping the content to full height
       immediately, before the JS's own "still closed" reflow ever
       registered, silently skipping the whole height transition while
       opacity kept fading separately (the "box opens instantly,
       content fades in after" mismatch). The overflow-y race: even
       after that fix, this rule's own overflow-y:auto ALSO switched on
       instantly via the class the moment .open landed — meaning a
       scrollbar could briefly flash in and back out mid-growth on any
       content tall enough to eventually need one, since the box hadn't
       reached its full height yet partway through. That's an open-only
       artifact (closing content is already full-size before the
       shrink starts), which lines up with Carson specifically
       reporting that contracting felt fine while expanding didn't.
       Both are now handled purely via inline style in JS, held at
       "still collapsed" values throughout the growth and only released
       once it's actually settled. Elements that start .open via static
       HTML (send-section, admin-users-section) get both explicitly
       initialized once via initAccordionStaticOpenState() instead,
       completely decoupled from this animated path. */
  }
  @media (prefers-reduced-motion: reduce) {
    .accordion-content { transition: opacity 0.15s ease; }
  }

  /* ---------- Admin card: search, user rows, group chips, save bar ---------- */
  .admin-search-input {
    width: 100%; font-family: inherit; font-size: 14px; padding: 10px 12px; border-radius: 9px;
    border: 1px solid var(--border); background: var(--bg); color: var(--text); margin-bottom: 12px;
    transition: background-color 2400ms ease, border-color 2400ms ease, color 2400ms ease;
  }
  .admin-search-input:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--accent); }

  /* max-height + overflow-y here rather than trusting the accordion-
     content flex chain alone — same belt-and-suspenders fix already
     applied to .thread-list/.session-list, and for the same
     documented reason: that chain SHOULD bound this on its own, but
     this codebase has already been bitten once by that assumption not
     holding on every browser. .admin-user-list gets more headroom
     (potentially many household members + guests); .admin-unban-list
     is naturally smaller and self-limiting via the 7-day expiry, but
     still deserves its own explicit cap rather than an implicit one. */
  .admin-user-list { display: flex; flex-direction: column; gap: 8px; max-height: 420px; overflow-y: auto; -webkit-overflow-scrolling: touch; }
  .admin-unban-list { display: flex; flex-direction: column; gap: 8px; max-height: 320px; overflow-y: auto; -webkit-overflow-scrolling: touch; }
  .admin-empty { font-size: 13px; color: var(--text-dim); padding: 4px 0 8px; }
  .admin-empty[hidden] { display: none; }

  .admin-user-row {
    display: flex; align-items: flex-start; gap: 12px;
    border: 1px solid var(--border); border-radius: 12px; background: var(--surface);
    padding: 12px 14px;
    transition: background-color 2400ms ease, border-color 2400ms ease;
  }
  /* --accent-strong, not --accent — same contrast fix as the pill-
     button harmonization pass (13px bold, well under the "large text"
     size WCAG needs to allow a relaxed 3:1 threshold, so this needs
     the full 4.5:1 — --accent alone doesn't clear it, especially in
     dark mode). */
  .admin-user-avatar {
    width: 36px; height: 36px; border-radius: 50%; flex: none;
    background: var(--accent-strong); color: #fff;
    display: flex; align-items: center; justify-content: center;
    font-size: 13px; font-weight: 700;
    transition: background-color 2400ms ease;
  }
  .admin-user-info { flex: 1; min-width: 0; }
  .admin-user-name { font-size: 14.5px; font-weight: 650; }
  .admin-user-email { font-size: 12.5px; color: var(--text-dim); margin-top: 1px; }
  .admin-user-groups-wrap { margin-top: 8px; }
  /* Capped and independently scrollable — without this, a heavily-
     grouped user (every svc-<name> + svc-<name>-admin pair, across
     several services) would just make that one row grow taller and
     taller, pushing every other user further down the list. ~3 rows
     worth caps it; anything beyond that scrolls in place instead. */
  .admin-user-groups {
    display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
    max-height: 84px; overflow-y: auto; padding: 4px; margin: -4px;
    /* Slight inset background reads as its own small scroll region,
       distinct from the row's own surface — same idea as .info-input
       nesting var(--bg) inside a var(--surface) card. */
    background: var(--bg); border-radius: 8px;
    transition: background-color 2400ms ease;
  }
  .admin-user-groups:empty { display: none; background: none; }
  /* Deliberately OUTSIDE the scrollable chip area, not mixed into it —
     with many groups already assigned, "+ Add group" scrolling out of
     view along with the chips would be exactly the wrong tradeoff for
     something meant to stay quick and reachable. */
  .admin-group-picker { margin-top: 6px; }

  .admin-group-chip {
    display: inline-flex; align-items: center; gap: 4px;
    background: var(--accent-soft); color: var(--accent);
    font-size: 12px; font-weight: 600; padding: 4px 6px 4px 10px; border-radius: 999px;
    border: 1px solid transparent;
    transition: background-color 2400ms ease, color 2400ms ease, border-color 2400ms ease,
                opacity 0.18s ease, transform 0.18s cubic-bezier(.4,0,.2,1);
  }
  /* .chip-removing is added just before the underlying data actually
     changes (see the remove button's click handler) — the chip fades/
     shrinks in place first, THEN the data updates and the whole list
     re-renders, so the removal is never just an instant disappearance
     mid-rebuild. .chip-pop-in is a one-shot entrance animation applied
     to a freshly-added chip's brand-new DOM node right after that same
     kind of re-render creates it. */
  .admin-group-chip.chip-removing { opacity: 0; transform: scale(0.8); }
  @keyframes chip-pop-in {
    0% { opacity: 0; transform: scale(0.7); }
    100% { opacity: 1; transform: scale(1); }
  }
  .admin-group-chip.chip-pop-in { animation: chip-pop-in 0.26s cubic-bezier(.22,.61,.36,1); }
  @media (prefers-reduced-motion: reduce) {
    .admin-group-chip { transition: background-color 2400ms ease, color 2400ms ease, border-color 2400ms ease, opacity 0.1s ease; }
    .admin-group-chip.chip-pop-in { animation: none; }
  }
  /* Not-yet-saved additions get a dashed ring so the batch-save preview
     is unambiguous about what's actually pending vs. already saved —
     matters more here than elsewhere since Save applies to every
     changed row at once, not just the one you're looking at. */
  .admin-group-chip.pending-add { border-color: var(--accent); border-style: dashed; }
  .admin-chip-remove {
    background: none; border: none; color: inherit; cursor: pointer;
    font-size: 14px; line-height: 1; padding: 0 0 0 2px; opacity: 0.7;
  }
  .admin-chip-remove:hover { opacity: 1; }

  .admin-add-group-btn {
    position: relative;
    display: inline-flex; align-items: center; gap: 5px;
    background: none; border: 1px dashed var(--border); color: var(--text-dim);
    padding: 4px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; cursor: pointer;
    transition: border-color 2400ms ease, color 2400ms ease, transform 0.15s ease;
  }
  .admin-add-group-btn::after {
    content: "";
    position: absolute; inset: -1px; border-radius: inherit; z-index: -1;
    background: var(--accent-soft); opacity: 0; transition: opacity 0.15s ease;
    pointer-events: none;
  }
  .admin-add-group-btn:hover::after { opacity: 1; }
  .admin-add-group-btn:hover { color: var(--accent); transform: translateY(-1px); }

  /* Custom dropdown, replacing a native <select> — matches the site's
     own glass/pill visual language instead of breaking immersion with
     OS-chrome. Same small-menu pattern already used for
     .row-context-menu elsewhere on this page, just positioned relative
     to its own trigger button instead of at a click coordinate. */
  .admin-group-picker { position: relative; display: inline-flex; }
  .admin-group-picker-menu {
    position: absolute; top: calc(100% + 6px); left: 0; z-index: 20;
    min-width: 160px; max-height: 220px; overflow-y: auto;
    background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
    /* Light-respecting shadow — own dedicated live-measurement call
       (from the click handler that opens this menu, see the admin
       users script) since this element has no existing glint-angle
       tracking. Element-scoped --shadow-x/-y, same generic name
       .row-context-menu/.notif-panel/the confirm modal all use — safe
       since it's set directly on this specific menu instance, not
       :root. */
    box-shadow: var(--shadow-x, 0px) var(--shadow-y, 10px) 30px rgba(0, 0, 0, 0.18); padding: 6px;
    /* Scale+translateY+opacity, matching .notif-panel's own small-
       dropdown treatment — not a height reveal, since this already has
       its own fixed 220px cap for internal scrolling (a real list-
       length cap, not something that should animate). fadeToggle()
       drives this via the .reveal-open class, same as the Edit/Change
       buttons — no max-height involved anywhere in this element's own
       animation at all. */
    transform: translateY(-6px) scale(0.96);
    opacity: 0;
    pointer-events: none;
    transition: transform 0.18s cubic-bezier(.22,.61,.36,1), opacity 0.16s ease,
                background-color 2400ms ease, border-color 2400ms ease;
  }
  .admin-group-picker-menu.reveal-open {
    transform: translateY(0) scale(1);
    opacity: 1;
    pointer-events: auto;
  }
  .admin-group-picker-menu[hidden] { display: none; }
  @media (prefers-reduced-motion: reduce) {
    .admin-group-picker-menu { transition: opacity 0.12s ease; transform: none; }
  }
  .admin-group-picker-menu button {
    display: block; width: 100%; text-align: left;
    font-size: 13px; font-weight: 550; padding: 8px 10px; border-radius: 8px;
    background: none; border: none; cursor: pointer; color: var(--text); font-family: inherit;
  }
  .admin-group-picker-menu button:hover { background: var(--accent-soft); color: var(--accent); }

  .admin-offboard-btn {
    position: relative; flex: none;
    font-size: 12.5px; font-weight: 650; color: var(--down);
    background: var(--surface); border: 1px solid var(--down); border-radius: 999px;
    padding: 8px 14px; cursor: pointer;
    transition: background-color 2400ms ease, border-color 2400ms ease, transform 0.15s ease;
  }
  .admin-offboard-btn::after {
    content: "";
    position: absolute; inset: -1px; border-radius: inherit; z-index: -1;
    /* --down-strong, not --down — same contrast fix as .admin-unban-
       btn's own --ok-strong just above (--down in dark mode measures
       2.71:1 with white text, also below the 4.5:1 this size needs —
       same underlying problem, just not yet reported on this
       specific button). */
    background: var(--down-strong); opacity: 0; transition: opacity 0.15s ease;
    pointer-events: none;
  }
  .admin-offboard-btn:hover::after { opacity: 1; }
  .admin-offboard-btn:hover { color: #fff; transform: translateY(-1px); }
  .admin-offboard-btn:disabled { opacity: 0.5; cursor: default; }

  .admin-save-bar {
    display: flex; align-items: center; justify-content: space-between; gap: 12px;
    background: var(--accent-soft); border-radius: 12px; padding: 12px 14px; margin-top: 12px;
    transition: background-color 2400ms ease;
  }
  .admin-save-bar[hidden] { display: none; }
  .admin-save-bar-text { font-size: 13px; font-weight: 600; color: var(--accent); }
  .admin-save-bar-actions { display: flex; gap: 8px; flex: none; }
  .admin-save-bar-actions .btn-save { flex: none; padding: 9px 16px; font-size: 13.5px; }
  .admin-save-bar-actions .btn-cancel { padding: 9px 14px; font-size: 13.5px; }

  .admin-unban-row {
    display: flex; align-items: center; gap: 12px;
    border: 1px solid var(--border); border-radius: 12px; background: var(--surface);
    padding: 12px 14px;
    transition: background-color 2400ms ease, border-color 2400ms ease, opacity 0.15s ease;
  }
  /* Marked-for-unban state — dashed border matches the same "pending,
     not yet saved" language already used on the group chips above. */
  .admin-unban-row.pending-unban { opacity: 0.6; border-style: dashed; }
  .admin-unban-info { flex: 1; min-width: 0; }
  .admin-unban-ip { font-size: 14.5px; font-weight: 650; font-family: ui-monospace, "SF Mono", Menlo, monospace; }
  .admin-unban-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 4px; }
  /* One unified badge — Carson's ban detection is a single pipeline
     (n8n logs the repeat attempt, triggers the Cloudflare block, sends
     the email), not genuinely two separate ban types, so this no
     longer needs a source-specific color split. */
  .admin-source-badge {
    font-size: 10.5px; font-weight: 700; letter-spacing: 0.03em; text-transform: uppercase;
    padding: 2px 7px; border-radius: 999px;
    background: rgba(199, 154, 62, 0.16); color: var(--pending);
    transition: background-color 2400ms ease, color 2400ms ease;
  }
  .admin-unban-reason { font-size: 12.5px; color: var(--text-dim); }
  .admin-unban-time { font-size: 11.5px; color: var(--text-dim); }
  .admin-unban-expiry { font-size: 11.5px; color: var(--text-dim); margin: 4px 0 0; }

  .admin-unban-btn {
    position: relative; flex: none;
    font-size: 12.5px; font-weight: 650; color: var(--ok);
    background: var(--surface); border: 1px solid var(--ok); border-radius: 999px;
    padding: 8px 14px; cursor: pointer;
    /* transform added — Carson's harmonization ask: every pill button's
       hover should include some real motion, subtle or not, and this
       one had none at all. Same 0.15s/-1px lift as the destructive
       buttons it's the positive-action counterpart to
       (.admin-offboard-btn right below). */
    transition: background-color 2400ms ease, border-color 2400ms ease, transform 0.15s ease;
  }
  .admin-unban-btn::after {
    content: "";
    position: absolute; inset: -1px; border-radius: inherit; z-index: -1;
    /* --ok-strong, not --ok — Carson's report: white text on --ok's
       own value was unreadable on hover in light mode (measured at
       3.47:1, below the 4.5:1 this text size needs). See --ok-strong's
       own definition for the full contrast math. */
    background: var(--ok-strong); opacity: 0; transition: opacity 0.15s ease;
    pointer-events: none;
  }
  .admin-unban-btn:hover::after { opacity: 1; }
  .admin-unban-btn:hover { color: #fff; transform: translateY(-1px); }
  .admin-unban-btn:disabled { opacity: 0.5; cursor: default; }

  /* ---------- Admin card: Site Message ---------- */
  /* Solid announcement colors, not glass — deliberately matches the
     real banner's own material (see .site-banner near the top of this
     stylesheet) rather than the chat-bubble accent color .thread-msg
     uses, so this preview genuinely shows what the banner will look
     like, not just a stand-in bubble shape. Same capped-height +
     internal-scroll + word-wrap treatment as every other live preview
     on this page. */
  .sitemsg-preview-bubble {
    background: var(--banner-announce-bg); color: var(--banner-announce-text);
    padding: 10px 16px; border-radius: 10px;
    font-size: 13.5px; font-weight: 600; line-height: 1.5; text-align: center;
    max-height: clamp(60px, 8vh, 100px); overflow-y: auto;
    white-space: pre-wrap; overflow-wrap: break-word;
    /* Matches the tier buttons' own 400ms — same reasoning: this
       recolors the instant you click a tier button, so it should track
       that click's pace, not lag behind it. */
    transition: background-color 400ms ease, color 400ms ease;
  }
  /* Same three tier classes as the real .site-banner — kept the exact
     same class names on purpose so both the compose preview and the
     "currently posted" display genuinely show the right color, not an
     approximation of it. */
  .sitemsg-preview-bubble.tier-danger { background: var(--banner-danger-bg); color: var(--banner-danger-text); }
  .sitemsg-preview-bubble.tier-warning { background: var(--banner-warning-bg); color: var(--banner-warning-text); }
  .sitemsg-preview-bubble.tier-announce { background: var(--banner-announce-bg); color: var(--banner-announce-text); }
  /* Same fix as .password-display-note a and the same reasoning as
     .thread-msg a / .site-banner-text a just above — renderMessageHtml()
     can generate a markdown-style <a> here too (an admin composing a
     site-wide message with a link), and without this it would render
     as unstyled, browser-default blue rather than this tier's own
     text color, snapping at the very end of a toggle instead of
     fading with everything else. inherit, not a fixed var(--accent)
     the way the passkey link uses, since this element's own text
     color already legitimately varies by tier (danger/warning/
     announce) — a fixed accent color would fight that instead of
     matching it. */
  .sitemsg-preview-bubble a { color: inherit; text-decoration: underline; }
  .admin-sitemsg-active {
    border: 1px solid var(--border); border-radius: 12px; background: var(--surface);
    padding: 14px; margin-bottom: 16px;
    transition: background-color 2400ms ease, border-color 2400ms ease;
  }
  .admin-sitemsg-active-label {
    font-size: 11px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase;
    color: var(--text-dim); margin: 0 0 8px;
  }
  .admin-sitemsg-active-meta { font-size: 12px; color: var(--text-dim); margin: 8px 0 12px; }

  .sitemsg-duration-row { margin: 14px 0; }
  .sitemsg-duration-label {
    display: block; font-size: 11.5px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase;
    color: var(--text-dim); margin: 0 0 8px;
  }
  .sitemsg-duration-options { display: flex; flex-wrap: wrap; gap: 8px; }
  .sitemsg-duration-options button {
    font-size: 12.5px; font-weight: 650; padding: 7px 14px; border-radius: 999px;
    border: 1px solid var(--border); background: var(--surface); color: var(--text-dim); cursor: pointer;
    /* Matches the TYPE tier pills right above — Carson's ask: same
       400ms speed, since these were still at the original 2400ms
       theme-color default and had never been brought in line with the
       tier picker's own speed-up. */
    transition: background-color 400ms ease, border-color 400ms ease, color 400ms ease;
  }
  /* --accent-strong, not --accent — same contrast fix as the rest of
     this pass, found while sweeping for the same pattern (12.5px bold
     white text on --accent, same underlying issue as everywhere else
     fixed here). */
  .sitemsg-duration-options button.active {
    background: var(--accent-strong); border-color: var(--accent-strong); color: #fff;
  }
  /* Tier picker — same pill mechanics as the duration row above, but
     each active state uses that tier's own color instead of the
     generic accent, so the choice reads clearly before you've even
     posted anything. */
  .sitemsg-tier-row { margin: 14px 0; }
  .sitemsg-tier-options { display: flex; flex-wrap: wrap; gap: 8px; }
  .sitemsg-tier-options button {
    font-size: 12.5px; font-weight: 650; padding: 7px 14px; border-radius: 999px;
    border: 1px solid var(--border); background: var(--surface); color: var(--text-dim); cursor: pointer;
    /* Down to 400ms now — a sixth of the original 2400ms. Still not
       instant/0ms, so clicking a tier still reads as a deliberate,
       visible change rather than a hard cut, but fast enough to feel
       essentially immediate. */
    transition: background-color 400ms ease, border-color 400ms ease, color 400ms ease;
  }
  .sitemsg-tier-options button[data-tier="danger"].active { background: var(--banner-danger-bg); border-color: var(--banner-danger-bg); color: var(--banner-danger-text); }
  .sitemsg-tier-options button[data-tier="warning"].active { background: var(--banner-warning-bg); border-color: var(--banner-warning-bg); color: var(--banner-warning-text); }
  .sitemsg-tier-options button[data-tier="announce"].active { background: var(--banner-announce-bg); border-color: var(--banner-announce-bg); color: var(--banner-announce-text); }
  .sitemsg-tier-hint { font-size: 12px; color: var(--text-dim); margin: 8px 0 0; }
  .sitemsg-custom-days {
    margin-top: 8px; width: 100px; font-family: inherit; font-size: 13px; padding: 8px 10px; border-radius: 9px;
    border: 1px solid var(--border); background: var(--bg); color: var(--text);
    transition: background-color 2400ms ease, border-color 2400ms ease, color 2400ms ease;
  }
  /* Extra bottom breathing room for Post Message specifically — without
     it, the button sat right at the accordion's own bottom edge with
     nothing like the padding the Save Changes/Discard bar gives itself
     (12px 14px) on the Users & Groups section right above this one.
     Scoped to this section rather than changing .card-actions generally,
     since Account Info/Services/the Feedback compose box all use that
     same class and don't have this problem. */
  #admin-sitemsg-content .card-actions { margin-bottom: 12px; }

  /* max-height + its own overflow-y is a deliberate belt-and-suspenders
     guarantee, not just relying on the accordion-content/card-body flex
     chain above (which SHOULD already bound this, but this codebase has
     already been bitten once by nested-flex-overflow assumptions not
     holding on every browser — see the mobile viewport notes near the
     end of this stylesheet). Same pattern as .session-list below, which
     solves the identical "could grow unbounded" problem. */
  .thread-list { display: flex; flex-direction: column; gap: 8px; max-height: 420px; overflow-y: auto; -webkit-overflow-scrolling: touch; }
  .inbox-empty { font-size: 13px; color: var(--text-dim); padding: 4px 0 8px; }
  .inbox-empty[hidden] { display: none; }

  .thread-row {
    border: 1px solid var(--border); border-radius: 12px; background: var(--surface); overflow: hidden;
    /* flex-shrink:0 (Carson's report, 2026-08-12, screenshot showing the
       button row visually sliced through the middle): .thread-row is a
       flex item inside .thread-list, which is capped at max-height:420px.
       Flex items default to flex-shrink:1, so the browser was allowed to
       compress an EXPANDED row shorter than its actual content (messages
       + textarea + button row) needed once the column ran out of room —
       and because this element also has overflow:hidden, that compression
       CLIPPED content instead of overflowing visibly. That's why a small
       padding increase was enough to visibly cut into the buttons: it
       tipped the row's natural height just past whatever budget the flex
       algorithm was allotting it. flex-shrink:0 stops this row from ever
       being compressed below its own content's natural size —
       .thread-list's own overflow-y:auto scroll handles anything that
       doesn't fit, which is the correct behavior (scroll to see the row,
       never squish it). */
    flex-shrink: 0;
  }
  .thread-row-header {
    display: flex; align-items: center; justify-content: space-between; gap: 10px;
    width: 100%; padding: 12px 14px; background: none; border: none; cursor: pointer; text-align: left; font-family: inherit;
  }
  .thread-row-main { display: flex; align-items: center; gap: 9px; min-width: 0; flex: 1; }
  .thread-unread-dot {
    width: 8px; height: 8px; border-radius: 50%; background: var(--accent); flex: none;
  }
  .thread-unread-dot[hidden] { display: none; }
  .thread-preview-text {
    font-size: 13.5px; font-weight: 550; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  }
  .thread-row.unread .thread-preview-text { font-weight: 700; }

  /* ---------- Status tags (Open / Resolved) ----------
     Open uses --pending (amber) — already the palette's existing
     "in progress / awaiting action" color elsewhere on the site — rather
     than introducing a new yellow. Resolved reuses --ok (green), same
     token used for the Share button and healthy-status indicators.
     Only Carson can ever apply Resolved (via the email magic link); a
     user can only Reopen, never self-resolve — enforced server-side,
     this styling just reflects whatever the backend actually set. */
  .thread-status-tag {
    font-size: 10px; font-weight: 700; letter-spacing: 0.03em; text-transform: uppercase;
    padding: 2px 8px; border-radius: 999px; flex: none; white-space: nowrap;
  }
  .thread-status-tag.open { background: rgba(199, 154, 62, 0.16); color: var(--pending); }
  .thread-status-tag.resolved { background: rgba(62, 154, 108, 0.16); color: var(--ok); }

  /* Surfaced at the list-row level, not just inside the expanded
     conversation — Carson's ask specifically: it was "too hidden" only
     showing once a thread was opened. A sibling of .thread-row-header
     (not nested inside it) so it can't disturb that button's own
     internal flex layout — this card has a documented history of
     external CSS rules failing to reach elements built INSIDE that
     specific region, so the safest change here is one that doesn't
     touch it at all. Only rendered for resolved threads, matching the
     existing rule that Open threads never age out. */
  .thread-expiry-badge {
    display: inline-flex; align-items: center;
    font-size: 11px; font-weight: 700;
    color: var(--pending); background: rgba(199, 154, 62, 0.14);
    padding: 3px 10px; border-radius: 999px;
    margin: 0 14px 10px;
    transition: background-color 2400ms ease, color 2400ms ease;
  }

  .thread-row-meta { display: flex; align-items: center; gap: 8px; flex: none; }
  .thread-time { font-size: 11.5px; color: var(--text-dim); }
  .thread-chevron { width: 13px; height: 13px; color: var(--text-dim); transition: transform 0.2s ease; }
  .thread-row.expanded .thread-chevron { transform: rotate(180deg); }

  /* Structural fix (Carson, 2026-08-12) replacing an earlier position:
     sticky attempt on .thread-reply-actions, which caused a NEW bug: the
     growing textarea started rendering ON TOP OF the button row instead
     of being blocked by it. Root cause: .thread-row (an ancestor) has
     overflow:hidden, which the CSS sticky-positioning spec treats as a
     scroll container for containing-block purposes — that box has no
     actual scrolling of its own, so the sticky element got pulled out of
     normal flow and rendered at a fixed spot immediately, while the
     textarea above it (still in normal flow) kept growing right past
     that same spot, producing the overlap.
     Fix: no sticky/absolute positioning anywhere in this box. .thread-
     detail is a bounded flex column; .thread-messages is the ONLY
     internally-scrolling piece (flex:1, min-height:0); the reply row +
     action buttons are a normal, always-in-flow flex:none sibling below
     it. Nothing is ever taken out of flow, so nothing can ever overlap
     or hide — the message history scrolls internally long before the
     reply box + buttons could ever be pushed out of view. */
  .thread-detail {
    display: flex; flex-direction: column; padding: 0 14px 14px; border-top: 1px solid var(--border);
    max-height: 480px;
  }
  .thread-detail[hidden] { display: none; }
  .thread-messages { display: flex; flex-direction: column; gap: 10px; margin: 12px 0; flex: 1; min-height: 0; overflow-y: auto; }
  .thread-msg {
    max-width: 88%; padding: 10px 13px; border-radius: 14px; font-size: 13.5px; line-height: 1.5;
    white-space: pre-wrap;
    /* white-space:pre-wrap alone wraps at spaces/newlines, but NOT
       within a single long unbroken token (a URL, for instance) — a
       native <textarea> always force-wraps regardless of word length,
       so without this the preview (and, it turns out, real sent
       messages too — this was a pre-existing gap, not preview-only)
       could overflow instead of matching what the compose box itself
       shows. */
    overflow-wrap: break-word;
  }
  /* --accent-strong, not --accent — same contrast fix as the pill-
     button harmonization pass. This is real body text at normal
     reading size (13.5px, not bold), the most significant of this
     whole contrast-fix pass to actually get right — --accent alone
     measures below 4.5:1 with white text, worst in dark mode at
     2.33:1. The reply-preview bubble (JS, further down) deliberately
     mirrors this rule's own colors exactly — updated together, not
     just here, so the two don't drift apart. */
  .thread-msg.from-user { align-self: flex-end; background: var(--accent-strong); color: #fff; border-radius: 14px 14px 4px 14px; }
  .thread-msg.from-carson { align-self: flex-start; background: var(--accent-soft); color: var(--text); border-radius: 14px 14px 14px 4px; }
  /* Basic formatting produced by renderMessageHtml() (see JS) — the
     stored text uses a small, custom plain-text convention (**bold**,
     _italic_, `code`, [text](url), "- " list lines) that's converted to
     real HTML only for DISPLAY, after the raw text has already been
     HTML-escaped, so this never introduces attacker-controlled markup —
     the only tags that can ever appear are the fixed ones baked into
     that renderer. Link color needs to stay legible against BOTH bubble
     backgrounds (solid accent + soft accent), so it's set explicitly
     here rather than inheriting. */
  .thread-msg a { color: inherit; text-decoration: underline; }
  .thread-msg code { background: rgba(0,0,0,0.14); padding: 1px 5px; border-radius: 4px; font-family: ui-monospace, monospace; font-size: 12.5px; }
  .thread-msg ul { margin: 4px 0; padding-left: 18px; }
  .thread-msg li { margin: 2px 0; }
  /* System messages (e.g. the 7-day auto-resolve note) are visually
     distinct from anything either party actually said — centered, no
     bubble/tail, muted — so they read as "the system did this," never
     mistakable for something Carson or the user typed. */
  .thread-msg.from-system {
    align-self: center; max-width: 92%; background: none; border: 1px dashed var(--border);
    color: var(--text-dim); font-size: 12px; text-align: center; border-radius: 10px;
  }
  .thread-msg-time { font-size: 10.5px; opacity: 0.7; margin-top: 4px; }
  .thread-msg-attachments { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
  .thread-msg-attachments a { display: inline-block; }
  .thread-msg-attachments img { width: 64px; height: 64px; object-fit: cover; border-radius: 8px; display: block; }
  .thread-msg-attachments .file-link {
    font-size: 11.5px; padding: 6px 10px; border-radius: 8px; background: rgba(255,255,255,0.18);
    display: inline-flex; align-items: center; gap: 5px;
  }

  .thread-expiry-note { font-size: 11.5px; color: var(--text-dim); margin: 4px 0 14px; }

  /* flex:none — a normal, always-in-flow sibling of .thread-messages
     above (never shrunk, never taken out of flow), so it can never be
     covered by or scroll behind anything. See .thread-detail's comment
     for the full reasoning — this replaces an earlier position:sticky
     attempt that caused an overlap bug instead of fixing anything. */
  .thread-reply-row { display: flex; flex-direction: column; gap: 8px; flex: none; }
  /* REVERTED back to a plain <textarea> (Carson, 2026-08-12): the
     contenteditable-div version was shipped to make Bold/Italic/
     Underline render live with no visible markdown symbols, but it
     rendered completely invisible (no border, nothing) once empty and
     unfocused, on a real device, after two attempted fixes. A native
     textarea is a standard, boring, guaranteed-to-render browser element
     — correctness and "looks like the version Carson already liked" won
     over the live-formatting nicety. Tradeoff, explicitly accepted:
     Bold/Italic/Underline once again insert visible **, _ and ^^
     characters into the box while composing (same as the very first
     version of this toolbar) rather than rendering styled text live —
     a plain textarea cannot show real bold/italic, that's a hard
     browser limitation, not a bug to fix later. The SENT/rendered
     message bubble still shows
     real bold/italic/underline/links/lists/code either way —
     renderMessageHtml() (below) is unrelated to which box composes it. */
  /* Background color pinned to match the Inbox panel exactly; native
     textarea/color-scheme theming stripped via appearance:none so the
     browser can't blend its own UI over these colors. max-height caps
     the resize drag so Send/toolbar/Share/Delete can never be buried
     below the fold. (Comment kept short deliberately — a prior long
     comment embedded inside this rule, containing an HTML-looking
     snippet and stray backticks, caused the ENTIRE rule to be silently
     dropped by the browser's CSS parser — confirmed live via
     document.styleSheets. That's the real reason earlier fixes never
     visibly applied.) */
  .thread-reply-text {
    font-family: inherit; font-size: 13.5px; padding: 10px 12px; border-radius: 10px;
    border: 1px solid var(--border) !important; background: var(--bg) !important; color: var(--text) !important;
    /* FINAL approach (Carson, 2026-08-12) after several attempts at
       dynamic sizing (native resize handle, then a custom drag handle,
       then auto-grow-on-type) each caused their own version of the same
       problem: the button row getting pushed out of view. Fixed height
       now (see the element's own inline style attribute —
       height:clamp(80px, 16vh, 130px)), responsive to the BROWSER WINDOW
       only, never to typed content. Long messages scroll INSIDE the box
       via overflow-y:auto instead of changing its size. Nothing about
       this box's height ever reacts to typing, so there's nothing left
       that could push the button row anywhere. Bonus: also just a
       cleaner interaction than a drag handle would have been on
       this is also the better mobile behavior, not just the safer one.
       min(220px, 40vh) additionally shrinks the cap itself on short
       mobile viewports so it can never eat the whole screen. */
    resize: none; min-height: 96px; max-height: min(220px, 40vh) !important; overflow-y: auto;
    appearance: none; -webkit-appearance: none; color-scheme: light;
  }
  /* Same box-shadow-as-focus-ring fix as #feedback-text above, applied
     here so this box (Carson's explicit target: "this is the exact kind
     of box I'm looking for in the Inbox section for Replies") matches
     it exactly, clipped-outline bug included in the fix, not just the
     background/size. */
  .thread-reply-text:focus-visible {
    outline: none;
    box-shadow: inset 0 0 0 2px var(--accent);
  }
  /* Single bottom row (Carson's ask, 2026-08-12 — the separate markdown-
     toolbar row above this one was clipping off the bottom of the card
     on some viewports): Send pinned left, the markdown formatting
     buttons TRULY centered in the row (not just "next to Send" — the
     toolbar wrapper takes flex:1 and centers its own contents within
     that space, so it stays centered regardless of how wide Send or the
     right-side icon group happen to be), Share + Delete grouped together
     on the right, matching the Inbox toolbar's icon grouping. flex-wrap
     is a safety net for narrow widths, not the primary layout mechanism;
     on any reasonably wide card this is one visual row. */
  /* position:sticky pins this row to the bottom of the Inbox's OWN
     scroll viewport (.thread-list, max-height:420px, overflow-y:auto —
     the nearest scrolling ancestor) whenever the expanded thread's total
     content (message history + a grown reply box) exceeds that 420px.
     ROOT CAUSE finally found (Carson's screenshot, 2026-08-12, after
     three prior attempts at capping the TEXTAREA'S OWN height, which
     were working correctly all along): the textarea was never actually
     growing unbounded — the buttons were being pushed off-screen by the
     SURROUNDING .thread-list scroll container clipping the row, not by
     the textarea itself. Capping the textarea's height was solving the
     wrong problem. Sticky positioning keeps Send/Share/Delete physically
     reachable regardless of how tall anything above them gets, without
     depending on any height-capping mechanism holding perfectly. */
  /* No position:sticky (removed — caused a textarea/button overlap bug
     instead of fixing anything; see .thread-detail's comment above for
     the full root cause). Just a normal, always-in-flow flex row now;
     .thread-reply-row's own flex:none is what actually keeps this
     visible, not any positioning trick on this element. */
  .thread-reply-actions {
    display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
    padding: 8px 0;
  }
  .thread-reply-icon-group { display: flex; align-items: center; gap: 8px; flex: none; }
  .thread-reply-send-btn {
    padding: 9px 18px; border-radius: 999px; border: none; background: var(--text); color: var(--bg);
    font-size: 13.5px; font-weight: 650; cursor: pointer;
    transition: opacity 0.15s ease, transform 0.1s ease;
  }
  /* Same primary-button hover/active pattern as .btn-save — this is
     the same functional role (the solid, primary action on its own
     row), just its own class since Feedback & Support's reply row
     isn't a .card-actions block. Had no hover state at all before. */
  .thread-reply-send-btn:hover:not(:disabled) { opacity: 0.9; transform: translateY(-1px); }
  .thread-reply-send-btn:active:not(:disabled) { transform: scale(0.97); }
  .thread-reply-send-btn:disabled { opacity: 0.6; cursor: default; }
  .thread-icon-btn {
    width: 32px; height: 32px; border-radius: 8px; flex: none;
    display: flex; align-items: center; justify-content: center; cursor: pointer;
    background: none; border: 1px solid var(--border); color: var(--down);
    transition: background 0.15s ease, color 0.15s ease, transform 0.15s ease;
  }
  .thread-icon-btn:hover { background: var(--down-strong); color: #fff; border-color: var(--down-strong); transform: translateY(-1px); }
  .thread-icon-btn svg { width: 15px; height: 15px; }
  .thread-reply-note { font-size: 12px; margin: 0; }
  .thread-reply-note.error { color: var(--down); }
  .thread-reply-note[hidden] { display: none; }

  /* ---------- Markdown formatting toolbar ----------
     Now lives INLINE in the same row as Send/Share/Delete (see
     .thread-reply-actions above) rather than its own separate row —
     Carson's ask, 2026-08-12, after the two-row version was clipping off
     the bottom of the card on some viewports. Plain buttons that wrap the
     current textarea selection in markdown syntax; no live preview/
     rendering is added anywhere — keeps this a lightweight formatting
     AID for someone composing, not a new rendering surface to maintain. */
  .md-toolbar { display: flex; align-items: center; justify-content: center; gap: 2px; flex: 1; min-width: 0; }
  .md-toolbar-btn {
    width: 30px; height: 30px; border-radius: 7px; flex: none;
    display: flex; align-items: center; justify-content: center; cursor: pointer;
    background: none; border: none; color: var(--text-dim); font-family: inherit;
    transition: background 0.15s ease, color 0.15s ease;
  }
  .md-toolbar-btn:hover { background: var(--accent-soft); color: var(--accent); }
  .md-toolbar-btn svg { width: 15px; height: 15px; }
  .md-toolbar-btn.md-bold { font-size: 13.5px; font-weight: 800; }
  .md-toolbar-btn.md-italic { font-size: 13.5px; font-style: italic; font-weight: 700; }
  .md-toolbar-btn.md-underline { font-size: 13.5px; font-weight: 700; text-decoration: underline; }
  .md-toolbar-divider { width: 1px; height: 16px; background: var(--border); margin: 0 4px; flex: none; }

  /* ---------- Reopen button (only shown on a Resolved thread) ---------- */
  .thread-reopen-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 12px; border-radius: 10px; background: var(--accent-soft); margin-bottom: 10px; }
  .thread-reopen-note { font-size: 12.5px; color: var(--accent); margin: 0; }
  .thread-reopen-btn {
    position: relative;
    flex: none; font-size: 12px; font-weight: 650; color: var(--accent);
    background: var(--surface); border: 1px solid var(--accent); border-radius: 999px;
    padding: 6px 12px; cursor: pointer;
    /* color deliberately left out of this transition (see hover rule
       below) — hover needs it to snap instantly to white, and a single
       property can't run at two different speeds for two different
       triggers, so it stays instant for both rather than picking one
       trigger to favor. */
    transition: background-color 2400ms ease, border-color 2400ms ease, transform 0.15s ease;
  }
  .thread-reopen-btn::after {
    content: "";
    position: absolute;
    inset: -1px;
    border-radius: inherit;
    z-index: -1;
    /* --accent-strong, not --accent — same contrast fix as .admin-
       offboard-btn's own copy of this comment (--accent measures
       2.33:1 with white text in dark mode, below the 4.5:1 this size
       needs). */
    background: var(--accent-strong);
    opacity: 0;
    transition: opacity 0.15s ease;
    pointer-events: none;
  }
  .thread-reopen-btn:hover { color: #fff; transform: translateY(-1px); }
  .thread-reopen-btn:hover::after { opacity: 1; }

  /* ---------- Thread row: select mode, checkboxes, right-click menu ---------- */
  .inbox-toolbar {
    display: flex; align-items: center; justify-content: space-between; gap: 10px;
    margin-bottom: 4px; flex: none;
  }
  .inbox-select-toggle-btn, .inbox-select-cancel-btn {
    position: relative;
    font-size: 12.5px; font-weight: 650; cursor: pointer;
    padding: 7px 15px; border-radius: 999px; border: 1px solid var(--border);
    background: var(--surface); color: var(--text);
    transition: background-color 2400ms ease, border-color 2400ms ease, transform 0.15s ease;
  }
  .inbox-select-toggle-btn::after, .inbox-select-cancel-btn::after {
    content: "";
    position: absolute;
    inset: -1px;
    border-radius: inherit;
    z-index: -1;
    background: var(--accent-soft);
    opacity: 0;
    transition: opacity 0.15s ease;
    pointer-events: none;
  }
  .inbox-select-toggle-btn:hover::after, .inbox-select-cancel-btn:hover::after { opacity: 1; }
  .inbox-select-toggle-btn:hover, .inbox-select-cancel-btn:hover { color: var(--accent); transform: translateY(-1px); }
  /* Glint parity with the sidebar — the Select toggle specifically,
     per Carson's request (not Cancel, which he didn't ask for). Uses
     ::before since ::after is already the hover-tint overlay here. */
  #inbox-select-toggle-btn::before {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: inherit;
    padding: 1px;
    /* Sparkle: tight, bright hot-spot layer + background-blend-
       mode:screen on top of the original glint below — same
       technique as index.html's own copy of this. */
    background:
      linear-gradient(var(--section-glint-angle, 225deg),
        rgba(255, 255, 255, 1) 0%,
        rgba(255, 255, 255, 0.85) 1.5%,
        rgba(255, 255, 255, 0.3) 4%,
        transparent 9%),
      linear-gradient(var(--section-glint-angle, 225deg),
      rgba(255, 255, 255, 0.9) 0%,
      rgba(var(--light-rgb), 0.45) 22%,
      transparent 48%);
    background-blend-mode: screen, normal;
    -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    -webkit-mask-composite: xor;
    mask-composite: exclude;
    pointer-events: none;
  }
  .inbox-select-bar {
    display: flex; align-items: center; gap: 10px; margin-bottom: 4px; flex: none;
  }
  .inbox-select-bar[hidden] { display: none; }
  .inbox-select-count { font-size: 12.5px; color: var(--text-dim); flex: 1; }
  .thread-icon-btn.share { color: var(--accent); }
  /* --accent-strong, not --accent — same contrast fix as .thread-
     reopen-btn's own copy of this comment. transform mirrors the base
     .thread-icon-btn:hover rule this overrides color/background/
     border-color from — same lift, just not re-declared as its own
     separate transition property (already inherited from the base
     rule). */
  .thread-icon-btn.share:hover { background: var(--accent-strong); color: #fff; border-color: var(--accent-strong); transform: translateY(-1px); }
  /* Neutral rather than danger-red (the base .thread-icon-btn default,
     meant for delete) — attaching a file isn't a destructive action,
     same accent-soft hover language as .attach-btn on the initial
     compose box. */
  .thread-icon-btn.attach { color: var(--text-dim); }
  .thread-icon-btn.attach:hover { background: var(--accent-soft); color: var(--accent); border-color: var(--accent-soft); }
  .inbox-select-bar .thread-icon-btn:disabled { opacity: 0.4; cursor: default; pointer-events: none; }

  .thread-row-checkbox {
    width: 17px; height: 17px; accent-color: var(--accent); flex: none; cursor: pointer; margin-right: 2px;
  }

  .row-context-menu {
    position: fixed; z-index: 90; min-width: 150px;
    background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
    /* Light-respecting shadow — own dedicated computation in
       openRowContextMenu() (see the feedback/inbox script), run AFTER
       the on-screen position nudge so it reflects this menu's final,
       corrected position, not its initial click coordinates. Same
       element-scoped --shadow-x/-y as .admin-group-picker-menu. */
    box-shadow: var(--shadow-x, 0px) var(--shadow-y, 10px) 30px rgba(0, 0, 0, 0.18); padding: 6px; display: none;
  }
  .row-context-menu.open { display: block; }
  .row-context-menu button {
    display: flex; align-items: center; gap: 9px; width: 100%; text-align: left;
    font-size: 13px; font-weight: 550; padding: 8px 10px; border-radius: 8px;
    background: none; border: none; cursor: pointer; color: var(--text); font-family: inherit;
  }
  .row-context-menu button:hover { background: var(--accent-soft); color: var(--accent); }
  /* --down-strong, not --down — same contrast fix as .admin-offboard-
     btn's own copy of this comment. No lift here, deliberately — this
     is a dropdown-menu list item (matches .admin-group-picker-menu
     button's own same exemption), where a background highlight alone
     is the expected convention, not the lift standalone pill buttons
     elsewhere on this page get. */
  .row-context-menu button.danger:hover { background: var(--down-strong); color: #fff; }
  .row-context-menu button svg { width: 14px; height: 14px; flex: none; }

  /* ---------- Share modal (forward a conversation to an email) ---------- */
  .share-modal-input {
    width: 100%; font-family: inherit; font-size: 14px; padding: 10px 12px; border-radius: 9px;
    border: 1px solid var(--border); background: var(--bg); color: var(--text); margin-top: 12px;
    transition: background-color 2400ms ease, border-color 2400ms ease, color 2400ms ease;
  }
  .share-modal-input:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--accent); }
  .share-modal-note { font-size: 12px; margin: 8px 0 0; }
  .share-modal-note.error { color: var(--down); }
  .share-modal-note[hidden] { display: none; }

  /* ---------- Widget customization modal ---------- */
  /* ---------- Audit Log / Login History modals ----------
     Both reuse the exact .confirm-overlay/.confirm-modal-flip-pane
     shell already established (widget-edit-modal, share-modal, etc.)
     rather than inventing a new modal pattern — same open/close JS
     shape too (see openRecordListModal/closeRecordListModal). Wider
     than the default 380px (and .widget-edit-modal's own 420px) since
     rows here carry more fields (timestamp + actor + action + target)
     than a single-line checkbox list needs to fit. */
  .record-list-modal { max-width: 560px; }
  .record-list {
    display: flex; flex-direction: column; gap: 8px;
    max-height: 400px; overflow-y: auto; margin: 16px 0 20px;
    /* Fade mask — same mechanic as every other scrollable glass
       surface on this page now (.card-body, .sidebar-list, the
       nested admin/thread/session lists). --fade-top/--fade-bottom
       are set by JS, computed from actual scroll position. */
    --fade-top: 0px;
    --fade-bottom: 0px;
    mask-image: linear-gradient(to bottom, transparent, black var(--fade-top), black calc(100% - var(--fade-bottom)), transparent);
    -webkit-mask-image: linear-gradient(to bottom, transparent, black var(--fade-top), black calc(100% - var(--fade-bottom)), transparent);
  }
  .record-row {
    display: flex; flex-direction: column; gap: 2px;
    border: 1px solid var(--border); border-radius: 12px; background: var(--surface);
    padding: 10px 12px;
    transition: background-color 2400ms ease, border-color 2400ms ease;
  }
  .record-row-top { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
  .record-row-action { font-size: 13.5px; font-weight: 650; }
  .record-row-time { font-size: 12px; color: var(--text-dim); flex: none; white-space: nowrap; }
  .record-row-detail { font-size: 12.5px; color: var(--text-dim); }
  /* Pruned/collapsed entries — a range of older individual rows
     rolled into one compact summary line rather than deleted
     outright (Carson's ask: a year of detail, then summarize rather
     than lose the record entirely). Dashed border and italic content
     is a deliberate visual break from a real, individual entry —
     this row represents MANY actions, not one, and shouldn't be
     mistaken for a single event at a glance. */
  .record-row-summary {
    border-style: dashed;
    background: transparent;
    font-style: italic;
    color: var(--text-dim);
  }
  .record-row-summary .record-row-action { font-weight: 600; color: var(--text-dim); }
  .record-list-empty { font-size: 13px; color: var(--text-dim); padding: 4px 0 8px; }

  .widget-edit-modal { max-width: 420px; }
  .widget-edit-list {
    display: flex; flex-direction: column; gap: 8px;
    max-height: 320px; overflow-y: auto; margin: 16px 0 20px;
  }
  .widget-edit-row {
    display: flex; align-items: center; gap: 10px;
    border: 1px solid var(--border); border-radius: 12px; background: var(--surface);
    padding: 10px 12px;
    transition: background-color 2400ms ease, border-color 2400ms ease;
  }
  /* Same custom checkbox technique as the Services card's
     .option input[type="checkbox"] — appearance:none + a hand-drawn
     checkmark, so it fades gracefully with the theme instead of
     snapping, same as everything else on this page. */
  .widget-edit-checkbox {
    appearance: none; -webkit-appearance: none;
    position: relative; width: 18px; height: 18px; border-radius: 5px; flex: none;
    border: 1.5px solid var(--border); background: var(--bg); cursor: pointer; margin: 0;
    transition: background-color 2400ms ease, border-color 2400ms ease;
  }
  .widget-edit-checkbox::after {
    content: ""; position: absolute; left: 5px; top: 1px; width: 5px; height: 9px;
    border: solid var(--accent); border-width: 0 2px 2px 0;
    transform: rotate(45deg) scale(0.6); opacity: 0;
    transition: opacity 0.15s ease, transform 0.15s ease;
  }
  .widget-edit-checkbox:checked::after { opacity: 1; transform: rotate(45deg) scale(1); }
  .widget-edit-icon {
    width: 30px; height: 30px; border-radius: 8px; flex: none;
    background: var(--accent-soft); color: var(--accent);
    display: flex; align-items: center; justify-content: center;
    transition: background-color 2400ms ease, color 2400ms ease;
  }
  .widget-edit-icon svg { width: 15px; height: 15px; }
  .widget-edit-name { flex: 1; min-width: 0; font-size: 14px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .widget-edit-reorder { display: flex; gap: 4px; flex: none; }
  .widget-edit-move {
    width: 26px; height: 26px; border-radius: 7px; flex: none;
    background: none; border: 1px solid var(--border); color: var(--text-dim);
    display: flex; align-items: center; justify-content: center; cursor: pointer;
    transition: background-color 2400ms ease, border-color 2400ms ease;
  }
  .widget-edit-move svg { width: 13px; height: 13px; }
  .widget-edit-move:hover:not(:disabled) { background: var(--accent-soft); color: var(--accent); }
  .widget-edit-move:disabled { opacity: 0.3; cursor: default; }

  /* Explicit width + max-width matching .stack-wrap exactly: without
     this, the nav row only ever shrink-wraps around the arrows + dots
     themselves, so centering its PARENT does nothing useful — it's
     centering a tiny box, not aligning to the (much wider) card stack
     above it. This makes the dots' own center match the stack's center
     directly, regardless of how many dots there are. */
  .stack-nav {
    display: flex; align-items: center; justify-content: center; gap: 16px; width: 100%; max-width: 940px;
    /* flip easter egg — belongs here, not on .stack-arrow itself; see
       .stat-widget's own comment for why perspective on the same
       element that rotates doesn't work. */
    perspective: 500px;
  }
  .stack-arrow {
    width: 34px; height: 34px; border-radius: 50%;
    /* Carson's ask: brings this in line with .theme-toggle's own
       blur/color-split treatment (which it never had at all before —
       plain solid var(--surface), no backdrop-filter), at the same
       new transparent values request.html's cards landed on. Same
       "free-standing, nothing behind it, not part of any stack"
       reasoning as everything else that's gotten this treatment —
       applies here regardless of whether this happens to be
       navigating the widget stack or the main settings-card stack,
       since the arrow button itself is never what's stacked. Visible
       fill/border moved to the new .stack-arrow-fill child below. */
    background: none;
    backdrop-filter: blur(2px) saturate(1.5);
    -webkit-backdrop-filter: blur(2px) saturate(1.5);
    border: 1px solid transparent;
    color: var(--text);
    display: flex; align-items: center; justify-content: center; cursor: pointer;
    position: relative;
    /* Light-respecting shadow, same small-button scale as .theme-
       toggle/.notif-bell-btn — --shadow-x/-y set per-button in
       recompute()'s arrowBtns loop, element-scoped (not :root), same
       generic name several other elements on this page already reuse
       safely the same way. */
    box-shadow: var(--shadow-x, 0px) var(--shadow-y, 7px) 22px rgba(0, 0, 0, 0.20);
    /* background-color/border-color moved to .stack-arrow-fill along
       with the properties they animate — transform/filter stay here
       for the flip easter egg, same split as .theme-toggle. */
    transition: transform 0.9s cubic-bezier(.65,.05,.36,1), filter 0.9s ease;
  }
  .stack-arrow-fill {
    position: absolute;
    inset: 0;
    z-index: -1;
    pointer-events: none;
    border-radius: inherit;
    background: rgba(var(--surface-rgb), 0.18);
    border: 1px solid var(--border);
    transition: background-color 2400ms ease, border-color 2400ms ease;
  }
  /* Same gradient-border glint technique as .stack-card/.sidebar/
     .stat-widget etc. — --arrow-glint-angle computed per-button off its
     own live position (see the glint-angle script). Layered on top of
     the flat border above rather than replacing it, matching how every
     other glass surface here does it too. Used to be a combined
     selector shared with .stats-arrow (the old widget-pagination
     arrows) — kept .stack-arrow's own copy when that element was
     removed entirely, since this rule was the ONLY place .stack-arrow
     ever got this styling from; there was no separate fallback. */
  .stack-arrow::before {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: inherit;
    padding: 1.5px;
    /* Sparkle: tight, bright hot-spot layer + background-blend-
       mode:screen on top of the original glint below — same
       technique as index.html's own copy of this. */
    background:
      linear-gradient(var(--arrow-glint-angle, 225deg),
        rgba(255, 255, 255, 1) 0%,
        rgba(255, 255, 255, 0.85) 1.5%,
        rgba(255, 255, 255, 0.3) 4%,
        transparent 9%),
      linear-gradient(var(--arrow-glint-angle, 225deg),
      rgba(255, 255, 255, 0.95) 0%,
      rgba(var(--light-rgb), 0.55) 18%,
      rgba(var(--light-rgb), 0.12) 40%,
      transparent 62%);
    background-blend-mode: screen, normal;
    -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    -webkit-mask-composite: xor;
    mask-composite: exclude;
    pointer-events: none;
  }
  /* flip easter egg — mirror-corrected glint + directional haze on the
     back, same technique as the sidebar's own copy of this. */
  .stack-arrow.flip-mid::before {
    /* Sparkle: tight, bright hot-spot layer + background-blend-
       mode:screen on top of the original glint below — same
       technique as index.html's own copy of this. */
    background:
      linear-gradient(var(--arrow-glint-angle-flipped, 225deg),
        rgba(255, 255, 255, 1) 0%,
        rgba(255, 255, 255, 0.85) 1.5%,
        rgba(255, 255, 255, 0.3) 4%,
        transparent 9%),
      linear-gradient(var(--arrow-glint-angle-flipped, 225deg),
      rgba(255, 255, 255, 0.95) 0%,
      rgba(var(--light-rgb), 0.55) 18%,
      rgba(var(--light-rgb), 0.12) 40%,
      transparent 62%);
    background-blend-mode: screen, normal;
  }
  .stack-arrow::after {
    background: linear-gradient(var(--arrow-glint-angle-flipped, 225deg),
      rgba(255, 255, 255, 0.16) 0%,
      rgba(255, 255, 255, 0.08) 35%,
      rgba(255, 255, 255, 0.03) 65%,
      transparent 100%);
  }
  .stack-arrow svg { width: 16px; height: 16px; }
  .stack-dots { display: flex; gap: 7px; }
  .stack-dots button {
    width: 7px; height: 7px; padding: 0; border-radius: 50%; border: none;
    background: var(--border); cursor: pointer; transition: background 0.25s ease, transform 0.25s ease;
  }
  .stack-dots button[aria-current="true"] { background: var(--accent); transform: scale(1.35); }
  /* .widget-stack-nav's rules used to live here. The mobile widget
     stack they belonged to is gone entirely — see the "Widgets: plain
     list" comment in the MOBILE OVERRIDES block at the end of this
     stylesheet for why, and the "Widgets: long-press to edit" block in
     the script for what replaced its edit affordance. .stack-nav /
     .stack-arrow / .stack-dots above are untouched: those are still
     the settings-card stack's own controls. */

  /* ---------- Password section ---------- */
  .password-section { margin-top: 6px; padding-top: 16px; border-top: 1px solid var(--border); flex: none; }
  .username-preview-note {
    font-size: 12.5px; color: var(--accent); margin: 4px 0 0; line-height: 1.4;
  }
  .username-preview-note[hidden] { display: none; }

  /* Right padding pulls the edit/change button in from .card-body's
     true edge — same reasoning and same fix as .preferences-row below:
     that edge is where the scrollbar renders (overflow-y:auto), so
     anything sitting flush against it gets covered while actively
     scrolling. Shared by Profile's Edit button and Password's Change
     button, both of which had the same problem, not just Password. */
  .subsection-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; padding-right: 12px; }
  .subsection-head .subsection-label {
    font-size: 11.5px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-dim); margin: 0;
  }
  .password-display-note { font-size: 13.5px; color: var(--text-dim); margin: 0; }
  /* Carson's report: this link had no explicit color at all — reading
     as browser-default blue, which most browsers actually vary
     between light/dark shades based on this page's own color-scheme
     property (set from data-theme). That's a browser-internal default
     for an unstyled link, not a CSS transition or custom property —
     it can't fade smoothly no matter what this page's own JS does,
     since there's nothing here for it to animate. It only changes
     the instant data-theme flips, at the very end of the toggle,
     which read as the link "snapping" independent of everything else
     already smoothly settling by that point. Fix is to stop leaving
     it to the browser at all — var(--accent) matches the teal already
     used for every other link-like element on this page (notif items,
     accordion headers, etc.), and being one of the colors applyColors()
     already interpolates every frame, this now fades in step with
     everything else instead of standing out as the one exception. */
  .password-display-note a { color: var(--accent); }

  /* ---------- Preferences section ---------- */
  .preferences-section { margin-top: 6px; padding-top: 16px; border-top: 1px solid var(--border); flex: none; }
  .preferences-list { display: flex; flex-direction: column; gap: 4px; }
  /* Right padding pulls the checkbox in from the card-body's own true
     edge, which is where the scrollbar renders (.card-body has
     overflow-y:auto). Without it, the checkbox sat flush against that
     edge and the scrollbar covered it — visible while scrolling,
     before it fades away again. */
  .preferences-row {
    display: flex; align-items: center; gap: 12px; padding: 8px 12px 8px 0; cursor: pointer;
  }
  /* Sub-options nested under a parent preference (currently just
     Notification Emails' own two) — indented and slightly smaller/
     lighter text than a top-level row, enough to read as "part of
     the option above" without needing a connecting line or other
     heavier visual treatment Carson didn't ask for. */
  .preferences-row-sub { padding-left: 28px; }
  .preferences-row-sub .preferences-row-text h4 { font-size: 13.5px; font-weight: 600; }
  .preferences-row-text { flex: 1; min-width: 0; }
  .preferences-row-text h4 { margin: 0 0 2px; font-size: 14.5px; font-weight: 650; }
  .preferences-row-text p { margin: 0; font-size: 12.5px; color: var(--text-dim); line-height: 1.4; }
  /* Replay button row isn't a checkbox toggle like every other row
     here — cursor:pointer inherited from .preferences-row above (meant
     for the label-wraps-a-checkbox rows) is slightly misleading on a
     div that isn't itself clickable, only the button inside it is;
     overridden back to the default rather than leaving that hint on
     the wrong element. */
  .preferences-section > .preferences-row { cursor: default; }
  /* Same secondary-outline-pill pattern as .avatar-upload-btn — this
     is the same functional role (a neutral, non-destructive action
     button on a settings card), see its own comment for the full
     reasoning behind the fast/slow transition split. */
  .replay-tutorial-btn {
    position: relative; flex: none;
    padding: 8px 14px; border-radius: 999px; font-size: 13px; font-weight: 650; cursor: pointer;
    border: 1px solid var(--border); background: var(--surface); color: var(--text);
    transition: background-color 2400ms ease, border-color 2400ms ease, color 2400ms ease, transform 0.15s ease;
  }
  .replay-tutorial-btn::after {
    content: "";
    position: absolute; inset: -1px; border-radius: inherit; z-index: -1;
    background: var(--accent-soft); opacity: 0; transition: opacity 0.15s ease;
    pointer-events: none;
  }
  .replay-tutorial-btn:hover::after { opacity: 1; }
  .replay-tutorial-btn:hover { color: var(--accent); transform: translateY(-1px); }
  /* Glint — Carson's report: every other pill button on this page has
     one (this button's own comment above already claims to follow
     .avatar-upload-btn's pattern, but only actually copied the hover-
     fill ::after, not the glint ::before that button also has).
     Identical recipe to .avatar-upload-btn's own copy of this — same
     --section-glint-angle variable, so no separate light-angle
     computation is needed, just this element added to the SAME
     querySelectorAll list that already drives it (see that script,
     near the other .avatar-upload-btn/#services-edit-btn entries). */
  .replay-tutorial-btn::before {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: inherit;
    padding: 1px;
    /* Sparkle: tight, bright hot-spot layer + background-blend-
       mode:screen on top of the original glint below — same
       technique as index.html's own copy of this. */
    background:
      linear-gradient(var(--section-glint-angle, 225deg),
        rgba(255, 255, 255, 1) 0%,
        rgba(255, 255, 255, 0.85) 1.5%,
        rgba(255, 255, 255, 0.3) 4%,
        transparent 9%),
      linear-gradient(var(--section-glint-angle, 225deg),
      rgba(255, 255, 255, 0.9) 0%,
      rgba(var(--light-rgb), 0.45) 22%,
      transparent 48%);
    background-blend-mode: screen, normal;
    -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    -webkit-mask-composite: xor;
    mask-composite: exclude;
    pointer-events: none;
  }
  /* Same custom checkbox as the Services card's .option checkboxes
     (which themselves match request.html's own checkbox theming) — one
     shared visual language for every checkbox on the site, not a
     one-off here. */
  .preferences-row input[type="checkbox"] {
    appearance: none;
    -webkit-appearance: none;
    position: relative;
    width: 19px;
    height: 19px;
    border-radius: 5px;
    border: 1.5px solid var(--border);
    background: var(--surface);
    flex: none;
    cursor: pointer;
    margin: 0;
    transition: background-color 2400ms ease, border-color 2400ms ease;
  }
  .preferences-row input[type="checkbox"]::after {
    content: "";
    position: absolute;
    left: 6px;
    top: 2px;
    width: 5px;
    height: 9px;
    border: solid var(--accent);
    border-width: 0 2px 2px 0;
    transform: rotate(45deg) scale(0.6);
    opacity: 0;
    transition: opacity 0.15s ease, transform 0.15s ease, border-color 2400ms ease;
  }
  .preferences-row input[type="checkbox"]:checked::after {
    opacity: 1;
    transform: rotate(45deg) scale(1);
  }
  .preferences-row input[type="checkbox"]:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
  }

  /* ---------- Active Sessions section ---------- */
  .sessions-section { margin-top: 6px; padding-top: 16px; border-top: 1px solid var(--border); flex: none; }
  .session-list { display: flex; flex-direction: column; gap: 8px; max-height: 260px; overflow-y: auto; -webkit-overflow-scrolling: touch; }
  .session-row {
    display: flex; align-items: center; gap: 12px;
    padding: 10px; border-radius: 10px; border: 1px solid var(--border); background: var(--bg);
  }
  .session-icon {
    width: 34px; height: 34px; border-radius: 8px; flex: none;
    background: var(--accent-soft); color: var(--accent);
    display: flex; align-items: center; justify-content: center;
  }
  .session-icon svg { width: 17px; height: 17px; }
  .session-details { flex: 1; min-width: 0; }
  .session-device-line { display: flex; align-items: center; gap: 7px; font-size: 13.5px; font-weight: 600; }
  .session-current-tag {
    font-size: 10.5px; font-weight: 700; letter-spacing: 0.03em; text-transform: uppercase;
    color: var(--ok); background: rgba(62, 154, 108, 0.14); padding: 2px 7px; border-radius: 999px;
  }
  .session-meta { font-size: 12px; color: var(--text-dim); margin-top: 2px; }
  .session-revoke-btn {
    flex: none; font-size: 12.5px; font-weight: 600; color: var(--down);
    background: none; border: 1px solid var(--down); border-radius: 999px; padding: 6px 12px; cursor: pointer;
    transition: background 0.15s ease, color 0.15s ease, transform 0.15s ease;
  }
  /* --down-strong, not --down — same contrast fix as .admin-offboard-
     btn's own copy of this comment (--down measures below the 4.5:1
     white-text-needs threshold in dark mode). */
  .session-revoke-btn:hover { background: var(--down-strong); color: #fff; transform: translateY(-1px); }
  .sessions-empty { font-size: 13px; color: var(--text-dim); }

  /* ---------- Danger zone (Delete Account) ---------- */
  .danger-zone { margin-top: 6px; padding-top: 16px; border-top: 1px solid var(--border); flex: none; }
  .danger-zone-label { font-size: 11.5px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-dim); margin: 0 0 10px; }
  .btn-delete-account {
    background: none; border: 1px solid var(--down); color: var(--down);
    padding: 10px 16px; border-radius: 999px; font-size: 13.5px; font-weight: 650; cursor: pointer;
    transition: background 0.15s ease, color 0.15s ease, transform 0.15s ease;
  }
  .btn-delete-account:hover { background: var(--down-strong); color: #fff; transform: translateY(-1px); }

  /* ---------- Confirm modal (Delete Account / Remove Service) ---------- */
  .confirm-overlay {
    position: fixed; inset: 0; z-index: 70;
    background: rgba(0,0,0,0.32);
    display: flex; align-items: center; justify-content: center; padding: 20px;
    opacity: 0; transition: opacity 0.22s ease;
  }
  .confirm-overlay.open { opacity: 1; }
  .confirm-overlay[hidden] { display: none; }
  .confirm-modal {
    position: relative;
    width: 100%; max-width: 380px;
    transform: scale(0.94) translateY(8px); opacity: 0;
    transition: transform 0.28s cubic-bezier(.22,.61,.36,1), opacity 0.22s ease;
    /* flip easter egg — .confirm-modal is now just a plain positioning
       shell for its own scale/translateY open animation (untouched,
       above) — the glass styling (background, blur, border, the
       glint) and the flip rotation both moved to
       .confirm-modal-flip-pane, a child, exact same reasoning as the
       login modal's own split on the home/request pages: this
       element's own transform is already spoken for by the open
       animation and can't also carry a rotateY flip. perspective here
       is what makes that child's rotation read as real depth. Shared
       by all three dialogs built on this class (confirm/delete, share,
       widget-edit) — one rule covers all of them. */
    perspective: 1400px;
  }
  /* flip easter egg — the actual glass pane now; see .confirm-modal's
     own comment above for why this split happened. Double-click a
     dialog's own background (not a field, button, or link, which
     should all behave normally) to flip it around. */
  /* flip easter egg — reuses .flip-pane (the generic base class already
     built for this on this page — see its own comment for why) for
     preserve-3d/transform-origin/transition/.flipped/reduced-motion,
     rather than duplicating that bespoke here. Only what's genuinely
     unique to this element is declared below: the glass styling
     itself, the glint (.flip-pane::after is deliberately left with no
     background of its own — that's element-specific, filled in per
     target), and pointer-events:none re-declared on .flipped — safe
     here since the double-click listener lives on the separate OUTER
     .confirm-modal, not this element, matching the sidebar/cards'
     own reasoning rather than the widgets/arrows' (where it wasn't
     safe, see .flip-pane.flipped's own comment). */
  .confirm-modal-flip-pane {
    width: 100%;
    position: relative;
    /* background: none / border: transparent — Carson's site-wide ask
       after confirming the blur/color split looks identical to the
       combined approach. Same reasoning as .stack-card-flip-pane's own
       copy of this comment: overriding here specifically rather than
       touching .flip-pane's own shared base rule. */
    background: none;
    backdrop-filter: blur(16px) saturate(1.5);
    -webkit-backdrop-filter: blur(16px) saturate(1.5);
    border: 1px solid transparent; border-radius: var(--radius);
    /* Light-respecting shadow, full panel scale — matches index.html/
       request.html's .getting-started-flip-pane and .login-modal-
       flip-pane exactly. --shadow-x/-y set per-modal in
       setConfirmModalGlintAngle(), directly on the currently-visible
       OUTER .confirm-modal (three dialogs share this class — confirm/
       delete, share, widget-edit — only one is ever open), inheriting
       down to this inner pane the same way the other pages' modals
       already work. */
    padding: 28px 26px; box-shadow: var(--shadow-x, 0px) var(--shadow-y, 16px) 52px rgba(0, 0, 0, 0.15);
  }
  .confirm-modal-flip-pane-fill {
    position: absolute;
    inset: 0;
    /* Negative, not left at auto/0 — same reasoning as
       .stat-widget-fill's own copy of this comment: a position:
       absolute element with default z-index still paints ABOVE
       normal-flow, non-positioned siblings regardless of DOM order. */
    z-index: -1;
    pointer-events: none;
    border-radius: inherit;
    background: rgba(var(--surface-rgb), 0.68);
    border: 1px solid var(--border);
    transition: background-color 2400ms ease, border-color 2400ms ease;
  }
  .confirm-modal-flip-pane.flipped {
    /* Plain rotateY (inherited from .flip-pane) here, not perspective()
       — .confirm-modal (the parent) and this flip-pane are a tight 1:1
       wrapper with no size/position mismatch between them (same
       reasoning as the login modal), so the parent's own separate
       perspective property already centers correctly on this element. */
    pointer-events: none;
  }
  .confirm-modal-flip-pane::before {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: inherit;
    padding: 1.5px;
    /* Sparkle: tight, bright hot-spot layer + background-blend-
       mode:screen on top of the original glint below — same
       technique as index.html's own copy of this. */
    background:
      linear-gradient(var(--modal-glint-angle, 225deg),
        rgba(255, 255, 255, 1) 0%,
        rgba(255, 255, 255, 0.85) 1.5%,
        rgba(255, 255, 255, 0.3) 4%,
        transparent 9%),
      linear-gradient(var(--modal-glint-angle, 225deg),
      rgba(255, 255, 255, 0.95) 0%,
      rgba(var(--light-rgb), 0.55) 18%,
      rgba(var(--light-rgb), 0.12) 40%,
      transparent 62%);
    background-blend-mode: screen, normal;
    -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    -webkit-mask-composite: xor;
    mask-composite: exclude;
    pointer-events: none;
  }
  /* flip easter egg — mirror-corrected glint + directional haze on the
     back, same technique as elsewhere on this site. */
  .confirm-modal-flip-pane.flip-mid::before {
    /* Sparkle: tight, bright hot-spot layer + background-blend-
       mode:screen on top of the original glint below — same
       technique as index.html's own copy of this. */
    background:
      linear-gradient(var(--modal-glint-angle-flipped, 225deg),
        rgba(255, 255, 255, 1) 0%,
        rgba(255, 255, 255, 0.85) 1.5%,
        rgba(255, 255, 255, 0.3) 4%,
        transparent 9%),
      linear-gradient(var(--modal-glint-angle-flipped, 225deg),
      rgba(255, 255, 255, 0.95) 0%,
      rgba(var(--light-rgb), 0.55) 18%,
      rgba(var(--light-rgb), 0.12) 40%,
      transparent 62%);
    background-blend-mode: screen, normal;
  }
  .confirm-modal-flip-pane::after {
    background: linear-gradient(var(--modal-glint-angle-flipped, 225deg),
      rgba(255, 255, 255, 0.16) 0%,
      rgba(255, 255, 255, 0.08) 35%,
      rgba(255, 255, 255, 0.03) 65%,
      transparent 100%);
  }
  .confirm-overlay.open .confirm-modal { transform: scale(1) translateY(0); opacity: 1; }
  @media (prefers-reduced-motion: reduce) {
    /* Covers every dialog built on these two classes — the confirm/
       delete dialogs, the share modal, and the widget-edit modal all
       reuse .confirm-overlay/.confirm-modal rather than having their
       own separate CSS, so one rule here covers all of them. */
    .confirm-overlay, .confirm-modal { transition: none; }
  }
  .confirm-modal h3 { font-size: 18px; font-weight: 650; margin: 0 0 8px; letter-spacing: -0.01em; }
  .confirm-modal p { font-size: 13.5px; color: var(--text-dim); line-height: 1.5; margin: 0 0 22px; }
  /* The confirm dialog's optional list (package D3, 7 Oct 2026): what a
     merge will do, one line each, coloured by what it means. */
  .confirm-lines { margin: 0 0 22px; padding-left: 18px; font-size: 13.5px; line-height: 1.5; color: var(--text-dim); }
  .confirm-line { margin: 2px 0; }
  .confirm-line.is-ok { color: var(--ok, #3E9A6C); }
  .confirm-line.is-warn { color: var(--pending, #C79A3E); }
  .confirm-line.is-bad { color: var(--down, #C1554B); font-weight: 600; }
  .confirm-actions { display: flex; gap: 10px; }
  /* No/Cancel is the bigger, filled, default-focus button; Yes/confirm is
     the smaller, outlined one — Carson's explicit ask, 2026-08-11: bias
     the UI toward the safe choice for anything destructive, since these
     dialogs are used for real data-deletion actions (remove service,
     remove Plex access, delete account). */
  .confirm-btn-no {
    flex: 1; padding: 12px; border-radius: 999px; border: none;
    background: var(--text); color: var(--bg); font-size: 14px; font-weight: 650; cursor: pointer;
    transition: opacity 0.15s ease, transform 0.1s ease;
  }
  .confirm-btn-no:hover { opacity: 0.9; transform: translateY(-1px); }
  .confirm-btn-no:active { transform: scale(0.97); }
  /* Same destructive-outline hover pattern as .admin-offboard-btn/
     .session-revoke-btn/.btn-delete-account — this is the same
     functional role (the destructive confirm action), and had no
     hover state at all before. --down-strong for the same contrast
     reason as those three (see --ok-strong's own definition for the
     full math). */
  .confirm-btn-yes {
    position: relative;
    padding: 12px 18px; border-radius: 999px; border: 1px solid var(--border);
    background: var(--surface); color: var(--down); font-size: 13px; font-weight: 600; cursor: pointer;
    transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, transform 0.15s ease;
  }
  .confirm-btn-yes:hover { background: var(--down-strong); border-color: var(--down-strong); color: #fff; transform: translateY(-1px); }

  /* ---------- Share modal actions ----------
     Deliberately its own class, NOT a reuse of .confirm-btn-yes/-no:
     those two are tuned to bias toward the SAFE choice for destructive
     confirmations (Delete Account, etc. — see note above). Sharing a
     conversation isn't destructive, so Carson's ask (2026-08-12) is the
     opposite bias here: Send is the bigger, primary, green action on the
     left; Cancel is the smaller, secondary action on the right. */
  .share-actions { display: flex; gap: 10px; margin-top: 26px; }
  .share-btn-send {
    flex: 1; padding: 14px; border-radius: 999px; border: none;
    /* --ok-strong, not --ok — this button is solid-filled with white
       text at REST, not just on hover, so the contrast problem (see
       --ok-strong's own definition) was visible 100% of the time here,
       not only while hovering the way it was on .admin-unban-btn. */
    background: var(--ok-strong); color: #fff; font-size: 14px; font-weight: 650; cursor: pointer;
    transition: opacity 0.15s ease, transform 0.15s ease;
  }
  .share-btn-send:hover { opacity: 0.9; transform: translateY(-1px); }
  .share-btn-send:disabled { opacity: 0.6; cursor: default; }
  /* Same neutral/no-color-shift hover as .btn-cancel — same role,
     same reasoning (see its own comment for the full "why no accent
     shift" explanation). Had no hover state at all before. */
  .share-btn-cancel {
    padding: 14px 20px; border-radius: 999px; border: 1px solid var(--border);
    background: var(--surface); color: var(--text); font-size: 13.5px; font-weight: 600; cursor: pointer;
    transition: transform 0.15s ease;
  }
  .share-btn-cancel:hover { transform: translateY(-1px); }

  /* ============================================================
     MOBILE OVERRIDES — deliberately placed at the very end of the
     stylesheet, after every rule below they need to beat.

     Two earlier attempts at these same two fixes (quick-stats-row
     full-width-per-widget, and settings-card mobile height) were
     placed near their related desktop rules instead of after them, and
     were silently overridden on every load: for two selectors of equal
     specificity, the one that appears LATER in the stylesheet always
     wins, regardless of whether the earlier one sits inside a media
     query. .stat-widget and .stack-wrap's real (desktop) declarations
     live later in this file than those two attempts did, so the mobile
     overrides never actually applied — confirmed by fetching the live
     deployed CSS and diffing rule order against what shipped. Putting
     both fixes here, past everything, makes that structurally
     impossible to repeat. If you add more desktop rules for these same
     properties later, add them ABOVE this block, not below it. ============================================================ */
  @media (max-width: 600px) {
    /* ---------- Quick-stats row: one full-width widget per swipe ----------
       Desktop lets widgets shrink to share the row (down to 168px each)
       before falling back to a horizontal scroll. On a phone, 168px is
       still too narrow for the app name + value + label to render
       without clipping. Below this width, each widget claims the FULL
       row width (matching the settings-card width above it, per
       .stats-row's own max-width), with additional widgets one swipe
       away rather than shrunk down to illegibility. scroll-snap makes
       each swipe land cleanly on a single widget instead of stopping
       mid-card.
       Superseded by the paginated carousel — same "fewer widgets per
       screenful on narrow viewports" intent is now handled by
       getWidgetsPerPage() in JS (1 per page below 640px, 2 below 860px,
       3 above), computed at render/resize time rather than a fixed CSS
       breakpoint, since the carousel already needs to rebuild its pages
       on resize regardless. */

    /* ---------- Settings-card height: fill to just above the nav row ----------
       Reported symptom: on phones, the settings-card stack was tall
       enough that the WHOLE PAGE needed a scrollbar to reach the
       nav-dots row below it — worse, Safari's address/tab bar EXPANDING
       (vs. minimized) ate enough extra height that the dots could be
       hidden behind it even when the page-level scrollbar theoretically
       had room, unless you scrolled further.

       HEIGHT SOURCE, after three attempts — dvh, then
       window.visualViewport.height, then a synthetic scroll nudge to
       try to force Safari's own viewport-height correction — all
       failed for the same root reason: Mobile Safari's height reporting
       can be genuinely stale on cold load (doesn't yet account for the
       toolbar actually on screen) and historically only self-corrects
       after a real user scroll gesture. Once the card is sized tight
       enough that the page has no scroll room left, there's nothing
       left to even trigger that self-correction — a dead end.

       100svh ("small viewport height") sidesteps the whole problem: by
       spec it's ALWAYS the viewport height assuming the browser's UI
       chrome is in its MAXIMALLY EXPANDED state, computed correctly by
       the browser from the very first paint — no JS, no scroll event,
       no race to lose, no stale-value bug possible. Tradeoff: the card
       won't grow to reclaim extra space on-screen when the toolbar
       happens to be minimized. That's an intentional, acceptable
       tradeoff — the actual goal is "never renders behind the URL bar,"
       not "maximize card size in the best case."

       --stack-top is the card's own ACTUAL measured distance from the
       top of the viewport (via getBoundingClientRect().top) — this
       automatically includes EVERY real thing rendered above the card
       (body's top padding, the header block and its margins, the
       stats-row and its margin) with nothing to forget or re-tally by
       hand. Three earlier versions of this fix instead hand-summed
       those pieces individually and each missed something real (most
       recently: body's own 48px top padding, never included in any
       prior tally) — measuring the actual rendered position sidesteps
       that whole class of mistake permanently. --stack-nav-h is the
       real height of the dots/arrows row below the card, measured the
       same direct way. The three flat numbers below (12px margin match,
       8px buffer, safe-area inset) are the only manually-specified
       constants left, and each has a single, explicit, named purpose —
       no more lumping unrelated spacing into one guessed catch-all.
       Both custom properties are kept live by the script above.

       FIFTH ATTEMPT (auto-fit-to-content, no viewport math at all) was
       STILL broken on a real device — Carson's report: neither issue
       it was meant to fix (card unreadable below the fold, widgets
       missing) actually resolved, even after a full rewrite of the
       measurement approach itself between two separate tries. That's
       the real tell: a SECOND, materially different implementation
       failing the exact same way points at something more fundamental
       than a bug in either specific attempt.

       SIXTH ATTEMPT, and Carson's own proposed direction taken
       directly rather than debugging JS measurement further: give up
       on trying to derive the "correct" height at all — fixed,
       flat 80svh (svh specifically, not vh, for the same Mobile-
       Safari-toolbar-instability reasons already proven out on
       .wave-band elsewhere on this page) with .card-body's own
       ORIGINAL overflow-y:auto restored for whatever doesn't fit
       inside that. No JavaScript involved in sizing this at all
       anymore — nothing to measure, nothing to get wrong, nothing that
       can silently fail and take other unrelated code down with it.
       The widgets bug turning out to persist unchanged through a full
       rewrite of this same code was itself the evidence that it was
       never actually caused by this system in the first place — worth
       being direct about that rather than continuing to treat it as
       collateral damage from the same fix. */
    /* Card height, and the one number here most likely to want
       adjusting later — hence a custom property rather than a literal
       buried in the rule below.
       80svh -> 66svh. The reorder above is what forced the question: with
       widgets below the cards, a card that fills the screen means the
       widget row begins exactly at or past the fold, and a feature that
       was merely in the way before becomes one nobody ever sees. Measured
       on a 390x844 viewport, the widget row's top edge lands at y=845 at
       80svh (nothing visible), y=811 at 66svh (a clear sliver), y=777 at
       62svh. 66svh is the deliberate compromise: enough of a peek to
       advertise that something is down there, while giving up as little
       card height as possible, since the card is what the page is for.
       Everything that doesn't fit still scrolls inside .card-body, exactly
       as it did at 80svh. */
    :root { --mobile-stack-h: 66svh; }
    .stack-wrap {
      height: var(--mobile-stack-h, 66svh) !important;
      max-height: none !important;
      margin-bottom: 12px !important;
    }

    /* The safe-area padding that used to sit on .stack-nav is gone, and
       its removal is part of the reorder rather than an unrelated
       cleanup: .stack-nav was the last thing on the page when it was
       added, so it was the element that needed to clear the home
       indicator. It isn't any more — the widget row is (see
       .stats-carousel-wrap just below) — and body's own
       padding-bottom: max(20px, env(safe-area-inset-bottom)) at the end
       of this block already covers the real bottom of the page. Leaving
       it here would have inserted ~34px of dead space mid-page on a
       notched phone, between the card dots and the widgets. */

    /* Edit Widgets, gone at this width. ~44px plus its own 24px
       margin-top, spent on a control that opens a preferences modal
       almost nobody opens twice — expensive, on the one screen where
       vertical space is the whole argument. It has two replacements,
       neither of them hidden behind this: a real row in Account ->
       Preferences (the findable, keyboard-reachable one) and a long
       press on any widget (the fast one, at the moment of intent).
       display:none rather than removing the button from the document,
       deliberately: the script keys the long-press gesture off whether
       this button is actually rendered (offsetParent), so the button's
       own visibility stays the single source of truth for which
       affordance is live at a given width. */
    .stats-nav { display: none; }

    /* ---------- Widgets: plain list ----------
       This reverses this element's own earlier mobile history rather
       than adding to it, so it's worth being direct about what changed
       and why, instead of leaving the reversal looking like a
       regression someone should undo.

       What used to be here: a one-at-a-time stack — every widget
       absolutely positioned on top of the others at a flat 92px, with
       dots, prev/next arrows, a horizontal swipe handler that had to
       arbitrate against the page's own vertical scroll on every touch,
       and a kick/settle animation tuned across four rounds to match the
       settings-card stack's timing exactly.

       Every bit of that existed to buy one thing: fitting six widgets
       into 92px at the TOP of a phone screen, which was the most
       expensive real estate on the page. Widgets don't sit there any
       more — they sit below the settings cards, matching document
       order (see .stats-carousel-wrap's own base rule), where vertical
       space is cheap and the page scrolls anyway. The stack was paying a high price for
       something that is no longer scarce, so it's deleted outright
       rather than left hidden behind a media query: the JS half went
       with it (see the "Widgets: long-press to edit" block in the
       script, which is what replaced it).

       What's left is the grid's own natural single-column fallback,
       which is what a phone wanted in the first place — and a vertical
       list read by a vertical scroll needs no gesture arbitration at
       all, which is the specific bug class this removes rather than
       fixes. */
    .stats-widgets-grid {
      grid-template-columns: 1fr;
      gap: 10px;
    }
    .stat-widget-wrap {
      /* Positioning context for the long-press pencil below. Nothing
         else on this element needs one now that widgets no longer
         stack on top of each other. */
      position: relative;
      /* A long press on a phone otherwise raises the native selection
         callout over the widget, which fights the gesture this element
         now owns. Scoped to the widget, not the row, so nothing else
         on the page loses text selection. */
      -webkit-touch-callout: none;
      -webkit-user-select: none;
      user-select: none;
    }

    /* ---------- Long-press pencil ----------
       Revealed by .widget-edit-armed, which the script adds to a single
       widget after a ~450ms press and clears on a tap elsewhere, on the
       next widget press, or after a few seconds. Hidden via opacity/
       transform/pointer-events rather than display so the reveal can
       actually animate; pointer-events is the part that keeps it
       genuinely untappable in the meantime, since opacity alone would
       leave an invisible 34px target sitting on every widget.
       34px, not the 44px the header buttons were raised to: this
       appears directly under a finger that is already on the widget and
       has just been told where to go, rather than being hunted for
       cold. */
    .stat-widget-edit-btn {
      display: inline-flex;
      align-items: center;
      justify-content: center;
      position: absolute;
      top: 8px;
      right: 8px;
      width: 34px;
      height: 34px;
      border-radius: 50%;
      border: 1px solid var(--border);
      background: rgba(var(--surface-rgb), 0.92);
      backdrop-filter: blur(10px) saturate(1.4);
      -webkit-backdrop-filter: blur(10px) saturate(1.4);
      color: var(--text);
      box-shadow: 0 4px 14px rgba(0, 0, 0, 0.10);
      opacity: 0;
      transform: scale(0.82);
      pointer-events: none;
      transition: opacity 0.18s ease, transform 0.18s cubic-bezier(.22,.61,.36,1);
      z-index: 2;
      cursor: pointer;
      padding: 0;
    }
    .stat-widget-edit-btn svg { width: 15px; height: 15px; }
    .stat-widget-wrap.widget-edit-armed .stat-widget-edit-btn {
      opacity: 1;
      transform: none;
      pointer-events: auto;
    }
    /* Restores the original, more opaque blur/alpha specifically at
       this mobile breakpoint — see .stat-widget/.stat-widget-fill's
       own base-level comments for the full reasoning. Widgets are a
       genuinely stacked, animated arrangement here (kick/slide/fade
       between them), unlike the spread-out desktop grid the lighter,
       more transparent base values were written for — same "leave the
       overlapping case alone" call Carson made for .card-flip-pane on
       index.html, just expressed as a responsive override here since
       this is the one element on the site that's spread-out on one
       breakpoint and stacked on another, rather than being one or the
       other everywhere. */
    .stat-widget {
      backdrop-filter: blur(16px) saturate(1.5);
      -webkit-backdrop-filter: blur(16px) saturate(1.5);
    }
    .stat-widget-fill {
      background: rgba(var(--surface-rgb), 0.68);
    }
    /* Shadow, FOURTH correction — Carson's own ask each time, and each
       round genuinely fixed the specific thing reported rather than
       guessing blind, so this history is worth keeping rather than
       collapsing into "we tried a few things."
       Round 1 (blur only, 30px->12px, alpha barely touched
       0.18->0.14): wrong mechanism. Tightening blur while leaving
       alpha nearly where it was CONCENTRATES a shadow rather than
       softening it — same darkness, smaller area, reads as heavier.
       Round 2 (alpha to 0.09, blur to 10px): still too dark.
       Round 3 (alpha to 0.04, blur/offset both scaled down
       proportionally to this element's ~92px height against the
       settings card's own ~620px — offset landed at ~1.5px): intensity
       finally read as correct, but Carson's next catch was sharper
       than the intensity issue — a couple pixels of offset doesn't
       actually register as DIRECTIONAL to the eye at any size, it just
       looks like a flat halo sitting underneath the object with no
       visible lean toward/away from the light. Proportional scaling by
       literal size ratio breaks down at the small end for exactly this
       reason: perceiving "which way is this shadow casting" has a
       real minimum offset floor that doesn't itself shrink just
       because the object does.
       This round: offset reach raised to 6px (see
       WIDGET_SHADOW_REACH_PX_MOBILE in the script — well above the
       "mathematically proportional" 1.5px, chosen for what actually
       reads as a visible, light-aware cast rather than a further
       calculation), blur loosened to 10px to match it — a 6px offset
       against only 5px of blur would look like a hard-edged, shifted
       silhouette rather than a soft cast, the blur needs enough room
       to actually soften an offset that size. Alpha held at 0.04,
       unchanged from round 3 — that was the one round Carson confirmed
       as correct, no reason to touch what already works. */
    .stat-widget-wrap {
      box-shadow: var(--widget-shadow-x, 0px) var(--widget-shadow-y, 6px) 10px rgba(0, 0, 0, 0.04) !important;
    }

    /* Reclaim unused whitespace: flat 60px of bottom body padding was
       pure margin no phone screen needed once the card height above is
       measurement-driven — still respects the home-indicator safe area
       on notched phones. */
    body { padding-bottom: max(20px, env(safe-area-inset-bottom)) !important; }
  }

  /* Same combined-condition pattern as the 780px reduced-motion block
     earlier in this stylesheet — the pencil's reveal is a real (small)
     animation, and the rule it overrides only exists inside the 600px
     block, so the override has to carry both conditions to land on it. */
  @media (max-width: 600px) and (prefers-reduced-motion: reduce) {
    .stat-widget-edit-btn { transition: none; }
  }

  /* =========================================================================
     Onboarding tutorial — first-login guided tour. Mockup-validated
     system (spotlight-as-light-source, staged reveals) carried over
     directly, using real site CSS variables throughout rather than the
     mockup's hardcoded hex values, so it inherits whatever theme is
     already active on the page with no separate light/dark detection
     of its own.
     ========================================================================= */
  #tutorial-block {
    position: fixed; inset: 0; z-index: 200;
    pointer-events: auto;
  }
  #tutorial-spotlight {
    position: absolute;
    box-shadow: 0 0 0 9999px rgba(var(--tutorial-dim-rgb), var(--tutorial-dim-alpha));
    pointer-events: none;
    opacity: 0;
    transition: opacity 0.3s ease;
  }
  #tutorial-spotlight.tutorial-open { opacity: 1; }
  :root { --tutorial-dim-rgb: 20,18,14; --tutorial-dim-alpha: 0.24; }
  /* Dark mode needs meaningfully MORE alpha than light mode's own
     0.24 to read as equally dimmed, not less, despite the base page
     already being dark — Carson's own report after seeing this against
     the real, full page rather than a simplified mockup. The bare
     near-black overlay wasn't muting light-colored text/icons enough
     against a dark backdrop; there's less headroom for a low-alpha
     overlay to visibly reduce contrast when it's blending toward a
     color close to what's already there. 0.15 (this session's earlier,
     mockup-only tuning pass) undershot badly once seen for real. */
  html[data-theme="dark"] { --tutorial-dim-rgb: 4,5,6; --tutorial-dim-alpha: 0.44; }
  @media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) { --tutorial-dim-rgb: 4,5,6; --tutorial-dim-alpha: 0.44; }
  }

  /* Lit ring for whichever real element is currently spotlighted — a
     conic gradient wrapping the whole ring, brightest facing the
     tutorial's own light and fading symmetrically toward the far side,
     rather than the site's usual linear glint (built for a distant,
     one-directional ambient light — the wrong shape for "a spotlight
     directly overhead"). Applied via a class toggled only while a
     given element is the active target, layered on TOP of that
     element's own existing ::before glint rather than replacing it in
     place, so nothing about the element's normal styling needs to be
     touched or restored afterward — this pseudo-element just stops
     rendering (padding:1.5px content is empty) once the class is gone. */
  .tutorial-lit { position: relative; }
  .tutorial-lit::after {
    content: "";
    position: absolute; inset: 0; border-radius: inherit; padding: 1.5px;
    background: conic-gradient(from var(--tutorial-glint-angle, 225deg) at 50% 50%,
      rgba(255,255,255,1) 0%,
      rgba(var(--light-rgb),0.62) 20%,
      rgba(var(--light-rgb),0.3) 50%,
      rgba(var(--light-rgb),0.62) 80%,
      rgba(255,255,255,1) 100%);
    -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    -webkit-mask-composite: xor; mask-composite: exclude; pointer-events: none;
    z-index: 1;
  }
  .tutorial-lit-glow {
    box-shadow: 0 0 32px 2px rgba(var(--light-rgb), 0.35) !important;
  }

  /* Same treatment, moved onto the spotlight overlay itself instead of
     a real DOM element — for the cases with no single element to hang
     .tutorial-lit off of at all: text-only targets (noGlow steps,
     where a "this is a lit surface" ring doesn't make sense on plain
     text), rectSelectors unions (stack-nav's arrows+dots — several
     elements, not one contiguous shape), and the grown notification-
     panel/drawer area (only the bell/hamburger itself had its own
     glow; the newly-revealed space around it had none). Carson's own
     diagnosis: in dark mode specifically, the dimmed-vs-undimmed
     boundary alone isn't obvious enough without SOME visible edge —
     light mode's own brighter base gave enough incidental contrast at
     that boundary that this wasn't as necessary there. Reuses
     --tutorial-glint-angle rather than a separate variable, since the
     spotlight already shares the exact same rect the angle would be
     computed from anyway. */
  #tutorial-spotlight.spotlight-glint::after {
    content: "";
    position: absolute; inset: 0; border-radius: inherit; padding: 1.5px;
    background: conic-gradient(from var(--tutorial-glint-angle, 225deg) at 50% 50%,
      rgba(255,255,255,1) 0%,
      rgba(var(--light-rgb),0.62) 20%,
      rgba(var(--light-rgb),0.3) 50%,
      rgba(var(--light-rgb),0.62) 80%,
      rgba(255,255,255,1) 100%);
    -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    -webkit-mask-composite: xor; mask-composite: exclude; pointer-events: none;
  }

  #tutorial-bubble {
    position: absolute; width: min(280px, calc(100vw - 40px)); z-index: 5;
    /* background: none / border: transparent — Carson's site-wide ask
       after confirming the blur/color split looks identical to the
       combined approach. Visible fill/border moved to the new
       #tutorial-bubble-fill child below; this element keeps only the
       static blur + its own entrance animation (left/top/transform/
       opacity, unrelated to color). border stays 1px transparent
       (border-box) to preserve this element's exact dimensions. */
    background: none; backdrop-filter: blur(20px) saturate(1.5);
    -webkit-backdrop-filter: blur(20px) saturate(1.5);
    border: 1px solid transparent; border-radius: 14px; padding: 18px;
    /* transform/opacity match .confirm-modal's own entrance exactly
       (scale(0.94) translateY(8px), opacity 0 at rest) — same "a modal
       just opened" language already established elsewhere on this
       page, not a new one invented for the tutorial specifically. */
    transform: scale(0.94) translateY(8px); opacity: 0;
    /* background-color/border-color moved to #tutorial-bubble-fill
       along with the properties they animate — everything left here
       is this element's own entrance/positioning animation, unrelated
       to color. */
    transition: left 0.25s cubic-bezier(.22,.61,.36,1), top 0.25s cubic-bezier(.22,.61,.36,1),
                transform 0.28s cubic-bezier(.22,.61,.36,1), opacity 0.22s ease;
  }
  #tutorial-bubble-fill {
    position: absolute;
    inset: 0;
    /* Negative, not left at auto/0 — a position:absolute element with
       default z-index still paints ABOVE normal-flow, non-positioned
       siblings (the title/body/buttons) regardless of DOM order — see
       .stat-widget-fill's own copy of this same comment for the full
       reasoning. */
    z-index: -1;
    pointer-events: none;
    border-radius: inherit;
    background: rgba(var(--surface-rgb),0.85);
    border: 1px solid var(--border);
    transition: background-color 2400ms ease, border-color 2400ms ease;
  }
  #tutorial-bubble.tutorial-open { transform: scale(1) translateY(0); opacity: 1; }
  #tutorial-bubble::before {
    content: "";
    position: absolute; inset: 0; border-radius: inherit; padding: 1.5px;
    background: linear-gradient(var(--tutorial-bubble-glint-angle, 225deg),
      rgba(255,255,255,1) 0%, rgba(255,255,255,0.85) 1.5%,
      rgba(255,255,255,0.3) 4%, transparent 9%),
      linear-gradient(var(--tutorial-bubble-glint-angle, 225deg),
      rgba(255,255,255,0.95) 0%, rgba(var(--light-rgb),0.55) 18%,
      rgba(var(--light-rgb),0.12) 40%, transparent 62%);
    background-blend-mode: screen, normal;
    -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    -webkit-mask-composite: xor; mask-composite: exclude; pointer-events: none;
  }
  #tutorial-bubble h4 { margin: 0 0 7px; font-size: 15px; font-weight: 650; color: var(--text); }
  #tutorial-bubble p { margin: 0 0 16px; font-size: 13.5px; color: var(--text-dim); line-height: 1.5; }
  /* Two stacked rows, not one — with up to 19 steps for admins (15 for
     everyone else), the dots alone can run to ~194px, and squeezing
     both Back and Next in alongside them on the same row needed ~284px
     against this bubble's own ~244px of available content width.
     Widening the bubble to fit was the wrong fix — it'd need to grow
     well past its own established "compact, unobtrusive" sizing just
     to cover the worst case. Giving the dots their own row instead
     fits comfortably regardless of step count, and flex-wrap on the
     dots row is a zero-cost safety net if a future step count ever
     runs long enough to need it. */
  .tutorial-bubble-foot { display: flex; flex-direction: column; gap: 12px; }
  .tutorial-step-dots { display: flex; flex-wrap: wrap; gap: 5px; }
  .tutorial-step-dots span { width: 5px; height: 5px; border-radius: 50%; background: var(--border); transition: background-color 2400ms ease; }
  .tutorial-step-dots span.on { background: var(--accent); width: 14px; border-radius: 999px; }
  .tutorial-bubble-btns { display: flex; justify-content: flex-end; gap: 8px; }
  .tutorial-pbtn {
    font-size: 12.5px; font-weight: 650; padding: 8px 14px; border-radius: 999px;
    border: 1px solid var(--border); background: var(--surface); color: var(--text); cursor: pointer;
    transition: transform 0.15s ease, opacity 0.15s ease;
  }
  .tutorial-pbtn:hover { transform: translateY(-1px); }
  .tutorial-pbtn.primary { background: var(--text); color: var(--bg); border: none; }
  .tutorial-pbtn:disabled { opacity: 0.35; cursor: default; transform: none; }
  .tutorial-skip-link {
    font-size: 12.5px; color: var(--text-dim); text-decoration: underline;
    text-decoration-color: rgba(146,146,143,0.4); cursor: pointer; margin-top: 14px; display: inline-block;
    background: none; border: none; padding: 0; font-family: inherit;
  }
  @media (prefers-reduced-motion: reduce) {
    /* left/top/transform/opacity (the entrance animation) are the only
       transitions left on this element post-split — background-color/
       border-color no longer live here at all. The fill layer's own
       2400ms theme fade is untouched by this selector entirely,
       exactly as intended: disabling it under reduced motion would
       reintroduce the instant-snap bug this whole site-wide pass just
       fixed. */
    #tutorial-bubble { transition: none; }
    #tutorial-spotlight { transition: none; }
  }

  /* =========================================================================
     PWA / STANDALONE: clear the iOS status bar
     -------------------------------------------------------------------------
     Installed-as-a-PWA only. In a Safari tab env(safe-area-inset-top) is 0
     and the layout viewport already starts below the status bar, so the
     existing offsets are correct there and are left untouched. In standalone
     mode the inset is real (62px on a 440x956pt device, read off the device
     rather than inferred) and the status bar genuinely occupies that space,
     so anything positioned into it is covered by the OS.

     Two consequences, both fixed here:

     1. The floating buttons used (var(--safe-top) / 2). Half an inset put
        them at ~49pt, level with the clock, where iOS composites its own
        glass over them — which is why they read as permanently washed out
        and why the fade never changed with scroll: that treatment belongs to
        the OS, sits above every page layer, and ignores scroll entirely.
        The full inset clears it. Gaps stay symmetric, so the frost bar's own
        height needs no matching change: the bar spans 0..(84 + --safe-top),
        the buttons sit at (18 + --safe-top) and are 48px tall at phone
        widths, giving 80 - 62 = 18 above (measured from the safe-area
        boundary, the first pixel actually visible) and 146 - 128 = 18 below.

     2. Only the buttons moved, so the page's own header ended up underneath
        them. body's padding-top already encodes 18px + button height + the
        design's gap, so moving the buttons down by exactly var(--safe-top)
        means moving content down by exactly var(--safe-top) too. That
        preserves the current spacing rather than inventing new spacing. The
        banner's negative margin exists only to cancel body's top padding, so
        it grows by the same amount in the same direction.

     Placed last in the stylesheet on purpose, following this file's existing
     convention for overrides: each selector below sets its own value in a
     rule further up, and for equal specificity the later rule wins whatever
     media query surrounds it. An earlier copy would be silently dead.
     ========================================================================= */
  @media (display-mode: standalone) {
    /* See --os-glass-bleed's own comment at the top of this stylesheet
       for what this is and how it was measured. Set here rather than
       at :root so it can only ever be non-zero in standalone mode —
       every rule that adds it is then a guaranteed no-op in a tab. */
    :root {
      --os-glass-bleed: 24px;
    }
    /* .sidebar-trigger's own top lives inside the max-width:780px block far
       above; listing it here, outside any width query, is what makes this
       win on source order rather than being overridden back to the halved
       value. Only `top` is touched — the display:none/display:flex
       breakpoint logic governing when this button exists is left alone. */
    .header-actions,
    .sidebar-trigger {
      top: calc(18px + var(--banner-h, 0px) + var(--safe-top) + var(--os-glass-bleed));
    }

    body {
      --body-pad-top: calc(72px + var(--safe-top) + var(--os-glass-bleed));
    }
    /* Nothing else to restate here: .site-banner is fixed at top: 0 and
       body's padding derives from the variable this rule just changed,
       so both follow automatically. */
  }
  /* Companion to the block above, for the width where body's own
     padding-top is 82px rather than 72px (the buttons grow from 38px to
     48px there). Same derivation, same + var(--safe-top); a separate query
     only because plain CSS cannot add to a value it does not know, so each
     breakpoint's base has to be restated. */
  @media (display-mode: standalone) and (max-width: 780px) {
    body {
      --body-pad-top: calc(82px + var(--safe-top) + var(--os-glass-bleed));
    }
  }
