/* ============================================================================
   Wishler — "Current" design system (soft gradient aqua)
   Imported from Claude Design project "Ocean-themed app colors", turn 4 (3b).

   The Tailwind token mapping that these variables feed lives in index.html,
   in the `tailwind.config` block right after the Tailwind CDN script.

   The gradient is the brand. It appears ONLY on: primary CTAs (.btn-grad),
   funding fills (.grad-fill), the nav "+" button, and switch-on states.
   ========================================================================== */

:root {
    --bg: #F3F9FA;
    --surface: #FFFFFF;
    --ink: #16323E;
    --ink-2: #5E7883;
    --ink-3: #7F97A1;
    --accent: #21759E;
    --accent-deep: #175E82;
    --accent-tint: #E9F4F7;
    --line: #DFECF0;
    --track: #DCEBF0;
    --grad-a: #37B5CE;
    --grad-b: #3E7BE0;
    --danger: #B4443C;
    --success: #2E7D5B;
    --ph-a: #E2F0F4;
    --ph-b: #EDF7F9;
    --scrim: rgba(10, 26, 34, .45);

    /* Space-separated RGB twins of the colours that get used with a Tailwind
       opacity modifier (bg-surface/95, bg-ink/15, …). A hex custom property
       cannot carry <alpha-value>, so those utilities need these instead.
       Add a twin here if you start using /NN on another token. */
    --surface-rgb: 255 255 255;
    --ink-rgb: 22 50 62;

    /* Legacy aliases — kept so any un-migrated markup degrades gracefully
       instead of falling back to browser defaults. */
    --bg-primary: var(--bg);
    --bg-secondary: var(--surface);
    --bg-tertiary: var(--accent-tint);
    --text-primary: var(--ink);
    --text-secondary: var(--ink-2);
    --text-tertiary: var(--ink-3);
    --border-color: var(--line);
    --border-light: var(--line);
    --shadow-sm: 0 4px 16px rgba(22, 50, 62, .10);
    --shadow-md: 0 12px 32px rgba(22, 50, 62, .08);
    --shadow-lg: 0 -12px 40px rgba(10, 26, 34, .30);
}

body.dark {
    --bg: #0A1A22;
    --surface: #12242E;
    --ink: #E8F2F6;
    --ink-2: #93A9B3;
    --ink-3: #6C8290;
    --accent: #5BB8D9;
    --accent-deep: #8FD0E8;
    --accent-tint: #15303C;
    --line: #1F3641;
    --track: #1B333E;
    --grad-a: #4FC7DE;
    --grad-b: #6E9BF2;
    --danger: #E08078;
    --success: #6FBF9A;
    --ph-a: #102028;
    --ph-b: #152A34;
    --scrim: rgba(0, 0, 0, .55);
    --surface-rgb: 18 36 46;
    --ink-rgb: 232 242 246;
}

/* ─── APP-SHELL HEIGHT — READ BEFORE CHANGING ───────────────────────────────
 * The shell must be exactly as tall as the VISIBLE viewport, which on a phone
 * is not 100vh.
 *
 * On mobile browsers 100vh means the viewport with the URL bar HIDDEN — the
 * largest it can ever get. While the URL bar is showing, the real visible area
 * is ~60-115px shorter. The app used `h-screen` (height:100vh) on <body>, so
 * on a real phone the shell hung that far below the fold and took three things
 * with it:
 *
 *   1. the bottom nav — anchored to the shell's bottom, so it sat under the
 *      browser chrome and looked simply missing;
 *   2. scrolling — the page itself became scrollable, so the document scroll
 *      and each view's inner scroller fought each other;
 *   3. the bottom sheets — parked just below the fold by translateY(100%),
 *      they came into view when the page scrolled, which is why the Wishler Pro
 *      sheet appeared over the home screen unbidden.
 *
 * 100dvh is the dynamic viewport height: it tracks the chrome as it shows and
 * hides. The 100vh line above it is the fallback for browsers without dvh —
 * declaration order matters, so keep it first.
 *
 * This is also why it looked fine on localhost: on a desktop window there is no
 * dynamic browser chrome, so 100vh and the visible height are the same number.
 *
 * dvh alone is still not enough on iOS Safari: it is resolved from the layout
 * viewport, which Safari does not always recompute after the URL bar animates
 * or after the app returns from the background. When it goes stale it keeps the
 * SMALLER number, so the shell ends short of the fold and the dock floats in
 * mid-air with a band of empty background under it. --app-h is the measured
 * window.innerHeight, written by syncViewportHeight() in script.js on every
 * resize; dvh stays as the value for the first paint and for anything without
 * JS. Do not drop the two lines under it — they are the fallback chain.
 * ─────────────────────────────────────────────────────────────────────────── */
html {
    height: 100vh;
    height: 100dvh;
    height: var(--app-h, 100dvh);
    /* The viewport takes its overflow from <html>; only when that is `visible`
       does it fall through to <body>. Setting it here states the intent at the
       element that actually governs it, instead of depending on a utility class
       on <body> that a markup edit could drop. The closed sheets sit below the
       fold by design, and this is what keeps them unreachable. */
    overflow: hidden;
}

/* ─── WHY <body> IS A POSITIONED CLIP — READ BEFORE CHANGING ─────────────────
 * `overflow: hidden` hides a scrollbar. It does NOT make a box unscrollable:
 * the browser can still scroll it programmatically, and it does — every engine
 * scrolls an ancestor to bring a newly focused <input> above the on-screen
 * keyboard. That is the whole bug behind "the dock is stranded in the middle
 * of the screen with a band of background under it".
 *
 * The closed bottom sheets are parked below the fold by translateY(100%), and
 * a mid-animation Add sheet hangs below it too. They are `position: absolute`
 * with no positioned ancestor, so their containing block was the initial
 * containing block — the viewport itself — and their overflow landed on the
 * ROOT. That gave <html> ~750px of scrollable overflow. Tap a text field, and
 * the browser cashed it in: the whole shell slid up by however much it wanted,
 * the dock came with it (it is anchored to the shell's bottom, not the
 * screen's), and nothing ever scrolled it back. The keyboard closed; the gap
 * stayed. Same story when a modal was opening — its panel is below the fold
 * for the length of the slide-up, which is exactly when its first field takes
 * focus.
 *
 *   position: relative  makes <body> the containing block for all of them, so
 *                       their overflow is <body>'s problem, not the viewport's.
 *   overflow: clip      is `hidden` that cannot scroll — no scroll container is
 *                       created at all, so there is no scroll position for the
 *                       browser to move. `hidden` is kept as the line before it
 *                       for Safari < 16, which is why order matters here.
 *
 * Geometry is untouched: Tailwind's preflight zeroes the body margin and the
 * height rule below makes its padding box exactly the viewport, so every
 * absolutely-positioned sheet lands where it did before.
 *
 * initShellAnchor() in script.js pins any scroll that gets through anyway back
 * to zero — that is the backstop for the engines without `clip`.
 * ─────────────────────────────────────────────────────────────────────────── */
