/* VIZ Hub UI — Gmail-style layout */

/* ---------------------------------------------------------------------------
 * Design tokens.
 *
 * Every colour in this file lives here and nowhere else. A literal typed into a
 * rule cannot follow the theme, and the failure is silent: the rule keeps its
 * light-theme colour while the token beside it flips, so the pair that was
 * readable in one theme is unreadable in the other. That is not hypothetical —
 * `.toast` set `background: #323232` beside `color: var(--bg-primary)` and read
 * at 1.30:1 in dark, and the toast is the only channel this client has for
 * reporting success and failure.
 *
 * The hues are the Free-Speech-Project palette (accent hue 203°, midnight
 * #14202c surfaces) so the hub looks like the rest of the VIZ ecosystem. The
 * LIGHTNESS is ours: FSP's #1c98e5 carries white text at 3.15:1, which fails
 * AA on a button label, so the light theme darkens the same hue to #1576b2 and
 * the dark theme — where the accent sits on midnight and carries dark text —
 * keeps FSP's value exactly. Copying a swatch wholesale is how a palette
 * becomes unreadable while still looking like the brand.
 *
 * tests/ui-theme.js welds all of this: no literals outside these two blocks,
 * both blocks define the same names, and any rule that sets a foreground and a
 * background together must clear 4.5:1 in BOTH themes.
 * --------------------------------------------------------------------------- */

:root {
  /* The plate a QR code is drawn on. Named as a token like everything else, and DELIBERATELY the
     same value in both themes: the modules are black by definition of the format, so a code drawn
     on a dark surface is a black square on grey that no camera resolves. Stating it twice, with
     the same value, is how "this one does not follow the theme" is said out loud rather than
     smuggled in as a literal. */
  --qr-plate: #ffffff;
  /* Surfaces */
  --bg-primary: #ffffff;
  --bg-secondary: #f2f6f9;
  --bg-hover: #eef4f9;
  --bg-active: #dceaf6;
  --bg-input: #ffffff;
  --bg-card: #ffffff;
  --bg-overlay: rgba(20, 32, 44, 0.45);
  --bg-hover-veil: rgba(20, 32, 44, 0.08);
  /* The chip a code span is drawn on, in the bodies the renderer assembles. A tint of the accent
     rather than a grey, because that is what FSP's own `--code-bg` is and a body that reads one
     way there and another here is a difference its author cannot account for. A token of its own
     rather than `--bg-secondary`, which answers a different question: this one has to be told
     apart from the surface it sits ON, and that surface is the card. */
  --code-bg: rgba(21, 118, 178, 0.14);
  /* Not a colour anyone sees: a mask keeps only the ALPHA of what it is given, so this token is
     here because the file keeps every colour literal inside the theme blocks (tests/ui-theme.js) —
     and because a fade has to be declared in both themes, even when they agree. */
  --fade-mask: #000000;
  /* The wallpapers a personal chat can be given (#1232). Tints rather than surfaces, because a
     wallpaper is laid OVER the chat's own background: the feed keeps whatever the theme gives it,
     and the wallpaper only leans its hue — which is also why one token serves both the painted feed
     and the chooser's swatch without either of them naming a colour of its own. `--wall-dot` is the
     grain of the dotted one, not a surface; the transparent half of that gradient is a keyword and
     so is not a colour the theme guard has to excuse. */
  --wall-mist: rgba(120, 170, 220, 0.22);
  --wall-sand: rgba(214, 176, 108, 0.24);
  --wall-mint: rgba(104, 196, 160, 0.22);
  --wall-lilac: rgba(172, 146, 222, 0.22);
  --wall-dot: rgba(20, 32, 44, 0.16);
  /* own chat bubble, a tint laid over --bg-primary in the tone of the chat's wallpaper (#6595);
     no wallpaper → greenish in the light theme, bluish in the dark one (owner 20.10) */
  --chat-own: rgba(104, 196, 140, 0.30);
  --own-mist: rgba(120, 170, 220, 0.45);
  --own-sand: rgba(214, 176, 108, 0.45);
  --own-mint: rgba(104, 196, 160, 0.45);
  --own-lilac: rgba(172, 146, 222, 0.45);

  /* Text */
  --text-primary: #0d1b26;
  --text-secondary: #5b6b78;
  --text-link: #1576b2;
  --text-on-primary: #ffffff;

  /* Lines and focus */
  --border-color: #ccccdd;
  --border-focus: #1576b2;
  --focus-ring: rgba(21, 118, 178, 0.25);

  /* Brand */
  --accent: #1576b2;
  --accent-hover: #105b89;
  /* a chat author's name: one step darker than a link (#6595) */
  --chat-author: #105b89;
  /* chat text size; Blink settings overwrite it on the root (four steps from 14px) */
  --chat-text: 14px;
  /* The accent weight that stays readable when it is set ON a tinted surface
     (--bg-active, --bg-hover) rather than beside one. The fill weight is 4.02:1
     there — a chip label nobody with average eyesight reads comfortably. */
  --accent-text: #105b89;

  /* Status. `--x` is the line/fill weight, `--x-text` the weight that stays
     readable when it is set ON the matching tinted background. */
  --danger: #be0a0a;
  --danger-hover: #8f1410;
  --danger-bg: rgba(190, 10, 10, 0.14);
  --danger-bg-soft: rgba(190, 10, 10, 0.08);
  --success: #1a7f37;
  --success-text: #156b2e;
  /* "The request is in flight." A hue of its own rather than the warning one, because waiting is
     not a problem, and a colour that reads as trouble teaches the reader to fear the normal case. */
  --pending: #9a7400;
  --warning: #b45309;
  /* Owner, 11.09: the muddy brown read as dirt rather than as a caution. Red-orange — still not
     the error red (--debit), still darker than --warning, so a warning stays a warning. */
  --warning-text: #cf1f00;
  --warning-bg: rgba(255, 200, 120, 0.45);
  --warning-bg-soft: rgba(255, 200, 120, 0.28);
  --info-text: #105b89;
  --credit: #156b2e;
  --debit: #be0a0a;

  /* Row tints */
  /* The folder badge: a veil rather than a colour, so it reads as a quiet count on
     whatever the folder row happens to be — plain, hovered or active. */
  --bg-badge: rgba(20, 32, 44, 0.08);

  --unread-bg: #e3eef7;
  --spam-bg: #fff4e0;
  --trusted-bg: #e4f3e9;
  --kbd-bg: #eef1f4;

  --shadow-sm: 0 1px 2px rgba(20, 32, 44, 0.08);
  --shadow-md: 0 2px 8px rgba(20, 32, 44, 0.14);
  --shadow-lg: 0 8px 32px rgba(20, 32, 44, 0.22);

  /* The toast is a floating dark surface in BOTH themes — it is deliberately
     not a page surface, so its values barely move. What must move with it is
     its own text token; pairing it with a page token is exactly the bug above. */
  --bg-toast: #14202c;
  --text-on-toast: #ffffff;
  --text-on-toast-dim: rgba(255, 255, 255, 0.72);
  --toast-btn-hover: rgba(255, 255, 255, 0.12);
  /* The thin bar of a scrolling notice: visible on the dark toast surface, and deliberately
     fainter than any control on it. */
  --toast-scroll: rgba(255, 255, 255, 0.35);
  --toast-success: #156b2e;
  --toast-error: #8f1410;
  --toast-warning: #b12c00;
  /* Not --accent. The informational toast used the brand fill, which in the dark
     theme is FSP's bright #1c98e5 and carried the toast's white text at 3.15:1.
     Found by measuring the painted element, not by reading the rule: the rule
     sets only a background and inherits its text from `.toast`, so a check that
     looks for a foreground and a background in the SAME rule cannot see it. */
  --toast-info: #105b89;

  --spinner-track: rgba(255, 255, 255, 0.35);
  --spinner-track-plain: rgba(20, 32, 44, 0.12);

  /* The bubble that marks a derived file. The owner picked the colour; the dark ink is a
     consequence of it — white on #ffbe2b reads at about 1.9:1. It is the same swatch in both
     themes because it is a mark on a picture, not part of the surface behind it. */
  --badge-derived-bg: #ffbe2b;
  --badge-derived-text: #1f2937;
  /* The sealed mark (owner, 11.09: purple). White ink on this violet reads at about 5.2:1, so the
     pair travels together and neither half is a theme token: it marks a file, not a surface. */
  --badge-sealed-bg: #7c3aed;
  --badge-sealed-text: #ffffff;
  /* The closed-folder mark (#660) — a pair of its own rather than the sealed one's, because the two
     say different things: sealed is about the BYTES, closed is about who may walk in. Slate, so a
     folder that is both closed and on sale can still be told apart at a glance. */
  --badge-locked-bg: #475569;
  --badge-locked-text: #ffffff;

  /* FSP state palette (owner 22.09: «возьми целиком цветовые гаммы из fsp») — the reference's
     own values: hover is a translucent wash over the glyph, active is translucent green with the
     glyph re-inked green. Same values in both schemes, as FSP's default/midnight do. */
  --icon-hover: rgba(105, 187, 238, 0.2);
  /* Light readable pair from FSP's own paper theme — the default theme's #26be0a is 2.3:1 on
     white and fails the contrast ratchet; FSP itself darkens green on light paper. */
  --positive-bg: rgba(38, 196, 9, 0.1);
  --positive-color: #15803d;
  --positive-alt-bg: rgba(180, 200, 3, 0.18);
  --positive-alt-color: #55650a;

  /* The glass of a phone is not a rectangle: a notch, a camera island and the home indicator lie
     on top of it. A layer the CLIENT places (the tooltip bubble) cannot ask for `env()` at the
     moment of placement, so the four values are read once per show from these tokens by `tipSafe`
     in app.js — one declaration, one reader (19.10.2026). */
  --safe-top: env(safe-area-inset-top, 0px);
  --safe-right: env(safe-area-inset-right, 0px);
  --safe-bottom: env(safe-area-inset-bottom, 0px);
  --safe-left: env(safe-area-inset-left, 0px);
}

[data-theme="dark"] {
  --qr-plate: #ffffff;
  --bg-primary: #14202c;
  --bg-secondary: #0e1822;
  --bg-hover: #1b2b3a;
  --bg-active: #23384b;
  --bg-input: #1b2b3a;
  --bg-card: #14202c;
  --bg-overlay: rgba(0, 0, 0, 0.6);
  --bg-hover-veil: rgba(255, 255, 255, 0.08);
  /* §7.9 bodies: the code chip. FSP's own dark value, kept to the digit — the accent there is the
     one this theme already carries, so the tint lands where theirs does. */
  --code-bg: rgba(28, 152, 229, 0.20);
  --fade-mask: #000000;
  /* The same five wallpapers over this theme's surfaces: the hues are the light ones carried a
     little further, because a tint laid over #14202c has less to lean on, and the values stated
     twice are how "this one follows the theme too" is said rather than assumed. */
  --wall-mist: rgba(96, 158, 222, 0.20);
  --wall-sand: rgba(206, 168, 96, 0.20);
  --wall-mint: rgba(88, 188, 150, 0.20);
  --wall-lilac: rgba(160, 132, 216, 0.20);
  --wall-dot: rgba(165, 180, 192, 0.22);
  --chat-own: rgba(96, 158, 222, 0.30);
  --own-mist: rgba(96, 158, 222, 0.42);
  --own-sand: rgba(206, 168, 96, 0.42);
  --own-mint: rgba(88, 188, 150, 0.42);
  --own-lilac: rgba(160, 132, 216, 0.42);

  --text-primary: #ffffff;
  --text-secondary: #a5b4c0;
  --text-link: #1c98e5;
  --text-on-primary: #14202c;

  --border-color: rgba(165, 180, 192, 0.35);
  --border-focus: #1c98e5;
  --focus-ring: rgba(28, 152, 229, 0.35);

  --accent: #1c98e5;
  --accent-hover: #3daff5;
  --chat-author: #1886cc;
  --chat-text: 14px;
  --accent-text: #7cc4ee;

  --danger: #ff8b84;
  --danger-hover: #ffb0aa;
  --danger-bg: rgba(255, 139, 132, 0.16);
  --danger-bg-soft: rgba(255, 139, 132, 0.10);
  --success: #5fd07a;
  --success-text: #7ede93;
  --pending: #e8c65a;
  --warning: #f5b445;
  --warning-text: #ffc86a;
  --warning-bg: rgba(245, 180, 69, 0.22);
  --warning-bg-soft: rgba(245, 180, 69, 0.14);
  --info-text: #7cc4ee;
  --credit: #5fd07a;
  --debit: #ff8b84;

  --bg-badge: rgba(255, 255, 255, 0.12);

  --unread-bg: #1b2f42;
  --spam-bg: #33280d;
  --trusted-bg: #143024;
  --kbd-bg: #23384b;

  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
  --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.45);
  --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.55);

  --bg-toast: #21313f;
  --text-on-toast: #ffffff;
  --text-on-toast-dim: rgba(255, 255, 255, 0.72);
  --toast-btn-hover: rgba(255, 255, 255, 0.12);
  /* The thin bar of a scrolling notice: visible on the dark toast surface, and deliberately
     fainter than any control on it. */
  --toast-scroll: rgba(255, 255, 255, 0.35);
  --toast-success: #156b2e;
  --toast-error: #8f1410;
  --toast-warning: #b12c00;
  --toast-info: #105b89;

  --spinner-track: rgba(255, 255, 255, 0.35);
  --spinner-track-plain: rgba(255, 255, 255, 0.18);

  /* The same swatch as in light. It is a mark drawn ON a picture, not part of the surface behind
     it, so the theme does not move it — but it is repeated here rather than inherited, because a
     token present in one block only is exactly how a colour silently keeps the other theme's
     value, and the guard reads the blocks, not the intent. */
  --badge-derived-bg: #ffbe2b;
  --badge-derived-text: #1f2937;
  /* The sealed mark (owner, 11.09: purple). White ink on this violet reads at about 5.2:1, so the
     pair travels together and neither half is a theme token: it marks a file, not a surface. */
  --badge-sealed-bg: #7c3aed;
  --badge-sealed-text: #ffffff;
  /* The closed-folder mark (#660) — a pair of its own rather than the sealed one's, because the two
     say different things: sealed is about the BYTES, closed is about who may walk in. Slate, so a
     folder that is both closed and on sale can still be told apart at a glance. */
  --badge-locked-bg: #475569;
  --badge-locked-text: #ffffff;

  --icon-hover: rgba(105, 187, 238, 0.2);
  --positive-bg: rgba(38, 196, 9, 0.12);
  --positive-color: #26be0a;
  --positive-alt-bg: rgba(180, 200, 3, 0.14);
  --positive-alt-color: #9ec600;

  /* The phone's glass is the same glass in both schemes — a notch does not follow the theme — but
     the four tokens are stated here too because `tests/ui-theme.js` holds the two blocks to the
     same names, and an exemption written for geometry would be an exemption nobody re-reads. The
     values are identical by construction: `env()` is the browser's, not the theme's. */
  --safe-top: env(safe-area-inset-top, 0px);
  --safe-right: env(safe-area-inset-right, 0px);
  --safe-bottom: env(safe-area-inset-bottom, 0px);
  --safe-left: env(safe-area-inset-left, 0px);
}

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

/* Where the keyboard is. Declared unconditionally and for every focusable thing at once, because
   "which control am I on" is the same question on every screen, and several rules in this file
   set `outline: none` on their own field — an invisible focus ring is reachability that cannot be
   used. `:focus-visible`, not `:focus`: the browser already knows not to ring a mouse click. */
a:focus-visible,
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
[tabindex]:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
  font-size: 14px;
  color: var(--text-primary);
  background: var(--bg-secondary);
}

#app {
  display: flex;
  flex-direction: column;
  height: 100vh;
}

/* ---------------------------------------------------------------------------
 * Direction of the cascade: MOBILE FIRST.
 *
 * Every layout rule in this file states the phone, and `@media (min-width:
 * 769px)` states the desktop. It used to be the other way round, and the
 * direction is not a matter of taste: with the desktop as the default, being
 * correct on a phone was an ENUMERATION — a patch list — and anything left off
 * the list got the desktop layout on a phone, silently. Two kinds of entry were
 * already rotting on that list, and nothing was red about either:
 *
 *   - `#compose-content` and a bare `.file-attachment` were patches for markup
 *     the client never builds — `compose-content` appears nowhere in the client
 *     at all, and every attachment it renders carries `.paywall`. A patch that
 *     matches nothing looks exactly like a patch that works.
 *   - `.ledger-reason` / `.ledger-amount` restated the 13px their own container
 *     already sets: an instruction whose only evidence of being needed is that
 *     somebody wrote it down.
 *
 * Pointed the other way, a patch that fails to apply fails on the DESKTOP —
 * where the author is looking — and the phone, which nobody can inspect while
 * writing CSS, is what the plain rules say.
 *
 * The flip itself changed no geometry: the painted layout at 1280 before and
 * after is identical except for the tap-target section at the end of this file,
 * which is a deliberate, separate change. tests/ui-responsive.js welds the
 * direction, the dead-selector sweep and the safe-area inset;
 * .secrets/ui-mobile-accept.mjs measures the painted page at 320, 360 and 1280.
 * --------------------------------------------------------------------------- */

/* ---------------------------------------------------------------------------
 * The layout mode, declared once.
 *
 * The stylesheet already decides at 769px which navigation the reader gets. The
 * script used to hold its own copy of that number — `window.innerWidth <= 768` —
 * and pick a class from it. Two copies of a boundary agree until somebody moves
 * one, and the one that moves is the one in the file being edited.
 *
 * So the mode is a custom property the script READS. It is deliberately never
 * used through `var()` here: it is not a value this file paints with, it is this
 * file telling the script which layout it has just painted.
 * --------------------------------------------------------------------------- */
#app {
  --layout: drawer;
}

/* ---------------------------------------------------------------------------
 * Icons.
 *
 * `<svg class="icon"><use href="#i-x"></use></svg>`, one shape per <symbol> in the sprite at the
 * top of index.html. Only geometry is set here on purpose: each symbol declares its own
 * `fill`/`stroke` (the set is part filled, part 2px outline, exactly as the Free-Speech-Project
 * set is), and a `fill:` here would be a CSS declaration overriding those presentation attributes
 * — one rule would silently flatten every outline icon into a blob.
 *
 * Sized in `em` so an icon follows the type size of whatever holds it: every holder in this file
 * already says `font-size`, and that is the number a person edits when they want the icon bigger.
 * Colour is not set at all — `currentColor` inside the symbols means the icon is whatever colour
 * the surrounding rule already resolved, in either theme. That is the whole reason these are not
 * emoji any more.
 * --------------------------------------------------------------------------- */
.icon {
  width: 1.2em;
  height: 1.2em;
  flex: none;
  vertical-align: -0.2em;
  pointer-events: none;
}

/* Header */
#header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  /* The horizontal padding is the larger of the design value and the notch, so
     the hamburger is not under a rounded corner in landscape. */
  padding: 8px max(12px, env(safe-area-inset-right)) 8px max(12px, env(safe-area-inset-left));
  background: var(--bg-primary);
  border-bottom: 1px solid var(--border-color);
  height: auto;
  min-height: 48px;
  flex-wrap: wrap;
}

#header-left {
  display: flex;
  align-items: center;
}

/* The round mark and the wordmark (owner-supplied SVG, path-only, no text node), replacing the
   typed "VIZ Hub" (22.09). The wordmark's `fill="currentColor"` reads `color` off THIS element,
   so the single `color` line below is the only place either theme has to be told the brand's
   shade — the mark itself carries its own gradient and needs none. `aria-label` on the heading
   is what keeps the brand a NAME to a screen reader: neither child is text any more. */
#header-left h1.brand {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 0;
  color: var(--text-secondary);
}

.brand-mark {
  width: 32px;
  height: 32px;
  flex: none;
}


/* Shown at every width: the sidebar is the only place the account, its balance and the way out
   live, so the control that reveals it can never be the one that is hidden. */
.menu-btn {
  display: block;
  background: none;
  border: none;
  font-size: 24px;
  color: var(--text-secondary);
  cursor: pointer;
  padding: 4px 8px;
  margin-right: 8px;
}

.menu-btn:hover {
  color: var(--text-primary);
}

/* The header carries the hamburger and the name and nothing else. Balance, account, settings and
   the lock all moved into the sidebar's account bar. */

/* Main layout */
#main {
  display: flex;
  flex-direction: column;
  flex: 1;
  overflow: hidden;
}

/* Sidebar. On a phone it is a drawer: off-canvas, over the content, revealed by
   the hamburger; on a desktop the block below turns it back into a column of the
   layout.
   ONE state, `#app[data-nav]`, read by both widths — not one class per width.
   It used to be two, `.active` on a phone and `.collapsed` on a desktop, each
   set and cleared only by the width that owned it, and they could strand each
   other: collapse the sidebar on a desktop, narrow the window, and `.collapsed`
   (declared with no upper bound) kept `display: none` on the phone while the
   hamburger toggled the OTHER class. Navigation gone, and the only control that
   reveals it doing nothing. With one attribute each width merely interprets the
   same intent, so whichever width the reader lands on can undo it. */
#sidebar {
  width: 256px;
  background: var(--bg-primary);
  border-right: 1px solid var(--border-color);
  display: flex;
  flex-direction: column;
  overflow-y: auto;
  position: fixed;
  left: -256px;
  top: 48px;
  bottom: 0;
  z-index: 100;
  transition: left 0.3s ease;
  box-shadow: var(--shadow-md);
  padding-bottom: env(safe-area-inset-bottom);
}

#app[data-nav="open"] #sidebar {
  left: 0;
}

/* The scrim. Pinned to every edge on purpose (registered in tests/ui-responsive.js as a full-bleed
   overlay): it is meant to reach under the notch. It exists so that a tap outside the drawer means
   ONLY "put the drawer away" — the document-level handler it replaces closed the drawer and let the
   same tap fall through to whatever was underneath. `pointer-events` is what makes it a wall, so it
   is tied to the same one state as the drawer. */
#nav-scrim {
  position: fixed;
  left: 0;
  right: 0;
  top: 0;
  bottom: 0;
  z-index: 99;
  background: var(--bg-overlay);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.3s ease;
}

#app[data-nav="open"] #nav-scrim {
  opacity: 1;
  pointer-events: auto;
}

/* No scrim without a drawer. Signed out the sidebar is `.hidden`, and a wall in front of the login
   screen with nothing behind it would be a page that refuses every click. Stated structurally,
   against the sibling, so no future sign-out path has to remember it. */
#sidebar.hidden ~ #nav-scrim {
  display: none;
}

/* The floating compose button. Shown wherever the inbox screen is shown, because it lives inside
   it; the desktop block puts it away and gives the header button back. */
.fab {
  position: fixed;
  right: max(16px, env(safe-area-inset-right));
  bottom: max(16px, env(safe-area-inset-bottom));
  width: 56px;
  height: 56px;
  border-radius: 50%;
  border: none;
  background: var(--accent);
  color: var(--text-on-primary);
  font-size: 24px;
  line-height: 1;
  cursor: pointer;
  box-shadow: var(--shadow-md);
  z-index: 90;
  /* The label is a shape now, not a glyph the line box centres for us. */
  display: flex;
  align-items: center;
  justify-content: center;
}
.fab .icon {
  vertical-align: 0;
}

/* The same action, worded for a wide screen. Hidden here rather than shown in both places: two
   controls for one action is what pushes the header row of the inbox off the side of a 320px
   screen, and the one that survives on a phone is the one a thumb can reach. */
#btn-compose {
  display: none;
}

#folders {
  padding: 8px 0;
}

.folder {
  display: flex;
  align-items: center;
  /* A row of navigation is a tap target at every width: a pointer misses a 41px
     link too. The value is the floor, not the height — the label still sets it. */
  min-height: 44px;
  padding: 8px 16px;
  text-decoration: none;
  color: var(--text-primary);
  border-radius: 0 24px 24px 0;
  margin-right: 8px;
  transition: background 0.2s;
}

.folder:hover {
  background: var(--bg-hover);
}

.folder.active {
  background: var(--bg-active);
  font-weight: 500;
}

/* A viz:// link in a rendered body (owner 22.09): drawn with no href — the client answers the
   press — but to the reader it is a link, and it has to look like one. */
.viz-scheme-link {
  color: var(--accent);
  cursor: pointer;
  text-decoration: underline;
}

/* The write button (#731) — FSP sets composing off from the navigation: same row anatomy as a
   folder (icon slot, label, the same tap floor), but drawn as the accent act it is. The accent
   ink pair keeps it readable in both schemes. */
.folder-write {
  background: var(--accent);
  color: var(--bg-primary);
  margin-top: 8px;
  margin-bottom: 8px;
  /* Inset like the sub-rows around it: the pill starts 16px in and its icon lands where theirs do (40px). */
  margin-left: 16px;
  /* FSP's publish button is a pill (border-radius 25px) — the one accent act in the menu. */
  border-radius: 25px;
}

.folder-write:hover {
  background: var(--accent-hover);
}

.folder-write.active {
  background: var(--accent);
  font-weight: 500;
}

/* A folder holding something unread says so with weight, on the WHOLE row rather than on the
   badge: what a reader catches at the edge of vision is the line going bold, not two characters
   changing shape. */
.folder.has-unread .label {
  font-weight: 700;
}

/* The count. It stands at the far end of the row, so the labels stay left-aligned and the numbers
   line up under each other; `margin-left: auto` is what pushes it there. */
.folder-count {
  margin-left: auto;
  padding: 1px 8px;
  border-radius: 10px;
  background: var(--bg-badge);
  color: var(--text-secondary);
  font-size: 12px;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* An empty folder is still an answer (§5.5), so the zero is drawn — dimmed, because "nothing here"
   is not news and should not compete with the folder that has two new letters in it. */
.folder-count.is-zero {
  opacity: 0.55;
}

.folder .icon-slot,
.folder-group-header .icon-slot {
  font-size: 20px;
  margin-right: 16px;
  display: inline-flex;
}

.folder .label {
  flex: 1;
}

/* Folder group (collapsible Mail section) */
.folder-group {
  margin-bottom: 8px;
}

.folder-group-header {
  display: flex;
  align-items: center;
  padding: 8px 16px;
  cursor: pointer;
  color: var(--text-secondary);
  font-weight: 500;
  user-select: none;
}

.folder-group-header:hover {
  background: var(--bg-hover);
}

/* The collapse arrow. One shape, one rule: it is used by the mail groups and by the contact
   sections, and stating it once per parent is how the two drift apart — the second copy had already
   lost the margin and gained a colour. The parents state only their own delta below. */
.folder-toggle {
  display: inline-flex;
  font-size: 14px;
  transition: transform 0.2s;
}

.folder-group-header .folder-toggle {
  margin-right: 8px;
}

.folder-group.collapsed .folder-toggle {
  transform: rotate(-90deg);
}

.folder-group.collapsed .folder-group-items {
  display: none;
}

/* A folder that hangs under another one. Stated from the class alone — written as
   `#mail-group-items .folder` it would be a rule about that one group. The row keeps the height of
   a top-level one (owner, 24.09: the 36px sub-rows read as squeezed); only the indent sets it apart. */
.subfolder {
  min-height: 44px;
  padding: 8px 16px;
}

.subfolder .icon-slot {
  /* 24x24 like the parent rows (owner 22.09): FSP does not shrink menu icons one level down,
     and neither do we — the icon is the row's face, not a decoration. 20px * 1.2em = 24px. */
  font-size: 20px;
  margin-right: 10px;
}

.folder-group-items .folder {
  padding-left: 40px;
}

/* After the rule above, same specificity: the inset pill gives back 16px of it as margin. */
.folder-group-items .folder-write {
  padding-left: 24px;
}

/* The account bar sits at the foot of the sidebar. `margin-top: auto` is what pins it there; the
   usage blocks above it stay under the folders. */
#account-bar {
  margin-top: auto;
  padding: 12px 16px;
  border-top: 1px solid var(--border-color);
  /* The account list opens UPWARD from the row that asks for it, so the bar is the box that list
     is measured against. Without this it would be measured against the page and land on the
     content behind the sidebar. */
  position: relative;
}

/* The balance row is a folder link like the ones above it, so it is reached, highlighted and
   themed by the same rules; only its weight says that the figure on it is the point. */
#account-bar #btn-balance {
  /* The negative left margin cancels the bar's own padding, so the row lines up with the folders
     above it instead of being indented twice. */
  margin: 0 8px 6px -16px;
  font-weight: 500;
}

#account-bar #user-balance {
  /* --accent-text, not --text-link. The balance row is a folder, so when Balance is the screen the
     reader is on, the figure is painted ON --bg-active — and the fill weight is 4.02:1 there in
     light and 3.84:1 in dark. That is the very number the --accent-text comment cites as the reason
     that token exists; the pair was invisible because its two halves live in two rules (the colour
     here, the surface on .folder.active), and a guard that reads rules only sees pairs a single
     rule states. Measured on the painted page by .secrets/ui-matrix-accept.mjs. */
  color: var(--accent-text);
}

/* The account block at the head of the sidebar; the list opens downward from it. */
#account-top {
  position: relative;
  padding: 0;
  border-bottom: none;
  flex: none;
  z-index: 25;
  background: var(--bg-primary);
}

.account-bar-row {
  display: flex;
  align-items: center;
  gap: 4px;
}

#btn-account {
  display: flex;
  align-items: center;
  gap: 6px;
  flex: 1;
  min-width: 0;
  padding: 6px 8px;
  background: none;
  border: none;
  border-radius: 4px;
  color: var(--text-primary);
  cursor: pointer;
  font-size: 14px;
  text-align: left;
}

#btn-account:hover {
  background: var(--bg-hover);
}

#btn-account #user-name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

#btn-account .caret {
  font-size: 12px;
  opacity: 0.7;
  display: inline-flex;
}

.account-bar-icon {
  background: none;
  border: none;
  border-radius: 4px;
  padding: 6px 8px;
  font-size: 16px;
  color: var(--text-secondary);
  cursor: pointer;
}

.account-bar-icon:hover {
  background: var(--bg-hover);
  color: var(--text-primary);
}

/* The face beside the account name. It wears the account's own picture when there is one and a
   derived circle when there is not; both arrive from `paintAccountFace`, which is why this rule
   states no colour at all.
   The one-pixel `--border-color` ring is gone and the circle is 32px (owner, 16.09). The ring was
   there to keep a picture that happens to be white from vanishing into a white sidebar — with it
   removed that case draws nothing, and the LETTER fallback (a derived colour, painted here from
   the login) is the form that still shows a face in every theme. Do not re-add the ring quietly:
   it framed every avatar, and the owner asked for the picture instead of the frame. */
.account-face {
  width: 32px;
  height: 32px;
  border-radius: 50%;
  flex-shrink: 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-size: 13px;
  font-weight: 600;
}

/* The picture is rounded by its own rule and the span is NOT clipped. A hidden overflow on the
   span would cut any layer opened inside the face at the face's box, and `tests/ui-layers.js`
   makes every such clipping argue itself — the same reason the contact card's picture carries the
   radius on the image. */
.account-face-img {
  width: 100%;
  height: 100%;
  border-radius: 50%;
  object-fit: cover;
}

/* The list the account row opens: every account this browser holds, and the door to the screen
   that manages them. It stands ABOVE the row rather than below it — the row is the last thing in
   the sidebar and there is nothing under it to open into. */
.account-switch-menu {
  position: absolute;
  left: auto;
  right: 0;
  width: 320px;
  max-width: calc(100vw - 24px);
  top: 100%;
  margin-top: 6px;
  max-height: min(75vh, 640px);
  overflow-y: auto;
  padding: 6px 0;
  background: var(--bg-card);
  border: 1px solid var(--border-color);
  border-radius: 6px;
  box-shadow: var(--shadow-md);
  z-index: 10;
}

.account-switch-menu.hidden {
  display: none;
}

.account-switch-item {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  padding: 8px 12px;
  background: none;
  border: none;
  color: var(--text-primary);
  font-size: 14px;
  text-align: left;
  cursor: pointer;
}

.account-switch-item:hover {
  background: var(--bg-hover);
}

/* The small face in a menu row. Same shape as the one in the bar, one size down: a row that is a
   line of text with a picture at its head, rather than the bar's larger standing identity. */
.account-switch-face {
  width: 24px;
  height: 24px;
  border-radius: 50%;
  flex-shrink: 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-size: 10px;
  font-weight: 600;
}

/* The row that is a door and not an account wears the same shape with no derived colour: it is the
   screen's own icon, in the theme's ink, so "Manage" cannot be mistaken for a thirteenth account. */
.account-switch-face-plain {
  background: var(--bg-secondary);
  color: var(--text-secondary);
  font-size: 14px;
}

.account-switch-name {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Which account is signed in. A tick and not a word: the row already names the account, and the
   only question left about it is whether this is the one the reader is looking at. */
.account-switch-mark {
  display: inline-flex;
  color: var(--accent-text);
}

.account-switch-sep {
  border-top: 1px solid var(--border-color);
  margin: 6px 0;
}

/* A sidebar figure: a heading that doubles as the way in, and a line of numbers under it. Three of
   them stand in the sidebar — storage, processing, network — and to a reader they are one kind of
   thing, so they are described once, by role.

   This used to be written per id, and the cost showed on 10.09: a fourth figure was added and
   arrived with no padding, no border, no heading style, because nothing in the sheet said what a
   figure IS. A style keyed on identity must be retyped for every new member; a style keyed on role
   is inherited by them. New figure = wear the class, nothing here changes. */
.sidebar-figure {
  padding: 16px;
  border-top: 1px solid var(--border-color);
}

/* The bottom margin belongs to the ROW, not to the heading: inside a flex line a heading that keeps
   its own margin sits lower than the icon beside it — the "icon on the right looks lower" from
   10.09. */
.sidebar-figure h3 {
  font-size: 12px;
  font-weight: 500;
  color: var(--text-secondary);
  margin: 0;
  text-transform: uppercase;
}

/* A heading with no screen behind it still needs the gap the row would have given it. */
.sidebar-figure > h3 {
  margin-bottom: 8px;
}

/* The numbers under the heading. */
.sidebar-figure-detail {
  font-size: 13px;
  color: var(--text-primary);
}

/* The heading of a sidebar figure, when the figure has a screen behind it. The margin moves from
   the heading to the row: a heading that keeps its own bottom margin inside a flex line pushes the
   icon off the baseline it should share with the words. */
.usage-link {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  margin-bottom: 8px;
  color: inherit;
  text-decoration: none;
}

.usage-link h3 {
  margin-bottom: 0;
}

.usage-link-icon {
  width: 14px;
  height: 14px;
  color: var(--text-secondary);
  flex: 0 0 auto;
}

.usage-link:hover h3,
.usage-link:hover .usage-link-icon {
  color: var(--text-primary);
}

.usage-detail {
  font-size: 11px;
  color: var(--text-secondary);
  margin-top: 4px;
}

/* Content */
#content {
  flex: 1;
  width: 100%;
  overflow-y: auto;
  padding: 16px;
  /* The home indicator sits over the last row of whatever scrolls here. */
  padding-bottom: max(16px, env(safe-area-inset-bottom));
}

.screen {
  max-width: 1200px;
  margin: 0 auto;
}

.hidden {
  display: none !important;
}

/* The title of a screen. `* { margin: 0 }` above means a gap that is not declared does not exist:
   the heading sat flush against the first card of every screen. */
.screen > h2 {
  margin-bottom: 16px;
}

/* ---------------------------------------------------------------------------
 * `.hint` — a secondary annotation: the sentence that explains a control, never
 * the control and never body copy.
 *
 * It is used a dozen times in the markup and half a dozen more in app.js
 * templates, and it had NO rule of its own. The only selector that mentioned it
 * was `#files-empty .hint` — the rule kept beside ONE of its subjects, which
 * reads as a rule about that screen, so every other screen got the body's size,
 * the body's colour and, under the reset above, no margin at all: an annotation
 * typographically identical to the thing it annotates and welded to it. On the
 * settings screen the intro touched the theme buttons at 0px, the theme buttons
 * touched their own label at 0px, and the passphrase warning touched the button
 * that acts on it.
 *
 * tests/ui-classes.js could not see this and says so in its own header:
 * presence of a selector is the floor, not the ceiling. tests/ui-layout.js asks
 * the other question — is the selector a rule about the ROLE, or about one
 * screen that happens to use it.
 * --------------------------------------------------------------------------- */
.hint {
  margin: 8px 0 16px;
  font-size: 13px;
  line-height: 1.5;
  color: var(--text-secondary);
  /* A measure. The other half of unreadable is a line that runs the width of a
     1200px screen. 68ch at 13px is about 440px — a measure sized for body copy
     applied to text two sizes below it, which broke a one-line annotation over
     three lines inside a card far wider than that (owner, 02.09). 90ch ≈ 585px:
     still short of the screen, long enough that a sentence stays a sentence. */
  max-width: 90ch;
}

/* A hint that FOLLOWS its control is an addendum to it, not an introduction to
   what comes next: it belongs to what is above it, so it sits closer to that and
   adds no gap below itself. Smaller again, because it annotates an annotation's
   worth of state ("currently light") rather than explaining the section. */
.hint-note {
  margin: 8px 0 0;
  font-size: 12px;
}

/* §5.13 the two lists the hub sends with a deletion notice — what goes and what stays. They are
   the hub's own words, so the styling only has to make them readable at a glance: two headings
   that say which list is which, and bullets the reader can count. The takes list is the longer of
   the two and the one that matters, which is why it is last — the reader arrives at the button
   having just read what he is about to lose. */
.del-lists ul {
  margin: 4px 0 12px;
  padding-left: 20px;
  font-size: 13px;
  line-height: 1.5;
  color: var(--text-secondary);
}

.del-lists li {
  margin: 2px 0;
}

/* How far a search reached. It annotates the list below it, so it sits above the
   list and belongs to it; no background of its own, because it is a sentence and
   not a banner — nothing has gone wrong, the reader is only being told what the
   answer is an answer ABOUT. */
.search-note {
  margin: 4px 0 8px;
  padding: 0 4px;
  font-size: 13px;
  line-height: 1.5;
  color: var(--text-secondary);
  max-width: 90ch;
}

.search-note .link-btn {
  margin-left: 6px;
}

/* Nothing inside a card adds a gap at the card's edge — the padding is the only
   margin there, and a doubled one is visible the moment two cards sit together. */
.deposit-card > :last-child,
.deposit-card form > :last-child {
  margin-bottom: 0;
}

/* Sign-in and key-prompt screens: one card, one column, one primary action.
   Both screens are the first thing a phone sees, so the card is sized for a phone first and only
   grows a border and a shadow once there is room around it. */
#key-prompt-screen,
#login-screen {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-start;
  padding: 16px 0 40px;
}

/* A surface that paints its own background must carry its own padding at EVERY width — not only
   at the width where the border shows up. This card used to have `padding: 8px 0 0` below 600px:
   its white rectangle was there at any size, so the text sat flush against the white edge. */
.auth-card {
  width: 100%;
  max-width: 420px;
  background: var(--bg-card);
  border-radius: 12px;
  padding: 20px 16px 24px;
}

/* The archive reader (`viewer.html`, #993) — a page of its own, and the only screen in this client
   with no hub behind it at all.
   The shell is the shape of the sign-in screen: one column, centred, card sized for a phone first.
   Written here rather than reused from `#login-screen` because that rule is keyed by an ID of the
   APP — this page has none of the app's screens, and wearing a rule about one would tie two
   documents together that share nothing but a look. `gap` and not margins: `* { margin: 0 }` above
   means an undeclared gap does not exist, and two cards would have sat flush. */
.archive-page {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-start;
  padding: 16px 0 40px;
  gap: 12px;
}

/* The facts of the file: one name/value pair per row, read top to bottom. `dt`/`dd` and not a
   table — there is nothing to align across columns, and the conversation's name is a 64-hex
   string that must be allowed to wrap inside a phone-width card rather than widen it. */
.archive-meta {
  display: flex;
  flex-wrap: wrap;
  gap: 2px 12px;
  margin: 0;
  font-size: 13px;
  line-height: 1.5;
}

.archive-meta dt {
  color: var(--text-secondary);
}

.archive-meta dd {
  margin: 0 12px 0 0;
  overflow-wrap: anywhere;
}

.auth-title {
  font-size: 22px;
  font-weight: 500;
  line-height: 1.3;
  margin-bottom: 8px;
  color: var(--text-primary);
  overflow-wrap: anywhere;
}

.auth-lead {
  font-size: 14px;
  line-height: 1.5;
  color: var(--text-secondary);
  margin-bottom: 20px;
}

.auth-block {
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.auth-label {
  font-size: 12px;
  font-weight: 500;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  color: var(--text-secondary);
  margin-bottom: -4px;
}

.auth-note {
  font-size: 12px;
  line-height: 1.5;
  color: var(--text-secondary);
  margin: 2px 0 0;
}

/* The sealed-link screen (§5.4). The address line is the only thing this screen can say about the
   file before anything is fetched — there is no name here, because the name lives in the hub's row
   and reading it takes an account this visitor does not have. */
.sealed-where {
  font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
  font-size: 12px;
  color: var(--text-secondary);
  word-break: break-all;
  margin: 8px 0 16px;
}

.sealed-owner {
  color: var(--text-primary);
}

/* Stated as its own rule rather than left to inherit from `.auth-note` beside it: this line is
   empty until something goes wrong, and a rule that exists only in the sibling is a rule that
   disappears the day the markup is rearranged. */
.sealed-hash {
  color: var(--text-secondary);
}

/* The signing screen a file page on ANOTHER hub sends a reader to (§5.4.6). The reader is about to
   put his key over a document, so the four facts and the bytes themselves are the whole screen —
   the same four the asking page prints, because both roads end at the same act. */
.sign-where {
  margin: 8px 0 16px;
}

.sign-where dt {
  font-size: 12px;
  color: var(--text-secondary);
}

.sign-where dd {
  margin: 0 0 6px;
  word-break: break-all;
}

/* The bytes are a document to be read, not code to be trusted: wrapped rather than scrolled
   sideways, because a signature covers what the reader can see, and a line he has to scroll to is
   a line he did not read. */
.sign-bytes {
  font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
  font-size: 12px;
  color: var(--text-primary);
  background: var(--bg-secondary);
  border-radius: 8px;
  padding: 10px;
  margin: 4px 0 12px;
  white-space: pre-wrap;
  word-break: break-all;
  max-height: 220px;
  overflow-y: auto;
}

/* Empty until something goes wrong, and an empty paragraph that keeps its line is a gap the reader
   reads as something that failed to load. */
.sign-note:empty {
  display: none;
}

.sealed-note:empty {
  display: none;
}

.auth-waiting {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 13px;
  color: var(--text-secondary);
  padding: 10px 0;
}

/* The waiting mark, sized down to sit beside the sentence that explains what is being waited for.
   It is the same mark as on any other screen (`spinnerArcs`), which is the point: a reader who has
   seen the hub wait once has seen it wait everywhere. */
.auth-waiting .spinner-arcs {
  width: 14px;
  height: 14px;
  flex: none;
}

/* A key most people never need does not get a third of the screen: it opens when asked for. */
.auth-more {
  margin-top: 20px;
  border-top: 1px solid var(--border-color);
  padding-top: 12px;
}

.auth-more > summary {
  cursor: pointer;
  font-size: 13px;
  color: var(--text-secondary);
  padding: 6px 0;
  list-style: none;
}

.auth-more > summary::-webkit-details-marker { display: none }

.auth-more > summary::before {
  content: '\25B8';
  display: inline-block;
  margin-right: 8px;
  transition: transform 0.15s;
}

.auth-more[open] > summary::before { transform: rotate(90deg) }

.auth-more > summary:hover { color: var(--text-primary) }

.auth-more .auth-block { margin-top: 10px }

/* A closed <details> hides its children by a UA rule that any explicit `display` overrides — and
   `.auth-block` sets one. Without this line the memo-key form is on screen while the disclosure
   that owns it says it is folded away. */
.auth-more:not([open]) .auth-block { display: none }

#form-enter-key input,
#form-memo-key input[type="password"],
#form-regular-key input {
  width: 100%;
  min-height: 44px;
  padding: 11px 12px;
  border: 1px solid var(--border-color);
  border-radius: 8px;
  font-size: 16px; /* 16px or iOS zooms the page on focus */
  background: var(--bg-input);
  color: var(--text-primary);
}

#form-enter-key input:focus,
#form-memo-key input:focus,
#form-regular-key input:focus {
  outline: none;
  border-color: var(--border-focus);
  box-shadow: 0 0 0 3px var(--focus-ring);
}

.login-btn {
  width: 100%;
  min-height: 44px;
  padding: 12px 24px;
  background: var(--accent);
  color: var(--text-on-primary);
  border: none;
  border-radius: 8px;
  font-size: 15px;
  font-weight: 500;
  cursor: pointer;
  transition: background 0.2s;
}

.login-btn:hover {
  background: var(--accent-hover);
}

.login-btn.secondary {
  background: transparent;
  color: var(--accent);
  border: 1px solid var(--border-color);
}

.login-btn.secondary:hover {
  background: var(--bg-hover);
}

@media (min-width: 600px) {
  #key-prompt-screen,
  #login-screen { padding-top: 48px }
  .auth-card {
    border: 1px solid var(--border-color);
    box-shadow: var(--shadow-sm);
    padding: 28px;
  }
}

.divider {
  text-align: center;
  margin: 24px 0;
  color: var(--text-secondary);
  position: relative;
}

.divider::before,
.divider::after {
  content: '';
  position: absolute;
  top: 50%;
  width: 45%;
  height: 1px;
  background: var(--border-color);
}

.divider::before {
  left: 0;
}

.divider::after {
  right: 0;
}

#form-regular-key input {
  width: 100%;
  padding: 12px;
  margin-bottom: 12px;
  border: 1px solid var(--border-color);
  border-radius: 4px;
  font-size: 14px;
}

#form-regular-key input:focus {
  outline: none;
  border-color: var(--accent);
}

/* Inbox */
#inbox-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 16px;
}

#inbox-header h2 {
  font-size: 20px;
  font-weight: 400;
}

#btn-refresh {
  padding: 8px 16px;
  background: var(--bg-primary);
  border: 1px solid var(--border-color);
  border-radius: 4px;
  cursor: pointer;
}

#btn-refresh:hover {
  background: var(--bg-hover);
}

#inbox-list,
#encoded-list,
#drafts-list,
#sent-list {
  background: var(--bg-primary);
  border: 1px solid var(--border-color);
  border-radius: 8px;
  overflow: hidden;
}

.inbox-item {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  /* The row is what a reader taps to open a letter — the single most-used
     target in the client, and it measured 35px before this. */
  min-height: 48px;
  padding: 10px 12px;
  border-bottom: 1px solid var(--kbd-bg);
  cursor: pointer;
  transition: background 0.2s;
}

.inbox-item:hover {
  background: var(--bg-hover);
}

.inbox-item:last-child {
  border-bottom: none;
}

.inbox-item.unread {
  font-weight: 500;
}

.inbox-item .sender {
  width: 120px;
  font-size: 13px;
  color: var(--text-primary);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.inbox-item .subject {
  flex: 1;
  font-size: 13px;
  color: var(--text-secondary);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.inbox-item .meta-info {
  display: flex;
  align-items: center;
  gap: 8px;
}

.inbox-item .collapsed {
  background: var(--kbd-bg);
  color: var(--text-secondary);
  font-size: 11px;
  padding: 2px 6px;
  border-radius: 10px;
}

.inbox-item .date {
  color: var(--text-secondary);
  font-size: 11px;
  white-space: nowrap;
}

/* Drafts and Sent put a body preview (and, when the hub refused, the reason) after the subject in
   the same cell. Dimmed, because the subject is what names the letter. */
.inbox-item .preview {
  margin-left: 8px;
  color: var(--text-secondary);
  opacity: 0.75;
}

.inbox-item .meta-info .action-btn {
  padding: 4px 10px;
  font-size: 12px;
}

/* A row in Sent that never left, marked the way the inbox marks spam: on the edge, so it reads
   before the text does. */
.inbox-item.not-sent {
  border-left: 3px solid var(--danger);
}

.inbox-item.bucket-0 {
  border-left: 3px solid var(--warning);
}

.inbox-item.bucket-2 {
  border-left: 3px solid var(--success);
}

.inbox-item.bucket--1 {
  opacity: 0.5;
}

/* Pagination */
#inbox-pagination {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 16px;
  margin-top: 16px;
  /* Room for `.fab`, which floats over this row on a phone. Without it the arrows sit under the
     button on a short screen and cannot be reached at all. The desktop block takes it back. */
  padding-bottom: 72px;
}

#inbox-pagination button {
  padding: 8px 16px;
  background: var(--bg-primary);
  border: 1px solid var(--border-color);
  border-radius: 4px;
  cursor: pointer;
}

#inbox-pagination button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

#page-info {
  color: var(--text-secondary);
}

/* Message view */
#message-content {
  background: var(--bg-primary);
  border: 1px solid var(--border-color);
  border-radius: 8px;
  padding: 12px;
}

/* Two columns at every width: the letter's facts on the left, its acts at the right end of the
   header. This used to be a column on a phone with `.message-actions` starting at the left, which
   put the «⋯» UNDER the metadata instead of beside «От» — where desktop already had it and where
   the reader reaches for it (owner, 19.10.2026 17:23). The same press then opened a menu anchored
   `right:0` to a button standing at the screen's left edge, so the panel grew off the left side
   and its items were unreadable. `minmax(0, 1fr)` lets a long address wrap inside its own column
   instead of pushing the acts out of the header. */
#message-content .message-header {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  column-gap: 8px;
  align-items: start;
  margin-bottom: 16px;
  padding-bottom: 16px;
  border-bottom: 1px solid var(--kbd-bg);
}

#message-content .message-meta {
  min-width: 0;
  color: var(--text-secondary);
  font-size: 13px;
  line-height: 1.6;
}

#message-content .message-actions {
  display: flex;
  justify-content: flex-end;
  gap: 8px;
  flex-wrap: wrap;
  align-items: flex-start;
}

/* The destructive button, stated once. It was worn on two screens and drawn only inside their
   scopes, so a third screen asking for the same role got an unstyled browser button — the role and
   its box have to be declared together, not implied by whoever happens to contain it. The scoped
   rules below still refine it; this is the box every wearer starts from. */
.delete-btn {
  padding: 6px 16px;
  background: var(--bg-primary);
  border: 1px solid var(--border-color);
  border-radius: 4px;
  color: var(--danger);
  cursor: pointer;
  font-size: 13px;
}

.delete-btn:hover {
  background: var(--bg-hover);
  border-color: var(--danger);
}

#message-content .delete-btn {
  padding: 6px 16px;
  background: var(--bg-primary);
  border: 1px solid var(--border-color);
  border-radius: 4px;
  color: var(--danger);
  cursor: pointer;
  font-size: 13px;
}

#message-content .delete-btn:hover {
  background: var(--bg-hover);
  border-color: var(--danger);
}

.trust-btn {
  padding: 6px 12px;
  border: 1px solid transparent;
  border-radius: 4px;
  cursor: pointer;
  font-size: 13px;
  font-weight: 500;
  transition: background 0.2s, border-color 0.2s;
}

.trust-btn-trusted {
  background: var(--trusted-bg);
  color: var(--success-text);
  border-color: var(--success);
}

.trust-btn-trusted:hover {
  background: var(--trusted-bg);
  border-color: var(--success-text);
}

.hint-warning {
  /* A hint that is a warning: the key under it exists in one place only, and a line the reader
     skims past is the difference between a file and a lost file. The token is the theme's own
     warning ink, so it is legible on both surfaces. */
  color: var(--warning-text);
}

.trust-btn-spam {
  background: var(--spam-bg);
  color: var(--warning-text);
  border-color: var(--warning);
}

.trust-btn-spam:hover {
  background: var(--warning-bg);
  border-color: var(--warning-text);
}

.trust-btn-drop {
  background: var(--bg-hover);
  color: var(--danger);
  border-color: var(--danger);
}

.trust-btn-drop:hover {
  background: var(--danger-bg);
  border-color: var(--danger);
}

.user-card-block {
  background: var(--danger) !important;
  color: var(--bg-primary) !important;
}

.user-card-block:hover {
  background: var(--danger-hover) !important;
}

#message-content .body {
  line-height: 1.6;
}

#message-content .blob-content {
  background: var(--bg-hover);
  border: 1px solid var(--text-primary);
  border-radius: 4px;
  padding: 16px;
  font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
  font-size: 13px;
  white-space: pre-wrap;
  word-wrap: break-word;
  overflow-x: auto;
}

#message-content .notice-body {
  background: var(--bg-active);
  border: 1px solid var(--bg-active);
  border-radius: 4px;
  padding: 16px;
  color: var(--info-text);
}

/* Deposit screen */
.deposit-card {
  background: var(--bg-primary);
  border: 1px solid var(--border-color);
  border-radius: 8px;
  padding: 24px;
  margin-bottom: 16px;
}

.deposit-card h3 {
  font-size: 16px;
  font-weight: 500;
  margin-bottom: 12px;
  color: var(--text-primary);
}

/* Name plus the open-in-a-tab control on one line. The icon keeps its own 24px box (it is a press
   target, not a glyph in the text), so the line is a flex row and the name is allowed to shrink:
   a long file name must push the icon along, not off the card. */
.file-detail-head {
  display: flex;
  align-items: center;
  gap: 8px;
}

.file-detail-head #file-detail-name {
  min-width: 0;
  overflow-wrap: anywhere;
}

.balance-amount {
  font-size: 32px;
  font-weight: 500;
  color: var(--accent);
  margin-bottom: 4px;
}

.balance-hold {
  font-size: 13px;
  color: var(--text-secondary);
  margin-bottom: 12px;
}

/* How long the amount above lasts (§8.5). It sits under the balance because it is a statement ABOUT
   that number, not a second number competing with it — hence the smaller head and the gap that
   separates the pair from the button below. */
.balance-runway {
  margin-bottom: 12px;
}

.runway-head {
  font-size: 15px;
  color: var(--text-primary);
  margin-bottom: 4px;
}

/* One role, two elements. `.action-btn` is worn by <button> and by <a> alike (a
   download is a link, and it must not stop being one), and the two agree on
   nothing by default: a button centres its label in its own box, a link is a run
   of text sitting on the baseline. With `display: inline-block` and the 44px tap
   floor below, the link's label parked at the top of a 44px box while the buttons
   beside it stayed centred (owner, 02.09). The box is therefore STATED here
   rather than inherited from the user agent — that is the whole point of a role
   two different elements can wear. */
/* FSP's own CTA (`fsp-ref/app.css` `.button`) is a pill: `border-radius:25px` plus a soft
   shadow. Its padding/font (15px 30px, 18px bold) assume short English labels ("Publish",
   "Send") — this project's labels run much longer (ru i18n, "Top up by transfer via
   Vizonator"), so padding and font stay as they were rather than risk wrapping/overflow on
   a screen nobody has looked at yet; 25px still reads as a full pill regardless, because a
   corner radius clamps to half the box's own height once it exceeds it. The icon-only role
   (`.icon-btn`, above) states its own padding/radius and is unaffected by this rule. */
.action-btn {
  padding: 8px 16px;
  background: var(--accent);
  color: var(--bg-primary);
  border: none;
  border-radius: 25px;
  box-shadow: var(--shadow-sm);
  cursor: pointer;
  font-size: 14px;
  /* A form control does not inherit the page's font — the UA gives a <button> its own — so this
     says what the rule above already promises: one role, one box, whichever element wears it.
     Measured at 1280px before this line: the links beside `#btn-fast-award` came out in
     `-apple-system` and the button itself in Arial, in the same row of the same weight. The rest
     of this file patches this per-selector on every input and textarea it styles; the role rule is
     where it belongs once. */
  font-family: inherit;
  line-height: 1.2;
  text-decoration: none;
  text-align: center;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
}

.action-btn:hover {
  background: var(--accent-hover);
}

/* The icon-only role FSP draws as `i.icon`: 35×35, a circle, filled only on hover — never a wedge
   of `.action-btn`'s accent colour, because an icon standing alone is not the recommended press
   the way a labelled CTA is (owner's own reference screenshots, 22.09: the editor's attach/gear
   glyphs carry no fill until touched). It still WEARS `.action-btn` or a tone modifier
   (`.delete-btn`, `.danger-btn`) in the markup — `iconControl()` builds it that way — but the box
   is this rule's, not theirs, and the two are kept apart the way `.act-btn`'s 44px target and its
   28px `::before` are: the accessibility floor stays 44×44 (untouched, invisible), the circle FSP
   actually draws is a `::before` sized to FSP's own number so a later tone rule can colour it
   without ever touching the box ([[a-global-tap-floor-outranks-a-local-height]]). Declared AFTER
   `.action-btn` on purpose — same specificity, so source order settles it here rather than by
   accident wherever a caller happens to add a second class. */
.icon-btn {
  position: relative;
  width: 44px;
  height: 44px;
  min-width: 44px;
  min-height: 44px;
  padding: 0;
  background: none;
  border: none;
  border-radius: 0;
  color: var(--text-primary);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
}

.icon-btn:hover,
.icon-btn:focus-visible {
  background: none;
}

.icon-btn::before {
  content: '';
  position: absolute;
  width: 35px;
  height: 35px;
  border-radius: 50%;
  transition: background 0.3s ease-out;
}

.icon-btn:hover::before,
.icon-btn:focus-visible::before {
  background: var(--icon-hover);
}

/* Lifted into the positioned layer so it paints ABOVE `::before` — a non-positioned child paints
   under a positioned pseudo-element regardless of DOM order, which is the one part of this rule
   that is not obvious from reading the box alone (same fix `.act-btn .icon` already carries). */
.icon-btn .icon {
  position: relative;
  width: 18px;
  height: 18px;
}

/* Two tones ride the same circle: the destructive press stays red, the rest of the box stays the
   base rule's. `!important` on both — `.delete-btn` is scoped (`.file-actions .delete-btn`,
   0,2,0, the same weight this rule carries) and a scoped rule beaten only by source order is a
   rule beaten again the day somebody reorders the file; `!important` makes the win a fact of the
   rule rather than of where it happens to sit. */
.icon-btn.delete-btn {
  background: none !important;
  border: none !important;
  color: var(--danger) !important;
}

.icon-btn.delete-btn:hover {
  background: none !important;
}

.icon-btn.delete-btn:hover::before {
  background: var(--icon-hover);
}

.icon-btn.danger-btn {
  background: none !important;
  border-color: transparent !important;
  color: var(--danger) !important;
}

.icon-btn.danger-btn:hover {
  background: none !important;
}

.icon-btn.danger-btn:hover::before {
  background: var(--icon-hover);
}

/* The two deposit routes sit side by side on a desktop and stack on a narrow screen. Both are
   buttons of the same weight: neither transfer nor award is the recommended one — they cost the
   sender different things (tokens vs energy), and only he knows which he has. */
/* The vocabulary of tasks, as chips: a list of names the hub knows, priced by one line of prose
   above them (the hub sells a second of time, not a task name). */
/* The third level of the CPU drilldown: what ran inside ONE hour, drawn under that hour's row and
   stepped in, so the rows below it still read as hours of the same day rather than as a new list. */
.cpu-hour-ops {
  margin: 4px 0 8px 24px;
  padding-left: 12px;
  border-left: 2px solid var(--border-color);
}

.cpu-ops-list {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-top: 8px;
}

.cpu-op-chip {
  flex: 0 0 auto;
  padding: 2px 8px;
  border: 1px solid var(--border-color);
  border-radius: 10px;
  font-size: 12px;
  color: var(--text-secondary);
}

.deposit-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-top: 12px;
}

.deposit-actions .action-btn {
  flex: 1 1 200px;
}

/* The two ways to ask for the SAME withdrawal: a stated amount, or everything at once. One row on
   a desktop and stacked on a narrow screen, and neither is the recommended one — asking for the
   whole balance is the shorter press, not the better one, so it wears the neutral role. */
.withdraw-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-top: 12px;
}

.withdraw-actions .btn-secondary,
.withdraw-actions .action-btn {
  flex: 1 1 200px;
}

/* The extension route: an amount of energy the user chooses, then one signed award. */
.deposit-award {
  margin-top: 16px;
  padding-top: 16px;
  border-top: 1px solid var(--border-color);
}

.deposit-award-row {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  align-items: center;
  margin: 8px 0;
}

.deposit-award-row .action-btn {
  flex: 1 1 220px;
}

.deposit-address {
  display: flex;
  align-items: center;
  gap: 12px;
  background: var(--bg-hover);
  border: 1px solid var(--text-primary);
  border-radius: 4px;
  padding: 12px 16px;
  margin: 12px 0;
  font-size: 18px;
  font-weight: 500;
  font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
}

.copy-btn {
  padding: 4px 12px;
  background: var(--bg-primary);
  border: 1px solid var(--border-color);
  border-radius: 4px;
  cursor: pointer;
  font-size: 12px;
  color: var(--text-secondary);
}

.copy-btn:hover {
  background: var(--bg-hover);
}

.deposit-rules {
  margin: 16px 0;
}

.deposit-rules h4 {
  font-size: 14px;
  font-weight: 500;
  margin-bottom: 8px;
  color: var(--text-primary);
}

.deposit-rules ul {
  padding-left: 20px;
  color: var(--text-secondary);
  line-height: 1.8;
}

.deposit-rules code {
  background: var(--bg-hover);
  padding: 2px 6px;
  border-radius: 3px;
  font-size: 13px;
}

/* `.external-link { margin-top: 8px }` stood here, and it is gone rather than kept (owner, 21.09:
   «зелёная кнопка в балансе больше чем соседние синие»). It was written when these two deposit
   links were blocks that needed a gap above them; `.deposit-actions` became a flex row with
   `gap: 8px` since, which is the same gap at every width INCLUDING the wrap, so the margin had no
   work left. What it did instead was stretch its neighbour: a flex row sizes itself to the tallest
   ITEM, a link's outer height was 44 + 8 of margin = 52, and `#btn-fast-award` — the only control
   in the row wearing no margin — was stretched to that 52 while the two links beside it stayed at
   44. Measured at 1280px: links 304x44, green button 304x52, all three with the same natural
   height of 44. One role worn by two elements, and a margin stated for one of them. */

/* Ledger list */
#ledger-list {
  max-height: 400px;
  overflow-y: auto;
}

.ledger-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 10px 12px;
  border-bottom: 1px solid var(--kbd-bg);
  font-size: 13px;
}

.ledger-item:last-child {
  border-bottom: none;
}

.ledger-reason {
  color: var(--text-primary);
  font-weight: 500;
}

.ledger-amount {
  font-weight: 500;
  font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
}

.ledger-amount.credit {
  color: var(--success);
}

.ledger-amount.debit {
  color: var(--danger);
}

.ledger-date {
  color: var(--text-secondary);
  font-size: 12px;
}

/* What a daily "resources" charge was made of, under its date. Its own rule rather than reuse of
   .ledger-date: the two say different kinds of thing (when it happened / what it consisted of) and
   a role shared by habit is a role that drifts when one of its wearers is restyled. */
.ledger-split {
  color: var(--text-secondary);
  font-size: 12px;
  margin-top: 2px;
}

/* A paywall row carries two controls where a ledger row carries one, and the card that opens
   under it is the second thing this list can show. Its own rule rather than reuse of
   `.file-actions`: that one is a row of controls over a FILE, and a role shared by habit is a role
   that drifts when one of its wearers is restyled. */
.ledger-actions {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
}

.paywall-card {
  padding: 4px 12px 10px;
  border-bottom: 1px solid var(--kbd-bg);
  font-size: 13px;
}

.paywall-card h4 {
  margin: 12px 0 4px;
  font-size: 12px;
  font-weight: 500;
  color: var(--text-secondary);
}

/* Files screen */
.file-actions {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
}

/* A row of controls is one shape wearing different colours: the size and the box
   are said for the ROW, and only the paint differs below. Said per class, the
   link and the two buttons drifted apart in padding and in height. */
.file-actions > * {
  font-size: 13px;
  padding: 6px 14px;
  line-height: 1.2;
  border-radius: 4px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  cursor: pointer;
  text-decoration: none;
}

/* The card's five controls are ICONS now (owner, 12.09): worded, the row read as a paragraph and
   wrapped on a phone. The label did not leave — it moved into `title`/`aria-label`, which the
   pointer and the screen reader both read.
   The padding patch that used to live here compensated for `.action-btn`'s rectangular box; the
   shared `.icon-btn` rule (22.09, FSP's own circle) now owns the box outright, so a second patch
   here would only fight it. */

.file-actions .delete-btn {
  background: var(--bg-primary);
  color: var(--danger);
  border: 1px solid var(--border-color);
}

.file-actions .delete-btn:hover {
  background: var(--bg-hover);
  border-color: var(--danger);
}

/* The processing panel is a SECTION, not a row (owner, 10.09): a heading of its own,
   then the tasks as a grid that wraps. It used to wear `.file-actions`, whose whole
   job is to lay controls out in ONE line — so the heading became the first control
   and every task past the card's width was off the edge. The set grows with whatever
   engines the operator installed, which is exactly the case a single line cannot hold. */
.file-task-section {
  margin-top: 16px;
  padding-top: 12px;
  border-top: 1px solid var(--border-color);
}

.file-task-title {
  margin: 0 0 8px;
  font-size: 13px;
  font-weight: 600;
  color: var(--text-secondary);
}

.file-task-grid {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}

/* Same box as a row control: the buttons here and the ones above the panel are the
   same kind of thing, and saying the size once per place is how they drift apart. */
.file-task-grid > .file-task-btn {
  font-size: 13px;
  padding: 6px 14px;
  line-height: 1.2;
  border-radius: 4px;
  box-sizing: border-box;
  cursor: pointer;
}

/* A task that will ASK before it spends anything (owner, #1010). The mark is part of the button,
   not a tooltip: a hint that needs hovering does not exist on a phone, and the difference it
   states — a press that opens a question versus a press that starts paid work — is one the reader
   needs BEFORE pressing. */
.file-task-grid > .file-task-btn-asks {
  display: inline-flex;
  align-items: center;
  gap: 6px;
}

.file-task-grid > .file-task-btn-asks .icon {
  width: 14px;
  height: 14px;
  opacity: 0.75;
}

/* A finished task. It reads as spent rather than offered — this button will not take money — but
   it is NOT `disabled`, because it still leads to the result (owner, 10.09). The pointer stays a
   pointer for exactly that reason: dimmed and un-clickable is a dead end, and the reader has no
   way left to ask where his preview went. */
.file-task-grid > .file-task-btn-done {
  opacity: 0.62;
  font-weight: normal;
}

/* What a derived file was derived FROM. The name is a link into Files, the icon opens
   the bytes; both sit on the baseline of the sentence that names them. */
.file-derived {
  display: flex;
  align-items: center;
  gap: 6px;
  flex-wrap: wrap;
}

.file-source-link {
  color: var(--accent);
  text-decoration: none;
}

.file-source-link:hover { text-decoration: underline; }

/* An icon-only control still needs a box, or it is a glyph sitting in text with
   nothing to aim at. Sized to the line it sits on rather than to the icon. */
.file-source-open {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 24px;
  height: 24px;
  border-radius: 4px;
  color: var(--text-secondary);
  text-decoration: none;
}

.file-source-open:hover {
  background: var(--bg-hover);
  color: var(--text-primary);
}

/* Upload area */
/*
 * The post-processing choices. A stack rather than a row: on a phone a checkbox with a sentence
 * beside it needs the whole width, and the numeric field belongs under the box it qualifies.
 */
.upload-tasks {
  margin-top: 16px;
}

/*
 * The fold. `<details>` is the platform's own disclosure, so the triangle, the keyboard handling
 * and the open/closed state come from the browser rather than from a handler here — the same
 * reason the memo-key row on the sign-in screen is one.
 */
.upload-tasks > summary {
  cursor: pointer;
  font-weight: 600;
  padding: 6px 0;
  color: var(--text-primary);
}

.upload-tasks > p {
  margin-top: 8px;
}

#upload-tasks-list {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

/*
 * One row of the panel — a checkbox, or the field that qualifies it. Which rows exist is the hub's
 * answer, not this stylesheet's: the role here is only "a line of the task list", and `.hidden`
 * does the showing. Declared because the class is real and a reader of the CSS should find it.
 */
.task-row {
  width: 100%;
}

/*
 * What was picked and not yet sent. A list of rows, each with the one control that belongs to a
 * row of this kind: take it back out.
 */
.upload-picked {
  margin-top: 12px;
}

.upload-picked-list {
  list-style: none;
  margin: 8px 0 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.upload-picked-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 8px;
  border: 1px solid var(--border-color);
  border-radius: 6px;
}

/* The name takes the room that is left, and a long one is cut rather than pushing the size and
   the remove button off the row. */
.upload-picked-name {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.upload-picked-size {
  flex: 0 0 auto;
  color: var(--text-secondary);
  font-size: 13px;
}

.upload-picked-drop {
  flex: 0 0 auto;
}

/* The button that starts the batch. Its own row, under everything it is about. */
.upload-actions {
  display: flex;
  /* Two buttons in a row are two targets, and without a gap they read as one wide control with a
     seam down it — and on a phone a fingertip lands on the seam (owner, 11.09, on the key row). */
  gap: 8px;
  flex-wrap: wrap;
  margin-top: 16px;
}

/* One file's paragraph of the batch report. The rule between two of them is a real <hr>. */
.upload-result-item {
  overflow-wrap: anywhere;
}

.upload-drop-zone {
  border: 2px dashed var(--border-color);
  border-radius: 8px;
  padding: 40px 20px;
  text-align: center;
  cursor: pointer;
  transition: border-color 0.2s, background 0.2s;
}

.upload-drop-zone:hover,
.upload-drop-zone.dragover {
  border-color: var(--accent);
  background: var(--bg-active);
}

.upload-icon {
  font-size: 36px;
  display: block;
  margin-bottom: 8px;
}

.upload-drop-text p {
  color: var(--text-secondary);
  margin: 4px 0;
}

.upload-hint {
  font-size: 12px;
  color: var(--text-secondary);
}

/* Upload progress */
.upload-progress {
  margin-top: 16px;
}

.upload-file-info {
  display: flex;
  justify-content: space-between;
  margin-bottom: 8px;
  font-size: 13px;
}

#upload-file-name {
  font-weight: 500;
  color: var(--text-primary);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  max-width: 70%;
}

#upload-file-size {
  color: var(--text-secondary);
}

.progress-bar {
  height: 6px;
  background: var(--text-primary);
  border-radius: 3px;
  overflow: hidden;
}

.progress-fill {
  height: 100%;
  background: var(--accent);
  border-radius: 3px;
  transition: width 0.3s ease;
}

.upload-status-row {
  display: flex;
  justify-content: space-between;
  margin-top: 6px;
  font-size: 12px;
  color: var(--text-secondary);
}

#upload-percent {
  font-weight: 500;
  color: var(--accent);
}

/* Upload result */
.upload-result {
  margin-top: 16px;
  padding: 12px 16px;
  background: var(--trusted-bg);
  border: 1px solid var(--trusted-bg);
  border-radius: 4px;
}

.upload-result.error {
  background: var(--bg-hover);
  border-color: var(--danger);
}

#upload-result-text {
  font-size: 13px;
  color: var(--text-primary);
  line-height: 1.6;
}

#upload-result-text a {
  color: var(--accent);
  text-decoration: none;
}

#upload-result-text a:hover {
  text-decoration: underline;
}

#upload-result-text code {
  background: var(--bg-hover);
  padding: 2px 6px;
  border-radius: 3px;
  font-size: 12px;
  font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
  word-break: break-all;
}

/* Compose screen */
#compose-screen {
  padding: 0;
}

#compose-header {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 12px 16px;
  background: var(--bg-primary);
  border-bottom: 1px solid var(--border-color);
}

#compose-header h2 {
  flex: 1;
  font-size: 18px;
  font-weight: 400;
  color: var(--text-primary);
}

.send-btn {
  padding: 8px 24px;
  background: var(--accent);
  color: var(--bg-primary);
  border: none;
  border-radius: 4px;
  font-size: 14px;
  font-weight: 500;
  cursor: pointer;
  transition: background 0.2s;
}

.send-btn:hover {
  background: var(--accent-hover);
}

.send-btn:disabled {
  background: var(--border-color);
  cursor: not-allowed;
}

#form-compose {
  padding: 16px;
}

.compose-field {
  margin-bottom: 16px;
}

/* The field's OWN caption, and only that.
 *
 * This was `.compose-field label`, which is every label in the field — including the two
 * `.checkbox-label` rows of the Encryption block, which are labels too. Both rules set `display`,
 * and a descendant selector (0,1,1) outranks a single class (0,1,0), so the flex row that puts a
 * box beside its sentence lost to `display: block` and the checkbox fell back to inline layout:
 * the box on one line, its sentence hanging off the box's BASELINE, which is what the owner saw.
 * `>` says what was always meant — the caption is the field's child, the checkbox rows are not. */
.compose-field > label,
.compose-fold > summary {
  font-size: 12px;
  font-weight: 500;
  color: var(--text-secondary);
  margin-bottom: 4px;
}

.compose-field > label {
  display: block;
}

/* The same caption, as a DOOR. The Encryption block of the compose screen is a fold (owner,
 * 12.10.2026), so the caption it carries is the toggle: the look above is shared rather than
 * restated, and only the pointer is added here.
 *
 * `display` is deliberately NOT set: `<summary>` draws the platform's arrow through
 * `display: list-item`, and the `block` the label above needs would take that arrow away — a
 * toggle that no longer looks like one. The same disclosure is used, and the same reason, on the
 * Files screen's Encryption fold (#644). */
.compose-fold > summary {
  cursor: pointer;
}

/* A closed list on the settings screen. Stated from the class alone — a role written only as
   `#settings-time select` would be a rule about that one section, and the next screen that needs a
   picker would grow a second one (see tests/ui-layout.js). */
/* A quantity and its unit read as one control, so they sit on one line and share the width the
   single select used to have; on a narrow screen they wrap rather than shrink into stubs. */
.sess-ttl-row {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  align-items: center;
}

.sess-ttl-row input[type="number"] {
  flex: 0 0 auto;
}

.sess-ttl-row .setting-select {
  width: auto;
  flex: 1 1 140px;
}

/* A select is a field like the boxes beside it, and in dark mode it used to sit on --bg-primary
   while an input sat on --bg-input: side by side in one row that reads as two different surfaces.
   One field background for both. */
.setting-select {
  width: 100%;
  max-width: 320px;
  min-height: 44px;
  padding: 10px 12px;
  border: 1px solid var(--border-color);
  border-radius: 4px;
  font-size: 14px;
  font-family: inherit;
  background: var(--bg-input);
  color: var(--text-primary);
  box-sizing: border-box;
}

.setting-select:focus {
  outline: none;
  border-color: var(--accent);
}

.compose-field input[type="text"],
.compose-field input[type="number"],
.compose-field input[type="date"],
.compose-field select,
.compose-field textarea {
  width: 100%;
  padding: 10px 12px;
  border: 1px solid var(--border-color);
  border-radius: 4px;
  font-size: 14px;
  font-family: inherit;
  transition: border-color 0.2s;
  background: var(--bg-primary);
  color: var(--text-primary);
  box-sizing: border-box;
}

.compose-field input[type="text"]:focus,
.compose-field input[type="number"]:focus,
.compose-field input[type="date"]:focus,
.compose-field select:focus,
.compose-field textarea:focus {
  outline: none;
  border-color: var(--accent);
}

.compose-field textarea {
  resize: vertical;
  min-height: 200px;
}

/* The press that opens the contact book, standing INSIDE the address field at its start
   (owner, 09.10). A pill below the field was a second thing to find: the reader who cannot recall
   an account name is looking at the address box, so the press belongs there. It is positioned over
   the field's own left padding rather than laid in a flex row beside it — the address runs long and
   a row would shorten the box the reader types into. The input's `padding-left` clears the glyph so
   typed text never runs under it, and `top:50%` centres it on the box whatever height the field
   takes on a phone. Declared AFTER `.compose-field input[type="text"]` above (same specificity, so
   source order settles the padding) and after `.icon-btn` (same, so `absolute` beats its `relative`). */
.compose-to-field {
  position: relative;
}

.compose-to-field input[type="text"] {
  padding-left: 48px;
}

/* Room for the verdict at the far end, but only while there is one to show: most letters are
   written with nothing typed in this field yet, and a permanent gutter is 44px of a phone's line
   spent on a mark that is not there. */
.compose-to-field:has(.to-status:not(.hidden)) input[type="text"] {
  padding-right: 44px;
}

.compose-to-contact {
  position: absolute;
  left: 2px;
  top: 50%;
  transform: translateY(-50%);
}

/* The glyph is two shapes (a person and a magnifier) where the rest of `.icon-btn` carries one, so
   it gets a little more room than the shared 18px to keep both readable. */
.compose-to-contact .icon {
  width: 20px;
  height: 20px;
}

/* The verdict on the recipient list — «Аккаунты найдены» and its three siblings — at the end of the
   field it is about (owner, 09.10). It used to be a line of text UNDER the field, which pushed the
   rest of the form down at the moment the answer arrived and put the words somewhere the reader had
   to look away from what he had typed to read. The mark is where his eye already is; the sentence is
   one hover or tap behind it. */
.to-status {
  position: absolute;
  right: 2px;
  top: 50%;
  transform: translateY(-50%);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
}

.to-status .icon {
  width: 18px;
  height: 18px;
}

.to-status.valid { color: var(--success); }
.to-status.invalid { color: var(--danger); }
.to-status.checking { color: var(--text-secondary); }
/* Not a verdict about the name: the chain did not answer. Red here would say the author typed it
   wrong, which is exactly the thing this state does not know. */
.to-status.unknown { color: var(--warning); }

/* The sentence, for a finger. `title` has no gesture on a phone, so the same words — already in the
   attribute — open as a bubble. Written with `attr()` so there is no second copy of the sentence in
   the DOM to keep true. */
.to-status.tip-open::after {
  content: attr(data-tip);
  position: absolute;
  right: 0;
  top: calc(100% + 4px);
  z-index: 20;
  width: max-content;
  max-width: min(280px, 74vw);
  /* Both axes bounded, because the sentence is not always short: a list of several names the hub
     did not find is the longest thing this bubble ever holds, and unbounded it would run off the
     bottom of a phone. `auto` and not `hidden` — a cut-off verdict would hide the names it exists
     to name (`tests/ui-layers.js` is the guard: every layer says what happens when it does not fit). */
  max-height: min(40vh, 200px);
  overflow-y: auto;
  overflow-wrap: anywhere;
  padding: 6px 10px;
  border-radius: 6px;
  background: var(--bg-toast);
  color: var(--text-on-toast);
  font-size: 12px;
  line-height: 1.35;
  text-align: left;
  white-space: normal;
  box-shadow: var(--shadow-md);
}

/* The same sentence for a control, placed by the client rather than by a `::after` — an icon can
   stand anywhere, and a bubble anchored to its button's own corner would leave the screen at the
   edges, which is the bug this owner report is about (19.10.2026 17:25). `position: fixed` and the
   measured box are what `tipShow` in app.js moves back inside the glass; `pointer-events: none`
   keeps the bubble from eating the finger that asked for it. */
.ui-tip {
  position: fixed;
  left: 0;
  top: 0;
  z-index: 2000;
  width: max-content;
  max-width: min(280px, calc(100vw - 16px - env(safe-area-inset-left) - env(safe-area-inset-right)));
  /* Bounded like the compose field's bubble above: the word can be a sentence on some controls,
     and a layer that says nothing about not fitting runs off a phone. `auto`, not `hidden`, so the
     sentence stays readable instead of disappearing (`tests/ui-layers.js` asks this of every layer). */
  max-height: min(40vh, 200px);
  overflow-y: auto;
  overflow-wrap: anywhere;
  padding: 6px 10px;
  border-radius: 6px;
  background: var(--bg-toast);
  color: var(--text-on-toast);
  font-size: 12px;
  line-height: 1.35;
  text-align: left;
  white-space: normal;
  box-shadow: var(--shadow-md);
  pointer-events: none;
  visibility: hidden;
  opacity: 0;
}

.ui-tip.open {
  visibility: visible;
  opacity: 1;
}

/* The paywall's file field: the address, and the press that opens the file manager beside it.
   The input keeps a line of its own on a narrow screen rather than shrinking to a stub — the same
   wrap the session-TTL row takes, and for the same reason. */
.pay-file-row {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  align-items: center;
}

.pay-file-row input[type="text"] {
  flex: 1 1 200px;
  width: auto;
  min-width: 0;
}

.pay-file-row .action-btn {
  flex: 0 0 auto;
}

/* What the content field is holding, said in words under it: which file is being sold, or which
   address that was not one of his. Stated from its own class rather than borrowed from
   `.confirm-field-hint` — the borrowed rule and the warning rule below name the same element with
   the same weight, and which one wins would be decided by where they sit in this file. */
.pay-file-note {
  display: block;
  margin-top: 4px;
  font-size: 12px;
  color: var(--text-secondary);
}

.pay-file-note.warning {
  color: var(--warning-text);
}

/* Nothing to say, no line: the field is followed by a form of three more fields, and a gap for a
   sentence that is not there reads as something that failed to load. */
.pay-file-note:empty {
  display: none;
}

/* The file manager inside a dialog: a filter, a list of what it holds, and the press that puts a
   new file into it. The list is the only part with a height of its own — a reader's library is
   whatever it is, and a dialog that grew with it would push its own buttons off a phone. */
.pick-box {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.pick-search {
  width: 100%;
  box-sizing: border-box;
  min-height: 44px;
  padding: 8px 12px;
  border: 1px solid var(--border-color);
  border-radius: 4px;
  background: var(--bg-input);
  color: var(--text-primary);
  font-size: 14px;
  font-family: inherit;
}

.pick-list {
  display: flex;
  flex-direction: column;
  gap: 2px;
  max-height: 42vh;
  overflow-y: auto;
  border: 1px solid var(--border-color);
  border-radius: 4px;
}

.pick-row {
  display: flex;
  justify-content: space-between;
  gap: 12px;
  align-items: center;
  width: 100%;
  box-sizing: border-box;
  min-height: 44px;
  padding: 8px 12px;
  border: 0;
  border-bottom: 1px solid var(--border-color);
  background: none;
  color: var(--text-primary);
  font-size: 14px;
  font-family: inherit;
  text-align: left;
  cursor: pointer;
}

.pick-row:last-child {
  border-bottom: 0;
}

.pick-row[aria-selected="true"] {
  background: var(--accent);
  color: var(--text-on-primary);
}

/* The words side of a row: a picture, when the hub already holds one, and the name beside it. */
.pick-row-main {
  display: flex;
  align-items: center;
  gap: 8px;
  min-width: 0;
}

/* A row of a tick-list — the mark and the face share one slot, and only one of them is drawn at a
   time (the owner's Gmail reference, 09.10). On a row that OWNS a face the slot holds the face alone
   while the row is merely offered, and the tick takes its place once the row is taken (owner, 12.10
   22:07). On a row with no face the small box IS the offer, so it stays until it is ticked. Which
   side a row is on is then one glance at the place the eye already goes, and the row is not asked to
   be read as two independent marks that could disagree. */
.pick-row-lead {
  display: inline-flex;
  align-items: center;
  flex: none;
}

.pick-check {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 18px;
  height: 18px;
  box-sizing: border-box;
  border: 2px solid currentColor;
  border-radius: 3px;
  opacity: .5;
}

/* The tick that stands where a FACE stood is cut from the face's own cloth: the same 40px, the same
   circle. That is not decoration — it is what keeps a row from moving when it is taken (owner, 12.10
   22:07: «сейчас при выборе скачет все окно, потому что высота выделенного блока меняется (а не
   должна, button чекбокса должна быть круглая как аватарка)»): the slot is the same size before and
   after, so neither the name beside it nor the height of the list turns. The rule is keyed to the
   face being IN the row, not to a list being a tick-list, so the file rows — which carry a thumb and
   no face — keep the small box that says they can be ticked. */
.pick-row:has(.chat-avatar) .pick-check {
  width: 40px;
  height: 40px;
  border-radius: 50%;
}

.pick-row:has(.chat-avatar) .pick-check .icon {
  width: 20px;
  height: 20px;
}

.pick-check .icon {
  width: 12px;
  height: 12px;
  opacity: 0;
}

/* The chosen row carries `--accent` as its background, so `currentColor` here is already the colour
   that has to sit ON the accent — the box fills by reading as one, without a second palette. */
.pick-row[aria-selected="true"] .pick-check {
  opacity: 1;
}

.pick-row[aria-selected="true"] .pick-check .icon {
  opacity: 1;
}

.pick-row[aria-selected="true"] .pick-row-main .chat-avatar {
  display: none;
}

.pick-thumb {
  width: 32px;
  height: 32px;
  object-fit: cover;
  border-radius: 3px;
  flex: none;
  background: var(--bg-secondary);
}

.pick-row-note {
  color: var(--text-secondary);
  font-size: 12px;
  white-space: nowrap;
}

.pick-row[aria-selected="true"] .pick-row-note {
  color: inherit;
}

.pick-note {
  color: var(--text-secondary);
  font-size: 12px;
}

.pick-upload-row {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  align-items: center;
}

/* The upload a reader starts from HERE is drawn where he is standing, not on the Files screen he
   is not looking at: the same bar as that screen's panel, one line of it. */
.pick-progress {
  margin: 0;
}

/* The formatting row of the compose field (#669). Buttons of the same weight as the field they sit
   on: no accent colour, because none of these is the recommended thing to press — the author came
   here to write, and the toolbar is not what he came for. The touch target is the size a thumb
   needs (the floor this client uses elsewhere), which is why the padding is not `.action-btn`'s. */
/* FSP never wraps this row (`.editor-formatter{overflow-x:auto;white-space:nowrap}`) — a phone too
   narrow for every button gets a thin sideways scrollbar, not a second row. `flex-wrap:wrap` was
   the owner's own bug report (22.09): a narrow screen broke the row onto two ragged lines with a
   gap between them the record's own text showed through. `nowrap` plus scroll is the fix, and the
   thin custom scrollbar (`::-webkit-scrollbar`, FSP's own 2px) is what keeps that fix from reading
   as "a normal browser scrollbar interrupting a toolbar". */
/* a blank line the author typed, kept as written (owner, 15.10: WYSIWYG) */
.md-blank {
  margin: 0;
}

.md-toolbar {
  display: flex;
  flex-wrap: nowrap;
  align-items: center;
  gap: 4px;
  margin-bottom: 6px;
  overflow-x: auto;
}

.md-toolbar > .md-scroll {
  display: flex;
  flex: 1 1 auto;
  min-width: 0;
  flex-wrap: nowrap;
  align-items: center;
  gap: 4px;
  overflow-x: auto;
}

.md-toolbar:has(> .md-scroll) {
  overflow-x: visible;
}

.md-toolbar > .md-scroll ~ * {
  flex: 0 0 auto;
}

.md-scroll::-webkit-scrollbar {
  width: 2px;
  height: 2px;
}

.md-scroll::-webkit-scrollbar-track {
  background: transparent;
}

.md-scroll::-webkit-scrollbar-thumb {
  background: var(--accent);
  border-radius: 2px;
}

.md-toolbar::-webkit-scrollbar {
  width: 2px;
  height: 2px;
}

.md-toolbar::-webkit-scrollbar-track {
  background: transparent;
}

.md-toolbar::-webkit-scrollbar-thumb {
  background: var(--accent);
  border-radius: 2px;
}

/* The row is cut off at the panel's own edge, and a thin scrollbar is easy to miss (owner, 23.09,
   next to a screenshot showing the row simply ending mid-icon, and asking for a border there that
   says so). Two things were tried first and both drew nothing: a fade to the panel's own
   background (transparent fading to a colour identical to the page behind it IS invisible — the
   defect restated, not fixed) and a sticky flex child carrying a solid line (this browser never
   engaged the sticky constraint on an element whose flow position already sat past the visible
   edge with nothing yet scrolled — it rendered off-screen with the rest of the overflow instead of
   snapping into view). `box-shadow: inset` is neither: it is painted against the element's OWN
   border box, not against its scrolling content, so it stays put at the true right edge
   regardless of `scrollLeft` — the property this trick actually needs. `.can-scroll-more` is a
   class `toolbarScrollHint` (`app.js`) keeps in step with the row's own measurements, on. */
.md-toolbar.can-scroll-more,
.md-scroll.can-scroll-more {
  box-shadow: inset -12px 0 8px -8px var(--accent);
}

/* Flexbox ignores `vertical-align` entirely — the separator's `vertical-align: middle` (below)
   only works once the ROW stops stretching every item to its own height by default
   (`align-items: stretch`), which is why a fixed-height rule beside auto-height buttons read as
   two things at two different heights (owner, 22.09). */

/* 36px square, exempted from the 44px floor two rules below (owner, 23.09 — see that rule's own
   comment for why the exemption is safe here and was not the first time this was tried). A 32px
   pair stood here once and caused a real miss, but in a row that WRAPPED to a second line 4px
   under the first; this row scrolls sideways in one line, the shape FSP's own `.editor-formatter`
   uses at 40px, its icon the same `1.2em` ours is. `padding: 0` and the flex centring below replace
   the old `4px 8px` — the icon is centred by the box now, not by ad-hoc padding around it. */
.md-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  padding: 0;
  background: transparent;
  color: var(--text-secondary);
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
  line-height: 1.2;
  font-family: inherit;
  transition: background 0.3s ease-out, color 0.3s ease-out;
  /* A button inside a scrolling toolbar must refuse to shrink — flex's default `shrink:1` would
     squeeze every icon thinner instead of letting the row scroll, which is the wrap bug's sibling:
     the row stays one line, but the line is unreadable. */
  flex-shrink: 0;
}

/* The rule between the toolbar's groups, drawn as FSP draws it (`editor.css`, `.editor-formatter
   .separator`): a one-pixel column, three pixels of air either side, a fixed 25-pixel height and
   `vertical-align: middle` rather than a stretch. The height is the part that had to be copied and
   not guessed — stretched, the rule grew to whatever the row happened to be and read as a border
   between fields instead of a pause between ideas. */
.md-sep {
  display: inline-block;
  width: 1px;
  height: 25px;
  margin: 3px;
  vertical-align: middle;
  background: var(--border-color);
  flex-shrink: 0;
}

.md-btn:hover {
  background: var(--icon-hover);
}

/* The mark itself, not just its 36px square (owner, 23.09): FSP draws a 24x24 svg inside the same
   button size, `.icon`'s generic 1.2em default undersized it here specifically. */
.md-btn .icon {
  width: 24px;
  height: 24px;
}

/* The editor's selection states (#731, FSP): lit = the selection stands in this mark; the list in
   its own green (the canon names it); half-transparent = nothing for the press to hold (empty or
   many-line selection under an inline pair) — a hint, not a disable. The quote wheel shows its
   depth with an inset bar. The bar itself is sticky: a long article scrolls, the buttons stay. */
.wall-compose .md-toolbar {
  position: sticky;
  top: 0;
  z-index: 3;
  background: var(--bg-card);
  width: 100%;
}

/* FSP's own states (owner 22.09): lit = translucent green wash, glyph re-inked green — never a
   filled verdict block. The deep quote wears FSP's positive-alt pair, so the wheel's two depths
   read as two different lights on one button. */
.md-btn.is-on {
  background: var(--positive-bg);
  color: var(--positive-color);
}

.md-btn.md-dim {
  opacity: 0.45;
}

/* FSP's `.editor-formatter a.disabled .icon{opacity:0.3}` (`editor.css`) — the title takes no
   mark but strikethrough, and a native `disabled` is what makes the press itself refuse, not just
   the paint: a `.md-dim` button still answers a click (the hint there is about applicability, not
   permission), and a heading is not a place any of these marks are meaningful at all. */
.md-btn:disabled {
  opacity: 0.3;
  cursor: default;
}

.md-btn.is-lvl2 {
  background: var(--positive-alt-bg);
  color: var(--positive-alt-color);
}

.md-btn:hover {
  color: var(--text-primary);
}

.field-status {
  font-size: 12px;
  margin-top: 4px;
  min-height: 16px;
}

.field-status.valid {
  color: var(--success);
}

.field-status.invalid {
  color: var(--danger);
}

.field-status.checking {
  color: var(--text-secondary);
}

/* Not a verdict about the name: the chain did not answer. Red here would say the author typed it
   wrong, which is exactly the thing this state does not know. */
.field-status.unknown {
  color: var(--warning);
}

/* Attachments */
.attachments-list {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-bottom: 8px;
  min-height: 24px;
}

/* One line about a file, and deliberately not a button: it says what is attached and offers to take
   it off. The radius is the small one for that reason — at 16px a 30px row is a pill, which is the
   shape this client uses for controls, and the owner read it as one ("это просто файл, а не
   кнопка", photo 16.09). */
.attachment-chip {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 6px 10px;
  background: var(--bg-active);
  border: 1px solid var(--bg-active);
  border-radius: 6px;
  font-size: 12px;
  color: var(--accent-text);
}

/* The cross is the fourth reading of the same 44px floor, and this one is the owner's own: the box
   that kept the floor was a TARGET LYING OVER THE FILE'S NAME (photo 12.10, the devtools highlight
   of `span.remove-attachment 44 × 44` covering the tail of `mrfreemanburn.png (523.3 KiB)`):
   «мышкой нажимаешь левее него и попадаешь, хотя там пустота». The empty space was not empty — it
   was the name, and a press there took the attachment off. A target wider than its glyph is only
   free where nothing else lives; on a chip it buys a miss that destroys the row, so the floor is
   given up HERE, by name and on the owner's instruction: `min-height: 20px; min-width: 20px`, no
   margins at all.

   The element is named in the selector so the floor's `[role="button"]:not(.file-row)` really
   loses. Both rules state `min-width`, that one is written LATER in this file, and `:not()`
   carries the specificity of its own argument — a bare class would have been outranked in silence
   (the same fight the tag chips above lost and had to re-enter as `button.tag-chip`). */
.attachment-chip span.remove-attachment {
  display: flex;
  align-items: center;
  justify-content: center;
  min-width: 20px;
  min-height: 20px;
  margin: 0;
  padding: 0;
  cursor: pointer;
  font-size: 14px;
  color: var(--text-secondary);
  line-height: 1;
}

.attachment-chip .remove-attachment:hover {
  color: var(--danger);
}

/* A file that has been PICKED but is not ready yet (owner, 12.10 22:19: «если выбрал файл, то нет
   никакого прогресс бара, только через какое-то время файл появляется внутри attachments-list,
   стоит там рисовать прогресс и кнопку отмены загрузки»). The wait was real and silent: the row
   appeared only once the bytes had been read and hashed, and until then nothing on screen said the
   pick had been heard at all. Same row, one more thing in it — the file's own name and size are what
   the reader needs to know WHICH file he is waiting for, and the cross beside it is now the way out.
   Grey rather than blue: this row is not attached to anything yet. */
.attachment-chip.loading {
  color: var(--text-secondary);
}

/* The bar is the only thing on this row that moves, and the number beside it is the bytes actually
   read — the reader watched a screen with nothing on it for the whole hash of a large file, and a
   bar that only animates would have said "working" without ever saying "nearly done". `min-width`
   on the number keeps the chip from resizing under the reader's cursor as 9% becomes 10%. */
.attachment-chip .attach-progress {
  width: 64px;
  height: 4px;
  border-radius: 2px;
  background: var(--bg-hover);
  overflow: hidden;
}

.attachment-chip .attach-progress > i {
  display: block;
  height: 100%;
  width: 0;
  background: var(--accent);
}

/* The bytes are all in and the hash is being taken: there is nothing left to count, and a bar that
   stood still at 100% would be indistinguishable from a hung upload. So it sweeps. */
@keyframes attach-sweep {
  from { transform: translateX(-100%); }
  to { transform: translateX(250%); }
}

.attachment-chip.hashing .attach-progress > i {
  width: 40%;
  animation: attach-sweep 1.1s linear infinite;
}

.attachment-chip .attach-pct {
  min-width: 34px;
  text-align: right;
}

/* Message navigation */
#message-nav {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 12px;
}

#message-actions-top {
  display: flex;
  gap: 8px;
}

/* The bar above a view of the Files screen that is not the list. Its own rule rather than a reuse
   of #message-nav: that one is a two-sided bar (back on the left, actions on the right) and this
   one has a single control, so `space-between` there says nothing here and would say the wrong
   thing the day an action is added beside it. */
.files-subnav {
  display: flex;
  align-items: center;
  /* The sales screen puts two ways back side by side (owner, 15.09) — without a gap they touch,
     and on a narrow screen the pair is allowed to wrap instead of overflowing. */
  gap: 8px;
  flex-wrap: wrap;
  margin-bottom: 12px;
}

/* File links in messages */
.file-link {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 4px 8px;
  background: var(--bg-hover);
  border: 1px solid var(--border-color);
  border-radius: 4px;
  color: var(--accent-text);
  text-decoration: none;
  font-size: 13px;
  cursor: pointer;
  transition: background 0.2s;
}

.file-link:hover {
  background: var(--bg-active);
  text-decoration: none;
}

.file-link .file-icon {
  font-size: 14px;
}

/* Message body */
.message-body {
  padding: 16px 0;
  line-height: 1.6;
  word-wrap: break-word;
}

/* Message attachments */
.message-attachments {
  margin-top: 16px;
  padding-top: 16px;
  border-top: 1px solid var(--border-color);
}

.message-attachments h4 {
  font-size: 13px;
  font-weight: 500;
  color: var(--text-secondary);
  margin-bottom: 8px;
}

.message-attachments .file-link {
  margin-right: 8px;
  margin-bottom: 8px;
}

/* The rest of the conversation, §5.5 */
.thread-box {
  margin-top: 16px;
  padding-top: 16px;
  border-top: 1px solid var(--border-color);
}

.thread-box h4 {
  font-size: 13px;
  font-weight: 500;
  color: var(--text-secondary);
  margin-bottom: 8px;
}

.thread-item {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  align-items: baseline;
  /* A row of the conversation is a tap target like any other row of mail. */
  min-height: 44px;
  padding: 8px 12px;
  border-radius: 6px;
  cursor: pointer;
  background: var(--bg-card);
  color: var(--text-primary);
}

.thread-item:hover {
  background: var(--bg-hover);
}

.thread-sender {
  font-weight: 500;
}

.thread-subject {
  flex: 1 1 auto;
}

.thread-date {
  color: var(--text-secondary);
  font-size: 13px;
}

/* Encoded messages screen */
#encoded-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 16px 24px;
  border-bottom: 1px solid var(--border-color);
  background: var(--bg-primary);
}

#encoded-header h2 {
  font-size: 18px;
  font-weight: 500;
}

.encoded-actions {
  display: flex;
  gap: 8px;
  padding: 12px 24px;
  background: var(--bg-hover);
  border-bottom: 1px solid var(--border-color);
}

.encoded-item {
  position: relative;
}

/* The sealed letter's controls are ICONS and take a line of their own (owner, 16.09). Worded,
   the three of them ran past the row's width and landed on the date — the photograph shows them
   half off the right edge — and the row is `flex-wrap: wrap` with `.subject { flex: 1 }`, so
   nothing was going to move them. `flex-basis: 100%` gives the subject back its line and puts the
   controls under it, which is also where a thumb expects them. */
.encoded-item .encoded-actions-row {
  flex-basis: 100%;
  display: flex;
  gap: 6px;
  margin-top: 6px;
}

/* The box, not the glyph: the global floor already makes every one of these 44×44. The padding
   compensation that used to live here fought the shared `.icon-btn` circle (22.09) the same way
   the file-actions one did — removed for the same reason, the shared rule owns it now. */

/* Danger button style */
.danger-btn {
  background: var(--accent) !important;
  color: var(--text-on-primary) !important;
  border-color: var(--accent) !important;
}

.danger-btn:hover {
  background: var(--danger) !important;
  color: var(--text-on-primary) !important;
  border-color: var(--danger) !important;
}

/* The one-press award (§8.4). A modifier over `.action-btn` like `.danger-btn`, so the BOX is
   the action button's and only the colour is this class's — three same-shaped buttons in one row
   are one shape by design, and the colour is what says which of them spends energy rather than
   tokens. */
.award-btn {
  background: var(--success) !important;
  color: var(--bg-primary) !important;
  border-color: var(--success) !important;
}

.award-btn:hover {
  background: var(--success-text) !important;
  color: var(--bg-primary) !important;
  border-color: var(--success-text) !important;
}

/* Decrypt button style */
.decrypt-btn {
  background: var(--accent);
  color: var(--bg-primary);
  border: none;
  padding: 8px 16px;
  border-radius: 4px;
  cursor: pointer;
  font-size: 13px;
  margin-left: 8px;
}

.decrypt-btn:hover {
  background: var(--accent-hover);
}

#encoded-pagination {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 16px;
  margin-top: 16px;
}

#encoded-pagination button {
  padding: 8px 16px;
  background: var(--bg-primary);
  border: 1px solid var(--border-color);
  border-radius: 4px;
  cursor: pointer;
}

#encoded-pagination button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

#encoded-pagination button:hover:not(:disabled) {
  background: var(--bg-hover);
}

#encoded-page-info {
  color: var(--text-secondary);
}

/* Encryption options in compose */
.encryption-options {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.checkbox-label {
  display: flex;
  align-items: center;
  gap: 8px;
  cursor: pointer;
  font-size: 13px;
  color: var(--text-secondary);
}

.checkbox-label input[type="checkbox"] {
  width: 24px;
  height: 24px;
  cursor: pointer;
  /* The row is a flex line, so a long label shrinks the box beside it — a 24px affordance became
     18px the day a caption grew a clause, and the size a finger aims at was decided by the length
     of the sentence next to it. The width above is the size, not a starting offer. */
  flex-shrink: 0;
}

/* An attachment row: the file, then the controls that act on it.
   Stated because until now the plain row had NO rule of its own — only the paywall variant below
   did — so its height was whatever the link brought with it. `.message-attachments .file-link`
   carries `margin-right`/`margin-bottom: 8px`, written for a WRAPPING LIST of link chips (§5.5);
   the day a row held one link and one control, that spacing became the row's height and a 28px
   button measured 44. A margin meaning "space between chips" cannot also mean "space below a
   row", so the row states its own box and clears the chip spacing inside itself. */
.file-attachment {
  display: flex;
  align-items: center;
  gap: 8px;
}

.file-attachment .file-link {
  margin: 0;
}

/* The same box as a file card's thumbnail, in a row rather than a grid cell: it states its own
   width and height instead of inheriting the card's 100px band, and drops the border that only
   made sense as a divider between a card's picture and its name. Everything else — how a preview
   is painted, when it is fetched, what the button says — is the grid's, unchanged. */
.file-attachment .att-thumb {
  flex: 0 0 auto;
  width: 96px;
  height: 72px;
  border-bottom: none;
  border-radius: 6px;
  overflow: hidden;
}

.file-attachment .att-thumb .file-type-icon {
  font-size: 28px;
}

.file-attachment .att-thumb .file-thumb-load {
  padding: 2px 6px;
  font-size: 11px;
  line-height: 1.2;
  max-width: 92px;
}

/* Paywall indicator for paid files */
.file-attachment.paywall {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 12px;
  margin: 4px 0;
  background: var(--spam-bg);
  border: 1px solid var(--warning);
  border-radius: 4px;
}

.file-attachment.paywall .file-icon {
  font-size: 18px;
}

.file-attachment.paywall .file-info {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.file-attachment.paywall .file-meta {
  font-size: 12px;
  color: var(--text-secondary);
}

.file-attachment.paywall .buy-btn {
  width: 100%;
  margin-top: 8px;
  padding: 6px 16px;
  background: var(--accent);
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 13px;
  font-weight: 500;
}

.file-attachment.paywall .buy-btn:hover {
  background: var(--accent-hover);
}

/* The second road past the same lock (§5.4, #1223): a subscription to the file's owner opens it,
   and the wallet is where one is taken. Drawn UNDER the priced row rather than inside it, because
   the two are alternatives and not one offer — the price is this hub's, the subscription is the
   seller's own and is sold on chain.

   Deliberately not dressed with `.file-attachment`: this line is not a row for a file, it has no
   file's state, and it answers no question about keeping one. Borrowing that row's classes for the
   layout is what made the file-row sweep ask this renderer for a keep offer. */
.subscription-road {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 12px;
  margin: 0 0 4px;
  background: var(--bg-secondary);
  border: 1px dashed var(--border-color);
  border-radius: 4px;
}

.subscription-road-text {
  flex: 1;
  font-size: 12px;
  color: var(--text-secondary);
}

.subscription-road .subscribe-btn {
  flex: 0 0 auto;
  padding: 4px 12px;
  background: transparent;
  color: var(--accent);
  border: 1px solid var(--accent);
  border-radius: 4px;
  cursor: pointer;
  font-size: 12px;
  font-weight: 500;
  text-decoration: none;
  white-space: nowrap;
}

.subscription-road .subscribe-btn:hover {
  background: var(--accent);
  color: white;
}

/* ---------------------------------------------------------------------------
 * Action state — ONE rule for every small control that acts on a row.
 *
 * The state is a colour and nothing else: the glyph says WHAT the control does, the tone says what
 * it can do RIGHT NOW. Written once here because the next such control (buy, revoke, resend) is
 * the one that would otherwise invent a fourth shade of "busy" — and because a tone that lives in
 * six places is a tone that gets five of them right.
 *
 *   ready    text colour   — pressable
 *   idle     50% opacity   — offered, but not now
 *   sent     --pending     — the request has left, the answer has not arrived
 *   done     --success     — already true: the file is in your storage
 *   pay      --warning     — possible, but it costs; the press opens the question
 *   gone     --danger      — no longer available
 *
 * Colour is never the only carrier: every tone also writes `title`, so the state is readable by
 * someone who cannot see the difference and by a screen reader that cannot see at all.
 * --------------------------------------------------------------------------- */
/* The box is 44, the drawing is 28, and that is deliberate rather than a leftover.
   `width: 28px` here did NOT produce a 28px control: the unconditional tap floor at the end of
   this file gives every `button` `min-width`/`min-height: 44px`, and a minimum outranks a width
   no matter which selector is more specific — measured 44x44 while the rule above said 28. Two
   ways out, and only one keeps both promises: shrink the control (and lose the tap target the
   floor exists to guarantee), or keep the target and draw inside it. The glyph is 16px, the
   surface that lights up on hover is 28px, and the area a finger may miss by 8px in any direction
   still hits. So the size a reader sees and the size a thumb aims at are stated separately. */
.act-btn {
  position: relative;
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  padding: 0;
  background: none;
  border: none;
  color: var(--text-primary);
  cursor: pointer;
}

.act-btn .icon {
  position: relative;
  width: 16px;
  height: 16px;
}

/* The visible surface. On the element itself it would be the whole 44px target. */
.act-btn::before {
  content: '';
  position: absolute;
  width: 28px;
  height: 28px;
  border-radius: 4px;
}

.act-btn:hover:not([disabled])::before {
  background: var(--bg-hover);
}

.act-btn[data-tone="idle"] {
  opacity: 0.5;
  cursor: default;
}

.act-btn[data-tone="sent"] { color: var(--pending); }
/* A transfer in progress. Same colour as `sent` and deliberately NOT the same behaviour: this one
   stays pressable, because pressing it is how the reader pauses (#645). */
.act-btn[data-tone="busy"] { color: var(--pending); cursor: pointer; }
.act-btn[data-tone="done"] { color: var(--success); cursor: default; }
.act-btn[data-tone="pay"] { color: var(--warning); }
.act-btn[data-tone="gone"] { color: var(--danger); cursor: default; }

/* §5.4 "Keep" is an `.act-btn` like any other row control: a glyph whose colour is its state. It
   used to be a filled pill with a word in it, twice the height of the file name it sat beside —
   a control louder than its subject. The tone rules above carry everything it needs to say. */

/* ---------------------------------------------------------------------------
 * Desktop. Everything above states the phone; this block states the wider
 * screen. Read it as the list of things a mouse and a large viewport are
 * allowed to change — not as a list of fixes.
 * --------------------------------------------------------------------------- */
@media (min-width: 769px) {
  /* Told to the script, which reads it instead of keeping its own copy of 769. */
  #app {
    --layout: sidebar;
  }

  #header {
    padding: 8px 16px;
    height: 48px;
    flex-wrap: nowrap;
  }

  .brand-mark {
    width: 32px;
    height: 32px;
  }


  /* The drawer becomes a column of the layout again. */
  #sidebar {
    position: static;
    left: auto;
    top: auto;
    bottom: auto;
    z-index: auto;
    transition: none;
    box-shadow: none;
  }

  /* Same one state, read the other way round: here the column is the default and the reader can
     put it away, there the drawer is away and the reader can call it up. */
  #app[data-nav="closed"] #sidebar {
    display: none;
  }

  /* A column of the layout is not covering anything, so there is nothing to dim and nothing to
     wall off — and a wall that survived into this width would swallow every click on the page. */
  #nav-scrim {
    display: none;
  }

  /* Room to write is not scarce here: the header button says what it does in words, and a circle
     floating over the last row would be a second control for the same thing. */
  .fab {
    display: none;
  }

  #btn-compose {
    display: flex;
  }

  #inbox-pagination {
    padding-bottom: 0;
  }

  #main {
    flex-direction: row;
  }

  #content {
    width: auto;
  }

  .inbox-item {
    padding: 12px 16px;
    flex-wrap: nowrap;
  }

  .inbox-item .sender {
    width: 160px;
    font-size: inherit;
  }

  .inbox-item .subject {
    font-size: inherit;
  }

  .inbox-item .date {
    font-size: 12px;
  }

  #message-content {
    padding: 24px;
  }

  /* The letter header needs no desktop override any more: its two columns already read the same
     at both widths (the row it used to be switched into is what the base rule is now). */

  .file-attachment.paywall .buy-btn {
    width: auto;
    margin-top: 0;
  }

  .ledger-item {
    padding: 10px 0;
  }
}

/* ===========================================================================
 * Contacts, Drafts, Sent screens
 * =========================================================================== */

#contacts-header, #drafts-header, #sent-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 12px 16px;
  border-bottom: 1px solid var(--bg-hover);
}

#contacts-header h2, #drafts-header h2, #sent-header h2 {
  margin: 0;
  font-size: 18px;
}

/* Contact section (collapsible group) */
.contact-section {
  margin-bottom: 8px;
  border: 1px solid var(--border-color);
  border-radius: 8px;
  overflow: hidden;
}

.contact-section-header {
  display: flex;
  align-items: center;
  padding: 10px 16px;
  background: var(--bg-hover);
  cursor: pointer;
  user-select: none;
  gap: 8px;
}

.contact-section-header:hover {
  background: var(--bg-hover);
}

.contact-section-header .folder-toggle {
  color: var(--text-secondary);
}

/* Same mechanism as the Mail group above, deliberately: the arrow is one shape and the state is a
   class, so there is no second arrow to forget. */
.contact-section.collapsed .folder-toggle {
  transform: rotate(-90deg);
}

.contact-section-title {
  font-weight: 500;
  color: var(--text-primary);
  flex: 1;
}

.contact-section-header .count {
  font-size: 12px;
  color: var(--text-secondary);
  background: var(--kbd-bg);
  padding: 2px 8px;
  border-radius: 10px;
}

.contact-section-items.hidden {
  display: none;
}

/* Contact row */
.contact-row {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  padding: 12px 16px;
  border-bottom: 1px solid var(--bg-hover);
  gap: 12px;
}

.contact-row:hover {
  background: var(--bg-hover);
}

.contact-icon {
  font-size: 20px;
  flex-shrink: 0;
  width: 32px;
  text-align: center;
}

.contact-info {
  flex: 1;
  min-width: 0;
}

.contact-info strong {
  display: block;
  overflow-wrap: anywhere;
  font-size: 14px;
  color: var(--text-primary);
}

.contact-meta {
  font-size: 12px;
  color: var(--text-secondary);
}

.contact-actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  flex-shrink: 0;
}

.contact-edit-btn {
  min-width: 44px;
  min-height: 44px;
}

/* The same hardcoded `white` as the user card, on the same control: a trust select. Its ink was
   the browser's default (black), so it read, but a white box among dark ones is a control that
   belongs to another screen — and the moment the text colour is themed, it stops reading at all.
   Both halves are named here so neither can drift back. */
.contact-trust-select {
  padding: 4px 8px;
  border: 1px solid var(--border-color);
  border-radius: 4px;
  font-size: 13px;
  background: var(--bg-input);
  color: var(--text-primary);
}

/* .contact-compose-btn used to be a small text button (font-size:12px, padding:4px 12px) on
   the contact row. It is now icon-btn (22.09) — that base rule owns padding/box entirely, and
   this leftover would have overridden it right back to a rectangle at the same specificity
   (same source-order fight .icon-btn.delete-btn had; here nobody needs a tone override, so the
   fix is just deleting the stale rule instead of !important-ing over it). */

/* Draft row */
.draft-row {
  cursor: pointer;
}

.draft-row:hover {
  background: var(--bg-hover);
}

/* Sent row */
.sent-row {
  cursor: pointer;
}

.sent-row:hover {
  background: var(--bg-hover);
}

/* The hub's refusal, shown on the letter itself. A badge says "not sent"; this says why, which is
   the only half the author can act on. */
.message-unsent {
  margin: 0 0 12px;
  padding: 10px 14px;
  border: 1px solid var(--danger);
  border-radius: 6px;
  background: var(--danger-bg-soft);
  color: var(--danger);
  font-size: 13px;
}

/* Where the body would be when the browser could not keep it. It is not a refusal — the letter
   left in full — so it is stated, not alarmed: the reader has to be able to tell this apart from a
   letter he sent blank, and from one the hub rejected. */
.message-body-missing {
  padding: 10px 14px;
  border: 1px dashed var(--border-color);
  border-radius: 6px;
  background: var(--bg-secondary);
  color: var(--text-secondary);
  font-size: 13px;
}

/* Sent pagination */
#sent-pagination {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 16px;
  margin-top: 16px;
}

#sent-pagination button {
  padding: 8px 16px;
  border: 1px solid var(--border-color);
  border-radius: 4px;
  background: var(--bg-primary);
  cursor: pointer;
}

#sent-pagination button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

#sent-page-info {
  color: var(--text-secondary);
}

/* Clickable sender links */
.sender-link {
  cursor: pointer;
  border-bottom: 1px dotted transparent;
  transition: border-color 0.15s;
}

.sender-link:hover {
  border-bottom-color: var(--text-secondary);
}

/* User card modal overlay */
#user-card-overlay {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: var(--bg-overlay);
  z-index: 1000;
  display: flex;
  align-items: center;
  justify-content: center;
  /* Every layer that covers the page scrolls if what it holds is taller than the glass, and centres
     its panel with `margin: auto` for the reason the dialog above spells out. Written once per
     overlay rather than shared through a selector: they are separate screens that happen to be
     drawn alike (see the note under this one), and a rule naming all of them could not be changed
     for one. */
  overflow-y: auto;
  padding: max(16px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) max(16px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left));
}

#user-card-overlay.hidden {
  display: none;
}

#user-card-body {
  width: 100%;
  max-width: 400px;
  /* The card is a page in a layer and can be as long as a page: it gives by scrolling rather than
     by growing past the glass. `margin: auto` centres it while it fits and keeps its top reachable
     once it does not. */
  margin: auto;
  max-height: calc(100dvh - 32px - env(safe-area-inset-top) - env(safe-area-inset-bottom));
  overflow-y: auto;
}

/* The card does NOT clip.
 *
 * It carried `overflow: hidden` — there only to keep the header's tint inside the rounded corner —
 * and the trust menu is an absolutely positioned child of a row in the middle of it. A clipping
 * ancestor cuts a layer at the box, not at the window, so half the choices were painted and then
 * erased by a rule that was about a corner. The corners are now rounded where they are: on the
 * header and on the action strip, the only two children that paint to the card's edge. */
/*
 * The card is a SURFACE, so it takes the surface token. It said `background: white`, and every
 * colour written on it is a theme variable — so in the dark theme the ink (`--text-primary` at
 * #e8eef4) was printed on white, and the fields the reader came to change were the ones that
 * vanished: the trust control's label was invisible in its own box, which is exactly what the owner
 * photographed (13.09). A hardcoded `white` is only ever correct in the theme it was written in.
 */
.user-card {
  background: var(--bg-card);
  border-radius: 12px;
  box-shadow: var(--shadow-lg);
  margin: 16px;
}

.user-card-loading {
  background: var(--bg-card);
  border-radius: 12px;
  padding: 32px;
  text-align: center;
  color: var(--text-secondary);
  max-width: 400px;
  margin: 16px auto;
  box-shadow: var(--shadow-lg);
}

.user-card-header {
  display: flex;
  align-items: center;
  padding: 20px;
  background: var(--bg-hover);
  border-bottom: 1px solid var(--bg-hover);
  border-radius: 12px 12px 0 0;
}

.user-card-avatar {
  width: 48px;
  height: 48px;
  border-radius: 50%;
  background: var(--accent);
  color: white;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 20px;
  font-weight: 600;
  flex-shrink: 0;
}

/* Was `.user-card_names` — one underscore, matching nothing, so the name block sat flush against
   the avatar with no gap. A rule that selects nothing looks exactly like a rule with no effect. */
.user-card-names {
  margin-left: 14px;
  flex: 1;
  min-width: 0;
}

.user-card-display-name {
  font-size: 18px;
  font-weight: 600;
  color: var(--text-primary);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.user-card-account {
  font-size: 14px;
  color: var(--text-secondary);
  margin-top: 2px;
}

.user-card-close {
  background: none;
  border: none;
  font-size: 24px;
  color: var(--text-secondary);
  cursor: pointer;
  padding: 4px 8px;
  border-radius: 4px;
  flex-shrink: 0;
}

.user-card-close:hover {
  background: var(--bg-hover-veil);
}

.user-card-details {
  padding: 16px 20px;
}

/* What THIS conversation has shown, on the correspondent's card. The strip inside is the very one the
   group's card wears (`chatMaterialsStrip`), so this block only gives it the card's own side padding
   and nothing else — a second set of tab styles would be a second answer to the same question. */
.user-card-materials {
  padding: 0 20px 12px;
}

.user-card-field {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  padding: 8px 0;
  border-bottom: 1px solid var(--kbd-bg);
  font-size: 14px;
}

.user-card-field:last-child {
  border-bottom: none;
}

.user-card-label {
  color: var(--text-secondary);
  font-weight: 500;
  flex-shrink: 0;
  margin-right: 12px;
}

/* An avatar published in the account profile. Same box as the letter avatar it replaces, so a
   picture that loads late does not move the rest of the card. */
.user-card-avatar-img {
  object-fit: cover;
  background: var(--kbd-bg);
}

/* The picture field on the profile screen (#668). Round and square like the card's avatar, and
   LARGER than it: the card shows the picture at 48px, which is enough to recognise a face and not
   enough to tell that the wrong file was picked. The frame is a fixed square so a picture that
   arrives late, or never, does not move the fields under it. */
.profile-avatar-preview {
  display: block;
  width: 96px;
  height: 96px;
  margin-top: 12px;
  border-radius: 50%;
  object-fit: cover;
  background: var(--kbd-bg);
}

/* `hidden` has to actually hide, and a class that sets `display` outranks the user agent's
   `[hidden] { display: none }` — so the preview above drew an empty grey disc beside the note
   saying there was no picture, for every account without one. Written against the element rather
   than as a global `[hidden]` rule so it names what it fixes; the check in the profile probe reads
   the ATTRIBUTE, which the client does set, so the probe was green over a circle a reader saw. */
.profile-avatar-preview[hidden] {
  display: none;
}

.user-card-field > span:last-child,
.user-card-field > a:last-child {
  text-align: right;
  min-width: 0;
  overflow-wrap: anywhere;
}

/* An `about` is prose, not a value (#1108 = B). It arrives from the markdown renderer as real
   paragraphs, lists and headings, so it takes the rest of the row and reads from the left edge —
   the value columns above are right-aligned, and a right-aligned paragraph is a ragged wedge. The
   outer margins are dropped because the prose is the whole field: the row's own padding is the
   spacing, and a first-child margin on top of it reads as a gap nobody asked for. */
.user-card-prose {
  flex: 1;
  min-width: 0;
  text-align: left;
  overflow-wrap: anywhere;
}

.user-card-prose > :first-child {
  margin-top: 0;
}

.user-card-prose > :last-child {
  margin-bottom: 0;
}

/* Trust is changed where it is shown: the current value is the button, the alternatives drop out
   of it. Anchored to the value rather than to the card, so the menu opens against the word it is
   about instead of jumping to a corner. */
.trust-menu-wrap {
  position: relative;
  display: inline-block;
  text-align: right;
}

/* A button standing INSIDE a details row, against the value it changes: the trust bucket, and the
   chat's sound act on the correspondent's card (`showUserCard`). One box for both — they are
   different acts with different owners, but a control in a row is ONE shape, and a second copy of
   these numbers is exactly what makes the second one drift from the first (FSP's `.button` rule
   argues the same thing about the pill). */
.trust-menu-toggle,
.user-card-sound,
.user-card-notify {
  background: none;
  border: 1px solid var(--border-color);
  border-radius: 6px;
  padding: 3px 8px;
  font-size: 14px;
  color: var(--text-primary);
  cursor: pointer;
}

.trust-menu-toggle:hover,
.user-card-sound:hover,
.user-card-notify:hover {
  background: var(--bg-hover);
}

/* The notifications row of the correspondent's card is the first `.user-card-field` to hold two
   controls instead of one, and `justify-content: space-between` — written when a row was a label and
   a value — would strand them apart with the label between them. The auto margin hands every spare
   pixel to the gap BEFORE the pair, so the two stay together against the right edge, where the value
   column of every other row sits; the 8px is the distance between two adjacent controls. */
.user-card-field > .user-card-sound {
  margin-left: auto;
}

.user-card-notify {
  margin-left: 8px;
}

.trust-menu-caret {
  display: inline-flex;
  color: var(--text-secondary);
  font-size: 11px;
}

/* A layer says what happens when it does not fit.
 *
 * `min-width` with no upper bound and no `max-height` is a menu the size of its longest hint,
 * hanging below a button that can sit anywhere on the screen: on a short window the last choices
 * were simply off the bottom, and a choice a reader cannot see is a choice the client does not
 * offer. The width is bounded against the VIEWPORT (not the card — the card is 400px and the
 * window can be narrower), the height is bounded here as a floor and again inline by the opener,
 * which knows how much room is actually left, and what does not fit scrolls. `.up` is the same
 * menu hung above its button, for when there is more room there.
 *
 * The floor was 240px and it was too narrow for what the rows hold: a bucket name that never breaks
 * plus a sentence explaining it, so every hint arrived as three or four ragged fragments ("не
 * схлопывается и не ротируется") — the reader was reading a column, not a choice. 320px lets the
 * name and most of a hint sit on one line, and it is still a `min()` against the viewport, so a
 * narrow phone gets the screen rather than an overflow. */
.trust-menu {
  position: absolute;
  right: 0;
  top: calc(100% + 4px);
  z-index: 20;
  min-width: min(320px, calc(100vw - 32px));
  max-width: calc(100vw - 32px);
  max-height: 60vh;
  overflow-y: auto;
  background: var(--bg-primary);
  border: 1px solid var(--border-color);
  border-radius: 8px;
  box-shadow: var(--shadow-lg);
  padding: 4px 0;
}

.trust-menu.up {
  top: auto;
  bottom: calc(100% + 4px);
}

.trust-menu.hidden {
  display: none;
}

.trust-menu-item {
  display: flex;
  align-items: flex-start;
  gap: 8px;
  width: 100%;
  padding: 8px 12px;
  background: none;
  border: none;
  text-align: left;
  font-size: 14px;
  color: var(--text-primary);
  cursor: pointer;
}

/* The name of a bucket is one word and never breaks; the sentence explaining it may wrap as much
   as it likes. Without this the hint claimed the row and the name broke mid-word ("Truste/d"). */
.trust-menu-label {
  flex: 0 0 auto;
  white-space: nowrap;
}

.trust-menu-item:hover {
  background: var(--bg-hover);
}

.trust-menu-item.current {
  font-weight: 600;
}

.trust-menu-item.danger {
  color: var(--danger);
}

.trust-menu-hint {
  flex: 1 1 auto;
  min-width: 0;
  text-align: right;
  color: var(--text-secondary);
  font-size: 12px;
}

/* Tag chips — the two lists an account keeps to be found by (§7.5 `interests` / `categories`).
   Drawn by `tagChips` and by nothing else: the contact card and the wall head show the same list,
   so the shape lives once and the two screens differ only in where the row starts.

   The two lists are told apart by COLOUR, everywhere (owner, 21.09, after Free Speech): they
   behave differently in selection — a category RESETS the others, interests ADD to a set — so a
   reader has to see which is which before that behaviour exists on any screen of ours. Free
   Speech's pair is its accent for interests and red for categories; ours is the same pair in our
   own tokens (`--accent` / `--danger`), which is what keeps both themes correct for free. The ink
   is the page background, as in FSP — on a filled chip in the dark theme a fixed white would fail
   against the light accent. */
.tag-chips {
  display: inline-flex;
  flex-wrap: wrap;
  gap: 5px;
}

/* The card is a label:value grid, so its chips end at the same right edge as the values above
   them; the wall head is prose, and its chips start where the paragraph above starts. */
.user-card-field .tag-chips {
  justify-content: flex-end;
}

/* Idle at 0.6 opacity, full at 1 on hover — FSP's own `.interests a` / `.categories a` (owner,
   23.09, «сверься с fsp»): the chip is a word the reader can act on, not a control competing for
   attention with the article beside it. Shape and padding are FSP's OWN numbers, not ours — the
   pill was rounder and wider than the reference (owner, 23.09, a screenshot pair): `border-radius`
   5px not 10, `padding` 5px sideways with a 2px nudge at the bottom rather than 1/8, 15px type on
   a 25-tall line rather than a 12px chip that had never been given FSP's own scale. */
.tag-chip {
  display: inline-block;
  border-radius: 5px;
  padding: 0 5px 2px;
  font-size: 15px;
  line-height: 25px;
  color: var(--bg-primary);
  background: var(--accent);
  opacity: 0.6;
  transition: opacity 0.2s ease;
}

.tag-chip:hover {
  opacity: 1;
}

/* Underlined-when-selected is a CATEGORY-ONLY mark, and only on the JS side does that show — this
   rule fires off the same `.selected` class either kind of chip carries, but `renderWallComposeTagChips`
   (owner, 23.09, second read: FSP's compose click handler recomputes `.selected` for every
   CATEGORY button on every press, and does nothing of the kind for an interest button in the
   plain-textarea form at all) now only ever adds the class to a category chip. An interest chip
   never gets it, so this rule never fires for one — it does not need its own `:not(.tag-category)`
   guard to say so. */
.tag-chip.selected {
  opacity: 1;
  text-decoration: underline;
}

.tag-chip.tag-category {
  background: var(--danger);
}

/* Two screens now draw the chip as a BUTTON rather than the read-only span `.tag-chip` was
   written for — the gear screen's own row (a press appends the word to the article, owner 23.09)
   and the wall head's filterable form (a press narrows the feed, owner 23.09, «сверься с fsp»).
   Named by both containers rather than a bare `button.tag-chip`: the reset undoes only what a
   `<button>` adds on top of the span (border, own font-FAMILY, pointer) — `font-family` alone,
   not the `font` shorthand: that shorthand resets `font-size`/`line-height` too, and a `<button>`
   is more specific than the bare `.tag-chip` rule that sets FSP's own 15px/25px scale, so the
   shorthand version silently shrank every chip drawn as a button back to the page's own type
   (owner, 23.09, second read — the same screenshot pair that caught the shape/padding drift). */
.wall-compose-tag-chips .tag-chip,
#wall-head-tags .tag-chip {
  border: none;
  font-family: inherit;
  cursor: pointer;
}

.user-card-services a {
  margin-left: 10px;
  white-space: nowrap;
}

.user-card-mono {
  font-family: monospace;
  font-size: 12px;
  color: var(--text-secondary);
}

.user-card-details a {
  color: var(--accent);
  text-decoration: none;
  word-break: break-all;
}

.user-card-details a:hover {
  text-decoration: underline;
}

.user-card-actions {
  display: flex;
  /* The card is at most 400px wide, and its acts row now carries a third button whose label is a
     phrase, not a word ("Voice profile and posts" — the same phrase the contacts list uses). Without
     the wrap the three share the width by shrinking, and the long one breaks into a stack of two-word
     lines inside a pill; with it, a row that does not fit moves down whole. */
  flex-wrap: wrap;
  gap: 8px;
  padding: 12px 20px 16px;
  border-top: 1px solid var(--bg-hover);
  border-radius: 0 0 12px 12px;
}

/* ===========================================================================
 * Files browser (Google Files-like)
 * =========================================================================== */

#files-storage-bar {
  background: var(--bg-primary);
  border: 1px solid var(--border-color);
  border-radius: 8px;
  padding: 16px 20px;
  margin-bottom: 16px;
}

/* No bar and no track: a fill needs a denominator, and the hub does not quote one (§7.4 charges
   what is spent). The rules for one are gone rather than left unused — an empty track is an
   invitation to find a number to fill it with. */
.storage-info {
  display: flex;
  justify-content: flex-start;
  align-items: baseline;
  gap: 8px;
  font-size: 13px;
}

.storage-label {
  font-weight: 500;
  color: var(--text-secondary);
}

.storage-detail {
  font-size: 12px;
  color: var(--text-secondary);
  margin-top: 6px;
}

/* Toolbar */
#files-toolbar {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-bottom: 16px;
  flex-wrap: wrap;
}

.files-upload-btn {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 10px 24px;
  background: var(--bg-primary);
  border: 1px solid var(--border-color);
  border-radius: 24px;
  font-size: 14px;
  font-weight: 500;
  color: var(--text-primary);
  cursor: pointer;
  box-shadow: var(--shadow-sm);
  transition: background 0.2s, box-shadow 0.2s;
  white-space: nowrap;
  width: 100%;
  justify-content: center;
}

.files-upload-btn:hover {
  background: var(--bg-hover);
  box-shadow: var(--shadow-md);
}

.upload-plus {
  font-size: 20px;
  font-weight: 300;
  color: var(--accent);
  line-height: 1;
}

#files-search-wrap {
  flex: 1;
  order: 3;
  width: 100%;
}

#files-search {
  width: 100%;
  padding: 10px 16px;
  border: 1px solid var(--border-color);
  border-radius: 24px;
  font-size: 14px;
  background: var(--kbd-bg);
  transition: background 0.2s, border-color 0.2s;
}

#files-search:focus {
  outline: none;
  background: var(--bg-primary);
  border-color: var(--accent);
}

#files-view-toggle {
  display: flex;
  gap: 2px;
  background: var(--kbd-bg);
  border-radius: 6px;
  padding: 2px;
}

.view-btn,
/* The picker's own switch carries this name and not `.view-btn`, and the split is load-bearing: the
   Files screen asks for its view with a GLOBAL `.view-btn.active` (`controlAt`), so a picker open at
   the same moment would answer for it — `?view=folders` written into the address of a screen that has
   no such button, and a view restored to nothing marked. Two organs, two names.
   ([[a-shared-class-cannot-say-which-of-two-organs]]) */
.pick-view-btn {
  padding: 6px 10px;
  border: none;
  background: transparent;
  border-radius: 4px;
  cursor: pointer;
  font-size: 16px;
  color: var(--text-secondary);
  line-height: 1;
}

.view-btn.active,
.pick-view-btn.active {
  background: var(--bg-primary);
  color: var(--accent);
  box-shadow: var(--shadow-sm);
}

/* The way into the folder tree (#655). Drawn like the view toggle beside it, because it is the
   same kind of thing: a mode of this screen, picked by an icon. Which mode is on is not shown on
   the button — the crumb line below says where the reader is, which is the more useful answer. */
.files-mode-btn {
  padding: 6px 10px;
  border: 1px solid var(--border-color);
  background: var(--bg-primary);
  border-radius: 6px;
  cursor: pointer;
  font-size: 16px;
  color: var(--text-secondary);
  line-height: 1;
  flex-shrink: 0;
}

.files-mode-btn:hover {
  color: var(--accent);
  border-color: var(--accent);
}

/*
 * Something is being looked at through this button and the list below is not everything it holds.
 * The colour is the same one the chosen chip wears, so "this control is doing something" reads as
 * one idea wherever it appears on the screen.
 */
.files-mode-btn.is-on {
  color: var(--accent-text);
  border-color: var(--accent-text);
  background: var(--bg-active);
}

/* Where the list is: a crumb per segment of the path, the last one a fact rather than a link. */
.file-crumbs {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px;
  margin-bottom: 12px;
  font-size: 14px;
  color: var(--text-secondary);
}

.file-crumbs .crumb {
  color: var(--accent);
  cursor: pointer;
}

.file-crumbs .crumb:hover { text-decoration: underline; }

.file-crumbs .crumb-sep { opacity: 0.6; }

.file-crumbs .crumb-here {
  color: var(--text-primary);
  font-weight: 600;
}

/* The same crumb bar, standing inside a sentence rather than on a line of its own: the upload
   panel says "Uploading to:" and the path follows it, so the bar has to flow with that word
   instead of starting a block under it. Two classes deep on purpose — the rule above sets
   `display: flex`, and a rule of the SAME depth would win or lose by its position in this file
   rather than by its meaning ([[a-same-specificity-rule-later-wins]]). */
.hint .file-crumbs {
  display: inline-flex;
  margin: 0;
  font-size: inherit;
  color: inherit;
}

/* The folders under the one being shown. A row of their own rather than a `.file-row` column for
   column: a folder has a name and a date and no size, no tick and no actions, and borrowing the
   file grid's template would leave three empty columns where the missing controls are. */
.files-folders { margin-bottom: 8px; }

.folder-row {
  display: flex;
  align-items: center;
  gap: 8px;
  cursor: pointer;
  padding: 8px 12px;
  border-bottom: 1px solid var(--border-color);
}

.folder-row:hover { background: var(--bg-hover); }

.folder-row .file-type-icon { color: var(--accent); }

/* The way up. Quieter than a folder, because it is a direction and not a place the account holds:
   it is not stored, not charged for, and carries no date — a folder's `changed_at` answers "where
   did I last put something", and that the parent changed says nothing about what is being looked
   at. The arrow points left, the way the crumb line walks. */
.parent-row .file-type-icon { color: var(--text-secondary); }
.parent-row .file-row-name { color: var(--text-secondary); }

.folder-row .file-row-name {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* `.folder-price-btn` stood here — the coin §5.7's folder money door wore on the row (#688). It is
   gone with the coin: the wall is reached from the row's own menu now (long press or right press),
   and a rule nothing renders is a rule the next reader spends a minute on for nothing. The name is
   left as a note rather than deleted in silence, the way `.file-row-actions` below is. */

/* Whose files: two exclusive ticks, both off = everything */
#files-owner-filter {
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
  margin-bottom: 12px;
}

/* Says a row is a shortcut to somebody else's file rather than storage of our own */
.file-origin {
  font-size: 12px;
  color: var(--text-secondary);
}

/* The "this came from something" mark (owner, 10.09): a bubble, not a sentence.
   It is a <button> because it navigates, and a button carries UA defaults — padding, border,
   font, background — that a <span> does not, so each of them is stated here rather than left to
   whichever browser draws it. `flex: none` keeps it out of the row's negotiation: it sits before
   the size column and must not be squeezed to make the name fit. */
/* The box both bubbles wear. Split from the colour on purpose: the derived mark NAVIGATES and is a
   <button>, the sealed mark states a fact and is a <span>, and a role worn by two elements has to
   declare its own box or the UA's button defaults show through on one of them and not the other.
   Sharing the click-bound class instead would make the span a pointer-only target. */
.file-badge {
  flex: none;
  align-self: center;
  /* Centred inside its own box: `min-height` on a plain inline box would grow the bubble without
     moving the word, leaving the text sitting on the top edge (owner asked for 22px, 10.09). */
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 22px;
  margin: 0;
  padding: 2px 8px;
  border: none;
  border-radius: 10px;
  font: inherit;
  font-size: 11px;
  font-weight: 600;
  line-height: 1.4;
  white-space: nowrap;
}

.file-derived-badge {
  background: var(--badge-derived-bg);
  color: var(--badge-derived-text);
  cursor: pointer;
}

/* The same bubble for a different KIND of fact — where the row came from versus what it takes to
   read it — so it is the same shape in another colour (owner, 11.09: purple). No pointer: there is
   nowhere to go. */
.file-badge-sealed {
  background: var(--badge-sealed-bg);
  color: var(--badge-sealed-text);
}

/* #660 the folder is closed with a password. Worded, not just a glyph: "closed" on its own leaves
   the reader guessing closed to WHOM, and the answer (anyone who was handed the password) is what
   the tooltip holds — the same shape the sealed badge uses one screen over. */
.file-badge-locked {
  background: var(--badge-locked-bg);
  color: var(--badge-locked-text);
}

/* In the card the badge sits ON the preview, top-right (owner, 10.09): the mark belongs to the
   picture it is a preview OF, and down in the body it was competing with the name and the size.
   `position: relative` for this is set on the thumb's OWN rule further down rather than in a
   second rule here — one box, one place that describes it. */
.file-card-thumb .file-badge {
  position: absolute;
  top: 8px;
  right: 8px;
}

/* If a row ever wears both, they stack instead of sitting on top of each other. */
.file-card-thumb .file-derived-badge ~ .file-badge-sealed {
  top: auto;
  bottom: 8px;
}

.file-derived-badge:hover {
  filter: brightness(1.05);
}


/*
 * The rows the two toolbar buttons open — what the list is narrowed to, and the order it is read
 * in. One box for both, because they are one kind of thing: a row of choices belonging to the
 * button that opened it, not a place of its own.
 *
 * It PUSHES the list down rather than floating over it. A popover would need a backdrop, a
 * stacking order and a way of being dismissed that a phone and a keyboard both agree on; here the
 * rows below simply move, and the filter a reader just set stays visible over the list it narrowed
 * — which is the whole reason he is choosing anything.
 */
.files-menu {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px 12px;
  margin-bottom: 12px;
  padding: 12px;
  border: 1px solid var(--border-color);
  border-radius: 12px;
  background: var(--kbd-bg);
}

/* The box's own bottom margin belongs to the panel that now holds it. */
.files-menu .checkbox-label {
  margin-bottom: 0;
}

/* Category tabs — and the orders, which are the same chip in a different row. */
#files-categories {
  display: flex;
  gap: 8px;
  margin-bottom: 0;
  overflow-x: auto;
  padding-bottom: 4px;
  -webkit-overflow-scrolling: touch;
}

/*
 * `flex: 0 0 auto` is what makes the row SCROLL instead of squeezing. A flex item shrinks by
 * default, so on a narrow screen the seven tabs each gave up width until their labels were cut
 * mid-word ("Изобр", "Докум") — `white-space: nowrap` only stops the wrap, it does not stop the
 * box from becoming too small for its text. The overflow rule on the container was already there
 * and did nothing, because nothing ever overflowed: the children absorbed the deficit instead.
 *
 * Three names share it, and the third is the operator's page chips (§8.11). They are a separate
 * name because the files row's click handler is bound by class across the whole document — a chip
 * wearing `.cat-tab` on another screen is read as a category the moment it is pressed
 * ([[an-anchor-that-occurs-twice-mutates-the-wrong-function]]). What the three have in common is
 * the box, and that is what is written once here.
 */
.cat-tab,
.files-sort-opt,
.admin-nav-btn {
  flex: 0 0 auto;
  padding: 8px 16px;
  border: 1px solid var(--border-color);
  border-radius: 16px;
  background: var(--bg-primary);
  font-size: 13px;
  color: var(--text-secondary);
  cursor: pointer;
  white-space: nowrap;
  transition: background 0.2s, border-color 0.2s, color 0.2s;
}

.cat-tab:hover,
.files-sort-opt:hover,
.admin-nav-btn:hover {
  background: var(--bg-hover);
}

.cat-tab.active,
.files-sort-opt.active,
.admin-nav-btn.active {
  background: var(--bg-active);
  border-color: var(--accent-text);
  color: var(--accent-text);
  font-weight: 500;
}

/* File grid */
.files-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  gap: 12px;
}

.files-list {
  display: flex;
  flex-direction: column;
}

/* The box the rows live in, and the one that moves when they do not fit (owner, 12.09).
   A list row is a row of COLUMNS — tick, icon, name, badges, size, actions. On a phone those do not
   fit, and the way they gave way was the wrong one: every fixed column held its width and the name,
   the only flexible one, collapsed to nothing, so the names disappeared and the actions slid off the
   screen edge. The row keeps its width here and the BOX scrolls, which also keeps a row readable as
   one line about one file. */
#files-scroll {
  overflow-x: auto;
  /* Touch scrolling with momentum on iOS, where this screen is used most. */
  -webkit-overflow-scrolling: touch;
  /* The columns' total, so the box knows when to start moving. Not `width`: on a wide screen the
     rows fill it, and a min-width here would put a scrollbar under a list that fits. */
  max-width: 100%;
}

/* File card (grid view) */
.file-card {
  background: var(--bg-primary);
  border: 1px solid var(--border-color);
  border-radius: 8px;
  overflow: hidden;
  cursor: pointer;
  /* The batch tick is placed against THIS box's corner (#641). Without it the tick would be
     measured from whatever ancestor happens to be positioned, and would land somewhere on the page
     rather than on the card it selects. */
  position: relative;
  transition: box-shadow 0.2s, border-color 0.2s;
}

.file-card:hover {
  box-shadow: var(--shadow-md);
  border-color: var(--text-secondary);
}

.file-card-thumb {
  height: 100px;
  /* The derived badge is placed against this box's corners, so the box has to be the one it is
     measured from; without this it would escape to the nearest positioned ancestor. */
  position: relative;
  display: flex;
  /* The icon says what KIND of file this is; the button under it offers to replace the icon with
     the picture itself. Laid out in a row they read as two peers side by side, and on a narrow
     card the button pushed the icon off centre. Stacked, the icon keeps the middle and the offer
     sits beneath the thing it is an offer about (owner, 10.09). */
  flex-direction: column;
  gap: 6px;
  align-items: center;
  justify-content: center;
  /* `background-color`, NOT the `background` shorthand: the shorthand resets `background-size` to
     `auto`, and this box is painted by adding `.has-preview`, whose whole job is to set it. With
     the shorthand here the picture was drawn at its own size — cropped, and only where the rules
     happened to be read in the right order (owner, 10.09). */
  background-color: var(--bg-hover);
  border-bottom: 1px solid var(--kbd-bg);
}

.file-card-thumb .file-type-icon {
  font-size: 48px;
  opacity: 0.8;
}

/* A preview is drawn as the box's background, sized `contain` (owner, #1007 = B — answered
   10.09, after this rule was first written): the whole picture
   stays visible and the box keeps its height, so a row of cards does not change shape depending on
   what was uploaded. `cover` would fill the box edge to edge but crop, and a thumbnail whose
   subject has been cut out of it is worse than a smaller thumbnail.
   `no-repeat` and `center` are not decoration: without them a picture narrower than the box tiles
   across it. */
/* The painted state, declared for the ROLE rather than for one of the elements that wears it: the
   grid card, the attachment in a letter and the file screen are all painted by `showPreview`, and a
   rule tied to one class leaves the others with a background image and no rules for it.
   `contain` — the whole picture, never cropped (#1007 = B). */
.has-preview {
  background-position: center;
  background-repeat: no-repeat;
  background-size: contain;
}

/* Offered only while there is no preview yet; `showPreview` removes it once there is one. */
.file-thumb-load {
  padding: 4px 10px;
  font-size: 12px;
  color: var(--text-secondary);
  background: var(--bg-secondary);
  border: 1px solid var(--border-color);
  border-radius: 4px;
  cursor: pointer;
}

.file-thumb-load:hover {
  color: var(--text-primary);
  border-color: var(--text-secondary);
}

.file-thumb-load:disabled {
  cursor: default;
  opacity: 0.6;
}

/* One task of §5.11, offered on the file screen. It wears `.action-btn` and takes that shape; the
   only thing declared here is that a task in flight looks unavailable — the work happens on the
   hub's CPU and takes as long as it takes. */
.file-task-btn:disabled {
  cursor: default;
  opacity: 0.6;
}

.file-card-body {
  padding: 12px;
}

.file-card-name {
  font-size: 13px;
  font-weight: 500;
  color: var(--text-primary);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  margin-bottom: 4px;
}

.file-card-meta {
  font-size: 11px;
  color: var(--text-secondary);
}

/* File row (list view). FLAT, the way every list in this client reads (#1518 = A): the row is
   padding and a bottom rule, no box of its own — the boxed form with a 2px gap and a radius
   (12.09) gave way to the FSP panel. */
.file-row {
  display: flex;
  align-items: center;
  padding: 10px 16px;
  border-bottom: 1px solid var(--border-color);
  cursor: pointer;
  gap: 12px;
  transition: background 0.15s;
}

.file-row:hover {
  background: var(--bg-hover);
}

/* The end of the list (#1135 = B). Drawn only when the HUB has said there is nothing after this
   page — never because the drawn rows ran out — so it reads as a fact about the collection rather
   than as a pause in it. Muted and unboxed on purpose: it is a full stop, not another row. */
.files-end {
  margin: 18px 0 6px;
  color: var(--text-secondary);
  font-size: 0.85rem;
  text-align: center;
}

.files-grid .files-end {
  grid-column: 1 / -1;
}

/* The file the detail card is currently showing.
   Two marks on purpose, as asked: the border says where the row ends, the outline draws a ring
   OUTSIDE it that survives `overflow: hidden` on the card and reads at a glance in a dense grid.
   `outline-offset` keeps the ring off the border so they are two lines, not one thick one. */
.file-card.selected {
  border-color: var(--accent);
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* Hover must not overrule the selection: the two states coexist whenever the pointer rests on the
   open file, and the ring is the answer to "which one am I looking at". */
.file-card.selected:hover {
  border-color: var(--accent);
}

/* The flat row has no gap to hold a ring outside itself, so its ring is drawn INSIDE the row —
   the mark survives (#1518 kept it), it just cannot lie on the neighbour it now touches. */
.file-row.selected {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}

/* --- batch selection (#641) ---------------------------------------------------------------- */

/* Ticked for a batch. A DIFFERENT state from `.selected`, which means "this is the file the card
   below is about": one file is open, any number are ticked, and both marks can be on one row at
   once — so this one tints the row instead of ringing it. */
.file-card.picked {
  /* The tint this client already uses for "this one is picked" — the same one the bar above the
     grid is painted with, so the ticked rows and the bar acting on them read as one thing. A new
     token here would be a second answer to a question the palette has already answered. */
  background: var(--bg-active);
  border-color: var(--accent);
}

/* The ticked ROW keeps only the tint: the side borders that carried the accent are gone with the
   box, and the tint alone is already the bar's own colour for "picked". */
.file-row.picked {
  background: var(--bg-active);
}

/* The picking column. A region rather than a glyph: it carries padding around the tick so that a
   press a few pixels wide of the mark still selects, instead of throwing the reader into the file
   he was aiming to tick (owner, 11.09). `inFileRowControls` names this class, so a press anywhere
   inside it stops here. */
.file-card .file-pick {
  position: absolute;
  top: 0;
  left: 0;
  /* Over the preview, at its corner, the way the badges sit — the thumbnail is what the reader is
     pointing at in this view, so the tick belongs on it and not on the card's outer edge. */
  padding: 8px;
  line-height: 0;
  /* No z-index: a positioned box already paints above the thumbnail's in-flow children, and
     declaring a layer would owe `tests/ui-layers.js` an answer about not fitting. */
  cursor: pointer;
}

.file-row .file-pick {
  /* The whole COLUMN, out to the row's edge. It was a 21px box around a 13px glyph, and every press
     outside it — the row's own left margin included — opened the file the reader was aiming to tick
     (owner, 11.09). Stretched to the row's height and pulled out over its padding, the strip that
     carries the tick is the strip that answers for it; being a `label`, the press lands on the box
     itself rather than on a handler that has to agree with it. */
  align-self: stretch;
  display: flex;
  align-items: center;
  /* These four numbers are `.file-row`'s own padding, spelled again because the column has to reach
     past it to the row's border. Two spellings of one measurement is how this project's defects are
     usually written, so `tests/ui-layout.js` reads both rules and requires them to agree: change the
     row's padding and the suite says so, rather than the column quietly stopping at the glyph. */
  margin: -10px 0 -10px -16px;
  padding: 10px 8px 10px 18px;
  line-height: 0;
  flex-shrink: 0;
  cursor: pointer;
}

.file-card .file-checkbox {
  margin: 0;
  cursor: pointer;
  /* Asked for on hover (owner, 11.09), and the tick must not disappear from a card that IS ticked
     — nor from one whose box has the keyboard focus, which is the only way it is reached without a
     pointer. Opacity rather than `display`, so the box keeps its place in the tab order. */
  opacity: 0;
  transition: opacity 0.15s;
}

.file-card:hover .file-checkbox,
.file-card:focus-within .file-checkbox,
.file-card.picked .file-checkbox,
.file-card .file-checkbox:focus-visible {
  opacity: 1;
}

/* A touch screen has no hover at all: there the tick is simply always there, or the batch would be
   unreachable on a phone. */
@media (hover: none) {
  .file-card .file-checkbox { opacity: 1; }
}

.file-row .file-checkbox {
  margin: 0;
  cursor: pointer;
  flex-shrink: 0;
}

.file-row .file-type-icon {
  font-size: 24px;
  width: 32px;
  text-align: center;
  flex-shrink: 0;
}

/* The row's own width, so that the columns keep their meaning when the box scrolls: it is never
   narrower than what it holds, which is the fixed columns (tick, icon, badges, size, date,
   actions), their gaps, the padding, and the name's floor below — the name is what this list is
   FOR, and it is the one column that may give way, down to its floor and no further.

   `min-width: 0`, and the reading the owner asked for is "the row fits the screen" (12.09 16:15:
   ".file-row переделай в min-width: stretch; чтобы блок с именем файла подстраивался под ширину
   экрана"). `stretch` is not a `min-width` value in CSS — it is a `width` value — so the property
   is spelled the one that does exist, and the INTENT is what is written here: no floor of its own,
   so the row is exactly the width it is given and the name is what gives way inside it.

   What stood here was `min-content`, and it was right for the row it was written against: the row
   used to be as wide as its columns regardless of the screen, so the box scrolled and the scrollbar
   was the only signal that there was more to the right. Measured at 500px on 12.09, that row was
   560px and the list scrolled sideways by 92 — one long filename, which cannot wrap, was setting
   the floor for the whole row. The name now ellipsizes at its own floor instead (below), so the
   signal that something was cut is on the name itself rather than on a scrollbar the reader has to
   notice. Collapsing the name to nothing was the older complaint, and the floor in the rule below
   is what answers it — the two rules are one decision and are read together by `tests/ui-layout.js`.

   (The figure this replaced before that, 680px, was wrong twice: the columns it was summed from had
   grown past it, and a same-specificity rule later in this file — the tap floor on `[role="button"]`
   — silently outranked it. That is why the floor is asked of the layout engine and not summed by
   hand.) */
.file-row {
  min-width: 0;
}

/* The name column: the one that GROWS, and the growth is now the whole alignment (owner, 12.09).
   Every column to its right is pinned to the row's right edge instead of standing after the name,
   so the name is what takes the slack and the columns take their places from the right.

   The ceiling that used to be here (`max-width: clamp(140px, 60vw, 420px)`) is gone, and it was
   the reason the folder date and the file date stood at different places: a capped name leaves
   slack that nothing claims, so the columns after it packed to the LEFT of that slack — where they
   landed depended on how wide the columns before them turned out to be, and a folder row, which
   has no badge and no size, landed 264px away from a file row. The owner read it as "the date is
   not in the date column", which is exactly what it was.

   The FLOOR stays, and for the reason it was added: `flex: 1` alone made this the only column that
   could shrink, and on a phone it shrank to zero — rows with no name at all (owner, 12.09). A
   floor plus `min-content` on the row (above) means a narrow screen scrolls the box instead of
   eating the name; the ellipsis below still does its work when the text outruns the column. */
.file-row .file-row-name {
  flex: 1 1 auto;
  min-width: 140px;
  font-size: 14px;
  color: var(--text-primary);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* The badges. No width of their own (owner, 12.09: "почему file-row-badges имеет 160 пикселей
   фиксированных — пусть вообще не будет width"). The 160px was a column sized for the widest pair
   of marks, and it claimed that space on EVERY row — a folder row, which carries no mark at all,
   still paid it, which is how the two date columns came apart.

   Right-aligned instead, so the marks hang off the size column: the box hugs its content and the
   group to its right is fixed-width, so a long mark grows leftwards into the name's slack and
   never pushes the size or the date. Nothing `overflow: hidden` here on purpose: cutting a mark
   silently is worse than a mark that takes room from a name that has room to give. */
.file-row .file-row-badges {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 4px;
}

.file-row .file-row-size {
  font-size: 12px;
  color: var(--text-secondary);
  width: 80px;
  text-align: right;
  flex-shrink: 0;
}

/* The moment, in ONE line (owner, 12.09 evening). At 100px the column printed `04.09.2025,` and
   then `17:33:21` under it — nothing overflowed and nothing was cut, so no reading of the row's
   WIDTH could see it: wrapped text fits by definition. What the column was too narrow for is its
   own content, and the figure was produced by the layout engine rather than summed by hand: the
   probe lays out the longest moment the settings can print (the AM/PM clock, `00.00.0000, 00:00:00
   AM`) in this very class, inside this very row, and reports what it asks for — 135px here, against
   the ~120px of the 24-hour forms. A column sized for the CURRENT preference would change width
   when the reader switches the clock, so it is sized for the longest one.

   Why 160 and not the measured 135: the figure is a property of the platform's font, and this
   client ships to whichever font the reader has (the measured box is the same one the shell gets,
   but DejaVu here is not Segoe there). The column is drawn only from 769px up, where the name — the
   one column that gives way — has slack to spare, so the headroom costs nothing. Making it tight
   would be buying 25px of a growing column with a risk of clipping the widest moment on someone
   else's machine.

   `nowrap` goes with the width, and it is a pair: without the width the date would spill over the
   size column instead of wrapping under it, and `overflow: hidden` is deliberately NOT here — a
   silently cut date is worse than one that takes the room it needs, the same argument the badge
   column makes above. `tests/tools/ui-file-actions-accept.mjs` holds all three readings (one line,
   nothing cut, column >= the widest moment) and prints the numbers it measured on every run. */
.file-row .file-row-date {
  display: none;
  font-size: 12px;
  color: var(--text-secondary);
  width: 160px;
  white-space: nowrap;
  text-align: right;
  flex-shrink: 0;
}

/* The file count on a folder row (owner, 15.09): quiet gray at the right, before the pinned
   group. No held width — the phrase is short and the name column is the one that grows. */
.file-row .file-row-count {
  font-size: 12px;
  color: var(--text-secondary);
  white-space: nowrap;
  flex-shrink: 0;
}

/* `.file-row-actions` and `.file-row-action-btn` stood here. They are gone with the row's quick
   actions (owner, 12.09: "убери вообще быстрые actions с файлом"). The acts moved to the detail
   card, whose own buttons already worked this way; nothing in the list hides behind a hover now. */

/* Empty state.
   Same shape as `.empty-state`: a centred COLUMN, not a block that centres its
   text. The difference is invisible until a child carries a measure — `.hint`
   caps itself at 90ch, and inside a text-align block that narrower box stays
   glued to the left edge while the lines above it sit in the middle (owner,
   02.09). As a flex column with centred items the measure is centred by
   construction, so the next measured child needs no rule of its own. */
#files-empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  padding: 60px 20px;
  color: var(--text-secondary);
}

.files-empty-icon {
  font-size: 64px;
  margin-bottom: 16px;
  opacity: 0.5;
}

#files-empty p {
  margin: 4px 0;
  font-size: 16px;
}

#files-empty .hint {
  font-size: 13px;
  color: var(--text-secondary);
}

/* A card that opens BELOW the thing it is about — a detail panel, an upload panel — needs a gap
   above it, because `.deposit-card` carries only a bottom margin and a card that follows content
   would otherwise touch it (owner, 02.09 and again 10.09).

   One CLASS rather than a rule per id (owner, 10.09): the same panel appeared twice in Files with
   the gap on one of them, because the second was styled by remembering the first. A card that
   needs the gap now says so on itself, in the markup, where the next card is copied from. Not
   folded into `.deposit-card`, which stacks with its own kind elsewhere and would double the gap
   there. */
.card-detached {
  margin-top: 24px;
}

/* File detail panel.
   Addressed by its id alone: the box carried an id AND a class of the same name, so a reader had to
   check which of the two any given rule or line of script meant (owner, 10.09). The script needs
   the id; one spelling is therefore the id. */
#sales-file-preview,
#file-detail-preview {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  /* See `.file-card-thumb`: the shorthand would undo `background-size: contain` from
     `.has-preview`, and here it also outranks it, being an id. */
  background-color: var(--bg-hover);
  border-radius: 8px;
  margin-bottom: 16px;
  /* Taller than a grid thumbnail on purpose (owner, 10.09): this is the screen the file is opened
     ON, and a picture shown at the height of a list cell is not why one opens it. */
  min-height: 240px;
}

#sales-file-preview .file-type-icon,
#file-detail-preview .file-type-icon {
  font-size: 64px;
  opacity: 0.7;
}

#sales-file-details,
#file-detail-details {
  line-height: 1.8;
  color: var(--text-secondary);
  font-size: 13px;
  margin-bottom: 16px;
}

#sales-file-details strong,
#file-detail-details strong {
  color: var(--text-primary);
}

#sales-file-details code,
#file-detail-details code {
  background: var(--kbd-bg);
  padding: 2px 6px;
  border-radius: 3px;
  font-size: 12px;
  font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
  word-break: break-all;
}

/* Desktop for files. */
@media (min-width: 769px) {
  #files-toolbar {
    flex-wrap: nowrap;
  }

  .files-upload-btn {
    width: auto;
    justify-content: flex-start;
  }

  #files-search-wrap {
    order: 0;
    width: auto;
  }

  .files-grid {
    grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  }

  .file-card-thumb {
    height: 140px;
  }

  .file-row .file-row-date {
    display: block;
  }
}

/* ===========================================================================
 * Toast notifications
 * =========================================================================== */

#toast-container {
  position: fixed;
  top: 60px;
  /* Full width on a phone, and clear of the rounded corners in landscape. */
  left: max(16px, env(safe-area-inset-left));
  right: max(16px, env(safe-area-inset-right));
  z-index: 2000;
  display: flex;
  flex-direction: column;
  gap: 8px;
  max-width: none;
}

.toast {
  display: flex;
  width: 100%;
  /* Not `center`: a long notice scrolls, and centring put the icon and the cross halfway down a
     tall box away from the first line the reader starts at. With `flex-start` all three parts share
     the FIRST line — and that line is given one explicit height below, so a one-line notice has its
     icon, its words and its cross on exactly the same line instead of each sitting wherever its own
     glyph box falls (owner, 10.09: "гуляют то вверх, то вниз"). */
  align-items: flex-start;
  /* The shared line. Every part of a toast is boxed to this height, so alignment is a fact about
     the row rather than a coincidence of three font sizes. */
  line-height: 20px;
  gap: 12px;
  padding: 12px 16px;
  background: var(--bg-toast);
  /* Not --bg-primary. The toast surface stays dark in both themes; a page token
     flips with the theme, so this pair read 1.30:1 in dark — the client's only
     channel for reporting success and failure, rendered as a grey rectangle. */
  color: var(--text-on-toast);
  border-radius: 8px;
  box-shadow: var(--shadow-lg);
  font-size: 14px;
  animation: toast-in 0.2s ease-out;
  transition: opacity 0.2s, transform 0.2s;
}

.toast.toast-out {
  opacity: 0;
  transform: translateX(100%);
}

.toast-success {
  background: var(--toast-success);
}

.toast-error {
  background: var(--toast-error);
}

.toast-error a {
  color: var(--text-on-toast);
  text-decoration: underline;
}

.toast-warning {
  background: var(--toast-warning);
}

.toast-info {
  background: var(--toast-info);
}

/* The neutral notice (owner, 22.09) — FSP floats voice notifications in on a PLAIN surface, not
   a verdict colour: "an author you follow answered" is news, not a success or a failure. This is
   the one toast drawn on page tokens instead of the dark toast surface, so the parts inked for
   that surface (the cross, the scrollbar) are re-inked here. Emitted as `toast(msg, 'voice')`;
   the icon falls back to info's. */
.toast-voice {
  background: var(--bg-primary);
  color: var(--text-primary);
  border: 1px solid var(--border-color);
}

.toast-voice .toast-close {
  color: var(--text-secondary);
}

.toast-voice .toast-close:hover {
  color: var(--text-primary);
}

.toast-voice .toast-message::-webkit-scrollbar-thumb {
  background: var(--border-color);
}

.toast-icon {
  font-size: 18px;
  flex-shrink: 0;
  /* An 18px glyph in a 20px line: centred inside the line box rather than sitting on its own
     baseline, which is what made it ride above the words. */
  height: 20px;
  display: flex;
  align-items: center;
}

.toast-message {
  flex: 1;
  /* A refusal can quote a whole price table back (a JSON blob with no spaces in it), so the break
     is FORCED rather than at word boundaries — `word-wrap` alone leaves such a string one long line
     that overflows the rounded surface. Long text scrolls inside the notice instead of growing it
     past the screen; the bar is thin and translucent so it does not read as a second control
     (owner, 10.09). */
  min-width: 0;
  overflow-wrap: anywhere;
  word-break: break-word;
  max-height: 40vh;
  overflow-y: auto;
  scrollbar-width: thin;
  scrollbar-color: var(--toast-scroll) transparent;
}

.toast-message::-webkit-scrollbar {
  width: 6px;
}

.toast-message::-webkit-scrollbar-thumb {
  background: var(--toast-scroll);
  border-radius: 3px;
}

.toast-close {
  background: none;
  border: none;
  color: var(--text-on-toast-dim);
  font-size: 18px;
  cursor: pointer;
  flex-shrink: 0;
  line-height: 20px;
  display: flex;
  align-items: center;
  /* The tap floor at the end of this file gives every `button` 44x44, and a minimum outranks a
     height however specific the selector (the same trap is written out over `.act-btn`). Here it
     did two visible things at once, both photographed by the owner on 11.09: the row grew to 68px
     because the tallest item was the cross, and the cross — centred in a 44px box — ended up 12px
     BELOW the line of text and 45px in from the edge, adrift in the middle of the surface.
     So the target and the drawing are stated separately. The box stays 44x44 for the finger; the
     glyph is pushed to the right of it so it lands at the padding edge where the eye expects it;
     and the negative block margins take the extra 24px back out of the row, leaving the outer box
     20px tall — the shared line the icon and the words already sit on. */
  width: 44px;
  height: 44px;
  padding: 0;
  justify-content: flex-end;
  margin: -12px 0;
}

.toast-close:hover {
  color: var(--bg-primary);
}

@keyframes toast-in {
  from {
    opacity: 0;
    transform: translateX(100%);
  }
  to {
    opacity: 1;
    transform: translateX(0);
  }
}

/* ===========================================================================
 * Confirmation dialog
 * =========================================================================== */

#confirm-overlay {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: var(--bg-overlay);
  z-index: 3000;
  display: flex;
  align-items: center;
  justify-content: center;
  /* A scrim taller than the glass scrolls, and the panel is centred by `margin: auto` rather than
     by `align-items: center`: centring a child that overflows clips its TOP, and the clipped part
     is the title — the one line that says what the dialog is asking (owner, 22.09, on the picture
     picker: «не помещается в экран и нет скролла»). The insets are the scrim's, because the panel
     must not be drawn under a notch to be reachable. */
  overflow-y: auto;
  padding: max(16px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) max(16px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left));
}

#confirm-overlay.hidden {
  display: none;
}

/* The camera, the same scrim as the dialog above and not a shared selector: the two are separate
   screens that happen to be drawn alike, and a rule that names both is a rule that cannot be
   changed for one of them. */
#camera-overlay {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: var(--bg-overlay);
  z-index: 3000;
  display: flex;
  align-items: center;
  justify-content: center;
  /* Every layer that covers the page scrolls if what it holds is taller than the glass, and centres
     its panel with `margin: auto` for the reason the dialog above spells out. Written once per
     overlay rather than shared through a selector: they are separate screens that happen to be
     drawn alike (see the note under this one), and a rule naming all of them could not be changed
     for one. */
  overflow-y: auto;
  padding: max(16px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) max(16px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left));
}

#camera-overlay.hidden {
  display: none;
}

#confirm-dialog {
  /* `margin: auto` centres it in the scrim AND keeps it reachable when it overflows — see the
     overlay above. */
  margin: auto;
  background: var(--bg-primary);
  border-radius: 12px;
  box-shadow: var(--shadow-lg);
  padding: 24px;
  max-width: 400px;
  width: 100%;
  /* NEVER TALLER THAN THE SCREEN IT IS DRAWN ON, and never with its actions out of reach. `dvh`
     rather than `vh`: on a phone the browser's own chrome is part of the viewport, and `100vh`
     counts the part of it that is behind the address bar (owner, 22.09: a dialog whose buttons
     stood below the fold, on a screen that has no fold to scroll to). */
  max-height: calc(100dvh - 32px - env(safe-area-inset-top) - env(safe-area-inset-bottom));
  display: flex;
  flex-direction: column;
}

/* THE STRETCH THAT GIVES. Everything the dialog says and asks is in here; the two buttons are
   outside it, because a dialog whose way out scrolls away is a dialog the reader hunts through.
   Written as ONE scroller for the whole middle rather than a scroller per part, because the defect
   was found in the part that had NONE: with the on-screen keyboard up the panel shortens to what
   the glass has left, and `#confirm-message` and `#confirm-input` — flex children with an
   automatic minimum size — could not shrink, so the panel painted them (and, under them, the
   buttons) past its white edge, «в воздухе» (owner, 19.10.2026, the decryption-key dialog).
   `min-height: 0` is what lets this box shrink below its content and scroll instead of pushing
   the floor out; `overscroll-behavior` keeps the page behind the scrim from taking the flick when
   the scroll reaches its end. */
#confirm-body {
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
}

#confirm-title {
  font-size: 18px;
  font-weight: 500;
  color: var(--text-primary);
  margin-bottom: 12px;
}

#confirm-message {
  font-size: 14px;
  color: var(--text-secondary);
  line-height: 1.5;
  margin-bottom: 20px;
  /* a message that carries LINES draws them (§7.11.4: what a rewritten line said before, one version
   * per line). Collapsed whitespace would run three versions into one sentence nobody wrote. */
  white-space: pre-wrap;
}

/* Shown only when the dialog asks FOR something. Full width, because what goes into it is a key. */
#confirm-input {
  display: block;
  width: 100%;
  box-sizing: border-box;
  min-height: 44px;
  padding: 8px 12px;
  margin-bottom: 20px;
  border: 1px solid var(--border-color);
  border-radius: 4px;
  background: var(--bg-primary);
  color: var(--text-primary);
  font-size: 14px;
}

#confirm-input.hidden {
  display: none;
}

/* Sits between the field and the buttons: the decision is about what was just typed. `.checkbox-label`
   carries the row itself; this only places it in the dialog. */
#confirm-check {
  margin-bottom: 20px;
}

#confirm-check.hidden {
  display: none;
}

/* A dialog asking about SEVERAL things: one control per row, each under its own word, so a form
   of five options reads down the page instead of wrapping into a paragraph of boxes. Hidden the
   same way its neighbours are — the container is emptied as well, so a form left over from the
   previous question cannot be tabbed into. */
/* The middle of the dialog is what gives when the panel runs out of room — but it gives as PART OF
   `#confirm-body`, the one scroller the whole middle shares (19.10.2026). This box used to scroll
   on its own, and a hundred pictures still fits: it is simply that the scroll now happens one
   ancestor up, where the message and the input also live. Keeping a second scroller here would
   have been a scrollbar with nothing to scroll ([[a-rule-killed-by-a-later-rule-stays-readable-as-live]]). */
#confirm-fields {
  display: flex;
  flex-direction: column;
  gap: 12px;
  margin-bottom: 20px;
  text-align: left;
}

#confirm-fields.hidden {
  display: none;
}

/* A dialog SHOWING something: the QR code of a file's address. Sized in the dialog rather than in
   the drawing, because the SVG has no pixels of its own — it is a matrix with a viewBox, and how
   big it should be is a property of the screen it is on, not of the code. Capped so it stays whole
   on a phone in portrait, and given a light plate of its own: the modules are black by definition,
   and on a dark theme a code drawn straight onto the surface would be a black square on grey that
   no camera resolves. */
/* A dialog SHOWING something. The FIGURE is the other half of what gives when the panel runs out of
   room — a form the reader GROWS (the poll's «add an answer»), a list of a hundred files. It was
   given `min-height: 0` and its own scroll on 12.10, when the form pushed the two buttons off the
   panel instead of scrolling under them; since 19.10 it shares `#confirm-body` with the message and
   the input, so the scroll that saves it is the same small one, one ancestor up. */
#confirm-figure {
  display: flex;
  justify-content: center;
  margin-bottom: 20px;
}

#confirm-figure.hidden {
  display: none;
}

.qr-figure {
  width: min(260px, 60vw);
  height: auto;
  background: var(--qr-plate);
  padding: 8px;
  border-radius: 8px;
}

/* The scanner's viewfinder (#829): the live camera, sized like the QR it is looking for. */
.qr-scan {
  width: min(320px, 80vw);
  aspect-ratio: 1;
  object-fit: cover;
  background: var(--bg-secondary);
  border-radius: 8px;
}

.confirm-field {
  display: flex;
  flex-direction: column;
  gap: 4px;
  font-size: 13px;
  color: var(--text-secondary);
}

.confirm-field input,
.confirm-field select,
.confirm-field textarea,
.chat-poll-q,
.chat-poll-o {
  width: 100%;
  box-sizing: border-box;
  /* The same 44px the rest of this client gives a control a finger has to hit. */
  min-height: 44px;
  padding: 8px 12px;
  border: 1px solid var(--border-color);
  border-radius: 4px;
  background: var(--bg-primary);
  color: var(--text-primary);
  font-size: 14px;
}

.confirm-field textarea {
  /* A description is a paragraph, not a line: the box starts tall enough to show two lines and may
     be pulled out, which is the one direction it can grow on a phone. */
  min-height: 72px;
  resize: vertical;
  font-family: inherit;
}

.confirm-field-hint {
  color: var(--text-secondary);
  font-size: 12px;
}

/* A choice explained IN PLACE (owner 19.10 22:10: what a public group means, what a private one
   does). It stands between the radio row and the fields it explains, in the form's own type —
   not as a `.hint` box, because it is the sentence the reader decides by, not a footnote. */
.confirm-note {
  margin: -4px 0 0;
  color: var(--text-secondary);
  font-size: 13px;
  line-height: 1.45;
}

/* A fork in the question, drawn as two full rows a finger can hit: the reader decides what KIND of
   group he is making, and aiming at a dot would be the wrong part of that act. */
.confirm-radios {
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.confirm-radio {
  display: flex;
  align-items: center;
  gap: 8px;
  min-height: 36px;
  color: var(--text-primary);
  font-size: 14px;
  cursor: pointer;
}

.confirm-radio input {
  width: 18px;
  height: 18px;
  margin: 0;
  accent-color: var(--accent-text);
}

/* The actions never shrink and never scroll: they are the dialog's floor, and every other part of
   it is allowed to give instead. */
#confirm-actions {
  flex: 0 0 auto;
  display: flex;
  justify-content: flex-end;
  gap: 8px;
}

/* A dialog that is a PICKER gets the room a list needs (owner, 12.10 21:20: «Для ПК версии модальное
   окно можно расширить сейчас #confirm-dialog max-width:400px, можно смело увеличить до 600px»).
   Scoped to the picker rather than raised on every dialog: 400px is right for a question that is a
   sentence and a field, and widening all of them would pay for this screen with all the others. The
   same `:has` shape the received-file view further down uses. */
#confirm-dialog:has(.pick-box) {
  max-width: 600px;
}

/* And the picker's endings stand in the order the owner wrote them (12.10 21:20): «Отменить,
   Прикрепить, Выбрать (N)». The markup keeps the one order every other dialog reads — the answer on
   the right — so the order is a claim about THIS row, and the act the reader came for stays the
   rightmost thing under his thumb. Without the second button (a chat line, the editor) the row
   comes out as it always did: Cancel left, the answer right. */
#confirm-dialog:has(.pick-box) #confirm-cancel { order: 1; }
#confirm-dialog:has(.pick-box) #confirm-alt { order: 2; }
#confirm-dialog:has(.pick-box) #confirm-ok { order: 3; }

#confirm-cancel {
  padding: 8px 16px;
  background: var(--bg-primary);
  border: 1px solid var(--border-color);
  border-radius: 4px;
  color: var(--text-secondary);
  cursor: pointer;
  font-size: 14px;
}

#confirm-cancel:hover {
  background: var(--bg-hover);
}

/* The dialog's THIRD button, and it had no rule at all until 12.10 — a `<button>` the markup has
   carried since the memo dialog, drawn by the user agent: grey slab, outset border, 2px corners.
   Nothing showed it: the memo dialog's alternative action is rare, and it was the only button in
   that row. The picker put it between two of this client's own controls (owner's photo 12.10:
   «#confirm-alt не совпадает по стилю с другими кнопками») — three buttons in one row, two of them
   the same shape and the middle one from a different client entirely.
   The geometry is the row's (the padding and radius `#confirm-cancel` and `#confirm-ok` carry) and
   the LOOK is the second-tier one this client already has for an action that is not the answer —
   `transparent`, accent text, a border — the shape `knowledge/ui-style-rules.md` names for a
   secondary action and `.login-btn.secondary` draws. The main act stays the blue one on the right,
   which is also why this is not simply a copy of the cancel: two greys would read as two ways to
   back out. */
#confirm-alt {
  padding: 8px 16px;
  background: transparent;
  border: 1px solid var(--border-color);
  border-radius: 4px;
  color: var(--accent);
  cursor: pointer;
  font-size: 14px;
}

#confirm-alt:hover {
  background: var(--bg-hover);
}

#confirm-ok {
  padding: 8px 16px;
  background: var(--accent);
  color: var(--bg-primary);
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
  font-weight: 500;
}

#confirm-ok:hover {
  background: var(--accent-hover);
}

#confirm-ok.danger {
  background: var(--danger);
}

#confirm-ok.danger:hover {
  background: var(--danger-hover);
}

/* ===========================================================================
 * The call screen (§7.14)
 * =========================================================================== */

/* The same scrim as the dialog above, and written out again rather than shared with it: they are
   separate screens that happen to be drawn alike, and a rule naming both could not be changed for
   one of them (the note under `#camera-overlay` says the same about the third). Centred by
   `margin: auto` and free to scroll — a panel taller than the glass clips its TOP when it is
   centred with `align-items`, and the clipped line is the caller's name. */
#call-overlay {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: var(--bg-overlay);
  z-index: 3000;
  display: flex;
  align-items: center;
  justify-content: center;
  overflow-y: auto;
  padding: max(16px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) max(16px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left));
}

#call-overlay.hidden {
  display: none;
}

#call-panel {
  margin: auto;
  background: var(--bg-primary);
  border-radius: 12px;
  box-shadow: var(--shadow-lg);
  padding: 24px;
  max-width: 320px;
  width: 100%;
  text-align: center;
  /* Never taller than the glass it is drawn on, for the reason the dialog above spells out — `dvh`
     counts the part of the viewport the phone's own chrome is not covering. */
  max-height: calc(100dvh - 32px - env(safe-area-inset-top) - env(safe-area-inset-bottom));
  display: flex;
  flex-direction: column;
}

#call-title {
  font-size: 18px;
  font-weight: 500;
  color: var(--text-primary);
  margin-bottom: 8px;
}

#call-state {
  font-size: 14px;
  color: var(--text-secondary);
  line-height: 1.5;
}

/* What the reader is being asked to share. Quieter than the state above it — it is the detail under
   the sentence, not a second sentence. */
#call-kinds {
  font-size: 12px;
  color: var(--text-secondary);
  margin-top: 4px;
}

#call-media { overflow: auto; min-height: 0; }
#call-media:empty { display: none; }
#call-media video { display: block; width: 100%; max-height: 40dvh; margin-top: 8px; }
#call-media audio[controls] { width: 100%; margin-top: 8px; }

/* The actions are the panel's floor and never scroll away. One row, centred, because the buttons
   are the whole of what the screen asks and there are at most two of them at a time. */
#call-actions {
  flex: 0 0 auto;
  display: flex;
  justify-content: center;
  gap: 8px;
  margin-top: 20px;
}

#call-accept,
#call-decline,
#call-end {
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
  font-weight: 500;
}

#call-accept {
  background: var(--accent);
  color: var(--bg-primary);
}

#call-accept:hover {
  background: var(--accent-hover);
}

/* Decline and End are the same act at different moments — the call stops here — so they are the
   same colour, and that colour is the one this client already uses for "this one is the end of
   something". A refusal drawn in the neutral grey of a Cancel would read as "not now". */
#call-decline,
#call-end {
  background: var(--danger);
  color: var(--bg-primary);
}

#call-decline:hover,
#call-end:hover {
  background: var(--danger-hover);
}

/* ===========================================================================
 * Where the picked lines go (#1144)
 * =========================================================================== */

/* The same scrim as the three above, and written out again rather than shared with them: they are
   separate screens that happen to be drawn alike, and a rule naming two of them could not be changed
   for one (the note under `#camera-overlay` says the same about the others). Centred by `margin:
   auto` and free to scroll, for the reason those spell out — a panel centred with `align-items`
   clips its TOP when it is taller than the glass, and the clipped line is the one saying what the
   layer wants. */
#fwd-overlay {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: var(--bg-overlay);
  z-index: 3000;
  display: flex;
  align-items: center;
  justify-content: center;
  overflow-y: auto;
  padding: max(16px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) max(16px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left));
}

#fwd-overlay.hidden {
  display: none;
}

/* ===========================================================================
 * The area a file is dragged onto (owner, 12.10 22:19)
 *
 * «Нужно добавить drag and drop файла на экран + показывать область куда перекинуть файл, чтобы
 * загрузить его на диск и прикрепить к письму/чату/voice публикации». The drop itself is older
 * than this box — dropping a chip-shot of a file, the client's order — but the AREA is what was
 * missing: the reader was asked to aim at nothing and told nothing, and a screen that accepts a file
 * without showing where is a screen where nobody tries.
 *
 * The area is a REGION and not just the page, and there are now TWO of them STACKED — upper: the file
 * as it is; lower: a picture re-encoded and shrunk (`data-drop-zone`, one branch in the handler). They
 * are a column rather than a row because "верхняя половина / нижняя половина" is what the owner asked
 * for, and halves are only halves if the split is the one he named.
 * =========================================================================== */
#drop-zone {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: var(--bg-overlay);
  /* Under the dialogs (3000) and the context menus: an area the reader is aiming at must not be the
     thing on top of a question he is being asked. */
  z-index: 2000;
  display: flex;
  flex-direction: column;
  gap: 12px;
  padding: max(16px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) max(16px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left));
}

#drop-zone.hidden {
  display: none;
}

/* Dashed, because the shape is the whole message: this is a place a thing is put INTO, and it is
   the one surface in this client that is drawn rather than filled. The pointer events are refused
   on everything inside it — the area is a target, and a child that swallowed the drop would take the
   file and do nothing with it. */
.drop-region {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 10px;
  padding: 24px;
  border: 2px dashed var(--accent);
  border-radius: 16px;
  background: var(--bg-primary);
  color: var(--text-primary);
  text-align: center;
  pointer-events: none;
}

.drop-region .icon {
  width: 40px;
  height: 40px;
  color: var(--accent);
}

.drop-title {
  margin: 0;
  font-size: 16px;
  font-weight: 500;
}

.drop-hint {
  margin: 0;
  font-size: 13px;
  color: var(--text-secondary);
}

#fwd-dialog {
  margin: auto;
  background: var(--bg-primary);
  border-radius: 12px;
  box-shadow: var(--shadow-lg);
  padding: 24px;
  max-width: 400px;
  width: 100%;
  /* Never taller than the glass it is drawn on — `dvh`, because on a phone the browser's own chrome
     is part of the viewport and `100vh` counts the part behind the address bar. The list scrolls
     inside it; the title above and the actions below never do. */
  max-height: calc(100dvh - 32px - env(safe-area-inset-top) - env(safe-area-inset-bottom));
  display: flex;
  flex-direction: column;
  gap: 12px;
}

#fwd-title {
  flex: 0 0 auto;
  font-size: 18px;
  font-weight: 500;
  color: var(--text-primary);
}

/* The list takes the room left over and scrolls on its own: a reader's chats are as many as he has,
   and a dialog that grew with them would push its own way out off the screen. `.pick-list` caps the
   height for the field inside a form; here the cap is the dialog, and `min-height: 0` is what lets a
   flex child scroll rather than stretch its parent. */
#fwd-dialog .pick-list {
  flex: 1 1 auto;
  min-height: 0;
  max-height: none;
}

/* A row of this list carries a FACE, as the chat list it is drawn from does: the words side of a
   `.pick-row` is already a flex box with a gap (`pick-row-main`), so the face only has to stand in
   it. The circle, the square and the bookmark are the shapes the chat list uses for a person, a
   group and the book. */
#fwd-dialog .pick-row-main .chat-avatar {
  flex: none;
}

#fwd-actions {
  flex: 0 0 auto;
  display: flex;
  justify-content: flex-end;
}

#fwd-cancel {
  padding: 8px 16px;
  background: var(--bg-primary);
  border: 1px solid var(--border-color);
  border-radius: 4px;
  color: var(--text-secondary);
  cursor: pointer;
  font-size: 14px;
}

#fwd-cancel:hover {
  background: var(--bg-hover);
}

/* ===========================================================================
 * Loading spinner
 * =========================================================================== */

/* The button's wheel — the ONLY mark left in this family that is a border, and it is here because
   of where it stands rather than what it is: `.btn-loading` and `.btn-spinner` each repaint it out
   of the button's own ink, which an SVG mark inside a button cannot do. Screens draw
   `.spinner-arcs` below instead. */
.spinner {
  display: inline-block;
  width: 20px;
  height: 20px;
  border: 2px solid var(--text-primary);
  border-top-color: var(--accent);
  border-radius: 50%;
  animation: spin 0.6s linear infinite;
}

/* The waiting mark on a screen: two arcs of one circle, turning against each other.
 *
 * It replaced a coloured ring (owner, 21.09) — a full circle in the track colour with one bright
 * third over it. That ring answered a question the client cannot answer: how much is done. One
 * third is what it said on an empty screen and on a page already on its way, and the hub gives the
 * feed a page at a time and never says how many are left. Two arcs carry no fraction at all — both
 * halves move, at different speeds — so the mark can only be read as "still working".
 *
 * SVG rather than a border for the same reason: the ring's weight was written twice (2px here, 3px
 * in the large one) and the two had to be kept looking alike by hand. Here the size is a width on
 * the box and the shape is one shape at every size. */
.spinner-arcs {
  display: inline-block;
  width: 32px;
  height: 32px;
  /* The arcs take their paint from here: `currentColor` reaches the circles, so a place that wants
     the mark in another colour overrides one declaration. */
  color: var(--accent);
  vertical-align: middle;
}

.spinner-arcs svg {
  display: block;
  width: 100%;
  height: 100%;
}

.spinner-arcs circle {
  fill: none;
  stroke: currentColor;
  /* Of the 50-unit box — a ninth of the mark's width, which is the weight the ring had. */
  stroke-width: 5;
  stroke-linecap: round;
  transform-box: view-box;
  transform-origin: center;
}

/* Circumference at r=20 is 126 units, and each pair of dashes adds up to it: what is left over is
   the gap the arc does not draw. The short, dimmer arc is the second hand — a third of the long
   one, and turning the other way, so the two never settle into one shape. */
.spinner-arcs circle:first-child {
  opacity: 0.45;
  stroke-dasharray: 30 96;
  animation: spinner-arcs 0.9s linear infinite;
}

.spinner-arcs circle:last-child {
  stroke-dasharray: 70 56;
  animation: spinner-arcs 1.35s linear infinite reverse;
}

@keyframes spinner-arcs {
  to { transform: rotate(360deg); }
}

/* Motion is a preference, not a decoration the stylesheet gets to decide: a mark that cannot be
   turned off is the kind of thing that makes a reader close the tab. Left still, the two arcs
   still say "waiting". */
@media (prefers-reduced-motion: reduce) {
  .spinner-arcs circle { animation: none; }
}

/* The waiting mark `busyWhile` puts inside a pressed button while its dialog is being asked of
   the hub: small enough not to move the label, and gone with the press's work. */
.btn-spinner {
  width: 12px;
  height: 12px;
  /* An EMPTY wheel (owner, 15.09): only one arc carries color, and that color is the BUTTON'S
     OWN ink (owner, 15.09) — an accent-colored arc vanishes on an accent-colored button. */
  border: 2px solid transparent;
  border-top-color: currentColor;
  margin-right: 6px;
  vertical-align: -2px;
}

/* A button whose press is still working fades (owner, 15.09): the dimming, not the wheel alone,
   is what says "inactive — a second press does nothing". */
.btn-busy {
  opacity: 0.55;
}

/* The context menu a folder row answers a right press or long touch with (owner, 15.09). */
.ctx-menu {
  position: fixed;
  z-index: 1000;
  min-width: 160px;
  /* The opener clamps the box to the window; a menu WIDER than the window cannot be clamped by
     moving it, so its own width is bounded here as well — the items wrap/scroll instead of the
     left edge leaving the screen. */
  max-width: calc(100vw - 8px);
  box-sizing: border-box;
  background: var(--bg-primary);
  border: 1px solid var(--border-color);
  border-radius: 8px;
  box-shadow: var(--shadow-lg);
  padding: 4px;
  /* Clamped to the viewport by its opener; past that it scrolls rather than runs off screen. */
  max-height: calc(100vh - 8px);
  overflow: auto;
}

.ctx-menu-item {
  display: block;
  width: 100%;
  text-align: left;
  padding: 8px 12px;
  border: 0;
  border-radius: 6px;
  background: none;
  color: var(--text-primary);
  font-size: 14px;
  cursor: pointer;
}
.ctx-menu-iconed { display: flex; align-items: center; gap: 8px; }
.ctx-menu-icon { display: inline-flex; }

.ctx-menu-item:hover {
  background: var(--bg-hover);
}

.screen-loading {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 60px 20px;
  color: var(--text-secondary);
  gap: 12px;
}

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

/* Button loading state */
/* A secondary action, wherever it stands. The class named the role since the ledger screen was
   written and styled nothing — the "Load more" button looked dressed only because its id carried
   the rules, so the role was a promise kept by one of its wearers. Callers still add their own
   layout; this says what a secondary button IS. */
.btn-secondary {
  min-height: 44px;
  padding: 8px 16px;
  border: 1px solid var(--border-color);
  border-radius: 6px;
  background: var(--bg-primary);
  color: var(--text-primary);
  font-size: 13px;
  /* Same line as `.action-btn`'s, same reason: a <button> does not inherit the page font, so a
     role that states its own box has to state this too or its two elements disagree. */
  font-family: inherit;
  cursor: pointer;
}

.btn-secondary:hover {
  background: var(--bg-hover);
  color: var(--text-primary);
}

.btn-loading {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  opacity: 0.8;
  cursor: not-allowed;
}

.btn-loading .spinner {
  width: 16px;
  height: 16px;
  border-width: 2px;
  border-color: var(--spinner-track);
  border-top-color: var(--bg-primary);
}

.btn:not(.btn-primary) .spinner {
  border-color: var(--spinner-track-plain);
  border-top-color: var(--accent);
}

/* ===========================================================================
 * Inbox search
 * =========================================================================== */

#inbox-search-wrap {
  position: relative;
  margin-bottom: 12px;
}

#inbox-search {
  width: 100%;
  padding: 10px 16px 10px 40px;
  border: 1px solid var(--border-color);
  border-radius: 24px;
  font-size: 14px;
  background: var(--kbd-bg);
  transition: background 0.2s, border-color 0.2s, box-shadow 0.2s;
}

#inbox-search:focus {
  outline: none;
  background: var(--bg-primary);
  border-color: var(--accent);
  box-shadow: 0 1px 6px var(--focus-ring);
}

#inbox-search-wrap .search-icon {
  position: absolute;
  left: 14px;
  top: 50%;
  transform: translateY(-50%);
  color: var(--text-secondary);
  font-size: 16px;
  pointer-events: none;
}

#inbox-search-wrap .search-clear {
  position: absolute;
  right: 12px;
  top: 50%;
  transform: translateY(-50%);
  background: none;
  border: none;
  color: var(--text-secondary);
  font-size: 18px;
  cursor: pointer;
  padding: 4px;
  display: none;
}

#inbox-search-wrap .search-clear:hover {
  color: var(--text-primary);
}

/* New-build strip (#1131). A surface with its own background carries its own padding, and this one
   sits in the flow at the top of #app rather than floating over anything. No dismiss control on
   purpose: the only answer to it is the reload it asks for, and a reader who has not reloaded has
   not answered it. */
.build-banner {
  display: flex;
  align-items: center;
  gap: 12px;
  flex-wrap: wrap;
  background: var(--bg-hover);
  border-bottom: 1px solid var(--accent);
  padding: 8px 16px;
  font-size: 14px;
  color: var(--text-primary);
}

.build-text {
  flex: 1;
}

/* Hub configuration banner */
.hub-config-banner {
  background: var(--bg-hover);
  border: 1px solid var(--accent);
  border-radius: 6px;
  padding: 12px 16px;
  margin-bottom: 12px;
}

.hub-config-content {
  display: flex;
  align-items: center;
  gap: 12px;
  flex-wrap: wrap;
}

.hub-config-icon {
  font-size: 20px;
  flex-shrink: 0;
}

.hub-config-text {
  flex: 1;
  font-size: 14px;
  color: var(--text-primary);
}

.hub-config-dismiss {
  background: none;
  border: none;
  font-size: 20px;
  cursor: pointer;
  color: var(--text-secondary);
  /* A 20px glyph is not a 20px target. */
  min-width: 44px;
  padding: 0 4px;
  line-height: 1;
  flex-shrink: 0;
}

.hub-config-dismiss:hover {
  color: var(--text-primary);
}

/* ===========================================================================
 * Screen header standardization
 * =========================================================================== */

.screen-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 16px;
  gap: 12px;
}

.screen-header h2 {
  font-size: 20px;
  font-weight: 400;
  color: var(--text-primary);
  margin: 0;
}

.screen-header-actions {
  display: flex;
  align-items: center;
  gap: 8px;
}

.header-btn {
  padding: 8px 16px;
  background: var(--bg-primary);
  border: 1px solid var(--border-color);
  border-radius: 4px;
  cursor: pointer;
  font-size: 13px;
  color: var(--text-secondary);
  transition: background 0.2s, border-color 0.2s;
  display: flex;
  align-items: center;
  gap: 6px;
}

.header-btn:hover {
  background: var(--bg-hover);
  border-color: var(--text-secondary);
}

/* A control that refuses a press must not look pressable (#2013). `chatMemberAct` disables the
   other rows' act buttons while one act is in flight — the roles line replaces the whole map, so a
   second act would undo the first — and a disabled button that kept its live look would be the same
   silence in a new costume. The hover is undone for the same reason: it is the button promising
   something it will not do. */
.header-btn:disabled {
  opacity: .5;
  cursor: default;
}

.header-btn:disabled:hover {
  background: var(--bg-primary);
  border-color: var(--border-color);
}

.header-btn .btn-icon {
  font-size: 16px;
}

/* ===========================================================================
 * Empty states
 * =========================================================================== */

.empty-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 60px 20px;
  text-align: center;
  color: var(--text-secondary);
}

.empty-state-icon {
  font-size: 56px;
  opacity: 0.5;
  margin-bottom: 16px;
}

.empty-state-title {
  font-size: 18px;
  font-weight: 500;
  color: var(--text-primary);
  margin-bottom: 8px;
}

.empty-state-text {
  font-size: 14px;
  color: var(--text-secondary);
  max-width: 300px;
  line-height: 1.5;
}

/* ===========================================================================
 * Theme chooser — three states, so a segmented control and not a toggle
 * =========================================================================== */

.theme-options,
.lang-options {
  display: inline-flex;
  border: 1px solid var(--border-color);
  border-radius: 20px;
  overflow: hidden;
}

/* Chain node (§7.5). Stacked rather than segmented: every label here is a URL, and a row of URLs
   squeezed side by side is unreadable at any width. Selection is carried by aria-pressed and
   painted from it — a separate `-active` class would be a second answer to "which node am I
   reading", sitting next to the one assistive technology is given. */
.node-options {
  display: flex;
  flex-direction: column;
  gap: 4px;
  margin: 8px 0;
}

.node-option {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 2px;
  /* The tap-target floor applies here like everywhere else. */
  min-height: 44px;
  padding: 8px 12px;
  border: 1px solid var(--border-color);
  border-radius: 6px;
  background: var(--bg-primary);
  color: var(--text-primary);
  cursor: pointer;
  text-align: left;
}

.node-option:hover {
  background: var(--bg-hover);
  color: var(--text-primary);
}

.node-option[aria-pressed="true"] {
  background: var(--accent);
  color: var(--text-on-primary);
  border-color: var(--accent);
}

.node-option-url {
  font-size: 13px;
  /* A long address wraps rather than widening the card past the screen. */
  overflow-wrap: anywhere;
}

.node-option-note {
  font-size: 12px;
  opacity: 0.85;
}

.theme-option,
.lang-option {
  background: none;
  border: none;
  /* 44px tall: this is the first control touched on a phone and the redesign's
     tap-target floor applies to it like everything else. */
  min-height: 44px;
  padding: 0 16px;
  cursor: pointer;
  font-size: 13px;
  font-weight: 500;
  color: var(--text-secondary);
  transition: background 0.2s, color 0.2s;
}

.theme-option + .theme-option,
.lang-option + .lang-option {
  border-left: 1px solid var(--border-color);
}

.theme-option:hover,
.lang-option:hover {
  background: var(--bg-hover);
  color: var(--text-primary);
}

.theme-option-active,
.lang-option-active {
  background: var(--accent);
  color: var(--text-on-primary);
}

.theme-option-active:hover,
.lang-option-active:hover {
  background: var(--accent-hover);
  color: var(--text-on-primary);
}

/* ===========================================================================
 * Bulk select action bar
 * =========================================================================== */

.bulk-action-bar {
  display: none;
  align-items: center;
  /* One line is what this bar wants and a phone cannot give it: "selected" plus a size plus three
     controls do not fit in 390px. Without a wrap they crushed each other — the count wrapped to
     three lines inside its own box and the controls were squeezed against it (owner's photo,
     12.09). Wrapping is the layout for a row that does not fit, and this is one. */
  gap: 8px 12px;
  flex-wrap: wrap;
  padding: 8px 16px;
  background: var(--bg-active);
  border-radius: 8px;
  margin-bottom: 8px;
  animation: slideDown 0.2s ease;
}

.bulk-action-bar.visible {
  display: flex;
}

@keyframes slideDown {
  from { opacity: 0; transform: translateY(-8px); }
  to { opacity: 1; transform: translateY(0); }
}

.bulk-action-bar .bulk-count {
  font-weight: 500;
  color: var(--text-primary);
  /* It takes the slack, so a wide screen pushes the actions to the right and a narrow one leaves
     the count where it is. It is also what keeps the count on the FIRST line: the box below asks
     for the whole line, and an item that has claimed the slack is not the one that moves. */
  margin-right: auto;
}

/* The batch actions, together, as one thing that either fits beside the count or does not. On a
   phone it takes the whole line below (a `flex-basis` of the row, not a `<br>`: the break belongs
   to the layout, and on a wide screen there is no break at all); from the desktop breakpoint up it
   takes its natural width and the count's slack puts it on the right. */
.bulk-action-bar .bulk-actions {
  display: flex;
  align-items: center;
  gap: 12px;
  flex-basis: 100%;
}

@media (min-width: 769px) {
  .bulk-action-bar .bulk-actions {
    flex-basis: auto;
  }
}

/* A glyph and a count, side by side, on ONE line whatever the width (owner, 13.09). `nowrap` is
   the fix itself and not a tidy-up: the two-line box in the photo was a word that wrapped under
   its own number, and a box that can grow a line can grow taller than the bar it was put in. */
.bulk-action-bar .bulk-btn {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 6px 10px;
  border: 1px solid var(--border-color);
  border-radius: 4px;
  background: var(--bg-primary);
  color: var(--text-primary);
  cursor: pointer;
  font-size: 13px;
  white-space: nowrap;
  transition: background 0.2s;
}

/* The count — the only text left in the button. No box of its own: it is a digit beside a glyph,
   and a second border inside the first one reads as a control nested in a control. */
.bulk-action-bar .bulk-btn .bulk-n {
  font-variant-numeric: tabular-nums;
}

/* Nothing to count: the button is a bare glyph, so the gap must not hold a line of its own. */
.bulk-action-bar .bulk-btn .bulk-n:empty {
  display: none;
}

.bulk-action-bar .bulk-btn:hover {
  background: var(--bg-hover);
}

/* A batch button with nothing to do: dimmed, and it does not light up under the pointer. The bar is
   on screen from the moment there are rows, so this state is what the reader meets first. */
.bulk-action-bar .bulk-btn:disabled,
.bulk-action-bar .bulk-btn:disabled:hover {
  opacity: 0.45;
  cursor: default;
  background: var(--bg-primary);
}

.bulk-action-bar .bulk-btn.danger {
  border-color: var(--danger);
  color: var(--danger);
}

.bulk-action-bar .bulk-btn.danger:hover {
  background: var(--danger);
  color: var(--text-on-primary);
}

.bulk-action-bar .bulk-close {
  background: none;
  border: none;
  font-size: 20px;
  color: var(--text-secondary);
  cursor: pointer;
  padding: 4px 8px;
}

.bulk-action-bar .bulk-close:hover {
  color: var(--text-primary);
}

/* Inbox item checkbox */
.inbox-item {
  position: relative;
}

.inbox-item:has(.inbox-checkbox) {
  padding-left: 40px;
}

.inbox-item .inbox-checkbox {
  position: absolute;
  left: 12px;
  top: 50%;
  transform: translateY(-50%);
  width: 24px;
  height: 24px;
  cursor: pointer;
  opacity: 0;
  transition: opacity 0.15s;
}

.inbox-item:hover .inbox-checkbox,
.inbox-checkbox:checked {
  opacity: 1;
}

.inbox-item.selected {
  background: var(--bg-active);
}

/* Select all checkbox in header */
/* The wrapper is the target, for the reason stated in the tap-target section: the box itself is a
   replaced element and cannot grow its own hit area. It measured 24px — exactly the checkbox. */
.select-all-wrap {
  display: flex;
  align-items: center;
  gap: 8px;
  min-height: 44px;
}

.select-all-checkbox {
  width: 24px;
  height: 24px;
  cursor: pointer;
}

@media (min-width: 769px) {
  #toast-container {
    left: auto;
    /* Wider than the old 360 (owner, 22.09): a voice notice quotes a title and an author, and at
       360 that wrapped to three lines. The cap is the desktop limit; below 769px the container is
       already edge-to-edge and adapts by itself. */
    max-width: 480px;
  }

  .toast {
    width: auto;
  }

  #confirm-dialog {
    margin: 0;
  }
}

/* ── The accounts screen and the unlock gate ───────────────────────────────── */

.account-entry {
  border: 1px solid var(--border-color);
  border-radius: 6px;
  margin-bottom: 8px;
  background: var(--bg-card);
}

.account-entry-head {
  display: flex;
  align-items: center;
}

.account-entry-main {
  display: flex;
  align-items: center;
  gap: 8px;
  flex: 1;
  min-width: 0;
  padding: 12px 14px;
  background: none;
  border: none;
  text-align: left;
  font-size: 14px;
  color: var(--text-primary);
  cursor: pointer;
}

.account-entry-main:hover {
  background: var(--bg-hover);
}

.account-entry-more {
  background: none;
  border: none;
  padding: 12px 14px;
  font-size: 18px;
  line-height: 1;
  color: var(--text-secondary);
  cursor: pointer;
}

.account-entry-more:hover {
  color: var(--text-primary);
}

.account-entry-menu {
  border-top: 1px solid var(--border-color);
  padding: 6px 0;
}

.account-entry-menu.hidden {
  display: none;
}

.account-menu-item {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  padding: 8px 14px;
  background: none;
  border: none;
  text-align: left;
  font-size: 14px;
  color: var(--text-primary);
  cursor: pointer;
}

.account-menu-item:hover {
  background: var(--bg-hover);
}

.account-menu-item.danger {
  color: var(--danger);
}

.account-keys {
  border-top: 1px solid var(--border-color);
  padding: 12px 14px;
}

.account-keys.hidden {
  display: none;
}

/* Why a row has no key panel. It takes the place the panel would have had, so the absence is
   answered where the reader looked for it — which means it is a PANEL, with the panel's edge, and
   the sentence inside it is the `.hint`. It used to be one element wearing both: a role that sets a
   measure and a box that draws a border, so the border stopped at the measure and read as a rule
   drawn half way across the card. */
.account-keys-note {
  border-top: 1px solid var(--border-color);
  padding: 10px 14px;
}

/* Nothing inside these panels adds a gap at the panel's own edge — the padding is the only margin
   there, and `.hint` carries a bottom margin for the controls it introduces. */
.account-keys > :last-child,
.account-keys-note > :last-child {
  margin-bottom: 0;
}

.account-keys .account-key-input {
  width: 100%;
  padding: 10px;
  margin-bottom: 8px;
  border: 1px solid var(--border-color);
  border-radius: 4px;
  font-size: 14px;
}

.account-keys .account-key-save {
  margin-bottom: 12px;
}

.account-row .account-current {
  width: 14px;
  color: var(--accent);
}

.account-row .account-name {
  flex: 1;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.account-row .account-kind {
  font-size: 12px;
  color: var(--text-secondary);
}

/* The names of the keys this browser holds. Green because it is the presence of a thing, read at a
   glance beside accounts that are missing one — the row's other states stay in the muted colour. */
.account-row .account-keys-held {
  color: var(--success-text);
  font-weight: 500;
}

/* §5.8 session rows. One line per session: the facts on the left, the one control on the right.
   Wrapping is allowed on purpose — the facts are a sentence of four parts, and a narrow screen
   should show all four rather than cut the state off the end. */
.session-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  padding: 10px 0;
  border-top: 1px solid var(--border-color);
}

/* The separator between facts lives here, not in the join: punctuation typed into the script is a
   piece of the sentence written in one language for all of them. */
/* A hash and a signature are long, and their meaning is the LABEL beside them: a definition list
   says which is which, and the value wraps instead of pushing the card wide. */
.agreement-facts {
  margin: 6px 0 10px;
  font-size: 13px;
}

.agreement-facts dt {
  color: var(--text-secondary);
  margin-top: 6px;
}

.agreement-facts dd {
  margin: 2px 0 0;
  overflow-wrap: anywhere;
}

.agreement-check p {
  margin: 6px 0 0;
}

/* The separator is spaced by margin, not by spaces inside content(): a space in the string is
   text and can be broken across a line, leaving a lone dot at the start of the next one. */
.session-fact + .session-fact::before {
  content: '\00b7';
  margin: 0 0.3em;
}

.session-facts {
  flex: 1;
  min-width: 200px;
  font-size: 13px;
  color: var(--text-secondary);
}

/* The revoke button never grows to fill the row: a destructive control the width of the screen is
   easier to hit by accident than to hit on purpose. */
.session-revoke {
  flex: 0 0 auto;
}

/* §5.12 the account archive. One row per job, and the row is the same shape as a session row —
   wrap freely, because a finished archive carries a link wide enough to want the whole width. */
.archive-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  padding: 10px 0;
  border-top: 1px solid var(--border-color);
}

.archive-facts {
  flex: 1;
  min-width: 200px;
  font-size: 13px;
  color: var(--text-secondary);
}

/* The separator is spaced by margin and drawn in CSS, not typed into the join in the script: a
   `' · '` in the source is one sentence's punctuation written once for every language. */
.archive-fact + .archive-fact::before {
  content: '\00b7';
  margin: 0 0.3em;
}

/* The link is the point of the screen, so it is selectable text rather than a button — a reader
   who cannot copy it still needs to be able to read it off. It is wide enough to hold an address
   and no wider than the card. */
.archive-link {
  flex: 1 1 100%;
  min-width: 0;
  font-size: 13px;
  padding: 6px 8px;
}

/* The pickers sit on one line while there is room and stack when there is not, which is the same
   rule the rest of the settings controls follow. */
.archive-opts {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin-bottom: 8px;
}

.archive-opts label {
  color: var(--text-secondary);
  font-size: 13px;
}

/* A section of a screen IS the card: it carries `.deposit-card` for the surface and adds only the
   gap between sections. Heading and introduction sit inside, so one white block is one feature —
   see the comment on `#settings-screen` in index.html and tests/ui-layout.js. */
.settings-block {
  margin-bottom: 24px;
}

.settings-block:last-child {
  margin-bottom: 0;
}

/* The heading of a section, at the top of its own card. */
.settings-section {
  margin: 0 0 8px;
  font-size: 16px;
  font-weight: 500;
  color: var(--text-primary);
}

/* A control group inside a section. A card inside a card is the two-shapes bug coming back, so a
   sub-control is separated by a rule and a gap instead of by a second surface. */
.settings-item {
  margin-top: 20px;
  padding-top: 20px;
  border-top: 1px solid var(--border-color);
}

/* A button that acts on the group it sits in. Declared here, once, as a role: a control appended
   to a settings item otherwise inherits no gap at all, and an undeclared gap is not a small gap —
   it is none, so the button reads as welded to the field above it (the node picker shipped that
   way, and the live layout probe measured 0px between "Another node" and "Check"). */
.settings-item > .btn-secondary {
  margin-top: 12px;
}

.settings-item h4 {
  margin: 0 0 12px;
  font-size: 14px;
  font-weight: 500;
  color: var(--text-primary);
}

/* The first VISIBLE item opens straight under the introduction — a rule there would separate it
   from the heading, not from a sibling. Two cases, because the encryption section shows either the
   "turn on" item or the two that follow it: the item that is first in the markup, and the item that
   follows a hidden one. */
.settings-block > .settings-item:not(.hidden):first-of-type,
.settings-item.hidden + .settings-item:not(.hidden) {
  border-top: none;
  padding-top: 0;
}

#unlock-overlay {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: var(--bg-overlay);
  z-index: 4000;
  display: flex;
  align-items: center;
  justify-content: center;
}

#unlock-overlay.hidden {
  display: none;
}

#unlock-card {
  width: 380px;
  max-width: 92vw;
  padding: 28px;
  background: var(--bg-card);
  border-radius: 8px;
  box-shadow: var(--shadow-lg);
}

#unlock-card h3 {
  margin: 0 0 8px;
  color: var(--text-primary);
}

#unlock-card input[type="password"] {
  width: 100%;
  padding: 10px 12px;
  margin: 12px 0 4px;
  background: var(--bg-input);
  border: 1px solid var(--border-color);
  border-radius: 4px;
  color: var(--text-primary);
  font-size: 14px;
  box-sizing: border-box;
}

#unlock-card hr {
  margin: 18px 0 10px;
  border: none;
  border-top: 1px solid var(--border-color);
}

.unlock-error {
  min-height: 18px;
  color: var(--danger);
  font-size: 13px;
}

.link-btn {
  background: none;
  border: none;
  padding: 0;
  font-size: 13px;
  color: var(--text-secondary);
  cursor: pointer;
  text-decoration: underline;
}

.link-btn.danger {
  color: var(--danger);
}

/* A text box in a settings group, addressed by what it IS rather than by its type. The rule used to
   name `password` alone, so the first field of another type to arrive — the session length, the
   token readout — was drawn by the browser's defaults: square, unpadded, in the wrong font, next
   to fields that were none of those things. Checkbox and radio are excluded by name, since they
   are not boxes with text in them.

   And it used to name a SCREEN (`#settings-screen`), which is the mistake this file has now paid
   for three times — the label rule below carries the same story for captions. The profile screen
   was built later from the same `.settings-item` blocks, so every field on it was drawn by the
   browser: square corners, no padding, a default width two thirds of the card, next to a button
   that was none of those things (owner's screenshot, 13.09). The role both screens share is the
   GROUP, so the group is what this names; a fourth screen with a settings block gets the look
   without anyone remembering to come back here. */
.settings-item input:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
.settings-item textarea {
  display: block;
  width: 320px;
  max-width: 100%;
  /* The same 44px the selects have always had: a field is a field, and two of them on one line
     that differ in height read as a mistake even when nothing overlaps. */
  min-height: 44px;
  padding: 10px 12px;
  margin-bottom: 10px;
  background: var(--bg-input);
  border: 1px solid var(--border-color);
  border-radius: 4px;
  color: var(--text-primary);
  font-size: 14px;
  font-family: inherit;
  transition: border-color 0.2s;
  box-sizing: border-box;
}

.settings-item input:not([type="checkbox"]):not([type="radio"]):focus,
.settings-item textarea:focus {
  outline: none;
  border-color: var(--accent);
}

/* The quantity keeps its own width — the shared rule above is the look, not the size. */
#settings-screen .sess-ttl-row input[type="number"] {
  width: 96px;
}

/* A strip is a label, its control and the sentence explaining it; the next strip begins after a
   gap. Declared on the row rather than on each control, because a row can hold two controls
   (number + unit) and the gap belongs to the ROW — which also means the controls inside it must
   not carry one of their own. Zeroing them for every child, not for the one that was noticed:
   the input had its bottom margin cleared while the select kept a 10px one, and the pair stood
   at different heights on the same line. */
#settings-screen .sess-ttl-row {
  margin-bottom: 12px;
}

/* Spelled with the same shape as the field rule above, not as `> *`: the field rule is written
   with three :not() clauses and outranks a plain child selector, so the short version cleared the
   select's margin and left the input's — which is the very pair that stood at two heights. */
#settings-screen .sess-ttl-row input:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
#settings-screen .sess-ttl-row .setting-select {
  margin-top: 0;
  margin-bottom: 0;
}

/* A button that acts on the group it sits in. Named as a ROLE and for both containers a settings
   screen has — an item and a block — because the gap was declared for one class in one of them,
   and every button of another class, or one section deeper, shipped welded to the control above
   it ("Issue a token" to the length; "Read the current version" to its status line). */
.settings-item > button,
.settings-block > button {
  margin-top: 12px;
}

/* A label naming the control under it. A bare <label> is inline and carries no gap at all, so the
   caption sat ON the box it names; h4 in the same position has always had this margin.
   The `:not()` says which labels are meant. A `.checkbox-label` is not a caption ABOVE a control —
   it is a flex row that holds the box BESIDE its sentence, and a descendant selector setting
   `display: block` on it drops the sentence off the box's baseline. That is the same collision
   `.compose-field > label` already lost once, so the exclusion is written here rather than left to
   whoever adds the next switch to this screen.
   Named by ROLE, not by screen: the rule used to read `#settings-screen …`, which is where the
   only screen using it then happened to live, so the profile screen — built later, same
   `.settings-item` blocks — got its captions welded to its boxes. A screen name is not a role. */
.settings-item > label:not(.checkbox-label) {
  display: block;
  margin-bottom: 6px;
  font-size: 14px;
  font-weight: 500;
  color: var(--text-primary);
}

#settings-screen .action-btn.danger {
  background: var(--danger);
  color: var(--text-on-primary);
}

/* A Sent row exists from the moment the message leaves the compose form, so it has to be able to
   say that the hub has not answered yet — and to keep saying it if the answer never came. */
.sent-badge {
  display: inline-block;
  margin-left: 6px;
  padding: 1px 6px;
  border-radius: 10px;
  font-size: 11px;
  font-weight: 600;
  vertical-align: middle;
}

.sent-badge-pending {
  background: var(--kbd-bg);
  color: var(--text-secondary);
}

.sent-badge-unknown {
  background: var(--warning-bg-soft);
  color: var(--warning-text);
}

.sent-badge-failed {
  background: var(--danger-bg-soft);
  color: var(--danger);
}

/* ===========================================================================
 * Tap targets.
 *
 * The rule this replaces was `button, .action-btn { min-height: 44px }` INSIDE
 * the phone-only block — a two-name list, and the things a reader actually taps
 * were not on it. Measured at 360 CSS pixels before this file was flipped: the
 * message row 35px tall, a folder link 41px, the checkboxes 16–18px, every text
 * input 38px, the hub-banner dismiss 20px wide.
 *
 * Two decisions are stated here rather than left to a media query:
 *
 *   1. The size of a control is NOT a function of the viewport. A mouse pointer
 *      misses a 20px target too, and a laptop with a touchscreen is one device.
 *      So this section is unconditional; there is no desktop counterpart that
 *      shrinks it back.
 *   2. Text inputs are 16px. Below that iOS Safari zooms the page on focus and
 *      leaves it zoomed — the field wins, the rest of the screen loses.
 *
 * A bare checkbox is exempt from the 44px floor and cannot be otherwise: an
 * `<input>` is a replaced element, so it carries neither padding that grows its
 * hit area nor a pseudo-element to spread one. What is a target is the row (or
 * the `.checkbox-label`) around it; the box itself only has to be visible and
 * comfortable, hence 24px. .secrets/ui-mobile-accept.mjs holds that exemption
 * by name and requires the row around it to clear 44px.
 * =========================================================================== */

button,
[role="button"],
.action-btn,
.file-row,
.file-card,
select,
textarea,
input:not([type="checkbox"]):not([type="radio"]):not([type="hidden"]) {
  min-height: 44px;
}

/* A target has two dimensions. The height floor alone leaves the icon-only
   controls — the hamburger, the settings gear, the grid/list toggles — 30 to 38
   CSS pixels wide: tall enough to look deliberate and still a miss. Stated only
   for the controls that can be narrow; a text input or a file row is already
   wider than this and does not need to be told.

   `:not(.file-row)` is the second half of that sentence, which said the row does not need telling
   and then told it anyway: a list row wears `role="button"` because it is pressable, so this rule
   matched it, and — same specificity, written later — it beat the row's own floor. The name column
   collapsed to nothing and the columns ran outside the row's box on a phone (owner's photo,
   12.09). A row is never narrow: the floor has nothing to add to it and one thing to take away. */
button:not(.file-row),
[role="button"]:not(.file-row),
.action-btn {
  min-width: 44px;
}

/* A tag chip is a WORD standing in an article or a profile, not a control held at arm's length —
   FSP draws it that size (owner, 23.09: «ты пытаешься их сделать как обычные buttons, не надо, у
   них свое назначение»). It is a `<button>` only for the click-delegation every other control on
   this page uses; the 44px floor above is stated for controls the reader reaches for, and a chip
   is read before it is pressed. Both `<button class="wall-tag">` (a record's own tags) and
   `<button class="tag-chip">` (the account's §7.5 lists, plain or the wall head's filterable
   form) are exempt.
   Named `button.wall-tag`/`button.tag-chip` rather than the bare class: the MIN-WIDTH floor two
   rules up is `button:not(.file-row)`, and `:not()` counts the specificity of its own argument —
   one class from the pseudo-class plus the element it qualifies outranks a bare class selector. A
   bare `.tag-chip { min-width: 0 }` here would have LOST that fight silently (min-height has no
   `:not()` twin and WOULD have been overridden, so the sweep that only checks a rule exists for
   the class stayed green while width alone stayed pinned at 44). */
button.wall-tag,
button.tag-chip {
  min-height: 0;
  min-width: 0;
}

/* The formatting row, drawn at FSP's own size rather than the reader's tap floor (owner, 23.09,
   after the tag chips above: «делай блок ровно высотой сколько 1 button шириной» — pointed at
   this bar specifically, `.editor-formatter` in FSP is a fixed 40px, its icon 1.2em same as ours).
   The 44px floor exists for controls held at arm's length in a single column; this is a DENSE row
   of them, meant to be scanned and tapped in quick succession like a keyboard row, and the floor's
   own regression (the comment on `.md-btn` below, 22.09) was about a row that WRAPPED to a second
   line 4px under the first — not this one, which scrolls sideways in ONE line and never grows a
   neighbour above or below a button to miss. `button.md-btn` for the same specificity reason as
   the tag chips just above. */
button.md-btn {
  min-height: 0;
  min-width: 0;
}

select,
textarea,
input:not([type="checkbox"]):not([type="radio"]):not([type="hidden"]) {
  font-size: 16px;
}

input[type="checkbox"],
input[type="radio"] {
  width: 24px;
  height: 24px;
}

.checkbox-label {
  min-height: 44px;
}

/* -----------------------------------------------------------------------------
 * Notes a list draws in place of its rows, and the one badge a ledger row carries.
 *
 * These lived as inline `style="color:#5f6368"` / `#d93025` / `#f9ab00` inside markup built in
 * app.js. A literal cannot follow the theme, and the theme guard reads the stylesheet — so five
 * Material colours sat exactly where no check could see them, on the empty and the failed states,
 * which is the text a reader needs most when something has gone wrong.
 * -------------------------------------------------------------------------- */
.list-note {
  padding: 16px;
  text-align: center;
  color: var(--text-secondary);
}

.list-note.error {
  color: var(--danger);
}

/* --------------------------------------------------------------------------
 * §8.11 §11 — the operator's area. Rows, not cards: every figure on those
 * pages is one of a list and is meant to be compared down the column, which
 * a stack of cards cannot do. The names carry the ROLE (`who`, `num`, `tag`,
 * `when`), as the rest of this file does, so the same row shape is reused by
 * the revenue page without a second set of rules.
 * -------------------------------------------------------------------------- */
/* The pages of the area. They wrap rather than scroll: there are four of them now, and a wrapping
   row is a whole row on every screen this client is used on — where a scrolling row would hide a
   page behind an edge. The count is named here rather than left to the reader, because it is the
   premise of the choice: past a full row the scroll would be the right answer and this would be
   the wrong one. */
.admin-nav {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-bottom: 12px;
}

/* The second row a section with more than one leaf gets (#729: Economy, Grants, Moderation).
   Same chip as the top row — an operator should not have to learn a second look for "this is
   still navigation" — just tighter and set off above the page it opens. */
.admin-subtabs {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin: -4px 0 16px 0;
}

.admin-subtabs .admin-nav-btn,
.admin-subtab-btn {
  padding: 6px 12px;
  font-size: 12px;
}

.admin-list {
  display: flex;
  flex-direction: column;
}

.admin-row {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 8px;
  padding: 8px 0;
  border-bottom: 1px solid var(--border-color);
  font-size: 13px;
}

.admin-row:last-child {
  border-bottom: none;
}

/* The decision on an unreviewed row — outcome, reason, spam mark, the "решить" pill — sits on its
   own line under the "why" the row already prints, rather than fighting that line's own wrap. */
.admin-decide {
  flex: 1 0 100%;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin-top: 4px;
}

.admin-who {
  flex: 1 1 40%;
  min-width: 0;
  overflow-wrap: anywhere;
  color: var(--text-secondary);
}

/* Every figure on this page is a number to be read down a column, so it is
   set apart and never wrapped: a token count broken across two lines is a
   number the reader misreads. */
.admin-num {
  flex: 0 0 auto;
  color: var(--text-primary);
  font-variant-numeric: tabular-nums;
}

.admin-why {
  flex: 0 0 auto;
  color: var(--text-secondary);
}

/* The batch name. It is an identifier rather than a link, so it sits on the row tint in the
   readable weight of the accent — the fill weight is 4.44:1 there (tests/ui-theme.js). */
.admin-tag {
  flex: 0 0 auto;
  padding: 1px 6px;
  border-radius: 3px;
  background: var(--bg-hover);
  color: var(--accent-text);
  font-size: 11px;
}

.admin-when {
  flex: 0 0 auto;
  color: var(--text-secondary);
  font-size: 12px;
}

/* The dashboard (§8.11 §11 `admin.summary`). Tiles rather than rows: these figures are read
   TOGETHER and against each other, and a column of rows would make the operator scroll to hold two
   of them in mind at once. Nothing here has a background of its own, so it carries no padding
   contract with the surface underneath. */
.dash-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
  gap: 10px;
}

.dash-tile {
  padding: 10px 12px;
  border: 1px solid var(--border-color);
  border-radius: 6px;
}

.dash-label {
  font-size: 12px;
  color: var(--text-secondary);
}

/* A figure read down a column, like `.admin-num`: never wrapped, never proportional. */
.dash-value {
  font-size: 20px;
  color: var(--text-primary);
  font-variant-numeric: tabular-nums;
}

.dash-sub {
  margin-top: 2px;
  font-size: 12px;
  color: var(--text-secondary);
}

.badge-warn {
  display: inline-block;
  margin-left: 4px;
  padding: 1px 6px;
  border-radius: 3px;
  font-size: 10px;
  background: var(--warning-bg-soft);
  color: var(--warning-text);
}

/* --- §8.6 price reference -----------------------------------------------------------------
 * A block of figures under its heading, and the figures as a definition list: the name and the
 * number are a pair, and a pair is what a <dl> is for. Every gap here is declared, because under
 * the reset a row with none is welded to the row above it and reads as one line.
 */
.pricing-block {
  margin-bottom: 24px;
}

.pricing-block h3 {
  margin-bottom: 8px;
}

.pricing-list {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: 6px 16px;
  align-items: baseline;
}

.pricing-list dt {
  color: var(--text-secondary);
}

.pricing-list dd {
  margin: 0;
  text-align: right;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}

/* --- §7.4 L2 explorer ---------------------------------------------------------------------
 * Mobile shows the list or selected block. Desktop restores both panes at the shared breakpoint.
 */
.l2-mobile-nav { display: flex; gap: 8px; margin-bottom: 16px; }
.l2-mobile-nav .action-btn { flex: 1; background: transparent; color: var(--text-secondary); border-radius: 0; box-shadow: none; }
.l2-mobile-nav [aria-pressed="true"] { background: var(--bg-active); color: var(--text-primary); box-shadow: inset 0 -3px var(--accent); }
#l2-screen:not(.l2-detail-open) #l2-block-pane,
#l2-screen.l2-detail-open #l2-chain-pane { display: none; }

.l2-cols {
  display: flex;
  flex-direction: column;
  gap: 16px;
}

.l2-col {
  min-width: 0;
}


.l2-list-head { display: none; padding: 8px 12px; gap: 8px 12px; color: var(--text-secondary); font-size: 12px; }
.l2-list-head span:nth-child(2) { text-align: center; }
.l2-row {
  display: grid;
  padding: 10px 12px;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: 8px 12px;
  align-items: center;
  cursor: pointer;
}
.l2-row[aria-current="true"] { background: var(--bg-active); }
.l2-row-main { min-width: 0; overflow-wrap: anywhere; }
.l2-row-count { font-variant-numeric: tabular-nums; text-align: center; }
.l2-row-hash {
  grid-column: 1 / -1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  color: var(--accent-text);
  font-size: 12px;
}

.l2-head {
  margin-bottom: 12px;
}

.l2-head-n {
  font-size: 18px;
  font-weight: 600;
  color: var(--text-primary);
}

.l2-kv {
  display: flex;
  gap: 8px;
  align-items: baseline;
  font-size: 13px;
  margin-top: 6px;
}

.l2-kv span {
  color: var(--text-secondary);
  flex: 0 0 auto;
}

.l2-kv code {
  word-break: break-all;
  color: var(--text-primary);
}

.l2-entry {
  border-top: 1px solid var(--kbd-bg);
  padding: 8px 0;
}

.l2-entry-head {
  cursor: pointer;
  min-height: 44px;
  display: flex;
  flex-direction: column;
  justify-content: center;
}

.l2-entry-full {
  margin-top: 8px;
}

/* §7.9 the personal wall (#676). A feed, and the posts in it are the surfaces: each carries its
   own background, so each carries its own padding at every width (the rule the files grid and the
   agreement card are written to as well). */
.wall-head {
  margin-bottom: 16px;
}

.wall-ident {
  display: flex;
  align-items: center;
  gap: 12px;
}

/* The reader's door to his own card, at the far end of the identity row. It wears the box of a
   `.wall-act` — this client has one icon-button and it is that one, sized to the tap floor and
   quiet until hovered — and this rule adds the only thing the head needs and a record's row does
   not: the push to the end. Everything else in this row answers "whose wall is this"; the door is
   the one thing here about the READER, and it must not read as part of the author's name. */
.wall-ident-act {
  margin-left: auto;
}

.wall-face {
  width: 56px;
  height: 56px;
  border-radius: 50%;
  object-fit: cover;
  flex-shrink: 0;
}

/* The initial stands in for a picture that is not there, so it is read at a glance from across the
   page rather than read as body text — and `--text-secondary` on `--bg-active` measures 4.49:1,
   which `tests/ui-theme.js` refuses by one hundredth. The primary colour is the same trade the
   nickname above it makes. */
.wall-face-empty {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  background: var(--bg-active);
  color: var(--text-primary);
  font-size: 22px;
  font-weight: 600;
}

.wall-names {
  min-width: 0;
}

.wall-name {
  font-size: 18px;
  font-weight: 600;
  color: var(--text-primary);
  overflow-wrap: anywhere;
}

/* The address, and it is not decoration: the records are signed by the ACCOUNT, so this is the
   half of the identity a reader can check while the nickname above it is a claim its owner set. */
.wall-handle {
  font-size: 13px;
  color: var(--text-secondary);
  overflow-wrap: anywhere;
}

.wall-about {
  margin-top: 12px;
}

/* The author's own viz:// address and the button that hands it over — one line, quiet. */
.wall-viz {
  margin-top: 8px;
  display: flex;
  align-items: center;
  gap: 8px;
  color: var(--text-secondary);
}

/* Where else the author says he is (`profile.services`) — a wrapping row of outbound links. */
.wall-service {
  display: inline-flex;
  width: 28px;
  height: 28px;
  color: var(--text-secondary);
}
.wall-service:hover { color: var(--accent); }
.wall-service svg { width: 24px; height: 24px; }
.wall-services {
  margin-top: 8px;
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
}

/* The tags the account is found by (§7.5), under its own words. Both lists flow as one wrapping
   row and are told apart by COLOUR (interests accent, categories red — see `.tag-chip`), which is
   how Free Speech keeps them; why colour matters is argued at `tagChips`. */
.wall-tags {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
  margin-top: 10px;
}

.wall-notes {
  margin-bottom: 12px;
}

.wall-note {
  font-size: 13px;
  color: var(--text-secondary);
  background: var(--bg-secondary);
  border-radius: 4px;
  padding: 8px 10px;
  margin: 0 0 6px;
}
.wall-foot-note {
  font-size: 12px;
  color: var(--text-secondary);
  margin: 4px 0;
}

/* What this reader has met answering the post above it (`ui/js/replies.js`). A surface of its own,
   in the same shape as `.wall-note`: background, corner and its own padding at every width — the
   rule the files grid and the agreement card are written to as well. It is drawn INSIDE the post,
   under the body, because it is a fact about that record and not about the feed. */
/* The answers drawn under the post they answer (шаг 4). The RULE is the whole of the nesting
   device: a card inside a card already reads as "under", and a line down the left is what makes a
   chain of them read as a thread rather than as an indentation accident. The indent is small on
   purpose — this is read on a phone, and every level of nesting that walks right takes words off
   the line. */
.wall-branch {
  margin-top: 10px;
  padding-left: 10px;
  border-left: 2px solid var(--border-color);
}

.wall-branch > .wall-post {
  margin-top: 8px;
}

/* Where an answer is typed (шаг 4), under the post it answers. It takes the box the wall's own
   composer is written with — the same background, the same field — and not its placement: this one
   is a part of the card it stands in, so it takes no margin of its own and lets the card's padding
   hold it. */
.wall-reply {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 8px;
  margin-top: 10px;
  padding-top: 10px;
  border-top: 1px solid var(--border-color);
}

.wall-reply-text {
  width: 100%;
  box-sizing: border-box;
  padding: 10px 12px;
  border: 1px solid var(--border-color);
  border-radius: 4px;
  background: var(--bg-primary);
  color: var(--text-primary);
  font-size: 14px;
  font-family: inherit;
  resize: vertical;
  transition: border-color 0.2s;
}

.wall-reply-text:focus {
  outline: none;
  border-color: var(--accent);
}

/* The composer (§7.9, #1388 = A) — the one place in this client that WRITES to an L2 chain. It is
   a surface of its own, in the same shape as the posts it produces: background, border, and its own
   padding at every width (the rule the files grid and the agreement card are written to as well).
   It stands above the feed, because what it writes appears there — and only once the block it was
   seated in is sealed. */
.wall-compose {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 8px;
  background: var(--bg-card);
  border: 1px solid var(--border-color);
  border-radius: 8px;
  padding: 12px;
  margin-bottom: 12px;
}

/* The field takes the client's existing writing-surface rules — the same border, background and
   focus colour the message composer's textarea carries — but not its `min-height: 200px`, which
   belongs to a letter and would make a post look like one. */
.wall-compose textarea,
.wall-field-input,
.wall-benef-account,
.wall-benef-percent,
.wall-sealed-pass {
  width: 100%;
  box-sizing: border-box;
  padding: 5px 10px;
  border: 1px solid var(--border-color);
  border-radius: 4px;
  background: var(--bg-primary);
  color: var(--text-primary);
  font-size: 14px;
  font-family: inherit;
  transition: border-color 0.2s;
}

.wall-compose textarea {
  resize: vertical;
}

/* THE OTHER BOX THE COMPOSER WRITES INTO (#1421 = B), drawn to be mistaken for the first: exactly
   one of the pair stands at a time, and the author changing his mind about which editor he wants
   must not also change the size, the weight and the colour of the field his text is standing in.
   So the frame is the textarea's, copied rather than shared — the two are different elements and
   there is no selector that would hold both.
 *
 * THE THREE THINGS A `contenteditable` DOES NOT BRING ITSELF, and each one is a textarea default
 * this box would otherwise lose:
 *   - `pre-wrap`, because a textarea renders its newlines and a block collapses them to spaces — a
 *     body moved into the surface and back would come out as one paragraph;
 *   - `min-height`, for `rows="4"`: without it the box is a line tall and the composer jumps when
 *     the pair changes hands;
 *   - the placeholder, which a `contenteditable` has no attribute for. It is drawn from the
 *     attribute the i18n pass fills, and it is `pointer-events: none` so that a tap on the hint is
 *     a tap on the box. `:empty` is why `HubEditor.write` leaves an emptied box with no children
 *     at all — a box holding one line break is not empty to a selector. */
/* THE FULL-SCREEN EDITOR (owner, 22.09, after FSP's publish screen). The composer's own box,
   stretched over the window: the author writing an article reads it at the width and scale a
   reader will, instead of through a 91-pixel slot with the feed moving behind it. Nothing is
   re-parented — it is the same form wearing a class, so every field, the toolbar, the preview and
   the publish press are the ones already bound, and closing it undoes nothing. */
.wall-compose.is-full {
  position: fixed;
  /* Four offsets spelled out rather than `inset: 0`: the two opposite edges are what says the
     height is the window's, and a shorthand hides that from every reader — including the one that
     asks layers how they are bounded (`ui-layers`). */
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  z-index: 60;
  margin: 0;
  border: 0;
  border-radius: 0;
  overflow-y: auto;
  /* The form reaches the edges of the screen, so the insets are the FORM's to carry: a composer
     drawn to the top of the glass would put its toolbar under the notch, and one drawn to the
     bottom would leave the publish press under the home indicator — on the one screen where
     every control matters. */
  padding: max(16px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) max(16px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left));
  background: var(--bg-primary);
  /* FSP fixes the article width on purpose (owner, 22.09): `.content{width:600px}` normally,
     `body.publication-mode .content{max-width:800px}` while writing — a wide monitor stretches
     paragraphs past a readable line length otherwise, and a full-bleed textbox is exactly that.
     Centred rather than left-edge, because the form no longer sits beside a feed it must align
     with — it is the whole screen now. */
  align-items: center;
}

.wall-compose.is-full > * {
  width: 100%;
  max-width: 800px;
}

/* The toolbar's own scroll parent is `.wall-compose.is-full` (it has the `overflow-y: auto` that
   scrolls in this mode) — owner, 23.09: writing an article long enough to scroll carried the
   formatting row off with the title, leaving no way to reach it without scrolling back up. Scoped
   to `.is-full` on purpose: the small inline composer (91px) never scrolls itself, and an unscoped
   sticky rule would pin the toolbar to the FEED's scroll instead, floating over the header while
   the composer sat still. The background matches the mode's own (`--bg-primary`, the same colour
   `.wall-compose.is-full` paints under everything) so the article does not show through as it
   passes under a bar that is meant to look solid, not translucent. No vertical padding, on the
   same visit that shrank `.md-btn` to a fixed 36px square (owner: «блок ровно высотой сколько 1
   button шириной») — padding here would have put the bar back to 36 plus its own air, the exact
   thing that instruction named. */
.wall-compose.is-full #wall-md-toolbar {
  position: sticky;
  top: 0;
  z-index: 2;
  background: var(--bg-primary);
}

/* In full screen the field IS the screen: no 60vh ceiling, and no fixed 91-pixel floor either —
   the author gets the window and scrolls the form, not a slot inside it. */
.wall-compose.is-full .wall-rich,
.wall-compose.is-full textarea {
  min-height: 45vh;
  max-height: none;
}

/* The surface and the mirror of its hints, one exactly over the other (FSP's
   `.editor-placeholders`). The wrapper is what makes "exactly over" true: the hint layer is
   positioned against the box, not against the form — the title row above must not shift it. */
.wall-rich-wrap {
  position: relative;
  width: 100%;
}

/* The hint layer is a SIBLING of the box it sits over, not a child of it — so a moment where the
   wrap has not yet been told to hide (the JS toggle races the box's own `.hidden`, or a caller
   flips one without the other) left the ghost lines floating with no surface under them (owner,
   22.09: "плейсхолдер от скрытого редактора все равно видно"). The rule reads the box's OWN
   class rather than trusting the wrap's, so it holds even when only the box was told. */
.wall-rich-wrap:has(#wall-compose-rich.hidden) .wall-hints {
  display: none;
}

/* THE HINTS ARE NOT A PLACEHOLDER ATTRIBUTE, and cannot be: `contenteditable` has none. They are a
   LAYER — absolutely positioned over the text, repeating its typography exactly (same size, same
   line-height, same margins), so a hint sits on the line the author's first word will occupy rather
   than beside it. `pointer-events: none` is what keeps the layer from eating the click that should
   put the caret in the box underneath; `aria-hidden` keeps a decoration out of the reading.
   The padding is the box's OWN — `10px 12px` below, spelled out rather than `inherit` — because
   the wrap it sits in (`.wall-rich-wrap`) carries none of its own to inherit; an owner screenshot
   is what caught the hint sitting flush against the wrap's edge while the real text sat 10px in. */
.wall-hints {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  padding: 10px 12px;
  pointer-events: none;
  opacity: 0.4;
  /* `.wall-post-body`'s OWN font-size/line-height, hand-copied for the same reason the margin
     below is — the real box carries that class and this layer deliberately does not (§ above),
     so without repeating these two the `<p>` hint inherited whatever the page's ambient text size
     was and sat visibly smaller than the paragraph it is meant to sit exactly on top of (owner,
     23.09: placeholder text "differs in font AND size" from the article's own p). */
  font-size: 20px;
  line-height: 1.45;
}

/* The hint's <p> matches `wall-post-body`'s `p{margin:15px 0}` because the real box carries that
   class and this one does not (it holds only the two placeholder lines FSP's own
   `.editor-placeholders` holds, never the full article vocabulary). */
.wall-hints p {
  margin: 15px 0;
}

/* `visibility`, never `display` — the reason is on the JS side (`wallHintSync`): the title hint
   and the body hint share this one overlay's flow, and a `display:none` on the title hint would
   collapse ITS box and slide the body hint up onto the line the real title now occupies. Kept
   present-but-invisible, the met hint still holds its own height, so the hint below it never moves
   off the line the real paragraph is actually on. */
.wall-hint-met {
  visibility: hidden;
}

/* The title has no rule to borrow anywhere else in this stylesheet: the feed never draws an
   `<h1>` inside a body, only the composer does. Ported from FSP's `.article h1` (`editor.css`) and
   shared by both boxes so the overlay sits on the exact line the real title occupies (the owner's
   screenshot is what caught the two adrift — this stylesheet had never given either one a size).
   The top margin is `.wall-hints`' own to set: `.wall-rich` gets its zero from
   `:is(.wall-post-body, .message-body) > :first-child` (the h1 is that box's first child), and
   this layer carries no such rule of its own to fall back on. */
:is(.wall-rich, .wall-hints) h1 {
  font-size: 24px;
  line-height: 30px;
  font-weight: 700;
  margin-bottom: 15px;
}

.wall-hints h1 {
  margin-top: 0;
}

/* No border, no card background — FSP's own `.editor-text{outline:none;margin-top:10px}` (owner,
   22.09, in so many words: "content editable без видимого бордюра, он повторяет тот же блок что и
   показ отрендеренной статьи"). The box this account was writing in and the block that shows the
   published article are the SAME shape now: the only thing that told them apart was a border this
   client had added and FSP never draws. The 10/12px padding stays — it is not the border, it is
   what keeps the hint overlay (below) pixel-aligned to this text, and removing it would reopen the
   drift the overlay was built to close. */
.wall-compose .wall-rich {
  width: 100%;
  box-sizing: border-box;
  padding: 10px 12px;
  /* `.wall-post-body` (the type rule this box shares with a published article) carries its own
     `margin: 10px 0` — right where a card places it against its neighbours. Here the box has a
     neighbour of a different kind: `.wall-hints`, absolutely positioned at `top: 0` of the SAME
     wrap and carrying no such margin, because it is a placeholder layer, not a card. Left
     un-zeroed, the margin pushed the real box ten pixels below the hint that is meant to sit on
     its first line — the owner's screenshot (23.09) of a title caret sitting under, not on, the
     grey "Заголовок" the two were supposed to share. */
  margin: 0;
  /* The TYPE is the article's, not the field's: the surface wears `wall-post-body`, and the
     size the author writes in is the size the reader reads in — that is the whole of the
     WYSIWYG claim, and a 14-pixel rule here would quietly make it false. */
  font-family: inherit;
  overflow-wrap: anywhere;
  min-height: 91px;
  max-height: 60vh;
  overflow-y: auto;
}

.wall-compose .wall-rich:focus {
  outline: none;
}

.wall-compose .wall-rich:empty::before {
  content: attr(data-placeholder);
  color: var(--text-secondary);
  pointer-events: none;
}

/* The one press in the formatting row that is not a mark: it says which box the row is editing, so
   it stands at the far end of the row and apart from the marks it edits. The pressed colour is the
   type chips' (`is-on`), because it answers the same kind of question they do. */
/* The editor toggle opens the right-hand group (owner, 22.09): the mode and the settings stand
   together at the toolbar's far edge — "formatting … mode+settings", two groups, no button
   floating alone in the middle. */
.md-btn-rich {
  margin-left: auto;
}

/* The editor toggle wears the SAME green FSP's `article-editor-action.positive` wears
   (`app.css`, `.header a.positive i.icon`) — the general `.md-btn.is-on` rule two screens up in
   this file already IS that wash, and needs no help here. A prior pass gave the toggle its own
   transparent/outline look instead and lit the CHECKMARK green below (`.md-btn-publish`) — the
   owner's screenshot corrects both: the pencil is what goes green when the editor is open, the
   check beside it stays the plain toolbar colour it is everywhere else. */

.wall-compose textarea:focus,
.wall-field-input:focus,
.wall-benef-account:focus,
.wall-benef-percent:focus,
.wall-sealed-pass:focus {
  outline: none;
  border-color: var(--accent);
}

/* The live preview (step 8). It is a SURFACE, so it carries its own padding at every width; the
   rule is DASHED because these are not the bytes on the chain — they are what the reader would
   make of them. It stands in the same column as the box it previews, so the author reads the two
   against each other rather than hunting for the second one. */
.wall-preview {
  width: 100%;
  box-sizing: border-box;
  padding: 10px 12px;
  border: 1px dashed var(--border-color);
  border-radius: 4px;
  background: var(--bg-primary);
  color: var(--text-primary);
  font-size: 14px;
  overflow-wrap: anywhere;
}

/* The article's own presentation — title/description/cover (§3.2) — plain in the compose flow
   the moment the record is an article, no fold and no caption around them (owner, 23.09, after
   FSP: these are not "additional settings", they are the article). `.wall-compose`'s own flex gap
   spaces this block from its neighbours; this rule only stacks ITS OWN rows. */
#wall-compose-record {
  display: flex;
  flex-direction: column;
  gap: 10px;
  width: 100%;
  box-sizing: border-box;
}

/* The extra settings of a publication — its beneficiaries, its seal (шаг 7). They are folded away
   behind a toggle, and the fold is drawn as a rule across the composer rather than as another box
   inside it: the fields belong to the post being written, and a border around them would say they
   are a thing apart from it. */
.wall-extra {
  display: flex;
  flex-direction: column;
  gap: 10px;
  width: 100%;
  box-sizing: border-box;
  padding-top: 10px;
  border-top: 1px solid var(--border-color);
}

/* FSP swaps the settings panel FOR the editor rather than stacking it below one (owner, 22.09,
   after a screenshot of `.article-settings`/`.article-editor` in the reference): the toolbar
   stays, because the author's way back is a press on the SAME gear, but the surface he was
   typing in is gone while the gear is lit — there is no border left to read as "and here is
   more" once the settings ARE the screen. */
.wall-compose.wall-settings-open #wall-compose-text,
.wall-compose.wall-settings-open .wall-rich-wrap,
.wall-compose.wall-settings-open #wall-compose-preview {
  display: none;
}

/* The settings folds (#731): native <details> groups inside the extra block. The summary is the
   group's own caption — same size as a field cap, but a pointer, because it is a door. */
.wall-fold {
  width: 100%;
  box-sizing: border-box;
}

/* An upload in flight: the paperclip itself turns, so the press that "did nothing" visibly did. */
.md-btn.is-busy .icon {
  animation: spin 0.9s linear infinite;
}
.md-btn.is-busy {
  pointer-events: none;
}

.wall-fold:last-child {
  border-bottom: none;
  padding-bottom: 0;
}

.wall-fold-cap {
  cursor: pointer;
  font-size: 13px;
  font-weight: 500;
  color: var(--text-secondary);
  user-select: none;
}

.wall-fold[open] > .wall-fold-cap {
  color: var(--text-primary);
  margin-bottom: 8px;
}

/* The two sections a single addons fold now holds (owner, 23.09: one press, not two) — a plain
   caption, not a second door. FSP prints these as inert text right inside `publish-addons`, and
   this is the same thing: bold enough to read as a heading, not a pointer cursor promising a
   click that does nothing. */
.wall-fold-sub {
  margin: 12px 0 6px;
  font-size: 13px;
  font-weight: 600;
  color: var(--text-primary);
}

.wall-fold-sub:first-of-type {
  margin-top: 0;
}

.wall-field {
  display: flex;
  flex-direction: column;
  gap: 6px;
  width: 100%;
  box-sizing: border-box;
}

/* §4.2 — the composer standing in for one of the author's own publications rather than writing a
   new one. The band says WHICH record is being changed, because nothing else about this box looks
   different from the box for a new post, and the two fields that belong to the message rather than
   to the object — §3.3's passphrase and §3.1's beneficiaries — come off the screen entirely: an
   edit carries the object one level inside `d`, so leaving them up would be collecting answers the
   act has nowhere to put. Hidden by the same class the band is shown by, so one state decides both
   and neither can be drawn without the other. */
.wall-amend {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  width: 100%;
  box-sizing: border-box;
  padding: 8px 10px;
  border: 1px solid var(--border-color);
  border-left: 3px solid var(--accent);
  border-radius: 4px;
  background: var(--bg-secondary);
  color: var(--text-primary);
  font-size: 13px;
  overflow-wrap: anywhere;
}

.wall-amend-which {
  color: var(--text-secondary);
}

/* The phrase rows of a layered seal (owner, 23.09): each row is a passphrase over its public
   comment, the first row being the fixed pair above the "+". */
.wall-envelope {
  min-width: 0;
}

.wall-seal-row {
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: 6px;
}

/* Two boxes side by side on any width, the caption inside as a placeholder (FSP): the phrase is the
   short one, the public note takes the rest. */
/* The small inputs of FSP (owner, 24.09): radius 15px, padding 5px 10px, 15px type. The composer's
   textarea keeps its own, larger rule above. */
.wall-field-input,
.wall-benef-account,
.wall-benef-percent,
.wall-sealed-pass {
  padding: 5px 10px;
  border-radius: 15px;
  font-size: 15px;
}

.wall-seal-row .wall-field-input {
  flex: 1 1 0;
  min-width: 0;
  width: auto;
}

.wall-seal-layer {
  margin-top: 8px;
}

.wall-layer-pass,
.wall-layer-comment {
  box-sizing: border-box;
}

.is-amending .wall-envelope {
  display: none;
}

/* The one exemption: a SEALED record being edited keeps the passphrase, because the edit is sent
   sealed again (#1408 = A) — the ciphertext in `d` is what the event replaces, so the author is
   asked for the phrase he sealed it with. The public note stays beside it: it travels in the clear
   next to the ciphertext and is a field of the envelope the edit rewrites. `display: flex` is named
   rather than inherited, because the rule above turns these rows off with `display: none` and this
   one has to put back the shape `.wall-field` gives them. */
.is-amending.is-amending-sealed .wall-seal-row {
  display: flex;
}

/* What a sealed edit owes its author in words: the record he is changing was sealed, and so will
   the change be. Without the sentence the composer asks for a passphrase with no reason given, and
   an author who sealed a record once may not expect to be sealing it again. */
.wall-amend-sealed {
  margin: 0 0 10px;
  font-size: 13px;
  color: var(--text-secondary);
}

/* A caption over its field, and a hint UNDER it — the hint is read once and the caption every
   time, so they are not dressed alike. */
.wall-field-cap {
  font-size: 12px;
  color: var(--text-secondary);
}

.wall-hint {
  margin: 0;
  font-size: 12px;
  color: var(--text-secondary);
  overflow-wrap: anywhere;
}

.wall-benefs {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

/* The account takes what is left and the share takes what it needs: a name is long and a percent
   is three characters, and a row that split evenly would leave the name unreadable on a phone. */
.wall-benef {
  display: flex;
  gap: 6px;
  align-items: center;
}

.wall-benef-account {
  flex: 1 1 auto;
  min-width: 0;
}

.wall-benef-percent {
  flex: 0 0 auto;
  width: 88px;
}

.wall-benef-drop {
  flex: 0 0 auto;
  padding: 0;
  border: 1px solid var(--border-color);
  border-radius: 4px;
  background: var(--bg-primary);
  color: var(--text-secondary);
  font-size: 18px;
  line-height: 1;
  font-family: inherit;
  cursor: pointer;
}

/* Adding a row is not the same act as publishing, so it does not wear the publish button: a dashed
   edge says "another one goes here" and says it without competing with the one filled button. */
.wall-add {
  align-self: flex-start;
  padding: 0 12px;
  border: 1px dashed var(--border-color);
  border-radius: 4px;
  background: transparent;
  color: var(--accent);
  font-size: 13px;
  font-family: inherit;
  cursor: pointer;
}

/* The add control wears FSP's cross instead of a sentence (#1423 = A), so the mark is the whole
   button and the button has to be the target the mark cannot be: a 13px cross is a press a finger
   misses. Sized to the tap floor, as `.wall-dot` is. It keeps the dashed edge `.wall-add` gives
   it — that edge is what says "another row goes here" without competing with the one filled
   button on the screen. */
.wall-add-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 44px;
  min-height: 44px;
  padding: 0;
  font-size: 16px;
  color: var(--accent);
}

.wall-compose-foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  width: 100%;
}

/* While the settings block is open, the two buttons stay at the bottom of the screen instead of
   riding down with it. The block is 357px tall on a 375×667 phone — it carried Publish from
   `top 519` (on screen) to `top 884` (217px below the fold), so the author filled in the settings
   and then went looking for the way to send them. Editing a record opens the same block by itself,
   which is why this is not only about the author's own press.

   Keyed on the SAME `hidden` class the toggle sets, so the bar, the block and the flag cannot
   disagree about whether it is open. The background is the card's, because a sticky row over a
   scrolling feed with nothing behind it is text on text. */
#wall-extra:not(.hidden) ~ .wall-compose-foot {
  position: sticky;
  bottom: 0;
  z-index: 2;
  padding: 6px 0;
  background: var(--bg-card);
  border-top: 1px solid var(--border-color);
}

/* The gear rides beside the editor toggle in the right-hand group (see .md-btn-rich): its own
   auto margin would push it away from the toggle and leave that button hanging mid-air. */

.wall-feed {
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.wall-post {
  background: var(--bg-card);
  border: 1px solid var(--border-color);
  border-radius: 8px;
  padding: 12px;
}

/* The acts of a record — a ROW the reader sees, not a menu he opens (owner, 20.09). The reference's
   own row under a post is the shape borrowed: one mark per act, in one line under the record, and a
   mark that has a figure to state carries it beside itself.
   It WRAPS rather than shrinking: a record on a narrow phone is offered four acts, an author's own
   six, and a row that tried to fit them all would put the marks under the tap floor. */
.wall-acts {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 2px;
}

/* One act: the mark, and its name for whoever cannot see the mark. Sized to the tap floor rather
   than to the glyph — a 16px icon is a coin toss on a phone, and this row is the only way to
   answer, repost or reward a record at all. */
.wall-act {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 4px;
  min-width: 44px;
  min-height: 44px;
  padding: 0 8px;
  border: 0;
  border-radius: 8px;
  background: none;
  color: var(--text-secondary);
  font: inherit;
  cursor: pointer;
}

.wall-act:hover {
  background: var(--bg-hover);
  color: var(--text-primary);
}

/* A reward THIS BROWSER has already given the record — FSP's own mark for the same fact, drawn the
   same green (fsp-ref `check_object_award`, `app.js`:14318: the class `success` on the act). It is
   the tone vocabulary's `done` (--success, one block up), and like every tone here the colour is
   not the only carrier: the row states the figure of the presses beside the mark, which is the
   journal's own number (`#/voice/awards` spells it the same way).

   AFTER the hover rule above on purpose. Both selectors are one class plus one attribute, so the
   later one wins — and the state of an act does not depend on where the mouse happens to be.
   Written without the act being disabled, because green here does not mean "already true, nothing
   more to do": pressing it again is a SECOND award, and the journal counts them. */
.wall-act[data-given] {
  color: var(--success);
}

/* The count of answers this reader has met, beside the act that answered them: a figure ABOUT the
   act rather than part of its name — the name is the record's own `aria-label`. */
.wall-act-count {
  font-size: 0.85em;
}

/* A record this reader refused, and it stays in the feed where it stood: a hole is what a deleted
   post looks like, so a substituted one may not leave one. */
.wall-post-refused {
  border-color: var(--danger);
  background: var(--danger-bg-soft);
}

.wall-refusal {
  color: var(--danger);
  font-size: 14px;
  font-weight: 600;
}

/* Not checked is NOT the same as refused, and the two are drawn so they cannot be mistaken for
   one another: this one keeps the ordinary surface and says in its foot why it could not be read
   as checked. */
.wall-post-unchecked {
  border-style: dashed;
}

.wall-title {
  font-size: 16px;
  font-weight: 600;
  color: var(--text-primary);
  margin-bottom: 6px;
  overflow-wrap: anywhere;
}

/* §3.2's description (шаг 8). Under the title and quieter than it, because that is what it is: the
   title names the record and this says what it is about, to a reader who is still deciding whether
   to read it. It is NOT the body, so it takes the muted colour rather than the text one — set in the
   body's own colour it would read as the first paragraph of the text. */
.wall-desc {
  font-size: 14px;
  color: var(--text-secondary);
  margin-bottom: 8px;
  overflow-wrap: anywhere;
}

/* The cover (§3.2 `i`, шаг 9): the picture the record leads with, in the composer and in the feed
   alike, because it is the same picture and the author is checking it against what a reader gets.
   Capped in HEIGHT rather than in width — a cover is wide by nature, and a rule that fixed its width
   would letterbox a tall one; the height cap is what keeps one record from pushing the next off the
   screen. `object-fit: cover` crops instead of squashing, so the row height holds for any picture. */
.wall-cover {
  display: block;
  width: 100%;
  max-height: 240px;
  object-fit: cover;
  border-radius: 4px;
  margin-bottom: 8px;
}

/* What the composer shows about the chosen cover: the picture, its address tail, and the press that
   takes it back — one row, because the three are one thing. */
.wall-cover-picked {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
}

.wall-cover-where {
  font-size: 12px;
  color: var(--text-secondary);
  overflow-wrap: anywhere;
}

/* The head of a record — face, name, when — ABOVE the text, which is the shape a feed is read in:
   a reader scanning for an author reads down the left edge, and the time belongs on that same line
   rather than at the end of a post of unknown length. Sized to the reference the owner chose (49px
   face); the stamp is pushed right and never wraps, so a long name loses letters before the time
   moves out of the row. */
.wall-post-head {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-bottom: 10px;
}

.wall-post-head .wall-face {
  width: 49px;
  height: 49px;
  flex: none;
}

.wall-stamp {
  margin-left: auto;
  font-size: 13px;
  color: var(--text-secondary);
  white-space: nowrap;
}

/* The stamp as a DOOR (волна 2): in the feed it is a button into the record's own screen, so the
   button sheds the browser's chrome and keeps the stamp's own face — the affordance is the cursor
   the `[data-wall-open]` rule below already gives every door on the card. */
button.wall-stamp {
  border: none;
  background: none;
  padding: 0;
  font: inherit;
  font-size: 13px;
}

/* The reader's slice of the feed (волна 2) — four buttons in the chip vocabulary the head's tag
   lists already speak, chosen the same way a filter anywhere on this client is: the active one is
   filled, the rest are outlines. */
/* The reference's tab row (fsp-ref app.css:2009, knowledge/fsp-ux-reference.md §9а): a full row
   over one border, and the active tab says so twice — a bolder face and the accent's line under
   it. A CHIP (`.wall-tab` below) is a different thing and keeps its pill: a chip is an act or a
   filter standing alone; a tab is one of a row's addresses. */
.tabs {
  display: flex;
  border-bottom: 1px solid var(--border-color);
  margin: 0 0 12px;
  overflow-x: auto;
}

.tab {
  flex-grow: 1;
  flex-shrink: 0;
  padding: 10px;
  min-height: 44px;
  background: none;
  border: 0;
  font-size: 14px;
  color: var(--text-secondary);
  cursor: pointer;
  text-align: center;
}

.tab:hover {
  color: var(--accent);
}

.tab.is-active {
  font-weight: 600;
  color: var(--text-primary);
  box-shadow: inset 0 -3px var(--accent);
}

.wall-tab {
  border: 1px solid var(--border-color);
  border-radius: 14px;
  padding: 4px 12px;
  font-size: 13px;
  background: none;
  color: var(--text-secondary);
  cursor: pointer;
}

.wall-tab.is-active {
  border-color: var(--accent);
  background: var(--accent);
  color: var(--bg-primary);
}

/* The line a repost carries (§3.1 `s`): the mark, the word, and the address it shares. Quiet like
   the stamp — it is a fact about the record, above the author's own words. */
.wall-share {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 13px;
  color: var(--text-secondary);
  margin: 6px 0 0;
  overflow-wrap: anywhere;
}

.wall-share .icon {
  width: 14px;
  height: 14px;
  flex: none;
}

.wall-share-link {
  color: var(--accent);
}

.wall-share-label {
  flex: none;
}

.wall-share-text {
  overflow-wrap: anywhere;
}

/* The tags the record's text carries (ui/js/hashtags.js). Each chip is a DOOR to the tag's
   screen (`data-tag-open`), so it wears the accent a press earns — quiet, no box: a chip drawn
   as a full button would outweigh the record it annotates. */
.wall-tags {
  display: flex;
  flex-wrap: wrap;
  gap: 4px 8px;
  font-size: 13px;
  margin: 6px 0 0;
}

.wall-tag {
  overflow-wrap: anywhere;
  background: none;
  border: 0;
  padding: 0;
  font: inherit;
  color: var(--accent);
  cursor: pointer;
}

.tag-row {
  margin: 6px 0;
  overflow-wrap: anywhere;
}

.tag-met {
  color: var(--text-secondary);
  font-size: 13px;
}

/* The per-author polling cadence (#725) — a small inline control, not a settings panel:
   the row is the author, the select is one fact about him. */
.follow-poll {
  width: auto;
  display: inline-block;
  font-size: 13px;
  padding: 2px 6px;
  margin: 0 6px;
}

/* The reader's own claim about the tag — the same toggle chips the wall head wears. */
#tag-marks {
  display: flex;
  gap: 8px;
  margin: 8px 0;
}

/* A record folded by the reader's OWN ignore list: one quiet line saying a record stands here —
   a silent gap and "the author wrote nothing" must not look alike. */
.wall-post-tag-folded {
  color: var(--text-secondary);
  font-size: 13px;
}

.wall-fold-note {
  margin-right: 4px;
}

/* A pinned tag's door in the sidebar is a BUTTON (drawn after boot, so the document-level
   `data-tag-open` handler takes it), wearing the folder row's look: the button's own chrome is
   what has to go, the rest comes from `.folder`. */
.pinned-tag {
  width: 100%;
  background: none;
  border: 0;
  font: inherit;
  text-align: left;
  cursor: pointer;
}

/* The head's pin (#1435 = A): one record of the wall's own author, drawn as a door. The same quiet
   vocabulary as the repost's line — a mark and a link, not a card: the record itself is one press
   away, and a head that drew it whole would be a feed above the feed. */
.wall-pinned {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 13px;
  margin: 8px 0 0;
}

.wall-pinned .icon {
  width: 14px;
  height: 14px;
  flex: none;
  color: var(--accent);
}

/* The follow box (#1439/#1440): the reader's two toggles about this author, in the same chip
   vocabulary as the tabs — a claim that is ON is filled, one that is not is an outline. */
.service-header-actions {
  display: flex;
  gap: 6px;
  margin: 0;
}

/* The wall's Follow / Ignore reuse the editor's `.icon-btn` (FSP's `i.icon`: no border, a translucent
   circle that fades in on hover). The state rides the same circle, as FSP's `a.positive/negative i.icon`
   does: a standing tint plus the icon colour. */
.service-header-actions .icon-btn .icon,
.tag-marks .icon-btn .icon { width: 24px; height: 24px; fill: currentColor }
.service-header-actions .icon-btn.is-on-follow,
.tag-marks .icon-btn.is-on-follow { color: var(--success-text) }
.service-header-actions .icon-btn.is-on-follow::before,
.tag-marks .icon-btn.is-on-follow::before { background: var(--trusted-bg) }
.service-header-actions .icon-btn.is-on-ignore,
.tag-marks .icon-btn.is-on-ignore { color: var(--danger) }
.service-header-actions .icon-btn.is-on-ignore::before,
.tag-marks .icon-btn.is-on-ignore::before { background: var(--danger-bg) }

/* The parts of a card that open the record's own screen (#1409 = A) are marked rather than boxed,
   so the mark has to carry its own affordance: without this the reader has no way to learn that the
   title and the body are pressable, and the screen would exist for nobody but the author. */
[data-wall-open] {
  cursor: pointer;
}

/* The line that leaves a record's screen for the wall it was read off — a crumb, the same one the
   file browser walks with, because it is the same act: one step back up a path. */
.wall-focus-back {
  margin: 0 0 12px;
}

.wall-post-body {
  color: var(--text-primary);
  font-size: 20px;
  line-height: 1.45;
  margin: 10px 0;
  overflow-wrap: anywhere;
}

/* THE TYPOGRAPHY OF A PUBLICATION — the block markup `js/markdown.js` assembles out of a body
   (§3.1 notes and §3.2 publications are written in the same syntax), drawn by the wall AND by the
   composer's live preview, which mount the same renderer into this same class. One rule set, not
   two: the preview is the author checking his text against what a reader will get, and two rule
   sets would be two answers to that question.
 *
 * WHY IT HAS TO BE WRITTEN AT ALL. The browser's defaults are not neutral in this stylesheet — the
 * reset at the top zeroes every margin and padding, and a list marker is painted OUTSIDE its item.
 * Between the two, `ul`/`ol` lose the indent the marker was to stand in, so the bullet hangs out
 * in the card's own padding; a two-digit number is wider than that padding and crosses the border.
 * That is the "it climbs out of the card" the owner saw, and it is not a scale problem — it is a
 * rule that was never written.
 *
 * THE SCALE IS FSP'S, borrowed whole (`fsp-ref/editor.css`, `.article`): 22px headings, 16px code,
 * 1.5em leading, the marker's indent in `em` of the body. The syntax is theirs and the two clients
 * are read side by side, so a body that renders one way there and another here is a difference its
 * author can neither predict nor account for. THE COLOURS ARE OURS: every one is a token.
 *
 * NO `position: absolute` ANYWHERE, which is the one place this departs from the reference. FSP
 * centres the citation's two rules with `left: calc(50% - 50px)`, and an absolutely positioned box
 * resolves against the nearest POSITIONED ancestor — here the card, not the citation — so a
 * citation inside a nested answer would draw its rules across whatever card stood above it. An
 * in-flow `::before`/`::after` centred with `margin: 0 auto` puts the same two lines in the same
 * place and cannot leave the box it belongs to. */
:is(.wall-post-body, .message-body) h2,
:is(.wall-post-body, .message-body) h3 {
  font-weight: 700;
  margin: 15px 0;
}

:is(.wall-post-body, .message-body) h2 {
  font-size: 1.1em;
  line-height: 1.4em;
}

:is(.wall-post-body, .message-body) h3 {
  font-size: 1em;
  line-height: 1.3em;
}

:is(.wall-post-body, .message-body) p {
  margin: 15px 0;
}

/* The indent the marker stands in. The item keeps a quarter-em of its own so a line that wraps
   does not come back flush against the bullet above it. */
:is(.wall-post-body, .message-body) ul,
:is(.wall-post-body, .message-body) ol {
  margin: 15px 0 15px 1.5em;
}

:is(.wall-post-body, .message-body) ul {
  list-style-type: disc;
}

:is(.wall-post-body, .message-body) li {
  margin-bottom: 10px;
  padding-left: 0.25em;
}

:is(.wall-post-body, .message-body) li:last-child {
  margin-bottom: 0;
}

/* A citation and a quote are told apart by their SHAPE, and that is the whole of what the reader
   has to go on: a citation stands alone between two short rules on the centre line, a quote is a
   column of text held by one rule down its left. The resting italic the browser gives `<cite>` is
   taken off — it is a caption, not an emphasis, and at a paragraph's length it is harder to read
   than what it quotes. */
:is(.wall-post-body, .message-body) cite,
:is(.wall-post-body, .message-body) blockquote {
  font-style: normal;
  margin: 15px 0;
}

:is(.wall-post-body, .message-body) cite {
  padding: 20px 0;
  text-align: center;
}

:is(.wall-post-body, .message-body) cite::before,
:is(.wall-post-body, .message-body) cite::after {
  content: "";
  display: block;
  width: 100px;
  margin: 0 auto;
  border-top: 2px solid var(--text-secondary);
}

:is(.wall-post-body, .message-body) cite::before {
  margin-bottom: 10px;
}

:is(.wall-post-body, .message-body) cite::after {
  margin-top: 10px;
}

:is(.wall-post-body, .message-body) blockquote {
  padding-left: 15px;
  border-left: 2px solid var(--text-secondary);
}

/* A code span is drawn at the size the reference draws it, which is BELOW the body's: monospace is
   wider than the text face at the same size, and a run of it set at the body's own size leaves the
   line it sits on ragged. Written as the reference's RATIO (16/20) rather than its pixel value,
   because this rule now draws two bodies at two sizes. The two colours are named together, so this
   is one of the pairs the theme test weighs. */
:is(.wall-post-body, .message-body) code {
  background: var(--code-bg);
  color: var(--text-primary);
  border-radius: 3px;
  padding: 1px 4px 2px;
  font-size: 0.8em;
}

/* A rule is drawn as the three stars it is TYPED as: the mark the reader wrote is the mark he
   reads, so there is no second thing to keep in step. */
:is(.wall-post-body, .message-body) hr {
  border: 0;
  margin: 0;
  padding: 30px 0;
  text-align: center;
}

:is(.wall-post-body, .message-body) hr::before {
  content: "***";
  font-size: 1.4em;
}

:is(.wall-post-body, .message-body) a {
  color: var(--text-link);
}

/* A picture is the other way a body leaves its card, and the renderer will assemble one from
   `![alt](src)` without asking how wide it is. The reference constrains the text and leaves images
   alone; measured here, a 2000px picture in a 390px card hangs 1660px past the border. The height
   is released with the width so that a picture keeping its own proportions keeps them. */
:is(.wall-post-body, .message-body) img {
  max-width: 100%;
  height: auto;
}

/* The body hugs its own text: the first and last block bring no margin of their own, so the card's
   padding is the whole of the space around what the author wrote — the same shape a body with no
   block markup in it has always had. */
:is(.wall-post-body, .message-body) > :first-child {
  margin-top: 0;
}

:is(.wall-post-body, .message-body) > :last-child {
  margin-bottom: 0;
}

/* A record longer than the card is cut at 30vh and faded rather than growing the card: a feed is
   scanned, and one long post must not push the next one off the screen. The fade is the promise
   that there is more — a hard cut reads as the end of the text — and the whole block is the tap
   target that opens the rest (the handler expands it in place; there is no second screen to keep
   in step with this one). */
.wall-post-body.is-clamped {
  max-height: 30vh;
  overflow: hidden;
  -webkit-mask-image: linear-gradient(180deg, var(--fade-mask) 62%, transparent 100%);
  mask-image: linear-gradient(180deg, var(--fade-mask) 62%, transparent 100%);
}

.wall-post-more {
  display: block;
  margin: 0 auto 10px;
  padding: 4px 12px;
  border: 0;
  background: none;
  color: var(--text-link);
  font: inherit;
  font-size: 15px;
  cursor: pointer;
}

/* The waiting mark under the feed, drawn while the next page is in flight. It is a mark and not a
   progress bar because there is nothing to count: the hub hands over a page at a time and does not
   say how many are left. `aria-hidden` on the wrapper keeps it out of a screen reader's way — it
   carries no information the reader can act on, and a live region announcing "loading" every page
   would be noise between him and the records. Addressed by id: the CLASS `.wall-more` is the
   «⋯»-menu anchor of a card and a letter — a different role on a different box, and one spelling
   worn by both would hand this centring and padding to every menu anchor. */
#wall-more {
  display: flex;
  justify-content: center;
  padding: 14px 0;
}

/* Said once, at the end, and only when the hub has confirmed there is nothing after it. */
.wall-end {
  padding: 10vh 0;
  text-align: center;
  color: var(--text-secondary);
  font-size: 15px;
}

.wall-sealed,
.wall-unknown {
  color: var(--text-secondary);
  font-style: italic;
}

/* The sealed card (§3.3) stacks: the sentence, the author's public note, the box that opens it. A
   column rather than a line of text, because the box is a control and a control inside a paragraph
   is a control nobody's thumb finds. */
.wall-sealed {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

/* What the author chose to say to readers who cannot open the rest — the one part of a sealed
   record written in the clear on purpose, so it is drawn in the ordinary ink rather than the
   italics the sentence around it wears. */
.wall-sealed-note {
  font-style: normal;
  color: var(--text-primary);
  overflow-wrap: anywhere;
}

.wall-sealed-row {
  display: flex;
  gap: 6px;
  align-items: center;
}

.wall-sealed-pass {
  flex: 1 1 auto;
  min-width: 0;
}

/* The card's last line, and now the row the acts hang off (owner, 20.09). A row rather than a line
   of text because the `⋯` stands at its end: the sentence that was here is one string with markup
   in it, so it goes into a span that takes the width and pushes the button right — with the button
   carrying `margin-left: auto` as well, for the record whose foot says nothing and has only the
   button. `position: relative` is what the menu is drawn against: it must open where its own button
   is, and the card grows downwards past this row. */
.wall-post-foot {
  position: relative;
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: 10px;
  font-size: 12px;
  color: var(--text-secondary);
  overflow-wrap: anywhere;
}

.wall-post-foot-text {
  flex: 1;
}

/* The withdrawal row on the author's own post (§7.9, §4). Wraps rather than scrolls: the two
   buttons are the whole row at a comfortable width, and on a narrow screen the second one drops
   under the first instead of being cut off. The order is the order of cost — the act that keeps
   the bytes first, the one that destroys them second, dressed as the danger it is — so the row
   reads the same way whether or not the reader stops to read it. */
.wall-post-acts {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-top: 10px;
}

.wall-unchecked {
  color: var(--warning-text);
  font-weight: 600;
  margin-left: 4px;
  cursor: help;
}

.wall-share-quote {
  display: block;
  margin: 4px 0 8px;
  padding: 6px 10px;
  border-left: 3px solid var(--border-color);
  color: inherit;
  text-decoration: none;
}

.wall-share-author {
  display: block;
  font-weight: 600;
}

.wall-share-preview {
  display: block;
  color: var(--text-secondary);
}

.wall-pin-mark {
  color: var(--warning-text);
  margin-left: 4px;
  cursor: help;
}

.wall-decoded {
  color: var(--text-secondary);
  margin-left: 4px;
  cursor: help;
}

h1.wall-title {
  font-size: 22px;
  margin: 0 0 8px;
}

.wall-hash {
  font-size: 12px;
  word-break: break-all;
  color: var(--text-secondary);
}

.wall-empty {
  color: var(--text-secondary);
  margin: 0;
}

/* The face in the sidebar is a control since 18.09, and it WRAPS its circle: the floor under every
   button is 44px while the circle is 32px, so the box is the target and the picture keeps the size
   the owner set. Without this rule the button would paint a UA background and a UA border over the
   avatar — the two the rule above `.account-bar-icon` states for the same reason. */
.account-face-btn {
  background: none;
  border: none;
  padding: 0;
  border-radius: 50%;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
}

@media (min-width: 769px) {
  .l2-mobile-nav { display: none; }
  .l2-list-head { display: grid; }
  .l2-row, .l2-list-head { grid-template-columns: minmax(0, 1fr) 56px minmax(0, 1fr); }
  .l2-row-hash { grid-column: auto; }
  #l2-screen:not(.l2-detail-open) #l2-block-pane,
  #l2-screen.l2-detail-open #l2-chain-pane { display: block; }
  /* The desktop block stays under the top edge while the chain list scrolls past. */
  .l2-col-block { position: sticky; top: 0; z-index: 2; max-height: 100vh; overflow-y: auto; }
  .l2-cols {
    flex-direction: row;
    align-items: flex-start;
  }

  .l2-col {
    flex: 1 1 0;
  }
  #l2-chain-pane { flex: 2 1 0; }
  .l2-col-block { min-width: 240px; }
}

/* §5.10 the operator agreement. Same layer mechanics as the unlock gate — it covers the page for
   the same reason: until it is answered there is nothing behind it the reader may use. */
#agreement-overlay {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: var(--bg-overlay);
  z-index: 4100;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 16px;
}

#agreement-overlay.hidden {
  display: none;
}

#agreement-card {
  width: 640px;
  max-width: 100%;
  /* The card is bounded and the TEXT scrolls inside it, rather than the card growing past the
     viewport: a document that pushes its own Accept button off screen cannot be accepted on a
     phone, and the reader has no way to tell that is what happened. */
  max-height: min(88vh, 720px);
  display: flex;
  flex-direction: column;
  padding: 20px;
  background: var(--bg-card);
  border-radius: 8px;
  box-shadow: var(--shadow-lg);
}

#agreement-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  flex-wrap: wrap;
}

#agreement-card h3 {
  margin: 0 0 4px;
}

#agreement-langs {
  display: flex;
  gap: 6px;
}

#agreement-langs button {
  padding: 2px 10px;
  font-size: 13px;
  border: 1px solid var(--border-color);
  border-radius: 999px;
  background: transparent;
  color: var(--text-secondary);
  cursor: pointer;
}

#agreement-langs button[aria-pressed="true"] {
  color: var(--text-primary);
  border-color: var(--accent);
}

#agreement-text {
  flex: 1 1 auto;
  overflow-y: auto;
  margin: 12px 0;
  padding: 12px 14px;
  border: 1px solid var(--border-color);
  border-radius: 6px;
  background: var(--bg-secondary);
  overflow-wrap: anywhere;
  font-size: 14px;
  line-height: 1.5;
}

/* The document is rendered as paragraphs (see paintAgreementBody), so it needs paragraph spacing
   rather than the pre-wrap this box used to carry for raw source. */
#agreement-text p {
  margin: 0 0 10px;
}

#agreement-text p:last-child {
  margin-bottom: 0;
}

/* Refusal is a way out of the gate, and it should read as one: it leaves the hub. */
#agreement-decline {
  color: var(--danger);
}

#agreement-decline:hover {
  color: var(--danger-hover);
}

/* The two are not a pair of equals on one line: accepting is the offer, leaving is the way out.
   They stack, both centred — the refusal used to sit pinned to the right edge, which reads as a
   secondary button in a button row rather than as the sentence it is. */
#agreement-actions {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 12px;
}

/* Half the card, so the button is a target and not the whole width of the dialog; the floor keeps
   it usable on a narrow screen, where half of the card is a stub. */
#agreement-accept {
  width: 50%;
  min-width: 160px;
  max-width: 100%;
}

/* §8.5 (#978) — the traffic breakdown: what was SERVED, beside the one line that says what was
   charged. Bars rather than numbers alone, because the question is comparative ("which file ate
   it", "when"), and a column of byte counts answers that slowest. */
.traffic-chart {
  display: flex;
  flex-direction: column;
  gap: 4px;
  margin: 8px 0 12px;
}

.traffic-row {
  display: grid;
  /* Label, bar, figure. The bar takes what is left, so days, hours and file names — three very
     different label widths — still line their bars up with each other within one chart. */
  grid-template-columns: minmax(72px, max-content) 1fr max-content;
  align-items: center;
  gap: 8px;
  padding: 2px 0;
  font-size: 0.9em;
}

.traffic-label {
  color: var(--text-secondary);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 220px;
}

.traffic-track {
  display: block;
  height: 10px;
  border-radius: 5px;
  background: var(--border-color);
  overflow: hidden;
}

.traffic-fill {
  display: block;
  height: 100%;
  background: var(--accent);
  border-radius: 5px;
}

.traffic-value {
  white-space: nowrap;
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
}

/* A day opens into its hours; an hour opens into nothing, and must not pretend otherwise. */
.traffic-row.is-clickable { cursor: pointer; }
.traffic-row.is-clickable:hover .traffic-fill { background: var(--accent-hover); }

.traffic-subhead {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-top: 4px;
}

#traffic-files h4 { margin: 12px 0 6px; }

/* The downloads this browser keeps (owner 09.09). A row per kind, including the kinds that hold
   nothing — a row that vanishes when empty makes the reader hunt for a category that is simply
   empty. The empty ones are dimmed rather than removed, and carry no button, because there is
   nothing for it to do. */
.cache-kind {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 10px 0;
  border-bottom: 1px solid var(--border-color);
}

.cache-kind-name {
  flex: 1 1 auto;
  font-weight: 500;
}

.cache-kind-size {
  color: var(--text-secondary);
  font-size: 13px;
}

.cache-kind-empty .cache-kind-name,
.cache-kind-empty .cache-kind-size {
  color: var(--text-secondary);
  opacity: 0.6;
}

.cache-actions,
.sync-actions {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 8px;
  margin-top: 16px;
}

/* The sync row wears its own name rather than the one above: `renderCache` puts `hidden` on every
   `.cache-actions` on the page, so a block sharing that class is switched off by an unrelated
   screen. Same layout, two carriers, one rule — the selector list is the sharing that is safe. */

/* The passphrases this browser remembers (#1411, #1414). One row per phrase: whose it is, the
   phrase itself — masked until the reader asks to see it — when it was last used, and the button
   that forgets it. A line of the same shape as the download rows above, because the two screens
   answer the same kind of question, and because `flex-wrap` is what keeps a long phrase and its
   button on one line on a desktop and lets them stack on a phone. */
.phrase-row {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 12px;
  padding: 10px 0;
  border-bottom: 1px solid var(--border-color);
}

.phrase-account {
  flex: 0 1 auto;
  font-weight: 500;
}

/* Monospace for the phrase alone: it is a string to be read letter by letter and retyped
   elsewhere, not prose — and `anywhere` is what keeps a long one inside its box rather than
   running under the button. */
.phrase-text {
  flex: 1 1 auto;
  font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
  overflow-wrap: anywhere;
}

.phrase-added {
  flex: 0 0 auto;
  color: var(--text-secondary);
  font-size: 13px;
}

/* The forget button sits at the far end of its row: it belongs to the row, not to the phrase, and a
   control that floats after the text moves along the line with every phrase of a different length. */
.phrase-forget {
  margin-left: auto;
}

/* A control that belongs to the box above it rather than to the panel: indented so the eye reads
   it as a statement about that task, not as a task of its own (#638). */
.task-sub {
  margin-left: 20px;
}

/* The camera's own three parts. The view is the stream's box and not a picture: it is given a size
   before anything arrives in it, so the dialog does not jump the moment the lens opens. */
#camera-dialog {
  /* Centred by `margin: auto` inside a scrim that scrolls — the same shape as the confirm dialog,
     and for the same reason: a panel taller than the glass must not lose its title off the top. */
  margin: auto;
  background: var(--bg-primary);
  border-radius: 12px;
  box-shadow: var(--shadow-lg);
  padding: 24px;
  max-width: 400px;
  width: 100%;
  max-height: calc(100dvh - 32px - env(safe-area-inset-top) - env(safe-area-inset-bottom));
  overflow-y: auto;
}

#camera-title {
  font-size: 18px;
  font-weight: 500;
  color: var(--text-primary);
  margin-bottom: 12px;
}

#camera-view {
  display: block;
  width: 100%;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  border-radius: 8px;
  background: var(--bg-secondary);
  margin-bottom: 12px;
}

#camera-note {
  font-size: 13px;
  color: var(--text-secondary);
  line-height: 1.5;
  margin-bottom: 20px;
}

#camera-actions {
  display: flex;
  gap: 8px;
  justify-content: flex-end;
}

/* §5.7 the sales tree (owner, 15.09): months over days over purchases, on native <details> — the
   summary is the row, both figures on it, the chevron is the browser's own. Nested levels step in,
   so "which month is this day under" is answered by the page's shape, not by memory.
   §8.11 §10 the inviter's book (owner, 16.09) is the same role one document over — a month over
   its days — and wears the same rules rather than a copy of them: two rules that say "a month row
   is a row with two figures" are two rules that part company the day one is restyled. The names
   differ because what they hold differs, which is what a reader of the markup needs from them. */
.sales-month, .sales-day, .ref-month {
  border-top: 1px solid var(--border-color);
}
.sales-month > summary, .sales-day > summary, .ref-month > summary {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
  padding: 10px 4px;
  cursor: pointer;
}
.sales-month > summary:hover, .sales-day > summary:hover, .ref-month > summary:hover { background: var(--bg-hover); }
.sales-day { margin-left: 16px; border-top: 1px dashed var(--border-color); }
.sales-buy, .ref-day {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
  margin-left: 32px;
  padding: 6px 4px;
  font-size: 13px;
}

/* The name an invitation carries, on the page its link opens. Its own rule rather than reuse of
   `.ledger-reason` or `.auth-title`: this is the one line on that screen a reader came for, and a
   role borrowed from a row of a table would be restyled with it. */
.invite-who {
  font-size: 18px;
  font-weight: 500;
  color: var(--text-primary);
  margin: 0 0 4px;
  overflow-wrap: anywhere;
}

/* Empty until something goes wrong, for the reason `.sign-note` above is: an empty line is a gap
   the reader takes for something that failed to load. */
#invite-status:empty, #ref-status:empty {
  display: none;
}

/* The calendar header breathes away from the summary line above it (owner, 15.09: «липнет»). */
#sales-card h4 { margin: 18px 0 8px; }

/*
 * §5.4 somebody else's tree is a READ (#655).
 *
 * The Files panel is drawn once and stands for whichever tree is open, so the two write entries on
 * it — the upload button and the way to make a subfolder — are hidden by a class the screen wears
 * while a shared tree is open rather than by a branch in every render. The guards in the client
 * refuse the acts as well; the rule here is what keeps them from being OFFERED, which is the half a
 * reader notices.
 *
 * `display: none` rather than the `hidden` class the button already carries: `.tree-shared
 * #btn-files-new-folder` outranks `.hidden`, and the two rules would otherwise take turns depending
 * on which render ran last.
 */
.tree-shared #btn-files-upload,
.tree-shared #btn-files-new-folder {
  display: none;
}

/* The feed's quick form (#725, FSP's fast publish; a real short form since 25.09): the reader's face,
   one line that grows and a Publish icon-btn, in the composer's own field look (border, radius).
   No settings, no editor — those are the Write screen's. The face is the wall's `.wall-face`,
   40px: one field's height, over the tap floor. */
.voice-quick {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  width: 100%;
  box-sizing: border-box;
  padding: 8px 10px;
  margin: 0 0 12px;
  background: var(--bg-primary);
  border: 1px solid var(--border-color);
  border-radius: 8px;
}

.voice-quick .wall-face {
  width: 40px;
  height: 40px;
  font-size: 17px;
  flex: none;
}

/* One line that grows: no border of its own, the row is the box (FSP's fast-publish `.text`). */
.voice-quick-text {
  flex: 1;
  min-width: 0;
  min-height: 40px;
  box-sizing: border-box;
  padding: 10px 0;
  border: none;
  outline: none;
  resize: none;
  background: transparent;
  color: var(--text-primary);
  font: inherit;
  field-sizing: content;
}

/* The «⋯» of a card (#1509 = A) — the author's rare acts folded behind one mark in the head,
   FSP's more-list shape: the box anchors the menu, the menu drops under the button's right edge
   and sits over the card that follows. Items spell mark AND word — a menu is read, not glanced
   at — and inherit `danger-btn` for the one act that destroys bytes. */
.wall-more {
  position: relative;
  flex: none;
}

/* The button itself is a `.wall-act` and wears that rule; this line only pins it as the menu's
   anchor sibling for the guard that asks every rendered class to be known to the stylesheet. */
.wall-more-btn {
  flex: none;
}

.wall-more-menu {
  position: absolute;
  right: 0;
  top: calc(100% + 4px);
  z-index: 30;
  min-width: 180px;
  /* The screen may be shorter than the list: the menu owes an answer for when it does not fit,
     and the answer is its own scroll — not a page that grew a second scrollbar. */
  max-height: 60vh;
  overflow-y: auto;
  background: var(--bg-secondary);
  border: 1px solid var(--border-color);
  border-radius: 8px;
  padding: 4px;
  box-shadow: var(--shadow-md);
}

.wall-more-item {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  box-sizing: border-box;
  padding: 8px 10px;
  border: none;
  border-radius: 5px;
  background: none;
  color: var(--text-primary);
  cursor: pointer;
  text-align: left;
  white-space: nowrap;
}

.wall-more-item:hover {
  background: var(--bg-hover);
}

/* The letter's trust marks live in the same menu (#726) and dress as its items — the buttons keep
   their own class because the handler asks for it, the menu decides how they look. */
.wall-more-menu .trust-btn {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  box-sizing: border-box;
  padding: 8px 10px;
  border: none;
  border-radius: 5px;
  background: none;
  color: var(--text-primary);
  cursor: pointer;
  text-align: left;
  white-space: nowrap;
}

.wall-more-menu .trust-btn:hover {
  background: var(--bg-hover);
}


/* FSP's composer add-ons (owner, 24.09, screenshot): a heading, the hint BEFORE the fields it explains,
   pill inputs whose height makes the radius a full half-circle at each end, a round "+" and a rule in
   the theme colour. Written with the fold's id: the global input floor (`min-height: 44px`, 16px type,
   three-part selector) outranks every class rule above, which is why the earlier pass changed nothing. */
#wall-fold-addons .wall-fold-sub {
  margin: 14px 0 6px;
  font-size: 20px;
  font-weight: 400;
}

#wall-fold-addons .wall-hint {
  margin: 0 0 8px;
  font-size: 15px;
  line-height: 1.4;
  color: var(--text-primary);
}

#wall-fold-addons input.wall-field-input,
#wall-fold-addons input.wall-benef-account,
#wall-fold-addons input.wall-benef-percent {
  height: 40px;
  min-height: 40px;
  padding: 0 14px;
  border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
  border-radius: 20px;
  font-size: 17px;
  background: transparent;
}

#wall-fold-addons .wall-seal-row .wall-field-input,
#wall-fold-addons .wall-benef-account,
#wall-fold-addons .wall-benef-percent {
  flex: 1 1 0;
  width: auto;
  min-width: 0;
}

#wall-fold-addons .wall-seal-layer {
  display: flex;
  flex-direction: row;
  gap: 6px;
  margin-top: 8px;
}

#wall-fold-addons .wall-add-icon {
  min-width: 40px;
  width: 40px;
  min-height: 40px;
  height: 40px;
  border: none;
  border-radius: 50%;
  transition: background 0.15s;
}

#wall-fold-addons .wall-add-icon:hover {
  background: color-mix(in srgb, var(--accent) 15%, transparent);
}

#wall-fold-addons .wall-fold-rule {
  margin: 12px 0 0;
  border: none;
  border-top: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
}

/* The two tag rows: each list under its own sentence, one row per list. */
.wall-tag-group {
  margin: 0 0 8px;
}

.wall-tag-group-cap {
  margin: 0 0 4px;
  font-size: 20px;
}

/* The Voice notice is a door to the new record (#757): it reads as the text it always was, only pressable. */
.toast-link { color: inherit; text-decoration: none; display: block }
.toast-link:hover { text-decoration: underline }

/* The count of new posts noticed since the feed was last opened (#757): a pill at the door's end. */
.folder-badge { margin-left: auto; min-width: 1.4em; padding: 0 .4em; border-radius: 1em; text-align: center;
  font-size: .75em; line-height: 1.6; background: var(--accent); color: var(--text-on-primary) }

/* FSP's «load new objects» plate on the feed (#757): a full-width action above the cards. */
.voice-new-plate { display: block; width: 100%; margin: 0 0 12px; padding: 10px 12px; border: none; border-radius: 8px; background: var(--accent); color: var(--text-on-primary); font: inherit; cursor: pointer }
.voice-new-plate.hidden { display: none }

/* The tag screen's header: back and #tag on the left, the reader's two marks on the right (FSP). */
.tag-header { display: flex; align-items: center; justify-content: space-between; gap: 8px }
.tag-marks { display: flex; gap: 4px }

/* FSP's right block: the pinned and most-met tags beside the main column. Wide screens only. */
#tags-addon { display: none }
@media (min-width: 1100px) {
  #tags-addon:not(.hidden) {
    display: block;
    flex: none;
    width: 240px;
    box-sizing: border-box;
    padding: 12px 14px;
    border-left: 1px solid var(--border-color);
    overflow-y: auto;
  }
}
.tags-addon-head { display: flex; align-items: center; justify-content: space-between; margin: 0 0 8px; font-weight: 600 }
.tags-addon-title .icon { vertical-align: -0.25em }
.tags-addon-group { margin: 0 0 14px }
.tags-addon-caption { margin: 0 0 4px; font-size: 13px; color: var(--text-secondary) }
.tags-addon-item {
  display: block; width: 100%; box-sizing: border-box; min-height: 44px; margin: 0; padding: 0 10px;
  border: none; border-radius: 8px; background: transparent; color: var(--text-primary);
  font: inherit; text-align: left; cursor: pointer;
}
.tags-addon-item:hover, .tags-addon-item:focus-visible { background: var(--icon-hover) }

/* FSP's tags screen: a numbered table of tags with their counts. */
.tags-table { width: 100%; border-collapse: collapse }
.tags-table th, .tags-table td { padding: 10px 8px; border-bottom: 1px solid var(--border-color); text-align: left }
.tags-table th { font-weight: 400 }
.tags-table .tags-no { width: 3em; color: var(--text-secondary) }
.tags-table .tags-count { text-align: right }

/* Tags belong to Voice (owner 25.09): outside voice screens neither the menu's pinned group nor the
   right block is drawn; inside, the right block takes them on wide screens and the menu on narrow. */
body:not(:has(#voice-screen:not(.hidden), #tag-screen:not(.hidden), #wall-screen:not(.hidden), #write-screen:not(.hidden))) #pinned-tags,
body:not(:has(#voice-screen:not(.hidden), #tag-screen:not(.hidden), #wall-screen:not(.hidden), #write-screen:not(.hidden))) #tags-addon { display: none !important }
@media (min-width: 1100px) {
  #pinned-tags { display: none !important }
}

/* Owner 25.09: pinned tags live at the bottom of the Voice group, as its last sub-block. */
#voice-group-items .pinned-in-voice { margin: 4px 0 0 }
#voice-group-items .pinned-in-voice .folder-group-header { padding-left: 28px; font-size: .9em; color: var(--text-secondary) }

/* FSP's preview (owner 25.09): a note in a list is clamped with a veil that fades into the card. */
.post-open { cursor: pointer }
/* the wall card's body is the same preview when it is not the record's own screen */
.post-preview, .wall-post-body[data-preview] { max-height: 30vh; overflow: hidden; position: relative; word-break: break-word }
.post-preview::after, .wall-post-body[data-preview]::after { content: ''; position: absolute; left: 0; right: 0; top: 20vh; height: 10vh;
  background: linear-gradient(to bottom, transparent, var(--bg-card)) }
.tag-link { text-decoration: underline }

/* FSP's reading line on a publication's own screen. */
.publication-readline { position: sticky; top: 0; z-index: 10; height: 6px; margin: 0 0 8px; overflow: hidden }
.publication-readline .fill-level { display: block; width: 0; height: 4px; background: var(--accent);
  border-bottom: 2px solid var(--accent-hover); transition: width .3s ease-out, opacity .3s ease-out }

/* The Read button of a publication's full view: an action, so a button-looking link. */

/* Links inside a record's text wear the theme's link colour, not the browser's visited violet. */
.wall-post-body a, .post-preview a, .tag-link { color: var(--accent) }
.wall-post-body a:visited, .post-preview a:visited, .tag-link:visited { color: var(--accent) }

/* Notifications: a row met after the previous opening of the screen (FSP's unread line). */
.tag-row[data-unread] { font-weight: 600; border-left: 3px solid var(--accent); padding-left: 8px }

/* Voice keeps FSP's column (fsp-ref app.css `.content`: 600px, and 800px in its publication mode):
   short records read as a feed, and a card stretched to a wide window reads as a banner. */
#voice-screen, #tag-screen, #wall-screen { max-width: 600px; margin-left: auto; margin-right: auto; }
#write-screen { max-width: 800px; margin-left: auto; margin-right: auto; }

/* The five reader acts draw FSP's marks at FSP's size (fsp-ref: a 24px glyph in its 35px round icon);
   at the row's 1.2em they shrank to ~14px and read as specks under a record. */
.wall-act > .icon { width: 24px; height: 24px; }

/* FSP's row (fsp-ref app.css .actions-view): the acts are spread across the card's full width —
   space-between, not packed to the left. */
.wall-acts { flex: 1; justify-content: space-between; }

/* FSP's record layout (owner 26.09, screenshots of fsp preview and object):
   — the name line reads "name @handle · date", the date following the handle rather than pushed to
     the card's right edge;
   — in the FEED the avatar owns a left column and the text, the acts and the rest stand to its
     right (FSP's preview), so the avatar is lifted out of the flow and the card indents by it;
   — on the record's OWN screen the head is avatar + name only, the text runs the card's full width,
     and the date stands under the text (`.wall-stamp-full`). */
.wall-names { display: flex; flex-wrap: wrap; align-items: baseline; column-gap: 6px; }
.wall-post-head .wall-stamp { margin-left: 0; margin-right: auto; }
.wall-post-head .wall-stamp::before { content: "· "; }
.wall-post:not([data-wall-focus]) { position: relative; padding-left: 71px; }
.wall-post:not([data-wall-focus]) > .wall-post-head .wall-face { position: absolute; left: 12px; top: 12px; }
.wall-post:not([data-wall-focus]) > .wall-post-head { margin-bottom: 4px; min-height: 0; }
.wall-stamp-full { margin-top: 10px; font-size: 15px; color: var(--text-secondary); }
.wall-post:not([data-wall-focus]) > .wall-post-head { gap: 6px; }
.wall-post:not([data-wall-focus]) > .wall-post-head .wall-more { margin: -12px -6px -12px auto; }
/* The stamp keeps the 44px tap floor every button has; the negative margin lets it keep that box
   without making the name line 44px tall. */
.wall-post-head button.wall-stamp { margin-top: -14px; margin-bottom: -14px; }
.wall-post-head > .wall-more { margin-left: auto; }

/* The address bar (owner 26.09, FSP's .header-link-wrapper): a rounded field with its copy/go acts
   inside it. The header owns its width (#1609=B) — the bar is a flex child of it on every screen
   and nothing places it over a content column. This section used to say the opposite: a wide-screen
   rule put the bar over the column (left/width from `alignAddressBar`, `top: 50%` + a translate),
   and both halves are gone. The rule was dead by then — `#address-bar { position: static;
   transform: none }` further down overrode every declaration it made — but a comment that named a
   helper which no longer did that was read as the design, so the helper's body and the rule went
   together rather than one of them staying as a decoy. */
#header { position: relative; }
#address-bar { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; margin: 0 12px; box-sizing: border-box; }
/* #1609=B: the bar lives in the header and stretches with it; only Voice keeps FSP's 600px column. */
#address-bar .address-wrap { position: relative; flex: 1; min-width: 0; }
/* FSP's .header-link-wrapper: 35px tall, 18px radius, 1px border, the field on the header's own
   background; the icons sit inside it, grey, blue on hover, green once copied. */
#address-input { width: 100%; box-sizing: border-box; height: 35px; line-height: 24px; padding: 5px 72px 5px 12px; border: 1px solid var(--border-color); border-radius: 18px; background: var(--bg-primary); color: var(--text-primary); font: inherit; font-size: 15px; }
#address-bar .address-icons { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); display: flex; gap: 2px; }
#address-bar .address-icons .icon-btn { min-width: 28px; min-height: 28px; width: 28px; height: 28px; padding: 0; color: var(--text-secondary); background: none; border: 0; }
#address-bar .address-icons .icon-btn .icon { width: 18px; height: 18px; }
#address-bar .address-icons .icon-btn:hover { color: var(--accent); }
#address-bar .address-icons .icon-btn.copied { color: var(--success); }
#address-acts:empty { display: none; }

/* The address bar at FSP's own size (owner 26.09: «слишком большой по высоте и липнет к header» —
   take FSP's .header-link-wrapper, 35px). Exempt from the 44px floor like the chips and the
   formatting row above: one field in the header, not a column of targets. The icons inside keep
   28px — the field's own height is the target a finger lands on. `input#address-input` and
   `button.icon-btn` for the specificity reason the chip rule explains. */
input#address-input { min-height: 0; height: 35px; font-size: 15px; }
#address-bar button.icon-btn { min-height: 0; min-width: 0; }

/* The bottom bar (owner 26.09, FSP's mobile .footer): a fixed row at the foot of a phone screen —
   up, the full menu, and the section's own two or three acts; icons only, the last one a round
   accent button like FSP's publish. The desktop layout hides it: the sidebar is the menu there. */
#bottom-bar { display: flex; justify-content: space-around; align-items: center; position: fixed; left: 0; right: 0; bottom: 0; height: 50px; padding: 0 15px env(safe-area-inset-bottom); background: var(--bg-primary); border-top: 1px solid var(--border-color); z-index: 20; }
#content { padding-bottom: calc(60px + env(safe-area-inset-bottom)); }
/* On a phone the menu lives in the bottom bar; the header's hamburger would be a second one. */
#header .menu-btn { display: none; }
/* …and the floating compose button: the bar's «+» is the new letter now (FSP has one publish). */
#bottom-bar ~ #content .fab, #content .fab { display: none; }
@media (min-width: 769px) {
  #header .menu-btn { display: block; }
  #bottom-bar { display: none; }
  #content { padding-bottom: max(16px, env(safe-area-inset-bottom)); }
}
#bottom-bar .bb-btn { position: relative; display: inline-flex; align-items: center; justify-content: center; min-width: 44px; min-height: 44px; padding: 0; border: 0; background: none; color: var(--accent); }
#bottom-bar .bb-btn .icon { width: 24px; height: 24px; }
#bottom-bar .bb-primary { min-width: 0; min-height: 0; width: 38px; height: 38px; border-radius: 50%; background: var(--accent); color: var(--text-on-primary); }
#bottom-bar .bb-count { position: absolute; top: 4px; right: 2px; min-width: 16px; padding: 1px 4px; border-radius: 8px; font-size: 10px; line-height: 12px; text-align: center; background: var(--accent); color: var(--text-on-primary); pointer-events: none; }

/* §7.11 chats screen (IM plan): one pane at a time on a phone, list and dialog side by side from
   769px — written phone-first, the wide layout is the addition. */
.chats-layout {
  display: flex;
  flex-direction: column;
  /* the screen, less the top bar and the bottom bar: the composer sits at the bottom, as in Telegram */
  height: calc(100dvh - 190px);
  min-height: 360px;
  border: 1px solid var(--border-color);
  border-radius: 8px;
}

/*
 * The transition between the two panes (owner 07.10 23:18: «переход вперёд и назад анимирован, список
 * не мигает загрузкой»). The pane that ARRIVES carries the animation: a chat opens and the dialog
 * comes in from the right, the reader leaves and the list comes back from the left.
 *
 * The pane being REPLACED is deliberately not animated, and that is a decision rather than an
 * omission: animating it away would mean holding `chats-open` until the animation ended, so the press
 * would answer 180 ms late — on a slow phone the row the reader pressed would still stand under his
 * finger while the client pretended to still be on the list. A cut is cheaper than a late answer.
 * Nothing in the script waits for these animations (`tests/ui-chattransition.js` pins that).
 *
 * Both are turned off from 769px, where the panes stand side by side and neither ever arrives, and
 * under `prefers-reduced-motion` (the block that stands just below, rather than with the spinner's at
 * the head of the file — its own note says why).
 */
@keyframes chat-pane-in { from { transform: translateX(16px); opacity: 0 } }
@keyframes chat-pane-back { from { transform: translateX(-16px); opacity: 0 } }

.chats-list-pane {
  width: 100%;
  padding: 8px;
  overflow-y: auto;
  animation: chat-pane-back .18s ease-out;
}

.chats-dialog-pane {
  flex: 1;
  display: none;
  /* The arrival of a chat, from the right — see the keyframes above the list pane. */
  animation: chat-pane-in .18s ease-out;
  flex-direction: column;
  min-width: 0;
  /* The box chain has to be able to SHRINK, or the feed's `overflow-y: auto` never gets a box to
     scroll in. This pane is a flex item of `.chats-layout` (a fixed height) and carried only
     `min-width: 0`, so its automatic minimum size was its own content: the 40-line feed grew the
     pane to 3575px inside a 618px box, spilling 3296px past the border and pushing the composer
     down behind the fixed hub bar (owner's phone, 02.10.2026 23:07). `min-height: 0` is what makes
     `flex: 1` mean "the room that is left" instead of "at least all of my content" — the same
     declaration `.chats-dialog-body` below already carries, which is why that one looked correct
     and this one did not. `tests/tools/ui-chat-mobile-accept.mjs` measures the box chain. */
  min-height: 0;
}

.chats-open .chats-list-pane {
  display: none;
}

.chats-open .chats-dialog-pane {
  display: flex;
}

/*
 * Why this block stands HERE and not with the spinner's stillness at the head of the file: the two
 * rules above are declared later and carry the same specificity, so a `prefers-reduced-motion`
 * override written earlier would be dead CSS — a reader who asked the system for less motion would
 * still get the slide, and nothing would look broken. Same class as the i18n toggle that lost to
 * `.card b`; a media query adds no weight of its own, only position.
 */
@media (prefers-reduced-motion: reduce) {
  .chats-list-pane,
  .chats-dialog-pane { animation: none; }
}

/* The folder rail (IM plan §7.11): on a phone it is a strip of tabs ABOVE the list — a 64px column
   beside a 320px list leaves nothing to read the chats in (owner's review 01.10). From 769px it
   turns into Telegram Desktop's column of icon-over-label tabs, which is the layout the desktop
   screenshot shows. */
.chats-rail {
  display: flex;
  flex: none;
  flex-direction: row;
  align-items: stretch;
  width: 100%;
  padding: 0;
  /* visible, not a scroll box: the ☰ menu hangs over the list and a scroll box would clip it */
  overflow: visible;
  border-bottom: 1px solid var(--border-color);
}
.chats-rail-tabs {
  display: flex;
  flex: 1;
  flex-direction: row;
  /* wraps instead of scrolling: a scroll box is a clip, and the ☰ menu opens out of the rail */
  flex-wrap: wrap;
  gap: 4px;
  min-width: 0;
  padding: 2px 0;
}
.chats-rail-tab {
  display: flex;
  flex: none;
  flex-direction: row;
  align-items: center;
  gap: 6px;
  min-height: 42px;
  padding: 0 8px;
  border: 0;
  background: transparent;
  color: var(--text-secondary);
  cursor: pointer;
  white-space: nowrap;
}
.chats-rail-tab svg {
  width: 24px;
  height: 24px;
}
.chats-rail-tab[data-folder] { position: relative; }
.chats-rail-count {
  /* in the row layout the number follows its label: absolutely placed it would sit on top of it */
  position: static;
  flex: none;
  min-width: 18px;
  padding: 0 5px;
  border-radius: 9px;
  background: var(--accent);
  color: var(--text-on-primary);
  font-size: 11px;
  line-height: 18px;
  text-align: center;
}
.chats-rail-count.chat-row-muted { background: var(--text-secondary); }
.chats-rail-label {
  max-width: 60px;
  overflow: hidden;
  font-size: 11px;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.chats-rail-menu { position: relative; }
/* The rail's ☰ stands at the LEFT edge of the screen, so its menu opens to the RIGHT of it. The
   shared `.wall-more-menu` rule right-aligns the panel to its button — right for the wall card's
   «⋯», which sits at the end of a row, and wrong here: measured at 320px it put the panel at
   −119..61, with every item label off the screen (owner, 02.10.2026 21:56). The desktop block
   below already mirrors this (`left: 100%`); the phone had no anchor of its own and inherited the
   other control's. */
.chats-rail-menu .wall-more-menu {
  left: 0;
  right: auto;
  top: 100%;
  /* A label that grows with a translation cannot push the panel off the right edge either. The
     panel scrolls instead: `overflow-y: auto` makes the other axis compute to `auto`, so an
     over-long menu is reachable rather than clipped. */
  max-width: calc(100vw - 16px);
}
.folder-icons {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
  margin: 6px 0;
}
.folder-icon {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  border-radius: 8px;
  color: var(--text-secondary);
  cursor: pointer;
}
.folder-icon input {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}
.folder-icon svg {
  width: 22px;
  height: 22px;
}
.folder-icon:has(input:checked) {
  background: var(--bg-primary);
  color: var(--accent-text);
}
.folder-icon:has(input:focus-visible) { outline: 2px solid var(--accent-text); }
.chats-rail-tab.active {
  background: var(--bg-primary);
  color: var(--accent-text);
}
/* The search box stands alone since the screen's own menu was removed (owner 19.10 22:10); the row
   stays so the box keeps its margin and the list keeps its height. */
.chats-list-top {
  display: flex;
  align-items: center;
  gap: 6px;
  margin: 0 0 8px;
}

.chats-list-top .chats-search {
  flex: 1;
  min-width: 0;
  margin: 0;
}

.chats-search {
  width: 100%;
  box-sizing: border-box;
  margin: 0 0 8px;
}

.chat-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px;
  border-radius: 8px;
  color: inherit;
  text-decoration: none;
}

.chat-row:hover,
.chat-row.active {
  background: var(--bg-hover);
}

.chat-avatar {
  width: 40px;
  height: 40px;
  flex: none;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--accent);
  color: var(--bg-primary);
  font-weight: 600;
}

/* A group wears a square with rounded corners where a person wears a circle (owner's review 03.10,
   Telegram's own): the shape is what says "this row is not one person" at a glance, and it says it
   without a second glyph beside the name. */
.chat-avatar-group {
  border-radius: 12px;
}

/* §7.11.9's book wears the bookmark its own row and its own head both draw (#992). A CIRCLE, like a
   person's and unlike a group's square: it is not one person, but it is also not a room with members
   — it is one reader's own book, and the shape says which of the two it is closer to. */
.chat-avatar-fav .icon {
  width: 20px;
  height: 20px;
}

.chat-row-text {
  position: relative;
  display: flex;
  flex: 1;
  /* The row is the PANE's width, not the longest thing ever said in it (owner's report 03.10 23:10:
     «у группы нет лимита на ширину второй строки, из-за чего она сместила весь блок»). A flex item's
     automatic minimum size is its min-content width, and for this COLUMN that is the longest line its
     children cannot break — and a preview is `nowrap` (`.chat-row-last`), so it cannot break at all.
     The column therefore grew to fit the sentence and the hour (`margin-left: auto`) rode out of the
     pane with it. Zero lets the column shrink to what it is given, and the ellipsis on the line does
     the rest. The sibling `.chat-row-top` has carried the same floor since it was built; this one was
     never asked, because until a row had a LONG second line nobody could see the difference. */
  min-width: 0;
  flex-direction: column;
  /* As tall as the avatar, whether or not there is a second line (owner's review 03.10: «если нет
     нижней строчки, то строчка с именем и временем переезжает в середину, не надо»). Two lines are
     a shade shorter than the avatar, so that block was centred against 40px while a row with ONE
     line had its single line centred in the same box — the name stood ~8px lower on every row that
     had nothing said in it yet. The floor makes the two rows the same box, and the name lands in the
     same place in both: what the owner asked for is one standard, not one standard per shape. */
  min-height: 40px;
}
/* Telegram's columns (owner's review 01.10): the time stands at the right of the name line, the
   unread badge at the right of the last-line, whatever the name's length */
.chat-row-top .chat-row-when { flex: none; margin-left: auto; }
.chat-row-text .chat-row-unread { position: absolute; right: 0; bottom: 0; margin-left: 0; }
/* The pin belongs to the SECOND line (owner's review 03.10: «pin svg должен быть только во второй
   строке, сейчас он смещает и первую строку, мешается»). It used to sit at the row's top right,
   where the time lives, so the first line paid for it — `.chat-row-pinned .chat-row-top` reserved
   18px and the time was shoved left on pinned rows alone. Down here it takes the same right-hand
   slot the unread badge takes, and the two make room for each other the way the badge already made
   room for itself: the reserved width stands on the LINE, so the preview truncates before it. */
.chat-row-pin {
  position: absolute;
  right: 0;
  bottom: 6px;
  display: inline-flex;
  width: 14px;
  height: 14px;
  color: var(--text-secondary);
}
.chat-row-text:has(.chat-row-pin) .chat-row-last { padding-right: 20px; }
.chat-row-text:has(.chat-row-pin):has(.chat-row-unread) .chat-row-last { padding-right: 54px; }
.chat-row-text:has(.chat-row-pin):has(.chat-row-unread) .chat-row-pin { right: 34px; }
.chat-row-text:has(.chat-row-unread) .chat-row-last { padding-right: 34px; }

.chat-row-name {
  font-weight: 600;
  overflow: hidden;
  text-overflow: ellipsis;
  color: inherit;
}

.chat-row-top {
  display: flex;
  align-items: center;
  gap: 4px;
  min-width: 0;
}
.chat-row-top .chat-row-name {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.chat-row-type svg {
  width: 14px;
  height: 14px;
}
.chat-row-handle {
  flex: none;
  color: var(--text-secondary);
  font-size: 12px;
}
.chat-row-last {
  overflow: hidden;
  color: var(--text-secondary);
  font-size: 13px;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.chat-row-who { color: var(--accent-text); }

.chat-head-title {
  display: flex;
  flex-direction: column;
  min-width: 0;
  flex: 1;
}

.chat-head-title.is-clickable { cursor: pointer; }

.chat-members-search {
  width: 100%;
  margin-bottom: 8px;
}

.presence-dot {
  display: inline-block;
  width: 8px;
  height: 8px;
  margin-right: 6px;
  border-radius: 50%;
  background: var(--border-color);
}

.presence-dot.is-online { background: var(--success); }

.chat-head-sub {
  color: var(--text-secondary);
  font-size: 12px;
}

.chat-head-sub.is-online { color: var(--accent); }

.chat-row-when {
  color: var(--text-secondary);
  font-size: 12px;
}

/* Telegram's third column: what became of the last line, when that line is OURS. Drawn from the
   sprite by `chatStateSvg` — one tick while only the hub holds the box, two once the RECIPIENT has
   said he opened it (§7.12.6). */
.chat-row-tick {
  flex: none;
  display: inline-flex;
  align-items: center;
  color: var(--text-secondary);
}
.chat-row-tick svg {
  width: 14px;
  height: 14px;
}

/* Read is the only thing on a line that is coloured: the peer's own word that he opened it, and
   `seen` and `dlv` wear the same mark — a line that was read was delivered (owner, 03.10 20:25). */
.chat-row-tick.chat-state-seen,
.chat-bubble-when.chat-state-seen {
  color: var(--accent);
}

/* The kind of file a line carries, beside the preview: the symbol the file's own table names, not
   the paperclip emoji that used to stand in the sentence (owner's review 03.10). */
.chat-row-file {
  display: inline-flex;
  margin-right: 3px;
  color: var(--text-secondary);
  vertical-align: -2px;
}
.chat-row-file svg {
  width: 13px;
  height: 13px;
}

.chats-dialog-head {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px 12px;
  border-bottom: 1px solid var(--border-color);
}

.chats-dialog-body {
  flex: 1;
  display: flex;
  flex-direction: column;
  min-height: 0;
  padding: 8px 12px;
}

.chats-dialog-body .chat-feed {
  flex: 1;
  overflow-y: auto;
}

/* On a phone an open dialog takes the whole box: the folder rail belongs to the list. */
.chats-open .chats-rail {
  display: none;
}

.chats-back {
  display: inline-flex;
}

.blink-page {
  padding: 12px;
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.blink-field {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
}

.blink-folder {
  padding: 10px;
  border-radius: 8px;
  background: var(--bg-secondary);
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.blink-folder-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.chat-pipe-feed { margin-top: 8px; }
/* #1514: a group shows the tail of its log; the older head is drawn but folded out */
.chat-feed > .chat-win-out { display: none !important; }
.chat-feed.chat-win-loading::before {
  content: attr(data-win-loading);
  align-self: center;
  color: var(--text-secondary);
  font-size: 12px;
  padding: 6px 0;
}
.chat-feed {
  align-self: stretch;
  display: flex;
  flex-direction: column;
  gap: 6px;
  padding-bottom: 12px;
}

/* The wallpapers of a personal chat (#1232). One rule paints BOTH the feed that carries the
   wallpaper and the cell the reader chose it from — the swatch puts the same `data-chat-wall` on
   itself, so what he is looking at and what he will get are drawn by one declaration and cannot
   drift apart. The dotted one is the exception in what it paints, not in how it is named: a grain
   says nothing about the surface under it, so it carries no colour of its own and the feed's
   background stays whatever the theme gave it. */
[data-chat-wall="mist"] { background-color: var(--wall-mist); }
[data-chat-wall="sand"] { background-color: var(--wall-sand); }
[data-chat-wall="mint"] { background-color: var(--wall-mint); }
[data-chat-wall="lilac"] { background-color: var(--wall-lilac); }
[data-chat-wall="dots"] {
  background-image: radial-gradient(var(--wall-dot) 1px, transparent 1px);
  background-size: 16px 16px;
}

/* The chooser hangs off the ⋯'s anchor exactly as the menu does (`.wall-more` is its positioned
   ancestor), and takes the menu's place under the button's right edge. Like every surface in this
   client it carries its own padding and its colours are theme tokens only; `[hidden]` is stated
   because the UA's rule loses to the display below, which is the same trap the composer's spare
   buttons are caught by. */
.chat-wall {
  position: absolute;
  right: 0;
  top: calc(100% + 4px);
  z-index: 30;
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  width: 208px;
  padding: 8px;
  background: var(--bg-secondary);
  border: 1px solid var(--border-color);
  border-radius: 8px;
  box-shadow: var(--shadow-md);
  /* Bounded like the menu it stands in for (`.ctx-menu`): five swatches plus «none» fit in the fixed
     width today, but a layer says what happens when it does not fit rather than running off the
     screen — and the swatch set is a code constant that can grow. */
  max-height: calc(100vh - 8px);
  overflow: auto;
}

.chat-wall[hidden] { display: none; }

.chat-wall-swatch {
  width: 40px;
  height: 40px;
  padding: 0;
  border: 1px solid var(--border-color);
  border-radius: 8px;
  cursor: pointer;
}

/* Which one the chat carries now. The ring rather than a checkmark, because four of the five cells
   are told apart BY their fill and a mark drawn inside one would sit on the very colour it names. */
.chat-wall-swatch[aria-pressed="true"] {
  border-color: var(--accent);
  box-shadow: 0 0 0 2px var(--focus-ring);
}

/* «Без обоев» is the absence of a wallpaper and says so in words: an empty cell beside five painted
   ones reads as one more of the five. It is twice as wide as a cell because it carries a phrase. */
.chat-wall-none {
  width: auto;
  min-width: 88px;
  padding: 0 10px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: var(--text-primary);
  font-size: 13px;
}

.chat-line {
  display: flex;
  /* the face stands at the foot of a run (#6595) */
  align-items: flex-end;
  gap: 6px;
  align-self: flex-start;
  max-width: 85%;
}

.chat-line.chat-out {
  align-self: flex-end;
}

.chat-line .chat-bubble {
  max-width: 100%;
}

/* #6595: an answered line's reply arrow and count, grey beside its clock */
.chat-replies {
  display: inline-flex;
  align-items: center;
  gap: 2px;
  margin-right: 6px;
  font-size: 12px;
  color: var(--text-secondary);
}

.chat-replies .icon {
  width: 12px;
  height: 12px;
}

/* the last bubble of a run points at its face: a sharp lower corner on the face's side */
.chat-line:not(.chat-out) .chat-bubble.chat-tail {
  border-bottom-left-radius: 4px;
}

.chat-line .chat-avatar,
.chat-avatar-gap {
  flex: none;
  width: 28px;
  height: 28px;
}

.chat-bubble {
  align-self: flex-start;
  max-width: 85%;
  padding: 8px 12px;
  border-radius: 12px;
  /* the panel itself is --bg-secondary: an incoming bubble on it must be the other surface */
  background: var(--bg-primary);
  border: 1px solid var(--border-color);
  color: var(--text-primary);
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.chat-bubble.chat-out {
  align-self: flex-end;
  background-color: var(--bg-primary);
  background-image: linear-gradient(var(--chat-own), var(--chat-own));
}

/* the own bubble takes the hue of the wallpaper it stands on (#6595) */
[data-chat-wall="mist"] .chat-bubble.chat-out { background-image: linear-gradient(var(--own-mist), var(--own-mist)); }
[data-chat-wall="sand"] .chat-bubble.chat-out { background-image: linear-gradient(var(--own-sand), var(--own-sand)); }
[data-chat-wall="mint"] .chat-bubble.chat-out { background-image: linear-gradient(var(--own-mint), var(--own-mint)); }
[data-chat-wall="lilac"] .chat-bubble.chat-out { background-image: linear-gradient(var(--own-lilac), var(--own-lilac)); }

.chat-bubble.chat-unread {
  font-weight: 600;
}

.chat-bubble-text {
  overflow-wrap: anywhere;
  /* the reader's chat text size (Blink settings, four steps from 14px) */
  font-size: var(--chat-text);
}

.chat-bubble-text p {
  margin: 0;
}

/* files a pipe line carries: the letter's attachment block, without its heading inside a bubble */
.chat-bubble-files .message-attachments { margin: 6px 0 0; }
.chat-bubble-files .message-attachments h4 { display: none; }

.chat-bubble-text p + p {
  margin-top: 6px;
}

/* §7.11.4 «удалить у всех»: the body is gone, the row is not — the line keeps its place, its face
   and its hour, and the words are replaced by the hint. Palette through variables only (no literal
   colours), so the dark scheme follows without a second rule. */
.chat-taken { opacity: .75; }
.chat-taken-hint { font-style: italic; color: var(--text-secondary); }

/* Telegram's mark beside the time of an own line: a clock while sending, one tick once the hub
   holds the box, two once the recipient has said he opened it — blue for the read one, the only
   coloured thing on the line (§7.12.6). Drawn as a symbol from the sprite by `chatStateSvg` — the
   same mark, from the same hand, as the row's third column — so nothing here writes a character
   into the stylesheet. */
.chat-bubble-when.chat-state-failed::after { content: " !"; }
.chat-bubble-when.chat-state-failed {
  color: var(--danger);
}

.chat-bubble-when.chat-state-unknown,
.chat-bubble-when.chat-state-sending {
  font-style: italic;
}


.chat-upload-state {
  align-self: stretch;
  margin: 0;
}

.chat-upload-state:not([hidden]) {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.chat-upload-card {
  display: flex;
  align-items: center;
  gap: 8px;
}

.chat-upload-text {
  flex: 1;
  min-width: 0;
  overflow-wrap: anywhere;
}

.chat-upload-kind {
  flex-shrink: 0;
  width: 48px;
  height: 48px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 8px;
  color: var(--text-secondary);
  background: var(--bg-secondary);
}

.chat-upload-kind svg {
  width: 24px;
  height: 24px;
}

.chat-upload-preview {
  width: 48px;
  height: 48px;
  object-fit: cover;
  border-radius: 8px;
}

.chat-upload-cancel {
  flex-shrink: 0;
  width: 44px;
  height: 44px;
  border: 0;
  border-radius: 50%;
  color: var(--text-primary);
  background: var(--bg-secondary);
  font-size: 28px;
  cursor: pointer;
}

/* «повторить» says how to get back into the queue, so it must not be dressed as the reason: the
   reason is the words, and the button is a button. It is only drawn on a card that failed. */
.chat-upload-retry {
  flex-shrink: 0;
}

/* `.header-btn` sets display: flex, which outranks the UA's [hidden] — the same trap the composer's
   own send button is spelled out for just below. Without this, a card that never failed wears a
   «повторить» beside a file that has not gone anywhere. */
.chat-upload-retry[hidden] {
  display: none;
}

/* The card the reason is about. It keeps its place in the queue — which is the point of the owner's
   «остальные файлы не исчезают» — so the sentence has to read as a state of THIS card and not as one
   more line of the queue. */
.chat-upload-card.chat-upload-failed .chat-upload-text {
  color: var(--danger);
}

.chat-attached {
  align-self: stretch;
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}

.chat-attached-file {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 2px 4px 2px 8px;
  border-radius: 12px;
  background: var(--bg-secondary);
}

.chat-attached-thumb {
  width: 40px;
  height: 40px;
  border-radius: 8px;
  object-fit: cover;
}

.chat-album {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
  gap: 4px;
}

.chat-album-pic img {
  display: block;
  width: 100%;
  aspect-ratio: 1;
  object-fit: cover;
  border-radius: 8px;
}

.chat-wave {
  grid-column: 1 / -1;
  display: flex;
  align-items: center;
  gap: 2px;
  height: 32px;
}

.chat-wave-bar {
  flex: 1;
  min-width: 2px;
  border-radius: 1px;
  background: var(--text-secondary);
}

.chat-wave-bar.chat-wave-done {
  background: var(--accent);
}

.chat-circle,
.chat-circle-view {
  display: block;
  width: 192px;
  height: 192px;
  border-radius: 50%;
  object-fit: cover;
  grid-column: 1 / -1;
}

.chat-circle-live {
  align-self: center;
}

.chat-album-audio {
  grid-column: 1 / -1;
  width: 100%;
}

.chat-voice.chat-recording {
  color: var(--danger);
}

.chat-album-file {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  grid-column: 1 / -1;
}

.chat-send {
  align-self: stretch;
  display: flex;
  gap: 2px;
  align-items: flex-end;
  padding-top: 8px;
  border-top: 1px solid var(--border-color);
  /* The emoji panel hangs off this row (see `.chat-emoji`), and an absolutely positioned child
     needs a positioned ancestor — otherwise it climbs to the dialog and lands over the feed. */
  position: relative;
}

.chat-send .icon-btn {
  flex: none;
  min-width: 40px;
  padding: 8px;
}
/* `.icon-btn` sets display: inline-flex, which outranks the UA's [hidden]: the composer hides its
   spare buttons by the attribute (e37e713 — one clip, one media button), so they must really go */
.chat-send .icon-btn[hidden] { display: none; }

.chat-send-text {
  flex: 1;
  min-width: 0;
  resize: none;
}

textarea.chat-send-text { min-height: 44px; max-height: min(200px, 30dvh); overflow-y: auto; }

.chat-send-btn {
  color: var(--accent);
}

/* §7.11.11: the way to a poll, beside the smile — the same slot's colour, because both are ways of
 * writing a line rather than ways of sending one */
.chat-poll-btn {
  color: var(--text-secondary);
}

/* The emoji panel (#1011, «смайлы»). It hangs ABOVE the composer and takes no room from it: the row
   is a flex line, and a panel in the flow would push the textarea up every time it opens. A surface
   with its own background carries its own padding at any width; the colours are the theme's tokens
   only, so the dark scheme follows without a second declaration, and the shadow is the token the
   other floating panels use rather than a literal (tests/ui-theme.js keeps literals out of here).
   Every cell is a real `<button>` and so keeps the 44px floor the whole client gives its controls —
   the grid is built FROM that floor rather than working around it. */
.chat-emoji {
  position: absolute;
  left: 0;
  right: 0;
  bottom: calc(100% + 4px);
  z-index: 30;
  padding: 6px;
  max-height: 46vh;
  /* The box that scrolls is the grid INSIDE this one, so the tabs stay put while the cells move
     under them; this clip is the belt to that suspenders — a flex child's automatic minimum size
     is what keeps the panel under `max-height`, and a browser that resolved it differently would
     otherwise paint the panel over the feed. Nothing opens a layer inside it. */
  overflow: hidden;
  display: flex;
  flex-direction: column;
  border: 1px solid var(--border-color);
  border-radius: 12px;
  background: var(--bg-primary);
  box-shadow: var(--shadow-lg);
}

.chat-emoji[hidden] { display: none; }

/* The tabs WRAP rather than scroll sideways: seven glyphs fit one line at any width this client is
   read at, and a row that wraps has no children to keep from shrinking (tests/ui-layout.js reads a
   sideways-scrolling row as one whose cells must declare `flex: none`, and a row built by script is
   not in the markup it reads). */
.chat-emoji-tabs {
  display: flex;
  flex-wrap: wrap;
  gap: 2px;
  flex: none;
  padding-bottom: 4px;
  margin-bottom: 4px;
  border-bottom: 1px solid var(--border-color);
}

.chat-emoji-tab {
  flex: none;
  padding: 0;
  border: 0;
  background: none;
  font-size: 20px;
  line-height: 1;
  cursor: pointer;
  border-radius: 8px;
}

.chat-emoji-tab[aria-pressed="true"],
.chat-emoji-tab:hover { background: var(--bg-hover); }

.chat-emoji-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(44px, 1fr));
  gap: 2px;
  overflow-y: auto;
}

.chat-emoji-cell {
  padding: 0;
  border: 0;
  background: none;
  font-size: 22px;
  line-height: 1;
  cursor: pointer;
  border-radius: 8px;
}

.chat-emoji-cell:hover { background: var(--bg-hover); }

/* §7.11.12: a pack's tab is a NAME where the standard tabs are one picture each. The vocabulary is
   picked by looking, but a pack is picked by knowing WHICH one it is, and its name is the only thing
   about it the reader can recognise — its address is 64 hex characters. The bar wraps, so a long name
   is cut here rather than pushing the other tabs onto a second line. */
.chat-emoji-pack {
  font-size: 13px;
  line-height: 1.9;
  padding: 0 6px;
  max-width: 12em;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* The showcase's mark, under the bar: the one place a pack is said to have passed, standing beside
   the hash it passed BY, because that hash is what the check was made against. `[hidden]` is spelled
   out because `display: flex` would otherwise beat the user agent's own rule for it. */
.chat-emoji-note {
  flex: none;
  display: flex;
  align-items: center;
  gap: 4px;
  margin: 0 0 4px;
  font-size: 12px;
  color: var(--text-secondary);
}

.chat-emoji-note[hidden] { display: none; }

/* §7.11.13: the reply keyboard a bot puts under the composer. A real BLOCK above the row and not a
   popover hanging over the feed, because the emoji panel is opened and closed while this one STANDS
   until the bot takes it away — a standing panel that floated would hide the last lines of the very
   conversation it belongs to. One line per `rows`, each key as wide as its share of the line; every key
   is a real `<button>` and keeps the client's tap floor. Colours are the theme tokens only, so the dark
   scheme follows without a second declaration. */
.chat-kb {
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding-top: 8px;
  align-self: stretch;
}

.chat-kb[hidden] { display: none; }

.chat-kb-row { display: flex; gap: 4px; }

.chat-kb-key {
  flex: 1;
  min-width: 0;
  min-height: 44px;
  padding: 8px;
  border: 1px solid var(--border-color);
  border-radius: 8px;
  background: var(--bg-secondary);
  color: var(--text-primary);
  font-size: 14px;
  cursor: pointer;
}

.chat-kb-key:hover { background: var(--bg-hover); }

/* §7.11.13's last rule: on a device whose SYSTEM keyboard is up, the panel gives the screen to it.
   `chatKbAway` decides it by the visible area and puts this class on the body; where `visualViewport`
   is missing the class is never set, which is the desktop case the panel exists for. */
body.chat-kb-away .chat-kb { display: none; }

/* §7.11.14: the keyboard INSIDE a message. It is NOT the panel above and must not read as one: the
   panel stands between the feed and the composer and describes the chat's newest line, while this one
   is part of the bubble it was drawn in and leaves the screen with that bubble. So it is drawn inside
   the bubble's own column, narrower keys and a smaller tap floor than the composer's keys — a message
   is sized by its words, not by a panel that owns the width of the screen. `body.chat-kb-away` does not
   reach it: it belongs to a line in the log, and hiding a line's own content because the reader is
   typing would be the screen losing a message. */
.chat-cb {
  display: flex;
  flex-direction: column;
  gap: 4px;
  margin-top: 6px;
}

.chat-cb-row { display: flex; gap: 4px; }

.chat-cb-key {
  flex: 1;
  min-width: 0;
  min-height: 34px;
  padding: 6px 10px;
  border: 1px solid var(--border-color);
  border-radius: 8px;
  background: var(--bg-secondary);
  color: var(--text-primary);
  font-size: 13px;
  cursor: pointer;
}

.chat-cb-key:hover { background: var(--bg-hover); }

.chat-bubble-author {
  font-size: 12px;
  font-weight: 600;
  /* #6595: a name, not a link — a step darker than the link colour, never underlined */
  color: var(--chat-author);
  text-decoration: none;
}

.chat-bubble-author:hover {
  text-decoration: none;
}

.chat-group-form {
  padding: 8px;
}

.chat-bubble-when {
  align-self: flex-end;
  color: var(--text-secondary);
  font-size: 11px;
}

.chat-info {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  padding: 12px;
  margin-bottom: 8px;
  border-bottom: 1px solid var(--border-color);
  text-align: center;
}

.chat-info-name {
  font-size: 18px;
  font-weight: 600;
}

/* The card's acts (owner 30.09 14:28). A tile carries its own background, so it carries its own
   padding at EVERY width (the surface rule): on a phone the row wraps rather than letting a label
   touch the edge. 6px, not a pill — in this client pills are controls, and a tile is a door.
   The glyph is sized in px like `.tag-marks .icon-btn .icon`: at the label's 12px, the shared 1.2em
   would draw a 14px icon in a 96px tile. */
.chat-info-tiles {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  width: 100%;
  max-width: 360px;
}

.chat-info-tile {
  flex: 1 1 96px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  padding: 10px 8px;
  border: 1px solid var(--border-color);
  border-radius: 6px;
  background: var(--bg-secondary);
  color: var(--text-primary);
  font-size: 12px;
  line-height: 1.25;
  cursor: pointer;
}

.chat-info-tile:hover {
  background: var(--bg-hover);
}

.chat-info-tile .icon {
  width: 22px;
  height: 22px;
}

.chat-rights {
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 8px 12px;
  border-left: 3px solid var(--accent);
}

.chat-rights-row {
  display: flex;
  align-items: center;
  gap: 8px;
}

.chat-info-pic {
  width: 96px;
  height: 96px;
  border-radius: 50%;
  object-fit: cover;
}

.chat-info-about {
  max-width: 100%;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

.chat-info-edit {
  display: flex;
  flex-direction: column;
  gap: 8px;
  width: 100%;
  max-width: 360px;
  text-align: left;
}

/* The form's way back (10.10.2026). It is a flex CHILD of a column, and a stretched icon button is a
   360px bar with an arrow at its left edge — so it takes its own width and sits at the head of the
   form, where every other screen here keeps its arrow. */
.chat-info-edit .chat-info-back {
  align-self: flex-start;
  margin-bottom: 2px;
}

.chat-info-edit input,
.chat-info-edit textarea {
  display: block;
  width: 100%;
}

/* The form's picture and its commit button, told apart from the two fields above.
   The fields are the UA's own box (a white 1px-border rectangle); the save used to wear
   `.header-btn` — the SAME box — so the card showed three identical rectangles and the reader could
   not tell which one to type in (owner's phone, 02.10.2026 23:13). The save wears the app's primary
   role now (`.action-btn`, the accent pill); all this rule does is keep it a pill rather than let
   the column stretch it edge to edge, and give the preview the same round frame the card's own
   picture has. */
.chat-info-edit .chat-info-pic {
  width: 96px;
  height: 96px;
  border-radius: 50%;
  object-fit: cover;
}

.chat-info-edit .chat-info-save {
  align-self: flex-start;
}

.chat-info-link {
  max-width: 100%;
  overflow-wrap: anywhere;
}

.chat-info-qr svg {
  width: 180px;
  height: auto;
}

.chat-pins,
.chat-pins-mine {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 2px;
  width: 100%;
  padding: 6px 10px;
  border: 0;
  border-left: 3px solid var(--accent);
  background: var(--bg-secondary);
  color: var(--text-primary);
  text-align: left;
  cursor: pointer;
}

.chat-pins-label {
  color: var(--accent);
  font-size: 12px;
}

/* the reader's own pins in a group: the same bar, told apart by its label («Закреплено 1 из 2»),
   not by a colour — the two claims differ in who made them, not in kind */
.chat-pins-mine .chat-pins-label {
  color: var(--text-secondary);
}

.chat-pins-text {
  max-width: 100%;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
  font-size: 13px;
}

/* The bar and its door to the whole list (#1232). A row, so the bar keeps the width it had and the
   door stands beside it: the two are two acts, and the row is what stands before the feed. The bar
   takes the room (`flex: 1 1 auto` over its own `width: 100%`) because the reader's common act is
   walking the pins; the door is as wide as its glyph needs and no wider. */
.chat-pins-row {
  display: flex;
  align-items: stretch;
  gap: 4px;
  width: 100%;
}

.chat-pins-row .chat-pins,
.chat-pins-row .chat-pins-mine {
  flex: 1 1 auto;
  width: auto;
  min-width: 0;
}

/* Two acts beside the bar, one shape: the door opens the whole list, the cross puts the bar itself
   away (the pin stays — the owner's reference, §3). Same width and same glyph box, because they are
   peer actions on one row and a reader should not have to learn two sizes for them. */
.chat-pins-door,
.chat-pins-hide {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 30px;
  padding: 0;
  border: 0;
  border-left: 1px solid var(--border-color);
  background: var(--bg-secondary);
  color: var(--text-secondary);
  cursor: pointer;
}

.chat-pins-door:hover,
.chat-pins-hide:hover {
  background: var(--bg-hover);
  color: var(--text-primary);
}

.chat-pins-door .icon,
.chat-pins-hide .icon {
  width: 18px;
  height: 18px;
}

/* The list the door opens: the conversation's own block, as the group card and the member list are.
   OLD ABOVE NEW — the order the history itself reads in, and not the newest-first rule of the media
   and file lists (a pin is a place in the conversation, not a recent thing). One strip per pin, and
   the claims are told apart by the label inside (which names the author) rather than by a colour a
   glance has to learn — the same rule the two bars follow. */
/* A list taller than the room it is given (measured 10.10: fifteen marks put the last row at 943px
   while the body's own frame ends at 786 — off the screen and past the window, with NOTHING in the
   chain scrolling: `chat-pins-rows`, the screen and the body all answered 0 to a scrollTop they were
   asked to take). The screen is a flex item of the dialog body's column, so it shrinks to the room
   that is left and the ROWS scroll inside it — the head and the act at the foot stay put, because a
   reader reaching the end of a long list is not the reader who is about to empty it.
   `flex: 0 1 auto` and not `1`: a list of two pins must not grow and take the conversation's room. */
.chat-pins-screen {
  display: flex;
  flex-direction: column;
  flex: 0 1 auto;
  min-height: 0;
  gap: 8px;
  padding: 8px 0;
  margin-bottom: 8px;
  border-bottom: 1px solid var(--border-color);
}

.chat-pins-head {
  display: flex;
  align-items: center;
  gap: 8px;
}

.chat-pins-title {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
  font-size: 16px;
  font-weight: 600;
}

.chat-pins-count {
  flex: none;
  color: var(--text-secondary);
  font-size: 12px;
}

.chat-pins-back {
  flex: none;
}

/* The rows carry the overflow, and only they: `flex: 1 1 auto` + `min-height: 0` is what lets the box
   take the room the screen has left and scroll inside it, instead of growing the screen past the frame
   it stands in. */
.chat-pins-rows {
  display: flex;
  flex-direction: column;
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  gap: 4px;
}

/* a row of the list is a LINE: the walk and the one act that takes the mark down, side by side. The
   act is a separate button BESIDE the row, not a control inside it — a nested button is invalid
   markup, and a row that unpins on the same press that walks is a hair-trigger under a finger that
   was aiming at the message. */
.chat-pins-line {
  display: flex;
  align-items: stretch;
  gap: 4px;
}

.chat-pins-item {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 2px;
  flex: 1 1 auto;
  min-width: 0;
  padding: 6px 10px;
  border: 0;
  border-left: 3px solid var(--accent);
  background: var(--bg-secondary);
  color: var(--text-primary);
  text-align: left;
  cursor: pointer;
}

.chat-pins-item:hover {
  background: var(--bg-hover);
}

/* taking one mark down, beside the row it belongs to. Quiet until looked at, and the danger colour
   only on hover — the act is small and its confirmation is the fact that it is one row, not a range. */
.chat-pins-item-unpin {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 34px;
  padding: 0;
  border: 0;
  background: var(--bg-secondary);
  color: var(--text-secondary);
  cursor: pointer;
}

.chat-pins-item-unpin:hover {
  background: var(--bg-hover);
  color: var(--danger);
}

.chat-pins-item-unpin .icon {
  width: 16px;
  height: 16px;
}

/* the reader's own marks: the bars' own distinction, carried into the list */
.chat-pins-item[data-kind="mine"] {
  border-left-color: var(--text-secondary);
}

.chat-pins-item-top {
  display: flex;
  align-items: baseline;
  gap: 6px;
  flex-wrap: wrap;
  width: 100%;
}

.chat-pins-item-author,
.chat-pins-item-when {
  color: var(--text-secondary);
  font-size: 12px;
}

.chat-pins-item-text {
  font-size: 13px;
}

.chat-pins-item-arrow {
  margin-left: auto;
  color: var(--text-secondary);
}

.chat-pins-item-arrow .icon {
  width: 16px;
  height: 16px;
}

.chat-pins-none {
  margin: 0;
  color: var(--text-secondary);
  font-size: 13px;
}

/* the list's one act, at its foot and away from the rows it takes down. It wears the danger colour
   because it cannot be undone and there is nothing to restore from — the confirmation says the
   number, and the colour says the same thing to a reader who has not read the number yet. */
.chat-pins-unpin-all {
  align-self: flex-start;
  color: var(--danger);
}

/* IM-32 (#1143): picking messages. A round tick beside every line while the phase is on, and the
   bar over the feed that acts on them. The tick is a real element and not a ::before, because the
   check inside it is the sprite's own `i-check` — a drawn glyph is a glyph the theme can recolour. */
.chat-picking .chat-line { cursor: pointer; }

.chat-pick {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 22px;
  height: 22px;
  padding: 0;
  margin-top: 6px;
  border-radius: 50%;
  border: 2px solid var(--text-secondary);
  background: var(--bg-primary);
  color: transparent;
  cursor: pointer;
}

.chat-pick.on {
  border-color: var(--accent);
  background: var(--accent);
  color: var(--text-on-primary);
}

.chat-pick .icon { width: 14px; height: 14px; }

.chat-pick-bar {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  padding: 4px 0 8px;
  border-bottom: 1px solid var(--border-color);
}

.chat-pick-count {
  flex: 1;
  padding: 0;
  border: 0;
  background: none;
  color: var(--text-secondary);
  font-size: 13px;
  text-align: left;
  cursor: default;
}

.chat-pick-act {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  min-width: 0;
  padding: 6px 12px;
  border: 1px solid var(--border-color);
  border-radius: 8px;
  background: var(--bg-primary);
  color: var(--text-primary);
  font-size: 13px;
  cursor: pointer;
}

.chat-pick-act:disabled { opacity: .5; cursor: default; }

/* The ✕ is the shared round icon button, and the label inside the act only needs to not be the
   thing that decides the button's height — the trash glyph beside it does. */
.chat-pick-x { flex: none; }

.chat-pick-label { line-height: 1.2; }

/* Two acts and the count share one bar on a phone, so neither act may hold more width than it needs:
   the count's `flex: 1` is the room they take it from, and a label left without any is cut instead of
   pushing the destructive act off the edge — where a thumb would not find it. */
.chat-pick-label { line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

.chat-line-flash .chat-bubble {
  outline: 2px solid var(--accent);
}

/* #995: the armed reply over the field — what the next line will answer, and the ✕ that drops it */
.chat-reply {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 10px;
  border: 1px solid var(--border-color);
  border-left: 3px solid var(--accent);
  border-radius: 6px;
  background: var(--bg-secondary);
  color: var(--text-primary);
}

.chat-reply-label {
  flex: none;
  color: var(--accent);
  font-size: 12px;
}

.chat-reply-text {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
  font-size: 13px;
}

.chat-reply-x {
  flex: none;
}

/* #6620 a scheduled line is drawn as one of our own: a clock stands where the tick would */
.chat-held-when svg {
  width: 12px;
  height: 12px;
  margin-left: 3px;
  vertical-align: -1px;
}

/* #6620 the scheduled messages of a chat: another screen in the same rows — the head row holds
 * «back» and the title, the feed row the lines (it is a .chat-feed), the field stays below */
.chat-held-head {
  flex: 1;
  display: flex;
  align-items: center;
  gap: 8px;
  min-width: 0;
}

.chat-held-back,
.chat-held-btn {
  flex: none;
}

.chat-held-title {
  font-weight: 600;
}

.chat-held-item {
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
  cursor: pointer;
}

/* §7.11.4: the armed rewrite over the field — the same bar as the armed reply, saying which words
 * are about to be replaced. Its own class and not the reply's: the two are armed independently (a
 * line can be answered and another rewritten), and each draw removes only its own bar. */
.chat-editing {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 10px;
  border: 1px solid var(--border-color);
  border-left: 3px solid var(--accent);
  border-radius: 6px;
  background: var(--bg-secondary);
  color: var(--text-primary);
}

.chat-editing-label {
  flex: none;
  color: var(--accent);
  font-size: 12px;
}

.chat-editing-text {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
  font-size: 13px;
}

.chat-editing-x {
  flex: none;
}

/* §7.11.4: the mark on a line that was rewritten. It is the READER's, not the log's — the wire
 * carries no version — and it is a real button, so the versions behind it are reachable by keyboard. */
.chat-edited-mark {
  flex: none;
  margin-left: 6px;
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  opacity: .75;
  font: inherit;
  font-size: 11px;
  font-style: italic;
  cursor: pointer;
}

.chat-edited-mark:hover { opacity: 1; }

/* §7.11.4, reactions: the answers the log put up on a line, under its words and inside the bubble —
 * `.chat-line` is a flex ROW (avatar beside bubble), so a chip row appended to it would stand beside
 * the message rather than under it. The count travels in the chip's own text, and who put it there is
 * its title, so a reaction with the same glyph twice is one chip. */
.chat-reacts {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
  margin-top: 2px;
}

.chat-react-chip {
  flex: none;
  border: 1px solid var(--border-color);
  border-radius: 10px;
  padding: 0 6px;
  /* the SECOND surface, because the chip stands on a bubble — which is itself `--bg-primary` when it
   * is incoming and `--bg-active` when it is the reader's own, so a chip painted `--bg-primary` would
   * be invisible on half the lines it appears on */
  background: var(--bg-secondary);
  color: inherit;
  font: inherit;
  font-size: 12px;
  line-height: 18px;
  cursor: pointer;
}

/* the reader's OWN answer, marked as the log's own press marks it: a member cannot tell his own
 * presses from another's by the glyph, and the chip is the one place the difference is visible */
.chat-react-chip.is-mine {
  border-color: var(--accent);
  color: var(--accent);
}

/* §7.11.10, read status: «5 из 8» under the reader's own last line, and the member list it opens.
 * Inside the bubble, like the answers above and for their reason — `.chat-line` is a flex ROW, so a
 * row appended to it would stand beside the message rather than under it. */
.chat-seen {
  display: flex;
  flex-direction: column;
  gap: 4px;
  margin-top: 2px;
}

.chat-seen-count {
  align-self: flex-start;
  padding: 0;
  border: 0;
  background: none;
  color: var(--text-secondary);
  font: inherit;
  font-size: 11px;
  cursor: pointer;
}

.chat-seen-count:hover { color: var(--accent); }

.chat-seen-list {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding-left: 8px;
  border-left: 2px solid var(--border-color);
}

.chat-seen-row {
  display: flex;
  gap: 8px;
  font-size: 12px;
  color: var(--text-secondary);
}

/* the rows the number above was taken over: a member whose mark reaches the line is named in the
 * accent, one whose does not stays plain — the list answers the very question the count asks */
.chat-seen-row.is-read .chat-seen-who { color: var(--accent); }

.chat-seen-who {
  flex: none;
  max-width: 40%;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

.chat-seen-at { flex: none; }

/* §7.11.11, open polls: the card under the question, inside the bubble like the answers above and
 * for their reason — `.chat-line` is a flex ROW, so a card appended to it would stand beside the
 * message rather than under it. */
/* §7.15: what one reader's press drew over one link. A surface of its own (it stands on a bubble,
 * which already carries `--bg-primary` or `--bg-active`), so it carries its own padding at every
 * width, per the house rule for surfaces. The words are the page's, drawn as TEXT — the card has no
 * markup inside it by construction. */
.chat-preview {
  align-self: stretch;
  margin-top: 6px;
  padding: 6px 8px;
  border: 1px solid var(--border-color);
  border-radius: 8px;
  background: var(--bg-secondary);
  color: var(--text-primary);
  font-size: 13px;
  line-height: 18px;
}

.chat-preview[data-url] { cursor: pointer; }

.chat-preview-site {
  display: block;
  color: var(--text-secondary);
  font-size: 12px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.chat-preview-title {
  display: block;
  font-weight: 600;
  word-break: break-word;
}

.chat-preview-text {
  display: block;
  color: var(--text-secondary);
  word-break: break-word;
}

.chat-preview.is-loading,
.chat-preview.is-refused {
  color: var(--text-secondary);
  font-style: italic;
}

.chat-poll {
  display: flex;
  flex-direction: column;
  gap: 2px;
  margin-top: 6px;
  /* The bar under every row is read against a length that does not change from poll to poll, and a
   * bubble is as wide as the words in it — so the card asks for the bar's own 200px rather than
   * letting «half of 120px» stand where the reader was promised a fixed track. The `2px` is the ROW's
   * and not the track's: the bar is positioned against the row's PADDING box (the containing block of
   * an absolutely positioned child), and the row wears a 1px border on each side, so 200px of track
   * is 202px of card. Asking for 200 here would hand the reader a 198px track and call it 200 — the
   * measurement that found this is the one in `tests/tools/ui-group-accept.mjs` §20. The bar's own
   * `max-width: 100%` still clips it on a screen narrower than that, which is the one case where a
   * share cannot be drawn to scale. */
  min-width: calc(200px + 2px);
}

/* One offered answer. A control and not a row: pressing it is how a member answers, and it is the
 * SAME control for a single-answer poll and a multiple one — what the poll takes is its own field
 * and is said in the foot below, rather than by two kinds of row a reader has to tell apart. */
.chat-poll-opt {
  position: relative;
  display: flex;
  align-items: center;
  gap: 6px;
  overflow: hidden;
  border: 1px solid var(--border-color);
  border-radius: 8px;
  padding: 3px 8px;
  /* the SECOND surface, because the card stands on a bubble — which is itself `--bg-primary` when
   * it is incoming and `--bg-active` when it is the reader's own (see the chips above) */
  background: var(--bg-secondary);
  color: inherit;
  font: inherit;
  font-size: 13px;
  line-height: 18px;
  text-align: left;
  cursor: pointer;
}

/* How much of the chat stands for this answer, drawn as a bar of a FIXED length rather than as a
 * share of the row: the row's width is the bubble's, and the bubble is as wide as the words in it —
 * so a share of it would measure a different length in every poll, and a poll of two short answers
 * against one of two long ones would not be the same picture (owner, 12.10: «прогресс бар в 200
 * пикселей кто сколько % голосов получил»). 200px is the whole of it — a full answer fills the bar
 * and nothing ever outgrows it — and the row's own edge clips it where the bubble is narrower. The
 * ground is drawn under the fill for the reason a meter has one: the bar is read against what is not
 * yet filled, and a lone sliver says «some» rather than «which part of the whole». */
.chat-poll-bar {
  position: absolute;
  left: 0;
  bottom: 0;
  width: 200px;
  max-width: 100%;
  height: 3px;
  background: var(--bg-active);
  pointer-events: none;
}

.chat-poll-fill {
  display: block;
  height: 100%;
  background: var(--accent);
}

.chat-poll-n {
  position: relative;
  flex: none;
  min-width: 1.2em;
  color: var(--text-secondary);
}

.chat-poll-text {
  position: relative;
  flex: 1;
  word-break: break-word;
}

/* The share in words, beside the number: the bar is read at a glance, and a glance answers «more»
   where the reader is asking «how much» (owner, 12.10). Tabular figures, so the column of
   percentages does not jitter as votes arrive while he is looking at it. */
.chat-poll-pct {
  position: relative;
  flex: none;
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
}

/* The reader's OWN answer, with a TICK and not only a colour: the colour is the one a link wears,
   and «не видно куда я проголосовал» was his own complaint about it (owner, 12.10). */
.chat-poll-tick {
  position: relative;
  flex: none;
  display: inline-flex;
  align-items: center;
  color: var(--accent-text);
}

/* A poll that takes several answers is ANSWERED ONCE and not as it is picked: the reader marks what
   he stands for and says so with this button, which is what makes «не менять ответ» possible for
   such a poll — a press that sent itself would have to mean either «add» or «replace», and both
   rewrite an answer already given (owner, 12.10). A poll that takes one answer needs no button: its
   press is the answer, and the second press is the one the reader is refused. */
.chat-poll-send { align-self: flex-start; }

/* the reader's OWN answer, marked as the log's own press marks it (see `.chat-react-chip.is-mine`) */
.chat-poll-opt.is-mine {
  border-color: var(--accent);
  color: var(--accent-text);
}

.chat-poll-foot {
  font-size: 11px;
  color: var(--text-secondary);
}

/* A message whose `poll` is not the shape §7.11.11 names: the words above stay words, and the card
 * SAYS there is no card. A card that silently failed to appear would read as a poll nobody has
 * answered yet, which is the one thing its author did not say. */
.chat-poll-bad {
  font-size: 12px;
  color: var(--warning-text);
}

/* The form the dialog shows for a new poll (§7.11.11): the question, the answers offered, and the
 * offer to add more. Its fields take the dialog's own field styling — they stand in the same
 * dialog, and a second look for a box in the same slot would be this client's second opinion. */
.chat-poll-form {
  display: flex;
  flex-direction: column;
  gap: 8px;
  width: 100%;
  text-align: left;
}

.chat-poll-rows {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

/* the way to a longer poll: the reader asks for the row rather than being handed ten boxes, and the
 * button goes when ten have been asked for (`sync` in `chatPollForm`) — the shape is §7.11.11's */
.chat-poll-add { align-self: flex-start; }

/* "More than one answer may be picked": the offer is the poll's own field, so it is a control beside
 * the rows rather than a second kind of row (`.checkbox-label` carries the row itself) */
.chat-poll-multi { flex: none; }

/* §7.11.4: the rule about answers, in the «Управление» window — what the chat takes, and for its
 * owner or an admin the form that changes it. */
.chat-manage-reacts { margin-top: 10px; }

/* the rule's two controls sit on the block's own left edge rather than stretching to the window's,
   as every button in this panel does */
.chat-reacts-edit,
.chat-reacts-save { align-self: flex-start; }

/* §7.11.4: what the members actually hold, under the rule in the same block — the figure an owner
 * decides the list by. Wrapped like `.chat-reacts`, because a chat can stand on more kinds than fit
 * on one line and the row must spill onto the next rather than scroll (the rule of this panel). */
.chat-reacts-tally { margin-top: 6px; }

.chat-reacts-tally-row {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
  margin-top: 2px;
}

/* NOT `.chat-react-chip`: a chip is a control that answers a line, this only says how many already
 * did — a borrowed class would also lend its press, and a window row that looks pressable and is not
 * is worse than a plain one. */
.chat-reacts-tally-item {
  flex: none;
  border: 1px solid var(--border-color);
  border-radius: 10px;
  padding: 0 6px;
  background: var(--bg-secondary);
  font-size: 12px;
  line-height: 18px;
}

.chat-reacts-edit-form {
  display: flex;
  flex-direction: column;
  gap: 6px;
  margin-top: 6px;
}

.chat-reacts-edit-form label {
  display: flex;
  align-items: center;
  gap: 6px;
  color: var(--text-secondary);
}

/* §7.11.4: the showcase the writer picks the list FROM — the composer's vocabulary drawn beside the
 * field rather than typed into it. A BLOCK in the form and not a popover: the field it edits stands
 * right under it, and the two are read together. The grid scrolls vertically inside a bounded box
 * for the reason the composer panel's does — the window it is drawn in has no height to give. */
.chat-reacts-pick {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.chat-reacts-pick-hint {
  margin: 0;
  font-size: 12px;
  color: var(--text-secondary);
}

/* the tabs WRAP rather than scroll sideways: seven glyphs fit one line at any width this client is
 * read at, and a row that wraps has no children left to shrink (the same reason `.chat-emoji-tabs`
 * wraps — `tests/ui-layout.js` reads a sideways-scrolling row from the markup, which a row built by
 * script is not in). */
.chat-reacts-pick-tabs {
  display: flex;
  flex-wrap: wrap;
  gap: 2px;
}

.chat-reacts-pick-tab {
  flex: none;
  padding: 0;
  border: 0;
  background: none;
  font-size: 20px;
  line-height: 1;
  cursor: pointer;
  border-radius: 8px;
}

.chat-reacts-pick-tab[aria-pressed="true"],
.chat-reacts-pick-tab:hover { background: var(--bg-hover); }

/* a pack's tab is a NAME where the standard ones are one picture each, cut here rather than pushing
 * the other tabs onto a second line */
.chat-reacts-pick-pack {
  font-size: 13px;
  line-height: 1.9;
  padding: 0 6px;
  max-width: 12em;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.chat-reacts-pick-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(40px, 1fr));
  gap: 2px;
  max-height: 132px;
  overflow-y: auto;
}

.chat-reacts-pick-cell {
  padding: 0;
  border: 0;
  background: none;
  font-size: 22px;
  line-height: 1;
  cursor: pointer;
  border-radius: 8px;
}

.chat-reacts-pick-cell:hover { background: var(--bg-hover); }

/* WHAT IS ALREADY IN THE LIST, marked as nothing in the composer panel needs to be: that grid adds a
 * character to a line and forgets it, while this one is a SET the writer is building, and a cell he
 * has already chosen must be told apart from a free one at a glance. The mark is the pressed state
 * itself and not a second opinion kept beside it — the field is the one source of truth. */
.chat-reacts-pick-cell[aria-pressed="true"] {
  background: var(--bg-active);
  outline: 1px solid var(--accent);
}

.chat-reacts-pick-note {
  margin: 0;
  font-size: 12px;
  color: var(--text-secondary);
}

/* §7.11.12: the rule about the packs a chat DRAWS, in the «Управление» window — the addresses it
 * offers, and for its owner or an admin the form that changes them. Drawn after §7.11.4's answers
 * and for the same reason: what a chat draws is part of what it IS. */
.chat-manage-packs { margin-top: 10px; }

/* the addresses as the rule carries them: one row each, an account and a manifest hash. A row is a
 * FACT about the chat and not an act, so it is a plain row and not a chip — nothing here is pressable
 * (the same reason `.chat-reacts-tally-item` is not `.chat-react-chip`). */
.chat-packs-list { margin-top: 6px; }

.chat-packs-row {
  font-size: 12px;
  line-height: 18px;
  color: var(--text-secondary);
  word-break: break-all;
}

/* the rule's two controls sit on the block's own left edge rather than stretching to the window's,
   as every button in this panel does */
.chat-packs-edit,
.chat-packs-save { align-self: flex-start; }

.chat-packs-edit-form {
  display: flex;
  flex-direction: column;
  gap: 6px;
  margin-top: 6px;
}

/* the box carries long hashes, so it WRAPS them rather than scrolling sideways, and it starts on the
   label's own line: `align-items: flex-start` so the caption sits above the box, not level with the
   middle of a four-row field. The first label is a single checkbox and is unaffected by either. */
.chat-packs-edit-form label {
  display: flex;
  align-items: flex-start;
  gap: 6px;
  color: var(--text-secondary);
}

.chat-packs-edit-form textarea {
  flex: 1;
  min-width: 0;
  font-family: monospace;
  font-size: 12px;
  resize: vertical;
}

/* #995: the quote inside a bubble; a press walks to the line it answers */
.chat-quote {
  margin-bottom: 4px;
  padding-left: 6px;
  border-left: 3px solid var(--accent);
  cursor: pointer;
}

.chat-quote-author {
  display: block;
  color: var(--accent);
  font-size: 12px;
}

.chat-quote-text {
  display: -webkit-box;
  overflow: hidden;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  font-size: 13px;
  color: var(--text-secondary);
}

/* #1149: the source of a forwarded line — the author's face and the chat it was taken from, drawn
   above the words as Telegram draws it. The accent bar is the same one a quote wears, so a bubble
   reads its own parts at a glance: a quote answers, a card says where this came from. */
.chat-fwd {
  display: flex;
  align-items: center;
  gap: 6px;
  margin-bottom: 4px;
  padding-left: 6px;
  border-left: 3px solid var(--accent);
}

.chat-fwd-say {
  display: flex;
  flex-direction: column;
  min-width: 0;
}

.chat-fwd-author {
  color: var(--accent);
  font-size: 12px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.chat-fwd-from {
  color: var(--text-secondary);
  font-size: 12px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* #997: what the READER established about the record the card carries — and nothing else, because
   the card never claimed to be verified. The mark is ADDED by `chatFwdCheck` and never assumed, and
   there is exactly ONE mark: the key the signature recovers to is listed by the chain for the named
   author, which takes the colour a confirmed fact wears and a tick. Every other outcome — no record,
   an anonymous card, a chain that could not be reached, or a key the chain does not list — leaves
   the card exactly as it looked before this rule existed, because none of them can tell an honest
   past (keys rotated after signing) from a forged one: an unverifiable past is skipped, not accused,
   and a danger mark there would put a false accusation on a real person's words. The glyph rides the
   card's own flex row, so no markup carries it and the bar keeps the quote's accent width. */
.chat-fwd-checked {
  border-left-color: var(--success);
}

.chat-fwd-checked::after {
  content: '✓';
  color: var(--success-text);
  font-size: 12px;
}

.chat-find {
  flex: 1;
  min-width: 0;
}

.chat-find-btn {
  margin-left: auto;
}

.chat-day {
  align-self: center;
  margin: 8px auto;
  padding: 2px 10px;
  border-radius: 10px;
  background: var(--bg-secondary);
  color: var(--text-secondary);
  font-size: 12px;
  width: fit-content;
}

@media (min-width: 769px) {
  /* wide: a row of rail, list and dialog, and the rail is a column again */
  .chats-layout {
    flex-direction: row;
  }

  .chats-rail {
    flex-direction: column;
    align-items: stretch;
    width: 64px;
    padding: 6px 0;
    border-right: 1px solid var(--border-color);
    border-bottom: 0;
  }

  .chats-rail-tabs {
    flex-direction: column;
    flex-wrap: nowrap;
    gap: 0;
    padding: 0;
    min-height: 0;
    overflow: auto;
  }

  .chats-rail-tab {
    flex-direction: column;
    gap: 2px;
    min-height: 56px;
    padding: 6px 2px;
  }

  .chats-rail-count {
    position: absolute;
    top: 2px;
    right: 2px;
  }

  .chats-rail-menu .wall-more-menu {
    left: 100%;
    right: auto;
    top: 0;
  }

  .chats-list-pane {
    width: 320px;
    flex: none;
    border-right: 1px solid var(--border-color);
  }

  .chats-dialog-pane,
  .chats-open .chats-dialog-pane {
    display: flex;
  }

  .chats-open .chats-list-pane {
    display: block;
  }

  .chats-open .chats-rail {
    display: flex;
  }

  .chats-back {
    display: none;
  }

  /* Neither pane arrives here: both stand side by side and the switch is a `display` on a column
     that never moves. A slide of a box that did not travel is motion for its own sake, and it is the
     one thing a reader on a wide screen would see as the app twitching. */
  .chats-list-pane,
  .chats-dialog-pane {
    animation: none;
  }
}

/* IM plan (#945): the add-a-contact row above the list. */
.contact-add {
  display: flex;
  gap: 8px;
  margin: 0 0 12px;
}

.contact-add input {
  flex: 1;
  min-width: 0;
}
/* §7.11.3 service lines of a private group: a request to enter and the rekey that admits */
.chat-service {
  align-self: center;
  margin: 6px 0;
  padding: 4px 10px;
  border-radius: 12px;
  background: var(--bg-secondary);
  color: var(--text-secondary);
  font-size: 13px;
  text-align: center;
}
.chat-service .chat-admit { margin-left: 6px; }
.chat-join { align-self: flex-start; margin-top: 8px; }
.chat-service.is-dim { opacity: .6; }
/* §7.11.6: lines before the entry, read with granted keys — set apart from the live feed. */
.chat-history { border-bottom: 1px dashed var(--border-color); margin-bottom: 8px; padding-bottom: 8px; }
.chat-service .chat-history-show { margin-left: 6px; }
.chat-service.is-suspect { color: var(--danger); }
/* §7.11.7: the owner's acts sit in the head; a closed chat ends in a panel instead of the composer. */
/* the chat's «⋯» sits at the right end of the head, like a wall card's */
.chat-more { margin-left: auto; }
/* the hub's half of the list search: one head over the found open groups */
.chat-global { border-top: 1px solid var(--border-color); }
.chat-global-head { padding: 8px 12px 4px; font-size: 13px; color: var(--text-secondary); }
/* §7.11.4 quiet_joins: an admin's switch hides the arrivals of a popular open group */
.chat-feed.is-quiet-joins .chat-join-line { display: none; }
.chat-info-quiet { display: flex; align-items: center; gap: 6px; }
.chat-base-perm { display: flex; flex-direction: column; gap: 6px; padding: 12px 0; }
.chat-base-perm-save { align-self: flex-start; margin-top: 6px; }
/* §7.11.15 an open group read before entry: the composer's place holds the one way in */
.chat-join-bar { display: flex; justify-content: center; padding: 10px 0; border-top: 1px solid var(--border-color); }
.chat-mute-bar { padding: 12px; text-align: center; color: var(--text-secondary); border-top: 1px solid var(--border-color); }
.chat-ended { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 12px 0; border-top: 1px solid var(--border-color); }
.chat-banned { border-top: 0; padding-top: 40px; }
.chat-members { display: flex; flex-direction: column; gap: 6px; padding: 8px 0; border-bottom: 1px solid var(--border-color); }
.chat-member { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.chat-member.is-dim { opacity: .6; }

/* A member's face: the same two forms the account rows wear (`paintFaceInto`), one size up from the
   menu row — this list is the screen's own content and not a dropdown. The box is NOT clipped and
   the picture is rounded by its own rule, exactly as the account list does it: a hidden overflow
   would cut any layer opened inside the face, and `tests/ui-layers.js` makes every such clipping
   argue itself. */
.chat-member-face {
  width: 28px;
  height: 28px;
  border-radius: 50%;
  flex-shrink: 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-size: 11px;
  font-weight: 600;
}

/* What the chat has shown, browsed one kind at a time (owner 30.09 14:28). The strip WRAPS rather
   than scrolling sideways: a scrolling row must have children that refuse to shrink (see the
   `ui/layout` guard — that pair is how the file-type tabs were quietly squeezed to nothing), and
   that guard reads its children out of the MARKUP while this row is drawn by the client. Wrapping
   needs nobody's cooperation, and a second line of tabs costs the card one row. `.hidden` carries
   `!important`, so a closed pane beats the grid/flex of its own rule. */
.chat-info-tabs {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  width: 100%;
  max-width: 360px;
}

.chat-info-tab {
  flex: 0 0 auto;
  padding: 6px 10px;
  border: 1px solid var(--border-color);
  border-radius: 6px;
  background: var(--bg-secondary);
  color: var(--text-secondary);
  font-size: 12px;
  white-space: nowrap;
  cursor: pointer;
}

.chat-info-tab.active {
  background: var(--bg-hover);
  color: var(--text-primary);
  font-weight: 600;
}

/* «Управление» — the group's own window (owner 30.09 14:29: «Управление» = окно «Редактировать
   группу»). It is drawn above the feed like the members list, but unlike the card it is a surface of
   its own: it carries a background, so it carries its own padding at EVERY width (the surface rule),
   and the two acts that end the chat sit at its bottom with the red one last, as Telegram puts them
   at the bottom of that same scroll. `.chat-manage-acts` keeps the class the feed toggles
   (`.chat-owner-acts`), so a hand-over that arrives while the window stands leaves it here too;
   `.hidden` carries `!important`, so the hidden rule beats this flex column. */
.chat-manage {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 10px;
  margin-bottom: 8px;
  border: 1px solid var(--border-color);
  border-radius: 6px;
  background: var(--bg-secondary);
}

.chat-manage-title {
  font-size: 15px;
  font-weight: 600;
}

.chat-manage-kind {
  color: var(--text-secondary);
  font-size: 12px;
}

.chat-manage-edit {
  width: 100%;
}

.chat-manage-acts {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

/* «Права (N/M)» (owner 30.09 14:29): what THIS reader may do, named. The names are chips on a
   wrapping row — the fraction above them counts exactly these, and a reader whose set is empty gets
   the fraction and no row. Chips wrap rather than scroll for the same reason the tab strip moves. */
.chat-manage-rights { display: flex; flex-direction: column; gap: 4px; }
.chat-manage-rightrow { display: flex; flex-wrap: wrap; gap: 4px; }
.chat-manage-right { font-size: 12px; color: var(--text-secondary); border: 1px solid var(--border-color); border-radius: 4px; padding: 0 4px; }

/* «Ссылки-приглашения» (owner 30.09 14:29): the group's ONE address and the rule entering by it. The
   address is a `<code>` and wraps rather than scrolls — a link is read, and a link cut by its pane is
   a link nobody can check — with the copy button beside it on the same row and dropping to its own
   line when the pane is narrow. The rule is a hint under the row, laid out by the window. */
.chat-manage-link { display: flex; flex-direction: column; gap: 4px; }
.chat-manage-linkrow { display: flex; align-items: flex-start; gap: 6px; flex-wrap: wrap; }
.chat-manage-linkrow code { flex: 1 1 220px; min-width: 0; word-break: break-all; font-size: 12px; color: var(--text-secondary); }

/* «Администраторы (N)» (owner 30.09 14:29): who runs the group, read from the last roles line. The
   rows stack the way the members list's do — a face, a name and what the account holds — and the
   window a row opens is `.chat-rights`, the same form the members list opens, drawn under the row it
   is about. This block is a column of its own and the window around it owns the gap, exactly as the
   history block below does. */
.chat-manage-admins {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.chat-manage-admin {
  display: flex;
  align-items: center;
  gap: 6px;
  flex-wrap: wrap;
  font-size: 12px;
  color: var(--text-secondary);
}

/* «Последние действия»: the group's service lines folded into one list (owner 30.09 14:29). Drawn
   from the feed, so it wears the feed's own marks — a refused rotation stays red here rather than
   reading as a settled fact. Deliberately NOT given a scroll of its own: this window already lives
   inside the dialog body, which scrolls, and a second box nested in the first is a trap for a thumb
   on a phone. The list is capped for the same reason (see `CHAT_ACTIONS_MAX`). */
.chat-manage-history {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

/* the list is only a column of rows: the block above owns the gap between the header and the first
   row, and this one stacks the rows themselves (they carry the separators, not the gaps) */
.chat-history-list {
  display: flex;
  flex-direction: column;
}

.chat-manage-history-row {
  color: var(--text-secondary);
  font-size: 12px;
  line-height: 1.35;
  padding: 3px 0;
  border-top: 1px solid var(--border-color);
}

.chat-manage-history-row:first-child { border-top: 0; }
.chat-manage-history-row.is-dim { opacity: .6; }
.chat-manage-history-row.is-suspect { color: var(--danger); }

.chat-info-panes {
  width: 100%;
  max-width: 360px;
}

.chat-info-links {
  display: flex;
  flex-direction: column;
  gap: 4px;
  max-width: 100%;
}

/* One link row: the address taking the room that is left, and — where the message it came from is known
   — the door back to that message beside it. Two controls on one line, for the reason the file row
   gives once below: opening the link leaves the client, walking to the message stays in it. */
.chat-info-linkrow {
  display: flex;
  align-items: baseline;
  gap: 8px;
  overflow-wrap: anywhere;
}

.chat-info-linkrow .chat-info-linkopen {
  flex: 1 1 auto;
  min-width: 0;
}

/* The door back to the message a material came from (§2.27). Quiet until looked at, the size of the
   pins list's arrow so one mark reads the same in both places, and given a box of its own rather than
   folded into the row's act — the row beside it leaves the client. */
.chat-info-goto {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  padding: 0 2px;
  border: 0;
  background: none;
  color: var(--text-secondary);
  cursor: pointer;
}

.chat-info-goto:hover {
  color: var(--accent-text);
}

.chat-info-goto .icon {
  width: 16px;
  height: 16px;
}

/* A file row: the type's icon, the name taking the room that is left, and the size and day at the
   end — with the door back to the message beside them where the row knows it. The name is the only
   part allowed to shrink, and it wraps rather than pushing the meta off the card — the size and the
   day are what tell two rows of the same name apart. */
.chat-info-filerow {
  display: flex;
  align-items: baseline;
  gap: 8px;
}

.chat-info-fileopen {
  display: flex;
  align-items: baseline;
  gap: 8px;
  flex: 1 1 auto;
  min-width: 0;
  color: var(--accent-text);
  text-decoration: none;
}

.chat-info-fileopen:hover {
  text-decoration: none;
}

/* A file the paywall holds: the row keeps its facts but loses the accent that marks something you can
   open, and takes no pointer — the only act left on the line is the walk beside it. Muted rather than
   hidden, because a room that dropped these rows would be saying the conversation never carried them. */
.chat-info-fileopen.is-locked {
  color: var(--text-secondary);
  cursor: default;
}

.chat-info-fileopen .chat-info-fileicon {
  flex: 0 0 auto;
  font-size: 14px;
}

.chat-info-fileopen .chat-info-filename {
  flex: 1 1 auto;
  min-width: 0;
  overflow-wrap: anywhere;
}

.chat-info-fileopen .chat-info-filemeta {
  flex: 0 0 auto;
  color: var(--text-secondary);
  font-size: 12px;
}

.chat-info-pics {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 4px;
  max-width: 100%;
}

.chat-info-picrow img {
  width: 100%;
  aspect-ratio: 1;
  object-fit: cover;
  display: block;
}

/* The door the cap owes (see `chatRoomFill`): the room drew what fits a card and keeps the rest
   behind a press. It stands at the END of the rows rather than beside the tab, because the reader
   meets it after the last row he was given — the place where the question "and the others?" is
   asked. Quiet like the walk arrow: this is not the room's main act. Inside the pictures grid it
   takes the whole line, so it reads as a row of the room and not as a thirty-first tile. */
.chat-info-more {
  display: block;
  width: 100%;
  margin-top: 6px;
  padding: 6px 4px;
  border: 0;
  background: none;
  color: var(--text-secondary);
  font-size: 12px;
  text-align: left;
  cursor: pointer;
}

.chat-info-pics .chat-info-more {
  grid-column: 1 / -1;
}

.chat-info-more:hover {
  color: var(--accent-text);
}

/* §5.4 of the owner's reference: the word a conversation this device CUT owes its card. It stands
   with the rooms and under them, and it is drawn even when not one room opened — the case it exists
   for is an empty card, whose emptiness would otherwise read as "nothing was ever sent". Quiet like
   the door above, and narrower than the card's body copy: it is a caveat about the material rather
   than material. Its own class rather than `.hint`, because the single-room shape draws its LABEL as
   a hint inside the same block. */
.chat-info-cut {
  margin: 6px 0 0;
  font-size: 12px;
  line-height: 1.5;
  color: var(--text-secondary);
}

.ctx-menu-parent,
.ctx-menu-check {
  display: flex;
  align-items: center;
  gap: 8px;
}

.ctx-menu-arrow {
  margin-left: auto;
  padding-left: 16px;
  color: var(--text-secondary);
}

.ctx-menu-tick {
  display: inline-flex;
  width: 16px;
  height: 16px;
  flex: none;
  color: var(--accent);
}

.ctx-sub {
  z-index: 1001;
}

/* the two ways into the chats stand together: through the extension, or with the reader's own
   key when the key inside the extension is not the one that sealed the store */
.chat-pipe-unlock,
.chat-pipe-key {
  align-self: flex-start;
  margin: 8px 0;
}

.chat-row-unread {
  margin-left: 6px;
  min-width: 20px;
  padding: 0 6px;
  border-radius: 10px;
  background: var(--accent);
  color: var(--text-on-primary);
  font-size: 12px;
  line-height: 20px;
  text-align: center;
}

.chat-row-unread.chat-row-muted {
  background: var(--text-secondary);
}

.chat-spam-bar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  padding: 8px 0;
}

/* The store bar (#1092) is the spam bar's box — a strip of hint and action at the head of a pane,
   where the hint keeps its own line only if the button cannot fit beside it. It carries its own
   name because what it is about is not spam. */
.chat-store-bar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  padding: 8px 0;
}

.chat-row-pinned {
  position: relative;
  background: var(--bg-secondary);
}

.chat-row-draft {
  color: var(--danger);
}

/* ── Animated emoji (#963, IM-15) ─────────────────────────────────────────────────────────────
   A cell is a fixed box that carries BOTH halves of the reader's experience: the glyph it falls
   back to, and the drawing mounted over it. The box keeps its size in every state, so a drawing
   arriving late, failing to parse or being unloaded far off screen never moves the line it stands
   in — the reader sees the same character either way, and only its animation comes and goes.
   The two sizes are the caller's decision (an emoji-only body, or emoji in a sentence); this file
   only draws them. */
.animated-emoji {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  vertical-align: middle;
  line-height: 1;
  cursor: pointer;
}

.animated-emoji .lottie-container,
.animated-emoji .lottie-container svg {
  width: 100%;
  height: 100%;
}

.animated-emoji .lottie-container video {
  width: 100%;
  height: 100%;
  object-fit: contain;
  display: block;
}

.animated-emoji.large {
  margin: 4px;
}

.animated-emoji:not(.large) {
  margin: 0 2px;
}

.animated-emoji .emoji-glyph {
  display: none;
  line-height: 1;
}

.animated-emoji.fallback .lottie-container {
  display: none;
}

.animated-emoji.fallback .emoji-glyph {
  display: inline-block;
}

/* A body that is nothing but emoji arranges the cells in the middle, the way the same message
   looks in Telegram — the alternative is a row of 96 px drawings pushed to one edge. */
.chat-bubble-text.emoji-only {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  align-items: center;
  gap: 4px;
}

/* Shared service identity and hub switcher (owner, 05.10). The service list is the hub-level
   door; the existing router, history and sign-in gates remain its only dispatcher. */
.service-mark {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 30px;
  height: 30px;
  color: var(--accent-text);
  flex: none;
}
#service-name {
  font-size: 18px;
  white-space: nowrap;
}
#service-switcher {
  position: relative;
  margin-left: auto;
  flex: none;
  z-index: 25;
  background: var(--bg-primary);
}
#btn-services {
  display: inline-flex;
  align-items: center;
  justify-content: center;
}
#service-menu {
  position: absolute;
  top: 100%;
  right: 0;
  width: 280px;
  max-width: calc(100vw - 24px);
  padding: 16px;
  background: var(--bg-primary);
  border: 1px solid var(--border-color);
  border-radius: 12px;
  box-shadow: var(--shadow-lg);
}
#service-menu h2 {
  font-size: 18px;
  margin-bottom: 20px;
  margin-top: 12px;
}
#service-menu nav {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 8px;
}
.service-link {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 6px;
  min-height: 72px;
  padding: 8px;
  border-radius: 8px;
  color: var(--text-primary);
  text-decoration: none;
  text-align: center;
  font-size: 14px;
}
.service-link:hover, .service-link:focus-visible {
  background: var(--bg-secondary);
}

/* The avatar opens one account menu in the header. Its figures are the original nodes and
   therefore stay on the same account-scoped loader as before the move. */
#account-top #account-bar { margin-top: 0; border-top: none; }
#account-top .sidebar-figure { padding: 8px 16px; }
#account-top .account-bar-row { padding: 8px 12px; border-top: 1px solid var(--border-color); }
#account-top #user-name, #account-top .caret { display: none; }
#service-switcher { margin-left: auto; }
@media (min-width: 769px) {
  #account-top #user-name, #account-top .caret { display: inline; }
}

/* The phone drawer exposes the same service routes as the header switcher. */
#mobile-hub-menu { padding: 12px; border-bottom: 1px solid var(--border-color); }
#mobile-hub-menu summary { cursor: pointer; min-height: 44px; display: flex; align-items: center; }
#mobile-hub-menu nav { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; }
@media (min-width: 769px) {
  #mobile-hub-menu { display: none; }
}

/* Service ownership is independent of collapse state and of permission-driven .hidden. */
#folders > [data-nav-service] { display: none; }
#app[data-service="mail"] #folders > [data-nav-service="mail"],
#app[data-service="voice"] #folders > [data-nav-service="voice"],
#app[data-service="blink"] #folders > [data-nav-service="blink"],
#app[data-service="contacts"] #folders > [data-nav-service="contacts"],
#app[data-service="files"] #folders > [data-nav-service="files"],
#app[data-service="paywalls"] #folders > [data-nav-service="paywalls"],
#app[data-service="l2"] #folders > [data-nav-service="l2"],
#app[data-service="hub"] #folders > [data-nav-service="hub"] { display: flex; }
#app[data-service="mail"] #folders > #mail-group,
#app[data-service="voice"] #folders > #voice-group { display: block; }

/* Search keeps its original input and handler, in a shared second header row. */
#service-search-slot { order: 99; flex: 0 0 100%; min-width: 0; padding-top: 8px; }
#service-search-slot > * { width: 100%; margin: 0; }
#service-search-slot input { width: 100%; min-width: 0; }
@media (min-width: 769px) {
  #header:has(#service-search-slot:not(.hidden)) { height: auto; flex-wrap: wrap; }
}

.voice-lookup-form { display: flex; gap: 8px; align-items: center; }
#service-search-slot .voice-lookup-form input { flex: 1; width: 0; }

#app[data-service="voice"] #service-search-slot { display: flex; align-items: center; gap: 8px; }
#app[data-service="voice"] #service-search-slot .voice-lookup-form { flex: 1; min-width: 0; }
#app[data-service="voice"] #service-search-slot #address-acts { flex: none; width: auto; }

/* Search adapters stay offscreen; the address field is the single visible input. */
#service-search-slot { display: none !important; }

/* Keep the first header frame private until identity, fonts and geometry are painted. */
#header.shell-pending > * { visibility: hidden; }
#header.shell-pending::after {
  content: "";
  position: absolute;
  left: 0;
  bottom: 0;
  width: 25%;
  height: 2px;
  background: var(--accent);
  animation: shell-progress 1s ease-in-out infinite alternate;
}
@keyframes shell-progress { from { left: 0; } to { left: 75%; } }
#address-bar { position: static; transform: none; margin: 0 12px; }
#header-left { flex: none; }
#app[data-service="blink"] #header .menu-btn { display: block; }

.service-folders { margin-bottom: 8px; }

#service-menu h2 { display: flex; justify-content: center; align-items: center; }
#service-menu h2 .brand-word { height: 24px; width: auto; color: var(--text-primary); }
.service-link .icon { width: 22px; height: 22px; }
#service-menu nav > .service-link:last-child:nth-child(3n + 1) { grid-column: 2; }

#account-switch-menu #btn-balance { width: 100%; justify-content: center; }
#account-switch-menu .account-bar-row { justify-content: flex-end; }
#account-switch-menu .account-bar-row .account-bar-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  min-height: 36px;
  padding: 6px;
  flex: none;
  color: var(--text-secondary);
}
#account-switch-menu .account-bar-row .icon { width: 24px; height: 24px; }
#account-switch-menu #account-bar { padding: 12px 0; }
#account-switch-menu #account-bar #btn-balance { margin: 0; }

/* Received files keep their service shell. The phone is the base layout. */
#confirm-overlay:has(.service-file-view) { padding: 0; }
#confirm-dialog:has(.service-file-view) {
  border-radius: 0;
  max-width: none;
  max-height: 100dvh;
  min-height: 100dvh;
  padding: 20px;
  padding-bottom: calc(20px + env(safe-area-inset-bottom));
}
.service-file-id { overflow-wrap: anywhere; }
.service-file-view .action-btn { width: 100%; }
@media (min-width: 769px) {
  #confirm-overlay:has(.service-file-view) { padding: 16px; }
  #confirm-dialog:has(.service-file-view) {
    border-radius: 12px;
    max-width: 720px;
    max-height: calc(100dvh - 32px);
    min-height: 0;
    padding: 24px;
  }
}

.service-file-preview {
  min-height: 200px;
  background-size: contain;
  background-position: center;
  background-repeat: no-repeat;
  display: flex;
  align-items: center;
  justify-content: center;
}

.pick-tools { display: flex; gap: 8px; align-items: center; position: relative; }
.pick-tools .pick-search { flex: 1; min-width: 0; }
.pick-sort { flex: none; }
.pick-sort-menu { position: absolute; right: 0; top: 100%; z-index: 2; background: var(--bg-primary); border: 1px solid var(--border-color); padding: 4px; max-height: calc(100dvh - 180px); overflow-y: auto; }
.pick-sort-menu button { display: block; width: 100%; min-height: 44px; background: transparent; color: var(--text-primary); border: 0; text-align: left; padding: 8px; }
.pick-row-main { flex: 1; }
.pick-row-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The right-hand end of a row, and it may carry a MARK as well as words — the lock on a folder he
   may not walk into. One line, so the mark and the sentence it belongs to cannot drift apart. */
.pick-row-note { flex: none; align-self: center; display: inline-flex; align-items: center; gap: 4px; }
.service-file-original { display: block; width: 100%; max-height: 65dvh; object-fit: contain; }

.service-file-text { margin: 0; max-height: 65dvh; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; min-width: 0; }

.service-file-audio { display: block; width: 100%; min-width: 0; }
.service-file-audio.hidden { display: none; }

.service-file-video { display: block; width: 100%; min-width: 0; max-height: 65dvh; object-fit: contain; }
.service-file-video.hidden { display: none; }

@media (min-width: 769px) {
  .contact-row { flex-direction: row; align-items: center; }
}

/* File access stays under the name, leaving extension and size their own column. */
.pick-row-words { flex: 1; min-width: 0; }
.pick-row-words .pick-row-name { display: block; }
.pick-row-detail { display: block; font-size: 12px; color: var(--text-secondary); overflow-wrap: anywhere; }
.pick-row[aria-selected="true"] .pick-row-detail { color: inherit; }

/* The switch between the walk and the flat list, standing beside the sort control. It WEARS the
   Files screen's look (`#files-view-toggle`, declared with `.view-btn` above) but not its class
   name: a reader coming from that screen has already learned the control, while the code must still
   be able to say which of the two organs a `.view-btn.active` belongs to. */
.pick-view-toggle {
  flex: none;
  display: flex;
  gap: 2px;
  background: var(--kbd-bg);
  border-radius: 6px;
  padding: 2px;
}

/* A folder in the list is a DIRECTION, and its row is drawn as the rows beside it are: a name where
   a file's name goes, and on the right what is on the other side — how much is in it, or the lock
   that stops him. Only the lead mark tells the two kinds apart. */
.pick-row-dir .pick-row-lead svg { width: 18px; height: 18px; }

/* WHERE he stands, over the list it describes — the Files screen's own crumb bar, in the same
   classes so it draws itself (`folderCrumbs`), given its own name for the room it is in. */
.pick-crumbs {
  margin-bottom: 8px;
  overflow-wrap: anywhere;
}

.pick-folder-access {
  display: flex;
  flex-direction: column;
  gap: 8px;
  min-width: 0;
}
.pick-folder-access p {
  overflow-wrap: anywhere;
}
.pick-folder-access button,
.pick-folder-access select,
.pick-folder-access input[readonly] {
  min-height: 44px;
  max-width: 100%;
}

/* The extension is an optional secondary sign-in path. */
#login-vizonator .login-btn { background: var(--bg-primary); }
#login-vizonator .login-btn:hover { background: var(--bg-hover); }

/* Icon controls have a circular hover surface, never a raised button box. */
.icon-btn, .icon-btn:hover, .icon-btn:focus-visible { box-shadow: none; }
/* Selection remains available to a keyboard; touch enters it through a hold. */
#files-screen .file-pick { opacity: 0; pointer-events: none; }
#files-screen .picked .file-pick { opacity: 1; pointer-events: auto; }
@media (hover: hover) {
  #files-screen .file-card:hover .file-pick,
  #files-screen .file-row:hover .file-pick,
  #files-screen .folder-row:hover .file-pick,
  #files-screen .file-pick:focus-within { opacity: 1; pointer-events: auto; }
}
@media (hover: none) {
  #files-screen.files-picking .file-pick { opacity: 1; pointer-events: auto; }
}

/* Group sections replace the conversation surface; pulls keep the hidden feed current. */
#chats-dialog-body:not(.chat-group-info-docked)[data-group-view="info"] > :not(.chat-info),
#chats-dialog-body[data-group-view="manage"] > :not(.chat-manage),
#chats-dialog-body[data-group-view="members"] > :not(.chat-members) { display: none; }
#chats-dialog-body[data-group-view="search"] > :not(.chat-feed) { display: none; }
#chats-dialog-body.is-held-view:has(> .chat-held-screen) > :not(.chat-held-screen):not(.chat-send) { display: none; }
#chats-dialog-head.is-held-view:has(> .chat-held-head) > :not(.chat-held-head) { display: none; }
.chat-group-pane-back { align-self: flex-start; }
.chat-add-members { width: 44px; height: 44px; border-radius: 50%; align-self: flex-start; }
.chat-members-empty[hidden] { display: none; }

.chat-members .icon-btn:disabled { opacity: .5; cursor: default; }

.icon-btn.btn-loading .spinner-arcs { width: 20px; height: 20px; color: inherit; }
#drop-zone.drop-picker { z-index: 3100; border-radius: 12px; }

.chat-attached { border-top: 1px solid var(--border-color); padding-top: 8px; margin-top: 6px; }
.chat-attached-label { font-size: 12px; }
.chat-wall-none { color: var(--text-primary); background: var(--bg-secondary); }
.chat-wall-none .icon { width: 24px; height: 24px; }
.chat-wall-settings { position: static; width: fit-content; }
#chats-screen, #chats-screen *, #blink-settings-screen * { scrollbar-width: thin; scrollbar-color: color-mix(in srgb, var(--text-secondary) 45%, transparent) transparent; }
#chats-screen ::-webkit-scrollbar { width: 8px; }
#chats-screen ::-webkit-scrollbar-button { display: none; width: 0; height: 0; }
#chats-screen ::-webkit-scrollbar-track { background: transparent; }
#chats-screen ::-webkit-scrollbar-thumb { background: color-mix(in srgb, var(--text-secondary) 45%, transparent); border-radius: 8px; }

/* Desktop sends with Enter; the busy wheel remains visible during the operation. */
@media (min-width: 769px) and (pointer: fine) {
  .chat-send-btn:not(.btn-loading) { display: none; }
}

/* The latest-message door floats in the feed, above the composer. */
.chats-dialog-body { position: relative; }
.chat-jump-down {
  position: absolute;
  z-index: 4;
  width: 44px;
  height: 44px;
  border-radius: 50%;
  border: 1px solid var(--border-color);
  background: var(--bg-primary);
  color: var(--text-primary);
  box-shadow: var(--shadow-md);
}
.chat-jump-down[hidden] { display: none; }

/* A personal Blink card is a separate desktop column when both columns fit. */
.chat-conversation-row { display: flex; flex: 1; min-height: 0; min-width: 0; }
.chat-conversation-row > .chats-dialog-body { min-width: 0; }
#user-card-overlay.user-card-docked {
  position: static;
  flex: 0 0 320px;
  width: 320px;
  min-height: 0;
  padding: 0;
  background: var(--bg-primary);
  border-left: 1px solid var(--border-color);
  z-index: auto;
  align-items: stretch;
}
#user-card-overlay.user-card-docked #user-card-body {
  flex: 1;
  min-height: 0;
  max-width: none;
  max-height: none;
  margin: 0;
}

/* Message links keep the surrounding text color in both themes and after visiting. */
.chat-bubble-text a, .chat-bubble-text a:visited { color: inherit; text-decoration: underline; }

.chat-attached[hidden] { display: none; }

/* Group information shares the desktop conversation row without hiding its feed or composer. */
.chat-group-card-row > .chat-group-card-docked {
  flex: 0 0 320px;
  width: 320px;
  min-height: 0;
  overflow-y: auto;
  margin: 0;
  padding: 16px;
  box-sizing: border-box;
  border-left: 1px solid var(--border-color);
  background: var(--bg-primary);
}

/* Group information follows the owner's Telegram reference, 14 October. */
.chat-info-avatar { width:96px; height:96px; border-radius:50%; display:grid; place-items:center; background:var(--accent); color:var(--bg-primary); }
.chat-info-avatar .icon { width:48px; height:48px; }

.chat-info .chat-info-link { display:flex; align-items:center; gap:12px; width:100%; padding:16px 0; border-top:1px solid var(--border-color); border-bottom:1px solid var(--border-color); text-align:left; }
.chat-info-link > a { flex:1; min-width:0; overflow-wrap:anywhere; color:inherit; }
.chat-info-link code { font:inherit; }
.chat-info-materials { width:100%; text-align:left; padding:8px 0; border-bottom:1px solid var(--border-color); }
.chat-info-category { display:flex; align-items:center; gap:16px; width:100%; padding:12px 8px; background:transparent; color:inherit; border:0; border-radius:6px; text-align:left; cursor:pointer; }
.chat-info-category:hover { background:var(--bg-hover); }
.chat-info-category .icon { width:22px; height:22px; flex:none; }
.chat-info > .chat-members { width:100%; text-align:left; padding:8px 0; }
.chat-info-members-head { display:flex; align-items:center; gap:8px; width:100%; }
.chat-info-members-head > span { flex:1; }
.chat-info .chat-add-members { position:static; align-self:flex-end; }
.chat-info .chat-member { display:grid; grid-template-columns:48px 1fr auto; gap:3px 10px; padding:10px 0; align-items:center; }
.chat-info .chat-member-face { grid-column:1; grid-row:1 / 3; width:48px; height:48px; }
.chat-info .chat-member .sender-link { grid-column:2; grid-row:1; }
.chat-info .chat-member .presence-dot { display:none; }
.chat-info-member-role { grid-column:3; grid-row:1; font-size:12px; color:var(--text-secondary); }
.chat-info-member-status { grid-column:2 / 4; grid-row:2; font-size:12px; color:var(--text-secondary); }
.chat-info-member-status.is-online { color:var(--accent); }
.chat-info { position:relative; }
.chat-info > .chat-group-pane-back { position:absolute; top:8px; right:8px; }
.chat-info-status { color:var(--text-secondary); }
.chat-info-qr-open { flex:none; }
.chat-info .chat-members-search[hidden] { display:none; }
.chat-info-link-caption { display:block; font-size:13px; color:var(--text-secondary); }
.chat-info-link > a code { color:var(--accent); }
.chat-info-members-head > .icon { width:22px; height:22px; flex:none; }
.chat-info-members-head > span { text-transform:uppercase; font-weight:600; }
.chat-info-materials:empty { display:none; }
.chat-info .chat-member > .hint { grid-column:2 / 4; font-size:12px; }
.chat-info .chat-member .sender-link, .chat-info-link > a { text-decoration:none; }
.chat-info .chat-member .sender-link { color:inherit; font-weight:600; }
.chat-info-member-role:not(:empty) { padding:1px 8px; border-radius:10px; background:var(--bg-hover); color:var(--accent-text); }
