/* The typeface as a set of variables, so one class can take it back out again.

   THREE weights, not one, and that is the whole point of this block. A system
   monospace is TWO static faces: measured, 400 and 500 paint identically, and so do
   600, 700 and 800 - so "bold" is one enormous step (+42% ink) with nothing in
   between. JetBrains is a variable font, a real continuum, where 700 is genuinely
   700. Shipping the same numbers to both is what flattened the interface: with the
   body raised to 500 to match the old apparent weight, 700 was only +14% ink and the
   chrome that used to read as bold - the channel name, [EXIT], RELAYS, every heading
   - stopped standing out at all.

   So the numbers are named by their JOB (body, mid, bold) and each font gets the
   value that does that job in it. 500/700/800 against 400/600/700 lands the webfont
   at +31% body-to-bold, close to the fallback's +42% and a long way from +14%.

   The trailing 800 is the top of JetBrains' axis, which is the ceiling on how much
   of that gap can be closed while the body stays at 500.
   "sysFont" is the settings toggle switched off: the webfont comes off the front of
   the stack and the compensating weight goes with it, landing a reader exactly where
   they were before this font shipped. The @font-face rules below stay declared and
   cost nothing - a face nobody asks for is never fetched. */
:root {
	--font-stack: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
	--font-body-weight: 500;
	--font-mid-weight: 700;
	--font-bold-weight: 800;
}

html.sysFont {
	--font-stack: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
	--font-body-weight: 400;
	--font-mid-weight: 600;
	--font-bold-weight: 700;
}

/* --- the typeface -------------------------------------------------------------
   JetBrains Mono (SIL Open Font License 1.1, © 2020 The JetBrains Mono Project
   Authors - the full licence ships beside the files in /fonts/OFL.txt, which the
   OFL requires and which is why THIS font is here and not Apple's: SF Mono is
   licensed for use only with Apple-branded apps and may not be embedded to
   display content, so "just ship the one iOS picks" was never available).

   Shipped rather than borrowed from the system, because "the system monospace"
   is a different typeface on every platform: SF Mono on iOS and macOS, and on
   Windows, Linux and Android the ui-monospace keyword maps to nothing at all and
   the stack falls through to Consolas or Liberation Mono or whatever the vendor
   chose. One file means one client, everywhere.

   Self-hosted, not pulled from a font CDN: an app whose whole pitch is that
   nothing leaves your device should not be announcing every page load to Google.

   Six subsets, each with its own unicode-range, so a browser fetches only the
   scripts the text actually uses - 40KB of latin for most people, and the
   cyrillic and greek subsets only for the readers who need them. The variable
   weight axis (100-800) means one file per subset covers every weight in the
   stylesheet, including the 500 body and 700 bold below.

   Everything it does not cover - arabic, cjk, devanagari, emoji - falls through
   to the stack below exactly as before. No latin webfont carries those, and a cjk
   one is megabytes; the platform already has them. */
@font-face {
	font-family: "JetBrains Mono";
	font-style: normal;
	font-weight: 100 800;
	font-display: swap;
	src: url("/fonts/jetbrains-mono-cyrillic-ext.woff2") format("woff2");
	unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}

@font-face {
	font-family: "JetBrains Mono";
	font-style: normal;
	font-weight: 100 800;
	font-display: swap;
	src: url("/fonts/jetbrains-mono-cyrillic.woff2") format("woff2");
	unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}

@font-face {
	font-family: "JetBrains Mono";
	font-style: normal;
	font-weight: 100 800;
	font-display: swap;
	src: url("/fonts/jetbrains-mono-greek.woff2") format("woff2");
	unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}

@font-face {
	font-family: "JetBrains Mono";
	font-style: normal;
	font-weight: 100 800;
	font-display: swap;
	src: url("/fonts/jetbrains-mono-vietnamese.woff2") format("woff2");
	unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}

@font-face {
	font-family: "JetBrains Mono";
	font-style: normal;
	font-weight: 100 800;
	font-display: swap;
	src: url("/fonts/jetbrains-mono-latin-ext.woff2") format("woff2");
	unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

@font-face {
	font-family: "JetBrains Mono";
	font-style: normal;
	font-weight: 100 800;
	font-display: swap;
	src: url("/fonts/jetbrains-mono-latin.woff2") format("woff2");
	unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

:root {
	/* --- theme palette. These are BITCHAT, the default; the ten other themes
	   override some of them from the data-theme blocks below, and everything
	   further down derives from them via color-mix, so a theme only has to supply
	   its core colors.

	   The names in those blocks have to match THEMES in js/themes.js, which is what
	   the /theme command lists and what hashes user names into a themed band. A
	   theme present in one and not the other is either a name that cannot be
	   selected or a selection that paints nothing - so themeparity.mjs checks the
	   two lists against each other. --- */
	--bg: #000000;
	--fg: #8fe89c;
	--muted: #7a828c;

	/* the clickable channel/location prefix - blue reads as "navigate here" and
	   is the one palette slot not already claimed by identity (orange = you /
	   hashed user hues), the app's voice (green), or metadata (gray). */
	--geo: #5cb3ff;
	--user: #8ef29c;
	/* bitchat's dark-mode green (SwiftUI Color.green ~ systemGreen #30D158) */
	--accent: #30d158;
	/* soft red for problem states (failed send, assist off) */
	--danger: #ff7b72;

	/* follows the page unless a theme says otherwise - see the note in themes.js.
	   What separates chrome from content is --chrome-border, not this. */
	--chrome-bg: var(--bg);

	/* --- surface tuning: dark-theme defaults; light themes override these too --- */
	--scanline-color: rgba(0, 0, 0, 0.16); /* CRT hairlines (body::before) */
	--vignette-color: rgba(0, 0, 0, 0.32); /* corner falloff (body::after) */
	--scrim: rgba(0, 0, 0, 0.82); /* backdrop behind gates/popups */
	/* the height of a button docked below a card ([ back ], the gates' [ close ]):
	   12px of text plus 6px of padding either side */
	--card-back-h: 30px;
	--selection-fg: #eaffea; /* text color inside ::selection */
	--field-bg: rgba(0, 0, 0, 0.18); /* inset inputs + the new-messages bar */
	/* The same recess, composited rather than translucent. A chamfered outline is
	   two stacked layers - the edge colour with the fill inset 1px on top - so the
	   fill has to be opaque or the edge shows through the whole box instead of just
	   its rim. Mixed against --chrome-bg rather than hardcoded, so a light theme
	   still gets a recess rather than a black hole. */
	--field-solid: color-mix(in srgb, #000 18%, var(--chrome-bg));
	--overlay-fg: rgba(255, 255, 255, 0.92); /* text on image overlays */
	--overlay-shadow: 0 1px 2px rgba(0, 0, 0, 0.45);
	--bubble-theirs: rgba(255, 255, 255, 0.03); /* their DM bubble fill */

	/* bottom safe-area with a js-supplied floor: standalone ios can report a
	   zero bottom inset while the window really does reach the home indicator
	   (same launch-bug family as the misreported viewport height), which sank
	   the composer into the indicator. fitViewport sets --standalone-inset-b
	   when it detects that state; max() means an honest env() always wins, so
	   browser/android layouts are untouched. */
	--inset-b: max(env(safe-area-inset-bottom, 0px), var(--standalone-inset-b, 0px));

	/* --- derived: never set by themes, always follow --accent --- */
	--chrome-border: color-mix(in srgb, var(--accent) 14%, transparent);
	--chrome-border-strong: color-mix(in srgb, var(--accent) 38%, transparent); /* hover/focus edge for bordered controls */
	--glow: 0 0 9px color-mix(in srgb, var(--accent) 42%, transparent); /* phosphor bleed, reserved for chrome (never body text) */

	/* css-generated labels, overridden by the i18n layer at runtime. english
	   defaults here so they still read correctly if scripting is disabled. */
	--label-toggle-on: "[ on ]";
	--label-toggle-off: "[ off ]";
	--label-no-users: "no one here yet";
	--label-dm-empty: "no messages yet";
	--label-no-convos: "no conversations yet";
	--label-no-pings: "no pings yet";
}

/* --- the themes ------------------------------------------------------------------

   Everything above is BITCHAT, the default. Everything here is one of the other ten,
   selected by a `data-theme` attribute on <html>.

   This is where the palettes live, and the reason they live here rather than in
   js/themes.js is timing. They used to be a table in that module, written onto
   documentElement.style at boot - but themes.js is imported by app.js, app.js pulls
   nostr-tools off the network, and nothing in that graph runs until the fetch lands.
   Measured on a warm cache: the saved theme reached the page 259ms AFTER first paint,
   so every load began with a frame of somebody else's colours - which is what the
   "connecting..." topbar was flashing.

   A stylesheet has no such problem. It is render-blocking by definition: the browser
   will not paint until it has this file, so a theme written here is on the FIRST
   frame. All the boot script in index.html has to do is set one attribute, which it
   can do synchronously, before this file has even finished loading.

   js/themes.js still owns the half that is not colour - the per-user name bands and
   the reserved "you" colour - and applyTheme is now just the attribute write. So each
   value is written down once: colours here, identity there.

   The default names no block of its own on purpose. :root IS bitchat, so an unknown
   or missing data-theme falls back to it rather than to nothing. --- */

/* cyberpunk 2077: cyan data readouts and hot-pink chrome. Ported value-for-value from
   the original prototype, which is why the accent is pink rather than the game's menu
   yellow - that yellow survives as the reserved "you" colour in themes.js. */
html[data-theme="cyberpunk-2077"] {
	/* OLED black: the pixels are OFF, not showing a very dark plum. The tint that
	   made this bg a plum now lives only in the panel above it, which is where it
	   can still be seen. */
	--bg: #000000;
	--fg: #d7d7dc;
	--muted: #7e7e88;
	--geo: #2de2e6;
	--accent: #ff2a6d;
	--danger: #ff5a5a;
	/* not in the prototype (it has no selection colour); tinted from the accent the
	   way every other theme here derives its own. */
	--selection-fg: #ffe3ee;
}

/* blade runner 2049: joi's blossom pink against K's violet LA haze. */
html[data-theme="blade-runner-blossom"] {
	--bg: #0a0712;
	--fg: #e4d9f2;
	--muted: #857a9e;
	--geo: #b48cff;
	--accent: #ff6ac2;
	--selection-fg: #ffeaf6;
}

/* ex machina: the lab's red emergency lighting during the power cuts. danger becomes
   an amber alarm, because red is the ambient here and could not also be the alert. */
html[data-theme="ex-machina"] {
	--bg: #0b0507;
	--fg: #f2d9d9;
	--muted: #93706e;
	--geo: #ff8a5c;
	--accent: #ff3b30;
	--danger: #ffd60a;
	--selection-fg: #ffecec;
}

/* dune part two, giedi prime: the black sun's infrared monochrome - a world with no
   colour at all. */
html[data-theme="giedi-prime"] {
	--bg: #050505;
	--fg: #ededed;
	--muted: #878787;
	--geo: #bdbdbd;
	--accent: #e6e6e6;
	--selection-fg: #ffffff;
}

/* the matrix: code-rain green on true black.
   Black is #000000 rather than a near-black tint, so an OLED panel switches those
   pixels off instead of lighting them a couple of points above nothing.
   The greens run cold: every colour is its old hue pushed toward cyan at the same
   saturation and lightness, so the theme is recognisably itself and just chillier.
   --geo is pushed furthest, so the channel colour stays distinct from the accent now
   that the accent has moved into its old range. */
html[data-theme="matrix-neo"] {
	--bg: #000000;
	--fg: #a8ffdf; /* hue 131 -> 158 */
	--muted: #567b70; /* 138 -> 162 */
	--geo: #80fffb; /* 158 -> 178 */
	--accent: #00ffa1; /* 144 -> 158 */
	--selection-fg: #e5fff6;
}

/* guardians of the galaxy: quill's amber-gold 70s haze, milano engine cyan for
   chrome. */
html[data-theme="star-lord"] {
	--bg: #0c0806;
	--fg: #f2ddb8;
	--muted: #8e7d67;
	--geo: #ff8c42;
	--accent: #3fd8ff;
	--selection-fg: #eafaff;
}

/* loki's TVA: mid-century amber CRTs and miss minutes orange. */
html[data-theme="time-variance-amber"] {
	--bg: #0b0906;
	--fg: #ffd98c;
	--muted: #97845f;
	--geo: #ffc861;
	--accent: #ff9e1f;
	--danger: #ff6b5e;
	--selection-fg: #fff3d9;
}

/* tron legacy: the grid's ice blue and identity-disc cyan. */
html[data-theme="tron-legacy"] {
	--bg: #04070c;
	--fg: #cdeeff;
	--muted: #5f7f8e;
	--geo: #5ce1ff;
	--accent: #00b8ff;
	--selection-fg: #eaf9ff;
}

/* shared tuning for the two light themes: fainter CRT glass, a bright scrim, and
   dark-on-light inputs and overlays. Each theme's own block follows and overrides
   from here, so this has to come first. */
html[data-theme="lumon-light"],
html[data-theme="her-light"] {
	--scanline-color: rgba(0, 0, 0, 0.05);
	--vignette-color: rgba(0, 0, 0, 0.10);
	--field-bg: rgba(0, 0, 0, 0.06);
	--overlay-fg: rgba(0, 0, 0, 0.75);
	--overlay-shadow: none;
	--bubble-theirs: rgba(0, 0, 0, 0.05);
}

/* severance: lumon's milky office light and MDR terminal teal. */
html[data-theme="lumon-light"] {
	--bg: #eef0ea;
	--fg: #24424a;
	--muted: #6c8189;
	--geo: #2f7d97;
	--accent: #106a74;
	--danger: #b3362b;
	--chrome-bg: #e3e8e1;
	--scrim: rgba(230, 235, 228, 0.88);
	--selection-fg: #143c42;
}

/* her: theodore's coral-and-cream world. */
html[data-theme="her-light"] {
	--bg: #f7efe6;
	--fg: #6b4a3f;
	--muted: #a2867a;
	--geo: #b04a36;
	--accent: #e0654f;
	--danger: #a63324;
	--chrome-bg: #f0e4d6;
	--scrim: rgba(247, 239, 230, 0.88);
	--selection-fg: #4a2f28;
}

/* --- `hidden` means hidden -------------------------------------------------------

   The browser's own `[hidden] { display: none }` lives in the USER-AGENT stylesheet,
   which every author rule outranks. So any element whose class or id sets `display` -
   and in a flexbox layout that is most of them - goes on painting while reporting
   hidden === true, and the js that set it is not wrong about anything. It is a silent
   failure with nothing to see in the debugger.

   The patron category shipped that way: `.usersBarrier { display: flex }` quietly beat
   the attribute, so the heading and its two rules stayed on screen over a field that
   had correctly hidden itself. An audit found two more already in the tree
   (#profileNpub, #confirmPreview - the latter guarded by `.actionPreview[hidden]` that
   its own id rule outranks) and thirty-odd others one toggle away from it.

   One rule instead of a guard per element, and `!important` because that is the only
   thing an id selector cannot outrank. It costs nothing: an element that is `hidden`
   and also meant to be visible is not a thing. */
[hidden] {
	display: none !important;
}

html,
body {
	margin: 0;
	padding: 0;
	width: 100%;
	height: 100%;
	background: var(--bg);
	color: var(--fg);
	/* JetBrains Mono first, then the old system stack unchanged behind it - so a blocked or failed font load lands exactly
	   where this used to and nothing looks broken.
	   SFMono-Regular and "SF Mono" stay in the list but have never matched
	   anything: SF Mono is not requestable by name from web content on Apple
	   platforms, and ui-monospace ahead of them is the only door to it.
	   Held in a variable so the settings toggle can drop the webfont without this
	   rule knowing anything about it - see html.sysFont at the top. */
	font-family: var(--font-stack);
	/* Ligatures OFF, and not as a matter of taste. JetBrains Mono ships programming
	   ligatures enabled, so "->" draws as a single arrow, "!=" as "≠", ">=" as "≥".
	   That is a good thing in an editor and the wrong thing here: this is a chat
	   client, where what somebody typed and what everybody else reads have to be the
	   same characters. It also mangles the two things this app is full of - pasted
	   keys and ascii art - and it would quietly rewrite the composer's own
	   "message -> #geo" placeholder. One line to undo if it is ever wanted. */
	font-variant-ligatures: none;
	/* A touch above regular. A webfont at 400 renders lighter than the SF Mono it
	   replaces - partly the typeface, partly that ios darkens stems in a way chrome
	   on android and the desktop do not - so the client looked thinner than
	   it used to on exactly the platform most people read it on. 500 puts the
	   weight back without approaching the 700 the bolds use, and costs nothing:
	   the variable axis carries it in the same file.
	   It travels WITH the webfont: the extra weight exists to compensate for that
	   typeface, so a reader who switches it off should get their system font at its
	   own normal weight, not a bolded version of it.
	   The handful of rules that set 400 explicitly are de-emphasis (a muted alias,
	   a select's options) and sit a step BELOW body while the webfont is on, which
	   is what they were always asking for. */
	font-weight: var(--font-body-weight);
	overflow: hidden;
}

/* CRT atmosphere: hairline scanlines + a corner vignette laid over the whole
   screen (gates included). both are static gradients - one composited layer,
   no animation, no repaints - and faint enough to feel rather than see.

   the whole layer rides the settings "retro terminal" toggle: js keeps a
   `retro` class on <html>, and everything CRT-flavored (scanlines, vignette,
   phosphor glow, the name-gate typewriter) gates on it. off = the same layout
   in a clean modern skin. */
.retro body::before {
	content: "";
	position: fixed;
	inset: 0;
	z-index: 9000; /* above the gates (1000): the glass sits over everything */
	pointer-events: none;
	background: repeating-linear-gradient(
		0deg,
		var(--scanline-color) 0px,
		var(--scanline-color) 1px,
		transparent 1px,
		transparent 3px
	);
}

.retro body::after {
	content: "";
	position: fixed;
	inset: 0;
	z-index: 9001;
	pointer-events: none;
	background: radial-gradient(ellipse at center, transparent 62%, var(--vignette-color) 100%);
}

/* modern mode: no phosphor bleed anywhere (--glow feeds every chrome
   text-shadow), no blinking block cursor after the handle, no relay status dot
   (the n/m count still tells the story), and the typewriter mask never
   animates - its resting transform leaves the text plainly visible. */
html:not(.retro) {
	--glow: none;
}

html:not(.retro) .cursor,
html:not(.retro) .dot {
	display: none;
}

html:not(.retro) #nameGate.typing .gateSub span::after {
	animation: none;
}

/* phosphor terminal touches: green-tinted selection, accent caret in inputs */
::selection {
	background: color-mix(in srgb, var(--accent) 30%, transparent);
	color: var(--selection-fg);
}

input,
textarea {
	caret-color: var(--accent);
}

/* thin accent-tinted scrollbars instead of the browser chrome defaults */
* {
	scrollbar-width: thin;
	scrollbar-color: color-mix(in srgb, var(--accent) 22%, transparent) transparent;
}

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

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

*::-webkit-scrollbar-thumb {
	background: color-mix(in srgb, var(--accent) 22%, transparent);
	border-radius: 3px;
}

*::-webkit-scrollbar-thumb:hover {
	background: color-mix(in srgb, var(--accent) 40%, transparent);
}

#app {
	/* fallback only: at runtime js sizes this to the measured visualViewport
	   height, because ios standalone misreports dvh (see fitViewport in app.js) */
	height: 100dvh;
	display: flex;
	flex-direction: column;
	position: relative; /* containing block for the DM thread gate, so its composer rides the keyboard like #inputbar */
}

#topbar {
	display: flex;
	align-items: center;
	justify-content: space-between;
	gap: 10px;
	padding: calc(10px + env(safe-area-inset-top)) 12px 10px;
	background: var(--chrome-bg);
	border-bottom: 1px solid var(--chrome-border);
	color: var(--accent);
	font-size: 13px;
	letter-spacing: 0.08em;
	text-transform: uppercase;
	text-shadow: var(--glow); /* phosphor bleed on the chrome only - body text stays crisp */
}

/* the brand takes the slack and ellipsizes a long handle; the relay status never
   shrinks or wraps, so the row stays a clean single line at any name length */
.brand {
	cursor: pointer;
	min-width: 0;
	overflow: hidden;
	text-overflow: ellipsis;
	white-space: nowrap;
}

#status {
	flex: none;
	white-space: nowrap;
}

/* blinking block cursor after your handle - the terminal's heartbeat. its blink
   phase resets whenever the topbar rerenders, so it holds solid during bursts of
   activity and settles into a blink when things go quiet (like a real tty). */
.cursor {
	display: inline-block;
	width: 0.5em;
	height: 0.72em; /* ~cap height */
	margin-left: 4px;
	/* sit the block on the text baseline so it fills the glyph cell and lines up
	   with the letters, instead of text-bottom which dropped it into the descender
	   gap and read low */
	vertical-align: baseline;
	background: var(--accent);
	animation: blink 1.2s steps(1) infinite;
}

@keyframes blink {
	50% {
		opacity: 0;
	}
}

/* relay link state: a solid dot while we hold connections, a dim pulsing one
   while we have none (searching) */
.dot {
	display: inline-block;
	width: 7px;
	height: 7px;
	margin-right: 7px;
	border-radius: 50%;
	vertical-align: 6%;
	background: var(--accent);
	box-shadow: 0 0 6px color-mix(in srgb, var(--accent) 70%, transparent);
}

.dot.off {
	background: var(--muted);
	box-shadow: none;
	animation: dotSearch 1.6s ease-in-out infinite;
}

@keyframes dotSearch {
	50% {
		opacity: 0.25;
	}
}

/* your handle, and the channel name, show verbatim - the topbar otherwise
   force-uppercases (so a case-sensitive channel reads faithfully) */
.handle,
.chan {
	text-transform: none;
}

/* the status is always interactive now: [EXIT] when focused, settings otherwise */
#status {
	cursor: pointer;
}

#terminal {
	flex: 1;
	min-height: 0;
	overflow-y: auto;

	/* THE LOG NEVER MOVES SIDEWAYS.
	   `overflow-y: auto` alone makes the other axis compute to `auto` as well, so
	   anything too wide to wrap - ascii art, a drawing, a board on a very narrow
	   phone - turned the whole log into a horizontally scrollable surface. Every row
	   then shifts together, the timestamps and names slide off the left edge, and a
	   log you have to drag back into place is worse than one with a picture cropped
	   in it.
	   `clip` would be the honest value here and is NOT usable: a box that scrolls on
	   one axis forces the other to a scrollable value too, so `clip` alongside
	   `overflow-y: auto` computes to `hidden` (measured, not assumed). `hidden` still
	   makes this a scroll container - it stops the reader dragging it, but the browser
	   can still shift it (focus, scrollIntoView) with no way to put it back.
	   So the guarantee is one level down, on .line: every row crops its own overflow,
	   nothing ever overflows this box, and there is no sideways position to scroll to.
	   This line is the backstop for anything that slips past that. */
	overflow-x: hidden;
	display: flex;
	flex-direction: column;
	padding: 12px 14px;
	/* Room at the foot for the digest bar, which - unlike [new messages] - does not
	   clear itself when you reach the bottom. Without this it sat permanently on top of
	   the newest line. Set from js to the COLLAPSED row's height (see setQuietBarInset)
	   and 0 whenever the bar is not up, so nothing is reserved for a bar nobody has.
	   AFTER the shorthand above, which would otherwise reset it - which is exactly what
	   it was doing, leaving a flat 12px that never changed. */
	padding-bottom: calc(12px + var(--quiet-bar-h, 0px));
	white-space: pre-wrap;
	word-break: break-word;
	font-size: 14px;
	/* The log stays ltr even when the ui is rtl (arabic). Every row is a record
	   with a fixed gutter - "[hh:mm:ss] <@name#tag> body" - and under an rtl
	   paragraph the bidi algorithm reorders that whole prefix: the clock lands on
	   the far side, the angle brackets mirror, and the columns stop lining up. A
	   terminal log is a table of records, not prose, so the frame is held ltr and
	   the CONTENT is what's allowed to flip - see the plaintext rule below. */
	direction: ltr;
}

/* Anything a person typed picks its own direction from its first strong
   character, the way `dir="auto"` does, and is isolated from its neighbours so
   it can't drag the surrounding punctuation around with it. That is what lets an
   arabic message read right-to-left inside a left-to-right row - and equally,
   an english message read correctly inside the rtl dm panel.

   .ts is in here because it wears two hats: the trailing clock AND the body of a
   system line. On the clock it's a no-op (no strong characters, so it keeps the
   container's direction); on a translated system line it's the whole point. */
.msg,
.ts,
.noteBody,
.dmMsg,
#chatInput,
#dmInput,
#notesInput {
	unicode-bidi: plaintext;
}

/* anchor messages to the bottom so the newest sit just above the input (and
   ride up with the keyboard) instead of a short backlog hanging at the top out
   of reach. The auto-margin spacer collapses to 0 once content overflows, so
   scrolling stays fully accessible. */
#terminal::before {
	content: "";
	margin-top: auto;
}

.line {
	/* #terminal is a flex column, so lines are flex items and would shrink to fit
	   an over-full view (the global feed) instead of letting it scroll. Plain lines
	   survive that visually - a flex item's automatic minimum size floors them at
	   their text height - but that floor only applies while overflow is VISIBLE, so
	   a decorated line that clips (see .flair) collapses to its padding and its text
	   disappears. Pin every line to its natural height: correct for a scrolling log,
	   and it keeps any future clipping decoration safe. */
	flex: none;
	margin: 0 0 4px 0;
	/* ...and no row may widen the log. #terminal wraps and breaks words, so ordinary
	   text - urls, CJK, emoji - never gets here; what does is the handful of things
	   that CANNOT wrap: a nowrap span, a game board narrower screens can't fit, ascii
	   someone drew wider than a phone. Those get cropped at the row's own edge rather
	   than pushing the whole column sideways.
	   `clip` on x with `visible` on y, which is the one legal mix of the two. It has
	   to be that pair: `hidden` on x would drag y along with it and make every row a
	   scroll container, cutting off the flair decoration that deliberately spills
	   above and below its line. `clip` crops without scrolling and leaves y alone.
	   The clip margin then lets sideways glow out as well - it widens the paint
	   boundary only, and creates no scrollable overflow (24px clears neon's widest
	   bloom, its outermost shadow being ~21px). */
	overflow-x: clip;
	overflow-y: visible;
	overflow-clip-margin: 24px;
}

/* live arrivals materialize with a short fade+rise; rerenders (channel hops,
   repaints) skip it so the whole backlog never re-animates. */
.line.arrive {
	animation: lineIn 0.18s ease-out;
}

@keyframes lineIn {
	from {
		opacity: 0;
		transform: translateY(3px);
	}
}

/* ephemeral status notices fade out before they're removed from the log */
.line.system {
	transition: opacity 0.3s ease;
}

.line.system.fading {
	opacity: 0;
}

/* messages that @-mention you: full-bleed across the terminal's horizontal
   padding (14px) so the highlight reaches the screen edges. The tint color is
   the sender's own color, set inline in JS. */
.line.mention {
	margin-left: -14px;
	margin-right: -14px;
	padding: 2px 14px;
}

.geo {
	color: var(--geo);
	cursor: pointer;
	text-decoration: underline;
	text-underline-offset: 2px;
	text-decoration-thickness: 1px;
	transition: filter 0.12s ease;
}

.geo:hover {
	filter: brightness(1.35);
}

/* "+7": how many messages the global feed passed over from this channel before
   drawing this line. Deliberately quiet - it is a density readout, not part of the
   message - but it sits tight against the channel name, because what it counts
   belongs to the channel rather than to the person speaking. Not underlined and
   not a tap target: the chip beside it is the thing you click. */
.geoMore {
	color: var(--muted);
	font-size: 0.82em;
	margin-left: 2px;
	vertical-align: 1px;
}

/* The quiet-channels digest: one folded row standing in for a burst of messages from
   channels with no history.
   Written as an aside, not an announcement. It is smaller than a message, muted to the
   same grey as a timestamp, and carries no "*" emphasis - because the thing it is
   reporting is precisely that nothing here demanded your attention. A notice that
   shouted about being flooded would be the flood arriving in a different costume. */
.quietDigest {
	display: inline-flex;
	align-items: baseline;
	gap: 6px;
	color: var(--muted);
	/* the same size as [new messages] directly beneath it - they are two notices on one
	   surface, and two type sizes made them read as two unrelated things */
	font-size: 0.9em;
	cursor: pointer;
	opacity: 0.8;
	transition: opacity 0.12s ease;
	-webkit-tap-highlight-color: transparent;
}

.quietDigest:hover {
	opacity: 1;
}

.quietMark {
	font-size: 0.9em;
}

.quietLabel {
	overflow-wrap: anywhere;
}

/* the channels themselves, once unfolded: a wrapped row of ordinary #geo chips, so
   each one is the same tappable thing it is anywhere else in the feed.
   It sits ABOVE its own toggle in the markup, because the bar is anchored to the foot
   of the feed - a list that opened downward would open into the composer. */
.quietGeos {
	display: block;
	padding: 2px 0 4px;
	font-size: 0.9em;
	/* Two independent limits, because a flood can name a great many rooms and neither
	   alone is enough. The chip COUNT is capped in js (QUIET_MAX_CHIPS) so the dom
	   never holds a thousand nodes; this caps the HEIGHT, so even sixty chips on a
	   narrow phone scroll inside the panel instead of swallowing the feed. */
	max-height: calc(var(--vvh, 100vh) * 0.3);
	overflow-y: auto;
	overscroll-behavior: contain;
}

/* One row per channel: where it was said on the left, what was said pinned to the
   right. A column of bare geohashes says nothing about whether you wanted any of it,
   which is the only question a reader has here. */
.quietRow {
	display: flex;
	align-items: baseline;
	gap: 10px;
	padding: 3px 12px;
}

.quietRow:hover {
	background: color-mix(in srgb, var(--accent) 7%, transparent);
}

.quietRowGeo {
	flex: none;
}

/* the preview: one line, clipped, never wrapping - it is a glimpse, and a row that
   wrapped would turn this list into the feed it is standing in for */
.quietRowText {
	flex: 1 1 auto;
	min-width: 0;
	overflow: hidden;
	white-space: nowrap;
	text-overflow: ellipsis;
	text-align: end;
	color: var(--muted);
	opacity: 0.85;
}

/* "+940 more" - the list is capped, and says so rather than just stopping */
.quietMore {
	display: block;
	padding: 4px 12px 2px;
	color: var(--muted);
	opacity: 0.8;
	white-space: nowrap;
	text-align: end;
}

.quietGeos[hidden] {
	display: none;
}

/* The bar itself: a fixture at the foot of the feed rather than a row inside it.
   Same neutral fill and blur as [new messages] directly beneath it, so the two read
   as one family of notices hovering over the same edge - but muted rather than accent,
   because one is an action and this is an aside. */
/* Transparent: the surface underneath both notices is #bottomBars (see there). */
#quietBar {
	background: none;
	user-select: none;
}

#quietBar[hidden] {
	display: none;
}

/* the same padding [new messages] uses, so the two lines sit on the same rhythm */
#quietBarToggle {
	display: flex;
	width: 100%;
	align-items: baseline;
	gap: 6px;
	padding: 8px 12px;
	border: 0;
	background: transparent;
	font: inherit;
	/* AFTER the shorthand, which resets size to inherit and was quietly undoing the
	   0.9em on .quietDigest - leaving this notice a size larger than the one beneath it.
	   The same trap the log's padding fell into. */
	font-size: 0.9em;
	text-align: start;
}

/* a hairline between the two notices ONLY when both are up: they are one surface, but
   they are still two different things and a shared edge with no articulation at all
   reads as one run-on sentence. Inset from the edges so it is a divider rather than a
   second border. */
#quietBar:not([hidden]) + #newMessagesBar:not([hidden]) {
	border-top: 1px solid color-mix(in srgb, var(--chrome-border) 55%, transparent);
}

.inlineLink,
.inlineGeo {
	color: inherit;
	text-decoration: underline;
	text-underline-offset: 2px;
	text-decoration-thickness: 1px;
	cursor: pointer;
	transition: filter 0.12s ease;
}

.inlineLink:hover,
.inlineGeo:hover {
	filter: brightness(1.35);
}

.inlineGeo {
	font-weight: var(--font-bold-weight);
}

/* An inline nostr: reference to a PERSON, drawn as their name.
   Styled like the #geo chip beside it rather than like a link: both are references
   to somewhere in this app, and neither leaves it. The accent colour is what says
   "this is a handle" - the same signal a name in a message header carries. */
.inlineMention {
	color: var(--accent);
	font-weight: var(--font-bold-weight);
	cursor: pointer;
	overflow-wrap: anywhere;
	transition: filter 0.12s ease;
}

.inlineMention:hover {
	filter: brightness(1.35);
}

/* ...and a reference we could not turn into anything better: an naddr, or a quote
   with nowhere to draw a card. Muted, because it is a placeholder standing where
   sixty characters of bech32 used to be - visible as a reference, not pretending to
   be content. */
.inlineRef {
	padding: 0 4px;
	border: 1px solid var(--chrome-border);
	border-radius: 2px;
	color: var(--muted);
	font-size: 0.85em;
}

/* bitchat weights: a medium name over a regular body, for everyone. Your own
   messages used to be fully bold on top of that (see .line.mine below, where the
   rule was and why it went); they are weighted like everyone else's now and marked
   only by their color. */
.user {
	font-weight: 500;
}

.msg {
	color: var(--fg);
}

/* a message made of nothing but emoji renders large - at body size a lone reaction
   reads as a typographic accessory rather than the gesture it was meant to be.
   Two sizes (see emojiScaleClass): fewer emoji, more room.

   inline-block + line-height:1 is what keeps the row from ballooning. Left inline,
   the glyph's own leading is added on top of the line's, so a 2.4em emoji pushes
   the row far taller than the emoji itself; taking it out of the inline flow and
   giving it a tight line box means the row grows by the glyph and nothing more.
   The small vertical-align nudge sits it on the run of text rather than hanging it
   off the baseline, so the "<@name#tag>" beside it stays visually attached. */
.emojiBig,
.emojiMed {
	display: inline-block;
	line-height: 1;
	vertical-align: -0.12em;
	/* emoji are color glyphs, and "&g" paints text by clipping a gradient to it -
	   which turns them into gradient silhouettes. Barely noticeable at body size,
	   glaring at 2.4em, and it throws away the only thing a jumbo emoji is for. The
	   fill is re-anchored so they always render as themselves.

	   An explicit color, not currentColor: "&g" sets `color: transparent` as well
	   as the fill, and color is inherited - so currentColor here resolves to the
	   very transparency being undone. (The pay/ui chips can use currentColor safely
	   because they declare a color of their own.) */
	color: var(--fg);
	-webkit-text-fill-color: var(--fg);
}

.emojiBig {
	font-size: 2.4em;
}

.emojiMed {
	font-size: 1.6em;
}

/* Minecraft "&"-code animated formatting (see format.js). &g rainbow: a hue
   gradient clipped to the text that flows sideways. background-clip:text needs
   transparent fill; the gradient repeats so the scroll loops seamlessly. */
.fmtRainbow {
	background-image: repeating-linear-gradient(
		90deg,
		#ff5555 0%,
		#ffcc00 8%,
		#55ff55 16%,
		#55ffff 24%,
		#5b8dff 32%,
		#c74ddb 40%,
		#ff5555 48%
	);
	background-size: 200% 100%;
	-webkit-background-clip: text;
	background-clip: text;
	-webkit-text-fill-color: transparent;
	color: transparent;
	animation: fmtRainbow 6s linear infinite;
	/* keep an underline/strike visible even though the glyph fill is transparent */
	text-decoration-color: #ff8fb0;
}
@keyframes fmtRainbow {
	to {
		background-position: 200% 0;
	}
}

/* An emoji inside a colored or gradient run, kept as itself.
   -webkit-text-fill-color and background-clip:text both overrule a color font and
   flatten an emoji into a solid shape, so a run that sets either has to hand the
   emoji back their own painting. Everything the parent could be doing to the glyph
   fill is undone here; the parent's bold/italic/underline still apply, which is
   right - those don't destroy the emoji. "&s" makes format.js stop emitting this
   wrapper, and the silhouette comes back. */
.fmtEmoji {
	/* var(--fg), not `initial`: initial resolves to BLACK, which is invisible on this
	   background the moment a matched sequence renders as a monochrome glyph rather
	   than a color one (keycaps and a few dingbats do). A color emoji paints from
	   its own palette either way - what actually rescued it is dropping the
	   background-clip mask below. */
	color: var(--fg);
	-webkit-text-fill-color: var(--fg);
	background: none;
	-webkit-background-clip: border-box;
	background-clip: border-box;
	animation: none;
}

/* &g is a WILDCARD, not literally "rainbow": with no flair equipped it's the rgb
   gradient above, but an equipped flair retunes it to its own palette, so your
   text and your row read as one effect. This needs no JS and no protocol change -
   the flair class is on the line and the &g span is inside it, so a descendant
   selector is the whole mechanism. Only the gradient (and its pace) is swapped;
   the scroll keyframes are shared, and the reduced-motion rule below still wins
   because it zeroes animation-name, leaving the themed gradient as a static fill. */
/* fire's &g reads as text lit BY the fire: the ember gradient keeps rolling, but
   the brightness huffs on the same uneven catch-and-settle curve as the row's glow,
   so the words swell and dim with the flame instead of scrolling at a fixed pace.
   Overriding animation-name here outranks the generic .fmtRainbow rule, so the
   reduced-motion block re-disables this selector by name. */
.flair-fire .fmtRainbow {
	background-image: repeating-linear-gradient(
		90deg,
		#ff3d00 0%,
		#ff7a18 8%,
		#ffb057 16%,
		#ffe6a3 24%,
		#ffb057 32%,
		#ff6a00 40%,
		#ff3d00 48%
	);
	animation: fmtEmberText var(--fire-text-dur, 4.4s) linear var(--fire-text-delay, 0s) infinite;
	text-decoration-color: #ffb057;
}

@keyframes fmtEmberText {
	0% {
		background-position: 0 0;
		filter: brightness(0.88) saturate(1);
	}
	9% {
		filter: brightness(1.42) saturate(1.15);
	}
	34% {
		filter: brightness(0.92) saturate(1);
	}
	46% {
		filter: brightness(1.24) saturate(1.1);
	}
	61% {
		filter: brightness(0.85) saturate(0.95);
	}
	72% {
		filter: brightness(1.38) saturate(1.15);
	}
	100% {
		background-position: 200% 0;
		filter: brightness(0.88) saturate(1);
	}
}

/* lightning's &g doesn't scroll - it STRIKES. A drifting gradient would fight the
   flair's whole idea, so the text sits dim and storm-blue and flares white on the
   same uneven double-tap cadence as the row's flashes (randomized per line via
   --lit-text-*). The gradient still creeps a little so the glow isn't static.
   Overriding animation-name here would outrank the reduced-motion rule below, so
   that block re-disables this selector explicitly. */
.flair-lightning .fmtRainbow {
	background-image: repeating-linear-gradient(
		90deg,
		#00a6ff 0%,
		#3fc9ff 12%,
		#a5ecff 24%,
		#ffffff 33%,
		#3fc9ff 42%,
		#00a6ff 48%
	);
	animation: fmtStormText var(--lit-text-dur, 7s) linear var(--lit-text-delay, 0s) infinite;
	text-decoration-color: #a5ecff;
}

@keyframes fmtStormText {
	0%,
	100% {
		background-position: 0 0;
		filter: brightness(0.62);
	}
	7% {
		filter: brightness(0.62);
	}
	8.5% {
		filter: brightness(1.85);
	}
	10% {
		filter: brightness(0.7);
	}
	11.5% {
		filter: brightness(1.4);
	}
	14%,
	48% {
		filter: brightness(0.62);
	}
	49.5% {
		filter: brightness(2.1);
	}
	51% {
		filter: brightness(0.72);
	}
	52.5% {
		filter: brightness(1.6);
	}
	56% {
		filter: brightness(0.62);
	}
	99.9% {
		background-position: 140% 0;
	}
}

/* rain's &g is the only wildcard that flows DOWN, matching the flair's motion: a
   vertical gradient scrolled along the y axis (hence the flipped background-size),
   so the text looks like water running over it. Overrides animation-name, so the
   reduced-motion block disables this selector by name too. */
.flair-rain .fmtRainbow {
	/* One period per TILE. A repeating-gradient at background-size:auto is sized to
	   the element, so its last repeat is cut wherever the box ends and the next tile
	   restarts mid-period - the seam that made the scroll snap back. Sizing the tile
	   to exactly the 26px period instead (first and last stop the same color) means
	   the tiles butt up perfectly, and a 26px scroll lands exactly one tile along:
	   a true seamless loop. */
	background-image: linear-gradient(
		180deg,
		#3f7fc4 0%,
		#6fb2e8 23%,
		#c4e7ff 42%,
		#8ecbf2 62%,
		#5595d4 81%,
		#3f7fc4 100%
	);
	background-size: 100% 26px;
	background-repeat: repeat;
	animation: fmtRainText var(--rain-text-dur, 3.2s) linear var(--rain-text-delay, 0s) infinite;
	text-decoration-color: #a9d6f5;
}

/* INCREASING background-position-y slides the image down the box, so the colors
   travel downward with the rain. (Going 0 -> 100% instead scrolls the viewport down
   THROUGH the image, which makes the pattern appear to climb - the bug this fixes.) */
@keyframes fmtRainText {
	0% {
		background-position: 0 0;
		filter: brightness(0.95);
	}
	50% {
		filter: brightness(1.16);
	}
	100% {
		background-position: 0 26px;
		filter: brightness(0.95);
	}
}

/* plume's &g runs the same way the flair does: blown-out at the start of the run
   and falling away to deep violet, with the hot band sweeping along it so the glyphs
   look lit from the left as the plume moves. The brightness lift is what sells the
   overexposure - the same clipping the puffs get from overlapping, applied to type.
   Overrides animation-name, so the reduced-motion block disables it by name too. */
.flair-plume .fmtRainbow {
	background-image: linear-gradient(
		90deg,
		#7e3ff2 0%,
		#a855f7 18%,
		#d8b4fe 34%,
		#fbf5ff 46%,
		#d8b4fe 58%,
		#a855f7 76%,
		#7e3ff2 100%
	);
	background-size: 220% 100%;
	background-repeat: repeat;
	animation: fmtPlumeText var(--plume-text-dur, 6.5s) ease-in-out var(--plume-text-delay, 0s) infinite;
	text-decoration-color: #d8b4fe;
}

@keyframes fmtPlumeText {
	0% {
		background-position: 0 0;
		filter: brightness(0.92);
	}
	50% {
		filter: brightness(1.3);
	}
	100% {
		background-position: 220% 0;
		filter: brightness(0.92);
	}
}

/* neon's &g is the TUBE, spelled out: an aqua field with a hard white core punched
   through it twice a cycle. The white stops are doubled at adjacent positions so the
   core is a crisp edge rather than a soft blend - a real tube's filament doesn't fade
   into its glow, it sits inside it.
   The field is exactly &b's aqua (#55ffff, see COLORS in format.js), which reads more
   like a lit tube than the deeper cyan that was here: a tube is bright and slightly
   washed out, not saturated. Nothing darkens between the cores either - the only
   variation across the run is the approach to white and back, which is all a real
   tube has. Aqua rather than the pink even though pink is the commoner tube, because
   at text size hot pink sat right on the row's own pink glass and the two smeared;
   aqua reads as a SECOND tube in the same sign, and simply matches on a cyan row. */
.flair-neon .fmtRainbow {
	background-image: repeating-linear-gradient(
		90deg,
		#55ffff 0%,
		#55ffff 6%,
		#c7ffff 8.5%,
		#ffffff 10%,
		#ffffff 11.4%,
		#c7ffff 13%,
		#55ffff 16%,
		#55ffff 28%,
		#c7ffff 31%,
		#ffffff 33%,
		#ffffff 34.4%,
		#c7ffff 36.5%,
		#55ffff 40%,
		#55ffff 50%
	);
	animation-duration: var(--neon-text-dur, 5.5s);
	animation-delay: var(--neon-text-delay, 0s);
	text-decoration-color: #55ffff;
}

.flair-stars .fmtRainbow {
	background-image: repeating-linear-gradient(
		90deg,
		#ffffff 0%,
		#cfe6ff 8%,
		#a8ccff 16%,
		#f0d2e8 24%,
		#ffffff 32%,
		#bcd8ff 40%,
		#ffffff 48%
	);
	animation-duration: 8s; /* a slow shimmer, matching the twinkle's calm */
	text-decoration-color: #dbe9ff;
}

/* &k obfuscated: app.js scrambles the glyphs on a timer; the class is just the
   hook it queries. A faint dim marks it as noise even between frames. */
.fmtObf {
	opacity: 0.92;
}

@media (prefers-reduced-motion: reduce) {
	/* freeze the rainbow to a static (still colorful) gradient; app.js likewise
	   scrambles obfuscated text only once instead of animating it. */
	.fmtRainbow {
		animation: none;
	}
}

/* --- line flair: preset ambient effects (see flair.js) ----------------------
   Drawn entirely by keyframes on pseudo-elements: no images, no canvas, and no
   per-frame JavaScript. Each effect animates only opacity/transform, which the
   browser keeps on the compositor, so a screenful of flaired lines costs almost
   nothing on the main thread. Particle fields are ONE 2px dot cloned by
   box-shadow into a dozen sparks - a whole ember shower in a single layer.
   `isolation` opens a stacking context so the z-index:-1 decoration paints behind
   the text yet stays inside the row; `overflow:hidden` stops particles spilling
   onto neighbouring lines. */
/* THE ONE RULE EVERY FLAIR'S WASH FOLLOWS, before the individual effects below.
   ==========================================================================
   A flair's ambient tint runs HORIZONTALLY - linear-gradient(90deg, ...) - and
   never top-to-bottom.
   ==========================================================================
   Not a style preference. A chat log is rows stacked directly on one another, so a
   wash that is bright at one horizontal edge and dark at the other puts a light
   line and a shadow line along every row boundary - which is exactly how a bevel is
   drawn. The whole log turns into a column of extruded 3d buttons, and it happens
   at the moment a SECOND flaired row appears, so it looks fine while you are
   building the flair on its own.
   Three of these were built vertical first and all three had to be turned: fire's
   glow, fireflies' lit meadow, and the bubbles tank. A flat wash loses the one
   effect it was drawn for and keeps the log flat, and flat is worth more every
   time. If a flair needs a vertical component, it belongs in the fx layer as
   something that MOVES - the eye reads motion as depth and reads a static vertical
   ramp as a bezel. */
.flair {
	position: relative;
	isolation: isolate;
	overflow: hidden;
}

.flair::before,
.flair::after {
	content: "";
	position: absolute;
	pointer-events: none;
	z-index: -1;
}

/* a flaired row gets breathing room for the effect, pulled back by negative
   margins so its text stays aligned with plain lines. */
.line.flair {
	padding: 3px 6px;
	margin: 0 -6px 4px;
	border-radius: 4px;
}

/* the live-animated swatches /flair prints */
.flairChip {
	display: inline-block;
	padding: 3px 10px;
	border-radius: 4px;
	margin-right: 4px;
	/* Every swatch the same width, so a strip that wraps lines up in columns instead
	   of ragging - the names run from four characters to nine, and left to size
	   themselves the rows had nothing in common. `ch` is the right unit for it: the
	   labels are the log's monospace, so one ch is exactly one character, and 9 is the
	   longest name there is. Centred, because a short name padded on one side only
	   looks like a mistake rather than a column.
	   Row spacing too - wrapped rows of chips with no gap between them read as one
	   block of color rather than as a list. */
	min-width: 9ch;
	text-align: center;
	margin-bottom: 5px;
	/* An inline-block's baseline is its last line box UNLESS its overflow is clipped,
	   in which case it's the bottom margin edge. Every flair here clips except neon,
	   which has to let its glow out - so left on the default the neon swatch aligned
	   by a different rule from its neighbours and sat visibly lower in the row. Taking
	   all of them off the baseline entirely is what keeps the strip level. */
	vertical-align: middle;
}

/* fire: a bonfire under a night sky. The glow fades HORIZONTALLY (as lightning's
   does): a vertical wash reads as a bevel once rows stack up, giving the whole log
   an unintended 3d-button look. It also HUFFS rather than pulses - a smooth sine
   reads as a machine breathing, so the keyframes surge fast and fall away slowly at
   uneven intervals, the way a fire catches and settles. Two independent
   particle fields rise off the bottom edge: bright embers straight off the flame
   (::after) and slower, wider-wobbling ash that snuffs out partway up (.flairFx).
   Both corkscrew instead of travelling straight, and every value - the huff's
   period and depth, each field's speed, phase, spacing, color and drift - is
   randomized per line in flair.js. Two fields is the trick that lets some sparks
   die early while others ride all the way, without paying a composited layer per
   spark: each field is one box-shadow-cloned dot. */
.flair-fire::before {
	inset: 0;
	background: linear-gradient(90deg, rgba(255, 96, 0, 0.24), rgba(255, 150, 40, 0.07) 60%, transparent);
	animation: flairFireGlow var(--fire-glow-dur, 3.4s) ease-in-out var(--fire-glow-delay, 0s) infinite;
}

/* the ash haze: still a box-shadow field, because for dim slow specks behind the
   embers a shared cadence doesn't read. Its sway eases per waypoint so it drifts
   rather than jolting, and it snuffs out partway up. */
.flair-fire::after {
	left: var(--ash-left, 20px);
	top: 100%;
	width: 2px;
	height: 2px;
	border-radius: 50%;
	background: #8a7f77;
	box-shadow: var(--ash-field, 40px 0 #6d635c, 96px 0 #b9a89a, 158px 0 #c9762f, 226px 0 #7d726a);
	animation: flairAshRise var(--ash-dur, 5.5s) linear var(--ash-delay, 0s) infinite;
}

/* the ember layer is just a container; every spark inside it is its own element */
.flair-fire > .flairFx {
	inset: 0;
	opacity: 1;
}

/* One ember. Two animations compose into its transform: `translate` carries the
   rise (linear - steady climb) and `transform` carries the sway (ease-in-out +
   alternate - a smooth oscillation with no corners). Their periods are unrelated
   and independently phase-shifted, so the path never repeats and the field never
   marches in step. */
.flair-fire .ember {
	position: absolute;
	bottom: -2px;
	left: var(--e-x, 20%);
	width: var(--e-size, 2px);
	height: var(--e-size, 2px);
	border-radius: 50%;
	background: var(--e-tint, #ffb057);
	opacity: 0;
	animation:
		emberRise var(--e-rise, 4s) linear var(--e-rise-delay, 0s) infinite,
		emberSway var(--e-sway, 2s) ease-in-out var(--e-sway-delay, 0s) infinite alternate;
}

/* the long invisible tail is deliberate: with every ember on its own period, those
   dead stretches drift past each other so the shower clusters and lulls instead of
   streaming evenly. */
@keyframes emberRise {
	0% {
		translate: 0 0;
		opacity: 0;
	}
	6% {
		opacity: var(--e-peak, 0.95);
	}
	48% {
		opacity: calc(var(--e-peak, 0.95) * 0.5);
	}
	66% {
		translate: 0 calc(var(--e-lift, 2.2em) * -1);
		opacity: 0;
	}
	100% {
		translate: 0 calc(var(--e-lift, 2.2em) * -1);
		opacity: 0;
	}
}

/* embers that burn out low: same rise, snuffed much earlier */
.flair-fire .ember.emberShort {
	animation-name: emberRiseShort, emberSway;
}

@keyframes emberRiseShort {
	0% {
		translate: 0 0;
		opacity: 0;
	}
	8% {
		opacity: calc(var(--e-peak, 0.95) * 0.85);
	}
	30% {
		opacity: calc(var(--e-peak, 0.95) * 0.35);
	}
	40% {
		translate: 0 calc(var(--e-lift, 2.2em) * -0.5);
		opacity: 0;
	}
	100% {
		translate: 0 calc(var(--e-lift, 2.2em) * -0.5);
		opacity: 0;
	}
}

@keyframes emberSway {
	from {
		transform: translate3d(calc(var(--e-amp, 6px) * -1), 0, 0);
	}
	to {
		transform: translate3d(var(--e-amp, 6px), 0, 0);
	}
}

/* fast catch, slow settle, twice per cycle at uneven depths - a fire breathing */
@keyframes flairFireGlow {
	0% {
		opacity: var(--fire-glow-low, 0.45);
	}
	9% {
		opacity: var(--fire-glow-peak, 0.95);
	}
	34% {
		opacity: calc(var(--fire-glow-low, 0.45) * 1.25);
	}
	46% {
		opacity: calc(var(--fire-glow-peak, 0.95) * 0.82);
	}
	61% {
		opacity: var(--fire-glow-low, 0.45);
	}
	72% {
		opacity: var(--fire-glow-peak, 0.95);
	}
	100% {
		opacity: var(--fire-glow-low, 0.45);
	}
}

/* ash: dim, slow, gone by ~70%. Each waypoint eases in and out of the next, so the
   drift is a soft serpentine instead of the hard corner a linear interpolation
   would put at every offset. */
@keyframes flairAshRise {
	0% {
		transform: translate3d(0, 0, 0);
		opacity: 0;
		animation-timing-function: ease-in-out;
	}
	20% {
		opacity: 0.42;
		animation-timing-function: ease-in-out;
	}
	42% {
		transform: translate3d(var(--ash-x1, -4px), -0.5em, 0);
		opacity: 0.32;
		animation-timing-function: ease-in-out;
	}
	60% {
		transform: translate3d(var(--ash-x2, 6px), -0.95em, 0);
		opacity: 0.18;
		animation-timing-function: ease-in-out;
	}
	74% {
		transform: translate3d(var(--ash-x3, -3px), -1.3em, 0);
		opacity: 0;
	}
	100% {
		transform: translate3d(var(--ash-x3, -3px), -1.45em, 0);
		opacity: 0;
	}
}

/* lightning: a distant thunderstorm. No moving highlight - a sliding shine reads
   as polished metal, not weather. Instead the row holds a slow-breathing blue haze
   (the storm sky) under a flash layer whose timeline is mostly DEAD AIR punctuated
   by sheet-lightning glows: each event is a double-tap flicker, one faint and one
   noticeably brighter, washing most of the line for a split second. Every value -
   cadence, phase, brightness, where the glow is centred - is randomized per line
   in flair.js, so a screenful flickers unevenly like real weather rather than
   pulsing in unison. Rare full strikes (bolt + whiteout) come from the ticker in
   app.js, the same way shooting stars do. */
.flair-lightning::before {
	inset: 0;
	background: linear-gradient(90deg, rgba(90, 150, 255, 0.2), rgba(150, 200, 255, 0.06) 60%, transparent);
	animation: flairStormHaze var(--lit-haze-dur, 6s) ease-in-out var(--lit-haze-delay, 0s) infinite;
}

.flair-lightning::after {
	inset: 0;
	/* a broad soft glow rather than a band, so a flash lights the row as a whole */
	background: radial-gradient(
		130% 190% at var(--lit-flash-x, 30%) 50%,
		rgba(226, 240, 255, 0.85),
		rgba(150, 200, 255, 0.28) 42%,
		transparent 74%
	);
	opacity: 0;
	animation: flairStormFlash var(--lit-flash-dur, 9s) linear var(--lit-flash-delay, 0s) infinite;
}

/* the sky itself never sits still, but it never strobes either */
@keyframes flairStormHaze {
	0%,
	100% {
		opacity: 0.45;
	}
	50% {
		opacity: 0.85;
	}
}

/* two flash events per cycle, both double-tapped, separated by long silence - the
   gap is the point: it's what makes the next one feel like weather. */
@keyframes flairStormFlash {
	0%,
	5% {
		opacity: 0;
	}
	6.5% {
		opacity: var(--lit-flash-peak, 0.3);
	}
	8% {
		opacity: 0.04;
	}
	9.5% {
		opacity: calc(var(--lit-flash-peak, 0.3) * 0.65);
	}
	12%,
	46% {
		opacity: 0;
	}
	47.5% {
		opacity: calc(var(--lit-flash-peak, 0.3) * 1.75);
	}
	49% {
		opacity: 0.06;
	}
	50.5% {
		opacity: calc(var(--lit-flash-peak, 0.3) * 1.2);
	}
	52% {
		opacity: 0.03;
	}
	53.5% {
		opacity: calc(var(--lit-flash-peak, 0.3) * 0.5);
	}
	57%,
	100% {
		opacity: 0;
	}
}

/* --- neon: the frame IS the effect -----------------------------------------------
   Every other flair fills the row - embers, drops, stars, a wash. This one puts
   nothing inside it at all: a glass tube bent around the outline, buzzing, with a
   section that occasionally gives out and catches again.

   Three layers, all animating opacity and nothing else, so despite looking like the
   most expensive effect here it is the cheapest - no blur, no blend, no particles:

     ::before   the tube: a band of colored glass
     ::after    the filament: a thinner white line down the MIDDLE of that band
     .neonDead  the failure, hosted in the shared fx layer

   The filament is the whole trick, and it is the opposite of what a first pass
   reaches for. Glow does not come from spread - a wide BRIGHT halo just reads as a
   blurred rectangle and crowds the rows either side of it. It comes from a hot white
   centre with color to either side of it, which is what a lit tube actually looks
   like: the arc is white, the gas around it carries the hue. Everything strong
   therefore stays within a few pixels of the glass; what reaches further out is
   faint enough that it never resolves as an edge, and only makes the dark less dark.

   The other thing that carries it is where the light GOES. Neon is the only flair
   with a hard closed shape - the other five are weather, and weather has no edges -
   so left throwing light outward only it reads as a css border with a glow bolted
   on. It throws the same light inward, across the row it frames and onto the text
   sitting there. A sign lights its own wall. A border does not.

   The light has to escape, because a sign's whole character is the light it throws;
   the base .flair clips, which exists to keep PARTICLES in, and this flair has none
   to lose. So y opens up - but x stays clipped, or a neon row would be the one thing
   in the log able to push the column sideways. Its glow still gets out sideways: the
   clip margin on .line reaches past the tube. */
.flair-neon {
	overflow-x: clip;
	overflow-y: visible;
	/* the row is inside the sign, so the sign's light is on its text too. Subtle
	   enough not to soften the glyph edges, present enough that the words belong to
	   the tube rather than sitting in front of it - which is what every other flair
	   gets for free from its background wash, and what neon had no way to get. */
	text-shadow: 0 0 7px color-mix(in srgb, var(--neon-halo, #ff5cb0) 38%, transparent);
}

/* the glass. Three stops out and three in, each wider and fainter than the last.
   The near stops are what you read as the tube; the far ones never resolve as an
   edge at all, they just make the dark around the row slightly less dark, and that
   is the difference between light and a stroke. The widest inward stop is the wash
   across the row - it is why this frame has an interior at all. */
.flair-neon::before {
	inset: 0;
	border: 3px solid var(--neon-lit, #ff2d95);
	border-radius: 6px;
	box-shadow:
		0 0 2px var(--neon-lit, #ff2d95),
		0 0 var(--neon-bloom, 8px) var(--neon-halo, #ff5cb0),
		0 0 calc(var(--neon-bloom, 8px) * 2.6) color-mix(in srgb, var(--neon-halo, #ff5cb0) 34%, transparent),
		inset 0 0 2px var(--neon-lit, #ff2d95),
		inset 0 0 var(--neon-bloom, 8px) color-mix(in srgb, var(--neon-halo, #ff5cb0) 62%, transparent),
		inset 0 0 calc(var(--neon-bloom, 8px) * 4) color-mix(in srgb, var(--neon-lit, #ff2d95) 26%, transparent);
	animation: flairNeonHum var(--neon-hum-dur, 0.9s) linear var(--neon-hum-delay, 0s) infinite;
}

/* the filament, centred in that band: the tube runs 0-3px in from the edge, so its
   middle is at 1.5px and a 1px line at inset 1px sits exactly on it. It carries a
   tight white bloom of its own - the arc is the brightest thing in the assembly and
   blows out slightly against the gas around it. Same phase as the glass, because it
   is one tube: the arc and the gas do not buzz separately. */
.flair-neon::after {
	inset: 1px;
	border: 1px solid #ffffff;
	border-radius: 5px;
	box-shadow:
		0 0 2px rgba(255, 255, 255, 0.65),
		inset 0 0 2px rgba(255, 255, 255, 0.5);
	animation: flairNeonHum var(--neon-hum-dur, 0.9s) linear var(--neon-hum-delay, 0s) infinite;
}

/* neon is the only flair whose decoration leaves its own box, so it is the only one
   that can collide with its neighbours. Everything else clips. */
.line.flair.flair-neon {
	margin-bottom: 12px;
}

/* The CRT glass, welded to the flairs that are ABOUT being a screen - neon and
   matrix - rather than to the app.
   The "retro terminal" setting lays scanlines over the WHOLE screen; this lays the
   same hairlines over those rows and nothing else, and does it whether or not that
   setting is on. A tube (or falling code) read through a scanline is just what those
   look like - it is the flair, not a preference - so it should not be something you
   have to go and switch on somewhere else to get.

   Unlike every other layer a flair owns, this one sits ABOVE the text (z-index 1,
   inside the row's own stacking context): a CRT overlays what it is displaying, and
   putting it under the glyphs would leave the words conspicuously the one thing in
   the row not behind glass. It is a single static gradient, so it costs one
   composited layer and no per-frame work - which is why, unlike .flairFx, it is
   never parked by the animation budget. */
.flairScan {
	position: absolute;
	inset: 0;
	z-index: 1;
	pointer-events: none;
	/* follows whatever the row is rounded to, so it never cuts a corner the flair
	   underneath it didn't (neon widens both - see .flair-neon .flairScan) */
	border-radius: inherit;
	background: repeating-linear-gradient(
		0deg,
		var(--scanline-color) 0px,
		var(--scanline-color) 1px,
		transparent 1px,
		transparent 3px
	);
}

/* with the whole-app CRT switched on, the global overlay is already drawing these
   exact lines over this exact row - a second pass would only make neon rows darker
   than everything around them. */
html.retro .flairScan {
	display: none;
}

/* neon's glass is rounded a little wider than the row it frames, so its scanlines
   match the tube rather than the row. */
.flair-neon .flairScan {
	border-radius: 6px;
}

/* The buzz. Shallow, fast, and deliberately NOT a sine: mains hum is a ragged thing,
   and evenly spaced keyframes read as a slow pulse no matter how quick you make them.
   The values wander within a few percent of full - enough to see, not enough to look
   like it is fading in and out. */
@keyframes flairNeonHum {
	0% {
		opacity: 0.93;
	}
	13% {
		opacity: 1;
	}
	19% {
		opacity: 0.88;
	}
	34% {
		opacity: 0.99;
	}
	48% {
		opacity: 0.92;
	}
	57% {
		opacity: 1;
	}
	71% {
		opacity: 0.9;
	}
	86% {
		opacity: 0.97;
	}
	100% {
		opacity: 0.93;
	}
}

/* A tube does not switch on, it CATCHES: the starter arcs, the gas won't hold, it
   arcs again, and only after a few goes does it settle. So a neon row strikes up as
   it arrives - which is the same physics as the dead segment, played at the other
   end of the tube's life, and it is what gives this flair the thing the other five
   all have and it did not: a motion with a story in it rather than a decoration.

   `.arrive` is added once, to live arrivals only, and never to a rerender (see
   app.js), so scrolling back through history doesn't re-fire a room full of signs.
   The strike is listed AFTER the hum so it owns the opacity while it runs and hands
   back cleanly the moment it ends - a non-forwards animation stops contributing
   once it's done, and the hum is already there underneath.

   :not(.flairQuiet) so a row that arrives already parked (off screen, or over the
   animation budget) never strikes - it just is, which is the whole point of quiet.
   The reduced-motion block below has to repeat the selector to outrank this one. */
.line.arrive.flair-neon:not(.flairQuiet)::before,
.line.arrive.flair-neon:not(.flairQuiet)::after {
	animation:
		flairNeonHum var(--neon-hum-dur, 0.9s) linear var(--neon-hum-delay, 0s) infinite,
		flairNeonStrike 1.05s linear 1;
}

@keyframes flairNeonStrike {
	0%,
	6% {
		opacity: 0;
	}
	8% {
		opacity: 1;
	}
	12% {
		opacity: 0.04;
	}
	21% {
		opacity: 0;
	}
	23% {
		opacity: 0.92;
	}
	27% {
		opacity: 0.08;
	}
	34% {
		opacity: 0.88;
	}
	38% {
		opacity: 0.12;
	}
	46% {
		opacity: 1;
	}
	51% {
		opacity: 0.35;
	}
	59% {
		opacity: 1;
	}
	66% {
		opacity: 0.72;
	}
	100% {
		opacity: 1;
	}
}

/* neon's fx layer is not a particle field - it is a full-row canvas the failing
   segment is placed on. Opacity 1 because the segment carries its own.

   The z-index shuffle is what makes the failure possible at all. Everything a flair
   draws sits at -1, and among equal z-index the paint order is tree order: ::before
   (glass), then .flairFx, then ::after (filament). So the segment could occlude the
   glass but the white core ran straight through it, which is not a tube going out,
   it is a smudge on one. Dropping both pseudo-elements a layer puts the fx canvas
   above the whole assembly - and still below the text, which is the only thing that
   must never be occluded. */
.flair-neon::before,
.flair-neon::after {
	z-index: -2;
}

.flair-neon > .flairFx {
	inset: 0;
	opacity: 1;
	z-index: -1;
}

/* The dead segment: a patch of the page's own background laid over one rail.
   Occluding rather than un-lighting is the trick - there is no way to switch off part
   of a border, but a piece of the background sitting on top of it is indistinguishable
   from a length of tube that has stopped conducting. It has to swallow the BLOOM as
   well as the glass and the filament, or the line goes dark while its glow keeps
   burning around the gap, which reads as dirt on the glass.

   An ellipse rather than a bar, because a bar has four hard edges and every one of
   them announces itself as a rectangle drawn over the effect. Fading to transparent
   at the ends lets the dark blend back into the lit tube the way a section losing its
   arc actually goes: dimmest in the middle, uncertain at the boundaries.

   It is deliberately much taller than the tube it covers. The bloom now reaches a
   long way on both sides, and an occluder sized to the glass alone leaves the glow
   still burning in a ring around the gap - which reads as dirt on the tube rather
   than as a length of it going out. Whatever the tube lights, the dead section has
   to stop lighting, so the patch has to be as big as the light. */
.flair-neon .neonDead {
	position: absolute;
	top: var(--neon-dead-top, -9px);
	bottom: var(--neon-dead-bottom, auto);
	left: var(--neon-dead-x, 22%);
	width: var(--neon-dead-w, 24%);
	/* biased inward: it has to reach far enough across the row to kill the wash the
	   dead length was throwing, but only far enough OUTWARD to take the near bloom
	   with it. Sized symmetrically it would reach past the row and start eating the
	   glow off the message above, and one sign's fault cannot dim another's. */
	height: 24px;
	background: radial-gradient(ellipse closest-side, var(--bg) 0 45%, transparent 100%);
	opacity: 0;
	animation: flairNeonDead var(--neon-dead-dur, 15s) linear var(--neon-dead-delay, 0s) infinite;
}

/* A failing tube does not blink on a timer - it drops out, gropes for the arc,
   catches, loses it again, then holds. So the segment is dark for the overwhelming
   majority of the cycle and the failure itself is a short irregular burst: out, back,
   out longer, back, one last flutter, then steady for another fifteen seconds or so.
   The long quiet stretch is what keeps it an event you catch rather than a rhythm you
   learn. */
@keyframes flairNeonDead {
	0%,
	87.4% {
		opacity: 0;
	}
	87.5% {
		opacity: 1;
	}
	88.1% {
		opacity: 0;
	}
	88.4% {
		opacity: 1;
	}
	89.6% {
		opacity: 1;
	}
	89.9% {
		opacity: 0;
	}
	90.6% {
		opacity: 0.85;
	}
	91% {
		opacity: 0;
	}
	91.4% {
		opacity: 1;
	}
	92.1% {
		opacity: 0;
	}
	100% {
		opacity: 0;
	}
}

/* --- matrix: falling code behind the row --------------------------------------
   The only flair made of glyphs. Everything else here is a fill; this is text, and
   that is the whole reason it reads differently from the rest of the list.

   Three layers, as usual, plus the shared CRT glass over the top (see .flairScan -
   matrix and neon are the two flairs that are ABOUT being a screen):
     ::before   the phosphor wash - the green a monitor throws on its own bezel
     ::after    a soft bloom off the bottom edge, where the code has piled up
     .flairFx   the columns themselves

   Note there is deliberately NO green cast on the message text. Every other
   character in the row belongs to somebody, and tinting it would take that away;
   the code falls BEHIND what people said, it doesn't recolor it. */
.flair-matrix::before {
	inset: 0;
	background: linear-gradient(180deg, rgba(0, 255, 65, 0.15), rgba(0, 255, 65, 0.05) 55%, transparent);
	animation: flairMatrixWash var(--mtx-wash-dur, 6s) ease-in-out var(--mtx-wash-delay, 0s) infinite;
}

.flair-matrix::after {
	left: 0;
	right: 0;
	bottom: -1px;
	height: 9px;
	background: linear-gradient(0deg, rgba(0, 255, 65, 0.3), transparent);
	animation: flairMatrixWash var(--mtx-wash-dur, 6s) ease-in-out var(--mtx-wash-delay, 0s) infinite;
}

@keyframes flairMatrixWash {
	0%,
	100% {
		opacity: calc(var(--mtx-wash-peak, 0.7) * 0.55);
	}
	50% {
		opacity: var(--mtx-wash-peak, 0.7);
	}
}

.flair-matrix > .flairFx {
	inset: 0;
	opacity: 1;
	overflow: hidden;
}

/* one falling column: a stack of glyph cells that travels the height of the row and
   keeps going, so it is always entering or leaving rather than sitting still. */
.flair-matrix .mtxCol {
	position: absolute;
	left: var(--m-x, 20%);
	top: 0;
	/* Height is the ROW's, not the glyph stack's, and that is load-bearing: a
	   translateY percentage resolves against the element's own height, so a column
	   sized to its glyphs would measure its fall in glyph-heights and spend most of
	   the cycle out of sight above or below a 26px row. Sized to the row, the same
	   percentages mean row-heights and the fall lands where it can be seen. The
	   stack overflows this box freely - the fx layer is what clips. */
	height: 100%;
	display: flex;
	flex-direction: column;
	/* anchored to the bottom so the HEAD leads: it is the lowest glyph, so it is the
	   first thing to enter the row and the last to leave it. */
	justify-content: flex-end;
	font-size: var(--m-size, 10px);
	line-height: 1;
	/* a stack that has CJK coverage before the ui font's fallback gets a say. The
	   1em cells below mean a substituted glyph can look different but cannot shift
	   the column, so this is about quality, not correctness. */
	font-family: "Hiragino Kaku Gothic ProN", "Yu Gothic", "Noto Sans CJK JP", "Noto Sans JP", "MS Gothic", monospace;
	white-space: nowrap;
	opacity: var(--m-op, 0.8);
	/* STEPPED, not linear. A terminal advances by whole character cells - it does not
	   glide - and a smooth translate is the single thing that made this read as snow
	   drifting rather than as a machine printing. Eight jumps across the travel puts
	   each one at roughly a glyph height for the sizes in use, so a column appears to
	   scroll a line at a time. It quantizes the fade in the same keyframes, which is
	   no loss: a terminal's output arrives, it doesn't ease in. */
	animation: flairMatrixFall var(--m-fall, 0.9s) steps(8) var(--m-delay, 0s) infinite;
}

/* every cell is exactly one glyph square, whatever font ends up drawing it */
.flair-matrix .mtxGlyph,
.flair-matrix .mtxHead {
	display: block;
	width: 1em;
	height: 1em;
	text-align: center;
	overflow: hidden;
}

.flair-matrix .mtxGlyph {
	color: #22ff5e;
	opacity: var(--m-dim, 0.5);
	text-shadow: 0 0 4px rgba(0, 255, 65, 0.5);
}

/* the head: the bright one at the leading edge, near-white the way an over-driven
   phosphor goes, with the green bleeding out around it. */
.flair-matrix .mtxHead {
	color: #eaffef;
	text-shadow:
		0 0 3px rgba(216, 255, 230, 0.9),
		0 0 7px rgba(0, 255, 65, 0.95),
		0 0 14px rgba(0, 255, 65, 0.5);
}

/* ...and the sprite sheet that makes it churn. The strip is MATRIX_REEL_LEN glyphs
   tall (see flair.js); stepping the translate by whole glyphs means the cell above
   only ever shows one of them, cleanly, and the character appears to change without
   a single byte of text being rewritten. */
.flair-matrix .mtxReel {
	display: block;
	animation: flairMatrixReel var(--m-reel, 0.8s) steps(5) var(--m-reel-delay, 0s) infinite;
}

.flair-matrix .mtxReel i {
	display: block;
	width: 1em;
	height: 1em;
	font-style: normal;
}

/* steps() and this distance are MATRIX_REEL_LEN from flair.js - see the note
   there. Stepping by whole glyphs is what keeps the cell showing exactly one. */
@keyframes flairMatrixReel {
	to {
		transform: translateY(-5em);
	}
}

/* Falls from clear of the top to clear of the bottom - the row clips it, so a column
   is only ever a slice of a longer fall you never see the whole of. It fades in and
   out at the ends rather than popping, which is what keeps the row's edges soft
   instead of looking like a marquee. */
@keyframes flairMatrixFall {
	0% {
		transform: translateY(-140%);
		opacity: 0;
	}
	9% {
		opacity: var(--m-op, 0.85);
	}
	88% {
		opacity: var(--m-op, 0.85);
	}
	100% {
		transform: translateY(115%);
		opacity: 0;
	}
}

/* matrix's &g is the code itself: phosphor green with the over-driven near-white
   heads punched through it, on the same scroll every other wildcard uses. */
.flair-matrix .fmtRainbow {
	background-image: repeating-linear-gradient(
		90deg,
		#00913a 0%,
		#00ff41 9%,
		#d8ffe6 13%,
		#d8ffe6 14.2%,
		#00ff41 18%,
		#00913a 27%,
		#00913a 33%,
		#00ff41 39%,
		#d8ffe6 42%,
		#d8ffe6 43.2%,
		#00ff41 47%,
		#00913a 54%,
		#00913a 60%
	);
	animation-duration: var(--mtx-text-dur, 5s);
	animation-delay: var(--mtx-text-delay, 0s);
	text-decoration-color: #00ff41;
}

/* --- sonar: a node pinging the mesh -------------------------------------------
   The first flair whose motion is radial. Everything else in this list travels
   along an axis, and expansion from a point is the one shape none of them can
   borrow - which is most of why this belongs in a chat client built on geohashes
   and relays rather than being one more weather effect.

   Two layers, plus the shared CRT glass over the top (see .flairScan - a scope is a
   display in the same way neon's tube and matrix's monitor are):
     ::before   the phosphor glow the scope throws around the origin
     .flairFx   the rings, and any contact the sweep has found

   Nothing is drawn AT the origin. There was a node dot there and it read as a control
   sitting on the row; the wavefronts arriving out of nothing is the better picture,
   and it costs one less animated pseudo-element.

   The row clips all of it, which is the point: see the note in flair.js.

   As with matrix, the message text is deliberately left alone. The scope is behind
   what people said; it doesn't recolor it. */
.flair-sonar::before {
	inset: 0;
	/* Two backgrounds in one layer, which is one composited layer and one animation
	   either way. The pool is centred on the emitter rather than washed across the
	   row, so the tint has a SOURCE - the same point the rings leave from - instead
	   of being a flat cast that happens to be green.
	   The floor under it is there because a radial glow alone left the far end of a
	   long row completely untinted, and a row that stops reading as flaired halfway
	   along is the one thing every other effect here manages to avoid. It is the
	   unswept phosphor: dim, even, and the thing the pool is brighter THAN. */
	background:
		radial-gradient(
			circle at var(--son-x, 30%) var(--son-y, 50%),
			rgba(46, 255, 160, 0.3),
			rgba(46, 255, 160, 0.1) 42%,
			transparent 78%
		),
		linear-gradient(rgba(46, 255, 160, 0.075), rgba(46, 255, 160, 0.075));
	opacity: var(--son-wash-peak, 0.75);
	animation: flairSonarWash var(--son-wash-dur, 7s) ease-in-out var(--son-wash-delay, 0s) infinite;
}

@keyframes flairSonarWash {
	0%,
	100% {
		opacity: calc(var(--son-wash-peak, 0.75) * 0.5);
	}
	50% {
		opacity: var(--son-wash-peak, 0.75);
	}
}

.flair-sonar > .flairFx {
	inset: 0;
	opacity: 1;
	overflow: hidden;
}

/* one wavefront. Sized to this row's full reach and scaled up into it, so the only
   animated properties are a transform and an opacity - the same discipline the rest
   of the file keeps. Positioned by half its own size off the origin for the same
   reason the emitter is: it leaves the transform free.

   The stroke scales with the ring, so a wavefront broadens as it travels. Left as a
   hairline that would be a defect; at 2px with a soft glow it is the one thing about
   a scaled ring that happens to be right - a real wavefront disperses as it goes,
   and the opacity falls off faster than the width grows (see below), so it still
   loses ink on the way out.

   A ring is at its thinnest in the first instant of its life, for the same reason. It
   used to have a bright dot at the origin covering that instant; without one it fades
   up over the first few percent of its travel instead, which reads as a wavefront
   forming rather than as one being switched on. */
.flair-sonar .ping {
	position: absolute;
	left: calc(var(--son-x, 30%) - var(--son-reach, 170px) / 2);
	top: calc(var(--son-y, 50%) - var(--son-reach, 170px) / 2);
	width: var(--son-reach, 170px);
	height: var(--son-reach, 170px);
	border-radius: 50%;
	border: 2px solid rgba(125, 255, 194, 0.95);
	box-shadow: 0 0 6px rgba(46, 255, 160, 0.4);
	opacity: 0;
	/* the row's whole cycle, offset by this ring's index times the burst gap and by
	   the row's phase. A SMALL gap, so the three leave close together and the rest of
	   the cycle is silence - see the pacing note in flair.js. None of the arithmetic
	   is written down twice: flair.js hands over the cycle, the gap and the phase, and
	   the burst falls out of them. */
	animation: flairSonarPing var(--son-ping, 10s) linear
		calc(var(--son-phase, 0s) - var(--son-ping, 10s) * var(--r-i, 0) * var(--son-gap, 0.09)) infinite;
}

/* LINEAR, and that is the whole physics of it: a wavefront travels at a constant
   speed, so anything eased turns the ping into a thrown object that decelerates.
   The fade is not linear, though - it drops off far faster than the radius grows,
   the way energy spread over a widening circle does.

   The travel is over by 45% of the cycle and the rest is a held, invisible rest. That
   rest is where the silence between bursts comes from, and it is the reason these
   offsets are what they are: the ring has to be DONE early enough that the last one
   of the burst still finishes with dead time to spare after it. app.js reads the
   expansion out of these keyframes rather than keeping its own copy of the number
   (see ringClock), so retuning the shape here does not put the contact quietly out
   of step with the wavefront that is supposed to be lighting it - and since how long
   a contact stays lit is derived from the same reading, the two cannot drift apart. */
@keyframes flairSonarPing {
	0% {
		transform: scale(0.1);
		opacity: 0;
	}
	4% {
		opacity: var(--son-lit, 0.9);
	}
	16% {
		opacity: calc(var(--son-lit, 0.9) * 0.42);
	}
	31% {
		opacity: calc(var(--son-lit, 0.9) * 0.14);
	}
	45% {
		transform: scale(1);
		opacity: 0;
	}
	100% {
		transform: scale(1);
		opacity: 0;
	}
}

/* --- the contact (see sonarBlip in app.js) ------------------------------------
   A zero-size anchor at a bearing off the origin; both children hang off that point
   by half their own size, which is what leaves each of their transforms free to be
   nothing but the pop or the expansion it wants.

   This is geometry and color only. Everything that MOVES is driven from app.js,
   because a contact is lit by a wavefront arriving at it: the flash has to be
   scheduled against a live ring rather than started when the element happens to be
   inserted, its LENGTH is the wait until the sweep gets back to it, and it happens
   several times, at several places, over one contact's life. None of that is
   expressible as a fixed keyframe list. Hence the resting state below is invisible -
   what is on screen between one contact fading out and the next sweep finding it
   again is nothing at all, for about a second. */
.flair-sonar .blip {
	position: absolute;
	left: calc(var(--son-x, 30%) + var(--b-dx, 60px));
	top: calc(var(--son-y, 50%) + var(--b-dy, 0px));
	width: 0;
	height: 0;
	opacity: 0;
}

/* Far brighter than anything the ambient effect does, because a return is
   information and the rings are only the asking. Hung off the anchor by half its own
   size, which leaves its transform free to be nothing but the pop.

   A dot and nothing else. It used to have a small ring expanding out of it as it lit,
   and that was one idea too many - radial expansion is what the sweep does, and a
   second source of it on the same row read as a second emitter rather than as
   something the first one had found. */
.flair-sonar .blipDot {
	position: absolute;
	left: -3px;
	top: -3px;
	width: 6px;
	height: 6px;
	border-radius: 50%;
	background: radial-gradient(circle, #ffffff 0%, #b6ffdd 38%, rgba(46, 255, 160, 0) 75%);
	box-shadow:
		0 0 8px rgba(125, 255, 194, 0.9),
		0 0 16px rgba(46, 255, 160, 0.45);
}

/* sonar's &g is the sweep, and it is the only wildcard here that isn't a color
   cycle: the run sits at its decayed floor, a hot line crosses it, and behind the
   line the phosphor falls back down to that floor again.

   Which is why the stops are a cliff followed by a ramp rather than a smooth loop.
   The shared scroll animates background-position to 200% against a 200%-wide image,
   which slides the image LEFT - so a fixed glyph samples the gradient left to right
   in TIME. Time order is therefore stop order: the line first, its persistence
   after. Writing the ramp before the cliff instead would give a screen that
   brightens ahead of its own sweep, which is a thing no scope has ever done.

   The floor is a readable green rather than the near-black an honest unswept scope
   would be, for the reason matrix's floor is: half a message is going to be sitting
   in it, and a wildcard run that can only be read twice a cycle is a worse idea than
   a slightly overexposed phosphor.

   Only the gradient and its pace are overridden, so the shared scroll keyframes and
   the reduced-motion switch-off both still apply as they are. */
.flair-sonar .fmtRainbow {
	background-image: repeating-linear-gradient(
		90deg,
		#f2fff9 0%,
		#f2fff9 0.5%,
		#9dffd2 2%,
		#45f2a8 6%,
		#1cc47c 12%,
		#12a06a 20%,
		#0e8256 30%,
		#0c7049 40%,
		#0c7049 50%
	);
	animation-duration: var(--son-text-dur, 9s);
	animation-delay: var(--son-text-delay, 0s);
	text-decoration-color: #2ee39a;
}

/* --- fireflies: a few of them over the row ------------------------------------
   The simplest flair in the list, and the one closest to fire - which is where its
   particles come from. It borrows the construction (see the note in flair.js: one
   element per insect, because a cloned field is one animation and lights as one) and
   does the opposite thing with it. An ember travels; it rises, and everything else
   decorates the rise. A firefly has nowhere to be, so its two axes are slow
   oscillations on unrelated periods and its glow is on a third - a path that wanders
   without closing, lit from somewhere new each time.

   Two layers, and no CRT glass: this is the one recent flair that is not a screen.
     ::before   the warm dark the insects are seen against
     .flairFx   the insects

   The wash is not decoration. Every insect is dark for about half its cycle, so there
   are moments when none of them is lit - and a PARKED row has its fx layer hidden
   outright. Without something behind them a row would blink out of reading as flaired
   at all, which is the one thing every effect here manages to avoid. */
.flair-fireflies::before {
	inset: 0;
	/* HORIZONTAL, as fire's and lightning's are, and for the reason written down over
	   at .flair-fire::before: a wash that runs top-to-bottom reads as a BEVEL once
	   rows stack up, and turns the whole log into a column of extruded 3d buttons.
	   This started out vertical - a bright bottom edge, meant to read as a lit meadow
	   - and that is exactly what it did. The flat look is worth more than the meadow,
	   and it is what every other flair here already does.

	   Two backgrounds in one layer, which costs the same as one. The flat floor under
	   the gradient is what stops the far end of a long row going properly black, which
	   had it not reading as flaired at all whenever the insects were between glows.
	   Being flat, it adds no shading in either direction. */
	background:
		linear-gradient(90deg, rgba(154, 224, 74, 0.26), rgba(126, 196, 66, 0.09) 58%, transparent),
		linear-gradient(rgba(122, 192, 62, 0.07), rgba(122, 192, 62, 0.07));
	opacity: var(--fly-wash-peak, 0.65);
	animation: flairFlyWash var(--fly-wash-dur, 8s) ease-in-out var(--fly-wash-delay, 0s) infinite;
}

@keyframes flairFlyWash {
	0%,
	100% {
		opacity: calc(var(--fly-wash-peak, 0.6) * 0.55);
	}
	50% {
		opacity: var(--fly-wash-peak, 0.6);
	}
}

.flair-fireflies > .flairFx {
	inset: 0;
	opacity: 1;
	overflow: hidden;
}

/* One insect: a hot little core inside a soft halo of its own color.

   The halo is a GRADIENT, not a blurred shadow, and that distinction is the whole
   reason this is visible at all. `box-shadow: 0 0 11px` on a two-pixel dot does not
   add light - it smears the same two pixels' worth of color over eleven, so the
   first cut of this rendered as almost nothing on a dark row. A radial gradient
   across a box the size the glow should actually BE paints the whole area. It is
   still one static rasterization and still costs nothing per frame; only opacity and
   transforms animate.

   So --f-size is the halo, not the insect - the bright part is the inner quarter of
   it. `currentColor` lets one custom property drive core, halo and falloff together,
   and the opacity animation dims all three as one, the way a real one dims. */
.flair-fireflies .fly {
	position: absolute;
	left: var(--f-x, 40%);
	top: var(--f-y, 50%);
	width: var(--f-size, 11px);
	height: var(--f-size, 11px);
	/* pulled back by half its own size so the halo is centred on the point, not hung
	   off it - the box is now big enough for that to be visible */
	margin: calc(var(--f-size, 11px) / -2) 0 0 calc(var(--f-size, 11px) / -2);
	border-radius: 50%;
	color: var(--f-tint, #d4ff6a);
	/* The core HOLDS before it falls away. A stop that is full alpha at 0% and gone by
	   25% is bright at one mathematical point and faint everywhere else, so the insect
	   came out as a smudge with no centre; keeping it solid through the first tenth
	   gives it something to actually be. Same reason the tint holds to 22% before its
	   falloff starts. */
	background:
		radial-gradient(circle, rgba(255, 255, 250, 0.98) 0%, rgba(255, 255, 250, 0.92) 9%, rgba(255, 255, 250, 0) 30%),
		radial-gradient(circle, currentColor 0%, currentColor 22%, transparent 70%);
	opacity: 0;
	animation:
		flairFlyDrift var(--f-drift, 6s) ease-in-out var(--f-drift-delay, 0s) infinite alternate,
		flairFlyBob var(--f-bob, 4s) ease-in-out var(--f-bob-delay, 0s) infinite alternate,
		flairFlyGlow var(--f-glow, 4.5s) ease-in-out var(--f-glow-delay, 0s) infinite;
}

/* The two axes, on separate PROPERTIES rather than in one keyframe set holding both.
   Sharing a set would mean sharing a period, and two oscillations on one period trace
   a line or an ellipse - a closed shape, and a shape you can see repeating. On
   unrelated periods the same two produce a figure that never quite returns to where
   it started, which is the difference between meandering and pacing. */
@keyframes flairFlyDrift {
	from {
		translate: calc(var(--f-ax, 16px) * -1) 0;
	}
	to {
		translate: var(--f-ax, 16px) 0;
	}
}

@keyframes flairFlyBob {
	from {
		transform: translate3d(0, calc(var(--f-ay, 5px) * -1), 0);
	}
	to {
		transform: translate3d(0, var(--f-ay, 5px), 0);
	}
}

/* Gradual, and dark for about half of it. The rise is quicker than the fall, which is
   what keeps it from reading as a sine - a firefly comes up and then lingers on the
   way out. The long dark tail does the same job it does for fire's embers: with every
   insect on its own period those stretches drift past each other, so the row is never
   all lit or all dark and a handful reads as many more than a handful. */
@keyframes flairFlyGlow {
	0% {
		opacity: 0;
	}
	11% {
		opacity: calc(var(--f-peak, 0.9) * 0.28);
	}
	21% {
		opacity: var(--f-peak, 0.9);
	}
	27% {
		opacity: calc(var(--f-peak, 0.9) * 0.82);
	}
	40% {
		opacity: calc(var(--f-peak, 0.9) * 0.17);
	}
	52% {
		opacity: 0;
	}
	100% {
		opacity: 0;
	}
}

/* fireflies' &g is the glow itself: the dark green of the field swelling up through
   yellow-green to the near-white an insect goes at its brightest, and back down.
   Only the gradient and its pace are overridden, so the shared scroll keyframes and
   the reduced-motion switch-off both still apply as they are. */
.flair-fireflies .fmtRainbow {
	background-image: repeating-linear-gradient(
		90deg,
		#4d7a24 0%,
		#7fbf2f 10%,
		#b6f542 20%,
		#e4ff9b 27%,
		#f7ffd6 31%,
		#d4ff6a 36%,
		#8fcc38 46%,
		#4d7a24 56%
	);
	animation-duration: var(--fly-text-dur, 8s);
	animation-delay: var(--fly-text-delay, 0s);
	text-decoration-color: #c8ff5a;
}

/* --- aurora: curtains, and nothing else ----------------------------------------
   The only flair here with no particles in it, and the point of it. Every other
   effect in this file is made of THINGS - sparks, drops, insects, bubbles, glyphs -
   so the one motion the set was missing is the one with no objects in it at all:
   something large and soft that changes shape without ever being a shape.

   Two layers, which is the whole flair. No fx layer, no nodes, nothing appended by
   a ticker. It is the cheapest effect in the file by a distance.
     ::before   the near curtain - green through teal, brighter, drifting one way
     ::after    the far curtain  - teal through violet, dimmer, drifting the other

   WHY IT IS STILL A 90deg GRADIENT, given it is described as vertical curtains.
   Those are two different axes and it is worth being precise, because getting it
   wrong here is the bevel THE ONE RULE up at .flair warns about. A curtain HANGS
   vertically; what varies from one curtain to the next is horizontal position. So a
   sky full of them is a horizontal ramp through vertical bands - exactly
   linear-gradient(90deg, ...) - and there is no top-to-bottom component anywhere in
   it to put a light edge and a shadow edge on the row boundaries.

   The lean is a skew rather than a gradient angle, and that is the same decision
   twice over: an angled gradient would reintroduce the vertical component the rule
   forbids, and a baked-in angle cannot move. As a transform it is free, it stays on
   the compositor, and it can BREATHE - which is what turns two sliding sheets into
   something that undulates.

   THE INTERFERENCE IS THE EFFECT. Two banded sheets crossing in opposite directions
   at unrelated speeds produce bands that widen, narrow, brighten and part - motion
   no single animated layer can imitate, and none of it drawn. Give them the same
   period and it collapses into one sheet sliding. flairVars holds their ratio well
   away from 1 for that reason. */
.flair-aurora::before {
	/* far wider and taller than the row, so a drifting sheet never uncovers an edge
	   and a skew never exposes a corner. Costs nothing: it is one gradient, and the
	   part outside the row is clipped by .flair's overflow. */
	inset: -70% -130%;
	/* THE WHOLE RIBBON IN ONE TILE, and the tile is a known width.
	   
	   Both halves of that matter and each fixes something.
	   
	   The whole ribbon, because a curtain that runs green to teal and stops is one
	   colour with a gradient in it. A real aurora - and the &g wildcard right below,
	   which had the variety this did not - travels green, spring, teal, cyan, azure,
	   violet. One tile carries all of it, so any given stretch of row shows two or
	   three hues rather than one.
	   
	   A known width, because that is what makes the drift LOOP. This was a
	   repeating-linear-gradient with percentage stops, whose repeat length is some
	   fraction of a box sized in percentages of the row - a number nothing here
	   knows. The drift then translated by an unrelated pixel distance and snapped
	   back at the end of every cycle. A background-size tile is an exact pixel
	   length, the keyframes below travel exactly one of them, and the pattern at the
	   end of a cycle is the pattern at the start. It is seamless by construction
	   rather than by luck.
	   
	   Both ends of the tile are transparent so the tile joins itself invisibly - the
	   dark sky between ribbons IS the join. */
	background-image: linear-gradient(
		90deg,
		transparent 0%,
		rgba(61, 255, 168, 0.24) 8%,
		rgba(124, 255, 212, 0.19) 15%,
		rgba(47, 230, 208, 0.22) 24%,
		rgba(49, 199, 255, 0.17) 34%,
		rgba(111, 155, 255, 0.14) 44%,
		rgba(139, 108, 255, 0.19) 54%,
		rgba(180, 108, 255, 0.13) 63%,
		rgba(76, 160, 255, 0.1) 72%,
		transparent 86%,
		transparent 100%
	);
	background-size: var(--aur-near-band, 240px) 100%;
	background-repeat: repeat;
	opacity: var(--aur-near-peak, 0.8);
	animation:
		flairAurNear var(--aur-near-dur, 20s) linear var(--aur-near-delay, 0s) infinite,
		flairAurLean var(--aur-lean-dur, 15s) ease-in-out var(--aur-lean-delay, 0s) infinite alternate,
		flairAurBreatheNear var(--aur-breathe-dur, 12s) ease-in-out var(--aur-breathe-delay, 0s) infinite;
}

.flair-aurora::after {
	inset: -70% -130%;
	/* the far sheet: the same ribbon walked in the other order, so where the near
	   curtain is green this one is violet and the pair never resolve into a single
	   sequence of hues. Dimmer and on a narrower tile - it is further away. */
	background-image: linear-gradient(
		90deg,
		transparent 0%,
		rgba(180, 108, 255, 0.2) 9%,
		rgba(139, 108, 255, 0.15) 17%,
		rgba(76, 160, 255, 0.16) 28%,
		rgba(47, 230, 208, 0.13) 40%,
		rgba(61, 255, 168, 0.15) 50%,
		rgba(124, 255, 212, 0.1) 60%,
		transparent 78%,
		transparent 100%
	);
	background-size: var(--aur-far-band, 170px) 100%;
	background-repeat: repeat;
	opacity: var(--aur-far-peak, 0.5);
	animation:
		flairAurFar var(--aur-far-dur, 13s) linear var(--aur-far-delay, 0s) infinite,
		/* leaning the OTHER way, and on the near sheet's clock: the two curtains are
		   one sky, so they sway together even though they drift apart */
		flairAurLeanBack var(--aur-lean-dur, 15s) ease-in-out var(--aur-lean-delay, 0s) infinite alternate;
}

/* The drift, on `translate` - so the skew below can own `transform` outright and
   neither has to know about the other. Opposite directions, which is what makes the
   two sheets cross rather than travel together.
   
   EXACTLY ONE TILE, which is the whole seam fix: after travelling one background-size
   the pattern is bit-for-bit where it began, so the loop point does not exist. Any
   other distance snaps, and it snaps once every cycle forever. */
@keyframes flairGlitchWash {
	0%,
	100% {
		opacity: calc(var(--gl-wash-peak, 0.75) * 0.55);
	}
	38% {
		opacity: var(--gl-wash-peak, 0.75);
	}
	62% {
		opacity: calc(var(--gl-wash-peak, 0.75) * 0.72);
	}
}

/* Off the row for most of the cycle, across it quickly, and gone again - a hold that
   slips now and then rather than a bar sweeping on a beat. The pauses are what make
   it read as a fault; a constant crossing would be a scanner. */
@keyframes flairGlitchTrack {
	0%,
	54% {
		transform: translateY(-115%);
	}
	72% {
		transform: translateY(0%);
	}
	86%,
	100% {
		transform: translateY(115%);
	}
}

@keyframes flairAurNear {
	from {
		translate: 0 0;
	}
	to {
		translate: calc(var(--aur-near-band, 240px) * -1) 0;
	}
}

@keyframes flairAurFar {
	from {
		translate: 0 0;
	}
	to {
		translate: var(--aur-far-band, 170px) 0;
	}
}

/* the undulation. A curtain does not slide rigidly - it leans, and the lean travels
   along it. A slow alternating skew is that, at the cost of nothing. */
@keyframes flairAurLean {
	from {
		transform: skewX(calc(var(--aur-lean, 7deg) * -1));
	}
	to {
		transform: skewX(var(--aur-lean, 7deg));
	}
}

@keyframes flairAurLeanBack {
	from {
		transform: skewX(var(--aur-lean, 7deg));
	}
	to {
		transform: skewX(calc(var(--aur-lean, 7deg) * -1));
	}
}

/* an aurora brightens and fades over minutes, not seconds. Long, shallow, and never
   all the way out - the sky is still lit at the trough. */
@keyframes flairAurBreatheNear {
	0%,
	100% {
		opacity: calc(var(--aur-near-peak, 0.8) * 0.55);
	}
	50% {
		opacity: var(--aur-near-peak, 0.8);
	}
}

/* &g in an aurora line: the ribbon running THROUGH the words rather than behind
   them - the same shared scroll keyframes every other flair's wildcard uses, with
   the sky's own colours and a pace to match. */
.flair-aurora .fmtRainbow {
	background-image: repeating-linear-gradient(
		90deg,
		#1fd98a 0%,
		#3dffa8 8%,
		#7cffd4 15%,
		#2fe6d0 23%,
		#31c7ff 32%,
		#6f9bff 40%,
		#8b6cff 48%,
		#b46cff 55%,
		#6f9bff 63%,
		#2fe6d0 72%,
		#1fd98a 82%
	);
	animation-duration: var(--aur-text-dur, 11s);
	animation-delay: var(--aur-text-delay, 0s);
	text-decoration-color: #7cffd4;
}

/* --- bitcoin ---------------------------------------------------------------------
   A circuit board etched behind the row, dark until power runs through it, ending on
   a coin. See the note over circuitBoardMarkup in flair.js for how a board is drawn and why it is spans rather
   than svg.

   THIS IS THE ONE FLAIR WITH NO AMBIENT STATE. Every other effect here is always
   doing something - a wash breathing, particles drifting - and this one is a flat
   near-black field that shows nothing at all until a surge crosses it. That is the
   whole idea (a pod lit only by what is running through it) and it is also the one
   thing that could read as broken, so the surge is rolled per row and often: see
   CIRCUIT_CHANCE in app.js. A board nobody ever sees light up is just a dark bar -
   which is exactly what the /flair swatch was, and why it is carved out below.

   The palette is bitcoin's, and the core is deliberately not the logo orange: at 1px
   #f7931a reads brown on a black row. The core runs pale gold, the halo carries the
   orange, and the two together read as the orange the logo is. */
.flair-bitcoin {
	/* The core is deliberately SEMI-TRANSPARENT while the shadows around it are not,
	   which is the whole reason a lit trace reads as a glowing wire rather than as a
	   rule drawn on the text. Opacity animates the element as a whole, so this is the
	   only way to hold the halo brighter than the line inside it. */
	--cir-core: rgba(255, 212, 105, 0.55);
	--cir-glow: rgba(247, 179, 43, 0.75);
	--cir-halo: rgba(247, 147, 26, 0.4);
}

/* The board itself: a grey so near black that on the default theme it is barely a
   surface at all, which is what was asked for. It is a FLAT fill rather than a wash,
   so it has no direction to read as a bevel (see THE ONE RULE above) - and being
   opaque is what lets it be the board on a light theme too, where the row underneath
   is cream. */
.flair-bitcoin::before {
	inset: 0;
	/* Nearly black, and it has to stay ON the near side of black rather than at it:
	   the plate is the only thing this flair shows between surges, so a row with the
	   power off still has to read as a board sitting there rather than as a gap in the
	   log. Two points of lift is what does that on the default theme, and it is also
	   what makes the flair work on a light one, where the row underneath is cream. */
	background: #08080a;
}

/* holds the traces, the pads and the coin. opacity:1 because the base .flairFx is 0
   (that suits stars, where the layer IS the effect); every piece inside starts
   invisible on its own account instead. */
.flair-bitcoin > .flairFx {
	inset: 0;
	opacity: 1;
}

/* One run of trace. The 1px side comes from the markup rather than from here: which
   of the two is the hairline depends on whether the run is horizontal or vertical,
   and that is known where the run is generated. */
.flair-bitcoin .cirSeg {
	position: absolute;
	left: var(--c-x, 0%);
	top: var(--c-y, 6px);
	width: var(--c-w, 10%);
	height: var(--c-h, 1px);
	background: var(--cir-core);
	/* the bloom, as two static shadows rather than a filter: a filter re-rasterizes
	   the layer, and this rasterizes once and then costs nothing for the rest of the
	   row's life. Only the opacity ever animates. */
	box-shadow:
		0 0 4px var(--cir-glow),
		0 0 12px var(--cir-halo);
	opacity: 0;
}

/* The coin, anchored right and sized to the row - so a five-line message carries a
   bigger seal than a one-line one, which is the behaviour asked for and comes free
   from a percentage height.

   The wrapper exists for the halo: box-shadow on the <svg> would draw a square glow
   around its border box, and a round wrapper of the same size gives the disc the
   round one it wants. It is also the element the surge animates, so the svg itself
   never has an animation on it at all. */
.flair-bitcoin .cirCoinWrap {
	position: absolute;
	right: 6px;
	top: 50%;
	/* nearly the row's own height: it is a seal stamped on the message, so it grows
	   with a five-line one. Held under 100% so it never touches the rounded corners
	   of the plate it sits on. */
	height: 84%;
	aspect-ratio: 1;
	transform: translateY(-50%);
	border-radius: 50%;
	box-shadow:
		0 0 6px var(--cir-glow),
		0 0 16px var(--cir-halo);
	opacity: 0;
}

.flair-bitcoin .cirCoin {
	display: block;
	width: 100%;
	height: 100%;
	fill: var(--cir-core);
}

/* A SWATCH HAS TO SHOW SOMETHING.
   Every other flair is doing something between its one-shots, so its chip and its
   /flair preview show the effect just by existing. This one is a flat dark plate
   until a surge crosses it, and a surge crosses a given board about every five
   seconds - so the swatch a reader glances at while choosing was blank, which reads
   as the flair being broken rather than as it being subtle. That was the report.

   So in those two places only, the board is parked lit. It is not a reproduction of
   the flair's timing and is not meant to be: a swatch's job is to show what the
   thing looks like, and "invisible until pulsed" cannot be shown in a 96x24 box.
   The surge still runs over both - it is driven by Web Animations, which overrides
   these while it plays and hands the element back afterwards - so the preview gets
   the motion too, on top of a board you can actually see.

   Dimmer than a surge's peak on purpose, so the lit pulse still reads as brighter
   than the resting board underneath it. */
.flairChip.flair-bitcoin .cirSeg,
.fmtPreviewLine.flair-bitcoin .cirSeg {
	opacity: 0.42;
}

.flairChip.flair-bitcoin .cirCoinWrap,
.fmtPreviewLine.flair-bitcoin .cirCoinWrap {
	opacity: 0.5;
}

/* --- glitch -----------------------------------------------------------------------
   A signal with a fault in it. Two pseudo-elements carry the idle - a chromatic wash
   and a tracking bar crossing the row - and the tear itself is a one-shot in the fx
   layer, built out of clones of the row's own content (see glitchTear in app.js).

   The palette is chromatic aberration: the cyan and magenta a picture separates into
   when the channels stop lining up. It is the one thing that says "signal" without
   drawing anything, which matters here because between tears there is nothing else to
   look at. */
.flair-glitch {
	--gl-cyan: #37e8ff;
	--gl-magenta: #ff3ba7;
}

/* the wash: the two channels pulling apart across the row and easing back together.
   Horizontal, per THE ONE RULE above. */
.flair-glitch::before {
	inset: 0;
	background: linear-gradient(90deg, rgba(55, 232, 255, 0.16), rgba(255, 59, 167, 0.07) 46%, rgba(55, 232, 255, 0.13) 100%);
	opacity: var(--gl-wash-peak, 0.75);
	animation: flairGlitchWash var(--gl-wash-dur, 7s) ease-in-out var(--gl-wash-delay, 0s) infinite;
}

/* The tracking bar: a bright line crossing the row, the way a picture that has lost
   its hold rolls.

   This is a VERTICAL gradient, which the rule above forbids, and it is allowed here
   for the reason the rule itself names: it is not a ramp, it MOVES. The two failures
   the rule exists to prevent are a light edge along the top of every row and a dark
   one along the bottom, and a thin band that is transparent at both ends of its own
   box and spends most of its cycle off the row entirely produces neither.

   Travelling by 100% of its own height rather than of the row's is what makes it
   independent of how tall the row is: the element IS the row, so one of its heights
   is one row either way, on a one-line message and on a five-line one. */
.flair-glitch::after {
	inset: 0;
	background: linear-gradient(
		180deg,
		transparent 44%,
		rgba(55, 232, 255, 0.1) 47%,
		rgba(255, 255, 255, 0.16) 50%,
		rgba(255, 59, 167, 0.1) 53%,
		transparent 56%
	);
	animation: flairGlitchTrack var(--gl-track-dur, 9s) linear var(--gl-track-delay, 0s) infinite;
}

/* --- the tear -------------------------------------------------------------------
   Each band is a whole copy of the row, clipped to one horizontal slice of it. The
   clip is written once by the markup and never animated - only the transform moves,
   so a band lives its whole short life on the compositor.

   Absolutely positioned to sit exactly over the original, which is invisible for as
   long as these exist. The padding comes from the row itself at build time rather
   than from here: these have to line up to the pixel, and .line.flair is free to
   change its padding without this file being told. */
/* The layer the tear is built in.

   All three declarations are load-bearing and two of them are the base .flairFx being
   turned back off. It is `opacity: 0` with no geometry of its own, which suits stars -
   where the layer IS the streak and the js animates the layer itself - and every flair
   that puts CONTENT in the layer has to undo both. Missing them, the bands were laid
   out in a 0x0 box at zero opacity: present in the dom, correct in every other
   respect, and drawing nothing.

   visibility is the third, and it is this flair's own: the row is held hidden while
   the bands stand in for it, and visibility - unlike opacity - is a property a
   descendant can take back. */
.flair-glitch > .flairFx {
	inset: 0;
	opacity: 1;
	visibility: visible;
}

.flairFx .tear {
	position: absolute;
	inset: 0;
	pointer-events: none;
}

.flairFx .tearBand {
	position: absolute;
	inset: 0;
	/* the channels separating, which is what a torn band looks like in a picture that
	   has lost registration. Static, so it costs a rasterisation and not a frame. */
	text-shadow:
		1.5px 0 rgba(255, 59, 167, 0.55),
		-1.5px 0 rgba(55, 232, 255, 0.55);
}

/* the &g wildcard, in the signal's own two colours */
.flair-glitch .fmtRainbow {
	background-image: repeating-linear-gradient(90deg, #37e8ff 0%, #b9f4ff 16%, #ff3ba7 34%, #ffd0e8 52%, #37e8ff 74%, #7de6ff 88%, #37e8ff 100%);
	animation-duration: var(--gl-text-dur, 8s);
	animation-delay: var(--gl-text-delay, 0s);
	text-decoration-color: #ff3ba7;
}

/* the &g wildcard, in the board's own gold */
.flair-bitcoin .fmtRainbow {
	background-image: repeating-linear-gradient(90deg, #b8791a 0%, #ffd469 22%, #fff0c2 38%, #ffb63d 58%, #a5670f 78%, #b8791a 100%);
	animation-duration: var(--cir-text-dur, 9s);
	animation-delay: var(--cir-text-delay, 0s);
	text-decoration-color: #ffd469;
}

/* --- bubbles: glub ------------------------------------------------------------
   The one the client is named after, built on fire's machinery and reading as its
   opposite. See the note over bubbleMarkup in flair.js for why an ember and a
   bubble come apart at a glance; the short version is that a bubble is a ring with
   a highlight, and it GROWS on the way up.

   Three layers:
     ::before   the tank - a slow wash of aquarium light
     ::after    the fizz - fine carbonation, one cloned field, far behind
     .flairFx   the bubbles, one element each

   The rare pop is a one-shot fired by the shared ticker (see bubblePop in app.js),
   not a CSS loop: a loop pops on a beat and always in the same place. */
.flair-bubbles::before {
	inset: 0;
	/* HORIZONTAL - see THE ONE RULE at .flair above. This was a vertical
	   surface-lit gradient first, which is precisely the bevel that note describes.
	   Two backgrounds in one layer, as fireflies does: the flat floor under the ramp
	   keeps the far end of a long row from going black, and being flat it shades in
	   neither direction. */
	background:
		linear-gradient(90deg, rgba(79, 200, 216, 0.22), rgba(110, 232, 234, 0.08) 62%, transparent),
		linear-gradient(rgba(95, 224, 216, 0.06), rgba(95, 224, 216, 0.06));
	opacity: var(--bub-wash-peak, 0.66);
	animation: flairBubWash var(--bub-wash-dur, 10s) ease-in-out var(--bub-wash-delay, 0s) infinite;
}

/* light moving on water: a long, shallow swell rather than a pulse. Nothing in a
   tank flashes. */
@keyframes flairBubWash {
	0%,
	100% {
		opacity: calc(var(--bub-wash-peak, 0.66) * 0.62);
	}
	50% {
		opacity: var(--bub-wash-peak, 0.66);
	}
}

/* the fizz: specks a pixel across, rising fast and far behind the bubbles. A cloned
   box-shadow field is right here and wrong for the bubbles themselves - at this size
   nobody can see that they share a cadence, and the whole field is one layer. */
.flair-bubbles::after {
	left: var(--fizz-left, 20px);
	top: 100%;
	width: 1px;
	height: 1px;
	border-radius: 50%;
	background: rgba(168, 247, 240, 0.75);
	box-shadow: var(--fizz-field, 44px 0 #7ef0e6, 104px 0 #5fe0d8, 166px 0 #a8f7f0, 232px 0 #4fc8d8);
	opacity: 0.55;
	animation: flairFizzRise var(--fizz-dur, 4.6s) linear var(--fizz-delay, 0s) infinite;
}

@keyframes flairFizzRise {
	0% {
		translate: 0 0;
		opacity: 0;
	}
	14% {
		opacity: 0.5;
	}
	70% {
		translate: var(--fizz-x2, 4px) -1.6em;
		opacity: 0.28;
	}
	100% {
		translate: var(--fizz-x3, 7px) -2.4em;
		opacity: 0;
	}
}

/* the bubble layer is just a container; every bubble inside it is its own element */
.flair-bubbles > .flairFx {
	inset: 0;
	opacity: 1;
	overflow: hidden;
}

/* One bubble: a hairline ring with almost nothing inside it.

   The border is 1px at every size on purpose - a bubble is a film, and film has no
   thickness to scale. That is also why the swell is a `scale` on the BOX rather than
   an animated width: scaling the element would thicken its own outline as it grew,
   and a bubble whose edge fattens on the way up reads as a balloon.

   Barely-there fill: enough to separate it from the row behind, not enough to hide
   the text it passes over. currentColor drives ring and fill together, so one custom
   property tints the whole thing. */
.flair-bubbles .bubble {
	position: absolute;
	bottom: -3px;
	left: var(--b-x, 30%);
	width: var(--b-size, 7px);
	height: var(--b-size, 7px);
	box-sizing: border-box;
	border-radius: 50%;
	color: var(--b-tint, #7ef0e6);
	border: 1px solid currentColor;
	background: radial-gradient(circle at 32% 30%, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.02) 55%, transparent 72%);
	opacity: 0;
	/* THREE properties, three clocks. translate is the rise, transform is the sway,
	   scale is the swell - separate longhands, so none of them clobbers another and
	   all three stay on the compositor. Fire needs two of these; the third axis is
	   free. */
	animation:
		bubbleRise var(--b-rise, 6s) linear var(--b-rise-delay, 0s) infinite,
		bubbleSway var(--b-sway, 3s) ease-in-out var(--b-sway-delay, 0s) infinite alternate,
		bubbleSwell var(--b-grow-dur, 6s) linear var(--b-rise-delay, 0s) infinite;
}

/* the specular highlight - the single detail that stops a ring reading as a puff of
   smoke. Up and to the left, always, because a tank is lit from above: a highlight
   that wandered per bubble would read as several light sources in one aquarium. */
.flair-bubbles .bubble::before {
	content: "";
	position: absolute;
	left: 18%;
	top: 14%;
	width: 34%;
	height: 34%;
	border-radius: 50%;
	background: rgba(255, 255, 255, 0.85);
}

/* the long invisible tail is deliberate, as fire's is: with every bubble on its own
   period those dead stretches drift past each other, so the stream clusters and
   lulls instead of running evenly. */
@keyframes bubbleRise {
	0% {
		translate: 0 0;
		opacity: 0;
	}
	8% {
		opacity: var(--b-peak, 0.8);
	}
	62% {
		opacity: calc(var(--b-peak, 0.8) * 0.72);
	}
	78% {
		translate: 0 calc(var(--b-lift, 2.2em) * -1);
		opacity: 0;
	}
	100% {
		translate: 0 calc(var(--b-lift, 2.2em) * -1);
		opacity: 0;
	}
}

/* bubbles that never reach the surface - the small ones caught in the current */
.flair-bubbles .bubble.bubbleShort {
	animation-name: bubbleRiseShort, bubbleSway, bubbleSwell;
}

@keyframes bubbleRiseShort {
	0% {
		translate: 0 0;
		opacity: 0;
	}
	10% {
		opacity: calc(var(--b-peak, 0.8) * 0.85);
	}
	38% {
		opacity: calc(var(--b-peak, 0.8) * 0.4);
	}
	50% {
		translate: 0 calc(var(--b-lift, 2.2em) * -0.45);
		opacity: 0;
	}
	100% {
		translate: 0 calc(var(--b-lift, 2.2em) * -0.45);
		opacity: 0;
	}
}

/* wider and slower than an ember's sway: an ember is pushed by draught, a bubble by
   water */
@keyframes bubbleSway {
	from {
		transform: translate3d(calc(var(--b-amp, 12px) * -1), 0, 0);
	}
	to {
		transform: translate3d(var(--b-amp, 12px), 0, 0);
	}
}

/* pressure drops as it climbs, so it swells - and it is back at its starting size
   for the next cycle, which is invisible because the bubble is transparent by then */
@keyframes bubbleSwell {
	0% {
		scale: 1;
	}
	78% {
		scale: var(--b-grow, 1.18);
	}
	100% {
		scale: var(--b-grow, 1.18);
	}
}

/* the pop: appended by the ticker, driven by the Web Animations API, and removed
   when it finishes - so between pops this costs nothing and leaves nothing behind. */
.flair-bubbles .bubblePop {
	position: absolute;
	left: var(--p-x, 50%);
	top: var(--p-y, 25%);
	width: var(--p-size, 8px);
	height: var(--p-size, 8px);
	margin: calc(var(--p-size, 8px) / -2) 0 0 calc(var(--p-size, 8px) / -2);
	box-sizing: border-box;
	border-radius: 50%;
	color: var(--p-tint, #a8f7f0);
	border: 1px solid currentColor;
	opacity: 0;
	pointer-events: none;
}

/* the ring the burst leaves - a hairline expanding out of where the bubble was.
   Sized off the pop rather than fixed, so a big bubble leaves a big ring. */
.flair-bubbles .bubblePop .popRing {
	position: absolute;
	left: 50%;
	top: 50%;
	width: 100%;
	height: 100%;
	margin: -50% 0 0 -50%;
	border-radius: 50%;
	border: 1px solid currentColor;
	opacity: 0;
}

/* &g in a bubbles line: aquarium teal through pale cyan and back, at a slow watery
   pace - the same shared scroll keyframes every other flair's wildcard uses, with
   only the gradient and its period swapped. */
.flair-bubbles .fmtRainbow {
	background-image: repeating-linear-gradient(
		90deg,
		#2f9fb5 0%,
		#4fc8d8 10%,
		#7ef0e6 20%,
		#c9fdfa 28%,
		#eaffff 32%,
		#a8f7f0 38%,
		#5fe0d8 48%,
		#2f9fb5 58%
	);
	animation-duration: var(--bub-text-dur, 9s);
	animation-delay: var(--bub-text-delay, 0s);
	text-decoration-color: #7ef0e6;
}

/* stars: two dot fields twinkling out of phase. Every value that shapes the sky -
   the field itself, its position, its period, its phase - comes from a custom
   property randomized per line in flair.js, so no two messages share a pattern and
   nothing beats in unison. The fallbacks here keep a var-less element (e.g. the
   /flair preview chip) looking right. */
.flair-stars::before {
	left: var(--star-a-left, 8px);
	top: var(--star-a-top, 32%);
	width: 2px;
	height: 2px;
	border-radius: 50%;
	background: #ffffff;
	box-shadow: var(
		--star-a-field,
		34px -5px #cfe6ff,
		79px 9px #ffffff,
		124px -11px #a8ccff,
		181px 5px #ffffff,
		238px -8px #dbe9ff,
		296px 7px #ffffff,
		351px -4px #bcd8ff
	);
	animation: flairTwinkleA var(--star-a-dur, 2.6s) ease-in-out var(--star-a-delay, 0s) infinite;
}

.flair-stars::after {
	left: var(--star-b-left, 20px);
	top: var(--star-b-top, 62%);
	width: 2px;
	height: 2px;
	border-radius: 50%;
	background: #e9f2ff;
	box-shadow: var(
		--star-b-field,
		47px -12px #ffffff,
		99px 6px #b9d5ff,
		152px -7px #ffffff,
		214px 10px #d6e7ff,
		271px -10px #ffffff
	);
	animation: flairTwinkleB var(--star-b-dur, 3.4s) ease-in-out var(--star-b-delay, 0s) infinite;
}

/* Each field fades through a couple of uneven peaks, blinks fully out, and is
   MOVED while invisible (the --star-*-d* offsets) so it returns somewhere new -
   the "twinkle out here, twinkle in over there" effect. The offset is held
   constant to 99% and snapped back at 100% while opacity is still 0, so the loop
   restarts invisibly instead of sliding the field back across the row. */
@keyframes flairTwinkleA {
	0% {
		opacity: 0;
		transform: translate3d(0, 0, 0);
	}
	9% {
		opacity: 1;
	}
	28% {
		opacity: 0.18;
	}
	44% {
		opacity: 0.85;
	}
	49% {
		opacity: 0;
		transform: translate3d(0, 0, 0);
	}
	51% {
		opacity: 0;
		transform: translate3d(var(--star-a-dx, 7px), var(--star-a-dy, -5px), 0);
	}
	63% {
		opacity: 1;
	}
	81% {
		opacity: 0.2;
	}
	92% {
		opacity: 0.7;
	}
	99% {
		opacity: 0;
		transform: translate3d(var(--star-a-dx, 7px), var(--star-a-dy, -5px), 0);
	}
	100% {
		opacity: 0;
		transform: translate3d(0, 0, 0);
	}
}

@keyframes flairTwinkleB {
	0% {
		opacity: 0;
		transform: translate3d(0, 0, 0);
	}
	14% {
		opacity: 0.9;
	}
	33% {
		opacity: 0.15;
	}
	41% {
		opacity: 1;
	}
	47% {
		opacity: 0;
		transform: translate3d(0, 0, 0);
	}
	49% {
		opacity: 0;
		transform: translate3d(var(--star-b-dx, -9px), var(--star-b-dy, 6px), 0);
	}
	58% {
		opacity: 0.75;
	}
	72% {
		opacity: 1;
	}
	88% {
		opacity: 0.22;
	}
	99% {
		opacity: 0;
		transform: translate3d(var(--star-b-dx, -9px), var(--star-b-dy, 6px), 0);
	}
	100% {
		opacity: 0;
		transform: translate3d(0, 0, 0);
	}
}

/* rain: the first flair that moves DOWNWARD. The overcast wash fades horizontally
   like the others - the motion is what carries the direction here, and a vertical
   wash would bevel every row - while individual drops fall through on their own
   timings. Each drop is also its own ripple: at the end of its fall it flattens
   and spreads from its foot, so every splash belongs to a real drop instead of
   being a decorative ring on the edge. Rare squalls come from the ticker in app.js. */
.flair-rain::before {
	inset: 0;
	background: linear-gradient(90deg, rgba(120, 160, 195, 0.22), rgba(90, 130, 165, 0.07) 60%, transparent);
	animation: flairRainHaze var(--rain-haze-dur, 7s) ease-in-out var(--rain-haze-delay, 0s) infinite;
}

/* `container-type: size` turns the layer into a query container, so a drop can fall
   `100cqh` - the row's own height - and land on the bottom edge whether the message
   is one line or wrapped to four. (Safe here: the stylesheet already requires
   color-mix(), which shipped later than container query units.) */
.flair-rain > .flairFx {
	inset: 0;
	opacity: 1;
	container-type: size;
}

/* a drop is a thin streak, TILTED to match the direction it's actually travelling -
   a vertical dash drifting sideways reads as sliding rather than falling. The fall
   itself is eased IN, so it accelerates the way gravity does instead of gliding at
   a constant speed. */
.flair-rain .drop {
	position: absolute;
	top: 0;
	left: var(--d-x, 40%);
	width: 1.4px;
	height: var(--d-len, 7px);
	border-radius: 1px;
	background: linear-gradient(180deg, transparent, var(--d-tint, #cfe4f5));
	opacity: 0;
	rotate: var(--d-tilt, 7deg);
	animation: rainFall var(--d-fall, 0.5s) cubic-bezier(0.36, 0, 0.78, 0.34) var(--d-delay, 0s) infinite;
}

/* the ripple: the drop's OWN pseudo-element, so it costs no extra node and lands
   exactly where its drop does (it rides the same translate). Counter-rotated so the
   ring lies flat on the ground even though the streak above it is tilted, and timed
   to bloom at 80% - the instant the fall keyframe puts the drop on the bottom edge. */
.flair-rain .drop::after {
	content: "";
	position: absolute;
	left: 50%;
	/* centred ON the foot (half the ellipse's own height), so the ring expands
	   symmetrically around the impact point rather than hanging below it */
	bottom: calc(var(--d-ring, 9px) * -0.18);
	width: var(--d-ring, 9px);
	height: calc(var(--d-ring, 9px) * 0.36);
	margin-left: calc(var(--d-ring, 9px) / -2);
	border: 1px solid var(--d-tint, #cfe4f5);
	border-radius: 50%;
	rotate: calc(var(--d-tilt, 7deg) * -1);
	opacity: 0;
	animation: rainRipple var(--d-fall, 0.5s) ease-out var(--d-delay, 0s) infinite;
}

@keyframes rainFall {
	0% {
		translate: 0 -12px;
		opacity: 0;
	}
	10% {
		opacity: var(--d-op, 0.8);
	}
	/* the translate moves the streak's TOP, so its own length is subtracted to put
	   its FOOT on the ground - otherwise the ring lands below the row and clips. */
	80% {
		translate: var(--d-lean, 3px) calc(100cqh - var(--d-len, 7px) - 6px);
		opacity: var(--d-op, 0.8);
	}
	/* the streak dies on impact so what you see next is the ring, not a dash sitting
	   on the floor */
	87% {
		translate: var(--d-lean, 3px) calc(100cqh - var(--d-len, 7px) - 6px);
		opacity: 0;
	}
	100% {
		translate: var(--d-lean, 3px) calc(100cqh - var(--d-len, 7px) - 6px);
		opacity: 0;
	}
}

@keyframes rainRipple {
	0%,
	80% {
		transform: scale(0.2);
		opacity: 0;
	}
	85% {
		transform: scale(0.55);
		opacity: 0.9;
	}
	100% {
		transform: scale(2.1);
		opacity: 0;
	}
}

@keyframes flairRainHaze {
	0%,
	100% {
		opacity: calc(var(--rain-haze-peak, 0.8) * 0.6);
	}
	50% {
		opacity: var(--rain-haze-peak, 0.8);
	}
}

/* plume: violet exhaust lit from the LEFT and dying to black toward the right, the
   same direction the other flairs fade and the same direction the eye already reads.
   Where they're made of particles you could count, this one is made of volume - a
   handful of very large, very soft blobs drifting past each other behind the text.

   Two things carry it, and neither is the blobs.

   `mix-blend-mode: screen` on the puffs: where two overlap their light ADDS, which
   is what a camera does when it can't hold the highlights - lit evenly, but wherever
   the volume is densest the value clips and blows out toward white. Painting those
   hot spots directly would mean deciding up front where they go; letting overlap
   decide means they move as the plume moves and land somewhere different on every
   line, for free. `isolation` keeps that blending among the puffs - it must not
   reach the text sitting above them.

   And the EDGE BURN: the light pools where the plume meets the row's top and bottom
   and thins through the middle, rather than washing the row evenly. That's the
   detail that makes it read as a lit volume seen edge-on instead of a colored
   rectangle. Bright at BOTH edges is also why it doesn't bevel - a bevel is light at
   one edge and dark at the other, which is the trap that sent fire and rain
   horizontal in the first place. Symmetry reads as glow. */
.flair-plume::before {
	inset: 0;
	background: linear-gradient(90deg, rgba(168, 85, 247, 0.3), rgba(124, 58, 237, 0.1) 20%, transparent 46%);
	transform-origin: left center;
	animation:
		flairPlumeWash var(--plume-wash-dur, 9s) ease-in-out var(--plume-wash-delay, 0s) infinite,
		flairPlumeReach var(--plume-reach-dur, 22s) ease-in-out var(--plume-reach-delay, 0s) infinite;
}

/* the edge burn, masked by the same left-to-right falloff as everything else so it
   dies out with the plume rather than ringing the whole row. */
.flair-plume::after {
	inset: 0;
	/* The bands need thickness to survive antialiasing on a 22px row - a stop at
	   exactly 0% puts the highlight on one pixel and it vanishes - but only just.
	   Reaching further in leaves no dim middle, and the middle is what the text is
	   read against: the falloff from bright edge to dark centre happens inside the
	   first and last EIGHTH, so the middle 60% of the row stays genuinely dark and
	   the bands stay the brightest thing in it. Defined rather than fused. */
	background: linear-gradient(
		180deg,
		rgba(233, 213, 255, 0.3) 0%,
		rgba(240, 224, 255, 0.44) 5%,
		rgba(196, 148, 250, 0.2) 12%,
		rgba(139, 92, 246, 0.05) 22%,
		rgba(124, 58, 237, 0.03) 50%,
		rgba(139, 92, 246, 0.05) 78%,
		rgba(196, 148, 250, 0.21) 88%,
		rgba(240, 224, 255, 0.43) 95%,
		rgba(233, 213, 255, 0.28) 100%
	);
	-webkit-mask-image: linear-gradient(90deg, #000 0%, rgba(0, 0, 0, 0.34) 19%, transparent 47%);
	mask-image: linear-gradient(90deg, #000 0%, rgba(0, 0, 0, 0.34) 19%, transparent 47%);
	/* scaled from the lit edge, so "reach" grows and shrinks the plume's LENGTH
	   without touching the height of the bands. Done with a transform rather than an
	   animated mask-size: the mask would have to be re-rasterized every frame, where
	   a scale is handed straight to the compositor. */
	transform-origin: left center;
	animation:
		flairPlumeLamp var(--plume-lamp-dur, 13s) ease-in-out var(--plume-lamp-delay, 0s) infinite alternate,
		flairPlumeReach var(--plume-reach-dur, 22s) ease-in-out var(--plume-reach-delay, 0s) infinite;
}

.flair-plume > .flairFx {
	inset: 0;
	opacity: 1;
	isolation: isolate; /* confine the screen blending to the puffs */
	/* The fog builds up instead of arriving. `display: none` cancels animations on a
	   parked row, so un-parking restarts this one from the top for free - no JS, no
	   class to add and remove. Fading the WRAPPER rather than the puffs is what makes
	   it compose: group opacity multiplies with whatever each puff's own density
	   animation is doing, so they keep churning while the layer as a whole comes up.
	   The implicit `to` resolves to the element's own opacity, which is how the
	   preview chip's damped 0.5 keeps working without restating it. */
	animation: flairPlumeWake 900ms ease-out;
	/* ...and the same in reverse when the slot is handed to a newer row. `display` is
	   what actually stops the puffs animating, so it has to be the LAST thing to
	   change - `allow-discrete` holds it at block until the opacity transition has
	   finished, which is the difference between the fog thinning out and it being
	   switched off. The puffs keep churning through those 700ms, which is the point:
	   a layer that freezes and then vanishes reads as a glitch, not a fade.
	   Out is quicker than in on purpose - arriving is the part worth watching. */
	transition: opacity 700ms ease, display 700ms allow-discrete;
	/* deliberately NOT masked, unlike the wash and the edge burn. A mask here would
	   also hide the motes, which exist precisely to be seen out in the dark before
	   they reach the glow - they'd fade in halfway across instead of arriving from
	   nowhere. The puffs don't need it either: they're radial gradients that already
	   fade to nothing inside their own box, and they're sized and positioned so the
	   furthest one stops around 40% of the row on its own. */
}

/* a puff is sized to fill a message row, so in the ~40px preview chip a single one
   covers everything and the label disappears into its hot core. Damping the layer
   there makes the swatch READ like a row rather than being one, which is what a
   preview is for. */
.flairChip.flair-plume > .flairFx {
	opacity: 0.5;
}

/* one puff. Three animations compose: translate (drift), transform (swell) and
   opacity (density) - each on its own unrelated period and phase, all eased and
   alternating, so the field churns instead of looping and never shows a corner. */
.flair-plume .puff {
	position: absolute;
	left: var(--p-x, 40%);
	top: var(--p-y, 50%);
	width: var(--p-size, 110px);
	height: var(--p-size, 110px);
	margin: calc(var(--p-size, 110px) / -2) 0 0 calc(var(--p-size, 110px) / -2);
	border-radius: 50%;
	background: radial-gradient(
		circle,
		var(--p-core, #f3e8ff) 0%,
		var(--p-core, #f3e8ff) 5%,
		var(--p-tint, #a855f7) 26%,
		color-mix(in srgb, var(--p-tint, #a855f7) 45%, transparent) 56%,
		transparent 74%
	);
	/* blur is static - only opacity/transform/translate animate, so the expensive
	   part is rasterized once and the motion stays on the compositor */
	filter: blur(var(--p-blur, 12px));
	mix-blend-mode: screen;
	opacity: var(--p-op, 0.7);
	animation:
		plumeDrift var(--p-drift-dur, 11s) ease-in-out var(--p-drift-delay, 0s) infinite alternate,
		plumeSwell var(--p-swell-dur, 7s) ease-in-out var(--p-swell-delay, 0s) infinite alternate,
		plumeBreathe var(--p-breathe-dur, 8s) ease-in-out var(--p-breathe-delay, 0s) infinite alternate;
}

@keyframes plumeDrift {
	from {
		translate: 0 0;
	}
	to {
		translate: var(--p-dx, 18px) var(--p-dy, 6px);
	}
}

@keyframes plumeSwell {
	from {
		transform: scale(1);
	}
	to {
		transform: scale(var(--p-swell, 1.28));
	}
}

@keyframes plumeBreathe {
	from {
		opacity: calc(var(--p-op, 0.7) * 0.62);
	}
	to {
		opacity: var(--p-op, 0.7);
	}
}

@keyframes flairPlumeWake {
	from {
		opacity: 0;
	}
}

@keyframes flairPlumeWash {
	0%,
	100% {
		opacity: calc(var(--plume-wash-peak, 0.85) * 0.55);
	}
	50% {
		opacity: var(--plume-wash-peak, 0.85);
	}
}

/* the edge burn breathes rather than travelling: the plume's brightness rises and
   falls, but the direction it comes from is fixed - that's what makes a row read as
   lit from one side rather than as something sweeping past. The swing is wide (a
   plume at 45% is genuinely dim) because an even glow reads as a painted shape;
   uneven brightness is most of what makes it read as burning. */
@keyframes flairPlumeLamp {
	from {
		opacity: var(--plume-lamp-low, 0.45);
	}
	to {
		opacity: var(--plume-lamp-peak, 0.9);
	}
}

/* the plume reaching out and drawing back in. The profile is deliberately UNEVEN,
   the same way fire's huff is: a couple of modest breaths and then, once per cycle,
   a long throw far past the resting range. An even oscillation between two values
   reads as a mechanism; occasional large excursions read as something burning.
   Because the period (16-30s) and the phase are both randomized per line, and the
   throw sits partway through the cycle, a screenful of rows throws at scattered
   moments rather than together - which is what makes it feel intermittent rather
   than scheduled. It's drawn back in slowly: a plume that snaps back reads as a
   glitch. */
@keyframes flairPlumeReach {
	0%,
	16% {
		transform: scaleX(var(--plume-reach-min, 0.72));
	}
	26% {
		transform: scaleX(var(--plume-reach-max, 1.15));
	}
	38% {
		transform: scaleX(var(--plume-reach-min, 0.72));
	}
	48% {
		transform: scaleX(calc(var(--plume-reach-max, 1.15) * 0.88));
	}
	/* the throw: up fast, then a two-stage fall - most of the distance shed quickly,
	   the last of it drifting back. Held briefly on purpose. Stretched over a third
	   of the cycle it stops being an event and becomes the resting state. */
	58% {
		transform: scaleX(var(--plume-reach-far, 2.6));
	}
	68% {
		transform: scaleX(var(--plume-reach-max, 1.15));
	}
	82%,
	100% {
		transform: scaleX(var(--plume-reach-min, 0.72));
	}
}

/* motes: sparse specks that fade up out in the dark - past where the plume's glow
   has already tapered off - and drift LEFT into it, dying as they reach the bright
   edge. Arriving from the dark and never coming out the other side is what makes
   the plume read as drawing them IN rather than shedding them.

   `left` is animated rather than a transform because the travel is expressed in
   percentages of a row whose width isn't known here, and this is two or three
   1.4px dots on a slow period - not a particle system. The vertical drift rides
   `translate` so the path arcs instead of tracking a ruled line. */
.flair-plume .mote {
	position: absolute;
	top: var(--m-y, 50%);
	left: var(--m-from, 90%);
	width: var(--m-size, 1.4px);
	height: var(--m-size, 1.4px);
	border-radius: 50%;
	background: var(--m-tint, #e9d5ff);
	box-shadow: 0 0 4px var(--m-tint, #e9d5ff);
	opacity: 0;
	animation: plumeMote var(--m-dur, 7s) linear var(--m-delay, 0s) infinite;
}

@keyframes plumeMote {
	0% {
		left: var(--m-from, 90%);
		translate: 0 0;
		opacity: 0;
	}
	/* fades up in the dark rather than arriving at full strength - a speck that pops
	   into existence reads as a rendering glitch, not a particle */
	18% {
		opacity: var(--m-op, 0.6);
	}
	/* gone before it lands: swallowed by the glow, not sitting on top of it */
	82% {
		opacity: var(--m-op, 0.6);
	}
	100% {
		left: var(--m-to, 12%);
		translate: 0 var(--m-rise, 3px);
		opacity: 0;
	}
}

/* the plume surging: injected, swelled, removed (plumeSurge in app.js). It blends
   like the puffs, so while it passes it gives them something far brighter to
   overlap and the row briefly blows out. */
.flair-plume > .flairFx .surge {
	position: absolute;
	left: var(--ps-x, 50%);
	top: 50%;
	width: 200px;
	height: 200px;
	margin: -100px 0 0 -100px;
	border-radius: 50%;
	background: radial-gradient(
		circle,
		var(--ps-core, #f3e8ff) 0%,
		var(--ps-tint, #a855f7) 30%,
		color-mix(in srgb, var(--ps-tint, #a855f7) 38%, transparent) 55%,
		transparent 74%
	);
	filter: blur(16px);
	mix-blend-mode: screen;
	opacity: 0;
}

/* the squall sheet: injected, swept across, removed (see rainGust in app.js) */
.flair-rain > .flairFx .gust {
	position: absolute;
	top: -20%;
	bottom: -20%;
	width: 45%;
	background: linear-gradient(90deg, transparent, rgba(198, 222, 240, 0.22) 45%, rgba(224, 238, 250, 0.3) 60%, transparent);
	rotate: var(--g-tilt, 12deg);
	opacity: 0;
}

/* the shooting star: a one-shot streak fired by a shared ticker (see app.js) at
   random intervals, from a random spot, so it stays a blink-and-you-miss-it event
   rather than a loop. Parked invisible; the ticker drives it with a short
   transform+opacity animation the compositor owns. */
/* the one-shot layer every fx flair shares; each flair styles its own inside it */
.flairFx {
	position: absolute;
	pointer-events: none;
	opacity: 0;
	z-index: -1;
}

/* stars: the streak itself IS the layer */
.flair-stars > .flairFx {
	left: 0;
	top: 0;
	width: 44px;
	height: 2px;
	border-radius: 2px;
	background: linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, rgba(214, 234, 255, 0.35) 45%, #ffffff 100%);
}

/* lightning: the layer spans the row and holds a generated bolt + its whiteout.
   Empty until a strike fires, emptied again when it ends.

   opacity:1 is load-bearing and was missing. The base .flairFx is opacity:0 - which
   suits stars, where the layer IS the streak and the js animates the layer itself -
   and every other flair that puts CONTENT in the layer turns it back on. Lightning
   did not, so the bolt was built, animated and removed inside a fully transparent
   box: it fired exactly as designed and could not be seen. That, not the rate, is
   why the flair looked like it had no lightning in it, and why rain's borrowed bolt
   was visible when the lightning flair's own never was - rain's layer is opaque. */
.flair-lightning > .flairFx {
	inset: 0;
	opacity: 1;
}

.flairFx .strike {
	position: absolute;
	inset: 0;
	opacity: 0;
}

.flairFx .boltFlash {
	position: absolute;
	inset: 0;
	background: radial-gradient(150% 220% at 50% 50%, rgba(240, 248, 255, 0.92), rgba(160, 205, 255, 0.4) 45%, transparent 78%);
}

.flairFx .boltSvg {
	position: absolute;
	inset: 0;
	display: block;
}

/* wide soft stroke under a thin bright core - the cheap way to fake a glow
   without a filter (which would cost a repaint every frame of the strike) */
.boltGlow {
	fill: none;
	stroke: rgba(150, 200, 255, 0.85);
	stroke-width: 3.4;
	stroke-linejoin: round;
	stroke-linecap: round;
}

.boltCore {
	fill: none;
	stroke: #ffffff;
	stroke-width: 1.2;
	stroke-linejoin: round;
	stroke-linecap: round;
}

/* --- the flair budget --------------------------------------------------------
   A flaired row is not free: plume alone carries two animated pseudo-elements
   plus a handful of blurred, screen-blended blobs and their motes, each with its
   own animations. A screenful is fine; a flood of them on a phone is not.

   So only a few rows animate at once (see FLAIR_BUDGET in app.js) and the rest are
   parked here - exactly the same resting state reduced-motion readers get, and for
   the same reason: keep the tint so the row still reads as flaired, drop everything
   that costs a frame. `display: none` on the fx layer rather than a paused
   animation, because paused blobs still cost a composited, blended layer each.

   The rules are spelled out twice, once here and once in the reduced-motion block
   below. CSS has no way to say "match this class OR this media condition", and the
   media query is worth keeping as a guarantee that doesn't depend on the budget
   pass having run. */
/* plume is carved out here rather than overridden below, because `animation: none`
   would zero animation-NAME, and a later rule that only sets play-state can't bring
   the name back. */
.flairQuiet:not(.flair-plume)::before,
.flairQuiet:not(.flair-plume)::after {
	animation: none;
}

/* ...plume's two ambient layers PAUSE instead of stopping.
   Everything else is uncapped, so it only ever goes quiet well off screen and is
   running again long before you scroll to it. Plume is capped at 3, so a row wakes
   when a SLOT frees up - which happens in front of you, mid-scroll.

   Stopping and restarting can't be made seamless here: every plume row carries a
   large NEGATIVE animation-delay to de-sync it from its neighbours, so re-applying
   the animation drops it at that phase offset rather than at the first keyframe.
   There is no parked value that matches, because the value it wakes to is a
   different point on the cycle for every row. Pausing sidesteps the whole problem -
   the row resumes from exactly where it froze, so there is nothing to jump from,
   in either direction.

   This is affordable for precisely the reason the fx layer below isn't: these are
   two plain gradients whose paused animations cost a static composited layer and no
   per-frame work, where the puffs are blurred and blended and cost a real
   compositing pass each even standing still. */
.flair-plume.flairQuiet::before,
.flair-plume.flairQuiet::after {
	animation-play-state: paused;
}

.flairQuiet > .flairFx {
	display: none;
}

/* plume alone fades on the way out rather than cutting. The other four drop their
   fx layer from off screen, where nobody is watching it go; plume's cap means a row
   loses its slot in plain sight, the moment a newer one takes it. Pairs with the
   transition on the layer itself - this is only the target it eases toward. */
.flair-plume.flairQuiet > .flairFx {
	opacity: 0;
}

.flair-fire.flairQuiet::after,
.flair-lightning.flairQuiet::after {
	opacity: 0;
}

/* a quiet neon keeps the glass AND its filament lit at full - the whole flair is the
   tube, so parking either at anything less would look like a fault rather than a
   stilled effect. The failing segment lives in the fx layer, which the shared rule
   above already hides: a frozen gap is a broken sign, not a quiet one. */
.flair-neon.flairQuiet::before,
.flair-neon.flairQuiet::after {
	opacity: 1;
}

.flair-lightning.flairQuiet .fmtRainbow,
.flair-fire.flairQuiet .fmtRainbow,
.flair-rain.flairQuiet .fmtRainbow,
.flair-neon.flairQuiet .fmtRainbow,
.flair-plume.flairQuiet .fmtRainbow {
	animation: none;
	filter: brightness(1);
}

/* deliberately no parked opacity/transform for plume here: it freezes on its own
   animated values (see the pause above), and pinning them would reintroduce exactly
   the jump that pausing removes. The reduced-motion block still parks it, because
   there the animation really is off. */

.flair-rain.flairQuiet::after {
	opacity: 0.4;
}

.flair-stars.flairQuiet::before,
.flair-stars.flairQuiet::after {
	opacity: 0.55;
	transform: none;
}

/* a quiet matrix keeps its phosphor wash - that is the tint that says "flaired" -
   and loses the code, which the shared .flairQuiet rule above already hides with the
   rest of the fx layer. Parked bright rather than mid-breath. */
.flair-matrix.flairQuiet::before,
.flair-matrix.flairQuiet::after {
	opacity: var(--mtx-wash-peak, 0.7);
}

/* a quiet sonar keeps its scope lit - the glow is the tint that says "flaired" here -
   and loses the rings, which the shared .flairQuiet rule above already hides with the
   rest of the fx layer. Parked lit rather than mid-breath. Any contact being tracked
   goes with the rings, and app.js drops the whole sequence when it finds the
   wavefronts it was timing against have stopped. */
.flair-sonar.flairQuiet::before {
	opacity: var(--son-wash-peak, 0.75);
}

/* a quiet fireflies keeps the warm dark and loses the insects, which the shared
   .flairQuiet rule above already hides with the rest of the fx layer. Parked lit
   rather than mid-breath - and this is the flair that most needs it, since on a live
   row the wash is often the only thing lit anyway. */
.flair-fireflies.flairQuiet::before {
	opacity: var(--fly-wash-peak, 0.6);
}

@media (prefers-reduced-motion: reduce) {
	/* keep each flair's ambient tint (so a flaired line still reads as flaired)
	   but stop the motion, parking the moving parts out of sight rather than
	   freezing them mid-sweep. */
	.flair::before,
	.flair::after {
		animation: none;
	}
	.flair-fire::after,
	.flair-lightning::after,
	/* the fizz is parked below the row by its own `top: 100%` and clipped away, but
	   said out loud rather than relied on - it is one declaration away from being a
	   line of specks frozen against the bottom edge */
	.flair-bubbles::after,
	/* the tracking bar's whole position comes from its keyframes: stopped, it parks
	   across the middle of the row as a bright static line through the words, which
	   is the one place a still frame of this effect is worse than none of it. The
	   wash below stays, and carries the flair on its own. */
	.flair-glitch::after {
		opacity: 0;
	}
	/* these &g overrides set animation-name at a higher specificity than the generic
	   .fmtRainbow rule above, so they have to be switched off by name here -
	   otherwise the storm flicker / ember huff would keep running for
	   reduced-motion readers. */
	.flair-lightning .fmtRainbow,
	.flair-fire .fmtRainbow,
	.flair-rain .fmtRainbow,
	.flair-neon .fmtRainbow,
	.flair-matrix .fmtRainbow,
	.flair-sonar .fmtRainbow,
	.flair-fireflies .fmtRainbow,
	.flair-bubbles .fmtRainbow,
	.flair-aurora .fmtRainbow,
	.flair-bitcoin .fmtRainbow,
	.flair-glitch .fmtRainbow,
	.flair-plume .fmtRainbow {
		animation: none;
		filter: brightness(1);
	}
	/* the wash stays, parked lit; the code is in the fx layer, which the shared rule
	   below hides outright. Nothing falls for a reader who asked for less motion. */
	.flair-matrix::before,
	.flair-matrix::after {
		opacity: var(--mtx-wash-peak, 0.7);
	}
	/* the scope stays lit; nothing pings. The rings live in the fx layer, which the
	   shared rule below hides outright, and the ticker that starts a contact never
	   runs under reduced motion - so a reader who asked for less gets a still scope
	   and no motion at all from this flair. */
	.flair-sonar::before {
		opacity: var(--son-wash-peak, 0.75);
	}
	/* aurora needs nothing here, and that is worth saying rather than leaving to be
	   rediscovered: it is made of the two pseudo-elements the shared rule above
	   already stills, and both carry a real opacity of their own rather than getting
	   one from their keyframes. So a reader who asked for less motion gets the sky,
	   lit and completely still - which is the best outcome any flair here manages. */
	/* BITCOIN IS THE ONE EXCEPTION to the rule below, and it has to be.
	   Every other flair keeps its look in the pseudo-elements and its motion in the
	   fx layer, so hiding that layer leaves something behind. Circuit keeps its
	   ENTIRE look in the fx layer - the board is the flair - and the surge that
	   lights it is a one-shot the ticker never fires under reduced motion anyway. So
	   the shared rule would leave a reader who asked for less motion with a flat dark
	   bar and nothing else.
	   Instead the board is shown, parked lit and completely still: no animation ever
	   starts on it, and what you get is a circuit board with the power on. That is
	   the same outcome aurora, matrix and sonar get here, reached the other way
	   round. Dimmer than a surge peak, because this one does not pass. */
	.flair-bitcoin > .flairFx {
		display: block;
	}
	.flair-bitcoin .cirSeg {
		opacity: 0.34;
	}
	.flair-bitcoin .cirCoinWrap {
		opacity: 0.42;
	}
	/* glitch needs nothing for its wash, and that is worth saying rather than leaving
	   to be rediscovered: it carries a real opacity of its own (--gl-wash-peak) rather
	   than getting one from its keyframes, so stopping the animation parks it lit. The
	   tear is in the fx layer, which the shared rule hides, and the ticker that fires
	   one never runs under reduced motion anyway - so nothing here can move the text. */
	/* the field stays, parked lit; the insects are in the fx layer, which the shared
	   rule below hides outright. Nothing wanders and nothing glows. */
	.flair-fireflies::before {
		opacity: var(--fly-wash-peak, 0.6);
	}
	/* both tubes stay lit and the fault never happens: a sign that is simply ON.
	   The arrival strike carries its own (higher) specificity, so switching it off
	   takes the same selector back - the generic `.flair::before` rule above cannot
	   reach it. */
	.flair-neon::before,
	.flair-neon::after,
	.line.arrive.flair-neon:not(.flairQuiet)::before,
	.line.arrive.flair-neon:not(.flairQuiet)::after {
		animation: none;
		opacity: 1;
	}
	.flair-neon .neonDead {
		opacity: 0;
	}
	/* haze keeps its violet, parked at a readable density rather than mid-breath */
	.flair-plume::after {
		opacity: 0.7;
		translate: 0 0;
	}
	/* rain's ambient layers are opacity-animated, so parking them needs a value */
	.flair-rain::after {
		opacity: 0.4;
	}
	.flair-stars::before,
	.flair-stars::after {
		opacity: 0.55;
		transform: none;
	}
	/* the ticker also skips firing under reduced motion; this is the belt-and-braces */
	.flairFx {
		display: none;
		animation: none;
		transition: none; /* gone immediately, not eased away */
	}
	/* plume is carved out of the generic quiet rule so it can pause; nothing wakes
	   under reduced motion, so here it stops outright like everything else. */
	.flair-plume::before,
	.flair-plume::after {
		animation: none;
	}
}

/* --- the tic-tac-toe board (/tictactoe) ---------------------------------------
   Drawn inside a system line, so it inherits the log's monospace and sits in the
   flow rather than floating over it. An inline-grid because the row it lives in is
   text: a block would break the line it is part of.

   Sized in PX, not em. The board started at 1.7em, which came out around 22px a
   cell - fine to look at and much too small to hit with a thumb, and it would have
   shrunk further for anyone reading at a smaller font size. A tap target does not
   scale with type; it scales with fingers, so the cell is a length of its own and
   the glyph inside it is sized from that rather than from the log's text.

   clamp so it grows on the phones that need it without turning into a billboard on
   a desktop: 13vw is about 42px on the narrowest phones and 51px on a common one,
   held between a floor near the 44px tap-target guideline and a sensible ceiling. */
.tttBoard {
	--ttt-cell: clamp(44px, 13vw, 58px);
	display: inline-grid;
	grid-template-columns: repeat(3, var(--ttt-cell));
	grid-auto-rows: var(--ttt-cell);
	gap: 4px;
	margin-top: 4px;
}

/* Every cell is a button, including the ones nobody may press. Keeping them all as
   the same element means the grid cannot go crooked as cells fill in, and `disabled`
   is doing real work rather than being decorative - a spectator's board, or one
   waiting on the other player, is genuinely inert instead of clickable-and-ignored. */
.tttCell {
	font: inherit;
	/* from the cell, not from the log - see the note above */
	font-size: calc(var(--ttt-cell) * 0.5);
	font-weight: var(--font-mid-weight);
	line-height: 1;
	display: flex;
	align-items: center;
	justify-content: center;
	padding: 0;
	border: 1px solid var(--chrome-border);
	border-radius: 4px;
	background: transparent;
	color: var(--muted);
	/* not `pointer`: most of these are not buttons you may press, and a hand cursor
	   over a dead cell is a promise the board does not keep */
	cursor: default;
}

/* the cells you may actually play. Only ever yours, only ever on your turn */
.tttCell.tttOpen {
	cursor: pointer;
	border-color: var(--muted);
}

.tttCell.tttOpen:hover,
.tttCell.tttOpen:focus-visible {
	background: rgba(255, 255, 255, 0.09);
}

/* the three that ended it */
.tttCell.tttWin {
	border-color: currentColor;
	background: rgba(255, 255, 255, 0.07);
}

/* --- the connect four board (/connectfour) -------------------------------------
   Seven COLUMNS, each one button holding six discs - the column is the control, not
   the disc. You do not choose where a disc lands, so clickable cells would offer a
   precision the game does not have; and a column button is a tap target the full
   height of the board, which on a phone is a far easier thing to hit than any single
   cell. It also means hovering lights the whole column, which no amount of per-cell
   styling can do.

   The disc is smaller than a noughts-and-crosses cell because there are seven of
   them across instead of three - about 38px on a common phone, for a board around
   290px. That is under the 44px tap guideline for a CELL and comfortably over it for
   a COLUMN, which is what is actually being pressed. */
.c4Board {
	--c4-disc: clamp(30px, 9.4vw, 42px);
	/* the rim of an empty slot; see the note on .c4Disc for why it is not
	   --chrome-border. Scoped to the board so it cannot drift into the chrome. */
	--c4-hole: color-mix(in srgb, var(--accent) 30%, transparent);
	display: inline-flex;
	gap: 3px;
	margin-top: 4px;
}

.c4Col {
	display: flex;
	flex-direction: column;
	gap: 3px;
	padding: 3px;
	border: 1px solid transparent;
	border-radius: 6px;
	background: transparent;
	/* not `pointer`: most columns are not yours to press, and a hand cursor over a
	   dead one is a promise the board does not keep */
	cursor: default;
}

.c4Col.c4Open {
	cursor: pointer;
	border-color: var(--chrome-border);
}

.c4Col.c4Open:hover,
.c4Col.c4Open:focus-visible {
	background: rgba(255, 255, 255, 0.07);
	border-color: var(--muted);
}

/* An empty slot is a hole in the board, so it is drawn as one - outlined and dark -
   rather than as a disc that happens to have no color. A filled one gets its
   player's color as a background from the renderer, the same color their handle
   and their message body wear.

   The outline is NOT --chrome-border, which the rest of the chrome uses. That is 14%
   of the accent, which is right for a panel edge sitting next to a solid background
   but too faint for this: forty-two 1px circles, each enclosing nothing, on a screen
   turned down in a bright room. They disappeared into the background and the board
   stopped reading as a grid. This is roughly double, still short of the 38% reserved
   for a hover/focus edge - those have to out-shout the resting state, and the resting
   state here is what needed the help. */
.c4Disc {
	/* border-box so a disc is the same size whatever its rim is doing - an empty hole
	   has a 1px outline, a disc carrying a photo has a 2px one, and without this the
	   grid would shift by a pixel as the board filled up */
	box-sizing: border-box;
	width: var(--c4-disc);
	height: var(--c4-disc);
	border-radius: 50%;
	border: 1px solid var(--c4-hole);
	background: transparent;
}

.c4Disc.c4Filled {
	border-color: transparent;
}

/* A player's own picture on their discs, when profiles are on and they have one.
   The color does not go away: it is the background under the image (what you see
   while it loads, and what is left if it never arrives) and the rim around it (what
   still tells two players apart when their photos are similar, or when only one of
   them has a photo at all). */
.c4Disc.c4Face {
	border-width: 2px;
	overflow: hidden;
}

.c4Photo {
	display: block;
	width: 100%;
	height: 100%;
	object-fit: cover;
	border-radius: 50%;
}

/* The four that ended it.
   A static ring is easy to miss on a board you were not staring at, so they pulse -
   and then stop. A burst is what makes the moment obvious; a permanent strobe on a
   row that sits in the log for the next eight minutes is just noise, and the reader
   who looks over after it has finished still needs to see which four won. So the
   animation runs a fixed number of times and settles onto a ring thicker than the
   hairline this used to be.

   The pulse is a HALO rather than a growing ring: the discs sit 3px apart, so a ring
   wide enough to notice would collide with its neighbours, while a blurred glow
   overlapping them reads as light. Both keyframes name the same color and differ
   only in blur and spread, which interpolates cleanly - fading a shadow to
   transparent instead can leave a grey fringe mid-transition. */
.c4Disc.c4Win {
	box-shadow: 0 0 0 3px var(--fg);
	/* ~1.1 flashes a second, well under the three-per-second that makes flashing
	   content a seizure risk, for about seven seconds */
	animation: c4WinFlash 900ms ease-in-out 8;
}

@keyframes c4WinFlash {
	0%,
	100% {
		box-shadow: 0 0 0 3px var(--fg), 0 0 0 0 var(--fg);
	}
	50% {
		box-shadow: 0 0 0 3px var(--fg), 0 0 14px 4px var(--fg);
	}
}

/* Motion off, by preference or by the settings toggle. The win still has to be
   obvious without it, so the ring it would have settled on is drawn straight away
   with the halo already on - the same information, held still. */
@media (prefers-reduced-motion: reduce) {
	.c4Disc.c4Win {
		animation: none;
		box-shadow: 0 0 0 4px var(--fg), 0 0 12px 2px var(--fg);
	}
}

html.fxOff .c4Disc.c4Win {
	animation: none;
	box-shadow: 0 0 0 4px var(--fg), 0 0 12px 2px var(--fg);
}

/* The disc that was just dropped.
   A dot in the middle rather than a ring around the outside, because c4Win is
   already a ring and the two need to be told apart at a glance - including on the
   disc that is both, when the last move is the one that won. Inside the disc also
   means it never grows the footprint, on a board whose columns are 3px apart.
   Drawn in the overlay white with a dark halo rather than in a theme color: it has
   to read on top of an arbitrary per-pubkey hue and on top of a profile photo,
   which between them can be any color at all. */
.c4Disc.c4Last {
	position: relative;
}
.c4Disc.c4Last::after {
	content: "";
	position: absolute;
	left: 50%;
	top: 50%;
	width: 32%;
	height: 32%;
	transform: translate(-50%, -50%);
	border-radius: 50%;
	background: var(--overlay-fg);
	box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5);
}

/* --- dots and boxes -----------------------------------------------------------
   A 9x9 grid whose tracks alternate: dots get a small track, the gaps between them
   get a large one. So a cell's role follows from its row/column parity and the
   markup is one flat list of spans.

   The DOTS are the controls here and the edges are inert, which is what lets the
   dots have such generous hit areas - they grow into the edge tracks around them,
   and there is nothing there any more to steal a tap from. */
.dbBoard {
	--db-dot: 12px;
	/* Generous on purpose. The gap is the distance between two controls, so it sets
	   how far apart the tap targets can sit - and with only four of them across
	   there is room to spend. This puts the board at about the width of a c4 board
	   on the same screen. */
	--db-gap: clamp(38px, 13vw, 60px);
	--db-line: 4px;
	display: inline-grid;
	grid-template-columns: var(--db-dot) repeat(4, var(--db-gap) var(--db-dot));
	grid-template-rows: var(--db-dot) repeat(4, var(--db-gap) var(--db-dot));
	align-items: center;
	justify-items: center;
	margin-top: 4px;
}

/* A dot reads as the thing you press: the theme's ink rather than the muted grey
   the lattice used to be drawn in. One that can start no line at all is dimmed
   instead of hidden - the lattice should still look like a lattice. */
.dbDot {
	width: var(--db-dot);
	height: var(--db-dot);
	padding: 0;
	border: 0;
	border-radius: 50%;
	background: var(--fg);
	opacity: 0.45;
	position: relative;
	cursor: default;
}

.dbDot.dbDotLive {
	opacity: 0.9;
	cursor: pointer;
}

/* The tap target, grown well past the dot into the edge tracks either side. At the
   largest gap this is about 50px across on a ~70px pitch, so two neighbouring dots
   never overlap. */
.dbDot::before {
	content: "";
	position: absolute;
	inset: calc(var(--db-gap) * -0.32);
	border-radius: 50%;
}

.dbDot.dbDotLive:hover,
.dbDot.dbDotLive:focus-visible {
	opacity: 1;
	box-shadow: 0 0 0 3px color-mix(in srgb, var(--fg) 30%, transparent);
}

/* the dot you are holding, and the ones it can still be joined to. Two different
   marks because they mean different things: one is a choice you have made, the
   others are the choices it leaves you. */
.dbDot.dbDotHeld {
	opacity: 1;
	background: var(--accent);
	box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 45%, transparent);
}

.dbDot.dbDotTarget {
	opacity: 1;
	box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 55%, transparent);
}

/* An undrawn edge is a faint groove that shows the lattice; a drawn one is its
   player's color. Inert either way - it is drawn TO, never pressed. */
.dbEdge {
	background: var(--chrome-border);
	border-radius: 2px;
}

.dbH {
	width: 100%;
	height: var(--db-line);
}

.dbV {
	width: var(--db-line);
	height: 100%;
}

/* a line the held dot could still make. Shown so the second tap is a choice
   between things you can see rather than a guess about what will happen. */
.dbEdge.dbCand {
	background: color-mix(in srgb, var(--accent) 45%, transparent);
}

/* a won box carries its owner's color, dimmed so the lines stay the loudest thing
   on the board - the edges are what you are reading when you plan a move */
.dbBox {
	width: 100%;
	height: 100%;
	border-radius: 3px;
	background: transparent;
	opacity: 0.34;
}

.dbBox.dbOwned {
	opacity: 0.42;
}

/* the boxes that won it, once there is nothing left to draw */
.dbBox.dbWin {
	opacity: 0.72;
}

/* The score, which the shared game header has no slot for. Two swatches and two
   numbers: whose color is already how the board says who is who, so naming the
   players again here would only repeat the line above. */
.dbScores {
	display: flex;
	gap: 14px;
	align-items: center;
	margin-top: 6px;
	flex-wrap: wrap;
}

.dbScore {
	display: inline-flex;
	align-items: center;
	gap: 5px;
	font-variant-numeric: tabular-nums;
}

.dbSwatch {
	width: 10px;
	height: 10px;
	border-radius: 2px;
	border: 1px solid var(--chrome-border);
}

/* --- sudoku --------------------------------------------------------------------
   Nine by nine, with the 3x3 blocks marked by borders on the cells that sit on
   their edges rather than by nesting nine sub-grids - one flat list of buttons is
   far easier to index against an 81-character board. */
.suBoard {
	--su-cell: clamp(26px, 8.2vw, 34px);
	display: inline-grid;
	grid-template-columns: repeat(9, var(--su-cell));
	grid-auto-rows: var(--su-cell);
	gap: 0;
	margin-top: 4px;
	border: 2px solid var(--chrome-border-strong);
	border-radius: 4px;
	overflow: hidden;
}

.suCell {
	/* border-box so the block edges below do not push the grid out of square */
	box-sizing: border-box;
	width: var(--su-cell);
	height: var(--su-cell);
	padding: 0;
	border: 1px solid var(--chrome-border);
	background: transparent;
	color: var(--fg);
	font: inherit;
	font-size: 0.95em;
	line-height: 1;
	text-align: center;
	cursor: default;
}

.suCell:not(:disabled) {
	cursor: pointer;
}

/* the block edges: a heavier rule where a 3x3 begins */
.suCell.suEdgeL {
	border-left-color: var(--chrome-border-strong);
	border-left-width: 2px;
}
.suCell.suEdgeT {
	border-top-color: var(--chrome-border-strong);
	border-top-width: 2px;
}

/* A given is the puzzle, not somebody's answer, so it reads as printed: the theme's
   own ink, heavier. Everything else wears whoever wrote it.
   It IS tappable, though - holding one lights every other cell with that digit, which
   is the question you ask of a 7 far more often than "can I change it". The held and
   same-digit rules below sit after this one deliberately, so they paint over a given
   rather than under it. */
.suCell.suGiven {
	font-weight: var(--font-bold-weight);
	color: var(--fg);
	background: color-mix(in srgb, var(--fg) 7%, transparent);
}

/* the cell you are holding, and the row, column and block it answers to - the three
   groups you are actually reading when you decide what can go in it */
.suCell.suPeer {
	background: color-mix(in srgb, var(--fg) 5%, transparent);
}

.suCell.suSame {
	background: color-mix(in srgb, var(--accent) 20%, transparent);
}

.suCell.suHeld {
	background: color-mix(in srgb, var(--accent) 34%, transparent);
	box-shadow: inset 0 0 0 2px var(--accent);
}

/* a digit that repeats one of its peers. Both offenders are marked, because the
   grid cannot know which of them is the mistake. */
.suCell.suBad {
	background: color-mix(in srgb, var(--danger) 26%, transparent);
	color: var(--danger);
}

.suBoard.suSolved {
	border-color: var(--accent);
}

/* the picker */
.suKeys {
	display: flex;
	flex-wrap: wrap;
	gap: 4px;
	margin-top: 6px;
	max-width: calc(var(--su-cell, 34px) * 9);
}

.suKey {
	min-width: 30px;
	min-height: 32px;
	padding: 0 8px;
	border: 1px solid var(--chrome-border);
	border-radius: 5px;
	background: transparent;
	color: var(--fg);
	font: inherit;
	cursor: pointer;
}

.suKey:disabled {
	opacity: 0.4;
	cursor: default;
}

/* used nine times already: still shown, because "there are no more of these" is
   worth knowing, but no longer offered */
.suKey.suKeyDone {
	opacity: 0.28;
	text-decoration: line-through;
}

.suKey:not(:disabled):hover,
.suKey:not(:disabled):focus-visible {
	border-color: var(--accent);
}

.suHint {
	display: block;
	margin-top: 5px;
}

/* --- polls -------------------------------------------------------------------
   A poll row is a button with the tally drawn behind it: the bar is an absolutely
   positioned span sized by percentage, so the count and the label sit on top of it
   rather than being pushed around by it. */
.pollQ {
	display: block;
	margin-top: 4px;
	font-weight: var(--font-mid-weight);
}

.pollOpts {
	display: flex;
	flex-direction: column;
	gap: 4px;
	margin-top: 6px;
	max-width: 420px;
}

.pollOpt {
	position: relative;
	display: flex;
	align-items: center;
	gap: 8px;
	width: 100%;
	padding: 7px 10px;
	border: 1px solid var(--chrome-border);
	border-radius: 6px;
	background: transparent;
	color: inherit;
	font: inherit;
	text-align: start;
	cursor: pointer;
	/* the row is a tap target on a phone, so it gets a real one */
	min-height: 34px;
	overflow: hidden;
}

.pollOpt:hover,
.pollOpt:focus-visible {
	border-color: var(--muted);
}

/* the one you chose. A border rather than a fill, so it reads as "selected" without
   competing with the bar behind it for the same visual channel. */
.pollOpt.pollPicked {
	border-color: var(--accent);
}

.pollBar {
	position: absolute;
	inset-inline-start: 0;
	top: 0;
	bottom: 0;
	background: color-mix(in srgb, var(--accent) 16%, transparent);
	/* width is set inline from the tally; a change animates rather than jumping, which
	   is the one moment a poll has any motion in it */
	transition: width 220ms ease;
	pointer-events: none;
}

.pollLabel,
.pollCount {
	position: relative; /* above the bar */
}

.pollLabel {
	flex: 1;
	min-width: 0;
	overflow-wrap: anywhere;
}

.pollCount {
	flex: none;
	color: var(--muted);
	font-variant-numeric: tabular-nums;
}

.pollTally {
	display: block;
	margin-top: 5px;
}

@media (prefers-reduced-motion: reduce) {
	.pollBar {
		transition: none;
	}
}

/* the whole message line is a tap target for the per-user action popup */
.msgTap {
	cursor: pointer;
}

/* a reply renders the quoted message in a bordered block beneath the sender's
   handle, then the reply body below it. The rule is on the inline-start edge, so
   it moves to the right of the block when the ui is rtl. */
.replyBlock {
	display: block;
	margin: 2px 0 0;
	margin-inline-start: 6px;
	padding-inline-start: 8px;
	border-inline-start: 1px solid var(--chrome-border);
}

.replyQuote {
	display: block;
	color: var(--muted);
	word-break: break-word;
}

.replyBody {
	display: block;
	word-break: break-word;
	white-space: pre-wrap;
}

/* a translated message: its own accent-bordered block beneath the original, with
   a small uppercase label and the translation at full legibility (not the dim
   timestamp styling the old prototype used). */
.translationBlock {
	display: block;
	margin: 3px 0 1px;
	margin-inline-start: 6px;
	padding-inline-start: 8px;
	border-inline-start: 2px solid var(--accent);
}

.translationLabel {
	display: block;
	color: var(--accent);
	font-size: 0.72em;
	letter-spacing: 0.08em;
	text-transform: uppercase;
	opacity: 0.85;
	margin-bottom: 1px;
}

.translationText {
	display: block;
	color: var(--fg);
	word-break: break-word;
	white-space: pre-wrap;
}

/* the #xxxx pubkey suffix is dimmed to 60% of the name color, matching native
   bitchat (it renders the suffix run as baseColor.opacity(0.6)). */
.tag {
	opacity: 0.6;
}

/* Deliberately no weight rule for .line.mine. Native bitchat renders your own
   messages fully bold, but the orange already says "this one is yours" on its own,
   and bolding every glyph of it on top made your side of a conversation read as
   shouted - the more so in pure-client mode, where profiles are off and every one of
   your lines takes that color. Your rows now carry exactly the weights everyone
   else's do; only the color marks them. */

.ts {
	color: var(--muted);
	font-size: 0.8em;
}

/* a text-censored message: reads like a muted system aside, tap to reveal.
   inherits .ts sizing/color; italic + pointer signal it's a placeholder. */
.censoredMsg {
	font-style: italic;
	cursor: pointer;
	-webkit-tap-highlight-color: transparent;
}

.censoredMsg:hover {
	text-shadow: var(--glow);
}

/* the same placeholder in the quiet-channels digest. Italic like the feed's, but
   NOT a tap target: that list is a live summary that rewrites itself, so there is
   no message behind this to reveal. */
.censoredQuiet {
	font-style: italic;
}

/* "(edited)" - the same muted weight as the timestamp, because it is the same kind
   of thing: a fact about the message rather than part of it. */
.editedMark {
	color: var(--muted);
	font-size: 0.85em;
}

/* send-latency badge on your own messages - same look as the timestamp */
.ack {
	margin-left: 2px;
}

/* a send past its quick rebroadcasts, still without a confirmed echo: a soft
   red "unverified" that's visible without shouting - it's still quietly
   retrying in the background, not a dead end, so it stays unbolded. */
.ackFail {
	color: var(--danger);
}

.toggleMore {
	color: var(--muted);
	font-size: 0.8em;
	text-decoration: underline;
	cursor: pointer;
	margin-left: 8px;
	user-select: none;
	-webkit-tap-highlight-color: transparent;
	transition: color 0.12s ease;
}

.toggleMore:hover {
	color: var(--fg);
}

/* payment chip: a detected lightning invoice / lnurl / cashu token, rendered as
   a compact tap-to-copy pill instead of a raw blob. sits inline in the message
   body, styled like the app's other bordered chrome so it reads as a control. */
.payChip,
.uiChip {
	display: inline-flex;
	align-items: baseline;
	gap: 5px;
	margin: 0 1px;
	padding: 1px 8px;
	font: inherit;
	font-size: 0.86em;
	line-height: 1.5;
	color: var(--accent);
	/* A chip is chrome, not message text, so it has to stay legible wherever it
	   lands. "&g" paints glyphs with a gradient by setting -webkit-text-fill-color
	   to transparent, and that inherits - so a chip inside a gradient run (every
	   message, if your /format template wraps {msg} in &g) drew its border and fill
	   around an invisible label. currentColor re-anchors the fill to the chip's own
	   color, whichever variant is in play. */
	-webkit-text-fill-color: currentColor;
	background: color-mix(in srgb, var(--accent) 8%, transparent);
	border: 1px solid var(--chrome-border-strong);
	border-radius: 5px;
	cursor: pointer;
	vertical-align: baseline;
	user-select: none;
	-webkit-tap-highlight-color: transparent;
	transition: background 0.12s ease, border-color 0.12s ease;
}

.payChip:hover {
	background: color-mix(in srgb, var(--accent) 16%, transparent);
	border-color: var(--accent);
}

/* in-chat ui buttons ("{{map}}" etc) take the channel-link color rather than the
   payment accent, so "this opens something in the app" reads differently at a
   glance from "this is money you can copy". */
.uiChip {
	color: var(--geo);
	background: color-mix(in srgb, var(--geo) 9%, transparent);
}

.uiChip:hover {
	background: color-mix(in srgb, var(--geo) 18%, transparent);
	border-color: var(--geo);
}

.payChipIcon {
	font-size: 0.95em;
}

.payChip.copied {
	color: var(--fg);
	border-color: var(--accent);
	background: color-mix(in srgb, var(--accent) 20%, transparent);
}

/* "beginning of chat" / "beginning of this guild's history" divider.

   The rules either side are drawn here rather than typed as em-dashes in the
   string. As text they were part of the same run as the label, so a label wide
   enough to fill the view took the dashes with it onto a second line and the
   whole thing stopped reading as centered. As flex children they shrink to
   nothing instead, which keeps the label on one line at any width - and keeps it
   centered, since both rules always split whatever the label leaves. */
.barrier {
	display: flex;
	align-items: center;
	gap: 0.7em;
	color: var(--muted);
	opacity: 0.7;
	letter-spacing: 0.06em;
}

.barrier::before,
.barrier::after {
	content: "";
	flex: 1 1 0; /* basis 0, so the two are always equal however long the label is */
	min-width: 0;
	border-top: 1px solid currentColor;
}

.barrierLabel {
	flex: 0 1 auto;
	min-width: 0;
	/* the point of the exercise. #terminal sets pre-wrap; this line opts out. */
	white-space: nowrap;
	/* a backstop for a translation longer than the whole view: clip it rather than
	   let it wrap, since wrapping is the thing being fixed. */
	overflow: hidden;
	text-overflow: ellipsis;
	/* "beginning of this guild's history" is 33 characters of monospace, which at a
	   flat 0.85em still needs more than a 320px phone has once the rules and gaps
	   take their share. Scale with the viewport below that, and cap at 0.85em so it
	   stops shrinking the moment there's room. */
	font-size: clamp(0.62em, 2.9vw, 0.85em);
}

/* --- link preview card ----------------------------------------------------------
   A bordered strip under the message: small square thumbnail, title, host. Kept
   deliberately short - it's a hint about where a link goes, not a reader. */
/* A quoted note, drawn where its "nostr:nevent1..." was.
   Borrows the link card's shape on purpose - both are "something from elsewhere,
   standing inside this message" - but takes the accent edge rather than the link
   blue, because what is inside came from a person here rather than from the web. */
.quoteCard {
	display: block;
	margin: 6px 0 2px;
	padding: 6px 8px;
	/* 420px is the comfortable reading width, but never past the container - the
	   notes panel is narrower than that on a phone, and a fixed 420 hung the card's
	   right edge off the side of the sheet */
	max-width: min(420px, 100%);
	box-sizing: border-box;
	border: 1px solid var(--chrome-border);
	border-inline-start: 2px solid var(--accent);
	background: var(--field-bg);
}

.quoteHead {
	display: flex;
	align-items: baseline;
	gap: 8px;
	margin-bottom: 3px;
	cursor: pointer;
}

.quoteWho {
	font-weight: var(--font-bold-weight);
	overflow: hidden;
	text-overflow: ellipsis;
	white-space: nowrap;
}

.quoteHead:hover .quoteWho {
	filter: brightness(1.35);
}

.quoteTime {
	flex: none;
	color: var(--muted);
	font-size: 0.72em;
}

.quoteBody {
	display: block;
	color: var(--muted);
	font-size: 0.92em;
	/* a quote is a glimpse, and a long one must not out-shout the message quoting it */
	overflow-wrap: anywhere;
	white-space: pre-wrap;
}

.linkCard {
	display: flex;
	align-items: center;
	gap: 10px;
	margin: 6px 0 2px;
	padding: 6px 8px;
	max-width: 420px;
	border: 1px solid var(--chrome-border);
	border-inline-start: 2px solid var(--geo); /* the same blue links already use */
	background: var(--field-bg);
	text-decoration: none;
	color: inherit;
}

.linkCard:hover {
	border-color: var(--chrome-border-strong);
	border-inline-start-color: var(--geo);
}

.linkThumb {
	flex: none;
	width: 56px;
	height: 56px;
	overflow: hidden;
	background: var(--chrome-bg);
	display: block;
	position: relative;
}

.linkThumb img {
	width: 100%;
	height: 100%;
	object-fit: cover;
	display: block;
}

/* same treatment inline images get: a preview picture is exactly the "surprise
   image from a stranger" case the blur setting exists for, and being small doesn't
   change what it can be. */
.linkThumbCensored img {
	filter: blur(12px);
}

.linkCardText {
	min-width: 0;
	display: flex;
	flex-direction: column;
	gap: 2px;
}

.linkCardTitle {
	color: var(--fg);
	font-weight: var(--font-bold-weight);
	/* two lines and then stop: a long headline shouldn't push the next message off */
	display: -webkit-box;
	-webkit-line-clamp: 2;
	-webkit-box-orient: vertical;
	overflow: hidden;
}

.linkCardSite {
	color: var(--muted);
	font-size: 0.85em;
	overflow: hidden;
	text-overflow: ellipsis;
	white-space: nowrap;
}

.mediaPreview {
	position: relative;
	display: block;
	overflow: hidden;
	border-radius: 8px;
	margin-top: 6px;
}

.mediaPreview[data-img-toggle] {
	cursor: pointer;
}

.chatImagePreview {
	display: block;
	/* auto/auto so the width+height ATTRIBUTES supply the intrinsic size, which is
	   what lets the browser claim the row's full height before the bytes land (see
	   imageSizeAttrs in app.js). The max-* pair still does the actual sizing, and a
	   replaced element resolves both of them preserving the ratio - so a small
	   picture is still drawn small and nothing is stretched. */
	width: auto;
	height: auto;
	max-width: min(100%, 420px);
	max-height: 420px;
	object-fit: contain;
	border-radius: 8px;
	border: 1px solid var(--chrome-border);
}

.chatImagePreviewCensored {
	filter: blur(18px) brightness(0.55);
	transform: scale(1.04);
}

.mediaCensorOverlay {
	position: absolute;
	inset: 0;
	display: flex;
	align-items: center;
	justify-content: center;
	pointer-events: none;
	font-size: 0.85em;
	font-weight: var(--font-mid-weight);
	letter-spacing: 0.04em;
	color: var(--overlay-fg);
	text-shadow: var(--overlay-shadow);
	background: var(--field-bg);
}

#composerWrap {
	position: relative;
	/* the "+" menu anchors here; #inputbar's border + top padding, and its side
	   padding, are what sit between this box's edges and the button */
	--menuAnchorTop: 7px;
	--menuAnchorRight: 14px;
}

/* the mirror of #newMessagesBar at the other end of the feed: a quiet strip over the
   top of the terminal while a page of older history is being fetched. Same neutral
   fill and blur, so the two read as one family. */
#historyHint {
	/* absolute rather than a flex row: taking layout space would shrink the feed the
	   moment it appears, moving the very content the scroll restore works to hold
	   still. `top` is set from the terminal's own offset when shown, so it lands just
	   under the topbar without anything hardcoded. */
	position: absolute;
	left: 0;
	right: 0;
	padding: 6px 12px;
	background: var(--field-bg);
	backdrop-filter: blur(3px);
	-webkit-backdrop-filter: blur(3px);
	color: var(--muted);
	font-size: 0.85em;
	text-align: center;
	pointer-events: none;
	z-index: 5;
}

#historyHint[hidden] {
	display: none;
}

/* The column both foot-of-feed notices live in. They used to be two absolutes at the
   same `bottom: 100%`, i.e. on top of each other - fine while only one existed.
   THE FILL AND THE BLUR LIVE HERE, not on the two children. Two stacked elements each
   with their own backdrop-filter each blur what is behind THEMSELVES, and two
   translucent fills compose differently from one - so over anything but a flat colour
   the boundary between them showed as a visible line. One surface, one blur, one
   border, and the two notices are transparent text sitting on it. */
#bottomBars {
	position: absolute;
	left: 0;
	right: 0;
	bottom: 100%;
	display: flex;
	flex-direction: column;
	z-index: 5;
	background: var(--field-bg);
	backdrop-filter: blur(3px);
	-webkit-backdrop-filter: blur(3px);
	border-top: 1px solid var(--chrome-border);
}

/* ...and with neither notice up there is nothing to paint. Without this the border
   alone draws a hairline across the foot of the feed at all times. */
#bottomBars:not(:has(> :not([hidden]))) {
	background: none;
	backdrop-filter: none;
	-webkit-backdrop-filter: none;
	border-top: 0;
}

/* Unfolded, the list is a block of text rather than a line and needs the feed to stop
   reading through it - but it must still be FROSTED, not a black panel. So the work is
   done by the blur rather than by opacity: a much heavier blur destroys the legibility
   of whatever is behind while keeping the surface translucent and alive.
   Applied to the whole surface so it stays ONE surface - a denser panel with a lighter
   strip under it would be the seam again, in a different place. */
#bottomBars:has(> #quietBar.open) {
	background: color-mix(in srgb, var(--chrome-bg) 46%, transparent);
	backdrop-filter: blur(22px) saturate(0.7);
	-webkit-backdrop-filter: blur(22px) saturate(0.7);
}

#newMessagesBar {
	padding: 8px 12px;
	/* the fill moved to #bottomBars so the two notices share one seamless surface; what
	   is left here is the text. Neutral rather than an accent tint over there for the
	   same old reason: the label is already the accent color, and a green bar under
	   green text reads as one solid block instead of a notice sitting over the feed. */
	background: none;
	color: var(--accent);
	font-size: 0.9em;
	font-weight: var(--font-bold-weight);
	text-align: start;
	cursor: pointer;
	user-select: none;
}

#newMessagesBar:not([hidden]) {
	animation: barIn 0.18s ease-out;
}

@keyframes barIn {
	from {
		opacity: 0;
		transform: translateY(4px);
	}
}

#newMessagesBar[hidden] {
	display: none;
}

/* generic autocomplete popup (mentions now, local commands later): sits above
   the composer and grows upward, so the best/nearest match is at the bottom by
   the keyboard. blurred backdrop, just a hairline separator - no heavy borders. */
#suggestBox {
	position: absolute;
	left: 0;
	right: 0;
	bottom: 100%;
	z-index: 6;
	display: flex;
	flex-direction: column-reverse;
	/* size against the MEASURED visual viewport (--vvh, see fitViewport): plain vh
	   is the LAYOUT viewport, which on mobile ignores the on-screen keyboard, so a
	   long list could be given more room than is actually visible above it. */
	max-height: calc(var(--vvh, 100vh) * 0.42);
	overflow-y: auto;
	-webkit-overflow-scrolling: touch;
	overscroll-behavior: contain;
	background: color-mix(in srgb, var(--chrome-bg) 60%, transparent);
	backdrop-filter: blur(10px);
	-webkit-backdrop-filter: blur(10px);
	border-top: 1px solid var(--chrome-border);
	padding: 4px 0;
	font-size: 14px;
}

#suggestBox[hidden] {
	display: none;
}

.suggestRow {
	/* flex children shrink by default: in a max-height flex column an over-long
	   list would squash every row (padding collapsing, text clipped by the
	   overflow below) instead of letting the box scroll. Pin the natural height so
	   the rows stay legible and the overflow becomes a scroll. */
	flex: none;
	padding: 7px 14px;
	cursor: pointer;
	white-space: nowrap;
	overflow: hidden;
	text-overflow: ellipsis;
	-webkit-tap-highlight-color: transparent;
}

/* the active row samples its own --row-accent (e.g. a user's assigned color) so
   the highlight never clashes with the label; --accent (terminal green) is the
   fallback for accent-less items like future /command hints. */
.suggestRow.active {
	background: color-mix(in srgb, var(--row-accent, var(--accent)) 16%, transparent);
}

.suggestRow:hover:not(.active) {
	background: color-mix(in srgb, var(--fg) 5%, transparent);
}

.suggestRow .sfx {
	opacity: 0.6;
}

/* optional dim secondary label (e.g. a command description); unused by mentions */
.suggestMeta {
	color: var(--muted);
	margin-left: 8px;
	font-size: 0.85em;
}

/* active-reply indicator: a slim bar above the composer showing who you're
   replying to, with an x to cancel. sending clears it; so does the x. */
.replyBanner {
	display: flex;
	align-items: center;
	gap: 8px;
	padding: 6px 14px;
	background: var(--chrome-bg);
	border-top: 1px solid var(--chrome-border);
	border-inline-start: 2px solid var(--accent);
	font-size: 13px;
	color: var(--muted);
}

.replyBanner[hidden] {
	display: none;
}

.replyBannerArrow {
	color: var(--accent);
}

/* the tappable half of the banner: handle on the left, ping state on the right.
   Borderless and font-inherited so it still reads as the banner's own text
   rather than a control bolted onto it - the whole strip is the switch. */
.replyBannerToggle {
	flex: 1;
	min-width: 0;
	display: flex;
	align-items: baseline;
	justify-content: space-between;
	gap: 10px;
	border: 0;
	background: transparent;
	color: inherit;
	font: inherit;
	text-align: start;
	padding: 0;
	cursor: pointer;
	-webkit-tap-highlight-color: transparent;
}

.replyBannerText {
	flex: 1;
	overflow: hidden;
	text-overflow: ellipsis;
	white-space: nowrap;
}

/* Reads as the live state, not decoration: lit in the accent while the reply
   will ping, dimmed to the banner's own muted grey when it won't. Color alone
   never carries it - the words say "on" or "off" either way. */
.replyBannerPing {
	flex: none;
	font-size: 11px;
	letter-spacing: 0.04em;
	white-space: nowrap;
	color: var(--muted);
}

.replyBannerToggle[aria-pressed="true"] .replyBannerPing {
	color: var(--accent);
}

.replyBannerToggle:hover .replyBannerText {
	color: var(--fg);
}

.replyBannerX {
	border: 0;
	background: transparent;
	color: var(--muted);
	font: inherit;
	cursor: pointer;
	padding: 2px 6px;
	-webkit-tap-highlight-color: transparent;
}

.replyBannerX:hover {
	color: var(--fg);
}

/* flat, borderless composer to match native bitchat: just the input text and a
   "send" label sitting low above the home indicator, no boxes or separator. */
#inputbar {
	display: flex;
	/* flex-end, not center: the composer grows upward as lines are added, and the
	   prompt glyph and send button belong on the last line rather than floating in
	   the middle of a five-line block. Identical to center at one row. */
	align-items: flex-end;
	gap: 12px;
	padding: 6px 14px calc(6px + var(--inset-b));
	background: var(--chrome-bg);
	border-top: 1px solid var(--chrome-border);
}

/* the classic shell prompt: dim at rest, lit (with a touch of phosphor) while
   the composer has focus - a quiet "the terminal is yours" signal. */
.prompt {
	flex: none;
	color: var(--muted);
	font-size: 15px;
	font-weight: var(--font-bold-weight);
	margin-right: -4px; /* tuck the input's text closer to the glyph */
	transition: color 0.15s ease, text-shadow 0.15s ease;
	user-select: none;
	/* Same vertical metrics as the composer beside it. The row bottom-aligns its
	   items (the composer grows upward), so a glyph with no padding lands on the
	   box floor while the composer's text is held 6px above it by its own - which
	   sat the ">" 7.8px below the line it introduces. Matching the padding and the
	   line box puts it back on the baseline, and on the LAST line once the
	   composer has several. */
	padding: 6px 0;
	line-height: 1.3;
}

#inputbar:focus-within .prompt {
	color: var(--accent);
	text-shadow: var(--glow);
}

/* a one-row textarea dressed as the single-line prompt it used to be. It is a
   textarea because an <input type=text> cannot hold a newline at all - the html
   value sanitizer strips them - so ascii art was flattened on the way in. */
#chatInput {
	flex: 1;
	min-width: 0;
	border: 0;
	background: transparent;
	color: var(--fg);
	font: inherit;
	font-size: 15px;
	outline: 0;
	padding: 6px 0;
	/* what makes a textarea stop looking like one */
	resize: none;
	overflow-y: auto;
	line-height: 1.3;
	/* border-box is load-bearing, not tidiness: autoGrowComposer sets height from
	   scrollHeight, and scrollHeight already includes the padding. Under the
	   default content-box that padding gets added a second time, so the composer
	   grew a few px every time it was resized and never came back to one row. */
	box-sizing: border-box;
	height: calc(1.3em + 12px); /* one row + the 6px padding pair */
	/* past this it scrolls rather than eating the screen. The inline height set by
	   autoGrowComposer can exceed it; max-height is what actually caps the box. */
	max-height: 7.8em;
}

#sendBtn {
	border: 0;
	background: transparent;
	color: var(--accent);
	font: inherit;
	font-weight: var(--font-bold-weight);
	cursor: pointer;
	padding: 6px 0;
	transition: text-shadow 0.15s ease, filter 0.15s ease;
	-webkit-tap-highlight-color: transparent;
}

#sendBtn:hover {
	text-shadow: var(--glow);
	filter: brightness(1.15);
}

#sendBtn:active {
	transform: translateY(1px);
}

/* media upload "+": sits on the right, just left of "send" but with a generous
   gap so the two don't read as one control. only shown in a focused channel with
   assist live (js toggles [hidden]). */
#mediaBtn,
#dmMediaBtn {
	border: 0;
	background: transparent;
	color: var(--accent);
	font: inherit;
	font-size: 18px;
	font-weight: var(--font-bold-weight);
	cursor: pointer;
	padding: 6px 0;
	margin-right: 12px; /* on top of the 12px inputbar gap -> ~24px from "send" */
	transition: text-shadow 0.15s ease, filter 0.15s ease;
	-webkit-tap-highlight-color: transparent;
}

#mediaBtn:hover,
#dmMediaBtn:hover {
	text-shadow: var(--glow);
	filter: brightness(1.15);
}

#mediaBtn:active,
#dmMediaBtn:active {
	transform: translateY(1px);
}

#mediaBtn[hidden],
#dmMediaBtn[hidden] {
	display: none;
}

.gate {
	position: fixed;
	top: 0;
	left: 0;
	width: 100%;
	/* size to the MEASURED visual viewport (--vvh, set by fitViewport) instead of
	   the layout viewport: ios keyboards cover the window without shrinking it, so
	   an inset-0 gate kept centering its card against the full height and the card
	   sat low, half-hidden behind the keyboard. with --vvh the centering happens
	   within the actually-visible strip, so the card rides up with the keyboard. */
	height: var(--vvh, 100dvh);
	box-sizing: border-box;
	z-index: 1000;
	display: none;
	align-items: center;
	justify-content: center;
	padding: 16px;
	background: var(--scrim);
	backdrop-filter: blur(6px);
	-webkit-backdrop-filter: blur(6px);
}

.gate.show {
	display: flex;
	animation: gateFade 0.16s ease-out;
}

/* the card (and the users panel) rises a few px as the backdrop fades in - a
   crisp materialize, not a bouncy modal. display:none -> flex kills transitions,
   so these are keyframes triggered by .show. */
.gate.show .gateCard,
.gate.show .gateCardWrap,
.gate.show .usersPanel,
.gate.show .notesPanel,
.gate.show .settingsPanel {
	animation: cardIn 0.2s ease-out;
}

/* ...but only once. Where there is a wrapper it is the thing that rises, so the
   card and the [ close ] docked beneath it travel together; animating both would
   move the card twice as far as the button hanging off it. */
.gate.show .gateCardWrap .gateCard {
	animation: none;
}

@keyframes gateFade {
	from {
		opacity: 0;
	}
}

@keyframes cardIn {
	from {
		opacity: 0;
		transform: translateY(10px);
	}
}

/* The name gate's assembly: the card, and the one control that is not on it.
   [ close ] hangs off THIS rather than off the card, because the card is clipped
   to its chamfered outline and clip-path clips absolutely positioned descendants
   as well - anything docked at top:100% off the card is cut away with the corner.
   (.profileCardWrap exists for the same reason, one level down.) */
.gateCardWrap {
	position: relative;
	width: min(380px, 100%);
}

.gateCardWrap > .gateCard {
	width: 100%; /* the wrapper owns the width now */
}

.gateCard {
	position: relative; /* anchors the [ settings ] link to the card's edges */
	width: min(380px, 100%);
	border: 1px solid var(--chrome-border);
	background: var(--chrome-bg);
	padding: 16px;
	box-sizing: border-box;
}

.gateTitle {
	color: var(--accent);
	font-size: 16px;
	font-weight: var(--font-bold-weight);
	margin-bottom: 8px;
	text-shadow: var(--glow);
}

.gateSub {
	color: var(--muted);
	font-size: 13px;
	line-height: 1.35;
	margin-bottom: 14px;
}

/* one feedback language for every bordered control: edge brightens + a whisper
   of green fill on hover, and the whole control drops 1px while pressed. */
#nameForm button,
#guildForm button,
#settingsClose,
.profileCard #profileClose,
.idBtn {
	transition: border-color 0.15s ease, background-color 0.15s ease;
	-webkit-tap-highlight-color: transparent;
}

#nameForm button:hover,
#guildForm button:hover,
#settingsClose:hover,
.profileCard #profileClose:hover,
.idBtn:hover {
	border-color: var(--chrome-border-strong);
	background-color: color-mix(in srgb, var(--accent) 6%, transparent);
}

#nameForm button:active,
#guildForm button:active,
#settingsClose:active,
.profileCard #profileClose:active,
.idBtn:active {
	transform: translateY(1px);
}

/* reveal opted out of the box look, so it opts out of the box hover too - it
   underlines like the inline control it reads as. */
#revealNsecBtn:hover {
	border-color: transparent;
	background-color: transparent;
	text-decoration: underline;
	text-underline-offset: 3px;
}

/* A .usersPanel is a full-screen blurred panel (no card/borders), title + EXIT
   pinned to the top, the list filling the rest. Which only works if the panel is
   the height of the gate - and .gate centers by default, so left alone the panel
   takes its CONTENT height, grows straight past the viewport, and the list inside
   it never gets a box small enough to have anything to scroll. Long lists then
   just ran off the bottom of the screen with no way to reach them; on a phone the
   drag rubber-bands the page instead of moving the list.
   Written per-gate three times (users, gallery, dm inbox) and missed on three more
   (favorites, pings, games), which is the argument for keying it on the panel
   rather than on the gate: anything that reuses this layout gets the layout.
   Gates with their own positioning (the gallery, the dm pair) keep their rules;
   this only supplies what they all shared. */
.gate:has(> .usersPanel) {
	align-items: stretch;
	padding: calc(16px + env(safe-area-inset-top)) 16px calc(16px + var(--inset-b));
}

.usersPanel {
	width: 100%;
	display: flex;
	flex-direction: column;
	gap: 16px;
	min-height: 0;
}

.usersHeader {
	display: flex;
	align-items: center;
	justify-content: space-between;
	gap: 12px;
	color: var(--accent);
	font-size: 15px;
}

#usersTitle {
	font-weight: var(--font-bold-weight);
}

/* [EXIT] stays the header's own colour, deliberately. It was tried in the block
   button's red, on the grounds that one colour for "the way out" is easier to learn -
   and on a panel whose header, title and body are already accent-on-dark, a red word
   in the corner reads as festive rather than as chrome. The gates' [ close ] does
   carry that red, and works, because a gate is a single card with one way out rather
   than a panel of accented furniture. */
.usersExit {
	cursor: pointer;
}

.usersHeaderBtns {
	display: inline-flex;
	gap: 14px;
}

/* --- geohash globe map --- */
.mapPanel {
	width: 100%;
	max-width: 640px;
	height: min(88vh, 720px);
	display: flex;
	flex-direction: column;
	border: 1px solid var(--chrome-border);
	background: var(--chrome-bg);
	padding: 14px;
	box-sizing: border-box;
}

/* holds the canvas + the live-chat feed overlay in one relative box, so the feed
   can float over the globe's lower third without stealing layout height */
.mapStage {
	position: relative;
	flex: 1;
	min-height: 0;
	margin-top: 8px;
	display: flex;
}

.mapCanvas {
	flex: 1;
	min-height: 0;
	width: 100%;
	touch-action: none; /* we own drag/pinch */
	cursor: grab;
}

.mapCanvas:active {
	cursor: grabbing;
}

/* ephemeral live-chat ticker over the globe: newest at the bottom, older lines
   drift up and fade. never intercepts pointer events so the globe stays draggable
   through it. */
.mapFeed {
	position: absolute;
	left: 0;
	right: 0;
	bottom: 8px;
	display: flex;
	flex-direction: column;
	justify-content: flex-end;
	gap: 3px;
	max-height: 46%;
	overflow: hidden;
	pointer-events: none;
	text-align: start;
	font-size: 11.5px;
	line-height: 1.35;
}

.mapFeedLine {
	white-space: nowrap;
	overflow: hidden;
	text-overflow: ellipsis;
	opacity: 0;
	/* one keyframe owns the whole life: fade in, hold, fade out. JS removes the
	   node on animationend. */
	animation: mapFeedLife 5.5s ease forwards;
	/* legible over the bright day-side grid and the dark night side alike */
	text-shadow: 0 0 5px var(--bg), 0 0 5px var(--bg), 0 1px 2px var(--bg);
}

.mapFeedLine .mfGeo {
	color: var(--accent);
}
.mapFeedLine .mfMsg {
	color: var(--fg);
	opacity: 0.9;
}

@keyframes mapFeedLife {
	0% {
		opacity: 0;
		transform: translateY(7px);
	}
	9% {
		opacity: 1;
		transform: translateY(0);
	}
	72% {
		opacity: 1;
		transform: translateY(0);
	}
	100% {
		opacity: 0;
		transform: translateY(-5px);
	}
}

/* the globe's clock: the local time where you're looking. Deliberately the same
   voice as .mapHint - muted, small, uninsistent - because it is ambient context for
   the day/night shading rather than something to act on. */
.mapClock {
	margin-top: 2px;
	/* flush with the "geohash map" title above it: both are direct children of
	   .mapPanel and the title's button has no padding of its own, so start-aligning
	   is all it takes. "start" rather than "left" so it follows the text direction
	   under an rtl locale, like the rest of the frame. */
	text-align: start;
	color: var(--muted);
	font-size: 11.5px;
	letter-spacing: 0.02em;
	min-height: 1em; /* reserve the line so the globe doesn't jump when it arrives */
}

.mapHint {
	margin-top: 8px;
	text-align: center;
	color: var(--muted);
	font-size: 11.5px;
	letter-spacing: 0.02em;
}

/* the map title doubles as the menu trigger (overlay mode + display toggles) */
.mapMenuBtn {
	display: inline-flex;
	align-items: center;
	gap: 6px;
	background: none;
	border: none;
	padding: 0;
	color: var(--accent);
	font: inherit;
	cursor: pointer;
}

/* the mark that says the title opens something. The same drawn arrow the expiry
   picker carries (see --pick-arrow) rather than the literal "v" this used to be:
   two dropdowns in the same app should not be two different characters. Dimmer than
   the title, because it is a hint and not a label, and it flips on open - rotating
   one arrow rather than swapping in a second shape pointing the other way. */
.mapCaret {
	display: inline-block;
	width: 8px;
	height: 4px;
	background-image: var(--pick-arrow);
	background-position:
		0 0,
		4px 0;
	background-size: var(--pick-arrow-size);
	background-repeat: no-repeat;
	opacity: 0.55;
	transition: transform 0.15s ease, opacity 0.15s ease;
}

.mapMenuBtn:hover .mapCaret {
	opacity: 1;
}

.mapMenuBtn[aria-expanded="true"] .mapCaret {
	transform: rotate(180deg);
	opacity: 1;
}

/* the dropdown itself: floats over the stage's top-left, under the header */
.mapMenu {
	position: absolute;
	top: 6px;
	left: 6px;
	z-index: 6;
	display: flex;
	flex-direction: column;
	min-width: 200px;
	border: 1px solid var(--chrome-border-strong);
	background: var(--chrome-bg);
	padding: 4px;
}

.mapMenu[hidden] {
	display: none;
}

.mapMenuItem {
	display: flex;
	align-items: center;
	gap: 9px;
	background: none;
	border: none;
	text-align: start;
	font: inherit;
	font-size: 13px;
	color: var(--fg);
	padding: 7px 8px;
	cursor: pointer;
}

.mapMenuItem:hover {
	background: color-mix(in srgb, var(--accent) 12%, transparent);
}

/* an author `display` beats the UA's [hidden] rule whatever the specificity, so a
   row that can be hidden (the games one, in a DM) needs this said out loud */
.mapMenuItem[hidden] {
	display: none;
}

.mapMenuItem .mmMark {
	color: var(--accent);
	min-width: 18px;
	text-align: center;
}

.mapMenuSep {
	height: 1px;
	background: var(--chrome-border);
	margin: 4px 2px;
}

/* The "+" menu, in every composer that has one: chat, dm, notes. It hangs off the
   button that opened it - right edge flush with the row's content edge, bottom 3px
   clear of the "+" itself - rather than the map menu's top-left.
   The three rows are built differently (the chat one pads its inner bar, the notes
   one is flush), so each anchor declares the chrome sitting between its own edges
   and the button and the same two numbers then describe all three. Hardcoding the
   result instead is what left them a few px apart from each other. */
.mediaMenu {
	top: auto;
	left: auto;
	bottom: calc(100% - var(--menuAnchorTop, 0px) + 3px);
	right: var(--menuAnchorRight, 0px);
	min-width: 150px;
}

/* full-height like the inbox it sits beside rather than a centered card, which it
   gets from .gate:has(> .usersPanel) along with every other panel. What is left
   here is the part only the gallery needs. */
#galleryGate {
	position: absolute;
	inset: 0;
	/* Every other gate is a first-level overlay and they never coexist, so sharing one
	   z-index and letting DOM order decide is fine for them. The gallery is the
	   exception: it opens FROM a composer, including the DM thread's, so it has to
	   land on top of a gate that is already showing rather than behind it. */
	z-index: 1010;
}

/* --- /explore: the media wall ------------------------------------------------------
   Mobile FIRST, which is the opposite of the desktop tool this is modelled on. That
   one put five inputs in a horizontal header that wrapped into a mess under 700px and
   laid the grid out in 220px columns. Here the filters stack, the two buttons take
   half a row each like every other pair in the client, and the grid starts at ONE
   column - a phone showing two 150px thumbnails of a photograph is showing neither. */
.exploreFilters {
	display: flex;
	flex-direction: column;
	gap: 8px;
	flex: 0 0 auto;
}

.exploreField {
	/* the search field's own clear button, which webkit draws in its own grey */
	appearance: none;
	-webkit-appearance: none;
}

/* The house shape on the filters, the same two-layer sandwich every other
   chamfered control here uses: the outer element paints the EDGE and clips, the
   inner one paints the fill inset 1px over it. It has to be built this way because
   a border cannot follow a chamfer - clip-path cuts the border off along with the
   corner and leaves the diagonal bare. Bottom-right only, one cut. */
.chamferField {
	--cut: 10px;
	display: block;
	background: var(--chrome-border);
	clip-path: polygon(
		0 0,
		100% 0,
		100% calc(100% - var(--cut)),
		calc(100% - var(--cut)) 100%,
		0 100%
	);
}

.chamferField > .exploreField {
	display: block;
	border: 0;
	/* the 1px that lets the wrapper's colour read as an outline */
	width: calc(100% - 2px);
	margin: 1px;
	clip-path: inherit;
}

.exploreActions .actionBtn {
	--cut: 10px;
	position: relative;
	/* the fill layer is painted behind the label with a negative z-index, so the
	   button needs its own stacking context or it would sink behind the panel */
	isolation: isolate;
	border: 0;
	background: var(--chrome-border);
	clip-path: polygon(
		0 0,
		100% 0,
		100% calc(100% - var(--cut)),
		calc(100% - var(--cut)) 100%,
		0 100%
	);
}

.exploreActions .actionBtn::before {
	content: "";
	position: absolute;
	inset: 1px;
	z-index: -1;
	clip-path: inherit;
	/* what the transparent original showed through to: the panel behind it */
	background: var(--bg);
}

/* Both controls carry their state on the EDGE now, because the edge is what the
   element's own background paints. Left alone, the base rules would have set
   background-color on hover and focus - which on a sandwich is the outline, so
   lighting the button up would have erased its frame. The fill layer takes the
   wash instead, mixed toward the panel rather than to `transparent`: a
   translucent fill would show the frame colour through the middle of the button. */
.exploreActions .actionBtn:hover {
	background: var(--chrome-border-strong);
}

.exploreActions .actionBtn:hover::before {
	background: color-mix(in srgb, var(--accent) 6%, var(--bg));
}

.chamferField:focus-within {
	background: var(--chrome-border-strong);
}

/* the live button while it is actually watching. The label already reads "stop"; the
   frame lighting up says the same thing without having to be read. */
.exploreActions .actionBtn.actionOn {
	background: var(--accent);
}

.exploreActions .actionBtn.actionOn::before {
	background: color-mix(in srgb, var(--accent) 14%, var(--bg));
}

.exploreActions {
	margin-top: 2px;
}

/* the next page, docked under the wall. Full width and outside the scroller, so it
   is reachable without scrolling to the very end of a grid of pictures. */
.exploreMoreBtn {
	--cut: 10px;
	position: relative;
	isolation: isolate;
	flex: 0 0 auto;
	margin-top: 8px;
	border: 0;
	background: var(--chrome-border);
	clip-path: polygon(
		0 0,
		100% 0,
		100% calc(100% - var(--cut)),
		calc(100% - var(--cut)) 100%,
		0 100%
	);
}

.exploreMoreBtn::before {
	content: "";
	position: absolute;
	inset: 1px;
	z-index: -1;
	clip-path: inherit;
	background: var(--bg);
}

.exploreMoreBtn[hidden] {
	display: none;
}

.exploreStatus {
	flex: 0 0 auto;
	color: var(--muted);
	font-size: 12px;
	min-height: 1.3em; /* holds its line so the grid does not jump when it fills */
}

.exploreGrid {
	flex: 1;
	min-height: 0;
	overflow: hidden auto;
	display: grid;
	grid-template-columns: 1fr;
	gap: 10px;
	align-content: start;
	/* the same lesson the gallery grid learned: rows sized to their content, or a card
	   whose picture has not loaded collapses and the ones below climb over it */
	grid-auto-rows: max-content;
	padding-bottom: 8px;
}

/* two up once there is room for two to still be worth looking at, and no more than
   three - past that they stop being pictures and become tiles */
@media (min-width: 560px) {
	.exploreGrid {
		grid-template-columns: repeat(2, 1fr);
	}
}

@media (min-width: 900px) {
	.exploreGrid {
		grid-template-columns: repeat(3, 1fr);
	}
}

.exploreCard {
	border: 1px solid var(--chrome-border);
	background: var(--chrome-bg);
	overflow: hidden;
	/* the house shape: never the top-left, always the bottom-right */
	clip-path: polygon(0 0, 100% 0, 100% calc(100% - 12px), calc(100% - 12px) 100%, 0 100%);
}

.exploreMedia {
	display: block;
	width: 100%;
	/* REQUIRED alongside the width/height attributes the card writes. Those are there
	   to reserve the right box before the picture loads, but left to themselves the
	   height attribute is taken literally - so a photo came out at its natural pixel
	   height inside a full-width box and was letterboxed by object-fit, and the 1x1
	   case collapsed to a one-pixel sliver. With height:auto the attributes do the one
	   job they are here for: give the browser the aspect ratio. */
	height: auto;
	/* Tall enough to see, capped so one portrait photograph does not become the whole
	   screen. `contain` rather than `cover`: this is a browser for looking at what
	   people posted, and cropping it to a neat rectangle throws away the picture. */
	max-height: 62vh;
	object-fit: contain;
	background: #000;
	cursor: pointer;
}

.exploreMeta {
	padding: 8px 10px;
	font-size: 12px;
	line-height: 1.45;
	color: var(--muted);
	display: flex;
	flex-direction: column;
	gap: 3px;
}

.exploreWho {
	font-weight: var(--font-bold-weight);
	word-break: break-all;
}

.exploreTags {
	color: var(--accent);
	word-break: break-word;
}

/* The post's own words, folded away. The picture is the point; the text is why it was
   posted, and wanting it is the exception rather than the rule. */
.exploreSay {
	margin-top: 2px;
}

.exploreSay > summary {
	cursor: pointer;
	color: var(--fg);
	list-style: none;
}

.exploreSay > summary::-webkit-details-marker {
	display: none;
}

.exploreSay > summary::before {
	content: "+ ";
}

.exploreSay[open] > summary::before {
	content: "- ";
}

.exploreBody {
	margin-top: 4px;
	white-space: pre-wrap;
	word-break: break-word;
	color: var(--muted);
}

/* --- the saved-media gallery ---------------------------------------------------
   A grid rather than a list, because the picture is the label - there are no urls in
   here to read. auto-fill with a minimum tile means a phone gets three columns and a
   wide window gets more, without a breakpoint. */
.galleryGrid {
	flex: 1;
	min-height: 0;
	overflow-y: auto;
	display: grid;
	grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
	gap: 6px;
	padding: 8px 0;
	align-content: start;
	/* Rows sized to what the tiles actually are, which auto tracks were not doing.
	   A tile's picture is absolutely positioned (see .galleryImg), so the only thing
	   left in flow to size the row is the little remove button - and an auto row
	   collapsed to about 42px while `aspect-ratio: 1` still made the tile a ~93px
	   square. Every tile overflowed its track and overlapped the one below, which is
	   why the grid read as a wall of thin strips crammed onto one screen instead of a
	   list of squares that scrolls. Measured: 60 tiles went from a 681px scroll height
	   to 1488px, which is the fifteen rows they always were. */
	grid-auto-rows: max-content;
}

/* square tiles: a saved grid of mixed aspect ratios reads as a mess, and a consistent
   cell is what makes the grid scannable. The image covers it and gets cropped. */
.galleryItem {
	position: relative;
	aspect-ratio: 1;
	overflow: hidden;
	border: 1px solid var(--chrome-border);
	background: var(--chrome-bg);
	cursor: pointer;
}

.galleryImg {
	width: 100%;
	height: 100%;
	object-fit: cover;
	display: block;
}

.galleryItem:hover {
	border-color: var(--accent);
}

/* A picture whose host has dropped the file. It still renders here - it is coming
   out of this browser's cache - so the tile has to say what everyone else sees, and
   say it over the image rather than instead of it: replacing the thumbnail would
   leave the reader unable to tell WHICH saved picture had died. Dimmed and desaturated
   so it reads as gone at a glance, with the word for anyone who scans rather than
   reads. The X to remove it is already there. */
.galleryGoneTag {
	display: none;
}

.galleryItem.galleryGone {
	border-color: color-mix(in srgb, var(--danger) 55%, transparent);
}

.galleryItem.galleryGone .galleryImg {
	filter: grayscale(1) brightness(0.42);
}

.galleryItem.galleryGone .galleryGoneTag {
	display: block;
	position: absolute;
	left: 0;
	right: 0;
	bottom: 0;
	padding: 2px 4px;
	background: color-mix(in srgb, var(--danger) 78%, transparent);
	color: var(--bg);
	font-size: 9px;
	font-weight: var(--font-bold-weight);
	letter-spacing: 0.04em;
	text-align: center;
	text-transform: lowercase;
	pointer-events: none;
}

/* a saved message. Same square cell as a picture, so the grid stays aligned however
   the two are mixed - the tile just has type in it instead of pixels. */
.galleryItemText {
	background: var(--chrome-bg);
}

/* the default is WRITING: wrapped, left at a legible size, clipped at the bottom
   like any preview. Reading the first words is what identifies it. */
.galleryText {
	margin: 0;
	padding: 4px; /* GALLERY_TEXT_PAD in app.js reads this */
	font: inherit;
	font-size: 9px;
	line-height: 1.2;
	color: var(--fg);
	width: 100%;
	white-space: pre-wrap;
	overflow-wrap: anywhere;
	pointer-events: none; /* the tile takes the tap, not the text inside it */
}

/* ...and this is a DRAWING. It keeps its columns (wrapping would be the end of it),
   is laid out at max-content so app.js can measure the longest line, and is scaled
   down to fit. Transform rather than a smaller font because it doesn't reflow: the
   drawing shrinks with its shape intact instead of being re-laid-out at a size where
   it stops being the drawing. */
.galleryTextArt {
	width: max-content;
	white-space: pre;
	transform: scale(var(--fit, 1));
	transform-origin: top left;
}

/* text runs off the bottom far more often than a picture does; fade the last few
   pixels so a clipped tile reads as "there's more" rather than as a bad crop. */
.galleryItemText::after {
	content: "";
	position: absolute;
	inset: auto 0 0 0;
	height: 14px;
	background: linear-gradient(to bottom, transparent, var(--chrome-bg));
	pointer-events: none;
}

/* the remove affordance sits ON the tile, small and dim until you're over it - it
   should be reachable without being the first thing you see on every picture. */
.galleryDrop {
	position: absolute;
	top: 2px;
	right: 2px;
	width: 20px;
	height: 20px;
	line-height: 1;
	border: 0;
	padding: 0;
	background: rgba(0, 0, 0, 0.6);
	color: var(--fg);
	font: inherit;
	font-size: 12px;
	cursor: pointer;
	opacity: 0.45;
	-webkit-tap-highlight-color: transparent;
}

.galleryDrop:hover {
	opacity: 1;
	color: var(--danger);
}

.galleryEmpty {
	grid-column: 1 / -1;
	padding: 24px 8px;
	text-align: center;
	color: var(--muted);
}

/* --- the games panel ------------------------------------------------------------
   Five rows, each a miniature of its own board beside the name and a line about it.

   A list, not a grid: the gallery is a grid because a picture IS its own label and
   a square is the only shape that keeps a wall of them scannable. A game needs its
   name said out loud AND a sentence about what it is, and neither fits under a
   thumbnail. So the icon is a fixed square gutter down the left and the words run
   beside it - which also gives every row the same left edge to read down. */
.gamesList {
	display: flex;
	flex-direction: column;
	gap: 4px;
	flex: 1;
	min-height: 0;
	/* same reason as .usersList: the rows bleed sideways for their hover tint, and
	   a bare overflow-y would promote overflow-x to auto and turn that into a
	   sideways scroll */
	overflow: hidden auto;
}

.gameRow {
	display: flex;
	align-items: center;
	gap: 14px;
	text-align: left;
	appearance: none;
	background: none;
	border: 0;
	border-radius: 4px;
	/* the tint bleeds 8px past the list on each side, so the row reads as full-width
	   on hover without the resting layout shifting - same trick as .userRow */
	padding: 10px 8px;
	margin: 0 -8px;
	width: calc(100% + 16px);
	color: inherit;
	font: inherit;
	cursor: pointer;
	transition: background-color 0.12s ease;
	-webkit-tap-highlight-color: transparent;
}

.gameRow:hover {
	background-color: color-mix(in srgb, var(--accent) 6%, transparent);
}

.gameMeta {
	display: flex;
	flex-direction: column;
	gap: 2px;
	min-width: 0;
}

.gameName {
	color: var(--accent);
	font-size: 14px;
	font-weight: var(--font-bold-weight);
}

.gameBlurb {
	color: var(--muted);
	font-size: 12px;
	line-height: 1.35;
}

/* --- the miniatures --------------------------------------------------------------
   Built the way the real boards are built - a 3x3 css grid of cells - rather than
   drawn as pictures or spelled with emoji. Which means they theme for free (every
   one of them is currentColor over the panel), they sit at exactly one size on
   every device, and there is no image to ship.

   3x3 for all five, including the ones whose real board isn't: a 9x9 sudoku or a
   7x6 connect four at 38px would be a smear. What has to survive the shrink is
   which game this is, and that is carried by the KIND of mark - letters, discs,
   dots-and-edges, digits, tiles - not by the dimensions. */
.gameIcon {
	flex: none;
	display: grid;
	grid-template-columns: repeat(3, 1fr);
	grid-template-rows: repeat(3, 1fr);
	width: 38px;
	height: 38px;
	color: var(--accent);
}

.gameIcon i {
	display: flex;
	align-items: center;
	justify-content: center;
	font-style: normal;
	font-size: 9px;
	line-height: 1;
	font-weight: var(--font-mid-weight);
}

/* tic-tac-toe: the hash, and nothing else. The lines come from the gaps - a
   background showing through 1px gutters draws exactly the four interior strokes
   and no border, which is the shape of the game. */
.gameIcon-ttt {
	gap: 1px;
	background: color-mix(in srgb, var(--accent) 45%, transparent);
}

.gameIcon-ttt i {
	background: var(--chrome-bg);
}

/* connect four: discs. The top row is left as empty rings and the discs pile up
   from the bottom, because gravity is the whole game and a full grid would not say
   so. Two fills rather than one, so the miniature reads as two players. */
.gameIcon-c4 {
	gap: 2px;
}

.gameIcon-c4 i {
	border-radius: 50%;
	border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
	box-sizing: border-box;
}

.gameIcon-c4 .gi-on {
	background: var(--accent);
	border-color: var(--accent);
}

.gameIcon-c4 .gi-off {
	background: color-mix(in srgb, var(--accent) 25%, transparent);
	border-color: color-mix(in srgb, var(--accent) 55%, transparent);
}

/* dots and boxes: one closed box. The corners are the dots, the edge cells are the
   strokes between them, and the middle is filled because a closed box is the thing
   you are playing for. Every part is centred inside its own cell, so the strokes
   meet the dots. */
.gameIcon-db .gi-dot::after {
	content: "";
	width: 4px;
	height: 4px;
	border-radius: 50%;
	background: var(--accent);
}

.gameIcon-db .gi-hbar::after {
	content: "";
	width: 100%;
	height: 2px;
	background: var(--accent);
}

.gameIcon-db .gi-vbar::after {
	content: "";
	width: 2px;
	height: 100%;
	background: var(--accent);
}

.gameIcon-db .gi-on {
	background: color-mix(in srgb, var(--accent) 22%, transparent);
}

/* sudoku: digits in a ruled grid. The full border is what tells it apart from
   tic-tac-toe at this size - both are characters in a 3x3, and only one of them is
   a boxed one. */
.gameIcon-su {
	gap: 1px;
	padding: 1px;
	box-sizing: border-box;
	background: color-mix(in srgb, var(--accent) 45%, transparent);
}

.gameIcon-su i {
	background: var(--chrome-bg);
	font-size: 8px;
}

/* wordle: three rows of tiles closing in on the answer - greyed, then half, then
   solved. Drawn rather than scored in the board's own emoji, which sit at a
   different width to everything else in this list. */
.gameIcon-wo {
	gap: 2px;
}

.gameIcon-wo i {
	border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
	box-sizing: border-box;
}

.gameIcon-wo .gi-on {
	background: var(--accent);
	border-color: var(--accent);
}

.gameIcon-wo .gi-off {
	background: color-mix(in srgb, var(--accent) 18%, transparent);
}

/* voice recording bar: mirrors #inputbar's box so swapping them doesn't jump */
#recordBar {
	display: flex;
	align-items: center;
	gap: 12px;
	padding: 6px 14px calc(6px + var(--inset-b));
	background: var(--chrome-bg);
	border-top: 1px solid var(--chrome-border);
}
#recordBar[hidden] {
	display: none;
}

/* Both shared media surfaces get MOVED into the DM panel rather than duplicated
   (see mountMediaUi). Inside the panel they sit within its own padding and against
   its own background, so they drop the side padding and chrome fill that only make
   sense out at the app floor. The menu follows the anchor's own vars, so its offset
   comes along for free - the DM "+" sits flush to the panel edge, not 14px in. */
#recordBar.inDm {
	padding-left: 0;
	padding-right: 0;
	background: transparent;
	margin-top: 8px;
}

.recDot {
	width: 10px;
	height: 10px;
	border-radius: 50%;
	background: #ff4d4d;
	flex: none;
	animation: recPulse 1.1s ease-in-out infinite;
}
@keyframes recPulse {
	0%,
	100% {
		opacity: 1;
	}
	50% {
		opacity: 0.25;
	}
}
.recTime {
	font-variant-numeric: tabular-nums;
	color: var(--fg);
}
.recHint {
	color: var(--fg-dim);
	flex: 1;
	overflow: hidden;
	white-space: nowrap;
	text-overflow: ellipsis;
}
.recBtn {
	background: none;
	border: 1px solid var(--chrome-border-strong);
	color: var(--fg);
	font: inherit;
	padding: 5px 12px;
	cursor: pointer;
}
.recBtn.recStop {
	border-color: var(--accent);
	color: var(--accent);
}

/* inline voice-note player under a "[voice]" message */
.voicePreview {
	margin-top: 6px;
}
.voicePreview audio {
	width: 100%;
	max-width: 320px;
	height: 36px;
	display: block;
}

/* inline video player, sized like an image preview so a row of mixed media reads
   as one column rather than two different widths */
.videoPreview {
	margin-top: 6px;
}
.chatVideoPreview {
	display: block;
	width: 100%;
	max-width: min(100%, 420px);
	max-height: 420px;
	border-radius: 8px;
	background: #000;
}
/* the placeholder that stands in for a censored video. It holds the row open to
   roughly a player's height so revealing one doesn't jolt the feed, and it is a
   flat block rather than a blurred frame because with preload="none" there is no
   frame to blur - nothing has been fetched yet, which is the point. */
.videoCensored {
	width: 100%;
	max-width: min(100%, 420px);
	aspect-ratio: 16 / 9;
	max-height: 240px;
	border-radius: 8px;
	background: var(--field-bg);
	border: 1px solid var(--chrome-border);
}

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

/* the region picker reuses the map menu's look. It opens UPWARD from the composer
   row now that its button lives there - same as the attach menu beside it, and for
   the same reason: a menu anchored to the top of a full-height panel opens miles
   from the control that summoned it. */
.notesMenu {
	top: auto;
	bottom: calc(100% + 6px);
	/* 16px, not 0: `left` is measured from the containing block's PADDING box, and
	   the strip it sits in is inset by that much - so 0 would hang the menu off the
	   screen edge rather than lining it up under the button that opens it */
	left: 16px;
	right: auto;
}

.usersExit.active {
	color: var(--fg);
}

/* the "nearby notes" divider between the exact-cell section and the surrounding
   neighborhood; a hair more inset than the composer edges read comfortably */
.notesBarrier {
	margin: 10px 2px 6px;
}

/* landscape: the globe is height-bound and leaves wide empty bands on either
   side, so give it more room (wider/taller panel, full stage width). the ticker
   stays a bottom-left overlay whose lines spill rightward across the globe rather
   than being cropped in a side rail. the header and hint keep spanning top/bottom. */
@media (orientation: landscape) and (min-width: 600px) {
	.mapPanel {
		max-width: min(94vw, 1040px);
		height: min(94vh, 700px);
	}
	.mapFeed {
		left: 16px;
		right: auto;
		bottom: 10px;
		max-height: 72%;
		overflow: visible; /* let long lines run over the globe instead of clipping */
	}
	.mapFeed .mapFeedLine {
		overflow: visible;
		text-overflow: clip;
	}
}

/* --- location notes: a per-geohash bulletin board (persistent kind-1) --- */
/* notes has a text composer, so - like the DM thread - it lives inside #app
   (which JS sizes to the visual viewport) and is absolutely positioned rather
   than fixed to the layout viewport. That way the panel shrinks with the mobile
   keyboard and its bottom-anchored composer rides above it instead of being
   covered. */
#notesGate {
	position: absolute;
	inset: 0;
}

/* Both sheets are the same shape - header, body, composer on the bottom edge - so
   they get the same box. The geotag one had no rule of its own and sat on the
   generic .gate padding, which left a 16px gap under a composer that is supposed to
   BE the bottom edge.
   No bottom padding: the composer carries the safe-area inset itself, so its rule
   can run the full width instead of stopping short of a gap below it. */
#notesGate,
#geotagGate {
	align-items: stretch;
	justify-content: center;
	padding: calc(16px + env(safe-area-inset-top)) 16px 0;
}

/* No frame. This used to be a bordered, filled, padded card floating inside the
   gate - a window inside a window - which is what made it feel cramped next to the
   rest of the app. Nothing else here is drawn that way: the chat log runs edge to
   edge, and the users list is a header and a list on the backdrop with no box at
   all. So this is the users panel's shape, with the composer of the chat view
   underneath it: header, content, input bar, and the gate's own inset as the only
   margin.
   Losing the max-width matters as much as losing the border - a 560px column
   centred in a wide window reads as a dialog, and this is a place, not a prompt. */
.notesPanel {
	position: relative; /* anchors the composer's menus */
	width: 100%;
	height: 100%;
	display: flex;
	flex-direction: column;
	gap: 16px;
	box-sizing: border-box;
}

.notesTitleGeo {
	color: var(--muted);
	font-weight: 400;
}

.notesList {
	flex: 1;
	min-height: 0;
	overflow: hidden auto;
	display: flex;
	flex-direction: column;
	gap: 12px;
	font-size: 14px;
	/* room for the picker strip, which floats over this list and - unlike the
	   "[ N new messages ]" bar it borrows its look from - never goes away. Without
	   it the newest note sits permanently behind the pickers with no way to scroll
	   clear of them. Only takes effect at the bottom of the scroll, which is the
	   one place it is needed.
	   MEASURED, not guessed: this was a hardcoded 40px, and changing the typeface
	   grew the strip to 43 and left three pixels of the last note underneath it. The
	   height moves with the font, the type size and the locale, so the strip reports
	   its own (see the ResizeObserver in app.js) and the fallback here is only for
	   the instant before the first measurement. */
	padding-bottom: var(--notes-strip-h, 48px);
}

/* a single centered line for loading / empty / no-relays states */
.notesStatus {
	margin: auto;
	color: var(--muted);
	font-size: 13px;
	text-align: center;
	padding: 0 12px;
	line-height: 1.4;
}

/* the same line, but at the FOOT of a list rather than standing in for one. The
   `margin: auto` above centers a status in an empty column, which is right when it
   is the only thing there and wrong when it is the last of thirty rows. */
.notesFoot {
	margin: 0;
	padding: 4px 12px 8px;
}

.noteItem {
	border-inline-start: 2px solid color-mix(in srgb, var(--accent) 28%, transparent);
	padding: 2px 0;
	padding-inline-start: 10px;
}

.noteMeta {
	display: flex;
	align-items: baseline;
	gap: 8px;
	font-size: 12px;
	color: var(--muted);
	flex-wrap: wrap;
}

.noteAuthor {
	font-weight: var(--font-bold-weight);
}

/* the origin cell of the note - a dim accent so it reads as a channel tag but
   doesn't compete with the author name */
.noteGeo {
	color: color-mix(in srgb, var(--accent) 55%, var(--muted));
}

.noteExpiry {
	font-size: 11.5px;
	letter-spacing: 0.01em;
}

.noteDelete {
	margin-left: auto;
	cursor: pointer;
	background: none;
	border: none;
	color: var(--danger);
	font: inherit;
	font-size: 11.5px;
	padding: 0;
	-webkit-tap-highlight-color: transparent;
}

.noteDelete:hover {
	text-decoration: underline;
	text-underline-offset: 2px;
}

/* What a note is replying to, above its body. Indented and dimmed so the eye reads
   the reply first and the context second - the note you opened the sheet for is the
   one somebody wrote, not the one they were answering. */
.noteReply {
	margin: 2px 0 6px;
	padding-inline-start: 8px;
	border-inline-start: 2px solid var(--chrome-border);
	opacity: 0.85;
}

.noteReply .inlineRef {
	font-size: 12px;
}

.noteBody {
	margin-top: 3px;
	color: var(--fg);
	white-space: pre-wrap;
	overflow-wrap: anywhere;
	line-height: 1.4;
}

/* #chatInput's metrics, deliberately to the pixel: a tinted, bordered box was the
   other half of what made this feel like a form. It is the same act - typing a
   thing and sending it - so it should be the same object. */
.notesInput {
	flex: 1;
	min-width: 0;
	border: 0;
	background: transparent;
	color: var(--fg);
	font: inherit;
	font-size: 15px;
	outline: 0;
	padding: 6px 0;
	resize: none;
	overflow-y: auto;
	line-height: 1.3;
	/* see #chatInput: autoGrow sets height from scrollHeight, which already counts
	   the padding, so content-box would add it twice and the box would creep */
	box-sizing: border-box;
	height: calc(1.3em + 12px);
	max-height: 7.8em;
}

/* the composer IS the chat one now: a rule, a row you type in, and nothing else in
   the flow. The pickers hover above it rather than sitting inside it (see below), so
   the box has the same height here as it does in chat - which was the point. */
.notesComposer {
	position: relative; /* anchors the picker strip */
	display: flex;
	flex-direction: column;
	margin-inline: -16px; /* out to the gate's edges, so the rule spans the width */
	padding-bottom: var(--inset-b);
	background: var(--chrome-bg);
	border-top: 1px solid var(--chrome-border);
}

/* The two pickers, floated over the log on the same strip the "[ N new messages ]"
   notice uses - same anchor (bottom: 100% of the composer), same neutral --field-bg
   fill, same blur, same accent text.
   Reused rather than invented because the app already has a vocabulary for "a strip
   hovering over the log, attached to the composer", and a second floating treatment
   two inches from the first is how an interface stops looking designed. It carries
   no urgency either: that bar is deliberately a neutral fill and not an accent one
   (see #newMessagesBar), so borrowing it says "this sits over the feed" and nothing
   more - which is exactly what these are.
   Out of the flow, so the composer below is the chat composer's height exactly. */
.notesComposerRow {
	position: absolute;
	left: 0;
	right: 0;
	bottom: 100%;
	display: flex;
	gap: 10px;
	align-items: center;
	padding: 7px 16px;
	background: var(--field-bg);
	backdrop-filter: blur(3px);
	-webkit-backdrop-filter: blur(3px);
	z-index: 5;
}

/* mirrors #inputbar: bottom-aligned so the prompt and the buttons stay on the last
   line as the box grows upward, rather than floating in the middle of it */
.notesInputRow {
	display: flex;
	align-items: flex-end;
	gap: 12px;
	padding: 6px 16px;
	position: relative; /* anchors the attach menu */
}

/* The two pickers that describe the POST rather than its text - where it is
   pinned, and how long it lasts - styled as one pair.

   They used to be a native <select> in the app's grey chrome, which is the one
   control here that looked like a form widget: OS-drawn arrow, its own font
   weight, and a color nothing else on the row used. This is .settingSelect's
   look, which is the app's own answer to "a select" and already carries it
   everywhere in settings - accent text, a square hairline border, appearance
   stripped so the platform draws nothing of its own. The region button is a
   <button> rather than a select (it opens a menu that has to compute distances
   live), so the shared class is what keeps the two identical. */
.notesPick {
	appearance: none;
	-webkit-appearance: none;
	flex: none;
	border: 1px solid var(--chrome-border);
	border-radius: 0;
	background: transparent;
	color: var(--accent);
	font: inherit;
	font-size: 12.5px;
	font-weight: var(--font-bold-weight);
	letter-spacing: 0.02em;
	padding: 5px 9px;
	cursor: pointer;
	outline: 0;
	transition: border-color 0.15s ease;
	-webkit-tap-highlight-color: transparent;
}

.notesPick:hover,
.notesPick:focus,
.notesPick.active {
	border-color: var(--chrome-border-strong);
}

/* THE arrow, singular. Every control in the app that opens something downward draws
   this same one - the expiry picker, the settings selects, the map's title menu -
   from the one pair of gradients below rather than from a font glyph.

   Drawn rather than typed because a glyph is at the mercy of whatever font has it:
   the map title used a literal "v" and the settings selects a "▾", so two dropdowns
   two taps apart were different marks at different weights and heights. A gradient
   triangle is the same shape everywhere, scales with the text, and takes
   currentColor - so it dims and brightens with whatever it is sitting in. */
:root {
	--pick-arrow: linear-gradient(45deg, transparent 50%, currentColor 50%), linear-gradient(135deg, currentColor 50%, transparent 50%);
	--pick-arrow-size: 4px 4px, 4px 4px;
}

/* on a <select>, where a pseudo-element cannot go, it is the control's own
   background - which is also why the appearance had to be stripped first */
.notesExpiry {
	padding-right: 22px;
	background-image: var(--pick-arrow);
	background-position:
		calc(100% - 12px) calc(50% + 1px),
		calc(100% - 8px) calc(50% + 1px);
	background-size: var(--pick-arrow-size);
	background-repeat: no-repeat;
}

.notesExpiry option {
	background: var(--chrome-bg);
	color: var(--fg);
	font-weight: 400;
}

/* upload feedback ("uploading..." / errors) filling the space between the expiry
   picker and the attach/post cluster; empty when idle so it costs no layout. */
.notesUploadHint {
	flex: 1;
	min-width: 0;
	text-align: end;
	color: var(--muted);
	font-size: 12px;
	overflow: hidden;
	text-overflow: ellipsis;
	white-space: nowrap;
}

.notesUploadHint.error {
	color: var(--danger);
}

/* attach "+": mirrors the chat composer's #mediaBtn - borderless accent glyph
   with a generous gap before "post" so the two don't read as one control. */
.notesAttach {
	border: 0;
	background: transparent;
	color: var(--accent);
	font: inherit;
	font-size: 18px;
	font-weight: var(--font-bold-weight);
	cursor: pointer;
	padding: 6px 0;
	margin-right: 12px;
	transition: text-shadow 0.15s ease, filter 0.15s ease;
	-webkit-tap-highlight-color: transparent;
}

.notesAttach:hover {
	text-shadow: var(--glow);
	filter: brightness(1.15);
}

.notesAttach:active {
	transform: translateY(1px);
}

.notesAttach:disabled {
	opacity: 0.5;
	cursor: default;
	text-shadow: none;
	filter: none;
}

/* borderless, matching the chat #sendBtn - just accent text that glows on hover */
.notesPost {
	border: 0;
	background: transparent;
	color: var(--accent);
	font: inherit;
	font-weight: var(--font-bold-weight);
	cursor: pointer;
	padding: 6px 0;
	transition: text-shadow 0.15s ease, filter 0.15s ease;
	-webkit-tap-highlight-color: transparent;
}

.notesPost:hover {
	text-shadow: var(--glow);
	filter: brightness(1.15);
}

.notesPost:active {
	transform: translateY(1px);
}

.notesPost:disabled {
	opacity: 0.5;
	cursor: default;
	text-shadow: none;
	filter: none;
}

/* channel location readout under the title: the resolved place (when the api
   answers) over the always-available cell-size coverage line. */
.usersLocation {
	margin-top: 4px;
	line-height: 1.35;
	/* It opens the map. Deliberately the ONLY two declarations added for that: a
	   cursor, which says so on a pointer without altering the label, and killing the
	   tap highlight, which stops mobile flashing a grey box over it. Anything more -
	   a colour, an underline, a border - would turn a caption into a button, and the
	   caption is what it should stay. */
	cursor: pointer;
	-webkit-tap-highlight-color: transparent;
}

.usersLocation[hidden] {
	display: none;
}

.usersPlace {
	color: var(--fg);
	font-size: 13px;
	word-break: break-word;
}

.usersPlace:empty {
	display: none;
}

.usersCoverage {
	color: var(--muted);
	font-size: 11.5px;
	letter-spacing: 0.02em;
}

.usersExit:hover,
#status.tapExit:hover,
#status:hover {
	text-shadow: var(--glow);
}

.usersList {
	display: flex;
	flex-direction: column;
	gap: 8px;
	flex: 1;
	min-height: 0;
	/* clip the horizontal axis explicitly: the rows bleed 8px each side (negative
	   margins, for the full-width hover tint), and a bare `overflow-y: auto` would
	   promote overflow-x to `auto` too, turning that 16px bleed into a sideways
	   scroll. hidden pins the panel to the device width - vertical scroll only. */
	overflow: hidden auto;
	font-size: 14px;
}

.usersList:empty::before {
	content: var(--label-no-users);
	color: var(--muted);
	font-size: 13px;
}

/* divider between actively-talking users (above) and "ghosts" - users we've only
   detected via presence heartbeats, maybe still around, maybe stale (below) */
.usersBarrier {
	display: flex;
	align-items: center;
	gap: 10px;
	color: var(--muted);
	font-size: 0.8em;
	text-transform: uppercase;
	letter-spacing: 0.08em;
	margin: 4px 0;
}

.usersBarrier::before,
.usersBarrier::after {
	content: "";
	flex: 1;
	height: 1px;
	background: var(--chrome-border);
}

.userRow {
	display: flex;
	align-items: baseline;
	justify-content: space-between;
	gap: 12px;
	/* rows are tappable (profile card) - pad the hit target and tint on hover,
	   with negative margins so the resting layout doesn't shift */
	padding: 4px 8px;
	margin: -4px -8px;
	border-radius: 4px;
	cursor: pointer;
	transition: background-color 0.12s ease;
	-webkit-tap-highlight-color: transparent;
}

.userRow:hover {
	background-color: color-mix(in srgb, var(--accent) 6%, transparent);
}

/* tiny nostr avatar prefixing a user row (only when nostr profiles is on). the
   blank variant reserves the same space so rows without an avatar stay aligned. */
.avatar {
	flex: none;
	width: 18px;
	height: 18px;
	border-radius: 50%;
	object-fit: cover;
	align-self: center;
	background: var(--chrome-border);
}

.avatarBlank {
	background: transparent;
}

/* inline avatar prefixing a name in the chat stream: big enough to actually read,
   centered against the text line (which grows a bit taller to fit it - fine). */
.avatarInline {
	width: 22px;
	height: 22px;
	vertical-align: middle;
	margin-right: 6px;
	align-self: auto;
}

/* name + "x ago" travel together on the left; origin stays pinned right */
.userMeta {
	display: flex;
	align-items: baseline;
	gap: 8px;
	min-width: 0;
}

.userAgo {
	flex: none;
	color: var(--muted);
	font-size: 0.8em;
}

.userRow .sfx {
	opacity: 0.6; /* dimmed suffix, like the chat #tag */
}

.userOrigin {
	flex: none;
	color: var(--muted);
	font-size: 0.8em;
	text-transform: uppercase;
	letter-spacing: 0.04em;
}

.userOrigin.local {
	color: var(--accent); /* physically-present users stand out */
}

/* "copied npub" briefly replacing the local/teleport label after a row is
   tapped. Same accent/danger pair the verification marks use, so a success reads
   as a success in every theme rather than only in the green one. */
.userCopied[data-ok="1"] {
	color: var(--accent);
	text-shadow: var(--glow);
}

.userCopied[data-ok="0"] {
	color: var(--danger);
}

/* nostr profile card (tap a user to see their avatar, banner + metadata). Built
   to survive wildly varying nostr metadata: any banner aspect ratio shows whole
   (never cropped/warped), and every field below is optional and collapses. */
.profileCard {
	display: flex;
	flex-direction: column;
	width: min(460px, 100%);
	/* Against the MEASURED viewport, not `vh`. On ios the layout viewport is
	   taller than the visible one whenever browser chrome is showing, so a card
	   capped at 88vh can be taller than the strip it has to fit in - and the button
	   docked below it then sits off the bottom of the screen entirely. --vvh is
	   what the gate itself is sized to (see fitViewport), and 44px is the gate's
	   own padding top and bottom plus a little air. */
	max-height: min(88vh, calc(var(--vvh, 100dvh) - var(--card-back-h) - 44px));
	overflow-y: auto;
	overflow-x: hidden;
}

/* banner: full-bleed across the card top, whole image visible. wide banners fill
   the width at their natural height; unusually tall ones are capped and letter-
   boxed (contain) rather than cropped to a sliver or squished. */
.profileBanner {
	display: block;
	width: calc(100% + 32px);
	margin: -16px -16px 0; /* bleed past the card's 16px padding */
	max-height: 40vh;
	object-fit: contain;
	object-position: center;
	background: rgba(255, 255, 255, 0.04); /* fills letterbox gaps for tall banners */
}

.profileBanner[hidden] {
	display: none;
}

/* tall (portrait) banners: taller than they are wide, so contain+cap would pillar-
   box them behind side bars. drop the cap and let them fill the full width at their
   natural height instead - whole image, no bars, nothing cropped. the card scrolls
   if the result is very tall. applied via JS once the intrinsic size is known. */
.profileBanner.bannerTall {
	max-height: none;
}

/* avatar: standalone, and when a banner is present it's pulled up to overlap the
   banner's bottom edge (with a ring in the card color to separate them). */
.profileAvatar {
	width: 68px;
	height: 68px;
	border-radius: 50%;
	object-fit: cover;
	background: var(--chrome-border);
	border: 3px solid var(--chrome-bg);
	margin-top: 12px;
}

.profileAvatar[hidden] {
	display: none;
}

.profileCard.hasBanner .profileAvatar {
	margin-top: -40px;
}

/* the handle, and the notes control pinned to the far end of the same line.
   baseline-aligned rather than centred so the small label sits on the handle's
   line rather than floating in the middle of a wrapped one. */
.profileHead {
	display: flex;
	align-items: baseline;
	justify-content: space-between;
	gap: 12px;
	margin-top: 10px;
}

.profileName {
	font-size: 16px;
	font-weight: var(--font-bold-weight);
	word-break: break-word;
	/* the margin moved to .profileHead, which is now what sits under the avatar */
	min-width: 0;
}

.profileName .sfx {
	opacity: 0.6;
}

/* their nostr display name, on its own line under the handle as "aka <name>".
   The handle stays primary - it is how everyone sees them in chat - so the alias
   is smaller, unbolded and dimmed.

   It rode at the end of the handle's line for a while, on the reasoning that the
   relationship between the two names is the point and one line states it. What
   that missed is how often the handle is already long: a 24-character name plus
   "aka" plus another 24 wraps on any phone, and a wrapped alias reads as a second
   line anyway - just a ragged one that starts wherever the handle happened to
   end. A row of its own is the same information, aligned.

   `aka` is still part of the alias rather than chrome introducing it - one phrase
   in one colour, so the eye takes "aka carson" as a single quieter thing. The
   whole span is styled; .profileAkaName carries no colour of its own. */
.profileAka {
	display: block;
	font-weight: 400;
	font-size: 13px;
	color: var(--fg);
	opacity: 0.7;
}

/* nip-05 and the lightning address, stacked under the handle. Both are addresses
   this person answers to, so they read as one block rather than as identity in
   one place and a payment detail somewhere further down. */
.profileIdent {
	margin-top: 3px;
}

.profileIdent[hidden] {
	display: none;
}

/* Plain rows, flush at the card's edge - the same edge the handle above starts
   from, which is the alignment that actually reads. The bolt trails the address
   rather than leading it: as a prefix it indented the lightning row past the
   nip-05, and reserving a gutter to fix that indented both of them away from the
   handle instead. Trailing, it costs no row any left-hand space. */
.identRow {
	color: var(--muted);
	font-size: 12px;
	line-height: 1.5;
	word-break: break-word;
}

.identBolt {
	margin-inline-start: 5px;
}

/* The verdict on a claim a profile makes about someone else's server (nip-05,
   lightning address). Left inline rather than laid out with flex on purpose: the
   row is toggled with the `hidden` attribute, and an explicit `display` would
   beat it. */
.claimMark {
	margin-left: 6px;
	font-size: 11px;
	white-space: nowrap;
	opacity: 0.85;
}

/* Three outcomes, three colors, and the third is the point: a check we could not
   COMPLETE must not look like either answer. Unreachable stays muted - the same
   weight as no answer at all - so nothing about a domain being down reads as an
   endorsement or as an accusation. */
.claimMark[data-state="pending"],
.claimMark[data-state="unknown"] {
	color: var(--muted);
	opacity: 0.6;
}

/* A pass is the glyph and nothing else, so it gets the size of a mark rather than
   of small print. What it stands for is in the tooltip - hence the cursor, which
   is the only hint that there is more to read. */
.claimMark[data-state="ok"] {
	color: var(--accent);
	font-size: 13px;
	opacity: 1;
	cursor: help;
}

.claimMark[data-state="bad"] {
	color: var(--danger);
}

.claimMark[data-state="bad"] {
	cursor: help; /* the reason is in the tooltip, since the row carries no words */
}

/* A failure is the cross and the strike-through, and nothing else - no label
   explaining which check failed, because the two states a reader acts on are
   "this is good" and "this is not", and the detail is one hover away. The bolt
   is struck with the address so a dead lightning line reads as one dead thing. */
.claimMark[data-state="bad"] ~ .claimAddr,
.claimAddr:has(~ .claimMark[data-state="bad"]),
.identRow:has(.claimMark[data-state="bad"]) .claimAddr,
.identRow:has(.claimMark[data-state="bad"]) .identBolt {
	text-decoration: line-through;
	opacity: 0.7;
}

/* --- added users, and the names that are not who they look like ---------------
   Deliberately the same vocabulary as a failed nip-05 just above: struck through,
   dimmed, with the reason one hover away. Somebody who has learned what a crossed-out
   address means on this card has already learned what a crossed-out NAME means, and a
   second visual language for "this claim does not hold up" would be one too many.

   The cross is a pseudo-element rather than text, so it never lands in a copied
   message and a name spliced into a sender's own /format template cannot shed it. */
/* A name that is not whose you think it is: struck through, and nothing else. The
   verdict glyph that used to hang off this rule now sits at the end of the row
   (see .nameMark) - against the name it landed inside "<@name#tag>", which a
   sender's own /format template can rearrange at will. */
.nameImposter {
	text-decoration: line-through;
	opacity: 0.7;
	cursor: help;
}

/* The verdict itself, in the profile card's vocabulary - same two glyphs as a nip-05
   row, same size, same muting, same "the reason is one hover away". A reader who has
   learned what a crossed-out address on a card means has already learned this. */
.nameMark {
	margin-left: 6px;
	font-size: 11px;
	white-space: nowrap;
	opacity: 0.85;
	cursor: help;
	text-decoration: none; /* never struck, even beside a struck name */
}

.nameMark[data-state="ok"] {
	color: var(--accent);
	font-size: 13px; /* the tick reads small at 11; the card's ok state lifts it too */
	opacity: 1;
}

.nameMark[data-state="bad"] {
	color: var(--danger);
}

.profileAbout {
	color: var(--fg);
	font-size: 14px;
	line-height: 1.45;
	white-space: pre-wrap;
	word-break: break-word;
	margin-top: 14px;
}

/* ONE division on the card: everything above the rule is who this is, everything
   below is what they chose to say. The website belongs with the bio - it is part
   of that - so it needs no rule of its own, and a second one only chopped the
   lower half into pieces that were not separate ideas. Collapses with its rule
   when there is no bio, so a bare profile is still just a name. */
.profileAbout {
	margin-top: 14px;
	padding-top: 14px;
	border-top: 1px solid var(--chrome-border);
}

.profileAbout:empty {
	display: none;
}

/* the website, on the line directly under the bio */
.profileSite {
	margin-top: 4px;
	font-size: 13px;
	word-break: break-all;
}

.profileSite[hidden] {
	display: none;
}

.profileLink {
	color: var(--geo);
	text-decoration: none;
}

.profileLink:hover {
	text-decoration: underline;
}

/* npub row: the shareable bech32 pubkey, tap to copy so it can be looked up on
   other nostr clients. Shows even for keys with no kind-0 profile, so it is often
   the only row here. The hint slot on the right doubles as copy feedback
   ("copied npub").

   It was a bordered, filled box directly above the close button - two full-width
   boxes of the same weight, which read as two buttons and made the least
   interesting content on the card the heaviest thing on it. Now it is a row like
   the others, and `close` is the only button on the card. */
/* notes row: what this person has written, and the way in to it. Built as the
   npub row's twin and sitting immediately above it - same two slots (the thing on
   the left in the row's muted voice, the verb on the right in accent at 11px),
   same metrics, same full-width button.

   It used to ride at the end of the HANDLE's line, which put a control that
   changes state three times inside the one line that must stay readable at a
   glance, and left it looking like part of the name. Down here the card reads as
   a short stack of rows of one kind, which is what it is.

   Three states through one shape:
     looking for notes...
     notes                 view
     no notes found        reload */
.profileNotes {
	display: flex;
	align-items: baseline;
	gap: 10px;
	width: 100%;
	font: inherit;
	font-size: 13px;
	text-align: start;
	background: none;
	border: 0;
	padding: 0;
	color: var(--fg);
	/* the gap the npub row used to carry: this row is now the first of the pair, so
	   it takes the space above and .profileNpub tightens up behind it (below) */
	margin-top: 16px;
	cursor: default;
}

/* Required, not tidying: the rule above sets `display: flex`, and a class rule
   outranks the ua sheet's `[hidden] { display: none }` - so `el.hidden = true`
   set the attribute and left the words on screen. */
.profileNotes[hidden] {
	display: none;
}

/* the two rows are one block: 16px above the pair, a line's worth between them */
.profileNotes:not([hidden]) + .profileNpub {
	margin-top: 4px;
}

.profileNotesKey {
	min-width: 0;
	color: var(--muted);
}

.profileNotesHint {
	margin-inline-start: auto; /* pushed to the row's end, opposite the label */
	flex: none;
	font-size: 11px;
	color: var(--accent);
	white-space: nowrap;
}

/* only the states with a verb on them can be acted on, and only those look it */
.profileNotes[data-state="ready"],
.profileNotes[data-state="empty"] {
	cursor: pointer;
}

.profileNotes[data-state="ready"]:hover .profileNotesKey,
.profileNotes[data-state="empty"]:hover .profileNotesKey {
	color: var(--fg); /* the same lift .profileNpub:hover gives its key */
}

.profileNotes:hover .profileNotesHint {
	text-shadow: var(--glow);
}

/* the notes sheet showing one PERSON's notes rather than one place's: there is
   nowhere to post, so the composer comes off - and with it the region and expiry
   pickers, which used to need naming here separately and now live inside it. */
.notesPanel.authorMode .notesComposer {
	display: none;
}

.profileNpub {
	display: flex;
	align-items: baseline;
	gap: 10px;
	width: 100%;
	font: inherit;
	font-size: 13px;
	text-align: start;
	background: none;
	border: 0;
	padding: 0;
	color: var(--fg);
	/* stands alone under the bio block rather than being ruled off from it */
	margin-top: 16px;
	cursor: pointer;
}

.profileNpub:hover .profileNpubKey {
	color: var(--fg);
}

.profileNpubKey {
	min-width: 0;
	color: var(--muted);
	word-break: break-all;
	direction: ltr; /* an npub is not prose - see .idField */
}

.profileNpubHint {
	margin-inline-start: auto; /* pushed to the row's end, opposite the label */
	flex: none;
	font-size: 11px;
	color: var(--accent);
	white-space: nowrap;
}

/* The card's actions are the message popup's buttons, because they are the same
   kind of thing in the same kind of sheet - only about a person rather than a
   message. Close matches the popup's "cancel" for the same reason: it is the way
   out, not an action. */
.profileAction {
	margin-top: 10px;
}

.profileCard #profileClose {
	width: 100%;
	margin-top: 4px;
	border: 0;
	background: transparent;
	color: var(--muted);
	font: inherit;
	padding: 8px;
	cursor: pointer;
	-webkit-tap-highlight-color: transparent;
}

.profileCard #profileClose:hover {
	color: var(--fg);
	border-color: transparent;
	background-color: transparent;
}

/* Your own card has neither "direct message" nor "block" on it - you cannot do
   either to yourself - which leaves close as the only control, and a borderless
   one reads as a caption floating under the npub rather than something to press.
   With nothing above it to be the quiet way out FROM, it takes the bordered look
   back: the same one every other lone button on a card wears. */
.profileCard:not(:has(.profileAction:not([hidden]))) #profileClose {
	margin-top: 18px;
	border: 1px solid var(--chrome-border);
	color: var(--accent);
	font-weight: var(--font-bold-weight);
	padding: 10px 12px;
}

.profileCard:not(:has(.profileAction:not([hidden]))) #profileClose:hover {
	color: var(--accent);
	border-color: var(--chrome-border-strong);
	background-color: color-mix(in srgb, var(--accent) 6%, transparent);
}

/* first-run ritual: the manifesto types itself out, one line after the other.
   each line is revealed by a solid card-colored mask that slides off it in
   char-width steps. it animates `transform` (not width), so it runs on the
   compositor thread and keeps ticking smoothly even while the main thread is busy
   loading chat behind the gate (the old max-width version, a layout property, ran
   on the main thread and jumped to the end whenever background work blocked it).

   the mask defaults to OFF, so the text is always visible if the reveal never
   runs - the animation only plays while the gate carries `.typing`, which JS adds
   the first time the gate opens. driving it off a class (not a display:none
   toggle, which iOS Safari won't reliably re-fire) is what keeps it dependable
   and, crucially, fail-safe: no `.typing`, no animation -> the text just shows.

   36 steps = 35 chars + one of slack, tuned to the en strings; other locales
   would need their own counts if/when they land. */
/* The typewriter reveal that used to wipe these two lines on first open is gone.
   It played once per session, delayed the only sentence explaining what the app
   is by a second and a half, and was the clearest example of the gate performing
   rather than working. The lines are simply there now. */

#nameForm {
	display: flex;
	flex-direction: column;
	gap: 10px;
}

.nameInputWrap {
	position: relative;
}

#nameInput {
	width: 100%;
	box-sizing: border-box;
	/* border and padding both live in the chamfered scope - an id here outranks it,
	   and a 1px border left behind is 1px the ghost-suffix overlay does not have, so
	   the name and its own #tag preview drifted apart by exactly that much */
	background: var(--field-bg);
	color: var(--fg);
	font: inherit;
	font-size: 16px;
	/* padding lives in the chamfered scope (.gate.chamfered .gateField input): the
	   prompt glyph needs room on the left, and an id here would outrank any number
	   of classes there - which it silently did, printing the `>` underneath the
	   first character of whatever you typed. */
	outline: 0;
	transition: border-color 0.15s ease;
}

#nameInput:focus,
.idField:focus {
	border-color: var(--chrome-border-strong);
}

/* live ghost-text that appends your #suffix to the name as you type, mirroring
   the input's box model so it lines up with the real text */
.nameHint {
	position: absolute;
	inset: 0;
	box-sizing: border-box;
	border: 1px solid transparent;
	padding: 10px 12px;
	font: inherit;
	font-size: 16px;
	white-space: pre;
	pointer-events: none;
	overflow: hidden;
}

.nameHint .typed {
	color: transparent; /* invisible spacer that pushes the suffix past the typed name */
}

.nameHint .sfx {
	color: var(--fg); /* 60% of the input's green - coherent, not jarring */
	opacity: 0.6;
}

#nameForm button {
	border: 1px solid var(--chrome-border);
	background: transparent;
	color: var(--accent);
	font: inherit;
	font-weight: var(--font-bold-weight);
	padding: 10px 12px;
	cursor: pointer;
}

/* --- guild gate ------------------------------------------------------------
   the same card/field/button vocabulary as the name gate - a guild is just
   another door, so it shouldn't announce itself as a different kind of UI. */
#guildForm {
	display: flex;
	flex-direction: column;
	gap: 10px;
}

#guildName,
#guildPass {
	width: 100%;
	box-sizing: border-box;
	/* border and padding: see #nameInput - the chamfered scope owns both */
	background: var(--field-bg);
	color: var(--fg);
	font: inherit;
	font-size: 16px; /* < 16px makes ios zoom the page on focus */
	/* padding: see #nameInput - it belongs to the chamfered scope, because an id
	   here cannot be overridden by the classes there. */
	outline: 0;
	transition: border-color 0.15s ease;
}

#guildName:focus,
#guildPass:focus {
	border-color: var(--chrome-border-strong);
}

#guildForm button {
	border: 1px solid var(--chrome-border);
	background: transparent;
	color: var(--accent);
	font: inherit;
	font-weight: var(--font-bold-weight);
	padding: 10px 12px;
	cursor: pointer;
}

#guildForm button:disabled {
	opacity: 0.45;
	cursor: default;
}

/* shown only while the name is filled and the password isn't: a passwordless
   guild is obfuscation, not privacy, and that has to be said before you enter,
   not after. warning-colored, but not an error - it's a legitimate choice. */
.guildWarn {
	margin-top: 10px;
	border-inline-start: 2px solid color-mix(in srgb, var(--danger) 45%, transparent);
	padding-inline-start: 8px;
	color: var(--muted);
	font-size: 12px;
	line-height: 1.4;
}

.guildWarn[hidden] {
	display: none;
}

.guildList {
	display: flex;
	flex-direction: column;
	gap: 2px;
	margin-top: 14px;
	/* a long membership list scrolls inside the card instead of pushing the
	   [ close ] link (which hangs off the card's bottom edge) off-screen. */
	max-height: 30vh;
	overflow-y: auto;
	overscroll-behavior: contain;
}

/* joined guilds are re-enterable without retyping the password: the derived key
   is already stored, so a row is a one-tap door back in. */
.guildRow {
	display: flex;
	align-items: baseline;
	gap: 8px;
	padding: 5px 6px;
	cursor: pointer;
	transition: background-color 0.15s ease;
	-webkit-tap-highlight-color: transparent;
}

.guildRow:hover {
	background-color: color-mix(in srgb, var(--accent) 7%, transparent);
}

.guildName {
	flex: 1 1 auto;
	min-width: 0;
	color: var(--fg);
	font-size: 14px;
	overflow: hidden; /* the name is already clipped in js, so this is just a backstop */
	white-space: nowrap;
}

/* the frequency's first 8 chars - two guilds can share a name, and this is the
   only thing that tells them apart. */
.guildFreq {
	flex: none;
	color: var(--muted);
	font-size: 11px;
	letter-spacing: 0.04em;
	opacity: 0.75;
	/* a password can be anything, including long - it takes the slack in the row
	   rather than pushing the buttons off the end of a phone */
	min-width: 0;
	overflow: hidden;
	text-overflow: ellipsis;
	white-space: nowrap;
}

/* a revealed password reads as the value it is rather than as a dim label: full
   strength, in the foreground colour, and tappable to copy. */
.guildPassShown {
	flex: 0 1 auto;
	color: var(--fg);
	opacity: 1;
	cursor: pointer;
}

.guildPassShown:hover {
	text-decoration: underline;
	text-underline-offset: 2px;
}

/* show/hide. Deliberately NOT the red the forget button carries - revealing a
   password is reversible and forgetting a guild is not, and the colour is what
   says which is which. */
.guildShow {
	flex: none;
	border: 0;
	background: transparent;
	color: var(--accent);
	font: inherit;
	font-size: 11px;
	padding: 2px 4px;
	cursor: pointer;
	-webkit-tap-highlight-color: transparent;
}

.guildShow:hover {
	text-decoration: underline;
	text-underline-offset: 2px;
}

/* the copy confirmation, and the two states a guild can be in that have no
   password to show: one that never had one, and one joined before we recorded
   them. Both are statements, not controls, so neither is a button. */
.guildCopied[data-ok="0"] {
	color: var(--danger);
}

.guildNoPass {
	flex: none;
	color: var(--muted);
	font-size: 11px;
	opacity: 0.75;
}

/* Forgetting a guild is destructive, so it is drawn in the one red - the same as
   .noteDelete, which is the same thing in the same place: a small text button
   repeated down the rows of a list. It used to be muted grey until hover, which on
   a phone means never, so the only irreversible control in this panel was the only
   one not marked as one. A whole column of red reads fine here for the same reason
   it does in the notes sheet: it is small type, and it is the point. */
.guildForget {
	flex: none;
	border: 0;
	background: transparent;
	color: var(--danger);
	font: inherit;
	font-size: 11px;
	padding: 2px 4px;
	cursor: pointer;
	transition: opacity 0.15s ease;
	-webkit-tap-highlight-color: transparent;
}

.guildForget:hover {
	text-decoration: underline;
	text-underline-offset: 2px;
}

.settingRow {
	position: relative; /* contains the visually-hidden checkbox */
	display: flex;
	align-items: center;
	justify-content: space-between;
	gap: 12px;
	margin-bottom: 16px;
	color: var(--fg);
	font-size: 14px;
	cursor: pointer;
}

/* a setting that depends on another (e.g. nostr profiles needs server assist):
   dimmed and inert until its dependency is on. */
.settingRow.disabled {
	opacity: 0.4;
	pointer-events: none;
}

/* terminal-styled dropdown for the few settings that aren't on/off (the proof-
   of-work level). native chrome is stripped (no ios pill) and replaced with our
   own bordered box + caret so it reads like the rest of the controls. the open
   list is still OS-drawn - unavoidable for <select> - but the resting control
   matches. the wrapper hosts the caret since <select> can't take ::after. */
.settingSelectWrap {
	position: relative;
	display: inline-flex;
	align-items: center;
}

.settingSelectWrap::after {
	content: "";
	position: absolute;
	right: 9px;
	width: 8px;
	height: 4px;
	color: var(--muted);
	background-image: var(--pick-arrow);
	background-position:
		0 0,
		4px 0;
	background-size: var(--pick-arrow-size);
	background-repeat: no-repeat;
	pointer-events: none;
}

.settingSelect {
	appearance: none;
	-webkit-appearance: none;
	border: 1px solid var(--chrome-border);
	border-radius: 0;
	background: transparent;
	color: var(--accent);
	font: inherit;
	font-size: 13px;
	font-weight: var(--font-bold-weight);
	letter-spacing: 0.02em;
	padding: 6px 24px 6px 11px;
	cursor: pointer;
	outline: 0;
	transition: border-color 0.15s ease;
	-webkit-tap-highlight-color: transparent;
}

.settingSelect:hover,
.settingSelect:focus {
	border-color: var(--chrome-border-strong);
}

.settingSelect option {
	background: var(--chrome-bg);
	color: var(--fg);
	font-weight: 400;
}

/* --- the visual section's two live controls --------------------------------
   A settingSub is a settingRow that needed more than one line: same left edge, same
   type, but stacked, because a picker and a preview cannot sit in a label's right
   margin. Indented a hair so the group reads as belonging to the toggle above it. */
.settingSub {
	display: flex;
	flex-direction: column;
	gap: 8px;
	padding: 10px 0 12px;
}

/* Without this the effect picker never went away.
   An author `display` outranks the UA's `[hidden] { display: none }` whatever the
   specificity - different origins, and author wins - so setting .hidden on a
   .settingSub set the property and changed nothing on screen. The picker sat there
   in full, chips animating, under a switch that said effects were off. */
.settingSub[hidden] {
	display: none;
}

.settingSubLabel {
	color: var(--muted);
	font-size: 12px;
	letter-spacing: 0.04em;
	text-transform: lowercase;
}

/* the effect list. Full width rather than shrink-to-fit like the other selects:
   this one is stacked in a .settingSub rather than pinned to a label's right
   margin, and a select the width of the word "lightning" floating in an empty row
   reads as an accident. */
.flairSelectWrap {
	display: flex;
	align-self: stretch;
}

.flairSelect {
	flex: 1;
	min-width: 0;
}

/* one flair, running, at the size it runs in the log. It gets its own line under
   the list rather than sitting beside it: several of these are wider than a chip's
   text (fire throws embers past its own box) and a preview that shifts the control
   next to it every time you change the selection is worse than one on its own row. */
.flairPreview {
	display: flex;
	align-items: center;
	min-height: 30px;
}

/* "none" has no flair to animate, so it gets the chip's shape and nothing else -
   which is exactly what choosing it looks like in the log. */
.flairChipNone {
	display: inline-block;
	padding: 3px 10px;
	border: 1px dashed var(--chrome-border);
	border-radius: 4px;
	color: var(--muted);
}

/* the line as it will actually look. Given the log's own background rather than the
   panel's, because that is what it will be sitting on. */
.fmtPreview {
	padding: 8px 10px;
	background: var(--bg);
	border: 1px solid var(--chrome-border);
	overflow: hidden;
	font-size: 14px;
}

/* the preview's flair wrapper needs to be a block for the ambient layers to have a
   box to fill; in the log that job is done by .line. */
.fmtPreviewLine {
	position: relative;
	display: block;
	isolation: isolate;
	padding: 3px 6px;
	border-radius: 4px;
}

.settingText {
	width: 100%;
	box-sizing: border-box;
	appearance: none;
	border: 1px solid var(--chrome-border);
	border-radius: 0;
	background: var(--field-bg);
	color: var(--fg);
	font: inherit;
	font-size: 13px;
	padding: 8px 10px;
	outline: 0;
}

.settingText:focus {
	border-color: var(--chrome-border-strong);
}

.settingSubHint {
	display: flex;
	align-items: baseline;
	justify-content: space-between;
	gap: 10px;
}

.settingSubHintText {
	color: var(--muted);
	font-size: 11.5px;
	min-width: 0;
	overflow: hidden;
	text-overflow: ellipsis;
	white-space: nowrap;
}

/* a second hint line under the first. Unlike .settingSubHintText it is allowed to
   wrap: that one shares a row with the "clear" button and has to stay on one line
   to keep it, while this one owns its row and would rather be readable on a phone
   than truncated with an ellipsis. */
.settingSubNote {
	color: var(--muted);
	font-size: 11.5px;
	line-height: 1.35;
	margin-top: -2px;
}

/* "clear" throws your template away, and everything else in this app that throws
   something away is drawn in the one red (unfavorite, delete note, block). It used
   to be muted grey until you hovered it, which on a phone means never - the only
   destructive control in the settings panel was also the only one not marked as
   one. */
.settingSubBtn {
	flex: none;
	border: 0;
	background: transparent;
	color: var(--danger);
	font: inherit;
	font-size: 11.5px;
	cursor: pointer;
	padding: 2px 0;
	-webkit-tap-highlight-color: transparent;
}

.settingSubBtn:hover {
	opacity: 0.75;
}

/* THE bold elements nobody wrote a rule for.
   <strong> and <b> have no rule in this stylesheet - they have never needed one,
   because the browser's own says `font-weight: bolder`. And `bolder` is not a
   number: it is one step up the *inherited* weight, and the step from 500 is 700.
   So the moment the body went to 500 for the webfont, every <strong> in the app
   quietly settled at 700 - +14% ink over its surroundings instead of +31% - and
   that is most of the bold chrome there is: [EXIT], the channel name, RELAYS,
   GLUB.CHAT, every panel heading. The 35 rules that name a weight were converted;
   these two dozen elements were never covered by any of them.
   Named explicitly now, so "bold" is one decision in one place. */
strong,
b {
	font-weight: var(--font-bold-weight);
}

/* bracketed text toggle in the terminal style ([ on ] / [ off ]) - the real
   checkbox is hidden and driven by the label; the visual reflects its state. */
.assistCheck {
	position: absolute;
	width: 1px;
	height: 1px;
	opacity: 0;
	pointer-events: none;
}

.toggleVisual {
	flex: none;
	font-weight: var(--font-bold-weight);
	letter-spacing: 0.06em;
}

.toggleVisual::after {
	content: var(--label-toggle-off);
	color: var(--muted);
}

.assistCheck:checked ~ .toggleVisual::after {
	content: var(--label-toggle-on);
	color: var(--accent);
}

/* identity section in settings: your (censored) nsec + reveal/copy/paste. kept
   out of the public composer on purpose - a secret key has no business in a
   broadcast box. sits right under the toggles (no divider) on the same rhythm. */
.idSection {
	/* match the field->buttons gap (.idButtons margin-top) so the button row sits
	   the same distance above "done" as it does below the nsec field. */
	margin-bottom: 8px;
}

.idHeading {
	color: var(--fg);
	font-size: 14px;
	margin-bottom: 8px;
}

.idFieldWrap {
	position: relative;
}

.idField {
	width: 100%;
	box-sizing: border-box;
	border: 1px solid var(--chrome-border);
	background: var(--field-bg);
	color: var(--fg);
	font: inherit;
	font-size: 14px;
	padding: 9px 12px;
	outline: 0;
	/* keys are bech32/hex, never prose - so they read left-to-right no matter
	   which way the ui runs. Left to inherit rtl, the masking asterisks and the
	   "nsec1" prefix swap ends and the field looks corrupted. */
	direction: ltr;
	text-align: start;
}

/* action feedback (copied / imported / errors) rides inside the field on the
   right, over the empty tail of the censored key - frees the row below. */
.idStatus {
	position: absolute;
	right: 12px;
	top: 50%;
	transform: translateY(-50%);
	padding-left: 10px;
	/* Opaque, so it hides whatever it sits over - the masked nsec, or the keys going
	   past during a vanity search. --field-solid is the recess colour composited
	   rather than translucent, which is what makes it match the box it is inside; the
	   panel background it used before was a shade off against the field's own inset. */
	background: var(--field-solid);
	font-size: 12px;
	color: var(--muted);
	pointer-events: none;
}

.idStatus.ok {
	color: var(--accent);
}

.idStatus.err {
	color: var(--danger);
}

.idButtons {
	display: flex;
	gap: 8px;
	margin-top: 8px;
}

.idBtn {
	flex: 1;
	border: 1px solid var(--chrome-border);
	background: transparent;
	color: var(--accent);
	font: inherit;
	font-weight: var(--font-bold-weight);
	padding: 9px 8px;
	cursor: pointer;
}

/* Your #tag and the control that mints a new one (settings > nostr). The nsec
   section's parts laid on one line: the tag field takes the room, the button keeps
   its own width so it does not resize as its label swaps between `mint` and
   `stop`. */
.idRotateRow {
	display: flex;
	gap: 8px;
	align-items: stretch;
	margin-bottom: 8px;
}

.idRotateWrap {
	flex: 1;
	min-width: 0;
}

.idRotateBtn {
	flex: 0 0 auto;
	min-width: 96px;
}

/* The "#" is a LABEL sitting in the field's padding, not a character in the value.
   That is the whole implementation of "undeletable": there is no caret position in
   front of it, no keystroke that can reach it, and nothing to put back afterwards -
   where a sentinel character inside the value has to be defended against backspace,
   select-all, paste and the caret being clicked in front of it, and loses eventually.

   Left where the field's own text would start, with the input's start padding moved
   over by exactly one character. 1ch is that width, and this font is monospace. */
.idTagHash {
	position: absolute;
	inset-inline-start: 12px;
	top: 50%;
	transform: translateY(-50%);
	font-size: 14px;
	line-height: 1;
	color: var(--muted);
	pointer-events: none;
}

/* --- the patron's nip-05 field --------------------------------------------------
   The mirror image of .idTagHash: a label the field cannot delete, on the other end
   because a nip-05 reads name@domain and the half you own is the half in front.

   Its width is not knowable from here - "@glub.chat" and "@some-longer.example" are
   the same element with different text, and the instance decides which - so the
   input is padded from the label's measured width at runtime (see syncPatronRow)
   rather than from a number typed in this file. */
.idTagSuffix {
	position: absolute;
	inset-inline-end: 12px;
	top: 50%;
	transform: translateY(-50%);
	font-size: 14px;
	line-height: 1;
	color: var(--muted);
	pointer-events: none;
	white-space: nowrap;
}

.idPatronField {
	/* a fallback only, until the measurement lands: enough room for a short domain,
	   so the very first paint is never text sitting underneath the label */
	padding-inline-end: 92px;
}

.patronStatus {
	margin-top: 8px;
	font-size: 12px;
	line-height: 1.45;
	color: var(--muted);
	min-height: 1.45em; /* holds its line, so a message appearing moves nothing below it */
}

.patronStatus.bad {
	color: var(--danger);
}

.patronStatus.good {
	color: var(--accent);
}

.idTagField {
	padding-inline-start: calc(12px + 1ch);
	/* four hex characters, and only ever four - so it can be spaced like the tag it
	   is rather than like prose */
	letter-spacing: 0.06em;
}

/* The hint is the tag you have NOW, so it reads as a statement of what you are
   until you type over it - and it wears the colour that tag wears in chat, which is
   what makes it a statement rather than an instruction. --own-color is set from
   pubkeyColor on every identity change (see syncRotateTag); the muted fallback is
   what it looks like for the instant before that runs. */
.idTagField::placeholder {
	color: var(--own-color, var(--muted));
	opacity: 1;
}

/* ...and the "#" goes with it, but ONLY while the hint is what is showing: `#a1b2`
   is one label and half of it in another colour would read as broken. The moment
   you type, the label is prefixing a REQUEST rather than stating a fact, and it
   drops back to the muted voice. :has because .idTagHash precedes the input, so no
   sibling combinator can reach it from :placeholder-shown. */
.idRotateWrap:has(.idTagField:placeholder-shown) .idTagHash {
	color: var(--own-color, var(--muted));
}

/* while a search runs the field shows the keys going past, which is not something
   you are editing */
.idTagField:disabled {
	color: var(--accent);
	opacity: 1;
	-webkit-text-fill-color: var(--accent); /* ios greys a disabled field out regardless */
}

/* ---- nostr profile editor (settings > nostr) ------------------------------ */
.profileEditSection {
	margin-bottom: 12px;
}

/* banner: a short full-width strip. the "+ banner" control fills it and, once an
   image is set, the preview lays over the button so the tap target stays. */
.profileEditBanner {
	position: relative;
	width: 100%;
	height: 72px;
	margin-bottom: 8px;
	border: 1px solid var(--chrome-border);
	background: var(--field-bg);
	overflow: hidden;
}

.profileEditBannerImg {
	position: absolute;
	inset: 0;
	width: 100%;
	height: 100%;
	object-fit: cover;
}

/* Why "+ banner" looked shaved off down its right-hand side.
   
   The control filled the strip EXACTLY - a <button> is border-box by the ua
   sheet's own default, so `width: 100%` came to the strip's inner width to the
   pixel - which put its 1px dashed border on the last pixel column of a parent
   that clips its overflow. On a fractional device-pixel boundary that border and
   that clip edge do not land together, and the line renders half-there or not at
   all. Nothing was overflowing; the border was simply standing on the cliff.
   
   It has room now: the frame moved to the sandwich below and the dashed line is
   drawn by a pseudo-element inset 4px, so nothing of this control's is within
   reach of the clip. The width/height go with it as redundant - `inset: 0`
   already stretches an absolutely positioned box to its containing block. */
/* Why "+ banner" looked shaved off down its right-hand side, and why this is
   `inset: 1px` rather than `inset: 0`.

   At 0 the control fills the strip EXACTLY - `inset: 0` sizes an absolutely
   positioned box to its containing block, borders included - which puts its 1px
   dashed border on the last pixel column of a parent that clips its overflow. On
   a fractional device-pixel boundary the border and the clip edge do not land
   together, and the line renders half-there or not at all. Nothing overflows; the
   border is simply standing on the cliff.

   One pixel of daylight is the whole fix. The tap target is unchanged for any
   practical purpose and the dashed edge is now clear of the clip on every side. */
.profileEditBanner .profileEditUpload {
	position: absolute;
	inset: 1px;
}

.profileEditAvatarRow {
	display: flex;
	align-items: center;
	gap: 10px;
	margin-bottom: 8px;
}

.profileEditAvatarImg {
	width: 44px;
	height: 44px;
	border-radius: 50%;
	object-fit: cover;
	border: 1px solid var(--chrome-border);
	flex: 0 0 auto;
}

/* the upload buttons read as accent affordances, not the bordered utility look */
.profileEditUpload {
	border: 1px dashed var(--chrome-border);
	background: color-mix(in srgb, var(--accent) 4%, transparent);
	color: var(--accent);
	font: inherit;
	font-size: 13px;
	padding: 7px 12px;
	cursor: pointer;
	-webkit-tap-highlight-color: transparent;
	transition: border-color 0.15s ease, background-color 0.15s ease;
}

.profileEditUpload:hover {
	border-color: var(--chrome-border-strong);
	background-color: color-mix(in srgb, var(--accent) 8%, transparent);
}

.profileEditUploadStatus {
	font-size: 12px;
	color: var(--muted);
	min-width: 0;
	overflow: hidden;
	text-overflow: ellipsis;
	white-space: nowrap;
}

.profileEditUploadStatus.error {
	color: var(--danger);
}

.profileEditField {
	width: 100%;
	box-sizing: border-box;
	border: 1px solid var(--chrome-border);
	background: var(--field-bg);
	color: var(--fg);
	font: inherit;
	font-size: 14px;
	padding: 9px 12px;
	outline: 0;
	/* margin lives on .profileEditFieldWrap now - the wrapper is the layout box, and
	   a margin inside it left the frames touching each other edge to edge */
}

.profileEditField:focus {
	border-color: var(--chrome-border-strong);
}

.profileEditAbout {
	resize: vertical;
	min-height: 3.4em;
}

.profileEditActions {
	display: flex;
	align-items: center;
	gap: 10px;
}

.profileEditSave {
	flex: 0 0 auto;
	padding-left: 18px;
	padding-right: 18px;
}

.profileEditSaveStatus {
	font-size: 12px;
	color: var(--muted);
	min-width: 0;
	overflow: hidden;
	text-overflow: ellipsis;
	white-space: nowrap;
}

.profileEditSaveStatus.ok {
	color: var(--accent);
}

.profileEditSaveStatus.error {
	color: var(--danger);
}

/* reveal is set apart from the two bordered utility buttons: no border, lighter
   weight, and flush to the start edge so it reads as a plain inline toggle
   rather than a third button in the row. */
#revealNsecBtn {
	border-color: transparent;
	font-weight: 400;
	text-align: start;
	padding-inline-start: 0;
}

/* settings is a full-screen blurred panel like the users list: title + [EXIT]
   pinned top, a fixed description blurb, then a scrolling categorized list. */
#settingsGate {
	align-items: stretch;
	padding: calc(16px + env(safe-area-inset-top)) 16px calc(16px + var(--inset-b));
}

.settingsPanel {
	width: 100%;
	max-width: 560px;
	margin: 0 auto;
	display: flex;
	flex-direction: column;
	gap: 12px;
	min-height: 0;
}

#settingsTitle {
	font-weight: var(--font-bold-weight);
}

/* The description floats OVER the settings rather than sitting above them, and the
   two share this box so it can.
   Overlaying is what makes the blur mean anything: settings scrolling underneath
   are what gets blurred. As a row above the list there was nothing behind it but
   the gate's own scrim, so the filter was applied and had nothing to work on.
   It also solves the reason the height was ever locked. The blurb swaps on
   POINTERDOWN, so a blurb that grows to fit its copy moves the row you are pressing
   before the click lands - measured at 18px, which is most of a row. Out of flow, it
   can be any height it likes and the list never moves. */
.settingsBody {
	position: relative;
	flex: 1;
	min-height: 0;
	display: flex;
	flex-direction: column;
}

/* how much of the list the blurb covers at rest. The list is padded by exactly this
   much, so at its resting height nothing is hidden; longer copy reaches further down
   over rows that are still there, blurred, and still reachable by scrolling. */
:root {
	--settings-desc-h: 6.9em;
}

.settingsDesc {
	position: absolute;
	top: 0;
	inset-inline-start: 0; /* logical, like the end below: in rtl the track flips sides */
	/* Stop where the scrollbar starts, so the list's thumb is never behind this.
	   The blurb is positioned against .settingsBody rather than against the list,
	   so at right:0 it covered the scroll track for its whole height - the thumb
	   vanished behind the frost at the top of the panel and reappeared below it,
	   which is what read as the scrollbar being above some things and under
	   others. Nothing else is lost: a classic scrollbar takes layout width, so the
	   rows already stop at the same line, and the strip this gives back contains
	   only the track. --sb-w comes from measureScrollbar, because the width is
	   different on every engine - and where the scrollbar is an overlay and takes
	   no width, the strip is made rather than measured (the list is padded to
	   match) rather than left alone: an overlay thumb is painted by the scroller
	   itself and loses to anything stacked above it, so it needs the room most. */
	inset-inline-end: var(--sb-w, 0px);
	z-index: 2;
	min-height: var(--settings-desc-h);
	color: var(--muted);
	font-size: 13px;
	line-height: 1.4;
	border-inline-start: 2px solid color-mix(in srgb, var(--accent) 28%, transparent);
	padding: 8px 12px;
	/* the app's standard frosted panel (see #suggestBox): a chrome tint over a blur.
	   It reads as black here, which is the point - the panel is not meant to announce
	   itself, only to soften whatever passes behind it. */
	background: color-mix(in srgb, var(--chrome-bg) 60%, transparent);
	backdrop-filter: blur(10px);
	-webkit-backdrop-filter: blur(10px);
	box-sizing: border-box;
}

/* the panic button's row. Set well apart from the identity block above it - the
   gap is the point, since the thing directly above it is where you keep the key
   this button destroys. */
.settingWipe {
	margin: 28px 0 4px;
}

.settingsList {
	flex: 1;
	min-height: 0;
	overflow: hidden auto;
	/* 0 where the scrollbar already takes width of its own; see measureScrollbar */
	padding-inline-end: var(--sb-pad, 0px);
	/* clears the blurb at its resting height. Fixed, not tracking the blurb: making
	   it follow would reintroduce the very layout shift the overlay removes. */
	padding-top: calc(var(--settings-desc-h) + 10px);
	/* the settingRow rhythm (margin-bottom) drives spacing; nothing extra here */
}

/* barriers double as category headers, so give them a touch more breathing room
   than the users-list divider and drop the bottom margin of the row above. */
.settingsList .usersBarrier {
	margin: 14px 0 12px;
}

.settingsList .usersBarrier:first-child {
	margin-top: 2px;
}

/* small accent links docked under the gate card's bottom edge - a more
   discoverable way into settings than the topbar "relays" readout. The name gate
   carries two of them: guilds on the left, settings on the right. Both are yours
   rather than a channel's, which is why they live here and not in the user list. */
/* the row of links that hangs off the gate card's bottom edge. It's the container
   that's positioned, not the links, so adding one doesn't need an offset that
   tracks its neighbour's width.

   Spanning the card's full width and spacing the links apart, rather than packing
   them against one edge, does two things: the row reads as belonging to the card
   (first link under its left edge, last under its right), and the spacing becomes
   whatever is left over instead of a fixed number that has to be guessed. That
   second part matters - at 393px the four labels come to 324px in a 359px row, so
   a hard 12px gap needed 359.9px and wrapped [ close ] onto its own line by less
   than a pixel. As a minimum rather than a fixed step, the same 12px fits with
   room to spare. */
/* Four links in a fixed 2x2, rather than a row that wraps.
   A wrapping row leaves a lone link stranded on a second line, and it does so far
   more often than it looks: measured across the six languages at four phone widths,
   nine of the twenty-four combinations came out 3+1. English orphans at 320 and
   360px, russian orphans even at 430px.
   No breakpoint can fix that, because what decides it is the LENGTH OF THE LABELS,
   which a media query cannot see - "[ настройки ]" is thirteen characters wherever
   the phone is wide. Nor can auto-fit: any width where four columns fit is a width
   where three fit too, so it can always produce the orphan.
   So the shape is decided instead of discovered. Two columns is the one arrangement
   four items can always fill exactly, it is symmetric in every language, and each
   link gets half the card to be tapped in rather than a quarter - which on a phone
   is the better target anyway. The columns hug the outer edges, so it reads as the
   same span the row used to. */
/* [ back ] on a profile card: docked below the frame, right-aligned, borrowing the
   name gate link's voice - same size, same accent, same glow. It sits outside the
   card because it is not a control ON the card; it is about the trail that led here.
   Positioned against a WRAPPER rather than against .gateCard, unlike the name gate's
   links: the profile card scrolls (overflow-y: auto, max-height 88vh), so a child of
   it docked at top:100% is clipped by its own scroll box and never seen. */
.profileCardWrap {
	position: relative;
	width: min(460px, 100%);
	display: flex;
	justify-content: center;
	/* ROOM FOR THE DOCKED BUTTON.
	   [ back ] is absolutely positioned at top:100%, so it contributes nothing to
	   this box - which means the gate centres the CARD and lets the button hang
	   into whatever happens to be below it. On a card short enough that was fine;
	   on one at its full height the button ended up against the bottom edge of the
	   screen, under the home indicator, and had to be chased.
	   Reserving its height here makes the centring honest: the pair is centred, not
	   the card with something dangling off it. */
	padding-bottom: var(--card-back-h);
	box-sizing: border-box;
	max-height: 100%;
}
.cardBack {
	position: absolute;
	/* Anchored to the BOTTOM of the wrapper, which is the strip reserved for it
	   (see .profileCardWrap). It used to be top:100%, and that measures the
	   wrapper's padded box - so reserving the room pushed the button down by
	   exactly the room reserved and gained nothing at all. */
	bottom: 0;
	inset-inline-end: 0;
	border: 0;
	background: transparent;
	/* the same red every other control that LEAVES wears - the gates' [ close ], the
	   block button, unfavorite. No glow with it: the glow is for chrome you open. */
	color: var(--danger);
	text-shadow: none;
	font: inherit;
	font-size: 12px;
	letter-spacing: 0.04em;
	white-space: nowrap;
	padding: 6px 4px;
	cursor: pointer;
	transition: opacity 0.15s ease;
	-webkit-tap-highlight-color: transparent;
}

.cardBack:hover {
	opacity: 0.75;
}

/* an author `display` beats the UA's [hidden] rule, so absolute positioning would
   keep this on screen with the attribute set - the same trap .settingSub and
   .mapMenuItem both fell into */
.cardBack[hidden] {
	display: none;
}

/* Secondary, and it took losing the glow to get there. These were the title's
   green with the title's text-shadow, which made four optional doors shout as
   loudly as the one thing the card is asking for. They light up on hover, which
   is when they are being considered. */
.nameGateLink {
	flex: none;
	white-space: nowrap;
	border: 0;
	background: transparent;
	color: var(--muted);
	font: inherit;
	font-size: 12px;
	letter-spacing: 0.04em;
	cursor: pointer;
	padding: 6px 2px;
	transition: color 0.15s ease;
	-webkit-tap-highlight-color: transparent;
}

.nameGateLink:hover,
.nameGateLink:focus-visible {
	color: var(--accent);
}

/* boot-sequence lines: the few dim lines the client prints while waking up.
   same muted voice as other system notices, just without the * emote * frame. */
.boot {
	color: var(--muted);
	font-size: 0.85em;
	letter-spacing: 0.03em;
}

/* motion is seasoning, not structure: every animation degrades to its resting
   state for users who ask for reduced motion. */
@media (prefers-reduced-motion: reduce) {
	.cursor,
	.dot.off,
	.line.arrive,
	#newMessagesBar:not([hidden]),
	.gate.show,
	.gate.show .gateCard,
	.gate.show .usersPanel {
		animation: none;
	}

	/* drop the typewriter mask entirely so the manifesto is just there (leaving
	   it un-animated would freeze it fully covering the text) */
	#nameGate .gateSub span::after {
		display: none;
	}

	.geo,
	.inlineLink,
	.inlineGeo,
	.toggleMore,
	.prompt,
	#sendBtn,
	#mediaBtn,
	.userRow,
	#nameInput,
	.idField,
	#nameForm button,
	#guildName,
	#guildPass,
	#guildForm button,
	.guildRow,
	.guildForget,
	#settingsClose,
	.profileCard #profileClose,
	.idBtn {
		transition: none;
	}
}

/* ============================================================================
   direct messages: tap-a-user action popup, conversation inbox + thread.
   DMs are NIP-17 gift-wrapped and encrypted with your local key, so this whole
   surface is deliberately kept apart from the public composer.
   ============================================================================ */

/* floating DM pill: sits just below the topbar, top-right, over the chat. It
   only appears while there are unread DMs (kept off the topbar so long usernames
   have room), and the /dms command opens the inbox any time. */
/* Both floating notifications live in one column so they can never land on top of
   each other. The stack is click-through - it spans the full pill width and the gap
   between them, and none of that should swallow a tap meant for the chat behind. */
.pillStack {
	position: fixed;
	top: calc(var(--topbar-h, 48px) + 8px);
	right: 12px;
	z-index: 900;
	display: flex;
	flex-direction: column;
	align-items: flex-end;
	gap: 6px;
	pointer-events: none;
}

.pillStack > * {
	pointer-events: auto;
}

/* The frame the rest of the app wears: a rectangle with the bottom-right corner
   cut - one cut, the corner that never changes - drawn as the two-layer sandwich
   every other outlined control here uses. Same colours as the capsule they
   replace, same outline; only the shape moved.

   The sandwich rather than a border, for the reason it always is: clip-path cuts
   a border off with the corner, so the edge has to be a layer of its own with the
   fill inset 1px over it. Written out rather than folded into the settings scope
   that shares it, because that scope sets --frame and --fill itself and sits
   later in the file, so joining it would mean fighting the cascade for the two
   values the pills need to differ on. */
.dmPill,
.pingPill {
	--cut: 8px;
	--frame: var(--chrome-border-strong);
	--fill: var(--chrome-bg);
	position: relative;
	isolation: isolate;
	display: inline-flex;
	align-items: center;
	border: 0;
	background: var(--frame);
	clip-path: polygon(
		0 0,
		100% 0,
		100% calc(100% - var(--cut)),
		calc(100% - var(--cut)) 100%,
		0 100%
	);
	color: var(--accent);
	font: inherit;
	font-size: 13px;
	font-weight: var(--font-bold-weight);
	letter-spacing: 0.06em;
	padding: 5px 10px;
	cursor: pointer;
	text-shadow: var(--glow);
	/* A drop SHADOW, not a box-shadow. clip-path clips the element's own painting
	   and a box-shadow is part of that, so the float these pills have over the chat
	   would simply have gone. A filter runs after the clip and follows the cut
	   silhouette, so the shadow has the same corner the pill does. */
	filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.5));
	-webkit-tap-highlight-color: transparent;
	animation: barIn 0.18s ease-out;
}

.dmPill::before,
.pingPill::before {
	content: "";
	position: absolute;
	inset: 1px;
	z-index: -1;
	clip-path: inherit;
	background: var(--fill);
}

.dmPill[hidden],
.pingPill[hidden] {
	display: none;
}

.dmPill:active,
.pingPill:active {
	transform: translateY(1px);
}

/* the count takes the same corner at a smaller size - a part gets one cut, and a
   rounded badge inside a cut frame would be two languages in one control */
.dmPill .dmCount,
.pingPill .pingCount {
	--cut: 4px;
	color: var(--bg);
	background: var(--accent);
	clip-path: polygon(
		0 0,
		100% 0,
		100% calc(100% - var(--cut)),
		calc(100% - var(--cut)) 100%,
		0 100%
	);
	padding: 0 5px;
	margin-left: 6px;
	font-size: 0.85em;
}

/* a ping is about a CHANNEL, so it wears the channel color - the same blue every
   #geohash in the log is drawn in. That is what tells the two pills apart at a
   glance without either needing a word on it: green is a person, blue is a room. */
.pingPill {
	color: var(--geo);
}

.pingPill .pingCount {
	background: var(--geo);
}

/* a ping row is a dm row with the channel promoted to the front: you decide whether
   to go by WHERE it came from at least as much as by who sent it. */
.pingRowGeo {
	color: var(--geo);
	font-weight: var(--font-bold-weight);
	margin-right: 8px;
}

/* --- action popup (tap a name/message) --- */
.actionCard {
	width: min(320px, 100%);
}

/* the handle (with this message's pow) on one side, the client label on the
   other - the same split the profile card's head row uses for its notes label */
.actionTitle {
	display: flex;
	align-items: baseline;
	justify-content: space-between;
	gap: 10px;
	font-size: 15px;
	font-weight: var(--font-bold-weight);
	margin-bottom: 12px;
}

.actionWho {
	min-width: 0; /* a long handle wraps rather than shoving the label off */
	word-break: break-word;
}

.actionTitle .sfx {
	opacity: 0.6;
}

/* the tapped message's proof-of-work, a muted monospace badge trailing the
   handle in the action popup */
.powBadge,
.clientBadge {
	color: var(--muted);
	font-size: 11px;
	font-weight: 400;
	letter-spacing: 0.04em;
	white-space: nowrap;
}

.powBadge {
	border: 1px solid var(--chrome-border);
	padding: 1px 5px;
	vertical-align: 1px;
}

/* Which app someone posted from is a caption, not a measurement - so it is a
   plain right-aligned label rather than a boxed chip. It keeps the accent tint
   that made it read as "identity" instead of a metric; losing the border is
   what stops it competing with the pow badge next to it. */
.clientBadge {
	flex: none;
	text-align: end;
	color: color-mix(in srgb, var(--accent) 55%, var(--muted));
}

/* cropped preview of the tapped message, so you can see which one you're acting
   on (mainly for the copy case). plain muted text - no border/indent, so it
   doesn't read as a reply - clamped to a few lines with an ellipsis. */
/* --- developer mode: the signed event ------------------------------------------
   Wider than the action popup it opens from, because a nostr event has an id, a
   pubkey and a signature in it, and those are 64 and 128 hex characters that mean
   nothing at all when they are folded across four lines each. */
.jsonCard {
	width: min(420px, 100%);
}

.jsonBody {
	margin: 0 0 12px;
	padding: 10px;
	max-height: min(46vh, 380px);
	/* Both ways on purpose. Wrapping the long hex fields would make them readable as
	   prose and useless as values - you cannot eyeball whether two ids match when one
	   of them is in four pieces - so the lines stay whole and the box scrolls under
	   them. `overscroll-behavior` keeps that scroll from handing off to the chat log
	   behind the gate once it hits the end. */
	overflow: auto;
	overscroll-behavior: contain;
	white-space: pre;
	/* eventJson indents with tabs; the browser default of 8 would push a nested tag
	   most of the way across a phone before its first character */
	tab-size: 2;
	-moz-tab-size: 2;
	/* no font-family: the whole app is already the mono stack, and naming it again
	   here would opt this box out of the jetbrains toggle in settings */
	font-size: 12px;
	line-height: 1.45;
	color: var(--fg);
	/* mixed toward the FOREGROUND rather than a fixed black, so the panel reads as
	   inset in the light themes as well as the dark ones */
	background: color-mix(in srgb, var(--fg) 7%, var(--chrome-bg));
	/* the house shape: never the top-left, always the bottom-right. No border - a
	   border cannot follow a chamfer (clip-path cuts it and leaves the diagonal
	   bare), and the fill alone already separates the panel from the card. */
	clip-path: polygon(0 0, 100% 0, 100% calc(100% - 10px), calc(100% - 10px) 100%, 0 100%);
	-webkit-user-select: text;
	user-select: text;
}

.actionPreview {
	margin: -4px 0 12px;
	color: var(--muted);
	font-size: 13px;
	line-height: 1.4;
	white-space: pre-wrap;
	word-break: break-word;
	overflow: hidden;
	display: -webkit-box;
	-webkit-box-orient: vertical;
	-webkit-line-clamp: 3;
	line-clamp: 3;
}

/* The confirm gate's preview is always OUR sentence - a warning, sometimes with a
   name clipped to 40 characters in front of it - never a stranger's message. It
   borrows .actionPreview for the type and inherited the three-line clamp that is
   there to stop a long quoted MESSAGE running down the card. On a warning that
   clamp cut "copy your nsec first" off the end of the very sentence it existed to
   deliver, which is the one line in the app that must not be truncated. */
#confirmPreview {
	display: block;
	-webkit-line-clamp: none;
	overflow: visible;
}

.actionPreview[hidden] {
	display: none;
}

.actionList {
	display: flex;
	flex-direction: column;
	gap: 8px;
}

/* Three paired rows - mention|reply, hug|slap, copy|save - then the full-width
   ones: translate, view profile, block. */
/* A pair of actions on one line. Flex rather than a two-column grid: when one of
   the two is hidden the other takes the whole width by itself, where a grid
   would leave its column empty and the button stranded on one side. */
.actionRow {
	display: flex;
	gap: 8px;
}

.actionRow > .actionBtn {
	flex: 1;
	min-width: 0;
}

/* ...and a row with nothing left in it takes itself out, rather than leaving a
   gap where hug/slap used to be on your own message. */
.actionRow:not(:has(> .actionBtn:not([hidden]))) {
	display: none;
}

.actionBtn {
	width: 100%;
	border: 1px solid var(--chrome-border);
	background: transparent;
	color: var(--accent);
	font: inherit;
	font-weight: var(--font-bold-weight);
	text-align: center;
	padding: 11px 12px;
	cursor: pointer;
	transition: border-color 0.15s ease, background-color 0.15s ease;
	-webkit-tap-highlight-color: transparent;
}

.actionBtn:hover {
	border-color: var(--chrome-border-strong);
	background-color: color-mix(in srgb, var(--accent) 6%, transparent);
}

.actionBtn:active {
	transform: translateY(1px);
}

/* not-yet-built actions read as muted placeholders so the wired one stands out */
.actionBtn.dummy {
	color: var(--muted);
	font-weight: 400;
}

/* destructive action (block): outlined in the same soft red as the "[!] failed"
   warning. declared after .dummy so it wins the color even while block is still
   a placeholder. */
.actionBtn.danger {
	color: var(--danger);
	border-color: color-mix(in srgb, var(--danger) 40%, transparent);
	font-weight: var(--font-bold-weight);
}

.actionBtn.danger:hover {
	border-color: var(--danger);
	background-color: color-mix(in srgb, var(--danger) 8%, transparent);
}

.actionBtn[hidden] {
	display: none;
}

.actionCancel {
	width: 100%;
	margin-top: 14px;
	border: 0;
	background: transparent;
	color: var(--muted);
	font: inherit;
	padding: 8px;
	cursor: pointer;
	-webkit-tap-highlight-color: transparent;
}

.actionCancel:hover {
	color: var(--fg);
}

/* --- reactions ------------------------------------------------------------------
   Not a chip with a label in it: the glyph IS the control, enlarged the way an
   emoji-only message is, with the tally tucked into its corner. That keeps a row
   of them compact - a message with five reactions is five glyphs, not five
   boxes - and it stops them competing with the message they belong to. */
.reactRow {
	display: flex;
	flex-wrap: wrap;
	gap: 2px;
	margin-top: 2px;
	margin-inline-start: 6px;
}

.reactPill {
	position: relative; /* the count is positioned against this */
	display: inline-flex;
	align-items: center;
	border: 0;
	border-radius: 8px;
	background: transparent;
	color: inherit;
	font: inherit;
	line-height: 1;
	padding: 2px 10px 2px 4px; /* right padding is the room the count sits in */
	cursor: pointer;
	-webkit-tap-highlight-color: transparent;
}

/* the same treatment an emoji-only message gets, at a size that reads as a stamp
   on the line rather than a second message under it */
.reactGlyph {
	display: inline-block;
	font-size: 1.5em;
	line-height: 1;
	/* an emoji is a color glyph, and a flair gradient would otherwise clip it to
	   a silhouette - re-anchored exactly as .emojiBig does it */
	color: var(--fg);
	-webkit-text-fill-color: var(--fg);
}

/* the tally, overlapping the glyph's bottom-right so the pair stays one compact
   unit. tabular figures keep it from twitching as 9 becomes 10. */
.reactCount {
	position: absolute;
	right: 1px;
	bottom: 0;
	font-size: 10px;
	font-weight: var(--font-bold-weight);
	line-height: 1;
	font-variant-numeric: tabular-nums;
	color: var(--muted);
	text-shadow:
		0 0 3px var(--bg),
		0 0 3px var(--bg); /* twice, so it stays legible over the glyph behind it */
}

.reactPill:hover {
	background: color-mix(in srgb, var(--fg) 8%, transparent);
}

.reactPill:active {
	transform: translateY(1px);
}

/* Yours. The color is set inline from your own pubkey color - the same one
   your handle and your mention highlight use - so the tint identifies WHO as
   well as saying "you are in this count". The rounded backdrop is what makes it
   read as pressed-in; tapping takes you back out. Never color alone: the count
   also brightens, and the button carries aria-pressed. */
.reactPill.mine .reactCount {
	color: inherit; /* inherits the inline pubkey color from the button */
}

.reactPill.mine {
	box-shadow: inset 0 0 0 1px currentColor;
}

/* the picker: a plain field, since the keyboard you already have is the palette */
#reactForm {
	display: flex;
	align-items: center;
	gap: 8px;
}

.reactInput {
	flex: 1 1 auto;
	/* a real floor, not min-width:0 - with two buttons beside it the field would
	   otherwise collapse to a sliver and there would be nowhere to type */
	min-width: 4.5em;
	border: 1px solid var(--chrome-border);
	background: transparent;
	color: var(--fg);
	font: inherit;
	font-size: 20px;
	text-align: center;
	padding: 8px;
	border-radius: 0;
}

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

/* .actionBtn is full-width by default, which is right for a stacked popup and
   wrong for a row - both of these size to their label instead */
#reactSend,
#reactSave {
	flex: 0 0 auto;
	width: auto;
	padding-inline: 12px;
}

/* the favourites row: what you reach for, filled from what you use. Wraps rather
   than scrolls - a dozen glyphs fit on two lines and a hidden overflow in a
   picker is a favourite you cannot find. */
.reactFavs {
	display: flex;
	flex-wrap: wrap;
	gap: 4px;
	margin-top: 12px;
}

.reactFavs[hidden] {
	display: none;
}

/* the glyph and its drop control share one box so the ✕ can sit on the corner */
.reactFav {
	position: relative;
	display: inline-flex;
	border: 1px solid var(--chrome-border);
	border-radius: 8px;
}

/* already on this message: same pressed-in treatment the pills wear, and the
   color is set inline from the message author's pubkey exactly as they are */
.reactFav.mine {
	border-color: currentColor;
}

.reactFavPick {
	border: 0;
	background: transparent;
	color: inherit;
	font: inherit;
	line-height: 1;
	padding: 7px 11px;
	cursor: pointer;
	-webkit-tap-highlight-color: transparent;
}

.reactFav:hover {
	border-color: var(--chrome-border-strong);
}

/* Small, dim, and in the corner - curation, not a primary control. It only has
   to be findable once you are looking for it, and it must not compete with the
   glyph you are actually here to tap. */
.reactFavDrop {
	position: absolute;
	top: -6px;
	inset-inline-end: -6px;
	width: 16px;
	height: 16px;
	display: flex;
	align-items: center;
	justify-content: center;
	border: 1px solid var(--chrome-border);
	border-radius: 999px;
	background: var(--bg);
	color: var(--muted);
	font: inherit;
	font-size: 9px;
	line-height: 1;
	padding: 0;
	cursor: pointer;
	opacity: 0;
	-webkit-tap-highlight-color: transparent;
}

/* revealed on hover for a pointer; always there on touch, which has no hover to
   reveal it with */
.reactFav:hover .reactFavDrop,
.reactFavDrop:focus-visible {
	opacity: 1;
}

@media (hover: none) {
	.reactFavDrop {
		opacity: 1;
	}
}

/* why a press did nothing. Danger-colored and brief - it is feedback, not a
   state you have to dismiss - and it takes the space rather than shifting the
   card, so the buttons above it don't jump. */
.reactError {
	margin-top: 8px;
	color: var(--danger);
	font-size: 12px;
	line-height: 1.3;
}

.reactError[hidden] {
	display: none;
}

#reactGate {
	z-index: 1020; /* opens from the action popup, so it draws over it */
}

/* The confirm sits ON TOP of whatever raised it, which is what makes it the one
   gate that cannot share the common z-index and let DOM order decide: it opens
   from the notes sheet today and is meant to be reusable from anywhere, so it
   goes above even the gallery's 1010 rather than relying on being last in the
   tree. Everything else about it is borrowed from the action popup - same card
   width, same title, same clamped preview, same danger button and cancel - so a
   destructive question looks like the popup that asked it. */
#confirmGate {
	z-index: 1020;
}

/* One question, not a handle-and-badge split: undo the action title's
   space-between so a short title doesn't get pushed around by the flex box. */
#confirmTitle {
	display: block;
}

/* --- DM inbox + thread. Both are absolutely positioned inside #app (which JS
   sizes to the visual viewport) rather than fixed to the layout viewport, so the
   thread's bottom-anchored composer rides above the keyboard exactly like the
   main #inputbar - no separate --vvh sizing, no fixed-position keyboard jank. --- */
#dmListGate,
#dmGate {
	position: absolute;
	inset: 0;
	/* These two are PAGES, not modals: full-bleed, with their own header and exit,
	   and everything else opens on top of them. Every other gate shares z-index 1000
	   and lets DOM order decide, which put these two - last in the tree - above the
	   action popup, the profile card and the notes sheet. The popup did open when you
	   tapped a message in a thread; it rendered underneath it, ghosted through the
	   scrim, unreadable and untappable.
	   Dropping BELOW the shared 1000 fixes the whole family at once (popup, card,
	   notes, added list) instead of raising each one over these. Still above the
	   floating pills at 900, which is the one thing that must stay underneath. */
	z-index: 950;
	/* the thread carries no .usersPanel, so it does not get the shared stretch and
	   still has to say so itself; the inbox gets both stretch and its inset from
	   .gate:has(> .usersPanel) */
	align-items: stretch;
}

/* the thread's bottom padding is 0 - its composer owns the safe-area inset,
   mirroring how #inputbar sits at the bottom of #app */
#dmGate {
	padding: calc(16px + env(safe-area-inset-top)) 16px 0;
}

/* the inbox reuses .usersPanel; the thread needs its own composer-anchored layout */
.dmPanel {
	width: 100%;
	height: 100%;
	display: flex;
	flex-direction: column;
	min-height: 0;
}

/* a button now, so it opens their card - but it has to keep reading as the header
   it already was, which means undoing everything a <button> brings with it */
.dmPeerName {
	border: 0;
	padding: 0;
	background: none;
	font: inherit;
	text-align: start;
	cursor: pointer;
	-webkit-tap-highlight-color: transparent;
	font-weight: var(--font-bold-weight);
	color: var(--accent);
	word-break: break-word;
	min-width: 0;
	overflow: hidden;
	text-overflow: ellipsis;
	white-space: nowrap;
}

.dmPeerName:hover {
	text-decoration: underline;
}

.dmPeerName .sfx {
	opacity: 0.6;
}

.dmPanel .usersHeader {
	margin-bottom: 12px;
}

.dmThread {
	flex: 1;
	min-height: 0;
	overflow-y: auto;
	overflow-x: hidden;
	display: flex;
	flex-direction: column;
	gap: 8px;
	padding: 4px 0;
	font-size: 14px;
}

.dmThread::before {
	content: "";
	margin-top: auto; /* anchor messages to the bottom, like the terminal */
}

.dmThread:empty::after {
	content: var(--label-dm-empty);
	color: var(--muted);
	font-size: 13px;
	margin: auto;
}

.dmMsg {
	max-width: 82%;
	border: 1px solid var(--chrome-border);
	border-radius: 10px;
	padding: 7px 11px;
	line-height: 1.4;
	word-break: break-word;
	white-space: pre-wrap;
}

.dmMsg.theirs {
	align-self: flex-start;
	border-bottom-left-radius: 2px;
	background: var(--bubble-theirs);
}

.dmMsg.mine {
	align-self: flex-end;
	border-bottom-right-radius: 2px;
	background: color-mix(in srgb, var(--accent) 8%, transparent);
	border-color: var(--chrome-border-strong);
}

/* An emote in a thread, styled like chat's: the whole "* ... *" muted, centred
   in its own bubble and italic so it reads as narration rather than as something
   either of you typed. The bubble keeps its side (mine/theirs) so you can still
   see who did it. */
.dmAction {
	display: block;
	color: var(--muted);
	font-style: italic;
	word-break: break-word;
}

/* the quoted message a dm reply answers, in the same left-bordered block chat
   uses - dim, so the reply body below it stays the thing you read first */
.dmReplyQuote {
	display: block;
	margin-bottom: 4px;
	padding-inline-start: 8px;
	border-inline-start: 2px solid var(--chrome-border-strong);
	color: var(--muted);
	font-size: 0.92em;
	word-break: break-word;
}

.dmReplyBody {
	display: block;
	word-break: break-word;
	white-space: pre-wrap;
}

/* a translation under the message it belongs to, accent-bordered like chat's */
.dmTranslation {
	display: block;
	margin-top: 4px;
	padding-inline-start: 8px;
	border-inline-start: 2px solid var(--accent);
	color: var(--fg);
	word-break: break-word;
	white-space: pre-wrap;
}

.dmTranslation.dmTranslating {
	color: var(--muted);
	font-style: italic;
}

.dmMsg {
	cursor: pointer; /* the whole bubble is the tap target for message actions */
}

.dmMeta {
	display: block;
	margin-top: 3px;
	font-size: 0.72em;
	color: var(--muted);
	text-align: end;
}

.dmMeta .dmStatus.read {
	color: var(--accent);
}

.dmInputbar {
	display: flex;
	align-items: flex-end; /* see #inputbar */
	gap: 12px;
	/* the relocated "+" menu anchors here, the way the main one anchors to
	   #composerWrap - so it floats just above this row rather than the whole panel */
	position: relative;
	--menuAnchorTop: 7px; /* border + the padding below; no side padding to clear */
	/* matches #inputbar: 6px top, safe-area-aware bottom so text clears the home
	   indicator instead of clipping into the floor */
	padding: 6px 0 calc(6px + var(--inset-b));
	border-top: 1px solid var(--chrome-border);
	margin-top: 8px;
}

#dmInput {
	flex: 1;
	min-width: 0;
	border: 0;
	background: transparent;
	color: var(--fg);
	font: inherit;
	font-size: 15px;
	outline: 0;
	padding: 6px 0;
	resize: none;
	overflow-y: auto;
	line-height: 1.3;
	box-sizing: border-box; /* see #chatInput - scrollHeight already counts padding */
	height: calc(1.3em + 12px);
	max-height: 7.8em;
}

#dmSendBtn {
	border: 0;
	background: transparent;
	color: var(--accent);
	font: inherit;
	font-weight: var(--font-bold-weight);
	cursor: pointer;
	padding: 6px 0;
	transition: text-shadow 0.15s ease, filter 0.15s ease;
	-webkit-tap-highlight-color: transparent;
}

#dmSendBtn:hover {
	text-shadow: var(--glow);
	filter: brightness(1.15);
}

.dmInputbar:focus-within .prompt {
	color: var(--accent);
	text-shadow: var(--glow);
}

/* inbox conversation rows: name + preview on the left, time + unread on the right */
.dmRow {
	display: flex;
	align-items: baseline;
	justify-content: space-between;
	gap: 12px;
	padding: 8px;
	margin: 0 -8px;
	border-radius: 4px;
	cursor: pointer;
	transition: background-color 0.12s ease;
	-webkit-tap-highlight-color: transparent;
}

.dmRow:hover {
	background-color: color-mix(in srgb, var(--accent) 6%, transparent);
}

.dmRowMain {
	min-width: 0;
	display: flex;
	flex-direction: column;
	gap: 2px;
}

.dmRowName {
	font-weight: var(--font-bold-weight);
	white-space: nowrap;
	overflow: hidden;
	text-overflow: ellipsis;
}

.dmRowName .sfx {
	opacity: 0.6;
}

.dmRowPreview {
	color: var(--muted);
	font-size: 0.85em;
	white-space: nowrap;
	overflow: hidden;
	text-overflow: ellipsis;
	max-width: 60vw;
}

.dmRowSide {
	flex: none;
	display: flex;
	flex-direction: column;
	align-items: flex-end;
	gap: 3px;
}

/* "accept request", sitting above the timestamp on a stranger's row.
   Accent rather than red: it is the constructive half of this list, opposite the
   clear underneath the bar. Small, because it repeats on every request row and a
   column of loud buttons is the thing the requests bar exists to prevent. */
.dmRowAccept {
	border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
	border-radius: 2px;
	padding: 2px 6px;
	background: transparent;
	color: var(--accent);
	font: inherit;
	font-size: 0.7em;
	white-space: nowrap;
	cursor: pointer;
	transition: border-color 0.12s ease, background-color 0.12s ease;
	-webkit-tap-highlight-color: transparent;
}

.dmRowAccept:hover {
	border-color: var(--accent);
	background-color: color-mix(in srgb, var(--accent) 10%, transparent);
}

/* ...and its opposite number on the rows you already keep. Same size, same place,
   same shape - the two are the same decision pointing in opposite directions - in the
   red every destructive control in this app wears (block, panic wipe, clear requests). */
.dmRowDelete {
	border: 1px solid color-mix(in srgb, var(--danger) 40%, transparent);
	border-radius: 2px;
	padding: 2px 6px;
	background: transparent;
	color: var(--danger);
	font: inherit;
	font-size: 0.7em;
	font-weight: var(--font-bold-weight);
	white-space: nowrap;
	cursor: pointer;
	transition: border-color 0.12s ease, background-color 0.12s ease;
	-webkit-tap-highlight-color: transparent;
}

.dmRowDelete:hover {
	border-color: var(--danger);
	background-color: color-mix(in srgb, var(--danger) 10%, transparent);
}

.dmRowTime {
	color: var(--muted);
	font-size: 0.72em;
}

.dmRowUnread {
	color: var(--bg);
	background: var(--accent);
	border-radius: 8px;
	padding: 0 6px;
	font-size: 0.72em;
	font-weight: var(--font-bold-weight);
}

/* The one row that stands for everyone you have never spoken to.
   Deliberately quieter than a conversation - muted rather than accent, and a rule
   above it to set it apart from the threads. Eleven strangers should read as one
   thing you can deal with later, not as eleven things demanding a decision. */
.dmRequestsBar {
	display: flex;
	align-items: center;
	gap: 8px;
	width: calc(100% + 16px);
	margin: 6px -8px 0;
	padding: 10px 8px;
	border: 0;
	border-top: 1px solid var(--chrome-border);
	background: transparent;
	color: var(--muted);
	font: inherit;
	font-size: 13px;
	text-align: start;
	cursor: pointer;
	transition: background-color 0.12s ease;
	-webkit-tap-highlight-color: transparent;
}

.dmRequestsBar:hover {
	background-color: color-mix(in srgb, var(--accent) 6%, transparent);
}

.dmRequestsMark {
	flex: none;
	color: var(--muted);
	font-size: 11px;
}

.dmRequestsLabel {
	flex: 1 1 auto;
	min-width: 0;
	overflow: hidden;
	text-overflow: ellipsis;
	white-space: nowrap;
}

/* The way out of the requests, directly under the bar that counts them.
   A label rather than a slab: it is destructive, so it takes the same red the block
   button and the panic wipe take, but it is not the thing you came to the inbox to
   do and should not out-shout the conversations above it. */
.dmRequestsClear {
	display: block;
	width: calc(100% + 16px);
	margin: 0 -8px;
	padding: 8px;
	border: 0;
	background: transparent;
	color: var(--danger);
	font: inherit;
	font-size: 12px;
	font-weight: var(--font-bold-weight);
	text-align: start;
	cursor: pointer;
	transition: background-color 0.12s ease;
	-webkit-tap-highlight-color: transparent;
}

.dmRequestsClear:hover {
	background-color: color-mix(in srgb, var(--danger) 8%, transparent);
}

/* a request's own unread badge is muted too: it is a count, not a summons */
.dmRequestsBar .dmRowUnread {
	background: color-mix(in srgb, var(--muted) 55%, transparent);
	color: var(--fg);
}

#dmList:empty::before {
	content: var(--label-no-convos);
	color: var(--muted);
	font-size: 13px;
}

#pingList:empty::before {
	content: var(--label-no-pings);
	color: var(--muted);
	font-size: 13px;
}

/* --- geotagged post ------------------------------------------------------- */
/* The panel reuses .notesPanel, so only the parts that have no notes equivalent
   are styled here: the photo being read, the facts pulled out of it, and the
   precision picker (which is a row of buttons rather than the notes dropdown -
   the choice is the whole point of the screen, so it isn't hidden behind a tap). */

/* The geotag sheet's hint strip. Same floating strip the notes pickers ride, so the
   two composers line up - but it holds only a status line, and an empty bar painted
   across the log for no reason is worse than no bar. It collapses when there is
   nothing in it, which is most of the time. */
.geotagHintRow:has(> .notesUploadHint:empty) {
	display: none;
}

.geotagBody {
	display: flex;
	flex-direction: column;
	align-items: flex-start;
	gap: 10px;
}

.geotagPreview {
	max-width: 100%;
	max-height: 220px;
	border: 1px solid var(--chrome-border);
	align-self: center;
}

.geotagFacts {
	color: var(--muted);
	font-size: 12px;
	line-height: 1.6;
	white-space: pre-wrap;
	word-break: break-word;
}

.geotagScopes {
	display: flex;
	flex-wrap: wrap;
	gap: 6px;
}

/* An explicit `display` beats the hidden attribute's UA rule, so without this the
   picker was permanently on screen - empty before a photo was read, and still
   showing the LAST photo's cells after one was cleared. */
.geotagScopes[hidden] {
	display: none;
}

.geotagScope {
	display: inline-flex;
	align-items: center;
	gap: 6px;
	background: none;
	border: 1px solid var(--chrome-border);
	color: var(--muted);
	font: inherit;
	font-size: 12px;
	padding: 4px 8px;
	cursor: pointer;
}

.geotagScope:hover {
	color: var(--fg);
}

/* the chosen precision is the one the post will use, so it reads as selected
   rather than merely hovered */
.geotagScope.on {
	color: var(--accent);
	border-color: var(--accent);
}

/* --- chamfered gates ------------------------------------------------------
   The look the name gate was rebuilt around, written as a SCOPE rather than an
   id so it can be moved onto one gate at a time. A gate opts in with `chamfered`
   on its class; everything else keeps the old chrome until it is moved over
   deliberately.

   A first pass at this was a different language wearing glub's colours - a
   filled panel, a bezel, a spine, a solid corner tab, surface texture, an amber
   label. Against the global feed, which is black, hairlines and text and nothing
   else, it read as a component from another app. What survived keeps only moves
   the feed already makes: the `>` prompt (the composer is already
   `> #channel...`), a small uppercase letterspaced label (RELAYS: 272/326 is
   exactly this voice), a hairline rule over a near-black strip (what
   [ new messages ] sits on), and black rather than a panel colour.

   THE CHAMFER RULE:

       top-left      never cut. The datum - the corner everything is measured
                     from, and the one that keeps the part the right way up.
       bottom-right  cut, essentially always. The signature.
       a second cut  optional, never automatic. TWO IS THE CEILING, not the
                     standard. An earlier pass cut three corners off everything,
                     which is how a motif turns into a texture.

   The second cut is spent on one thing per gate: the card, at top-right, so both
   its cuts land on the right edge. That gives the panel a side - the left runs
   square top to bottom and reads as the mounted edge, the right is cut at both
   ends and reads as the free one. Fields and buttons take one cut each. So the
   COUNT does work rather than decoration: containers two, parts one, and what
   they share is the corner that never changes.
   -------------------------------------------------------------------------- */

.gate.chamfered {
	/* Bigger than the three-cut pass, which is not a contradiction: with three
	   cuts per element the motif was everywhere, so each could be small and still
	   register. At one or two, each cut carries the signal alone. */
	--n: 14px;
	--n-sm: 10px;
	/* the feed's own hairline weight - the topbar rule and the composer's top
	   border are this, not brighter */
	--edge: color-mix(in srgb, var(--accent) 22%, transparent);
	--edge-lit: color-mix(in srgb, var(--accent) 45%, transparent);
}

/* The polygons are written into `clip-path` on each element rather than parked
   in a custom property.

   They were in custom properties first - one `--chamfer` holding a polygon that
   referenced `var(--cut)`, so a single definition could serve any size. That does
   not work, and it fails SILENTLY: a custom property's own var() references are
   substituted where the property is DECLARED, not where it is used. Declared on
   the gate, which has no --cut, it computed to the guaranteed-invalid value,
   every `clip-path: var(--chamfer)` fell back to `none`, and the chamfers were
   simply switched off - on a panel that still looked plausible enough to read as
   "too subtle" for two rounds before the computed value was measured. */

/* TWO cuts: containers. */
.gate.chamfered .gateCard {
	clip-path: polygon(
		0 0,
		calc(100% - var(--cut)) 0,
		100% var(--cut),
		100% calc(100% - var(--cut)),
		calc(100% - var(--cut)) 100%,
		0 100%
	);
}

/* THE CARD CARRIES THE MOTIF AND ITS CONTENTS DO NOT REPEAT IT.
   Every field and every button used to take the bottom-right cut as well, which
   put four or five chamfers in a vertical stack inside one card - the motif
   stopped being a signature and became a texture, and the gate read as a costume
   rather than a form. Two cuts on the frame, none inside it. */

/* --- the card ------------------------------------------------------------ */

.gate.chamfered .gateCard {
	--cut: var(--n);
	position: relative;
	isolation: isolate;
	border: 0;
	/* the extra 14 at the foot is the strip the build/kdf stamp sits in. It is
	   absolutely positioned, so it contributes no height of its own and would
	   otherwise print straight through whatever the card ends with. */
	padding: 15px 16px 29px;
	/* THE FILL - the card's own background, and see-through enough to matter.
	   
	   The number has to be read against the SCRIM, not against the picture: the
	   gate already lays 82% black over whatever is behind it, so only about a fifth
	   of a shared image survives to reach the card in the first place. A card at
	   78% over that keeps 4% of the picture, which is black. At 0.55 it keeps
	   getting on for half of what the scrim beside it shows, and the frosting reads
	   straight through - the card is a darker pane of the same glass rather than a
	   hole punched in it.
	   
	   Plain rgba rather than color-mix: this is the one surface where a colour
	   function that an older webview does not understand would take the background
	   out entirely, and it costs nothing here - the value is black, which no theme
	   changes.
	   
	   It is the card's own background rather than a layer over one because the
	   edge is a RING now (see ::before). It used to be a sandwich - this box in the
	   edge colour with the fill inset 1px over it - and that only works while the
	   fill is opaque: the wash spans the whole box, so a see-through fill lets the
	   accent through everywhere and the card comes out green. */
	--card-fill: rgba(0, 0, 0, 0.55);
	background: var(--card-fill);
}

/* THE HAIRLINE, as a ring rather than as a wash the fill sits on.
   One polygon with two subpaths and `evenodd`: the outer chamfer, then the same
   chamfer inset 1px, which punches the interior out. So the edge colour is painted
   on 1px of rim and nowhere else, and the fill meets the backdrop directly - which
   is what keeps a see-through card BLACK instead of washing the accent across all
   of it. The inner subpath reuses --cut rather than deriving the true 1px-inset
   chamfer: the difference is a fraction of a pixel along a 45-degree edge. */
.gate.chamfered .gateCard::before {
	content: "";
	position: absolute;
	inset: 0;
	z-index: -1;
	background: var(--edge);
	pointer-events: none;
	clip-path: polygon(
		evenodd,
		0 0,
		calc(100% - var(--cut)) 0,
		100% var(--cut),
		100% calc(100% - var(--cut)),
		calc(100% - var(--cut)) 100%,
		0 100%,
		1px 1px,
		1px calc(100% - 1px),
		calc(100% - var(--cut)) calc(100% - 1px),
		calc(100% - 1px) calc(100% - var(--cut)),
		calc(100% - 1px) var(--cut),
		calc(100% - var(--cut)) 1px
	);
}

/* --- header --------------------------------------------------------------- */

/* Title left, the way out right, one rule under both. The technical stamp used to
   ride in here too, at the far end of the same line, where it competed with the
   only thing on the card that should be read first. */
.gate.chamfered .gateHead {
	display: flex;
	align-items: center;
	gap: 10px;
	margin-bottom: 10px;
	padding-bottom: 9px;
	border-bottom: 1px solid var(--edge);
}

.gate.chamfered .gateHead .gateTitle {
	flex: 1;
	margin: 0;
}

/* A close, not a warning. It was a red [ close ] hanging below the frame, docked
   to nothing, in the colour every destructive control in this app wears - for a
   gesture that destroys nothing at all (closing the name gate adopts a random
   name, which is what pressing enter on an empty field does). Muted, in the
   header, where a sheet's close lives. */
.gateClose {
	flex: none;
	border: 0;
	background: transparent;
	color: var(--muted);
	font: inherit;
	font-size: 20px;
	line-height: 1;
	padding: 2px 2px 4px;
	margin: -2px -4px -4px 0;
	cursor: pointer;
	transition: color 0.15s ease;
	-webkit-tap-highlight-color: transparent;
}

.gateClose:hover,
.gateClose:focus-visible {
	color: var(--accent);
}

/* The same voice as `RELAYS: 272/326` in the topbar: uppercase, letterspaced,
   small, green. Each gate names the thing it actually does, and each label is
   TRUE - the name gate's key really is secp256k1, and a guild password really is
   stretched with PBKDF2 at 300k iterations. An annotation that is merely
   atmospheric is what makes an interface a costume. */
/* Positioned against the CARD rather than flowed in the header, so the stamp can
   sit at the foot of the gate while the attribute it is drawn from stays on the
   title - which is where app.js reads the build from (see BUILD). Moving the
   attribute to follow the pixels would have moved a load-bearing selector to gain
   nothing.

   Dim, and dimmer than it was: this is a thing to read off the screen when
   reporting a bug, not a label. */
.gate.chamfered .gateTitle::after {
	position: absolute;
	inset-inline-start: 16px;
	bottom: 10px;
	font-size: 9px;
	font-weight: 400;
	letter-spacing: 0.18em;
	color: color-mix(in srgb, var(--accent) 34%, transparent);
	text-shadow: none;
	white-space: nowrap;
	pointer-events: none;
}

/* The commit this build was cut from, read off the attribute the server stamps.
   It replaced SECP256K1, which was true but decorative; the sha is the one thing
   in this corner that changes, and it is what somebody reporting a bug needs to
   be able to read off the screen. */
#nameGate .gateTitle::after {
	content: "BUILD " attr(data-build);
}

/* ...and nothing at all when there is no sha: served by anything other than our
   own static server, the attribute is empty (or absent), and "BUILD " on its own
   is worse than no stamp. */
#nameGate .gateTitle[data-build=""]::after,
#nameGate .gateTitle:not([data-build])::after {
	content: none;
}

#guildGate .gateTitle::after {
	content: "PBKDF2 · 300K";
}

/* #nameGate .gateSub is deliberately untouched. It is a flex column so each line
   shrinks to its own width and the typewriter mask stays clipped to it;
   overriding it to `display: block` makes the spans inline, the clipping stops,
   and the parked mask paints a black block out past the card. */

/* --- fields --------------------------------------------------------------- */
/* An <input> cannot draw the sandwich itself - it has no pseudo-elements to
   spare - so every field in a chamfered gate is wrapped, and the wrapper carries
   the frame. */

/* A border, now that there is no chamfer to draw around.
   The two-layer sandwich - a box in the edge colour with the fill inset 1px over
   it - existed because an element clipped to a polygon cannot have a border that
   follows the cut. Nothing here is clipped any more, so it is one hairline and no
   stacking context. */
.gate.chamfered .gateField {
	position: relative;
	background: transparent;
	border: 1px solid var(--edge);
	transition: border-color 0.15s ease;
}

/* focus lives on the frame, so the ring brightens as one piece and the field
   gains no furniture just for being focused */
.gate.chamfered .gateField:focus-within {
	border-color: var(--edge-lit);
}

.gate.chamfered .gateField input,
.gate.chamfered .nameHint {
	padding: 10px 12px;
}

.gate.chamfered .gateField input,
.gate.chamfered .gateField textarea {
	border: 0;
	background: transparent; /* the wrapper draws the frame now */
	box-shadow: none;
}

.gate.chamfered .nameHint {
	border: 0;
}

/* The `>` that used to sit in every field is gone. The placeholder already says
   what to type, so the glyph was a second answer to a question nobody had asked -
   and it cost 16px of indent on every field to say it. */

/* --- buttons -------------------------------------------------------------- */

/* FILLED, so the primary action outranks the field above it.
   It was an outlined box of the same width, the same height and the same border as
   the input - two controls of identical weight stacked, with nothing to say which
   one finishes the job. */
/* Written against the ids rather than `form button`, because #nameForm/#guildForm
   set `background: transparent` at id strength and would otherwise win. */
.gate.chamfered #nameForm button,
.gate.chamfered #guildForm button {
	position: relative;
	border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent);
	background: color-mix(in srgb, var(--accent) 16%, transparent);
	color: var(--accent);
	letter-spacing: 0.08em;
	transition: background-color 0.15s ease, border-color 0.15s ease;
}

.gate.chamfered #nameForm button:hover,
.gate.chamfered #guildForm button:hover {
	background: color-mix(in srgb, var(--accent) 24%, transparent);
	border-color: color-mix(in srgb, var(--accent) 72%, transparent);
}

/* a disabled button keeps the frame but stops answering to the pointer */
.gate.chamfered #nameForm button:disabled,
.gate.chamfered #guildForm button:disabled {
	opacity: 0.5;
}

.gate.chamfered #nameForm button:disabled:hover,
.gate.chamfered #guildForm button:disabled:hover {
	background: color-mix(in srgb, var(--accent) 16%, transparent);
	border-color: color-mix(in srgb, var(--accent) 55%, transparent);
}

/* --- the secondary row --------------------------------------------------- */
/* Four plain links in one row, at secondary weight.

   What was here: a 2x2 grid of bracketed labels in a banded strip, the band
   mitred at 45 degrees into the card's right edge with three stacked background
   gradients so the rule would meet the chamfer at the same angle. All of it to
   frame four links that are not the point of the card - and the frame gave them
   the same weight as the form above, which is the point of the card.

   The brackets went with it. `[ map ]` is a terminal affectation on a control
   that is already obviously a control; the row reads as links because it is
   spaced and coloured like links. */

#nameGate .nameGateLinks {
	display: flex;
	flex-wrap: wrap;
	justify-content: center;
	gap: 2px 18px;
	margin-top: 14px;
	padding-top: 12px;
	border-top: 1px solid var(--edge);
}

/* --- the guild gate's list ------------------------------------------------ */
/* Rows stay square. Two is the ceiling for cuts, and it is a ceiling per
   ELEMENT, not per screen - a list of eight rows each carrying the motif is the
   texture the rule exists to prevent. They get the hairline rule instead, which
   is what separates rows everywhere else in the app. */

#guildGate .guildList {
	border-top: 1px solid var(--edge);
	padding-top: 8px;
}

#guildGate .guildList:empty {
	border-top: 0;
	padding-top: 0;
}

/* --- the narrow end -------------------------------------------------------
   The name gate's subtitle is 34 monospace characters and the card at 320px has
   256 to put them in, so at 13px it ran ~10px past the edge. That predates this
   redesign - the old card overflowed by the same amount on the same span - but a
   pass that is otherwise about the panel's edges is the wrong moment to leave one
   being crossed. Below 360 is the only place it bites. */
@media (max-width: 359px) {
	#nameGate .gateSub {
		font-size: 12px;
	}
}

/* --- one cut, bottom-right -------------------------------------------------
   The chamfer rule at its minimum: a single cut, on the corner that always takes
   one, on things that are boxes rather than panels. See the chamfered-gate block
   above for the rule itself - top-left is never cut, bottom-right essentially
   always, two is the ceiling. These get one.

   Drawn as a SANDWICH: the element's own background is the edge colour and a
   pseudo-element inset 1px carries the fill, both cut by the same polygon. That
   construction is not decoration, it is the only one that works. A `border` is
   cut off WITH the corner, and an inset box-shadow is painted on the RECTANGLE
   and then clipped - so both leave the diagonal undrawn and the corner hanging
   open. Magnified side by side, the border version reads as a broken frame
   rather than a chamfer.

   It is also why several inputs gained a wrapper: an <input> or <textarea> has no
   pseudo-element to host the second layer, so the wrapper carries the frame and
   the field inside it goes transparent.

   --frame and --fill are the two knobs; the defaults suit a bordered box on the
   panel, and anything recessed overrides --fill. */

.settingSelectWrap,
.settingTextWrap,
.idFieldWrap,
.idBtn:not(#revealNsecBtn),
.fmtPreview,
.profileEditFieldWrap,
.profileCard .actionBtn,
.profileCard:not(:has(.profileAction:not([hidden]))) #profileClose {
	--cut: 10px;
	--frame: var(--chrome-border);
	--fill: var(--chrome-bg);
	position: relative;
	isolation: isolate;
	border: 0;
	background: var(--frame);
	clip-path: polygon(
		0 0,
		100% 0,
		100% calc(100% - var(--cut)),
		calc(100% - var(--cut)) 100%,
		0 100%
	);
}

.settingSelectWrap::before,
.settingTextWrap::before,
.idFieldWrap::before,
.idBtn:not(#revealNsecBtn)::before,
.fmtPreview::before,
.profileEditFieldWrap::before,
.profileCard .actionBtn::before,
.profileCard:not(:has(.profileAction:not([hidden]))) #profileClose::before {
	content: "";
	position: absolute;
	inset: 1px;
	z-index: -1;
	clip-path: inherit;
	background: var(--fill);
	transition: background-color 0.15s ease;
}

/* the fields are recessed rather than flush with the panel */
.settingSelectWrap,
.settingTextWrap,
.idFieldWrap,
.profileEditFieldWrap,
.fmtPreview {
	--fill: var(--field-solid);
}

/* ...and the field inside a wrapper stops drawing its own frame, since the
   wrapper is now doing it */
.settingTextWrap .settingText,
.idFieldWrap .idField,
.profileEditFieldWrap .profileEditField,
.settingSelectWrap .settingSelect {
	border: 0;
	background: transparent;
	box-shadow: none;
}

/* focus lands on the frame, as it does in the gates: the ring brightens as one
   piece rather than the field growing a second weight */
.settingTextWrap:focus-within,
.idFieldWrap:focus-within,
.profileEditFieldWrap:focus-within,
.settingSelectWrap:focus-within {
	--frame: var(--chrome-border-strong);
}

.idBtn:not(#revealNsecBtn):hover,
.profileCard .actionBtn:hover,
.profileCard:not(:has(.profileAction:not([hidden]))) #profileClose:hover {
	--frame: var(--chrome-border-strong);
	--fill: color-mix(in srgb, var(--accent) 6%, var(--chrome-bg));
}

/* --- the profile editor's rhythm ------------------------------------------
   The fields are wrapped now, and the margin that used to separate them sat on
   the field INSIDE its wrapper - so the frames ended up touching edge to edge
   with the gap hidden inside them. It belongs to the wrapper. */

.profileEditFieldWrap {
	margin-bottom: 10px;
}

/* ...and the save row is one more step in the same column, not something docked
   against the last field */
.profileEditActions {
	margin-top: 14px;
}

/* Three things deliberately NOT in the list above.

   #panicBtn stays fully rectangular. It is the one control here that destroys
   something, and a shape shared with every ordinary box is the wrong way to say
   so - square and red is the more legible signal than square-with-a-corner-off.

   #revealNsecBtn has no frame at all, which is what it had before this pass: it
   is a plain inline toggle sitting beside two real buttons, not a third one. The
   sandwich gave it a visible frame because the sandwich paints its edge as a
   BACKGROUND, so the `border-color: transparent` that used to hide it had nothing
   left to act on.

   The upload controls (+ banner, + avatar) stay rectangular and, crucially, stay
   TRANSPARENT. + banner lies over the banner preview so the tap target survives
   once an image is set - and the sandwich made it opaque, so a loaded banner
   vanished underneath its own button. Their dashed border comes back with them.

/* A destructive button on the card keeps its own colour through the sandwich.
   The sandwich paints its frame as a BACKGROUND rather than a border, so the
   `border-color` .actionBtn.danger sets had nothing left to act on and unfavorite
   lost the red outline it used to have. */
.profileCard .actionBtn.danger {
	--frame: color-mix(in srgb, var(--danger) 40%, transparent);
	color: var(--danger);
}

.profileCard .actionBtn.danger:hover {
	--frame: var(--danger);
	--fill: color-mix(in srgb, var(--danger) 8%, var(--chrome-bg));
}

/* --- the profile card's own frame -----------------------------------------
   One cut, like everything else here - but NOT built the same way, and the
   difference is forced. The card is a scroll box (max-height 88vh, overflow-y
   auto), and a pseudo-element inset inside it scrolls with the content: the fill
   would slide off the top the moment a long bio made the card scroll.

   So the fill is the card's OWN background, which never scrolls, and the rim is a
   layer on the wrapper sitting 1px proud behind it. The wrapper is deliberately
   not clipped - [ back ] hangs off it at top:100% and would be cut away with the
   corner, which is the whole reason the wrapper exists. */

#profileGate .profileCardWrap {
	--cut: 12px;
}

#profileGate .profileCardWrap::before {
	content: "";
	position: absolute;
	inset: -1px;
	z-index: -1;
	pointer-events: none;
	background: var(--chrome-border);
	clip-path: polygon(
		0 0,
		100% 0,
		100% calc(100% - var(--cut)),
		calc(100% - var(--cut)) 100%,
		0 100%
	);
}

#profileGate .profileCard {
	border: 0;
	background: var(--chrome-bg);
	clip-path: polygon(
		0 0,
		100% 0,
		100% calc(100% - var(--cut)),
		calc(100% - var(--cut)) 100%,
		0 100%
	);
}

/* --- direct message bubbles ------------------------------------------------
   Chamfered, and wearing THE ORIGINAL BORDER - the 1px hairline these bubbles
   always had, at the colours they always had it: --chrome-border for theirs,
   --chrome-border-strong for yours. Nothing about the outline changed except
   where it turns.

   Neither of the constructions used elsewhere works here.

   The SANDWICH (edge underneath, fill inset 1px on top) needs an opaque fill:
   these bubbles are translucent and sit on the gate's scrim, so the edge layer
   would show through the whole interior and tint it.

   The RIM (a ::before 1px proud, behind) is what was here, and it drew nothing at
   all - which is the bug being fixed. `clip-path` clips an element's
   DESCENDANTS, pseudo-elements included, so a rim that is 1px proud of the very
   polygon clipping it is trimmed back to nothing. The profile card gets away with
   this only because its rim hangs on a WRAPPER, one level out from the clip.

   So: the border does what a border does on four sides, and the only segment it
   cannot draw - the diagonal, which is not one of the four - is drawn as a
   background band. `linear-gradient(135deg, ...)` puts its stops perpendicular to
   the bottom-left/top-right axis, which is the axis of the cut, and in a square
   the size of the cut pinned to that corner the diagonal falls at the halfway
   point. The band sits just SHORT of halfway, and the sign matters: the gradient
   runs toward the bottom-right, which is the side the clip throws away, so a band
   centred on halfway loses half its width and one nudged past it is not drawn at
   all. (It was written the wrong way round first, and looked perfectly plausible;
   sampling the rendered pixels along the diagonal is what caught it.)

   Both bubbles cut the same corner. The side they sit on and the colour already
   say who is talking; mirroring would put a cut on the bottom-LEFT, which the
   rule does not have. */
.dmMsg {
	--cut: 12px;
	/* one definition, two users. Safe as a custom property BECAUSE --dm-edge is
	   declared on this same element (by .theirs/.mine) - a var() inside a custom
	   property is substituted where it is DECLARED, so this would silently compute
	   to nothing if the colour lived on an ancestor. */
	--dm-cut-line: linear-gradient(
		135deg,
		transparent calc(50% - 1.5px),
		var(--dm-edge) calc(50% - 1.5px) calc(50% - 0.3px),
		transparent calc(50% - 0.3px)
	);
	border: 1px solid var(--dm-edge);
	border-radius: 0;
	clip-path: polygon(
		0 0,
		100% 0,
		100% calc(100% - var(--cut)),
		calc(100% - var(--cut)) 100%,
		0 100%
	);
}

/* background-COLOR, not the shorthand: the shorthand would reset the diagonal
   away, and these two rules are the ones with the specificity to win. */
.dmMsg.theirs {
	--dm-edge: var(--chrome-border);
	border-bottom-left-radius: 0;
	background-color: var(--bubble-theirs);
	background-image: var(--dm-cut-line);
	background-repeat: no-repeat;
	/* border-box, or the square would sit a border-width inside the corner the clip
	   actually cuts, and the diagonal would miss it by that much */
	background-origin: border-box;
	background-position: right bottom;
	background-size: var(--cut) var(--cut);
}

.dmMsg.mine {
	--dm-edge: var(--chrome-border-strong);
	border-bottom-right-radius: 0;
	background-color: color-mix(in srgb, var(--accent) 8%, transparent);
	background-image: var(--dm-cut-line);
	background-repeat: no-repeat;
	/* border-box, or the square would sit a border-width inside the corner the clip
	   actually cuts, and the diagonal would miss it by that much */
	background-origin: border-box;
	background-position: right bottom;
	background-size: var(--cut) var(--cut);
}

/* --- the chamfer, continued: popups, the notes controls, the action card ----
   All one cut, bottom-right, all drawn as the sandwich for the reason the block
   above gives: a border is cut off WITH the corner and an inset shadow is painted
   on the rectangle and then clipped, so both leave the diagonal undrawn. */

/* The popup that opens from the map's top-left control - and, being the same
   class, the local-area menu in the notes composer. Its rows stay plain: a menu
   of four items each carrying the motif is the texture the rule exists to
   prevent, and the frame around them already says what shape this thing is. */
.mapMenu {
	--cut: 10px;
	--frame: var(--chrome-border-strong);
	/* NO `position` here. The sandwich normally sets `relative` so its ::before has
	   something to be absolute against, but this element is ALREADY positioned -
	   `.mapMenu` is `position: absolute` up at the top of the file, and .notesMenu
	   and .mediaMenu only move its offsets. Restating it as `relative` in a later
	   rule of equal specificity overrode all of that: every one of these menus - the
	   map's, the notes composer's local-area and media pickers, and the chat
	   composer's `+` - dropped back into the flow, took a slot in the row that owns
	   them (shoving the buttons sideways) and then offset itself from wherever it
	   landed. `isolation` is still needed, so the ::before's negative z-index stops
	   at this element instead of sinking behind the page. */
	isolation: isolate;
	border: 0;
	background: var(--frame);
	clip-path: polygon(
		0 0,
		100% 0,
		100% calc(100% - var(--cut)),
		calc(100% - var(--cut)) 100%,
		0 100%
	);
}

.mapMenu::before {
	content: "";
	position: absolute;
	inset: 1px;
	z-index: -1;
	clip-path: inherit;
	background: var(--chrome-bg);
	/* the menu's own padding is on the element, so the fill has to cover the whole
	   interior rather than just the content box */
}

/* the two controls that describe a note rather than write it. The <select> is
   wrapped (see the markup) because it cannot host the second layer itself. */
.notesPick:not(select),
.notesPickWrap {
	--cut: 8px;
	--frame: var(--chrome-border);
	position: relative;
	isolation: isolate;
	border: 0;
	background: var(--frame);
	clip-path: polygon(
		0 0,
		100% 0,
		100% calc(100% - var(--cut)),
		calc(100% - var(--cut)) 100%,
		0 100%
	);
}

.notesPickWrap {
	display: inline-flex;
	flex: none;
}

.notesPick:not(select)::before,
.notesPickWrap::before {
	content: "";
	position: absolute;
	inset: 1px;
	z-index: -1;
	clip-path: inherit;
	background: var(--chrome-bg);
}

/* the select inside the wrapper stops drawing a frame of its own */
.notesPickWrap .notesPick {
	border: 0;
	background: transparent;
}

.notesPick:not(select):hover,
.notesPickWrap:focus-within,
.notesPickWrap:hover {
	--frame: var(--chrome-border-strong);
}

/* the scope menu's rows: name left, distance right. Two columns rather than one
   comma-joined string, so the four sizes line up and can be read down. */
.nsLabel {
	display: flex;
	flex: 1 1 auto;
	align-items: baseline;
	justify-content: space-between;
	gap: 14px;
	min-width: 0;
}

.nsSpan {
	color: var(--muted);
	white-space: nowrap;
}

/* --- the action card -------------------------------------------------------
   The popup a tapped message opens, and - being the same gate - the one a tapped
   NOTE opens. One cut on the frame, one on every button in it, with three
   deliberate exceptions noted below. */

.actionCard {
	--cut: 12px;
	--frame: var(--chrome-border);
	position: relative;
	isolation: isolate;
	border: 0;
	background: var(--frame);
	clip-path: polygon(
		0 0,
		100% 0,
		100% calc(100% - var(--cut)),
		calc(100% - var(--cut)) 100%,
		0 100%
	);
}

.actionCard::before {
	content: "";
	position: absolute;
	inset: 1px;
	z-index: -1;
	clip-path: inherit;
	background: var(--chrome-bg);
}

.actionCard .actionBtn {
	--cut: 9px;
	--frame: var(--chrome-border);
	--fill: var(--chrome-bg);
	position: relative;
	isolation: isolate;
	border: 0;
	background: var(--frame);
	clip-path: polygon(
		0 0,
		100% 0,
		100% calc(100% - var(--cut)),
		calc(100% - var(--cut)) 100%,
		0 100%
	);
}

.actionCard .actionBtn::before {
	content: "";
	position: absolute;
	inset: 1px;
	z-index: -1;
	clip-path: inherit;
	background: var(--fill);
	transition: background-color 0.15s ease;
}

.actionCard .actionBtn:hover {
	--frame: var(--chrome-border-strong);
	--fill: color-mix(in srgb, var(--accent) 6%, var(--chrome-bg));
}

/* THE EXCEPTIONS.

   The destructive pair stays rectangular and keeps its own border. Square-and-red
   is the more legible signal than square-with-a-corner-off, and it is the same
   call panic wipe already makes in settings - a shape shared with every ordinary
   box is the wrong way to say "this one ends something".

   (The ask named `block`. `delete message` is the other red button in this same
   popup - hidden unless the message is yours - and leaving the two of them
   different shapes in one column would read as an oversight rather than a rule.) */
.actionCard .actionBtn.danger {
	clip-path: none;
	border: 1px solid color-mix(in srgb, var(--danger) 40%, transparent);
	background: transparent;
}

.actionCard .actionBtn.danger::before {
	content: none;
}

.actionCard .actionBtn.danger:hover {
	border-color: var(--danger);
	background: color-mix(in srgb, var(--danger) 8%, transparent);
}

/* cancel stays frameless - it is the quiet way out, not a control in the list -
   and the pow badge keeps the small bordered box it has always had. Neither is
   touched by anything above; this note is here so a later pass does not "fix"
   the inconsistency. */

/* --- the geocache card (NIP-CC) ---------------------------------------------
   The action popup's frame with a different body, because that is what it is: one
   thing, a few facts, three things you can do about it. Everything structural -
   the chamfer, the button rules, the frameless cancel - is inherited from
   .actionCard rather than restated here. */
.cacheTitle {
	color: var(--accent);
	font-weight: var(--font-bold-weight);
	word-break: break-word;
}

/* difficulty, terrain, size, type, finds - one line of small facts in the topbar's
   voice, which is where this app puts numbers about a thing rather than the thing */
.cacheMeta:not(:empty) {
	margin-top: 4px;
	font-size: 11.5px;
	letter-spacing: 0.04em;
	color: var(--muted);
}

.cacheBody:not([hidden]) {
	margin-top: 8px;
	font-size: 13px;
	line-height: 1.4;
	color: var(--fg);
	white-space: pre-wrap;
	word-break: break-word;
}

/* The hint's own control, and the hint itself. Quieter than the actions below it:
   this is something you read, not something you do - so it is a row you tap rather
   than a fourth box in the stack, and it is the profile card's notes row wearing a
   different noun. Same two slots, same metrics, same borderless full-width button;
   see .profileNotes for why that shape exists at all.

     hint                                                                   show
     hint                                                                   hide */
.cacheHintRow {
	display: flex;
	align-items: baseline;
	gap: 10px;
	width: 100%;
	margin-top: 10px;
	padding: 0;
	border: 0;
	background: none;
	font: inherit;
	font-size: 13px;
	text-align: start;
	color: var(--fg);
	cursor: pointer;
	-webkit-tap-highlight-color: transparent;
}

/* Required, not tidying: the rule above sets `display: flex`, and a class rule
   outranks the ua sheet's `[hidden] { display: none }` - so a cache with no hint
   would set the attribute and leave the word on screen. (The same trap
   .profileNotes[hidden] exists for.) */
.cacheHintRow[hidden] {
	display: none;
}

.cacheHintKey {
	min-width: 0;
	color: var(--muted);
}

.cacheHintVerb {
	margin-inline-start: auto; /* pushed to the row's end, opposite the label */
	flex: none;
	font-size: 11px;
	color: var(--accent);
	white-space: nowrap;
}

.cacheHintRow:hover .cacheHintKey {
	color: var(--fg);
}

.cacheHintRow:hover .cacheHintVerb {
	text-shadow: var(--glow);
}

/* The gap between the last thing you READ and the first thing you can DO. The
   message popup puts 12px there (.actionPreview's margin-bottom); this card had
   none at all, so `navigate` sat flush against the hint line. Set on the list
   rather than on the hint, because the hint is optional and the gap is not: a
   cache with no hint needs the same air under its description. */
.cacheCard .actionList {
	margin-top: 12px;
}

.cacheHint:not([hidden]) {
	margin-top: 6px;
	padding: 7px 9px;
	font-size: 13px;
	line-height: 1.4;
	color: var(--fg);
	white-space: pre-wrap;
	word-break: break-word;
	border-inline-start: 2px solid color-mix(in srgb, var(--accent) 28%, transparent);
	background: rgba(255, 255, 255, 0.015);
}