body {
    /* Fills <html> rather than re-deriving the viewport, so there is one source
       of truth for the shell's height. */
    height: 100%;
    position: relative;
    overflow: hidden;
    overflow: clip;
    /* Stops iOS rubber-banding from dragging the shell and revealing the closed
       sheets parked underneath it. */
    overscroll-behavior: none;

    background: var(--bg);
    color: var(--ink);
    font-family: Inter, system-ui, sans-serif;
    -webkit-font-smoothing: antialiased;
    -webkit-tap-highlight-color: transparent;
    /* Pairs with the no-zoom viewport meta: also removes the double-tap-to-zoom
       delay so taps feel native. Panning and scrolling are unaffected. */
    touch-action: manipulation;
    transition: background-color .3s ease, color .3s ease;
}

/* ─── ON-SCREEN KEYBOARD — --kb ─────────────────────────────────────────────
 * With <body> clipped, the browser can no longer shove the shell up to reveal
 * the field you just tapped — which is the point, but it also means nothing
 * moves out of the keyboard's way any more. --kb is how the app does it
 * itself, deliberately, and gives the height back the moment it should.
 *
 * syncKeyboardInset() in script.js writes it: how much of the window the
 * keyboard is covering, and 0 whenever no editable element has focus. That
 * second half is what makes it self-correcting — a stale keyboard inset cannot
 * outlive the blur, so the app can never be left short by this.
 *
 * The two engines get here differently and both land right:
 *   · a browser that shrinks the layout viewport for the keyboard (Chrome on
 *     Android) has already shortened window.innerHeight, so --app-h shrinks and
 *     --kb computes to ~0. The shell is short because the SCREEN is short.
 *   · a browser that leaves the layout viewport alone (iOS Safari) keeps
 *     --app-h at full height and --kb carries the whole keyboard.
 *
 * Padding, not height: the shell still spans the window, so its background
 * reaches the bottom of the screen and only the CONTENT — every view's
 * scroller, and the dock anchored to their bottom — is pushed clear.
 * ─────────────────────────────────────────────────────────────────────────── */
#app-shell {
    padding-bottom: var(--kb, 0px);
}

/* The sheets are not inside the shell, so they need the same lift themselves.
   `body .sheet-open` rather than `.sheet-open`: Tailwind's `bottom-0` is on
   the same markup at the same specificity, and the Play CDN injects its styles
   after this file — the descendant selector is what makes this win no matter
   what the load order does. Closed sheets are deliberately excluded: parked at
   translateY(100%) from a lifted position, part of one would sit on screen. */
body .sheet-open,
#add-modal > [role="dialog"],
#edit-profile-modal > [role="dialog"] {
    bottom: var(--kb, 0px);
}

/* ---- pull to refresh ----
   The dragged view is moved with transform, never with margin or top: transform
   is composited, so the page tracks the finger at 60fps and — unlike a layout
   property — moving it does not reflow the list being dragged.

   No transition while the finger is down; JS adds .ptr-settling only for the
   release, so the drag is 1:1 and the snap-back is eased. */
.ptr-shift {
    will-change: transform;
}

.ptr-shift.ptr-settling,
#ptr-indicator.ptr-settling {
    transition: transform .3s cubic-bezier(.22, 1, .36, 1), opacity .2s ease;
}

/* Rides in the gap the drag opens up, in the accent rather than the brand
   gradient: the gradient is reserved for CTAs, and this is a status. */
#ptr-indicator {
    position: fixed;
    top: 0;
    left: 50%;
    z-index: 250;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 32px;
    height: 32px;
    margin-left: -16px;
    border-radius: 50%;
    background: var(--surface);
    box-shadow: var(--shadow-sm);
    opacity: 0;
    /* Parked above the top edge so it is out of sight at rest. */
    transform: translateY(-40px);
    pointer-events: none;
}

.ptr-ring {
    width: 17px;
    height: 17px;
    border-radius: 50%;
    border: 2.5px solid var(--track);
    border-top-color: var(--accent);
}

/* While the finger is down the ring is turned by hand, in proportion to the
   pull, so the indicator reports progress rather than faking activity. It only
   starts spinning on its own once the refresh is actually running. */
#ptr-indicator.is-spinning .ptr-ring {
    animation: boot-spin .7s linear infinite;
}

/* ---- boot splash ----
   Shown until the components are loaded AND Firebase Auth has reported whether
   anyone is signed in, so the app never paints a screen it is about to swap. */
.boot-spinner {
    width: 26px;
    height: 26px;
    border-radius: 50%;
    border: 2.5px solid var(--track);
    border-top-color: var(--accent);
    animation: boot-spin .7s linear infinite;
}

/* Inline size, for the "still searching" mark inside the Explore field. */
.boot-spinner-sm {
    width: 16px;
    height: 16px;
    border-width: 2px;
}

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

/* Faded out rather than hidden outright: the app behind it is already painted
   and correct by this point, so a cut would be a flash of its own.

   The animation is a SAFETY NET, not the normal path — JS dismisses this within
   a moment of boot. It exists because a full-screen cover that depends on
   script to leave is a single point of total failure: if script.js fails to
   parse, or errors before bootRoute(), the app is unusable behind an opaque
   wall. After 8s CSS alone retires it, with no JS involved. Reaching this means
   something is wrong, but the user still gets their app. */
#boot-splash {
    transition: opacity .25s ease;
    animation: boot-giveup .4s ease 8s forwards;
}

@keyframes boot-giveup {
    to {
        opacity: 0;
        visibility: hidden;
    }
}

#boot-splash.is-done {
    opacity: 0;
    pointer-events: none;
}

@media (prefers-reduced-motion: reduce) {
    .boot-spinner,
    #ptr-indicator.is-spinning .ptr-ring {
        animation-duration: 1.4s;
    }

    #boot-splash {
        transition: none;
    }
}

