eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1/* ---- themes ----------------------------------------------------------------
2 Every colour is light-dark(LIGHT, DARK); the chosen appearance sets
3 `color-scheme` on <html> (light / dark / "light dark" = follow the OS), so a
4 single set of tokens drives both modes. Themes are selected with a
5 data-theme attribute on <html>. The app sets it to "classic" by default (see
6 DEFAULT_THEME in ui.js); the bare :root values below double as the Lit Room
7 theme and the no-JS fallback. Structural tokens (radius, fonts, shadow, pills,
8 status colours) live here once; theme blocks only override what differs. Order
9 matters: themes come after this block so they win on equal specificity. */
10:root,
11[data-theme="litroom"] {
12 --radius: 18px;
13 /* Message + composer text size. The desktop default is a touch over Mac
14 iMessage's (we used to inherit the browser's 16px); the coarse-pointer
15 block further down bumps it for touch and layers Dynamic Type on top. A
16 member's picked size (theme sheet slider, lchat_text_size) is applied by
17 shell.js as an inline override on <html> — desktop only; touch follows
18 the system. */
19 --msg-font-size: 15px;
20 --font-display: ui-rounded, "SF Pro Rounded", "Avenir Next", -apple-system, system-ui, sans-serif;
21 /* depth + status colours are theme-agnostic */
22 --shadow: light-dark(rgba(20, 30, 80, 0.10), rgba(0, 0, 0, 0.55));
23 --danger: light-dark(#c2410c, #f0936b);
24 --warn: light-dark(#b4530a, #e29457);
25 --pill-bg: light-dark(#23252e, #eef0f6);
26 --pill-ink: light-dark(#ffffff, #1c2333);
27 /* selected-chat row background; defaults to the hover grey, themes may override */
28 --row-active: var(--bubble-them);
29 /* Lifted-panel fill shared by text inputs (chat composer, App Studio, …) — see
30 the .eleven-input class. Derived from --panel via color-mix, so it adapts to
31 every theme; light lifts a touch above the panel, dark blends into the band. */
32 --input-bg: light-dark(color-mix(in srgb, var(--panel) 50%, white 50%), transparent);
33
34 /* Lit Room — warm dusk, honey glow */
35 --bg: light-dark(#efe3d6, #1b1418);
36 --panel: light-dark(#fff8f0, #241a1e);
37 --sidebar: light-dark(#fbf1e6, #1f161a);
38 --ink: light-dark(#2a211b, #f4e9dc);
39 --muted: light-dark(#736657, #b09a89);
40 --border: light-dark(#ece2d4, rgba(255, 255, 255, 0.10));
41 --accent: light-dark(#f0a93c, #ffb23e);
42 --accent-ink: light-dark(#3a2412, #2a1808);
43 --link: light-dark(#956925, #ffb23e);
44 --bubble-me: light-dark(#ffb23e, #ffb23e);
45 --bubble-me-ink: light-dark(#3a2412, #2a1808);
46 --bubble-them: light-dark(#f1ece4, #2f2329);
47}
48
49/* Classic — the original lchat blue (now with a dark mode) */
50[data-theme="classic"] {
51 --font-display: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
52 --bg: light-dark(#f6f7fb, #15171c);
53 --panel: light-dark(#ffffff, #1d2026);
54 --sidebar: light-dark(#f8f9fc, #1a1d22);
55 --ink: light-dark(#1c2333, #e7e9f0);
56 --muted: light-dark(#5a6170, #9aa1b3);
57 --border: light-dark(#e7e9f0, rgba(255, 255, 255, 0.10));
58 --accent: light-dark(#5666f0, #7d8bff);
59 --accent-ink: light-dark(#ffffff, #0a0e2a);
60 --link: light-dark(#5666f0, #7d8bff);
61 --bubble-me: light-dark(#5666f0, #5666f0);
62 --bubble-me-ink: light-dark(#ffffff, #ffffff);
63 --bubble-them: light-dark(#eef0f6, #2a2e38);
64 --row-active: light-dark(#d9dfec, #2a2e38);
65}
66
67/* Lagoon — cool, fresh; a coral accent that pops */
68[data-theme="lagoon"] {
69 --bg: light-dark(#d7ecea, #06201f);
70 --panel: light-dark(#f3fbfa, #0c2a29);
71 --sidebar: light-dark(#e7f6f4, #082523);
72 --ink: light-dark(#123a3a, #d8efec);
73 --muted: light-dark(#4c7271, #6fa6a3);
74 --border: light-dark(#d2e8e6, rgba(255, 255, 255, 0.10));
75 --accent: light-dark(#b85840, #ff8a6a);
76 --accent-ink: light-dark(#ffffff, #2a0f08);
77 --link: light-dark(#b2553e, #ff8a6a);
78 --bubble-me: light-dark(#0e827e, #16b3ad);
79 --bubble-me-ink: light-dark(#ffffff, #042220);
80 --bubble-them: light-dark(#ffffff, #123634);
81}
82
83/* Sunny — bright-side daytime */
84[data-theme="sunny"] {
85 --bg: light-dark(#ffeecb, #1c1710);
86 --panel: light-dark(#fffaf0, #241d12);
87 --sidebar: light-dark(#fff4df, #1f190f);
88 --ink: light-dark(#3a2f1c, #f6ecd6);
89 --muted: light-dark(#796d57, #b6a079);
90 --border: light-dark(#f0e6d0, rgba(255, 255, 255, 0.10));
91 --accent: light-dark(#ffc62e, #ffc62e);
92 --accent-ink: light-dark(#3a2f1c, #2a2106);
93 --link: light-dark(#8a6b19, #ffc62e);
94 --bubble-me: light-dark(#2c7ab0, #3aa0e8);
95 --bubble-me-ink: light-dark(#ffffff, #04243a);
96 --bubble-them: light-dark(#ffffff, #2e2616);
97}
98
99/* Linen — quiet, grown-up; warmth through restraint */
100[data-theme="linen"] {
101 --font-display: "Avenir Next", "Helvetica Neue", -apple-system, system-ui, sans-serif;
102 --bg: light-dark(#e7e1d6, #181714);
103 --panel: light-dark(#f5f1ea, #211f1b);
104 --sidebar: light-dark(#ece6dc, #1c1b17);
105 --ink: light-dark(#33302a, #ece7dd);
106 --muted: light-dark(#69655c, #9c958a);
107 --border: light-dark(#e2dccf, rgba(255, 255, 255, 0.10));
108 --accent: light-dark(#647686, #93a8ba);
109 --accent-ink: light-dark(#ffffff, #0c1217);
110 --link: light-dark(#5c6e7c, #93a8ba);
111 --bubble-me: light-dark(#647686, #7d94a8);
112 --bubble-me-ink: light-dark(#ffffff, #0c1217);
113 --bubble-them: light-dark(#fbf9f4, #2a2823);
114}
115
116/* Plum — soft grape; playful but not childish, light or deep-aubergine dark */
117[data-theme="plum"] {
118 --bg: light-dark(#ece2f0, #1a1320);
119 --panel: light-dark(#fdf8fd, #221829);
120 --sidebar: light-dark(#f4ecf6, #1e1525);
121 --ink: light-dark(#2f2436, #f0e6f4);
122 --muted: light-dark(#6f6578, #ab9bb5);
123 --border: light-dark(#e7dced, rgba(255, 255, 255, 0.10));
124 --accent: light-dark(#9456c9, #b57ae8);
125 --accent-ink: light-dark(#ffffff, #20102e);
126 --link: light-dark(#9456c9, #b57ae8);
127 --bubble-me: light-dark(#9456c9, #b57ae8);
128 --bubble-me-ink: light-dark(#ffffff, #20102e);
129 --bubble-them: light-dark(#f1e8f5, #2e2238);
130}
131
132/* Default appearance: follow the OS. The app overrides this on <html> per the
133 saved preference (light / dark / system). */
134html { color-scheme: light dark; }
135
136* { box-sizing: border-box; }
137
138html, body {
139 margin: 0;
140 height: 100%;
141 background: var(--bg);
142 color: var(--ink);
143 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
144 -webkit-font-smoothing: antialiased;
145}
146/* The app shell must fill the viewport so the flex height chain (.tt → .thread-view
147 → .feed flex:1) resolves and the composer pins to the bottom. Without an explicit
148 height here, .tt's height:100% resolves against an auto-height parent, the layout
149 collapses to content height, and the composer floats with blank space below it. */
150#app { height: 100%; }
151
152/* Full-screen loading cover (see index.html) — matches the pre-painted theme bg,
153 fades out once the app's first real paint is ready. */
154.app-loading {
155 position: fixed;
156 inset: 0;
157 z-index: 100;
158 display: grid;
159 place-items: center;
160 align-content: center;
161 gap: 20px;
162 background: var(--bg);
163 transition: opacity 0.25s ease;
164}
165.app-loading.hidden { opacity: 0; pointer-events: none; }
166/* The cover's EXIT class (hideLoading): opacity-only, unlike the global
167 `.hidden` utility whose display:none !important would snap the cover off
168 with no transition. The boot scene's exit is two quick fades — the scene
169 bows to the bare cover (bootscene.js), then this fades the cover to the
170 chat (.scene-out tunes the second fade's length). */
171.app-loading.cover-out { opacity: 0; pointer-events: none; }
172.app-loading.scene-out { transition-duration: 0.35s; }
173/* The join page's "Plugging in…" flash between claiming and the app. */
174.plugging-in {
175 min-height: 22px;
176 color: var(--muted);
177 font-size: 15px;
178 text-align: center;
179 opacity: 0;
180 transform: translateY(6px);
181 transition: opacity 0.3s ease, transform 0.3s ease;
182}
183.plugging-in.show { opacity: 1; transform: none; }
184/* The boot scene (bootscene.js): its elements start invisible and are posed
185 per-frame from JS (ease-in at start, the bow at the end) — so the embed and
186 signed-out paths, which never start the scene, only ever show the plain
187 theme bg. (Scoped to the scene's own classes: join.html shares .app-loading
188 for its plain spinner.) The rules here are layout + type only. */
189.boot-canvas, .boot-brand, .boot-spin, .boot-quote, .boot-goesto { opacity: 0; }
190.boot-canvas {
191 position: absolute;
192 inset: 0;
193 width: 100%;
194 height: 100%;
195 pointer-events: none;
196}
197/* Whose space this is — the same photo + name lockup as the join card. */
198.boot-brand {
199 position: absolute;
200 left: 0;
201 right: 0;
202 top: 18%;
203 text-align: center;
204 pointer-events: none;
205}
206.boot-avatar {
207 position: relative;
208 width: 88px;
209 height: 88px;
210 margin: 0 auto 16px;
211 border-radius: 22px;
212 overflow: hidden;
213 background: linear-gradient(160deg, #6b6cf0, #4a48c8);
214 box-shadow: 0 10px 32px rgba(0, 0, 0, 0.3);
215}
216.boot-avatar-letter {
217 display: block;
218 color: #fff;
219 font-size: 40px;
220 font-weight: 600;
221 line-height: 88px;
222}
223.boot-avatar img {
224 position: absolute;
225 inset: 0;
226 width: 100%;
227 height: 100%;
228 object-fit: cover;
229}
230.boot-name {
231 font-size: 17px;
232 font-weight: 600;
233 letter-spacing: 0.01em;
234 color: var(--ink);
235}
236/* A quiet spinner dead centre — the app's own, at a zen 2.4s/rev. */
237.boot-spin {
238 position: absolute;
239 left: 50%;
240 top: 50%;
241 width: 26px;
242 height: 26px;
243 margin: -13px 0 0 -13px;
244 border-radius: 50%;
245 border: 3px solid color-mix(in srgb, var(--ink) 14%, transparent);
246 border-top-color: var(--accent);
247 animation: spin 2.4s linear infinite;
248 transition: opacity 0.3s ease;
249 pointer-events: none;
250}
251@media (prefers-reduced-motion: reduce) {
252 .boot-spin { animation: none; }
253}
254/* The quotes: what a member actually reads while the green drifts. A quiet
255 serif, so they read as words from somewhere else, not UI. */
256.boot-quote {
257 position: absolute;
258 left: 0;
259 right: 0;
260 top: 66%;
261 padding: 0 28px;
262 text-align: center;
263 pointer-events: none;
264}
265.boot-quote-text {
266 font-family: Georgia, "Iowan Old Style", "Times New Roman", serif;
267 font-style: italic;
268 font-size: 21px;
269 line-height: 1.55;
270 color: var(--ink);
271 max-width: 38ch;
272 margin: 0 auto 12px;
273 text-wrap: balance;
274}
275.boot-quote-attr {
276 font-size: 10px;
277 letter-spacing: 0.2em;
278 text-transform: uppercase;
279 color: var(--muted);
280}
281/* The one clickable thing on the cover — where all this came from. */
282.boot-goesto {
283 position: absolute;
284 left: 0;
285 right: 0;
286 bottom: 16px;
287 text-align: center;
288 font-size: 12px;
289 letter-spacing: 0.04em;
290 color: var(--muted);
291 text-decoration: none;
292}
293.boot-goesto b { font-weight: 600; color: var(--accent); }
294.boot-goesto:hover { color: var(--ink); }
295
296/* The join page's card yields entirely to the full-screen loader while a claim
297 is in flight (see showLoader/showCard in join.js). */
298.card.hidden { display: none; }
299.spinner {
300 width: 32px;
301 height: 32px;
302 border-radius: 50%;
303 border: 3px solid color-mix(in srgb, var(--ink) 15%, transparent);
304 border-top-color: var(--accent);
305 animation: spin 0.7s linear infinite;
306}
307@keyframes spin { to { transform: rotate(360deg); } }
308@media (prefers-reduced-motion: reduce) {
309 .spinner { animation: none; opacity: 0.5; }
310}
311
312button {
313 font: inherit;
314 cursor: var(--ui-cursor, pointer);
315 border: none;
316 border-radius: 999px;
317 padding: 10px 18px;
318 background: var(--accent);
319 color: var(--accent-ink);
320 font-weight: 600;
321 white-space: nowrap;
322 line-height: 1.2;
323}
324button.secondary {
325 background: var(--bubble-them);
326 color: var(--ink);
327}
328button:disabled { opacity: 0.5; cursor: default; }
329
330input, textarea {
331 font: inherit;
332 border: 1px solid var(--border);
333 border-radius: 12px;
334 padding: 12px 14px;
335 background: var(--panel);
336 color: var(--ink);
337 width: 100%;
338 caret-color: var(--accent); /* the typing cursor picks up the accent (à la iMessage) */
339}
340input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: transparent; }
341/* Plain <select> otherwise renders as an unstyled native control everywhere it
342 appears (profile/bot-creation forms, admin) — the Admin mini-app's
343 `.admin-row select` (apps/admin.js) is the one place it already looks
344 right, so the same rounded field (native arrow left alone) becomes the
345 default here instead of staying scoped to that one app. */
346select {
347 font: inherit;
348 border: 1px solid var(--border);
349 border-radius: 12px;
350 padding: 12px 14px;
351 background: var(--panel);
352 color: var(--ink);
353 width: 100%;
354 cursor: var(--ui-cursor, pointer);
355}
356select:focus { outline: 2px solid var(--accent); border-color: transparent; }
357select:disabled { opacity: 0.5; cursor: default; }
358/* the message box doesn't get the bright focus ring (chat-app convention) — the
359 coloured caret is the cue. */
360.composer .composer-input:focus { outline: none; border-color: var(--border); }
361
362/* ---- centered card layout (join / fallback) ---- */
363.center {
364 min-height: 100%;
365 display: grid;
366 place-items: center;
367 padding: 24px;
368}
369.card {
370 background: var(--panel);
371 border: 1px solid var(--border);
372 border-radius: 24px;
373 padding: 28px;
374 width: 100%;
375 max-width: 420px;
376 box-shadow: 0 12px 40px var(--shadow);
377}
378.card h1 { margin: 0 0 6px; font-size: 24px; font-family: var(--font-display); }
379.card p { margin: 0 0 18px; color: var(--muted); }
380
381/* Transient join states ("Setting up…", "Signing you in…") — a spinner + message
382 centered in the card, instead of a small line stuck top-left. */
383.card.status {
384 min-height: 180px;
385 display: flex;
386 flex-direction: column;
387 align-items: center;
388 justify-content: center;
389 text-align: center;
390}
391.card.status .status-msg { margin: 0; font-size: 16px; color: var(--ink); }
392.status-spinner {
393 width: 30px; height: 30px; border-radius: 50%;
394 border: 3px solid var(--border); border-top-color: var(--accent);
395 animation: spin 0.8s linear infinite; margin-bottom: 14px; /* @keyframes spin defined above */
396}
397.card .row { display: flex; gap: 10px; margin-top: 14px; }
398
399/* Landing branding: the server photo + name — plaintext by design (CLAUDE.md
400 branding exception), so a signed-out visitor sees whose door they're at.
401 The photo stays hidden until /api/group confirms one. */
402.join-brand { display: flex; flex-direction: column; align-items: center; gap: 10px; margin: 6px 0 22px; }
403.join-brand-photo { width: 72px; height: 72px; border-radius: 18px; object-fit: cover; }
404.join-brand-name { font-weight: 650; font-size: 18px; font-family: var(--font-display); }
405/* The single-CTA landing: branding, ONE Sign in button, and the invite-paste
406 form folded behind a quiet text link ("Have an invite link?"). */
407.card.landing { text-align: center; }
408.card.landing .row { justify-content: center; margin-top: 16px; }
409.card.landing #paste-join .row { margin-top: 14px; }
410/* The folded-form reveal — quiet, link-like. */
411.card .signin-link {
412 background: none; border: none; padding: 0; font: inherit; font-size: 14px;
413 color: var(--muted); text-decoration: underline; text-underline-offset: 2px;
414 cursor: pointer;
415}
416.card .signin-link:hover { color: var(--link); }
417
418
419
420/* kebab settings menu */
421.icon-btn {
422 background: transparent;
423 color: var(--ink);
424 font-size: 22px;
425 line-height: 1;
426 padding: 4px 10px;
427 border-radius: 10px;
428 display: inline-flex;
429 align-items: center;
430 justify-content: center;
431}
432.icon-btn svg { display: block; }
433#thread-menu-btn svg { width: 19px; height: 19px; }
434#back-btn svg { width: 22px; height: 22px; }
435/* The Views dropdown (thread header): a labelled button, and menu rows with a
436 fixed glyph column so long view titles wrap under their own text, never
437 under the emoji. */
438.views-btn { padding: 6px 10px; font-size: 13px; font-weight: 600; gap: 4px; }
439#views-menu-label { max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
440.views-btn svg { width: 14px; height: 14px; }
441.views-item-glyph svg { width: 15px; height: 15px; display: inline-block; vertical-align: -2px; }
442.menu-item.views-item { display: flex; align-items: center; gap: 0; padding: 0; }
443.menu-item.views-item:hover { background: none; }
444.views-item-main {
445 flex: 1 1 auto; min-width: 0; display: flex; align-items: flex-start; gap: 8px;
446 background: none; border: none; color: inherit; font: inherit; text-align: left;
447 padding: 10px 12px; border-radius: 10px; cursor: pointer;
448}
449.views-item-main:hover, .views-item-main:focus-visible { background: var(--bubble-them); }
450.views-item-glyph { flex: 0 0 20px; text-align: center; }
451.views-item-label { flex: 1 1 auto; min-width: 0; }
452.views-item-x {
453 flex: 0 0 auto; border: none; background: none; color: var(--muted); cursor: pointer;
454 padding: 2px; border-radius: 6px; min-width: 24px; min-height: 24px;
455}
456.views-item-x svg { width: 13px; height: 13px; display: block; margin: auto; }
457.views-item-x:hover, .views-item-x:focus-visible { color: var(--ink); background: var(--bubble-them); }
458.icon-btn:hover { background: var(--bubble-them); }
459.menu-wrap { position: relative; flex: none; }
460.menu {
461 position: absolute;
462 top: calc(100% + 6px);
463 right: 0;
464 background: var(--panel);
465 border: 1px solid var(--border);
466 border-radius: 14px;
467 box-shadow: 0 12px 40px var(--shadow);
468 min-width: 230px;
469 padding: 6px;
470 z-index: 20;
471}
472.menu-item {
473 display: block;
474 width: 100%;
475 text-align: left;
476 background: transparent;
477 color: var(--ink);
478 border-radius: 10px;
479 padding: 10px 12px;
480 font-weight: 500;
481 white-space: normal;
482 text-decoration: none; /* menu-upgrade is an <a>; buttons ignore this */
483}
484.menu-item:hover { background: var(--bubble-them); }
485/* Menu rows read danger by text colour alone — the red pill border
486 (button.danger) is for standalone buttons, not flat list rows. */
487.menu-item.danger { border: none; color: var(--danger); }
488.menu-item.disabled,
489.menu-item.disabled:hover { color: var(--muted); cursor: default; background: transparent; }
490.msg {
491 display: flex;
492 flex-direction: column;
493 align-self: flex-start; /* shrink to the bubble width (me overrides to flex-end) */
494 max-width: 78%;
495 margin-top: 10px;
496 position: relative; /* anchors hover affordances + the swipe-to-reply icon */
497 touch-action: pan-y; /* vertical scroll stays native; we handle horizontal swipes */
498}
499/* continuation of a same-sender, same-minute run — tighter gap (see refreshGrouping) */
500.msg.cont { margin-top: 2px; }
501/* swipe-to-reply (touch): reply arrow that fades in just left of the bubble */
502.swipe-reply-icon {
503 position: absolute;
504 top: 50%;
505 left: 0;
506 transform: translate(-130%, -50%);
507 color: var(--link);
508 opacity: 0;
509 pointer-events: none;
510 display: inline-flex;
511}
512.swipe-reply-icon svg { width: 20px; height: 20px; }
513.msg .name { font-size: 12px; color: var(--muted); margin: 0 6px 3px; }
514.msg .bubble {
515 padding: 9px 13px;
516 border-radius: var(--radius);
517 background: var(--bubble-them);
518 font-size: var(--msg-font-size);
519 line-height: 1.35;
520 white-space: pre-wrap;
521 /* break long unbroken strings (e.g. a giant URL) instead of overflowing */
522 overflow-wrap: anywhere;
523 min-width: 0;
524}
525.msg .bubble code {
526 font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
527 font-size: 0.92em;
528 background: color-mix(in srgb, var(--ink) 8%, transparent);
529 padding: 1px 5px;
530 border-radius: 5px;
531}
532.msg.me .bubble code { background: color-mix(in srgb, var(--bubble-me-ink) 22%, transparent); }
533/* ``` fenced code block — the <code> inside inherits the font rules above but
534 drops the inline chip background; long lines scroll rather than wrap */
535.msg .bubble pre {
536 margin: 6px 0;
537 padding: 8px 10px;
538 border-radius: 8px;
539 background: color-mix(in srgb, var(--ink) 8%, transparent);
540 overflow-x: auto;
541 white-space: pre;
542 line-height: 1.4;
543}
544.msg .bubble pre code { background: none; padding: 0; }
545.msg.me .bubble pre code { background: none; padding: 0; }
546.msg.me .bubble pre { background: color-mix(in srgb, var(--bubble-me-ink) 22%, transparent); }
547/* "> " blockquote — quiet left rule, slightly dimmed text */
548.msg .bubble blockquote {
549 margin: 6px 0;
550 padding: 1px 0 1px 10px;
551 border-left: 3px solid color-mix(in srgb, var(--ink) 30%, transparent);
552 color: color-mix(in srgb, var(--ink) 72%, transparent);
553}
554.msg.me .bubble blockquote {
555 border-left-color: color-mix(in srgb, var(--bubble-me-ink) 55%, transparent);
556 color: color-mix(in srgb, var(--bubble-me-ink) 85%, transparent);
557}
558/* Markdown blocks (#531): paragraphs and lists inside bubbles. Lists render
559 tight always (the parse tree erases loose/tight), so item paragraphs are
560 flush; nested lists indent from their <li>. */
561.msg .bubble p { margin: 6px 0; }
562.msg .bubble ul, .msg .bubble ol { margin: 6px 0; padding-left: 22px; }
563.msg .bubble li { margin: 2px 0; }
564.msg .bubble li p, .msg .bubble li ul, .msg .bubble li ol { margin: 0; }
565/* blocks flush with the bubble's own padding at the edges */
566.msg .bubble p:first-child, .msg .bubble ul:first-child, .msg .bubble ol:first-child,
567.msg .bubble pre:first-child, .msg .bubble blockquote:first-child { margin-top: 0; }
568.msg .bubble p:last-child, .msg .bubble ul:last-child, .msg .bubble ol:last-child,
569.msg .bubble pre:last-child, .msg .bubble blockquote:last-child { margin-bottom: 0; }
570.msg.me { align-self: flex-end; align-items: flex-end; }
571.msg.me .bubble { background: var(--bubble-me); color: var(--bubble-me-ink); border-bottom-right-radius: 6px; }
572.msg.them .bubble { border-bottom-left-radius: 6px; }
573.msg .bubble a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
574/* a message of just a few emoji: large and bubble-less, no background or padding (see fillContent) */
575.msg .bubble.jumbo-emoji,
576.msg.me .bubble.jumbo-emoji { background: none; color: inherit; padding: 2px 0; font-size: 44px; line-height: 1.15; }
577
578/* centered time separator that opens a burst (replaces per-message timestamps) */
579.time-sep {
580 align-self: center;
581 margin: 16px 0 6px;
582 font-size: 11px;
583 color: var(--muted);
584 text-align: center;
585}
586.time-sep strong { color: var(--ink); font-weight: 600; }
587
588/* persistent "Edited" label under an edited bubble */
589.edited-tag { font-size: 11px; color: var(--muted); margin: 1px 6px 0; }
590
591/* Optimistic send: an outgoing message renders instantly (`.pending`) and looks
592 completely normal — a sub-second send is never decorated. Only once it's gone
593 unconfirmed for a while (`.slow`, flipped 5s in) does the bubble dim and a
594 "Sending…" status appear, hinting delivery is taking a while and may not have
595 gone through. The text carries the meaning (not colour alone); the spinner is
596 decoration and stops under Reduce Motion. */
597.send-status {
598 display: none;
599 align-items: center;
600 gap: 5px;
601 font-size: 11px;
602 color: var(--muted);
603 margin: 1px 6px 0;
604}
605.msg.me .send-status { align-self: flex-end; }
606.msg.slow .content { opacity: 0.6; } /* dims the bubble/photo/card together; the status sits outside .content */
607.msg.slow .send-status { display: inline-flex; }
608.send-status::before {
609 content: "";
610 width: 9px;
611 height: 9px;
612 border-radius: 50%;
613 border: 1.5px solid currentColor;
614 border-top-color: transparent;
615 animation: send-spin 0.7s linear infinite;
616}
617@media (prefers-reduced-motion: reduce) {
618 .send-status::before { animation: none; border-top-color: currentColor; opacity: 0.4; }
619}
620@keyframes send-spin { to { transform: rotate(360deg); } }
621
622/* Hover affordances live in a full-height strip beside the bubble: the hover zone
623 spans the whole message height, so you can move straight from anywhere in the
624 bubble to an icon (no need to find the vertical centre), and the action icons +
625 the exact time sit in a row so they never overlap. Revealed on hover (desktop). */
626.msg-actions {
627 position: absolute;
628 top: 0;
629 bottom: 0;
630 display: flex;
631 align-items: center;
632 gap: 2px;
633 opacity: 0;
634 pointer-events: none;
635 transition: opacity 0.12s;
636}
637.msg.them .msg-actions { left: 100%; padding-left: 4px; } /* just right of the bubble */
638.msg.me .msg-actions { right: 100%; padding-right: 4px; flex-direction: row-reverse; } /* just left */
639.msg-actions button {
640 background: transparent;
641 color: var(--muted);
642 padding: 4px 6px;
643 border-radius: 50%;
644 display: inline-flex;
645 align-items: center;
646 line-height: 1;
647}
648.msg-actions button:hover { background: var(--bubble-them); color: var(--accent); }
649.msg-actions button svg { width: 18px; height: 18px; }
650.msg-actions .msg-time {
651 font-size: 11px;
652 color: var(--muted);
653 white-space: nowrap;
654 margin: 0 4px;
655}
656@media (hover: hover) {
657 .msg:hover .msg-actions { opacity: 1; pointer-events: auto; }
658}
659
660/* message content (text bubble + any attachments) */
661.content {
662 position: relative; /* anchors the absolute hover reply icon */
663 display: flex;
664 flex-direction: column;
665 gap: 4px;
666 align-items: flex-start;
667 min-width: 0;
668}
669.msg.me .content { align-items: flex-end; }
670
671.msg-image {
672 max-width: min(260px, 100%);
673 max-height: 320px;
674 border-radius: var(--radius);
675 display: block;
676 background: var(--bubble-them);
677 cursor: zoom-in;
678}
679/* a GIF's click toggles play/pause, so the zoom-in cursor would lie */
680.msg-image.msg-gif { cursor: pointer; }
681
682/* full-screen image viewer */
683.lightbox {
684 position: fixed;
685 inset: 0;
686 z-index: 80;
687 background: transparent; /* the dark backing is its own layer (.lightbox-backdrop) */
688 display: flex;
689 align-items: center;
690 justify-content: center;
691 padding: 20px;
692}
693/* separate layer so dimming animates via opacity (GPU-composited) — no repaint */
694.lightbox-backdrop {
695 position: absolute;
696 inset: 0;
697 z-index: 0;
698 background: rgba(0, 0, 0, 0.9);
699 will-change: opacity;
700}
701.lightbox img {
702 position: relative;
703 z-index: 1; /* sit above the backdrop */
704 max-width: 100%;
705 max-height: 100%;
706 border-radius: 8px;
707 touch-action: none; /* we own the gesture: pinch-zoom, pan, flick-to-dismiss */
708 user-select: none;
709 -webkit-user-drag: none;
710 will-change: transform;
711}
712
713/* the lightbox open/close is driven in JS (a FLIP fly-in/out from the thumbnail) */
714@keyframes fade-in { from { opacity: 0; } }
715
716/* a newly-sent/arrived message eases up into the feed (initial history doesn't) */
717.msg.appear { animation: msg-appear 0.22s cubic-bezier(0.2, 0.8, 0.2, 1); }
718@keyframes msg-appear {
719 from { opacity: 0; transform: translateY(10px); }
720}
721
722@media (prefers-reduced-motion: reduce) {
723 .msg.appear { animation: none; }
724}
725
726/* drag-and-drop overlay */
727.drop-overlay {
728 position: absolute;
729 inset: 0;
730 z-index: 40;
731 background: color-mix(in srgb, var(--accent) 12%, transparent);
732 border: 3px dashed var(--accent);
733 border-radius: 12px;
734 display: flex;
735 align-items: center;
736 justify-content: center;
737 font-weight: 700;
738 font-size: 18px;
739 color: var(--link);
740 pointer-events: none;
741}
742.file-chip {
743 display: inline-flex;
744 align-items: center;
745 gap: 6px;
746 background: var(--bubble-them);
747 color: var(--ink);
748 padding: 10px 14px;
749 border-radius: var(--radius);
750 text-decoration: none;
751 font-size: 14px;
752 max-width: 260px;
753 word-break: break-word;
754}
755.msg.me .file-chip { background: var(--bubble-me); color: var(--bubble-me-ink); }
756.file-chip .file-icon { font-size: 22px; line-height: 1; flex: none; }
757.file-chip .file-meta { display: flex; flex-direction: column; min-width: 0; }
758.file-chip .file-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
759.file-chip .file-sub { font-size: 12px; opacity: 0.7; }
760
761/* click-to-play YouTube card (see buildYouTubeCard) — 16:9, bubble-styled */
762.yt-card {
763 width: min(420px, 100%);
764 aspect-ratio: 16 / 9;
765 border-radius: var(--radius);
766 overflow: hidden;
767 background: #000;
768 border: 1px solid var(--border);
769}
770.yt-card.yt-bare { display: none; } /* dead/private video → no card, just the link */
771.yt-poster {
772 position: relative;
773 display: block;
774 width: 100%;
775 height: 100%;
776 padding: 0;
777 border-radius: 0;
778 background: #000;
779}
780.yt-thumb { width: 100%; height: 100%; object-fit: cover; display: block; }
781.yt-play {
782 position: absolute;
783 inset: 0;
784 display: grid;
785 place-items: center;
786 color: #fff;
787}
788.yt-play svg {
789 width: 56px;
790 height: 56px;
791 padding: 10px 8px 10px 12px;
792 box-sizing: border-box;
793 border-radius: 50%;
794 background: rgba(0, 0, 0, 0.55);
795 transition: transform 0.15s ease, background 0.15s ease;
796}
797.yt-poster:hover .yt-play svg { background: rgba(0, 0, 0, 0.75); transform: scale(1.06); }
798.yt-frame { width: 100%; height: 100%; border: 0; display: block; }
799
800/* send-and-play video card (see buildVideoCard) — poster + play/download badge;
801 the clip is fetched + decrypted only when tapped (never auto-downloaded) */
802.msg-video {
803 position: relative;
804 display: block;
805 max-width: min(260px, 100%);
806 max-height: 320px;
807 border-radius: var(--radius);
808 overflow: hidden;
809 background: #000;
810 cursor: pointer;
811 line-height: 0;
812}
813.msg-video-poster {
814 display: block;
815 width: 100%;
816 height: 100%;
817 object-fit: cover;
818 background: var(--bubble-them);
819}
820.msg-video-play {
821 position: absolute;
822 inset: 0;
823 display: grid;
824 place-items: center;
825 color: #fff;
826 pointer-events: none;
827}
828.msg-video-play svg {
829 width: 52px;
830 height: 52px;
831 padding: 12px;
832 box-sizing: border-box;
833 border-radius: 50%;
834 background: rgba(0, 0, 0, 0.55);
835 transition: transform 0.15s ease, background 0.15s ease;
836}
837.msg-video:hover .msg-video-play svg { background: rgba(0, 0, 0, 0.75); transform: scale(1.06); }
838.msg-video.downloading .msg-video-play svg { opacity: 0.5; }
839.msg-video-dur {
840 position: absolute;
841 right: 6px;
842 bottom: 6px;
843 padding: 1px 6px;
844 border-radius: 10px;
845 background: rgba(0, 0, 0, 0.6);
846 color: #fff;
847 font-size: 12px;
848 font-variant-numeric: tabular-nums;
849 pointer-events: none;
850}
851/* radial download-progress ring around the play button (see showVideoRing) */
852.msg-video-ring {
853 position: absolute;
854 inset: 0;
855 display: grid;
856 place-items: center;
857 pointer-events: none;
858}
859.msg-video-ring svg { width: 60px; height: 60px; transform: rotate(-90deg); }
860.msg-video-ring .ring-track { fill: none; stroke: rgba(255, 255, 255, 0.3); stroke-width: 3; }
861.msg-video-ring .ring-fill {
862 fill: none;
863 stroke: #fff;
864 stroke-width: 3;
865 stroke-linecap: round;
866 transition: stroke-dashoffset 0.12s linear;
867}
868
869/* full-screen video player in the lightbox */
870.video-lightbox { animation: fade-in 0.2s ease; }
871.lightbox-video {
872 position: relative;
873 z-index: 1; /* above the backdrop */
874 max-width: 100%;
875 max-height: 100%;
876 border-radius: 8px;
877 background: #000;
878}
879/* staged video preview: poster tile with a small play glyph over it */
880.preview-chip.preview-video { position: relative; }
881.preview-chip.preview-video .preview-play {
882 position: absolute;
883 inset: 0;
884 display: grid;
885 place-items: center;
886 color: #fff;
887 pointer-events: none;
888}
889.preview-chip.preview-video .preview-play svg {
890 width: 24px;
891 height: 24px;
892 padding: 5px;
893 box-sizing: border-box;
894 border-radius: 50%;
895 background: rgba(0, 0, 0, 0.5);
896}
897
898/* link (OpenGraph) preview card */
899.link-preview {
900 display: flex;
901 flex-direction: column;
902 max-width: 280px;
903 background: var(--bubble-them);
904 color: var(--ink);
905 border: 1px solid var(--border);
906 border-radius: var(--radius);
907 overflow: hidden;
908 text-decoration: none;
909}
910.lp-image { width: 100%; max-height: 150px; object-fit: cover; display: block; }
911.lp-body { padding: 8px 11px; }
912.lp-title { font-weight: 600; font-size: 14px; line-height: 1.3; }
913.lp-desc {
914 font-size: 12px;
915 color: var(--muted);
916 margin-top: 2px;
917 display: -webkit-box;
918 -webkit-line-clamp: 2;
919 -webkit-box-orient: vertical;
920 overflow: hidden;
921}
922.lp-host { font-size: 11px; color: var(--muted); margin-top: 5px; }
923
924/* composer attachment preview. .composer-float is the in-flow anchor at the top
925 of the composer stack; the typeahead drop-up absolutely anchors to it. */
926.composer-float { position: relative; z-index: 5; }
927/* The staging strip occupies real layout space (the feed shrinks above it) —
928 floating it over the feed covered whatever sat at the bottom, including
929 tappable action chips ("Skip for now" became dead text under a thumbnail). */
930.composer-preview {
931 padding: 8px 16px 0;
932 display: flex;
933 flex-wrap: wrap;
934 gap: 8px;
935}
936.composer-preview.hidden { display: none; }
937.preview-spinner {
938 width: 16px; height: 16px; border-radius: 50%;
939 border: 2px solid var(--border); border-top-color: var(--accent);
940 animation: spin 0.8s linear infinite; flex: none;
941}
942.preview-chip {
943 position: relative;
944 display: inline-flex;
945 align-items: center;
946 gap: 8px;
947 /* Panel bg + border + shadow so the chip reads as a floating card over
948 whatever messages happen to be underneath it. */
949 background: var(--panel);
950 border: 1px solid var(--border);
951 box-shadow: 0 6px 20px var(--shadow);
952 border-radius: 12px;
953 padding: 6px 34px 6px 8px;
954 max-width: 100%;
955}
956.preview-chip img { width: 40px; height: 40px; object-fit: cover; border-radius: 8px; }
957.preview-file { font-size: 24px; }
958/* video upload progress chip: percentage label above a determinate bar */
959.preview-chip.preview-uploading {
960 flex-direction: column;
961 align-items: stretch;
962 gap: 6px;
963 width: 160px;
964}
965.preview-progress {
966 height: 6px;
967 border-radius: 3px;
968 background: var(--border);
969 overflow: hidden;
970}
971.preview-progress-fill {
972 height: 100%;
973 width: 0;
974 background: var(--accent);
975 border-radius: 3px;
976 transition: width 0.15s ease;
977}
978.preview-name {
979 font-size: 13px;
980 color: var(--ink);
981 overflow: hidden;
982 text-overflow: ellipsis;
983 white-space: nowrap;
984 max-width: 220px;
985}
986.preview-remove {
987 position: absolute;
988 right: 6px;
989 top: 50%;
990 transform: translateY(-50%);
991 background: var(--ink);
992 color: var(--panel);
993 width: 22px;
994 height: 22px;
995 padding: 0;
996 border-radius: 50%;
997 display: inline-flex;
998 align-items: center;
999 justify-content: center;
1000}
1001.preview-remove svg { width: 13px; height: 13px; }
1002
1003/* Action chips: one-tap buttons a helper attaches under its bubble */
1004.chip-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
1005.action-chip {
1006 background: var(--panel); color: var(--link); border: 1px solid var(--border);
1007 border-radius: 14px; padding: 7px 14px; font-size: 14px; font-weight: 600; cursor: pointer;
1008}
1009.action-chip:hover:not(:disabled) { background: var(--bubble-them); }
1010.action-chip:disabled { color: var(--muted); cursor: default; }
1011/* A spent chip (its step was answered/skipped): a quiet part of the record,
1012 not something that still looks tappable. */
1013.action-chip--spent { background: transparent; border-color: transparent; padding-left: 6px; padding-right: 6px; }
1014/* A trailing chip run (the quiet alternative path, e.g. the quick form) sits at
1015 the row's far end: auto inline-start margin on its first chip pushes it over
1016 (RTL-safe), or onto its own right-aligned line when the row wraps. */
1017.action-chip--trailing { margin-inline-start: auto; }
1018
1019/* Local notice: a command's private "only visible to you" reply — a client-only
1020 feed row (never sent, gone on reload). It lives IN the feed and PERSISTS (no
1021 auto-dismiss). It reads like a chat reply: the sender (+ AI/BOT badge) and the
1022 "Only visible to you" tag sit ABOVE, outside the bubble (like a message's name
1023 row); the reply text is a chat-style bubble. */
1024.local-notice {
1025 /* No side inset: notice bubbles share the message grid, so they line up with
1026 .msg bubbles and the typing indicator (which sit flush with the feed). */
1027 align-self: stretch; margin: 6px 0 8px;
1028 display: flex; flex-direction: column; align-items: flex-start;
1029}
1030/* A continuation in a same-speaker run: no header, tucked up under the previous
1031 bubble — the same rhythm as .msg.cont. */
1032.local-notice--cont { margin-top: -4px; }
1033.local-notice-head { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; align-self: stretch; }
1034.local-notice-glyph { font-size: 15px; line-height: 1; }
1035.local-notice-from { font-weight: 600; font-size: 14px; color: var(--ink); }
1036.local-notice-badge {
1037 font-size: 10px; font-weight: 600; letter-spacing: 0.03em; line-height: 1;
1038 padding: 2px 5px; border-radius: 6px; background: var(--accent); color: #fff;
1039}
1040.local-notice-tag { margin-left: auto; font-size: 11px; color: var(--muted); }
1041.local-notice-bubble {
1042 max-width: 90%; padding: 10px 12px; border-radius: 14px; background: var(--bubble-them);
1043}
1044.local-notice-body { font-size: 15px; color: var(--ink); white-space: pre-wrap; }
1045.local-notice-body code {
1046 font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1047 font-size: 0.92em;
1048 background: color-mix(in srgb, var(--ink) 8%, transparent);
1049 padding: 1px 5px;
1050 border-radius: 5px;
1051}
1052.local-notice-body a { color: var(--accent); }
1053/* Markdown blocks in notice bodies (#531) — same tight-list treatment as bubbles */
1054.local-notice-body p, .local-notice-body ul, .local-notice-body ol { margin: 6px 0; }
1055.local-notice-body ul, .local-notice-body ol { padding-left: 22px; }
1056.local-notice-body li p, .local-notice-body li ul, .local-notice-body li ol { margin: 0; }
1057.local-notice-body p:first-child, .local-notice-body ul:first-child, .local-notice-body ol:first-child { margin-top: 0; }
1058.local-notice-body p:last-child, .local-notice-body ul:last-child, .local-notice-body ol:last-child { margin-bottom: 0; }
1059/* Quoted machine output on a notice (a provider's error): a quotation, set off
1060 with a rule and muted — readable prose framing above, evidence below. Not
1061 monospaced: it's a quote, not code. */
1062.local-notice-quote {
1063 margin-top: 8px; padding: 6px 10px;
1064 border-left: 3px solid color-mix(in srgb, var(--muted) 45%, transparent);
1065 font-size: 13px; color: var(--muted);
1066 white-space: pre-wrap; overflow-wrap: anywhere;
1067}
1068/* Where a routed command's data landed ("for everyone in this space") — the
1069 places grammar's disclosure on the reply card itself. Muted footer, never
1070 color-only: it's the words that carry the meaning. */
1071.local-notice-place { margin-top: 6px; font-size: 12px; color: var(--muted); }
1072.local-notice--compact .lc-place { flex: 0 0 auto; font-size: 12px; color: var(--muted); }
1073.local-notice--compact .lc-place::before { content: "·"; margin-right: 5px; font-weight: 400; }
1074/* Optional preview image on a notice (in-code same-origin URLs only — e.g. the
1075 setup conversation previewing the just-uploaded server photo). Fixed box so
1076 the feed's scroll position is right BEFORE the image loads. */
1077.local-notice-img {
1078 display: block; margin-top: 8px; width: 180px; height: 180px; object-fit: cover;
1079 border-radius: 12px; border: 1px solid var(--border);
1080 background: color-mix(in srgb, var(--ink) 6%, transparent);
1081}
1082/* An avatar preview renders as the circle it will actually be. */
1083.local-notice-img--round { border-radius: 50%; object-fit: cover; }
1084/* Notices ease in like live messages (see .msg.appear). */
1085.local-notice.appear { animation: msg-appear 0.22s cubic-bezier(0.2, 0.8, 0.2, 1); }
1086@media (prefers-reduced-motion: reduce) {
1087 .local-notice.appear { animation: none; }
1088}
1089.local-notice .chip-row { margin-top: 8px; }
1090/* A chips-only notice row (the live actions under a persisted bubble): tuck it
1091 right up under the bubble it belongs to instead of floating a full row lower.
1092 (Doubled class so it outranks the base rules above regardless of order.) */
1093.local-notice.local-notice--chips { margin-top: 2px; } /* + the feed's 4px gap = a 6px breath */
1094.local-notice.local-notice--chips .chip-row { margin-top: 0; }
1095/* A confirmation is loud only for a while: after ~30s it "blinds up" (its height
1096 animates down — see collapseNotice) to this compact one-line record
1097 (glyph · who · what happened · action). */
1098.local-notice--compact {
1099 display: flex; flex-direction: row; align-items: baseline; flex-wrap: wrap; gap: 5px;
1100 margin: 2px 10px; padding: 3px 12px;
1101 background: transparent; border: none; /* shed the bubble */
1102 font-size: 13px; color: var(--muted);
1103}
1104.local-notice--compact .local-notice-glyph { font-size: 14px; align-self: center; }
1105.local-notice--compact .lc-from { font-weight: 600; color: var(--ink); }
1106.local-notice--compact .lc-summary { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1107.local-notice--compact .lc-action {
1108 flex: 0 0 auto; background: none; border: none; padding: 0; font: inherit; font-weight: 600;
1109 color: var(--link); cursor: var(--ui-cursor, pointer);
1110}
1111.local-notice--compact .lc-action::before { content: "·"; margin-right: 5px; color: var(--muted); font-weight: 400; }
1112/* App-thread action line: a centered, muted /me-style record of an interaction
1113 ("added 'milk'") — deliberately unlike a chat bubble. */
1114.chatapp-action { align-self: center; text-align: center; color: var(--muted); font-size: 13px; padding: 3px 14px; margin: 10px 0; max-width: 90%; }
1115/* The "start of your chat history" intro: a calm, centered explainer pinned at
1116 the top of a bot's chat (a persistent first row that scrolls up with history). */
1117.chatapp-intro {
1118 align-self: center; max-width: 40ch; text-align: center;
1119 color: var(--muted); font-size: 14px; line-height: 1.5; padding: 18px 16px 22px;
1120}
1121
1122/* Text selection works everywhere now: desktop reacts with a stationary hold
1123 (drag-select cancels it) and touch reacts with a double-tap, so long-press is
1124 free for native selection/copy on mobile. */
1125.reactions { display: flex; flex-wrap: wrap; gap: 4px; margin: 3px 2px 0; }
1126.msg.me .reactions { justify-content: flex-end; }
1127.reactions:empty { display: none; }
1128.reaction-chip {
1129 background: var(--bubble-them);
1130 color: var(--ink);
1131 border: 1px solid transparent;
1132 border-radius: 999px;
1133 padding: 2px 9px;
1134 font-size: 13px;
1135 font-weight: 500;
1136 line-height: 1.35;
1137}
1138.reaction-chip.mine { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 16%, var(--panel)); }
1139.emoji-picker {
1140 position: fixed;
1141 display: flex;
1142 gap: 2px;
1143 background: var(--panel);
1144 border: 1px solid var(--border);
1145 border-radius: 999px;
1146 padding: 4px 6px;
1147 box-shadow: 0 12px 40px var(--shadow);
1148 z-index: 60;
1149}
1150.emoji-opt {
1151 background: transparent;
1152 font-size: 22px;
1153 padding: 4px 6px;
1154 border-radius: 50%;
1155 line-height: 1;
1156}
1157.emoji-opt:hover { background: var(--bubble-them); }
1158/* the "Reply" action in the message menu, set off from the emoji row */
1159.picker-action {
1160 background: transparent;
1161 color: var(--link);
1162 padding: 4px 8px;
1163 margin-left: 2px;
1164 border-radius: 50%;
1165 border-left: 1px solid var(--border);
1166 display: inline-flex;
1167 align-items: center;
1168 line-height: 1;
1169}
1170.picker-action svg { width: 22px; height: 22px; }
1171.picker-action:hover { background: var(--bubble-them); }
1172
1173/* mobile long-press message menu (WhatsApp-style): a dim backdrop, the emoji
1174 reactions floating above the message, and a labelled action menu below it
1175 (see openMessageSheet in shell.js). */
1176.msg-sheet {
1177 position: fixed;
1178 inset: 0;
1179 z-index: 200;
1180 background: rgba(0, 0, 0, 0.38);
1181 animation: fade-in 0.15s ease;
1182}
1183/* the lifted copy of the pressed message, above the dim (see openMessageSheet) */
1184.msg-sheet-clone {
1185 position: fixed;
1186 margin: 0 !important;
1187 pointer-events: none;
1188}
1189.msg-sheet-reactions,
1190.msg-sheet-menu {
1191 position: absolute;
1192 background: var(--panel);
1193 border: 1px solid var(--border);
1194 box-shadow: 0 12px 40px var(--shadow);
1195}
1196.msg-sheet-reactions {
1197 display: flex;
1198 gap: 2px;
1199 padding: 5px 7px;
1200 border-radius: 999px;
1201}
1202.sheet-emoji {
1203 background: transparent;
1204 font-size: 26px;
1205 line-height: 1;
1206 padding: 6px 8px;
1207 border-radius: 50%;
1208}
1209.sheet-emoji:active { background: var(--bubble-them); }
1210.msg-sheet-menu {
1211 min-width: 200px;
1212 border-radius: 14px;
1213 overflow: hidden;
1214}
1215.msg-sheet-item {
1216 display: flex;
1217 align-items: center;
1218 justify-content: space-between;
1219 gap: 18px;
1220 width: 100%;
1221 padding: 13px 16px;
1222 background: transparent;
1223 font-size: 16px;
1224 text-align: left;
1225 color: var(--ink);
1226}
1227.msg-sheet-item:active { background: var(--bubble-them); }
1228.msg-sheet-item.danger { border: none; color: var(--danger); } /* flat row, no pill border (see .menu-item.danger) */
1229.msg-sheet-icon { display: inline-flex; color: var(--muted); flex: none; }
1230.msg-sheet-item.danger .msg-sheet-icon { color: var(--danger); }
1231.msg-sheet-icon svg { width: 20px; height: 20px; }
1232
1233/* a reply: a tappable quote of the parent sits above the bubble (à la Messages),
1234 and the parent gets a "N Replies" tag below it. */
1235.reply-quote {
1236 align-self: stretch;
1237 text-align: left;
1238 max-width: 100%;
1239 background: transparent;
1240 color: var(--muted);
1241 border: 1px solid var(--border);
1242 border-radius: 14px;
1243 padding: 6px 11px;
1244 margin-bottom: 1px;
1245 font-weight: 400;
1246 white-space: normal;
1247}
1248.msg.me .reply-quote { text-align: right; }
1249.reply-quote-name { font-size: 11px; color: var(--muted); margin-bottom: 1px; }
1250.reply-quote-text {
1251 /* two notches under the bubble text at every size (15→13, touch 17→15) */
1252 font-size: calc(var(--msg-font-size) - 2px);
1253 color: var(--muted);
1254 max-width: 220px;
1255 overflow: hidden;
1256 text-overflow: ellipsis;
1257 white-space: nowrap;
1258}
1259.msg.me .reply-quote-text { margin-left: auto; }
1260/* "N Replies" tag under a message that's been replied to (opens the thread view) */
1261.reply-count {
1262 font-size: 11px;
1263 font-weight: 600;
1264 color: var(--link);
1265 margin: 3px 6px 0;
1266 cursor: pointer;
1267}
1268.reply-count[hidden] { display: none; }
1269/* flash a message when you jump to it from a quote */
1270.msg.flash { animation: flash-msg 1.2s ease; border-radius: var(--radius); }
1271@keyframes flash-msg {
1272 0%, 100% { background: transparent; }
1273 25% { background: color-mix(in srgb, var(--accent) 16%, transparent); }
1274}
1275/* Search landing (searchui.js): the found bubble gives a little wave — a
1276 gentle rock in place, no lift (a lift exposes .flash's row tint beneath
1277 and reads as a stray shadow). Pivot at the bubble's foot so it waves
1278 rather than spins. */
1279.msg.search-wave .bubble {
1280 animation: search-wave 0.85s ease-in-out;
1281 transform-origin: 50% 100%;
1282}
1283@keyframes search-wave {
1284 0%, 100% { transform: none; }
1285 20% { transform: rotate(-2.5deg); }
1286 45% { transform: rotate(2.5deg); }
1287 70% { transform: rotate(-1.5deg); }
1288 85% { transform: rotate(0.5deg); }
1289}
1290/* Reduced motion: no movement — the dashed ring the wave replaced, fading
1291 away on its own. (After the base rule so it wins on equal specificity.) */
1292@media (prefers-reduced-motion: reduce) {
1293 .msg.search-wave .bubble { animation: search-found 2s ease forwards; }
1294 @keyframes search-found {
1295 0%, 50% { outline: 2px dashed var(--accent); outline-offset: 3px; }
1296 100% { outline: 2px dashed transparent; outline-offset: 3px; }
1297 }
1298}
1299/* ---- reply-thread view (apps/chat.js): tap a quote or a "N Replies" tally and
1300 the thread lifts over the transcript, iMessage-style — the root pinned on
1301 top, its replies below, the real composer beneath staged to the root. The
1302 overlay lives inside .chatapp-feedwrap, so it covers exactly the feed's box
1303 and never the composer. All colors are theme vars → light/dark for free. */
1304.chatapp-thread { position: absolute; inset: 0; z-index: 30; display: flex; }
1305.chatapp-thread-dim {
1306 position: absolute;
1307 inset: 0;
1308 /* lighter than the msg-sheet dim: the frosted panel reads the transcript
1309 through itself, so the backdrop only needs to recede, not vanish */
1310 background: rgba(0, 0, 0, 0.25);
1311 animation: fade-in 0.15s ease;
1312}
1313.chatapp-thread-panel {
1314 position: relative; /* above the dim */
1315 margin: 10px;
1316 flex: 1;
1317 min-height: 0;
1318 min-width: 0;
1319 display: flex;
1320 flex-direction: column;
1321 background: var(--panel);
1322 border: 1px solid var(--border);
1323 border-radius: 16px;
1324 box-shadow: 0 12px 40px var(--shadow);
1325 overflow: hidden;
1326 animation: thread-rise 0.18s cubic-bezier(0.2, 0.8, 0.2, 1);
1327}
1328@keyframes thread-rise { from { opacity: 0; transform: translateY(12px); } }
1329/* Frosted glass: the conversation stays visible under the lifted thread — a
1330 translucent panel over a backdrop blur, both themes via --panel. The solid
1331 var(--panel) above is the fallback where backdrop-filter/color-mix aren't
1332 supported (a see-through panel with NO blur would be unreadable). */
1333@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) and (background: color-mix(in srgb, red 50%, transparent)) {
1334 .chatapp-thread-panel {
1335 background: color-mix(in srgb, var(--panel) 72%, transparent);
1336 -webkit-backdrop-filter: blur(18px) saturate(1.15);
1337 backdrop-filter: blur(18px) saturate(1.15);
1338 }
1339}
1340@media (prefers-reduced-motion: reduce) {
1341 .chatapp-thread-dim,
1342 .chatapp-thread-panel { animation: none; }
1343}
1344/* embedded in the native shells: keep the panel clear of the notch/home bar */
1345.native-embed .chatapp-thread-panel {
1346 margin-top: calc(10px + var(--native-top-inset, 0px));
1347 margin-bottom: calc(10px + var(--native-bottom-inset, 0px));
1348}
1349.chatapp-thread-head {
1350 display: flex;
1351 align-items: baseline;
1352 gap: 8px;
1353 padding: 12px 14px 10px;
1354 border-bottom: 1px solid var(--border);
1355}
1356.chatapp-thread-title { font-weight: 700; font-size: 15px; color: var(--ink); }
1357.chatapp-thread-count { flex: 1; min-width: 0; font-size: 12px; color: var(--muted); }
1358.chatapp-thread-x {
1359 flex: none;
1360 align-self: center;
1361 width: 28px;
1362 height: 28px;
1363 padding: 0;
1364 border-radius: 50%;
1365 background: transparent;
1366 color: var(--muted);
1367 display: inline-flex;
1368 align-items: center;
1369 justify-content: center;
1370}
1371.chatapp-thread-x svg { width: 16px; height: 16px; }
1372.chatapp-thread-x:hover { background: var(--bubble-them); }
1373/* the pinned root message — capped so a long one can't swallow the replies */
1374.chatapp-thread-rootmsg {
1375 flex: none;
1376 max-height: 40%;
1377 overflow-y: auto;
1378 overflow-x: hidden;
1379 padding: 12px 16px 10px;
1380 border-bottom: 1px solid var(--border);
1381 display: flex;
1382 flex-direction: column;
1383}
1384/* the quoted snapshot standing in for a root that isn't loaded */
1385.chatapp-thread-rootfall { border-left: 2px solid var(--accent); padding-left: 10px; }
1386.chatapp-thread-fallsnippet { font-size: var(--msg-font-size); color: var(--ink); overflow-wrap: anywhere; }
1387.chatapp-thread-note {
1388 padding: 8px 16px;
1389 font-size: 12px;
1390 color: var(--muted);
1391 border-bottom: 1px solid var(--border);
1392}
1393.chatapp-thread-note.hidden { display: none; }
1394.chatapp-thread-msgs {
1395 flex: 1;
1396 min-height: 0;
1397 overflow-y: auto;
1398 overflow-x: hidden; /* clip mid swipe-to-reply, like the feed */
1399 padding: 12px 16px 16px;
1400 display: flex;
1401 flex-direction: column;
1402 gap: 4px;
1403}
1404/* inside the view, every row is already the thread — a per-row quote card or
1405 tally would only point back at where you're standing */
1406.chatapp-thread .reply-quote,
1407.chatapp-thread .reply-count { display: none; }
1408
1409/* composer "Replying to …" / "Editing message" banners, above the composer
1410 (and any attachment preview). They share the .reply-bar inner markup. */
1411.composer-reply,
1412.composer-edit {
1413 padding: 8px 14px;
1414 border-top: 1px solid var(--border);
1415 background: var(--panel);
1416}
1417.composer-reply.hidden,
1418.composer-edit.hidden { display: none; }
1419.composer-edit { display: flex; align-items: center; justify-content: space-between; color: var(--muted); font-size: 14px; }
1420.banner-x { background: transparent; color: var(--muted); font-size: 20px; line-height: 1; padding: 0 6px; }
1421.reply-bar { display: flex; align-items: center; gap: 10px; }
1422.reply-bar-icon { flex: none; color: var(--link); display: inline-flex; }
1423.reply-bar-icon svg { width: 18px; height: 18px; }
1424.reply-bar-body {
1425 flex: 1;
1426 min-width: 0;
1427 border-left: 2px solid var(--accent);
1428 padding-left: 10px;
1429}
1430.reply-bar-name { font-size: 12px; font-weight: 600; color: var(--ink); }
1431.reply-bar-text {
1432 font-size: 13px;
1433 color: var(--muted);
1434 overflow: hidden;
1435 text-overflow: ellipsis;
1436 white-space: nowrap;
1437}
1438.reply-bar-cancel {
1439 flex: none;
1440 width: 26px;
1441 height: 26px;
1442 padding: 0;
1443 border-radius: 50%;
1444 background: transparent;
1445 color: var(--muted);
1446 display: inline-flex;
1447 align-items: center;
1448 justify-content: center;
1449}
1450.reply-bar-cancel svg { width: 15px; height: 15px; }
1451.reply-bar-cancel:hover { background: var(--bubble-them); }
1452
1453/* Subtle "… is typing" line. It renders INSIDE the feed, below the newest
1454 message (shell.js appends/removes it), not in a band between feed and
1455 composer: a band resized the feed as it toggled — jolting the stream — and
1456 the native shells float their composer over the page bottom, which covered
1457 it entirely. In-stream it scrolls with the messages, like an incoming one. */
1458.typing {
1459 align-self: flex-start;
1460 max-width: 78%;
1461 margin-top: 10px;
1462 padding: 0 6px;
1463 font-size: 12px;
1464 font-style: italic;
1465 color: var(--muted);
1466 overflow: hidden;
1467 text-overflow: ellipsis;
1468 white-space: nowrap;
1469 /* Make the row behave like an incoming .msg — the feed is a flex column, so a
1470 bare row misbehaves two ways. flex-shrink:0: with overflow:hidden a flex
1471 item's auto min-height is 0, so the feed (once it overflows) shrinks this
1472 row to nothing — the group "X is typing…" line was present but 0px tall.
1473 position:relative: like .msg, it makes the row its own containing block so
1474 the bubble's overflow:visible (the dots bobbing out of the box) can't
1475 propagate up and give the whole app a second scrollbar. */
1476 flex-shrink: 0;
1477 position: relative;
1478}
1479/* DM variant: an iMessage-style incoming bubble with pulsing dots — the typer
1480 can only be the one other person, so no name (groups keep the line above). */
1481.typing.typing-bubble {
1482 display: flex;
1483 align-items: center;
1484 gap: 3px;
1485 padding: 13px 14px;
1486 border-radius: var(--radius);
1487 border-bottom-left-radius: 6px;
1488 background: var(--bubble-them);
1489 overflow: visible; /* the dots bob upward, out of the content box */
1490}
1491.typing-bubble .dot {
1492 width: 8px;
1493 height: 8px;
1494 border-radius: 50%;
1495 background: color-mix(in srgb, var(--ink) 45%, transparent);
1496 animation: typing-dot 1.3s ease-in-out infinite;
1497}
1498.typing-bubble .dot:nth-child(2) { animation-delay: 0.18s; }
1499.typing-bubble .dot:nth-child(3) { animation-delay: 0.36s; }
1500@keyframes typing-dot {
1501 0%, 60%, 100% { opacity: 0.35; transform: translateY(0); }
1502 30% { opacity: 1; transform: translateY(-3px); }
1503}
1504@media (prefers-reduced-motion: reduce) {
1505 .typing-bubble .dot { animation: none; opacity: 0.5; }
1506}
1507/* screen-reader-only text (the bubble's dots carry no words) */
1508.sr-only {
1509 position: absolute;
1510 width: 1px;
1511 height: 1px;
1512 overflow: hidden;
1513 clip-path: inset(50%);
1514 white-space: nowrap;
1515}
1516
1517/* composer autocomplete (:emoji / @mention / slash) — a drop-UP that OVERLAYS the
1518 feed rather than a flex band that shoves the messages up as you type. It's
1519 anchored to .composer-float (the zero-height anchor at the top of the composer
1520 stack), so it floats over the messages and rises with any reply/edit banner. */
1521.typeahead {
1522 position: absolute;
1523 bottom: 6px;
1524 left: 8px;
1525 right: 8px;
1526 max-height: 216px;
1527 overflow-y: auto;
1528 border: 1px solid var(--border);
1529 border-radius: 14px;
1530 background: var(--panel);
1531 box-shadow: var(--shadow);
1532 /* Clip the full-bleed active row to the rounded corners (overflow-y already
1533 makes this a clipping container). */
1534 overflow-x: hidden;
1535}
1536.typeahead.hidden { display: none; }
1537.ta-item {
1538 display: flex;
1539 align-items: center;
1540 gap: 10px;
1541 width: 100%;
1542 text-align: left;
1543 background: transparent;
1544 color: var(--ink);
1545 border: none;
1546 border-radius: 0; /* override the global pill button radius — the active row fills edge to edge */
1547 padding: 8px 12px;
1548 font-size: 15px;
1549}
1550.ta-item.active { background: var(--bubble-them); }
1551.ta-item .avatar { width: 28px; height: 28px; font-size: 13px; flex: none; }
1552.ta-emoji { font-size: 20px; width: 24px; text-align: center; flex: none; }
1553.ta-code { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1554.ta-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1555/* Command typeahead: the "/todo" name, then a muted one-line description after it. */
1556.ta-desc { color: var(--muted); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1557/* an @mention inside a message — just bold; it inherits the bubble ink, so it
1558 stays legible in both the grey "them" and accent "me" bubbles. */
1559.mention { font-weight: 600; }
1560.mention-link { cursor: pointer; }
1561.mention-link:hover { text-decoration: underline; text-underline-offset: 2px; }
1562
1563.composer {
1564 display: flex;
1565 gap: 8px;
1566 align-items: flex-end;
1567 /* Desktop (fine pointer): the input hugs the bottom — env() is 0, so no fixed
1568 bottom padding. Touch devices get breathing room + the safe-area inset below
1569 (see the pointer:coarse rule), which desktop never picks up. */
1570 padding: 12px 16px;
1571 padding-bottom: env(safe-area-inset-bottom, 0px);
1572 background: var(--panel);
1573}
1574/* Touch: 12px of breathing room ABOVE the Home indicator / swipe bar (the inset),
1575 so the input never sits flush against the bottom edge or a browser toolbar.
1576 Scoped to coarse pointers so desktop stays at the bare inset (≈0). */
1577@media (pointer: coarse) {
1578 .composer { padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px)); }
1579 /* While typing, the keyboard covers the home-indicator inset, so drop it entirely
1580 (the composer sits directly on the keyboard) — .editing is toggled on focus. */
1581 .composer.editing { padding-bottom: 0; }
1582 /* Long-press opens the message menu, so suppress the native selection/callout that
1583 would otherwise fight it (Copy lives in the menu instead). */
1584 .feed .bubble, .chatapp-feed .bubble, .chatapp-thread .bubble { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
1585 /* A phone is held further from the eye than a laptop screen sits — bump the
1586 core reading + typing text up a notch so messages are comfortable. Desktop
1587 (fine pointer) keeps its sizes. Composer stays ≥16px so iOS doesn't
1588 auto-zoom the page when the field is focused. */
1589 :root { --msg-font-size: 17px; }
1590 /* iOS/iPadOS honour the system Text Size (Dynamic Type): -apple-system-body
1591 tracks it, so a member who's set large type gets large messages. Browsers
1592 without the keyword (Android) drop the whole shorthand and keep the 17px
1593 variable from the base rule; the two declarations after it restore what
1594 the shorthand resets. Higher-specificity sizes (.jumbo-emoji) still win. */
1595 .msg .bubble { font: -apple-system-body; font-family: inherit; line-height: 1.35; }
1596 .composer .composer-input { font-size: 17px; }
1597 .thread-row-name { font-size: 17px; }
1598 .thread-row-preview { font-size: 14px; }
1599}
1600.composer-field {
1601 position: relative;
1602 flex: 1;
1603 display: flex;
1604}
1605/* The inset field label ("Your name:") — the Mail/Messages "To:" pattern: a
1606 quiet accent pill pinned INSIDE the input on its first line; chat.js indents
1607 the textarea's text past it. Absolute (over the textarea, which owns the box
1608 visuals) and inert to the pointer; top 9px centres it in the 40px rest
1609 height and keeps it on line one as the box grows. */
1610.composer-label {
1611 position: absolute;
1612 /* Above the input: the rich composer's contenteditable is position:relative
1613 (unlike the classic textarea), so without a z-index its opaque pill paints
1614 over this label in DOM order. */
1615 z-index: 1;
1616 left: 12px;
1617 top: 9px;
1618 padding: 1px 9px;
1619 border-radius: 999px;
1620 /* The theme's designed on-accent pairing — the one combination every theme
1621 guarantees AA contrast for (a tinted-text pill fails on amber). */
1622 background: var(--accent);
1623 color: var(--accent-ink);
1624 font-size: 12.5px;
1625 font-weight: 600;
1626 line-height: 20px;
1627 white-space: nowrap;
1628 pointer-events: none;
1629 opacity: 1;
1630 transition: opacity 120ms ease;
1631}
1632.composer-label[hidden] {
1633 display: block; /* keep it measurable/animatable; hidden = faded out */
1634 opacity: 0;
1635}
1636@media (prefers-reduced-motion: reduce) {
1637 .composer-label { transition: none; }
1638}
1639/* The labelled ask's dismiss X (#1375): sits in the send button's slot while
1640 that's free — same geometry, quiet ink instead of accent fill. Focusable,
1641 ≥24px target (34px box, and the same rightward tap-target extension as
1642 Send would be overkill: the X is a secondary action). */
1643.composer-label-x {
1644 position: absolute;
1645 right: 4px;
1646 bottom: 3px;
1647 width: 34px;
1648 height: 34px;
1649 padding: 0;
1650 border-radius: 50%;
1651 display: inline-flex;
1652 align-items: center;
1653 justify-content: center;
1654 border: 0;
1655 background: none;
1656 color: inherit;
1657 opacity: 0.55;
1658 cursor: pointer;
1659}
1660.composer-label-x:hover,
1661.composer-label-x:focus-visible {
1662 opacity: 1;
1663 background: var(--input-bg);
1664}
1665.composer-label-x[hidden] { display: none; }
1666.composer .composer-input {
1667 flex: 1;
1668 resize: none;
1669 max-height: 140px;
1670 /* 8+8 padding + 22 line + 2 border = 40px at rest. Symmetric so the text is
1671 vertically centred in the box; right padding leaves room for the send button.
1672 Type at the reading size; past 16px the 22px line would get cramped, so the
1673 line grows with the font from there (and the box grows with it). */
1674 padding: 8px 46px 8px 14px;
1675 font-size: var(--msg-font-size);
1676 line-height: max(22px, 1.35em);
1677 min-height: 40px;
1678 border-radius: 20px;
1679 font-family: inherit;
1680 /* Autogrow caps at max-height (see the JS); past that the field must scroll its
1681 own overflow rather than swallow the earlier lines — so overflow-y is auto,
1682 not hidden. overflow-x stays hidden since the text wraps. */
1683 overflow-x: hidden;
1684 overflow-y: auto;
1685 /* The shared lifted-panel input fill (see --input-bg / .eleven-input). */
1686 background: var(--input-bg);
1687}
1688/* The rich composer (composer-pm.js): a ProseMirror contenteditable wearing
1689 the same skin. Auto-grow is pure CSS here — min/max-height + overflow — so
1690 there is no JS measuring to go stale (the lesson from the reverted native
1691 attempt). The textarea's implicit border comes back explicitly. */
1692.composer .composer-input[contenteditable] {
1693 position: relative;
1694 border: 1px solid var(--border);
1695 white-space: pre-wrap;
1696 overflow-wrap: break-word;
1697 color: var(--ink);
1698 caret-color: var(--accent);
1699}
1700.composer .composer-input[contenteditable] p { margin: 0; }
1701/* In-composer blocks: the bubble treatments at composer scale. */
1702.composer .composer-input[contenteditable] ul,
1703.composer .composer-input[contenteditable] ol { margin: 2px 0; padding-left: 22px; }
1704.composer .composer-input[contenteditable] blockquote {
1705 margin: 2px 0;
1706 padding: 1px 0 1px 10px;
1707 border-left: 3px solid color-mix(in srgb, var(--ink) 30%, transparent);
1708 color: color-mix(in srgb, var(--ink) 72%, transparent);
1709}
1710.composer .composer-input[contenteditable] pre {
1711 margin: 2px 0;
1712 padding: 6px 8px;
1713 border-radius: 8px;
1714 background: color-mix(in srgb, var(--ink) 8%, transparent);
1715 font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1716 font-size: 0.92em;
1717 overflow-x: auto;
1718 white-space: pre;
1719}
1720.composer .composer-input[contenteditable] code {
1721 font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1722 font-size: 0.92em;
1723 background: color-mix(in srgb, var(--ink) 8%, transparent);
1724 padding: 1px 5px;
1725 border-radius: 5px;
1726}
1727.composer .composer-input[contenteditable] pre code { background: none; padding: 0; font-size: 1em; }
1728/* Placeholder: contenteditable has no native one — the pm-empty class is
1729 maintained by the backend; float keeps the caret on the first line. */
1730/* Theme-sheet composer preference rows (#531) */
1731.composer-pref-row {
1732 display: flex;
1733 align-items: center;
1734 gap: 10px;
1735 margin: 8px 0;
1736 font-size: 14px;
1737 color: var(--ink);
1738 cursor: pointer;
1739}
1740.composer-pref-row input[type="checkbox"] { accent-color: var(--accent); width: 18px; height: 18px; margin: 0; }
1741.composer-pref-hint { margin: -4px 0 8px 28px; font-size: 12px; color: var(--muted); }
1742.composer .composer-input.pm-empty::before {
1743 content: attr(data-placeholder);
1744 color: var(--muted);
1745 float: left;
1746 height: 0;
1747 pointer-events: none;
1748}
1749
1750/* eleven-input — the reusable text-input surface: the same lifted-panel fill the
1751 chat composer uses, so inputs across the app and its mini-apps read as one.
1752 Layer your own shape (radius override, min-height, monospace, …) on top. */
1753.eleven-input {
1754 background: var(--input-bg);
1755 color: var(--ink);
1756 border: 1px solid var(--border);
1757 border-radius: var(--radius);
1758 font-family: inherit;
1759}
1760.eleven-input:focus { outline: none; border-color: var(--accent); }
1761/* circular up-arrow send button, tucked into the bottom-right of the field */
1762.send-btn {
1763 position: absolute;
1764 right: 4px;
1765 bottom: 3px;
1766 width: 34px;
1767 height: 34px;
1768 padding: 0;
1769 border-radius: 50%;
1770 display: inline-flex;
1771 align-items: center;
1772 justify-content: center;
1773 background: var(--accent);
1774 color: var(--accent-ink);
1775}
1776.send-btn[hidden] { display: none; }
1777.send-btn:disabled { opacity: 0.45; cursor: default; }
1778/* A send queued behind an in-flight upload (Enter mid-upload — see chat.js
1779 sendQueued): swap the arrow for a spinner, at full strength so it reads as
1780 "working", not the greyed can't-send state. */
1781.send-btn.send-waiting svg { display: none; }
1782.send-btn.send-waiting:disabled { opacity: 1; }
1783.send-btn.send-waiting::before {
1784 content: "";
1785 width: 16px; height: 16px; border-radius: 50%;
1786 border: 2px solid color-mix(in srgb, var(--accent-ink) 35%, transparent);
1787 border-top-color: var(--accent-ink);
1788 animation: spin 0.8s linear infinite; /* @keyframes spin, top of file */
1789}
1790/* Enlarge the send button's tap target — mostly to the right, into the composer's
1791 padding — so a near-miss on touch triggers Send instead of falling through to the
1792 field/feed and dismissing the keyboard. */
1793.send-btn::after {
1794 content: "";
1795 position: absolute;
1796 top: -10px;
1797 right: -20px;
1798 bottom: -10px;
1799 left: -6px;
1800}
1801/* left-side attach (+) button */
1802.attach-btn {
1803 flex: none;
1804 width: 40px;
1805 height: 40px;
1806 padding: 0;
1807 border-radius: 50%;
1808 display: inline-flex;
1809 align-items: center;
1810 justify-content: center;
1811 background: var(--bubble-them);
1812 color: var(--ink);
1813 font-size: 24px;
1814 line-height: 1;
1815}
1816
1817
1818/* invite QR (admin) — full-screen opaque takeover so nothing else is on screen */
1819.qr-screen {
1820 position: fixed;
1821 inset: 0;
1822 z-index: 50;
1823 background: var(--bg);
1824 overflow: auto;
1825}
1826.qr-modal {
1827 margin: 8vh auto;
1828 max-width: 360px;
1829 text-align: center;
1830 border: none;
1831 box-shadow: none;
1832}
1833.qr-modal h2 { margin: 0 0 6px; font-size: 20px; }
1834.qr-wrap {
1835 display: flex;
1836 justify-content: center;
1837 margin: 18px 0;
1838}
1839.qr-wrap canvas {
1840 max-width: 100%;
1841 height: auto;
1842 image-rendering: pixelated; /* keep modules crisp when CSS scales the canvas down */
1843 border-radius: 8px;
1844}
1845.qr-modal .row { justify-content: center; }
1846
1847/* input + button on one row (e.g. Create invite) */
1848.inline-form {
1849 display: flex;
1850 gap: 10px;
1851 align-items: stretch;
1852}
1853.inline-form input {
1854 flex: 1;
1855 min-width: 0;
1856}
1857
1858.banner {
1859 text-align: center;
1860 font-size: 13px;
1861 color: var(--muted);
1862 padding: 6px;
1863}
1864.banner.warn { color: var(--warn); }
1865
1866
1867/* ---- admin ---- */
1868.admin { max-width: 760px; margin: 0 auto; padding: 24px; }
1869.admin h1 { margin: 0 0 4px; }
1870.admin .sub { color: var(--muted); margin: 0 0 24px; }
1871.admin section {
1872 background: var(--panel);
1873 border: 1px solid var(--border);
1874 border-radius: 18px;
1875 padding: 18px;
1876 margin-bottom: 18px;
1877}
1878.admin section h2 { margin: 0 0 12px; font-size: 16px; }
1879.invite {
1880 display: flex;
1881 align-items: center;
1882 gap: 10px;
1883 padding: 12px 0;
1884 border-top: 1px solid var(--border);
1885}
1886.invite:first-of-type { border-top: none; }
1887.invite .meta { flex: 1; min-width: 0; }
1888.invite .meta .label { font-weight: 600; }
1889.invite .meta .status { font-size: 12px; color: var(--muted); }
1890.invite .device-note { flex: none; max-width: 220px; font-size: 12px; color: var(--muted); text-align: right; }
1891/* custom-app rows: the name is an identifier, so it's mono — but row-title sized,
1892 not .mono's 12px */
1893.invite .label.mono { font-size: 14px; }
1894.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
1895.key-box {
1896 background: var(--bubble-them);
1897 border-radius: 12px;
1898 padding: 12px;
1899 word-break: break-all;
1900 margin: 10px 0;
1901}
1902.hidden { display: none !important; }
1903.toast {
1904 position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%);
1905 background: var(--pill-bg); color: var(--pill-ink); padding: 10px 16px; border-radius: 999px;
1906 font-size: 14px; opacity: 0; transition: opacity .2s; pointer-events: none;
1907 z-index: 100; /* above full-screen overlays (qr-screen) so it's never hidden */
1908}
1909.toast.show { opacity: 1; }
1910
1911
1912/* full-screen onboarding cards */
1913.card.onboard { text-align: center; }
1914.card.onboard .row { justify-content: center; }
1915.card.onboard p { margin-bottom: 14px; }
1916.onboard-icon { font-size: 46px; line-height: 1; margin-bottom: 10px; }
1917/* the instance branding photo, when set — replaces the emoji on onboarding cards */
1918.onboard-photo {
1919 width: 72px; height: 72px; border-radius: 18px; object-fit: cover;
1920 margin: 0 auto 12px; display: block;
1921}
1922/* the browser-only caveat on the welcome card: present but quiet, with clear
1923 air between it and the button it annotates. (.card selectors so this outranks
1924 the generic .card p margin rule.) */
1925.card .welcome-caveat { font-size: 13px; color: var(--muted); margin: 20px auto 4px; max-width: 42ch; }
1926/* A quiet escape hatch on the onboarding cards (e.g. the prejoin card's
1927 "Continue without signing in"): small print that acts, not a competing
1928 button — a real <button> so it stays keyboard-operable and focusable. */
1929button.link-quiet {
1930 background: none;
1931 color: var(--muted);
1932 font-size: 13px;
1933 font-weight: 400;
1934 text-decoration: underline;
1935 padding: 6px 8px; /* keeps the tap target ≥24px despite the small type */
1936}
1937/* the space's own description on the welcome card: the admin's note to the
1938 person joining — content to read, not chrome. A tinted panel with
1939 full-strength text sets it apart from the muted boilerplate around it. */
1940.card .welcome-desc {
1941 font-size: 15px; color: var(--ink); text-align: left; line-height: 1.5;
1942 background: color-mix(in srgb, var(--accent) 8%, transparent);
1943 border-radius: 12px; padding: 12px 16px; margin: 2px auto 18px; max-width: 46ch;
1944 white-space: pre-wrap; overflow-wrap: anywhere;
1945}
1946.steps {
1947 text-align: left;
1948 color: var(--muted);
1949 line-height: 1.6;
1950 padding-left: 20px;
1951 margin: 4px 0 18px;
1952}
1953.steps li { margin-bottom: 6px; }
1954.steps strong { color: var(--ink); }
1955
1956/* admin: a labelled checkbox row (e.g. "let members invite people") */
1957.toggle-row { display: flex; align-items: center; gap: 10px; cursor: pointer; font-weight: 600; }
1958/* profile sheet: the Away toggle + its inline error line (never a toast) */
1959.away-row { margin-top: 14px; font-size: 14px; justify-content: space-between; }
1960.away-note { color: var(--muted); font-size: 13px; min-height: 1em; margin: 4px 0 0; }
1961/* A slider switch (settings idiom) — a styled checkbox, so behaviour/forms
1962 stay stock. input.switch outranks the generic .toggle-row input sizing. */
1963input.switch {
1964 -webkit-appearance: none; appearance: none; margin: 0; flex: none;
1965 width: 42px; height: 26px; border-radius: 13px; position: relative;
1966 background: var(--bubble-them); cursor: pointer; transition: background 0.15s;
1967}
1968input.switch::after {
1969 content: ""; position: absolute; top: 2px; left: 2px; width: 22px; height: 22px;
1970 border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
1971 transition: left 0.15s;
1972}
1973input.switch:checked { background: var(--accent); }
1974input.switch:checked::after { left: 18px; }
1975input.switch:disabled { opacity: 0.5; cursor: default; }
1976/* BOT badge on admin People rows (shell.js has its own inline-styled agentTag) */
1977.bot-tag {
1978 margin-left: 6px; font-size: 0.7em; font-weight: 600; letter-spacing: 0.03em;
1979 padding: 1px 5px; border-radius: 6px; background: var(--accent, #888); color: #fff; vertical-align: middle;
1980}
1981.toggle-row input { width: 18px; height: 18px; }
1982.toggle-row input.switch { width: 42px; height: 26px; } /* the switch keeps its own size */
1983
1984/* pending DM: waiting for an invited person to join (web/static/shell.js). While
1985 waiting there's no one to talk to, so the feed + composer are hidden and a single
1986 centered card fills the pane. It clears once the invitee joins and chats.
1987 .app-pane is included so the chat mini-app's full-pane feed/composer (which
1988 mount there instead of .feed/.composer) get the same treatment. */
1989.thread-view.waiting .feed,
1990.thread-view.waiting .app-pane,
1991.thread-view.waiting .composer,
1992.thread-view.waiting .composer-reply,
1993.thread-view.waiting .composer-edit,
1994.thread-view.waiting .composer-preview,
1995.thread-view.waiting .typing { display: none !important; }
1996.pending-banner {
1997 flex: 1;
1998 min-height: 0;
1999 display: flex;
2000 align-items: center;
2001 justify-content: center;
2002 padding: 24px;
2003 overflow-y: auto;
2004}
2005.pending-card {
2006 width: 100%;
2007 max-width: 580px; /* wide enough that a typical invite link fits on one line */
2008 text-align: center;
2009 padding: 28px 24px;
2010 border: 1px solid var(--border);
2011 border-radius: 24px; /* the onboarding-card radius — same family as join */
2012 background: var(--bubble-them);
2013}
2014.pending-card .pending-avatar { margin: 0 auto 4px; }
2015.pending-card .pending-title {
2016 font-family: var(--font-display);
2017 font-weight: 700;
2018 font-size: 20px;
2019 margin: 8px 0 6px;
2020}
2021.pending-card p { margin: 0 0 16px; font-size: 15px; color: var(--muted); }
2022/* The link well hugs the link (centered), so it wraps only when the pane is
2023 genuinely too narrow for one line. */
2024.pending-card .key-box {
2025 width: fit-content; max-width: 100%;
2026 margin: 0 auto 16px; text-align: left; background: var(--panel);
2027}
2028/* display: flex is load-bearing: this card is .pending-card, not .card, so it
2029 never inherits .card .row's flex — without it the gap and centering are
2030 no-ops and the buttons sit flush. */
2031.pending-card .row { display: flex; justify-content: center; gap: 10px; flex-wrap: wrap; }
2032.linkish.danger { color: var(--danger); }
2033
2034/* Per-viewer names (#577): a DM partner renamed themself while you hold your
2035 own name for them. A slim inline row between header and feed — it occupies
2036 layout space (never floats over content) and clears once you choose. The
2037 choices wear the action-chip idiom (the app's one-tap-decision shape, same
2038 as a helper's chips), so "the app is asking you something" reads instantly;
2039 the two NAMES carry the emphasis, the frame stays quiet. */
2040.rename-banner {
2041 flex: none;
2042 display: flex;
2043 align-items: center;
2044 justify-content: center;
2045 gap: 8px;
2046 flex-wrap: wrap;
2047 padding: 8px 12px;
2048 font-size: 14px;
2049 color: var(--ink);
2050 background: var(--panel);
2051 border-bottom: 1px solid var(--border);
2052}
2053.rename-banner .rename-msg { margin-right: 2px; }
2054.rename-name { font-weight: 600; }
2055.rename-banner .action-chip:focus-visible,
2056.rename-note .action-chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
2057/* The same choice on a profile card (the group-chat path to it) — identical
2058 chips, so both surfaces of the one decision look like the one decision. */
2059.rename-note {
2060 display: flex;
2061 align-items: center;
2062 justify-content: center;
2063 gap: 8px;
2064 flex-wrap: wrap;
2065 margin: 2px 0 8px;
2066 font-size: 14px;
2067 color: var(--ink);
2068}
2069/* The profile card's "what I call them" block: the Goes-by fact, the rename
2070 entry point, and the inline editor. */
2071.nick-area { text-align: center; margin: 2px 0 4px; }
2072.nick-area .nick-input {
2073 padding: 8px 12px;
2074 width: 240px;
2075 max-width: 100%;
2076 text-align: center;
2077 font-size: 15px;
2078}
2079.nick-area .row { display: flex; justify-content: center; gap: 10px; margin-top: 8px; }
2080
2081/* ---- multi-thread layout (docs/threads-and-dms.md) ---- */
2082.tt { display: flex; flex-direction: column; height: 100%; width: 100%; background: var(--panel); }
2083/* The sidebar + thread row lives below the (usually absent) integrity banner. */
2084.tt-body { display: flex; flex: 1; min-height: 0; width: 100%; }
2085
2086/* Tamper-evidence failure banner: a full-width band across the top of the whole
2087 shell, shown ONLY on an unresolved integrity alert (fork/rollback/withheld/
2088 bad-signature). Amber, with a ⚠️ glyph so it's never colour-alone; occupies
2089 layout (pushes the shell down), never a floating overlay. */
2090.integrity-banner {
2091 flex: none;
2092 display: flex;
2093 align-items: flex-start;
2094 gap: 10px;
2095 padding: 12px 16px calc(12px + env(safe-area-inset-top, 0px));
2096 background: color-mix(in srgb, #d97706 16%, var(--panel));
2097 border-bottom: 1px solid color-mix(in srgb, #d97706 45%, transparent);
2098 color: var(--ink);
2099 font-size: 13.5px;
2100 line-height: 1.45;
2101}
2102.integrity-banner.hidden { display: none; }
2103.integrity-banner .ib-icon { font-size: 18px; line-height: 1.2; flex: none; }
2104.integrity-banner .ib-text { flex: 1; min-width: 0; }
2105.integrity-banner .ib-title { font-weight: 700; }
2106.integrity-banner .ib-actions { display: flex; gap: 8px; flex: none; }
2107.integrity-banner button {
2108 font: inherit;
2109 font-weight: 600;
2110 border-radius: 8px;
2111 padding: 6px 12px;
2112 min-height: 32px;
2113 cursor: pointer;
2114 border: 1px solid color-mix(in srgb, #d97706 55%, transparent);
2115 background: transparent;
2116 color: var(--ink);
2117}
2118.integrity-banner button.ib-primary {
2119 background: color-mix(in srgb, #d97706 85%, black 5%);
2120 color: #fff;
2121 border-color: transparent;
2122}
2123.integrity-banner button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
2124@media (prefers-color-scheme: dark) {
2125 .integrity-banner { background: color-mix(in srgb, #f59e0b 22%, var(--panel)); }
2126}
2127@media (max-width: 640px) {
2128 .integrity-banner { flex-wrap: wrap; }
2129 .integrity-banner .ib-actions { width: 100%; }
2130}
2131
2132/* Demo countdown band: same full-width top-band idiom as the integrity banner,
2133 but informational, not an alarm — accent-tinted while the demo runs, amber
2134 only once it has ended. Occupies layout, never overlays. */
2135.demo-banner {
2136 flex: none;
2137 display: flex;
2138 align-items: center;
2139 justify-content: center;
2140 flex-wrap: wrap;
2141 gap: 8px 14px;
2142 padding: 9px 16px;
2143 padding-top: calc(9px + env(safe-area-inset-top, 0px));
2144 background: color-mix(in srgb, var(--accent) 12%, var(--panel));
2145 border-bottom: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
2146 color: var(--ink);
2147 font-size: 13.5px;
2148 line-height: 1.45;
2149 text-align: center;
2150}
2151.demo-banner.hidden { display: none; }
2152.demo-banner .demo-banner-text { font-variant-numeric: tabular-nums; }
2153.demo-banner .demo-banner-keep {
2154 font: inherit;
2155 font-weight: 600;
2156 border-radius: 8px;
2157 padding: 4px 12px;
2158 min-height: 24px;
2159 display: inline-flex;
2160 align-items: center;
2161 text-decoration: none;
2162 background: var(--accent);
2163 color: #fff;
2164}
2165.demo-banner .demo-banner-keep:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
2166.demo-banner.demo-over {
2167 background: color-mix(in srgb, #d97706 16%, var(--panel));
2168 border-bottom-color: color-mix(in srgb, #d97706 45%, transparent);
2169}
2170@media (prefers-color-scheme: dark) {
2171 .demo-banner.demo-over { background: color-mix(in srgb, #f59e0b 22%, var(--panel)); }
2172}
2173/* floating connection-status pill (only shown during a sustained outage) */
2174.conn-status {
2175 position: fixed;
2176 top: calc(10px + env(safe-area-inset-top, 0px));
2177 left: 50%;
2178 transform: translateX(-50%);
2179 z-index: 90;
2180 background: var(--pill-bg);
2181 color: var(--pill-ink);
2182 font-size: 13px;
2183 font-weight: 600;
2184 padding: 6px 14px;
2185 border-radius: 999px;
2186 box-shadow: 0 6px 20px var(--shadow);
2187}
2188.conn-status.hidden { display: none; }
2189.sidebar { width: 320px; flex: none; display: flex; flex-direction: column; background: var(--sidebar); border-right: 1px solid var(--border); min-height: 0; }
2190/* Inline passkey-backup nudge: sits in the sidebar between the search box and the
2191 chat list, occupying its own layout band (never a floating toast). A subtle
2192 accent tint sets it apart without shouting. Contents are built in backup.js. */
2193.backup-banner {
2194 flex: none;
2195 margin: 8px 12px;
2196 padding: 12px 14px;
2197 border: 1px solid var(--border);
2198 border-radius: 12px;
2199 background: color-mix(in srgb, var(--accent) 12%, transparent);
2200 display: flex;
2201 flex-direction: column;
2202 gap: 8px;
2203}
2204.backup-banner.hidden { display: none; }
2205.backup-banner-title { font-weight: 700; font-size: 14px; color: var(--ink); }
2206.backup-banner-body { font-size: 13px; line-height: 1.4; color: var(--muted); }
2207
2208/* The "a new app is available" offer — a slim inline sidebar nudge, not a
2209 forced reload. Deliberately quieter than the backup banner. */
2210.update-banner {
2211 flex: none;
2212 margin: 4px 12px 8px;
2213 padding: 10px 12px;
2214 border: 1px solid var(--border);
2215 border-radius: 12px;
2216 background: var(--bubble-them);
2217 display: flex;
2218 flex-direction: column;
2219 gap: 8px;
2220}
2221.update-banner.hidden { display: none; }
2222.update-banner-msg { font-size: 13px; color: var(--ink); }
2223.update-banner-row { display: flex; gap: 8px; }
2224.update-banner-btn {
2225 cursor: var(--ui-cursor, pointer);
2226 font-size: 13px;
2227 padding: 5px 12px;
2228 border-radius: 999px;
2229 border: 1px solid var(--border);
2230 background: var(--panel);
2231 color: var(--ink);
2232}
2233.update-banner-btn.primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
2234.backup-banner-cta { align-self: flex-start; padding: 8px 14px; font-size: 13px; }
2235/* Full-width + left-aligned so the escalated "I can lose access…" label wraps
2236 inside the banner instead of overflowing its right edge. */
2237.backup-banner-dismiss {
2238 align-self: stretch;
2239 text-align: left;
2240 font-size: 13px;
2241 white-space: normal;
2242 overflow-wrap: anywhere;
2243}
2244/* Once escalated (.danger) the label wraps to two lines, and .linkish's
2245 6px/8px padding plus the default button's 999px pill radius crowd the text
2246 against the curved corners. Scoped to .danger (beats .linkish's padding at
2247 higher specificity) rather than .linkish itself, so the single-line
2248 "Remind me later" stage keeps its original tight link-style padding. */
2249.backup-banner-dismiss.danger {
2250 padding: 12px 16px;
2251 border-radius: 16px;
2252}
2253/* Both headers share a height so the thread pane's header lines up with the
2254 sidebar's. Only the thread header carries a divider — the sidebar's "Chats"
2255 header flows straight into the chat list (with the search field below it). */
2256.side-head, .thread-head { box-sizing: border-box; min-height: 62px; padding: 8px 16px; }
2257.thread-head { border-bottom: 1px solid var(--border); }
2258.side-head { display: flex; align-items: center; gap: 8px; }
2259.side-head .title { flex: 1; min-width: 0; font-family: var(--font-display); font-weight: 700; font-size: 17px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2260.head-actions { display: flex; align-items: center; gap: 2px; flex: none; }
2261.avatar {
2262 display: inline-flex; align-items: center; justify-content: center; flex: none;
2263 width: 34px; height: 34px; border-radius: 50%; color: #fff; font-weight: 700; font-size: 15px; line-height: 1;
2264}
2265.avatar-btn { background: transparent; padding: 0; border-radius: 50%; flex: none; }
2266.avatar-lg { width: 64px; height: 64px; font-size: 28px; }
2267.avatar.zoomable { cursor: pointer; } /* header avatar with a real photo: tap to zoom */
2268.field-label { display: block; font-size: 13px; color: var(--muted); margin: 2px 0 4px; }
2269/* Filter the chat list. Sits between the header and the list; hidden when there
2270 are no chats (renderThreadList toggles .hidden). */
2271.chat-search { flex: none; padding: 4px 12px 6px; display: flex; gap: 8px; }
2272.chat-search input {
2273 flex: 1; min-width: 0; box-sizing: border-box; font: inherit; font-size: 14px;
2274 padding: 8px 12px; border: 1px solid transparent; border-radius: 12px;
2275 background: var(--bubble-them); color: var(--ink); -webkit-appearance: none; appearance: none;
2276}
2277/* The funnel beside search: opens the list-filter menu (online-only +
2278 People/Bots/Apps). Highlights while any filter is narrowing the list.
2279 Stretched to the search field's height so the pair reads as one control. */
2280.chat-search .menu-wrap { display: flex; align-items: stretch; } /* so the funnel can match the field's height */
2281.filter-btn { color: var(--muted); padding: 0; width: 38px; align-self: stretch; justify-content: center; }
2282.filter-btn.active { color: var(--accent); }
2283.filter-menu { display: flex; flex-direction: column; gap: 2px; min-width: 180px; padding: 6px; }
2284/* Rows read like menu items: box, label, hover wash — mirroring .menu-item. */
2285.filter-row {
2286 display: flex; align-items: center; gap: 10px; cursor: pointer;
2287 font-size: 14px; font-weight: 500; padding: 8px 10px; border-radius: 8px;
2288}
2289.filter-row:hover { background: var(--bubble-them); }
2290.filter-sep { height: 1px; background: var(--border); margin: 4px 8px; }
2291/* The checkbox is drawn entirely here: the app-wide `input` rule (width:100%,
2292 panel fill, field padding) mangles a native checkbox into a blank pill, so
2293 appearance:none and take over — accent fill + a white tick when checked. */
2294input.filter-check {
2295 -webkit-appearance: none; appearance: none; margin: 0; padding: 0; flex: none;
2296 width: 18px; height: 18px; border-radius: 6px; cursor: pointer;
2297 border: 1.5px solid var(--border); background: var(--panel);
2298 display: inline-grid; place-content: center;
2299}
2300input.filter-check::before {
2301 content: ""; width: 10px; height: 10px; background: #fff;
2302 clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
2303 transform: scale(0); transition: transform 0.1s;
2304}
2305input.filter-check:checked { background: var(--accent); border-color: var(--accent); }
2306input.filter-check:checked::before { transform: scale(1); }
2307/* Keyboard focus keeps the app's accent ring; pointer clicks stay quiet. */
2308input.filter-check:focus { outline: none; }
2309input.filter-check:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
2310.chat-search input::placeholder { color: var(--muted); }
2311/* Message search results (#586): a dropdown under the search box, floating
2312 over the thread list (same treatment as the filter menu — a user-invoked,
2313 transient surface, not a toast) — rows read like slim thread rows;
2314 everything keyboard-reachable. The zero-height anchor keeps the sidebar
2315 column's flow untouched, so nothing below shifts while searching. */
2316.msg-results-anchor { position: relative; flex: none; }
2317.msg-results {
2318 position: absolute; top: 2px; left: 12px; right: 12px;
2319 z-index: 15; /* over the thread list, under the filter menu's 20 */
2320 background: var(--panel); border: 1px solid var(--border);
2321 border-radius: 14px; box-shadow: 0 12px 40px var(--shadow);
2322 overflow-y: auto; overflow-x: hidden;
2323 max-height: 60vh; max-height: 60dvh; /* the anchor has no height, so sidebar-relative % can't work */
2324 padding: 0 8px 6px;
2325}
2326.msg-results-count { font-size: 12px; font-weight: 600; color: var(--muted); padding: 6px 8px 4px; }
2327.msg-result {
2328 display: block; width: 100%; text-align: left; box-sizing: border-box;
2329 font: inherit; color: var(--ink); background: none; border: none;
2330 padding: 8px 10px; border-radius: 10px; cursor: pointer;
2331}
2332.msg-result:hover { background: var(--bubble-them); }
2333.msg-result:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
2334/* the keyboard-highlighted row — the top hit by default, so Enter just opens it */
2335.msg-result.active { background: color-mix(in srgb, var(--accent) 16%, transparent); }
2336/* Conversations section: matching chats as avatar + name rows. The dropdown
2337 floats over the chat list, so the chats a name-search finds must be
2338 clickable in here — same consolidation as the native apps' search. */
2339.msg-result.conv-result { display: flex; align-items: center; gap: 10px; }
2340.conv-result-name { font-size: 14px; font-weight: 600; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2341.msg-result-top { display: flex; justify-content: space-between; gap: 8px; font-size: 12px; }
2342.msg-result-who { font-weight: 600; }
2343.msg-result-where { color: var(--muted); flex: none; max-width: 55%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2344.msg-result-snippet { font-size: 13px; color: var(--muted); margin-top: 2px; overflow-wrap: anywhere; }
2345/* The deeper-search affordance + its progress line ("Searching back to…"). */
2346.msg-result-deeper, .msg-result-cancel {
2347 font: inherit; font-size: 13px; font-weight: 600; color: var(--accent);
2348 background: none; border: none; cursor: pointer; padding: 8px 10px; border-radius: 10px;
2349}
2350.msg-result-deeper:hover, .msg-result-cancel:hover { background: var(--bubble-them); }
2351.msg-result-deeper:focus-visible, .msg-result-cancel:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
2352.msg-result-status { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 13px; color: var(--muted); padding: 4px 0 4px 10px; }
2353/* Keep the app's standard focus ring (global input:focus); just lift the fill. */
2354.chat-search input:focus { background: var(--panel); }
2355.thread-list { flex: 1; overflow-y: auto; min-height: 0; padding: 6px; display: flex; flex-direction: column; gap: 2px; }
2356.thread-empty-note { color: var(--muted); padding: 16px; font-size: 14px; }
2357.thread-row {
2358 display: flex; align-items: center; gap: 12px; width: 100%; text-align: left;
2359 background: transparent; color: var(--ink); border-radius: 14px; padding: 9px 14px;
2360}
2361.thread-row:hover { background: var(--bubble-them); }
2362.thread-row.active { background: var(--row-active); }
2363/* Bigger avatar in the chat list (à la WhatsApp/iMessage); scoped to the list so
2364 header/profile/reaction avatars keep their sizes. */
2365.thread-row .avatar { width: 52px; height: 52px; font-size: 21px; }
2366/* Presence: a coloured dot + status text (the DM header line, the member
2367 sheet's note) — never a pip on an avatar. Green/yellow/red per Paul's call. */
2368:root { --online: #34c759; --away: #f5b400; --offline: #ff3b30; }
2369.presence-line { display: inline-flex; align-items: center; gap: 6px; } /* dot truly centred on the text */
2370.p-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
2371.p-dot.online { background: var(--online); }
2372.p-dot.away { background: var(--away); }
2373.p-dot.offline { background: var(--offline); }
2374/* Member sheet: the muted status note beside a member's name. */
2375.member-presence { margin-left: 8px; font-size: 12px; color: var(--muted); flex: none; }
2376/* Access-level chips on the profile card — the person's levels ("Coach",
2377 "VIP"), a quiet accent-tinted badge (names are plaintext space policy). */
2378.level-chips { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; margin: 8px 0 4px; }
2379.level-chip {
2380 font-size: 12px; font-weight: 600; line-height: 1;
2381 color: var(--accent-ink); background: var(--accent);
2382 border-radius: 999px; padding: 5px 10px;
2383}
2384
2385/* Membership sheet (paid access levels). Quiet cards in the app's own
2386 vocabulary; the price is the one emphasized number, one accent, and the
2387 state line ("Renews …" / "Access ends …") is the reassuring signature —
2388 always dated, never color alone. */
2389.plan-card {
2390 border: 1px solid var(--border); border-radius: 14px;
2391 padding: 14px 16px; margin: 10px 0; display: flex; flex-direction: column; gap: 8px;
2392}
2393.plan-card.current { border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); }
2394.plan-head { display: flex; align-items: baseline; gap: 10px; }
2395.plan-name { font-size: 16px; font-weight: 650; color: var(--ink); flex: 1; min-width: 0; }
2396.plan-price { font-size: 15px; font-weight: 700; color: var(--ink); white-space: nowrap; }
2397.plan-price .per { font-size: 12px; font-weight: 500; color: var(--muted); }
2398/* The dated status line — the one thing a member always reads. Icon + words,
2399 so a state is never carried by colour alone (WCAG 2.2 AA). */
2400.plan-state { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--muted); }
2401.plan-state.warn { color: light-dark(#9a6a00, #f0b84a); } /* payment-failed, still icon+words */
2402.plan-state .dot { width: 7px; height: 7px; border-radius: 999px; background: currentColor; flex: none; }
2403.plan-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 2px; }
2404.plan-actions .pill-btn { font-size: 13px; }
2405.membership-note { font-size: 13px; color: var(--muted); margin: 4px 0 10px; }
2406.membership-sec { font-size: 11px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); margin: 16px 0 2px; }
2407.thread-row-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
2408.thread-row-name { font-size: 16px; font-weight: 600; display: flex; align-items: center; min-width: 0; }
2409.thread-row-name-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2410/* Up to two lines of the latest message, then ellipsize (the spacious look).
2411 white-space:normal undoes the nowrap inherited from the global `button` rule
2412 (.thread-row is a <button>) — without it the line-clamp can't wrap and the
2413 preview gets hard-clipped on a single line. overflow-wrap lets long unbroken
2414 tokens (e.g. a pasted URL) wrap too. */
2415.thread-row-preview {
2416 font-size: 13px; font-weight: 400; color: var(--muted); line-height: 1.3;
2417 white-space: normal; overflow-wrap: anywhere;
2418 display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
2419}
2420/* Time sits top-right, aligned with the name, even when the preview wraps to two lines. */
2421.thread-row-meta { display: flex; flex-direction: column; align-items: flex-end; gap: 5px; flex: none; align-self: flex-start; padding-top: 3px; }
2422.thread-row-time { font-size: 11px; font-weight: 400; color: var(--muted); white-space: nowrap; }
2423.thread-row-pin { color: var(--muted); flex: none; }
2424.thread-row-pin svg { width: 15px; height: 15px; display: block; transform: rotate(45deg); }
2425.thread-row-mute { color: var(--muted); flex: none; }
2426.thread-row-mute svg { width: 14px; height: 14px; display: block; }
2427/* A muted member's folded message (issue #578): a quiet, obviously-interactive
2428 stub occupying the bubble's place — never a silent gap in the conversation. */
2429.muted-stub {
2430 display: inline-flex;
2431 align-items: center;
2432 gap: 10px;
2433 padding: 8px 14px;
2434 border-radius: 16px;
2435 border: 1px dashed var(--border);
2436 color: var(--muted);
2437 font-size: 13px;
2438 font-style: italic;
2439}
2440.muted-stub-show {
2441 all: unset;
2442 cursor: pointer;
2443 font-style: normal;
2444 font-weight: 600;
2445 font-size: 13px;
2446 color: var(--accent);
2447 padding: 4px 8px; /* padding keeps the tap target comfortably ≥24px tall */
2448 border-radius: 6px;
2449}
2450.muted-stub-show:hover { text-decoration: underline; }
2451.muted-stub-show:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
2452/* A closed DM's banner (issue #578): sits where the composer was — inline,
2453 occupying layout space, never floating over the feed. */
2454.dm-closed-bar {
2455 display: flex;
2456 align-items: center;
2457 justify-content: center;
2458 gap: 12px;
2459 padding: 14px 16px;
2460 border-top: 1px solid var(--border);
2461 color: var(--muted);
2462 font-size: 14px;
2463}
2464/* Small helper line under a settings field (e.g. the DM-privacy select). */
2465.field-note { margin-top: 6px; font-size: 12px; color: var(--muted); }
2466/* Destructive button — the moderation ladder's remove/ban rungs. */
2467button.danger { background: transparent; border: 1px solid var(--danger); color: var(--danger); }
2468/* Report sheet (issue #578): category chips + quoted evidence. */
2469/* The send button's in-flight spinner: the join loader's ring at button
2470 scale, inheriting the button's text color so it reads on the filled bg. */
2471.btn-spinner {
2472 display: inline-block;
2473 width: 14px;
2474 height: 14px;
2475 vertical-align: -2px;
2476 border-radius: 50%;
2477 border: 2px solid color-mix(in srgb, currentColor 30%, transparent);
2478 border-top-color: currentColor;
2479 animation: spin 0.7s linear infinite;
2480}
2481@media (prefers-reduced-motion: reduce) {
2482 .btn-spinner { animation: none; opacity: 0.5; }
2483}
2484.report-cats { display: flex; gap: 8px; margin: 10px 0; flex-wrap: wrap; }
2485.report-cat {
2486 background: transparent;
2487 border: 1px solid var(--border);
2488 color: var(--ink);
2489 border-radius: 999px;
2490 padding: 6px 14px;
2491 font-size: 13px;
2492}
2493.report-cat.on { border-color: var(--accent); color: var(--accent); font-weight: 600; }
2494.report-quote {
2495 margin: 10px 0;
2496 padding: 8px 12px;
2497 border-left: 3px solid var(--border);
2498 font-size: 13px;
2499 color: var(--muted);
2500 overflow-wrap: anywhere;
2501}
2502.report-mute-row { display: flex; align-items: center; gap: 6px; font-size: 13px; margin-top: 10px; cursor: pointer; }
2503/* The sent confirmation that replaces the report form. */
2504.report-done { text-align: center; padding: 30px 10px; font-size: 15px; }
2505/* Escape the app-wide `input { width:100% }` rule (the #761 fix's pattern) —
2506 a full-width checkbox becomes the whole flex row and shoves its label text
2507 clean out of the sheet. */
2508.report-mute-row input { width: 18px; height: 18px; flex: none; margin: 0; }
2509/* An admin warning's framing on the bubble (issue #578). */
2510.warn-tag { font-size: 12px; font-weight: 700; color: var(--danger); margin-bottom: 4px; }
2511/* Admin app: report queue + audit log (issue #578). */
2512.admin-report { border: 1px solid var(--border); border-radius: 12px; padding: 12px 14px; margin: 10px 0; }
2513.admin-report.resolved { opacity: 0.65; }
2514.admin-report-head { display: flex; justify-content: space-between; gap: 10px; align-items: baseline; }
2515.admin-report-when { font-size: 11px; color: var(--muted); white-space: nowrap; }
2516.admin-report-byline { font-size: 12px; color: var(--muted); margin-top: 2px; }
2517.admin-report-words { font-size: 13px; margin: 8px 0 0; overflow-wrap: anywhere; }
2518.admin-report-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
2519.admin-report-detail { margin-top: 8px; display: flex; flex-direction: column; gap: 8px; }
2520.admin-audit-row {
2521 display: flex;
2522 justify-content: space-between;
2523 gap: 10px;
2524 font-size: 13px;
2525 padding: 6px 0;
2526 border-bottom: 1px solid var(--border);
2527}
2528.admin-audit-row:last-child { border-bottom: none; }
2529.unread-dot { width: 8px; height: 8px; border-radius: 50%; background: transparent; flex: none; }
2530.unread-dot.on { background: var(--accent); }
2531/* Mobile list treatment (à la Messages/WhatsApp): rows run edge-to-edge, the active
2532 state is a full-width fill (no rounded bubble), and a hairline divider separates
2533 rows starting just after the avatar (not under it). */
2534@media (pointer: coarse) {
2535 .thread-list { padding: 0; gap: 0; }
2536 .thread-row { border-radius: 0; padding: 11px 16px; position: relative; }
2537 .thread-row::after {
2538 content: "";
2539 position: absolute;
2540 left: calc(16px + 52px + 12px); /* row padding-left + avatar width + gap */
2541 right: 0;
2542 bottom: 0;
2543 height: 1px;
2544 background: var(--border);
2545 }
2546 /* No divider under the last row, nor under the active row (its fill owns the edge). */
2547 .thread-row:last-child::after,
2548 .thread-row.active::after { display: none; }
2549}
2550.thread-pane { flex: 1; display: flex; min-height: 0; min-width: 0; position: relative; }
2551.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--muted); gap: 6px; text-align: center; padding: 24px; }
2552.empty-art { font-size: 42px; }
2553/* min-width:0 lets these panes shrink below their content so a long unbroken
2554 URL wraps inside a bubble instead of widening the whole column. */
2555.thread-view { flex: 1; display: flex; flex-direction: column; min-height: 0; min-width: 0; width: 100%; position: relative; }
2556.thread-head { display: flex; align-items: center; gap: 6px; }
2557.thread-head-title { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
2558/* App-thread view toggle: a small segmented [💬 | ⏰] control beside the kebab —
2559 Chat (the DM with the app's bot) vs App (the interactive UI). */
2560.view-toggle { display: flex; gap: 2px; flex: none; background: var(--bubble-them); border-radius: 10px; padding: 2px; }
2561.view-toggle-btn {
2562 display: inline-flex; align-items: center; justify-content: center;
2563 width: 32px; height: 30px; border: none; border-radius: 8px; background: transparent;
2564 font-size: 15px; line-height: 1; color: var(--ink); cursor: var(--ui-cursor, pointer);
2565}
2566.view-toggle-btn.active { background: var(--panel); box-shadow: 0 1px 4px var(--shadow); }
2567
2568/* App-thread Views: the Chat pane (#app-pane) + the Interface pane
2569 (#interface-pane). A normal chat thread has only the chat pane — the interface
2570 pane stays empty and hidden, so it fills the width unchanged. Shown only in
2571 .app-mode; the framework CSS already gives each .app-pane its display/flex. */
2572.app-split { display: none; flex: 1 1 auto; min-height: 0; }
2573.thread-view.app-mode #app-split { display: flex; }
2574#interface-pane:empty { display: none; } /* a normal chat has no interface pane */
2575.view-toggle-btn.split-only { display: none; } /* Split is desktop-only (revealed below) */
2576.app-split-divider { display: none; } /* only ever shown in split view on desktop, below */
2577@media (min-width: 721px) {
2578 /* Desktop: three views — chat | split (both, the default) | interface. */
2579 #app-split { flex-direction: row; }
2580 #interface-pane { border-left: 1px solid var(--border); }
2581 .view-toggle-btn.split-only { display: inline-flex; }
2582 .thread-view[data-app-view="chat"] #interface-pane { display: none; }
2583 .thread-view[data-app-view="interface"] #app-pane { display: none; }
2584 /* Split: the divider between the panes is draggable (mouse/touch/pen) and
2585 keyboard-operable (arrow keys), and owns the separating line — so the
2586 plain border above is switched off here in favour of the divider's own. */
2587 .thread-view[data-app-view="split"] #app-pane {
2588 flex: 0 0 auto;
2589 width: calc(var(--split-ratio, 0.5) * 100%);
2590 }
2591 .thread-view[data-app-view="split"] #interface-pane {
2592 flex: 1 1 auto;
2593 border-left: none;
2594 }
2595 .thread-view[data-app-view="split"] #app-split-divider {
2596 display: flex; align-items: center; justify-content: center;
2597 flex: 0 0 11px; width: 11px; min-height: 0;
2598 cursor: col-resize; touch-action: none; position: relative;
2599 }
2600 .app-split-divider::before {
2601 content: ""; position: absolute; top: 0; bottom: 0; left: 50%;
2602 width: 1px; background: var(--border);
2603 }
2604 .app-split-divider::after {
2605 content: ""; width: 3px; height: 32px; border-radius: 2px;
2606 background: var(--border);
2607 }
2608 .app-split-divider:hover::after,
2609 .app-split-divider:focus-visible::after,
2610 .app-split-divider.dragging::after { background: var(--muted); }
2611 .app-split-divider:focus-visible { outline: none; } /* the grip itself shows focus */
2612 .app-split-divider:focus-visible::after { box-shadow: 0 0 0 2px var(--accent); }
2613}
2614@media (max-width: 720px) {
2615 /* Mobile: one pane, no split — chat/split → Chat, interface → Interface. */
2616 .thread-view.app-mode #interface-pane { display: none; }
2617 .thread-view[data-app-view="interface"] #app-pane { display: none; }
2618 .thread-view[data-app-view="interface"] #interface-pane { display: flex; }
2619}
2620.thread-title { font-family: var(--font-display); font-weight: 700; font-size: 16px; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2621.thread-sub { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2622.back-btn { display: none; font-size: 24px; padding: 0 8px; }
2623.feed { flex: 1; overflow-y: auto; overflow-x: hidden; min-height: 0; min-width: 0; padding: 16px; display: flex; flex-direction: column; gap: 4px; }
2624.feed > .msg { max-width: 78%; }
2625.feed::before { content: ""; margin-top: auto; }
2626
2627/* modal sheet (new chat / add device) — centered overlay */
2628.overlay {
2629 position: fixed; inset: 0; z-index: 60; background: rgba(20, 30, 80, 0.45);
2630 display: flex; align-items: center; justify-content: center; padding: 20px;
2631}
2632.sheet { max-width: 460px; width: 100%; max-height: 82vh; overflow-y: auto; }
2633.sheet-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
2634/* A sheet's title spaces the same whether or not it's wrapped in a .sheet-head:
2635 most sheets write a bare <h2>, and the browser's default heading margin then
2636 pushed it far off the card's top padding. Both shapes now leave 10px below. */
2637.sheet-head h2 { margin: 0; }
2638.sheet > h2:first-child { margin: 0 0 10px; }
2639.linkish { background: transparent; color: var(--link); padding: 6px 8px; font-weight: 600; }
2640.sheet-input { margin-bottom: 10px; }
2641/* The quick forms ("Fill in a form instead"): labelled fields over the shared
2642 sheet inputs, an Extras group heading, and inline (never floating) errors. */
2643.qf-label { display: block; font-size: 13px; font-weight: 600; color: var(--ink); margin: 6px 2px 4px; }
2644.qf-note { margin: 2px 2px 0; }
2645.qf-error { margin: 8px 2px 0; font-size: 14px; color: var(--danger); }
2646.qf-error[hidden] { display: none; }
2647/* full-bleed list inside the sheet: rows span the card edges (which pads 28px) */
2648.pick-list { margin: 4px -28px 0; max-height: 56vh; overflow-y: auto; }
2649/* Picker sheets (new chat / add people) had nested scrolling — the sheet AND the
2650 .pick-list both scrolled — plus a stray horizontal scrollbar (overflow-y:auto
2651 promotes overflow-x to auto, so a sub-pixel overflow showed a bar). Make the
2652 sheet a clipped flex column so its head, search, and Apps footer stay put and
2653 ONLY the list scrolls: one clean scroll region. :has() scopes this to sheets
2654 that actually hold a pick-list, leaving every other modal untouched. */
2655.sheet:has(.pick-list) { display: flex; flex-direction: column; overflow: hidden; }
2656.sheet:has(.pick-list) > * { flex: none; }
2657.sheet:has(.pick-list) .pick-list {
2658 flex: 0 1 auto; min-height: 0; max-height: none;
2659 overflow-y: auto; overflow-x: hidden;
2660}
2661.list-row {
2662 display: flex; align-items: center; gap: 12px; width: 100%; text-align: left;
2663 background: transparent; color: var(--ink); border: none; border-bottom: 1px solid var(--border);
2664 padding: 14px 28px; border-radius: 0; font-weight: 500; font-size: 16px; cursor: pointer;
2665}
2666.list-row:hover:not(.static) { background: var(--bubble-them); }
2667.list-row.static { cursor: default; }
2668.list-row input { width: auto; }
2669/* The border is a BETWEEN-rows divider — the last row's would just float under
2670 the list (same rule the thread list applies to its rows). */
2671.pick-list .list-row:last-child { border-bottom: none; }
2672/* The Apps sheet's focus-and-explain flow: › focuses one app (the other rows
2673 drop away), then Open | Add to chat | Add to space over an explanation
2674 panel. Dimmed options stay CLICKABLE — tapping one is how you learn why it
2675 isn't available here; the panel explains, never a dead control. */
2676.app-entry .list-row { border-bottom: none; }
2677/* Focusing an app EASES the others away instead of jump-cutting: each entry
2678 is a one-row grid whose track collapses 1fr → 0fr (height animates without
2679 measuring) while it fades, so the sheet visibly shrinks to the chosen app.
2680 The inner wrapper is the clipped track child. visibility flips only after
2681 the collapse (the delayed 0s transition), taking hidden rows out of the
2682 tab order and accessibility tree without cutting the animation short. */
2683.app-entry {
2684 display: grid; grid-template-rows: 1fr;
2685 border-bottom: 1px solid var(--border);
2686 transition:
2687 grid-template-rows 0.28s cubic-bezier(0.2, 0.8, 0.2, 1),
2688 opacity 0.2s ease, border-bottom-width 0.28s, visibility 0s linear 0s;
2689}
2690.app-entry-inner { min-height: 0; overflow: hidden; }
2691.app-entry.collapsed {
2692 grid-template-rows: 0fr; opacity: 0; border-bottom-width: 0; visibility: hidden;
2693 transition:
2694 grid-template-rows 0.28s cubic-bezier(0.2, 0.8, 0.2, 1),
2695 opacity 0.2s ease, border-bottom-width 0.28s, visibility 0s linear 0.28s;
2696}
2697/* The between-rows divider disappears when nothing follows it — the genuine
2698 last row, but also a FOCUSED entry once every row after it has collapsed
2699 (a lone visible entry shouldn't underline itself). border-bottom-width is
2700 already in the entry's transition list, so the divider fades out in the
2701 same motion as the collapse. */
2702.pick-list .app-entry:last-child { border-bottom: none; }
2703.pick-list .app-entry:not(:has(~ .app-entry:not(.collapsed))) { border-bottom-width: 0; }
2704/* The focused app's places drawer grows in the same motion the rows leave. */
2705.app-places {
2706 display: grid; grid-template-rows: 0fr; opacity: 0;
2707 transition: grid-template-rows 0.28s cubic-bezier(0.2, 0.8, 0.2, 1), opacity 0.2s ease;
2708}
2709.app-places.open { grid-template-rows: 1fr; opacity: 1; }
2710.app-places-inner { min-height: 0; overflow: hidden; }
2711@media (prefers-reduced-motion: reduce) {
2712 .app-entry, .app-places { transition: none; }
2713}
2714.app-go {
2715 flex: 0 0 auto; width: 32px; height: 32px; padding: 0;
2716 font: inherit; font-size: 20px; line-height: 1; font-weight: 400;
2717 color: var(--muted); background: transparent; border: none; border-radius: 8px;
2718 cursor: var(--ui-cursor, pointer);
2719}
2720.app-go:hover { color: var(--ink); background: var(--bubble-them); }
2721.place-options { display: flex; gap: 6px; padding: 2px 28px 0; }
2722.place-option {
2723 flex: 1 1 0; min-width: 0; padding: 7px 4px;
2724 font: inherit; font-size: 13.5px; font-weight: 600; white-space: nowrap;
2725 color: var(--ink); background: var(--bubble-them);
2726 border: 1px solid var(--border); border-radius: 8px;
2727 cursor: var(--ui-cursor, pointer);
2728}
2729.place-option[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); }
2730/* Dimmed, not disabled — the words in the panel carry the "why", never the
2731 fade alone (state is never color alone). */
2732.place-option.dim { opacity: 0.45; }
2733/* The places sentence, shown with the choice it explains (under the focused
2734 app's name, above its place buttons). */
2735.place-grammar { margin: 0; padding: 0 28px 10px; font-size: 13px; color: var(--muted); line-height: 1.45; }
2736.place-panel { padding: 10px 28px 14px; }
2737.place-panel p { margin: 0; font-size: 13.5px; color: var(--muted); line-height: 1.45; }
2738/* The selected place's action, right-aligned at the sheet's bottom (rides
2739 .sheet-foot). min-height keeps the sheet from jittering as selections
2740 change; :empty removes it entirely in the list state (no app focused →
2741 no button rendered → no unexplained strip under the list). */
2742.place-foot { min-height: 38px; margin-top: 0; }
2743.place-foot:empty { display: none; }
2744.place-foot button { font-size: 14px; }
2745/* Helpers catalog: bots sit under their own picker heading, with a one-line blurb */
2746.pick-section {
2747 padding: 16px 28px 6px; font-size: 12px; font-weight: 700; color: var(--muted);
2748 text-transform: uppercase; letter-spacing: 0.06em;
2749}
2750.helper-desc { display: block; font-size: 13px; font-weight: 400; color: var(--muted); margin-top: 2px; }
2751/* member row: tappable identity on the left, a Remove action on the right */
2752.member-main {
2753 flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px;
2754 background: transparent; color: inherit; border: none; text-align: left; font: inherit; padding: 0; cursor: pointer;
2755}
2756.member-main span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2757.member-remove { flex: none; background: transparent; color: var(--danger); font-weight: 600; font-size: 14px; padding: 4px 8px; }
2758.sheet-foot { display: flex; justify-content: flex-end; gap: 10px; margin-top: 16px; }
2759/* An inline failure reason in a sheet (#786) — occupies layout space above
2760 the buttons, never a floating toast. Colour is not the only signal: the
2761 role=alert announcement + the wording carry it. */
2762.sheet-error { color: var(--danger, #c0392b); font-size: 14px; margin: 10px 0 0; }
2763
2764/* Open groups. Two pieces of vocabulary, both built from the sheet's existing
2765 language so community UI reads as native, not bolted on:
2766 - .opt-row: a choice with its consequence spelled out underneath (label sets
2767 the what, .opt-desc the so-what) — checkboxes stop needing run-on labels.
2768 - Discover rows: roster-style avatar + name + quiet facts, action on the
2769 right. Join is the sheet's ONE loud control (accent pill); everything a
2770 member already has (Open) stays quiet. */
2771.opt-row {
2772 display: flex; align-items: flex-start; gap: 12px;
2773 padding: 10px 2px; cursor: pointer; font-weight: 550;
2774}
2775.opt-row input { width: auto; flex: none; margin-top: 4px; accent-color: var(--accent); }
2776.opt-desc { display: block; font-size: 13px; font-weight: 400; color: var(--muted); margin-top: 1px; }
2777/* A dependent option that can't apply right now (default needs open) dims as a
2778 unit — state is the row's opacity AND the disabled input, never color alone. */
2779.opt-row:has(input:disabled) { opacity: 0.45; cursor: default; }
2780.pill-btn {
2781 flex: none; background: var(--accent); color: var(--accent-ink);
2782 border-radius: 999px; padding: 7px 16px; font-weight: 650; font-size: 14px;
2783}
2784.pill-btn.quiet { background: var(--bubble-them); color: var(--ink); }
2785.discover-main { flex: 1; min-width: 0; }
2786.discover-main > div:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2787
2788/* theme picker (kebab → Theme) */
2789.appearance-seg { display: flex; gap: 6px; background: var(--bubble-them); border-radius: 12px; padding: 4px; }
2790.appearance-seg button { flex: 1; background: transparent; color: var(--muted); border-radius: 9px; padding: 9px; font-weight: 600; }
2791.appearance-seg button[aria-pressed="true"] { background: var(--panel); color: var(--ink); box-shadow: 0 1px 4px var(--shadow); }
2792.theme-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
2793.theme-swatch {
2794 display: flex; align-items: center; gap: 11px; text-align: left;
2795 background: var(--panel); color: var(--ink);
2796 border: 1px solid var(--border); border-radius: 14px; padding: 12px;
2797 font-weight: 600; font-family: var(--font-display);
2798}
2799.theme-swatch:hover { background: var(--bubble-them); }
2800.theme-swatch.active { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
2801.theme-swatch .disc { width: 26px; height: 26px; border-radius: 50%; flex: none; box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12); }
2802/* "Text size" slider in the theme sheet (desktop only): A ——◦—— A */
2803.text-size-row { display: flex; align-items: center; gap: 12px; }
2804.text-size-row input[type="range"] { flex: 1; margin: 0; accent-color: var(--accent); }
2805.text-size-a { color: var(--muted); font-weight: 600; line-height: 1; }
2806.text-size-a.small { font-size: 12px; }
2807.text-size-a.big { font-size: 20px; }
2808
2809/* mobile: show one pane at a time, toggled by .show-thread */
2810@media (max-width: 720px) {
2811 .sidebar { width: 100%; border-right: none; }
2812 .thread-pane { display: none; }
2813 .tt.show-thread .sidebar { display: none; }
2814 .tt.show-thread .thread-pane { display: flex; }
2815 .back-btn { display: inline-flex; }
2816}
2817
2818/* Native wrapper (html.native, set from the "lchatMac" user agent): app chrome uses
2819 the normal arrow, not the web "hand" cursor. Content is untouched — message URLs and
2820 link-preview cards keep the pointer, images keep zoom-in.
2821 Buttons switch through the --ui-cursor variable so each button has just ONE cursor
2822 declaration (resolving to default) rather than a base `pointer` + an override — a
2823 WKWebView flickers when it has two competing values to choose between. The remaining
2824 non-button pointer bits still override directly (!important because #thread-sub sets
2825 its cursor via an inline JS style). Mirror new chrome cursors here. */
2826html.native { --ui-cursor: default; }
2827html.native #thread-sub,
2828html.native .toggle-row,
2829html.native .reply-count,
2830html.native .mention-link,
2831html.native .avatar.zoomable { cursor: default !important; }
2832
2833/* The header is a window-drag region in the native wrapper, so its title text isn't
2834 selectable — don't show the text I-beam or a selection affordance on it. */
2835html.native .side-head,
2836html.native .thread-head {
2837 cursor: default;
2838 -webkit-user-select: none;
2839 user-select: none;
2840}
2841
2842/* Native shell (iOS/macOS) embeds ONE thread's feed — the app draws its own
2843 sidebar, title bar, and composer. Hide the web sidebar / thread header / composer
2844 and let the feed fill the view (overriding the mobile one-pane-at-a-time rules).
2845 Gated on body.native-embed, which only shell.js sets when window.__lchatEmbed is
2846 injected; browsers are unaffected. */
2847.native-embed .sidebar { display: none !important; }
2848.native-embed .thread-pane { display: flex !important; }
2849.native-embed .thread-head,
2850.native-embed .composer,
2851.native-embed .composer-reply,
2852.native-embed .composer-edit {
2853 display: none;
2854}
2855/* Keep the attachment preview visible: it's how a staged photo shows up just above
2856 the native composer (drag-drop or the native + button stage into it). The composer
2857 below is hidden, so clear the native bar's height (plus the old 10px breathing room). */
2858.native-embed .composer-preview { padding-bottom: calc(10px + var(--native-bottom-inset, 0px)); }
2859/* The Interface view AND the Chat/takeover pane (both .app-pane scrollers —
2860 #interface-pane and #app-pane) run under the same floating native bars as
2861 the feed: pad their top with the injected inset so the app's own UI (To-do's
2862 add row, Reminders' form, a takeover app like Vault) starts clear of the
2863 header, and end with a real in-flow spacer — not padding-bottom, which
2864 WebKit drops at a scroller's end — so the last row clears the home
2865 indicator/keyboard. #app-pane hosting a nested chat mini-app instead
2866 (:has(> .chatapp-root) — a companion app's Chat view) is excluded: that
2867 nested .chatapp-feed already pads itself with the same vars, so double-
2868 padding the outer pane too would push it down/shrink it twice over. */
2869.native-embed #interface-pane,
2870.native-embed #app-pane:not(:has(> .chatapp-root)) {
2871 padding-top: calc(16px + var(--native-top-inset, 0px));
2872}
2873.native-embed #interface-pane::after,
2874.native-embed #app-pane:not(:has(> .chatapp-root))::after {
2875 content: "";
2876 flex: none;
2877 height: calc(16px + var(--native-bottom-inset, 0px));
2878}
2879/* A sandboxed member app (.sandbox-pane — the pane itself is tagged) is an
2880 iframe surface that owns its own padding, so the 16px breathing room above
2881 reads as a bare strip around it. Keep only the native-bar insets: the app
2882 still starts clear of the floating bars, but runs edge-to-edge otherwise. */
2883.native-embed #interface-pane.sandbox-pane,
2884.native-embed #app-pane.sandbox-pane {
2885 padding-top: var(--native-top-inset, 0px);
2886}
2887.native-embed #interface-pane.sandbox-pane::after,
2888.native-embed #app-pane.sandbox-pane::after {
2889 height: var(--native-bottom-inset, 0px);
2890}
2891
2892/* Command-host embed (embed.mode === "command"): the page the native Mac shell
2893 keeps beside each thread purely to run slash commands — everything except a
2894 command's panel collapses, so the webview the shell reveals IS the panel.
2895 The shell owns the frame (and caps the height via the posted measure). */
2896body.command-host .chatapp-root > :not(.cmd-panel) { display: none !important; }
2897body.command-host .cmd-panel { border: none; box-shadow: none; } /* the native frame draws the border + shadow */
2898/* The shell grants the frame; the body scrolls within it (100vh = the frame). */
2899body.command-host .cmd-panel-body { max-height: calc(100vh - 40px); }
2900