:root {
  color-scheme: dark;
  --message-media-width: 320px;
  --message-card-width: calc(var(--message-media-width) + 28px);
  /* Long-post collapse (v765): max height a feed/channel post body renders at before it is clamped behind the
     "Show full post" button. SINGLE SOURCE — the JS side never hardcodes this; it detects the clamp biting via
     scrollHeight vs clientHeight. */
  --feed-post-collapsed-max-height: 480px;
  /* Shared-post embed (v766): the forwarded-post card clamps tighter than a full feed post. */
  --shared-post-embed-max-height: 220px;
  /* Opaque embed card background — the collapse fade must blend into it, so it cannot be translucent. */
  --shared-post-embed-bg: #10161a;
  --bg: #0b0d0f;
  --panel: #111417;
  --panel-2: #151a1d;
  --panel-3: #1c2225;
  --line: #283136;
  --line-soft: #1e262a;
  --text: #f4f7f5;
  --muted: #96a19c;
  --muted-2: #66726d;
  --accent: #30d5b0;
  --accent-2: #49a6ff;
  --amber: #d7ad57;
  --rose: #e06666;
  --green: var(--accent);
  --shadow: rgba(0, 0, 0, 0.32);
  /* Accent-tinted outline for modal panels so a dark dialog separates from the dark app background (owner). */
  --modal-outline: rgba(48, 213, 176, 0.5);
  /* SINGLE SOURCE OF TRUTH for how wide a dialog card is. There used to be four numbers across eight dialogs —
     440 for the forms, 460 for the quick start, 520 for the install sheet, and whatever the newest one copied —
     and the owner read that straight off the screen as "this one is wider than the rest" (2026-08-07). Widths that
     agree only by someone remembering to type the same digits do not stay in agreement.
     ONE EXCEPTION, and it is a different kind of surface rather than a different taste: the docs reader is a
     document viewer, so it has its own token below. The image lightbox is full-viewport and not a card at all. */
  --modal-card-width: 460px;
  --modal-reader-width: 980px;
  --header-actions-width: 216px;
  /* SINGLE SOURCE OF TRUTH for every header (pane headers, the public absolute action cluster, the
     conversation/post-detail headers): one top offset, one bar height, one control size, one derived
     control inset. Every header rule below MUST derive from these four — never hardcode header geometry. */
  --header-top-offset: 11px;
  --header-height: 50px;
  --header-button-size: 34px;
  --header-button-inset: calc((var(--header-height) - var(--header-button-size)) / 2);
  /* ONE dimming for every disabled control. Five different values had drifted in (0.46 / 0.55 /
     0.58 / 0.62) plus three cursors, so "disabled" looked like a different state depending on which button. */
  --disabled-opacity: 0.46;
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}

* {
  box-sizing: border-box;
}

* {
  scrollbar-width: thin;
  scrollbar-color: rgba(150, 161, 156, 0.42) transparent;
}

*::-webkit-scrollbar {
  width: 8px;
  height: 8px;
}

*::-webkit-scrollbar-track {
  background: transparent;
}

*::-webkit-scrollbar-thumb {
  min-height: 36px;
  border: 2px solid transparent;
  border-radius: 999px;
  background: rgba(150, 161, 156, 0.42);
  background-clip: content-box;
}

*::-webkit-scrollbar-thumb:hover {
  background: rgba(48, 213, 176, 0.56);
  background-clip: content-box;
}

*::-webkit-scrollbar-button,
*::-webkit-scrollbar-corner {
  display: none;
  width: 0;
  height: 0;
}

html,
body {
  /* THE PAGE IS EXACTLY AS TALL AS WHAT CAN BE SEEN. That is the whole of it.
     [MEASURED in the owner's installed app 2026-08-14] With the keyboard up the screen is 894 and the visible
     area is 508, and he could push the interface up by 386 — the keyboard's height, to the pixel. That number is
     not a coincidence and not a gesture: it is the page being 894 tall inside a 508 window, so the browser holds
     386px of slack and hands it to any finger that asks. "The page is longer than the screen by exactly the
     height of the keyboard", as he put it, reading it off the behaviour before any measurement confirmed it.
     His fuller account is the clearest statement of the bug anyone produced: the page size does not change when
     the keyboard appears, the interface redraws itself into the smaller area, and what is left underneath is a
     black band that can be scrolled out from behind the keyboard.
     Two earlier shapes did not remove that slack. `min-height: 100%` + `overflow: hidden` left the page the
     height of the SCREEN, and overflow cannot help anyway — when a field takes focus iOS must bring the caret
     into view and scrolls regardless. `position: fixed` + `inset: 0` took the root out of flow but still
     stretched it to the screen, so the slack survived untouched.
     Sizing it to --app-viewport-height is what actually closes the gap: page height equals window height, slack
     is zero, and there is nothing left to drag. Every scroller in the app is an element inside the shell, so
     nothing is lost. */
  height: var(--app-viewport-height, 100dvh);
  max-height: var(--app-viewport-height, 100dvh);
  overflow: hidden;
  overscroll-behavior: none;
  /* No browser zoom anywhere (owner request): pan tokens only — no pinch-zoom, no double-tap zoom. iOS
     ignores user-scalable=no in the viewport meta, but honors touch-action. The image lightbox is NOT
     affected: its viewport is touch-action:none and its zoom is its own pointer/transform implementation. */
  touch-action: pan-x pan-y;
}

body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  overflow: hidden;
}

button,
input {
  font: inherit;
}

button {
  color: inherit;
}

button:disabled,
.settings-list button:disabled,
.rail-item:disabled,
.action-grid button:disabled,
.icon-button:disabled,
.recipient-submit:disabled {
  opacity: var(--disabled-opacity);
  cursor: not-allowed;
}

.settings-list button:disabled strong {
  color: var(--muted-2);
}

.settings-list button:disabled:hover,
.action-grid button:disabled:hover,
.icon-button:disabled:hover {
  border-color: var(--line);
}

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.app-scroll,
.thread-list,
.public-feed,
.message-strip,
.wallet-scroll-content,
.profile-scroll-content {
  scrollbar-width: none;
  -ms-overflow-style: none;
}

.app-scroll::-webkit-scrollbar,
.thread-list::-webkit-scrollbar,
.public-feed::-webkit-scrollbar,
.message-strip::-webkit-scrollbar,
.wallet-scroll-content::-webkit-scrollbar,
.profile-scroll-content::-webkit-scrollbar {
  display: none;
}

.app-shell {
  display: grid;
  grid-template-columns: 88px minmax(0, 1fr);
  width: 100vw;
  max-width: 100vw;
  min-width: 0;
  height: 100vh;
  height: 100dvh;
  height: var(--app-viewport-height, 100dvh);
  overflow: hidden;
  background:
    linear-gradient(180deg, rgba(48, 213, 176, 0.05), transparent 280px),
    var(--bg);
}

.sidebar {
  display: grid;
  grid-template-rows: auto 1fr;
  gap: 24px;
  padding: 18px 12px;
  border-right: 1px solid var(--line-soft);
  background: rgba(11, 13, 15, 0.92);
}

.brand {
  display: grid;
  justify-items: center;
  gap: 10px;
}

.brand-mark {
  width: 34px;
  height: 34px;
  object-fit: contain;
}

.brand-name {
  display: none;
}

.network-pill {
  display: grid;
  place-items: center;
  gap: 4px;
  font-size: 10px;
  line-height: 1;
  color: var(--muted);
  text-transform: uppercase;
}

.app-version-label {
  min-height: 11px;
  font-size: 9px;
  font-weight: 700;
  color: rgba(48, 213, 176, 0.72);
}

.status-dot {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--green);
  box-shadow: 0 0 0 3px rgba(48, 213, 176, 0.14);
}

.rail {
  display: grid;
  align-content: start;
  gap: 8px;
}

.rail-item,
.icon-button,
.send-button,
.action-grid button,
.settings-list button {
  border: 1px solid transparent;
  cursor: pointer;
}

.rail-item {
  display: grid;
  justify-items: center;
  gap: 6px;
  min-height: 62px;
  padding: 9px 4px;
  border-radius: 8px;
  background: transparent;
  color: var(--muted);
}

.rail-item span:last-child {
  font-size: 11px;
  line-height: 1;
}

.rail-item.is-active {
  background: #17211f;
  border-color: rgba(48, 213, 176, 0.24);
  color: var(--text);
}

/* A tab gated until account activation (e.g. Vault): dimmed but still clearly readable, inert. */
.rail-item.is-locked {
  color: #6b7480;
  cursor: not-allowed;
}

.rail-item.is-locked .icon {
  opacity: 0.5;
}

.rail-vault-balance {
  display: grid;
  grid-template-rows: auto auto auto;
  align-content: center;
  justify-items: center;
  gap: 3px;
  min-height: 64px;
  padding: 5px 2px 10px;
  border-bottom: 1px solid rgba(48, 213, 176, 0.16);
  color: var(--muted);
  line-height: 1.05;
  text-align: center;
  cursor: default;
  user-select: none;
}

/* While the balance is unknown the block keeps its layout slot (min-height reserves the space, so the rail buttons
   never shift when the balance arrives) but its content is invisible — empty space, no spinner. */
.rail-vault-balance.is-pending {
  opacity: 0;
}

.rail-vault-balance span {
  color: var(--accent);
  font-size: 9px;
  font-weight: 900;
  letter-spacing: 0;
  text-transform: uppercase;
}

.rail-vault-balance strong {
  display: block;
  max-width: 100%;
  overflow: hidden;
  color: var(--text);
  /* "GRAM" is a char wider than "TON"; 9px keeps "N.NNNN GRAM" on one line in the narrow rail,
     and auto-height rows + normal wrapping let a longer value drop cleanly to a second line
     instead of overlapping the row below (the fixed 14px rows used to clip/overlap it). */
  font-size: 9px;
  font-weight: 800;
  line-height: 1.05;
  overflow-wrap: normal;
  text-overflow: clip;
  white-space: normal;
  word-break: normal;
}

/* The rail balance shows NOTHING until it is known (no spinner — the global header sync indicator already signals
   activity); when a real balance lands the whole block fades in. */
.rail-vault-balance-reveal {
  animation: rail-balance-fade-in 0.45s ease-out both;
}