/* ---- tactile press: every control responds instantly to touch/click ----
   Ported from padler (src/styles/global.css) so the two apps feel the same
   under the thumb. This is the DEFAULT push-down; the per-component presses
   already in the markup (active:scale-90, active:scale-95, active:scale-[.98])
   are Tailwind class selectors, so they outrank this and keep their own feel.
   What this adds is a press for every control that had none. */
button,
[role='button'] {
    -webkit-user-select: none;
    user-select: none;
    -webkit-touch-callout: none;
    transition: transform 0.12s ease, opacity 0.12s ease;
}

button:active,
[role='button']:active {
    transform: scale(0.96);
}

/* A dead control must not appear to respond. */
button:disabled,
button[aria-disabled='true'] {
    transform: none;
}

/* Honor users who ask for less motion: keep a subtle dim instead of scaling. */
@media (prefers-reduced-motion: reduce) {

    button,
    [role='button'] {
        transition: opacity 0.12s ease;
    }

    button:active,
    [role='button']:active {
        transform: none;
        opacity: 0.7;
    }
}

h1,
h2,
h3 {
    font-family: Sora, Inter, system-ui, sans-serif;
}

a {
    color: var(--accent);
}

a:hover {
    color: var(--accent-deep);
}

:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
    border-radius: 4px;
}

/* ---------------------------------------------------------------- utilities */

.pb-safe {
    padding-bottom: max(16px, env(safe-area-inset-bottom));
}

/* script.js / existing markup use this name for the same idea */
.safe-area-pb {
    padding-bottom: max(2rem, env(safe-area-inset-bottom));
}

.no-scrollbar {
    -ms-overflow-style: none;
    scrollbar-width: none;
}

.no-scrollbar::-webkit-scrollbar {
    display: none;
}

.scrim {
    position: fixed;
    inset: 0;
    background: var(--scrim);
}

/* Brand gradient — CTAs and funding only */
.btn-grad {
    background: linear-gradient(90deg, var(--grad-a), var(--grad-b));
}

/* The confirm button of a DESTRUCTIVE dialog. Flat danger rather than the
   brand gradient: the gradient is the app's yes-please colour and it appears
   on every primary CTA, so wearing it here would make "Delete for good" look
   like the same invitation as "Create wish". */
.btn-danger {
    background: var(--danger);
}

/* The confirm/prompt dialog card. It scales up a hair rather than sliding:
   sliding is what every sheet in the app does on its way to somewhere, and
   this is a stop. */
.confirm-card {
    opacity: 0;
    transform: scale(.96);
    transition: opacity .2s ease, transform .2s cubic-bezier(0.32, 0.72, 0, 1);
}

.confirm-card.is-in {
    opacity: 1;
    transform: scale(1);
}

@media (prefers-reduced-motion: reduce) {
    .confirm-card {
        transition: opacity .2s ease;
        transform: none;
    }

    .confirm-card.is-in {
        transform: none;
    }
}

/* Saving state for a primary CTA (see whileSubmitting()). While an async submit
   is in flight the button is disabled and a bright segment runs continuously
   around its border — the "working" signal, in place of any spinner or swapped
   caption. It's a conic gradient whose start angle is animated (registered with
   @property so it can actually tween) revealed through a two-layer mask that
   keeps only a thin ring at the very edge; the fill and label show through
   underneath, untouched. */
@property --run-angle {
    syntax: '<angle>';
    inherits: false;
    initial-value: 0deg;
}

.is-saving {
    position: relative;
}

