/* ────────────────────────────────────────────
   FANTOPIA AI — Liquid Glass Dark
   iOS 26-inspired dark translucent design
──────────────────────────────────────────── */

*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
* { -webkit-tap-highlight-color: transparent; }

/* Text selection follows the app accent (adaptive hue), not the browser default */
::selection {
  background: color-mix(in srgb, var(--accent) 36%, transparent);
  color: var(--text);
}
/* Inside user bubbles the backdrop IS the accent - accent-tinted selection would
   melt into it; a white tint keeps the selected range readable */
.bubble.user::selection,
.bubble.user *::selection {
  background: rgba(255, 255, 255, 0.3);
  color: #fff;
}

:root {
  /* ── Background layers ── */
  --bg:          #1b0e11;
  --bg-tint:     #241417;

  /* ── Glass surfaces (solid MD3 surface containers, set by applyTgTheme) ── */
  --glass:       #291e21;
  --glass-bold:  #35272a;
  --glass-border:#2f2325;

  /* ── Glass blur radius (backdrop-filter) — perf dial ──
     backdrop-filter is re-run every frame the content behind it moves, and cost
     scales with radius x area, so this is the main lever for scroll fps on
     weak/no-GPU devices (docs/perf-backdrop-filter.md). These two are LEGACY
     frost radii, kept only so old inline usages do not break: the shell uses
     --chrome-glass-blur below, which is less than half of them. */
  --glass-blur:        24px;
  --glass-blur-strong: 28px;

  /* ══ Shell glass (v5.216) ═══════════════════════════════════════════════
     The material of every FLOATING shell object: nav islands, chat bar chips,
     the title pill, float chips, the folder capsule and tab strips, toolbar
     chips, the search field, menus, sheets, toasts, viewer chrome. Never on
     content (cards, rows, forms, bubbles, sheet BODIES) - those are opaque
     tone steps, and a filter on them is a per-frame bill for nothing.

     Two things make it read as glass rather than a darkened blur: the fill is
     a THIN step of the theme (so the page keeps moving under it) and the edge
     is SCULPTED - a diagonal rim of light and dark, never one flat hairline.

     The fill is a step of --bg-tint, the app's own bar tone, kept DARK on
     purpose. A lifted base (--glass and up) at a low opacity measures several
     L* above the page and reads as a light theme; the lift here comes from
     the rim and the blur, never from brightening the backdrop.
     ══════════════════════════════════════════════════════════════════════ */
  /* TWO OPTICAL VARIANTS OF ONE MATERIAL (owner + Codex debate, 2026-08-19,
     the Apple regular/clear split): geometry, rim, press and the dense twin
     are shared, only the way the veil holds its output brightness differs.
       REGULAR - chrome over UI (nav, chips, strips, search, menus, sheets,
         the catalog toolbar even with cards passing under it): a thin veil of
         the island tone with NO darkening, saturate keeps the passing content
         coloured. 40%: the 54px avatars and cards that scroll under the nav
         stay readable through it (60% of the backdrop gets through; at 62% it
         was barely distinguishable from an opaque chip), while over the bare
         page the centre still lands on main's chip brightness (L* ~8).
       CLEAR - chrome over GUARANTEED media (wallpaper chat scope, chips on
         catalog cards, profile cards over a banner, lightbox = neutral): a
         thin veil of the page tone plus brightness(.80) - the owner's "шик"
         over the wallpaper and the banner. Over a bare page this one would
         merge with it, which is exactly why it never goes there.
     A clear/regular variant must never be picked by reading the pixel under
     a control: it follows the CONTEXT (scope classes below). */
  --chrome-glass-blur: 12px;
  --chrome-glass-fill-regular:            color-mix(in srgb, var(--glass) 40%, transparent);
  --chrome-glass-filter-regular:          blur(var(--chrome-glass-blur)) saturate(1.8);
  --chrome-glass-fill-regular-readable:   color-mix(in srgb, var(--glass) 60%, transparent);
  --chrome-glass-filter-regular-readable: blur(var(--chrome-glass-blur)) saturate(1.65);
  /* the clear tint/alpha are tokens because OLED re-steps them: over a pure
     black page a 30% veil of #000 is a black pill, so applyTgTheme() hands
     OLED the --glass tone at 45% (see the oled branch there) */
  --chrome-glass-clear-tint: var(--bg-tint);
  --chrome-glass-clear-a:    30%;
  --chrome-glass-clear-a-readable: 58%;
  --chrome-glass-fill-clear:              color-mix(in srgb, var(--chrome-glass-clear-tint) var(--chrome-glass-clear-a), transparent);
  --chrome-glass-filter-clear:            brightness(0.80) blur(var(--chrome-glass-blur));
  --chrome-glass-fill-clear-readable:     color-mix(in srgb, var(--chrome-glass-clear-tint) var(--chrome-glass-clear-a-readable), transparent);
  --chrome-glass-filter-clear-readable:   brightness(0.86) blur(var(--chrome-glass-blur)) saturate(1.5);
  /* the ladder the consumers read: standard / readable (regular by default)
     and the media pair (clear); a media scope remaps the first two onto the
     second two, so no consumer restates a recipe */
  --chrome-glass-fill:          var(--chrome-glass-fill-regular);
  --chrome-glass-filter:        var(--chrome-glass-filter-regular);
  --chrome-glass-fill-readable:   var(--chrome-glass-fill-regular-readable);
  --chrome-glass-filter-readable: var(--chrome-glass-filter-regular-readable);
  --chrome-glass-fill-media:            var(--chrome-glass-fill-clear);
  --chrome-glass-filter-media:          var(--chrome-glass-filter-clear);
  --chrome-glass-fill-media-readable:   var(--chrome-glass-fill-clear-readable);
  --chrome-glass-filter-media-readable: var(--chrome-glass-filter-clear-readable);
  /* legacy alias kept for the few consumers that step DOWN a rung */
  --chrome-glass-fill-clear-rung:   var(--chrome-glass-fill-clear);
  --chrome-glass-filter-clear-rung: blur(6px) saturate(1.4);
  /* The rim: a diagonal TL/BR approximation of a real edge, NOT a continuous
     specular band (a uniform band measures far brighter and reads as an
     outline). Four white layers (ring 2.5%, top bevel 9%, counter rim 15%,
     bottom-right glow 12%) and four dark ones that give the capsule its
     thickness. Do not simplify it into one hairline. */
  /* whole-pixel lines, no negative spread (the 1.5/2.5px + -2px layers of
     the v5.213 stack rasterised to nothing at some browser zooms - the owner
     saw the top glint of the search capsule vanish); the two blurred dark
     layers survive any zoom */
  --chrome-glass-rim:
    inset 0 1px 0 rgb(255 255 255 / 22%),
    inset 1px 0 0 rgb(255 255 255 / 10%),
    inset -1px 0 0 rgb(255 255 255 / 6%),
    inset 0 -1px 0 rgb(0 0 0 / 24%),
    inset 0 3px 4px -2px rgb(0 0 0 / 26%),
    inset 0 -4px 3px -3px rgb(0 0 0 / 16%);
  /* The rim is sized to the OBJECT. On a 32-40px chip the full stack has no
     room to resolve: the layers overlap into a permanent glint and the chip
     reads as a Vista bevel instead of a control. So chips get two lines. */
  /* whole-pixel offsets and no negative spread on purpose: a 1.5px offset with
     a -1px spread rasterises to nothing at some browser zooms (the owner saw
     the chip glint vanish at 140% and return at 130%), integers survive */
  --chrome-glass-rim-thin:
    inset 0 1px 0 rgb(255 255 255 / 16%),
    inset 1px 0 0 rgb(255 255 255 / 6%),
    inset -1px 0 0 rgb(255 255 255 / 4%),
    inset 0 -1px 0 rgb(0 0 0 / 20%);
  --chrome-glass-elev:      0 1px 5px rgb(0 0 0 / 20%), 0 6px 16px rgb(0 0 0 / 16%);
  --chrome-glass-elev-chip: 0 1px 2px rgb(0 0 0 / 25%), 0 4px 12px rgb(0 0 0 / 12%);
  --chrome-glass-shadow:      var(--chrome-glass-rim),      var(--chrome-glass-elev);
  --chrome-glass-shadow-thin: var(--chrome-glass-rim-thin), var(--chrome-glass-elev-chip);
  /* Dense (body.no-blur) rendering: the Telegram-Android chrome of the same
     product, not glass with the blur switched off: opaque tone, no
     translucency, no radial light, no lens stretch, but it KEEPS the top
     highlight glint like Telegram Android without blur (owner) - main ships
     exactly that on its chips. */
  --chrome-glass-fill-solid: var(--bg-tint);
  --chrome-glass-shadow-solid:
    inset 0 1px 0 rgb(255 255 255 / 10%),
    inset 0 0 0 .5px rgb(255 255 255 / 4%),
    0 1px 2px rgb(0 0 0 / 30%),
    0 8px 12px 6px rgb(0 0 0 / 15%);
  /* dense chips = main's: opaque tone, a 1px top highlight (the TG-Android
     no-blur glint the owner asked to keep) and main's chip elevation */
  --chrome-glass-shadow-solid-thin:
    inset 0 1px 0 rgb(255 255 255 / 10%),
    inset 0 0 0 .5px rgb(255 255 255 / 4%),
    0 1px 2px rgb(0 0 0 / 25%),
    0 4px 12px rgb(0 0 0 / 12%);
  /* dense surfaces that must read RAISED above other dense chrome (a menu
     over a sheet, an in-flow chat chip over a bubble) */
  --chrome-dense-fill-raised: var(--glass-bold);
  /* media-viewer chrome is deliberately non-theme (neutral black) */
  --chrome-glass-fill-neutral:          rgba(0, 0, 0, 0.42);
  --chrome-glass-fill-neutral-readable: rgba(0, 0, 0, 0.62);
  --chrome-glass-fill-neutral-solid:    #1c1c1e;
  /* Selection lens (nav tabs, folder tabs, strip tabs): a neutral capsule,
     the tint goes on the GLYPH. At rest it is the flat fill alone; the rim
     exists only while it travels (see the .moving keyframe). */
  /* selection lens: the accent-soft capsule of main (owner pick, 2026-08-19,
     over the neutral white lens); the tint stays OFF the glyphs and labels */
  --chrome-lens-fill: var(--accent-soft);
  --chrome-lens-shadow:
    inset 0 0 0 .5px rgb(255 255 255 / 6%),
    inset 1.5px 1.5px 0 -1px rgb(255 255 255 / 26%),
    inset -1.5px -1.5px 0 -1px rgb(255 255 255 / 12%),
    inset 0 -2px 3px -2px rgb(0 0 0 / 22%),
    0 2px 5px rgb(0 0 0 / 14%);

  /* ── Top edge scrim (top-fade + community c-toolbar-fade) — one dial ──
     Plain tint gradient (edges carry no blur). Content dims by opacity toward the
     top rather than blurring: a bit stays visible in the mid of the fade, going
     solid only at the very top. Without blur this is the honest middle between
     "solid pad" and "sharp content"; the soft look was the blur itself. Raise the
     22% toward 40% for a taller solid cap, lower it to reveal more. */
  --top-scrim: linear-gradient(to bottom, var(--bg) 22%, transparent);

  /* ── Text ── */
  --text:        #ffffff;
  --text2:       rgba(255,255,255,0.55);
  --chip-glyph-alpha: 0.55; /* --text2 as opacity, for stroke glyphs whose paths cross */
  --text3:       rgba(255,255,255,0.3);
  /* chat message text size; user-adjustable (Settings → Appearance), boot-applied from localStorage */
  --msg-fs:      15px;

  /* ── Accent ── */
  --accent:      #d64c6a;
  --accent-soft: rgba(214,76,106,0.2);
  --accent-text: #e99faf;
  /* The SAME tonal selection colour as --accent-soft, but as a material: an
     opaque step above --glass-bold instead of a 20% wash. --accent-soft is a
     tint and only becomes a pill by compositing, so it needs an opaque thing
     under it; on GLASS there is none, and the "selected" control turns into a
     hole that shows whatever card happens to lie behind it. This token is what
     a selected control takes when it sits on a glass surface. */
  --accent-soft-solid: color-mix(in srgb, var(--accent) 22%, var(--glass-bold));
  /* glyph tint on glass chrome (the selected nav/folder/strip tab). NOT
     --accent-text, whose +20 L and capped saturation are built for text on a
     tonal fill and read as washed skin tone when it is the only colored thing
     on a glass bar. Rewritten per theme by applyTgTheme(). */

  /* ── Semantic ── */
  --destructive: #ff453a;
  --success:     #34c759;

  /* ── Separators ── */
  --sep:         #38292c;
  /* Hairline between rows of ONE grouped island (iOS 26 content core): a
     white alpha step, not the opaque --sep tone, so it reads the same over
     every island fill the adaptive theme produces. */
  --core-separator: rgba(255,255,255,0.10);

  /* ── Geometry ── */
  --r-island:    24px;
  --r-card:      18px;
  --r-btn:       14px;
  --r-pill:      999px;

  /* ── Spacing ── */
  --pad:         16px;
  --safe-top:    env(safe-area-inset-top, 0px);
  --safe-bottom: max(
    env(safe-area-inset-bottom, 0px),
    var(--tg-safe-area-inset-bottom, 0px),
    var(--tg-content-safe-area-inset-bottom, 0px)
  );
  /* TG fullscreen mode: status bar + Telegram's own controls row.
     Both vars are 0 outside fullscreen and on desktop. */
  --tg-top:      calc(var(--tg-safe-area-inset-top, 0px) + var(--tg-content-safe-area-inset-top, 0px));
  --nav-h:       64px;
  --header-h:    52px;
  /* ── Desktop sidebar (used only at >=1000px; see media query at end of file) ── */
  --sidebar-w:   220px;
  --sidebar-gap: 16px;
}

body.no-blur *,
body.no-blur *::before,
body.no-blur *::after {
  backdrop-filter: none !important;
  -webkit-backdrop-filter: none !important;
}
body.no-blur .input-area::before,
body.no-blur .input-blocker-bar,
body.no-blur .sort-dropdown {
  background: var(--chrome-glass-fill-solid) !important;
}
/* Only chips that FLOAT over passing content need a solid no-blur fill (the
   translucent mix without blur shows text through - mud). In-flow chat chips
   (act icons, pager, side chips, in-flow date pills) scroll WITH the content:
   nothing ever passes behind them, the glass fill renders as one constant
   tone, so no-blur changes nothing there on purpose (owner, 2026-07-21). */

/* ═══════════════════════════════════════════
   GLASS PRESS — the shell's press grammar (v5.216)
═══════════════════════════════════════════ */
/* A GLASS control under the finger lifts toward you (scale 1.04) and catches a
   light at the touch point; only opaque CONTENT buttons depress (.97 / .92).
   glassPress() in app.js stamps data-glass + data-pressed and writes
   --press-x/--press-y ONCE on pointerdown - no pointermove, no per-frame
   repaint. Each family appends `scale 120ms ease-out` to its OWN transition
   list: one shared transition rule here would sit at the same specificity as
   .glass-chip / .act-icon and silently replace theirs.
   The attribute value is literally "1" in the selector so a review switch on
   <html> can never match it and grow a stray light layer on the document element.
   The light is a BACKGROUND on a pseudo, so the pseudo's own border-radius
   clips it - the control needs no overflow:hidden (which would cut off a 44px
   hit-area ::before) and the circle can never spill past the pill. scale (the
   individual property) composites after transform, so chips that use transform
   for placement keep working. */
[data-glass="1"][data-pressed="true"] { scale: 1.04; }
[data-glass="1"]:not(.nav-ava)::after { content: ''; }
[data-glass="1"]:not(.nav-ava)::after,
.glass-touch-light {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  background: radial-gradient(circle 150px at var(--press-x, 50%) var(--press-y, 50%),
    rgb(255 255 255 / 100%) 0%,
    rgb(255 255 255 / 50%) 50%,
    rgb(255 255 255 / 22%) 72%,
    transparent 100%);
  opacity: 0;
  transition: opacity 220ms ease-in-out;
}
[data-glass="1"][data-pressed="true"]:not(.nav-ava)::after,
[data-glass="1"][data-pressed="true"] > .glass-touch-light {
  opacity: 0.10;
  transition-duration: 120ms;
  transition-timing-function: ease-out;
}
/* the nav profile chip already spends ::before on the glass and ::after on the
   lens, so its light is an explicit child (the one place that needs one) */
.nav-ava > .glass-touch-light { z-index: 3; }
/* The NSFW chip shares its base class with a CONTENT twin on a form: the twin
   keeps the shrink, the glass instance drops it while it is the one pressed. */
.nsfw-chip.glass[data-glass="1"][data-pressed="true"]:active { transform: none; }
/* Dense: no light, no lift. The opaque fill steps up (each family's :active)
   and the shrink stays - that is the Telegram-Android press. */
body.no-blur [data-glass="1"][data-pressed="true"] { scale: 1; }
body.no-blur [data-glass="1"]:not(.nav-ava)::after,
body.no-blur .glass-touch-light { display: none; }
/* reduced motion keeps the light (it says "touched") and drops the geometry */
@media (prefers-reduced-motion: reduce) {
  [data-glass="1"][data-pressed="true"] { scale: 1; }
}

/* Live theme drag (themeDrag in app.js): the vars retarget every frame, and
   any state transition on a var-based color (switch rows: background 0.2s at
   rest) re-eases toward each frame's value and visibly trails the preview.
   Transitions are for state changes; during the drag everything tracks 1:1. */
body.theme-live *,
body.theme-live *::before,
body.theme-live *::after {
  transition: none !important;
}

html {
  height: 100%;
  height: 100dvh;
}
body {
  height: 100%;
  margin: 0;
  padding: 0;
  overflow: hidden;
  background: var(--bg);
  color: var(--text);
  font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text', system-ui, sans-serif;
  -webkit-font-smoothing: antialiased;
  overscroll-behavior: none;
  font-size: 16px;
  line-height: 1.4;
}

#app {
  height: 100%;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

.screen {
  height: 100%;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}
.view-content {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  /* opacity/transform driven by direct style writes in transitionToView/
     openSettingsPage (app.js), not a class - see the comment there */
  transition: opacity 150ms cubic-bezier(0.4,0,0.2,1), transform 150ms cubic-bezier(0.4,0,0.2,1);
  position: relative;
}

/* ═══════════════════════════════════════════
   HEADER
═══════════════════════════════════════════ */
.header {
  height: calc(var(--header-h) + var(--tg-top));
  min-height: calc(var(--header-h) + var(--tg-top));
  padding-top: var(--tg-top);
  display: flex;
  align-items: center;
  padding: 0 6px 0 4px;
  gap: 4px;
  background: var(--bg);
  flex-shrink: 0;
  position: relative;
  z-index: 10;
}

/* ── Large-title pattern: header floats over the scroller, transparent at
   rest; gains blur + tint + hairline once content scrolls under it.
   The small bar title fades in at the same moment (the large in-content
   title has scrolled away). ── */
.large-title {
  padding: 8px var(--pad) 4px;
  font-size: 30px;
  font-weight: 700;
  letter-spacing: -0.6px;
  color: var(--text);
}

/* Tertiary / text button (pressables grammar v5.214): no container, color-only
   states, min 44px hit via padding. Shared by .art-clear-btn, .ci-seeall,
   .shared-load-more and any bare text action; the dead .header-txt-btn is gone. */
.text-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 44px;
  padding: 0 8px;
  background: none;
  border: none;
  border-radius: var(--r-pill);
  font-family: inherit;
  font-size: 14px;
  font-weight: 600;
  line-height: 1.2;
  color: var(--accent-text);
  white-space: nowrap;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: color 130ms cubic-bezier(0.4, 0, 0.2, 1), opacity 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
@media (hover: hover) {
  .text-btn:hover:not(:disabled) { opacity: 0.8; }
}
.text-btn:active { opacity: 0.6; }
.text-btn:disabled { opacity: 0.32; pointer-events: none; }
.text-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* The name shares a line with a CIRCLE (the avatar), and the two are centred
   by different things: the circle by its box, the text by its LINE box - which
   carries the font's descent space under the baseline, so the letters end up
   off the axis the circle sits on. `text-box` trims the line box to the
   cap-to-baseline band, so centring centres the LETTERS: measured at 3x, the
   ink drifts 1 device pixel from the circle's centre without it and exactly 0
   with it (a device font with heavier descent drifts further - the owner's
   phone measured ~2.5 CSS px). Chromium and Safari 18.2+ honour it; Firefox
   ignores the declaration and keeps today's line-box centring.

   The trim shrinks the BOX to the cap-to-baseline band, but the INK does not
   fit that band: `overflow: hidden` (which the ellipsis needs) then cuts every
   tail under the baseline and every mark over the cap. Measured at 14px in
   Chromium on "Peggy Йоджи": 4px of ink below the box, 3.8px above it - the
   name rendered as "Peaav Иоджи". The padding gives that ink room inside the
   clip (overflow clips at the PADDING box) and the mirrored negative margin
   hands the room back to the layout, so the trimmed geometry the centring
   depends on is unchanged. Same trick as `.chat-bar-collapse-in` above.
   Firefox never trims, so there the padding is pure slack. */
.header-title {
  font-size: 17px;
  font-weight: 600;
  letter-spacing: -0.3px;
  color: var(--text);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  text-box: trim-both cap alphabetic;
  padding-block: 0.35em;
  margin-block: -0.35em;
}

.header-sub {
  font-size: 12px;
  color: var(--text2);
  margin-top: 1px;
  height: 16px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.header-sub-dots::after {
  content: '';
  animation: dots 1.5s steps(4, end) infinite;
}
@keyframes dots {
  0%  { content: ''; }
  25% { content: '.'; }
  50% { content: '..'; }
  75% { content: '...'; }
}

/* ═══════════════════════════════════════════
   ICON BUTTON
═══════════════════════════════════════════ */
/* Icon action on content: transparent disc, tonal on press, never glass */
.btn-icon {
  width: 40px;
  height: 40px;
  border-radius: var(--r-pill);
  background: transparent;
  border: none;
  color: var(--text2);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  flex-shrink: 0;
  transition: background 130ms cubic-bezier(0.4, 0, 0.2, 1), color 130ms cubic-bezier(0.4, 0, 0.2, 1), transform 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
@media (hover: hover) {
  .btn-icon:hover:not(:disabled) { background: var(--glass); color: var(--text); }
}
.btn-icon:active { background: var(--glass-bold); color: var(--text); transform: scale(0.92); }
.btn-icon:disabled { opacity: 0.32; pointer-events: none; }
.btn-icon:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.btn-icon svg { width: 20px; height: 20px; }

/* ═══════════════════════════════════════════
   SCROLL AREA
═══════════════════════════════════════════ */
.scroll-area {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  overflow-y: scroll;
  -webkit-overflow-scrolling: touch;
  overscroll-behavior: contain;
  overflow-anchor: none;
  scrollbar-width: none;
  /* vertical pans native; horizontal ones reach JS (swipe-back peek) */
  touch-action: pan-y;
  /* Bottom padding: nav island height + gap above nav + safe area */
  padding-bottom: calc(var(--nav-h) + 24px + var(--safe-bottom));
  scroll-padding-bottom: calc(var(--nav-h) + 60px + var(--safe-bottom));
}
.scroll-area::-webkit-scrollbar { display: none; }

/* ═══════════════════════════════════════════
   BOTTOM NAV — Floating Island
═══════════════════════════════════════════ */
/* One gradient for blur AND no-blur (owner, v5.101.4): edges carry no blur in
   either mode since v5.79, so the lighter blur-mode scrim was a leftover. The
   bottom edges keep the heavier (former no-blur) ramp; the chats top fade keeps
   the lighter translucent wall (see .top-fade.chatfade). */
.nav-fade {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  height: calc(var(--nav-h) + 32px + var(--safe-bottom));
  /* softer than main's 75%-solid ramp: with a CLEAR nav the band under the
     island was darkening the bottom of the screen more than the glass itself
     (owner, 2026-08-19) - a lighter, shorter ramp that never goes fully opaque */
  background: linear-gradient(to bottom, transparent, color-mix(in srgb, var(--bg) 72%, transparent) 100%);
  pointer-events: none;
  z-index: 99;
}
.nav-fade::before,
.nav-fade::after,
.input-fade::before,
.input-fade::after {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
}
/* Edges (top fade, nav fade, input fade, nav island) never carry blur, on purpose
   (owner, v5.79): a full-width edge blur is re-filtered every scroll frame and was
   the single priciest chrome cost of a scroll. The edges use a plain tint gradient
   instead; blur stays only on the chips/strips/bars, where the scroll suspend
   (body.no-blur) still drops it during motion. Pseudos kept inert. */
.nav-fade::before,
.nav-fade::after,
.input-fade::before,
.input-fade::after {
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
}
/* ── Community-only mode (/app?mode=community): no app chrome ──
   The bottom nav island and its fade are gone, and --nav-h collapses so
   everything that clears the island (community overlay bottom padding,
   modal max-height) reclaims the space. */
body.community-only .bottom-nav,
body.community-only .nav-fade { display: none; }
body.community-only { --nav-h: 0px; }

/* ═══════════════════════════════════════════
   TOP FADE — tint gradient at the top edge so list content dissolves under the
   floating chips/bars. NO blur (owner, v5.79): the edge blur was the priciest
   per-frame cost of a scroll. Just a gradient scrim now.
═══════════════════════════════════════════ */
/* The old solid-topped no-blur gradient (var(--bg) 30% -> transparent) hid ~26px
   of list under a dead block. This keeps only a thin near-solid cap at the very
   top (clean behind the chip row) then drops light fast, so the list stays
   visible much higher up. Same gradient at rest and while scrolling (blur is off
   at the edges either way), so nothing flickers. Tune the stops to taste. */
.top-fade {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: calc(88px + var(--tg-top));
  z-index: 15;
  pointer-events: none;
  background: var(--top-scrim);
}
.top-fade::before,
.top-fade::after {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
}
/* ── Bottom nav: two islands in one fixed row (v5.212) ──
   .bottom-nav is a transparent fixed ROW: the 4-tab island + the round profile
   chip with the avatar (iOS 26 separates the search action from the tab pill
   the same way). Everything that hides/moves the nav as a whole (kb-open,
   community-only, desktop, EDGE_CHROME) targets the row.
   The glass lives on .nav-pill::before, NOT on .nav-pill: the lens is a
   sibling of that layer, so a filter on the pill itself would make it a
   Backdrop Root and blind anything under it (ADR-0014). */
.bottom-nav {
  position: fixed;
  bottom: calc(10px + var(--safe-bottom));
  left: 50%;
  transform: translateX(-50%);
  width: min(500px, calc(100% - 32px));
  height: var(--nav-h);
  display: flex;
  align-items: stretch;
  gap: 10px;
  z-index: 100;
}
.nav-pill,
.nav-ava {
  background: transparent;
  border: none;
}
/* TEST (owner, 2026-08-19): the nav on the CLEAR variant - remap its two token
   pairs on the bar so the ::before layers below pick clear up. Revert = delete. */
.bottom-nav {
  --chrome-glass-fill:   var(--chrome-glass-fill-media);
  --chrome-glass-filter: var(--chrome-glass-filter-media);
}
.nav-pill::before,
.nav-ava::before {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: var(--chrome-glass-fill);
  backdrop-filter: var(--chrome-glass-filter);
  -webkit-backdrop-filter: var(--chrome-glass-filter);
  box-shadow: var(--chrome-glass-shadow);
  z-index: 0;
  pointer-events: none;
}
/* no-blur: the same silhouette in the dense material, no rim, no translucency
   (see-through without blur is mud) */
body.no-blur .nav-pill::before,
body.no-blur .nav-ava::before {
  background: var(--chrome-glass-fill-solid);
  box-shadow: var(--chrome-glass-shadow-solid);
}
.nav-pill {
  position: relative;
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  padding: 4px;
  border-radius: var(--r-pill);
  /* the glass is a child layer now, so the pill needs its own stacking
     context instead of the old overflow clip (which would cut the lens rim) */
  isolation: isolate;
}

/* Selection lens: a neutral clear capsule (iOS 26 tints the SYMBOL, not the
   lens; the accent-soft capsule is the alternative kept behind data-lens).
   Positioned by BOTH edges so a tab change can move them at different speeds:
   the leading edge (in the direction of travel) arrives in 260ms, the trailing
   one in 400ms - the capsule stretches toward the target and contracts into it.
   Both are plain length transitions, so rapid tab changes retarget from the
   live frame. JS writes --nav-l/--nav-r and data-dir. */
.nav-indicator {
  position: absolute;
  --nav-l: 4px;
  --nav-r: 4px;
  --lens-l-dur: 400ms;
  --lens-l-ease: cubic-bezier(.3,.7,.1,1);
  --lens-r-dur: 260ms;
  --lens-r-ease: cubic-bezier(.2,.8,.2,1);
  --lens-vis-delay: 240ms;
  top: 4px;
  bottom: 4px;
  left: var(--nav-l);
  right: var(--nav-r);
  opacity: 0;
  visibility: hidden;
  /* at rest the lens is JUST a color: flat fill, no rim, no filter. The rim
     and the glass exist only while it travels, see .moving below */
  background: var(--chrome-lens-fill);
  border-radius: var(--r-pill);
  /* The lens belongs to the tab GROUP. Crossing to the separate profile chip
     is not a slide (nothing travels between islands, iOS 26 does the same
     with its search chip): the lens fades out in place (240ms) while the
     chip's own lens material fades in over the same 240ms, and on the way
     back it materializes at the target tab. So opacity is part of the
     transition, and visibility waits for the fade only when hiding. */
  transition:
    left var(--lens-l-dur) var(--lens-l-ease),
    right var(--lens-r-dur) var(--lens-r-ease),
    opacity 240ms cubic-bezier(0.4,0,0.2,1),
    visibility 0s linear var(--lens-vis-delay);
  pointer-events: none;
  z-index: 1;
}
.nav-pill[data-dir="back"] .nav-indicator {
  --lens-l-dur: 260ms;
  --lens-l-ease: cubic-bezier(.2,.8,.2,1);
  --lens-r-dur: 400ms;
  --lens-r-ease: cubic-bezier(.3,.7,.1,1);
}
/* Hold-expand (Telegram's tab lens: isLifted grows the lens bounds): while a
   tab is physically held the capsule stretches 6px into its neighbours and
   settles back on release over the same length transition it travels with.
   Horizontal only, and clamped at the pill's own 4px inset: growing vertically
   or past the inset would poke the white capsule outside the glass pill.
   glassPress() in app.js adds .lens-hold; it never does in no-blur or reduced
   motion (a dense lens is one rigid slide). */
.nav-pill.lens-hold .nav-indicator {
  left: max(4px, calc(var(--nav-l) - 6px));
  right: max(4px, calc(var(--nav-r) - 6px));
}
.nav-indicator.is-visible { opacity: 1; visibility: visible; --lens-vis-delay: 0s; }
.nav-indicator.nav-no-motion { transition: none; }
/* The capsule becomes glass only while it TRAVELS: JS holds .moving for the
   transition and this keyframe fades the rim and a light-catching filter in
   over the first third and out by the end, so rest -> moving -> rest never
   snaps by a frame (both ends equal the rest look). No standing filter:
   nothing to pay while the lens sits still. Re-taps only extend the class
   (adding an existing class does not restart an animation). */
@keyframes nav-lens-glass {
  0% {
    box-shadow:
      inset 0 0 0 .5px rgb(255 255 255 / 0%),
      inset 1.5px 1.5px 0 -1px rgb(255 255 255 / 0%),
      inset -1.5px -1.5px 0 -1px rgb(255 255 255 / 0%),
      inset 0 -2px 3px -2px rgb(0 0 0 / 0%),
      0 2px 5px rgb(0 0 0 / 0%);
    backdrop-filter: saturate(1) brightness(1);
    -webkit-backdrop-filter: saturate(1) brightness(1);
  }
  30% {
    box-shadow: var(--chrome-lens-shadow);
    backdrop-filter: saturate(1.6) brightness(1.18);
    -webkit-backdrop-filter: saturate(1.6) brightness(1.18);
  }
  100% {
    box-shadow:
      inset 0 0 0 .5px rgb(255 255 255 / 0%),
      inset 1.5px 1.5px 0 -1px rgb(255 255 255 / 0%),
      inset -1.5px -1.5px 0 -1px rgb(255 255 255 / 0%),
      inset 0 -2px 3px -2px rgb(0 0 0 / 0%),
      0 2px 5px rgb(0 0 0 / 0%);
    backdrop-filter: saturate(1) brightness(1);
    -webkit-backdrop-filter: saturate(1) brightness(1);
  }
}
.nav-indicator.moving { animation: nav-lens-glass 420ms cubic-bezier(0.4,0,0.2,1) both; }
/* dense lens: a rigid capsule on one uniform slide, never glass - no rim, no
   light while moving, no hold stretch */
body.no-blur .nav-indicator,
body.no-blur .nav-pill[data-dir="back"] .nav-indicator {
  --lens-l-dur: 340ms;
  --lens-r-dur: 340ms;
  --lens-l-ease: cubic-bezier(.2,.8,.2,1);
  --lens-r-ease: cubic-bezier(.2,.8,.2,1);
}
body.no-blur .nav-indicator.moving { animation: none; }
/* ONE promoted layer for ALL items, never per-item. Items paint above the
   animating lens layer, so without a layer of their own WebKit re-rasters
   them during the transition and their fractional origins round 1px off
   (v5.101.2, caught frame-exact by the harness lensprobe steps). But
   per-item will-change gave every tab its OWN compositor layer, and a slow
   mobile GPU could miss one tile in the bar's first frame - a tab visibly
   "loaded in" after the bar (v5.101.5). The single group layer keeps both
   invariants: items never re-raster while the lens moves, and they can only
   ever appear all together. */
.nav-items {
  display: flex;
  flex: 1 1 auto;
  min-width: 0;
  position: relative;
  z-index: 2;
  will-change: transform;
}
.nav-item {
  flex: 0 0 var(--nav-item-w, 25%);
  min-width: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 3px;
  cursor: pointer;
  color: var(--text2);
  border: none;
  background: transparent;
  padding: 0;
  transition: color 130ms cubic-bezier(0.4,0,0.2,1);
}
.nav-glyph {
  width: 24px;
  height: 24px;
  display: grid;
  place-items: center;
}
.nav-item:focus-visible { outline: none; }
.nav-item:focus-visible .nav-label { text-decoration: underline; text-underline-offset: 2px; }
/* Three states, no icon animation: rest = filled neutral glyph dimmed once as
   a whole (solid fill, so no overlapping-stroke blobs); press = a touch bigger
   and lighter; selected = accent fill. The tint sits on the GLYPH, never on
   the lens - the lens is a neutral capsule. */
.nav-item svg {
  width: 22px;
  height: 22px;
  color: var(--text);
  opacity: 0.55;
  transform-origin: 50% 50%;
  transition:
    transform 130ms cubic-bezier(0.4,0,0.2,1),
    opacity 130ms cubic-bezier(0.4,0,0.2,1),
    color 130ms cubic-bezier(0.4,0,0.2,1);
}
.nav-item:active svg { transform: scale(1.12); opacity: 0.85; }
.nav-item:active .nav-label { color: rgba(255,255,255,0.85); }
.nav-item.active { color: var(--text); }
.nav-item.active svg { color: var(--text); opacity: 0.92; }
.nav-item.active:active svg { transform: scale(1.12); }
.nav-label {
  max-width: 100%;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
  font-size: 10px;
  font-weight: 500;
  letter-spacing: 0.1px;
  transition: color 130ms cubic-bezier(0.4,0,0.2,1);
}
/* Profile chip: the avatar in its own island of the same material. Selected =
   the chip takes the lens fill (::after overlay, opacity flip: no relayout),
   no accent ring - a stroke is never a state here. */
.nav-ava {
  position: relative;
  flex: 0 0 var(--nav-h);
  width: var(--nav-h);
  height: var(--nav-h);
  border-radius: var(--r-pill);
  padding: 0;
  color: var(--text);
  cursor: pointer;
  display: grid;
  place-items: center;
  isolation: isolate;
  font-family: inherit;
  transition: scale 120ms ease-out;
}
.nav-ava::after {
  content: '';
  position: absolute;
  /* 4px inset = the 56px lens of the tab pill: leaving the group is ONE
     crossfade between two capsules of the same size, not a size change */
  inset: 4px;
  border-radius: inherit;
  background: var(--chrome-lens-fill);
  opacity: 0;
  /* same length as the lens fade: leaving/entering the group is ONE crossfade */
  transition: opacity 240ms cubic-bezier(0.4,0,0.2,1);
  z-index: 1;
  pointer-events: none;
}
.nav-ava.active::after { opacity: 1; }
.nav-ava-img {
  position: relative;
  z-index: 2;
  width: 40px;
  height: 40px;
  border-radius: var(--r-pill);
  object-fit: cover;
  display: grid;
  place-items: center;
  font-size: 16px;
  font-weight: 700;
  color: #fff;
  transform-origin: 50% 50%;
  transition: transform 130ms cubic-bezier(0.4,0,0.2,1);
}
/* the CHIP lifts now (GLASS PRESS): a second grow on the avatar inside it
   double-scaled the photo. Dense mode has no lift, so the avatar keeps it. */
body.no-blur .nav-ava:active .nav-ava-img { transform: scale(1.08); }
.nav-ava:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* A 6.1-inch iPhone gives the floating island only ~358 CSS px. Preserve the
   same optical weight as the 64px regular bar instead of letting the island
   turn into a tall, heavy capsule once the screenshot is device-scaled. */
@media (max-width: 400px) {
  :root { --nav-h: 60px; }
  .nav-item { gap: 2px; }
  .nav-glyph { width: 22px; height: 22px; }
  .nav-item svg { width: 20px; height: 20px; }
  .nav-label { font-size: 9.5px; }
  .nav-ava-img { width: 38px; height: 38px; }
}
@media (prefers-reduced-motion: reduce) {
  .nav-indicator, .nav-item svg, .nav-ava-img, .nav-ava::after { transition: none; }
}
body.kb-open .bottom-nav,
body.kb-open .nav-fade { display: none; }
/* Browser: lift fixed elements above keyboard */
body.kb-open .input-area {
  bottom: calc(6px + var(--kb-h, 0px));
}
body.kb-open .input-fade {
  bottom: var(--kb-h, 0px);
}
body.kb-open .messages-area::after {
  height: calc(100px + var(--kb-h, 0px));
}

/* ═══════════════════════════════════════════
   ISLAND — glass card container
═══════════════════════════════════════════ */
.island {
  margin: 0 var(--pad);
  background: var(--glass);
  border-radius: var(--r-card);
  overflow: hidden;
}

/* ═══════════════════════════════════════════
   SECTION LABEL
═══════════════════════════════════════════ */
/* Grouped-list header (iOS 26 content core): 13/18 600 uppercase, the one
   uppercase exception in the product. Applied in CSS so no localized string is
   duplicated in caps. A trailing VALUE on the same line (a counter, "Все 48")
   stays sentence case - see .ci-seeall. .section-lbl.sentence opts a long,
   sentence-shaped header out. */
.section-lbl {
  padding: 24px var(--pad) 8px;
  font-size: 13px;
  line-height: 18px;
  font-weight: 600;
  letter-spacing: 0.4px;
  text-transform: uppercase;
  color: var(--text2);
}
.section-lbl.sentence { text-transform: none; letter-spacing: 0; }

/* Group header: a tier above .section-lbl, clusters related settings
   (AI / Appearance / Account). Sits below .large-title, above .section-lbl. */
.settings-group {
  padding: 30px var(--pad) 2px;
  font-size: 20px;
  font-weight: 700;
  letter-spacing: -0.3px;
  color: var(--text);
}
/* tighten the gap to the group's first child so the header reads as attached */
.settings-group + .section-lbl { padding-top: 8px; }
.settings-group + .island { margin-top: 12px; }

/* ═══════════════════════════════════════════
   ROW — settings / list item inside island
═══════════════════════════════════════════ */
.settings-row {
  display: flex;
  align-items: center;
  padding: 13px var(--pad);
  gap: 12px;
  cursor: pointer;
  min-height: 48px;
  position: relative;
  transition: background 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* WHOLE pixels, never 0.5: a sub-pixel box is resolved against the device
   grid, and when a row's bottom edge does not land on a whole device pixel the
   snapped rect comes out zero-high and the engine paints NOTHING - not a
   fainter line, nothing at all. Measured over 6 rows of 52.25px: Firefox drew
   3 of 6 at 0.5px (0 of 6 when the rows were whole pixels), 6 of 6 at 1px;
   Chromium kept them at 1.25x-3x either way. Same failure mode the glass rim
   already documents ("sub-pixel layers vanish at some browser zooms"). The
   hairline look comes from --core-separator's 10% alpha, not from geometry:
   if 1px ever reads heavy on a 3x phone, lower the ALPHA, not the height. */
.settings-row::after {
  content: '';
  position: absolute;
  left: var(--pad);
  right: 0;
  bottom: 0;
  height: 1px;
  background: var(--core-separator);
}
.settings-row:last-child::after { display: none; }
.settings-row:active { background: rgba(255,255,255,0.04); }
@media (hover: hover) {
  .settings-row:hover { background: rgba(255,255,255,0.03); }
}
.settings-row:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -3px;
}

.settings-label {
  font-size: 16px;
  font-weight: 400;
  color: var(--text);
  flex: 1;
}
.settings-label-sub {
  font-size: 13px;
  color: var(--text2);
  margin-top: 2px;
}
/* Кегль равен label (16px), а разводятся они ЦВЕТОМ - как в iOS. При 15px
   капители label и value разной высоты, и пара не читается как одна строка
   (базовые линии при этом совпадают: замер 0.00px, дело именно в кегле). */
.settings-val {
  font-size: 16px;
  color: var(--text2);
  margin-right: 2px;
  /* пользовательские значения (имя чата, имя комнаты) бывают длиннее строки:
     режем в одну строку, а не разносим ряд на два этажа */
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* flex, not inline: an inline svg sits on the TEXT BASELINE and rides ~3px
   above the row's optical center (owner-caught on the wallpaper row) */
.settings-chev { color: var(--text3); flex-shrink: 0; display: flex; align-items: center; }
.settings-chev svg { width: 16px; height: 16px; }

/* ═══════════════════════════════════════════
   ICON TILE — rounded-square leading icon for list rows
═══════════════════════════════════════════ */
/* M3 variant: bare 24dp leading icon, no container.
   iOS variant (tiles) kept for reference:
   width/height 30px; border-radius 9px; background var(--accent-soft);
   color var(--accent-text); svg 17px; ::after left +42px */
.icon-tile {
  width: 24px;
  height: 24px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--text);
  flex-shrink: 0;
}
/* solid stroke + element opacity: path overlaps composite once (no alpha stacking) */
.icon-tile svg { width: 22px; height: 22px; opacity: 0.55; }
.icon-tile.solid svg { opacity: 1; }
.settings-row.has-tile::after { left: calc(var(--pad) + 36px); }

/* ═══════════════════════════════════════════
   SEG GROUP — M3 connected button group.
   Selected segment fills tonally and morphs to a full pill.
═══════════════════════════════════════════ */
/* This is the VALUE picker (rating, tier, payment method, a tri-state
   preference) for 2-4 short options. A control that switches WHAT IS SHOWN
   below it is a .view-strip instead - search "VIEW STRIP" in this file.
   A value with MORE than 4 options is not a seg-group: image orientation
   outgrew it at five and became the .orient-tiles row (v5.229). */
.seg-group { display: flex; gap: 3px; }
.seg-btn {
  flex: 1;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  /* min-width:0 is what makes the segments EQUAL: flex:1 alone still refuses
     to shrink a segment below its label, so one long localized string quietly
     takes the room from its neighbours. Both systems size segments equally by
     default (Apple: "Within a segmented control, all segments are usually
     equal in width"; M3's rows weight every child alike), and both answer a
     too-long label with shorter copy - neither has a truncation rule. So the
     `overflow` below is a containment guard, not a plan: labels must FIT.
     Budget at 320px: (container - gaps)/N - 2*padding. */
  min-width: 0;
  overflow: hidden;
  padding: 0 10px;
  /* fixed height so the pill-end radius below can be exactly half of it; a
     999px pill on the outer corners would otherwise make the browser scale ALL
     radii down to fit the vertical edge, crushing the inner 10px to ~0 */
  height: 44px;
  font-size: 14px;
  font-weight: 500;
  font-family: inherit;
  color: var(--text2);
  background: var(--glass);
  border: none;
  border-radius: 10px;
  cursor: pointer;
  white-space: nowrap;
  transition: background 130ms cubic-bezier(0.4,0,0.2,1),
              color 130ms cubic-bezier(0.4,0,0.2,1),
              border-radius 130ms cubic-bezier(0.4,0,0.2,1);
}
.seg-btn:first-child { border-top-left-radius: 22px; border-bottom-left-radius: 22px; }
/* an island whose only content is a seg-group + its one-line hint (NSFW
   level in Settings, room create, room settings) */
.island.seg-island { padding: 14px 16px; }
.seg-hint { font-size: 12px; color: var(--text3); margin-top: 8px; }
.seg-btn:last-child { border-top-right-radius: 22px; border-bottom-right-radius: 22px; }
.seg-btn:active:not(.active) { background: var(--glass-bold); }
@media (hover: hover) {
  .seg-btn:hover:not(.active):not(:disabled) { background: var(--glass-bold); }
}
.seg-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.seg-btn.active {
  background: var(--accent-soft);
  color: var(--accent-text);
  border-radius: 22px;
  font-weight: 600;
}
/* floor-locked option (publish/edit tag pickers): visibly unpickable */
.seg-btn:disabled { opacity: 0.3; pointer-events: none; }
/* Narrow phones (320-380): in the character form an equal segment is only
   ~83px there, and at 14px a RU label like "Не задано" wants 72 of the 59 that
   leaves. One step of type + tighter padding buys the room (67 of 71) without
   shrinking the control or wrapping the label (M3: "Don't allow segments to
   wrap onto a new line"). Measured by the `segfit` harness step at 320. */
@media (max-width: 380px) {
  .seg-btn { font-size: 13px; padding: 0 6px; }
}

/* ═══════════════════════════════════════════
   SELECTION CHECK — trailing single-select mark for list rows (.m3-radio)
═══════════════════════════════════════════ */
/* iOS 26 lists mark the chosen row with a trailing accent CHECKMARK, not a
   radio dot. The slot is always reserved (the row never reflows on pick); the
   unselected state hides the mark with opacity + scale. The class name stays
   .m3-radio because markup on both surfaces already emits it. */
.m3-radio {
  width: 20px;
  height: 22px;
  border: 0;
  border-radius: 0;
  flex-shrink: 0;
  position: relative;
}
.m3-radio::before {
  content: '';
  position: absolute;
  left: 2px;
  top: 6px;
  width: 15px;
  height: 8px;
  border-left: 2.2px solid var(--accent-text);
  border-bottom: 2.2px solid var(--accent-text);
  border-radius: 1px;
  transform-origin: left bottom;
  transform: rotate(-45deg) scale(0.6);
  opacity: 0;
  transition: opacity 130ms cubic-bezier(0.4,0,0.2,1),
              transform 130ms cubic-bezier(0.05,0.7,0.1,1);
}
.m3-radio.on::before { opacity: 1; transform: rotate(-45deg) scale(0.92); }

/* ═══════════════════════════════════════════
   FIELD — tonal text field/area, the canonical form input.
   Same grammar as the search field: uniform radius, no stroke,
   rest --glass, focus = one tone step up + accent caret.
═══════════════════════════════════════════ */
.m3-field {
  width: 100%;
  padding: 12px 14px;
  font-size: 14px;
  line-height: 1.45;
  font-family: inherit;
  color: var(--text);
  background: var(--glass);
  border: none;
  border-radius: var(--r-btn);
  outline: none;
  resize: none;
  caret-color: var(--accent);
  transition: background-color 130ms cubic-bezier(0.4,0,0.2,1);
}
.m3-field:focus { background: var(--glass-bold); }
.m3-field::placeholder { color: var(--text3); }
/* Inside an island the tonal token melts into the island's own --glass fill and
   the field is invisible until focused (memory textarea, v5.104). Same escape as
   the profile-card wells: step up with white-alpha instead of the tone token. */
.island .m3-field { background: rgba(255,255,255,0.07); }
.island .m3-field:focus { background: rgba(255,255,255,0.11); }
/* seg-group inside an island: --glass on --glass vanishes, same fix as m3-field */
.island .seg-btn:not(.active) { background: rgba(255,255,255,0.07); }
.island .seg-btn:active:not(.active) { background: rgba(255,255,255,0.11); }

/* ═══════════════════════════════════════════
   SWITCH — the ONE control, both surfaces
═══════════════════════════════════════════ */
/* iOS 26 geometry: the switch got flatter and wider than the pre-26 51x31 with
   its round knob - track 2.27:1, the knob a wide PILL 0.8 of the track height,
   inset ~0.1. So 64x28 track, 37x22.5 knob inset 2.75, 21.5px travel, accent
   track on, and nothing grows or lights up under the finger - the flip itself
   is the feedback, exactly as in iOS Settings.
   This block is canonical for the app AND the community forms; community.css
   loads later and must not redefine it. */
.switch-row {
  display: flex;
  align-items: center;
  gap: 12px;
  background: var(--glass-bold);
  border: none;
  border-radius: var(--r-btn);
  padding: 14px 16px;
  min-height: 44px;
  /* The SWITCH is the control; the row is not. Tapping a label in iOS Settings
     does nothing, and lighting the whole row while the switch flips is the M3
     list-item metaphor this dialect left behind. So the row leaves hit testing
     altogether and only the track opts back in - pointer-events changes hit
     testing, not event flow, so the click still bubbles from the track up to
     this <label> and activates the input the way it always did. */
  pointer-events: none;
}
.switch-text {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}
.switch-label { font-size: 15px; font-weight: 500; color: var(--text); }
.switch-desc { font-size: 13px; color: var(--text3); line-height: 1.3; }
.switch-input {
  position: absolute;
  opacity: 0;
  width: 0;
  height: 0;
  pointer-events: none;
}
.switch-track {
  position: relative;
  width: 64px;
  height: 28px;
  flex-shrink: 0;
  border: 0;
  border-radius: 14px;
  pointer-events: auto;
  cursor: pointer;
  /* white alpha, not a tone token: the off track sits on an island whose fill
     is only 4% away from --glass-bold and would swallow it */
  background: rgba(255,255,255,0.16);
  transition: background 200ms cubic-bezier(0.4,0,0.2,1);
}
.switch-thumb {
  position: absolute;
  top: 2.75px;
  left: 2.75px;
  width: 37px;
  height: 22.5px;
  border-radius: 11.25px;
  background: #fff;
  transform: none;
  transition: transform 200ms cubic-bezier(0.4,0,0.2,1);
}
/* the 28px track alone is under the 44px floor, so it carries its target as a
   pseudo instead of growing: 72 wide by 44 tall around it, no pixel moved */
.switch-track::before {
  content: '';
  position: absolute;
  inset: -8px -4px;
}
.switch-input:checked + .switch-track { background: var(--accent); }
.switch-input:checked + .switch-track .switch-thumb { transform: translateX(21.5px); }
.switch-input:disabled + .switch-track { opacity: 0.32; cursor: default; }
/* Режим, недоступный в этом контексте (NSFW у несовершеннолетнего
   персонажа): ряд остаётся на месте и в той же геометрии, гаснет сам
   контрол и его название. Подпись держит нормальный тон - в ней
   лежит причина, и её надо прочитать. */
.switch-row.is-unavailable .switch-label { opacity: 0.4; }
.switch-input:focus-visible + .switch-track {
  outline: 2px solid var(--accent-text);
  outline-offset: 2px;
}

/* Grouped switch row: inside an island the row drops its own card and becomes
   a full-bleed island row - a filled box inside a filled box is the nested-card
   habit this dialect drops. */
.island .switch-row {
  background: transparent;
  border-radius: 0;
  padding: 10px var(--pad);
  min-height: 48px;
  position: relative;
}
/* The hairline sits on the row's TOP, not its bottom: rows in these islands are
   conditionally hidden (the publish switch hides the two rows under it), and a
   bottom line would stay hanging under the last VISIBLE row. A top line on a
   hidden row simply never paints. */
.island > .switch-row:not(:first-child)::before,
.island > .switch-row:has(+ .settings-row)::after {
  content: '';
  position: absolute;
  left: var(--pad);
  right: 0;
  height: 1px;
  background: var(--core-separator);
}
.island > .switch-row:not(:first-child)::before { top: 0; }
.island > .switch-row:has(+ .settings-row)::after { bottom: 0; }
/* a settings-row above a switch row already gets the switch's top line */
.island > .settings-row:has(+ .switch-row)::after { display: none; }

/* ═══════════════════════════════════════════
   AVATAR
═══════════════════════════════════════════ */
.avatar {
  border-radius: 50%;
  object-fit: cover;
  flex-shrink: 0;
}
/* Ступень `char` (ADR-0034) с v5.230.4 не кропится на сервере: файл несёт весь
   снимок, короткая сторона равна ступени. Квадрат режет место показа, и правило
   висит на самом адресе, чтобы ни один потребитель (карточка, аватарки чатов и
   уведомлений, обложка подборки, будущие) не показал портрет сплющенным.
   Якорь вверху повторяет прежний серверный кроп (`_square`): лицо почти всегда
   там. Карусель и лайтбокс берут `charfull` и под правило не попадают. */
img[src*="/d/char/"] {
  object-fit: cover;
  object-position: 50% 0;
}
.avatar-ph {
  display: flex;
  align-items: center;
  justify-content: center;
  color: rgba(255,255,255,0.9);
  font-weight: 700;
  flex-shrink: 0;
  border-radius: 50%;
}
.avatar-ph.sq, .avatar.sq { border-radius: var(--r-btn); }
/* Аватар чата с несколькими персонажами (web/static/rosterava.js): тот же
   круг, разрезанный швами на ячейки активных участников. Шов прозрачный -
   сквозь него виден фон строки, выделения или стекла. Ячейка тональная с
   первого кадра, фото проявляются вместе (класс ready). */
.roster-ava {
  display: grid;
  grid-template-columns: 1fr 1fr;
  grid-template-rows: 1fr 1fr;
  border-radius: 50%;
  overflow: hidden;
  flex-shrink: 0;
}
.roster-ava.fill { border-radius: 0; }
.roster-ava-cell {
  position: relative;
  min-width: 0;
  min-height: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
  background: var(--glass-bold);
  color: rgba(255,255,255,0.9);
  font-weight: 700;
  line-height: 1;
}
.roster-ava-cell.is-more { color: var(--text2); }
/* Каждая ячейка показывает фото в масштабе ЦЕЛОГО аватара (коробка
   --ra-w x --ra-h по центру ячейки, верх портрета): лица в коллаже того же
   размера, что у соседней строки с одним персонажем, а половина или четверть
   просто режет лишнее. Обычный cover по ячейке сжимал портрет в половине до
   фигуры в полный рост с лицом в 8px. Слой фото в информации о чате задаёт
   коробку своим размером (102vw на высоту слоя). */
.roster-ava-cell > img {
  position: absolute;
  top: 0;
  left: 50%;
  width: var(--ra-w, 100%);
  height: var(--ra-h, 100%);
  max-width: none;
  transform: translateX(-50%);
  object-fit: cover;
  object-position: 50% 0;
  opacity: 0;
  -webkit-user-drag: none;
  user-select: none;
}
.roster-ava.ready .roster-ava-cell > img { opacity: 1; }
/* четверть круга видит только верх портрета: окно спускается к лицу */
.roster-ava.n3 > .roster-ava-cell:not(:first-child) > img,
.roster-ava.n4 > .roster-ava-cell > img { top: calc(var(--ra-h, 100%) * -0.12); }
.roster-ava.n1 > .roster-ava-cell { grid-column: 1 / 3; grid-row: 1 / 3; }
.roster-ava.n2 > .roster-ava-cell { grid-row: 1 / 3; }
.roster-ava.n3 > .roster-ava-cell:first-child { grid-row: 1 / 3; }

/* ═══════════════════════════════════════════
   REFERRALS VIEW
═══════════════════════════════════════════ */
.ref-summary {
  display: flex;
  align-items: baseline;
  gap: 8px;
  padding: 2px var(--pad) 16px;
}
.ref-summary-num {
  font-size: 34px;
  font-weight: 800;
  letter-spacing: -0.5px;
  color: var(--text);
  font-variant-numeric: tabular-nums;
}
.ref-summary-lbl {
  font-size: 15px;
  color: var(--text2);
}
/* Wrapper animates height in/out (grid-rows 0fr->1fr) instead of the line
   snapping into existence once bonusStatus resolves the referral bonus part. */
.ref-summary-sub-wrap {
  display: grid;
  grid-template-rows: 0fr;
  margin-top: -14px;
  transition: grid-template-rows 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
.ref-summary-sub-wrap.open { grid-template-rows: 1fr; }
.ref-summary-sub {
  min-height: 0;
  overflow: hidden;
  padding: 0 var(--pad);
  font-size: 13px;
  color: var(--text2);
}
/* padding-bottom only in the open state - collapsed padding would still
   render its own height even with min-height:0, leaking a sliver of text */
.ref-summary-sub-wrap.open .ref-summary-sub { padding-bottom: 16px; }
.ref-row {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 11px var(--pad);
  position: relative;
}
.ref-row::after {
  content: '';
  position: absolute;
  left: calc(var(--pad) + 54px);
  right: 0;
  bottom: 0;
  height: 1px;
  background: var(--sep);
}
.ref-row:last-child::after { display: none; }
.ref-ava {
  width: 42px;
  height: 42px;
  border-radius: 50%;
  object-fit: cover;
  font-size: 16px;
  flex-shrink: 0;
}
.ref-meta { flex: 1; min-width: 0; }
.ref-name {
  font-size: 15px;
  font-weight: 500;
  color: var(--text);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.ref-date {
  font-size: 12px;
  color: var(--text3);
  margin-top: 1px;
}
.ref-bonus {
  display: flex;
  align-items: baseline;
  gap: 1px;
  flex-shrink: 0;
  padding: 4px 10px;
  border-radius: var(--r-pill);
  background: var(--accent-soft);
  color: var(--accent-text);
  font-size: 15px;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}
.ref-bonus-day {
  font-size: 11px;
  font-weight: 500;
  opacity: 0.75;
}

/* ═══════════════════════════════════════════
   PROFILE VIEW
═══════════════════════════════════════════ */
.profile-scroll {
  flex: 1;
  min-height: 0;
  overflow-y: scroll;
  -webkit-overflow-scrolling: touch;
  overscroll-behavior: contain;
  scrollbar-width: none;
  /* former toolbar zone stays clear (see .art-screen note) */
  padding-top: calc(var(--header-h) + var(--tg-top));
  padding-bottom: calc(var(--nav-h) + 32px + var(--safe-bottom));
}
.profile-scroll::-webkit-scrollbar { display: none; }
/* Profile dashboard wrappers: invisible on mobile (single column, unchanged),
   become a 2-column grid on desktop (see the html.layout-desktop block). */
.profile-body { display: block; }
.pf-col-left, .pf-col-right { display: contents; }

.profile-hero {
  position: relative;
  overflow: hidden;
  border-radius: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
}
/* Poster: the photo is a 1:1 SQUARE like the community profile card (owner,
   2026-08-19: same style as the community profile, not a freely scaling banner):
   a grid with a ::before ratio spacer, the content sits in the same cell and
   is centred; the square only grows past 1:1 when the identity card's content
   needs more room (a long bio shows in full, never clipped). Inset by --pad like
   the cards below it (a desktop override drops the margin for the 2-col grid). */
.profile-hero.has-backdrop {
  margin: 0 var(--pad);
  min-height: 0;
  border-radius: var(--r-island);
  display: grid;
  grid-template-columns: 100%;
}
.profile-hero.has-backdrop::before {
  content: '';
  grid-area: 1 / 1;
  width: 0;
  padding-top: 100%;
}
.profile-hero.has-backdrop .profile-backdrop-media,
.profile-hero.has-backdrop .profile-backdrop-scrim { grid-area: 1 / 1; }
.profile-backdrop-media {
  position: absolute;
  inset: 0;
  z-index: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  filter: brightness(0.92) saturate(1.02);
}
.profile-backdrop-scrim {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  background:
    linear-gradient(180deg, rgba(0,0,0,0.26) 0%, rgba(0,0,0,0) 22%),
    linear-gradient(180deg, rgba(0,0,0,0) 34%, rgba(0,0,0,0.34) 62%, rgba(0,0,0,0.72) 100%);
}
.profile-hero-content {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  width: 100%;
  padding: 24px var(--pad) 16px;
}
.profile-hero.has-backdrop .profile-hero-content {
  grid-area: 1 / 1;
  justify-content: center;
  gap: 12px;
  padding: 18px 16px 18px;
}
/* the identity CARD over the photo = the community profile card's glass (its
   recipe verbatim, incl. the dense twin): name + badges, the tier chip and the
   bio live on it, so nothing is written straight on the photo anymore. Without
   a photo the card is transparent and the hero keeps its plain column. */
.profile-hero-card {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  width: 100%;
}
.profile-hero.has-backdrop .profile-hero-card {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  width: min(100%, clamp(300px, 86vw, 360px));
  margin: 0 auto;
  padding: clamp(11px, 3.3vw, 14px) clamp(14px, 4.4vw, 18px) clamp(12px, 3.6vw, 15px);
  border-radius: var(--r-island);
  /* CLEAR-readable: the card stands on guaranteed media (the banner) */
  background: var(--chrome-glass-fill-media-readable);
  backdrop-filter: var(--chrome-glass-filter-media-readable);
  -webkit-backdrop-filter: var(--chrome-glass-filter-media-readable);
  box-shadow: var(--chrome-glass-shadow);
}
body.no-blur .profile-hero.has-backdrop .profile-hero-card {
  background: var(--glass);
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  box-shadow: 0 1px 2px rgb(0 0 0 / 25%), 0 12px 28px rgb(0 0 0 / 26%);
}
.profile-hero.has-backdrop .profile-hero-bio {
  -webkit-line-clamp: unset;
  display: block;
  overflow: visible;
}
.profile-avatar-frame {
  position: relative;
}
/* Decorative avatar frame (animated APNG). Sits over the avatar at 1.2x so it
   overhangs the edges; the transparent centre shows the avatar through it. */
.avatar-frame-ov {
  position: absolute;
  top: -10%;
  left: -10%;
  width: 120%;
  height: 120%;
  pointer-events: none;
  z-index: 3;
  object-fit: contain;
}
.profile-hero.has-backdrop .avatar,
.profile-hero.has-backdrop .avatar-ph {
  border: 3px solid var(--bg-tint);
  box-shadow: 0 5px 18px rgba(0,0,0,0.52);
}
.profile-name {
  /* ONE rule with the community profile card: name and badges on one flex line,
     centred on each other, 22/700, badges 18 with a 6px gap (owner, 2026-08-19:
     the two profiles laid the badges out by different rules) */
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  max-width: 100%;
  overflow-wrap: anywhere;
  font-size: 22px;
  line-height: 1.16;
  font-weight: 700;
  letter-spacing: -0.5px;
}
.profile-name .user-badges { margin-left: 0; }
.profile-username {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 5px;
  /* The handle is the name's SUBTITLE, so it sits ~4px under it and ~13px
     above the tier chip - the 44px box put it 13/17, floating equidistant
     between the two. A copy affordance cannot buy its full target here: the
     badges' own target ends 1px under the name line, and anything taller
     would eat into it. 34px with the text 8px in is the honest compromise.
     Same rhythm as .p-username-copy on the community card. */
  min-height: 34px;
  max-width: 100%;
  margin: -12px 0 -7px;
  padding: 4px 8px;
  border: 0;
  border-radius: var(--r-pill);
  background: transparent;
  color: var(--text2);
  font: inherit;
  font-size: 14px;
  cursor: pointer;
  transition: color 130ms cubic-bezier(0.4, 0, 0.2, 1), opacity 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
.profile-username:active { opacity: 0.55; }
.profile-username:focus-visible {
  outline: none;
  text-decoration: underline;
  text-decoration-color: var(--accent);
  text-decoration-thickness: 2px;
  text-underline-offset: 3px;
}
@media (hover: hover) {
  .profile-username:hover { color: var(--text); }
  .profile-username:hover .profile-username-ic { opacity: 0.7; }
}
.profile-username > span:first-child {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.profile-username-ic {
  display: inline-flex;
  width: 14px;
  height: 14px;
  color: var(--text);
  opacity: 0.48;
  transition: opacity 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
.profile-username-ic svg { display: block; width: 100%; height: 100%; }
/* Значки рядом с именем. Сами иконки и окно с описанием живут в community.css /
   community.js - это ОДИН набор на оба профиля, здесь только курсор и область
   нажатия: 18px иконка в строке 22px это маленькая цель, поэтому коробка растёт
   отступом, а отрицательное поле возвращает исходный зазор. */
.profile-name .user-badges { cursor: pointer; }
.profile-name .badge-icon {
  box-sizing: content-box;
  padding: 5px 3px;
  margin: -5px -3px;
}
.profile-hero.has-backdrop .profile-name { color: var(--text); }
.profile-hero.has-backdrop .profile-hero-bio { color: var(--text2); }
.profile-hero-bio {
  font-size: 13px;
  color: var(--text2);
  line-height: 1.4;
  max-width: 280px;
  display: -webkit-box;
  -webkit-line-clamp: 4;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.profile-edit-trigger-ic {
  display: inline-flex;
  width: 12px;
  height: 12px;
  margin-right: 4px;
  vertical-align: -1px;
}
.profile-edit-trigger-ic svg {
  width: 100%;
  height: 100%;
}
/* the tier chip sits ON the card now, so it keeps its ordinary tonal look
   (glass over glass would be a chip floating on a floating island) */
.tier-chip {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  /* no margin of its own: the card is a flex column and OWNS the spacing.
     The 7px lived here from before the card existed and only applied without
     a banner (the has-backdrop rule cancelled it), so the same profile had two
     different gaps under the handle depending on whether a photo was set. */
  /* main's size: 24px tag under the name, not a 36px chip (the pressables
     sweep had grown it; owner, 2026-08-19). It is a status tag with a tap
     target of its own, the pill radius is the only thing the sweep keeps. */
  padding: 6px 13px;
  font-size: 12px;
  font-weight: 600;
  line-height: 1;
  font-family: inherit;
  text-transform: capitalize;
  color: var(--text2);
  background: var(--glass-bold);
  border: none;
  border-radius: var(--r-pill);
  cursor: pointer;
  transition: background 130ms cubic-bezier(0.4, 0, 0.2, 1), transform 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
@media (hover: hover) {
  .tier-chip:hover { background: var(--glass-border); }
  .tier-chip-paid:hover { background: color-mix(in srgb, var(--accent-text) 14%, var(--accent-soft)); }
}
.tier-chip:active { transform: scale(0.94); }
.tier-chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.tier-chip-paid {
  color: var(--accent-text);
  background: var(--accent-soft);
}
.tier-chip-exp {
  font-weight: 500;
  text-transform: none;
  opacity: 0.75;
}
.tier-ic {
  display: flex;
  width: 13px;
  height: 13px;
  flex: none;
}
.tier-ic svg {
  width: 100%;
  height: 100%;
}
.tier-ic-lite { color: #e8dcb5; filter: drop-shadow(0 0 5px rgba(232, 220, 181, 0.2)); }
.tier-ic-premium { color: #ffc107; filter: drop-shadow(0 0 6px rgba(255, 193, 7, 0.4)); }
.tier-ic-ultimate { color: #00e5ff; filter: drop-shadow(0 0 7px rgba(0, 229, 255, 0.5)); }

/* Profile stats — M3 filled metric tiles, each its own tonal surface
   (no hairline dividers: every metric gets equal visual weight) */
.stat-strip {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  padding: 14px 0 12px;
}
/* three figures in ONE island, hairlines between cells (the compact strip
   the owner liked on the old branch); read-only numbers never look like rows */
.stat-cell {
  position: relative;
  min-width: 0;
  padding: 0 8px;
  text-align: center;
}
.stat-cell + .stat-cell::before {
  content: '';
  position: absolute;
  left: 0;
  top: 4px;
  bottom: 4px;
  width: 0.5px;
  background: var(--core-separator);
}
.stat-cell-val {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 4px;
  position: relative;
  font-size: 22px;
  line-height: 28px;
  font-weight: 700;
  letter-spacing: -0.02em;
  font-variant-numeric: tabular-nums;
  color: var(--text);
}
.stat-cell-val svg { width: 20px; height: 20px; display: block; flex-shrink: 0; }
.stat-cell-lbl {
  margin-top: 2px;
  font-size: 12px;
  line-height: 16px;
  color: var(--text2);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.stat-joined {
  font-size: 12px;
  color: var(--text3);
  margin: 12px 18px 0;
}

/* Promo code: ONE grouped field row - the island carries the fill, the field is
   transparent inside it and the action is a TRAILING TEXT button in the same
   row, the way iOS ends a field row with "Add" / "Join". A square field next to
   a separate round pill was the owner's complaint (2026-08-19). */
.promo-row {
  display: flex;
  align-items: center;
  margin: 0 16px;
  background: var(--glass);
  border-radius: var(--r-btn);
}
.promo-row .m3-field {
  flex: 1;
  min-width: 0;
  background: transparent;
  border-radius: 0;
  min-height: 48px;
}
.shop-load-error {
  min-height: min(52vh, 420px);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 10px;
  text-align: center;
}
/* trailing text action of the field row: 15/600 accent text, no container,
   colour-only states */
.promo-apply {
  flex-shrink: 0;
  align-self: stretch;
  min-height: 44px;
  background: none;
  color: var(--accent-text);
  border: none;
  border-radius: var(--r-btn);
  font-family: inherit;
  font-size: 15px;
  font-weight: 600;
  padding: 0 16px;
  cursor: pointer;
  white-space: nowrap;
  transition: opacity 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
@media (hover: hover) {
  .promo-apply:hover:not(:disabled) { opacity: 0.7; }
}
.promo-apply:active:not(:disabled) { opacity: 0.55; }
.promo-apply:disabled { opacity: 0.32; pointer-events: none; }
.promo-apply.is-pending:disabled { opacity: 1; }
.promo-apply:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }

/* ═══════════════════════════════════════════
   PROFILE POSTER — shared pf-* pieces
   (the profile EDITOR itself lives in the community
   profile modal; community.js reuses these classes)
═══════════════════════════════════════════ */
.pf-edit-ava-badge {
  position: absolute; bottom: 0; right: 0;
  width: 26px; height: 26px; border-radius: 50%;
  background: var(--accent);
  color: var(--text);
  display: flex; align-items: center; justify-content: center;
  border: 2.5px solid rgba(255,255,255,0.86);
}
.pf-edit-ava-badge svg { width: 13px; height: 13px; }

.pf-view { display: flex; flex-direction: column; gap: 12px; }
.pf-view .profile-edit-trigger { align-self: center; margin-top: 0; }
.pf-poster-tools {
  position: absolute; top: 12px; right: 12px; z-index: 4;
  display: flex; gap: 8px;
}
/* Beat .glass-chip's --text2 (semi-transparent) so multi-stroke icons don't
   compound at overlaps and read dim. */
.glass-chip.pf-poster-chip { color: var(--text); }
/* Icons are wrapped in inline spans on some surfaces; flex them so there is no
   baseline gap pushing the glyph off-centre. */
.pf-poster-chip > span,
.pf-backdrop-add > span,
.pf-backdrop-lock > span,
.pf-poster-hint > span { display: inline-flex; align-items: center; }
.pf-poster-hint {
  position: absolute; top: 14px; left: 14px; z-index: 3; pointer-events: none;
  display: inline-flex; align-items: center; gap: 7px;
  max-width: calc(100% - 116px);
  padding: 7px 12px; border-radius: var(--r-pill);
  color: var(--text); font-size: 12px; font-weight: 600; white-space: nowrap;
  overflow: hidden; text-overflow: ellipsis;
  background: var(--chrome-glass-fill-media);
  backdrop-filter: var(--chrome-glass-filter-media);
  -webkit-backdrop-filter: var(--chrome-glass-filter-media);
  box-shadow: var(--chrome-glass-shadow-thin);
}
.pf-poster-hint-ic { width: 14px; height: 14px; flex: none; }
body.no-blur .pf-poster-hint { background: var(--bg-tint); }

/* Zoom slider mirrors the theme hue-bar interaction (pointer track + thumb). */
/* avatar-crop zoom: the ONE slider grammar (4px track, accent leading fill,
   28px round thumb) - the 22px pill bar was the old M3 look. JS keeps writing
   left/width percents; only the paint changed. */
.pf-zoom {
  position: relative;
  height: 4px;
  margin: 12px 0;
  border-radius: var(--r-pill);
  /* white alpha for the same reason as .switch-track / .wp-bar */
  background: rgba(255, 255, 255, 0.16);
  cursor: pointer;
  touch-action: none;
}
/* invisible hit extension to 44px */
.pf-zoom::before {
  content: '';
  position: absolute;
  inset: -20px 0;
}
.pf-zoom-fill {
  position: absolute; left: 0; top: 0; height: 100%;
  border-radius: var(--r-pill);
  background: var(--accent);
  pointer-events: none;
}
.pf-zoom-thumb {
  position: absolute; top: 50%;
  width: 28px; height: 28px; border-radius: var(--r-pill);
  background: #fff;
  box-shadow: 0 1px 4px rgba(0,0,0,0.45);
  transform: translate(-50%, -50%);
  pointer-events: none;
}
/* secondary buttons of the profile editor (grammar: pill 48, 15/600) */
.pf-backdrop-add,
.pf-backdrop-lock {
  width: 100%;
  min-height: 48px;
  padding: 0 20px;
  border: none;
  border-radius: var(--r-pill);
  background: var(--glass-bold);
  color: var(--text);
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  font: inherit;
  font-size: 15px;
  font-weight: 600;
  cursor: pointer;
  transition: background 130ms cubic-bezier(0.4, 0, 0.2, 1), transform 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
.pf-backdrop-add svg { width: 16px; height: 16px; flex: none; color: var(--accent-text); }
@media (hover: hover) {
  .pf-backdrop-add:hover:not(:disabled),
  .pf-backdrop-lock:hover:not(:disabled) { background: var(--glass-border); }
}
.pf-backdrop-add:active:not(:disabled),
.pf-backdrop-lock:active:not(:disabled) { background: var(--glass-border); transform: scale(0.97); }
.pf-backdrop-add:disabled,
.pf-backdrop-lock:disabled { opacity: 0.32; pointer-events: none; }
.pf-backdrop-add:focus-visible,
.pf-backdrop-lock:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.pf-backdrop-lock svg {
  width: 16px;
  height: 16px;
  flex: none;
  color: var(--accent-text);
}
/* ═══════════════════════════════════════════
   BONUS SECTION
═══════════════════════════════════════════ */
.profile-section {
  /* used as wrapper if needed */
}
.profile-bonus-row {
  display: flex;
  align-items: center;
  padding: 16px var(--pad);
  gap: 14px;
}
.bonus-amount-big {
  font-size: 34px;
  font-weight: 700;
  color: var(--text);
  letter-spacing: -1px;
  font-variant-numeric: tabular-nums;
}
.bonus-amount-lbl {
  font-size: 13px;
  color: var(--text2);
  margin-top: 2px;
}

/* Countdown line: height animates in/out instead of snapping, so claiming
   the bonus (which flips this from hidden to shown) doesn't jump the layout. */
.bonus-countdown-wrap {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
.bonus-countdown-wrap.open { grid-template-rows: 1fr; }
.bonus-countdown-inner {
  min-height: 0;
  overflow: hidden;
  padding: 0 16px;
  font-size: 13px;
  color: var(--text3);
}
/* padding-bottom only in the open state - collapsed padding would still
   render its own height even with min-height:0, leaking a sliver of text */
.bonus-countdown-wrap.open .bonus-countdown-inner { padding-bottom: 14px; }

/* bonus formula: segmented stack, each row is an addend (base + sub gain + streak = claim amount) */
.bonus-brk {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 2px 12px 0;
}
.bonus-load-error,
.model-load-error,
.referrals-load-error {
  min-height: 92px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 14px;
  border-radius: var(--r-btn);
  background: var(--glass-bold);
  color: var(--text2);
  font-size: 13px;
  line-height: 1.4;
}
.bonus-load-error .btn-sm,
.model-load-error .btn-sm,
.referrals-load-error .btn-sm { min-height: 44px; flex-shrink: 0; }
/* referrals: единственный из трёх голый в .settings-screen (остальные внутри
   островков) - без своего инсета карточка шире островной сетки */
.referrals-load-error { margin: 0 var(--pad); }
.bonus-brk-row {
  display: flex;
  /* label/sub/val are three different font sizes: центрирование по высоте
     разъезжается, ровно смотрится только общая базовая линия */
  align-items: baseline;
  gap: 8px;
  padding: 11px 14px;
  background: var(--glass-bold);
  border-radius: 6px;
  transition: background 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
.bonus-brk-row:first-child { border-radius: 13px 13px 6px 6px; }
.bonus-brk-row:last-child { border-radius: 6px 6px 13px 13px; }
.bonus-brk-row.is-nav { cursor: pointer; }
.bonus-brk-row.is-nav:active { background: var(--glass-border); }
.bonus-brk-lbl {
  font-size: 14px;
  font-weight: 500;
  color: var(--text);
  white-space: nowrap;
}
.bonus-brk-sub {
  flex: 1;
  min-width: 0;
  font-size: 12px;
  color: var(--text3);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.bonus-brk-val {
  font-size: 15px;
  font-weight: 600;
  color: var(--text);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.bonus-brk-val.dim { color: var(--text3); }
.bonus-brk-chev { display: flex; align-self: center; color: var(--text3); margin-left: -2px; }
.bonus-brk-chev svg { width: 12px; height: 12px; }
.bonus-brk-cap {
  padding: 8px 16px 12px;
  font-size: 12px;
  color: var(--text3);
  line-height: 1.4;
}

.credits-block {
  padding: 24px 20px;
  text-align: center;
}
.credits-num {
  font-size: 52px;
  font-weight: 700;
  color: var(--text);
  line-height: 1;
  letter-spacing: -2px;
}
.credits-lbl {
  font-size: 14px;
  color: var(--text2);
  margin-top: 6px;
}
/* ═══════════════════════════════════════════
   BUTTONS
═══════════════════════════════════════════ */
.countdown {
  text-align: center;
  font-size: 14px;
  color: var(--text2);
  padding: 10px 0 0;
}
.countdown strong { color: var(--text2); font-variant-numeric: tabular-nums; }

/* Text button family (pressables grammar v5.214): pill, 48px, 15/600; primary =
   solid accent (one per screen), secondary = tonal --glass-bold, destructive =
   opaque destructive mix. States: hover under (hover:hover) = next tone, press
   scale .97, disabled .32, focus-visible = accent outline. inline-flex so an
   <a> in the family centres its label the same way a <button> does. */
.btn-primary {
  width: 100%;
  min-height: 48px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  vertical-align: top;
  box-sizing: border-box;
  background: var(--accent);
  border: none;
  border-radius: var(--r-pill);
  padding: 12px 20px;
  color: #fff;
  font-size: 15px;
  font-weight: 600;
  line-height: 1.25;
  cursor: pointer;
  font-family: inherit;
  text-decoration: none;
  -webkit-tap-highlight-color: transparent;
  transition: transform 130ms cubic-bezier(0.4, 0, 0.2, 1), background 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
@media (hover: hover) {
  .btn-primary:hover:not(:disabled) { background: color-mix(in srgb, var(--accent) 88%, #fff); }
}
.btn-primary:disabled { opacity: 0.32; pointer-events: none; }
.btn-primary:active:not(:disabled) { transform: scale(0.97); }
.btn-primary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.btn-primary.is-pending {
  position: relative;
  opacity: 1;
  pointer-events: none;
}
.btn-primary.is-pending > span { visibility: hidden; }
.btn-primary.is-pending::after {
  content: '';
  position: absolute;
  left: 50%;
  top: 50%;
  width: 18px;
  height: 18px;
  margin: -9px 0 0 -9px;
  border: 2px solid rgb(255 255 255 / 35%);
  border-top-color: #fff;
  border-radius: 50%;
  animation: actionPendingSpin 700ms linear infinite;
}
@media (prefers-reduced-motion: reduce) {
  .btn-primary.is-pending::after { animation: none; }
}

.btn-secondary {
  width: 100%;
  min-height: 48px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  vertical-align: top;
  box-sizing: border-box;
  background: var(--glass-bold);
  border: none;
  border-radius: var(--r-pill);
  padding: 12px 20px;
  color: var(--text);
  font-size: 15px;
  font-weight: 600;
  line-height: 1.25;
  cursor: pointer;
  font-family: inherit;
  text-decoration: none;
  margin-top: 8px;
  -webkit-tap-highlight-color: transparent;
  transition: transform 130ms cubic-bezier(0.4, 0, 0.2, 1), background 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
@media (hover: hover) {
  .btn-secondary:hover:not(:disabled) { background: var(--glass-border); }
}
.btn-secondary:active:not(:disabled) { background: var(--glass-border); transform: scale(0.97); }
.btn-secondary:focus-visible,
.btn-danger:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.btn-secondary:disabled,
.btn-danger:disabled { opacity: 0.32; pointer-events: none; }

/* Tappable option row (card), e.g. the personal-vs-community create choice */
.choice-row {
  width: 100%;
  display: block;
  text-align: left;
  background: var(--glass);
  border: none;
  border-radius: var(--r-btn);
  padding: 14px 16px;
  margin-bottom: 10px;
  color: var(--text);
  cursor: pointer;
  font-family: inherit;
  transition: transform 130ms cubic-bezier(0.4,0,0.2,1), background 130ms cubic-bezier(0.4,0,0.2,1);
}
@media (hover: hover) {
  .choice-row:hover { background: var(--glass-bold); }
}
.choice-row:active { transform: scale(0.98); background: var(--glass-bold); }
.choice-row:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.choice-row-title { font-size: 15px; font-weight: 600; }
.choice-row-desc { font-size: 13px; color: var(--text2); margin-top: 3px; line-height: 1.4; }

/* destructive = tonal opaque destructive mix (grammar: ordinary destructive) */
.btn-danger {
  width: 100%;
  min-height: 48px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  vertical-align: top;
  box-sizing: border-box;
  background: color-mix(in srgb, var(--destructive) 12%, var(--glass));
  border: none;
  border-radius: var(--r-pill);
  padding: 12px 20px;
  color: var(--destructive);
  font-size: 15px;
  font-weight: 600;
  line-height: 1.25;
  cursor: pointer;
  font-family: inherit;
  margin-top: 8px;
  -webkit-tap-highlight-color: transparent;
  transition: transform 130ms cubic-bezier(0.4, 0, 0.2, 1), background 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
@media (hover: hover) {
  .btn-danger:hover:not(:disabled) { background: color-mix(in srgb, var(--destructive) 18%, var(--glass)); }
}
.btn-danger:active:not(:disabled) { background: color-mix(in srgb, var(--destructive) 24%, var(--glass)); transform: scale(0.97); }

/* compact secondary: pill, 40px, 14/600 */
.btn-sm {
  min-height: 40px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  vertical-align: top;
  box-sizing: border-box;
  background: var(--glass-bold);
  border: none;
  border-radius: var(--r-pill);
  color: var(--text2);
  font-size: 14px;
  font-weight: 600;
  line-height: 1.2;
  padding: 8px 18px;
  cursor: pointer;
  font-family: inherit;
  white-space: nowrap;
  -webkit-tap-highlight-color: transparent;
  transition: background 130ms cubic-bezier(0.4, 0, 0.2, 1),
              color 130ms cubic-bezier(0.4, 0, 0.2, 1),
              transform 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* press = the next tone step, same family as every other control; the old
   brightness() filter produced an off-family tint (owner audit 2026-08-07) */
.btn-sm:active:not(:disabled) { background: var(--glass-border); color: var(--text); transform: scale(0.97); }
@media (hover: hover) {
  .btn-sm:hover:not(:disabled) { background: var(--glass-border); color: var(--text); }
}
.btn-sm:disabled { opacity: 0.32; pointer-events: none; }
.btn-sm:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* solid-accent small CTA (pay / check payment / claim): the ONE filled-accent
   action of its screen, named once instead of inline forks */
.btn-sm.cta { background: var(--accent); color: #fff; }
.btn-sm.cta:active:not(:disabled) { background: var(--accent); color: #fff; }
@media (hover: hover) {
  .btn-sm.cta:hover:not(:disabled) { background: color-mix(in srgb, var(--accent) 88%, #fff); color: #fff; }
}
.profile-edit-trigger {
  margin-top: 4px;
  padding: 0 20px;
}
/* ═══════════════════════════════════════════
   INFO CARD (bonus details etc)
═══════════════════════════════════════════ */
.info-card {
  /* use .island for the container */
  overflow: hidden;
}
.info-row {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 13px var(--pad);
  font-size: 15px;
  position: relative;
}
.info-row::after {
  content: '';
  position: absolute;
  left: var(--pad);
  right: 0;
  bottom: 0;
  height: 1px;
  background: var(--sep);
}
.info-row:last-child::after { display: none; }
.info-row-label { color: var(--text2); }
.info-row-val { color: var(--text); font-weight: 500; }

/* ═══════════════════════════════════════════
   FORM ELEMENTS
═══════════════════════════════════════════ */
.form-field { margin-bottom: 12px; }
/* sentence case: uppercase labels were dropped app-wide with the M3 pass */
.form-label {
  font-size: 14px;
  color: var(--text2);
  margin-bottom: 6px;
  display: block;
  font-weight: 500;
}
.form-input, .form-textarea {
  width: 100%;
  background: var(--glass);
  border: none;
  border-radius: var(--r-btn);
  padding: 12px 14px;
  color: var(--text);
  font-size: 16px;
  font-family: inherit;
  outline: none;
  caret-color: var(--accent);
  transition: background-color 0.2s;
}
.form-input:focus, .form-textarea:focus { background: var(--glass-bold); }
.form-input::placeholder, .form-textarea::placeholder { color: var(--text3); }
.form-textarea { resize: none; min-height: 90px; line-height: 1.5; }
.char-limit { font-size: 12px; text-align: right; margin-top: 4px; color: var(--text3); }
.char-limit.warning { color: var(--accent); }
.char-limit.error { color: var(--destructive); }

/* ═══════════════════════════════════════════
   MODEL LIST
═══════════════════════════════════════════ */
.model-row {
  display: flex;
  align-items: center;
  padding: 14px var(--pad);
  gap: 12px;
  cursor: pointer;
  position: relative;
  transition: background 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
.model-row::after {
  content: '';
  position: absolute;
  left: var(--pad);
  right: 0;
  bottom: 0;
  height: 1px;
  background: var(--sep);
}
.model-row:last-child::after { display: none; }
.model-row:active { background: rgba(255,255,255,0.04); }
.model-row.locked { opacity: 0.35; cursor: default; }
.model-name { font-size: 16px; font-weight: 500; color: var(--text); }
.model-desc { font-size: 13px; color: var(--text2); margin-top: 2px; line-height: 1.4; }
.model-check { flex-shrink: 0; color: var(--accent-text); }
.model-check svg { width: 18px; height: 18px; }
.model-lock { flex-shrink: 0; color: var(--text3); }
.model-lock svg { width: 16px; height: 16px; }

/* ═══════════════════════════════════════════
   CHAT LIST
═══════════════════════════════════════════ */
.chat-list {
  flex: 1;
  min-height: 0;
  overflow-y: scroll;
  -webkit-overflow-scrolling: touch;
  overscroll-behavior: contain;
  scrollbar-width: none;
  padding-bottom: calc(var(--nav-h) + 32px + var(--safe-bottom));
}
.chat-list::-webkit-scrollbar { display: none; }

/* Telegram iOS 26 chat row: it lives directly ON THE PAGE, not inside a
   lighter card (owner, 2026-08-17: the island is out). The .island element
   stays in the markup (it also carries .select-mode and the soft-load dim),
   it just stops painting a card. */
.chat-list .island {
  margin: 0;
  background: transparent;
  border-radius: 0;
  overflow: visible;
  box-shadow: none;
}
/* 54px avatar, title and time sharing one baseline at the top of the row
   (align-items: flex-start puts both line boxes on the same line whether the
   preview is one line or two), .5px hairline starting at the title axis.
   The avatar centers itself against the whole row. */
.chat-item {
  display: flex;
  align-items: flex-start;
  padding: 9px var(--pad);
  gap: 12px;
  min-height: 70px;
  user-select: none;
  -webkit-user-select: none;
  cursor: pointer;
  position: relative;
  transition: background 0.15s;
}
.chat-item > .avatar,
.chat-item > .avatar-ph,
.chat-item > .roster-ava,
.chat-item > .skel-avatar { align-self: center; }
.chat-item::after {
  content: '';
  position: absolute;
  /* title axis: --pad + avatar 54 + gap 12 */
  left: calc(var(--pad) + 66px);
  right: 0;
  bottom: 0;
  height: 1px;
  background: var(--core-separator);
}
.chat-item:last-child::after { display: none; }
/* the pinned block is a wrapper in the middle of the run, so its last row is
   a :last-child that is NOT the end of the list - without this the hairline
   run has a hole exactly where the two groups meet */
.pinned-wrap:not(:last-child) > .chat-item:last-child::after { display: block; }
.chat-item:active { background: var(--glass-bold); }
.chat-item.selected { background: var(--accent-soft); }

.skel-row { pointer-events: none; }
.skel {
  background: linear-gradient(90deg, var(--glass) 25%, var(--glass-bold) 50%, var(--glass) 75%);
  background-size: 200% 100%;
  animation: skel-shimmer 1.4s linear infinite;
  border-radius: 7px;
}
.skel-avatar { width: 54px; height: 54px; border-radius: 50%; flex-shrink: 0; }
/* Значение строки настроек, которое ещё едет с сервера. Занимает место сразу,
   поэтому строка не перекрашивается из пустой в заполненную у пользователя на
   глазах; ширина по умолчанию под двузначное число, длинные значения
   (название модели) задают свою инлайном. */
.val-skel { width: 22px; height: 13px; border-radius: 5px; flex-shrink: 0; }
.skel-line { border-radius: 6px; }
body.no-blur .skel { animation: none; background: var(--glass-bold); }
/* никакой бесконечной анимации при сниженной подвижности: плейсхолдер всё
   ещё резервирует место, но не пульсирует */
@media (prefers-reduced-motion: reduce) {
  .skel { animation: none; background: var(--glass-bold); }
}
@keyframes skel-shimmer {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

.chat-info { flex: 1; min-width: 0; }
.chat-name {
  font-size: 16px;
  /* an explicit line box so the trailing date can sit on the same baseline */
  line-height: 22px;
  font-weight: 600;
  color: var(--text);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  letter-spacing: -0.2px;
}
.chat-preview {
  font-size: 14px;
  color: var(--text2);
  margin-top: 2px;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  line-height: 1.3;
}
/* image last message: SVG instead of the stored 🖼 emoji. Solid stroke dimmed
   by svg opacity (paths overlap - alpha strokes blob at crossings) */
.chat-preview .prev-img-ico { color: var(--text); }
.chat-preview .prev-img-ico svg { width: 15px; height: 15px; opacity: 0.55; vertical-align: -3px; }
.chat-preview .prev-voice-ico { margin-right: 4px; }
.chat-preview .prev-voice-ico svg {
  width: 13px;
  height: 13px;
  /* rides the text line: em-based so OS font scaling keeps it aligned */
  vertical-align: -0.07em;
}

/* time / pin: on the title's baseline at the trailing edge (the row is
   flex-start, so no per-item alignment math is needed) */
.chat-meta {
  display: flex;
  align-items: center;
  gap: 3px;
  flex-shrink: 0;
  padding-left: 8px;
  line-height: 22px;
}

/* Select mode a-la Telegram Android: rows never move (the preview text must
   not re-wrap); a check badge grows over the avatar's bottom-right corner on
   selected rows only. */
.chat-check {
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: var(--accent);
  border: 2px solid var(--bg);
  display: flex;
  align-items: center;
  justify-content: center;
  position: absolute;
  left: calc(var(--pad) + 36px);
  top: calc(50% + 7px);
  transform: scale(0.3);
  opacity: 0;
  pointer-events: none;
  z-index: 1;
  transition: transform 0.18s cubic-bezier(0.05, 0.7, 0.1, 1), opacity 0.12s cubic-bezier(0.4, 0, 0.2, 1);
}
.chat-check.checked { transform: scale(1); opacity: 1; }
.chat-item { position: relative; transition: background 0.15s; }
/* float-actions chip sets crossfade when entering/leaving select mode */
.fa-group { display: flex; align-items: center; gap: 8px; }
.chipswap-enter-active { transition: opacity 0.14s cubic-bezier(0.4, 0, 0.2, 1), transform 0.14s cubic-bezier(0.4, 0, 0.2, 1); }
.chipswap-enter-from { opacity: 0; transform: scale(0.94); }
.chipswap-leave-active { transition: opacity 0.1s cubic-bezier(0.4, 0, 0.2, 1); }
.chipswap-leave-to { opacity: 0; }
@media (prefers-reduced-motion: reduce) {
  .chat-check, .chat-item, .chipswap-enter-active, .chipswap-leave-active { transition: none; }
}
.chat-check.checked::after {
  content: '';
  width: 5px;
  height: 9px;
  border: 2px solid #fff;
  border-top: none;
  border-left: none;
  transform: rotate(45deg) translateY(-1px);
}

.active-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--accent);
  flex-shrink: 0;
}

/* ═══════════════════════════════════════════
   EMPTY STATE
═══════════════════════════════════════════ */
.empty-state {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 32px 24px;
  gap: 10px;
  text-align: center;
}
.empty-icon {
  width: 44px;
  height: 44px;
  margin-bottom: 4px;
  /* Feather paths cross and retrace. Keep every stroke fully opaque, then
     composite the complete glyph once so intersections never turn darker. */
  color: var(--text);
  opacity: 0.36;
}
.empty-icon svg { width: 100%; height: 100%; display: block; opacity: 1; }
.empty-title { font-size: 18px; font-weight: 600; color: var(--text); }
.empty-desc { font-size: 15px; color: var(--text2); line-height: 1.5; }
.empty-retry { width: auto; min-width: 132px; margin-top: 8px; padding-inline: 24px; }
/* ═══════════════════════════════════════════
   FAB
═══════════════════════════════════════════ */
/* ═══════════════════════════════════════════
   MESSAGES
═══════════════════════════════════════════ */
/* User chat wallpaper: the BAKED webp (blur + dim applied server-side at
   save), so at runtime this is a plain static image layer - zero CSS filter
   cost per frame (ADR-0015) and body.no-blur needs no special case.
   Absolute inside .view-content (not fixed): the swipe-back slide and the
   frozen peek clone both carry it with the screen for free. Negative z-index
   keeps every static-flow sibling (messages, empty states) painting above.
   Own compositor layer (translateZ) so scrolling the messages never repaints
   the image; 100lvh so the mobile URL-bar collapse (dvh breathing) never
   re-crops the cover fit mid-scroll. */
.chat-wallpaper {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 100vh;
  height: 100lvh;
  /* --vh-rest is the viewport height WITHOUT the keyboard (frozen by the
     kb-open detector): the TG webview resize on keyboard open must not
     re-crop the cover image, the keyboard just covers its bottom */
  height: var(--vh-rest, 100lvh);
  z-index: -1;
  overflow: hidden;
  pointer-events: none;
  transform: translateZ(0);
}
/* the crop (object-position + scale from the editor) rides on the img; both
   values are STATIC per chat - still zero per-frame work */
.chat-wallpaper img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
/* (The frosted header strip that used to sit here was removed 2026-07-20 -
   owner decision, the editor mock never showed it; the dark .top-fade below
   carries the chrome-band readability alone. ADR-0020 amendment.) */

/* Chrome over a wallpaper. Scoped to the OWN view-content of the chat that
   carries the layer (`.view-content:has(> .chat-wallpaper)`), NOT `.screen:has`:
   during a swipe-back the screen hosts BOTH the chat sheet and the peeked
   parent clone, and a screen-wide :has leaked the black fades onto every
   layer (owner report).

   Default mode = MEDIA TIER: the bar chips/pill and every in-flow chip stand
   on the user's photo, so they swap to the denser media fill and the
   saturate-free media filter (saturate would hand the photo's hue to the
   control: a chip on a green wallpaper renders green and reads as an ON
   state). Done by REMAPPING the two token pairs on the scope instead of
   restating a dozen selectors: custom properties inherit, so the bar chips,
   the day/suggestion pills, the side chips, the composer, the menus and the
   search panel all follow, and no state rule (.on / .pinned / .glass.on)
   loses to a longer selector. The swipe regen hint is deliberately outside:
   it overlays the OPAQUE bubble, not the wallpaper. */
.view-content:has(> .chat-wallpaper) {
  --chrome-glass-fill:            var(--chrome-glass-fill-media);
  --chrome-glass-fill-readable:   var(--chrome-glass-fill-media-readable);
  --chrome-glass-filter:          var(--chrome-glass-filter-media);
  --chrome-glass-filter-readable: var(--chrome-glass-filter-media-readable);
}
/* dense over a wallpaper: the theme tone the chips have always had here (a
   raised opaque step, so the chip never sinks into a dark photo) */
body.no-blur .view-content:has(> .chat-wallpaper) {
  --chrome-glass-fill-solid: var(--chrome-dense-fill-raised);
}
/* Secondary text on the media tier: --text2 is 55% white, fine over our
   near-black content but under 4.5:1 on a small label once the media fill
   sits on a high-key wallpaper. Densifying the whole tier would kill the
   material, so only the labels lift. */
.view-content:has(> .chat-wallpaper) .nsfw-chip.glass:not(.on),
.view-content:has(> .chat-wallpaper) .glass-pill .header-sub {
  color: rgb(255 255 255 / 78%);
}
.view-content:has(> .chat-wallpaper) .chat-dropdown {
  --text2: rgb(255 255 255 / 78%);
  --text3: rgb(255 255 255 / 60%);
}
.view-content:has(> .chat-wallpaper) .pager-count { color: rgba(255, 255, 255, 0.75); }
.view-content:has(> .chat-wallpaper) .pager-count b { color: #fff; }
/* the pin "on"/"pinned" fill is accent-soft (0.2 alpha) - translucent over a
   photo reads muddy, so it goes solid accent here */
.view-content:has(> .chat-wallpaper) .act-icon.on,
.view-content:has(> .chat-wallpaper) .msg-pin-btn.pinned {
  background: var(--accent);
  color: #fff;
}
/* Opt-in SCRIM mode (.wp-chips-scrim on .screen, Settings toggle on the
   wallpaper page): chips take the baked service color of the ACTIVE
   wallpaper (--wp-scrim, Telegram serviceMessageColor idiom) instead of the
   theme tone. */
.screen.wp-chips-scrim .view-content:has(> .chat-wallpaper) .day-divider-pill,
.screen.wp-chips-scrim .view-content:has(> .chat-wallpaper) .suggestion-pill,
.screen.wp-chips-scrim .view-content:has(> .chat-wallpaper) .act-icon,
.screen.wp-chips-scrim .view-content:has(> .chat-wallpaper) .pager-btn,
.screen.wp-chips-scrim .view-content:has(> .chat-wallpaper) .msg-undo-btn,
.screen.wp-chips-scrim .view-content:has(> .chat-wallpaper) .msg-pin-btn,
.screen.wp-chips-scrim .view-content:has(> .chat-wallpaper) .msg-edit-btn,
.screen.wp-chips-scrim .view-content:has(> .chat-wallpaper) .msg-anim-btn,
.screen.wp-chips-scrim .view-content:has(> .chat-wallpaper) .msg-goto-btn,
.screen.wp-chips-scrim .view-content:has(> .chat-wallpaper) .scroll-bottom-btn {
  background: var(--wp-scrim, rgba(18, 18, 18, 0.7));
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  box-shadow: none;
  color: #fff;
}
.screen.wp-chips-scrim .view-content:has(> .chat-wallpaper) .pager-count { color: rgba(255, 255, 255, 0.75); }
.screen.wp-chips-scrim .view-content:has(> .chat-wallpaper) .pager-count b { color: #fff; }
.screen.wp-chips-scrim .view-content:has(> .chat-wallpaper) .act-icon.on,
.screen.wp-chips-scrim .view-content:has(> .chat-wallpaper) .msg-pin-btn.pinned {
  background: var(--accent);
  color: #fff;
}
/* theme-tinted edge fades turn into colored fog over an unrelated photo:
   neutral black scrims instead (photo-viewer idiom). The top zone dims
   HARD through the whole chrome band (TG fullscreen adds ~100px of its own
   chrome above ours - content floating there nearly undimmed was the owner
   complaint) and releases just below the glass bar. */
.view-content:has(> .chat-wallpaper) .top-fade,
.wp-editor .wpe-topfade {
  height: calc(120px + var(--tg-top));
  background: linear-gradient(to bottom,
    rgba(0, 0, 0, 0.62),
    rgba(0, 0, 0, 0.5) calc(var(--tg-top) + 52px),
    rgba(0, 0, 0, 0.22) calc(var(--tg-top) + 88px),
    transparent);
}
/* the mock's copy of the over-wallpaper top dim: same gradient (shared rule
   above), anchored to the editor root above the mock messages */
.wpe-topfade {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  z-index: 2;
  pointer-events: none;
}
.view-content:has(> .chat-wallpaper) .input-fade {
  background: linear-gradient(to top, rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.16) 60%, transparent);
}

.messages-area {
  flex: 1;
  min-height: 0;
  overflow-y: scroll;
  -webkit-overflow-scrolling: touch;
  overscroll-behavior: contain;
  overflow-anchor: none;
  scrollbar-width: none;
  /* vertical pans stay native; horizontal ones reach JS (swipe-back peek) */
  touch-action: pan-y;
  padding: 8px 0;
  /* clear the floating chat-glass-bar; the sticky day header subtracts this
     inset again (sticky offsets count from the scroller's CONTENT edge) */
  --msgs-pad-top: calc(64px + var(--tg-top));
  padding-top: var(--msgs-pad-top);
  padding-bottom: 0;
  scroll-padding-bottom: calc(110px + var(--safe-bottom));
  display: flex;
  flex-direction: column;
}
/* Bottom clearance for the fixed composer is a real flex item, NOT
   padding-bottom: WebKit (TG iOS) excludes a scroll container's end padding
   from scrollable overflow, which zeroed the scroll range on one-message
   chats and parked the action row behind the composer (2026-07-18). */
.messages-area::after {
  content: '';
  display: block;
  flex: none;
  height: calc(105px + var(--safe-bottom));
}
/* The suggestion pills are a FIXED overlay (band top ~98px + safe-bottom);
   content does NOT reserve space for them - at bottom rest the tail clears
   them via the base clearance above (8px scroller padding + this ::after).
   If the row happens to sit inside their band when they arrive,
   loadSuggestions magnets to the bottom; otherwise nothing moves. */
.messages-area::-webkit-scrollbar { display: none; }
.messages-spacer { flex: 1 0 0; min-height: 0; }

.load-more-area { text-align: center; padding: 8px; }

/* position: relative anchors the text-selection layer (.tsel-layer) */
.message-row { display: flex; padding: 4px 12px; align-items: flex-end; position: relative; }
.message-row.user { justify-content: flex-end; }
.message-row.model { justify-content: flex-start; }
/* Undo button appears BEFORE user bubble (visually left of it) */
.message-row.user .msg-undo-btn { order: -1; margin-left: 0; margin-right: 6px; }

.bubble {
  max-width: min(78%, 500px);
  padding: 10px 14px;
  border-radius: 20px;
  font-size: var(--msg-fs);
  line-height: 1.5;
  word-break: break-word;
}
.bubble.user {
  background: var(--accent);
  color: #fff;
  border-bottom-right-radius: 6px;
  white-space: pre-wrap;
}
.bubble.user.voice-bubble {
  --voice-progress: 0%;
  width: min(var(--voice-w, 290px), calc(100vw - 76px));
  max-width: min(88%, 360px);
  min-width: 220px;
  padding: 9px 10px 8px;
  white-space: normal;
  overflow: hidden;
  transition: border-radius 200ms ease-in-out;
}
.voice-player-row {
  min-height: 48px;
  display: flex;
  align-items: center;
  gap: 9px;
}
.voice-play-btn {
  width: 44px;
  height: 44px;
  flex: 0 0 44px;
  border: 0;
  /* icon primary (grammar: circle token, press .9) */
  border-radius: var(--r-pill);
  background: var(--text);
  color: var(--accent);
  display: grid;
  place-items: center;
  cursor: pointer;
  transition: transform 130ms cubic-bezier(.2,.8,.2,1);
}
.voice-play-btn:active { transform: scale(.9); }
.voice-play-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.voice-play-btn svg { width: 21px; height: 21px; }
.voice-wave-wrap {
  position: relative;
  height: 39px;
  min-width: 0;
  flex: 1;
  cursor: pointer;
  touch-action: none;
}
.voice-wave {
  position: absolute;
  /* bottom-aligned bars on a common baseline (Telegram iOS draws the voice
     waveform with style .bottom, never center-mirrored); 8px right inset
     keeps the last bar off the transcript button */
  inset: 3px 8px 16px 0;
  display: flex;
  align-items: flex-end;
  gap: 2px;
  pointer-events: none;
}
.voice-wave i {
  display: block;
  min-width: 2px;
  flex: 1 1 2px;
  max-width: 3px;
  min-height: 2px;
  border-radius: 2px;
}
.voice-wave-base i { background: color-mix(in srgb, var(--text) 42%, transparent); }
.voice-wave-progress {
  color: var(--text);
  clip-path: inset(0 calc(100% - var(--voice-progress)) 0 0);
}
.voice-wave-progress i { background: currentColor; }
.voice-time {
  position: absolute;
  left: 0;
  bottom: -1px;
  color: color-mix(in srgb, var(--text) 65%, transparent);
  font-size: 12px;
  font-variant-numeric: tabular-nums;
  line-height: 14px;
}
.voice-transcript-btn {
  width: 32px;
  height: 32px;
  min-width: 32px;
  min-height: 32px;
  max-width: 32px;
  max-height: 32px;
  padding: 0;
  flex: 0 0 32px;
  align-self: center;
  border: 0;
  /* icon action on content: circle disc, tonal, no glass; 44px hit via ::before */
  border-radius: var(--r-pill);
  background: color-mix(in srgb, var(--bg) 38%, transparent);
  color: var(--text);
  display: grid;
  place-items: center;
  position: relative;
  cursor: pointer;
  transition: transform 130ms ease-out, background 200ms ease-in-out;
}
.voice-transcript-btn::before { content: ''; position: absolute; inset: -6px; border-radius: inherit; }
.voice-transcript-btn:active { transform: scale(.92); }
.voice-transcript-btn:disabled { cursor: default; opacity: .58; }
.voice-transcript-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.voice-transcript-btn.is-loading::after {
  content: '';
  position: absolute;
  width: 14px;
  height: 14px;
  border: 2px solid color-mix(in srgb, var(--text) 28%, transparent);
  border-top-color: currentColor;
  border-radius: 50%;
  animation: spin 800ms linear infinite;
}
.voice-transcript-btn.is-loading > * { opacity: 0; }
.voice-transcript-a {
  display: flex;
  align-items: center;
  gap: 2px;
  font-size: 13px;
  font-weight: 600;
  line-height: 1;
  transition: opacity 150ms ease-in-out, transform 250ms ease-in-out;
}
.voice-transcript-a svg { width: 11px; height: 11px; }
.voice-transcript-x {
  position: absolute;
  width: 14px;
  height: 14px;
  opacity: 0;
  transform: translateY(4px);
  transition: opacity 150ms ease-in-out, transform 250ms cubic-bezier(.4,0,.2,1);
}
.voice-transcript-btn.expanded .voice-transcript-a {
  opacity: 0;
  transform: translateY(-4px) scale(.8);
}
.voice-transcript-btn.expanded .voice-transcript-x {
  opacity: 1;
  transform: none;
}
.voice-transcript-clip {
  display: grid;
  grid-template-rows: 0fr;
  opacity: 0;
  transition:
    grid-template-rows 300ms cubic-bezier(.4,0,.2,1),
    opacity 200ms ease-out;
}
.voice-bubble.transcript-open .voice-transcript-clip {
  grid-template-rows: 1fr;
  opacity: 1;
}
.voice-transcript-text {
  overflow: hidden;
  padding: 0 4px;
  font-size: var(--msg-fs);
  line-height: 1.42;
  white-space: pre-wrap;
}
.voice-bubble.transcript-open .voice-transcript-text { padding-top: 7px; padding-bottom: 2px; }
.bubble.model {
  background: var(--glass);
  color: var(--text);
  border: none;
  border-bottom-left-radius: 6px;
}
/* Solo chat character avatar (Settings toggle): the REAL group-room run
   mechanism (.grp-run gutter + sticky .grp-ava, v5.121.0) - the avatar floats
   over the composer while a long run scrolls and lands on the tail bubble.
   Only the sticky inset differs: the solo composer is lower than the group
   one (72px vs 112px band). Content, not chrome: no glass, no wallpaper
   override needed. */
.solo-run .grp-ava { bottom: calc(72px + var(--safe-bottom)); }
body.kb-open .solo-run .grp-ava { bottom: calc(72px + var(--kb-h, 0px)); }
/* Пилюли подсказок занимают ТУ ЖЕ полосу над композером, что и припаркованная
   аватарка. Разъезжаются они по вертикали, а не по горизонтали: пока подсказки
   на экране, аватарка паркуется над ними - тем же приёмом и на ту же величину,
   что и чип "вниз" (.sug-lift). Горизонтальный инсет самого ряда (v5.121, левый
   край на колонке бублика) выглядел поломкой на КАЖДОМ обычном экране: в покое
   аватарка стоит на своём хвостовом бублике и в этой полосе её нет вообще, а ряд
   всё равно терял 38px ширины и обрезал последнюю пилюлю. */
.messages-area.sugs-on .solo-run .grp-ava { bottom: calc(72px + var(--safe-bottom) + 44px); }
body.kb-open .messages-area.sugs-on .solo-run .grp-ava { bottom: calc(72px + var(--kb-h, 0px) + 44px); }
/* the wallpaper-editor mock is its own short overflow box: sticky against it
   would lift the avatar off its bubble - rest at the gutter bottom instead */
.wpe-msgs .grp-ava { position: static; }

/* grouped runs (TG-style): tighter gap, flattened adjacent speaker-side corners */
.message-row.grp-mid, .message-row.grp-end { padding-top: 0; margin-top: -2px; }
.message-row.user.grp-mid .bubble.user,
.message-row.user.grp-end .bubble.user { border-top-right-radius: 6px; }
.message-row.model.grp-mid .bubble.model,
.message-row.model.grp-end .bubble.model { border-top-left-radius: 6px; }

.cursor {
  display: inline-block;
  width: 2px;
  height: 1em;
  background: var(--text);
  margin-left: 2px;
  vertical-align: text-bottom;
  animation: blink 0.85s step-end infinite;
}
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0} }

.typing-dots {
  display: inline-flex;
  gap: 4px;
  align-items: center;
  padding: 3px 0;
}
.typing-dots span {
  width: 6px; height: 6px;
  background: var(--text3);
  border-radius: 50%;
}
.typing-dots span:nth-child(1) { animation: tdot 1.2s ease-in-out infinite; }
.typing-dots span:nth-child(2) { animation: tdot 1.2s ease-in-out 0.2s infinite; }
.typing-dots span:nth-child(3) { animation: tdot 1.2s ease-in-out 0.4s infinite; }
@keyframes tdot { 0%,60%,100%{transform:translateY(0);opacity:.3} 30%{transform:translateY(-5px);opacity:1} }

/* ═══════════════════════════════════════════
   SWIPEABLE BUBBLE (last assistant message)
═══════════════════════════════════════════ */
.swipeable {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: stretch;
  /* Cards are absolutely positioned, so the wrapper has no intrinsic width.
     Take the chat-bubble allowance directly to keep width stable across
     swipes (variants of different lengths shouldn't reflow the bubble). */
  width: 100%;
  max-width: min(78%, 500px);
  min-width: 0;
}

/* Off-screen mirror that holds a hidden copy of every variant. We measure
   each card's natural height there to drive the visible stage's height. */
.swipe-mirror {
  position: absolute;
  top: -10000px;
  left: 0;
  width: 100%;
  visibility: hidden;
  pointer-events: none;
  contain: layout paint;
}
/* Mirror cards reuse .bubble.model classes for full styling parity (typography,
   padding, border, max-width). We only override the visible-only properties. */
.swipe-mirror-card.bubble.model {
  width: 100%;
  /* mirror should always fill the swipeable wrapper, not respect bubble cap */
  max-width: 100%;
  color: transparent !important;
  background: transparent !important;
}
.swipe-mirror-card .typing-dots { padding: 3px 0; }

/* Visible bubble. Shares .bubble.model styling but stages the cards itself. */
.swipe-stage {
  position: relative;
  overflow: hidden;
  padding: 0;
  /* The .swipeable wrapper already enforces the chat bubble width cap;
     the stage just fills it (otherwise we'd cap it twice). */
  max-width: 100%;
  min-height: 36px;
  touch-action: pan-y;
  cursor: default;
  user-select: text;
  /* Smooth height changes from ResizeObserver (eg. window resize); the rAF
     loop disables this with .dragging / explicit transitions during gesture. */
  transition: height 0.18s cubic-bezier(0.32, 0.72, 0.28, 1.0);
}
/* Drag enabled for any non-streaming swipeable bubble (incl. 1-variant —
   so the user can swipe past the right edge to fire the first regen). */
.swipe-stage { cursor: grab; }
.swipe-stage:active { cursor: grabbing; }
.swipe-stage.dragging {
  transition: none;
  user-select: none;
  -webkit-user-select: none;
  cursor: grabbing;
}
.swipe-stage.streamingTail { cursor: default; }

/* Each card is absolutely positioned and translated by JS for the gesture.
   Padding lives here (not on the stage) so cards render flush with the
   bubble interior — same visual as a static .bubble. */
.swipe-card {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  padding: 10px 14px;
  /* MUST track .bubble's size: the stage height comes from mirror cards that
     carry the real .bubble classes, so a hardcoded size here desyncs the
     visible card from the measured height (v5.104.1, user-set --msg-fs) */
  font-size: var(--msg-fs);
  line-height: 1.5;
  word-break: break-word;
  white-space: normal;
  will-change: transform, opacity, filter;
  transform-origin: 50% 50%;
  -webkit-font-smoothing: antialiased;
  backface-visibility: hidden;
}
/* Текущая карточка живёт В ПОТОКЕ, остальные абсолютные: пока stageH ещё не
   измерен (первый кадр после монтирования - откат переписал хвост, финализация
   подменила плоский бубл свайпером), высоту стейджу обязана давать она сама.
   Иначе стейдж встаёт в min-height 36px, лента на кадр проседает на высоту
   бубла, браузер прижимает скролл к новому потолку и читатель, стоявший у
   пола, оказывается выше него (v5.234.12; замер - шаг харнесса tailpinalt).
   На жест это не влияет: карточки двигает transform, а во время анимации
   высоту диктует stageH. */
.swipe-card.cur { position: relative; }
.swipe-card .typing-dots { padding: 3px 0; }
.swipe-card .cursor { vertical-align: text-bottom; }

/* Regen hint: a circle with the regen icon that fades in as the user
   drags left past the last variant. When `armed` (progress = 1) it brightens
   to the accent colour to signal that releasing now will fire the regen. */
.swipe-regen-hint {
  position: absolute;
  top: calc(50% - 22px);
  right: 14px;
  width: 44px;
  height: 44px;
  border-radius: 50%;
  background: var(--glass-bold);
  border: none;
  color: var(--text2);
  display: flex;
  align-items: center;
  justify-content: center;
  pointer-events: none;
  opacity: 0;
  transform-origin: 50% 50%;
  transition: background 0.18s, color 0.18s;
  z-index: 3;
  box-shadow: 0 4px 12px rgba(0,0,0,0.25);
}
.swipe-regen-hint svg { width: 20px; height: 20px; display: block; }
.swipe-regen-hint.armed {
  background: var(--accent);
  color: #fff;
}

/* Variant pager under the bubble: the same action-chip vocabulary as
   .act-icon (owner, v5.108 - the old pill card read as another message).
   Bare row, two 32px glass circles, count as plain typography between them. */
.swipe-pager {
  display: inline-flex;
  align-items: center;
  align-self: flex-start;
  gap: 6px;
  margin: 6px 0 2px 0;
  padding: 0;
  background: none;
  border: none;
  font-size: 13px;
  color: var(--text2);
  user-select: none;
  animation: pager-in 0.26s cubic-bezier(0.32, 0.72, 0.28, 1.0) both;
}
@keyframes pager-in {
  from { opacity: 0; transform: translateY(-3px) scale(0.96); }
  to   { opacity: 1; transform: translateY(0) scale(1); }
}

.pager-btn {
  position: relative;
  width: 32px;
  height: 32px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: var(--r-pill);
  background: var(--chrome-glass-fill);
  backdrop-filter: var(--chrome-glass-filter);
  -webkit-backdrop-filter: var(--chrome-glass-filter);
  box-shadow: var(--chrome-glass-shadow-thin);
  border: none;
  cursor: pointer;
  color: var(--text);
  padding: 0;
  font-family: inherit;
  transition: background 0.15s, transform 130ms cubic-bezier(0.4, 0, 0.2, 1), scale 120ms ease-out;
}
/* 44px hit on a 32px disc: the count between the chevrons is not a target,
   above/below is the bubble margin */
.pager-btn::before { content: ''; position: absolute; inset: -6px; border-radius: inherit; }
.pager-btn svg { width: 16px; height: 16px; display: block; opacity: 0.72; transition: opacity 0.15s; }
@media (hover: hover) {
  .pager-btn:hover { background: var(--glass-border); }
  .pager-btn:hover svg { opacity: 1; }
}
.pager-btn:active { background: var(--glass-border); }
body.no-blur .pager-btn:active { transform: scale(0.92); }
.pager-btn:active svg { opacity: 1; }
.pager-btn:disabled { opacity: 0.32; cursor: default; pointer-events: none; }
.pager-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

.pager-count {
  font-feature-settings: "tnum" 1, "lnum" 1;
  padding: 0 4px;
  display: inline-flex;
  align-items: baseline;
  min-width: 36px;
  justify-content: center;
  letter-spacing: 0.2px;
}
.pager-count b { color: var(--text); font-weight: 600; }
.pager-sep { opacity: 0.4; padding: 0 1px; }

/* When the swipe bubble is sandwiched in a model row, keep the per-message
   undo button visually aligned with the bubble (not the pager) by anchoring
   the undo to the top of the swipeable wrapper. The CSS for msg-undo-btn
   already aligns to flex-end of the row — for the LAST message we hide it
   in template, so this only matters defensively. */

/* ═══════════════════════════════════════════
   ACTION BAR
═══════════════════════════════════════════ */
/* Compact action icons row */
.action-icons {
  display: flex;
  gap: 8px;
  padding: 2px 12px 6px;
  /* reserved slot: the container renders EMPTY while streaming/editing so the
     finalize adds zero height - icons materialize into space that is already
     there (min-height = 32px chip + vertical padding, border-box) */
  min-height: 40px;
  /* fades out under body.msg-ctx-open (long-press message menu) */
  transition: opacity 0.15s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Always-circled iOS glass chips (owner, 2026-07-21): they used to be flat
   --glass-bold circles that read as M3 next to the glass suggestion/day pills.
   Now the SAME glass material as the pills and the chat-bar .glass-chip. Over
   a wallpaper the fill swaps to solid (rules near .chat-wallpaper); no-blur
   keeps the fill as-is - these chips are in-flow, nothing passes behind them
   (see the note in the no-blur block). Stroke is SOLID var(--text) dimmed by
   GROUP opacity on the svg - alpha strokes double up where paths cross (slop
   checklist). */
.act-icon {
  position: relative;
  width: 32px; height: 32px;
  border-radius: var(--r-pill);
  background: var(--chrome-glass-fill);
  backdrop-filter: var(--chrome-glass-filter);
  -webkit-backdrop-filter: var(--chrome-glass-filter);
  box-shadow: var(--chrome-glass-shadow-thin);
  border: none;
  color: var(--text);
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: background 0.15s, transform 130ms cubic-bezier(0.4, 0, 0.2, 1), scale 120ms ease-out;
}
/* hit extension: the row packs 32px discs at an 8px gap, so 4px per side is
   the most that does not overlap the neighbour (40px targets, edge to edge) */
.act-icon::before { content: ''; position: absolute; inset: -4px; border-radius: inherit; }
.act-icon svg { width: 16px; height: 16px; opacity: 0.72; transition: opacity 0.15s; }
@media (hover: hover) {
  .act-icon:hover:not(:disabled):not(.on) { background: var(--glass-border); }
  .act-icon:hover:not(:disabled) svg { opacity: 1; }
}
.act-icon:active { background: var(--glass-border); }
body.no-blur .act-icon:active { transform: scale(0.92); }
.act-icon:active svg { opacity: 1; }
.act-icon:disabled { opacity: 0.32; pointer-events: none; }
.act-icon:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* pinned state of the last-message pin action: tonal fill, not stroke */
.act-icon.on { color: var(--accent-text); background: var(--accent-soft); }
.act-icon.on svg { opacity: 1; }

/* Settle reveal: when generation/editing ends the icons float into the
   reserved slot with the sugIn cascade (same vocabulary as the suggestion
   pills and pager-in). .act-reveal is transient (_actRevealPoke in app.js),
   so chat open and later remounts paint atomically without replaying it. */
.action-icons.act-reveal .act-icon { animation: sugIn 0.26s cubic-bezier(0.05, 0.7, 0.1, 1) backwards; }
.action-icons.act-reveal .act-icon:nth-child(2) { animation-delay: 45ms; }
.action-icons.act-reveal .act-icon:nth-child(3) { animation-delay: 90ms; }
.action-icons.act-reveal .act-icon:nth-child(4) { animation-delay: 135ms; }
.action-icons.act-reveal .act-icon:nth-child(5) { animation-delay: 180ms; }
.action-icons.act-reveal .act-icon:nth-child(6) { animation-delay: 225ms; }
@media (prefers-reduced-motion: reduce) {
  .action-icons.act-reveal .act-icon { animation: none; }
}

/* ═══════════════════════════════════════════
   INPUT AREA — Floating Island
═══════════════════════════════════════════ */
.suggestions-row {
  position: fixed;
  bottom: calc(64px + var(--safe-bottom));
  left: 14px;
  right: 14px;
  display: flex;
  gap: 6px;
  z-index: 20;
  overflow-x: auto;
  scrollbar-width: none;
  -ms-overflow-style: none;
  padding-bottom: 4px;
}
.suggestions-row::-webkit-scrollbar { display: none; }
.suggestion-pill {
  position: relative;
  flex-shrink: 0;
  max-width: 45%;
  height: 32px;
  padding: 0 14px;
  border-radius: var(--r-pill);
  border: none;
  background: var(--chrome-glass-fill-readable);
  backdrop-filter: var(--chrome-glass-filter-readable);
  -webkit-backdrop-filter: var(--chrome-glass-filter-readable);
  box-shadow: var(--chrome-glass-shadow-thin);
  color: var(--text);
  font-size: 13px;
  font-weight: 500;
  font-family: inherit;
  cursor: pointer;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  transition: background 0.15s, transform 130ms cubic-bezier(0.4, 0, 0.2, 1), scale 120ms ease-out;
}
.suggestion-pill:active { background: var(--glass-bold); }
body.no-blur .suggestion-pill:active { transform: scale(0.94); }
.suggestion-pill:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* Pills float up staggered when suggestions arrive (mirrors the .card cascade),
   the whole row fades out fast when one is picked / a message is sent */
.suggestion-pill { animation: sugIn 0.26s cubic-bezier(0.05, 0.7, 0.1, 1) backwards; }
.suggestion-pill:nth-child(2) { animation-delay: 45ms; }
.suggestion-pill:nth-child(3) { animation-delay: 90ms; }
@keyframes sugIn {
  from { opacity: 0; transform: translateY(10px) scale(0.96); }
}
.sugrow-leave-active { transition: opacity 0.12s cubic-bezier(0.4, 0, 0.2, 1); }
.sugrow-leave-to { opacity: 0; }
@media (prefers-reduced-motion: reduce) {
  .suggestion-pill { animation: none; }
  .sugrow-leave-active { transition: none; }
}

/* Scroll-to-bottom chip (TG idiom): fixed above the composer, appears once the
   reader is well above the tail. Material = .glass-chip; this class only adds
   placement + show/hide. Always mounted, class-toggled - so the show fade works
   and hidden state costs nothing. */
/* double class beats .glass-chip's later `transition: transform` line */
.glass-chip.scroll-bottom-btn {
  position: fixed;
  right: 14px;
  bottom: calc(72px + var(--safe-bottom));
  width: 40px;
  z-index: 20;
  color: var(--text2);
  opacity: 0;
  pointer-events: none;
  transform: translateY(6px) scale(0.9);
  transition: opacity 0.15s cubic-bezier(0.4, 0, 0.2, 1),
              transform 0.15s cubic-bezier(0.4, 0, 0.2, 1),
              bottom 0.15s cubic-bezier(0.4, 0, 0.2, 1);
}
.glass-chip.scroll-bottom-btn.show { opacity: 1; pointer-events: auto; transform: none; }
body.no-blur .glass-chip.scroll-bottom-btn.show:active { transform: scale(0.92); }
/* suggestion pills occupy the band above the composer: hop over them */
.scroll-bottom-btn.sug-lift { bottom: calc(72px + var(--safe-bottom) + 44px); }
/* group composer is taller (class row + input row) */
.scroll-bottom-btn.grp-scroll-btn { bottom: calc(118px + var(--safe-bottom)); }
body.kb-open .scroll-bottom-btn { bottom: calc(72px + var(--kb-h, 0px)); }
body.kb-open .scroll-bottom-btn.grp-scroll-btn { bottom: calc(118px + var(--kb-h, 0px)); }
/* long-press message menu dims the action row; the chip follows */
body.msg-ctx-open .glass-chip.scroll-bottom-btn { opacity: 0; pointer-events: none; }
@media (prefers-reduced-motion: reduce) {
  .glass-chip.scroll-bottom-btn { transition: opacity 0.15s cubic-bezier(0.4, 0, 0.2, 1); transform: none; }
}

.input-fade {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  height: calc(70px + var(--safe-bottom));
  /* same ramp in blur and no-blur, mirrors .nav-fade */
  background: linear-gradient(to bottom, transparent, var(--bg) 75%);
  pointer-events: none;
  z-index: 19;
}
.input-area {
  position: fixed;
  bottom: calc(6px + var(--safe-bottom));
  left: 14px;
  right: 14px;
  padding: 6px;
  display: flex;
  align-items: flex-end;
  /* the edit strip is a full-width first row; rest state stays a single line */
  flex-wrap: wrap;
  gap: 6px;
  border: none;
  border-radius: var(--r-island);
  z-index: 20;
  --comp-gap: 0px;
  --comp-lead: 0px;
  --composer-shadow: var(--chrome-glass-rim), 0 -1px 2px rgb(0 0 0 / 30%), 0 -4px 8px 3px rgb(0 0 0 / 15%);
  /* Telegram's own numbers for this swap: the iOS panel moves the island and
     the send disk on ONE .animated(duration: 0.4, curve: .spring) transaction
     and rides the alpha on a separate 0.2s easeInOut; tweb spends the same
     0.4s on its grow-icon. .23,1,.32,1 is the app's existing iOS-shaped
     decelerate (the Android lock rail uses it) - fast start, long settle,
     no bounce, which is how kCAMediaTimingFunctionSpring reads. */
  --comp-dur: 400ms;
  --comp-ease: cubic-bezier(.23, 1, .32, 1);
}
/* Telegram iOS composer: the voice island lives OUTSIDE the field capsule
   (ChatTextInputPanelNode - mediaActionButtons sits at capsule.maxX + 6 and
   parks at width + 8 the moment the field has text, while textFieldInsets.right
   goes 54 -> 8). Only this PAINTED layer resizes by --comp-gap: the flow box,
   the field and every voice coordinate stay put, so typing the first character
   moves the glass edge and nothing else - no text reflow, no flex churn. */
.input-area::before {
  content: '';
  position: absolute;
  inset: 0 var(--comp-gap) 0 var(--comp-lead);
  /* BELOW every child, unlike the strips' glass (.g-tabs::before,
     .view-strip::before at z-index 0, whose single content wrapper lifts
     itself). A positioned layer paints OVER static in-flow children, and this
     layer blurs its backdrop: a plain row inside the composer sank under it,
     was smeared into the wallpaper and vanished while staying clickable (the
     room's send-class segments, v5.221.2 - v5.231.5). The composer hosts
     open-ended content rows, so the layer yields to all of them at once
     instead of every future row remembering to position itself. The parent is
     a stacking context (fixed + z-index 20) and paints no background of its
     own, so -1 stays inside the composer. Gecko renders it the other way
     round, which is why only a Blink/WebKit screenshot shows the loss - the
     guard is the harness step compglassgrp. */
  z-index: -1;
  border-radius: inherit;
  background: var(--chrome-glass-fill-readable);
  backdrop-filter: var(--chrome-glass-filter-readable);
  -webkit-backdrop-filter: var(--chrome-glass-filter-readable);
  box-shadow: var(--composer-shadow);
  transition: right var(--comp-dur) var(--comp-ease), left var(--comp-dur) var(--comp-ease);
  pointer-events: none;
}
/* the capsule ends 6px before the island: 36 disk + 6 gap */
.input-area.mic-island { --comp-gap: 54px; }
/* the leading island (catalog chats: the participants button) is the mirror
   of the voice island: Telegram's attachment button sits at 8 + 40 + 6 */
.input-area.lead-island { --comp-lead: 54px; }
/* While recording, the field runs UNDER the record disk all the way to its
   CENTRE, so the capsule's own cap never shows: Telegram merges the two into
   one shape (the field grows a round socket around the button, ~55pt across on
   a 40pt field), we keep two shapes and simply bury the seam. 24px = half the
   island, i.e. the field edge lands exactly on the disk's centre, and the cap
   (r=24) sits entirely inside the disk (r=49.5). */
.input-area.voice-active {
  --comp-gap: 24px;
  min-height: 48px;
  /* the timer, hint and Cancel share one vertical midpoint with the bar */
  align-items: center;
  flex-wrap: nowrap;
  overflow: visible;
  --voice-slide-x: 0px;
  --voice-hint-fade: 1;
  --voice-lock-y: 0px;
  --voice-lockness: 0;
  --voice-button-x: 0px;
  --voice-button-y: 0px;
}
/* The edit strip stays ABOVE the recording row (Telegram iOS keeps its
   accessory panel and offsets the recorder by its height): the strip keeps its
   own full-width line instead of being squeezed into the row. */
.input-area.voice-active.has-strip { flex-wrap: wrap; }
/* the edit strip stays mounted during recording; its close would sit under the
   record disk, so it steps aside until the recorder is gone */
.input-area.voice-active .ceb-close,
.input-area.voice-canceling .ceb-close { opacity: 0; pointer-events: none; transition: opacity 150ms ease-out; }
.input-area.lead-island.voice-active,
.input-area.lead-island.voice-canceling { --comp-lead: 0px; }
.voice-record-panel {
  flex: 1;
  min-width: 0;
  height: 36px;
  display: flex;
  align-items: center;
  position: relative;
  padding: 0 6px 0 10px;
  font-variant-numeric: tabular-nums;
}
.voice-record-status {
  display: flex;
  /* mixed text sizes align by BASELINE, not box centers; the dot opts out */
  align-items: baseline;
  gap: 9px;
  flex-shrink: 0;
}
.voice-record-dot {
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background: var(--destructive);
  align-self: center;
  /* Telegram: scale .3 -> 1 over 150ms, then a 0.5s opacity keyframe
     [1, 1, 0] at keyTimes [0, .4546, .9091] with autoreverse forever */
  animation: voice-dot-in 150ms ease-out both,
             voice-dot 1s linear 150ms infinite;
}
@keyframes voice-dot-in { from { scale: .3; opacity: 0; } }
@keyframes voice-dot {
  0%, 22.7% { opacity: 1; }
  45.5%, 54.5% { opacity: 0; }
  77.3%, 100% { opacity: 1; }
}
.voice-record-time {
  font-size: 15px;
  color: var(--text);
  letter-spacing: .01em;
}
.voice-slide-hint {
  position: absolute;
  left: 50%;
  /* the midpoint of the RECORDING row: the composer's own midpoint while it is
     one row (48px), and still the row's when an edit strip sits above it */
  top: calc(100% - 24px);
  display: flex;
  align-items: center;
  gap: 6px;
  color: var(--text2);
  font-size: 14px;
  white-space: nowrap;
  transform: translate(calc(-50% + var(--voice-slide-x)), -50%) scale(1);
  /* the hint dissolves along the drag and can never overlap the timer */
  opacity: calc(.72 * var(--voice-hint-fade, 1));
}
.voice-slide-hint > * { animation: voice-hint-jiggle 1s ease-in-out infinite alternate; }
@keyframes voice-hint-jiggle { to { translate: 6px 0; } }
.input-area.hint-still .voice-slide-hint > * { animation: none; }
@media (max-width: 320px) {
  .voice-slide-hint > * { animation: none; }
}
/* Telegram morphs ONE indicator between the two states: the hint rises 22px
   and shrinks to .25 while Cancel comes up from +22px to full size, everything
   crossfading over 250ms (ChatTextInputAudioRecordingCancelIndicator). Both
   stay mounted so the swap is a morph, not a DOM pop. */
.voice-slide-hint,
.voice-record-cancel {
  transition:
    transform 250ms cubic-bezier(.4, 0, .2, 1),
    opacity 250ms linear;
}
/* while the finger drags, both the offset and the fade are written per frame:
   a transition here would make the hint lag the finger by a quarter second */
.input-area.hint-still .voice-slide-hint { transition: none; }
/* one transform LIST per state, not the individual translate/scale properties:
   those apply BEFORE `transform`, so the centring -50% would be scaled by .25
   and the label would drift out of the middle while it shrinks */
.voice-record-cancel {
  transform: translate(-50%, calc(-50% + 22px)) scale(.25);
  opacity: 0;
  pointer-events: none;
}
.voice-locked .voice-record-cancel {
  transform: translate(-50%, -50%) scale(1);
  opacity: 1;
  pointer-events: auto;
}
.voice-locked .voice-slide-hint {
  transform: translate(calc(-50% + var(--voice-slide-x)), calc(-50% - 22px)) scale(.25);
  opacity: 0;
}
.voice-slide-hint svg { width: 18px; height: 18px; }
@media (max-width: 340px) {
  .voice-slide-hint span {
    max-width: 150px;
    overflow: hidden;
    text-overflow: ellipsis;
  }
}
.voice-free-note {
  margin-left: 2px;
  font-size: 12px;
  color: var(--text3);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
/* while held the centered slide-hint owns the bar: the limit label yields
   and the paid state speaks through the timer tint alone. The label shows
   in locked/paused, where the short centered Cancel leaves room */
.input-area.voice-active:not(.voice-locked) .voice-free-note { display: none; }
@media (max-width: 359px) {
  .voice-free-note { display: none; }
}
.voice-paid .voice-record-time,
.voice-paid .voice-free-note { color: var(--accent-text); }
/* the cancel drag owns the tint while armed: destructive wins over paid */
.cancel-armed .voice-slide-hint { color: var(--destructive); }
.cancel-armed .voice-record-time { color: var(--destructive); }
/* The lock pill is a 40x72 capsule whose center rests 122px above the press
   point and it is ANCHORED there: it never rides up with the drag (owner
   verified against live Telegram iOS, 2026-07-28; the literal legacy ObjC
   wrapper-ride line does not match the shipping app's read). The finger and
   the disk rise TOWARD it while the capsule contracts to meet them: the top
   swallows down 40L, the bottom drifts +8L, the glyph counter-slides -16L,
   so the pill visibly sinks into the rising blob and the finger ends INSIDE
   the capsule exactly at the 110px commit. After lock the SAME pill becomes
   the 40x40 pause/resume control at 106px (there is no separate pause
   button in Telegram). */
.voice-lock-afford {
  position: absolute;
  right: 4px;
  bottom: calc(110px - 8px * var(--voice-lockness, 0));
  width: 40px;
  height: calc(72px - 32px * var(--voice-lockness, 0));
  border: 0;
  padding: 0;
  border-radius: 20px;
  background: var(--chrome-glass-fill-readable);
  backdrop-filter: var(--chrome-glass-filter-readable);
  -webkit-backdrop-filter: var(--chrome-glass-filter-readable);
  color: var(--text);
  overflow: hidden;
  box-shadow: var(--chrome-glass-shadow-thin);
  cursor: pointer;
  transition: color 150ms ease-out, background 150ms ease-out;
  pointer-events: none;
}
.voice-lock-afford.is-pause {
  height: 40px;
  bottom: 106px;
  pointer-events: auto;
  transition: color 150ms ease-out, background 150ms ease-out,
    height 200ms cubic-bezier(.2,.8,.2,1), bottom 200ms cubic-bezier(.2,.8,.2,1),
    transform 250ms cubic-bezier(.2,.8,.2,1);
}
.voice-lock-afford.is-pause:active { transform: scale(.92); }
/* Glyph choreography = the LockPause Lottie scrubbed by lockness L (the
   modern LockView drives lockingView.currentProgress with L directly):
   shackle untilts by L=.17 and sinks into the body, both gone by L=.47;
   the arrow rides ~21px UP into the body and exits at the same point; from
   L=.43 to .77 the body splits into two pause bars rotating in from
   horizontal. --vl-p is that bar-morph progress. After commit the SAME
   bars are the pause control; paused swaps them for the resume triangle. */
.voice-lock-afford {
  --vl-p: clamp(0, calc((var(--voice-lockness, 0) - 0.433) / 0.334), 1);
}
.voice-lock-afford.is-pause { --voice-lockness: 1; }
.vl-glyph {
  position: absolute;
  top: 6px;
  left: 0;
  width: 40px;
  height: 40px;
  /* the whole lock stack counter-slides -16px over the approach
     (_lock.transform in TGModernConversationInputMicButton) */
  transform: translateY(calc(-16px * var(--voice-lockness, 0)));
  transition: opacity 150ms ease-out;
}
.vl-glyph svg { display: block; width: 40px; height: 40px; }
.vl-shackle {
  transform-box: view-box;
  transform: translateY(calc(-1.7px +
    13.7px * min(var(--voice-lockness, 0) * 2, 1)));
  opacity: clamp(0, calc((0.47 - var(--voice-lockness, 0)) / 0.05), 1);
}
.vl-shackle-i {
  transform-box: view-box;
  transform-origin: 14px 22px;
  transform: rotate(-10deg);
  animation: vl-shackle-idle 4s ease-in-out infinite;
}
.lock-tracking .vl-shackle-i,
.voice-lock-afford.is-pause .vl-shackle-i {
  animation: none;
  transform: rotate(calc(-10deg * clamp(0, 1 - var(--voice-lockness, 0) * 6, 1)));
}
.vl-body {
  opacity: clamp(0, calc(1 - (var(--voice-lockness, 0) - 0.4) * 20), 1);
}
.vl-keyhole { fill: var(--glass-bold); }
.vl-bars {
  opacity: clamp(0, calc(var(--vl-p, 0) * 12), 1);
  transition: opacity 150ms ease-out;
}
.vl-bars rect {
  transform-box: fill-box;
  transform-origin: center;
}
.vl-bar1 {
  transform: translateX(calc(4.5px * (1 - var(--vl-p, 0))))
    rotate(calc(-90deg * (1 - var(--vl-p, 0))));
}
.vl-bar2 {
  transform: translateX(calc(-4.5px * (1 - var(--vl-p, 0))))
    rotate(calc(-90deg * (1 - var(--vl-p, 0))));
}
.vl-arrow {
  position: absolute;
  left: 50%;
  bottom: 10px;
  width: 18px;
  height: 10px;
  margin-left: -9px;
  /* rides -16px with the lock stack plus its own ~21px exit into the body */
  transform: translateY(calc(-16px * var(--voice-lockness, 0)
    - 21px * min(var(--voice-lockness, 0) / 0.53, 1)));
  opacity: clamp(0, calc((0.5 - var(--voice-lockness, 0)) / 0.08), 1);
}
.vl-arrow svg { display: block; width: 18px; height: 10px; }
.vl-arrow-i { animation: vl-arrow-idle 4s ease-in-out infinite; }
.lock-tracking .vl-arrow-i { animation: none; }
@keyframes vl-shackle-idle {
  0%, 100% { transform: rotate(-10deg); }
  37% { transform: translateY(1.7px) rotate(0deg); }
  49% { transform: rotate(-5deg); }
  62% { transform: translateY(1.7px) rotate(0deg); }
}
@keyframes vl-arrow-idle {
  0%, 100% { transform: none; }
  37% { transform: translateY(-1.7px); }
  49% { transform: translateY(-0.9px); }
  62% { transform: translateY(-1.7px); }
}
.vl-pause {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  opacity: 0;
  transform: scale(.6);
  transition: opacity 150ms ease-out, transform 200ms cubic-bezier(.2,.8,.2,1);
}
.vl-pause svg { width: 18px; height: 18px; }
.voice-lock-afford.is-paused .vl-pause { opacity: 1; transform: none; }
.voice-lock-afford.is-paused .vl-glyph { opacity: 0; }
/* Telegram: panel enters from translationY(100) with a 0.5s damped spring
   and leaves in 0.18s toward translationY(100) scale(.2) */
.vlk-enter-active { transition: transform 500ms cubic-bezier(.18,.89,.32,1.28), opacity 120ms linear; }
.vlk-leave-active { transition: transform 180ms cubic-bezier(.4,0,.2,1), opacity 180ms linear; }
.vlk-enter-from { opacity: 0; transform: translateY(100px); }
.vlk-leave-to { opacity: 0; transform: translateY(100px) scale(.2); }
.input-area.voice-active .send-btn.voice-main-btn {
  /* VoiceBlobView smallBlob: the modern solid record disk is a FIXED 99px
     circle (220 x 0.45) with no ring or glow; the reactive halos live on
     the 220px canvas behind it. Telegram lays the recording node out at the
     panel's minimalHeight with its bottom on the capsule's bottom and centres
     the blob INSIDE it, so the disk's centre is the capsule's midline and the
     row never grows: symmetric vertical margins say exactly that (flow
     footprint stays 36, the island stays 48). The right edge deliberately
     sinks past the screen edge like Telegram's blob. */
  width: 99px;
  height: 99px;
  margin: -31.5px -31.5px -31.5px 0;
  /* rides under the finger on both axes through the 0.7/0.3 follower and
     shrinks with the RAW cancel drag (max(.4, 1 - |dx|/300)); translateX
     applies in scaled space, matching the native transform order */
  transform: translateY(var(--voice-button-y))
    scale(var(--voice-cancel-scale, 1)) translateX(var(--voice-button-x));
  box-shadow: none;
  touch-action: none;
  user-select: none;
  -webkit-user-select: none;
  transition:
    width 260ms ease-out, height 260ms ease-out,
    margin 260ms ease-out, box-shadow 260ms ease-out;
  /* Telegram springs the decoration from scale .2 damping .55 (500ms) while
     the alpha rides a short independent fade */
  animation:
    voice-main-enter 500ms cubic-bezier(.18,.89,.32,1.28) both,
    voice-main-fade 150ms ease-out both;
}
@keyframes voice-main-enter {
  from { scale: .2; }
}
@keyframes voice-main-fade {
  from { opacity: .2; }
}
.input-area.voice-active .send-btn.voice-main-btn.voice-held {
  transition-property: width, height, margin, box-shadow;
}
.input-area.voice-active .send-btn.voice-main-btn svg { width: 30px; height: 30px; }
.input-area.voice-active .send-btn-icon { width: 31px; height: 31px; }
.input-area.voice-active .send-btn.voice-main-btn.voice-send .send-btn-icon {
  /* mic -> send swap springs in from .3 (animateLock's icon swap) */
  animation: voice-icon-pop 300ms cubic-bezier(.34,1.56,.64,1) both;
}
@keyframes voice-icon-pop {
  from { scale: .3; opacity: 0; }
}
.input-area.voice-locked .send-btn.voice-main-btn {
  transform: none;
  transition: transform 250ms cubic-bezier(.2,.8,.2,1);
}
.input-area.voice-locked.voice-lock-settling .send-btn.voice-main-btn {
  transform: translateY(var(--voice-button-y))
    scale(var(--voice-cancel-scale, 1)) translateX(var(--voice-button-x));
  transition: none;
}
.voice-blob-canvas {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 220px;
  height: 220px;
  transform: translate(-50%, -50%);
  pointer-events: none;
}
/* Telegram Android does not reuse the iOS 110pt recorder geometry. Its
   RecordCircle has a 41dp solid radius, 47-55dp wave bands and a 36dp locked
   pause control. The pre-lock control is a 36x50 capsule that contracts over
   the native 57dp drag, then settles down to the 36px pause island. */
html.tg-android .input-area.voice-active .send-btn.voice-main-btn {
  width: 82px;
  height: 82px;
  margin: -23px -23px -23px 0;
  box-shadow:
    0 0 0 var(--voice-solid-grow, 0px) var(--accent),
    0 0 0 1px color-mix(in srgb, var(--text) 25%, transparent),
    0 10px 32px color-mix(in srgb, var(--accent) 45%, transparent);
  transition-property: width, height, margin;
}
html.tg-android .input-area.voice-active .send-btn.voice-main-btn.voice-held svg {
  width: 24px;
  height: 24px;
}
html.tg-android .input-area.voice-active .send-btn.voice-main-btn.voice-held .send-btn-icon {
  width: 25px;
  height: 25px;
}
html.tg-android .input-area.voice-active .send-btn.voice-main-btn.voice-send svg {
  width: 32px;
  height: 32px;
}
html.tg-android .input-area.voice-active .send-btn.voice-main-btn.voice-send .send-btn-icon {
  width: 33px;
  height: 33px;
}
html.tg-android .voice-blob-canvas {
  width: 264px;
  height: 264px;
}
html.tg-android .voice-lock-afford {
  right: 6px;
  bottom: calc(92px + 63px * var(--voice-lockness, 0));
  width: 36px;
  height: calc(50px - 14px * var(--voice-lockness, 0));
  border-radius: 18px;
  transform: none;
}
html.tg-android .voice-lock-afford.is-pause {
  right: 6px;
  bottom: 84px;
  width: 36px;
  height: 36px;
  transform: none;
  /* ChatActivityEnterView.startLockTransition(): the contracted island holds
     at the top for 100ms, then lockAnimatedTranslation returns over 350ms. */
  transition:
    color 150ms ease-out, background 150ms ease-out,
    height 250ms cubic-bezier(.23, 1, .32, 1),
    bottom 350ms ease-in-out 100ms;
}
html.tg-android .voice-lock-afford .vl-glyph {
  left: -2px;
}
html.tg-android .input-area.voice-lock-settling .voice-lock-afford.is-pause {
  transform: none;
  /* the settling rule above outranks the Android island cascade; restate the
     100ms hold + 350ms drop so lock commit keeps the native timing */
  transition:
    color 150ms ease-out, background 150ms ease-out,
    height 250ms cubic-bezier(.23, 1, .32, 1),
    bottom 350ms ease-in-out 100ms;
}
html.tg-android .input-area.voice-active .send-btn.voice-main-btn {
  transform: translate3d(var(--voice-button-x), 0, 0);
}
html.tg-android .input-area.voice-locked .send-btn.voice-main-btn,
html.tg-android .input-area.voice-locked.voice-lock-settling .send-btn.voice-main-btn {
  transform: none;
}
/* Telegram centres the recording indicator on the PANEL's width, not on the
   short capsule (ChatTextInputPanelNode: the cancel indicator's x comes from
   baseWidth), and lets the blob cover whatever ends up under it. */
.voice-record-cancel {
  position: absolute;
  left: 50%;
  top: calc(100% - 24px);
  transform: translate(-50%, -50%);
  border: 0;
  background: transparent;
  color: var(--accent-text);
  font: inherit;
  font-size: 17px;
  cursor: pointer;
  min-height: 44px;
  padding: 0 12px;
}
.voice-trash-flight {
  width: 34px;
  height: 34px;
  color: var(--destructive);
  display: grid;
  place-items: center;
  /* one 760ms timeline shared by every phase (fly-in, lid, drop, squash,
     ring, exit) - the native bin gag runs ~0.7-0.8s; 220ms was invisible */
  animation: voice-trash-arrive 760ms cubic-bezier(.2,.8,.25,1) both;
  position: relative;
}
.voice-trash-flight svg {
  width: 31px;
  height: 31px;
  overflow: visible;
}
.voice-trash-lid {
  transform-box: fill-box;
  transform-origin: 20% 90%;
  animation: voice-trash-lid 760ms cubic-bezier(.2,.75,.25,1) both;
}
.voice-trash-body {
  transform-box: fill-box;
  transform-origin: 50% 100%;
  animation: voice-trash-body 760ms cubic-bezier(.2,.8,.25,1) both;
}
.voice-trash-drop {
  position: absolute;
  z-index: 1;
  width: 5px;
  height: 13px;
  border: 2px solid currentColor;
  border-radius: 4px;
  animation: voice-trash-drop 760ms cubic-bezier(.25,.7,.25,1) both;
}
.voice-trash-drop::after {
  content: '';
  position: absolute;
  left: 50%;
  bottom: -5px;
  width: 7px;
  height: 2px;
  border-radius: 2px;
  background: currentColor;
  transform: translateX(-50%);
}
.voice-trash-flight::after {
  content: '';
  position: absolute;
  inset: 4px;
  border: 1px solid currentColor;
  border-radius: 50%;
  animation: voice-trash-ring 760ms ease-out both;
}
.voice-cancel-placeholder {
  margin-left: 9px;
  color: var(--text3);
  font-size: 16px;
  animation: voice-trash-placeholder 760ms ease-out both;
}
.input-area.voice-canceling .send-btn.voice-main-btn {
  width: 36px;
  height: 36px;
  margin: 0;
  transform: none;
  box-shadow: none;
  opacity: 1;
  transition-duration: 90ms;
}
.send-btn.voice-main-btn.is-pending { transition-duration: 90ms; }
.input-area.voice-canceling .send-btn.voice-main-btn svg { width: 19px; height: 19px; }
.input-area.voice-canceling .send-btn-icon { width: 19px; height: 19px; }
@keyframes voice-trash-arrive {
  0% { opacity: 0; transform: translateX(62px) rotate(20deg) scale(.62); }
  12% { opacity: 1; transform: translateX(-5px) rotate(-7deg) scale(1.08); }
  19% { transform: translateX(2px) rotate(2deg) scale(.98); }
  24%, 80% { opacity: 1; transform: none; }
  100% { opacity: 0; transform: translateY(6px) scale(.55); }
}
@keyframes voice-trash-lid {
  0%, 14% { transform: none; }
  24%, 48% { transform: translate(-1px, -2px) rotate(-38deg); }
  58% { transform: translateY(1px) rotate(7deg); }
  64%, 100% { transform: none; }
}
@keyframes voice-trash-body {
  0%, 48% { transform: none; }
  58% { transform: translateY(2px) scaleY(.88); }
  68% { transform: translateY(-1px) scaleY(1.04); }
  74%, 100% { transform: none; }
}
@keyframes voice-trash-drop {
  0%, 10% { opacity: 0; transform: translate(42px, -18px) rotate(32deg) scale(.8); }
  17% { opacity: 1; transform: translate(30px, -22px) rotate(24deg) scale(.92); }
  45% { opacity: 1; transform: translate(1px, 2px) rotate(2deg) scale(.52); }
  52%, 100% { opacity: 0; transform: translate(0, 8px) scale(.25); }
}
@keyframes voice-trash-ring {
  0%, 60% { opacity: 0; transform: scale(.4); }
  72% { opacity: .5; transform: scale(1.12); }
  86%, 100% { opacity: 0; transform: scale(1.6); }
}
@keyframes voice-trash-placeholder {
  0%, 55% { opacity: 0; transform: translateX(8px); }
  78%, 100% { opacity: 1; transform: none; }
}

.char-count {
  position: absolute;
  top: -22px;
  right: 4px;
  font-size: 12px;
  font-variant-numeric: tabular-nums;
  color: var(--text3);
  pointer-events: none;
}
.char-count.over { color: var(--destructive); }

/* Shared message-edit strip over the composer (TG idiom). Max-height reveal on
   the clip: a grid 0fr/1fr row collapses to zero as a flex item here (FF). */
/* The strip lives INSIDE the field capsule, above the text: the islands beside
   the capsule stay bottom-aligned with the text row (Telegram iOS grows the
   field for its accessory panel, not the whole bar), so the strip takes the
   capsule's insets and follows its edges when they move. */
.composer-edit-bar {
  position: relative;
  flex: 1 1 100%;
  min-width: 0;
  margin: 0 var(--comp-gap) 0 var(--comp-lead);
  transition: margin var(--comp-dur) var(--comp-ease);
}
@media (prefers-reduced-motion: reduce) {
  .composer-edit-bar, .input-wrap { transition: none; }
}
.ceb-clip { overflow: hidden; }
.ceb-in {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 4px 2px 6px 10px;
  border-bottom: 1px solid rgba(255,255,255,0.06);
}
.ceb-ico {
  display: flex;
  flex-shrink: 0;
  color: var(--accent-text);
}
.ceb-ico svg { width: 18px; height: 18px; }
.ceb-txt { flex: 1; min-width: 0; }
.ceb-title {
  font-size: 13px;
  font-weight: 600;
  color: var(--accent-text);
}
.ceb-snippet {
  font-size: 13px;
  color: var(--text2);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.ceb-close {
  width: 34px; height: 34px;
  flex-shrink: 0;
  border: none;
  background: transparent;
  border-radius: var(--r-pill);
  color: var(--text);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  transition: transform 130ms cubic-bezier(0.4,0,0.2,1);
  position: relative;
}
.ceb-close::before { content: ''; position: absolute; inset: -5px; border-radius: inherit; }
.ceb-close svg { width: 18px; height: 18px; opacity: 0.55; }
@media (hover: hover) {
  .ceb-close:hover:not(:disabled) svg { opacity: 1; }
}
.ceb-close:active { transform: scale(0.92); }
.ceb-close:active svg { opacity: 1; }
.ceb-close:disabled { opacity: 0.32; pointer-events: none; }
.ceb-close:focus-visible { outline: 2px solid var(--accent-text); outline-offset: 2px; }
.editbar-enter-active,
.editbar-leave-active { transition: opacity 150ms cubic-bezier(0.4,0,0.2,1); }
.editbar-enter-active .ceb-clip,
.editbar-leave-active .ceb-clip { transition: max-height 150ms cubic-bezier(0.4,0,0.2,1); }
.editbar-enter-to .ceb-clip,
.editbar-leave-from .ceb-clip { max-height: 64px; }
.editbar-enter-from .ceb-clip,
.editbar-leave-to .ceb-clip { max-height: 0; }
.editbar-enter-from,
.editbar-leave-to { opacity: 0; }
@media (prefers-reduced-motion: reduce) {
  .editbar-enter-active, .editbar-leave-active,
  .editbar-enter-active .ceb-clip, .editbar-leave-active .ceb-clip { transition: none; }
}
/* the strip grows the island upward: keep the tail message visible */
.messages-area.editing-message::after { height: calc(150px + var(--safe-bottom)); }
body.kb-open .messages-area.editing-message::after { height: calc(145px + var(--kb-h, 0px)); }

.input-wrap {
  position: relative;
  flex: 1;
  min-height: 36px;
  /* the leading island is out of the flow: the field reserves its column and
     slides with the glass edge when the island arrives or leaves, so the text
     and the caret never jump */
  margin-left: var(--comp-lead);
  transition: margin-left var(--comp-dur) var(--comp-ease);
  background: transparent;
  border: none;
  border-radius: 0;
  display: flex;
  align-items: flex-end;
  padding: 6px 10px;
}
.input-wrap.voice-input-parked {
  position: absolute;
  left: 6px;
  right: 48px;
  bottom: 6px;
  opacity: 0;
  pointer-events: none;
}

.msg-input {
  flex: 1;
  background: transparent;
  border: none;
  color: var(--text);
  font-size: 16px;
  line-height: 1.45;
  font-family: inherit;
  resize: none;
  outline: none;
  max-height: 120px;
  overflow-y: auto;
}
.msg-input::placeholder { color: var(--text3); }

.send-btn {
  width: 36px;
  height: 36px;
  border-radius: var(--r-pill);
  background: var(--accent);
  border: none;
  color: #fff;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  flex-shrink: 0;
  position: relative;
  transition: transform 130ms cubic-bezier(0.4,0,0.2,1);
}
.send-btn.voice-main-btn {
  touch-action: none;
  transition:
    transform 130ms cubic-bezier(.4,0,.2,1),
    width 180ms ease-out, height 180ms ease-out,
    margin 180ms ease-out, box-shadow 180ms ease-out,
    opacity 180ms ease-out;
}
.send-btn::before { content: ''; position: absolute; inset: -4px; border-radius: inherit; }
.send-btn:disabled { opacity: 0.2; }
.send-btn.is-pending { opacity: 1; }
/* ── The two-island composer (Telegram iOS mediaActionButtons / sendActionButtons)
   The island (mic, or Stop while the answer streams) sits flush with the bar's
   own margin, 6px outside the capsule's glass; the send disk lives INSIDE the
   capsule at its right edge. They are never both live: text arrives, the island
   leaves to the right while the glass edge follows it and the send disk grows.
   translate/scale are individual properties on purpose - `transform` stays free
   for the press scale and for the voice follower. */
/* Telegram's voice control is GLASS, not a bare accent disk (owner reference,
   2026-08-20). Same grammar as the NSFW chip: the glass is the fence and the
   accent lives INSIDE it with the same 4px inset, so the core comes out 40px -
   exactly the send disk, which is what makes the two states of this slot share
   a radius. `::before` on .send-btn is already the hit-slop pad, so the core
   takes `::after`. */
.send-btn.composer-mic {
  width: 48px;
  height: 48px;
  margin: -6px -6px -6px 0;
  isolation: isolate;
  background: var(--chrome-glass-fill-readable);
  backdrop-filter: var(--chrome-glass-filter-readable);
  -webkit-backdrop-filter: var(--chrome-glass-filter-readable);
  box-shadow: var(--composer-shadow);
  transition:
    transform 130ms cubic-bezier(.4,0,.2,1),
    width 180ms ease-out, height 180ms ease-out,
    margin 180ms ease-out, box-shadow 180ms ease-out,
    background 180ms ease-out,
    opacity 200ms ease-in-out,
    translate var(--comp-dur) var(--comp-ease);
}
.send-btn.composer-mic::after {
  content: '';
  position: absolute;
  inset: 4px;
  border-radius: var(--r-pill);
  background: var(--accent);
  z-index: -1;
  transition: inset 180ms ease-out;
}
/* Recording: the island IS the record disk, so the fence goes and the accent
   takes the whole circle, growing with it. */
.input-area.voice-active .send-btn.composer-mic:not(.composer-lead),
.input-area.voice-canceling .send-btn.composer-mic:not(.composer-lead) {
  background: var(--accent);
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
}
.input-area.voice-active .send-btn.composer-mic:not(.composer-lead)::after,
.input-area.voice-canceling .send-btn.composer-mic:not(.composer-lead)::after { inset: 0; }
/* Telegram parks the island past the panel's right edge (width + 8) and does
   not shrink it; the fade only exists because on desktop the bar's edge is
   not the screen's edge and there is nothing to hide behind. */
.composer-mic.is-away {
  translate: 68px 0;
  opacity: 0;
  pointer-events: none;
}
.send-btn.composer-mic .send-btn-icon,
.send-btn.composer-mic svg { width: 24px; height: 24px; }
.send-btn.composer-send .send-btn-icon,
.send-btn.composer-send svg { width: 20px; height: 20px; }
.send-btn.composer-send {
  position: absolute;
  width: 40px;
  height: 40px;
  right: 4px;
  bottom: 4px;
  /* rides the glass edge exactly like sendActionButtons rides capsule.maxX */
  translate: calc(var(--comp-gap) * -1) 0;
  opacity: 0;
  scale: .2;
  pointer-events: none;
  transition:
    translate var(--comp-dur) var(--comp-ease),
    scale var(--comp-dur) var(--comp-ease),
    opacity 200ms ease-in-out,
    transform 130ms cubic-bezier(.4,0,.2,1);
}
.send-btn.composer-send.is-on {
  opacity: 1;
  scale: 1;
  pointer-events: auto;
}
.send-btn.composer-send.is-on:disabled:not(.is-pending) { opacity: .2; }
/* Telegram springs the plane in from the lower left on top of the scale
   (animatePositionAdditive(-22, 18) on a 46pt button, ours is 36) */
.composer-send.is-on .send-btn-icon,
.composer-send.is-on .send-btn-spinner {
  animation: composer-send-fly var(--comp-dur) var(--comp-ease);
}
@keyframes composer-send-fly {
  from { translate: -13px 10px; }
}
.send-btn.stop.stopping { opacity: 0.55; }
.send-btn:active:not(:disabled) { transform: scale(0.9); }
.send-btn:focus-visible { outline: 2px solid var(--accent-text); outline-offset: 2px; }
.send-btn svg { width: 18px; height: 18px; }
.send-btn-icon {
  width: 18px;
  height: 18px;
  display: flex;
  align-items: center;
  justify-content: center;
  line-height: 0;
}
.send-btn-icon svg { display: block; }
.send-btn-icon.is-check svg { transform: translateY(0.5px); }
.send-btn-spinner {
  width: 16px;
  height: 16px;
  border: 2px solid rgb(255 255 255 / 35%);
  border-top-color: #fff;
  border-radius: 50%;
  animation: spin 600ms linear infinite;
}
@media (prefers-reduced-motion: reduce) {
  .send-btn-spinner { animation: none; }
  .voice-record-dot, .voice-slide-hint > *, .voice-trash-flight,
  .voice-trash-lid, .voice-trash-body, .voice-trash-drop,
  .voice-cancel-placeholder,
  .voice-trash-flight::after,
  .vl-shackle-i, .vl-arrow-i,
  .voice-transcript-btn.is-loading::after,
  .input-area.voice-active .send-btn.voice-main-btn.voice-send .send-btn-icon,
  .input-area.voice-active .send-btn.voice-main-btn { animation: none; }
  .vlk-enter-active, .vlk-leave-active,
  .voice-lock-afford.is-pause { transition-duration: 100ms; }
  .voice-transcript-clip, .voice-transcript-btn svg, .voice-transcript-a,
  .input-area.voice-active .send-btn.voice-main-btn { transition-duration: 100ms; }
  .input-area::before, .send-btn.composer-mic, .composer-mic.is-away,
  .composer-send, .composer-send.is-on { transition: none; }
  .composer-send.is-on .send-btn-icon,
  .composer-send.is-on .send-btn-spinner { animation: none; }
}

/* ═══════════════════════════════════════════
   INPUT BLOCKER BAR (no credits / channel sub)
═══════════════════════════════════════════ */
/* Takes the input island's place while sending is blocked; mirrors
   .input-area's geometry and glass exactly so the island reads as the
   same element swapping content, not a layout change. */
.input-blocker-bar {
  position: fixed;
  bottom: calc(6px + var(--safe-bottom));
  left: 14px;
  right: 14px;
  padding: 6px 6px 6px 16px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  background: var(--chrome-glass-fill-readable);
  backdrop-filter: var(--chrome-glass-filter-readable);
  -webkit-backdrop-filter: var(--chrome-glass-filter-readable);
  border: none;
  border-radius: var(--r-island);
  box-shadow: var(--chrome-glass-shadow);
  z-index: 20;
  font-size: 14px;
  font-weight: 500;
  color: var(--text);
}
.input-blocker-bar span { min-width: 0; }
/* compact primary CTA of the bar (grammar: 40px pill, 14/600) */
.input-blocker-bar button {
  min-height: 40px;
  padding: 0 18px;
  background: var(--accent);
  border: none;
  color: #fff;
  border-radius: var(--r-pill);
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
  font-family: inherit;
  flex-shrink: 0;
  transition: transform 130ms cubic-bezier(0.4, 0, 0.2, 1), background 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
@media (hover: hover) {
  .input-blocker-bar button:hover { background: color-mix(in srgb, var(--accent) 88%, #fff); }
}
.input-blocker-bar button:active { transform: scale(0.97); }
.input-blocker-bar button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* ═══════════════════════════════════════════
   NSFW CHIP
═══════════════════════════════════════════ */
/* chip toggle (grammar): pill, 36px visual, 0 14px, 13/600; selected = solid
   fill + heavier label, never colour alone */
.nsfw-chip {
  height: 36px;
  padding: 0 14px;
  border-radius: var(--r-pill);
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
  border: none;
  background: var(--glass-bold);
  color: var(--text2);
  font-family: inherit;
  transition: transform 130ms cubic-bezier(0.4, 0, 0.2, 1), background 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
.nsfw-chip:active { transform: scale(0.94); }
.nsfw-chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.nsfw-chip.on { background: var(--accent); color: #fff; font-weight: 700; }

/* ═══════════════════════════════════════════
   CHAT GLASS BAR — floating liquid-glass islands over the messages
   (circle back chip · center pill with avatar/name/status · NSFW · menu)
═══════════════════════════════════════════ */
.chat-glass-bar {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  z-index: 20;
  display: flex;
  align-items: center;
  gap: 8px;
  padding: calc(6px + var(--tg-top)) 16px 8px;
  pointer-events: none;
  /* shared chrome state changes may soften this bar without moving it */
  transition: opacity 0.15s cubic-bezier(0.4, 0, 0.2, 1);
}
.chat-glass-bar > * { pointer-events: auto; }

/* Message date dividers (glass pill, iOS chrome over content). Metrics from
   Telegram-iOS ChatMessageDateHeader: 13pt medium, 22pt pill, 6pt side
   insets, 34pt row (spec 2026-09-08-chat-date-navigation-design §9). In the
   solo chat the pill is a BUTTON (in place = calendar; torn off its day and
   riding under the bar = start of that day, see the sticky block below),
   hence the reset and the 40px hit band. */
.day-divider-pill {
  position: relative;
  display: inline-flex;
  align-items: center;
  height: 22px;
  line-height: 22px;
  font-family: inherit;
  font-size: 13px;
  font-weight: 500;
  color: var(--text2);
  padding: 0 7px;
  border: none;
  border-radius: var(--r-pill);
  background: var(--chrome-glass-fill-readable);
  backdrop-filter: var(--chrome-glass-filter-readable);
  -webkit-backdrop-filter: var(--chrome-glass-filter-readable);
  box-shadow: var(--chrome-glass-shadow-thin);
  -webkit-tap-highlight-color: transparent;
  transition: transform 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* rooms still render the divider as a span: only the button form is pressable */
button.day-divider-pill { cursor: pointer; }
/* 40px target that stays out of the neighbouring rows' hold gesture */
button.day-divider-pill::before { content: ''; position: absolute; inset: -9px -6px; border-radius: inherit; }
/* glass grows under the finger (glassPress); the shrink is the no-blur twin */
body.no-blur button.day-divider-pill:active { transform: scale(0.97); }
button.day-divider-pill:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.wpe-msgs .day-divider-pill { pointer-events: none; }
/* Монолог Colophon внутри бубла модели: строка-заголовок + фолд с мыслью над
   репликой. Живёт и в зеркале swipe-карусели (те же классы, та же анимация):
   ResizeObserver зеркала ведёт высоту стейджа по кадрам фолда, поэтому бубл
   дышит синхронно с раскрытием. Появление/уход всей секции (стрим-прилёт
   монолога, reset при регене) - тем же grid-приёмом через transition monoin.
   Внутри бубла - никакого фона/бордера: регистр мысли задают тон и кегль. */
.mono-entry {
  display: grid;
  grid-template-rows: 1fr;
}
.mono-entry-in {
  overflow: hidden;
  min-height: 0;
}
.monoin-enter-active, .monoin-leave-active {
  transition: grid-template-rows 240ms cubic-bezier(0.4, 0, 0.2, 1),
              opacity 200ms cubic-bezier(0.4, 0, 0.2, 1);
}
.monoin-enter-from, .monoin-leave-to {
  grid-template-rows: 0fr;
  opacity: 0;
}
.mono-head {
  display: flex;
  align-items: center;
  gap: 5px;
  padding: 0 0 3px;
  margin: 0;
  background: none;
  border: none;
  cursor: pointer;
  font-family: inherit;
  /* 12.5px при дефолтных 15px --msg-fs; em ведёт кегль за настройкой размера */
  font-size: 0.83em;
  font-weight: 600;
  line-height: 1.3;
  color: var(--text3);
  transition: color 130ms cubic-bezier(0.4, 0, 0.2, 1),
              transform 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* отклик на pointerdown, не на отпускание */
.mono-head:active { transform: scale(0.96); color: var(--text2); }
.mono-head:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }
.mono-head svg {
  width: 1.12em;
  height: 1.12em;
  opacity: 0.6;
  transition: transform 240ms cubic-bezier(0.4, 0, 0.2, 1);
}
.mono-sec.open .mono-head svg { transform: rotate(180deg); }
.mono-fold {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 240ms cubic-bezier(0.4, 0, 0.2, 1);
}
.mono-sec.open .mono-fold { grid-template-rows: 1fr; }
.mono-fold-in {
  overflow: hidden;
  min-height: 0;
}
.mono-text {
  /* 13px при дефолтных 15px --msg-fs; em ведёт кегль за настройкой размера */
  font-size: 0.87em;
  line-height: 1.45;
  /* на полступени тише text2: приглушённые действия (.rp-action.dim) тоже
     text2, одинаковый тон сливал мысль с действиями */
  color: color-mix(in srgb, var(--text2) 40%, var(--text3));
  padding: 1px 0 9px;
  opacity: 0;
  transform: translateY(-4px);
  transition: opacity 200ms cubic-bezier(0.4, 0, 0.2, 1),
              transform 240ms cubic-bezier(0.4, 0, 0.2, 1);
}
.mono-sec.open .mono-text {
  opacity: 1;
  transform: none;
}
/* Зеркало карусели МГНОВЕННОЕ по построению: оно источник конечной высоты для
   stageH, анимации в нём дали бы недомеренный таргет */
.swipe-mirror .mono-fold,
.swipe-mirror .mono-text,
.swipe-mirror .mono-head svg,
.swipe-mirror .monoin-enter-active,
.swipe-mirror .monoin-leave-active { transition: none !important; }
/* Видимая высота стейджа догоняет новый stageH той же кривой, что фолд секции
   (вешается компонентом только на время тогла - свайпы/стрим остаются
   мгновенными; :not(.dragging) - начатый в окне тогла драг обязан вести
   высоту 1:1 без хвоста транзишена) */
.swipe-stage.mono-hsync:not(.dragging) { transition: height 240ms cubic-bezier(0.4, 0, 0.2, 1); }

@media (prefers-reduced-motion: reduce) {
  .mono-fold, .mono-text, .mono-head, .mono-head svg,
  .monoin-enter-active, .monoin-leave-active,
  .swipe-stage.mono-hsync:not(.dragging) { transition: none; }
}

.day-divider {
  display: flex;
  justify-content: center;
  margin: 6px 0;
}
/* ── Sticky day header (daystick.js) ──
   The divider IS the floating date: Telegram iOS ChatMessageDateHeader on top
   of position: sticky. .day-group is the day's containing block (a flex
   column, so pills and runs stay flex items and their margins do not
   collapse), the header rides under the chat bar while its day passes and is
   pushed out by the next day's header (ListView: min(max(top, natural),
   groupEnd - height)). States are stamped by daystick.js, never by Vue:
   [data-stuck] on the divider torn 17px+ off its natural place (TG
   stickDistanceFactor >= 0.5) and [data-dayflash] on the scroller while the
   user drags / the momentum runs and 0.3 s after (TG headerItemsAreFlashing).
   A torn header without the flash fades 0.4 s and then goes visibility:
   hidden: untouchable and unfocusable, but tappable while still visible (TG
   hitTest: nil at alpha 0 only); in 0.3 s. The band under the bar is shared with
   the toast stack: the TORN pill ducks under a live toast by --toast-shift as
   a relative offset of the pill, never as part of the sticky inset (an inset
   below the content edge displaces the FIRST date at the start of the chat,
   which tears it, hides it and turns its tap into a jump: the owner's bug,
   back for the whole life of an image-generation toast). */
.day-group { display: flex; flex-direction: column; }
.day-group > .day-sep {
  position: sticky;
  /* the pill lands 4px under the bar (header-h + tg-top + 4, minus its own 6px
     padding); sticky insets are measured from the scroller's content edge, so
     the scroller's top padding is taken back out (the result is -14px: a date
     at the content edge is never displaced) */
  top: calc(var(--header-h) + var(--tg-top) - 2px - var(--msgs-pad-top, 0px));
  z-index: 18;
  margin: 0;
  padding: 6px 0;
  /* the row is a full-width strip riding OVER the messages while torn: only
     the pill takes taps, the strip lets them through to the row beneath */
  pointer-events: none;
}
/* durations belong to the TARGET state: 300ms in (base and flash rules),
   400ms out (the torn rule); visibility flips at the END of the fade-out and
   at the START of the fade-in, so a pill that can be seen can be tapped and a
   pill that cannot takes neither taps nor keyboard focus */
.day-group > .day-sep .day-divider-pill {
  pointer-events: auto;
  transition: opacity 300ms cubic-bezier(0.4, 0, 0.2, 1), transform 130ms cubic-bezier(0.4, 0, 0.2, 1),
    box-shadow 150ms cubic-bezier(0.4, 0, 0.2, 1), background-color 150ms cubic-bezier(0.4, 0, 0.2, 1),
    top 150ms cubic-bezier(0.4, 0, 0.2, 1), visibility 0s;
}
/* torn off its day: the floating look (TG fades dateFloatingBackground in with
   the stick factor; here a step at the tear, softened by the transition). The
   rim stays the thin chip rim (a 22px pill has no room for the full stack),
   only the elevation grows. */
.day-group > .day-sep[data-stuck] .day-divider-pill {
  opacity: 0;
  visibility: hidden;
  box-shadow: var(--chrome-glass-rim-thin), var(--chrome-glass-elev);
  top: var(--toast-shift, 0px);
  transition-duration: 400ms, 130ms, 150ms, 150ms, 150ms, 0s;
  transition-delay: 0s, 0s, 0s, 0s, 0s, 400ms;
}
.messages-area[data-dayflash] .day-sep[data-stuck] .day-divider-pill {
  opacity: 1;
  visibility: visible;
  transition-duration: 300ms, 130ms, 150ms, 150ms, 150ms, 0s;
  transition-delay: 0s;
}
.glass-chip {
  /* the glass-press light is an ::after inside the chip */
  position: relative;
  height: 40px;
  min-width: 40px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: none;
  border-radius: var(--r-pill);
  background: var(--chrome-glass-fill);
  backdrop-filter: var(--chrome-glass-filter);
  -webkit-backdrop-filter: var(--chrome-glass-filter);
  box-shadow: var(--chrome-glass-shadow-thin);
  color: var(--text2);
  cursor: pointer;
  flex-shrink: 0;
  font-family: inherit;
}
/* stroke glyphs (X, back arrow) cross themselves: a translucent stroke
   doubles up at the crossing, so the glyph is solid --text dimmed by opacity
   (frontend.md, "semi-transparent stroke icons"); text chips keep --text2 */
.glass-chip svg { width: 20px; height: 20px; }
.glass-chip:not(.on) svg { color: var(--text); opacity: var(--chip-glyph-alpha); transition: opacity 130ms cubic-bezier(0.4,0,0.2,1); }
.glass-chip { transition: transform 130ms cubic-bezier(0.4,0,0.2,1), color 130ms cubic-bezier(0.4,0,0.2,1), scale 120ms ease-out; }
@media (hover: hover) {
  .glass-chip:hover:not(:disabled) { color: var(--text); }
  .glass-chip:hover:not(:disabled) svg { opacity: 1; }
}
/* icon disc presses .92; the labeled .pad chip is a chip toggle and presses .94 */
.glass-chip:active { color: var(--text); }
.glass-chip:active svg { opacity: 1; }
/* a destructive glyph is a solid colour already: no dimming */
.glass-chip.destructive svg { color: var(--destructive); opacity: 1; }
body.no-blur .glass-chip:active { transform: scale(0.92); }
body.no-blur .glass-chip.pad:active { transform: scale(0.94); }
.glass-chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.glass-chip:disabled { opacity: 0.32; pointer-events: none; }
/* toggled state (search chip): tonal fill, never a stroke */
.glass-chip.on { background: var(--accent-soft); color: var(--accent-text); }
/* The one-tap slide to the top (totop.js) moves two frozen screens by
   transform for 300ms, and WebKit re-rasters a backdrop-filter on every frame
   of it (ADR-0040). The glass in a feed sits over the bare page, where the blur
   has nothing to blur: a copy drops the filter and keeps the fill. A copy is on
   screen by definition, so nothing in it skips rendering while it travels out. */
.totop-ghost .glass-chip,
.totop-ghost .search-field-control { backdrop-filter: none; -webkit-backdrop-filter: none; }
.totop-ghost * { content-visibility: visible !important; }

/* Search contract (ADR-0011): tonal inline pill, semantic input, leading
   affordance, explicit clear/progress and mobile Cancel. Toolbar search is
   the glass variant; a plain text field is never used for collection search. */
.search-session { display: flex; align-items: center; min-width: 0; }
.search-field-shell { position: relative; flex: 1; min-width: 0; }
.search-field-leading {
  position: absolute;
  left: 14px;
  top: 50%;
  transform: translateY(-50%);
  display: inline-flex;
  color: var(--text);
  opacity: 0.4;
  pointer-events: none;
  z-index: 1;
}
.search-field-leading svg { width: 18px; height: 18px; display: block; }
.search-field-control {
  display: block;
  width: 100%;
  height: 44px;
  padding: 0 42px 0 42px;
  border: none;
  border-radius: var(--r-pill);
  outline: none;
  background: var(--chrome-glass-fill-readable);
  backdrop-filter: var(--chrome-glass-filter-readable);
  -webkit-backdrop-filter: var(--chrome-glass-filter-readable);
  box-shadow: var(--chrome-glass-shadow);
  color: var(--text);
  font: inherit;
  font-size: 15px;
  appearance: none;
  -webkit-appearance: none;
  transition: background 130ms cubic-bezier(0.4,0,0.2,1);
}
.search-field-control::-webkit-search-cancel-button { display: none; }
.search-field-control::placeholder { color: var(--text3); }
/* focus never swaps the material (ADR-0011): the field is shell chrome */
.search-field-control:focus { background: var(--chrome-glass-fill-readable); }
.search-field-shell.has-clear.has-progress .search-field-control { padding-right: 82px; }
.search-field-clear {
  position: absolute;
  top: 2px;
  right: 2px;
  width: 40px;
  height: 40px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  border: none;
  border-radius: var(--r-pill);
  background: transparent;
  color: var(--text);
  cursor: pointer;
}
.search-field-clear:hover { background: var(--glass-bold); color: var(--text); }
.search-field-clear:active { background: var(--accent-soft); color: var(--accent-text); transform: scale(0.94); }
.search-field-clear:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.search-field-clear svg { width: 14px; height: 14px; display: block; opacity: 0.55; }
.search-field-clear:hover svg,
.search-field-clear:active svg { opacity: 1; }
.search-field-progress {
  position: absolute;
  top: 15px;
  right: 15px;
  width: 14px;
  height: 14px;
  border: 2px solid var(--glass-bold);
  border-top-color: var(--accent);
  border-radius: 50%;
  animation: spin 0.6s linear infinite;
  pointer-events: none;
}
.search-field-shell.has-clear .search-field-progress { right: 44px; }
.chat-list-search-close.glass-chip {
  width: 0;
  min-width: 0;
  height: 44px;
  margin-left: 0;
  overflow: hidden;
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  color: var(--text);
  transform: scale(0.86);
  transition:
    width 180ms cubic-bezier(0.4,0,0.2,1),
    min-width 180ms cubic-bezier(0.4,0,0.2,1),
    margin-left 180ms cubic-bezier(0.4,0,0.2,1),
    opacity 130ms cubic-bezier(0.4,0,0.2,1),
    transform 180ms cubic-bezier(0.4,0,0.2,1),
    visibility 180ms;
}
.chat-list-search-close.glass-chip svg { opacity: 0.55; }
.search-session.searching .chat-list-search-close.glass-chip {
  width: 44px;
  min-width: 44px;
  margin-left: 8px;
  opacity: 1;
  visibility: visible;
  pointer-events: auto;
  transform: none;
}

/* ── Search inside a conversation ─────────────────────────────────────────
   The title pill IS the search field: one persistent glass island whose
   content crossfades while the side chips give way and a 44px close island
   grows in. Telegram iOS swaps navigation-bar content in one symmetric 0.2s
   transition (NavigationBarImpl.setContentNode); in island chrome the swap
   is this morph. Because there is no second header element, title and
   search geometry cannot drift apart. Every animated property here is
   width/margin/opacity on chips or plain content opacity INSIDE the glass:
   the pill's own backdrop-filter never gains an animated ancestor. */
.chat-bar-collapse {
  position: relative;
  display: grid;
  grid-template-columns: 1fr;
  flex: none;
  transition:
    grid-template-columns 200ms cubic-bezier(0.4, 0, 0.2, 1),
    margin 200ms cubic-bezier(0.4, 0, 0.2, 1);
}
.chat-bar-collapse-in {
  display: flex;
  align-items: center;
  min-width: 0;
  overflow: hidden;
  /* The clip is for the collapse wipe only, but overflow:hidden cuts at the
     padding box, so at rest it also sliced the chip's drop shadow into a
     visible square over any light backdrop (wallpapers). The padding gives
     the shadow room inside the clip; the negative margin hands that room
     back to the layout, so track/gap geometry is unchanged. The padded box
     overlaps its neighbors: it must not intercept their taps. */
  padding: 20px;
  margin: -20px;
  pointer-events: none;
  transition:
    opacity 200ms cubic-bezier(0.4, 0, 0.2, 1),
    visibility 200ms;
}
.chat-bar-collapse-in > * { pointer-events: auto; }
/* chips right of the pill anchor to their right edge, so the stretching
   pill appears to slide over them instead of pushing clipped leftovers */
.chat-bar-collapse-end .chat-bar-collapse-in { justify-content: flex-end; }
.message-search-mode .chat-bar-collapse { grid-template-columns: 0fr; }
/* a zero-width track still owns flex gaps on both sides: the margin folds
   the neighboring 8px gap away so the pill lands exactly on the bar inset */
.message-search-mode .chat-bar-collapse:not(.chat-bar-collapse-end) { margin-right: -8px; }
.message-search-mode .chat-bar-collapse-end { margin-left: -8px; }
.message-search-mode .chat-bar-collapse-in {
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
}
.message-search-mode .chat-bar-collapse-in > * { pointer-events: none; }
.chat-title-search-trigger { position: relative; }
.chat-glass-bar:not(.message-search-mode) .chat-title-search-trigger {
  -webkit-touch-callout: none;
  -webkit-user-select: none;
  user-select: none;
  touch-action: pan-y;
  cursor: pointer;
}
.chat-title-content {
  flex: 1;
  min-width: 0;
  display: flex;
  align-items: center;
  gap: 9px;
  transition: opacity 200ms cubic-bezier(0.4, 0, 0.2, 1);
}
.message-search-mode .chat-title-content {
  opacity: 0;
  pointer-events: none;
}
/* Focus can only be raised synchronously inside the initiating pointerup if
   the input is focusable at that instant: opacity may be 0, but never
   visibility:hidden and never display:none. */
.chat-search-content {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  opacity: 0;
  pointer-events: none;
  transition: opacity 200ms cubic-bezier(0.4, 0, 0.2, 1);
}
.message-search-mode .chat-search-content {
  opacity: 1;
  pointer-events: auto;
}
/* the focus fill steps the pill up on the same 200ms as the rest of the
   morph, not in one frame */
.glass-pill.chat-title-search-trigger { transition: transform 130ms cubic-bezier(0.4, 0, 0.2, 1), background-color 200ms cubic-bezier(0.4, 0, 0.2, 1); }
.message-search-mode .chat-title-search-trigger:focus-within {
  background: color-mix(in srgb, var(--glass-bold) 70%, transparent);
}
.chat-glass-bar:not(.message-search-mode) .glass-pill.chat-title-search-trigger:active {
  transform: scale(0.97);
}
/* the input lives INSIDE the glass pill: it must carry no glass of its own
   (the family's backdrop-filter + sculpted rim). Glass inside glass is
   forbidden by §3, and here it also sat under the opacity fade of
   .chat-search-content - an ADR-0014 backdrop root - so on iOS the inner
   blur and its white rim highlights popped in one frame when the fade ended
   (owner: "белый градиент, будто внутреннее освещение") */
.chat-message-search-input.search-field-control {
  flex: 1;
  height: 44px;
  background: transparent;
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  box-shadow: none;
  caret-color: var(--accent);
}
.chat-message-search-input.search-field-control:focus {
  background: transparent;
}
/* the close island mirrors .chat-list-search-close: same separate-44px-chip
   affordance for exiting a search session, retimed to this morph's 200ms.
   visibility sits in the transition list on purpose: it interpolates as
   visible for the whole run, so the chip fades instead of vanishing. */
.chat-message-search-close.glass-chip {
  width: 0;
  min-width: 0;
  height: 44px;
  margin-left: -8px;
  overflow: hidden;
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  color: var(--text);
  transform: scale(0.86);
  transition:
    width 200ms cubic-bezier(0.4,0,0.2,1),
    min-width 200ms cubic-bezier(0.4,0,0.2,1),
    margin-left 200ms cubic-bezier(0.4,0,0.2,1),
    opacity 150ms cubic-bezier(0.4,0,0.2,1),
    transform 200ms cubic-bezier(0.4,0,0.2,1),
    visibility 200ms;
}
.message-search-mode .chat-message-search-close.glass-chip {
  width: 44px;
  min-width: 44px;
  margin-left: 0;
  opacity: 1;
  visibility: visible;
  pointer-events: auto;
  transform: none;
}
.chat-message-search-close.glass-chip svg { opacity: 0.55; }
.chat-list-search-close.glass-chip:active svg,
.chat-message-search-close.glass-chip:active svg { opacity: 1; }
/* Predictive dismissal scrubs the same morph: per-frame JS writes partial
   fr/width/opacity values onto these exact nodes, so transitions must yield
   while a finger owns the progress. Release re-arms them and the class
   endpoints finish the motion from the live frame. */
body.ms-scrubbing .chat-bar-collapse,
body.ms-scrubbing .chat-bar-collapse-in,
body.ms-scrubbing .chat-title-content,
body.ms-scrubbing .chat-search-content,
body.ms-scrubbing .chat-message-search-close.glass-chip { transition: none; }
body.no-blur .message-search-mode .chat-title-search-trigger:focus-within { background: var(--glass-bold); }

.chat-search-mark {
  padding: 0 1px;
  border-radius: 4px;
  background: color-mix(in srgb, var(--accent) 42%, transparent);
  color: inherit;
}
.bubble.user .chat-search-mark { background: rgba(255,255,255,0.28); }
/* the jump highlight comes and goes on a curve on EVERY bubble (search
   target, pin, date, media): iOS applies highlightedFill and fades it out
   over 0.3 s; the arrival is quicker than the exit, nothing snaps */
.messages-area .bubble {
  transition: background-color 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
.chat-search-target-pulse .bubble {
  transition-duration: 150ms;
}
.chat-search-target-pulse .bubble.model {
  background: color-mix(in srgb, var(--accent-soft) 52%, var(--glass));
}
/* own bubble: a darker step of the accent (Telegram's outgoing highlightedFill),
   never a whitening that drops the white text below 3:1 */
.chat-search-target-pulse .bubble.user {
  background: color-mix(in srgb, var(--accent) 84%, black);
}
.chat-search-panel {
  position: fixed;
  left: 14px;
  bottom: calc(6px + var(--safe-bottom));
  z-index: 20;
  display: flex;
  align-items: center;
  pointer-events: none;
  background: transparent;
  box-shadow: none;
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
}
.chat-search-counter-island {
  min-width: 40px;
  max-width: calc(100vw - 108px);
  height: 40px;
  display: inline-flex;
  align-items: center;
  padding: 0 16px;
  border-radius: var(--r-pill);
  background: var(--chrome-glass-fill-readable);
  backdrop-filter: var(--chrome-glass-filter-readable);
  -webkit-backdrop-filter: var(--chrome-glass-filter-readable);
  box-shadow: var(--chrome-glass-shadow-thin);
}
.chat-search-counter {
  display: grid;
  align-items: center;
  color: var(--text);
  font-size: 15px;
  font-weight: 500;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.chat-search-counter > * { grid-area: 1 / 1; }
.chat-search-counter-muted {
  color: var(--text);
  opacity: 0.55;
}
/* Telegram's history controls spring from a small scale. Transform-only
   motion keeps the glass backdrop chain intact in iOS WebKit. */
.message-search-control-enter-active {
  transition: transform 300ms cubic-bezier(0.25, 1, 0.4, 1);
}
.message-search-control-leave-active {
  transition: transform 160ms cubic-bezier(0.4, 0, 0.2, 1);
}
.chat-search-panel.message-search-control-enter-active,
.chat-search-panel.message-search-control-leave-active {
  transform-origin: left bottom;
}
.chat-search-nav-buttons.message-search-control-enter-active,
.chat-search-nav-buttons.message-search-control-leave-active {
  transform-origin: center bottom;
}
.message-search-control-enter-from,
.message-search-control-leave-to {
  transform: translateY(4px) scale(0.2);
}
/* Counter changes carry the direction of the selected history hop. Text may
   cross-fade; unlike the glass wrapper it is not a backdrop-filter ancestor. */
.message-search-count-enter-active,
.message-search-count-leave-active {
  transition:
    opacity 140ms cubic-bezier(0.4, 0, 0.2, 1),
    transform 140ms cubic-bezier(0.4, 0, 0.2, 1);
}
.chat-search-counter-island.direction-older .message-search-count-enter-from,
.chat-search-counter-island.direction-newer .message-search-count-leave-to {
  opacity: 0;
  transform: translateY(5px);
}
.chat-search-counter-island.direction-older .message-search-count-leave-to,
.chat-search-counter-island.direction-newer .message-search-count-enter-from {
  opacity: 0;
  transform: translateY(-5px);
}
/* One bottom baseline for the whole search band: the arrow stack's bottom
   equals the counter island's bottom, so the down arrow and the counter share
   one 40px row (mirrors the composer's 14px/6px insets on both sides).
   Telegram lifts its arrows 20px only to clear a FULL-WIDTH input panel; the
   content-sized island leaves nothing to clear on the right, and a lifted
   stack reads as a second coordinate system. Horizontal collision is
   impossible: the island's max-width reserves the arrows' column. */
.chat-search-nav-buttons {
  position: fixed;
  right: 14px;
  bottom: calc(6px + var(--safe-bottom));
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  z-index: 20;
  pointer-events: auto;
}
.chat-search-nav-btn {
  position: relative;
  width: 40px;
  height: 40px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  border: 0;
  border-radius: var(--r-pill);
  background: var(--chrome-glass-fill);
  backdrop-filter: var(--chrome-glass-filter);
  -webkit-backdrop-filter: var(--chrome-glass-filter);
  box-shadow: var(--chrome-glass-shadow-thin);
  color: var(--text);
  transition: transform 130ms cubic-bezier(0.4, 0, 0.2, 1),
              background-color 130ms cubic-bezier(0.4, 0, 0.2, 1),
              scale 120ms ease-out;
}
.chat-search-nav-btn svg {
  width: 20px;
  height: 20px;
  display: block;
  opacity: 0.82;
}
.chat-search-nav-btn:active:not(:disabled) { background: var(--glass-bold); }
body.no-blur .chat-search-nav-btn:active:not(:disabled) { transform: scale(0.92); }
.chat-search-nav-btn:disabled { color: var(--text); opacity: 1; pointer-events: none; }
.chat-search-nav-btn:disabled svg { opacity: 0.28; }
.chat-search-nav-btn:focus-visible,
.chat-message-search-clear:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}
/* Jump to date lives in the bottom search band, where Telegram iOS keeps it
   (owner, 2026-09-14): the calendar is the leading 40px slot of the count
   island, present from the moment search opens, and the count unfolds next
   to it with the first response (0fr -> 1fr, the .chat-bar-collapse idiom).
   The button is a plain transparent pressable INSIDE the glass island, in
   the .search-field-clear dialect: no glass of its own, no transition, no
   positioned pseudo (the pill-hosted version drew a second rounded box
   around the glyph in iOS WebKit, not reproduced by Linux WebKit). */
.chat-search-counter-island.has-cal {
  padding: 0;
  pointer-events: auto;
  transition: padding-right 200ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* the trailing inset rides on the island, not on the folding counter: a
   padded grid item cannot shrink below its own padding, so a 0fr track
   would still hold 16px and the empty island would not be a 40px disc */
.chat-search-counter-island.has-cal.has-count { padding-right: 16px; }
.chat-search-cal {
  flex-shrink: 0;
  width: 40px;
  height: 40px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  border: none;
  border-radius: var(--r-pill);
  background: transparent;
  color: var(--text);
  cursor: pointer;
}
.chat-search-cal svg { width: 20px; height: 20px; display: block; opacity: 0.82; }
.chat-search-cal:active { background: var(--glass-bold); }
.chat-search-cal:active svg { opacity: 1; }
.chat-search-cal:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.chat-search-count-wrap {
  display: grid;
  grid-template-columns: 0fr;
  transition: grid-template-columns 200ms cubic-bezier(0.4, 0, 0.2, 1);
}
.chat-search-counter-island.has-count .chat-search-count-wrap { grid-template-columns: 1fr; }
.chat-search-count-wrap > .chat-search-counter {
  min-width: 0;
  overflow: hidden;
}

/* Jump-to-date sheet (web/static/daynav.js): Telegram iOS's calendar inside
   the house sheet. The bar is an iOS sheet bar (Close, title, "to the start"
   as a text action); months stack in their own scroller, no arrows; a day is
   bare text (with messages --text, without --text3 but tappable, today accent
   and semibold, future disabled), the tonal circle shows only under the
   finger. 7 columns must fit 320px: 8px side gutters give 43px cells. */
.modal-sheet.daynav-sheet { padding: 14px 8px 12px; }
.daynav-bar {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  padding: 0 4px 4px;
}
.daynav-bar .text-btn { min-height: 40px; }
.daynav-close { justify-self: start; }
.daynav-start { justify-self: end; }
.daynav-bar-title { font-size: 17px; font-weight: 600; color: var(--text); text-align: center; }
.daynav-note { text-align: center; color: var(--text3); font-size: 14px; padding: 18px 0 8px; }
.daynav-note .text-btn { display: inline-flex; min-height: 32px; }
/* the loading slot has a month's height: the sheet never grows after opening */
.daynav-skel { display: grid; grid-template-columns: repeat(7, 1fr); row-gap: 4px; padding: 62px 2px 6px; }
.daynav-skel span { height: 44px; display: flex; align-items: center; justify-content: center; }
.daynav-skel span::before { content: ''; width: 20px; height: 12px; border-radius: 6px; background: var(--glass); }
.daynav-host {
  max-height: min(62vh, 448px);
  overflow-y: auto;
  overscroll-behavior: contain;
  scrollbar-width: none;
  padding: 0 2px;
}
.daynav-host::-webkit-scrollbar { display: none; }
.daynav-month { padding: 6px 0 8px; }
.daynav-month + .daynav-month { margin-top: 8px; }
.daynav-mtitle { text-align: center; font-size: 17px; font-weight: 600; color: var(--text); padding: 4px 0 12px; }
.daynav-week, .daynav-grid { display: grid; grid-template-columns: repeat(7, 1fr); }
.daynav-week { margin-bottom: 6px; }
.daynav-wd { text-align: center; font-size: 12px; font-weight: 500; color: var(--text2); }
.daynav-grid { row-gap: 4px; }
.daynav-cell { height: 44px; display: flex; align-items: center; justify-content: center; }
.daynav-day {
  position: relative;
  isolation: isolate;
  width: 100%;
  height: 44px;
  border: none;
  background: none;
  padding: 0;
  margin: 0;
  font-family: inherit;
  font-size: 17px;
  font-weight: 400;
  color: var(--text3);
  font-variant-numeric: tabular-nums;
  border-radius: var(--r-pill);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.daynav-day.has { color: var(--text); font-weight: 500; }
.daynav-day.today { color: var(--accent-text); font-weight: 600; }
.daynav-day:disabled { opacity: 0.35; cursor: default; }
.daynav-day::before {
  content: '';
  position: absolute;
  left: 50%; top: 50%;
  width: 40px; height: 40px;
  border-radius: var(--r-pill);
  background: var(--glass-bold);
  transform: translate(-50%, -50%) scale(0.85);
  opacity: 0;
  transition: opacity 120ms cubic-bezier(0.4, 0, 0.2, 1), transform 120ms cubic-bezier(0.4, 0, 0.2, 1);
  z-index: -1;
}
.daynav-day:not(:disabled):active::before { opacity: 1; transform: translate(-50%, -50%) scale(1); }
.daynav-day:focus-visible { outline: none; }
.daynav-day:focus-visible::before { opacity: 1; transform: translate(-50%, -50%) scale(1); outline: 2px solid var(--accent); outline-offset: 2px; }
@media (max-width: 359px) {
  .daynav-day::before { width: 38px; height: 38px; }
}
body.kb-open .chat-search-panel { bottom: calc(6px + var(--kb-h, 0px)); }
body.kb-open .chat-search-nav-buttons { bottom: calc(6px + var(--kb-h, 0px)); }
body.no-blur .chat-search-counter-island,
body.no-blur .chat-search-nav-btn {
  background: var(--bg-tint);
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
}

@media (prefers-reduced-motion: reduce) {
  .chat-bar-collapse,
  .chat-bar-collapse-in,
  .chat-title-content,
  .chat-search-content,
  .chat-message-search-close.glass-chip,
  .chat-search-target .bubble,
  .chat-search-nav-btn,
  .chat-search-count-wrap,
  .chat-list-search-close.glass-chip,
  .search-session.searching .chat-list-search-close.glass-chip,
  .message-search-control-enter-active,
  .message-search-control-leave-active,
  .message-search-count-enter-active,
  .message-search-count-leave-active { transition: none; }
  .chat-message-search-progress { animation: none; }
}

/* ghost icon button in list rows (persona delete, folder rename/delete):
   solid stroke dimmed via opacity (overlapping paths in feather icons blotch
   when the stroke itself carries alpha) */
.persona-del, .persona-edit, .ghost-ico {
  position: relative;
  flex-shrink: 0;
  width: 34px;
  height: 34px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: none;
  border-radius: var(--r-pill);
  background: transparent;
  color: var(--text);
  opacity: 0.45;
  cursor: pointer;
  transition: opacity 130ms cubic-bezier(0.4, 0, 0.2, 1), transform 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* 44px hit: the rows place these at a 10px gap, 5px per side never overlaps */
.persona-del::before, .persona-edit::before, .ghost-ico::before { content: ''; position: absolute; inset: -5px; border-radius: inherit; }
.persona-del svg, .persona-edit svg, .ghost-ico svg { width: 17px; height: 17px; }
@media (hover: hover) {
  .persona-del:hover, .persona-edit:hover, .ghost-ico:hover { opacity: 0.9; }
}
.persona-del:active, .persona-edit:active, .ghost-ico:active { opacity: 0.9; transform: scale(0.92); }
.persona-del:focus-visible, .persona-edit:focus-visible, .ghost-ico:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; opacity: 1; }
/* destructive variant: red needs more opacity than dim white to read equal */
.ghost-ico.danger { color: var(--destructive); opacity: 0.7; }

/* ═══ Chat folders ═══ */
/* Collapsing header, Telegram-style: search row on top, folder tabs under it.
   Scroll collapses the search row (height+fade) and the tabs slide up; the
   header search chip fades in to bring it back. Rides on .top-fade.tall. */
.chat-hdr {
  position: absolute;
  top: calc(48px + var(--tg-top));
  left: 0;
  right: 0;
  z-index: 19;
  overflow: hidden;
}
.chat-hdr-inner { will-change: transform; }
.chat-search-row {
  height: 52px;
  padding: 4px var(--pad);
}
.chat-search-row.morph-hidden, .search-chip.morph-hidden {
  opacity: 0 !important;
  pointer-events: none;
}
/* header search chip appearance (mirrors the sub-bar title pill fade) */
.fadechip-enter-active, .fadechip-leave-active { transition: opacity 150ms cubic-bezier(0.4,0,0.2,1), transform 150ms cubic-bezier(0.4,0,0.2,1); }
.fadechip-enter-from, .fadechip-leave-to { opacity: 0; transform: scale(0.8); }

/* Search morph ghost: the search field and the header chip are ONE piece of
   glass (iOS 26 idiom). JS paints its rect between the two endpoints while
   the real elements hide; .sm-field wears the field look and dissolves as
   .sm-glass (verbatim chip material) fades in with progress (--smp), so
   both endpoints are pixel-identical to the real elements and the swap is
   invisible. Text fades out long before the pill can clip it, so no
   overflow clipping = the chip's drop shadow survives. */
.search-morph {
  position: fixed;
  z-index: 20;
  visibility: hidden;
  pointer-events: none;
}
.search-morph .sm-field {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-sizing: border-box;
  background: var(--chrome-glass-fill-readable);
  box-shadow: var(--chrome-glass-shadow);
  border: none;
  opacity: calc(1 - var(--smp, 0));
}
.search-morph .sm-glass {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: var(--chrome-glass-fill);
  backdrop-filter: var(--chrome-glass-filter);
  -webkit-backdrop-filter: var(--chrome-glass-filter);
  box-shadow: var(--chrome-glass-shadow);
  opacity: var(--smp, 0);
}
body.no-blur .search-morph .sm-glass {
  background: var(--bg-tint);
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
}
.search-morph .sm-ico {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  color: var(--text);
  display: inline-flex;
}
.search-morph .sm-ico svg { width: 100%; height: 100%; display: block; }
.search-morph .sm-ph {
  position: absolute;
  left: 42px;
  top: 50%;
  transform: translateY(-50%);
  font-size: 15px;
  color: var(--text3);
  white-space: nowrap;
}

/* Swipe track: the active list plus a ghost pane of the neighbor folder,
   translated together while the finger drags (Telegram folder swipe). */
.folder-track {
  flex: 1;
  min-height: 0;
  position: relative;
  display: flex;
  touch-action: pan-y;
  will-change: transform;
}
.folder-track > .chat-list { flex: 1; min-width: 0; }
.ghost-pane {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 100%;
  overflow: hidden;
  pointer-events: none;
}
.ghost-pane.right { left: 100%; }
.ghost-pane.left { right: 100%; }
/* A short folder still holds the collapsed search row: TG inflates the content
   by the header height (iOS keepMinimalScrollHeightWithTopInset, Android
   LastEmptyView), so a long scrolled list switching to a three-chat folder
   never pops the search open. 100% = the scroller's content box, so the
   scroll range is exactly 52px past the rows (the island's 8px margin-top
   is part of the range). ADR-0040. */
.chat-list.has-search-row > .island { min-height: calc(100% + 44px); }
.chat-list.has-search-row > .empty-state { min-height: calc(100% + 52px); }

/* Tabs distribute across the full width when they fit (flex-grow), scroll
   horizontally when they don't. Selected = tonal pill fill, never a stroke.
   The bar is a real glass capsule (verbatim recipe): one big pill holds the
   small tab pills, tying it to the glass chips above (TG folder-chips
   reference; a flat tonal strip read as a foreign material next to them). */
.folder-bar {
  position: relative;
  display: flex;
  align-items: stretch;
  height: 42px;
  margin: 8px var(--pad) 0;
  padding: 4px;
  /* native long-press selection/callout on tab labels races the hold-to-lift
     gesture (drag "works every other time" without this) */
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
  background: var(--chrome-glass-fill);
  backdrop-filter: var(--chrome-glass-filter);
  -webkit-backdrop-filter: var(--chrome-glass-filter);
  /* мягкая тень: капсула всё ещё приподнята, но большой размытый нижний сброс
     (0 4px 12px) падал заметной полосой на первый chat-item под ней */
  box-shadow: var(--chrome-glass-rim), 0 1px 2px rgb(0 0 0 / 22%), 0 2px 6px rgb(0 0 0 / 7%);
  border-radius: var(--r-pill);
  overflow: hidden;
}
/* no-blur: solid fill, shadow stack (rims included) stays - matches how
   .glass-chip / .bottom-nav / .chat-dropdown degrade app-wide */
body.no-blur .folder-bar {
  background: var(--bg-tint);
}
/* The scroller: tabs and the lens live in HERE, clipped by its own pill inside
   the capsule's 4px padding (Telegram iOS: scrollView/lensView cornerRadius =
   the inner box, HorizontalTabsComponent.swift:887-891). Pill in pill is
   concentric by construction, and a tab scrolling away dissolves at an inner
   edge instead of crossing the capsule's own rim. position: relative = the
   offsetParent both _captureTabRects and .folder-ind measure against. */
.folder-scroll {
  position: relative;
  flex: 1;
  min-width: 0;
  height: 100%;
  display: flex;
  align-items: center;
  gap: 3px;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scrollbar-width: none;
  border-radius: var(--r-pill);
}
.folder-scroll::-webkit-scrollbar { display: none; }
/* Chats header fade: height follows the collapsing header (style-bound).
   Telegram look: a LIGHT translucent blur wall that holds until the tabs'
   bottom edge, then feathers out over ~28px. Content ghosts through the
   blur; the tint never goes near-opaque. */
.top-fade.chatfade {
  background: linear-gradient(to bottom,
    color-mix(in srgb, var(--bg) 78%, transparent) calc(100% - 30px),
    transparent);
}
.top-fade.chatfade::before {
  mask-image: linear-gradient(to bottom, black calc(100% - 34px), transparent calc(100% - 4px));
  -webkit-mask-image: linear-gradient(to bottom, black calc(100% - 34px), transparent calc(100% - 4px));
}
.top-fade.chatfade::after {
  mask-image: linear-gradient(to bottom, black calc(100% - 46px), rgba(0,0,0,0.5) calc(100% - 24px), transparent);
  -webkit-mask-image: linear-gradient(to bottom, black calc(100% - 46px), rgba(0,0,0,0.5) calc(100% - 24px), transparent);
}
.folder-tab {
  /* TG-семантика: мало вкладок - растягиваются на всю ширину поровну,
     много - min-width включает горизонтальный скролл бара */
  flex: 1 1 0;
  min-width: max-content;
  height: 34px;
  line-height: 34px;
  padding: 0 16px;
  font-size: 14px;
  border: none;
  border-radius: var(--r-pill);
  background: transparent;
  color: var(--text2);
  font-family: inherit;
  font-weight: 600;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  cursor: pointer;
  transition: background 130ms cubic-bezier(0.4,0,0.2,1), color 130ms cubic-bezier(0.4,0,0.2,1);
}
.folder-tab:active { color: var(--text); }
.folder-tab { position: relative; z-index: 1; }
.folder-tab.on { color: var(--accent-text); }
.folder-tab.dragging { background: var(--glass-bold); color: var(--text); }
.folder-ind {
  position: absolute;
  /* the scroller IS the lens box (the capsule minus its 4px padding), so the
     lens fills it edge to edge instead of insetting itself again */
  top: 0;
  bottom: 0;
  left: 0;
  width: 0;
  height: auto;
  /* content-box: JS writes width in px, and the hold-expand below grows the
     box with padding instead (left/width are inline, so a CSS override of
     either would be ignored) */
  box-sizing: content-box;
  border-radius: var(--r-pill);
  background: var(--chrome-lens-fill);
  pointer-events: none;
  visibility: hidden;
}
/* hold-expand: 6px per side, and the negative margin keeps the growth
   symmetric around the tab. The scroller clips it, so the first/last tab
   cannot push the capsule out through the bar's rim. */
.folder-bar.lens-hold .folder-ind {
  padding-inline: 6px;
  margin-inline: -6px;
}

/* selection counter chip: data, not a button */
.sel-count {
  pointer-events: none;
  font-variant-numeric: tabular-nums;
  min-width: 40px;
  justify-content: center;
}

/* pin glyph next to the date in a pinned chat row */
.chat-pin-ico {
  display: inline-flex;
  vertical-align: -2px;
  margin-right: 4px;
  color: var(--text);
  opacity: 0.35;
}
.chat-pin-ico svg { width: 12px; height: 12px; display: block; }

/* assign modal rows: settings-row vocabulary, static check circle
   cloned from .chat-check (which is absolutely positioned for chat rows) */
.assign-list {
  max-height: 46vh;
  overflow-y: auto;
  margin-bottom: 12px;
}
/* a picker ROW in a list: no radius of its own, the press is a full-bleed
   row tint (the 14px rounded press box was the last M3 leftover here -
   sonnet sweep, 2026-08-20) */
.assign-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 12px 6px;
  cursor: pointer;
  border-radius: 0;
  transition: background 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
.assign-row:active { background: var(--glass); }
.assign-count {
  flex-shrink: 0;
  font-size: 12px;
  color: var(--text3);
  font-variant-numeric: tabular-nums;
}
/* trailing check, the .m3-radio idiom (the 22px ring checkbox was the retired
   M3 control - owner, 2026-08-19). The slot is always reserved: unchecked just
   hides the mark, rows never reflow. Multi-select is fine with a check: the
   row's tap toggles it. */
.assign-check {
  width: 20px;
  height: 22px;
  position: relative;
  flex-shrink: 0;
}
.assign-check::after {
  content: '';
  position: absolute;
  left: 2px;
  top: 6px;
  width: 15px;
  height: 8px;
  border-left: 2.2px solid var(--accent-text);
  border-bottom: 2.2px solid var(--accent-text);
  border-radius: 1px;
  transform-origin: left bottom;
  transform: rotate(-45deg) scale(0.6);
  opacity: 0;
  transition: opacity 130ms cubic-bezier(0.4, 0, 0.2, 1), transform 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
.assign-check.checked::after {
  opacity: 1;
  transform: rotate(-45deg) scale(1);
}
/* Folders settings screen: device-list dialect. Full-bleed rows in a bare
   island, hairline separators, grip + name + count + ghost actions.
   The grip box is 30px wide with a 16px glyph (7px inset), so the row's
   left padding backs off by 7px to land the glyph on the var(--pad) grid. */
.folder-edit-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px var(--pad) 8px calc(var(--pad) - 7px);
  position: relative;
}
.folder-edit-row::after {
  content: '';
  position: absolute;
  left: calc(var(--pad) + 33px);
  right: 0;
  bottom: 0;
  height: 1px;
  background: var(--sep);
}
.folder-edit-row:last-child::after { display: none; }
.folder-edit-name {
  flex: 1;
  min-width: 0;
  font-size: 15px;
  font-weight: 500;
  color: var(--text);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* counter + add button below the island share its side insets */
.folders-cap {
  margin: 8px var(--pad) 0;
  text-align: right;
  font-size: 12px;
  color: var(--text3);
  font-variant-numeric: tabular-nums;
}
.folders-add {
  width: calc(100% - var(--pad) * 2);
  margin: 12px var(--pad) 0;
}
.folder-drag {
  flex-shrink: 0;
  width: 30px;
  height: 34px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: var(--text);
  opacity: 0.35;
  cursor: grab;
  touch-action: none;
}
.folder-drag svg { width: 16px; height: 16px; }
/* drag affordance on pinned rows in select mode: grab the three lines */
.pin-drag {
  position: absolute;
  right: 0;
  z-index: 2;
  top: 0;
  bottom: 0;
  width: 44px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: var(--text);
  opacity: 0.35;
  cursor: grab;
  touch-action: none;
}
.pin-drag svg { width: 16px; height: 16px; }
/* while selecting, the grip takes the date's spot on pinned rows; the
   hidden date is opacity<1 (its own stacking context above the grip), so it
   must also stop hit-testing or it swallows the grip's touches */
.chat-meta { transition: opacity 0.12s cubic-bezier(0.4, 0, 0.2, 1); }
.island.select-mode .chat-item.is-pinned .chat-meta { opacity: 0; pointer-events: none; }

/* select-mode close lives on the LEFT, back-chip style */
.sel-back {
  position: absolute;
  top: calc(8px + var(--tg-top));
  left: 10px;
  z-index: 20;
}

/* pinned block lift while reordering in select mode */
.chat-item.dragging {
  position: relative;
  z-index: 5;
  background: var(--glass-bold);
  box-shadow: 0 6px 18px rgb(0 0 0 / 35%);
}
.folder-edit-row.dragging {
  background: var(--glass-bold);
  box-shadow: 0 6px 18px rgb(0 0 0 / 35%);
  opacity: 0.95;
  z-index: 5;
}
.glass-pill {
  flex: 1;
  min-width: 0;
  height: 44px;
  display: flex;
  align-items: center;
  gap: 9px;
  padding: 0 14px 0 7px;
  border-radius: var(--r-pill);
  background: var(--chrome-glass-fill-readable);
  backdrop-filter: var(--chrome-glass-filter-readable);
  -webkit-backdrop-filter: var(--chrome-glass-filter-readable);
  box-shadow: var(--chrome-glass-shadow);
}
.glass-pill .header-sub { margin-top: 0; height: auto; font-size: 11px; }
/* Typing line reveals by height (grid-rows 0fr->1fr, same recipe as
   .ref-summary-sub-wrap) so the name glides instead of snapping when
   streaming starts/ends. */
.glass-pill .header-sub-wrap {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
.glass-pill .header-sub-wrap.open { grid-template-rows: 1fr; }
.glass-pill .header-sub-wrap .header-sub { min-height: 0; overflow: hidden; }
.glass-pill .header-sub-wrap:not(.open) .header-sub-dots::after { animation: none; }
/* Pill press: card-open tap and the search-hold trigger share the subtle
   0.97 scale (wide element); the search-mode variant lives with the morph
   rules and stops pressing once the pill hosts the input. */
.glass-pill { transition: transform 130ms cubic-bezier(0.4, 0, 0.2, 1); }
.glass-pill.tappable { cursor: pointer; }
.glass-pill.tappable:active { transform: scale(0.97); }
.nsfw-chip.glass {
  position: relative;
  transition: transform 130ms cubic-bezier(0.4, 0, 0.2, 1), background 130ms cubic-bezier(0.4, 0, 0.2, 1), scale 120ms ease-out;
  height: 40px;
  background: var(--chrome-glass-fill);
  backdrop-filter: var(--chrome-glass-filter);
  -webkit-backdrop-filter: var(--chrome-glass-filter);
  box-shadow: var(--chrome-glass-shadow-thin);
}
/* ON = the accent capsule fenced INSIDE the glass ring (owner reference image
   26 and his liquid-nav note "кнопку NSFW огородили стеклом отдельно"): the
   ring is the same glass as the chip, the accent sits in the padding box */
.nsfw-chip.glass { isolation: isolate; }
.nsfw-chip.glass.on::before {
  content: '';
  position: absolute;
  inset: 4px;
  border-radius: var(--r-pill);
  background: var(--accent);
  z-index: -1;
}
/* the chip itself keeps its glass fill and its exact box: switching NSFW must
   never change the geometry of the row (owner, 2026-08-19) */
body.no-blur .glass-chip,
body.no-blur .glass-pill,
body.no-blur .nsfw-chip.glass { background: var(--bg-tint); }

/* Level dots (v5.232): two dots under the label = the remembered level
   (1 Баланс, 2 Максимум). Absolutely placed in the label's descender space,
   so the chip box and the label never move (the label stays on the bar's
   centreline with the ⋯ glyph); they only light while ON, so an OFF chip
   reads as before. */
/* touch-action is decided at pointerdown: the hold-drag must own the touch
   from the first contact or the WebView pans and cancels it mid-gesture */
.nsfw-chip { position: relative; touch-action: none; }
.nsfw-chip-dots {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 6px;
  display: flex;
  justify-content: center;
  gap: 4px;
  opacity: 0;
  pointer-events: none;
  transition: opacity 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
.nsfw-chip.on .nsfw-chip-dots { opacity: 1; }
/* while the dial exists the chip hides in the SAME Vue patch that mounts the
   dial's ghost face over it (and shows again in the patch that drops it):
   translucent glass would otherwise show two labels during the flight. No
   transition on purpose - the swap must be one frame, both ways */
.nsfw-chip.under-dial { opacity: 0; transition: none; }
.nsfw-chip-dot {
  width: 4px;
  height: 4px;
  border-radius: var(--r-pill);
  background: color-mix(in srgb, currentColor 40%, transparent);
}
/* white-alpha like .switch-track: the dots ride on the accent fill, which can
   be any hue, so a neutral of the label colour is the only stable choice */
.nsfw-chip-dot.lit { background: currentColor; }
/* ═══════════════════════════════════════════
   NSFW DIAL (hold the chip, v5.232)
   ═══════════════════════════════════════════
   The chip's own glass grown into a slider, the way the composer's voice
   island is an accent button fenced inside glass: the track IS the chip
   material (`--chrome-glass-fill` + rim, dense twin in the no-blur block),
   the accent slider lives in the 4px-inset well exactly where the chip's
   accent capsule sits, and the ghost face (label + dots) is what the chip
   shows at rest. Both ends of the morph are therefore the same picture:
   the box flies from the chip rect to the final box (250ms arrival, back
   150ms feedback), the ghost crossfades with the slider contents, and when
   the node is dropped the chip underneath is pixel-identical - nothing
   appears from nowhere, in either direction (owner, frame-by-frame). A dim
   scrim only (NO blur, design-system §3) sits behind. Manners of the iOS 26
   slider: the knob grows while held, past an end the whole track rubber-bands
   toward the finger (UIScrollView's curve in app.js, painted as a scaleX
   anchored at the far end) and springs back on release. app.js paints the
   thumb as --nd-x; CSS owns the rest transitions. */
.nsfw-dial {
  position: fixed;
  inset: 0;
  z-index: 4200;
  touch-action: none;
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
  /* a held physical object moves on SPRINGS, not on the UI-transition bands
     (owner, iOS 26: the whole glass grows under the finger and everything
     settles with a slow, jelly-like spring). The curves are Apple's
     spring(duration:bounce:) sampled into linear() - the only CSS easing
     that can oscillate; engines without linear() keep these bezier stand-ins
     (a damped-spring approximation, one overshoot) */
  --nd-flight-dur: 300ms;
  --nd-flight-ease: cubic-bezier(0.2, 0.9, 0.3, 1.05);
  --nd-jelly-dur: 320ms;
  --nd-jelly-ease: cubic-bezier(0.3, 1.25, 0.5, 1);
  --nd-settle-dur: 320ms;
  --nd-settle-ease: cubic-bezier(0.2, 0.9, 0.3, 1.05);
  --nd-grip-dur: 180ms;
  --nd-grip-ease: cubic-bezier(0.2, 0.9, 0.3, 1.1);
}
@supports (transition-timing-function: linear(0, 1)) {
  .nsfw-dial {
    /* flight: spring(0.3, bounce 0.22) - measured on the reference video:
       growth done in ~200ms, ~1.3% overshoot, ~150ms settle */
    --nd-flight-dur: 405ms;
    --nd-flight-ease: linear(0.0000, 0.0142 2.1%, 0.0519 4.2%, 0.1063 6.2%, 0.1719 8.3%, 0.2442 10.4%, 0.3197 12.5%, 0.3955 14.6%, 0.4695 16.7%, 0.5401 18.8%, 0.6063 20.8%, 0.6672 22.9%, 0.7225 25.0%, 0.7720 27.1%, 0.8158 29.2%, 0.8541 31.2%, 0.8871 33.3%, 0.9152 35.4%, 0.9389 37.5%, 0.9585 39.6%, 0.9745 41.7%, 0.9874 43.8%, 0.9975 45.8%, 1.0052 47.9%, 1.0110 50.0%, 1.0150 52.1%, 1.0177 54.2%, 1.0192 56.2%, 1.0199 58.3%, 1.0198 60.4%, 1.0192 62.5%, 1.0183 64.6%, 1.0171 66.7%, 1.0157 68.8%, 1.0142 70.8%, 1.0127 72.9%, 1.0112 75.0%, 1.0097 77.1%, 1.0084 79.2%, 1.0071 81.2%, 1.0060 83.3%, 1.0049 85.4%, 1.0040 87.5%, 1.0032 89.6%, 1.0025 91.7%, 1.0019 93.8%, 1.0014 95.8%, 1.0010 97.9%, 1.0000);
    /* press grow / release: spring(0.5, bounce 0.4) - the jelly */
    --nd-jelly-dur: 675ms;
    --nd-jelly-ease: linear(0.0000, 0.0145 2.1%, 0.0539 4.2%, 0.1124 6.3%, 0.1846 8.3%, 0.2658 10.4%, 0.3522 12.5%, 0.4402 14.6%, 0.5271 16.7%, 0.6107 18.7%, 0.6892 20.8%, 0.7615 22.9%, 0.8266 25.0%, 0.8842 27.1%, 0.9340 29.2%, 0.9762 31.2%, 1.0109 33.3%, 1.0387 35.4%, 1.0601 37.5%, 1.0758 39.6%, 1.0863 41.7%, 1.0924 43.8%, 1.0947 45.8%, 1.0939 47.9%, 1.0907 50.0%, 1.0855 52.1%, 1.0789 54.2%, 1.0714 56.2%, 1.0632 58.3%, 1.0549 60.4%, 1.0466 62.5%, 1.0386 64.6%, 1.0311 66.7%, 1.0241 68.8%, 1.0177 70.8%, 1.0121 72.9%, 1.0072 75.0%, 1.0031 77.1%, 0.9996 79.2%, 0.9969 81.2%, 0.9947 83.3%, 0.9931 85.4%, 0.9920 87.5%, 0.9913 89.6%, 0.9910 91.7%, 0.9911 93.8%, 0.9913 95.8%, 0.9918 97.9%, 1.0000);
    /* the grip: spring(0.25, bounce 0.2) - the object jumps to its held
       size the moment it is touched, then holds there */
    --nd-grip-dur: 338ms;
    --nd-grip-ease: linear(0.0000, 0.0142 2.1%, 0.0517 4.2%, 0.1056 6.3%, 0.1705 8.3%, 0.2420 10.4%, 0.3164 12.5%, 0.3910 14.6%, 0.4638 16.7%, 0.5332 18.7%, 0.5982 20.8%, 0.6581 22.9%, 0.7125 25.0%, 0.7613 27.1%, 0.8045 29.2%, 0.8424 31.2%, 0.8753 33.3%, 0.9034 35.4%, 0.9273 37.5%, 0.9472 39.6%, 0.9637 41.7%, 0.9770 43.8%, 0.9878 45.8%, 0.9962 47.9%, 1.0026 50.0%, 1.0074 52.1%, 1.0108 54.2%, 1.0131 56.2%, 1.0144 58.3%, 1.0151 60.4%, 1.0151 62.5%, 1.0148 64.6%, 1.0141 66.7%, 1.0132 68.8%, 1.0122 70.8%, 1.0111 72.9%, 1.0099 75.0%, 1.0088 77.1%, 1.0077 79.2%, 1.0067 81.2%, 1.0057 83.3%, 1.0049 85.4%, 1.0041 87.5%, 1.0034 89.6%, 1.0027 91.7%, 1.0022 93.8%, 1.0017 95.8%, 1.0013 97.9%, 1.0000);
    /* thumb snap and rubber-band return: spring(0.24, bounce 0.2) - the
       reference's tap between positions, measured frame by frame (2026-09-02):
       peak 1.4% past the stop at ~200ms, at rest by ~320ms, the same clock for
       one stop and for two */
    --nd-settle-dur: 324ms;
    --nd-settle-ease: linear(0.0000, 0.0142 2.1%, 0.0515 4.2%, 0.1054 6.2%, 0.1702 8.3%, 0.2415 10.4%, 0.3159 12.5%, 0.3904 14.6%, 0.4631 16.7%, 0.5325 18.8%, 0.5974 20.8%, 0.6573 22.9%, 0.7117 25.0%, 0.7605 27.1%, 0.8038 29.2%, 0.8418 31.2%, 0.8747 33.3%, 0.9029 35.4%, 0.9268 37.5%, 0.9468 39.6%, 0.9633 41.7%, 0.9767 43.8%, 0.9875 45.8%, 0.9960 47.9%, 1.0024 50.0%, 1.0073 52.1%, 1.0107 54.2%, 1.0130 56.2%, 1.0144 58.3%, 1.0151 60.4%, 1.0151 62.5%, 1.0148 64.6%, 1.0141 66.7%, 1.0133 68.8%, 1.0122 70.8%, 1.0111 72.9%, 1.0100 75.0%, 1.0089 77.1%, 1.0078 79.2%, 1.0068 81.2%, 1.0058 83.3%, 1.0049 85.4%, 1.0041 87.5%, 1.0034 89.6%, 1.0028 91.7%, 1.0022 93.8%, 1.0018 95.8%, 1.0013 97.9%, 1.0000);
  }
}
/* the closing layer is dead pixels: it must not eat the next tap or scroll
   (before `open` lands the gesture is on document listeners anyway) */
.nsfw-dial:not(.open) { pointer-events: none; touch-action: auto; }
.nsfw-dial-scrim {
  position: absolute;
  inset: 0;
  background: color-mix(in srgb, var(--bg) 72%, transparent);
  opacity: 0;
  transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
.nsfw-dial.open .nsfw-dial-scrim { opacity: 1; }
/* the WHOLE object is bigger while a finger is ON it (iOS 26 interactive
   glass; the house glassPress grows a chip the same way), moving or not:
   .dragging = "finger down" (app.js), set with the open so the flight lands
   at the held size, and dropped on release so the jelly spring settles it to
   rest; a release inside the flight waits for the flight to land first
   (owner: the morph must reach the same size as if held, THEN spring back).
   The knob inside never changes. The transition is read from the destination
   state: the grow rides the .dragging rule (grip, sharp), the release the
   base one (jelly) */
.nsfw-dial-box {
  position: fixed;
  transform-origin: 50% 50%;
  transform: scale(var(--nd-press, 1));
  transition: left var(--nd-flight-dur) var(--nd-flight-ease), top var(--nd-flight-dur) var(--nd-flight-ease),
              width var(--nd-flight-dur) var(--nd-flight-ease), transform var(--nd-jelly-dur) var(--nd-jelly-ease);
}
.nsfw-dial-box.dragging {
  --nd-press: 1.02;
  transition: left var(--nd-flight-dur) var(--nd-flight-ease), top var(--nd-flight-dur) var(--nd-flight-ease),
              width var(--nd-flight-dur) var(--nd-flight-ease), transform var(--nd-grip-dur) var(--nd-grip-ease);
}
.nsfw-dial:not(.open) .nsfw-dial-box {
  transition: left 150ms cubic-bezier(0.4, 0, 0.2, 1), top 150ms cubic-bezier(0.4, 0, 0.2, 1),
              width 150ms cubic-bezier(0.4, 0, 0.2, 1), transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* the track: the chip's glass, verbatim tokens (§3); no opacity ever */
.nsfw-dial-track {
  position: relative;
  border-radius: var(--r-pill);
  background: var(--chrome-glass-fill);
  backdrop-filter: var(--chrome-glass-filter);
  -webkit-backdrop-filter: var(--chrome-glass-filter);
  /* the chip's thin rim, not the full stack: the box starts AS the chip and
     a rim that changes recipe on the first frame is a pop */
  box-shadow: var(--chrome-glass-shadow-thin);
  transform-origin: var(--nd-so, 0%) 50%;
  transform: scaleX(var(--nd-sx, 1));
  transition: height var(--nd-flight-dur) var(--nd-flight-ease);
}
.nsfw-dial:not(.open) .nsfw-dial-track { transition: height 150ms cubic-bezier(0.4, 0, 0.2, 1); }
/* the rubber band springs back (ADR-0008's residual spring is the precedent).
   `data-snap` is set by app.js (_ndPaint): an attribute, because the class
   list of this node belongs to the Vue binding and is rewritten whole */
.nsfw-dial-track[data-snap] { transition: height var(--nd-flight-dur) var(--nd-flight-ease), transform var(--nd-settle-dur) var(--nd-settle-ease); }
/* the well = the chip's accent-capsule box (inset 4px), clips the fill */
.nsfw-dial-well {
  position: absolute;
  inset: 4px;
  border-radius: var(--r-pill);
  overflow: hidden;
}
.nsfw-dial-fill,
.nsfw-dial-thumb,
.nsfw-dial-dot { position: absolute; }
/* the accent fill: a well-wide pill whose rounded end wraps the thumb with
   the same 4px ring the chip's capsule keeps from the glass. At the chip
   rect (not open, ON) it is the chip's full capsule - transform none - and
   it slides out to the thumb during the flight; OFF starts invisible */
.nsfw-dial-fill {
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  border-radius: var(--r-pill);
  background: var(--accent);
  transform: translateX(calc(var(--nd-x, 0px) + var(--nd-hw, 48px) - var(--nd-ww, 104px)));
}
.nsfw-dial:not(.open) .nsfw-dial-track.on .nsfw-dial-fill { transform: none; }
.nsfw-dial:not(.open) .nsfw-dial-track:not(.on) .nsfw-dial-fill { opacity: 0; }
.nsfw-dial-track.morph .nsfw-dial-fill { transition: transform var(--nd-flight-dur) var(--nd-flight-ease), opacity 120ms cubic-bezier(0.4, 0, 0.2, 1) 40ms; }
.nsfw-dial:not(.open) .nsfw-dial-track.morph .nsfw-dial-fill { transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1), opacity 100ms cubic-bezier(0.4, 0, 0.2, 1); }
/* stop marks: ~15% of the track height, the reference's proportion (a 9pt
   dot on a 57pt track); centred on the thumb travel, so the end ones sit
   under the thumb at the ends */
.nsfw-dial-dot {
  top: 50%;
  width: 8px;
  height: 8px;
  margin: -4px 0 0 -4px;
  border-radius: var(--r-pill);
  background: var(--text3);
}
/* on the accent fill (any hue) only a white-alpha stays legible, like the
   chip dots and .switch-track */
.nsfw-dial-dot.lit { background: rgba(255, 255, 255, 0.4); }
/* the thumb is the positioned box; the knob inside it scales, so the grow
   never rescales the translate */
.nsfw-dial-thumb {
  left: 4px;
  top: 4px;
  width: 40px;
  height: 40px;
  transform: translateX(var(--nd-x, 0px));
}
/* flat white like .switch-thumb: no elevation recipe on a content control,
   and nothing inside the slider reacts to the press - the whole box does */
.nsfw-dial-knob {
  width: 100%;
  height: 100%;
  border-radius: var(--r-pill);
  background: #fff;
}
.nsfw-dial-track[data-snap] .nsfw-dial-thumb { transition: transform var(--nd-settle-dur) var(--nd-settle-ease); }
.nsfw-dial-track[data-snap] .nsfw-dial-fill { transition: transform var(--nd-settle-dur) var(--nd-settle-ease); }
/* the ghost face = the chip at rest (same type, colour and dots), shown
   while the box still looks like the chip, gone once the slider takes over */
.nsfw-dial-ghost {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 13px;
  font-weight: 600;
  color: var(--text2);
  opacity: 1;
  transition: opacity 100ms cubic-bezier(0.4, 0, 0.2, 1) 60ms;
}
.nsfw-dial-track.on .nsfw-dial-ghost { color: #fff; font-weight: 700; }
.nsfw-dial-ghost .nsfw-chip-dots { opacity: 0; }
.nsfw-dial-track.on .nsfw-dial-ghost .nsfw-chip-dots { opacity: 1; }
.nsfw-dial.open .nsfw-dial-ghost { opacity: 0; transition: opacity 100ms cubic-bezier(0.4, 0, 0.2, 1); }
/* slider contents arrive as the ghost leaves, and leave before the pill is
   small enough to clip them */
.nsfw-dial-thumb,
.nsfw-dial-dot,
.nsfw-dial-label {
  opacity: 0;
  transition: opacity 120ms cubic-bezier(0.4, 0, 0.2, 1) 40ms;
}
.nsfw-dial.open .nsfw-dial-thumb,
.nsfw-dial.open .nsfw-dial-dot,
.nsfw-dial.open .nsfw-dial-label { opacity: 1; }
.nsfw-dial:not(.open) .nsfw-dial-well .nsfw-dial-thumb,
.nsfw-dial:not(.open) .nsfw-dial-well .nsfw-dial-dot,
.nsfw-dial:not(.open) .nsfw-dial-label { transition: opacity 70ms cubic-bezier(0.4, 0, 0.2, 1); }
.nsfw-dial-label {
  margin-top: 10px;
  text-align: center;
  font-size: 15px;
  font-weight: 600;
  color: var(--text);
}
@media (prefers-reduced-motion: reduce) {
  .nsfw-dial-scrim, .nsfw-dial-box, .nsfw-dial:not(.open) .nsfw-dial-box,
  .nsfw-dial-track, .nsfw-dial:not(.open) .nsfw-dial-track, .nsfw-dial-track[data-snap], .nsfw-dial-box.dragging,
  .nsfw-dial-fill, .nsfw-dial-track.morph .nsfw-dial-fill, .nsfw-dial:not(.open) .nsfw-dial-track.morph .nsfw-dial-fill,
  .nsfw-dial-thumb, .nsfw-dial-dot, .nsfw-dial-label, .nsfw-dial-ghost, .nsfw-dial.open .nsfw-dial-ghost,
  .nsfw-dial-track[data-snap] .nsfw-dial-fill, .nsfw-dial-track[data-snap] .nsfw-dial-thumb { transition: none; }
}

/* Sub-screen floating bar: back chip left, collapsing title pill center.
   The pill is invisible while the in-content .large-title is on screen and
   fades in once the content scrolls under (iOS 26 large-title collapse). */
.sub-bar {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  z-index: 20;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  padding: calc(8px + var(--tg-safe-area-inset-top, 0px)) 10px 8px;
  pointer-events: none;
}
.sub-bar > * { pointer-events: auto; }
.sub-bar-spacer { width: 40px; flex-shrink: 0; pointer-events: none; }
.sub-bar-title {
  height: 40px;
  /* inline-BLOCK, не inline-flex: на flex-контейнере text-overflow не работает
     (текст становится анонимным flex-элементом), и длинное имя персонажа
     обрезалось без многоточия. Вертикальное центрирование даёт line-height. */
  display: inline-block;
  line-height: 40px;
  padding: 0 18px;
  border-radius: var(--r-pill);
  font-size: 15px;
  font-weight: 600;
  color: var(--text);
  background: var(--chrome-glass-fill-readable);
  backdrop-filter: var(--chrome-glass-filter-readable);
  -webkit-backdrop-filter: var(--chrome-glass-filter-readable);
  box-shadow: var(--chrome-glass-shadow-thin);
  opacity: 0;
  transform: translateY(4px) scale(0.96);
  transition: opacity 150ms cubic-bezier(0.4,0,0.2,1), transform 150ms cubic-bezier(0.4,0,0.2,1);
  pointer-events: none !important;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  /* по ширине бара минус кнопка назад и симметричный спейсер: 60% отрезали
     имена персонажей задолго до реального края */
  max-width: calc(100% - 96px);
}
.sub-bar-title.show { opacity: 1; transform: none; }
body.no-blur .sub-bar-title { background: var(--bg-tint); }
/* Phone Telegram draws its OWN controls in this very row - back on the left,
   collapse + menu on the right, ~88px of chrome per side (measured on iOS,
   both flanks symmetric around the screen center). Our chip never mounts
   there, so the row keeps only its middle: reserve the flanks, drop the empty
   side slots and let the pill span what is left (the -96px above subtracts a
   back chip that is not there). Otherwise a long title slides under both
   controls, and the rare right-side chip (public rooms search) under the menu. */
html.tg-native-back .sub-bar { padding-left: 88px; padding-right: 88px; }
html.tg-native-back .sub-bar-spacer { width: 0; }
/* the TG fullscreen row belongs to the client's own controls: the scrolled
   search chip DROPS BELOW it into the corner (the community-catalog chip
   pattern, owner's call 2026-08-04) instead of sharing the row. 56px = the
   row's bottom (safe-top + 48, see the toast-anchor rhythm) + 8px gap */
html.tg-native-back .sub-bar .sub-bar-search-chip {
  position: absolute;
  top: calc(var(--tg-safe-area-inset-top, 0px) + 56px);
  right: var(--pad);
}

/* the pill must not share flex space with the side slots: with a zero-width
   left spacer, a right chip appearing (publicrooms/collections search) used
   to walk the title 20px off center. Absolute + auto margins center it in
   the row for good; transform stays free for the .show animation */
html.tg-native-back .sub-bar-title {
  position: absolute;
  left: 0;
  right: 0;
  top: calc(8px + var(--tg-safe-area-inset-top, 0px));
  width: fit-content;
  margin-inline: auto;
  max-width: calc(100% - 176px);
}

/* Floating action chips for root list views (no toolbar, just islands) */
.float-actions {
  position: absolute;
  top: calc(8px + var(--tg-top));
  /* правый край выравнивается с колонкой контента (поиск/список/папки),
     а не торчит на пару пикселей правее */
  right: var(--pad);
  z-index: 20;
  display: flex;
  align-items: center;
  gap: 8px;
}
.glass-chip.pad {
  padding: 0 14px;
  font-size: 13px;
  font-weight: 600;
  gap: 6px;
}

.rename-bar {
  position: absolute;
  top: calc(58px + var(--tg-top));
  left: 10px;
  right: 10px;
  z-index: 19;
  display: flex;
  gap: 8px;
  padding: 8px;
  border-radius: var(--r-card);
  background: var(--chrome-glass-fill-readable);
  backdrop-filter: var(--chrome-glass-filter-readable);
  -webkit-backdrop-filter: var(--chrome-glass-filter-readable);
  box-shadow: var(--chrome-glass-shadow);
}
body.no-blur .rename-bar { background: var(--bg-tint); }
/* compact pill field inside the rename glass bar (chat + group rename) */
.chat-input-el {
  flex: 1;
  min-width: 0;
  padding: 7px 12px;
  font-size: 14px;
  font-family: inherit;
  border-radius: var(--r-pill);
  background: var(--glass-bold);
  border: none;
  outline: none;
  color: var(--text);
  caret-color: var(--accent);
}
/* Rename bar drops out from under the chat bar, leaves with a quick lift-fade */
.renamebar-enter-active {
  transition: transform 0.2s cubic-bezier(0.05, 0.7, 0.1, 1), opacity 0.15s cubic-bezier(0.4, 0, 0.2, 1);
}
.renamebar-enter-from { transform: translateY(-10px) scale(0.98); opacity: 0; }
.renamebar-leave-active {
  transition: transform 0.13s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.13s cubic-bezier(0.4, 0, 0.2, 1);
}
.renamebar-leave-to { transform: translateY(-8px); opacity: 0; }
@media (prefers-reduced-motion: reduce) {
  .renamebar-enter-active, .renamebar-leave-active { transition: none; }
}

/* ═══════════════════════════════════════════
   TOAST
═══════════════════════════════════════════ */
.toast-stack {
  position: fixed;
  /* clear the TG fullscreen chrome (--tg-top) or the device notch (--safe-top) */
  top: calc(var(--header-h) + 8px + max(var(--safe-top), var(--tg-top)));
  left: 50%;
  transform: translateX(-50%);
  /* max-content escapes the half-viewport shrink-to-fit of a left:50% box;
     without it toasts wrap at 50vw */
  width: max-content;
  max-width: min(calc(100vw - 24px), 420px);
  display: flex;
  flex-direction: column;
  align-items: center;
  /* above the community overlay stack (caps at 3800) and the sidebar (4000):
     banners outrank everything transient */
  z-index: 4500;
  pointer-events: none;
}
.toast {
  display: flex;
  align-items: center;
  gap: 10px;
  pointer-events: auto;
  touch-action: none;
  user-select: none;
  -webkit-user-select: none;
  /* the inter-toast gap lives here (not stack gap) so the leave collapse can close it */
  margin-bottom: 6px;
  max-width: 100%;
  background: var(--chrome-glass-fill-readable);
  backdrop-filter: var(--chrome-glass-filter-readable);
  -webkit-backdrop-filter: var(--chrome-glass-filter-readable);
  border: none;
  color: var(--text);
  border-radius: var(--r-pill);
  padding: 10px 20px;
  font-size: 14px;
  font-weight: 500;
  line-height: 1.35;
  text-align: center;
  text-wrap: balance;
  box-shadow: var(--chrome-glass-shadow);
}
.toast-msg { min-width: 0; }
.toast-act {
  flex: none;
  border: none;
  background: none;
  padding: 0 2px;
  margin: -2px -6px -2px 0;
  color: var(--accent-text);
  font-family: inherit;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
  transition: opacity 0.13s cubic-bezier(0.4, 0, 0.2, 1), transform 0.13s cubic-bezier(0.4, 0, 0.2, 1);
}
/* invisible inset grows the tap target (same trick as .toast-x) */
.toast-act { position: relative; }
.toast-act::before { content: ''; position: absolute; inset: -10px; }
.toast-act:active { transform: scale(0.94); opacity: 0.8; }
.toast-x {
  position: relative;
  flex: none;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 24px;
  height: 24px;
  margin: -2px -8px -2px -2px;
  padding: 0;
  border: none;
  background: none;
  color: var(--text);
  cursor: pointer;
  transition: transform 0.13s cubic-bezier(0.4, 0, 0.2, 1);
}
/* invisible inset grows the tap target to ~44px (card-author-chip precedent) */
.toast-x::before { content: ''; position: absolute; inset: -10px; }
.toast-x svg { width: 16px; height: 16px; opacity: 0.55; }
.toast-x:hover svg { opacity: 0.85; }
.toast-x:active { transform: scale(0.92); }
.toast-x:active svg { opacity: 0.9; }
body.no-blur .toast { background: var(--bg-tint); }
body.no-blur .suggestion-pill { background: var(--bg-tint); }
/* Phone Telegram draws the back arrow itself, so our chip never mounts in a
   sub-screen's chrome band and the default `--header-h` offset leaves the toast
   hanging in empty space over the large title (in a browser the chip is there
   and the same offset reads right). Anchor it under whichever chrome actually
   ends lower: the client's own header (`--tg-top`) or the sub-bar row itself,
   whose condensed title pill sits at `--tg-safe-area-inset-top + 8` and is 40
   tall. ONE position for both states on purpose - the pill condensing in must
   never teleport a live toast, and on clients that put the pill inside the
   client's own header row there is nothing in our band to clear anyway.
   A community modal over the screen brings its OWN top chrome (close / menu
   chips), so it keeps the default anchor. */
html.tg-native-back body:not(.community-modal-over-chat) .view-content:has(> .sub-bar) > .toast-stack {
  top: calc(8px + max(var(--safe-top), var(--tg-top), calc(var(--tg-safe-area-inset-top, 0px) + 48px)));
}
.toast-enter-active { animation: toast-in 0.34s cubic-bezier(0.34, 1.3, 0.4, 1); }
.toast-leave-active {
  animation: toast-out 0.22s cubic-bezier(0.4, 0, 0.2, 1) forwards;
  overflow: hidden;
  pointer-events: none;
}
/* a swipe-dismissed toast already flew out imperatively: only close its gap */
.toast-swiped.toast-leave-active { animation-name: toast-out-swiped; animation-duration: 0.15s; }
/* restack glide when a toast pushes in above or leaves the column */
.toast-move { transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1); }
@keyframes toast-in  { from{opacity:0;transform:translateY(-14px) scale(0.9)} to{opacity:1;transform:none} }
@keyframes toast-out {
  from { opacity: 1; max-height: 96px; }
  to   { opacity: 0; max-height: 0; margin-bottom: 0; padding-top: 0; padding-bottom: 0; }
}
@keyframes toast-out-swiped {
  from { opacity: 0; max-height: 96px; }
  to   { opacity: 0; max-height: 0; margin-bottom: 0; padding-top: 0; padding-bottom: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .toast-enter-active { animation: toast-fade-in 0.12s ease-out; }
  .toast-leave-active, .toast-swiped.toast-leave-active { animation: toast-fade-out 0.1s ease-in forwards; }
  .toast-move { transition: none; }
  .day-group > .day-sep .day-divider-pill { transition: none; }
}
@keyframes toast-fade-in  { from { opacity: 0; } }
@keyframes toast-fade-out { to { opacity: 0; } }

/* Community modal over the chat (character card / profile from a room): the
   chat STAYS PAINTED under the 0.72 scrim like any other modal host surface -
   it used to go visibility:hidden and the user watched the chat black out on
   open and pop back ~350ms after close (owner report, 2026-07-21). The perf
   cost the freeze was for is the per-frame ANIMATION work under the modal, so
   only that is stopped; static chat layers below a modal composite for free. */
body.community-modal-over-chat .messages-area *,
body.community-modal-over-chat .chat-glass-bar *,
body.community-modal-over-chat .suggestions-row *,
body.community-modal-over-chat .input-area * {
  animation-play-state: paused !important;
}

/* ═══════════════════════════════════════════
   MODAL SHEET
═══════════════════════════════════════════ */
.modal-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0,0,0,0.5);
  z-index: 200;
  display: flex;
  align-items: flex-end;
  justify-content: center;
  padding: 0 10px calc(10px + var(--safe-bottom));
}
.modal-sheet {
  width: 100%;
  max-width: 520px;
  background: var(--chrome-glass-fill-readable);
  backdrop-filter: var(--chrome-glass-filter-readable);
  -webkit-backdrop-filter: var(--chrome-glass-filter-readable);
  border-radius: var(--r-island);
  padding: 14px var(--pad) 16px;
  max-height: 88vh;
  overflow-y: auto;
  overscroll-behavior: contain;
  overflow-anchor: none;
  scroll-padding-bottom: 120px;
  border: none;
  box-shadow: var(--chrome-glass-shadow);
}
/* A sheet that fills the screen stops being glass. Apple's own rule for the
   same component: "When a half sheet expands to full height, it transitions to
   a more opaque appearance to help maintain focus on the task" (Adopting
   Liquid Glass). We have no detents, so the sheet's own height decides -
   markSheetMaterial() in community.js writes the class at open and on resize,
   so ONE component keeps both materials instead of forking per screen.
   Two other things ride along: over a tall sheet the tone ladder inverts (its
   fill is 60% of --glass over whatever art is behind, so on a bright backdrop
   the sheet ends up LIGHTER than the opaque cards on it), and a near-
   fullscreen backdrop-filter is the priciest thing we can paint. */
.modal-sheet.sheet-solid {
  background: var(--chrome-glass-fill-solid);
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  box-shadow: var(--chrome-glass-shadow-solid);
}
body.no-blur .modal-sheet { background: var(--bg-tint); }
.modal-handle {
  width: 36px;
  height: 4px;
  background: var(--text3);
  border-radius: 2px;
  margin: 0 auto 16px;
}
.modal-title {
  font-size: 17px;
  font-weight: 700;
  margin-bottom: 16px;
  text-align: center;
}
/* шит сам паддит контент: островной var(--pad)-маргин здесь даёт двойной
   инсет и остров становится уже соседних чипов/полей */
.modal-sheet .island { margin: 0; }

.ci-prompt-row {
  width: 100%;
  text-align: left;
  background: none;
  border: none;
  color: inherit;
  font: inherit;
}
.ci-prompt-sheet { display: flex; flex-direction: column; overflow: hidden; }
.ci-prompt-sheet > :not(.ci-prompt-text) { flex-shrink: 0; }
.ci-prompt-text {
  min-height: 0;
  padding: 12px 14px;
  background: var(--glass-bold);
  border-radius: var(--r-btn);
  overflow-y: auto;
  overscroll-behavior: contain;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  -webkit-user-select: text;
  user-select: text;
  -webkit-touch-callout: default;
  font-size: 15px;
  line-height: 1.6;
  margin-bottom: 16px;
}
.ci-prompt-sheet > .btn-secondary { margin-top: 8px; }

/* Бэкдроп затемняется НА МЕСТЕ, вверх едет только сам шит. Раньше оверлей
   анимировал OPACITY - а предок с анимируемой прозрачностью становится
   backdrop root, и стеклянный шит весь подъём ехал БЕЗ блюра, с голым
   прозрачным фоном (владелец, 2026-08-20). Поэтому оверлей анимирует только
   background-color (не создаёт корня), а шит сам растворяется opacity на
   СЕБЕ - фильтр элемента с собственной анимацией прозрачности не отключается,
   отключается у детей предка. */
.modal-enter-active { animation: modal-dim 0.3s ease; }
/* fill both on the leaves: a node removed by a timer rather than Vue's
   animationend (the catalog consent sheet) must hold the end frame, or the
   scrim snaps back to full dim until removal. --scrim-out: a swipe commit
   writes the slide's duration (wireSheetDismiss, catalog closeConsent) */
.modal-leave-active { animation: modal-dim 0.3s ease reverse both; animation-duration: var(--scrim-out, .3s); }
.modal-enter-active .modal-sheet { animation: sheet-up 0.35s cubic-bezier(0.32, 0.72, 0, 1); }
.modal-leave-active .modal-sheet { animation: sheet-dn 0.35s cubic-bezier(0.32, 0.72, 0, 1) both; }
@keyframes modal-dim { from{background-color: rgba(0,0,0,0)} to{background-color: rgba(0,0,0,0.5)} }
/* opacity fade for NON-ancestor layers only (the ag overlay's ::before scrim);
   never put it back on an ancestor of a glass sheet */
@keyframes modal-fade { from{opacity:0} to{opacity:1} }
@keyframes sheet-up { from{transform:translateY(100%)} to{transform:none} }
@keyframes sheet-dn { from{transform:none} to{transform:translateY(100%)} }
/* Swipe-dismissed sheet (wireSheetDismiss in app.js): it already slid out
   from the dragged position via an inline transition; replaying sheet-dn
   from the top would yank it back. The backdrop keeps the reverse fade. */
.modal-leave-active.sheet-dismissed .modal-sheet { animation: none; }

/* ═══════════════════════════════════════════
   TRANSITIONS
═══════════════════════════════════════════ */
.message-enter-active { animation: msg-in 0.16s ease-out; }
@keyframes msg-in { from{opacity:0;transform:translateY(5px)} to{opacity:1;transform:none} }

/* Loading completion: fresh content replacing skeletons fades in ONCE, on the
   container - no stagger, no scale (staggered card entrances are a generated-UI
   marker; stagger stays reserved for small chrome settles like .act-reveal).
   250ms decelerate, NOT the 150ms feedback band: at 150ms the fade dissolved
   into the overlay's own 150ms entry fade and read as no transition at all.
   One-shot by contract: the applier (JS class add or a Vue ref flip) must drop
   the class right after play, so display-toggled re-shows of kept-alive
   surfaces never replay it. */
.content-in { animation: content-in 250ms cubic-bezier(0.05, 0.7, 0.1, 1); }
@keyframes content-in { from{opacity:0} to{opacity:1} }
@media (prefers-reduced-motion: reduce) {
  .content-in { animation: none; }
}

/* ═══════════════════════════════════════════
   BUBBLE FORMATTING
═══════════════════════════════════════════ */
.bubble b, .bubble strong { font-weight: 700; }
.bubble i, .bubble em { font-style: italic; }
/* RP actions (<b>*...*</b> in model output, restyled at render per user setting) */
.rp-action { font-weight: 700; }
.rp-action.dim { font-weight: 400; font-style: italic; color: var(--text2); }
/* Paced streaming reveal (app.js _paceStart): each drained chunk fades in;
   with blur on, word groups materialize from blur. Spans exist only while the
   message streams - the settled render carries none. Plain inline (never
   inline-block): line wrapping must not shift when a span later unwraps. */
.sw-in { animation: swIn 0.2s ease-out both; }
.sw-in.sw-blur { animation: swBlurIn 0.3s ease-out both; }
body.no-blur .sw-in.sw-blur { animation-name: swIn; }
@keyframes swIn { from { opacity: 0; } }
@keyframes swBlurIn { from { opacity: 0; filter: blur(6px); } }
@media (prefers-reduced-motion: reduce) { .sw-in, .sw-in.sw-blur { animation: none; } }
/* settings live preview: a miniature model bubble */
.rp-preview {
  margin-top: 14px;
  background: var(--glass-bold);
  border-radius: 14px;
  border-bottom-left-radius: 5px;
  padding: 10px 12px;
  font-size: 14px;
  line-height: 1.5;
}

/* ═══════════════════════════════════════════
   CHAT WALLPAPERS (Settings → Appearance → Chat wallpaper)
═══════════════════════════════════════════ */
/* tile grid; islands rhythm: content is inset by var(--pad) like .island.
   position:relative anchors the FLIP leave (a deleted tile pins absolute at
   its cell while survivors glide into the gap - transition-group `wpg`) */
.wp-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 8px;
  margin: 0 var(--pad);
  position: relative;
}
/* .wp-tile.* specificity: the base .wp-tile carries its own
   `transition: transform 130ms` (press feedback) LATER in the file, which
   would override a bare .wpg-move at equal specificity */
.wp-tile.wpg-move { transition: transform 260ms cubic-bezier(0.4, 0, 0.2, 1); }
.wp-tile.wpg-leave-active {
  position: absolute;
  z-index: 0;
  transition: opacity 200ms cubic-bezier(0.4, 0, 0.2, 1), transform 200ms cubic-bezier(0.4, 0, 0.2, 1);
}
.wp-tile.wpg-leave-to { opacity: 0; transform: scale(0.86); }
.wp-tile.wpg-enter-active { transition: opacity 200ms cubic-bezier(0.4, 0, 0.2, 1), transform 240ms cubic-bezier(0.34, 1.2, 0.4, 1); }
.wp-tile.wpg-enter-from { opacity: 0; transform: scale(0.86); }
.wp-tile {
  position: relative;
  aspect-ratio: 9 / 16;
  border-radius: var(--r-btn);
  overflow: hidden;
  border: none;
  padding: 0;
  background: var(--glass);
  cursor: pointer;
  font-family: inherit;
  transition: transform 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
.wp-tile:active { transform: scale(0.96); }
.wp-tile:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.wp-tile img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
.wp-tile-badge {
  position: absolute;
  top: 6px;
  right: 6px;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  background: var(--accent);
  color: #fff;
  display: flex;
  align-items: center;
  justify-content: center;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
}
.wp-tile-badge svg { width: 12px; height: 12px; }
.wpbadge-enter-active { transition: transform 200ms cubic-bezier(0.34, 1.3, 0.4, 1), opacity 140ms cubic-bezier(0.4, 0, 0.2, 1); }
.wpbadge-leave-active { transition: transform 130ms cubic-bezier(0.4, 0, 0.2, 1), opacity 130ms cubic-bezier(0.4, 0, 0.2, 1); }
.wpbadge-enter-from, .wpbadge-leave-to { transform: scale(0.3); opacity: 0; }
/* the "no wallpaper" tile: the bare default background, one tone step up so
   the tile shape reads on OLED; the label carries the meaning */
.wp-tile-none { background: var(--bg-tint); }
.wp-tile-lbl {
  position: absolute;
  left: 6px;
  right: 6px;
  bottom: 8px;
  font-size: 11.5px;
  font-weight: 500;
  color: var(--text2);
  text-align: center;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.wp-tile-add {
  background: var(--glass);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
}
.wp-tile-add.busy { opacity: 0.6; cursor: default; }
/* rendered 1px input (NOT display:none): some WebKit builds ignore a
   programmatic .click() on hidden inputs, a real <label for> activation
   works everywhere */
.wp-file-input {
  position: fixed;
  left: 0;
  bottom: 0;
  width: 1px;
  height: 1px;
  opacity: 0.001;
  overflow: hidden;
  pointer-events: none;
  border: 0;
  padding: 0;
}
.wp-add-ico { width: 24px; height: 24px; color: var(--text2); }
.wp-add-ico svg { width: 24px; height: 24px; }

/* fullscreen editor: live chat mock over the image. Above the desktop
   sidebar (4000) like the lightbox, below toasts (4500); its delete confirm
   carries an inline z-index above the editor (the base .modal-overlay 200
   would otherwise sink under it). The root is TRANSPARENT: the live grid
   stays visible under the zoom transition (no seam frame), dimmed by the
   animated .wpe-scrim sibling. touch-action none = the surface owns pan and
   pinch (crop gestures); sliders still run their own JS handlers. */
.wp-editor {
  position: fixed;
  inset: 0;
  /* UNDER the desktop sidebar (4000): on desktop the sidebar stays visible
     over the editor exactly like over the real chat; on mobile nothing sits
     above this except toasts and the delete confirm (inline z 4300) */
  z-index: 3900;
  overflow: hidden;
  touch-action: none;
}
.wpe-scrim {
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, 0.5);
  opacity: 0;
  pointer-events: none;
}
/* the zoom layer is the shared element: two-axis transform/border-radius are
   written imperatively by _wpeEnter/closeWpEditor (tile rect <-> fullscreen);
   .wpe-counter carries the inverse scale that keeps the content cover-true
   while the wrapper's aspect morphs (desktop tiles are 9:16 in a 16:9 view) */
.wpe-zoom {
  position: absolute;
  inset: 0;
  overflow: hidden;
  background-size: cover;
  background-position: center;
  will-change: transform;
}
.wpe-counter {
  position: absolute;
  inset: 0;
  will-change: transform;
}
/* blur + its fringe oversize live here (center origin), so the img keeps the
   PURE crop transform (origin at the crop point, applyCropStyle semantics) */
.wpe-fit {
  position: absolute;
  inset: 0;
}
/* desktop-only scrim next to the sidebar, mirrors .chat-wallpaper::after in
   the real chat; opacity is painted by the editor choreography */
.wpe-leftfade {
  display: none;
}
.wpe-img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0;
  transition: opacity 180ms cubic-bezier(0.4, 0, 0.2, 1);
  /* filter/object-position/scale are painted imperatively by _wpeApply */
  user-select: none;
  -webkit-user-select: none;
  -webkit-user-drag: none;
}
.wpe-img.ready { opacity: 1; }
.wpe-dim {
  position: absolute;
  inset: 0;
  background: #000;
  opacity: 0;
  pointer-events: none;
}
/* chrome mock: the real chat classes anchor to the editor root (the bar is
   position:absolute), so geometry matches the live chat exactly */
.wpe-msgs {
  position: absolute;
  left: 0;
  right: 0;
  top: calc(64px + var(--tg-top));
  bottom: calc(316px + var(--safe-bottom));
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  overflow: hidden;
  pointer-events: none;
  /* bottom-anchored + overflow:hidden slices the FIRST element (the day
     pill) at an arbitrary line when the mock is taller than the gap between
     bar and dock (big --msg-fs, short viewports). Dissolve the top instead,
     like content under real chrome. Static layer, no glass inside - safe. */
  -webkit-mask-image: linear-gradient(to bottom, transparent, #000 34px);
  mask-image: linear-gradient(to bottom, transparent, #000 34px);
}
/* a bottom-anchored box needs no divider top margin - it only pushed the
   pill into the dissolve zone on normal phones */
.wpe-msgs .day-divider { margin-top: 0; }
/* The mock mirrors the REAL over-wallpaper chrome in both chip modes: the same
   token remap `.view-content:has(> .chat-wallpaper)` does for the live chat,
   because the mock sits over the wallpaper image outside that scope. Remapping
   instead of restating selectors also keeps the mock from flipping material
   when blur is off (it did once: owner report) - the dense value is one line
   below, not a specificity race. */
.wp-editor .wpe-msgs {
  --chrome-glass-fill:            var(--chrome-glass-fill-media);
  --chrome-glass-fill-readable:   var(--chrome-glass-fill-media-readable);
  --chrome-glass-filter:          var(--chrome-glass-filter-media);
  --chrome-glass-filter-readable: var(--chrome-glass-filter-media-readable);
}
body.no-blur .wp-editor .wpe-msgs {
  --chrome-glass-fill-solid: var(--chrome-dense-fill-raised);
}
.wpe-actions { padding-top: 6px; }
/* scrim mode: same swap the real chat does, using THIS wallpaper's baked
   service color (bound as --wp-scrim on the editor root) */
.screen.wp-chips-scrim .wp-editor .wpe-msgs .day-divider-pill,
.screen.wp-chips-scrim .wp-editor .act-icon,
.screen.wp-chips-scrim .wp-editor .msg-undo-btn,
.screen.wp-chips-scrim .wp-editor .msg-pin-btn,
.screen.wp-chips-scrim .wp-editor .msg-edit-btn {
  background: var(--wp-scrim, rgba(18, 18, 18, 0.7));
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  box-shadow: none;
  color: #fff;
}
/* floating control island: glass, chips-tier blur, standard no-blur solid.
   10px control inset makes the 14px children truly concentric with the 24px
   island (24 - 10 = 14, codex-verified against current HIG); the labels keep
   their 16px text axis via their own 6px inline padding */
.wpe-panel {
  position: absolute;
  left: 10px;
  right: 10px;
  bottom: calc(10px + var(--safe-bottom));
  border-radius: var(--r-island);
  padding: 14px 10px 10px;
  background: var(--chrome-glass-fill-readable);
  backdrop-filter: var(--chrome-glass-filter-readable);
  -webkit-backdrop-filter: var(--chrome-glass-filter-readable);
  box-shadow: var(--chrome-glass-shadow);
}
body.no-blur .wpe-panel {
  background: var(--bg-tint);
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  box-shadow: 0 4px 12px rgb(0 0 0 / 25%);
}
.wpe-panel-lbl {
  /* readable over user media (the panel floats on the wallpaper): 13/600 at
     78% white - 12px --text3 measured ~2.7:1 there */
  font-size: 13px;
  line-height: 18px;
  font-weight: 600;
  color: rgb(255 255 255 / 78%);
  margin-bottom: 6px;
  padding: 0 6px;
}
/* ONE slider grammar: iOS value slider - 4px track, accent leading fill, 28px
   round thumb, 44px target. The thick 28px bar with a 6x40 bar-knob was the
   old M3 look (owner, 2026-08-19). Colour pickers below keep their 28px
   gradient STRIP (they are colour pickers, a 4px hue ramp is unreadable) but
   wear the same round thumb. The thumb is positioned by --f (0..1) with
   INSET travel, so at the ends it lands flush with the rounded track. */
.wp-bar {
  --sl-thumb-w: 28px;
  position: relative;
  height: 4px;
  margin: 14px 0;
  border-radius: var(--r-pill);
  cursor: pointer;
  /* white alpha, not a tone token: the track sits on --glass islands and on
     the glass panel, where --glass-bold is only a half step away (the same
     reasoning as .switch-track) */
  background: rgba(255, 255, 255, 0.16);
  touch-action: none;
  /* the leading fill is painted BY THE THUMB and runs off the track's left
     end; this trims it. The vertical outset keeps the 28px thumb and the
     44px hit ::after whole - a clipped region takes no pointer events. */
  clip-path: inset(-24px 0px);
}
/* invisible hit extension: the visible track stays 4px, the target reaches
   44px without moving anything */
.wp-bar::after {
  content: '';
  position: absolute;
  inset: -20px 0;
}
.wp-thumb {
  position: absolute;
  top: calc(50% - var(--sl-thumb-w) / 2);
  left: calc((100% - var(--sl-thumb-w)) * var(--f, 0));
  width: var(--sl-thumb-w);
  height: var(--sl-thumb-w);
  border-radius: var(--r-pill);
  background: #fff;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
  pointer-events: none;
}
/* the leading accent fill hangs off the handle (needs no --f of its own on
   the bar); z-index -1 keeps it under the white disc but over the track
   inside the bar's clip-path stacking context */
.wp-thumb::before {
  content: '';
  position: absolute;
  right: 50%;
  top: calc(50% - 2px);
  z-index: -1;
  width: 100vw;
  height: 4px;
  background: var(--accent);
  pointer-events: none;
}
.wp-bar.hue-bar,
.wp-bar.sat-bar { height: 28px; margin: 0 0 4px; }
.wp-bar.hue-bar::after,
.wp-bar.sat-bar::after { inset: -8px 0; }
.wp-bar.hue-bar .wp-thumb::before,
.wp-bar.sat-bar .wp-thumb::before { display: none; }
/* the reachable default marked ON the track (a continuous control never gets
   a reset button popping in beside it): same inset travel */
.wp-notch {
  position: absolute;
  top: 50%;
  left: calc(var(--sl-thumb-w) / 2 + (100% - var(--sl-thumb-w)) * var(--f, 0));
  width: 2px;
  height: 10px;
  border-radius: 1px;
  background: var(--text3);
  transform: translate(-50%, -50%);
  pointer-events: none;
}
/* theme-from-wallpaper: tonal row button with the dominant-color swatch;
   sits between the sliders and the CTA row (static - nothing above the bars
   moves during a drag) */
/* a standalone text action = a PILL like every other button (the 52px
   rounded rect was the last M3 stack member - owner, 2026-08-20); label
   left, the colour sample trailing */
.wpe-theme-btn {
  display: flex;
  align-items: center;
  justify-content: flex-start;
  gap: 8px;
  width: 100%;
  height: 48px;
  margin-top: 14px;
  padding: 0 20px;
  border: none;
  border-radius: var(--r-pill);
  background: var(--glass-bold);
  color: var(--text);
  font-size: 15px;
  font-weight: 500;
  font-family: inherit;
  cursor: pointer;
  transition: background 130ms cubic-bezier(0.4, 0, 0.2, 1), transform 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
@media (hover: hover) {
  .wpe-theme-btn:hover { background: var(--glass-border); }
}
.wpe-theme-btn:active { background: var(--glass-border); transform: scale(0.97); }
.wpe-theme-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* trailing rounded-SQUARE swatch in a 2px tonal moat: a passive color
   sample, deliberately unlike a radio dot or an interactive color well */
/* round sample in a round moat - the 7px rounded square was the last M3
   shape on this panel (owner, 2026-08-20) */
.wpe-theme-swatch {
  width: 20px;
  height: 20px;
  border-radius: var(--r-pill);
  padding: 2px;
  background: var(--glass-border);
  flex: none;
  margin-left: auto;
  display: flex;
}
.wpe-theme-swatch i {
  flex: 1;
  border-radius: var(--r-pill);
}
.wpe-cta-row {
  display: flex;
  gap: 8px;
  margin-top: 12px;
}
/* the CTA keeps .btn-primary's own pill; the destructive twin is a circle at
   the same height (icon action = circle, per the pressables grammar - the
   52px rounded squares were the old M3 stack, owner 2026-08-19) */
.wpe-apply {
  flex: 1;
  margin: 0;
}
.wpe-trash {
  flex: none;
  width: 48px;
  height: 48px;
  border: none;
  border-radius: var(--r-pill);
  background: var(--glass-bold);
  color: var(--destructive);
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: transform 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
.wpe-trash:active { transform: scale(0.94); background: var(--glass-border); }
.wpe-trash:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.wpe-trash svg { width: 20px; height: 20px; }
.bubble del { text-decoration: line-through; opacity: 0.6; }
.bubble code {
  background: rgba(255,255,255,0.1);
  border-radius: 5px;
  padding: 1px 6px;
  /* 13px at the default 15px --msg-fs; em so it tracks the user size */
  font-size: 0.87em;
  font-family: 'SF Mono', 'Fira Code', monospace;
}
.bubble.user code { background: rgba(0,0,0,0.12); }
.bubble pre {
  background: rgba(0,0,0,0.3);
  border-radius: 10px;
  padding: 10px 12px;
  overflow-x: auto;
  margin: 6px 0;
}
.bubble pre code {
  background: transparent;
  padding: 0;
  font-size: 0.87em;
  line-height: 1.5;
  color: #ddd;
  white-space: pre;
}

/* ═══════════════════════════════════════════
   UNDO BUTTON
═══════════════════════════════════════════ */
/* iOS glass material for the per-message side chips (undo / pin / edit), the
   same recipe as .act-icon and the pills (owner, 2026-07-21). Over a wallpaper
   they swap to solid (rules near .chat-wallpaper); no-blur leaves them alone -
   in-flow chips, see the no-blur block note. */
.msg-undo-btn,
.msg-pin-btn,
.msg-edit-btn,
.msg-anim-btn,
.msg-goto-btn {
  position: relative;
  transition: transform 130ms cubic-bezier(0.4, 0, 0.2, 1), scale 120ms ease-out;
  background: var(--chrome-glass-fill);
  backdrop-filter: var(--chrome-glass-filter);
  -webkit-backdrop-filter: var(--chrome-glass-filter);
  box-shadow: var(--chrome-glass-shadow-thin);
}
/* hit extension for the 28px side chips: 44px tall; sideways only 3px, the
   chips sit at a 34px pitch (28 + 6px margin) so more would overlap the
   neighbour chip / the bubble gutter */
.msg-undo-btn::before,
.msg-pin-btn::before,
.msg-edit-btn::before,
.msg-anim-btn::before,
.msg-goto-btn::before { content: ''; position: absolute; inset: -8px -3px; border-radius: inherit; }
.msg-undo-btn:focus-visible,
.msg-pin-btn:focus-visible,
.msg-edit-btn:focus-visible,
.msg-anim-btn:focus-visible,
.msg-goto-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* Solid stroke + svg opacity (not translucent color): undo/pin paths overlap,
   an alpha stroke would show darker blobs at the crossings */
.msg-undo-btn {
  width: 28px; height: 28px;
  border-radius: var(--r-pill);
  border: none;
  color: var(--text);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  flex-shrink: 0;
  align-self: flex-end;
  margin-bottom: 4px; margin-left: 6px;
  font-family: inherit;
  transition: transform 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
.msg-undo-btn svg { width: 13px; height: 13px; opacity: 0.72; }
.message-row:hover .msg-undo-btn svg,
.message-row:active .msg-undo-btn svg { opacity: 1; }
.msg-undo-btn:active { background: color-mix(in srgb, var(--destructive) 20%, transparent); color: var(--destructive); }
body.no-blur .msg-undo-btn:active { transform: scale(0.92); }
.msg-undo-btn:active svg { opacity: 1; }
.msg-undo-btn:disabled,
.msg-pin-btn:disabled,
.msg-edit-btn:disabled,
.msg-anim-btn:disabled,
.msg-goto-btn:disabled { opacity: 0.32; pointer-events: none; }

/* Pin-as-important: same vocabulary as .msg-undo-btn; pinned state is a
   persistent tonal fill (state = fill, not stroke) */
.msg-pin-btn {
  width: 28px; height: 28px;
  border-radius: var(--r-pill);
  border: none;
  color: var(--text);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  flex-shrink: 0;
  align-self: flex-end;
  margin-bottom: 4px; margin-left: 6px;
  font-family: inherit;
  transition: transform 130ms cubic-bezier(0.4, 0, 0.2, 1), background 0.15s, color 0.15s;
}
.msg-pin-btn svg { width: 13px; height: 13px; opacity: 0.72; }
.message-row:hover .msg-pin-btn svg,
.message-row:active .msg-pin-btn svg { opacity: 1; }
body.no-blur .msg-pin-btn:active { transform: scale(0.92); }
.msg-pin-btn:active svg { opacity: 1; }
.msg-pin-btn.pinned {
  background: var(--accent-soft);
  color: var(--accent-text);
}
.msg-pin-btn.pinned svg { opacity: 1; }
.message-row.user .msg-pin-btn { order: -1; margin-left: 0; margin-right: 6px; }

/* Edit own message: same side-button vocabulary as undo/pin, neutral action */
.msg-edit-btn {
  width: 28px; height: 28px;
  border-radius: var(--r-pill);
  border: none;
  color: var(--text);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  flex-shrink: 0;
  align-self: flex-end;
  margin-bottom: 4px; margin-left: 6px;
  font-family: inherit;
  transition: transform 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
.msg-edit-btn svg { width: 13px; height: 13px; opacity: 0.72; }
.message-row:hover .msg-edit-btn svg,
.message-row:active .msg-edit-btn svg { opacity: 1; }
body.no-blur .msg-edit-btn:active { transform: scale(0.92); }
.msg-edit-btn:active svg { opacity: 1; }
.message-row.user .msg-edit-btn { order: -1; margin-left: 0; margin-right: 6px; }

/* Animate photo (Wan): same side-button vocabulary, neutral action */
.msg-anim-btn {
  width: 28px; height: 28px;
  border-radius: var(--r-pill);
  border: none;
  color: var(--text);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  flex-shrink: 0;
  align-self: flex-end;
  margin-bottom: 4px; margin-left: 6px;
  font-family: inherit;
  transition: transform 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
.msg-anim-btn svg { width: 13px; height: 13px; opacity: 0.72; }
.message-row:hover .msg-anim-btn svg,
.message-row:active .msg-anim-btn svg { opacity: 1; }
body.no-blur .msg-anim-btn:active { transform: scale(0.92); }
.msg-anim-btn:active svg { opacity: 1; }

/* NO no-blur fill swap for these chips on purpose (owner, 2026-07-21): they
   are IN-FLOW - they scroll with the content, nothing ever passes behind
   them, so the glass mix renders as one constant tone with or without blur.
   Swapping to --bg-tint only made the two modes look different. The global
   no-blur rule still strips the (invisible) backdrop-filter for free. */

/* Side-chip entrance (the action-row sugIn vocabulary, horizontal): model
   chips slide out of the bubble left-to-right, user chips right-to-left
   (owner, 2026-07-21). Only state-change mounts animate - a transition's
   initial render never plays, so chat open and pagination paint atomically.
   Undo/pin/edit stagger like the bottom row. */
.scil-enter-active, .scir-enter-active {
  transition: opacity 0.26s cubic-bezier(0.05, 0.7, 0.1, 1),
              transform 0.26s cubic-bezier(0.05, 0.7, 0.1, 1);
}
.scil-enter-from { opacity: 0; transform: translateX(-10px) scale(0.9); }
.scir-enter-from { opacity: 0; transform: translateX(10px) scale(0.9); }
.msg-pin-btn.scil-enter-active, .msg-pin-btn.scir-enter-active { transition-delay: 45ms; }
.msg-edit-btn.scil-enter-active, .msg-edit-btn.scir-enter-active { transition-delay: 90ms; }
@media (prefers-reduced-motion: reduce) {
  .scil-enter-active, .scir-enter-active { transition: none; }
}

/* Pinned messages screen (view chatpins): real bubbles in chat order, a meta
   line above each, the two actions below. An unpinned entry stays in place
   greyed until the screen is reopened (misclick protection: the message is
   hard to find in the chat to pin it back), the glyph re-pins it. */
/* ═══ PINNED MESSAGES: the chat itself in pinned mode (Telegram iOS,
   ChatControllerImpl subject .pinnedMessages). The list reuses the chat's
   classes wholesale (.chat-glass-bar, .messages-area, .grp-run, .message-row,
   .bubble, .day-divider), so the wallpaper scope and the no-blur twins apply
   for free; only the title pill, the side "go to" chip, the grey state and
   the bottom capsule are new. ═══ */
/* compact and centred like Telegram's "N Pinned Messages" pill, not the
   chat's full-width identity pill */
.pins-title-pill {
  flex: 0 1 auto;
  margin: 0 auto;
  max-width: calc(100% - 96px);
  flex-direction: column;
  justify-content: center;
  gap: 0;
  padding: 0 22px;
  text-align: center;
}
.pins-title-pill .header-title {
  font-size: 14px;
  line-height: 17px;
  /* the single-line chat title is optically centred by text-box trim
     (WebKit, Chromium; Firefox ignores it); with a second line the trimmed
     box shrinks and the counter rides up under the title on iOS */
  text-box: normal;
  padding-block: 0;
  margin-block: 0;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.pins-title-sub {
  font-size: 12px;
  line-height: 14px;
  color: var(--text2);
  font-variant-numeric: tabular-nums;
}
/* keeps the pill centred against the single X chip, like Telegram's bar */
.pins-bar-spacer { width: 40px; flex-shrink: 0; }
/* the bottom capsule is lower than the composer */
.pins-area::after { height: calc(84px + var(--safe-bottom)); }
/* unpinned in place: the message dims, the two chips stay fully live
   (a dimmed control reads as disabled and defeats the mis-tap guard) */
.pin-row .bubble { transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1); }
.pin-row.off .bubble { opacity: 0.4; }
/* "go to the message": same side-chip vocabulary as undo/pin; sits next to
   the bubble on the incoming side (Telegram's navigate button), the pin
   glyph after it; mirrored for own messages */
.msg-goto-btn {
  width: 28px; height: 28px;
  border-radius: var(--r-pill);
  border: none;
  color: var(--text);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  flex-shrink: 0;
  align-self: flex-end;
  margin-bottom: 4px; margin-left: 6px;
  font-family: inherit;
}
.msg-goto-btn svg { width: 15px; height: 15px; opacity: 0.72; }
.message-row:hover .msg-goto-btn svg,
.message-row:active .msg-goto-btn svg { opacity: 1; }
body.no-blur .msg-goto-btn:active { transform: scale(0.92); }
.msg-goto-btn:active svg { opacity: 1; }
.message-row.user .msg-goto-btn { order: -1; margin-left: 0; margin-right: 6px; }
.message-row.user.pin-row .msg-pin-btn { order: -2; }
/* Telegram's navigate button: 8pt from the bubble edge, bottom-docked with
   its bottom 1pt above the bubble's bottom (ChatMessageBubbleItemNode:
   `x: maxX + 8.0, y: maxY - size - 1.0`); the second chip keeps the chat's
   6px pitch */
.pin-row .msg-goto-btn, .pin-row .msg-pin-btn { margin-bottom: 1px; }
.message-row.model.pin-row .msg-goto-btn { margin-left: 8px; }
.message-row.user.pin-row .msg-goto-btn { margin-right: 8px; }
.pins-foot {
  position: fixed;
  left: 0;
  right: 0;
  bottom: calc(12px + var(--safe-bottom));
  z-index: 20;
  display: flex;
  justify-content: center;
  padding: 0 16px;
  pointer-events: none;
}
.pins-foot-pill {
  height: auto;
  min-height: 48px;
  max-width: 100%;
  padding: 8px 24px;
  text-align: center;
  font-size: 15px;
  line-height: 1.3;
  font-weight: 500;
  color: var(--text2);
  white-space: normal;
}

/* ═══════════════════════════════════════════
   PREDICTIVE SWIPE-BACK (pushed sub-screens, v5.93)
═══════════════════════════════════════════ */
/* The peek is a frozen clone of the parent screen (built imperatively in
   app.js: _bpClaim). It is a full-size stacked sheet UNDER the live
   .view-content; the transform on .nav-peek makes it the containing block for
   the cloned fixed chrome (nav, bars), so it slides as one sheet. */
.nav-peek {
  position: fixed;
  inset: 0;
  z-index: 1;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  background: var(--bg);
  will-change: transform;
}
.nav-peek > .view-content {
  flex: 1;
  transition: none !important;
  opacity: 1 !important;
  transform: none !important;
}
/* TG-iOS dims the revealed parent: alpha (1-position)*0.15 - a shade, never a
   blackout (opacity is painted per-frame by _bpPaint) */
.nav-peek-dim {
  position: absolute;
  inset: 0;
  background: #000;
  opacity: 0.15;
  pointer-events: none;
  z-index: 60;
}
/* the live child rides above the peek with an edge shadow; the real fixed
   bottom-nav (z 100) stays on top for sub-screens that keep it. An OPAQUE
   fill is mandatory: message/list gaps are transparent, so without it the
   peek underneath bleeds through and the background "disappears". */
.view-content.nav-swiping {
  transition: none;
  z-index: 3;
  background: var(--bg);
  box-shadow: -8px 0 28px rgba(0, 0, 0, 0.45);
  will-change: transform;
}

/* ═══════════════════════════════════════════
   LONG-PRESS MESSAGE MENU (v5.99)
═══════════════════════════════════════════ */
.msg-ctx-root {
  position: fixed;
  inset: 0;
  z-index: 4200;
  /* backdrop: the menu persists after the opening finger lifts, so the root
     catches outside taps to dismiss (and keeps them off the chat below) */
  pointer-events: auto;
  /* menu labels are chrome: without this Android WebView's native long-press
     grabs the first row's text mid-gesture and shows the system callout */
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
}
/* pre-trigger hold acknowledgment: the pressed bubble compresses a step
   (the delay keeps taps and scroll starts from flashing it); removal snaps
   back on purpose - a canceled hold is mid-scroll where 2% is invisible */
.messages-area .bubble.msg-hold-press {
  transform: scale(0.98);
  transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1) 0.12s;
}
/* Focus is paint-only on the REAL bubble: no clone, portal, movement, crop,
   scroll viewport, or stacking-layer handoff. */
.message-row.msg-ctx-src .bubble {
  filter: brightness(1.12) saturate(1.04);
  box-shadow:
    0 0 0 1px color-mix(in srgb, var(--text) 16%, transparent),
    0 5px 16px rgba(0,0,0,0.22);
  transition: filter 0.1s ease-out, box-shadow 0.1s ease-out;
}
.msg-ctx-reveal {
  position: absolute;
  pointer-events: auto;
  width: min(240px, calc(100vw - 24px));
  min-width: 0;
  max-width: none;
}
.msg-ctx-reveal > .msg-ctx-menu { width: 100%; min-width: 0; }
/* Reveal travels a few pixels toward the finger. */
.msg-ctx-reveal.chatmenu-enter-from,
.msg-ctx-reveal.chatmenu-leave-to { transform: translateY(var(--ctx-enter-y, -6px)); }
/* Message actions use the 44px iOS target and one trailing icon family.
   Button semantics add keyboard focus without changing the glass row idiom. */
.msg-ctx-menu .chat-dropdown-item {
  width: 100%;
  min-height: 44px;
  padding: 0 16px;
  border: 0;
  background: transparent;
  color: var(--text);
  font-family: inherit;
  text-align: left;
  justify-content: space-between;
}
.msg-ctx-menu .chat-dropdown-item > span:first-child { min-width: 0; }
.msg-ctx-icon {
  width: 18px;
  height: 18px;
  flex: 0 0 18px;
  opacity: 0.72;
}
.msg-ctx-icon svg { display: block; width: 18px; height: 18px; }
/* slide-finger highlight (iOS full-bleed row, mirrors :active) */
.msg-ctx-menu .chat-dropdown-item.drag-hover { background: rgba(255,255,255,0.07); }
body.msg-ctx-open .action-icons { opacity: 0; }
@media (pointer: coarse) {
  /* the hold gesture owns long-press on touch; Copy lives in the menu */
  .messages-area .bubble {
    -webkit-touch-callout: none;
    -webkit-user-select: none;
    user-select: none;
  }
}
@media (prefers-reduced-motion: reduce) {
  .message-row.msg-ctx-src .bubble { transition: none; }
  .messages-area .bubble.msg-hold-press {
    transform: none;
    transition: none;
  }
}

/* ── In-message text selection (custom engine; ADR-0021) ──
   The layer sits on .message-row (outside .swipe-stage's overflow clip) in
   row-local coords, so highlight and knobs ride the scroll for free. */
.tsel-layer {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 3;
}
.tsel-hl {
  position: absolute;
  background: color-mix(in srgb, var(--accent) 36%, transparent);
  border-radius: 4px;
}
/* mirrors ::selection: accent melts into the accent user bubble, tint white */
.message-row.user .tsel-hl { background: rgba(255, 255, 255, 0.3); }
.tsel-hl.in { animation: tselIn 0.22s cubic-bezier(0.4, 0, 0.2, 1); }
@keyframes tselIn {
  from { transform: scale(1.6); opacity: 0; }
  to { transform: scale(1); opacity: 1; }
}
/* Telegram-style knob: stem spanning the line edge + ball (start on top,
   end below), solid accent; white inside the accent user bubble */
.tsel-knob {
  position: absolute;
  width: 0;
  pointer-events: none;
  animation: tselKnob 0.2s cubic-bezier(0.34, 1.3, 0.64, 1) 0.1s backwards;
}
.tsel-knob-stem {
  position: absolute;
  top: 0;
  bottom: 0;
  left: -1px;
  width: 2px;
  border-radius: 1px;
  background: var(--accent);
}
.tsel-knob-ball {
  position: absolute;
  left: -6px;
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: var(--accent);
}
.tsel-knob.a .tsel-knob-ball { top: -11px; }
.tsel-knob.b .tsel-knob-ball { bottom: -11px; }
.message-row.user .tsel-knob-stem,
.message-row.user .tsel-knob-ball { background: #fff; }
/* 44px finger target around the whole knob; transparent to caret hit-tests
   while a drag is in flight. touch-action: none is load-bearing - without it
   the WebView can claim the touch as a scroll and pointercancel the drag */
.tsel-knob-hit {
  position: absolute;
  left: -22px;
  top: -22px;
  width: 44px;
  height: calc(100% + 44px);
  pointer-events: auto;
  touch-action: none;
}
.tsel-layer.dragging .tsel-knob-hit { pointer-events: none; }
@keyframes tselKnob {
  from { opacity: 0; transform: scale(0.5); }
  to { opacity: 1; transform: scale(1); }
}
.tsel-menu-root {
  position: fixed;
  inset: 0;
  z-index: 4300;
  pointer-events: none;
  /* same Android guard as .msg-ctx-root: row labels are never selectable */
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
}
.tsel-reveal {
  position: absolute;
  pointer-events: auto;
  min-width: 0;
  max-width: none;
}
.tsel-reveal > .tsel-menu { width: 100%; min-width: 0; }
@media (prefers-reduced-motion: reduce) {
  .tsel-hl.in, .tsel-knob { animation: none; }
}

/* ═══════════════════════════════════════════
   IMAGE BUBBLE + LIGHTBOX
═══════════════════════════════════════════ */
.image-bubble { padding: 3px; overflow: hidden; cursor: pointer; }
/* The box is reserved from the payload's width/height BEFORE the file arrives,
   so nothing below a loading photo moves when it decodes. Two of these three
   lines are load-bearing for that, and both look like tidying:
   - a definite `width` instead of `width: 100%`. The bubble is a shrink-to-fit
     flex item, so a percentage resolves against a width that the picture is
     itself supposed to supply - circular, and an unloaded img has no intrinsic
     width to break the cycle, so the whole bubble collapsed to zero and then
     snapped open on load. 280px is the same final size (it was the max-width);
     max-width: 100% keeps it shrinking inside a narrow bubble.
   - `height: auto`. The attributes map to the width/height properties as
     presentational hints as well as to the ratio, and only width is overridden
     here - without this the element would take the raw pixel height (1280px
     for a portrait). */
.gen-image {
  width: 280px;
  max-width: 100%;
  height: auto;
  border-radius: var(--r-card);
  display: block;
  opacity: 0;
  transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.gen-image.loaded { opacity: 1; }
/* :active after .loaded so press dim wins over the load fade */
.gen-image:active { opacity: 0.75; }

/* Chat video (H3 animation): the poster IS the source photo, so it shows
   instantly - no load fade dance like images. The box comes from an inline
   aspect-ratio (mediaBox in app.js); object-fit absorbs the difference
   between the source photo's ratio and the size H3 rendered at (zero for
   v5.229 presets, ~1.2% for legacy-grid photos). */
.gen-video { opacity: 1; object-fit: cover; }
.anim-hint {
  font-size: 12px;
  color: var(--text3);
  line-height: 1.4;
  margin-top: 6px;
}

/* Fullscreen image viewer: fade the backdrop in, gently zoom the image up.
   (Not the bottom-sheet slide used by .modal — that looks wrong full-screen.) */
/* ═══════════════════════════════════════════
   MEDIA VIEWER (v5.194 rework)
   spec: docs/superpowers/specs/2026-08-10-media-viewer-rework-design.md
═══════════════════════════════════════════ */
/* the layer fades; the dismiss wrapper carries the 0.96 arrival scale (its
   rest transform is translate3d(0,0,0), so a keyframe can own it briefly) */
.lb-enter-active { animation: lb-fade-in 0.26s cubic-bezier(0.05, 0.7, 0.1, 1); }
.lb-leave-active { animation: lb-fade-in 0.15s cubic-bezier(0.4, 0, 0.2, 1) reverse; }
@keyframes lb-fade-in { from { opacity: 0; } to { opacity: 1; } }
.lb-enter-active .lb-dismiss { animation: lb-zoom-in 0.26s cubic-bezier(0.05, 0.7, 0.1, 1); }
@keyframes lb-zoom-in { from { transform: scale(0.96); } to { transform: scale(1); } }
/* NO backdrop-filter on the container: the old fullscreen blur was the
   measured perf whale (docs/perf-backdrop-filter.md) and an ADR-0014
   backdrop root. The dim is the solid scrim layer below (TG parity). */
.lightbox {
  position: fixed;
  inset: 0;
  z-index: 300;
  overflow: hidden;
  touch-action: none;
}
/* photo-viewer black is deliberate non-theme (TG hardcodes its viewer dark);
   opacity is the one dim knob the engine writes per frame */
.lb-scrim {
  position: absolute;
  inset: 0;
  background: #000;
  pointer-events: none;
}
.lb-dismiss { position: absolute; inset: 0; will-change: transform; }
.lb-track { position: absolute; inset: 0; will-change: transform; }
/* slides are engine-positioned (inline width/height/transform) */
.lb-slide {
  position: absolute;
  left: 0;
  top: 0;
  display: flex;
  align-items: center;
  justify-content: center;
}
.lb-slide .lb-img {
  display: block;
  object-fit: contain;
  will-change: transform;
  opacity: 0;
  transition: opacity 0.25s cubic-bezier(0.05, 0.7, 0.1, 1);
}
.lb-slide .lb-img.loaded { opacity: 1; }
/* dead file: broken-media glyph, the slide stays pageable */
.lb-slide-dead::after {
  content: '';
  position: absolute;
  width: 48px;
  height: 48px;
  opacity: 0.35;
  background: currentColor;
  color: #fff;
  -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='3' width='18' height='18' rx='3'/%3E%3Ccircle cx='8.5' cy='8.5' r='1.5'/%3E%3Cpath d='M21 15l-5-5-8 8'/%3E%3Cline x1='3' y1='3' x2='21' y2='21'/%3E%3C/svg%3E") center / contain no-repeat;
  mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='3' width='18' height='18' rx='3'/%3E%3Ccircle cx='8.5' cy='8.5' r='1.5'/%3E%3Cpath d='M21 15l-5-5-8 8'/%3E%3Cline x1='3' y1='3' x2='21' y2='21'/%3E%3C/svg%3E") center / contain no-repeat;
}
/* Viewer chrome is deliberately NON-THEME neutral dark (TG hardcodes its
   viewer dark; a theme-tinted capsule over photos reads alien - owner,
   2026-08-10). --lb-glass/-solid are viewer-local, not app tokens. */
.lightbox {
  --lb-glass: rgba(28, 28, 30, 0.6);
  --lb-solid: #1f1f21;
}
/* rejected autoplay: visible play control instead of a silent black frame */
.lb-play-chip {
  position: absolute;
  width: 64px;
  height: 64px;
  border-radius: var(--r-pill);
  border: none;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  color: #fff;
  background: var(--lb-glass);
  backdrop-filter: var(--chrome-glass-filter-media);
  -webkit-backdrop-filter: var(--chrome-glass-filter-media);
  box-shadow: var(--chrome-glass-shadow);
}
.lb-play-chip svg { width: 30px; height: 30px; }
.lb-play-chip { transition: transform 130ms cubic-bezier(0.4, 0, 0.2, 1); }
.lb-play-chip:active { transform: scale(0.92); }
.lb-play-chip:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
body.no-blur .lb-play-chip { background: var(--lb-solid); }
/* delayed load spinner (500ms): only when nothing is displayable yet */
.lb-loader {
  position: absolute;
  width: 28px;
  height: 28px;
  border: 2.5px solid rgba(255,255,255,0.25);
  border-top-color: rgba(255,255,255,0.9);
  border-radius: 50%;
  animation: spin 0.6s linear infinite;
  pointer-events: none;
}
/* progressive edge scrims behind the chrome: plain gradients, never blur
   (TG EdgeEffect parity; the ADR-0020 scrolling-blur rule) */
.lb-fade {
  position: absolute;
  left: 0;
  right: 0;
  height: 140px;
  pointer-events: none;
  transition: opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1);
  z-index: 8;
}
.lb-fade-top { top: 0; background: linear-gradient(rgba(0,0,0,0.65), transparent); }
.lb-fade-bottom { bottom: 0; background: linear-gradient(transparent, rgba(0,0,0,0.65)); }
.lb-fade.off { opacity: 0; }
/* chrome bars fade/slide as UNITS; a dismiss drag writes inline opacity on
   top of these classes and clears it at rest */
.lb-topbar {
  position: absolute;
  top: calc(8px + max(var(--safe-top), var(--tg-top)));
  left: 10px;
  right: 10px;
  display: flex;
  align-items: center;
  /* close chip left, menu chip (or its ghost) right, whether or not the
     counter is rendered: with a single image there is no counter and its
     margin: 0 auto is the only thing that used to push the menu chip right,
     so both chips sat in the left corner (prod, 2026-09-08). */
  justify-content: space-between;
  gap: 8px;
  z-index: 10;
  /* the bar animates only its TRANSFORM: an opacity < 1 on an ancestor makes
     that ancestor the backdrop root, so every glass chip inside loses its blur
     for the whole fade (owner saw the chips go flat while animating in the
     photo viewer, 2026-08-19). The fade lives on the children instead. */
  transition: none;
}
/* each child fades and slides on its own (translate, not transform: the chips
   use transform for their press scale); the BAR itself never animates, so no
   ancestor ever becomes a backdrop root or a moving composited group while
   the glass underneath is being sampled */
/* NO opacity anywhere in the chrome fade: WebKit drops backdrop-filter on an
   element while its opacity animates, so a fading glass chip pops its blur in
   at the end (owner, 2026-08-19). The chips SLIDE off-screen instead and go
   visibility:hidden once parked - blur constant for the whole move. */
.lb-topbar > * { transition: translate 0.22s cubic-bezier(0.4, 0, 0.2, 1), visibility 0s linear 0.22s; }
.lb-topbar.off { pointer-events: none; }
.lb-topbar.off > * { translate: 0 calc(-100% - 40px - var(--safe-top) - var(--tg-top)); visibility: hidden; }
.lb-counter {
  margin: 0 auto;
  height: 24px;
  padding: 0 10px;
  border-radius: 12px;
  background: rgba(0,0,0,0.5);
  color: #fff;
  font-size: 13px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  display: flex;
  align-items: center;
  white-space: nowrap;
}
.lb-chip-ghost { width: 40px; flex: 0 0 40px; }
/* glass chip verbatim (recipe §3) + no-blur solid fallback */
.lb-chip {
  position: relative;
  width: 40px;
  height: 40px;
  flex: 0 0 40px;
  border-radius: var(--r-pill);
  background: var(--lb-glass);
  backdrop-filter: var(--chrome-glass-filter-media);
  -webkit-backdrop-filter: var(--chrome-glass-filter-media);
  box-shadow: var(--chrome-glass-shadow-thin);
  border: none;
  color: #fff;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  transition: transform 130ms cubic-bezier(0.4, 0, 0.2, 1), scale 120ms ease-out, translate 0.22s cubic-bezier(0.4, 0, 0.2, 1), visibility 0s linear 0.22s;
}
.lb-chip svg { width: 18px; height: 18px; }
body.no-blur .lb-chip:active { transform: scale(0.92); }
.lb-chip:disabled { cursor: default; opacity: 0.32; }
.lb-chip:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
body.no-blur .lb-chip { background: var(--lb-solid); }
/* paging chevrons: the swipe gesture's visible control twin (frontend.md),
   on ALL pointer types; they ride the chrome fade */
.lb-nav {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 9;
  /* the overlay never animates its own opacity: the arrow chips are glass, and
     an opacity < 1 on this ancestor turned it into their backdrop root, so the
     arrows faded in with NO blur behind them (owner, 2026-08-19). The chips
     fade themselves (.lb-chip already transitions opacity). */
}
.lb-nav .lb-chip { position: absolute; top: 50%; margin-top: -20px; pointer-events: auto; }
.lb-nav.off .lb-chip { pointer-events: none; visibility: hidden; }
.lb-nav.off .lb-nav-prev { translate: -70px 0; }
.lb-nav.off .lb-nav-next { translate: 70px 0; }
.lb-nav-prev { left: 10px; }
.lb-nav-next { right: 10px; }
/* bottom action capsule: ONE glass pill, max 3 labeled actions; archive adds
   a separate trash chip (the TG left/right capsule split) */
.lb-bottombar {
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
  bottom: calc(12px + var(--safe-bottom));
  display: flex;
  align-items: center;
  gap: 8px;
  z-index: 10;
  max-width: calc(100% - 20px);
  /* the bar keeps its static centring transform and never animates; the
     children fade and slide (see .lb-topbar) */
  transition: none;
}
.lb-bottombar > * { transition: translate 0.22s cubic-bezier(0.4, 0, 0.2, 1), visibility 0s linear 0.22s; }
.lb-bottombar.off { pointer-events: none; }
.lb-bottombar.off > * { translate: 0 calc(100% + 40px + var(--safe-bottom)); visibility: hidden; }
.lb-actions {
  display: flex;
  align-items: center;
  gap: 2px;
  padding: 4px;
  border-radius: var(--r-pill);
  background: var(--lb-glass);
  backdrop-filter: var(--chrome-glass-filter-media);
  -webkit-backdrop-filter: var(--chrome-glass-filter-media);
  box-shadow: var(--chrome-glass-shadow);
}
body.no-blur .lb-actions { background: var(--lb-solid); }
.lb-action {
  width: 52px;
  height: 44px;
  border: none;
  border-radius: var(--r-pill);
  background: transparent;
  color: #fff;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  font-family: inherit;
  transition: background 130ms cubic-bezier(0.4, 0, 0.2, 1), transform 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
.lb-action svg, .lb-action .lb-action-ic svg { width: 22px; height: 22px; }
.lb-action .lb-action-ic { display: flex; }
@media (hover: hover) {
  .lb-action:hover { background: rgba(255,255,255,0.08); }
}
.lb-action:active { transform: scale(0.92); background: rgba(255,255,255,0.12); }
.lb-action:focus-visible { outline: 2px solid #fff; outline-offset: -2px; }
.lb-del { width: 44px; height: 44px; flex: 0 0 44px; }
/* the overflow menu portal: sibling of the fading bars (ADR-0014) */
.lb-menu-root {
  position: absolute;
  top: calc(8px + max(var(--safe-top), var(--tg-top)) + 46px);
  right: 10px;
  z-index: 20;
}
.lb-menu-reveal > .chat-dropdown {
  position: static;
  background: rgba(28, 28, 30, 0.85);
  color: #fff;
}
body.no-blur .lb-menu-reveal > .chat-dropdown { background: var(--lb-solid); }
/* the rows are <button>s: strip UA chrome (the msg-ctx-menu recipe), or iOS
   paints them as light content-sized blobs (owner report, 2026-08-10) */
.lb-menu-reveal .chat-dropdown-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  width: 100%;
  min-height: 44px;
  padding: 0 16px;
  border: 0;
  border-radius: 0;
  appearance: none;
  -webkit-appearance: none;
  background: transparent;
  color: #fff;
  font-family: inherit;
  font-size: 15px;
  text-align: left;
  cursor: pointer;
}
.lb-menu-reveal .chat-dropdown-item:active { background: rgba(255,255,255,0.07); }
.lb-menu-reveal .chat-dropdown-item:disabled { opacity: 0.5; cursor: default; }
.lb-spinner {
  width: 18px;
  height: 18px;
  border: 2px solid color-mix(in srgb, currentColor 30%, transparent);
  border-top-color: currentColor;
  border-radius: 50%;
  animation: spin 0.6s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
  /* keep the short opacity fade (spec: "instant settles, fade open/close"),
     drop only the scale motion and chrome transitions */
  .lb-enter-active .lb-dismiss { animation: none; }
  .lb-topbar > *, .lb-bottombar > *, .lb-nav .lb-chip, .lb-fade, .lb-slide .lb-img { transition: none; }
}

/* ═══════════════════════════════════════════
   CHAT DROPDOWN
═══════════════════════════════════════════ */
/* iOS-style glass menu: full-bleed row highlight, hairline separators,
   thick band between groups. Matches the glass chips it hangs off. */
.chat-dropdown {
  position: absolute;
  right: 0;
  top: calc(100% + 6px);
  background: var(--chrome-glass-fill-readable);
  backdrop-filter: var(--chrome-glass-filter-readable);
  -webkit-backdrop-filter: var(--chrome-glass-filter-readable);
  border: none;
  border-radius: 16px;
  min-width: 220px;
  z-index: 100;
  overflow-y: auto;
  max-height: 70vh;
  box-shadow: var(--chrome-glass-shadow);
}
body.no-blur .chat-dropdown { background: var(--bg-tint); }
/* Canonical glass-menu reveal shell. Its grid row changes the visible menu
   height without putting opacity or clip-path on an ancestor of the filtered
   surface: both create a Backdrop Root and can make frost appear only after
   the animation. transform is safe here and supplies the anchored travel. */
.dropdown-reveal {
  position: absolute;
  right: 0;
  top: calc(100% + 6px);
  z-index: 100;
  display: grid;
  grid-template-rows: 1fr;
}
/* Permanently mounted menus (community.js) use state classes instead of
   Vue's transient enter/leave classes. Keeping both endpoints in CSS lets a
   rapid second tap reverse from the current frame without a visual snap. */
.dropdown-reveal.menu-reveal {
  grid-template-rows: 0fr;
  transform: translateY(-6px);
  pointer-events: none;
  transition:
    grid-template-rows 0.15s cubic-bezier(0.4, 0, 0.2, 1),
    transform 0.15s cubic-bezier(0.4, 0, 0.2, 1);
}
.dropdown-reveal.menu-reveal.active {
  grid-template-rows: 1fr;
  transform: translateY(0);
  pointer-events: auto;
  transition-duration: 0.22s;
  transition-timing-function: cubic-bezier(0.05, 0.7, 0.1, 1);
}
.dropdown-reveal > .chat-dropdown {
  position: static;
  right: auto;
  top: auto;
  bottom: auto;
  z-index: auto;
  min-height: 0;
}
/* ⋯ menu motion: unfolds top-down from the anchor without squishing text;
   close is the same motion reversed, just quicker. */
.chatmenu-enter-active {
  transition:
    grid-template-rows 0.22s cubic-bezier(0.05, 0.7, 0.1, 1),
    transform 0.22s cubic-bezier(0.05, 0.7, 0.1, 1);
}
.chatmenu-enter-active > .chat-dropdown,
.chatmenu-leave-active > .chat-dropdown {
  /* A temporarily short grid row is not a genuinely scrollable menu. Keep
     Firefox from flashing a scrollbar thumb during the unfold. */
  overflow-y: hidden;
}
.chatmenu-enter-from {
  grid-template-rows: 0fr;
  transform: translateY(-6px);
}
.chatmenu-enter-to {
  grid-template-rows: 1fr;
  transform: translateY(0);
}
.chatmenu-leave-active {
  pointer-events: none;
  transition:
    grid-template-rows 0.15s cubic-bezier(0.4, 0, 0.2, 1),
    transform 0.15s cubic-bezier(0.4, 0, 0.2, 1);
}
.chatmenu-leave-from {
  grid-template-rows: 1fr;
  transform: translateY(0);
}
.chatmenu-leave-to {
  grid-template-rows: 0fr;
  transform: translateY(-6px);
}
@media (prefers-reduced-motion: reduce) {
  .chatmenu-enter-active,
  .chatmenu-leave-active,
  .dropdown-reveal.menu-reveal { transition: none; }
}
.chat-dropdown-item {
  padding: 12px 16px;
  font-size: 15px;
  font-weight: 400;
  color: var(--text);
  cursor: pointer;
  position: relative;
  display: flex;
  align-items: center;
  gap: 10px;
}
.chat-dropdown-item::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 1px;
  background: rgba(255,255,255,0.06);
}
.chat-dropdown-item:last-child::after { display: none; }
/* folder tab hold menu: same glass menu, anchored under the held tab
   (the wrapper floats fixed at the held tab; the inner .chat-dropdown blurs) */
.folder-tab-menu {
  position: fixed;
  right: auto;
  top: auto;
  min-width: 200px;
  z-index: 260;
}
.folder-tab-menu > .chat-dropdown { min-width: 200px; }
.folder-menu-overlay { position: fixed; inset: 0; z-index: 255; }
.chat-dropdown-item:active { background: rgba(255,255,255,0.07); }
@media (hover: hover) {
  .chat-dropdown-item:hover { background: rgba(255,255,255,0.07); }
}
.chat-dropdown-item:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.chat-dropdown-danger { color: var(--destructive); }
.chat-dropdown-sep {
  height: 6px;
  background: rgba(0,0,0,0.18);
}

/* ═══════════════════════════════════════════
   CHAR DETAIL
═══════════════════════════════════════════ */
.char-detail-screen {
  flex: 1;
  min-height: 0;
  overflow-y: scroll;
  -webkit-overflow-scrolling: touch;
  overscroll-behavior: contain;
  scrollbar-width: none;
}
.char-detail-screen::-webkit-scrollbar { display: none; }
.char-detail-photo {
  aspect-ratio: 16/9;
  background: var(--glass);
  position: relative;
  overflow: hidden;
  max-height: 260px;
}
.char-detail-photo img { width: 100%; height: 100%; object-fit: cover; }
.char-detail-photo-ph {
  width: 100%; height: 100%;
  display: flex; align-items: center; justify-content: center;
  font-size: 64px; font-weight: 700; color: var(--text3);
}
.char-detail-body { padding: var(--pad); }
.char-detail-name {
  font-size: 22px;
  font-weight: 700;
  margin-bottom: 4px;
  letter-spacing: -0.4px;
}
.char-detail-score { font-size: 13px; color: var(--text2); margin-bottom: 12px; }
.char-detail-date { color: var(--text3, rgba(255,255,255,0.4)); }
.char-detail-desc {
  font-size: 15px;
  color: rgba(255,255,255,0.7);
  line-height: 1.6;
  white-space: pre-wrap;
}

/* ═══════════════════════════════════════════
   CHAR GRID
═══════════════════════════════════════════ */

/* ═══════════════════════════════════════════
   SETTINGS SCREEN + SEARCH
═══════════════════════════════════════════ */
.settings-screen {
  flex: 1;
  min-height: 0;
  overflow-y: scroll;
  -webkit-overflow-scrolling: touch;
  overscroll-behavior: contain;
  overflow-anchor: none;
  scrollbar-width: none;
  /* vertical pans native; horizontal ones reach JS (swipe-back peek) */
  touch-action: pan-y;
  padding-bottom: calc(var(--nav-h) + 24px + var(--safe-bottom));
  scroll-padding-bottom: calc(var(--nav-h) + 60px + var(--safe-bottom));
}
.settings-screen::-webkit-scrollbar { display: none; }
.notification-prefs-screen-intro {
  margin: 8px var(--pad) 0;
  color: var(--text2);
  font-size: 14px;
  line-height: 1.45;
}
.notification-prefs-screen-content {
  padding-bottom: 16px;
}
.public-rooms-screen { display: flex; flex-direction: column; }
.public-room-controls {
  display: grid;
  gap: 12px;
  padding: 4px var(--pad) 0;
}
/* the sort decides WHAT is listed, so it is a .view-strip; only the placement
   lives here */
.public-room-sort { width: 100%; }
/* Directory of chat rows: the same anatomy as the chat list, so the same
   surface - rows sit directly ON THE PAGE, no lighter card around them. The
   hairline of .chat-item already runs from the title axis to the trailing
   screen edge. */
.public-room-list {
  margin: 12px 0 0;
  background: transparent;
  border-radius: 0;
  box-shadow: none;
  overflow: visible;
  /* the screen is a column flexbox and overflow != visible zeroes the automatic
     minimum size: without this the island shrinks to the leftover viewport space
     and clips the rows instead of growing the screen's scrollHeight (no scroll) */
  flex-shrink: 0;
  transition: opacity 130ms cubic-bezier(0.4,0,0.2,1);
}
.public-room-list.search-loading, .chat-list.search-loading > .island {
  opacity: 0.52;
  pointer-events: none;
}
.public-room-row {
  width: 100%;
  border: none;
  background: transparent;
  color: var(--text);
  font: inherit;
  text-align: left;
}
.public-room-row:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -3px;
}
.public-room-description { -webkit-line-clamp: 1; }
.public-room-meta {
  display: flex;
  flex-wrap: wrap;
  gap: 3px 10px;
  min-width: 0;
  margin-top: 5px;
  color: var(--text3);
  font-size: 11px;
  line-height: 1.25;
}
.public-room-meta span {
  max-width: 100%;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.public-room-nsfw {
  flex-shrink: 0;
  padding: 1px 7px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.09);
  color: var(--text2);
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 0.3px;
}
body.no-blur .public-room-nsfw { background: var(--glass-bold); }
.public-room-row .chat-meta { color: var(--text3); font-size: 11px; }
.public-rooms-sentinel { height: 1px; }
.search-bar { padding: 8px 12px; background: var(--bg); flex-shrink: 0; }

html.layout-desktop .public-room-controls {
  grid-template-columns: minmax(0, 1fr) minmax(220px, 280px);
  align-items: center;
}

@media (prefers-reduced-motion: reduce) {
  .search-field-control, .chat-list-search-close.glass-chip, .public-room-list { transition: none; }
}

/* ═══════════════════════════════════════════
   VIEW STRIP — the capsule for switching WHAT IS SHOWN
═══════════════════════════════════════════ */
/* Semantic rule (owner, 2026-08-19): a control that switches the VIEW under it
   - a pane, a workspace, a peer dataset, what the list below is filtered to -
   is this strip, the same object as the chat-list .folder-bar and the
   community .g-tabs in a fixed-width form. A control that sets a VALUE of the
   current view (rating, tier, payment method, a tri-state preference) stays a
   .seg-group; a 5+-option value takes its own row (.orient-tiles).
   Material = the .folder-bar recipe (shell glass capsule; the dense twin sits
   in the DENSE SHELL block at the end of this file), geometry = the strip
   family's 42 capsule / 4 inset / 34 lens, motion = the folder lens's 300ms
   slide. Tabs are equal width and the lens is pure CSS: --vs-n = tab count,
   --vs-i = active index, both written by the template, so no instance needs
   JS of its own. */
.view-strip {
  --vs-n: 2;
  --vs-i: 0;
  position: relative;
  display: flex;
  align-items: center;
  gap: 3px;
  height: 42px;
  padding: 4px;
  border-radius: var(--r-pill);
  /* native long-press selection on a tab label races the hold-to-stretch
     gesture, same guard as the folder bar */
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
}
.view-strip::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  border-radius: inherit;
  background: var(--chrome-glass-fill);
  backdrop-filter: var(--chrome-glass-filter);
  -webkit-backdrop-filter: var(--chrome-glass-filter);
  box-shadow: var(--chrome-glass-shadow);
}
/* The lens is placed by BOTH edges (like .nav-indicator and .folder-ind),
   never by a percentage translate: a percentage resolves against the lens's
   OWN width, so the hold-expand below would walk the capsule off by one
   growth per tab index. --vs-step is one tab plus the 3px gap. */
.view-strip-ind {
  --vs-w: calc((100% - 8px - (var(--vs-n) - 1) * 3px) / var(--vs-n));
  --vs-step: calc(var(--vs-w) + 3px);
  position: absolute;
  top: 4px;
  height: calc(100% - 8px);
  left: calc(4px + var(--vs-i) * var(--vs-step));
  right: calc(4px + (var(--vs-n) - 1 - var(--vs-i)) * var(--vs-step));
  border-radius: var(--r-pill);
  background: var(--chrome-lens-fill);
  transition: left 300ms cubic-bezier(.32,.72,.35,1.1),
              right 300ms cubic-bezier(.32,.72,.35,1.1);
  pointer-events: none;
}
/* hold-expand, the nav/folder lens rule: 6px per side while a tab is held,
   clamped at the capsule's own 4px inset so the pill never crosses the rim.
   glassPress() adds .lens-hold, and never in dense or reduced motion. */
.view-strip.lens-hold .view-strip-ind {
  left: max(4px, calc(4px + var(--vs-i) * var(--vs-step) - 6px));
  right: max(4px, calc(4px + (var(--vs-n) - 1 - var(--vs-i)) * var(--vs-step) - 6px));
}
.view-strip-tab {
  position: relative;
  z-index: 1;
  flex: 1 1 0;
  min-width: 0;
  height: 34px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0 12px;
  border: none;
  background: transparent;
  border-radius: var(--r-pill);
  font-family: inherit;
  font-size: 14px;
  font-weight: 600;
  color: var(--text2);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  cursor: pointer;
  transition: color 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
.view-strip-tab:active { color: var(--text); }
.view-strip-tab.active { color: var(--accent-text); }
.view-strip-tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
@media (prefers-reduced-motion: reduce) {
  .view-strip-ind { transition: none; }
}

/* Profile -> Collections is a real pushed view. It reuses the shared
   collection mosaic/card atoms from community.css, while this scoped anatomy
   owns only the directory grid, author chip and screen controls. */
#app .collections-screen { display: flex; flex-direction: column; }
#app .collections-controls {
  display: grid;
  gap: 12px;
  padding: 4px var(--pad) 0;
}
/* Mine/Public switcher = a .view-strip (see the VIEW STRIP block above): the
   tab decides WHICH directory is listed. Only the desktop width lives here,
   and the .collections-tabs class stays on the element as its hook - the
   community overlay keeps its own live .g-tabs instances, so the app view
   must not share those class names. */
#app .collection-browser-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 12px;
  padding: 14px var(--pad) 0;
  flex-shrink: 0;
  transition: opacity 130ms cubic-bezier(0.4,0,0.2,1);
}
#app .collection-browser-grid.search-loading {
  opacity: .52;
  pointer-events: none;
}
#app .collection-browser-card {
  position: relative;
  width: 100%;
  min-width: 0;
  flex: none;
}
#app .collection-card-main {
  display: block;
  width: 100%;
  padding: 0;
  border: 0;
  background: transparent;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
}
#app .collection-card-main:focus-visible,
#app .collection-card-add:focus-visible {
  outline: 2px solid var(--accent-text);
  outline-offset: -3px;
}
/* the over-art author chip anchors to the card (the main surface is a nested
   button, so the chip cannot live inside it like in the catalog markup).
   It is a BUTTON for focusability where the catalog's is a div: strip the UA
   border/font the div-designed class never needed to reset */
#app .collection-browser-card > .card-author-chip {
  top: 8px;
  left: 8px;
  border: 0;
  font-family: inherit;
}
#app .collection-mosaic-cover-fallback {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100%;
  min-width: 0;
  min-height: 0;
  color: rgba(255, 255, 255, 0.92);
  font-size: 20px;
  font-weight: 800;
}
#app .collection-mosaic > .collection-mosaic-cover-fallback:first-child { grid-row: 1 / 3; }
#app .collection-mosaic.count-1 > .collection-mosaic-cover-fallback:first-child { grid-column: 1 / 3; }
#app .collection-mosaic.count-2 > .collection-mosaic-cover-fallback:first-child { grid-row: 1; }
#app .collections-empty { min-height: 220px; }
#app .collections-sentinel { height: 1px; }
#app .collections-inline-error {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin: 12px var(--pad) 0;
  padding: 10px 12px;
  border-radius: var(--r-card);
  background: var(--glass);
  color: var(--text2);
  font-size: 13px;
}
#app .collections-page-spinner { padding: 18px; text-align: center; }
#app .collections-page-spinner .spinner { width: 20px; height: 20px; }

#app .collection-favorites-glyph { display: flex; }
#app .collection-card-add-glyph { display: flex; }

/* desktop: one controls row, publicrooms-style. The strip keeps EQUAL halves
   at a fixed width - its lens is placed by tab index and hug-content tabs
   would walk the label out of it (fleet finding, browser_desktop.png) */
html.layout-desktop #app .collections-controls {
  grid-template-columns: auto minmax(0, 1fr);
  align-items: center;
}
html.layout-desktop #app .collections-tabs { width: 320px; }

@media (min-width: 768px) {
  #app .collection-browser-grid { grid-template-columns: repeat(3, 1fr); gap: 16px; }
}
@media (min-width: 1024px) {
  #app .collection-browser-grid { grid-template-columns: repeat(4, 1fr); gap: 20px; }
}

@media (prefers-reduced-motion: reduce) {
  #app .collection-browser-grid { transition: none; }
}


/* ═══════════════════════════════════════════
   COMMUNITY CONTROLS (Vue version)
═══════════════════════════════════════════ */
.community-screen {
  flex: 1;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}
.community-controls {
  background: var(--bg);
  flex-shrink: 0;
}

/* ═══════════════════════════════════════════
   SPINNER
═══════════════════════════════════════════ */
.spinner {
  width: 24px; height: 24px;
  border: 2px solid var(--glass-bold);
  border-top-color: var(--accent);
  border-radius: 50%;
  animation: spin 0.6s linear infinite;
  margin: 0 auto;
}
@keyframes spin { to { transform: rotate(360deg); } }

/* ═══════════════════════════════════════════
   LOGIN SCREEN
═══════════════════════════════════════════ */
.login-screen {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100%;
  padding: 24px;
}
.login-card {
  width: 100%;
  max-width: 360px;
  display: flex;
  flex-direction: column;
  gap: 12px;
}
.login-brand {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 14px;
  margin-bottom: 30px;
}
.login-tile {
  width: 96px;
  height: 96px;
  border-radius: var(--r-island);
  background: var(--glass);
  display: grid;
  place-items: center;
}
/* logo mark: favicon alpha silhouette filled with the theme text color */
.login-mark {
  width: 52px;
  height: 52px;
  background: var(--text);
  -webkit-mask: url(/static/favicon.png) center / contain no-repeat;
  mask: url(/static/favicon.png) center / contain no-repeat;
}
.login-wordmark {
  font-size: 28px;
  font-weight: 700;
  letter-spacing: -0.6px;
  color: var(--text);
  text-align: center;
}
.login-tagline {
  font-size: 14px;
  color: var(--text2);
  text-align: center;
  margin-top: -8px;
}
/* second entry group: the newcomer path to the bot */
.login-bot-btn { margin-top: 22px; }
/* block-level member of the family so the <a> spans the column like the button */
.login-cta {
  display: flex;
  text-align: center;
  text-decoration: none;
}
.login-hint {
  font-size: 12px;
  color: var(--text3);
  line-height: 1.5;
  text-align: center;
}
.login-error {
  color: var(--destructive);
  font-size: 13px;
  text-align: center;
}
/* ═══════════════════════════════════════════
   SHOP
═══════════════════════════════════════════ */
.shop-tier { display: flex; align-items: center; gap: 10px; font-size: 18px; font-weight: 700; color: var(--text); margin-bottom: 12px; }
.shop-badge { font-size: 11px; font-weight: 700; letter-spacing: 0.3px; color: #fff; background: var(--accent); padding: 3px 10px; border-radius: var(--r-pill); }
.shop-desc { font-size: 13px; color: var(--text3); line-height: 1.5; }
/* Streak note only applies once bonusStatus resolves and only for some tiers/
   users - animates in (grid-rows 0fr->1fr) rather than popping the card taller. */
.shop-streak-note-wrap {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
.shop-streak-note-wrap.open { grid-template-rows: 1fr; }
.shop-streak-note-inner { min-height: 0; overflow: hidden; }
/* margin only in the open state - collapsed margin would still render its
   own height even with min-height:0, leaking a sliver of text */
.shop-streak-note-wrap.open .shop-streak-note-inner { margin-top: 4px; }
.shop-feat { display: flex; align-items: flex-start; gap: 8px; font-size: 13px; color: var(--text2); line-height: 1.5; margin-bottom: 4px; }
.shop-feat-hl { color: var(--text); font-weight: 600; }
.shop-check { width: 16px; height: 16px; flex-shrink: 0; margin-top: 2px; display: inline-flex; align-items: center; justify-content: center; color: var(--accent); border-radius: 50%; }
.shop-check svg { width: 11px; height: 11px; }
.shop-feat-hl .shop-check { background: var(--accent); color: #fff; }
.shop-divider { height: 1px; background: var(--sep); margin: 14px 0 4px; }
.shop-btn-ico { display: inline-flex; flex-shrink: 0; }
.shop-btn-ico svg { width: 14px; height: 14px; display: block; }
.shop-custom-row::after { display: none; }
.shop-custom-row-price {
  min-width: 72px;
  min-height: 20px;
  display: inline-flex;
  align-items: center;
  justify-content: flex-end;
  gap: 4px;
  font-size: 13px;
  font-variant-numeric: tabular-nums;
}
.shop-custom-wrap {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
.shop-custom-wrap.open { grid-template-rows: 1fr; }
.shop-custom-wrap:not(.open) { pointer-events: none; }
.shop-custom-inner {
  min-height: 0;
  min-width: 0;
  overflow: hidden;
  padding: 0 2px;
}
.shop-custom-wrap.open .shop-custom-inner { padding-bottom: 12px; }
.shop-custom-input {
  box-sizing: border-box;
  font-size: 16px;
  font-variant-numeric: tabular-nums;
}
.shop-custom-hint {
  min-height: 18px;
  margin: 5px 2px 0;
  font-size: 12px;
  line-height: 18px;
  color: var(--text3);
  font-variant-numeric: tabular-nums;
  transition: color 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
.shop-custom-hint.error { color: var(--destructive); }
.shop-total-price {
  height: 34px;
  min-height: 34px;
  line-height: 34px;
}
.shop-total-label {
  min-height: 17px;
  margin-top: 2px;
  overflow: hidden;
  color: var(--text3);
  font-size: 12px;
  line-height: 15px;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.pay-ico-ton { display: inline-flex; flex-shrink: 0; }
.pay-ico-ton svg { width: 16px; height: 16px; display: block; }
.hint-ico { display: inline-flex; flex-shrink: 0; }
.hint-ico svg { width: 15px; height: 15px; display: block; }

/* ── Shop tier-card swap (cross-dissolve on tier change) ── */
.shop-card-island { overflow: hidden; }
.shop-card-enter-active,
.shop-card-leave-active {
  transition: opacity 0.16s cubic-bezier(0.4,0,0.2,1), transform 0.18s cubic-bezier(0.4,0,0.2,1);
}
.shop-card-enter-from { opacity: 0; transform: translateY(10px); }
.shop-card-leave-to   { opacity: 0; transform: translateY(-10px); }

/* ── Price value pop (on product/tier/currency change) ── */
.price-pop-enter-active,
.price-pop-leave-active {
  transition: opacity 0.14s cubic-bezier(0.4,0,0.2,1), transform 0.14s cubic-bezier(0.4,0,0.2,1);
}
.price-pop-enter-from { opacity: 0; transform: translateY(6px); }
.price-pop-leave-to   { opacity: 0; transform: translateY(-6px); }

/* ── Gift screen (v5.231): recipient over the shop ──
   The username field is the coauthor picker's .uname-* family (community.css);
   here it sits on the page, so the hint reads left under the field instead of
   centred in a sheet. The result row and the product section never pop:
   the row arrives on the house decelerate inside a grid-rows fold, the
   products unfold once as one block (content never staggers). */
.shop-pending-for { font-size: 13px; color: var(--text2); margin: -4px 0 10px; }
/* Запасной путь к странице оплаты. Именно <a>, а не кнопка: переход по якорю
   не зависит от того, считает ли браузер клик ещё "живым", тогда как
   window.open после ответа сервера уже может быть срезан блокировщиком.
   Нейтральный .btn-sm, не .cta: единственный акцент островка занят проверкой. */
a.btn-sm { text-decoration: none; }
/* Тональная, а не нейтральная: островок ожидания сам покрашен в --glass-bold,
   то есть в верхнюю ступень, и нейтральный .btn-sm на нём сливается с
   подложкой и читается подписью, а не кнопкой (проверено снимком). Ступени
   светлее --glass-bold в лестнице нет, поэтому контрол берёт тональную
   заливку; единственный сплошной акцент островка остаётся у проверки. */
.shop-pending-open {
  display: flex;
  width: 100%;
  margin: 0 0 8px;
  background: var(--accent-soft);
  color: var(--accent-text);
}
@media (hover: hover) {
  .shop-pending-open:hover { background: var(--accent-soft); color: var(--accent-text); }
}
.shop-pending-open:active { background: var(--accent-soft); color: var(--accent-text); }
/* picker <-> recipient: two folds trading height, never an out-in swap of
   two different heights (that dropped the whole page 91px in one frame) */
/* standard curve, not the arrival decelerate: that one spends ~40% of the
   path in its first frame, which on a 97px height trade is a visible 54px
   step (measured); the symmetric curve keeps every frame under ~15px */
.gift-swap {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 200ms cubic-bezier(0.4, 0, 0.2, 1);
}
.gift-swap.open { grid-template-rows: 1fr; }
.gift-swap-inner {
  min-height: 0;
  overflow: hidden;
  opacity: 0;
  transition: opacity 200ms cubic-bezier(0.4, 0, 0.2, 1);
}
.gift-swap.open .gift-swap-inner { opacity: 1; }
/* the scroll area already pads --pad: the island sits flush with the field */
.gift-rcpt { margin: 0; padding: 2px 0; }
.gift-rcpt-row {
  width: 100%;
  border: 0;
  background: transparent;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
  border-radius: var(--r-island);
}
.gift-rcpt-row:active { opacity: 0.6; }
.gift-rcpt-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -3px; }
/* the page field rests on --glass like every other page field */
.gift-who .uname-field { background: var(--glass); }
.gift-who .uname-field:focus-within { background: var(--glass-bold); }
.gift-who-hint { padding: 8px 4px 0; font-size: 13px; color: var(--text3); line-height: 1.45; }
/* outcome line: ONE reserved row, so a result never shoves the fold below */
.gift-who .uname-hint {
  min-height: calc(1.45em + 6px);
  justify-content: flex-start;
  text-align: left;
  padding: 6px 4px 0;
  line-height: 1.45;
  transition: color 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
.gift-who .uname-hint.error { color: var(--destructive); }
.gift-res-wrap {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
.gift-res-wrap.open { grid-template-rows: 1fr; }
.gift-res-wrap:not(.open) { pointer-events: none; }
.gift-res-inner { min-height: 0; overflow: hidden; }
.gift-res-inner .uname-row { margin-top: 10px; }
.gift-res-inner .uname-row .settings-chev { color: var(--accent-text); }
.gift-res-enter-active {
  transition: opacity 250ms cubic-bezier(0.05, 0.7, 0.1, 1), transform 250ms cubic-bezier(0.05, 0.7, 0.1, 1);
}
.gift-res-leave-active {
  transition: opacity 120ms cubic-bezier(0.4, 0, 0.2, 1), transform 120ms cubic-bezier(0.4, 0, 0.2, 1);
}
.gift-res-enter-from, .gift-res-leave-to { opacity: 0; transform: translateY(6px); }
.gift-fold {
  display: grid;
  grid-template-rows: 1fr;
  transition: grid-template-rows 220ms cubic-bezier(0.05, 0.7, 0.1, 1);
}
.gift-fold:not(.open) { grid-template-rows: 0fr; pointer-events: none; }
.gift-fold-inner {
  min-height: 0;
  overflow: hidden;
  transition: opacity 220ms cubic-bezier(0.05, 0.7, 0.1, 1);
}
.gift-fold:not(.open) .gift-fold-inner { opacity: 0; }
.is-gift .gift-fold.open .gift-fold-inner { padding-top: 16px; }

/* ── Definite-height fold (ADR-0041): the reserve row of the total island,
   the pending-payment islands, the TON note.
   Why not `grid-template-rows: 0fr / 1fr` like the folds above: measured
   frame by frame, the fr interpolation ignores the timing curve (70% of the
   travel inside a quarter of the duration, then a dead tail), and its
   auto-sized row cannot pin content to the bottom, so the content is cut in
   place instead of moving. A pixel height (--fold-h, written by _foldSync in
   app.js from ONE scrollHeight read per opening, never per frame) follows
   the curve exactly, and flex-end makes the content slide out from under the
   row above and back under it. Class-driven: a tap mid-motion retargets from
   the current height; visibility flips only when the collapse has finished,
   so the folded control leaves the tab order without ever popping. */
.hfold {
  height: 0;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  visibility: hidden;
  transition: height 200ms cubic-bezier(0.4, 0, 0.2, 1), visibility 200ms;
}
.hfold.open { height: var(--fold-h, auto); visibility: visible; }
.hfold:not(.open) { pointer-events: none; }
/* flow-root: the island's bottom margin must stay INSIDE the body, or the
   measured height misses it and the next block sits flush under the island */
.hfold-body { flex: none; display: flow-root; opacity: 0; transition: opacity 200ms cubic-bezier(0.4, 0, 0.2, 1); }
.hfold.open .hfold-body { opacity: 1; }
.shop-reserve-fold { --fold-h: 52px; }
/* tonal, not neutral: the total island is --glass-bold itself, the same
   reason .shop-pending-open is tonal */
.shop-reserve { display: flex; width: 100%; margin-top: 12px; background: var(--accent-soft); color: var(--accent-text); }
.shop-reserve:active:not(:disabled) { background: color-mix(in srgb, var(--accent-text) 14%, var(--accent-soft)); color: var(--accent-text); }
@media (hover: hover) {
  .shop-reserve:hover:not(:disabled) { background: color-mix(in srgb, var(--accent-text) 14%, var(--accent-soft)); color: var(--accent-text); }
}
/* the total island's CTA cell: the pay button and the "higher tier already"
   note are stacked and crossfade; the cell keeps the wider of the two, so the
   price block never shifts when a lower tier is selected */
.shop-cta-slot { display: grid; flex-shrink: 0; }
.shop-cta-slot > * { grid-area: 1 / 1; transition: opacity 130ms cubic-bezier(0.4, 0, 0.2, 1); }
.shop-cta-note {
  align-self: center; justify-self: end;
  max-width: 132px; text-align: right;
  font-size: 13px; line-height: 17px; font-weight: 500; color: var(--text3);
  opacity: 0; pointer-events: none;
}
.shop-cta-slot.locked > .btn-sm { opacity: 0; pointer-events: none; }
.shop-cta-slot.locked > .shop-cta-note { opacity: 1; }
/* busy label: two stacked labels crossfade, so the button keeps its size and
   no text swaps in one frame */
.lbl-x { display: grid; }
.lbl-x > span { grid-area: 1 / 1; transition: opacity 130ms cubic-bezier(0.4, 0, 0.2, 1); }
.lbl-x > .busy { opacity: 0; }
.lbl-x.busy > .busy { opacity: 1; }
.lbl-x.busy > .idle { opacity: 0; }
@media (prefers-reduced-motion: reduce) {
  .hfold,
  .hfold-body,
  .lbl-x > span,
  .shop-cta-slot > *,
  .shop-custom-wrap,
  .shop-custom-hint,
  .shop-card-enter-active,
  .shop-card-leave-active,
  .price-pop-enter-active,
  .price-pop-leave-active,
  .gift-res-wrap,
  .gift-res-enter-active,
  .gift-res-leave-active,
  .gift-swap,
  .gift-swap-inner,
  .gift-fold,
  .gift-fold-inner {
    transition: none;
  }
}

/* ── Streak icon ── */
.streak-icon { flex-shrink: 0; }
.streak-none { opacity: 0.3; color: #888; }
.streak-common { color: #ffd700; filter: drop-shadow(0 0 3px rgba(255,215,0,0.4)); }
.streak-rare { color: #ff8c00; filter: drop-shadow(0 0 4px rgba(255,140,0,0.6)); }
.streak-epic { color: #ff3030; filter: drop-shadow(0 0 5px rgba(255,48,48,0.6)); }
.streak-legendary { color: #ff2020; filter: drop-shadow(0 0 6px rgba(255,32,32,0.8)); animation: streak-pulse 2s ease-in-out infinite; }
.streak-mythic { color: #da70d6; filter: drop-shadow(0 0 8px rgba(218,112,214,0.8)); animation: streak-pulse 1.5s ease-in-out infinite; }
.streak-eternal { color: #b8ecff; filter: drop-shadow(0 0 9px rgba(125,230,255,0.9)); animation: streak-pulse 1.2s ease-in-out infinite; }
.streak-immortal { color: #ff9ad5; animation: streak-iri-glow 8s linear infinite; }
.streak-immortal path { fill: url(#strk-ir); }
@keyframes streak-pulse {
  0%, 100% { transform: scale(1); }
  50% { transform: scale(1.15); }
}
@keyframes streak-iri-glow {
  0%, 100% { filter: drop-shadow(0 0 9px rgba(255, 216, 107, 0.8)); }
  25% { filter: drop-shadow(0 0 9px rgba(255, 154, 213, 0.8)); }
  50% { filter: drop-shadow(0 0 9px rgba(192, 154, 255, 0.8)); }
  75% { filter: drop-shadow(0 0 9px rgba(139, 233, 255, 0.8)); }
}
@media (prefers-reduced-motion: reduce) {
  .streak-legendary, .streak-mythic, .streak-eternal, .streak-immortal { animation: none; }
}

/* Streak +1 animation */
.streak-anim-up {
  animation: streak-pop 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.streak-anim-up .streak-icon {
  animation: streak-fire-grow 1s ease-out;
}
.streak-anim-up .stat-cell-lbl {
  color: #ffd700 !important;
  font-weight: 600;
  animation: streak-label-fade 2.5s ease-out forwards;
}
.streak-num-pop {
  animation: streak-count-up 0.8s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes streak-pop {
  0% { transform: scale(1); }
  30% { transform: scale(1.25); }
  100% { transform: scale(1); }
}
@keyframes streak-fire-grow {
  0% { transform: scale(1); filter: drop-shadow(0 0 2px currentColor); }
  40% { transform: scale(1.8); filter: drop-shadow(0 0 12px currentColor); }
  100% { transform: scale(1); }
}
@keyframes streak-count-up {
  0% { opacity: 0; transform: translateY(12px) scale(0.5); }
  50% { opacity: 1; transform: translateY(-4px) scale(1.3); }
  100% { transform: translateY(0) scale(1); }
}
@keyframes streak-label-fade {
  0%, 60% { opacity: 1; }
  100% { opacity: 0.5; }
}

/* Streak reset animation */
.streak-anim-reset {
  animation: streak-shake 0.6s ease-in-out;
}
.streak-anim-reset .streak-icon {
  animation: streak-fire-die 2s ease-out forwards;
}
.streak-anim-reset .stat-cell-lbl {
  color: var(--destructive) !important;
  font-weight: 600;
}
.streak-old-val {
  position: absolute;
  color: var(--destructive);
  animation: streak-old-fly 1s ease-out forwards;
  pointer-events: none;
}
@keyframes streak-shake {
  0%, 100% { transform: translateX(0); }
  15% { transform: translateX(-6px); }
  30% { transform: translateX(5px); }
  45% { transform: translateX(-4px); }
  60% { transform: translateX(3px); }
  75% { transform: translateX(-2px); }
}
@keyframes streak-fire-die {
  0% { opacity: 1; filter: drop-shadow(0 0 6px currentColor); }
  50% { opacity: 0.4; filter: drop-shadow(0 0 0px currentColor) grayscale(0.8); }
  100% { opacity: 0.3; filter: grayscale(1); }
}
@keyframes streak-old-fly {
  0% { opacity: 1; transform: translateY(0) scale(1); }
  100% { opacity: 0; transform: translateY(-30px) scale(0.6); }
}

/* ═══════════════════════════════════════════
   MD3 EXPRESSIVE SEGMENTED TOGGLE (Art Studio)
   Outlined container, tonal secondary-container
   on active, spring motion, no shadows/glow.
═══════════════════════════════════════════ */
.art-mode-hint {
  font-size: 12px;
  color: var(--text3);
  padding: 10px 4px 0;
  line-height: 1.4;
}
/* The advanced-mode hint folds through the same 0fr->1fr wrapper as the card
   block (and on the same 140ms clock): switching modes swaps cards for hint
   as one coordinated height change instead of a reserved-forever empty row. */
.art-hint-fold {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 140ms cubic-bezier(0.4, 0, 0.2, 1);
}
.art-hint-fold.open { grid-template-rows: 1fr; }
.art-hint-fold > .art-hint-in {
  min-height: 0;
  overflow: hidden;
  opacity: 0;
  transition: opacity 140ms cubic-bezier(0.4, 0, 0.2, 1);
}
.art-hint-fold.open > .art-hint-in { opacity: 1; }

/* ═══════════════════════════════════════════
   ART STUDIO
═══════════════════════════════════════════ */
.art-screen {
  padding: 16px;
  /* keep the former toolbar zone clear: users expect the top strip to be
     chrome-only, and content at rest must sit below the top-fade blur */
  padding-top: calc(var(--header-h) + var(--tg-top));
  /* clears the nav island AND the floating CTA docked above it */
  padding-bottom: calc(var(--nav-h) + 100px + var(--safe-bottom));
  display: flex;
  flex-direction: column;
  gap: 16px;
}
/* .section-lbl carries list-screen padding; inside the padded art column keep
   8px top so labeled sections get the house 24px separation (16px flex gap +
   8px label air, the Settings/chat-info unit) while unlabeled attachments
   stay at the tight 16px - the two-tier rhythm that makes it ONE page */
.art-seclbl { padding: 8px 2px 8px; }

/* Art prompt fields grow with content (v-autogrow) up to this cap, then scroll
   internally with a thin tonal scrollbar. Scoped to the art screen so the
   community form textareas keep their fixed rows. */
.art-screen .form-textarea {
  max-height: 40vh;
  overflow-y: auto;
  scrollbar-width: thin;
  scrollbar-color: var(--text3) transparent;
}
.art-screen .form-textarea::-webkit-scrollbar { width: 6px; }
.art-screen .form-textarea::-webkit-scrollbar-thumb {
  background: var(--text3);
  border-radius: 999px;
}

/* Art layout wrappers: on mobile they reproduce the single 16px-gap column
   (the column gaps used to come from .art-screen's flex gap); on desktop they
   become a 2-column studio (output left, controls right) — see layout-desktop. */
.art-body { display: flex; flex-direction: column; gap: 16px; }
.art-col-left, .art-col-right { display: contents; }
/* recents are an archive: on the phone flex order drops them below the CTA;
   on desktop they are the left column's last child anyway, so this is a no-op */
.art-recent-block { order: 9; }

/* Stage: the always-present canvas at the top of the screen.
   Empty hint -> shimmer progress -> result, all in one place. */
.art-stage {
  position: relative;
  flex-shrink: 0;
  height: clamp(240px, 42vh, 480px);
  border-radius: var(--r-card);
  background: var(--glass);
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
}
/* Darker backdrop behind the result so letterbox bars around contain-fit
   images read as a viewer, not as empty glass. */
.art-stage.has-result { background: rgba(0, 0, 0, 0.3); }
.art-stage-img {
  width: 100%;
  height: 100%;
  object-fit: contain;
  display: block;
  cursor: pointer;
  opacity: 0;
  transition: opacity 0.32s cubic-bezier(0.4, 0, 0.2, 1);
}
.art-stage-img.loaded { opacity: 1; }
/* Fresh generation results MATERIALIZE: sharpen out of blur with a gentle
   scale settle (same language as the chat stream's .sw-in blur reveal).
   One-shot keyframes on a transient .revealing class (app.js drops it right
   after play): no lingering filter layer at rest, and a kept-alive re-entry
   can never replay it (the v5.183 display-toggle trap). History taps keep
   the plain fade - the reveal marks ARRIVAL, not browsing. */
.art-stage-img.revealing {
  animation: art-materialize 0.5s cubic-bezier(0.05, 0.7, 0.1, 1) both;
}
@keyframes art-materialize {
  from { filter: blur(16px); transform: scale(1.03); }
  to { filter: blur(0); transform: scale(1); }
}
/* no-blur users bought paint budget: they get the fade without the blur */
body.no-blur .art-stage-img.revealing { animation: none; }
@media (prefers-reduced-motion: reduce) {
  .gen-image, .art-stage-img { transition: none; transform: none; }
  .art-stage-img.revealing { animation: none; }
}
.art-stage-expand {
  position: absolute;
  bottom: 8px;
  right: 8px;
  background: rgba(0, 0, 0, 0.55);
  border-radius: var(--r-pill);
  padding: 4px 10px;
  font-size: 11px;
  color: #fff;
  pointer-events: none;
}
.art-stage-empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  padding: 0 32px;
  text-align: center;
}
/* solid stroke dimmed as a group (alpha strokes darken at path crossings) */
.art-stage-empty-ic { width: 36px; height: 36px; color: var(--text); opacity: 0.36; }
.art-stage-empty-txt { font-size: 14px; color: var(--text2); line-height: 1.5; }

/* Sweep is transform-based (GPU-composited): animating background-position
   repaints every frame and visibly stutters at the loop seam. The bar starts
   and ends fully outside the stage, so the loop restart is invisible. */
.art-shimmer {
  position: absolute;
  inset: 0;
  overflow: hidden;
  pointer-events: none;
}
/* The sheen band: a PURELY horizontal gradient (endpoints exactly transparent)
   slanted by skewX instead of a tilted gradient - a tilted gradient in a tall
   box clips its ramp at the band edges and paints visible seams (the "png
   with borders" look). Skew keeps the slant while the edges stay transparent
   by construction, and the whole sweep remains one compositor transform. */
.art-shimmer::before {
  content: '';
  position: absolute;
  top: -10%;
  bottom: -10%;
  left: -80%;
  width: 70%;
  background: linear-gradient(90deg, transparent, rgba(255,255,255,0.06) 50%, transparent);
  transform: skewX(-12deg);
  animation: art-shimmer 1.9s linear infinite;
  will-change: transform;
}
@keyframes art-shimmer {
  to { transform: skewX(-12deg) translateX(360%); }
}
.art-stage-status {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  padding: 0 24px;
  text-align: center;
}
.art-stage-status-line {
  font-size: 14px;
  font-weight: 600;
  color: var(--text);
  font-variant-numeric: tabular-nums;
}
.art-stage-status-sub { font-size: 12px; color: var(--text3); }

/* Латентные превью (v5.204): кадр 1/8 разрешения с воркера, растянутый на
   стейдж. Обычный filter-блюр на статичной картинке (не backdrop-filter),
   scale прячет светлый ореол блюра у краёв; скрим держит контраст подписи.
   Блюр НЕ отключается в no-blur (владелец, v5.204.2): режим борется с
   покадровым backdrop-filter при скролле, а статичный filter растеризуется
   один раз на приход кадра (1-2/с) и в простое бесплатен; lowres без блюра
   выглядит плохо. Анимированный filter (.revealing) в no-blur выключен
   как и был - вот он покадровый по-настоящему. */
.art-stage-preview { position: absolute; inset: 0; }
.art-stage-preview img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: contain;
  filter: blur(10px) saturate(1.05);
  transform: scale(1.04);
}
/* кроссфейд кадров: :key перемонтирует верхний слой, нижний держит прошлый
   кадр; та же 250мс кривая прибытия, что у .content-in / .card-image */
.art-stage-preview img.pv-in {
  animation: pv-in 0.25s cubic-bezier(0.05, 0.7, 0.1, 1) both;
}
@keyframes pv-in {
  from { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .art-stage-preview img.pv-in { animation: none; }
}
.art-stage-preview::after {
  content: '';
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, 0.3);
}

/* Полоса общего прогресса генерации: трек и заливка в цветах домовых
   спиннеров (.spinner: --glass-bold + --accent). Заливка - transform,
   ширина фиксированная, чтобы статус-блок не переставлялся между фазами. */
.art-progress {
  position: relative;
  width: 148px;
  height: 3px;
  border-radius: 999px;
  background: var(--glass-bold);
  overflow: hidden;
}
.art-progress-fill {
  position: absolute;
  inset: 0;
  border-radius: 999px;
  background: var(--accent);
  transform-origin: left center;
  transform: scaleX(0);
  transition: transform 0.25s cubic-bezier(0.05, 0.7, 0.1, 1);
}
/* Индетерминант до первой вехи: бегущий сегмент, M3-образный */
.art-progress.ind .art-progress-fill {
  inset: 0 auto 0 0;
  width: 40%;
  transform: none;
  transition: none;
  animation: art-prog-ind 1.4s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
@keyframes art-prog-ind {
  from { transform: translateX(-110%); }
  to { transform: translateX(360%); }
}
@media (prefers-reduced-motion: reduce) {
  .art-progress-fill { transition: none; }
  .art-progress.ind .art-progress-fill { animation: none; width: 100%; opacity: 0.35; }
}

.art-result-actions {
  display: flex;
  justify-content: center;
  /* three RU labels can overflow 360px; a centered second row reads fine */
  flex-wrap: wrap;
  gap: 8px;
  margin-top: -6px;
}
/* peer-action grammar (the chat-info .ci-act precedent): equal-width tonal
   tiles at the button radius, not hugging pills - a narrow full-pill with a
   short label reads as a tag chip, not an action (owner, 2026-08-07) */
.art-result-actions .btn-sm {
  flex: 1 1 0;
  min-width: 0;
  min-height: 44px;
  padding-inline: 8px;
  border-radius: var(--r-btn);
}

.art-recent-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
}
/* trailing text action of a section label = the chat-info "see all" species */
.art-clear-btn {
  flex-shrink: 0;
  /* .text-btn carries the look; the row's 8px label rhythm sits inside the
     44px hit box instead of under it */
  margin: -14px -8px -10px 0;
}
.art-clear-btn:active { opacity: 0.6; }

.art-history-strip {
  display: flex;
  gap: 8px;
  overflow-x: auto;
  scrollbar-width: none;
  padding-bottom: 2px;
}
.art-history-strip::-webkit-scrollbar { display: none; }
/* Selection = the accent check badge shared with style tiles / wallpaper
   picker; the old non-selected dimming (opacity .55) was this screen's second
   selection grammar and made the whole strip read disabled (coherence pass) */
.art-thumb {
  position: relative;
  width: 56px;
  height: 56px;
  flex-shrink: 0;
  overflow: hidden;
  border-radius: var(--r-btn);
  background: var(--glass);
  border: none;
  padding: 0;
  cursor: pointer;
  transition: transform 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
.art-thumb:active { transform: scale(0.96); }
.art-thumb:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.art-thumb img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: inherit;
  display: block;
}
/* 56px tile: shrink the shared badge geometry */
.art-thumb.active::after { top: 4px; right: 4px; width: 16px; height: 16px; }
.art-thumb.active::before {
  top: 7px;
  right: 10px;
  width: 4px;
  height: 7px;
}
.art-thumb-play {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  pointer-events: none;
}
.art-thumb-play svg {
  width: 18px;
  height: 18px;
  color: #fff;
  filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.5));
}

.art-style-strip {
  display: flex;
  gap: 10px;
  overflow-x: auto;
  scrollbar-width: none;
  padding-bottom: 2px;
  /* Reserves a tile's height (76px square + name label) before artStyles has
     loaded, so the strip doesn't grow from 0 and shove Orientation/Mode/
     Prompt/Generate down once the list arrives on first Art-tab visit. */
  min-height: 100px;
}
.art-style-strip::-webkit-scrollbar { display: none; }
.art-style-tile {
  flex-shrink: 0;
  width: 76px;
  border-radius: var(--r-btn);
  overflow: hidden;
  border: none;
  padding: 0;
  background: var(--glass);
  cursor: pointer;
  position: relative;
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
  transition: transform 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
.art-style-tile:active:not(.dragging) { transform: scale(0.96); }
.art-style-tile:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.art-style-tile.active::after,
.art-thumb.active::after {
  content: '';
  position: absolute;
  top: 6px;
  right: 6px;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: var(--accent);
  box-shadow: 0 1px 4px rgba(0,0,0,0.4);
}
.art-style-tile.active::before,
.art-thumb.active::before {
  content: '';
  position: absolute;
  top: 10px;
  right: 12px;
  z-index: 1;
  width: 5px;
  height: 9px;
  border: solid #fff;
  border-width: 0 2px 2px 0;
  transform: rotate(45deg);
}
.art-style-tile.active .art-style-tile-name {
  color: var(--accent-text);
  font-weight: 600;
}
.art-style-tile-img {
  width: 100%;
  aspect-ratio: 1;
  object-fit: cover;
  display: block;
  pointer-events: none;
  -webkit-touch-callout: none;
}
.art-style-tile-name {
  padding: 5px 6px;
  font-size: 11px;
  font-weight: 500;
  color: var(--text3);
  text-align: center;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  transition: color 0.15s cubic-bezier(0.4, 0, 0.2, 1);
}
.art-style-tile.active .art-style-tile-name { color: var(--text); }
/* lifted by reorder.js (hold a tile, then move) */
.art-style-tile.dragging {
  z-index: 5;
  cursor: grabbing;
  box-shadow: 0 12px 28px rgba(0, 0, 0, 0.45);
}
/* reorder.js drop glide (all reorderable lists app-wide): the just-dropped
   item stays above its siblings while it slides into the final slot */
.drag-settle {
  position: relative;
  z-index: 5;
}

/* ── Orientation tiles: формат генерации, пять плиток-форм (v5.229) ──
   Пять значений не влезают в .seg-group (лимит 2-4: на 320px пятому сегменту
   остаётся ~43px под лейбл), поэтому формат - ряд плиток в грамматике
   tile/select: тональная заливка --r-btn, выбор = tone step + залитый глиф +
   вес подписи. Глиф несёт РЕАЛЬНОЕ соотношение кадра, подпись - точное
   (9:16, 3:4, 1:1, 4:3, 16:9; сетка пикселей - ORIENTATION_DIMENSIONS в
   API/image.py). Подписи совпадают в обоих языках и не локализуются. */
.orient-tiles { display: flex; gap: 8px; }
.orient-tile {
  flex: 1 1 0;
  min-width: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 5px;
  padding: 9px 2px 8px;
  border: none;
  border-radius: var(--r-btn);
  background: var(--glass);
  color: var(--text2);
  font: inherit;
  cursor: pointer;
  user-select: none;
  -webkit-user-select: none;
  -webkit-tap-highlight-color: transparent;
  transition: background 130ms cubic-bezier(0.4, 0, 0.2, 1),
              transform 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
.orient-tile:active { transform: scale(0.97); }
.orient-tile:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
@media (hover: hover) {
  .orient-tile:hover:not(.active) { background: var(--glass-bold); }
  /* on the sheet the rest state IS --glass-bold, and --glass-border sits
     BELOW it in the ladder (0.09 vs 0.11 of rest lightness in applyTgTheme) -
     hover must lighten, so it takes the white-overlay step, same as
     .island .seg-btn's overrides */
  .modal-sheet .orient-tile:hover:not(.active) { background: rgba(255, 255, 255, 0.11); }
}
.ot-glyph {
  height: 28px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--text);
  opacity: 0.38;
  transition: opacity 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
.ot-glyph svg { display: block; }
.ot-cap {
  font-size: 12px;
  font-weight: 500;
  color: var(--text2);
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
  transition: color 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
.orient-tile.active { background: var(--accent-soft); }
.orient-tile.active .ot-glyph { opacity: 1; color: var(--accent-text); }
.orient-tile.active .ot-glyph rect { fill: currentColor; }
.orient-tile.active .ot-cap { color: var(--accent-text); font-weight: 600; }
/* на листе шторки контейнер шагает на --glass-bold (тональное правило §2b) */
.modal-sheet .orient-tile { background: var(--glass-bold); }
.modal-sheet .orient-tile.active { background: var(--accent-soft); }

/* ── Art studio recipe and picker sheets (ONE family) ──
   Studio: the recipe is one island of list rows (house .settings-row), each
   row = the chosen card's picture (or the slot's bare icon while Auto),
   label, the whole value, chevron; the island rides the shipped .hfold of
   the mode switch. Sheets: one height, solid, header [slot] title [slot]
   with "Сбросить" while something is chosen; uncontained 3:4 cards with a
   two-line caption; selection = iOS Photos badge + accent caption. */
.art-recipe { margin: 14px 0 0; }
.art-recipe-row {
  appearance: none; border: 0; background: transparent; color: inherit; font: inherit;
  width: 100%; text-align: left;
  min-height: 48px; padding: 5px 12px 5px 10px; gap: 12px;
  -webkit-tap-highlight-color: transparent;
}
.art-recipe-row::after { left: 58px; }
.art-recipe-lead {
  position: relative; flex-shrink: 0; width: 36px; height: 36px;
  border-radius: 9px; overflow: hidden;
  display: flex; align-items: center; justify-content: center;
}
.art-recipe-lead img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: 50% 12%; }
/* the character row shows a face: the standardised art preview (full body,
   head centred at the top) zoomed to the head */
.art-recipe-lead.bust img { inset: auto; width: 300%; height: auto; left: -100%; top: -3%; }
.art-recipe-lead.scene img { object-position: 50% 50%; }
/* solid stroke dimmed as a group so path overlaps stay one tone */
.art-recipe-ic { width: 24px; height: 24px; color: var(--text); opacity: .55; }
.art-recipe-ic svg { width: 24px; height: 24px; display: block; }
.art-recipe-text { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.art-recipe-label { font-size: 12px; line-height: 15px; color: var(--text2); }
.art-recipe-value {
  font-size: 15px; line-height: 20px; color: var(--text2); overflow-wrap: anywhere;
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden;
}
.art-recipe-row.set .art-recipe-value { color: var(--text); }
/* identity and look on their own lines; a long look name may take a third */
.art-char-slot .art-recipe-value { -webkit-line-clamp: 3; }
.art-recipe-look { display: block; color: var(--text2); }

/* Keep the backdrop-filtered surface off the animated layer (ADR-0014). */
.ag-modal-overlay { background: transparent; }
.ag-modal-overlay::before {
  content: '';
  position: absolute;
  inset: 0;
  background: rgba(0,0,0,0.5);
  pointer-events: none;
}
.modal-enter-active.ag-modal-overlay,
.modal-leave-active.ag-modal-overlay {
  animation: none;
}
/* fill-mode both everywhere: the Vue :duration timer outlives the keyframes
   by a frame or two, and without a held end state the scrim and the sheet
   popped back fully visible for ~2 frames on every close (verified by probe) */
.modal-enter-active.ag-modal-overlay::before {
  animation: modal-fade 0.35s ease both;
}
.modal-leave-active.ag-modal-overlay::before {
  animation: modal-fade 0.35s ease reverse both;
  /* a swipe commit writes --scrim-out on the overlay (wireSheetDismiss) */
  animation-duration: var(--scrim-out, .35s);
}
/* positioned for good: the scrim above is an absolute ::before, and a
   non-positioned sibling paints UNDER it. The transform of the slide lifted
   the sheet over the scrim only while it ran, so the sheet dropped 50% darker
   in one frame when the animation ended (and lit up again when a drag set a
   transform on it) */
.ag-sheet-motion {
  position: relative;
  width: 100%;
  max-width: 520px;
}
.modal-enter-active .ag-sheet-motion {
  animation: sheet-up 0.35s cubic-bezier(0.32, 0.72, 0, 1) both;
}
.modal-leave-active .ag-sheet-motion {
  animation: sheet-dn 0.35s cubic-bezier(0.32, 0.72, 0, 1) both;
}
.modal-enter-active .ag-sheet-motion > .modal-sheet,
.modal-leave-active .ag-sheet-motion > .modal-sheet {
  animation: none;
}
.modal-leave-active.sheet-dismissed .ag-sheet-motion { animation: none; }

/* All five Art sheets: one height, solid from the first frame (owner,
   2026-09-13). The height-driven material rule flipped glass -> solid a tick
   after mount (a visible blink) and differed per slot; .sheet-solid-pinned
   opts these sheets out of markSheetMaterial (community.js). The sheet itself
   never scrolls: the page scroller (.ag-scroll) does. */
.ag-sheet {
  display: flex; flex-direction: column; overflow: hidden;
  height: min(720px, 82dvh); padding-bottom: 0;
}
/* the dialog container takes focus on a pointer open so Esc/Tab start inside
   it; it is not a control and paints no ring (keyboard opens focus the
   current card, whose ring stays) */
.ag-sheet:focus, .ag-sheet:focus-visible, .ag-page:focus { outline: none; }
.ag-sheet .modal-handle { flex-shrink: 0; }
.ag-pages { position: relative; flex: 1; min-height: 0; display: grid; grid-template-rows: minmax(0, 1fr); }
.ag-page {
  grid-area: 1 / 1; min-width: 0; min-height: 0; display: flex; flex-direction: column;
  transition: transform 350ms cubic-bezier(0.32, 0.72, 0, 1), opacity 110ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* the look step pushes over the identity grid: the grid slides a third back
   and fades out fast so two titles never overlap mid-flight */
.ag-page.under { transform: translateX(-28%); opacity: 0; pointer-events: none; }
.ag-push-enter-active, .ag-push-leave-active { transition: transform 350ms cubic-bezier(0.32, 0.72, 0, 1); }
.ag-push-enter-from, .ag-push-leave-to { transform: translateX(100%); }
/* 4px of slack so a 2px-offset focus ring is never clipped by the scroller;
   the negative margin gives the geometry back */
.ag-scroll {
  flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
  padding: 4px 4px 16px; margin: -4px -4px 0;
  scrollbar-width: thin; scrollbar-color: var(--text3) transparent;
}
/* header: the side slots share the free width equally (title centred) but
   never shrink below their own content, so at 320px a long title ellipsizes
   instead of the fixed slots eating it */
.ag-head {
  display: grid; grid-template-columns: minmax(max-content, 1fr) minmax(0, auto) minmax(max-content, 1fr); align-items: center;
  min-height: 44px; margin: -8px -6px 12px; flex-shrink: 0;
}
.ag-head .modal-title { margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ag-head-slot { display: flex; align-items: center; min-width: 0; }
.ag-head-slot.end { justify-content: flex-end; }
.ag-reset { padding: 0 6px; }
/* content icon action on a sheet (pressables grammar: --glass-bold disc) */
.ag-back {
  width: 40px; height: 40px; border: 0; border-radius: var(--r-pill); padding: 0;
  background: var(--glass-bold); color: var(--text); display: flex; align-items: center; justify-content: center;
  cursor: pointer; transition: transform 130ms cubic-bezier(0.4, 0, 0.2, 1);
  -webkit-tap-highlight-color: transparent;
}
.ag-back svg { width: 20px; height: 20px; }
.ag-back:active { transform: scale(0.92); }
.ag-back:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

.ag-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
html.layout-desktop .ag-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
/* Card: the picture carries no tonal band (on white-backed art the band made
   a two-tone sticker and an empty stripe under short captions). Every caption
   reserves two lines, so row pitch never depends on a name. */
.ag-tile {
  appearance: none; border: 0; background: transparent; color: var(--text); font: inherit;
  padding: 0; margin: 0; min-width: 0; text-align: left; cursor: pointer;
  display: flex; flex-direction: column; gap: 6px;
  user-select: none; -webkit-user-select: none; -webkit-touch-callout: none;
  -webkit-tap-highlight-color: transparent;
  transition: transform 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
.ag-tile:active { transform: scale(0.97); }
.ag-tile:focus-visible { outline: none; }
.ag-tile:focus-visible .ag-tile-media { outline: 2px solid var(--accent); outline-offset: 2px; }
.ag-tile-media {
  position: relative; display: block; width: 100%; aspect-ratio: 3 / 4;
  border-radius: var(--r-btn); overflow: hidden; background: var(--glass-bold);
}
.ag-tile-media img {
  position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block;
  pointer-events: none; transform-origin: 50% 27%;
  /* arrival grammar (§6): each card fades in as its file lands */
  opacity: 0;
  transition: opacity 250ms cubic-bezier(0.05, 0.7, 0.1, 1), transform 180ms cubic-bezier(0.2, 0, 0, 1);
}
.ag-tile-media img.loaded { opacity: 1; }
/* identity = head to chest: every art preview is a standardised full-body
   figure with the head centred at the top, so ONE crop serves all */
.ag-tile-media.bust img { inset: auto; width: 210%; height: auto; left: -55%; top: -1.5%; }
.ag-tile-cap {
  font-size: 13px; line-height: 16px; font-weight: 500; color: var(--text);
  min-height: 32px; padding: 0 2px;
  /* a word wider than a 320px card hyphenates in the interface language
     (lang on the sheet) instead of splitting at an arbitrary letter */
  hyphens: auto; -webkit-hyphens: auto; overflow-wrap: break-word;
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden;
  transition: color 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* drill-in mark on a character with looks, glued to the last word */
.ag-tile-nb { white-space: nowrap; }
.ag-tile-more { display: inline-flex; vertical-align: -2px; margin-left: 2px; color: var(--text2); }
.ag-tile-more svg { width: 14px; height: 14px; }
/* Selected (owner pick, iOS Photos): the picture is untouched; an accent disc
   with a white rim (it keeps the disc legible over dark art, it is not a card
   outline) and an optically centred check glyph; the caption in accent. */
.ag-tile-check {
  position: absolute; top: 6px; right: 6px; z-index: 1; width: 24px; height: 24px;
  border-radius: var(--r-pill); background: var(--accent); color: #fff;
  box-shadow: 0 0 0 1.5px #fff, 0 1px 4px rgba(0, 0, 0, 0.35);
  display: flex; align-items: center; justify-content: center;
  opacity: 0; transform: scale(0.6);
  transition: opacity 130ms cubic-bezier(0.4, 0, 0.2, 1), transform 180ms cubic-bezier(0.32, 0.72, 0.35, 1.1);
}
.ag-tile-check svg { width: 16px; height: 16px; display: block; }
.ag-tile.on .ag-tile-check { opacity: 1; transform: none; }
.ag-tile.on .ag-tile-cap { color: var(--accent-text); }
@media (hover: hover) {
  .ag-tile:not(.on):hover .ag-tile-media img { transform: scale(1.04); }
}

/* sections of a long deck (pose) */
.ag-sec { padding: 4px 2px 8px; }
.ag-grid + .ag-sec, .ag-grid + .ag-nsfw-head { margin-top: 14px; }
/* 18+ deck: a collapsed last SECTION (label, count, chevron), not a button */
.ag-nsfw-head {
  appearance: none; border: 0; background: transparent; font: inherit; cursor: pointer;
  width: 100%; min-height: 44px; display: flex; align-items: center; gap: 8px; text-align: left;
  -webkit-tap-highlight-color: transparent;
}
.ag-nsfw-count { color: var(--text3); font-variant-numeric: tabular-nums; letter-spacing: 0; }
.ag-nsfw-chev { margin-left: auto; width: 16px; height: 16px; color: var(--text2); transition: transform 200ms cubic-bezier(0.4, 0, 0.2, 1); }
.ag-nsfw-chev svg { width: 16px; height: 16px; display: block; }
.ag-nsfw-head[aria-expanded="true"] .ag-nsfw-chev { transform: rotate(180deg); }
.ag-nsfw-head:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: var(--r-btn); }
/* a minor-coded hero: the head stays in its place, locked. Not a control, so
   no press, hover or focus; the glyph is solid --text dimmed by group opacity */
.ag-nsfw-head.ag-nsfw-locked { cursor: default; opacity: 0.6; }
.ag-nsfw-lock { margin-left: auto; width: 16px; height: 16px; color: var(--text); opacity: 0.6; flex-shrink: 0; }
.ag-nsfw-lock svg { width: 16px; height: 16px; display: block; }
.ag-nsfw-locked-note { margin: -6px 2px 8px; font-size: 13px; line-height: 18px; color: var(--text2); }
/* ring slack inside the fold (it clips its body) */
.ag-nsfw-fold { margin: 0 -4px; padding: 0 4px; }
.ag-nsfw-fold .ag-grid { padding: 0 0 4px; }

@media (prefers-reduced-motion: reduce) {
  .ag-page, .ag-push-enter-active, .ag-push-leave-active, .ag-tile, .ag-tile-media img, .ag-tile-check, .ag-nsfw-chev { transition: none; }
  .ag-tile:active { transform: none; }
}

/* Press-and-hold style preview (peek). Glass scrim over the whole screen, the
   cover floats centered. Non-interactive: releasing the held tile dismisses it. */
.style-peek {
  position: fixed;
  inset: 0;
  z-index: 600;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 16px;
  padding: 24px;
  pointer-events: none;
}
/* Scrim is a separate layer so only it fades; the cover and name stay at full
   opacity (a faded white-on-dark name would read as gray mid-transition). */
.style-peek::before {
  content: '';
  position: absolute;
  inset: 0;
  background: color-mix(in srgb, var(--bg) 94%, transparent);
}
.style-peek-img {
  position: relative;
  width: min(82vw, 78vh, 460px);
  aspect-ratio: 1;
  object-fit: cover;
  border-radius: var(--r-island);
  box-shadow: 0 24px 60px rgba(0,0,0,0.55);
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
  pointer-events: none;
}
.style-peek-meta {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 5px;
}
.style-peek-name {
  font-size: 16px;
  font-weight: 600;
  color: var(--text);
  text-align: center;
  max-width: 82vw;
}
.style-peek-desc {
  font-size: 13px;
  line-height: 1.4;
  color: var(--text2);
  text-align: center;
  max-width: min(82vw, 360px);
}
.peek-enter-active .style-peek::before { transition: opacity 170ms cubic-bezier(0.4, 0, 0.2, 1); }
.peek-leave-active .style-peek::before { transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1); }
.peek-enter-from .style-peek::before, .peek-leave-to .style-peek::before { opacity: 0; }
.peek-enter-active .style-peek-img {
  transition: transform 230ms cubic-bezier(0.32, 0.72, 0.35, 1.1);
}
.peek-enter-from .style-peek-img { transform: scale(0.84); }
.peek-leave-active .style-peek-img {
  transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
.peek-leave-to .style-peek-img { transform: scale(0.92); }

/* Art card hold preview (artcards.js): the same .style-peek layer, the
   card's own crop at a larger size. The wrapper carries the peek-* motion
   classes, so the descendant rules above apply to it. */
.art-peek-media {
  aspect-ratio: 3 / 4; width: min(72vw, 56vh, 380px);
  position: relative; overflow: hidden; background: var(--glass-bold);
}
.art-peek-media img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; }
.art-peek-media.bust img { inset: auto; width: 210%; height: auto; left: -55%; top: -1.5%; }
.peek-enter-active .art-peek-media { transition: transform 230ms cubic-bezier(0.32, 0.72, 0.35, 1.1); }
.peek-enter-from .art-peek-media { transform: scale(0.84); }
.peek-leave-active .art-peek-media { transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1); }
.peek-leave-to .art-peek-media { transform: scale(0.92); }
.art-peek.peek-enter-active .style-peek-meta { transition: opacity 170ms cubic-bezier(0.4, 0, 0.2, 1); }
.art-peek.peek-leave-active .style-peek-meta { transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1); }
.art-peek.peek-enter-from .style-peek-meta, .art-peek.peek-leave-to .style-peek-meta { opacity: 0; }

.art-orient-row { display: flex; gap: 8px; }

/* form-footer air above the one accent CTA (.form-buttons margin precedent) */
.art-screen .btn-primary { margin-top: 8px; }
/* ── Floating generate CTA (phone) ──
   The screen's ONE filled-accent action docks above the nav island so the
   recipe submits from any scroll depth (M3E surface: the sanctioned FAB
   species, in the submit-button shape). Centering = left/right + margin
   auto, NOT a translate: the press scale must not fight a centering
   transform. Desktop keeps it in-flow (the studio column always shows it). */
.art-cta {
  position: fixed;
  left: 16px;
  right: 16px;
  bottom: calc(var(--nav-h) + var(--safe-bottom) + 20px);
  /* above the .nav-fade gradient (z 99), same stratum as the nav island:
     chrome rides the fade, only content dissolves under it */
  z-index: 100;
  width: auto;
  max-width: 500px;
  margin: 0 auto;
  /* the enabled/disabled fill swap is a tone change in the house 130ms
     feedback band; transform rides the 250ms arrival curve because the rest
     transform only changes when the pill comes back from under a sheet (the
     press scale takes its own 130ms on :active below) */
  transition: transform 250ms cubic-bezier(0.05, 0.7, 0.1, 1),
              background 130ms cubic-bezier(0.4, 0, 0.2, 1),
              color 130ms cubic-bezier(0.4, 0, 0.2, 1);
  box-shadow:
    0 1px 2px rgb(0 0 0 / 30%),
    0 8px 12px 6px rgb(0 0 0 / 15%);
}
.art-cta:active { transition: transform 130ms cubic-bezier(0.4, 0, 0.2, 1), background 130ms cubic-bezier(0.4, 0, 0.2, 1), color 130ms cubic-bezier(0.4, 0, 0.2, 1); }
/* a sheet is up (showModal / the animate sheet): the one CTA steps out below
   its dock, the catalog selection's .off-pick-cta rule. A solid accent pill
   at z 100 under a glass sheet read as a pink smear behind the sheet's text.
   Hidden only after the slide, back (visible at once) on the same curve */
.art-cta.sheet-up {
  transform: translateY(calc(100% + 32px + var(--nav-h) + var(--safe-bottom)));
  visibility: hidden;
  transition: transform 250ms cubic-bezier(0.05, 0.7, 0.1, 1),
              background 130ms cubic-bezier(0.4, 0, 0.2, 1),
              color 130ms cubic-bezier(0.4, 0, 0.2, 1),
              visibility 0s linear 250ms;
}
@media (prefers-reduced-motion: reduce) {
  .art-cta, .art-cta.sheet-up { transition-duration: 0s; transition-delay: 0s; }
}
/* opaque tonal disabled instead of the house 0.3 ghost: a translucent pill
   FLOATING over passing content reads as mud. One grammar on both layouts. */
.art-cta:disabled { opacity: 1; background: var(--glass-bold); color: var(--text3); }
/* keep the dock while SD works: .btn-primary.is-pending carries position
   relative for its spinner at the same weight, and relative under the dock
   offsets walks the pill into the content mid-screen; later-in-file wins */
.art-cta.is-pending { position: fixed; background: var(--accent); }
/* same rule as the nav island: keyboard owns the bottom edge */
body.kb-open .art-cta { display: none; }

/* ═══════════════════════════════════════════
   WEB ACCESS (MD3-style) — settings section
═══════════════════════════════════════════ */
.webaccess-card {
  padding: 16px;
  display: flex;
  flex-direction: column;
  gap: 14px;
}
.webaccess-desc {
  font-size: 13px;
  color: var(--text2);
  line-height: 1.5;
}
.url-chip {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 12px 14px;
  background: var(--glass-bold);
  border-radius: var(--r-btn);
  text-decoration: none;
  color: var(--text);
  transition: background 0.15s;
  min-height: 48px;
}
@media (hover: hover) {
  .url-chip:hover { background: var(--glass-border); }
}
.url-chip:active { background: var(--glass-border); }
.url-chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.url-chip-icon {
  width: 20px; height: 20px; flex-shrink: 0;
  color: var(--accent-text);
  display: flex; align-items: center; justify-content: center;
}
.url-chip-icon svg { width: 100%; height: 100%; }
.url-chip-text {
  flex: 1;
  font-size: 15px;
  color: var(--text);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.url-chip-action {
  width: 18px; height: 18px; flex-shrink: 0;
  color: var(--text3);
  display: flex; align-items: center; justify-content: center;
}
.url-chip-action svg { width: 100%; height: 100%; }
.url-chip:hover .url-chip-action { color: var(--text2); }

.webaccess-hint {
  font-size: 12px;
  color: var(--text3);
  line-height: 1.5;
}
.webaccess-card .btn-secondary { margin-top: 0; }

/* Devices (browser sessions) list */
.device-row {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 12px var(--pad);
  position: relative;
}
.device-row::after {
  content: '';
  position: absolute;
  left: calc(var(--pad) + 36px);
  right: 0;
  bottom: 0;
  height: 1px;
  background: var(--core-separator);
}
.device-row:last-child::after { display: none; }
.device-ico {
  width: 24px; height: 24px; flex-shrink: 0;
  color: var(--text);
  display: flex; align-items: center; justify-content: center;
}
/* solid stroke + element opacity: overlapping/retraced path segments composite
   once, no darker blobs at intersections (alpha strokes stack per draw) */
.device-ico svg { width: 100%; height: 100%; opacity: 0.55; }
.device-info { flex: 1; min-width: 0; }
.device-name {
  font-size: 15px;
  font-weight: 500;
  color: var(--text);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.device-sub {
  font-size: 12px;
  color: var(--text3);
  margin-top: 1px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.device-current { color: var(--accent-text); }
.device-end {
  position: relative;
  width: 34px; height: 34px; flex-shrink: 0;
  display: flex; align-items: center; justify-content: center;
  background: none;
  border: none;
  border-radius: var(--r-pill);
  color: var(--text);
  cursor: pointer;
  transition: background 130ms cubic-bezier(0.4, 0, 0.2, 1), transform 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
.device-end::before { content: ''; position: absolute; inset: -5px; border-radius: inherit; }
.device-end svg { width: 16px; height: 16px; opacity: 0.35; transition: opacity 0.13s; }
@media (hover: hover) {
  .device-end:hover { background: var(--glass-bold); }
  .device-end:hover svg { opacity: 0.8; }
}
.device-end:active { transform: scale(0.92); background: var(--glass-bold); }
.device-end:active svg { opacity: 0.8; }
.device-end:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.devices-empty {
  padding: 18px var(--pad);
  text-align: center;
  font-size: 13px;
  color: var(--text3);
}

/* Settings menu picker: island row + iOS pull-down anchored under it
   (reuses .chat-dropdown visuals + the shared chatmenu grid reveal).
   Outside-tap close = document capture listener in app.js, NOT a fixed
   overlay: on iOS the composited scroller is its own stacking context and
   a fixed z-99 sibling sat ABOVE the z-100 menu inside it (items dead). */
.settings-menu-wrap { position: relative; }
.settings-pick-menu {
  right: var(--pad);
  top: calc(100% + 6px);
  min-width: 200px;
}
.settings-pick-menu > .chat-dropdown { min-width: 200px; }
/* drop-up when the row is near the viewport bottom: reveal unfolds bottom-up */
.settings-pick-menu.up {
  top: auto;
  bottom: calc(100% + 6px);
}
.settings-pick-menu.up.chatmenu-enter-from {
  transform: translateY(6px);
}
.settings-pick-menu.up.chatmenu-leave-to { transform: translateY(6px); }
.settings-pick-check {
  width: 16px;
  height: 16px;
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--text);
}
.settings-pick-check svg { width: 100%; height: 100%; }

/* ═══════════════════════════════════════════
   CHAT INFO — экран информации о чате и «Все медиа»
   Диалект настроек: тональные острова, никакого стекла в контенте.
   Спека: docs/superpowers/specs/2026-08-01-chat-info-screen-design.md
═══════════════════════════════════════════ */
.ci-hero {
  padding: 6px var(--pad) 2px;
  text-align: center;
  position: relative;
  z-index: 2;
}
/* Переходы морфа живут ТОЛЬКО на время флипа (.ci-anim ставит ciFlip):
   постоянный transition анимировал бы и смену геометрии в покое - замер
   при входе на экран заметно поддёргивал круг (owner, 2026-08-20). */
.ci-hero.ci-anim { transition: height 350ms cubic-bezier(0.38, 0.7, 0.125, 1); }
.ci-ava {
  width: 88px;
  height: 88px;
  border-radius: 50%;
  object-fit: cover;
  display: block;
  margin: 0 auto 12px;
}
.ci-ava.roster-ava { display: grid; }
.ci-ava-ph {
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--glass-bold);
  font-size: 34px;
  font-weight: 800;
  color: var(--text2);
}
.ci-name { font-size: 27px; font-weight: 700; letter-spacing: -0.4px; }
/* одна строка с многоточием в обоих состояниях, как ImmediateTextNode у TG
   (одна строка, обрез хвоста): многострочный бокс центрировал строки и в
   левом нижнем углу фото имя не доходило до края */
.ci-name-t {
  max-width: 100%;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  vertical-align: top;
}
.ci-facts { font-size: 13.5px; color: var(--text2); margin-top: 4px; }

/* ── Потяг аватарки: морф круга в полноширинное фото (механика профиля
   TG iOS, спека docs/superpowers/specs/2026-08-19-tg-ios-avatar-expansion.md).
   Слой .ci-photo всегда свёрстан в развёрнутый прямоугольник (102vw на
   --ci-h + 1vw: по 1vw кровотёка за края и сверху, потому что пружина
   возврата недолетает на доли процента, и без запаса в этот кадр у краёв
   проглядывал бы фон; снизу запас не нужен, там контент); свёрнутое состояние -
   это clip-окно круга 88px плюс уменьшенный img, поэтому морф идёт только по
   композитным свойствам (clip-path + transform), без layout в кадре.
   Геометрию (--ci-h, --ci-s0, --ci-oy) пишет ciMeasure() в app.js; кривая
   морфа - спрингов TG (bezier .38 .7 .125 1, 350мс).
   Два слоя, как аддитивная анимация у TG: .ci-photo несёт ЖИВОЕ растяжение
   пальцем (scale 1 + ry/W вокруг центра квадрата, подъём -ΔH/4, параллакс
   -ry/2; пишется каждый кадр, переход только на возврате .ci-settle),
   .ci-photo-clip - обратную матрицу момента коммита, уезжающую в единицу той
   же пружиной, что и морф, поэтому круг не прыгает на хаптике, а растёт.
   clip-path режет и hit-область, так что тап ловится ровно по кругу
   (.ci-photo сам без hit-области, иначе тап по имени разворачивал бы фото).
   Возврат на отпускании - Apple spring(0.45, bounce 0.15), семплированный в
   linear() (перелёт 0.6%, потому и кровотёк); где linear() нет - безье. */
.ci-ava-slot { width: 88px; height: 88px; margin: 0 auto 12px; }
/* нативный bounce iOS дал бы вторую резинку поверх синтетической: у экранов
   с морфом потяг целиком наш (contain глушит только цепочку, не сам bounce) */
.settings-screen:has(.ci-photo) {
  overscroll-behavior: none;
  overflow-x: hidden;
  --ci-spring-dur: 320ms;
  --ci-spring-ease: cubic-bezier(0.3, 1.25, 0.5, 1);
}
@supports (transition-timing-function: linear(0, 1)) {
  .settings-screen:has(.ci-photo) {
    --ci-spring-dur: 650ms;
    --ci-spring-ease: linear(0.0000, 0.0161 2.1%, 0.0577 4.2%, 0.1166 6.2%, 0.1863 8.3%, 0.2616 10.4%, 0.3387 12.5%, 0.4148 14.6%, 0.4878 16.7%, 0.5563 18.8%, 0.6195 20.8%, 0.6770 22.9%, 0.7285 25.0%, 0.7741 27.1%, 0.8141 29.2%, 0.8487 31.2%, 0.8785 33.3%, 0.9037 35.4%, 0.9250 37.5%, 0.9427 39.6%, 0.9573 41.7%, 0.9691 43.8%, 0.9787 45.8%, 0.9863 47.9%, 0.9922 50.0%, 0.9967 52.1%, 1.0001 54.2%, 1.0025 56.2%, 1.0042 58.3%, 1.0053 60.4%, 1.0060 62.5%, 1.0063 64.6%, 1.0063 66.7%, 1.0061 68.8%, 1.0058 70.8%, 1.0054 72.9%, 1.0049 75.0%, 1.0044 77.1%, 1.0039 79.2%, 1.0035 81.2%, 1.0030 83.3%, 1.0026 85.4%, 1.0022 87.5%, 1.0019 89.6%, 1.0016 91.7%, 1.0013 93.8%, 1.0011 95.8%, 1.0009 97.9%, 1.0000);
  }
}
.ci-photo.ci-settle, .ci-body.ci-settle { transition: transform var(--ci-spring-dur) var(--ci-spring-ease); }
.ci-photo {
  position: absolute;
  left: -1vw;
  top: calc(-56px - var(--tg-top) - 1vw);
  width: 102vw;
  height: calc(var(--ci-h, 480px) + 1vw);
  transform-origin: 50% 51vw;
  z-index: 1;
  pointer-events: none;
}
.ci-photo-clip {
  position: absolute;
  inset: 0;
  clip-path: inset(calc(62px + var(--tg-top) + 1vw) calc(51vw - 44px) calc(var(--ci-h, 480px) - 150px - var(--tg-top)) calc(51vw - 44px) round 44px);
  transform-origin: 50% 51vw;
  pointer-events: auto;
  cursor: pointer;
}
.ci-hero.ci-anim .ci-photo-clip {
  transition: clip-path 350ms cubic-bezier(0.38, 0.7, 0.125, 1),
              transform 350ms cubic-bezier(0.38, 0.7, 0.125, 1);
}
.ci-photo-img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  transform: scale(var(--ci-s0, 0.23));
  transform-origin: 50% var(--ci-oy, 90px);
}
.ci-hero.ci-anim .ci-photo-img { transition: transform 350ms cubic-bezier(0.38, 0.7, 0.125, 1); }
.ci-hero.ci-x .ci-photo-clip { clip-path: inset(0 0 0 0 round 0); }
.ci-hero.ci-x .ci-photo-img { transform: scale(1); }
/* тени читаемости: сверху под системные кнопки, снизу под имя. У TG внизу
   ещё прогрессивный блюр, но веб не умеет плавно проявлять backdrop-filter
   (opacity его не фейдит - зона выпадала одним кадром), поэтому читаемость
   несёт один градиент, чуть плотнее телеграмного */
.ci-shade-top, .ci-shade-bot {
  position: absolute;
  left: 0;
  right: 0;
  opacity: 0;
  pointer-events: none;
}
.ci-hero.ci-anim .ci-shade-top, .ci-hero.ci-anim .ci-shade-bot {
  transition: opacity 350ms cubic-bezier(0.38, 0.7, 0.125, 1);
}
.ci-shade-top {
  top: 0;
  height: calc(76px + var(--tg-top));
  background: linear-gradient(rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0));
}
.ci-shade-bot {
  bottom: 0;
  height: 132px;
  background: linear-gradient(to top, rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.26) 45%, rgba(0, 0, 0, 0));
}
.ci-hero.ci-x .ci-shade-top, .ci-hero.ci-x .ci-shade-bot { opacity: 1; }
/* имя и факты - ОДНИ и те же ноды в обоих состояниях (как у TG): FLIP-сдвиг
   в левый низ фото пишет ciFlip() инлайном, тут только переходы и цвет */
.ci-name-t, .ci-facts-t {
  display: inline-block;
  transform-origin: 0 50%;
}
.ci-hero.ci-anim .ci-name-t, .ci-hero.ci-anim .ci-facts-t {
  transition: transform 350ms cubic-bezier(0.38, 0.7, 0.125, 1),
              color 350ms cubic-bezier(0.38, 0.7, 0.125, 1);
}
.ci-name, .ci-facts { position: relative; z-index: 2; }
.ci-hero.ci-x .ci-name-t { color: #fff; }
.ci-hero.ci-x .ci-facts-t { color: rgba(255, 255, 255, 0.85); }
/* верхний фейд экрана гасится, пока фото развёрнуто: bg-туман поверх картинки */
.ci-tf { transition: opacity 250ms cubic-bezier(0.4, 0, 0.2, 1); }
.ci-tf.ci-fade-off { opacity: 0; }
@media (prefers-reduced-motion: reduce) {
  .ci-hero.ci-anim, .ci-hero.ci-anim .ci-photo-clip, .ci-hero.ci-anim .ci-photo-img,
  .ci-hero.ci-anim .ci-name-t, .ci-hero.ci-anim .ci-facts-t,
  .ci-hero.ci-anim .ci-shade-top, .ci-hero.ci-anim .ci-shade-bot,
  .ci-photo.ci-settle, .ci-body.ci-settle { transition: none; }
}

/* Действия ОДНИМ островом в три колонки, а не тремя карточками: три карточки
   читаются как три конкурирующих CTA. M3E: connected button group;
   iOS 26: кнопки бара разделяют общий фон. */
.ci-acts {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 1fr;
  gap: 8px;
  margin: 18px var(--pad) 0;
}
.ci-act {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  padding: 13px 4px;
  min-height: 44px;
  min-width: 0; /* equal thirds: a long label never widens its column */
  background: var(--glass);
  border: 0;
  border-radius: var(--r-btn);
  font: inherit;
  cursor: pointer;
  color: var(--text);
  transition: background 130ms cubic-bezier(0.4, 0, 0.2, 1), transform 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
@media (hover: hover) {
  .ci-act:hover { background: var(--glass-bold); }
}
.ci-act:active { background: var(--glass-bold); transform: scale(0.97); }
.ci-act:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* сплошной stroke + групповая непрозрачность: пересечения путей не темнеют */
.ci-act svg { width: 22px; height: 22px; stroke-width: 1.9; opacity: 0.8; display: block; }
/* one line always: the three tiles keep one height. "Закреплённое" measures 82.6px
   against an 82.7px content box at 320, so the label may borrow the tile's
   side padding before it ever ellipsizes (fonts differ per device) */
.ci-act-lbl { font-size: 12.5px; color: var(--text2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  max-width: calc(100% + 6px); margin-inline: -3px; }

/* заголовок секции с действием справа (идиома iOS "See All") */
.ci-seclbl {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  padding: 24px var(--pad) 8px;
  font-size: 13px;
  line-height: 18px;
  font-weight: 600;
  letter-spacing: 0.4px;
  text-transform: uppercase;
  color: var(--text2);
}
/* .text-btn carries the look; negative margins keep the label on the section
   label's baseline while the 44px hit box grows around it */
.ci-seeall {
  flex-shrink: 0;
  margin: -15px -8px -15px 0;
  /* the trailing VALUE of a grouped header stays sentence case at the header's
     own size - uppercase belongs to the section NAME only */
  text-transform: none;
  letter-spacing: 0;
}

/* Ряд медиа НЕ прокручивается вбок: вписан в --pad, поэтому ничто не касается
   края экрана и не спорит с предиктивным свайпом-назад (_bpClaim). При медиане
   в 2 фото на чат прокручивать нечего. */
.ci-row {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 6px;
  padding: 0 var(--pad);
}
.ci-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 3px;
  padding: 0 var(--pad);
}
.ci-tile, .ci-cell {
  position: relative;
  aspect-ratio: 1;
  border-radius: var(--r-btn);
  overflow: hidden;
  background: var(--glass);
  border: 0;
  padding: 0;
  cursor: pointer;
  transition: transform 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
.ci-tile:active, .ci-cell:active { transform: scale(0.96); }
.ci-tile:focus-visible, .ci-cell:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.ci-tile img, .ci-cell img { width: 100%; height: 100%; object-fit: cover; display: block; }
.ci-tile-more {
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--glass-bold);
  color: var(--text2);
  font-size: 15px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}
/* Файл не отдался: архив на sshfs имеет аптайм не 100%. Плитка остаётся в
   списке (иначе счётчик разъедется) и ведёт к исходному сообщению. */
.ci-tile-dead { display: flex; align-items: center; justify-content: center; }
.ci-dead-glyph { display: flex; color: var(--text); opacity: 0.28; }
.ci-dead-glyph svg { width: 22px; height: 22px; }
.ci-badge {
  position: absolute;
  left: 6px;
  bottom: 6px;
  display: flex;
  align-items: center;
  gap: 3px;
  padding: 2px 6px 2px 4px;
  border-radius: var(--r-pill);
  background: rgba(0,0,0,0.55);
  font-size: 11px;
  color: #fff;
  font-variant-numeric: tabular-nums;
}
.ci-badge svg { width: 9px; height: 9px; display: block; }
.ci-media-sub { padding: 2px var(--pad) 14px; font-size: 13.5px; color: var(--text2); }

/* ── Мои медиа: экран архива генераций ── */
/* тип решает, ЧТО показано в ленте, поэтому это .view-strip; здесь только
   его посадка на экране */
.media-type-seg { margin: 4px var(--pad) 6px; }
.media-month-lbl { margin-top: 18px; }
.media-empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  padding: 64px 24px;
  text-align: center;
}
.media-body.soft-loading { opacity: 0.5; pointer-events: none; }
/* Секции-бакеты (задача 10): резерв высоты приходит инлайн min-height из
   формулы rows*cell; лишнее место НЕ растягивает auto-ряды - иначе высота
   ready-секции разошлась бы с резервом и скролл дёргался бы на догрузке. */
.media-grid { align-content: start; }
/* Скелет пустой/грузящейся секции - фон контейнера, не тысячи div (все
   месяцы лежат в DOM сразу, на тяжёлом архиве плитки-заглушки стоили бы
   тысячи узлов). Ячейки ВЫРЕЗАЕТ плитка-маска --mda-skel: её пишет только
   валидный замер mediaMeasure на скроллер, и рисует она ту же ячейку и то
   же скругление --r-btn, что у реальной .ci-cell - иначе месяц грузится
   квадратами рядом со скруглёнными фото (finding владельца). Гуттеры -
   прозрачные полосы маски, цвет заливки остаётся в CSS и переживает смену
   темы. Фолбэчной константы размера нет НАМЕРЕННО - приблизительная ячейка
   рисовала сетку не в фазе с реальной, тоже finding владельца. Пока замера
   нет, var() без фолбэка роняет mask-image в none (invalid at
   computed-value time) - остаётся ровный glass-фон из background-color.
   content-box: и заливка, и ФАЗА маски начинаются на первой ячейке, а не на
   боковом поле. */
.media-grid:not([data-state="ready"]) {
  background-color: var(--glass);
  background-clip: content-box;
  -webkit-mask-image: var(--mda-skel);
  mask-image: var(--mda-skel);
  -webkit-mask-size: var(--mda-pitch) var(--mda-pitch);
  mask-size: var(--mda-pitch) var(--mda-pitch);
  -webkit-mask-repeat: repeat;
  mask-repeat: repeat;
  -webkit-mask-origin: content-box;
  mask-origin: content-box;
}

/* HIDDEN:wallet */
/* ── История кредитов: тот же скелет, что у архива медиа (sub-bar, большой
   заголовок, seg-фильтр, секции), ряд операции - обычный .settings-row с
   суммой в слоте значения. ── */
.tx-head { padding: 18px var(--pad) 14px; }
.tx-bal {
  font-size: 34px;
  font-weight: 700;
  color: var(--text);
  letter-spacing: -1px;
  font-variant-numeric: tabular-nums;
}
.tx-bal-lbl { font-size: 13px; color: var(--text2); margin-top: 2px; }
.tx-figs { display: flex; gap: 10px; margin-top: 16px; }
.tx-fig {
  flex: 1;
  min-width: 0;
  padding: 10px 12px;
  border-radius: var(--r-btn);
  background: var(--glass-bold);
}
.tx-fig-val {
  font-size: 19px;
  font-weight: 600;
  color: var(--text);
  font-variant-numeric: tabular-nums;
}
/* Приход - акцентным тоном, расход - обычным текстом. --destructive здесь не
   при чём: трата не ошибка (правило ролей цвета в design-system). */
.tx-fig-val.is-in { color: var(--accent-text); }
.tx-fig-lbl { font-size: 12.5px; color: var(--text2); margin-top: 2px; }
.tx-head-cap { font-size: 12.5px; color: var(--text3); margin-top: 10px; }
/* направление решает, ЧТО показано в списке, поэтому это .view-strip; здесь
   только его посадка на экране */
.tx-dir-seg { margin: 18px var(--pad) 6px; }
.tx-sec-lbl { margin-top: 18px; }
/* Ряды истории не кликабельны (кроме покупки с id платежа): гасим у базового
   ряда курсор и подсветку нажатия, иначе экран обещает переход, которого нет. */
.tx-row { cursor: default; }
.tx-row:active { background: transparent; }
@media (hover: hover) {
  .tx-row:hover { background: transparent; }
}
.tx-row.is-copy { cursor: pointer; }
.tx-row.is-copy:active { background: rgba(255,255,255,0.04); }
@media (hover: hover) {
  .tx-row.is-copy:hover { background: rgba(255,255,255,0.03); }
}
/* min-width:0 на самом лейбле: без него flex-ребёнок не сжимается ниже своей
   ширины по содержимому, и длинная подстрока (id платежа, имя чата) не режется
   многоточием, а выпихивает сумму за край ряда. */
.tx-row .settings-label { min-width: 0; }
.tx-row .settings-label-sub {
  display: flex;
  align-items: center;
  gap: 5px;
  min-width: 0;
}
.tx-sub-txt {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* видимый двойник жеста: по ряду покупки тап копирует id платежа (ADR-0005:
   жест не ходит один). Глиф сплошным цветом, приглушён прозрачностью всего
   svg - пересечения путей не темнеют. */
.tx-copy-ic {
  flex-shrink: 0;
  display: flex;
  color: var(--text);
  opacity: 0.45;
}
.tx-copy-ic svg { width: 13px; height: 13px; display: block; }
.tx-amt {
  flex-shrink: 0;
  font-size: 16px;
  font-weight: 600;
  color: var(--text2);
  font-variant-numeric: tabular-nums;
}
.tx-amt.is-in { color: var(--accent-text); }
/* Итоговая строка месяца - та же анатомия, но это сумма, а не операция:
   иконка вида уходит на полтона глубже, чтобы ряд читался как свод. */
.tx-roll .icon-tile svg { opacity: 0.36; }
.tx-sentinel { height: 1px; }
.tx-note {
  padding: 22px var(--pad) 0;
  font-size: 12.5px;
  color: var(--text3);
  line-height: 1.45;
}

/* /HIDDEN:wallet */

/* ═══════════════════════════════════════════
   LEADERBOARDS
═══════════════════════════════════════════ */
/* Скелет экрана тот же, что у истории кредитов и архива медиа: .sub-bar с
   конденсирующейся пилюлей, большой заголовок в контенте, острова и ряды
   настроек. Значения рядов продублированы, а не взяты из блока wallet выше:
   тот вырезается из сборки вместе со своей фичей, и лидерборд остался бы без
   стилей.

   Борд решает, ЧТО в списке, поэтому его переключатель - полоса вида. Но
   вкладок пять и на 390px они не помещаются, поэтому это ПЕРЕПОЛНЯЕМАЯ полоса
   (design-system, "Overflowing tab strip"): капсула держит материал, форму и
   обрезку, а вкладки вместе с линзой едут во внутреннем скроллере со своей
   скруглённой коробкой внутри 4px поля капсулы. Период - значение текущего
   вида, поэтому он остаётся .seg-group. */
.ldr-strip {
  position: relative;
  display: flex;
  align-items: stretch;
  height: 42px;
  margin: 10px var(--pad) 0;
  padding: 4px;
  /* нативное выделение подписи долгим тапом гоняется с удержанием линзы -
     тот же гард, что у .folder-bar */
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
  background: var(--chrome-glass-fill);
  backdrop-filter: var(--chrome-glass-filter);
  -webkit-backdrop-filter: var(--chrome-glass-filter);
  box-shadow: var(--chrome-glass-rim), 0 1px 2px rgb(0 0 0 / 22%), 0 2px 6px rgb(0 0 0 / 7%);
  border-radius: var(--r-pill);
  overflow: hidden;
}
/* position: relative = тот offsetParent, относительно которого ldrPaintLens
   меряет вкладку; он же владелец горизонтального жеста (_bpClaim) */
.ldr-strip-scroll {
  position: relative;
  flex: 1;
  min-width: 0;
  height: 100%;
  display: flex;
  align-items: center;
  gap: 3px;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scrollbar-width: none;
  border-radius: var(--r-pill);
}
.ldr-strip-scroll::-webkit-scrollbar { display: none; }
.ldr-strip-tab {
  position: relative;
  z-index: 1;
  /* мало вкладок - растягиваются поровну, много - min-width включает скролл */
  flex: 1 1 0;
  min-width: max-content;
  height: 34px;
  line-height: 34px;
  padding: 0 16px;
  border: none;
  border-radius: var(--r-pill);
  background: transparent;
  font-family: inherit;
  font-size: 14px;
  font-weight: 600;
  color: var(--text2);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  cursor: pointer;
  transition: color 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
.ldr-strip-tab:active { color: var(--text); }
.ldr-strip-tab.on { color: var(--accent-text); }
.ldr-strip-tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.ldr-strip-ind {
  position: absolute;
  /* скроллер И ЕСТЬ коробка линзы (капсула минус её 4px поля): линза
     заполняет его от края до края, не вставляясь внутрь второй раз */
  top: 0;
  bottom: 0;
  left: 0;
  width: 0;
  height: auto;
  /* content-box: JS пишет ширину в px, а удержание растит коробку полями */
  box-sizing: content-box;
  border-radius: var(--r-pill);
  background: var(--chrome-lens-fill);
  pointer-events: none;
  visibility: hidden;
  transition: left 300ms cubic-bezier(.32,.72,.35,1.1),
              width 300ms cubic-bezier(.32,.72,.35,1.1);
}
/* удержание: 6px на сторону, отрицательное поле держит рост симметричным
   вокруг вкладки, а обрезка скроллера не пускает линзу на рим капсулы */
.ldr-strip.lens-hold .ldr-strip-ind { padding-inline: 6px; margin-inline: -6px; }
@media (prefers-reduced-motion: reduce) {
  .ldr-strip-ind { transition: none; }
}

/* период: посадка на экране, сам контрол - .seg-group */
.ldr-period { margin: 10px var(--pad) 0; }
/* одна строка фиксированной высоты: описания разной длины не должны сдвигать
   тумблер и список при переключении вкладок */
.ldr-desc {
  margin: 8px var(--pad) 0;
  font-size: 12.5px;
  color: var(--text3);
  line-height: 18px;
  height: 18px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* ранг: колонка фиксированной ширины, цифры моноширинные */
.ldr-rank {
  width: 24px;
  flex-shrink: 0;
  text-align: right;
  font-size: 15px;
  font-weight: 600;
  color: var(--text2);
  font-variant-numeric: tabular-nums;
}
/* Тройка лидеров выделена РАЗМЕРОМ: аватар, счёт и имя на ступень крупнее.
   Цифра ранга при этом обычная, только на шаг светлее - носитель эмфазиса
   один, размерный, и цвет его не дублирует. */
.ldr-row.top .ldr-rank { color: var(--text); }

.ldr-ava {
  width: 40px;
  height: 40px;
  border-radius: 50%;
  flex-shrink: 0;
  font-size: 15px;
}
.ldr-row.top .ldr-ava { width: 48px; height: 48px; font-size: 18px; }
/* аноним: нейтральный тёмный круг, светлее любой из двух заливок ряда */
.ldr-ava.anon { background: rgba(255,255,255,0.08); }
.ldr-name {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.ldr-row.top .ldr-name { font-size: 17px; font-weight: 600; }
.ldr-name.anon { color: var(--text2); }
/* счёт - типографика, а не кнопка (tonal rules) */
.ldr-score {
  flex-shrink: 0;
  font-size: 17px;
  font-weight: 600;
  letter-spacing: -0.2px;
  color: var(--text);
  font-variant-numeric: tabular-nums;
}
.ldr-row.top .ldr-score { font-size: 19px; font-weight: 700; letter-spacing: -0.3px; }

/* Ряд ведёт в профиль только у публичного участника. У анонимного он никуда
   не ведёт, поэтому у него нет ни курсора, ни нажатия, ни ховера - экран не
   обещает перехода, которого нет. */
.ldr-row { cursor: default; }
.ldr-row:not(.is-open) { pointer-events: none; }
.ldr-row.is-open { cursor: pointer; }
/* разделитель начинается от имени: 24 ранг + 12 + 40 аватар + 12 */
.ldr-row::after { left: calc(var(--pad) + 88px); }
/* У тройки аватар 48px, поэтому её имя начинается на 8px правее. Двух РАЗНЫХ
   инсетов у одного списка быть не может, а тянуть общий до широкого - значит
   отодвинуть хайрлайн от имени во всех остальных строках. Поэтому внутри
   тройки хайрлайна нет вовсе: она читается как один блок, а её границу с
   остальным списком держит тональный шаг - ровно то, что предписывают
   tonal rules (границу рисует тон, а не штрих). */
.ldr-row.top::after { display: none; }
/* тональный шаг у тройки и у своей строки */
.ldr-row.top { background: var(--glass-bold); }
.ldr-row.me { background: var(--accent-soft); }
.ldr-row.me .ldr-name { color: var(--accent-text); }
/* Нажатие считается от СВОЕЙ заливки, а не общей белой подсветкой: тона
   островов непрозрачные, и одна подсветка на всех либо тонула бы под
   --glass-bold, либо стирала бы её. */
.ldr-row.is-open:active { background: rgba(255,255,255,0.06); }
.ldr-row.is-open.top:active { background: color-mix(in srgb, #fff 6%, var(--glass-bold)); }
.ldr-row.is-open.me:active { background: color-mix(in srgb, var(--accent-text) 14%, var(--accent-soft)); }
@media (hover: hover) {
  .ldr-row.is-open:hover { background: rgba(255,255,255,0.04); }
  .ldr-row.is-open.top:hover { background: color-mix(in srgb, #fff 4%, var(--glass-bold)); }
  .ldr-row.is-open.me:hover { background: color-mix(in srgb, var(--accent-text) 10%, var(--accent-soft)); }
}
/* Мягкая перезагрузка (смена борда или периода): список остаётся на месте и
   гаснет, а не схлопывается в спиннер. Переход живёт на БАЗОВОМ классе, иначе
   снятие .soft-loading даёт скачок вместо возврата. */
.ldr-list { transition: opacity 130ms cubic-bezier(0.4, 0, 0.2, 1); }
.ldr-list.soft-loading { opacity: 0.5; pointer-events: none; }
/* скелетные плейсхолдеры холодного входа занимают ту же геометрию, что ряд */
.ldr-rank-skel { width: 18px; height: 13px; border-radius: 5px; flex-shrink: 0; margin-left: 6px; }
.ldr-name-skel { display: block; height: 15px; }
.ldr-score-skel { width: 54px; height: 15px; border-radius: 5px; flex-shrink: 0; }
/* первые три скелета повторяют увеличенную геометрию тройки */
.ldr-row.top .ldr-name-skel { height: 17px; }
.ldr-row.top .ldr-score-skel { width: 62px; height: 17px; }
.ldr-note {
  padding: 16px var(--pad) 0;
  font-size: 12.5px;
  color: var(--text3);
  line-height: 1.45;
}

/* Закреплённая плашка своей позиции: стекло по канону §3 (плотный двойник -
   в общем блоке body.no-blur в конце файла). Центрируется через left/right +
   margin auto, как .art-cta: центрирующий transform спорил бы с масштабом
   нажатия. Токены -readable, потому что плашка несёт текст. */
.ldr-me-bar {
  position: fixed;
  left: 16px;
  right: 16px;
  bottom: calc(var(--nav-h) + 20px + var(--safe-bottom));
  width: auto;
  max-width: 500px;
  margin: 0 auto;
  height: 56px;
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 0 16px 0 14px;
  border-radius: var(--r-pill);
  background: var(--chrome-glass-fill-readable);
  backdrop-filter: var(--chrome-glass-filter-readable);
  -webkit-backdrop-filter: var(--chrome-glass-filter-readable);
  box-shadow: var(--chrome-glass-shadow);
  /* над градиентом .nav-fade (z 99), тот же слой, что у нав-острова */
  z-index: 100;
  cursor: default;
}
/* Доскроллить можно только к строке, которая реально есть на экране; тогда же
   плашка становится нажимаемой и берёт канонический жест стекла (рост + свет
   от glassPress), для чего в её список переходов добавляется scale. */
.ldr-me-bar.is-jump {
  cursor: pointer;
  transition: scale 120ms ease-out;
}
.ldr-me-bar .ldr-rank { width: auto; min-width: 24px; }
.ldr-me-out { flex-shrink: 0; font-size: 13px; color: var(--text2); }
.ldr-me-bar .ldr-name { flex: 1; font-size: 16px; color: var(--text); }
/* Скрим над плашкой. Одна непрерывная заливка, а не вторая полоса поверх
   .nav-fade: две наложенные почти закрыли бы низ экрана. Эта доходит до 45% и
   держит его до края, .nav-fade добавляет свой градиент снизу - суммарная
   кривая монотонна, без шва. Как и все краевые скримы, без backdrop-filter
   (правило v5.79). */
.ldr-me-fade {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  height: calc(var(--nav-h) + 108px + var(--safe-bottom));
  background: linear-gradient(to bottom, transparent,
    color-mix(in srgb, var(--bg) 45%, transparent) 68%);
  pointer-events: none;
  z-index: 98;
}
/* клавиатура владеет нижним краем - то же правило, что у нав-острова */
/* Пока собственная строка видна в списке, плашка не нужна: её плавающий
   дубль поверх других строк читается как глюк рендера. Появление/уход - ровно
   рецепт конденсирующейся пилюли заголовка .sub-bar-title (та же функция:
   плавающая копия элемента, уехавшего из вьюпорта): всегда смонтирована,
   класс-тоггл, те же 4px/0.96/150мс, только у нижнего края. */
.ldr-me-bar {
  transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1),
              transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
.ldr-me-fade { transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1); }
.ldr-me-bar.off {
  opacity: 0;
  pointer-events: none;
  transform: translateY(4px) scale(0.96);
}
.ldr-me-fade.off { opacity: 0; pointer-events: none; }
@media (prefers-reduced-motion: reduce) {
  .ldr-me-bar { transition: opacity 0.15s cubic-bezier(0.4, 0, 0.2, 1); }
  .ldr-me-bar.off { transform: none; }
}
body.kb-open .ldr-me-bar,
body.kb-open .ldr-me-fade { display: none; }

/* ── Скраббер архива: транзиентная хрома iOS-грамматики в твёрдом материале
   (blur на движущемся элементе поверх плиток запрещён - ADR-0020). ── */
.mda-scrub {
  position: fixed;
  top: calc(56px + var(--tg-top) + 8px);
  bottom: calc(var(--nav-h) + var(--safe-bottom) + 8px);
  right: 0;
  width: 24px;
  z-index: 30;
  touch-action: none;
  opacity: 0;
  pointer-events: none;
  transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
.mda-scrub.on { opacity: 1; pointer-events: auto; }
.mda-scrub-thumb {
  position: absolute;
  top: 0;
  right: 4px;
  width: 4px;
  height: 36px;
  border-radius: 999px;
  background: color-mix(in srgb, var(--text) 34%, transparent);
  transition: width 140ms cubic-bezier(0.4, 0, 0.2, 1),
              height 140ms cubic-bezier(0.4, 0, 0.2, 1),
              background 140ms cubic-bezier(0.4, 0, 0.2, 1);
  will-change: transform;
}
.mda-scrub.grab .mda-scrub-thumb {
  width: 10px;
  height: 44px;
  background: var(--bg-tint);
  border: 0.5px solid var(--glass-border);
  box-shadow: 0 1px 2px rgb(0 0 0 / 25%), 0 4px 12px rgb(0 0 0 / 12%);
}
.mda-scrub-pill {
  position: absolute;
  top: 0;
  right: 22px;
  transform: translateY(-50%);
  font-size: 12.5px;
  font-weight: 600;
  color: var(--text2);
  padding: 5px 12px;
  border-radius: var(--r-pill);
  background: var(--bg-tint);
  border: 0.5px solid var(--glass-border);
  box-shadow: 0 1px 2px rgb(0 0 0 / 25%), 0 4px 12px rgb(0 0 0 / 12%);
  white-space: nowrap;
  opacity: 0;
  pointer-events: none;
  transition: opacity 140ms cubic-bezier(0.4, 0, 0.2, 1);
  will-change: transform;
}
.mda-scrub.grab .mda-scrub-pill { opacity: 1; }
.mda-scrub-years { position: absolute; inset: 0 30px 0 auto; pointer-events: none; }
.mda-scrub-year {
  position: absolute;
  right: 0;
  transform: translateY(-50%);
  font-size: 11px;
  font-weight: 600;
  color: var(--text3);
  padding: 2px 8px;
  border-radius: 999px;
  background: var(--bg-tint);
  opacity: 0;
  transition: opacity 120ms cubic-bezier(0.4, 0, 0.2, 1);
}
.mda-scrub.grab .mda-scrub-year { opacity: 1; }
@media (prefers-reduced-motion: reduce) {
  .mda-scrub, .mda-scrub-thumb, .mda-scrub-pill, .mda-scrub-year { transition: none; }
}

/* ═══════════════════════════════════════════
   CHAT SHARING — settings list (collapsible)
═══════════════════════════════════════════ */
.shared-island { padding: 6px; }
.shared-empty {
  padding: 18px 8px 14px;
  text-align: center;
  color: var(--text3);
  font-size: 13px;
}
.shared-list {
  display: flex;
  flex-direction: column;
  gap: 4px;
}
.shared-row {
  border-radius: 14px;
  overflow: hidden;
  transition: background 0.18s;
}
.shared-row-head {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 12px 12px;
  cursor: pointer;
  user-select: none;
  transition: background 0.15s;
}
.shared-row-head:active { background: var(--glass); }
.shared-row-text {
  flex: 1;
  min-width: 0;
}
.shared-row-name {
  font-size: 14px;
  font-weight: 600;
  color: var(--text);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  line-height: 1.3;
}
.shared-row-sub {
  display: flex;
  align-items: center;
  gap: 5px;
  margin-top: 2px;
  font-size: 12px;
  color: var(--text3);
  line-height: 1.3;
}
.shared-row-dot { opacity: 0.5; }
.shared-row-chev {
  width: 18px; height: 18px;
  color: var(--text3);
  flex-shrink: 0;
  display: flex; align-items: center; justify-content: center;
  transition: transform 0.22s cubic-bezier(0.2, 0, 0, 1);
}
.shared-row-chev svg { width: 100%; height: 100%; }
.shared-row.expanded .shared-row-chev { transform: rotate(90deg); }
.shared-row-body {
  display: flex;
  /* narrow phones (360): the destructive label drops to its own full-width
     row instead of overflowing the island */
  flex-wrap: wrap;
  gap: 8px;
  padding: 4px 10px 12px;
  animation: shared-row-expand 0.22s cubic-bezier(0.2, 0, 0, 1);
}
@keyframes shared-row-expand {
  from { opacity: 0; transform: translateY(-4px); }
  to   { opacity: 1; transform: translateY(0); }
}
/* compact secondary / destructive pair inside the row (grammar: 40px pill, 14/600) */
.shared-action {
  flex: 1;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  min-height: 40px;
  padding: 0 14px;
  border-radius: var(--r-pill);
  border: none;
  font-size: 14px;
  font-weight: 600;
  font-family: inherit;
  cursor: pointer;
  white-space: nowrap;
  transition: transform 130ms cubic-bezier(0.4, 0, 0.2, 1), background 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
.shared-action svg { width: 16px; height: 16px; }
.shared-action:active { transform: scale(0.97); }
.shared-action:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.shared-action-primary {
  background: var(--accent-soft);
  color: var(--accent-text);
}
@media (hover: hover) {
  .shared-action-primary:hover { background: color-mix(in srgb, var(--accent-text) 14%, var(--accent-soft)); }
}
.shared-action-primary:active { background: color-mix(in srgb, var(--accent-text) 14%, var(--accent-soft)); }
.shared-action-danger {
  background: color-mix(in srgb, var(--destructive) 12%, var(--glass));
  color: var(--destructive);
  flex: 1 0 auto;
  padding: 0 16px;
}
@media (hover: hover) {
  .shared-action-danger:hover { background: color-mix(in srgb, var(--destructive) 18%, var(--glass)); }
}
.shared-action-danger:active { background: color-mix(in srgb, var(--destructive) 24%, var(--glass)); }
/* .text-btn carries the look */
.shared-load-more { margin-top: 4px; }

.share-link-row { margin-bottom: 10px; }
.share-link-label {
  font-size: 12px;
  font-weight: 500;
  color: var(--text2);
  margin-bottom: 6px;
  padding: 0 2px;
}
.share-link-chip {
  display: flex;
  align-items: center;
  gap: 10px;
  min-height: 48px;
  padding: 11px 12px;
  background: var(--glass-bold);
  border-radius: var(--r-btn);
  cursor: pointer;
  transition: transform 130ms cubic-bezier(0.4, 0, 0.2, 1), background 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
@media (hover: hover) {
  .share-link-chip:hover { background: var(--glass-border); }
}
.share-link-chip:active { transform: scale(0.98); background: var(--glass-border); }
/* ссылку не читают, а копируют: одна строка с эллипсисом,
   перенос посреди токена ломал ритм чипов */
.share-link-text {
  flex: 1;
  min-width: 0;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 12px;
  color: var(--text);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  line-height: 1.4;
}
.share-link-copy {
  width: 18px; height: 18px; flex-shrink: 0;
  color: var(--text3);
  display: flex; align-items: center; justify-content: center;
}
.share-link-copy svg { width: 100%; height: 100%; }

/* ── Public share preview ── */
.share-preview-body {
  flex: 1;
  display: flex;
  flex-direction: column;
  min-height: 0;
}
.share-msg-list {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  padding: 12px var(--pad) 16px;
}
.share-import-bar {
  border-top: 1px solid var(--sep);
  background: var(--bg-tint);
  padding: 14px var(--pad) calc(14px + var(--safe-bottom));
  display: flex;
  flex-direction: column;
  gap: 10px;
}
.share-import-info { padding: 0 2px; }
.share-import-title {
  font-size: 14px;
  font-weight: 600;
  color: var(--text);
  margin-bottom: 2px;
}
.share-import-desc {
  font-size: 12px;
  color: var(--text3);
  line-height: 1.45;
}
.share-import-btn { display: flex; }
.share-error {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 40px var(--pad);
  text-align: center;
}
.share-error-icon {
  width: 40px;
  height: 40px;
  margin-bottom: 14px;
  color: var(--text3);
  display: inline-flex;
  align-items: center;
  justify-content: center;
}
.share-error-icon svg { width: 100%; height: 100%; }
.share-error-text { color: var(--text2); font-size: 14px; line-height: 1.5; }

/* ═══════════════════════════════════════════
   GROUP ROOMS — мульти-юзер комнаты
═══════════════════════════════════════════ */
/* счётчик участников в списке чатов: иконка solid + opacity (не rgba-обводка,
   перекрытия штрихов дают пятна) */
.grp-count {
  display: inline-flex;
  align-items: center;
  gap: 3px;
  margin-left: 6px;
  font-size: 12px;
  color: var(--text3);
  font-variant-numeric: tabular-nums;
}
.grp-count svg { width: 12px; height: 12px; stroke: var(--text); opacity: 0.45; }
/* имя говорящего над чужим пузырём; тап - профиль (как аватарка) */
.grp-sender {
  font-size: 12px;
  font-weight: 600;
  color: var(--accent-text);
  margin-bottom: 2px;
  cursor: pointer;
  transition: opacity 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* the name is a text target: it dims under the finger, the iOS text-button press */
.grp-sender:active { opacity: 0.6; }
.grp-ava.tappable { cursor: pointer; }
/* лента комнаты: серия сообщений одного отправителя = один флекс-ряд
   (аватарный жёлоб + колонка пузырей). Аватарка в жёлобе sticky: при
   скролле плывёт вдоль нижнего края над композером и приземляется у
   последнего пузыря серии, как в Telegram. Жёлоб растянут на всю серию,
   так что вести аватарку покадрово не нужно - это делает компоситор.
   Инсет sticky ДОЛЖЕН быть меньше нижнего клиренса ленты (иначе аватарка
   всплывает над пузырём уже в покое) и больше нижней краевой полосы
   edge-band guard (64px, иначе тап по плывущей аватарке съедается). */
.message-row.grp-left { display: flex; align-items: flex-end; }
.grp-run { display: flex; align-items: stretch; gap: 8px; padding-left: 12px; }
.grp-run .message-row { padding-left: 0; }
/* 32px = the .act-icon chip size: the avatar and the first action button
   below it sit on one vertical line at the same diameter (owner, 2026-07-21) */
.grp-run-gutter {
  width: 32px;
  flex-shrink: 0;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
}
.grp-run-body { flex: 1; min-width: 0; }
.grp-ava {
  width: 32px;
  height: 32px;
  border-radius: 50%;
  flex-shrink: 0;
  object-fit: cover;
  font-size: 13px;
  font-weight: 700;
  margin-bottom: 6px;
  position: sticky;
  bottom: calc(112px + var(--safe-bottom));
}
/* клавиатура (браузер): композер поднят на kb-h - инсет едет вместе с ним */
body.kb-open .grp-ava { bottom: calc(112px + var(--kb-h, 0px)); }
/* чужое сообщение человека: слева, тон-степ выше персонажного; plain-текст
   с переносами, как .bubble.user */
.bubble.grp-peer { background: var(--glass-bold); white-space: pre-wrap; }
/* OOC: персонаж "этого не слышал" — приглушено, курсив, двойные скобки */
/* Out-of-context: the bubble surface stays SOLID (whole-bubble opacity let the
   wallpaper bleed through and the state became unreadable over an image, the
   Telegram idiom is muted CONTENT inside a normal bubble); the state lives in
   the text: gray + italic + the (( )) roleplay-OOC glyphs. */
.bubble.grp-ooc { color: var(--text2); font-style: italic; }
/* on the accent user bubble --text2's 55% white sinks into the fill: dim the
   bubble's own white instead (0.72 = the chip-stroke dim constant) */
.bubble.user.grp-ooc { color: rgba(255, 255, 255, 0.72); }
.bubble.grp-ooc span::before { content: "(( "; }
.bubble.grp-ooc span::after { content: " ))"; }
/* композер комнаты: ряд классов над строкой ввода, внутри общего стекла */
.grp-input-area { flex-direction: column; align-items: stretch; }
.grp-input-row { display: flex; align-items: flex-end; gap: 6px; }
.grp-class-row {
  display: flex;
  padding: 2px 2px 6px;
}
.grp-class-row .seg-group { flex: 1; min-width: 0; }
.grp-class-row .seg-btn { height: 32px; font-size: 12.5px; padding: 0 8px; }
.grp-class-row .seg-btn:first-child { border-top-left-radius: 16px; border-bottom-left-radius: 16px; }
.grp-class-row .seg-btn:last-child { border-top-right-radius: 16px; border-bottom-right-radius: 16px; }
.grp-class-row .seg-btn.active { border-radius: 16px; }
/* кик участника: строка тапабельна (профиль), поэтому у контрола
   тональная заливка в покое - иначе читается как часть строки
   (словарь .device-end: 34px круг, solid stroke + opacity) */
.grp-kick {
  position: relative;
  width: 34px; height: 34px; flex-shrink: 0;
  display: flex; align-items: center; justify-content: center;
  background: var(--glass-bold);
  border: none;
  border-radius: var(--r-pill);
  color: var(--text);
  cursor: pointer;
  transition: background 130ms cubic-bezier(0.4, 0, 0.2, 1), transform 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* the row around it opens the profile: the extension covers only the row's
   trailing gutter (44px), not the name */
.grp-kick::before { content: ''; position: absolute; inset: -5px; border-radius: inherit; }
.grp-kick svg { width: 16px; height: 16px; opacity: 0.7; }
@media (hover: hover) { .grp-kick:hover { background: var(--glass-border); } }
.grp-kick:active { transform: scale(0.92); background: var(--glass-border); }
.grp-kick:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* секционный лейбл ВНУТРИ модалки комнаты: контент шита флаш к краям,
   поэтому без x-инсета .section-lbl (тот выравнен под островные экраны) */
.grp-modal-lbl {
  padding: 20px 2px 8px;
  font-size: 13px;
  line-height: 18px;
  font-weight: 600;
  letter-spacing: 0.4px;
  text-transform: uppercase;
  color: var(--text2);
}
.grp-modal-hint {
  font-size: 12px;
  color: var(--text3);
  line-height: 1.4;
  padding: 0 2px;
}

/* композер комнаты выше одиночного (ряд классов): больший нижний клиренс.
   Клиренс - через ::after, как у базовой ленты (конечный padding скролл-
   контейнера WebKit выкидывает из scrollable overflow); заодно это
   единственный источник низа - раньше padding-bottom 150 суммировался с
   базовым ::after 105 и давал 255px пустоты на браузерных движках.
   Ряды, пилюли и раны - flex-дети дневной группы .day-group (сама она
   flex-колонка внутри flex-колонки .messages-area), так что маргины пилюль
   не схлопываются без спец-правил */
.grp-msgs {
  scroll-padding-bottom: calc(155px + var(--safe-bottom));
}
.grp-msgs::after {
  height: calc(150px + var(--safe-bottom));
}
body.kb-open .grp-msgs::after { height: calc(145px + var(--kb-h, 0px)); }
/* системная пилюля: действие тихим тоном, имя участника - основным;
   имя с sender_user_id тапабельно (профиль) */
/* the room's system line shares the date pill's material but not its box:
   inline-flex would split "Alex" and " joined" into flex items and eat the
   space between them, and a long line must be allowed to wrap */
.grp-sys-pill {
  display: inline-block;
  height: auto;
  line-height: 1.35;
  padding: 3px 9px;
  white-space: normal;
  text-align: center;
  color: var(--text2);
  font-weight: 500;
}
.grp-sys-pill b.tappable { cursor: pointer; }
.grp-sys-pill b { color: var(--text); font-weight: 600; }
/* чипы комнаты на join-экране. Заливка НЕ токеном: чип лежит на острове
   --glass, а токены соседних тонов на нём сливаются (та же болезнь, что у
   кнопки сценария) - полупрозрачный белый слой виден на любом оттенке темы.
   Премиальная модель (цена > 1) - тональный акцент, не сплошной (бюджет CTA) */
.grp-chip-row {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
  margin-top: 12px;
}
.grp-room-chip {
  display: inline-block;
  padding: 4px 11px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.08);
  color: var(--text2);
  font-size: 12px;
  font-weight: 600;
}
.grp-room-chip.prem {
  background: var(--accent-soft);
  color: var(--accent-text);
}

/* ═══════════════════════════════════════════
   DESKTOP SIDEBAR — hidden by default (mobile-first)
   Revealed only inside the >=1000px media query (below).
═══════════════════════════════════════════ */
/* Off-screen by default (mobile). It slides in when html.layout-desktop is set,
   so the mobile->desktop switch reads as one motion instead of a hard pop. The
   full visual styling lives in the html.layout-desktop block below. */
.desktop-sidebar {
  display: flex;
  flex-direction: column;
  position: fixed;
  top: 0; left: 0; bottom: 0;
  width: var(--sidebar-w);
  /* solid panel always, so during the slide it never flashes the page through */
  background: var(--bg-tint);
  padding: 16px 14px;
  transform: translateX(-100%);
  pointer-events: none;
  overflow: hidden;
  /* above every community overlay (modals ratchet from 1000 capped at 3800,
     badge 3000), matching the desktop sidebar below */
  z-index: 4000;
  transition: transform 0.34s cubic-bezier(0.32,0.72,0,1);
}
/* The children's full sizing lives in the html.layout-desktop block, so while
   that class is absent (mobile, or mid-slide on the way out) they'd render
   unsized — hide them so only the clean panel slides. They re-appear (sized)
   the moment the desktop layout is active. */
.desktop-sidebar > * { opacity: 0; }
html.layout-desktop .desktop-sidebar > * { opacity: 1; transition: opacity 0.2s ease 0.06s; }

/* ═══════════════════════════════════════════
   ГАМБА (краш + мины на бонусные кредиты)
   Бэкенд web/gamba_api.py, таблица gamba; блоки в app.js
   (грепать gb- / gamba). Внутренности экрана тональные, гласс тут ровно один -
   полоса переключения игр, и она берёт материал общего .view-strip.
═══════════════════════════════════════════ */
/* две равноправные игры = переключение панели, а не значение: .view-strip */
.gb-tab-strip { margin: 0 0 12px; }
.gb-island {
  margin: 0;
  padding: 14px;
  display: flex;
  flex-direction: column;
  gap: 12px;
  /* бюджет вертикали вокруг игрового поля (шапка+табы+хром острова+навбар):
     из него поле/канвас считают свой размер, чтобы на низких экранах весь
     раунд был виден без скролла */
  --gb-chrome: calc(390px + var(--tg-top) + var(--safe-bottom));
}
/* обе вкладки въезжают со стороны своего таба (150мс, стандартная кривая) */
.gb-tab-in-l { animation: gbTabIn 0.15s cubic-bezier(0.4, 0, 0.2, 1); --gb-dx: -14px; }
.gb-tab-in-r { animation: gbTabIn 0.15s cubic-bezier(0.4, 0, 0.2, 1); --gb-dx: 14px; }
@keyframes gbTabIn { from { opacity: 0; transform: translateX(var(--gb-dx)); } }
.gb-loading { color: var(--text3); text-align: center; margin-top: 40px; }
/* шапка: заголовок слева, баланс/дневной лимит справа - без отдельной
   строки метрик экономится вертикаль для игрового поля */
.gb-head {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 12px;
  padding: 8px 0 14px;
}
.gb-head .large-title { padding: 0; }
.gb-head-meta {
  min-width: 0;
  padding-bottom: 4px;
  text-align: right;
  font-variant-numeric: tabular-nums;
}
.gb-meta-bal { font-size: 16px; font-weight: 700; }
.gb-meta-lbl { font-size: 12px; font-weight: 400; color: var(--text3); }
.gb-meta-day { margin-top: 1px; font-size: 11px; color: var(--text3); }

.gb-terms { gap: 14px; }
.gb-terms-title { font-size: 18px; font-weight: 700; margin-bottom: 2px; }
.gb-terms-txt { font-size: 14px; color: var(--text2); line-height: 1.45; }

/* строка правила: голая 24px иконка + заголовок и пояснение (M3 list row).
   Иконки solid stroke, приглушение - opacity на контейнере (не alpha в stroke) */
.gb-rule { display: flex; gap: 12px; align-items: flex-start; }
.gb-rule-ico { flex: 0 0 24px; width: 24px; height: 24px; margin-top: 1px; color: var(--text); opacity: 0.6; }
.gb-rule-ico svg { width: 100%; height: 100%; display: block; }
.gb-rule-txt { min-width: 0; }
.gb-rule-t { font-size: 15px; font-weight: 600; margin-bottom: 2px; }
.gb-rule-b { font-size: 13px; color: var(--text2); line-height: 1.45; }

/* правила доступны и после принятия; там же самоблокировка */
.gb-rules-link,
.gb-selfban-link {
  position: relative;
  border: none;
  background: none;
  margin: 2px auto 0;
  padding: 8px 16px;
  font-size: 13px;
  font-family: inherit;
  cursor: pointer;
  color: var(--text3);
  transition: opacity 130ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* footnote links: 44px hit without growing the row */
.gb-rules-link::before,
.gb-selfban-link::before { content: ''; position: absolute; inset: -7px 0; }
.gb-rules-link:active,
.gb-selfban-link:active { opacity: 0.6; }
.gb-rules-link:focus-visible,
.gb-selfban-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.gb-selfban-link { color: color-mix(in srgb, var(--destructive) 70%, var(--text2)); }
.gb-selfban { display: flex; flex-direction: column; gap: 10px; }
/* .gb-cta несёт flex:1 для ряда ставки; в колонке правил это схлопывает высоту */
.gb-selfban > .gb-cta { flex: none; width: 100%; }
/* сег стоит на острове (--glass): неактивной кнопке нужен свой тон,
   иначе она сливается с фоном (на --bg у вкладок такой проблемы нет) */
.gb-selfban-seg .seg-btn:not(.active) { background: var(--bg-tint); }
.gb-selfban-seg { margin: 0; }
.gb-cta.danger { background: var(--destructive); }
.gb-blocked { gap: 6px; }

/* верхняя полоса острова, одинаковая у обеих игр: конфиг слева (автостоп /
   число мин, в игре - множитель), история справа. Фиксированная высота, чтобы
   морф конфиг-статус и первый чип истории не двигали поле и CTA */
.gb-strip {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  min-height: 44px;
}
.gb-ctl {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  gap: 8px;
}
.gb-ctl-lbl { font-size: 13px; color: var(--text2); }
.gb-hist {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  align-items: center;
  gap: 6px;
  overflow: hidden;
  height: 26px;
  /* хвост истории растворяется у правого края вместо жёсткого обреза */
  -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 20px), transparent);
  mask-image: linear-gradient(90deg, #000 calc(100% - 20px), transparent);
}
.gb-chip {
  flex: 0 0 auto;
  padding: 3px 9px;
  border-radius: var(--r-pill);
  background: var(--bg-tint);
  font-size: 12px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  color: var(--text2);
}
.gb-chip.hi { color: var(--success); }
.gb-chip.lo { color: color-mix(in srgb, var(--destructive) 70%, var(--text2)); }
.gb-hist-empty { font-size: 12px; color: var(--text3); padding: 0 2px; white-space: nowrap; }

/* высота канваса дышит: на низких экранах ужимается, чтобы ставка и CTA
   оставались на экране без скролла (см. --gb-chrome) */
.gb-stage {
  position: relative;
  height: clamp(180px, calc(100vh - var(--gb-chrome)), 240px);
  height: clamp(180px, calc(100dvh - var(--gb-chrome)), 240px);
  background: var(--bg-tint);
  border-radius: 12px;
  overflow: hidden;
}
.gb-stage canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
.gb-stage-ov {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 2px;
  pointer-events: none;
}
.gb-mult {
  font-size: 44px;
  font-weight: 800;
  letter-spacing: -1px;
  font-variant-numeric: tabular-nums;
  color: var(--text);
  transition: color 0.15s cubic-bezier(0.4,0,0.2,1);
}
.gb-mult.dim { color: var(--text3); }
.gb-mult.bust { color: var(--destructive); }
.gb-mult.win { color: var(--success); }
/* max-width: длинная строка автозабора переносится, не наезжая на подписи оси
   справа (оверлей абсолютный, перенос ничего не сдвигает) */
.gb-status { font-size: 13px; color: var(--text3); min-height: 18px; font-variant-numeric: tabular-nums; max-width: 78%; text-align: center; }

.gb-bet-row { display: flex; gap: 8px; }
/* one tone ABOVE its island, not the page tone (a control darker than its
   surface reads sunken) */
.gb-step { display: flex; align-items: center; background: var(--glass-bold); border-radius: var(--r-pill); }
.gb-step button {
  width: 38px;
  height: 48px;
  border: none;
  background: none;
  cursor: pointer;
  font-size: 20px;
  font-family: inherit;
  color: var(--text2);
  transition: transform 0.12s cubic-bezier(0.4,0,0.2,1);
}
.gb-step button:active:not(:disabled) { transform: scale(0.92); }
.gb-step button:disabled { opacity: 0.35; cursor: default; }
.gb-step-v { min-width: 46px; text-align: center; font-weight: 700; font-variant-numeric: tabular-nums; }
/* компактный степпер верхней полосы (автозабор краша) */
.gb-step.sm button { width: 36px; height: 40px; font-size: 17px; }
.gb-step.sm .gb-step-v { min-width: 48px; font-size: 14px; }
/* значение автозабора - голый инпут в ячейке степпера: ввод любого множителя
   с клавиатуры, коммит по blur/Enter с клампом в потолок. Префикс "x" сидит
   внутри value (парсер его отбрасывает) - так "x1.5" центрируется целиком */
.gb-auto-in {
  width: 52px;
  border: none;
  background: none;
  padding: 0;
  font: inherit;
  text-align: center;
  color: var(--text);
  caret-color: var(--accent);
  /* шортхэнд font сбивает variant и вес из .gb-step-v - возвращаем */
  font-variant-numeric: tabular-nums;
  font-weight: 700;
}
/* в полёте значение остаётся читаемым (это заряженный стоп, не мёртвый контрол) */
.gb-auto-in:disabled { color: var(--text); -webkit-text-fill-color: var(--text); opacity: 1; }
/* молния-скип: всегда в ряду (раскладка не прыгает), оживает тональным
   акцентом только в полёте с автозабором - тап доматывает раунд к развязке */
.gb-inst {
  flex: 0 0 44px;
  height: 48px;
  border: none;
  border-radius: var(--r-pill);
  background: var(--bg-tint);
  color: var(--text2);
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  transition: transform 130ms cubic-bezier(0.4,0,0.2,1), background 130ms cubic-bezier(0.4,0,0.2,1), color 130ms cubic-bezier(0.4,0,0.2,1);
}
.gb-inst svg { width: 22px; height: 22px; }
.gb-inst.on { background: var(--accent-soft); color: var(--accent-text); }
.gb-inst:active:not(:disabled) { transform: scale(0.92); }
.gb-inst:disabled { opacity: 0.32; cursor: default; }
.gb-inst:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.gb-cta {
  flex: 1;
  height: 48px;
  padding: 0 12px;
  white-space: nowrap;
  border: none;
  /* кнопки в приложении - пилюли (ios26), тональный только фон островов */
  border-radius: var(--r-pill);
  cursor: pointer;
  background: var(--accent);
  color: #fff;
  font-size: 15px;
  font-weight: 600;
  font-family: inherit;
  font-variant-numeric: tabular-nums;
  transition: transform 130ms cubic-bezier(0.4,0,0.2,1), background 130ms cubic-bezier(0.4,0,0.2,1), color 130ms cubic-bezier(0.4,0,0.2,1);
}
/* вне .gb-bet-row (условия, старт мин) кнопка занимает всю ширину острова */
.gb-island > .gb-cta { flex: none; width: 100%; margin-top: 2px; }
@media (hover: hover) {
  .gb-cta:hover:not(:disabled) { background: color-mix(in srgb, var(--accent) 88%, #fff); }
  .gb-cta.cash:hover:not(:disabled) { background: color-mix(in srgb, var(--success) 88%, #fff); }
  .gb-cta.danger:hover:not(:disabled) { background: color-mix(in srgb, var(--destructive) 88%, #fff); }
}
.gb-cta:active:not(:disabled) { transform: scale(0.97); }
.gb-cta:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.gb-cta.cash { background: var(--success); }
.gb-cta:disabled { background: var(--glass-bold); color: var(--text2); cursor: default; }

.gb-seg-mini .seg-btn { flex: 0 0 auto; padding: 0 16px; }
/* тот же случай, что .gb-selfban-seg: сег внутри острова */
.gb-seg-mini .seg-btn:not(.active) { background: var(--bg-tint); }
/* играющий статус занимает левый слот полосы вместо конфига (.gb-ctl):
   держит ширину контента, чтобы "следующая" не наезжала на историю справа -
   ужимается и растворяет хвост сама история (flex:1 + mask), как в .gb-ctl */
.gb-mines-status { display: flex; align-items: baseline; gap: 10px; flex: 0 0 auto; }
.gb-mines-mult { font-size: 22px; font-weight: 800; letter-spacing: -0.5px; font-variant-numeric: tabular-nums; }
.gb-mines-next { font-size: 12px; color: var(--text3); font-variant-numeric: tabular-nums; white-space: nowrap; }

/* ширина поля: не шире острова и 440px (десктопная 720px-колонка), а на
   низких экранах ограничена высотой (--gb-chrome), чтобы поле и CTA были
   видны одновременно; пол в 212px держит плитки тапабельными */
.gb-grid {
  position: relative;
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  gap: 8px;
  width: min(100%, 440px, max(212px, calc(100vh - var(--gb-chrome))));
  width: min(100%, 440px, max(212px, calc(100dvh - var(--gb-chrome))));
  margin: 0 auto;
}
.gb-tile {
  aspect-ratio: 1;
  border: none;
  border-radius: var(--r-btn);
  cursor: pointer;
  padding: 0;
  background: var(--glass-bold);
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--accent-text);
  transition: transform 130ms cubic-bezier(0.4,0,0.2,1), background 0.15s, opacity 0.2s;
}
.gb-tile:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.gb-grid.idle .gb-tile { opacity: 0.45; }
.gb-tile:active:not(:disabled):not(.open) { transform: scale(0.92); }
.gb-tile:disabled { cursor: default; }
.gb-tile svg { width: 46%; height: 46%; }
.gb-tile.open svg { animation: gbPop 0.18s cubic-bezier(0.34, 1.56, 0.64, 1); }
.gb-tile.open.gem { background: color-mix(in srgb, var(--accent) 16%, var(--glass-bold)); }
.gb-tile.open.boom { background: color-mix(in srgb, var(--destructive) 28%, var(--glass-bold)); color: var(--text); }
.gb-tile.open.boom-src { background: color-mix(in srgb, var(--destructive) 52%, var(--glass-bold)); }
@keyframes gbPop { from { transform: scale(0.3); } to { transform: scale(1); } }
.gb-grid.shake { animation: gbShake 0.4s cubic-bezier(0.36, 0.07, 0.19, 0.97); }
@keyframes gbShake {
  10%, 90% { transform: translateX(-1px); }
  20%, 80% { transform: translateX(2px); }
  30%, 50%, 70% { transform: translateX(-3px); }
  40%, 60% { transform: translateX(3px); }
}

.gb-float {
  position: absolute;
  left: 50%;
  top: 20%;
  transform: translate(-50%, 0);
  font-size: 26px;
  font-weight: 800;
  color: var(--success);
  font-variant-numeric: tabular-nums;
  pointer-events: none;
  z-index: 5;
  text-shadow: 0 2px 12px rgb(0 0 0 / 50%);
  animation: gbFloatUp 1.1s cubic-bezier(0.2, 0.6, 0.2, 1) forwards;
}
@keyframes gbFloatUp {
  from { opacity: 0; transform: translate(-50%, 14px); }
  20% { opacity: 1; }
  to { opacity: 0; transform: translate(-50%, -34px); }
}

/* совсем узкие экраны (320): ряд ставки краша несёт три контрола - ужимаем */
@media (max-width: 340px) {
  .gb-step button { width: 34px; }
  .gb-inst { flex-basis: 38px; }
  .gb-cta { font-size: 15px; }
}

@media (prefers-reduced-motion: reduce) {
  .gb-tile.open svg, .gb-grid.shake, .gb-tab-in-l, .gb-tab-in-r { animation: none; }
  .gb-float { animation: gbFloatUp 0.5s ease forwards; }
}

/* ═══════════════════════════════════════════
   DESKTOP LAYOUT
   Gated on html.layout-desktop (toggled by JS from viewport width OR a manual
   override in Settings), NOT a media query — so a tablet user can force the
   mobile layout on a wide screen and vice versa. Uses CSS nesting.
═══════════════════════════════════════════ */
html.layout-desktop {

  /* Drop the mobile bottom nav immediately when the desktop sidebar takes over. */
  .nav-fade { display: none !important; }
  .bottom-nav { transform: translate(-50%, calc(100% + 24px)); opacity: 0; pointer-events: none; }

  /* Профиль и арт на десктопе не имеют верхней плашки (навигация - левый
     сайдбар), поэтому мобильный top-fade (градиент+блюр сверху) только
     затемняет верх контента ложной "тенью". Убираем его на этих экранах. */
  .view-content:has(> .art-screen) > .top-fade,
  .view-content:has(> .profile-scroll) > .top-fade { display: none; }

  /* Floating glass sidebar island */
  /* Flush tonal panel (macOS / M3 drawer idiom). Nothing scrolls under it,
     so no glass: opaque surface one tone step above the bg, no shadow, no rim. */
  .desktop-sidebar {
    display: flex;
    flex-direction: column;
    position: fixed;
    top: 0;
    left: 0;
    bottom: 0;
    width: var(--sidebar-w);
    padding: 16px 14px;
    /* above EVERY community overlay (modals ratchet from 1000 and cap at
       3800 in _nextCommunityZIndex, badge 3000) so nav stays clickable and
       crisp while their full-bleed dim+blur covers the rest of the screen.
       Image lightbox (5000) and banned screen (9999) still cover it. */
    z-index: 4000;
    background: var(--bg-tint);
    border: none;
    border-radius: 0;
    overflow: hidden;
    transform: translateX(0);
    pointer-events: auto;
    transition: width 0.18s cubic-bezier(0.4,0,0.2,1), padding 0.18s cubic-bezier(0.4,0,0.2,1), transform 0.34s cubic-bezier(0.32,0.72,0,1);
  }

  /* Logo + collapse toggle row */
  .ds-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 4px 6px 14px; }
  /* favicon glyph as a mask so the logo tints with the adaptive theme */
  /* same tone as the inactive nav icons so it reads as chrome, not a CTA */
  .ds-logo-img {
    width: 26px; height: 26px; flex: none;
    background: var(--text2);
    -webkit-mask: url('/static/favicon.png') center / contain no-repeat;
    mask: url('/static/favicon.png') center / contain no-repeat;
  }
  .ds-collapse {
    flex: none; width: 28px; height: 28px; border: none; background: transparent;
    border-radius: var(--r-pill); color: var(--text3); cursor: pointer;
    display: flex; align-items: center; justify-content: center;
    transition: background 0.14s cubic-bezier(0.4,0,0.2,1), color 0.14s cubic-bezier(0.4,0,0.2,1);
  }
  .ds-collapse:hover { background: var(--glass); color: var(--text); }
  .ds-collapse svg { width: 17px; height: 17px; transition: transform 0.18s cubic-bezier(0.4,0,0.2,1); }

  /* Section label + nav items (spacious / typographic direction) */
  .ds-nav { display: flex; flex-direction: column; gap: 2px; }
  .ds-item {
    display: flex; align-items: center; gap: 12px;
    padding: 11px 12px; border: none; background: transparent;
    border-radius: var(--r-btn); color: var(--text2);
    font-size: 14px; font-weight: 500; font-family: inherit;
    cursor: pointer; text-align: left;
    transition: background 0.14s cubic-bezier(0.4,0,0.2,1), color 0.14s cubic-bezier(0.4,0,0.2,1), padding 0.18s cubic-bezier(0.4,0,0.2,1);
  }
  .ds-item:hover { background: var(--glass); color: var(--text); }
  .ds-item.active { background: var(--accent-soft); color: var(--accent-text); }
  .ds-item.active .ds-ic { color: var(--accent-text); }
  .ds-ic { display: flex; width: 20px; height: 20px; flex: none; }
  .ds-ic svg { width: 20px; height: 20px; }
  .ds-label { white-space: nowrap; overflow: hidden; }
  .ds-spacer { flex: 1; min-height: 12px; }

  /* User card: avatar + name + subscription tier (no credits) */
  .ds-user {
    display: flex; align-items: center; gap: 10px;
    padding: 10px; border: 1px solid var(--glass-border);
    background: color-mix(in srgb, var(--text) 4%, transparent);
    border-radius: var(--r-card); cursor: pointer; font-family: inherit;
    transition: background 0.14s cubic-bezier(0.4,0,0.2,1), padding 0.18s cubic-bezier(0.4,0,0.2,1);
  }
  .ds-user:hover { background: color-mix(in srgb, var(--text) 8%, transparent); }
  .ds-ava {
    width: 38px; height: 38px; border-radius: var(--r-pill); flex: none;
    display: flex; align-items: center; justify-content: center;
    object-fit: cover;
    color: #fff; font-weight: 600; font-size: 16px;
  }
  .ds-uinfo { display: flex; flex-direction: column; min-width: 0; gap: 3px; align-items: flex-start; }
  .ds-uname {
    font-size: 13px; font-weight: 600; color: var(--text);
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 130px;
  }
  .ds-tier {
    display: inline-flex; align-items: center; gap: 4px;
    font-size: 11px; font-weight: 600; line-height: 1; text-transform: capitalize;
    color: var(--text2); background: var(--glass-bold);
    padding: 3px 8px; border-radius: var(--r-pill);
  }
  .ds-tier .tier-ic { width: 11px; height: 11px; }
  .ds-tier.paid { color: var(--accent-text); background: var(--accent-soft); }

  /* Drag-to-resize handle on the right edge */
  .ds-resize {
    position: absolute; top: 0; right: 0; bottom: 0; width: 7px;
    cursor: col-resize; touch-action: none;
  }
  .ds-resize::after {
    content: ''; position: absolute; top: 50%; right: 2px; transform: translateY(-50%);
    width: 3px; height: 36px; border-radius: 999px; background: transparent;
    transition: background 0.14s cubic-bezier(0.4,0,0.2,1);
  }
  .ds-resize:hover::after { background: var(--glass-bold); }

  /* ── Collapsed rail (icon-only) ──
     Animate the collapse as ONE smooth motion: the sidebar width transitions
     (above) and the text fades + clips (the sidebar's overflow:hidden trims
     labels as the width shrinks) instead of snapping out via display:none.
     Icons stay put at the left, so nothing jumps. */
  .ds-label, .ds-uinfo {
    transition: opacity 0.12s cubic-bezier(0.4,0,0.2,1);
  }
  .ds-label { min-width: 0; }
  .ds-seclbl {
    max-height: 30px;
    transition: max-height 0.18s cubic-bezier(0.4,0,0.2,1),
                opacity 0.12s cubic-bezier(0.4,0,0.2,1),
                padding 0.18s cubic-bezier(0.4,0,0.2,1);
  }
  .desktop-sidebar.collapsed .ds-label,
  .desktop-sidebar.collapsed .ds-uinfo { opacity: 0; }
  .desktop-sidebar.collapsed .ds-seclbl {
    max-height: 0; opacity: 0; padding-top: 0; padding-bottom: 0;
  }
  .desktop-sidebar.collapsed .ds-collapse svg { transform: rotate(180deg); }
  /* tighten paddings so icons + avatar center cleanly in the 64px rail */
  .desktop-sidebar.collapsed { padding-left: 8px; padding-right: 8px; }
  .desktop-sidebar.collapsed .ds-item { padding-left: 14px; padding-right: 14px; }
  .desktop-sidebar.collapsed .ds-user { padding: 5px; }
  .desktop-sidebar.collapsed .ds-top { flex-direction: column; justify-content: center; gap: 10px; }

  /* Freeze layout transitions while actively dragging the resize handle */
  & body.sb-resizing .desktop-sidebar,
  & body.sb-resizing .view-content,
  & body.sb-resizing .input-area,
  & body.sb-resizing .chat-search-panel,
  & body.sb-resizing .chat-search-nav-buttons,
  & body.sb-resizing .suggestions-row,
  & body.sb-resizing .modal-overlay,
  & body.sb-resizing .input-fade { transition: none !important; }

  /* Modal sheets are viewport-fixed while the sidebar (z-index 4000) draws
     over them: center them in the free column right of the sidebar, like the
     content. Gated on :has like .view-content — no sidebar, no offset. */
  .screen:has(.desktop-sidebar) .modal-overlay {
    padding-left: calc(var(--sidebar-w) + var(--sidebar-gap) * 2 + 10px);
    transition: padding-left 0.18s cubic-bezier(0.4, 0, 0.2, 1);
  }

  /* Media viewer on desktop: the scrim covers the whole screen (the sidebar,
     z 4000, draws over the viewer's z 300); slides center in the free column
     via the JS stage rect, so only the floating CHROME needs the offset. */
  .screen:has(.desktop-sidebar) .lb-topbar {
    left: calc(var(--sidebar-w) + var(--sidebar-gap) * 2 + 10px);
  }
  .screen:has(.desktop-sidebar) .lb-nav-prev {
    left: calc(var(--sidebar-w) + var(--sidebar-gap) * 2 + 10px);
  }
  .screen:has(.desktop-sidebar) .lb-bottombar {
    left: calc(50% + (var(--sidebar-w) + var(--sidebar-gap) * 2) / 2);
  }
  .screen:has(.desktop-sidebar) .lb-fade {
    left: calc(var(--sidebar-w) + var(--sidebar-gap) * 2);
  }

  /* Offset content to the right of the sidebar — ONLY when the sidebar is
     actually present. Its v-if drops it from the DOM on the login screen and
     the standalone share view; without :has() those would get a phantom left
     gutter and shifted content. */
  .screen:has(.desktop-sidebar) .view-content {
    padding-left: calc(var(--sidebar-w) + var(--sidebar-gap) * 2);
    /* keep the base view enter animation AND animate the sidebar offset */
    transition: opacity 150ms cubic-bezier(0.4,0,0.2,1), transform 150ms cubic-bezier(0.4,0,0.2,1), padding-left 0.18s cubic-bezier(0.4,0,0.2,1);
  }

  /* The header isn't a scroller — constrain it to the centered ~720px column so
     it lines up with the body content below. */
  .screen:has(.desktop-sidebar) .view-content > .header,
  .screen:has(.desktop-sidebar) .view-content > .chat-glass-bar,
  .screen:has(.desktop-sidebar) .view-content > .rename-bar,
  .screen:has(.desktop-sidebar) .view-content > .sub-bar {
    width: 100%;
    max-width: 720px;
    margin-left: auto;
    margin-right: auto;
  }

  /* Absolutely positioned bars/chips sit in the view-content PADDING box,
     which includes the sidebar gutter (padding-left). Inset them past the
     gutter so they line up with the centered 720px content column. (The
     sticky day header lives INSIDE the scroller and needs no inset.) */
  .screen:has(.desktop-sidebar) .view-content > .chat-glass-bar,
  .screen:has(.desktop-sidebar) .view-content > .sub-bar,
  .screen:has(.desktop-sidebar) .view-content > .rename-bar,
  .screen:has(.desktop-sidebar) .view-content > .header.hdr-overlay {
    left: calc(var(--sidebar-w) + var(--sidebar-gap) * 2);
    right: 0;
  }
  .screen:has(.desktop-sidebar) .view-content > .float-actions {
    /* +var(--pad): строки контента (список/поиск/папки) инсетятся на var(--pad)
       внутри 720px-колонки, поэтому чипы должны совпадать с их правым краем,
       а не с краем самой колонки (иначе "+" торчит на 16px правее) */
    right: calc(max(10px, calc((100% - var(--sidebar-w) - var(--sidebar-gap) * 2 - 720px) / 2)) + var(--pad));
  }
  .screen:has(.desktop-sidebar) .view-content > .sel-back {
    left: calc(var(--sidebar-w) + var(--sidebar-gap) * 2 + max(10px, (100% - var(--sidebar-w) - var(--sidebar-gap) * 2 - 720px) / 2));
  }
  /* the collapsing chat header hugs the same 720px content column as the list */
  .screen:has(.desktop-sidebar) .view-content > .chat-hdr {
    left: calc(var(--sidebar-w) + var(--sidebar-gap) * 2 + max(10px, (100% - var(--sidebar-w) - var(--sidebar-gap) * 2 - 720px) / 2));
    right: max(10px, calc((100% - var(--sidebar-w) - var(--sidebar-gap) * 2 - 720px) / 2));
  }
  /* The toast stack is fixed and self-centers with translateX(-50%); the chrome
     offset above (left/right) would left-align it instead. Shift its anchor to
     the middle of the content zone (past the sidebar gutter) so it stays
     centered over the content, not the full viewport. */
  .screen:has(.desktop-sidebar) .view-content > .toast-stack {
    left: calc(50% + (var(--sidebar-w) + var(--sidebar-gap) * 2) / 2);
  }
  /* Плашка своей позиции в лидербордах - тот же случай: fixed-хрома, которую
     надо посадить в свободную колонку справа от сайдбара. Навбар на десктопе
     убран вместе со своим скримом, поэтому плашка опускается к нижнему краю,
     а скрим над ней остаётся один. */
  /* left/right = ровно та же коробка, что у контента (.view-content минус
     жёлоб сайдбара), поэтому margin auto центрирует плашку по колонке 720 */
  .screen:has(.desktop-sidebar) .view-content > .ldr-me-bar {
    left: calc(var(--sidebar-w) + var(--sidebar-gap) * 2);
    right: 0;
  }
  .ldr-me-bar { bottom: calc(20px + var(--safe-bottom)); }
  .ldr-me-fade { height: calc(108px + var(--safe-bottom)); }
  /* нав-остров убран, но --nav-h остаётся 64: без этого хвост экрана отбивал
     бы место под несуществующую хрому и под плашкой зияла бы дыра */
  .view-content > .ldr-screen { padding-bottom: 28px; }

  /* The scroll bodies stay FULL WIDTH and center their content with horizontal
     padding instead of max-width + margin. This way the native scroller spans
     the whole area, so the mouse wheel works (with the browser's native scroll
     smoothing) anywhere in the view — not just over the 720px column. Covers
     chats (.chat-list), art/shop (.scroll-area), profile (.profile-scroll),
     settings (.settings-screen), chat/share (.messages-area). */
  .screen:has(.desktop-sidebar) .view-content > .folder-track > .chat-list,
  .screen:has(.desktop-sidebar) .view-content > .scroll-area:not(.art-screen),
  .screen:has(.desktop-sidebar) .view-content > .settings-screen,
  .screen:has(.desktop-sidebar) .view-content > .messages-area {
    /* !important so the centered column wins over the inline padding that the
       shop/art .scroll-area set (style="padding:16px"); without it those two
       views stretch full width while the others stay in the 720px column. */
    padding-left: max(0px, calc((100% - 720px) / 2)) !important;
    padding-right: max(0px, calc((100% - 720px) / 2)) !important;
  }
  /* The shop and gamba put their content FLUSH inside the scroll-area and get
     the 16px side inset purely from that scroll-area's inline padding:16px.
     The centering override above replaces that padding, so on desktop their
     content sat 16px further left than every other sub-screen (whose content
     carries its own var(--pad) margins) — leaving the back chip visibly offset
     from the content. Restore the same --pad so the column lines up with the
     rest. */
  .screen:has(.desktop-sidebar) .view-content > .scroll-area.shop-scroll,
  .screen:has(.desktop-sidebar) .view-content > .scroll-area.gb-scroll {
    padding-left: calc(max(0px, (100% - 720px) / 2) + var(--pad)) !important;
    padding-right: calc(max(0px, (100% - 720px) / 2) + var(--pad)) !important;
  }

  /* Fixed bottom bars (chat): center over the same column. The suggestions row
     and input bar are position:fixed, so they need the explicit content-zone
     left offset rather than relying on the parent. */
  .input-area,
  .input-blocker-bar,
  .suggestions-row {
    left: calc(var(--sidebar-w) + var(--sidebar-gap) * 2 + 14px);
    right: 14px;
    max-width: 720px;
    margin-left: auto;
    margin-right: auto;
    transition: left 0.18s cubic-bezier(0.4,0,0.2,1);
  }
  .chat-search-panel {
    left: calc(
      var(--sidebar-w) + var(--sidebar-gap) * 2 +
      max(14px, calc((100% - var(--sidebar-w) - var(--sidebar-gap) * 2 - 720px) / 2 + 14px))
    );
    transition: left 0.18s cubic-bezier(0.4,0,0.2,1);
  }
  .chat-search-nav-buttons {
    right: max(
      14px,
      calc((100% - var(--sidebar-w) - var(--sidebar-gap) * 2 - 720px) / 2 + 14px)
    );
    transition: right 0.18s cubic-bezier(0.4,0,0.2,1);
  }
  /* the mobile cap is viewport-relative; inside the 720px column a long
     status could otherwise reach the arrows' column at its right edge */
  .chat-search-counter-island { max-width: 612px; }
  .input-fade,
  .pins-foot {
    left: calc(var(--sidebar-w) + var(--sidebar-gap) * 2);
    right: 0;
    transition: left 0.18s cubic-bezier(0.4,0,0.2,1);
  }
  .pins-foot-pill { max-width: 692px; }
  /* scroll-to-bottom chip hugs the right edge of the centered 720px column */
  .glass-chip.scroll-bottom-btn {
    right: calc(max(14px, calc((100% - var(--sidebar-w) - var(--sidebar-gap) * 2 - 720px) / 2 + 14px)));
  }

  /* ── Profile dashboard ──
     Profile opts OUT of the 720px clamp (see scroller list above): it uses the
     width as a two-column dashboard — identity card on the left (sticky),
     bonus / actions / stats / promo flowing on the right. Mobile is untouched
     (the column wrappers are display:contents there). */
  /* На десктопе у профиля/арта нет верхней плашки, поэтому мобильный
     padding-top в один header-h (52px) оставлял пустую полосу сверху.
     Даём чистый компактный верхний отступ. */
  .view-content > .profile-scroll { padding-left: 28px; padding-right: 28px; padding-top: 24px; }
  .profile-body {
    display: grid;
    grid-template-columns: minmax(290px, 330px) minmax(0, 1fr);
    column-gap: 18px;
    row-gap: 0;
    max-width: 1060px;
    margin: 0 auto;
    align-items: start;
  }
  .pf-col-left {
    grid-column: 1;
    grid-row: 2;
    display: flex; flex-direction: column; gap: 12px;
    position: sticky; top: 8px;
  }
  .profile-bonus-label {
    grid-column: 2;
    grid-row: 1;
    margin-top: 0;
    padding-top: 0;
  }
  .pf-col-right {
    grid-column: 2;
    grid-row: 2;
    display: flex; flex-direction: column; gap: 2px;
  }
  /* wallpaper picker: phone-scale tiles, not third-of-pane posters */
  .wp-grid { grid-template-columns: repeat(5, 1fr); }

  /* the real chat's wallpaper edge next to the sidebar gets the same soft
     scrim the top/bottom edges already have */
  .chat-wallpaper::after {
    content: '';
    position: absolute;
    left: var(--sidebar-w);
    top: 0;
    bottom: 0;
    width: 140px;
    background: linear-gradient(to right, rgba(0, 0, 0, 0.45), transparent);
    pointer-events: none;
  }

  /* wallpaper editor sits UNDER the visible sidebar (z 3900 < 4000), so its
     chat mock and dock center in the free pane RIGHT of it - the exact
     formula the real chat's fixed bars use */
  .wp-editor .chat-glass-bar,
  .wpe-msgs {
    left: calc(var(--sidebar-w) + var(--sidebar-gap) * 2 + max(10px, (100% - var(--sidebar-w) - var(--sidebar-gap) * 2 - 720px) / 2));
    right: max(10px, calc((100% - var(--sidebar-w) - var(--sidebar-gap) * 2 - 720px) / 2));
  }
  .wpe-panel {
    left: calc(var(--sidebar-w) + var(--sidebar-gap) * 2 + max(10px, (100% - var(--sidebar-w) - var(--sidebar-gap) * 2 - 720px) / 2));
    right: max(10px, calc((100% - var(--sidebar-w) - var(--sidebar-gap) * 2 - 720px) / 2));
  }
  /* soft scrim where the wallpaper meets the sidebar (owner: top and bottom
     fades exist, the sidebar edge had none). Same treatment in the real chat
     (below) and in the editor (.wpe-leftfade, painted by the choreography). */
  .wpe-leftfade {
    display: block;
    position: absolute;
    left: var(--sidebar-w);
    top: 0;
    bottom: 0;
    width: 140px;
    background: linear-gradient(to right, rgba(0, 0, 0, 0.45), transparent);
    pointer-events: none;
    opacity: 0;
  }

  /* the grid gaps own the spacing now; drop the per-island side margins */
  .profile-body .island { margin-left: 0; margin-right: 0; }
  .profile-body .profile-hero.has-backdrop { margin-left: 0; margin-right: 0; }
  .profile-body .stat-strip,
  .profile-body .promo-row { margin-left: 0; margin-right: 0; }
  .profile-body .stat-joined { margin-left: 4px; margin-right: 4px; }
  .profile-body .section-lbl { padding-left: 4px; padding-right: 4px; }
  /* the hero (view mode) becomes a real card to anchor the left column */
  .profile-hero {
    background: var(--glass); border-radius: var(--r-card);
    padding: 0 !important;
  }
  .profile-hero-content {
    padding: 30px 20px 24px;
  }
  /* Desktop 2-col: the left column is narrow and bounded, so a 1:1 poster reads
     clean here (unlike wide single-column, which stays content-driven). It still
     grows past the square if the bio runs long. */
  /* the hero keeps the phone mechanism here: the grid spacer gives the 1:1
     floor and the square grows with the identity card (a fixed aspect-ratio
     plus a clamped bio cut the card off at the bottom - owner, 2026-08-19) */

  /* ── Art studio: 2-column on desktop ──
     Output (stage + history) on the left and sticky, controls on the right —
     instead of one tall scrolling strip. Art opts out of the 720px clamp via
     .scroll-area:not(.art-screen) above. */
  .view-content > .art-screen { padding-left: 28px; padding-right: 28px; padding-top: 24px; }
  .art-body {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(330px, 380px);
    gap: 24px;
    /* explicit width: .art-screen is a column flexbox, and a flex item with
       auto cross margins is fit-content sized, so without it the grid (and its
       1fr column) followed the widest in-flow child - the result <img> at
       rest, the recents strip while generating (the shimmer and the latent
       preview are absolute), and the stage jumped from 736px to 440px on
       every generation */
    width: 100%;
    max-width: 1140px;
    margin: 0 auto;
    align-items: start;
  }
  /* top: 0, not a gap: the sticky line is measured from the scroller's
     CONTENT edge (its 24px padding is already subtracted), so any inset
     pushed the column below its rest position whenever the grid had room -
     only while the controls column was the taller one, i.e. in Simple, and
     switching modes jumped the stage and recents by that inset */
  .art-col-left {
    display: flex; flex-direction: column; gap: 14px;
    position: sticky; top: 0;
  }
  .art-col-right { display: flex; flex-direction: column; gap: 14px; }
  /* taller stage now that it owns a column */
  .art-col-left .art-stage { height: clamp(360px, 64vh, 620px); }
  /* the CTA re-enters the controls column: the 2-col studio always shows it,
     including under kb-open (an iPad keyboard must not eat an in-flow button).
     Relative, not static: this nested selector outweighs the is-pending
     spinner recipe, and a static pill sends the absolute spinner to the
     nearest positioned ancestor; inset auto kills the phone dock offsets so
     relative lays out exactly like static in every state */
  .art-cta {
    position: relative;
    inset: auto;
    max-width: none;
    width: 100%;
    box-shadow: none;
  }
  /* in the column the pill never sits under a sheet's glass: it stays put */
  .art-cta.sheet-up { transform: none; visibility: visible; }
  body.kb-open .art-cta { display: block; }
  .view-content > .art-screen { padding-bottom: calc(var(--nav-h) + 32px + var(--safe-bottom)); }
}

/* ── Publish-to-gallery sheet (ADR-0009) ── */
/* tonal-accent variant of .btn-sm (sanctioned by the cookbook) */
.btn-sm-accent { background: var(--accent-soft); color: var(--accent-text); }
.btn-sm-accent:active:not(:disabled) { background: color-mix(in srgb, var(--accent-text) 14%, var(--accent-soft)); color: var(--accent-text); }
@media (hover: hover) {
  .btn-sm-accent:hover:not(:disabled) { background: color-mix(in srgb, var(--accent-text) 14%, var(--accent-soft)); color: var(--accent-text); }
}
.pub-center { display: flex; justify-content: center; padding: 28px 0; }
.pub-preview-wrap { display: flex; justify-content: center; margin-bottom: 14px; }
.pub-preview {
  max-height: 120px;
  max-width: 60%;
  border-radius: var(--r-card, 18px);
  object-fit: contain;
  background: var(--glass);
}
.pub-note { font-size: 13px; color: var(--text3); line-height: 1.45; margin: 10px 0; }
.pub-note b { color: var(--text2); font-weight: 600; }
.pub-warn { font-size: 12.5px; color: var(--text3); line-height: 1.45; margin: 10px 0 2px; text-align: center; }
/* правила галереи из шита публикации: строка-ссылка в футер-зоне у заметки
   о модерации (iOS-грамматика футера группы), акцент без подчёркивания */
.pub-rules-link {
  border: none;
  background: none;
  display: block;
  position: relative;
  margin: -4px 0 10px;
  padding: 4px 0;
  text-align: left;
  font-size: 13px;
  font-family: inherit;
  cursor: pointer;
  color: var(--accent-text);
}
.pub-rules-link::before { content: ''; position: absolute; inset: -10px 0; }
.pub-rules-link:active { opacity: 0.7; }
.pub-done-ic {
  width: 56px;
  height: 56px;
  margin: 8px auto 12px;
  border-radius: 999px;
  background: var(--accent-soft);
  color: var(--accent-text);
  display: flex;
  align-items: center;
  justify-content: center;
}
.pub-done-ic svg { width: 28px; height: 28px; }
.pub-done-title { text-align: center; font-size: 17px; font-weight: 700; margin-bottom: 6px; }
.pub-flag-title { font-size: 16px; font-weight: 700; margin-bottom: 8px; }

/* publish sheet: rules step */
.pub-rules-scroll {
  max-height: 44vh;
  overflow-y: auto;
  background: var(--glass);
  border-radius: var(--r-card, 18px);
  padding: 14px;
  margin: 10px 0 4px;
}
.pub-rule { display: flex; gap: 10px; margin-bottom: 12px; }
.pub-rule:last-child { margin-bottom: 2px; }
.pub-rule-num {
  flex: none;
  width: 22px;
  height: 22px;
  border-radius: 999px;
  background: var(--glass-bold);
  color: var(--text2);
  font-size: 12px;
  font-weight: 600;
  display: flex;
  align-items: center;
  justify-content: center;
  font-variant-numeric: tabular-nums;
}
.pub-rule-text { font-size: 13.5px; line-height: 1.5; color: var(--text2); }

/* ═══════════════════════════════════════════
   CATALOG CHAT (catalog.js creates it, catalogchat.js knows the wire,
   app.js renders it). Several authors in one thread: the room's sender line
   above a run, the addressee island in the composer and the roster island on
   the info screen. Tokens only; the dense twins of the glass parts are
   inherited (.chat-dropdown, .composer-mic).
═══════════════════════════════════════════ */
/* the author of a run above its first bubble (the room's .grp-sender, its tap
   and press included); the line is reserved while the speaker is unknown so
   the bubble never jumps */
.off-sender { min-height: 17px; line-height: 17px; margin: 0 0 3px 14px; }
/* a run whose author has not been announced yet: a blank disc, never a wrong face */
.grp-ava.off-ava-pending { background: var(--glass-bold); }
/* "@Name" of a participant inside a user turn: a mention entity in the text run.
   The bubble IS the accent, so the accent (or --accent-text) on it vanishes; the
   entity takes the bubble's name treatment instead (same color, 600). No pill,
   no fill: it stays part of the selectable text. */
.msg-mention { font-weight: 600; }
/* the addressee island: the voice island's glass fence with the group glyph
   (auto) or the chosen face inside. It LEADS the composer, where Telegram keeps
   its attachment / bot menu button: the mirror of the voice island, flush with
   the bar's left margin. */
.send-btn.off-target-btn { color: #fff; }
/* out of the flow at every state: the field's --comp-lead margin holds its
   column, so arriving with the roster, leaving it or leaving for a recording
   moves no text in one frame */
.send-btn.composer-mic.composer-lead { position: absolute; left: 0; bottom: 0; margin: 0; }
/* On the left the island is a secondary control beside the voice CTA: plain
   glass with a neutral glyph (Telegram's attachment button), never a second
   accent disc. A chosen addressee is the selection state of glass - the core
   takes the tonal fill and the face fills the core - never a ring. */
.send-btn.composer-lead { color: var(--text); }
.send-btn.composer-lead::after { background: transparent; }
.send-btn.composer-lead .send-btn-icon { opacity: .8; }
.send-btn.composer-lead.is-set::after { background: var(--accent-soft-solid); }
.send-btn.composer-lead.is-set .off-target-ava { width: 40px; height: 40px; }
body.no-blur .send-btn.composer-mic.composer-lead { background: var(--chrome-glass-fill-solid); }
body.no-blur .send-btn.composer-mic.composer-lead.is-set::after { display: block; }
/* Recording: the island leaves to the LEFT (Telegram moves the attachment
   button to -8 - width and the field's left inset to 8), so the timer starts
   at the composer edge exactly as in a chat without it. */
.input-area.voice-active .send-btn.composer-lead,
.input-area.voice-canceling .send-btn.composer-lead {
  translate: -68px 0;
  opacity: 0;
  pointer-events: none;
}
/* arriving with the roster (/info answers after the composer painted): it
   grows in place the way the send disk does, never pops */
.send-btn.composer-lead.lead-isl-enter-active,
.send-btn.composer-lead.lead-isl-leave-active {
  transition: scale var(--comp-dur) var(--comp-ease), opacity 200ms ease-in-out;
}
.send-btn.composer-lead.lead-isl-enter-from,
.send-btn.composer-lead.lead-isl-leave-to { scale: .2; opacity: 0; }
@media (prefers-reduced-motion: reduce) {
  .send-btn.composer-lead.lead-isl-enter-active,
  .send-btn.composer-lead.lead-isl-leave-active { transition: none; }
}
.off-target-ava {
  width: 32px; height: 32px; border-radius: var(--r-pill); object-fit: cover;
  font-size: 13px; font-weight: 700; display: inline-flex; align-items: center; justify-content: center;
}
/* the addressee menu opens UPWARD from the composer's leading edge, over its
   island (the glass reveal recipe with its anchor flipped); it is a sibling of
   the island inside the composer so the composer's own glass layer never
   becomes its ancestor filter */
.off-target-reveal { top: auto; bottom: calc(100% + 8px); left: 0; right: auto; }
.off-target-reveal > .chat-dropdown { min-width: 240px; max-width: min(320px, calc(100vw - 24px)); }
/* the rows are real buttons (menuitemradio): the UA button box goes, the
   menu's own hover/active fills (higher specificity) stay */
.off-target-menu > button { appearance: none; background: transparent; border: none; width: 100%; text-align: left; font: inherit; color: inherit; }
.off-target-reveal.chatmenu-enter-from, .off-target-reveal.chatmenu-leave-to { transform: translateY(6px); }
.off-target-row-ava {
  width: 28px; height: 28px; border-radius: var(--r-pill); object-fit: cover; flex-shrink: 0;
  font-size: 12px; font-weight: 700;
}
.off-target-row-auto { background: var(--glass-bold); }
.off-target-row-auto svg { width: 16px; height: 16px; stroke: var(--text); opacity: 0.72; }
/* the check trails the row (the .m3-radio/.assign-check grammar: reserved
   slot, hidden by opacity, never a leading gap before the avatar) - the
   name sits between avatar and check and ellipsizes like .mention-item */
.off-target-row-name { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* the roster rows on the info screen open the hero's sheet, like a room's
   member rows: the ordinary .settings-row press, nothing of their own */

/* @ mention completion: the addressee menu family, anchored over the composer
   start; at most three and a half 52px rows, so a partial row signals the
   scroll (Telegram iOS caps its panel at itemHeight * 3.5 the same way) */
.mention-reveal { top: auto; bottom: calc(100% + 8px); left: 0; right: auto; }
.mention-reveal > .chat-dropdown { min-width: 240px; max-width: min(320px, calc(100vw - 24px)); }
.mention-menu { max-height: 182px; overflow-y: auto; }
.chatmenu-enter-active .mention-menu, .chatmenu-leave-active .mention-menu { overflow: hidden; }
.mention-menu > button { appearance: none; background: transparent; border: none; width: 100%; text-align: left; font: inherit; color: inherit; }
.mention-item { gap: 10px; }
/* one line per candidate, like the Telegram panel: a long name ellipsizes
   instead of growing the row */
.mention-item > span:last-of-type { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* keyboard focus walks the list with the same full-bleed highlight the rows
   hover with; two cues never fight (pointerenter moves the same index) */
.mention-item.is-kb { background: rgba(255, 255, 255, 0.07); }
.mention-reveal.chatmenu-enter-from, .mention-reveal.chatmenu-leave-to { transform: translateY(6px); }

/* roster editing on the chat info screen: remove is a quiet trailing icon
   action (content layer, transparent at rest), add is the tinted row idiom */
.off-prow-remove { flex-shrink: 0; margin-right: -8px; }
.off-prow-remove svg { width: 18px; height: 18px; }
.off-former .off-prow { opacity: 0.55; }
.off-prow-add { width: 100%; appearance: none; background: transparent; border: none; font: inherit; color: inherit; text-align: left; cursor: pointer; }
.off-prow-add:disabled { opacity: 0.32; cursor: default; }
.off-prow-add-ic { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.off-prow-add-ic svg { width: 20px; height: 20px; stroke: var(--accent-text); }
.off-prow-add-lbl { color: var(--accent-text); }

/* loading/empty well of a catalog sheet (Art picker) and the minor consent of
   the catalog's selection (catalog.js openConsent) */
.off-add-state { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 28px 12px; color: var(--text2); font-size: 14px; text-align: center; }
.off-add-spin { width: 22px; height: 22px; flex-shrink: 0; }
.off-add-confirm { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 8px 0 4px; }
.off-add-confirm-ava { width: 56px; height: 56px; border-radius: var(--r-pill); object-fit: cover; object-position: top; font-size: 22px; font-weight: 700; display: flex; align-items: center; justify-content: center; }
.off-add-confirm-name { font-size: 17px; font-weight: 600; color: var(--text); }
.off-minor-warn { text-align: center; color: var(--text2); font-size: 14px; line-height: 1.5; margin: 8px 4px 14px; }
/* the art studio's minor refusal sheet: the pose sheet's lock, calm, above the title */
.art-refused-ic { display: block; width: 36px; height: 36px; margin: 4px auto 10px; color: var(--text); opacity: 0.4; }
.art-refused-ic svg { width: 36px; height: 36px; display: block; }

/* Art character sheet: search header + identity grid (the sheet recipe is
   .ag-sheet above). */
.art-char-search { margin-bottom: 12px; flex-shrink: 0; }
.art-char-more { height: 1px; }
.art-char-overlay .search-field-control,
body.no-blur .art-char-overlay .search-field-control {
  background: var(--glass-bold); backdrop-filter: none; -webkit-backdrop-filter: none; box-shadow: none;
}
.art-char-overlay .search-field-control:focus,
body.no-blur .art-char-overlay .search-field-control:focus {
  background: color-mix(in srgb, var(--text) 8%, var(--glass-bold));
}
.art-char-overlay .ag-grid { transition: opacity 130ms cubic-bezier(0.4, 0, 0.2, 1); }
.art-char-overlay .art-search-pending { opacity: .5; pointer-events: none; }
.art-char-empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 24px 12px; text-align: center; color: var(--text2); overflow-wrap: anywhere; }
.art-char-empty svg { width: 28px; height: 28px; }
.art-char-empty strong { font-size: 15px; color: var(--text); }
@media (prefers-reduced-motion: reduce) {
  .art-char-overlay .ag-grid { transition: none; }
}

/* ═══════════════════════════════════════════════════════════════════════════
   DENSE SHELL (body.no-blur) — one block, after every surface rule
   ═══════════════════════════════════════════════════════════════════════════
   body.no-blur is the DENSE rendering of the same product (the
   Telegram-Android look), not liquid glass with the blur switched off: every
   floating surface goes opaque and loses the rim, the radial press light and
   the lens stretch, keeping the two static hairlines plus elevation. Geometry,
   radii and timing are identical to blur mode.
   It lives at the end of the file on purpose: `body.no-blur .glass-chip` has
   the same specificity as the per-surface fallbacks scattered above, so the
   cascade order is what makes this the single source of the dense material. */
body.no-blur .glass-pill,
body.no-blur .input-area::before,
body.no-blur .input-blocker-bar,
body.no-blur .search-field-control,
body.no-blur .search-morph .sm-glass,
body.no-blur .search-morph .sm-field,
body.no-blur .folder-bar,
body.no-blur .view-strip::before,
body.no-blur .ldr-strip,
body.no-blur .ldr-me-bar,
body.no-blur .chat-dropdown,
body.no-blur .modal-sheet,
body.no-blur .toast,
body.no-blur .rename-bar,
body.no-blur .wpe-panel {
  background: var(--chrome-glass-fill-solid);
  box-shadow: var(--chrome-glass-shadow-solid);
}
body.no-blur .glass-chip,
body.no-blur .nsfw-chip.glass,
body.no-blur .sub-bar-title,
body.no-blur .act-icon,
body.no-blur .pager-btn,
body.no-blur .suggestion-pill,
body.no-blur .day-divider-pill,
body.no-blur .msg-undo-btn,
body.no-blur .msg-pin-btn,
body.no-blur .msg-edit-btn,
body.no-blur .msg-anim-btn,
body.no-blur .msg-goto-btn,
body.no-blur .chat-search-counter-island,
body.no-blur .chat-search-nav-btn,
body.no-blur .pf-poster-hint {
  background: var(--chrome-glass-fill-solid);
  box-shadow: var(--chrome-glass-shadow-solid-thin);
}
/* the torn day header floats over passing content: dense fill (the in-flow
   pill keeps the translucent mix), dense chip edge, no elevation step without
   blur. Plain specificity, no !important: .wp-chips-scrim keeps its baked
   service colour on the torn pill too */
body.no-blur .day-sep[data-stuck] .day-divider-pill {
  background: var(--chrome-glass-fill-solid);
  box-shadow: var(--chrome-glass-shadow-solid-thin);
}
/* the viewer keeps its own neutral (non-theme) tone, only the edge changes */
body.no-blur .lb-play-chip,
body.no-blur .lb-actions { box-shadow: var(--chrome-glass-shadow-solid); }
body.no-blur .lb-chip { box-shadow: var(--chrome-glass-shadow-solid-thin); }
/* state fills outrank the dense material (longer selectors), restated here so
   a toggled chip never falls back to the neutral tone */
body.no-blur .search-field-control:focus { background: var(--chrome-glass-fill-solid); }
/* the chat search input stays transparent inside its pill in dense mode too */
body.no-blur .chat-message-search-input.search-field-control:focus { background: transparent; }
body.no-blur .glass-chip.on { background: var(--accent-soft); }
/* dense: the flat accent chip of main, no ring, no shadow */
body.no-blur .nsfw-chip.glass.on { background: var(--accent); }
body.no-blur .nsfw-chip.glass.on::before { display: none; }
/* the dial track is the chip's glass grown large: same dense twin. The dense
   ON chip is one flat accent pill with no ring (above), so the dense dial has
   no fenced well either: the accent fills the whole track height and the
   thumb sits 8px in (app.js reads the inset from body.no-blur) */
body.no-blur .nsfw-dial-track {
  background: var(--chrome-glass-fill-solid);
  box-shadow: var(--chrome-glass-shadow-solid-thin);
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
}
body.no-blur .nsfw-dial-well { inset: 0; }
body.no-blur .nsfw-dial-thumb { left: 8px; top: 8px; }
/* the composer's voice island tells the same story: with no glass to fence
   with, it collapses to one accent button */
body.no-blur .send-btn.composer-mic {
  background: var(--accent);
  box-shadow: var(--chrome-glass-shadow-solid-thin);
}
body.no-blur .voice-lock-afford {
  background: var(--chrome-glass-fill-solid);
  box-shadow: var(--chrome-glass-shadow-solid-thin);
}
body.no-blur .send-btn.composer-mic::after { display: none; }