@keyframes rail-balance-fade-in {
  from {
    opacity: 0;
    transform: translateY(4px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

/* Kept for the global header sync indicator's spinner. */
@keyframes rail-balance-spin {
  to {
    transform: rotate(360deg);
  }
}

.workspace {
  display: grid;
  grid-template-columns: minmax(280px, clamp(280px, 32vw, 392px)) minmax(0, 1fr);
  grid-template-rows: minmax(0, 1fr);
  width: 100%;
  max-width: 100%;
  min-width: 0;
  height: 100%;
  overflow: hidden;
}

.list-pane,
.content-pane {
  min-width: 0;
  min-height: 0;
  height: 100%;
  max-height: 100%;
}

.list-pane {
  display: grid;
  grid-template-rows: auto auto minmax(0, 1fr);
  gap: 14px;
  padding: var(--header-top-offset) 24px 24px;
  border-right: 1px solid var(--line-soft);
  background: rgba(17, 20, 23, 0.96);
}

.content-pane {
  display: none;
  background:
    linear-gradient(90deg, rgba(48, 213, 176, 0.04), transparent 380px),
    var(--panel);
}

.content-pane.is-active {
  display: grid;
}

.chat-pane {
  grid-template-rows: auto minmax(0, 1fr) auto;
  min-width: 0;
  max-width: 100%;
  overflow: hidden;
}

.pane-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  min-height: var(--header-height);
}

.conversation-header {
  display: grid;
  grid-template-columns: 64px minmax(0, 1fr) max-content;
  align-items: center;
  gap: 16px;
  min-width: 0;
  max-width: 100%;
  overflow: hidden;
}

/* Header action clusters are pinned to the TOP of the header bar at the shared inset (not centered against the
   bar's actual height), so every tab's buttons sit at exactly the same offset even when a device's font scale
   grows a title and stretches one header taller than another. Same formula as the public absolute cluster. */
.pane-header .header-actions,
.conversation-header .header-actions {
  align-self: start;
  margin-top: var(--header-button-inset);
}

.pane-header > div:first-child,
.conversation-title {
  min-width: 0;
}

.pane-title-block {
  min-width: 0;
}

.pane-header h1,
.conversation-title h2,
.profile-pane h2,
.feed-item h2 {
  margin: 0;
  font-weight: 700;
  letter-spacing: 0;
}

.pane-header h1 {
  font-size: 26px;
  line-height: 1.1;
  /* On a narrow screen the public header (title + Feed/Channels toggle + info) can run out of room; let the
     title truncate with an ellipsis so the action buttons stay fully visible instead of overflowing the edge.
     The title-block is min-width: 0, so this only kicks in when space is genuinely tight. */
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.pane-header p,
.conversation-title p,
.profile-pane p,
.feed-item p,
.balance-card small,
.ledger-row small {
  margin: 4px 0 0;
  color: var(--muted);
}

.pane-header p,
.conversation-title p,
.balance-card small,
.ledger-row small {
  font-size: 13px;
}

.conversation-title h2,
.conversation-title p {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  max-width: 100%;
}

.header-actions {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 8px;
  flex: 0 0 auto;
  min-width: max-content;
}

.icon-button,
.send-button {
  display: grid;
  place-items: center;
  flex: 0 0 auto;
  width: 40px;
  height: 40px;
  border-radius: 8px;
  background: rgba(48, 213, 176, 0.06);
  border-color: rgba(48, 213, 176, 0.24);
  color: var(--accent);
}

.icon-button:hover,
.send-button:hover,
.action-grid button:hover,
.settings-list button:hover {
  border-color: rgba(48, 213, 176, 0.42);
}

.icon-button:hover {
  background: rgba(48, 213, 176, 0.1);
}

.icon-button.accent,
.send-button {
  background: var(--accent);
  color: #06100d;
  border-color: transparent;
}

.icon-button:disabled {
  background: var(--panel-3);
  border-color: var(--line);
  color: var(--muted);
}

.icon-button[hidden] {
  display: none;
}

/* ONE rule for EVERY header control (docs/install/discover/edit/identity/back/refresh — anything that lives in a
   header container): the same compact square with the same near-black fill, on desktop AND mobile. Addressed by
   CONTAINER so a new header button can never be missed by a per-class list. Composer/send/search controls are not
   inside header containers and keep the generic .icon-button sizing above. */
.pane-header .icon-button,
.public-header-actions .icon-button,
.conversation-header .icon-button {
  width: var(--header-button-size);
  height: var(--header-button-size);
  background: #0d1012;
}

.pane-header .icon-button:hover,
.public-header-actions .icon-button:hover,
.conversation-header .icon-button:hover {
  background: rgba(48, 213, 176, 0.1);
}

/* A dialog's ✕ is a header control too, and it was the ONLY one still at the generic 40px — visibly bigger than the
   sync button sitting a few pixels away (owner, 2026-08-09). SIZE only: the dialog card keeps its own teal-tinted
   .icon-button fill, which is a separate decision and not what was being compared. Addressed by CONTAINER for the
   same reason as the rule above — every modal header is covered, including ones not written yet. A per-dialog list
   has already failed here once: MODALGEO-03 exists because a new dialog was missed by exactly that shape of rule. */
.modal-backdrop header .icon-button {
  width: var(--header-button-size);
  height: var(--header-button-size);
}

/* Global sync indicator: a green spinner while any sync is running, a green check when synced. Sits in the header
   corner (last in .header-actions) on every tab, so the sync state is visible everywhere — replacing the private
   dialog's text subtitle. Pure CSS (reuses rail-balance-spin); no new icon asset. A real <button>: in check mode
   a tap runs a context-aware "sync now" (syncNowForCurrentScreen); while spinning the tap is a no-op, so the
   pointer affordance shows only when idle. */
.global-sync-indicator {
  width: var(--header-button-size);
  height: var(--header-button-size);
  display: inline-grid;
  place-items: center;
  flex: 0 0 auto;
  padding: 0;
  background: transparent;
  border: 0;
  border-radius: 8px;
}
.global-sync-indicator[data-syncing="false"] {
  cursor: pointer;
}
.global-sync-indicator[data-syncing="false"]:hover {
  background: rgba(48, 213, 176, 0.1);
}
.global-sync-indicator::before {
  content: "";
  box-sizing: border-box;
}
.global-sync-indicator[data-syncing="true"]::before {
  width: 18px;
  height: 18px;
  border-radius: 50%;
  border: 2px solid rgba(48, 213, 176, 0.28);
  border-top-color: var(--accent);
  animation: rail-balance-spin 0.8s linear infinite;
}
.global-sync-indicator[data-syncing="false"]::before {
  width: 7px;
  height: 12px;
  margin-top: -3px;
  border: solid var(--accent);
  border-width: 0 2px 2px 0;
  transform: rotate(45deg);
}

@media (display-mode: standalone) {
  .install-header-button {
    display: none;
  }
}

/* Inside a Telegram Mini App the install/"Add to Home screen" affordance is
   meaningless; hide it reliably regardless of the JS install-state refresh. */
html[data-platho-telegram="true"] .install-header-button {
  display: none !important;
}

.search-row {
  display: grid;
  grid-template-columns: 20px minmax(0, 1fr);
  align-items: center;
  gap: 10px;
  height: 44px;
  padding: 0 12px;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: #0d1012;
}

.public-channel-search-row[hidden] {
  display: none;
}

.search-row input {
  min-width: 0;
  border: 0;
  outline: 0;
  color: var(--text);
  background: transparent;
  font: inherit;
}

.search-row input::placeholder {
  color: var(--muted-2);
}

.thread-list {
  display: grid;
  align-content: start;
  gap: 8px;
  overflow: auto;
  padding-right: 4px;
}

.thread-item {
  display: grid;
  grid-template-columns: 44px minmax(0, 1fr) auto;
  align-items: center;
  gap: 12px;
  min-height: 72px;
  padding: 10px;
  border: 1px solid transparent;
  border-radius: 8px;
  background: transparent;
  text-align: left;
}

.thread-item:hover,
.thread-item.is-selected {
  background: var(--panel-2);
  border-color: var(--line);
}

.thread-item.has-unread {
  border-color: rgba(48, 213, 176, 0.26);
}

.thread-main {
  min-width: 0;
}

.thread-top {
  display: flex;
  align-items: center;
  gap: 8px;
}

.thread-name,
.thread-time,
.thread-preview,
.thread-state {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.thread-name {
  font-size: 15px;
  font-weight: 700;
}

.identity-label-wallet {
  color: var(--text);
}

.identity-label-ton {
  color: #9fd3f2;
}

.identity-label-platho {
  color: #8fdcc8;
}

.thread-time {
  justify-self: end;
  max-width: 72px;
  color: var(--muted);
  font-size: 12px;
}

.thread-side {
  display: grid;
  justify-items: end;
  gap: 7px;
  min-width: 72px;
}

.thread-unread-badge {
  /* Count-only pill ("3", "99+") — the localized word wrapped this badge onto two lines in the narrow side column. */
  min-width: 22px;
  padding: 3px 7px;
  border: 1px solid rgba(48, 213, 176, 0.24);
  border-radius: 999px;
  background: rgba(48, 213, 176, 0.12);
  color: var(--accent);
  font-size: 11px;
  font-weight: 800;
  line-height: 1.1;
  text-align: center;
  white-space: nowrap;
}

.thread-preview {
  margin-top: 4px;
  color: var(--muted);
  font-size: 13px;
}

.thread-state {
  margin-top: 6px;
  color: var(--accent);
  font-size: 11px;
  text-transform: uppercase;
}

/* Same red the failed message meta uses, so the list and the open dialog agree on what "wrong" looks like. */
.thread-state[data-tone="failed"] {
  color: #ff8f8f;
}

.avatar {
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  border-radius: 8px;
  background: #19221f;
  color: var(--accent);
  border: 1px solid rgba(48, 213, 176, 0.25);
  font-weight: 800;
  background-size: cover;
  background-position: center;
}

.avatar.has-image {
  color: transparent;
  text-shadow: none;
}

.avatar.large {
  width: 64px;
  height: 64px;
  font-size: 24px;
}

/* Saved (My notes) thread: pencil icon instead of the wallet-letter fallback. */
.avatar.avatar-saved {
  background: rgba(48, 213, 176, 0.12);
}

.avatar.avatar-saved svg {
  width: 20px;
  height: 20px;
}

.feed-author-row {
  display: flex;
  align-items: center;
  gap: 10px;
  min-width: 0;
}

.feed-avatar {
  width: 30px;
  height: 30px;
  border-radius: 8px;
  flex: 0 0 30px;
  font-size: 12px;
}

.conversation-header {
  min-height: var(--header-height);
  /* Vertical padding 0 so the bar is exactly the shared header height; the chat-pane top padding
     (both breakpoints) lands it at the shared top offset — identical geometry to the pane headers. */
  padding: 0 24px;
  border-bottom: 1px solid var(--line-soft);
  background: rgba(15, 18, 20, 0.94);
}

.conversation-title {
  flex: 1 1 auto;
  max-width: 100%;
  min-width: 0;
  margin-right: auto;
  overflow: hidden;
}

.chat-back-button {
  display: none;
}

.identity-title-label {
  display: block;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.identity-popover {
  position: fixed;
  z-index: 50;
  display: grid;
  gap: 8px;
  width: min(280px, calc(100vw - 24px));
  padding: 12px;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: #111417;
  box-shadow: 0 18px 50px var(--shadow);
  /* A long channel description must never spill off-screen (its bottom was clipped, unreadable): cap the height to the
     viewport and scroll the overflow. The close-on-scroll handler ignores scrolls INSIDE the popover, so this is safe. */
  max-height: calc(var(--app-viewport-height, 100dvh) - 24px);
  overflow-y: auto;
}

.identity-popover[hidden] {
  display: none;
}

.identity-popover-title {
  color: var(--muted);
  font-size: 11px;
  text-transform: uppercase;
}

.identity-variant {
  display: grid;
  gap: 2px;
  min-width: 0;
  padding: 9px 10px;
  border: 1px solid transparent;
  border-radius: 8px;
  background: transparent;
  text-align: left;
  cursor: pointer;
}

.identity-variant:hover,
.identity-variant[aria-checked="true"] {
  border-color: rgba(48, 213, 176, 0.24);
  background: rgba(48, 213, 176, 0.08);
}

.identity-variant strong,
.identity-variant span {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.identity-variant strong {
  font-size: 13px;
}

.identity-variant span {
  color: var(--muted);
  font-size: 11px;
}

.identity-variant-localname {
  display: flex;
  align-items: stretch;
  gap: 6px;
  min-width: 0;
}

.identity-variant-localname > .identity-variant {
  flex: 1 1 auto;
  min-width: 0;
}

.identity-variant-edit {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 38px;
  padding: 0;
  border: 1px solid transparent;
  border-radius: 8px;
  background: transparent;
  color: var(--muted);
  cursor: pointer;
}

.identity-variant-edit:hover {
  border-color: rgba(48, 213, 176, 0.24);
  background: rgba(48, 213, 176, 0.08);
  color: var(--text);
}

.identity-variant-edit svg {
  width: 15px;
  height: 15px;
  display: block;
}

/* Transient "copied" confirmation on the address-row copy button (no global toast exists). */
.identity-variant-edit.identity-variant-copied {
  color: var(--accent);
  border-color: rgba(48, 213, 176, 0.24);
}

.conversation-title h2 {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  max-width: 100%;
  font-size: 20px;
}

.message-strip {
  display: flex;
  flex-direction: column;
  gap: 10px;
  min-height: 0;
  overflow-x: hidden;
  overflow-y: auto;
  overscroll-behavior: contain;
  /* Swipe-to-reply grabs the whole ROW LINE (v647), so the CONTAINER must also surrender horizontal touch
     gestures to JS: without pan-y here, a swipe starting on the strip's empty space beside a bubble lets the
     browser claim the gesture and fire pointercancel mid-drag — the row jerks and snaps back (v648). */
  touch-action: pan-y;
  padding: 22px;
}

.message-strip::before {
  content: "";
  margin-top: auto;
}

.message {
  display: grid;
  gap: 5px;
  flex: 0 0 auto;
  min-width: 0;
  max-width: min(var(--message-card-width), 82%);
  overflow-wrap: anywhere;
}

/* Swipe-to-reply (v646): rows keep native VERTICAL scroll (touch-action pan-y) while horizontal drags feed the
   gesture; transform gives live feedback and springs back through the transition (killed mid-drag).
   position: relative anchors the desktop hover Reply button (v647). */
.message,
.comment-item {
  position: relative;
  touch-action: pan-y;
  transition: transform 0.15s ease-out;
}

/* Desktop reply affordance (v647): visible ONLY on hover-capable devices — touch keeps the clean swipe UX. */
/* ONE CLUSTER, NOT PER-BUTTON OFFSETS.
   Each row action used to place itself absolutely by its ORDINAL — reply at -26px, copy at -52px — which silently
   assumed every earlier button exists. In "My notes" there is no Reply (you do not reply to yourself), so Copy sat
   at -52px with a 26px DEAD GAP between it and the bubble: the pointer left the row on its way there, :hover ended,
   and the button vanished before it could be clicked. [OWNER 2026-08-04: "the copy button is away from the window,
   so it cannot be pressed"]
   A flex cluster has no ordinals to get wrong: whatever buttons a row has are contiguous and flush against the
   bubble, and adding or removing one cannot open a gap. */
/* ANCHORED BY THE EDGE THAT FACES THE BUBBLE, never by a fixed pixel offset. A single 26px button could be placed
   at left:-26px and land flush; a CLUSTER placed that way grows RIGHTWARD and climbs onto the message (owner saw
   the delete icon sitting on the post). Pinning the facing edge to the row's edge — right:100% on the left side,
   left:100% on the right — makes the cluster grow AWAY from the bubble whatever it contains. */
.row-actions {
  display: none;
  position: absolute;
  top: 0;
  left: 100%;
  right: auto;
  /* PADDING, NOT MARGIN, on the side facing the bubble. A margin puts the breathing room OUTSIDE the cluster's box,
     and on an outgoing row — where .message shrinks to its bubble — those few pixels belong to neither the bubble,
     the row, nor the cluster. The pointer crossing them is over nothing, :hover drops, and the buttons vanish just
     as the cursor arrives (owner: "I can't press them, they disappear as soon as I move the mouse there").
     As padding the gap is INSIDE the cluster: the hit area touches the bubble, the eye still sees the space. */
  padding-left: 4px;
  gap: 2px;
  align-items: flex-start;
}

/* Incoming bubbles get the cluster to the RIGHT of the bubble; outgoing (right-edge-hugging) to the LEFT, where
   row-reverse keeps the FIRST button nearest the bubble. */
.message.out .row-actions {
  left: auto;
  right: 100%;
  padding-left: 0;
  padding-right: 4px;   /* the facing side flips with the cluster — see the note above */
  flex-direction: row-reverse;
}

/* Comment cards are wide and their top-right corner is free, so the cluster sits INSIDE them (v651) — the one
   place it is deliberately over the card rather than beside it. */
.comment-item .row-actions {
  top: 2px;
  right: 2px;
  left: auto;
  padding-left: 0;
  flex-direction: row-reverse;
}

@media (hover: hover) {
  .message:hover .row-actions,
  .comment-item:hover .row-actions,
  .row-actions:focus-within {
    display: inline-flex;
  }
}

.row-reply-button {
  display: inline-flex;
  position: static;
  align-items: center;
  justify-content: center;
  width: 26px;
  height: 26px;
  padding: 0;
  border: 1px solid var(--line-soft);
  border-radius: 6px;
  background: rgba(15, 18, 20, 0.92);
  color: var(--accent);
  font-size: 14px;
  line-height: 1;
  cursor: pointer;
}

/* v653 (owner: buttons overlapped the message text): on PRIVATE rows the hover actions sit OUTSIDE the bubble,
   flush against its edge — never over the content. Hover survives because the cluster is a DOM child of the row
   (CSS :hover follows the tree, not the box) and flush placement leaves no dead gap; it renders on hover-capable
   (desktop) viewports only, where the strip has ample side space (bubbles cap at 82%). */
.row-reply-button:hover {
  border-color: var(--accent);
}

/* A failed publish badge doubles as its own Retry control (v649) — the red text is the affordance. */
.public-publish-status--retryable {
  cursor: pointer;
  text-decoration: underline;
  text-underline-offset: 2px;
}

/* Copy (v651) and, in "My notes", Delete — they carry no geometry of their own any more: the cluster places them. */
.row-note-delete-button .icon {
  width: 15px;
  height: 15px;
}

.row-note-delete-button:hover {
  border-color: var(--danger, #ff6b6b);
  color: var(--danger, #ff6b6b);
}

/* Long-press copy confirmation (v651): a quick green "camera flash" over the bubble/row. */
@keyframes copy-flash {
  0% { background-color: rgba(48, 213, 176, 0); }
  18% { background-color: rgba(48, 213, 176, 0.55); }
  100% { background-color: transparent; }
}

.copy-flash {
  animation: copy-flash 0.6s ease-out 1;
}

/* Touch devices: long-press IS the copy action — suppress the native selection callout / context selection on
   the rows (desktop keeps normal text selection; this applies only where hover is unavailable). */
@media (hover: none) {
  .message .bubble,
  .comment-item {
    -webkit-touch-callout: none;
    -webkit-user-select: none;
    user-select: none;
  }
}

/* An image-bearing avatar is tappable (v651) — zoom cursor everywhere it opens the lightbox. */
.avatar.has-image {
  cursor: zoom-in;
}

/* ...but NOT inside an interactive row (private chat list, etc.). The avatar-lightbox click handler
   deliberately yields to the row's own click there (`avatar.closest('button, a, [role="button"]')` →
   opening the chat wins), so the zoom cursor would LIE. Mirror that exact guard here: fall back to the
   row's cursor so the pointer tells the truth (single source of truth = the JS guard's boundary). */
button .avatar.has-image,
a .avatar.has-image,
[role="button"] .avatar.has-image {
  cursor: inherit;
}

/* File attachment chip (v652): name + size, tap to download. Shared by private bubbles and public surfaces. */
.message-file-chip {
  display: flex;
  align-items: center;
  gap: 8px;
  max-width: 100%;
  margin-top: 8px;
  padding: 8px 12px;
  border: 1px solid var(--line-soft);
  border-radius: 8px;
  background: rgba(48, 213, 176, 0.08);
  color: var(--text);
  font-size: 14px;
  cursor: pointer;
  text-align: left;
}

.message-file-chip:hover {
  border-color: var(--accent);
}

.message-file-name {
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
  font-weight: 700;
}

.message-file-size {
  flex: 0 0 auto;
  color: var(--muted);
  font-size: 12px;
}

.message.is-swiping,
.comment-item.is-swiping {
  transition: none;
}

.message.swipe-armed .bubble,
.comment-item.swipe-armed {
  border-color: var(--accent);
}

/* Own bubbles override border-color later in the sheet (.message.out .bubble) at equal specificity — this
   higher-specificity rule keeps the armed ring visible on OUTGOING messages too (v651, owner report). */
.message.out.swipe-armed .bubble {
  border-color: var(--accent);
}

/* Reply quote strip inside a bubble / comment: tappable, accent-edged, one-line snippet. */
.message-reply-quote {
  display: grid;
  gap: 2px;
  padding: 4px 8px;
  margin-bottom: 6px;
  border-left: 2px solid var(--accent);
  border-radius: 4px;
  background: rgba(48, 213, 176, 0.08);
  cursor: pointer;
  min-width: 0;
}

.message-reply-quote-author {
  color: var(--accent);
  font-size: 12px;
  font-weight: 700;
}

.message-reply-quote-snippet {
  color: var(--muted);
  font-size: 12px;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

/* Scroll-to-original highlight (quote tap). */
@keyframes reply-target-flash {
  0% { background-color: rgba(48, 213, 176, 0.22); }
  100% { background-color: transparent; }
}

.reply-target-flash {
  animation: reply-target-flash 1.2s ease-out 1;
}

.message.in {
  align-self: flex-start;
  justify-self: start;
}

.message.out {
  align-self: flex-end;
  justify-self: end;
}

.message.in .bubble {
  justify-self: start;
}

.message.out .bubble {
  justify-self: end;
}

.bubble {
  /* The anchor for the row's hover actions. MEASURED 2026-08-04: an INCOMING .message is a grid that stretches to
     max-width min(348px, 82%) whatever the bubble holds, so a short reply left its row box 240px wider than the
     bubble — actions anchored to the ROW hung that far out in space. Outgoing rows happen to shrink to their
     bubble, which is why only one side ever looked right. The bubble is the only edge that is always the edge the
     user sees, so it is the one the cluster hangs off. */
  position: relative;
  width: fit-content;
  min-width: 0;
  max-width: 100%;
  padding: 11px 13px;
  border-radius: 8px;
  background: var(--panel-3);
  border: 1px solid var(--line);
  line-height: 1.38;
  font-size: 15px;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  word-break: break-word;
}

.message.out .bubble {
  background: #13312b;
  border-color: rgba(48, 213, 176, 0.24);
}

.message.system {
  align-self: center;
  justify-self: center;
  max-width: min(560px, 92%);
}

.message.system .bubble {
  color: var(--muted);
  background: #101416;
  border-style: dashed;
  font-size: 13px;
}

.message-meta {
  color: var(--muted-2);
  font-size: 11px;
}

.message.out .message-meta {
  justify-self: end;
}

.message[data-status="sending"] .bubble {
  opacity: 0.74;
}

.message[data-status="sending"] .message-meta {
  color: var(--accent);
}

.message[data-status="failed"] .message-meta {
  color: #ff8f8f;
}

.message-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-top: 3px;
}

.message.out .message-actions {
  justify-self: end;
}

.message-actions button {
  min-height: 18px;
  padding: 1px 7px;
  border: 1px solid var(--line);
  border-radius: 6px;
  background: rgba(10, 13, 15, 0.72);
  color: var(--text);
  font: inherit;
  font-size: 11px;
  font-weight: 400;
  line-height: 1.2;
  cursor: pointer;
}

.message-actions button:hover {
  border-color: var(--accent);
  color: var(--accent);
}

.message-actions button:disabled {
  opacity: var(--disabled-opacity);
  cursor: not-allowed;
}

.payment-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-top: 10px;
}

.payment-actions button {
  min-height: 34px;
  padding: 0 12px;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: rgba(10, 13, 15, 0.62);
  color: var(--text);
  font: inherit;
  font-size: 13px;
  font-weight: 800;
  cursor: pointer;
}

.payment-actions button:hover {
  border-color: var(--accent);
  color: var(--accent);
}

.message-payment-block .payment-actions {
  flex: 0 0 auto;
  margin-top: 0;
}

.message-payment-block .payment-actions button {
  min-height: 32px;
  white-space: nowrap;
}

/* v769: the composer is a vertical stack (flex column) — context strips, the formatting toolbar, the input row,
   attachment panels, and the cost status stack top-to-bottom in DOM order. This replaced the fragile 5-column
   grid (which needed a mobile 2-row reshuffle + an .is-replying juggle): with fewer buttons on the input row
   (only the anon / comments control + textarea + send), everything just stacks naturally on every width. */
.composer {
  display: flex;
  flex-direction: column;
  gap: 8px;
  min-width: 0;
  max-width: 100%;
  overflow: visible;
  padding: 12px 24px calc(16px + var(--app-safe-area-bottom, env(safe-area-inset-bottom)));
  border-top: 1px solid var(--line-soft);
  background: rgba(15, 18, 20, 0.96);
}

.composer > * {
  min-width: 0;
}

/* The input row: leading control (anon toggle / comments toggle) | textarea | send. align-items:end keeps the
   leading control and send pinned to the textarea's bottom line as it grows. */
.composer-input-row {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: end;
  gap: 10px;
}

/* WYSIWYG contenteditable composer (v771): a single-line-min, 144px-max editable area that renders formatting
   live. The placeholder rides on a ::before gated by the JS is-empty class (a contenteditable has no native
   placeholder). Inline format = CLASS spans (no inline styles per prod CSP style-src 'self'). */
.composer .composer-input {
  position: relative; /* anchor for the absolutely-positioned placeholder below */
  min-width: 0;
  min-height: 44px;
  max-height: 144px;
  padding: 11px 14px;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: #0d1012;
  color: var(--text);
  font: inherit;
  line-height: 1.35;
  overflow-y: auto;
  outline: 0;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  cursor: text;
}

/* The placeholder is absolutely positioned (out of the inline flow) so the caret in the empty editor sits at the
   real start, offset 0 — NOT after the placeholder text. An inline ::before is generated content that precedes
   offset 0, so the browser rendered the caret at the END of the placeholder. Out of flow also keeps the box at one
   line even when the browser leaves a bogus <br> after a manual clear. nowrap+ellipsis degrades a placeholder too
   long for a narrow screen to a single truncated line (absolute content can't grow the box, so wrapping would clip
   the 2nd line mid-word). top/left/right match the input padding. */
.composer .composer-input.is-empty::before {
  content: attr(placeholder);
  position: absolute;
  top: 11px;
  left: 14px;
  right: 14px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  color: var(--muted-2);
  pointer-events: none;
}

.composer .composer-input.is-disabled {
  opacity: 0.55;
  cursor: default;
}

.composer .composer-input .fmt-bold { font-weight: 700; }
.composer .composer-input .fmt-italic { font-style: italic; }
/* A heading is a LINE format, so it renders as the line it will become — same weight and size the recipient sees
   (.msg-heading / .msg-heading-1), not a literal `# ` the writer has to read past. inline-block keeps it inside the
   <br>-separated flow the editor is built on while still taking the line's own type size. */
.composer .composer-input .fmt-heading {
  display: inline-block;
  font-weight: 800;
  font-size: 1.25em;
  line-height: 1.3;
}
.composer .composer-input .fmt-code {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.92em;
  background: rgba(255, 255, 255, 0.08);
  border-radius: 4px;
  padding: 0 3px;
}

/* v773: marker atoms render their REAL content live (image/file/payment/share). Each is one atomic, deletable
   unit — its inner content is display-only (pointer-events off) so a click selects the block, not the media. */
.composer .composer-input .composer-block {
  display: inline-block;
  max-width: 100%;
  vertical-align: bottom;
  margin: 2px 1px;
  /* Every atom (link/image/file/payment/post) selects ATOMICALLY: contenteditable=false stops the caret going inside,
     so a mouse drag includes the whole chip as one unit, and copy/cut serialize its marker. user-select:text (NOT
     'all') — 'all' made the selection FLICKER as the cursor moved over the chip and blocked extending a right-to-left
     selection past it; text keeps the atomic-via-contenteditable behavior without the flicker. */
  -webkit-user-select: text;
  user-select: text;
}

.composer .composer-input .composer-block > * {
  pointer-events: none;
}

.composer .composer-input .composer-block-image {
  display: block;
  max-width: 100%;
  max-height: 200px;
  border-radius: 10px;
  object-fit: contain;
}

.composer .composer-input .composer-block-pill {
  padding: 1px 9px;
  border-radius: 999px;
  background: rgba(48, 213, 176, 0.14);
  border: 1px solid rgba(48, 213, 176, 0.3);
  color: var(--accent);
  font-size: 0.85em;
  white-space: nowrap;
}

/* v778: an inserted link renders live as a link chip (accent + underline), not raw [label](url) markdown. It is
   plain inline text (unlike the block image/file atoms), so it flows on the text BASELINE — the inherited
   inline-block + vertical-align:bottom + vertical margin from .composer-block would drop it below the line (v779). */
.composer .composer-input .composer-block-link {
  /* inline-BLOCK (like the image/file atoms), NOT inline: an inline contenteditable=false chip let the MOUSE hit-test
     between its letters (partial/letter-by-letter selection + the drag got stuck on it); inline-block makes the mouse
     treat it as ONE atomic unit. vertical-align:baseline + margin:0 keep it on the text baseline (v779). */
  display: inline-block;
  vertical-align: baseline;
  margin: 0;
  color: var(--accent);
  text-decoration: underline;
  text-underline-offset: 2px;
  cursor: pointer; /* click to edit the link (opens the dialog pre-filled) */
  /* user-select inherits `text` from .composer-block above (atomic via contenteditable=false + inline-block, no flicker). */
}

.composer .composer-input .composer-block-file {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 4px 10px;
  border-radius: 10px;
  background: rgba(255, 255, 255, 0.05);
  border: 1px solid var(--line);
  font-size: 0.85em;
}

.composer .composer-input .composer-block.is-selected,
.composer .composer-input .composer-block:focus {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

.composer-post-option {
  position: relative;
  justify-self: stretch;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  border: 1px solid rgba(48, 213, 176, 0.26);
  border-radius: 8px;
  background: rgba(48, 213, 176, 0.06);
  color: var(--accent);
  font-size: 13px;
  cursor: pointer;
}

/* Comments toggle reads its state at a glance: ON = accent green, no slash (see
   :has(input:checked) below); OFF = muted with a diagonal slash across the chat icon,
   like the eye/eye-off send-privacy button. */
.composer-post-option::after {
  content: "";
  position: absolute;
  left: 50%;
  top: 50%;
  width: 26px;
  height: 2px;
  border-radius: 2px;
  background: currentColor;
  box-shadow: 0 0 0 2px var(--panel-3);
  transform: translate(-50%, -50%) rotate(-45deg);
  pointer-events: none;
}

.composer-post-option:has(input:checked)::after {
  opacity: 0;
}

.composer-post-option[hidden] {
  display: none;
}

.composer-post-option input {
  position: absolute;
  width: 1px;
  height: 1px;
  opacity: 0;
  pointer-events: none;
}

.composer-post-option:has(input:checked) {
  border-color: rgba(48, 213, 176, 0.42);
  background: rgba(48, 213, 176, 0.1);
  color: var(--accent);
}

.composer-post-option .icon {
  width: 20px;
  height: 20px;
}

.composer-post-option:hover {
  border-color: rgba(48, 213, 176, 0.42);
  background: rgba(48, 213, 176, 0.1);
}

/* Disabled dim: the .is-disabled class is the LOAD-BEARING selector (toggled by refreshPublicSendButtonState
   in lockstep with the checkbox's disabled) — the :has() variant no-ops on the iOS floor (Safari 14). Kept as
   a SEPARATE rule: one unsupported selector in a shared list would drop the whole rule there. */
.composer-post-option.is-disabled {
  cursor: not-allowed;
  opacity: 0.62;
}

.composer-post-option:has(input:disabled) {
  cursor: not-allowed;
  opacity: var(--disabled-opacity);
}

/* Keyboard focus only (matches the eye/send-privacy button): no ring on a plain mouse press. */
.composer-post-option:has(input:focus-visible) {
  outline: 2px solid rgba(48, 213, 176, 0.28);
  outline-offset: 2px;
}

.composer-post-option input {
  accent-color: var(--accent);
}

.composer-context {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  min-height: 32px;
  padding: 6px 8px;
  border: 1px solid var(--line-soft);
  border-radius: 8px;
  background: rgba(48, 213, 176, 0.08);
  color: var(--muted);
  font-size: 12px;
}

.composer-context[hidden] {
  display: none;
}

/* Swipe-to-reply quote strip: in the PRIVATE composer grid it spans the full row ABOVE the input (the public
   composer's strip lives inside .composer-stack and needs no placement). The text ellipsizes on one line. */
.composer > .composer-reply-context {
  grid-column: 1 / -1;
}

.composer-context .composer-reply-text {
  display: block; /* block so text-overflow:ellipsis applies (an inline span ignores it), truncating the snippet */
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
  min-width: 0;
}

/* The text-holder flex child (#…ContextText) must be allowed to SHRINK: a flex item defaults to min-width:auto = its
   nowrap min-content width, so on a narrow screen it kept the FULL snippet width and pushed the Cancel button off the
   edge. min-width:0 lets it shrink and the .composer-reply-text inside ellipsizes. The <button> sibling is unaffected. */
.composer-context > span {
  min-width: 0;
}

.composer-context .composer-reply-author {
  color: var(--accent);
  font-weight: 700;
  margin-right: 6px;
}

.composer-context button {
  min-height: 24px;
  border: 0;
  background: transparent;
  color: var(--accent);
  font: inherit;
  font-weight: 800;
  cursor: pointer;
}

.composer .send-button {
  width: 52px;
  height: 44px;
}

/* The only icon-button left on the input row is the private anonymous / wallet-visibility toggle (the public
   input row's leading control is the comments toggle). Everything else moved to the formatting toolbar (v769). */
.composer .private-anonymous-button {
  width: 44px;
  height: 44px;
  background: rgba(48, 213, 176, 0.06);
  border-color: rgba(48, 213, 176, 0.26);
  color: var(--accent);
}

.composer .private-anonymous-button:hover {
  border-color: rgba(48, 213, 176, 0.42);
  background: rgba(48, 213, 176, 0.1);
}

/* NO :has() here: Safari 14 (iOS floor) drops a whole rule when ANY selector in the list is unsupported. */
.composer .private-anonymous-button:disabled {
  background: var(--panel-3);
  border-color: var(--line);
  color: var(--muted);
}

/* Formatting toolbar (v769): a slide-out row above the input. Shown on textarea focus/click, hidden while typing
   and by the trailing ▼ button. Collapsed = max-height 0 + opacity 0 (animated). No sticky/fixed: the whole
   app-shell tracks the visual viewport, so the toolbar rides above the mobile keyboard as a normal stacked row. */
.composer-toolbar {
  display: flex;
  align-items: center;
  gap: 4px;
  margin: 0;
  max-height: 0;
  overflow: hidden;
  opacity: 0;
  transform: translateY(6px);
  transition: max-height 0.22s ease, opacity 0.18s ease, transform 0.22s ease;
  pointer-events: none;
}

.composer-toolbar.is-open {
  max-height: 48px;
  opacity: 1;
  transform: translateY(0);
  pointer-events: auto;
}

@media (prefers-reduced-motion: reduce) {
  .composer-toolbar { transition: opacity 0.18s ease; transform: none; }
}

/* Maximize (v780): expand the composer to a full-screen overlay for composing long posts. The editor DOM is untouched
   (draft/caret/selection preserved) — only the container is restyled. Height = --app-viewport-height (JS tracks the
   visual viewport), so the send button stays above the mobile keyboard. z-index 30 sits ABOVE the app chrome (<=4)
   but BELOW every dialog/picker/lightbox (>=40), so Insert-link / emoji / attachments still open on top. */
.composer.is-maximized {
  position: fixed;
  /* TOP-ANCHORED, and the keyboard is left to the browser.
     [OWNER 2026-08-14] He worked this out from the behaviour alone: "it thought it was at the top of the screen,
     and you were just yanking it down by hand... every frame it jumped back to where it believed it was, and the
     app shoved it back again." He was right, and the measurement that finally settled it says the correction
     itself was never needed.
     [MEASURED on his iPhone with the keyboard OPEN, which no earlier reading had captured: innerHeight 755,
     visible height 438, keyboard 317, offsetTop 0, scrollY 0, and a fixed strip anchored exactly like this one
     sitting at 0 — precisely where it belongs.] The visible area does NOT slide when the keyboard opens; it only
     shrinks, from the bottom, and a top-anchored fixed element is already in the right place. Every correction
     tried here — bottom pinned to the keyboard gap, top driven from offsetTop — was moving something that was
     not out of position, which is why each one flew upward by the amount it "corrected".
     So the composer needs exactly one thing from us: to be as TALL as the visible area. Nothing else. */
  top: 0;
  left: 0;
  right: 0;
  margin: 0; /* the public composer bleeds to the pane edges with margin: 0 -24px — reset it so left/right:0 = viewport */
  height: var(--app-viewport-height-exact, var(--app-viewport-height, 100dvh)); /* exact (unfloored) so send/restore stay above the keyboard on short viewports */
  z-index: 30;
  max-width: none;
  background: #0f1214; /* opaque (the resting composer bg is translucent — it would show the app behind) */
  padding-top: calc(12px + var(--app-safe-area-top, env(safe-area-inset-top)));
}

/* Maximize/restore (v783): the composer visibly GROWS from its inline footprint to full-screen (and shrinks back) via
   a JS FLIP — composerRunMaximizeFlip measures the inline rect, then transitions a transform that maps full-screen
   back to that rect. So the animation is driven by inline transform/transition set in JS (CSSOM per-property, CSP-ok),
   NOT a keyframe. transform-origin is set to top left by the FLIP. */

/* Dock-toggle (v782, slower v783): when the ↕ button flips the toolbar above<->below the input, it slides + fades back
   in on its new side. --dock-shift (set by JS) points the slide the right way. */
@keyframes composerDockShift {
  from { opacity: 0.15; transform: translateY(var(--dock-shift, 12px)); }
  to { opacity: 1; transform: translateY(0); }
}
.composer-toolbar.is-docking { animation: composerDockShift 0.48s cubic-bezier(0.16, 1, 0.3, 1); }

@media (prefers-reduced-motion: reduce) {
  .composer-toolbar.is-docking { animation: none; }
}

/* The toolbar auto-hides while typing; force it visible when maximized so the restore button is always reachable. */
.composer.is-maximized .composer-toolbar {
  max-height: 48px;
  opacity: 1;
  transform: none;
  pointer-events: auto;
}

/* The input row grows to fill the overlay; the editor stretches to fill it, the leading/send controls stay pinned to
   the bottom edge (as in the resting align-items:end layout). */
.composer.is-maximized .composer-input-row {
  flex: 1 1 auto;
  min-height: 0;
  align-items: stretch;
}
.composer.is-maximized .composer-input-row > .icon-button,
.composer.is-maximized .composer-input-row > .send-button,
.composer.is-maximized .composer-input-row > .composer-post-option {
  align-self: end;
}
.composer.is-maximized .composer-input {
  max-height: none;
  height: auto;
}

/* The button flips to the inward-arrows "restore" glyph while maximized. */
.composer.is-maximized .composer-toolbar-maximize .icon {
  --mask: url("./assets/icons/collapse.svg?v=2");
}

.composer-toolbar-scroll {
  display: flex;
  align-items: center;
  gap: 2px;
  flex: 1;
  min-width: 0;
  overflow-x: auto;
  scrollbar-width: none;
  padding: 4px 2px;
}

.composer-toolbar-scroll::-webkit-scrollbar {
  display: none;
}

/* MEASURED 2026-08-04 at 375 CSS px (iPhone SE2, the narrowest device this project targets): the eight buttons
   are 8x28 = 224px and the seven 2px gaps make 238 — the row fit with 7px to spare, and the Heading button
   returning in v831 pushed it 11px over. It is a horizontal scroller, so nothing was unreachable, but the last
   button sat half-cut on exactly the device with the least room to swipe.
   Closing the gap on narrow screens buys back 14px and touches nothing wider: the buttons keep their 28px hit
   target (they are already separated by their own padding), and the separators keep the groups readable. */
@media (max-width: 380px) {
  .composer-toolbar-scroll { gap: 0; }
}

.composer-toolbar-button {
  display: grid;
  place-items: center;
  flex: 0 0 auto;
  width: 28px;
  height: 28px;
  border: 1px solid transparent;
  border-radius: 7px;
  background: transparent;
  color: var(--muted);
  cursor: pointer;
}

/* hover: hover only — on touch devices :hover STICKS after a tap, so Bold/Italic stayed accent-green after pressing. */
@media (hover: hover) {
  .composer-toolbar-button:hover:not(:disabled) {
    background: rgba(48, 213, 176, 0.1);
    color: var(--accent);
  }
}

.composer-toolbar-button.is-active {
  background: rgba(48, 213, 176, 0.16);
  color: var(--accent);
}

.composer-toolbar-button:disabled {
  color: var(--muted-2);
  cursor: default;
}

.composer-toolbar-button .icon {
  width: 18px;
  height: 18px;
}

.composer-toolbar-sep {
  flex: 0 0 auto;
  width: 1px;
  height: 20px;
  margin: 0 2px;
  background: var(--line);
}

.composer-toolbar-hide,
.composer-toolbar-dock,
.composer-toolbar-maximize {
  display: grid;
  place-items: center;
  flex: 0 0 auto;
  width: 30px;
  height: 30px;
  border: 0;
  border-radius: 7px;
  background: transparent;
  color: var(--muted);
  cursor: pointer;
}

@media (hover: hover) {
  .composer-toolbar-hide:hover,
  .composer-toolbar-dock:hover,
  .composer-toolbar-maximize:hover {
    color: var(--accent);
  }
}

.composer-toolbar-hide .icon,
.composer-toolbar-dock .icon,
.composer-toolbar-maximize .icon {
  width: 18px;
  height: 18px;
}

/* v774: the ↕ dock button flips the formatting toolbar BELOW the input (owner-chosen, persisted). Reorder the
   composer's flex children; reply/share strips stay above the input, attachments + cost stay below the toolbar.
   Default (no class) keeps the toolbar above the input (DOM order) — right for iOS/desktop. */
:root.is-composer-dock-below .composer-context { order: 0; }
:root.is-composer-dock-below .composer-input-row { order: 1; }
:root.is-composer-dock-below .composer-toolbar { order: 2; }
:root.is-composer-dock-below .composer-attachment { order: 3; }
:root.is-composer-dock-below .composer-cost-status { order: 4; }

.composer-post-option.is-disabled {
  background: var(--panel-3);
  border-color: var(--line);
  color: var(--muted);
}

.composer-post-option:has(input:disabled) {
  background: var(--panel-3);
  border-color: var(--line);
  color: var(--muted);
}

.private-anonymous-button[aria-pressed="true"] {
  border-color: rgba(48, 213, 176, 0.42);
  background: rgba(48, 213, 176, 0.1);
  color: var(--accent);
}

/* Emoji picker: a global fixed popover (positioned via JS relative to the trigger, so it survives pane switches),
   with a scrollable grid of self-contained unicode emojis — no external font/library (CSP style-src 'self'). */
.emoji-picker {
  position: fixed;
  z-index: 60;
  width: min(320px, calc(100vw - 24px));
  max-height: min(320px, 48vh);
  overflow-y: auto;
  padding: 8px;
  border: 1px solid var(--line);
  border-radius: 12px;
  background: #0d1012;
  box-shadow: 0 18px 50px var(--shadow);
  -webkit-overflow-scrolling: touch;
}

.emoji-picker[hidden] {
  display: none;
}

.emoji-picker-grid {
  display: grid;
  grid-template-columns: repeat(8, 1fr);
  gap: 2px;
}

.emoji-picker-item {
  display: flex;
  align-items: center;
  justify-content: center;
  aspect-ratio: 1 / 1;
  padding: 0;
  font-size: 22px;
  line-height: 1;
  border: 0;
  border-radius: 8px;
  background: transparent;
  cursor: pointer;
}

.emoji-picker-item:hover,
.emoji-picker-item:focus-visible {
  background: rgba(255, 255, 255, 0.08);
  outline: none;
}

.composer-attachment-controls {
  display: flex;
  align-items: center;
  gap: 8px;
}

.public-composer .composer-attachment-controls:not(:has(> :not([hidden]))) {
  display: none;
}

.composer-attachment-controls select,
.composer-attachment select {
  min-height: 34px;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: #0d1012;
  color: var(--text);
  font: inherit;
  font-size: 13px;
}

.composer-attachment {
  grid-column: 1 / -1;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  min-height: 34px;
  padding: 6px 8px;
  border: 1px solid var(--line-soft);
  border-radius: 8px;
  background: rgba(48, 213, 176, 0.08);
  color: var(--muted);
  font-size: 12px;
}

.composer-attachment.is-list {
  align-items: stretch;
  flex-direction: column;
  max-height: 150px; /* v775: ~3 rows, then scroll — a long attachment list must not eat the whole screen */
  overflow-y: auto;
}

.composer-attachment-row {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
}

.composer-attachment-label {
  min-width: 0;
  flex: 1;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.composer-attachment[hidden] {
  display: none;
}

.composer-attachment button {
  min-height: 26px;
  border: 0;
  background: transparent;
  color: var(--accent);
  font: inherit;
  font-weight: 800;
  cursor: pointer;
}

/* Private chat-bubble image: bounded to the narrow bubble media width (a bubble is not the full pane). This was
   previously SHARED with .feed-image; v747 split them so the public feed can go wider (below) while private
   bubbles keep the bubble cap. */
.message-image {
  display: block;
  width: 100%;
  max-width: var(--message-media-width);
  max-height: 360px;
  margin-top: 8px;
  border: 1px solid var(--line-soft);
  border-radius: 8px;
  object-fit: contain;
  background: #0d1012;
}

/* Public post/comment image (v747, owner ask): span the CONTENT column when the column is no wider than the image
   (fill, downscaling a big image), else sit CENTERED at natural size when the image is narrower than the column —
   never upscaled, never left-stuck. width:auto (no upscale) + max-width:100% (never exceed the content column,
   replacing the old 320px --message-media-width chat-bubble cap that made feed images tiny) + margin-inline:auto
   (center when narrower); height:auto keeps aspect. Verified in-browser: 804x1024 -> natural 805x1025 centered on a
   1111px column; 1600x600 -> 1111px full width; 200x150 -> natural centered; on a 321px mobile column all fill the
   width. max-height 1400px is a scale-safety that bounds a pathological super-tall image (e.g. 300x4000 -> 106x1400)
   without touching normal posts (a 1024px recipe is well under it) — the lightbox still shows the full image.
   object-fit is gone: with auto width/height there is no fixed box to fit into. */
.feed-image {
  display: block;
  width: auto;
  height: auto;
  max-width: 100%;
  max-height: 1400px;
  margin-top: 8px;
  margin-inline: auto;
  border: 1px solid var(--line-soft);
  border-radius: 8px;
  background: #0d1012;
}

.message-text-block,
.feed-block-text {
  min-width: 0;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  word-break: break-word;
}

.message-text-block + .message-text-block,
.message-text-block + .message-image,
.message-image + .message-text-block,
.feed-block-text + .feed-block-text,
.feed-block-text + .feed-image,
.feed-image + .feed-block-text,
.message-payment-block {
  margin-top: 8px;
}

/* Safe message formatting (v769): block-level elements emitted by appendFormattedMessageText into the
   .message-text-block / .feed-block-text container (which stays white-space: pre-wrap, so newlines inside a
   paragraph/quote render). Sizes are deliberately modest — this is chat/feed body text, not a document. */
.msg-block {
  min-width: 0;
  overflow-wrap: anywhere;
  word-break: break-word;
}

.msg-block + .msg-block {
  margin-top: 6px;
}

.msg-heading {
  font-weight: 800;
  line-height: 1.3;
}

.msg-heading-1 { font-size: 1.25em; }
.msg-heading-2 { font-size: 1.12em; }
.msg-heading-3 { font-size: 1em; }

.msg-quote {
  margin: 0;
  padding: 2px 0 2px 12px;
  border-left: 3px solid var(--accent);
  color: var(--muted);
}

.msg-list {
  margin: 0;
  padding-left: 22px;
}

.msg-list li {
  margin: 2px 0;
}

.msg-align-center { text-align: center; }
.msg-align-justify { text-align: justify; }

.msg-code {
  padding: 1px 5px;
  border-radius: 4px;
  background: rgba(150, 161, 156, 0.16);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 0.92em;
}

.message-payment-block {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  width: min(100%, 360px);
  min-height: 44px;
  padding: 8px 10px;
  border: 1px solid rgba(48, 213, 176, 0.34);
  border-radius: 8px;
  background: rgba(48, 213, 176, 0.08);
}

.message-payment-label {
  min-width: 0;
  font-weight: 800;
  overflow-wrap: anywhere;
}

.message-image,
.feed-image[data-full-image-src] {
  cursor: zoom-in;
}

.message-image:hover,
.message-image:focus-visible,
.feed-image[data-full-image-src]:hover,
.feed-image[data-full-image-src]:focus-visible {
  border-color: var(--accent);
  outline: none;
}

.composer-cost-status {
  grid-column: 1 / -1;
  min-width: 0;
  max-width: 100%;
  min-height: 18px;
  /* On a narrow screen the cost/reserve/discount line wraps to many rows and pushes the whole composer tall
     (owner). Cap it at 3 lines (line-height 1.35) and scroll the overflow. Harmless on wide screens where it fits
     in one or two lines. */
  max-height: calc(1.35em * 3);
  overflow-y: auto;
  color: var(--accent);
  font-size: 12px;
  line-height: 1.35;
  overflow-wrap: anywhere;
  white-space: pre-line;
}

.composer-cost-status[data-state="short"] {
  color: var(--accent);
}

.composer-cost-status[data-state="ready"] {
  color: var(--accent);
}

.composer[data-read-only="true"] {
  display: none;
}

.public-composer[hidden] {
  display: none;
}

.public-composer {
  /* Full-bleed: the negative margins span the whole pane (the base .composer max-width:100% would otherwise
     cap the width to the padded pane). Private's composer spans its pane the same way. Layout otherwise
     inherits the base .composer flex column + the shared .composer-input-row grid. */
  margin: 0 -24px -24px;
  max-width: none;
  padding: 12px 24px calc(16px + var(--app-safe-area-bottom, env(safe-area-inset-bottom)));
}

/* Comment mode (post detail screen) hides the "allow comments" toggle — the input row becomes textarea | send
   (no empty leading track where the toggle used to be). */
.public-composer[data-mode="comment"] .composer-input-row {
  grid-template-columns: minmax(0, 1fr) auto;
}

.public-pane,
.wallet-pane,
.profile-pane {
  grid-template-rows: auto minmax(0, 1fr) auto;
  gap: 14px;
  overflow: hidden;
  /* Shared top offset (not 24): every pane's header bar starts at the same y as the conversation/comment
     headers, so nothing jumps when switching tabs. */
  padding: var(--header-top-offset) 24px 24px;
}

.public-pane {
  position: relative;
  grid-template-rows: auto auto minmax(0, 1fr) auto;
  /* Keep the Channels list column identical to the Private dialog-list column (.workspace). */
  --public-list-col: clamp(280px, 32vw, 392px);
}

/* The public action cluster is pinned top-right: absolutely positioned against the pane (.public-pane is
   position:relative) at the SAME derived offset as every in-flow header cluster (top offset + control inset),
   so the buttons sit at an identical y across all tabs by construction. */
.public-pane .public-header-actions {
  position: absolute;
  top: calc(var(--header-top-offset) + var(--header-button-inset));
  right: 24px;
  z-index: 3;
}
/* Pin the (title-only) pane header to row 1, column 1 (the menu is absolutely positioned and out of flow, so it
   neither shares nor disturbs this cell). */
.public-pane .pane-header {
  grid-column: 1;
  grid-row: 1;
}

/* The feed's row is stated rather than left to auto-placement, because the jump-to-newest button has to share it
   (see .public-jump-down-button). Auto-placement would hand the button its own row and push the feed down —
   MEASURED: the pane went from four rows to five and the feed dropped into row 4. */
.public-pane > .public-feed {
  grid-column: 1;
  grid-row: 3;
}

.wallet-pane {
  grid-template-rows: auto minmax(0, 1fr);
  overflow: hidden;
}

/* The two headline balances. They are driven by the SAME refresh that fills the rail corner, so they follow its
   pending rule too: the block keeps its layout slot and stays invisible until a real balance lands (no spinner —
   the header sync indicator already signals activity), then fades in via .rail-vault-balance-reveal. */
.wallet-headline {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: 10px;
}

.wallet-headline.is-pending {
  opacity: 0;
}

.wallet-headline-card {
  display: grid;
  gap: 6px;
  padding: 16px 18px;
  border: 1px solid rgba(48, 213, 176, 0.16);
  border-radius: 14px;
  background: var(--surface, rgba(255, 255, 255, 0.03));
}

.wallet-headline-card span {
  color: var(--accent);
  font-size: 11px;
  font-weight: 900;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}

.wallet-headline-card strong {
  color: var(--text);
  /* Big by request, but it must not push a long value out of the card on a narrow phone: clamp scales it down
     rather than letting "1234.5678 GRAM" overflow or clip. */
  font-size: clamp(20px, 5.2vw, 30px);
  font-weight: 800;
  line-height: 1.1;
  overflow-wrap: anywhere;
}

.profile-pane {
  grid-template-rows: auto minmax(0, 1fr);
  overflow: hidden;
}

.pane-header.inline {
  border-bottom: 0;
  padding-bottom: 0;
}

.wallet-scroll-content,
.profile-scroll-content {
  display: grid;
  align-content: start;
  gap: 14px;
  min-height: 0;
  overflow-y: auto;
  /* overflow-y:auto alone COMPUTES overflow-x to auto too, and iOS Safari lets an auto axis rubber-band
     even with zero horizontal overflow (the "pull the list sideways and it springs back" feel). Pin the x
     axis shut and hand horizontal gestures back to the browser via pan-y — the same cure the chat strip and
     the feed scrollers already use. */
  overflow-x: hidden;
  touch-action: pan-y;
  overscroll-behavior: contain;
  padding-right: 4px;
}

.public-feed,
.ledger,
.settings-list {
  display: grid;
  align-content: start;
  gap: 10px;
}

/* Cap the implicit grid column at the container width. Without this, the single auto column sizes to the
   widest child's max-content (e.g. the RPC-key row: label + input placeholder + "recommended" + Get), which on
   a narrow phone exceeds the viewport and stretches every settings row past the right edge. minmax(0,1fr) forces
   rows to the container width so flexible children (the RPC input, min-width:0) shrink instead of overflowing. */
.settings-list {
  grid-template-columns: minmax(0, 1fr);
}

.public-feed {
  /* Cap the implicit grid column at the container width (same fix as .settings-list above): without it the
     single auto column sizes to its widest child's max-content, so a feed card with a long unbroken token
     (URL / wallet / id) stretches EVERY .feed-item past the right edge on a phone. */
  grid-template-columns: minmax(0, 1fr);
  overflow: auto;
  padding-right: 4px;
}

.public-header-actions {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 8px;
}

.mini-action-button,
.public-jump-down-button {
  min-height: 34px;
  border-radius: 8px;
  color: var(--text);
  font: inherit;
  font-size: 13px;
  font-weight: 800;
  cursor: pointer;
}

.mini-action-button {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 0 12px;
  border: 1px solid var(--line);
  background: rgba(10, 13, 15, 0.62);
}

.mini-action-button[hidden] {
  display: none;
}

/* [OWNER 2026-08-13, screenshot] It sat on top of the composer's formatting toolbar. It was placed by a
   HAND-MEASURED 134px from the bottom of the pane, and nothing about that number can know how tall the composer
   currently is: the toolbar adds 48px when it opens, and the cost line under the field wraps to two or three
   lines depending on the language and the discount text. Any single number is wrong for most of those states.

   So it is no longer measured from the pane at all. The pane is a grid whose third row IS the feed and whose
   fourth is the composer; putting the button in the feed's own cell and pinning it to the bottom of that cell
   makes "just above the composer" true BY CONSTRUCTION, whatever the composer is doing. It shares the cell with
   the feed rather than taking a row of its own, so it overlays the posts exactly as before and the layout is
   untouched.

   Icon only (owner: "leave just the arrow, it is obvious"). The label lives on aria-label/title, so the button
   is still announced — this is a deliberate exception to the labelled-actions rule, for a control whose meaning
   is carried by one of the few genuinely universal glyphs. */
.public-jump-down-button {
  grid-column: 1;
  grid-row: 3;
  align-self: end;
  justify-self: end;
  position: relative;
  margin: 0 8px 12px 0;
  z-index: 4;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 38px;
  min-height: 38px;
  padding: 0;
  border: 1px solid rgba(48, 213, 176, 0.3);
  border-radius: 50%;
  background: rgba(13, 16, 18, 0.92);
  box-shadow: 0 14px 34px var(--shadow);
  backdrop-filter: blur(12px);
}

.public-jump-down-button:hover {
  border-color: rgba(48, 213, 176, 0.58);
  color: var(--accent);
}

.public-jump-down-button[hidden] {
  display: none;
}

.feed-item {
  display: block;
  width: 100%;
  max-width: 100%;
  padding: 18px;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: var(--panel-2);
  color: var(--text);
  text-align: left;
}

/* F2: "show older" affordance at the BOTTOM of the newest-first feed (newest at the top) — reveals another page of
   older posts that the render cap is holding back. */
.feed-show-older {
  display: block;
  width: 100%;
  max-width: 100%;
  padding: 10px 18px;
  margin-bottom: 4px;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: var(--panel-2);
  color: var(--muted);
  font: inherit;
  text-align: center;
  cursor: pointer;
}

.feed-show-older:hover {
  border-color: var(--accent);
  color: var(--text);
}

.public-feed[data-public-mode="feed"] > .feed-item:not(.compact) {
  /* Full-width like the compact channel cards (was capped to image width, which left real posts
     looking narrow/unfinished on desktop). Images inside (.feed-image) fill this content column or center
     at natural size (v747) — no longer the narrow --message-media-width bubble cap. */
  width: 100%;
}

.feed-item.is-unread {
  border-color: rgba(48, 213, 176, 0.42);
}

button.feed-item {
  cursor: pointer;
}

button.feed-item:hover {
  border-color: rgba(48, 213, 176, 0.36);
  background: #171d1f;
}

.feed-item.compact {
  padding: 14px 18px;
}

.feed-meta {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  color: var(--muted);
  font-size: 12px;
  text-transform: uppercase;
}

.feed-item h2 {
  margin-top: 10px;
  font-size: 18px;
}

.feed-item p {
  line-height: 1.45;
  /* Break long unbroken tokens (URLs / wallet addresses / ids) so a plain post body cannot exceed the
     viewport (matches .feed-block-text). The grid-column cap above only helps if children can shrink. */
  overflow-wrap: anywhere;
  word-break: break-word;
}

/* Long-post collapse (v765, feed + channel view): the post body is clamped while the collapsible ROOT (the
   feed <article>, or a shared-post embed since v766) carries .feed-post-collapsible (added by JS only when
   ResizeObserver support exists — no observer, no clamp, so content is never cut with no way to reveal it).
   feedPostClampObserver marks roots whose content the clamp ACTUALLY cuts with .feed-post-overflowing — that
   shows the bottom fade + the "Show full post" button. All rules are DIRECT-CHILD (`>`): a shared-post embed
   nests its own clamp INSIDE a post's clamped body, and a descendant selector would leak the post's clamp onto
   the embed's body (and keep clamping it after the embed expands). The post-detail screen and comments never
   get the wrapper and always render in full. */
.feed-post-collapsible > .feed-post-body {
  position: relative;
  max-height: var(--feed-post-collapsed-max-height);
  overflow: hidden;
}

.feed-post-collapsible.feed-post-overflowing > .feed-post-body::after {
  content: "";
  position: absolute;
  right: 0;
  bottom: 0;
  left: 0;
  height: 72px;
  background: linear-gradient(to bottom, transparent, var(--panel-2));
  pointer-events: none;
}

/* Expand animation (v766): expandClampedBody drives max-height via CSSOM from the clamped height to the full
   height under this transition, then removes the inline value + this class. Reduced motion opts out — the JS
   timer backstop releases the clamp without a transitionend. */
.feed-post-expanding > .feed-post-body {
  overflow: hidden;
  transition: max-height 0.3s ease;
}

@media (prefers-reduced-motion: reduce) {
  .feed-post-expanding > .feed-post-body {
    transition: none;
  }
}

.feed-expand-button {
  display: none;
  width: 100%;
  margin-top: 10px;
  padding: 8px 12px;
  border: 1px solid var(--line-soft);
  border-radius: 8px;
  background: transparent;
  color: var(--accent);
  font: inherit;
  font-size: 14px;
  text-align: center;
  cursor: pointer;
}

.feed-post-overflowing > .feed-expand-button,
.feed-post-expanded > .feed-expand-button {
  display: block;
}

.feed-expand-button:hover {
  border-color: var(--accent);
}

/* Shared-post embed (v766): a forwarded public post inside a private bubble or a public post/comment.
   Header = source channel (tappable, opens the channel view); body reuses the long-post clamp machinery with
   its own tighter max height. */
.shared-post-embed {
  margin-top: 8px;
  padding: 10px 12px;
  border: 1px solid var(--line-soft);
  border-left: 3px solid var(--accent);
  border-radius: 8px;
  background: var(--shared-post-embed-bg);
}

.shared-post-embed-header {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  padding: 0;
  border: 0;
  background: transparent;
  color: var(--text);
  font: inherit;
  text-align: left;
  cursor: pointer;
}

.shared-post-embed-avatar {
  width: 28px;
  height: 28px;
  flex: 0 0 28px;
  font-size: 12px;
}

.shared-post-embed-channel {
  display: grid;
  min-width: 0;
  line-height: 1.25;
}

.shared-post-embed-kind {
  color: var(--muted);
  font-size: 11px;
  text-transform: uppercase;
}

.shared-post-embed-name {
  overflow: hidden;
  color: var(--accent);
  font-size: 14px;
  font-weight: 600;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.shared-post-embed-header:hover .shared-post-embed-name {
  text-decoration: underline;
}

.shared-post-embed-title {
  margin-top: 8px;
  font-size: 15px;
  font-weight: 700;
}

.shared-post-embed-text {
  margin: 6px 0 0;
  color: var(--text);
  font-size: 14px;
}

.shared-post-embed-media-hint {
  display: inline-block;
  margin-top: 6px;
  color: var(--muted);
  font-size: 12px;
}

/* "Show earlier comments" — one page (96 rows, the contract's get_page cap) further back in the thread. Sits above
   the list because that is where the older comments appear. */
.public-detail-earlier-comments {
  display: block;
  width: 100%;
  margin: 4px 0 10px;
  padding: 8px 12px;
  border: 1px solid var(--line-soft);
  border-radius: 8px;
  background: transparent;
  color: var(--accent);
  font: inherit;
  font-size: 13px;
  cursor: pointer;
}

.public-detail-earlier-comments:disabled {
  color: var(--muted);
  cursor: default;
}

/* The private twin of the control above: same affordance, so paging back through a dialog and paging back through
   a comment thread do not look like two different features. It sits inside the scroller above the first row. */
.conversation-earlier-button {
  display: block;
  width: calc(100% - 24px);
  margin: 6px auto 12px;
  padding: 8px 12px;
  border: 1px solid var(--line-soft);
  border-radius: 8px;
  background: transparent;
  color: var(--accent);
  font: inherit;
  font-size: 13px;
  cursor: pointer;
}

.conversation-earlier-button:disabled {
  color: var(--muted);
  cursor: default;
}

/* v797: the referenced post's real image, resolved by entryId from the local cache (no wire copy). */
.shared-post-embed-image {
  display: block;
  margin-top: 8px;
  max-width: 100%;
  max-height: 260px;
  border-radius: 10px;
  object-fit: cover;
}

/* Once the original is read, the card renders the post through the FEED's own body renderer, so its pictures arrive
   as .feed-image — sized for a full-width feed column (max-height 1400px). Inside a quoted card that would tower over
   the message it is quoted in, so keep the embed's own ceiling. */
.shared-post-embed .feed-image {
  max-height: 260px;
}

.shared-post-embed.feed-post-collapsible > .feed-post-body {
  max-height: var(--shared-post-embed-max-height);
}

.shared-post-embed.feed-post-collapsible.feed-post-overflowing > .feed-post-body::after {
  height: 48px;
  background: linear-gradient(to bottom, transparent, var(--shared-post-embed-bg));
}

.shared-post-embed > .feed-expand-button {
  margin-top: 8px;
  padding: 6px 10px;
  font-size: 13px;
}

/* Share-target modal (v766). */
.share-post-dialog .share-post-list {
  display: grid;
  gap: 8px;
  margin-top: 14px;
  max-height: min(50vh, 420px);
  overflow-y: auto;
}

.share-target-row {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  padding: 10px 12px;
  border: 1px solid var(--line-soft);
  border-radius: 8px;
  background: transparent;
  color: var(--text);
  font: inherit;
  text-align: left;
  cursor: pointer;
}

.share-target-row:hover {
  border-color: var(--accent);
}

.share-target-avatar {
  width: 34px;
  height: 34px;
  flex: 0 0 34px;
  font-size: 13px;
}

.share-target-text {
  display: grid;
  min-width: 0;
}

.share-target-name {
  overflow: hidden;
  font-weight: 600;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.share-target-sublabel {
  overflow: hidden;
  color: var(--muted);
  font-size: 12px;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.share-target-empty {
  margin: 12px 0 0;
  color: var(--muted);
}

/* Composer preview modal (v769): renders the draft via the same safe formatting renderer as the live surfaces. */
.composer-preview-dialog .composer-preview-body {
  margin-top: 14px;
  max-height: min(60vh, 520px);
  overflow-y: auto;
  padding: 14px;
  border: 1px solid var(--line-soft);
  border-radius: 8px;
  background: var(--panel-2);
  overflow-wrap: anywhere;
  word-break: break-word;
}

.composer-preview-body .feed-image,
.composer-preview-body .message-image {
  max-width: 100%;
}

.composer-preview-empty {
  margin: 0;
  color: var(--muted);
}

.comment-list {
  display: grid;
  gap: 8px;
  margin-top: 14px;
  padding-top: 12px;
  border-top: 1px solid var(--line-soft);
}

.comment-item {
  padding: 10px 12px;
  border: 1px solid var(--line-soft);
  border-radius: 8px;
  background: rgba(8, 11, 13, 0.42);
}

.comment-item p {
  margin-top: 6px;
  color: var(--text);
  font-size: 14px;
}

/* Public post detail screen (per-post comments). The Public tab is a single pane (no second column like the chat
   list), so the detail slides over the feed within the same pane, toggled by .public-pane[data-post-open]. The
   shared composer stays pinned below. */
.public-post-detail {
  display: none;
  min-height: 0;
}

.public-pane[data-post-open="true"] {
  grid-template-rows: minmax(0, 1fr) auto;
}

.public-pane[data-post-open="true"] > .pane-header,
.public-pane[data-post-open="true"] > .public-header-actions,
.public-pane[data-post-open="true"] > .public-channel-search-row,
.public-pane[data-post-open="true"] > .public-feed,
.public-pane[data-post-open="true"] > .public-jump-down-button {
  display: none;
}

.public-pane[data-post-open="true"] > .public-post-detail {
  display: grid;
  grid-template-rows: auto minmax(0, 1fr);
  min-height: 0;
  overflow: hidden;
  /* Bleed the detail to the pane's SIDE edges (24px gutters) so the header spans the pane full-width; the body
     re-insets its own content with padding. The TOP is NOT bled: the pane's top padding is
     var(--header-top-offset) (11px, the v688 shared header line) — the old -24px top margin dated from a
     24px-padded pane and pushed the header 13px past the pane top, clipping the title. Zero top margin puts
     this header on the exact same y as every other pane header. */
  margin: 0 -24px;
}

/* Discovery panel: mirrors the post-detail overlay (shown over the feed, same bleed-to-edges header). */
.public-discovery {
  display: none;
  min-height: 0;
}

.public-pane[data-discover-open="true"] {
  grid-template-rows: minmax(0, 1fr) auto;
}

.public-pane[data-discover-open="true"] > .pane-header,
.public-pane[data-discover-open="true"] > .public-header-actions,
.public-pane[data-discover-open="true"] > .public-channel-search-row,
.public-pane[data-discover-open="true"] > .public-feed,
.public-pane[data-discover-open="true"] > .public-jump-down-button {
  display: none;
}

.public-pane[data-discover-open="true"] > .public-discovery {
  display: grid;
  grid-template-rows: auto minmax(0, 1fr);
  min-height: 0;
  overflow: hidden;
  /* Side-only bleed for the same reason as .public-post-detail above (zero top margin = shared header line). */
  margin: 0 -24px;
}

/* Channel view: one channel's posts on their own screen — mirrors the post-detail/discovery overlay. */
.public-channel-view {
  display: none;
  min-height: 0;
}

.public-pane[data-channel-open="true"] {
  grid-template-rows: minmax(0, 1fr) auto;
}

.public-pane[data-channel-open="true"] > .pane-header,
.public-pane[data-channel-open="true"] > .public-header-actions,
.public-pane[data-channel-open="true"] > .public-channel-search-row,
.public-pane[data-channel-open="true"] > .public-feed,
.public-pane[data-channel-open="true"] > .public-jump-down-button {
  display: none;
}

.public-pane[data-channel-open="true"] > .public-channel-view {
  display: grid;
  grid-template-rows: auto minmax(0, 1fr);
  min-height: 0;
  overflow: hidden;
  /* Side-only bleed, zero top margin = shared header line (see .public-post-detail above). */
  margin: 0 -24px;
}

/* The post detail (and discovery) stack ON TOP of the channel view: open a post from the channel screen, Back
   returns to it (the channel's data attribute stays set underneath). */
.public-pane[data-post-open="true"] > .public-channel-view,
.public-pane[data-discover-open="true"] > .public-channel-view {
  display: none;
}

/* A foreign channel hides the post composer (you publish to YOUR OWN channel from the feed; commenting happens in
   the post detail, which re-shows the composer in comment mode when stacked on top). The own channel keeps it. */
.public-pane[data-channel-open="true"]:not([data-post-open="true"]):not([data-channel-own="true"]) > .public-composer {
  display: none;
}

/* A post whose author switched comments off shows no composer at all. HIDDEN rather than switched back to post
   mode: the shared composer publishes a NEW ENTRY when it has no comment target, so leaving it visible here would
   turn "reply to this" into "publish to my own channel". Reached via a permalink, which opens the detail screen
   without passing the feed's disabled Comments button (owner, 2026-08-20). */
.public-pane[data-post-open="true"][data-post-comments="off"] > .public-composer {
  display: none;
}

.public-channel-view-body {
  overflow-y: auto;
  padding: 16px 24px 24px;
  display: grid;
  gap: 12px;
  align-content: start;
}

.channel-view-about {
  border-color: rgba(48, 213, 176, 0.24);
}

/* Author avatar/name on feed cards (and discovery card heads) open the channel screen. */
.feed-author-link {
  cursor: pointer;
}

.feed-author-link:hover {
  opacity: 0.85;
}

.public-channel-follow-button[data-followed="true"] {
  color: var(--muted);
}

.public-post-detail-header .avatar .icon {
  width: 20px;
  height: 20px;
}

.public-discovery-body {
  overflow-y: auto;
  padding: 16px 24px 24px;
  display: grid;
  gap: 12px;
  align-content: start;
}

.discovery-tag-filter {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}

.discovery-tag-filter .channel-about-tag {
  cursor: pointer;
  border: 1px solid rgba(48, 213, 176, 0.24);
}

.discovery-tag-filter .channel-about-tag[aria-pressed="true"] {
  background: var(--accent);
  color: #05201a;
}

.discovery-card {
  display: grid;
  gap: 8px;
  padding: 14px;
  border: 1px solid var(--line);
  border-radius: 12px;
  background: rgba(255, 255, 255, 0.02);
}

.discovery-card-head {
  display: flex;
  align-items: center;
  gap: 10px;
  min-width: 0;
}

.discovery-card-name {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-weight: 600;
  color: var(--text);
}

/* The "Display as" chevron sits flush at the right edge of the card head (same 30px icon-button as the feed
   author-row chevron); margin-left:auto pushes it past the truncating name. */
.icon-button.discovery-card-identity {
  flex: 0 0 30px;
  width: 30px;
  height: 30px;
  min-height: 30px;
  padding: 0;
  margin-left: auto;
}

.discovery-card-desc {
  margin: 0;
  color: var(--text);
  font-size: 13px;
  line-height: 1.45;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  /* [OWNER 2026-08-21] The latest post moved in above the description; the description yields the room — three
     lines on the card, the whole text in the channel's own about. */
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* The channel's LATEST POST on a Discover card [OWNER 2026-08-21: "good to see the last post" — "alongside" the
   description]. A quiet plate with an accent rule, a muted "Latest post · when" label, the feed's one-line preview
   text clamped to four lines. While the read is out the same plate carries the label and a muted loading line, so the
   card keeps its height when the post arrives. */
.discovery-card-latest {
  display: grid;
  gap: 4px;
  padding: 8px 12px;
  border-left: 2px solid rgba(48, 213, 176, 0.45);
  border-radius: 0 8px 8px 0;
  background: rgba(255, 255, 255, 0.03);
  min-width: 0;
}

.discovery-card-latest-label {
  color: var(--muted);
  font-size: 12px;
  letter-spacing: 0.01em;
}

.discovery-card-latest-text {
  margin: 0;
  color: var(--text);
  font-size: 14px;
  line-height: 1.45;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  display: -webkit-box;
  -webkit-line-clamp: 4;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.discovery-card-latest.is-loading .discovery-card-latest-text {
  color: var(--muted);
  font-style: italic;
}

.discovery-card-actions {
  display: flex;
  justify-content: flex-end;
  /* [OWNER 2026-08-10] The row had no gap at all, so "Open channel" and "Follow" rendered as one welded slab —
     two destinations that look like a single control. flex-wrap so a narrow phone drops the second plate to its
     own line instead of squeezing both. */
  flex-wrap: wrap;
  gap: 8px;
}

/* The discovery follow button reuses the shared .discovery-cta-action plate ("Add contact" look). Its only extra
   state is the post-click "following…" disabled look. */
.discovery-cta-action:disabled {
  opacity: var(--disabled-opacity);
  cursor: not-allowed;
}

.discovery-status {
  color: var(--muted);
  font-size: 14px;
  text-align: center;
  padding: 24px 12px;
}

/* Call-to-action plates: ONE shared rule set for the channels plate at the top of the public feed AND the
   add-a-contact plate at the top of the private thread list (the labeled replacements for the removed
   search-row "+" buttons). The card itself is passive; the affordance lives on the real action buttons. */
.discovery-cta {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 16px;
  padding: 18px;
  border-color: rgba(48, 213, 176, 0.32);
  background:
    linear-gradient(135deg, rgba(48, 213, 176, 0.1), rgba(48, 213, 176, 0) 62%),
    var(--panel-2);
}

/* display:flex above beats the UA [hidden] rule — restore it (the private plate hides while searching). */
.discovery-cta[hidden] {
  display: none;
}

.discovery-cta-head {
  flex: 1 1 240px;
  min-width: 0;
  display: flex;
  gap: 14px;
  align-items: center;
}

.discovery-cta-icon {
  flex: 0 0 auto;
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  border-radius: 12px;
  background: rgba(48, 213, 176, 0.12);
  border: 1px solid rgba(48, 213, 176, 0.28);
  color: var(--accent);
}

.discovery-cta-icon .icon {
  width: 24px;
  height: 24px;
}

.discovery-cta-text {
  min-width: 0;
}

.discovery-cta h2 {
  margin: 0;
}

.discovery-cta p {
  margin: 4px 0 0;
  color: var(--muted);
}

.discovery-cta-actions {
  flex: 0 0 auto;
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
}

.discovery-cta-action {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  padding: 10px 18px;
  border-radius: 8px;
  background: rgba(48, 213, 176, 0.1);
  border: 1px solid rgba(48, 213, 176, 0.32);
  color: var(--accent);
  font: inherit;
  font-weight: 700;
  font-size: 14px;
  cursor: pointer;
  transition: border-color 0.15s ease, background 0.15s ease;
}

.discovery-cta-action:not(:disabled):hover,
.discovery-cta-action:not(:disabled):focus-visible {
  background: rgba(48, 213, 176, 0.16);
  border-color: rgba(48, 213, 176, 0.5);
  outline: none;
}

/* The detail container bleeds to the pane edges, so this header (filling it) lines up with the private chat header.
   Give it its own column track (back + avatar + title + actions) so it renders the same at every width — the shared
   conversation-header grid assumes the Back button is hidden on desktop (the private two-pane layout), which doesn't
   hold for the single-pane Public tab. The fixed 38px/44px back+avatar tracks + 10px gap MATCH the private mobile
   header exactly so the avatar lines up identically in both. */
.public-pane .public-post-detail-header {
  grid-template-columns: var(--header-button-size) 44px minmax(0, 1fr) max-content;
  gap: 10px;
}

/* Back is display:none on desktop for the private layout; Public has no second pane, so force it visible. */
.public-post-detail-header .chat-back-button {
  display: grid;
}

.public-post-detail-header .avatar {
  width: 44px;
  height: 44px;
}

.public-detail-post-meta {
  margin-bottom: 8px;
}

.public-post-detail-body {
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 14px;
  min-height: 0;
  /* Same as .message-strip: the comment swipe grabs the row LINE, so the scroller itself must hand horizontal
     touch gestures to JS (v648) — else a swipe from the row's padding gets pointercancel'd by native scroll. */
  touch-action: pan-y;
  /* The detail bled to the pane edges, so re-inset the content here (matches the pane's 24px gutter). */
  padding: 16px 24px 0;
}

.public-post-detail-body .comment-list {
  margin-top: 0;
}

.public-detail-comments-heading {
  margin: 4px 0 2px;
  font-size: 13px;
  font-weight: 700;
  color: var(--muted);
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

.public-detail-status {
  margin: 10px 0 0;
  color: var(--muted);
  font-size: 13px;
}

.public-detail-retry {
  margin-left: 6px;
  padding: 2px 8px;
  border: 1px solid var(--line);
  border-radius: 6px;
  background: rgba(10, 13, 15, 0.62);
  color: var(--text);
  font: inherit;
  font-size: 12px;
  font-weight: 700;
  cursor: pointer;
}

.feed-actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: 8px;
  margin-top: 14px;
}

.feed-actions button {
  min-height: 36px;
  padding: 0 13px;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: rgba(10, 13, 15, 0.62);
  color: var(--text);
  font: inherit;
  font-size: 13px;
  font-weight: 800;
  cursor: pointer;
}

.feed-actions button:hover {
  border-color: rgba(48, 213, 176, 0.42);
  color: var(--accent);
}

.feed-actions button:disabled {
  cursor: not-allowed;
  color: var(--muted);
  border-color: var(--line-soft);
  opacity: var(--disabled-opacity);
}

.feed-actions button:disabled:hover {
  color: var(--muted);
  border-color: var(--line-soft);
}

/* The "Display as" chevron in the post's author row. A clean square with no text padding so the 20px chevron
   sits dead-centre (place-items:center from .icon-button), sized down to the compact author row. NO
   margin-left:auto here: flexbox splits the free space EQUALLY between auto margins, so a second auto margin
   opened a wide gap between the [about] and [chevron] buttons — the about button (always present when this
   one is, and first in DOM) is the ONE auto margin, and this one sits flush beside it on the row's 10px gap. */
.icon-button.feed-author-identity {
  flex: 0 0 30px;
  width: 30px;
  height: 30px;
  min-height: 30px;
  padding: 0;
}

/* The channel "about" (description + tags) button: the ONE auto margin in the author row — absorbs the free
   space and pins the [about][chevron] cluster to the right edge. */
.icon-button.feed-author-about {
  margin-left: auto;
  flex: 0 0 30px;
  width: 30px;
  height: 30px;
  min-height: 30px;
  padding: 0;
}

.feed-author-about svg {
  width: 17px;
  height: 17px;
}

/* Channel-description popover body (reuses the shared .identity-popover shell for position + outside-click). */
.channel-about-popover {
  display: grid;
  gap: 8px;
}

.channel-about-description {
  margin: 0;
  color: var(--text);
  font-size: 13px;
  line-height: 1.45;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

.channel-about-empty {
  margin: 0;
  color: var(--muted);
  font-size: 13px;
}

.channel-about-tags {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}

.channel-about-tag {
  padding: 2px 8px;
  border: 1px solid rgba(48, 213, 176, 0.24);
  border-radius: 999px;
  background: rgba(48, 213, 176, 0.08);
  color: var(--accent);
  font-size: 12px;
}

.channel-about-edit {
  /* Green plate CTA (owner: same style as "Add channel") — the plate look comes from .discovery-cta-action; this
     class only adds the popover top spacing. */
  margin-top: 8px;
}

.channel-about-edit:hover {
  border-color: rgba(48, 213, 176, 0.24);
  background: rgba(48, 213, 176, 0.08);
}

.balance-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr));
  gap: 10px;
}

.balance-grid[hidden],
.action-grid[hidden],
.ledger[hidden] {
  display: none;
}

.balance-card {
  min-height: 116px;
  padding: 16px;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: var(--panel-2);
}

.balance-card span {
  color: var(--muted);
  font-size: 12px;
  text-transform: uppercase;
}

.balance-card strong {
  display: block;
  margin-top: 18px;
  font-size: 30px;
  line-height: 1;
}

.balance-card.warn strong {
  color: var(--amber);
}

.vault-pocket-note {
  display: grid;
  gap: 4px;
  margin: 10px 0 0;
  padding: 12px 14px;
  border: 1px solid var(--line-soft);
  border-radius: 8px;
  background: color-mix(in srgb, var(--accent) 7%, var(--panel-2));
  color: var(--muted);
  font-size: 13px;
  line-height: 1.35;
}

.vault-pocket-note strong {
  color: var(--text);
}

.action-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr));
  gap: 10px;
  margin-top: 10px;
}

.action-grid button,
.settings-list button {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 9px;
  min-height: 48px;
  border-radius: 8px;
  background: var(--panel-2);
  border-color: var(--line);
}

/* An author `display:` rule beats the UA `[hidden] { display: none }` regardless of specificity -- author sheets
   win over the user-agent sheet -- so any element given a display here keeps rendering when `hidden` is set.
   Restore native hiding for the elements that toggle that way.

   [OWNER 2026-08-09] .action-summary and .action-footnotes were missing from this list, and the owner saw the
   result: an empty bordered box with min-height 42px sitting between the content and the button in EVERY dialog
   that has no summary -- renderActionSummary sets `hidden = true` and the box stayed. The trap was already
   documented right here for buttons; the two blocks below just never got added. */
.action-grid button[hidden],
.settings-list button[hidden],
.action-summary[hidden],
.action-footnotes[hidden],
/* Found by the completeness gate, not by eye: walletTonGroup.hidden = !plathoWallet, so with no wallet the
   Receive/Send GRAM pair was meant to disappear and did not. */
.wallet-ton-group[hidden],
/* [OWNER 2026-08-10] The FOURTH offender, and the one that mattered most: .discovery-cta-action sets
   display:inline-flex, so the quick-start activation plate stayed on screen with `hidden` set — right under the
   red line saying the wallet has no GRAM for it. Pressing it then reported "Done" for something that never
   happened. My own check had read the .hidden ATTRIBUTE and called it hidden; the attribute was set the whole
   time. Verify with computed display, never with the property. */
.discovery-cta-action[hidden],
/* The FIFTH, and nobody had seen it yet because it needs an NFT with no inline art: `art.hidden = !nft.image` on a
   block with width:100% and aspect-ratio:1 left a full-width grey square where the picture would be. Found by the
   widened gate, not by eye — which is the entire argument for widening it. */
.nft-card-art[hidden] {
  display: none;
}

.ledger-row {
  display: grid;
  grid-template-columns: 12px minmax(0, 1fr) auto;
  align-items: center;
  gap: 12px;
  min-height: 62px;
  padding: 12px;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: var(--panel-2);
}

.ledger-row div {
  min-width: 0;
}

.ledger-row strong,
.ledger-row small {
  display: block;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.ledger-icon {
  width: 10px;
  height: 10px;
  border-radius: 50%;
}

.ledger-icon.green {
  background: var(--green);
}

.ledger-icon.cyan {
  background: var(--accent-2);
}

.ledger-icon.amber {
  background: var(--amber);
}

.identity-block {
  display: flex;
  align-items: center;
  gap: 16px;
  padding: 6px 0 4px;
}

.identity-copy-layout {
  display: grid;
  min-width: 0;
  gap: 4px;
}

.identity-title-row {
  display: grid;
  grid-template-columns: minmax(0, auto) 38px;
  align-items: center;
  gap: 10px;
  min-width: 0;
}

.identity-title-row h2 {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
}

.identity-copy-button {
  justify-self: start;
}

.identity-copy-button:disabled {
  opacity: var(--disabled-opacity);
  cursor: not-allowed;
}

.profile-sections {
  display: grid;
  gap: 18px;
}

.profile-section {
  display: grid;
  gap: 10px;
}

.profile-section h2 {
  color: var(--muted);
  font-size: 12px;
  font-weight: 800;
  text-transform: uppercase;
}

.settings-list button,
.settings-select-row {
  justify-content: space-between;
  padding: 0 16px;
}

/* Buttons default to text-align:center, so a label that wraps to two lines centers -- inconsistent with the
   left-aligned single-line labels. Left-align the label so wrapped lines stay flush-left (the value <strong>
   keeps its own right alignment). */
.settings-list button > span,
.settings-select-row > span {
  text-align: left;
}

.profile-stat-list {
  display: grid;
  gap: 8px;
}

.profile-stat-row {
  display: grid;
  grid-template-columns: minmax(120px, 1fr) auto;
  align-items: center;
  gap: 12px;
  min-height: 48px;
  padding: 0 16px;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: var(--panel-2);
}

.settings-list span {
  color: var(--muted);
}

.settings-list strong,
.profile-stat-row strong {
  justify-self: end;
  text-align: right;
  white-space: nowrap;
  font-size: 14px;
}

.settings-list strong.profile-action-status {
  max-width: min(62%, 420px);
  white-space: normal;
  line-height: 1.25;
}

.settings-list strong.profile-action-status[data-state="error"] {
  color: var(--danger);
}

.settings-list strong.profile-action-status[data-state="busy"] {
  color: var(--green);
}

.wallet-ton-group {
  display: grid;
  /* [OWNER 2026-08-03] TWO columns, not three. The balance button used to hold the first (1.2fr) slot; removing it
     left the row declaring a column nothing occupies, so the two remaining actions sat squeezed into the left
     two-thirds while every row below them ran the full width. */
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: 8px;
}

.wallet-ton-group button {
  min-width: 0;
}

.profile-stat-row span {
  color: var(--muted);
}

.settings-select-row {
  display: grid;
  grid-template-columns: minmax(76px, auto) minmax(0, 1fr) auto;
  align-items: center;
  gap: 8px 12px;
  min-height: 56px;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: var(--panel-2);
}

/* The label COLUMN was a fixed 108px and the label itself `white-space: nowrap` — a pair that only works while
   every translation is short. Several are not: the Russian strings for "App language" and "Private message
   sender" both run past 108px, and grid does not clip overflow, so the text ran out of its own column and under
   the select. [OWNER 2026-08-04: "some phrases in the profile do not fit and overlap"]
   Now the column has a floor and a ceiling instead of one exact size, and the label wraps inside it. Columns
   cannot overlap by construction, so this cannot come back for a longer word in some other language — the row
   simply grows taller (min-height, never a fixed height). */
.settings-select-row.compact-select-row {
  grid-template-columns: minmax(108px, 38%) minmax(0, 1fr);
}

.settings-select-row.compact-select-row > span {
  white-space: normal;
  overflow-wrap: anywhere;
  line-height: 1.25;
}

.settings-select-row strong {
  justify-self: end;
  color: var(--text);
  white-space: nowrap;
}

.settings-select-row select {
  min-width: 0;
  height: 38px;
  border: 1px solid var(--line);
  border-radius: 8px;
  padding: 0 34px 0 12px;
  background: var(--panel);
  color: var(--text);
  font: inherit;
}

/* RPC key: one row — label, an inline (frameless) field, a status, and a Get button. The whole row
   (except the field) is clickable and opens the @toncenter bot, like a settings-list button. */
.settings-rpc-row {
  display: flex;
  align-items: center;
  gap: 10px;
  min-height: 48px;
  padding: 0 16px;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: var(--panel-2);
  cursor: pointer;
}

.settings-rpc-row .rpc-key-label {
  color: var(--muted);
  white-space: nowrap;
}

.settings-rpc-row input {
  flex: 1 1 auto;
  min-width: 0;
  height: 34px;
  /* A distinct dark field box (like the select controls) so it reads as an input against the lighter row. */
  border: 1px solid var(--line);
  border-radius: 8px;
  padding: 0 12px;
  background: var(--panel);
  color: var(--text);
  font: inherit;
  cursor: text;
}

.settings-rpc-row input:focus {
  outline: none;
  border-color: var(--accent);
}

.settings-rpc-row input::placeholder {
  color: var(--muted-2);
}

.rpc-key-status {
  flex: 0 0 auto;
  color: var(--muted);
  white-space: nowrap;
  /* A single pictogram since v651 (✓ / ✕ / ⏳, full text in the tooltip) — bigger glyph, no more row squeeze. */
  font-size: 16px;
  line-height: 1;
}

.rpc-key-status[data-state="error"] {
  color: var(--danger);
}

.rpc-key-status[data-state="ok"] {
  color: var(--green);
}

/* Higher specificity than the generic `.settings-list button` rule so this compact inline
   button wins over the full-height settings-row button styling. */
.settings-rpc-row .rpc-key-get {
  flex: 0 0 auto;
  min-height: 34px;
  padding: 0 16px;
  border: 1px solid var(--line);
  border-radius: 6px;
  background: var(--panel);
  color: var(--text);
  font: inherit;
  cursor: pointer;
}

.settings-rpc-row .rpc-key-get:hover {
  border-color: rgba(48, 213, 176, 0.42);
}

/* Persistent "your wallet key is not backed up" warning row in the Profile (higher specificity than the
   generic .settings-list button so the danger styling wins). Shown only while the backup is pending. */
.settings-list button.wallet-backup-warning {
  border-color: var(--danger);
}

.settings-list button.wallet-backup-warning > span,
.settings-list button.wallet-backup-warning > strong {
  color: var(--danger);
}

.modal-backdrop {
  position: fixed;
  inset: 0;
  z-index: 40;
  display: grid;
  place-items: center;
  align-items: center;
  padding: 20px;
  min-height: var(--app-viewport-height, 100dvh);
  background: rgba(0, 0, 0, 0.48);
}

.modal-backdrop[hidden] {
  display: none;
}

/* Quick-start steps open the shared #actionDialog (password prompts, the Telegram
   seed-backup hard gate, the manual wallet-key copy dialog) WHILE the #quickStartDialog
   overlay is still visible. Both are .modal-backdrop at z-index:40 and #quickStartDialog
   is later in the DOM, so without this override it paints over the action dialog and
   pointer-blocks it — burying those prompts and freezing onboarding (worst in the
   Telegram WebView, where the seed gate is non-dismissible). Keep it below the lightbox (70). */
#actionDialog {
  z-index: 60;
}

.recipient-dialog,
.action-dialog,
.docs-dialog,
.install-dialog {
  display: grid;
  gap: 14px;
  width: min(var(--modal-card-width), 100%);
  padding: 18px;
  border: 1px solid var(--modal-outline);
  border-radius: 8px;
  background: var(--panel);
  box-shadow: 0 18px 50px var(--shadow);
}

/* The post-activation briefing. Longer than the other dialogs by design — it is read once and it is the only place
   the irreversible-and-public facts are stated before a first message — so it scrolls as a WHOLE, capped to the
   viewport, like .quick-start-dialog. A keyboard-shrunk Telegram viewport must scroll, never clip. */
.activation-welcome-dialog {
  display: grid;
  gap: 12px;
  /* Matched to .quick-start-dialog, not to .install-dialog: the dialogs sit in a 440-460 band and 520 is the install
     outlier. This one is the tail of onboarding — often the screen right after the quick-start — so it should read
     as the same card, not a wider one. (Owner spotted it as "wider than the rest"; measured across all eight.) */
  width: min(var(--modal-card-width), 100%);
  max-height: calc(var(--app-viewport-height, 100dvh) - 40px);
  padding: 18px;
  overflow-y: auto;
  border: 1px solid var(--modal-outline);
  border-radius: 8px;
  background: var(--panel);
  box-shadow: 0 18px 50px var(--shadow);
}

.activation-welcome-dialog > p {
  margin: 0;
  line-height: 1.55;
}

.activation-welcome-dialog h3 {
  margin: 4px 0 0;
  font-size: 14px;
}

.activation-welcome-mission {
  color: var(--muted);
}

.activation-welcome-list {
  display: grid;
  gap: 6px;
  margin: 0;
  padding-left: 20px;
  line-height: 1.5;
}

.activation-welcome-actions {
  display: grid;
  margin-top: 4px;
}

/* Tall action dialogs (e.g. the image-attach preview: hint + preview + quality select + summary + button) scroll
   as a WHOLE — like .quick-start-dialog — capped to the viewport. The earlier version scrolled ONLY the
   .action-fields region, which TRAPPED the scroll around a tall portrait preview and buried the quality dropdown
   (and any field below it) inside that inner scroller where the user never reached it (owner). Scrolling the whole
   dialog keeps every field + the submit button reachable; the modal-backdrop centres it and the @media
   (max-height) block below lets the backdrop itself scroll on very short viewports. */
.action-dialog {
  max-height: calc(var(--app-viewport-height, 100dvh) - 40px);
  overflow-y: auto;
}

/* Quick-start onboarding */
.quick-start-dialog {
  display: grid;
  gap: 16px;
  width: min(var(--modal-card-width), 100%);
  /* The dialog is a grid item of the .modal-backdrop (place-items:center). Without min-width:0 its
     automatic minimum is min-content, so a wide descendant (a long localized paragraph's max-content,
     the footer's action button) inflates the box PAST its declared width and it spills off a narrow
     screen (iPhone / TG Mini App). min-width:0 makes the width authoritative so inner text wraps instead. */
  min-width: 0;
  max-height: calc(var(--app-viewport-height, 100dvh) - 40px);
  overflow-y: auto;
  padding: 20px;
  border: 1px solid var(--modal-outline);
  border-radius: 8px;
  background: var(--panel);
  box-shadow: 0 18px 50px var(--shadow);
}

.quick-start-dialog header {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 12px;
}

.quick-start-view {
  display: grid;
  gap: 14px;
  /* Grid item of the dialog: min-width:0 so its own auto column clamps to the dialog width and
     long localized text wraps instead of forcing the dialog wider than a phone viewport. */
  min-width: 0;
}

.quick-start-view[hidden] {
  display: none;
}

.quick-start-language-row {
  display: flex;
  align-items: center;
  gap: 10px;
}

.quick-start-language-label {
  color: var(--muted);
  font-size: 13px;
}

.quick-start-language-row select {
  flex: 1;
  min-width: 0;
  padding: 8px 10px;
  border: 1px solid rgba(48, 213, 176, 0.25);
  border-radius: 8px;
  background: #101815;
  color: var(--text);
  font: inherit;
}

.quick-start-pitch,
.quick-start-step-why {
  margin: 0;
  line-height: 1.5;
  color: var(--text);
  opacity: 0.82;
}

.quick-start-highlights {
  margin: 0;
  padding-left: 18px;
  display: grid;
  gap: 6px;
  color: var(--text);
}

.quick-start-actions {
  display: grid;
  gap: 8px;
}

/* Footer row: Back + one primary, and nothing else since Skip was removed (owner, 2026-08-10). Both share the
   row equally — `flex: 1 1 0` gives them the SAME width whatever their labels say, which is only possible now
   that the row holds two buttons instead of three. Basis 0 (not 160px) is what makes them equal rather than
   merely both-growing, and min-width:0 lets a long localized label shrink instead of pushing the dialog wider
   than a phone viewport, which is the overflow QS-RESUME-05 exists to prevent. flex-wrap is kept as the last
   resort for a viewport narrow enough that half a row cannot hold a word. */
.quick-start-step-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  align-items: center;
}

.quick-start-step-actions > button {
  flex: 1 1 0;
  min-width: 0;
  /* MEASURED, not decorative: with `flex-basis: 0` the horizontal padding still enters each item's base size, so
     Back (0 16px) came out exactly 20px wider than the primary (the UA's 1px 6px) — 183 vs 163 in a 355px row.
     Equal padding is what makes "equal halves" actually equal: 173.33 each. Height is untouched at 42px. */
  padding-left: 16px;
  padding-right: 16px;
}

.quick-start-foot {
  margin: 0;
  font-size: 12px;
  text-align: center;
  color: var(--text);
  opacity: 0.6;
}

.quick-start-progress {
  display: flex;
  gap: 6px;
}

.quick-start-progress-dot {
  flex: 1;
  height: 4px;
  border-radius: 2px;
  background: var(--line);
}

.quick-start-progress-dot.is-done {
  background: var(--accent);
}

.quick-start-progress-dot.is-current {
  background: var(--accent-2);
}

.quick-start-step-head {
  display: grid;
  gap: 4px;
}

.quick-start-step-counter {
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--accent);
}

.quick-start-step-head h3 {
  margin: 0;
}

.quick-start-step-body {
  display: grid;
  gap: 10px;
  min-width: 0;
}

.quick-start-step-body:empty {
  display: none;
}

/* Shared quick-start step body (step-2 TON Center key + step-4 top-up): content stacks with a clear gap and a
   plate-CTA (.discovery-cta-action) sits BELOW on its own row, sized to its label — same look as "Add contact". */
.quick-start-key-body {
  display: grid;
  gap: 12px;
}

.quick-start-key-body > .discovery-cta-action {
  justify-self: start;
}

/* ...except the get-a-key plate, which the owner asked to span the body like the input above it (2026-08-10).
   Must stay AFTER the rule above — same specificity, so source order is what decides. Scoped to this one plate on
   purpose: the shorter Check-balance plates on the top-up and activate steps still hug their text. */
.quick-start-key-body > .quick-start-key-cta {
  justify-self: stretch;
  /* The plate is an inline-flex with no justify-content, which is invisible while it hugs its text and became a
     left-shoved label the moment it spanned the row (owner, 2026-08-10). Centring belongs on the STRETCHED
     variant only — the content-sized plates elsewhere in the app are unaffected either way. */
  justify-content: center;
  text-align: center;
}

.quick-start-step-address {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  word-break: break-all;
  padding: 10px 12px;
  border: 1px solid var(--line);
  border-radius: 6px;
  background: var(--bg);
  color: var(--text);
}

/* Live wallet balance line on the Top-up / Activate quick-start steps + the underfunded hint. */
.quick-start-balance-line {
  margin-top: 10px;
  font-size: 13px;
  color: var(--text);
}

/* The explanatory lines under step 1's password fields — the same three sentences the standalone dialog shows in
   its summary box, in the same bordered plate so the step reads as one card rather than a form with loose text. */
.quick-start-step-summary {
  display: grid;
  gap: 6px;
  padding: 12px;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: var(--panel);
  color: var(--muted);
  font-size: 12px;
  line-height: 1.45;
}

.quick-start-step-hint {
  line-height: 1.45;
  margin-top: 6px;
  font-size: 12px;
  color: var(--text-muted, #8b949e);
}

.quick-start-step-hint.is-error {
  color: var(--danger, #f85149);
  font-weight: 600;
}

.quick-start-status {
  margin: 0;
  min-height: 16px;
  font-size: 12px;
  color: var(--accent);
}

.docs-dialog {
  grid-template-rows: auto minmax(0, 1fr);
  width: min(var(--modal-reader-width), 100%);
  height: min(760px, 86dvh);
}

.recipient-dialog header,
.action-dialog header,
.docs-dialog header,
.install-dialog header,
.activation-welcome-dialog header {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 12px;
}

.recipient-dialog h2,
.recipient-dialog p,
.action-dialog h2,
.action-dialog p,
.docs-dialog h2,
.docs-dialog p,
.install-dialog h2,
.install-dialog p,
.activation-welcome-dialog h2,
.activation-welcome-dialog p {
  margin: 0;
}

.recipient-dialog h2,
.action-dialog h2,
.docs-dialog h2,
.install-dialog h2 {
  font-size: 18px;
  letter-spacing: 0;
}

.recipient-dialog p,
.action-dialog p,
.docs-dialog header p,
.install-dialog p {
  margin-top: 5px;
  color: var(--muted);
  font-size: 13px;
}

.recipient-dialog p[data-tone="error"],
.action-dialog p[data-tone="error"] {
  color: var(--rose);
}

/* [OWNER 2026-08-10] The quick-start step body joins this list instead of keeping its own look. Its old rule
   styled `input[type="text"]` only — so the password fields that moved INTO step 1 got the browser default and
   looked, in his words, frightening. It also disagreed with this one on radius, background and height, which
   nobody noticed while the two never appeared on the same screen. One definition, every field. */
.recipient-dialog input,
.action-dialog input,
.action-dialog select,
.action-dialog textarea,
.quick-start-step-body input,
.quick-start-step-body textarea {
  width: 100%;
  min-height: 44px;
  border: 1px solid var(--line);
  border-radius: 8px;
  padding: 0 12px;
  color: var(--text);
  background: var(--panel-2);
  outline: 0;
}

.action-dialog select,
.recipient-dialog select {
  -webkit-appearance: none;
  appearance: none;
  display: block;
  min-height: 56px;
  padding-right: 44px;
  font: inherit;
  line-height: 1.25;
  cursor: pointer;
  /* A clearly visible accent-coloured down-chevron so the field reads as an expandable dropdown. The shared
     chevron-down.svg is fill=#000 (invisible on the dark field), so use an inline accent (#30d5b0) chevron. */
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2330d5b0' d='m12 15.8-6.4-6.4L7 8l5 5 5-5 1.4 1.4L12 15.8Z'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 14px center;
  background-size: 22px 22px;
}

.action-dialog textarea {
  min-height: 108px;
  padding: 12px;
  resize: vertical;
  line-height: 1.4;
}

.recipient-dialog input:focus,
.action-dialog input:focus,
.action-dialog select:focus,
.action-dialog textarea:focus,
.quick-start-step-body input:focus {
  border-color: rgba(48, 213, 176, 0.6);
}

/* Live field budget counter (e.g. an on-chain byte cap) — a small right-aligned hint below the input; red when over. */
.action-dialog-field-counter {
  display: block;
  margin-top: 4px;
  font-size: 11px;
  color: var(--muted);
  text-align: right;
}

.action-dialog-field-counter.is-over {
  color: #ff7a7a;
  font-weight: 600;
}

.action-fields {
  display: grid;
  gap: 10px;
}

.action-field {
  display: grid;
  gap: 6px;
}

.action-field label {
  color: var(--muted);
  font-size: 12px;
  font-weight: 800;
  text-transform: uppercase;
}

/* Informational block inside an action dialog (e.g. the "Get an RPC key" help modal): a muted intro line
   and a numbered steps list — no input. */
.action-note {
  display: grid;
  gap: 8px;
  color: var(--muted);
  font-size: 13px;
  line-height: 1.45;
}

.action-note p {
  margin: 0;
}

.action-note-steps {
  margin: 0;
  padding-left: 20px;
  display: grid;
  gap: 6px;
}

.action-note-steps li {
  padding-left: 2px;
}

.action-custom-field {
  display: grid;
  gap: 10px;
}

.wallet-receive-card {
  display: grid;
  justify-items: center;
  gap: 12px;
  padding: 14px;
  border: 1px solid var(--line-soft);
  border-radius: 8px;
  background: rgba(8, 11, 13, 0.64);
}

.wallet-receive-qr {
  width: min(260px, 100%);
  aspect-ratio: 1;
  padding: 10px;
  border-radius: 8px;
  background: #fff;
}

.wallet-receive-address {
  width: 100%;
  overflow-wrap: anywhere;
  color: var(--text);
  font-size: 12px;
  line-height: 1.35;
  text-align: center;
}

.image-preview-card {
  display: grid;
  gap: 8px;
  padding: 10px;
  border: 1px solid var(--line-soft);
  border-radius: 8px;
  background: rgba(8, 11, 13, 0.64);
}

.image-preview-card img {
  display: block;
  width: 100%;
  max-height: min(320px, 42dvh);
  object-fit: contain;
  border-radius: 6px;
  background: #050708;
}

.image-preview-card img[data-full-image-src] {
  cursor: zoom-in;
}

.image-preview-card img[data-full-image-src]:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

.image-preview-meta {
  color: var(--muted);
  font-size: 12px;
  font-weight: 700;
}

.image-lightbox-backdrop {
  z-index: 70;
  place-items: center;
  align-items: center;
  justify-items: center;
  overflow: hidden;
  padding:
    max(12px, var(--app-safe-area-top, env(safe-area-inset-top)))
    max(12px, var(--app-safe-area-right, env(safe-area-inset-right)))
    max(12px, var(--app-safe-area-bottom, env(safe-area-inset-bottom)))
    max(12px, var(--app-safe-area-left, env(safe-area-inset-left)));
  background: rgba(0, 0, 0, 0.78);
}

/* Fullscreen (v647, owner ask): the lightbox takes the WHOLE viewport — zooming needs the room, not a
   content-sized card. Safe areas are padded INSIDE the dialog; the backdrop override below removes its inset. */
.image-lightbox-dialog {
  display: grid;
  grid-template-rows: auto minmax(0, 1fr);
  gap: 12px;
  width: 100%;
  /* Bound the dialog to the viewport height on EVERY platform (was height:100%, which does not resolve against the
     min-height-only, place-items:center .modal-backdrop on desktop -> the dialog grew to a tall image and overflowed,
     bottom clipped). --app-viewport-height is set on desktop too (syncViewportCssVars). This lets the viewport
     (minmax(0,1fr)) + img (max-height:100%) shrink the image to fit. */
  height: var(--app-viewport-height, 100dvh);
  max-height: var(--app-viewport-height, 100dvh);
  padding:
    max(10px, var(--app-safe-area-top, env(safe-area-inset-top)))
    max(10px, var(--app-safe-area-right, env(safe-area-inset-right)))
    max(10px, var(--app-safe-area-bottom, env(safe-area-inset-bottom)))
    max(10px, var(--app-safe-area-left, env(safe-area-inset-left)));
  border: 0;
  border-radius: 0;
  background: var(--panel);
  overflow: hidden;
}

.image-lightbox-backdrop.image-lightbox-backdrop {
  padding: 0;
}

.image-lightbox-dialog header {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 12px;
}

.image-lightbox-actions {
  display: flex;
  align-items: center;
  gap: 8px;
  flex: 0 0 auto;
}

.image-lightbox-dialog h2,
.image-lightbox-dialog p {
  margin: 0;
}

.image-lightbox-dialog h2 {
  font-size: 18px;
  letter-spacing: 0;
}

.image-lightbox-dialog p {
  margin-top: 5px;
  color: var(--muted);
  font-size: 13px;
}

.image-lightbox-viewport {
  display: grid;
  /* One DEFINITE cell (minmax(0,1fr) row+column), NOT the implicit auto track: an auto grid track grows to the
     img's natural height, so the img's max-height:100% resolved against that circular content height and never
     clamped — a tall image (e.g. 804x1024) overflowed the height-bounded dialog on DESKTOP, where its width did
     not bind either (on a narrow phone max-width:100% clamps width first, so the bug hid). An explicit
     minmax(0,1fr) fills the dialog's definite viewport row, so max-height:100% finally clamps and a tall image
     scales to fit. Verified in-browser: a 804x1024 img went 1024px→708px (fits an 800px window). */
  grid-template-rows: minmax(0, 1fr);
  grid-template-columns: minmax(0, 1fr);
  align-items: center;
  justify-items: center;
  min-height: 0;
  /* The zoom engine owns all motion: the image FITS at rest and scales via transform (no native scroll);
     touch-action none hands pinch/pan to the app instead of the browser. */
  overflow: hidden;
  touch-action: none;
  user-select: none;
  padding: 12px;
  border: 1px solid var(--line-soft);
  border-radius: 8px;
  background: #050708;
}

.image-lightbox-viewport img {
  display: block;
  width: auto;
  height: auto;
  max-width: 100%;
  max-height: 100%;
  border-radius: 6px;
  transform-origin: 0 0;
  will-change: transform;
}

.image-lightbox-viewport img.is-zoom-animated {
  transition: transform 0.18s ease-out;
}

.image-lightbox-viewport img.is-zoomed {
  cursor: grab;
}

.action-summary {
  display: grid;
  gap: 6px;
  min-height: 42px;
  padding: 12px;
  border: 1px solid var(--line-soft);
  border-radius: 8px;
  background: rgba(8, 11, 13, 0.64);
  color: var(--muted);
  font-size: 13px;
}

.action-summary strong {
  color: var(--text);
}

.action-checkbox-field label {
  display: grid;
  grid-template-columns: 18px minmax(0, 1fr);
  align-items: start;
  gap: 10px;
  color: var(--text);
  line-height: 1.35;
}

/* min-height RESET, and it is the whole fix for a box that looked like it had fallen out of its own row.
   .action-dialog input gives every dialog input a 44px touch target. A checkbox keeps its 16x16 painted glyph but
   INHERITS that minimum, so the element still starts in grid column 1 of row 1 while the glyph renders centred in a
   44px box — about one line below its own label, which reads as "the label is a heading and the box is under it".
   MEASURED on the deployed stylesheet: width 16px, height 16px, min-height 44px.

   The margin-top that used to sit here went with it. It was nudging the box down to meet a label it could never
   reach while the 44px minimum held, and once the minimum was gone it became the whole remaining skew — the owner
   saw it immediately. MEASURED again, box centre against label centre: 2px -> 1.9px low, 0 -> 0.1px. The label's
   line box is 16.2px and the box is 16px, so `align-items: start` lands them level with no nudge at all, and a
   caption that wraps still keeps the box on its first line. */
.action-checkbox-field input {
  width: 16px;
  height: 16px;
  min-height: 0;
}

.recipient-submit {
  height: 42px;
  border: 1px solid rgba(48, 213, 176, 0.3);
  border-radius: 8px;
  color: #061411;
  background: var(--accent);
  font-weight: 800;
  cursor: pointer;
}

/* Two LABELLED ways out of a dialog, side by side and equal width. Equal padding is required, not decorative:
   horizontal padding enters the flex base size, so `flex: 1 1 0` alone leaves the wider label's button wider
   (measured 183 vs 163 on the quick-start row before the paddings were matched). */
.action-dialog-actions {
  display: flex;
  gap: 8px;
}

.action-dialog-actions > .recipient-submit {
  flex: 1 1 0;
  min-width: 0;
  padding-left: 16px;
  padding-right: 16px;
}

/* The dismiss reads as secondary: it is the way OUT, not the thing we are suggesting. */
.action-dialog-dismiss {
  color: var(--text);
  background: transparent;
  border-color: var(--line);
  font-weight: 700;
}

.action-dialog-dismiss[hidden] {
  display: none;
}

.install-dialog > p {
  line-height: 1.55;
}

.install-steps {
  display: grid;
  gap: 9px;
  margin: 0;
  padding: 12px 14px 12px 32px;
  border: 1px solid var(--line);
  border-radius: 8px;
  color: var(--text);
  background: #0b0f10;
}

.install-steps[hidden] {
  display: none;
}

.install-steps li {
  padding-left: 4px;
  color: var(--muted);
  line-height: 1.35;
}

.install-dialog-actions {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: 10px;
  align-items: center;
}

.secondary-button {
  min-height: 42px;
  border: 1px solid var(--line);
  border-radius: 8px;
  padding: 0 16px;
  color: var(--muted);
  background: #0d1012;
  font: inherit;
  font-weight: 800;
  cursor: pointer;
}

.secondary-button:hover {
  color: var(--text);
  border-color: rgba(48, 213, 176, 0.34);
}

/* v749: auto-linked URLs in messages/posts + the external-link interstitial (built in JS with createElement +
   textContent only). */
.msg-link {
  color: var(--accent);
  text-decoration: underline;
  text-underline-offset: 2px;
  overflow-wrap: anywhere;
  word-break: break-word;
  cursor: pointer;
}
.msg-link:hover {
  text-decoration: none;
}
.external-link-backdrop {
  z-index: 80; /* above the image lightbox (70) — a leaving-the-app confirm must sit on top */
}
.external-link-dialog {
  text-align: left;
}
.external-link-dialog h2 {
  margin: 0;
  font-size: 18px;
}
.external-link-dialog p {
  margin: 0;
}
.external-link-url {
  padding: 8px 10px;
  border: 1px solid var(--line-soft);
  border-radius: 8px;
  background: #0d1012;
  color: var(--muted);
  font-size: 13px;
  overflow-wrap: anywhere;
  word-break: break-word;
}
.external-link-ip-warning {
  color: #e8b64a;
  font-size: 13px;
}
.external-link-dontask {
  display: flex;
  align-items: center;
  gap: 8px;
  color: var(--muted);
  font-size: 13px;
  cursor: pointer;
}
.external-link-dontask input[type="checkbox"] {
  flex: 0 0 auto;
  width: 18px;
  height: 18px;
  min-height: 0;
  margin: 0;
  accent-color: var(--accent);
}
.external-link-actions {
  display: flex;
  justify-content: flex-end;
  gap: 10px;
}
.external-link-open {
  min-height: 42px;
  border: 0;
  border-radius: 8px;
  padding: 0 18px;
  color: #04110d;
  background: var(--accent);
  font: inherit;
  font-weight: 800;
  cursor: pointer;
}
.external-link-open:hover {
  filter: brightness(1.06);
}
/* Anti-phishing line: shown when a link's visible text differs from its destination ([text](url)). */
.external-link-shownas {
  color: var(--muted);
  font-size: 13px;
}
.external-link-shownas b {
  color: var(--text);
}
/* Composer "Insert link" dialog (reuses .action-dialog + .external-link-actions). */
.link-composer-dialog h2 {
  margin: 0;
  font-size: 18px;
}
.link-composer-input {
  width: 100%;
  min-height: 42px;
  padding: 0 12px;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: #0d1012;
  color: var(--text);
  font: inherit;
}
.link-composer-error {
  margin: 0;
  color: #e8b64a;
  font-size: 13px;
}

.docs-layout {
  display: grid;
  grid-template-columns: minmax(150px, 210px) minmax(0, 1fr);
  min-height: 0;
  border: 1px solid var(--line-soft);
  border-radius: 8px;
  overflow: hidden;
  background: rgba(8, 11, 13, 0.38);
}

.docs-nav {
  display: grid;
  align-content: start;
  gap: 6px;
  padding: 10px;
  border-right: 1px solid var(--line-soft);
  background: rgba(13, 16, 18, 0.72);
}

.docs-nav button {
  min-height: 38px;
  border: 1px solid transparent;
  border-radius: 8px;
  padding: 0 11px;
  color: var(--muted);
  background: transparent;
  font: inherit;
  font-size: 13px;
  font-weight: 800;
  text-align: left;
  cursor: pointer;
}

.docs-nav button:hover,
.docs-nav button.is-active {
  color: var(--text);
  border-color: rgba(48, 213, 176, 0.28);
  background: rgba(48, 213, 176, 0.08);
}

.docs-content {
  min-width: 0;
  overflow: auto;
  padding: 18px 20px 24px;
  outline: 0;
}

.docs-status {
  color: var(--muted);
  font-size: 14px;
}

.docs-status-error {
  color: var(--rose);
}

.docs-rendered {
  max-width: 760px;
  line-height: 1.64;
  color: var(--text);
}

.docs-rendered h2,
.docs-rendered h3,
.docs-rendered h4,
.docs-rendered h5 {
  margin: 24px 0 10px;
  line-height: 1.2;
  letter-spacing: 0;
}

.docs-rendered h2:first-child {
  margin-top: 0;
}

.docs-rendered h2 {
  font-size: 24px;
}

.docs-rendered h3 {
  font-size: 18px;
}

.docs-rendered h4,
.docs-rendered h5 {
  font-size: 15px;
}

.docs-rendered p,
.docs-rendered ul,
.docs-rendered ol,
.docs-rendered pre,
.docs-table-wrap {
  margin: 0 0 15px;
}

.docs-rendered ul,
.docs-rendered ol {
  padding-left: 22px;
}

.docs-rendered li + li {
  margin-top: 6px;
}

.docs-rendered code {
  border: 1px solid var(--line-soft);
  border-radius: 6px;
  padding: 1px 5px;
  background: rgba(255, 255, 255, 0.04);
  color: #dfeee8;
  font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
  font-size: 0.92em;
}

.docs-rendered pre {
  overflow: auto;
  border: 1px solid var(--line-soft);
  border-radius: 8px;
  padding: 12px;
  background: #090c0e;
}

.docs-rendered pre code {
  display: block;
  border: 0;
  padding: 0;
  background: transparent;
  white-space: pre;
}

.docs-rendered a {
  color: var(--accent-2);
}

.docs-table-wrap {
  overflow-x: auto;
  border: 1px solid var(--line-soft);
  border-radius: 8px;
}

.docs-rendered table {
  width: 100%;
  min-width: 520px;
  border-collapse: collapse;
  font-size: 13px;
}

.docs-rendered th,
.docs-rendered td {
  padding: 9px 10px;
  border-bottom: 1px solid var(--line-soft);
  text-align: left;
  vertical-align: top;
}

.docs-rendered th {
  color: var(--muted);
  background: rgba(255, 255, 255, 0.03);
  font-size: 12px;
  text-transform: uppercase;
}

.docs-rendered tr:last-child td {
  border-bottom: 0;
}

.icon {
  display: inline-block;
  width: 20px;
  height: 20px;
  background: currentColor;
  -webkit-mask: var(--mask) center / contain no-repeat;
  mask: var(--mask) center / contain no-repeat;
}

.icon-chat { --mask: url("./assets/icons/chat-outline.svg"); }
.icon-broadcast { --mask: url("./assets/icons/broadcast.svg"); }
.icon-vault { --mask: url("./assets/icons/vault.svg"); }
.icon-wallet { --mask: url("./assets/icons/wallet.svg"); }
.icon-user { --mask: url("./assets/icons/user.svg"); }
.icon-eye { --mask: url("./assets/icons/eye.svg"); }
.icon-eye-off { --mask: url("./assets/icons/eye-off.svg"); }
.icon-search { --mask: url("./assets/icons/search.svg"); }
.icon-plus { --mask: url("./assets/icons/plus.svg"); }
.icon-image { --mask: url("./assets/icons/image.svg"); }
.icon-link { --mask: url("./assets/icons/link.svg"); }
.icon-down { --mask: url("./assets/icons/down.svg"); }
.icon-up { --mask: url("./assets/icons/up.svg"); }
.icon-back { --mask: url("./assets/icons/back.svg"); }
/* [OWNER 2026-08-09] "X is not centred in the box." It was a text glyph (&times;) in eight dialogs while every
   other icon in the app is a mask. Measured: the glyph ink centre sits 4.5px above the baseline while the line
   box centre sits 6.5px above it, so place-items:center centred the BOX and left the ink 2px low — 5% of a 40px
   button. A mask has no baseline, so it is centred by construction rather than by luck of the font. */
.icon-close { --mask: url("./assets/icons/close.svg"); }
.icon-chevron-down { --mask: url("./assets/icons/chevron-down.svg"); }
.icon-refresh { --mask: url("./assets/icons/refresh.svg"); }
.icon-swap-vertical { --mask: url("./assets/icons/swap-vertical.svg"); }
.icon-text-select { --mask: url("./assets/icons/text-select.svg"); }
.icon-expand { --mask: url("./assets/icons/expand.svg?v=2"); }
.icon-collapse { --mask: url("./assets/icons/collapse.svg?v=2"); }
.icon-bolt { --mask: url("./assets/icons/bolt.svg"); }
.icon-gear { --mask: url("./assets/icons/gear.svg"); }
.icon-install { --mask: url("./assets/icons/install.svg"); }
.icon-open-app { --mask: url("./assets/icons/open-app.svg"); }
.icon-info { --mask: url("./assets/icons/info.svg"); }
.icon-copy { --mask: url("./assets/icons/copy.svg"); }
.icon-trash { --mask: url("./assets/icons/trash.svg"); }
.icon-download { --mask: url("./assets/icons/download.svg"); }
.icon-compass { --mask: url("./assets/icons/compass.svg"); }
.icon-about { --mask: url("./assets/icons/about.svg"); }
.icon-smiley { --mask: url("./assets/icons/smiley.svg"); }
.icon-heading { --mask: url("./assets/icons/heading.svg"); }
.icon-bold { --mask: url("./assets/icons/bold.svg"); }
.icon-italic { --mask: url("./assets/icons/italic.svg"); }
.icon-list { --mask: url("./assets/icons/list.svg"); }
.icon-quote { --mask: url("./assets/icons/quote.svg"); }
.icon-align-center { --mask: url("./assets/icons/align-center.svg"); }
.icon-align-justify { --mask: url("./assets/icons/align-justify.svg"); }

@media (max-width: 900px) {
  :root {
    --header-actions-width: 204px;
    --mobile-nav-bottom-reserve: 10px;
  }

  /* Fit the three post-action buttons (e.g. Comments off / Private chat / Unfollow) on ONE row on phones:
     at ~360px the card's action row is ~270px but the buttons need ~318px at the desktop sizing and wrap to two
     lines. Tighten the gap, horizontal padding and font just enough to fit with ~8px to spare at 360px. Desktop
     (>900px) keeps the roomier defaults. */
  .feed-actions {
    gap: 6px;
  }

  .feed-actions button {
    padding: 0 7px;
    font-size: 12px;
  }

  body {
    overflow: hidden;
  }

  .app-shell {
    position: fixed;
    inset: 0;
    grid-template-columns: 1fr;
    grid-template-rows: minmax(0, 1fr) auto;
    max-width: 100vw;
    /* TOP-ANCHORED, and `min-height: 0` is the whole fix for the tab bar.
       [OWNER 2026-08-14] Half the tab bar hung below the screen edge. The cause was the FLOOR, not the anchor:
       min-height BEATS height, so `min-height: 100svh` held the shell at full screen height while `height` tried
       to follow the visible area — a box taller than what can be seen, with its bottom row off the edge. Dropping
       the floor lets the height do its job, and the tab bar fits.
       The bottom edge was briefly pinned here too (--app-viewport-bottom-gap, as the maximized composer still is)
       and that had to be undone: on iOS a bottom-anchored fixed element is re-resolved every time the visible area
       slides, so dragging the page made the whole shell shudder, over and over, in whichever direction had not
       shuddered last. Anchored at the top it is simply carried along, which is what every other site does and why
       none of them shudder. The composer keeps its bottom pin because it is a single box that must clear the
       keyboard exactly; the shell only needs to be the right SIZE. */
    min-height: 0;
    height: 100dvh;
    height: var(--app-viewport-height, 100dvh);
    overflow: hidden;
  }

  .sidebar {
    grid-column: 1;
    grid-row: 2;
    grid-template-columns: 1fr;
    grid-template-rows: auto;
    width: 100%;
    max-width: 100vw;
    min-width: 0;
    padding: 8px 10px var(--mobile-nav-bottom-reserve);
    padding-bottom: max(var(--mobile-nav-bottom-reserve), var(--app-safe-area-bottom, env(safe-area-inset-bottom, 0px)));
    border-right: 0;
    border-top: 1px solid var(--line-soft);
    overflow: hidden;
  }

  .brand {
    display: none;
  }

  .rail {
    grid-template-columns: minmax(58px, 0.72fr) repeat(4, minmax(0, 1fr));
    gap: 6px;
    width: 100%;
    min-width: 0;
    overflow: hidden;
  }

  .rail-vault-balance {
    min-width: 0;
    min-height: 58px;
    padding: 5px 2px;
    border-right: 1px solid rgba(48, 213, 176, 0.14);
    border-bottom: 0;
  }

  .rail-vault-balance span {
    font-size: 8px;
  }

  .rail-vault-balance strong {
    font-size: 9px;
  }

  .rail-item {
    width: 100%;
    min-height: 54px;
    min-width: 0;
    padding-inline: 2px;
    overflow: hidden;
  }

  .rail-item span:last-child {
    max-width: 100%;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .workspace {
    grid-column: 1;
    grid-row: 1;
    grid-template-columns: 1fr;
    width: 100%;
    max-width: 100vw;
    min-width: 0;
    overflow: hidden;
  }

  .list-pane {
    display: none;
    width: 100%;
    max-width: 100vw;
  }

  .app-shell[data-view="chats"] .list-pane {
    display: grid;
  }

  .app-shell[data-view="chats"] .chat-pane {
    display: none;
  }

  .app-shell[data-chat-open="true"][data-view="chats"] .list-pane {
    display: none;
  }

  .app-shell[data-chat-open="true"][data-view="chats"] .chat-pane {
    display: grid;
    /* Shared top offset so the conversation bar starts at the same y as every pane header. */
    padding-top: var(--header-top-offset);
  }

  .content-pane {
    width: 100%;
    max-width: 100vw;
    min-height: 0;
    overflow-x: hidden;
  }

  .public-pane,
  .wallet-pane,
  .profile-pane,
  .list-pane {
    padding: var(--header-top-offset) 24px 24px;
  }

  .list-pane {
    gap: 14px;
    border-right: 0;
  }

  .pane-header {
    gap: 10px;
  }

  .header-actions {
    /* Match the desktop 8px gap so the menu buttons sit the same distance apart in both views (mobile's 6px set
       them a couple of pixels closer). */
    gap: 8px;
    min-width: max-content;
  }

  .icon-button,
  .send-button {
    width: 38px;
    height: 38px;
  }

  /* Drop the Public header's install button on mobile so the info button sits on one line with the title
     (install stays available on the other tabs' headers). */
  .public-pane .pane-header .install-header-button {
    display: none;
  }

  .conversation-header {
    display: grid;
    grid-template-columns: var(--header-button-size) 44px minmax(0, 1fr) max-content;
    align-items: center;
    /* Same bar as desktop (shared height, zero vertical padding); the chat-pane top padding below provides the
       shared top offset. RIGHT 24 matches desktop; LEFT 14 is the mobile gutter for the back button + avatar,
       shared with the message area. */
    padding: 0 24px 0 14px;
    gap: 10px;
    max-width: 100vw;
    overflow: hidden;
  }

  .chat-back-button {
    display: grid;
  }

  .conversation-header .avatar {
    width: 44px;
    height: 44px;
  }

  .conversation-header .conversation-title {
    width: 100%;
    min-width: 0;
    max-width: 100%;
    overflow: hidden;
  }

  .conversation-header .conversation-title h2,
  .conversation-header .conversation-title p,
  .conversation-header .identity-title-label {
    max-width: 100%;
  }

  .conversation-header .header-actions {
    min-width: 0;
  }

  .conversation-header .install-header-button {
    display: none;
  }

  /* Header control sizing comes from the base container rule (var(--header-button-size)) — its specificity
     already beats the generic 38px mobile .icon-button touch size above, so no mobile override is needed. */

  .message-strip {
    padding: 16px 14px;
  }

  /* Mobile composer (v769): the flex-column stack needs only tighter padding + smaller controls. With the
     buttons moved into the toolbar, the input row is just [leading control | textarea | send] and fits one line
     at every phone width — no more 2-row grid reshuffle, no .is-replying juggle (the reply strip is just an
     earlier flex child). */
  .composer {
    /* NO safe-area inset here: on mobile the composer is NOT the bottommost element — the tab bar (.sidebar)
       sits below it and reserves env(safe-area-inset-bottom) itself. Adding the inset here too produced a dead
       gap between the composer and the tab bar on home-indicator iPhones. Desktop keeps the base rule's inset. */
    padding: 8px 14px 8px;
    gap: 6px;
    overflow: visible;
  }

  .composer-input-row {
    gap: 8px;
  }

  .composer .private-anonymous-button,
  .public-composer .composer-post-option {
    width: 38px;
    height: 44px;
  }

  .composer .send-button {
    width: 48px;
  }

  .public-composer {
    margin: 0 -24px -24px;
    /* Same as .composer above: the tab bar below owns the safe-area inset on mobile. */
    padding: 8px 14px 8px;
  }

  .balance-grid,
  .action-grid,
  .wallet-ton-group {
    grid-template-columns: 1fr;
  }

  .docs-dialog {
    height: min(820px, 92dvh);
    padding: 14px;
  }

  .docs-layout {
    grid-template-columns: 1fr;
    grid-template-rows: auto minmax(0, 1fr);
  }

  .docs-nav {
    grid-auto-flow: column;
    grid-auto-columns: max-content;
    overflow-x: auto;
    border-right: 0;
    border-bottom: 1px solid var(--line-soft);
  }

  .docs-nav button {
    min-width: 84px;
    text-align: center;
  }

  .docs-content {
    padding: 16px;
  }

  .docs-rendered table {
    min-width: 480px;
  }

  .modal-backdrop {
    align-items: center;
    padding:
      max(12px, var(--app-safe-area-top, env(safe-area-inset-top)))
      max(12px, var(--app-safe-area-right, env(safe-area-inset-right)))
      max(12px, var(--app-safe-area-bottom, env(safe-area-inset-bottom)))
      max(12px, var(--app-safe-area-left, env(safe-area-inset-left)));
    overflow: hidden;
  }

  /* Fullscreen everywhere (v647): the base rule already sizes the dialog to the viewport; mobile keeps only the
     tighter viewport padding. --app-viewport-height pins the height where 100% of the backdrop is unreliable
     (iOS dynamic toolbars). */
  .image-lightbox-dialog {
    height: var(--app-viewport-height, 100%);
  }

  .image-lightbox-viewport {
    padding: 8px;
  }
}

/* Short viewport = on-screen keyboard up (or a small phone). MUST come after the max-width:900px block above so its
   .modal-backdrop align-items:center doesn't win by source order on a phone (both media queries match). The default
   layout pins header + summary + submit and scrolls ONLY the fields region, so a focused textarea gets squeezed into
   a tiny scroll window and can vanish behind the keyboard (owner: "the mnemonic field collapses entirely when
   tapped"). Instead: TOP-align the modal (so the header + focused field stay visible, not centered off-screen) and
   scroll the WHOLE dialog — the textarea keeps its full min-height and every control is reachable by scrolling.
   --app-viewport-height already tracks the keyboard-shrunk visual viewport. */
@media (max-height: 620px) {
  .modal-backdrop {
    align-items: start;
    overflow-y: auto;
    padding-top: max(8px, var(--app-safe-area-top, env(safe-area-inset-top)));
    padding-bottom: 8px;
  }
  .action-dialog,
  .quick-start-dialog {
    max-height: none;
    overflow: visible;
  }
  .action-dialog > .action-fields {
    flex: 0 0 auto;
    overflow: visible;
    min-height: auto;
  }
}

@media (min-width: 901px) {
  .app-shell[data-view="chats"] .chat-pane {
    display: grid;
    /* Shared top offset so the conversation bar starts at the same y as every pane header
       (no double-shift on the sibling list-pane, which carries its own pane padding). */
    padding-top: var(--header-top-offset);
  }

  .app-shell[data-view="chats"] .list-pane .header-actions {
    display: none;
  }

  .app-shell[data-view="chats"] .list-pane .install-header-button,
  .app-shell[data-view="chats"] .list-pane .docs-header-button {
    display: none;
  }

  .app-shell:not([data-view="chats"]) .list-pane {
    display: none;
  }

  .app-shell:not([data-view="chats"]) .workspace {
    grid-template-columns: minmax(0, 1fr);
  }
}

/* Public publish status badge (own pending posts/comments): the background confirm driver's visible state.
   CSS classes only — the production CSP (style-src 'self') silently kills inline styles. */
.public-publish-status {
  color: var(--text-muted, #8b949e);
  font-size: 12px;
  font-style: italic;
}

.public-publish-status--failed {
  color: var(--danger, #f85149);
  font-style: normal;
  font-weight: 600;
}

/* ── Boot screen ─────────────────────────────────────────────────────────────
   Masks the post-unlock initial-sync while the branded signal-field animates.
   z-index 55: above the app shell (<=50), below the unlock dialog (#actionDialog 60). */
.boot-screen {
  position: fixed;
  inset: 0;
  z-index: 55;
  display: flex;
  overflow: hidden;
  background: radial-gradient(125% 95% at 50% 22%, #101a17 0%, var(--bg) 62%);
  opacity: 1;
  transition: opacity 460ms ease;
}

.boot-screen[hidden] {
  display: none;
}

.boot-screen.is-hiding {
  opacity: 0;
  pointer-events: none;
}

.boot-signal {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}

.boot-screen-inner {
  position: relative;
  z-index: 1;
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: space-between;
  gap: 24px;
  padding: clamp(40px, 12vh, 96px) 24px clamp(28px, 8vh, 64px);
  text-align: center;
}

.boot-brand {
  font-size: clamp(26px, 6.4vw, 40px);
  font-weight: 800;
  letter-spacing: -0.02em;
  color: var(--text);
  text-shadow: 0 0 26px rgba(48, 213, 176, 0.22);
}

.boot-brand-suffix {
  color: var(--accent);
}

.boot-center {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 20px;
  transition: opacity 260ms ease;
}

/* Idle = waiting for the unlock dialog (which sits on top); the spinner is only for the
   post-unlock loading phase. */
.boot-screen[data-phase="idle"] .boot-center {
  opacity: 0;
  visibility: hidden;
}

/* Pure-CSS spinner: ONLY transform/opacity animate, so the browser composites them on the GPU thread and they
   keep moving even while the main thread is blocked by the synchronous post-unlock crypto. No SVG filter (that
   forces main-thread paint and would freeze). The teal glow is a STATIC box-shadow that rides the composited
   transform. */
.boot-spinner {
  position: relative;
  width: clamp(84px, 22vw, 108px);
  height: clamp(84px, 22vw, 108px);
}

.boot-spinner-ring {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  border: 2.5px solid transparent;
  will-change: transform;
}

.boot-spinner-ring-outer {
  border-top-color: var(--accent);
  border-right-color: var(--accent);
  box-shadow: 0 0 10px rgba(48, 213, 176, 0.35);
  animation: bootSpin 1.4s linear infinite;
}

.boot-spinner-ring-inner {
  inset: 20%;
  border-bottom-color: rgba(48, 213, 176, 0.72);
  border-left-color: rgba(48, 213, 176, 0.72);
  animation: bootSpinReverse 2s linear infinite;
}

.boot-spinner-core {
  position: absolute;
  inset: 41%;
  border-radius: 50%;
  background: var(--accent);
  box-shadow: 0 0 12px rgba(48, 213, 176, 0.8);
  will-change: transform, opacity;
  animation: bootCorePulse 1.5s ease-in-out infinite;
}

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

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

@keyframes bootCorePulse {
  0%, 100% { transform: scale(0.72); opacity: 0.72; }
  50% { transform: scale(1.12); opacity: 1; }
}

.boot-loading-label {
  margin: 0;
  color: var(--muted);
  font-size: 14px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

.boot-tagline {
  margin: 0;
  max-width: 30ch;
  color: var(--muted);
  font-size: clamp(12px, 3.4vw, 14px);
  line-height: 1.5;
  opacity: 0.82;
}

@media (prefers-reduced-motion: reduce) {
  .boot-spinner-ring-outer,
  .boot-spinner-ring-inner {
    animation: none;
  }
  .boot-spinner-core {
    animation-duration: 2.4s;
  }
  .boot-screen {
    transition: none;
  }
}

/* Buy-ATH: the two conversion inputs sit side by side on a wide screen and stack on a narrow one. They are ONE
   number shown two ways, so they must read as a pair rather than as two independent questions. */
.buy-ath-pair {
  display: grid;
  gap: var(--space-3, 12px);
  grid-template-columns: 1fr 1fr;
}
@media (max-width: 480px) {
  .buy-ath-pair { grid-template-columns: 1fr; }
}

/* Buy-ATH input cards: the same shape as the Wallet tab's GRAM/ATH headline, which the owner pointed at as what
   money should look like here. Accent uppercase ticker above, the number big and bold below — the field reads as money rather than
   as a form control, and the ticker needs no translation. */
.buy-ath-card {
  display: grid;
  gap: 6px;
  padding: 14px 16px;
  border: 1px solid rgba(48, 213, 176, 0.16);
  border-radius: 14px;
  background: var(--surface, rgba(255, 255, 255, 0.03));
}
.buy-ath-card span {
  color: var(--accent);
  font-size: 11px;
  font-weight: 900;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}
.buy-ath-card input.buy-ath-input {
  width: 100%;
  padding: 0;
  border: 0;
  background: transparent;
  color: var(--text);
  /* Clamped like the headline card: a long value scales down instead of overflowing a narrow phone. */
  font-size: clamp(20px, 5.2vw, 30px);
  font-weight: 800;
  line-height: 1.1;
}
.buy-ath-card input.buy-ath-input:focus {
  outline: none;
}
.buy-ath-card:focus-within {
  border-color: rgba(48, 213, 176, 0.45);
}

/* Footnotes under the submit button — the explanation the title's asterisk points at. */
.action-footnotes {
  display: grid;
  gap: 8px;
  margin: 14px 0 0;
  padding-left: 18px;
  color: var(--muted);
  font-size: 12px;
  line-height: 1.5;
}

/* .ath names as property — the list in "My .ath names".
   THE ART LEADS: the registry draws each name on chain, and that picture is the whole reason this screen exists.
   It arrives as an on-chain SVG in a data: URI (see safeInlineImage) — img-src forbids a remote one, so nothing
   here can reach out to a host. Two columns from 420px so four names fit without a scroll. */
.nft-card-list {
  display: grid;
  grid-template-columns: 1fr;
  gap: 12px;
  max-height: 58vh;
  overflow-y: auto;
}

@media (min-width: 420px) {
  .nft-card-list {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}

.nft-card {
  display: flex;
  flex-direction: column;
  border: 1px solid var(--line);
  border-radius: 12px;
  background: var(--panel-2);
  overflow: hidden;
}

.nft-card-art {
  display: block;
  width: 100%;
  aspect-ratio: 1;
  object-fit: cover;
  background: var(--panel-3);
}

.nft-card-foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  padding: 10px 12px;
  min-width: 0;
}

.nft-card-name {
  font-size: 14px;
  overflow-wrap: anywhere;
  min-width: 0;
}

/* Matches every other button in the app (panel-2 fill, --line border, accent-tinted hover). The first version had
   no rule at all and fell through to the browser default — a grey slab in a dark themed dialog. */
.nft-card-send {
  flex: 0 0 auto;
  min-height: 32px;
  padding: 0 12px;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: var(--panel-3);
  color: var(--text);
  font-size: 13px;
  cursor: pointer;
}

.nft-card-send:hover {
  border-color: rgba(48, 213, 176, 0.42);
}

/* Quick start, top-up step: the address and its own copy button on one row. The step's primary button is Next now;
   copying belongs next to the thing being copied, not disguised as the way forward. */
.quick-start-address-row {
  display: flex;
  align-items: center;
  gap: 8px;
}

.quick-start-address-row .quick-start-step-address {
  flex: 1 1 auto;
  min-width: 0;
}

.quick-start-address-copy {
  width: 36px;
  height: 36px;
  flex: 0 0 auto;
}