.is-saving::after {
    content: '';
    position: absolute;
    inset: 0;
    border-radius: inherit;
    padding: 2.5px;
    /* One bright arc chasing around an otherwise transparent ring. */
    background: conic-gradient(from var(--run-angle),
            rgba(255, 255, 255, 0) 0deg,
            rgba(255, 255, 255, 0) 230deg,
            rgba(255, 255, 255, 0.35) 300deg,
            rgba(255, 255, 255, 0.95) 340deg,
            rgba(255, 255, 255, 0) 360deg);
    /* Punch the fill out of the middle so only the padding-width ring shows. */
    -webkit-mask:
        linear-gradient(#fff 0 0) content-box,
        linear-gradient(#fff 0 0);
    -webkit-mask-composite: xor;
    mask:
        linear-gradient(#fff 0 0) content-box,
        linear-gradient(#fff 0 0);
    mask-composite: exclude;
    animation: run-border 1.1s linear infinite;
    pointer-events: none;
}

@keyframes run-border {
    to {
        --run-angle: 360deg;
    }
}

/* Reduced motion: hold a steady ring instead of chasing one around. */
@media (prefers-reduced-motion: reduce) {
    .is-saving::after {
        animation: none;
        background: rgba(255, 255, 255, 0.7);
    }
}

/* The quick-add name field: a 2px ring of the brand gradient that shimmers
   slowly, marking type-a-name as the AI door the same way .btn-grad marks
   auto-fill. The wrapper IS the border — inner content brings its own
   background and a radius 2px tighter. */
.sparkle-field {
    padding: 2px;
    background: linear-gradient(115deg, var(--grad-a), var(--grad-b), var(--grad-a));
    background-size: 220% 100%;
    animation: sparkle-sweep 3.2s ease-in-out infinite alternate;
}

@keyframes sparkle-sweep {
    from {
        background-position: 0% 50%;
    }

    to {
        background-position: 100% 50%;
    }
}

@media (prefers-reduced-motion: reduce) {
    .sparkle-field {
        animation: none;
    }
}

/* The ring IS the focus style — the global :focus-visible outline on top of
   it reads as a glitchy double border. */
.sparkle-field :focus-visible {
    outline: none;
}

/* --------------------------------------------------------------- stories */
/* The bubble: a gradient ring around the avatar, greyed once the story is
   seen. The "add" variant (your own story) swaps the ring for a neutral track
   and hangs a gradient + badge off the corner. */
.story-ring {
    display: inline-flex;
    padding: 2.5px;
    border-radius: 9999px;
    background: linear-gradient(135deg, var(--grad-a), var(--grad-b));
}

.story-ring.seen {
    background: var(--track);
}

.story-ring.add {
    position: relative;
    background: var(--track);
}

.story-av {
    display: block;
    height: 60px;
    width: 60px;
    border-radius: 9999px;
    object-fit: cover;
    border: 2.5px solid var(--surface);
    background: var(--surface);
}

.story-add-badge {
    position: absolute;
    right: -2px;
    bottom: -2px;
    display: flex;
    height: 20px;
    width: 20px;
    align-items: center;
    justify-content: center;
    border-radius: 9999px;
    color: #fff;
    background: linear-gradient(135deg, var(--grad-a), var(--grad-b));
    border: 2px solid var(--surface);
}

/* Viewer progress: a track per frame; the active one sweeps 0→100% over the
   dwell time via a width transition (--story-ms, set by openStory()). */
.story-seg {
    height: 3px;
    flex: 1;
    border-radius: 9999px;
    background: rgba(255, 255, 255, .35);
    overflow: hidden;
}

.story-seg-fill {
    height: 100%;
    width: 0;
    border-radius: 9999px;
    background: #fff;
}

.story-seg-fill.full {
    width: 100%;
}

.story-seg-fill.run {
    width: 100%;
    transition: width var(--story-ms, 4000ms) linear;
}

/* The viewer is full-screen and opaque; hide the chrome that would otherwise
   sit on top of it (the floating nav dock and the update banner) so the story
   owns the screen. Both reappear when it closes. */
body.story-open #bottom-dock,
body.story-open #update-banner {
    visibility: hidden;
}

.grad-fill {
    background: linear-gradient(90deg, var(--grad-a), var(--grad-b));
}

/* ------------------------------------------------------- video greetings */
/* The orbit: the public avatar sits centred and the greeting circles float
   around it, each parked on a slot (--gx/--gy, set inline by renderGreetings)
   and bobbing gently on its own rhythm (--gd/--gdelay). The wrapper's height
   is set by renderGreetings too — 76px bare, taller when circles need room —
   so a page with no greetings looks exactly as it did before the feature. */
.greet-orbit {
    position: relative;
    display: flex;
    align-items: center;
    justify-content: center;
    height: 76px;
    /* NO height transition, deliberately: animating it slides the circles —
       and everything below — while a finger is already travelling toward a
       button, which on a phone reads as "the button dodged my tap". The
       orbit snaps to size instead; only the visuals inside it float. */
}

.greet-circle {
    position: absolute;
    left: 50%;
    top: 50%;
    /* A generous, STATIC hitbox. The bob animation lives on the inner
       .greet-float, so the visual drifts a few px while the box the finger
       aims at never moves — a moving tap target is how the record button
       kept "running away" on phones. 56px also clears the 44pt minimum
       with room for aim error, and flex-centering removes the baseline
       slack that used to sit under the inline circle. */
    width: 56px;
    height: 56px;
    display: flex;
    align-items: center;
    justify-content: center;
    transform: translate(calc(-50% + var(--gx, 0px)), calc(-50% + var(--gy, 0px)));
    touch-action: manipulation;
    -webkit-tap-highlight-color: transparent;
}

/* ── the press must not move the circle ──────────────────────────────────────
   These are <button>s, so the global `button:active { transform: scale(0.96) }`
   applied to them — and transform is ONE property, so that press REPLACED the
   translate above. Every circle snapped from its slot on the orbit to the dead
   centre of it for as long as a finger was down: the camera button sits at
   +62px/+28px, so it visibly leapt up and to the left on touch and back on
   release. That is the "button jumping" — not a wobble, a 68px teleport.

   The position is restored here, and the press feedback moves inside to
   .greet-cta / .greet-ring, which own no transform of their own. */
.greet-circle:active {
    transform: translate(calc(-50% + var(--gx, 0px)), calc(-50% + var(--gy, 0px)));
}

.greet-circle:active .greet-cta,
.greet-circle:active .greet-ring {
    transform: scale(.94);
}

.greet-cta,
.greet-ring {
    transform-origin: center;
    transition: transform .12s ease, box-shadow .18s ease;
}

/* The bob lives on an INNER element so it never fights the slot transform.
   position:relative because the waiting/birthday badge hangs off it. */
.greet-float {
    position: relative;
    display: block;
    animation: greet-bob var(--gd, 4s) ease-in-out var(--gdelay, 0s) infinite alternate;
}

@keyframes greet-bob {
    from {
        transform: translateY(-3px);
    }

    to {
        transform: translateY(4px);
    }
}

/* The record button holds still. It is the one circle that is a CONTROL
   rather than decoration, and a control that drifts under the finger is a
   control people miss — the greeting circles keep the float, the CTA parks. */
.greet-float.greet-still {
    animation: none;
}

@media (prefers-reduced-motion: reduce) {

    .greet-float {
        animation: none;
    }
}

/* The circle itself: the story strip's gradient ring, one size down. */
.greet-ring {
    position: relative;
    display: inline-flex;
    padding: 2px;
    border-radius: 9999px;
    background: linear-gradient(135deg, var(--grad-a), var(--grad-b));
}

/* A greeting only its recorder/owner can see yet — quieter ring, and the
   badge below marks it as waiting. */
.greet-ring.pending {
    background: var(--track);
}

.greet-av {
    display: block;
    height: 40px;
    width: 40px;
    border-radius: 9999px;
    object-fit: cover;
    border: 2px solid var(--surface);
    background: var(--surface);
}

.greet-badge {
    position: absolute;
    right: -1px;
    bottom: -1px;
    display: flex;
    height: 16px;
    width: 16px;
    align-items: center;
    justify-content: center;
    border-radius: 9999px;
    font-size: 9px;
    line-height: 1;
    color: #fff;
    background: linear-gradient(135deg, var(--grad-a), var(--grad-b));
    border: 2px solid var(--surface);
}

/* The record-a-greeting circle: same footprint, a camera glyph on the brand
   gradient — the one CTA in the orbit, so the gradient is doing its job. */
.greet-cta {
    display: flex;
    height: 40px;
    width: 40px;
    align-items: center;
    justify-content: center;
    border-radius: 9999px;
    color: #fff;
    background: linear-gradient(135deg, var(--grad-a), var(--grad-b));
    border: 2px solid var(--surface);
    box-shadow: 0 4px 12px rgba(62, 123, 224, .35);
}

/* The "+N" overflow chip, when more people greeted than the orbit has slots. */
.greet-more {
    display: flex;
    height: 40px;
    width: 40px;
    align-items: center;
    justify-content: center;
    border-radius: 9999px;
    font-size: 12px;
    font-weight: 700;
    color: var(--ink);
    background: var(--accent-tint);
    border: 2px solid var(--surface);
}

/* ------------------------------------------------- birthday sparkles */
/* Golden twinkles around the avatar while the page owner's birthday window
   is open (same window as the 🎂 badge on the record button). Two breeds
   share one element: a four-pointed star that blinks in place, and a .glint
   dot that drifts upward and dies — together they read as the Telegram-star
   shimmer. Slots come inline from renderBirthdaySparkles as --sx/--sy off
   the avatar's centre, sized by --ss, paced by --sd/--sdelay. */
.bday-sparkles {
    position: absolute;
    inset: 0;
    pointer-events: none;
}

.bday-spark {
    position: absolute;
    left: calc(50% + var(--sx, 0px));
    top: calc(50% + var(--sy, 0px));
    width: var(--ss, 10px);
    height: var(--ss, 10px);
    background: #ffd54f;
    clip-path: polygon(50% 0, 62% 38%, 100% 50%, 62% 62%, 50% 100%, 38% 62%, 0 50%, 38% 38%);
    filter: drop-shadow(0 0 3px rgba(255, 193, 7, .75));
    /* Born invisible: every keyframe track starts and ends at opacity 0, so
       the stagger delays (--sdelay) never flash a spark at full strength. */
    opacity: 0;
    animation: bday-twinkle var(--sd, 2s) ease-in-out var(--sdelay, 0s) infinite;
}

@keyframes bday-twinkle {

    0%,
    100% {
        transform: translate(-50%, -50%) scale(0) rotate(0deg);
        opacity: 0;
    }

    50% {
        transform: translate(-50%, -50%) scale(1) rotate(45deg);
        opacity: 1;
    }
}

/* The floating breed: a soft mote that rises past the avatar and fades,
   like the embers off a sparkler. */
.bday-spark.glint {
    clip-path: none;
    border-radius: 9999px;
    animation-name: bday-glint;
    animation-timing-function: ease-out;
}

@keyframes bday-glint {
    0% {
        transform: translate(-50%, -50%) translateY(8px) scale(.4);
        opacity: 0;
    }

    30% {
        opacity: .9;
    }

    100% {
        transform: translate(-50%, -50%) translateY(-24px) scale(1);
        opacity: 0;
    }
}

/* Sparkles are pure garnish — under reduced motion they simply do not
   exist, rather than hanging frozen mid-blink. */
@media (prefers-reduced-motion: reduce) {
    .bday-sparkles {
        display: none;
    }
}

/* ---- viewer + recorder: the big playback circle and its progress ring ---- */
.greet-play-circle {
    position: relative;
    height: min(72vw, 320px);
    width: min(72vw, 320px);
    overflow: hidden;
    border-radius: 9999px;
    background: #111;
}

/* Drawn just OUTSIDE the circle: the ring svg is a slightly larger square
   centred on it, so the stroke never overlaps the video. */
.greet-play-ring {
    position: absolute;
    left: 50%;
    top: 50%;
    height: calc(min(72vw, 320px) + 18px);
    width: calc(min(72vw, 320px) + 18px);
    transform: translate(-50%, -50%) rotate(-90deg);
    pointer-events: none;
}

/* A selfie preview that isn't mirrored reads as broken — every camera app
   mirrors it. The RECORDING is left unmirrored, which is also what people
   expect: text on a shirt reads correctly to the person watching. */
.greet-mirrored {
    transform: scaleX(-1);
}

/* The spinner on the black circle while getUserMedia is in flight. The camera
   is not any faster than it was; this is the difference between a wait and a
   screen that looks like it failed to load. */
.greet-rec-wake {
    position: absolute;
    left: 50%;
    top: 50%;
    width: 30px;
    height: 30px;
    margin: -15px 0 0 -15px;
    border-radius: 9999px;
    border: 2.5px solid rgba(255, 255, 255, .22);
    border-top-color: #fff;
    animation: boot-spin .7s linear infinite;
}

/* The record button: white ring, red core — the universal "this records".
   Held down to record, released to stop.

   NOTHING here may change the button's box. It is a target a finger is already
   resting on, and the orbit circles above learned this the hard way (see the
   note on .greet-circle): a control that resizes or shifts under a held finger
   reads as the button running away. So the press cue is a box-shadow and the
   core's own transform — neither of which lays anything out — the size is
   fixed, and `flex: none` stops the column from ever squashing it. */
.greet-rec-btn {
    position: relative;
    display: flex;
    flex: none;
    height: 72px;
    width: 72px;
    align-items: center;
    justify-content: center;
    border-radius: 9999px;
    border: 4px solid #fff;
    background: transparent;
    /* A held button must not scroll the page, select the label, or flash the
       platform's tap highlight — all three fight the gesture. */
    touch-action: none;
    user-select: none;
    -webkit-user-select: none;
    -webkit-tap-highlight-color: transparent;
    transition: box-shadow .18s ease, opacity .18s ease;
}

/* Opts OUT of the global `button:active { transform: scale(0.96) }`.
   That press feedback is right for a button you tap: it lasts ~100ms and the
   finger is already leaving. This one is held for up to thirty seconds, so the
   same rule shrank the button by 3px and shifted it 1.44px sideways for the
   WHOLE take — small enough to look fine in a screenshot, and exactly the
   "it moved out from under me" the gesture cannot afford. The press already
   has feedback that costs no geometry: the core squares off and the ring
   blooms. */
.greet-rec-btn:active {
    transform: none;
}

/* Waiting for the camera. Dimmed, not resized: the button that appears on the
   tap has to be the same size and in the same place as the one that goes live
   a moment later, or a finger already travelling toward it arrives at a
   control that moved. */
.greet-rec-btn:disabled {
    opacity: .4;
    cursor: default;
}

.greet-rec-dot {
    display: block;
    height: 54px;
    width: 54px;
    border-radius: 9999px;
    background: #F43F5E;
    transition: border-radius .2s ease, transform .2s ease;
}

/* Recording: the core shrinks to a square, the way camera apps say "let go to
   stop". The ring around it blooms rather than the button growing. */
.greet-rec-btn.recording {
    box-shadow: 0 0 0 6px rgba(244, 63, 94, .28);
}

.greet-rec-btn.recording .greet-rec-dot {
    border-radius: 8px;
    transform: scale(.55);
}

/* The button sits at the bottom of the sheet, where a camera app puts its
   shutter, with room under it for the hint and the home indicator. */
.greet-rec-shell {
    position: relative;
    display: flex;
    flex: none;
    flex-direction: column;
    align-items: center;
    margin-bottom: calc(34px + env(safe-area-inset-bottom, 0px));
}

/* The hold hint under the button.
   ABSOLUTE, and that is the whole point. Laid out normally it sat in the same
   centred column as the button, so the column was as wide as the WIDEST of the
   two — the text — and swapping "Hold to record" for "Release to stop" changed
   that width and slid the button sideways by half the difference. About 1.4px,
   which is invisible in a screenshot and unmistakable under a finger that is
   already holding the button down. Out of flow, the column is always exactly
   the button's 72px and nothing the copy does can move it. */
.greet-rec-hint {
    position: absolute;
    top: calc(100% + 10px);
    left: 50%;
    transform: translateX(-50%);
    white-space: nowrap;
    font-size: 13px;
    line-height: 18px;
    color: rgba(255, 255, 255, .7);
    text-align: center;
    pointer-events: none;
    user-select: none;
    -webkit-user-select: none;
}

/* ─────────────────────────────── the greeting flying into orbit ───────────
   On send, the recorded circle shrinks and travels to the ring of circles
   around the profile photo, as if the orbit pulled it in. It is a throwaway
   clone on top of everything (the recorder is still fading out underneath),
   driven by custom properties that greetingFlyToOrbit() measures at run time.

   The easing overshoots slightly on the way in — a pull, not a slide. */
.greet-fly {
    position: fixed;
    z-index: 130;
    border-radius: 9999px;
    overflow: hidden;
    background: #111;
    pointer-events: none;
    will-change: transform, opacity;
    animation: greet-fly-in var(--fly-ms, 620ms) cubic-bezier(.5, -0.35, .3, 1.2) forwards;
}

.greet-fly video,
.greet-fly img {
    height: 100%;
    width: 100%;
    object-fit: cover;
}

@keyframes greet-fly-in {
    0% {
        transform: translate(0, 0) scale(1) rotate(0deg);
        opacity: 1;
    }

    70% {
        opacity: 1;
    }

    100% {
        transform: translate(var(--fly-dx), var(--fly-dy)) scale(var(--fly-scale)) rotate(var(--fly-spin, 25deg));
        opacity: .45;
    }
}

/* The orbit answering: the ring gives a short pulse as the circle lands. */
.greet-orbit.greet-caught {
    animation: greet-caught .5s ease-out;
}

@keyframes greet-caught {

    0%,
    100% {
        transform: scale(1);
    }

    45% {
        transform: scale(1.07);
    }
}

@media (prefers-reduced-motion: reduce) {

    /* The circle still goes where it went — it just gets there without the
       flight, because the point being made is "it landed in the orbit". */
    .greet-fly {
        animation-duration: 1ms;
    }

    .greet-orbit.greet-caught {
        animation: none;
    }

    .greet-rec-btn,
    .greet-rec-dot,
    .greet-cta,
    .greet-ring {
        transition: none;
    }

    /* The spinner slows rather than stops: it is the only thing saying the
       camera is still coming, the same call the boot spinner makes. */
    .greet-rec-wake {
        animation-duration: 1.4s;
    }
}

/* Both greeting overlays own the screen the way the story viewer does. */
body.greeting-open #bottom-dock,
body.greeting-open #update-banner {
    visibility: hidden;
}

/* Image placeholder — doubles as the loading/error fallback behind <img>. */
.img-ph {
    background: repeating-linear-gradient(45deg, var(--ph-a) 0 14px, var(--ph-b) 14px 28px);
    display: flex;
    align-items: center;
    justify-content: center;
}

.img-ph::after {
    content: attr(data-label);
    font: 10px ui-monospace, Menlo, monospace;
    letter-spacing: .08em;
    color: var(--ink-3);
}

/* Skeletons */
.skel {
    position: relative;
    overflow: hidden;
    background: var(--track);
}

.skel::after {
    content: "";
    position: absolute;
    inset: 0;
    transform: translateX(-100%);
    background: linear-gradient(90deg, transparent, rgba(255, 255, 255, .5), transparent);
    animation: skel 1.4s infinite;
}

body.dark .skel::after {
    background: linear-gradient(90deg, transparent, rgba(255, 255, 255, .08), transparent);
}

@keyframes skel {
    100% {
        transform: translateX(100%);
    }
}

/* ------------------------------------------------------------ bottom sheets */
/* The design names these .sheet / [data-closed]; script.js toggles
   .sheet-open / .sheet-closed. Both are supported so either can drive it. */

/* `bottom` is in the transition because an open sheet rides up on --kb when the
   keyboard appears — see the ON-SCREEN KEYBOARD note at the top of this file. */
.sheet,
.sheet-transition {
    transition: transform .34s cubic-bezier(0.32, 0.72, 0, 1), bottom .2s ease;
    will-change: transform;
}

/* A closed sheet is parked below the fold by translateY(100%) — but "off the
   bottom of the viewport" is not the same as "cannot be seen". Focusing the
   comment input opens the on-screen keyboard, and the browser then scrolls the
   layout viewport to bring that input into view; that scroll dragged the parked
   sheets up into sight, which is how the Wishler Pro sheet appeared underneath
   the comments box without anyone opening it.

   That scroll is now impossible — see the WHY <body> IS A POSITIONED CLIP note
   at the top of this file — but this rule stays: it is the guarantee that does
   not depend on nothing ever moving the viewport again.

   visibility:hidden makes it unpaintable regardless of how the viewport moves.
   The delayed transition is what keeps the close animation: visibility flips
   only AFTER the .34s slide finishes, so the sheet is still painted on its way
   out. Opening is immediate, because .sheet-open's rule has no delay. */
.sheet[data-closed],
.sheet-closed {
    transform: translateY(100%);
    visibility: hidden;
    transition: transform .34s cubic-bezier(0.32, 0.72, 0, 1), visibility 0s linear .34s;
}

.sheet-open {
    transform: translateY(0%);
    visibility: visible;
}

/* A bottom sheet that is taller than the screen must scroll INSIDE itself.
   It is anchored to the bottom edge, so without a cap it grows upward past the
   top of the viewport and that overflow is unreachable — there is nothing to
   scroll, the content is simply gone. The settings sheet was doing exactly that
   on a phone: its header and the Wishler Pro row sat above the top edge.

   92dvh, not 92vh, for the same reason the shell uses dvh: 92% of the tall
   URL-bar-hidden viewport can still exceed what is actually visible. The vh
   line is the fallback for browsers without dvh, so keep it first. */
.sheet-scroll {
    max-height: 92vh;
    max-height: 92dvh;
    overflow-y: auto;
}

/* iOS-style scale-back of the app behind an open sheet.
   .app-scale is the design's name; script.js toggles .content-scaled. */
.content-wrapper {
    transition: transform .34s cubic-bezier(0.32, 0.72, 0, 1), opacity .34s ease, border-radius .34s ease;
    transform-origin: top center;
    background-color: var(--bg);
}

.app-scale,
.content-scaled {
    transform: scale(.92) translateY(12px);
    transform-origin: top center;
    border-radius: 32px;
    overflow: hidden;
    opacity: .8;
}

/* ------------------------------------------------------------------- toast */
/* Positioned here; script.js only toggles .show. */

.toast {
    position: absolute;
    top: 20px;
    left: 50%;
    transform: translateX(-50%) translateY(-120px);
    transition: transform .34s cubic-bezier(0.32, 0.72, 0, 1), opacity .2s ease;
    opacity: 0;
    z-index: 100;
}

.toast.show {
    transform: translateX(-50%) translateY(20px);
    opacity: 1;
}

/* ------------------------------------------------------------- transitions */

@keyframes slideUp {
    from {
        transform: translateY(100%);
        opacity: 0;
    }

    to {
        transform: translateY(0);
        opacity: 1;
    }
}

.animate-slide-up {
    animation: slideUp .4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}

@keyframes fadeIn {
    from {
        opacity: 0;
        transform: scale(.98);
    }

    to {
        opacity: 1;
        transform: scale(1);
    }
}

.animate-fade-in {
    animation: fadeIn .3s ease-out forwards;
}

/* Fades a hero image into the sheet body below it. */
.image-fade-gradient {
    background: linear-gradient(to top, var(--surface) 12%, rgba(0, 0, 0, 0) 100%);
}

/* --------------------------------------------------------- tabs, nav, dots */

.tab-active {
    border-bottom: 2px solid var(--accent);
    color: var(--ink);
    font-weight: 600;
}

.tab-inactive {
    border-bottom: 2px solid transparent;
    color: var(--ink-2);
}

/* ---------------------------------------------------------- bottom nav dock
   Every tab carries its label. It used to be icon-only, and two of the five
   were guesses: a compass for Explore and a cake for Occasions name screens
   you cannot derive from the drawing. Recognition beats recall, and both
   platform guidelines put text in a tab bar.

   The active tab is an accent-tint circle behind the icon AND accent-coloured
   text, paired with aria-current — state is never colour-alone.

   While the active view scrolls down the labels collapse and the dock scales
   slightly, so it gets out of the way exactly as before; scrolling back up
   restores them (initDockShrink() in script.js). The collapse is a
   max-height/opacity tween rather than display:none so it animates. */
.dock {
    transform-origin: center bottom;
    transition: transform .25s ease;
}

.dock.shrink {
    transform: scale(.88);
}

.dockbtn {
    position: relative;
    display: inline-flex;
    min-width: 56px;
    flex: 1 1 0;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 1px;
    padding: 3px 2px;
    border-radius: 14px;
    color: var(--ink-3);
    transition: color .15s ease, transform .12s ease;
}

/* The circle is its own element so the active pill hugs the icon rather than
   the icon+label stack, which is what makes the label readable against the
   dock instead of sitting inside a tinted block. */
.dock-icon {
    display: inline-flex;
    height: 30px;
    width: 30px;
    flex: none;
    align-items: center;
    justify-content: center;
    border-radius: 50%;
    transition: background-color .18s ease;
}

.dockbtn.active {
    color: var(--accent);
}

.dockbtn.active .dock-icon {
    background: var(--accent-tint);
}

.dockbtn:active {
    transform: scale(.9);
}

.dock-label {
    max-width: 100%;
    overflow: hidden;
    font-size: 10px;
    font-weight: 600;
    line-height: 12px;
    max-height: 12px;
    text-overflow: ellipsis;
    white-space: nowrap;
    transition: max-height .2s ease, opacity .15s ease;
}

.dock.shrink .dock-label {
    max-height: 0;
    opacity: 0;
}

/* The add button keeps the gradient — it is the one primary action in the
   dock — but wears it on the circle, so it stacks with a label like the rest
   instead of being a differently-shaped hole in the row. */
.dockbtn-add .dock-icon {
    height: 34px;
    width: 34px;
    background: linear-gradient(90deg, var(--grad-a), var(--grad-b));
    box-shadow: 0 8px 18px rgba(62, 123, 224, .28);
    color: #fff;
}

.dockbtn-add.active .dock-icon,
.dockbtn-add:hover .dock-icon {
    background: linear-gradient(90deg, var(--grad-a), var(--grad-b));
}

/* The dock's side gutter. Here rather than as a px-* utility in the markup
   because it has to narrow on small screens, and a Tailwind utility would
   outrank the media query below (the CDN injects its styles after this file).

   Five 56px cells plus their gaps need ~300px. A 320px screen with a 16px
   gutter leaves 286, so the labels would have to shrink — and the longest,
   Georgian "პროფილი" at 51px, has nothing to give. Narrowing the gutter hands
   the width back instead; the dock is a floating pill, so tighter margins on
   the smallest phones cost nothing but breathing room. */
.dock-shell {
    padding-left: 16px;
    padding-right: 16px;
}

@media (max-width: 359px) {
    .dock-shell {
        padding-left: 6px;
        padding-right: 6px;
    }
}

@media (prefers-reduced-motion: reduce) {

    .dock,
    .dock-icon,
    .dock-label,
    .dockbtn {
        transition: none;
    }
}

/* Dark-mode switch in the settings sheet. Driven straight off `body.dark`
   so it can never fall out of sync with the actual theme. */
.dm-switch,
.dm-knob {
    transition: background-color .2s ease, transform .2s cubic-bezier(0.32, 0.72, 0, 1);
}

body.dark .dm-switch {
    background: linear-gradient(90deg, var(--grad-a), var(--grad-b));
}

body.dark .dm-knob {
    transform: translateX(16px);
}

/* Incognito switch. Same mechanics as the dark-mode one, but driven off
   .is-on on the row rather than a body class — incognito is per-row state,
   and refreshPlanUI() is the single writer. Gradient on the on-state is the
   one place besides CTAs and funding fills where the brand gradient is used. */
.ig-switch,
.ig-knob {
    transition: background-color .2s ease, transform .2s cubic-bezier(0.32, 0.72, 0, 1);
}

#incognito-row.is-on .ig-switch {
    background: linear-gradient(90deg, var(--grad-a), var(--grad-b));
}

#incognito-row.is-on .ig-knob {
    transform: translateX(16px);
}

/* Group-gift switch in the add/edit form. Unlike the others here it is driven
   by a real <input type="checkbox">, so :checked on the peer is the only state —
   nothing in JS has to keep the visual in sync with the value being submitted,
   and it stays keyboard- and screen-reader-operable for free.
   The focus ring is on the switch because the input itself is sr-only. */
.gg-switch,
.gg-knob {
    transition: background-color .2s ease, transform .2s cubic-bezier(0.32, 0.72, 0, 1);
}

.peer:checked~.gg-switch {
    background: linear-gradient(90deg, var(--grad-a), var(--grad-b));
}

.peer:checked~.gg-switch .gg-knob {
    transform: translateX(16px);
}

.peer:focus-visible~.gg-switch {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
}

/* Public-profile switch in Settings. Same mechanics as the incognito one —
   .is-on on the row, one writer (refreshPublicProfileUI) — because this is
   also a visibility state rather than a theme. */
.pub-switch,
.pub-knob {
    transition: background-color .2s ease, transform .2s cubic-bezier(0.32, 0.72, 0, 1);
}

#settings-public-row.is-on .pub-switch {
    background: linear-gradient(90deg, var(--grad-a), var(--grad-b));
}

#settings-public-row.is-on .pub-knob {
    transform: translateX(16px);
}

/* Push switch in Settings. Same mechanics again — .is-on on the row, one
   writer (refreshPushUI). The .is-locked state is what the others do not need:
   a browser-level "denied" cannot be undone from inside the page, so the row
   goes flat and unactionable rather than pretending a tap will do something. */
.push-switch,
.push-knob {
    transition: background-color .2s ease, transform .2s cubic-bezier(0.32, 0.72, 0, 1);
}

#settings-push-row.is-on .push-switch {
    background: linear-gradient(90deg, var(--grad-a), var(--grad-b));
}

#settings-push-row.is-on .push-knob {
    transform: translateX(16px);
}

#settings-push-row.is-locked {
    opacity: .55;
}

/* Incognito is live. A hairline along the top edge of the app, in the deep
   accent rather than the gradient: this is a status, not something to tap.
   Persistent on purpose — a privacy mode you forget you left on is a privacy
   mode you stop trusting. */
body.incognito::before {
    content: "";
    position: fixed;
    inset: 0 0 auto 0;
    height: 3px;
    z-index: 300;
    background: var(--accent-deep);
    pointer-events: none;
}

/* ---------------------------------------------------- install page pointer */
/* Hand-drawn arrow pointing at the browser's own menu button, bottom right.
   Accent, never the brand gradient — that stays reserved for CTAs and funding
   fills, and a gradient here would read as an action you can tap. The motion is
   a slow drift along the arrow's own axis, not a bounce: it should catch the
   eye on the second pass, not the first. */

.install-hint {
    color: var(--accent);
    animation: hint-drift 2.8s ease-in-out infinite;
}

@keyframes hint-drift {

    0%,
    100% {
        transform: translate(0, 0);
        opacity: .75;
    }

    50% {
        transform: translate(5px, 7px);
        opacity: 1;
    }
}

/* Draws itself once each time the view is shown (display:none → block restarts
   CSS animations), so it reads as a gesture rather than static decoration. */
.install-hint-path {
    stroke-dasharray: 190;
    stroke-dashoffset: 190;
    animation: hint-draw .95s cubic-bezier(.4, 0, .2, 1) .2s forwards;
}

@keyframes hint-draw {
    to {
        stroke-dashoffset: 0;
    }
}

.install-hint-head {
    opacity: 0;
    animation: hint-head .35s ease-out .95s forwards;
}

@keyframes hint-head {
    to {
        opacity: 1;
    }
}

/* Login screen while an auth call is in flight */
.is-busy {
    pointer-events: none;
    opacity: .6;
}

/* Loading dots used by the AI extract button */
.loading-dots:after {
    content: '.';
    animation: dots 1.5s steps(5, end) infinite;
}

@keyframes dots {

    0%,
    20% {
        content: '.';
    }

    40% {
        content: '..';
    }

    60% {
        content: '...';
    }

    80%,
    100% {
        content: '';
    }
}

/* --------------------------------------------------------- reduced motion */

@media (prefers-reduced-motion: reduce) {

    .sheet,
    .sheet-transition,
    .content-wrapper,
    .toast,
    .skel::after,
    .ptr-shift.ptr-settling,
    #ptr-indicator.ptr-settling,
    .animate-slide-up,
    .animate-fade-in,
    .install-hint,
    .install-hint-path,
    .install-hint-head {
        transition-duration: .01ms !important;
        animation-duration: .01ms !important;
        animation-iteration-count: 1 !important;
    }
}

/* ── Desktop / wide-viewport framing ───────────────────────────────────────
   Wishler is a phone-shaped app. On a wide screen the shell stretched edge to
   edge and looked lost. Instead we centre it in a phone-width column — like
   Instagram's web view — and darken the surrounding gutter so the frame reads
   as a device on a page.

   The bottom sheets are NOT children of #app-shell — they are direct children
   of <body>, so capping the shell alone left every sheet spanning the whole
   viewport, detached from the column and with almost no "outside" left to tap
   shut. The rule below pulls the sheet PANELS back into the same column.

   Why width + auto margins, not transform: an absolutely-positioned box with
   left:0 and right:0 (the panels' `inset-x-0`) centres inside its container
   once a max-width leaves slack for `margin-inline: auto` to split. That keeps
   `transform` free for the open/close translateY and the swipe-to-dismiss drag
   — centring with translateX would fight both.

   The scrims stay full-viewport on purpose: each dims the whole page and its
   onclick closes the sheet, so a tap anywhere off the panel — gutter included
   — still shuts it. */
@media (min-width: 640px) {
    body {
        background: var(--gutter, #E2ECEF);
    }

    #app-shell {
        width: 100%;
        max-width: 460px;
        margin-inline: auto;
        box-shadow: 0 0 0 1px var(--line), 0 24px 60px -20px rgba(10, 26, 34, .35);
    }

    /* Bottom-sheet panels (their own root) and the slide-up panels nested
       inside a full-screen scrim (#add-modal, #edit-profile-modal). */
    #comments-sheet,
    #settings-sheet,
    #upgrade-sheet,
    #detail-sheet,
    #story-viewer,
    #add-modal > [role="dialog"],
    #edit-profile-modal > [role="dialog"] {
        max-width: 460px;
        margin-inline: auto;
    }
}

body.dark {
    --gutter: #06121A;
}

