eleven / ocho Public

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

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/* Padding shaved from 6px to 5px when Reply-in-thread joined the strip — four
640 icons plus the time now, on a bubble's shoulder. Not shaved further: an 18px
641 glyph in 10px of padding keeps every button 28x26, clear of the 24x24
642 target-size floor (WCAG 2.2 AA 2.5.8), and the 2px gap keeps neighbours
643 distinct. The strip is flex-nowrap and shrink-to-fit, so it can never wrap
644 or squash — where room runs out it simply runs past the feed's edge and
645 overflow-x clips it (the gutter floor below keeps that off the main feed;
646 the thread pane's own narrow column is where it still bites). The time is
647 last at BOTH alignments (.me reverses the row), so the graceful casualty is
648 a timestamp, never an action — which is why the gutter is left alone: a
649 fourth icon is not worth reshaping every bubble in every chat to widen it,
650 and own-messages already clipped this way before the icon existed. */
651.msg-actions button {
652 background: transparent;
653 color: var(--muted);
654 padding: 4px 5px;
655 border-radius: 50%;
656 display: inline-flex;
657 align-items: center;
658 line-height: 1;
659}
660.msg-actions button:hover { background: var(--bubble-them); color: var(--accent); }
661.msg-actions button svg { width: 18px; height: 18px; }
662.msg-actions .msg-time {
663 font-size: 11px;
664 color: var(--muted);
665 white-space: nowrap;
666 margin: 0 3px;
667}
668@media (hover: hover) {
669 .msg:hover .msg-actions { opacity: 1; pointer-events: auto; }
670}
671.msg:focus-within .msg-actions { opacity: 1; pointer-events: auto; } /* keyboard: a focused strip is a visible strip */
672
673/* message content (text bubble + any attachments) */
674.content {
675 position: relative; /* anchors the absolute hover reply icon */
676 display: flex;
677 flex-direction: column;
678 gap: 4px;
679 align-items: flex-start;
680 min-width: 0;
681}
682.msg.me .content { align-items: flex-end; }
683
684.msg-image {
685 max-width: min(260px, 100%);
686 max-height: 320px;
687 border-radius: var(--radius);
688 display: block;
689 background: var(--bubble-them);
690 cursor: zoom-in;
691}
692/* a GIF's click toggles play/pause, so the zoom-in cursor would lie */
693.msg-image.msg-gif { cursor: pointer; }
694
695/* full-screen image viewer */
696.lightbox {
697 position: fixed;
698 inset: 0;
699 z-index: 80;
700 background: transparent; /* the dark backing is its own layer (.lightbox-backdrop) */
701 display: flex;
702 align-items: center;
703 justify-content: center;
704 padding: 20px;
705}
706/* separate layer so dimming animates via opacity (GPU-composited) — no repaint */
707.lightbox-backdrop {
708 position: absolute;
709 inset: 0;
710 z-index: 0;
711 background: rgba(0, 0, 0, 0.9);
712 will-change: opacity;
713}
714.lightbox img {
715 position: relative;
716 z-index: 1; /* sit above the backdrop */
717 max-width: 100%;
718 max-height: 100%;
719 border-radius: 8px;
720 touch-action: none; /* we own the gesture: pinch-zoom, pan, flick-to-dismiss */
721 user-select: none;
722 -webkit-user-drag: none;
723 will-change: transform;
724}
725/* collection-mode prev/next controls (openLightbox opts.collection). The
726 backdrop is always dark regardless of theme (see .lightbox-backdrop above),
727 so these match it directly rather than the light/dark panel variables. */
728.lightbox-nav {
729 position: fixed;
730 top: 50%;
731 transform: translateY(-50%);
732 z-index: 2; /* above the image */
733 width: 44px;
734 height: 44px;
735 min-width: 44px;
736 min-height: 44px;
737 display: grid;
738 place-items: center;
739 border: none;
740 border-radius: 50%;
741 background: rgba(0, 0, 0, 0.4);
742 color: #fff;
743 font-size: 28px;
744 line-height: 1;
745 cursor: pointer;
746 transition: background 0.15s ease;
747}
748.lightbox-nav:hover { background: rgba(0, 0, 0, 0.6); }
749.lightbox-nav:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
750.lightbox-nav-prev { left: 12px; }
751.lightbox-nav-next { right: 12px; }
752/* touch already navigates via swipe (dragIntent) — the buttons would only
753 duplicate the gesture and eat thumb space at the edges, so hide them */
754@media (pointer: coarse) {
755 .lightbox-nav { display: none; }
756}
757
758/* the lightbox open/close is driven in JS (a FLIP fly-in/out from the thumbnail) */
759@keyframes fade-in { from { opacity: 0; } }
760
761/* a newly-sent/arrived message eases up into the feed (initial history doesn't) */
762.msg.appear { animation: msg-appear 0.22s cubic-bezier(0.2, 0.8, 0.2, 1); }
763@keyframes msg-appear {
764 from { opacity: 0; transform: translateY(10px); }
765}
766
767@media (prefers-reduced-motion: reduce) {
768 .msg.appear { animation: none; }
769}
770
771/* drag-and-drop overlay */
772.drop-overlay {
773 position: absolute;
774 inset: 0;
775 z-index: 40;
776 background: color-mix(in srgb, var(--accent) 12%, transparent);
777 border: 3px dashed var(--accent);
778 border-radius: 12px;
779 display: flex;
780 align-items: center;
781 justify-content: center;
782 font-weight: 700;
783 font-size: 18px;
784 color: var(--link);
785 pointer-events: none;
786}
787.file-chip {
788 display: inline-flex;
789 align-items: center;
790 gap: 6px;
791 background: var(--bubble-them);
792 color: var(--ink);
793 padding: 10px 14px;
794 border-radius: var(--radius);
795 text-decoration: none;
796 font-size: 14px;
797 max-width: 260px;
798 word-break: break-word;
799}
800.msg.me .file-chip { background: var(--bubble-me); color: var(--bubble-me-ink); }
801.file-chip .file-icon { font-size: 22px; line-height: 1; flex: none; }
802.file-chip .file-meta { display: flex; flex-direction: column; min-width: 0; }
803.file-chip .file-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
804.file-chip .file-sub { font-size: 12px; opacity: 0.7; }
805
806/* click-to-play YouTube card (see buildYouTubeCard) — 16:9, bubble-styled */
807.yt-card {
808 width: min(420px, 100%);
809 aspect-ratio: 16 / 9;
810 border-radius: var(--radius);
811 overflow: hidden;
812 background: #000;
813 border: 1px solid var(--border);
814}
815.yt-card.yt-bare { display: none; } /* dead/private video → no card, just the link */
816.yt-poster {
817 position: relative;
818 display: block;
819 width: 100%;
820 height: 100%;
821 padding: 0;
822 border-radius: 0;
823 background: #000;
824}
825.yt-thumb { width: 100%; height: 100%; object-fit: cover; display: block; }
826.yt-play {
827 position: absolute;
828 inset: 0;
829 display: grid;
830 place-items: center;
831 color: #fff;
832}
833.yt-play svg {
834 width: 56px;
835 height: 56px;
836 padding: 10px 8px 10px 12px;
837 box-sizing: border-box;
838 border-radius: 50%;
839 background: rgba(0, 0, 0, 0.55);
840 transition: transform 0.15s ease, background 0.15s ease;
841}
842.yt-poster:hover .yt-play svg { background: rgba(0, 0, 0, 0.75); transform: scale(1.06); }
843.yt-frame { width: 100%; height: 100%; border: 0; display: block; }
844
845/* send-and-play video card (see buildVideoCard) — poster + play/download badge;
846 the clip is fetched + decrypted only when tapped (never auto-downloaded) */
847.msg-video {
848 position: relative;
849 display: block;
850 max-width: min(260px, 100%);
851 max-height: 320px;
852 border-radius: var(--radius);
853 overflow: hidden;
854 background: #000;
855 cursor: pointer;
856 line-height: 0;
857}
858.msg-video-poster {
859 display: block;
860 width: 100%;
861 height: 100%;
862 object-fit: cover;
863 background: var(--bubble-them);
864}
865.msg-video-play {
866 position: absolute;
867 inset: 0;
868 display: grid;
869 place-items: center;
870 color: #fff;
871 pointer-events: none;
872}
873.msg-video-play svg {
874 width: 52px;
875 height: 52px;
876 padding: 12px;
877 box-sizing: border-box;
878 border-radius: 50%;
879 background: rgba(0, 0, 0, 0.55);
880 transition: transform 0.15s ease, background 0.15s ease;
881}
882.msg-video:hover .msg-video-play svg { background: rgba(0, 0, 0, 0.75); transform: scale(1.06); }
883.msg-video.downloading .msg-video-play svg { opacity: 0.5; }
884.msg-video-dur {
885 position: absolute;
886 right: 6px;
887 bottom: 6px;
888 padding: 1px 6px;
889 border-radius: 10px;
890 background: rgba(0, 0, 0, 0.6);
891 color: #fff;
892 font-size: 12px;
893 font-variant-numeric: tabular-nums;
894 pointer-events: none;
895}
896/* radial download-progress ring around the play button (see showVideoRing) */
897.msg-video-ring {
898 position: absolute;
899 inset: 0;
900 display: grid;
901 place-items: center;
902 pointer-events: none;
903}
904.msg-video-ring svg { width: 60px; height: 60px; transform: rotate(-90deg); }
905.msg-video-ring .ring-track { fill: none; stroke: rgba(255, 255, 255, 0.3); stroke-width: 3; }
906.msg-video-ring .ring-fill {
907 fill: none;
908 stroke: #fff;
909 stroke-width: 3;
910 stroke-linecap: round;
911 transition: stroke-dashoffset 0.12s linear;
912}
913
914/* full-screen video player in the lightbox */
915.video-lightbox { animation: fade-in 0.2s ease; }
916.lightbox-video {
917 position: relative;
918 z-index: 1; /* above the backdrop */
919 max-width: 100%;
920 max-height: 100%;
921 border-radius: 8px;
922 background: #000;
923}
924/* staged video preview: poster tile with a small play glyph over it */
925.preview-chip.preview-video { position: relative; }
926.preview-chip.preview-video .preview-play {
927 position: absolute;
928 inset: 0;
929 display: grid;
930 place-items: center;
931 color: #fff;
932 pointer-events: none;
933}
934.preview-chip.preview-video .preview-play svg {
935 width: 24px;
936 height: 24px;
937 padding: 5px;
938 box-sizing: border-box;
939 border-radius: 50%;
940 background: rgba(0, 0, 0, 0.5);
941}
942
943/* link (OpenGraph) preview card */
944.link-preview {
945 display: flex;
946 flex-direction: column;
947 max-width: 280px;
948 background: var(--bubble-them);
949 color: var(--ink);
950 border: 1px solid var(--border);
951 border-radius: var(--radius);
952 overflow: hidden;
953 text-decoration: none;
954}
955.lp-image { width: 100%; max-height: 150px; object-fit: cover; display: block; }
956.lp-body { padding: 8px 11px; }
957.lp-title { font-weight: 600; font-size: 14px; line-height: 1.3; }
958.lp-desc {
959 font-size: 12px;
960 color: var(--muted);
961 margin-top: 2px;
962 display: -webkit-box;
963 -webkit-line-clamp: 2;
964 -webkit-box-orient: vertical;
965 overflow: hidden;
966}
967.lp-host { font-size: 11px; color: var(--muted); margin-top: 5px; }
968
969/* composer attachment preview. .composer-float is the in-flow anchor at the top
970 of the composer stack; the typeahead drop-up absolutely anchors to it. */
971.composer-float { position: relative; z-index: 5; }
972/* The staging strip occupies real layout space (the feed shrinks above it) —
973 floating it over the feed covered whatever sat at the bottom, including
974 tappable action chips ("Skip for now" became dead text under a thumbnail). */
975.composer-preview {
976 padding: 8px 16px 0;
977 display: flex;
978 flex-wrap: wrap;
979 gap: 8px;
980}
981.composer-preview.hidden { display: none; }
982.preview-spinner {
983 width: 16px; height: 16px; border-radius: 50%;
984 border: 2px solid var(--border); border-top-color: var(--accent);
985 animation: spin 0.8s linear infinite; flex: none;
986}
987.preview-chip {
988 position: relative;
989 display: inline-flex;
990 align-items: center;
991 gap: 8px;
992 /* Panel bg + border + shadow so the chip reads as a floating card over
993 whatever messages happen to be underneath it. */
994 background: var(--panel);
995 border: 1px solid var(--border);
996 box-shadow: 0 6px 20px var(--shadow);
997 border-radius: 12px;
998 padding: 6px 34px 6px 8px;
999 max-width: 100%;
1000}
1001.preview-chip img { width: 40px; height: 40px; object-fit: cover; border-radius: 8px; }
1002.preview-file { font-size: 24px; }
1003/* video upload progress chip: percentage label above a determinate bar */
1004.preview-chip.preview-uploading {
1005 flex-direction: column;
1006 align-items: stretch;
1007 gap: 6px;
1008 width: 160px;
1009}
1010.preview-progress {
1011 height: 6px;
1012 border-radius: 3px;
1013 background: var(--border);
1014 overflow: hidden;
1015}
1016.preview-progress-fill {
1017 height: 100%;
1018 width: 0;
1019 background: var(--accent);
1020 border-radius: 3px;
1021 transition: width 0.15s ease;
1022}
1023.preview-name {
1024 font-size: 13px;
1025 color: var(--ink);
1026 overflow: hidden;
1027 text-overflow: ellipsis;
1028 white-space: nowrap;
1029 max-width: 220px;
1030}
1031.preview-remove {
1032 position: absolute;
1033 right: 6px;
1034 top: 50%;
1035 transform: translateY(-50%);
1036 background: var(--ink);
1037 color: var(--panel);
1038 width: 22px;
1039 height: 22px;
1040 padding: 0;
1041 border-radius: 50%;
1042 display: inline-flex;
1043 align-items: center;
1044 justify-content: center;
1045}
1046.preview-remove svg { width: 13px; height: 13px; }
1047
1048/* Action chips: one-tap buttons a helper attaches under its bubble */
1049.chip-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
1050.action-chip {
1051 background: var(--panel); color: var(--link); border: 1px solid var(--border);
1052 border-radius: 14px; padding: 7px 14px; font-size: 14px; font-weight: 600; cursor: pointer;
1053}
1054.action-chip:hover:not(:disabled) { background: var(--bubble-them); }
1055.action-chip:disabled { color: var(--muted); cursor: default; }
1056/* A spent chip (its step was answered/skipped): a quiet part of the record,
1057 not something that still looks tappable. */
1058.action-chip--spent { background: transparent; border-color: transparent; padding-left: 6px; padding-right: 6px; }
1059/* A trailing chip run (the quiet alternative path, e.g. the quick form) sits at
1060 the row's far end: auto inline-start margin on its first chip pushes it over
1061 (RTL-safe), or onto its own right-aligned line when the row wraps. */
1062.action-chip--trailing { margin-inline-start: auto; }
1063
1064/* Local notice: a command's private "only visible to you" reply — a client-only
1065 feed row (never sent, gone on reload). It lives IN the feed and PERSISTS (no
1066 auto-dismiss). It reads like a chat reply: the sender (+ AI/BOT badge) and the
1067 "Only visible to you" tag sit ABOVE, outside the bubble (like a message's name
1068 row); the reply text is a chat-style bubble. */
1069.local-notice {
1070 /* No side inset: notice bubbles share the message grid, so they line up with
1071 .msg bubbles and the typing indicator (which sit flush with the feed). */
1072 align-self: stretch; margin: 6px 0 8px;
1073 display: flex; flex-direction: column; align-items: flex-start;
1074}
1075/* A continuation in a same-speaker run: no header, tucked up under the previous
1076 bubble — the same rhythm as .msg.cont. */
1077.local-notice--cont { margin-top: -4px; }
1078.local-notice-head { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; align-self: stretch; }
1079.local-notice-glyph { font-size: 15px; line-height: 1; }
1080.local-notice-from { font-weight: 600; font-size: 14px; color: var(--ink); }
1081.local-notice-badge {
1082 font-size: 10px; font-weight: 600; letter-spacing: 0.03em; line-height: 1;
1083 padding: 2px 5px; border-radius: 6px; background: var(--accent); color: #fff;
1084}
1085.local-notice-tag { margin-left: auto; font-size: 11px; color: var(--muted); }
1086.local-notice-bubble {
1087 max-width: 90%; padding: 10px 12px; border-radius: 14px; background: var(--bubble-them);
1088}
1089.local-notice-body { font-size: 15px; color: var(--ink); white-space: pre-wrap; }
1090.local-notice-body code {
1091 font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1092 font-size: 0.92em;
1093 background: color-mix(in srgb, var(--ink) 8%, transparent);
1094 padding: 1px 5px;
1095 border-radius: 5px;
1096}
1097.local-notice-body a { color: var(--accent); }
1098/* Markdown blocks in notice bodies (#531) — same tight-list treatment as bubbles */
1099.local-notice-body p, .local-notice-body ul, .local-notice-body ol { margin: 6px 0; }
1100.local-notice-body ul, .local-notice-body ol { padding-left: 22px; }
1101.local-notice-body li p, .local-notice-body li ul, .local-notice-body li ol { margin: 0; }
1102.local-notice-body p:first-child, .local-notice-body ul:first-child, .local-notice-body ol:first-child { margin-top: 0; }
1103.local-notice-body p:last-child, .local-notice-body ul:last-child, .local-notice-body ol:last-child { margin-bottom: 0; }
1104/* Quoted machine output on a notice (a provider's error): a quotation, set off
1105 with a rule and muted — readable prose framing above, evidence below. Not
1106 monospaced: it's a quote, not code. */
1107.local-notice-quote {
1108 margin-top: 8px; padding: 6px 10px;
1109 border-left: 3px solid color-mix(in srgb, var(--muted) 45%, transparent);
1110 font-size: 13px; color: var(--muted);
1111 white-space: pre-wrap; overflow-wrap: anywhere;
1112}
1113/* Where a routed command's data landed ("for everyone in this space") — the
1114 places grammar's disclosure on the reply card itself. Muted footer, never
1115 color-only: it's the words that carry the meaning. */
1116.local-notice-place { margin-top: 6px; font-size: 12px; color: var(--muted); }
1117.local-notice--compact .lc-place { flex: 0 0 auto; font-size: 12px; color: var(--muted); }
1118.local-notice--compact .lc-place::before { content: "·"; margin-right: 5px; font-weight: 400; }
1119/* Optional preview image on a notice (in-code same-origin URLs only — e.g. the
1120 setup conversation previewing the just-uploaded server photo). Fixed box so
1121 the feed's scroll position is right BEFORE the image loads. */
1122.local-notice-img {
1123 display: block; margin-top: 8px; width: 180px; height: 180px; object-fit: cover;
1124 border-radius: 12px; border: 1px solid var(--border);
1125 background: color-mix(in srgb, var(--ink) 6%, transparent);
1126}
1127/* An avatar preview renders as the circle it will actually be. */
1128.local-notice-img--round { border-radius: 50%; object-fit: cover; }
1129/* Notices ease in like live messages (see .msg.appear). */
1130.local-notice.appear { animation: msg-appear 0.22s cubic-bezier(0.2, 0.8, 0.2, 1); }
1131@media (prefers-reduced-motion: reduce) {
1132 .local-notice.appear { animation: none; }
1133}
1134.local-notice .chip-row { margin-top: 8px; }
1135/* A chips-only notice row (the live actions under a persisted bubble): tuck it
1136 right up under the bubble it belongs to instead of floating a full row lower.
1137 (Doubled class so it outranks the base rules above regardless of order.) */
1138.local-notice.local-notice--chips { margin-top: 2px; } /* + the feed's 4px gap = a 6px breath */
1139.local-notice.local-notice--chips .chip-row { margin-top: 0; }
1140/* A confirmation is loud only for a while: after ~30s it "blinds up" (its height
1141 animates down — see collapseNotice) to this compact one-line record
1142 (glyph · who · what happened · action). */
1143.local-notice--compact {
1144 display: flex; flex-direction: row; align-items: baseline; flex-wrap: wrap; gap: 5px;
1145 margin: 2px 10px; padding: 3px 12px;
1146 background: transparent; border: none; /* shed the bubble */
1147 font-size: 13px; color: var(--muted);
1148}
1149.local-notice--compact .local-notice-glyph { font-size: 14px; align-self: center; }
1150.local-notice--compact .lc-from { font-weight: 600; color: var(--ink); }
1151.local-notice--compact .lc-summary { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1152.local-notice--compact .lc-action {
1153 flex: 0 0 auto; background: none; border: none; padding: 0; font: inherit; font-weight: 600;
1154 color: var(--link); cursor: var(--ui-cursor, pointer);
1155}
1156.local-notice--compact .lc-action::before { content: "·"; margin-right: 5px; color: var(--muted); font-weight: 400; }
1157/* App-thread action line: a centered, muted /me-style record of an interaction
1158 ("added 'milk'") — deliberately unlike a chat bubble. */
1159.chatapp-action { align-self: center; text-align: center; color: var(--muted); font-size: 13px; padding: 3px 14px; margin: 10px 0; max-width: 90%; }
1160/* The "start of your chat history" intro: a calm, centered explainer pinned at
1161 the top of a bot's chat (a persistent first row that scrolls up with history). */
1162.chatapp-intro {
1163 align-self: center; max-width: 40ch; text-align: center;
1164 color: var(--muted); font-size: 14px; line-height: 1.5; padding: 18px 16px 22px;
1165}
1166
1167/* Text selection works everywhere now: desktop reacts with a stationary hold
1168 (drag-select cancels it) and touch reacts with a double-tap, so long-press is
1169 free for native selection/copy on mobile. */
1170.reactions { display: flex; flex-wrap: wrap; gap: 4px; margin: 3px 2px 0; }
1171.msg.me .reactions { justify-content: flex-end; }
1172.reactions:empty { display: none; }
1173.reaction-chip {
1174 background: var(--bubble-them);
1175 color: var(--ink);
1176 border: 1px solid transparent;
1177 border-radius: 999px;
1178 padding: 2px 9px;
1179 font-size: 13px;
1180 font-weight: 500;
1181 line-height: 1.35;
1182}
1183.reaction-chip.mine { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 16%, var(--panel)); }
1184.emoji-picker {
1185 position: fixed;
1186 display: flex;
1187 gap: 2px;
1188 background: var(--panel);
1189 border: 1px solid var(--border);
1190 border-radius: 999px;
1191 padding: 4px 6px;
1192 box-shadow: 0 12px 40px var(--shadow);
1193 z-index: 60;
1194}
1195.emoji-opt {
1196 background: transparent;
1197 font-size: 22px;
1198 padding: 4px 6px;
1199 border-radius: 50%;
1200 line-height: 1;
1201}
1202.emoji-opt:hover { background: var(--bubble-them); }
1203/* the "Reply" action in the message menu, set off from the emoji row */
1204.picker-action {
1205 background: transparent;
1206 color: var(--link);
1207 padding: 4px 8px;
1208 margin-left: 2px;
1209 border-radius: 50%;
1210 border-left: 1px solid var(--border);
1211 display: inline-flex;
1212 align-items: center;
1213 line-height: 1;
1214}
1215.picker-action svg { width: 22px; height: 22px; }
1216.picker-action:hover { background: var(--bubble-them); }
1217
1218/* mobile long-press message menu (WhatsApp-style): a dim backdrop, the emoji
1219 reactions floating above the message, and a labelled action menu below it
1220 (see openMessageSheet in shell.js). */
1221.msg-sheet {
1222 position: fixed;
1223 inset: 0;
1224 z-index: 200;
1225 background: rgba(0, 0, 0, 0.38);
1226 animation: fade-in 0.15s ease;
1227}
1228/* the lifted copy of the pressed message, above the dim (see openMessageSheet) */
1229.msg-sheet-clone {
1230 position: fixed;
1231 margin: 0 !important;
1232 pointer-events: none;
1233}
1234.msg-sheet-reactions,
1235.msg-sheet-menu {
1236 position: absolute;
1237 background: var(--panel);
1238 border: 1px solid var(--border);
1239 box-shadow: 0 12px 40px var(--shadow);
1240}
1241.msg-sheet-reactions {
1242 display: flex;
1243 gap: 2px;
1244 padding: 5px 7px;
1245 border-radius: 999px;
1246}
1247.sheet-emoji {
1248 background: transparent;
1249 font-size: 26px;
1250 line-height: 1;
1251 padding: 6px 8px;
1252 border-radius: 50%;
1253}
1254.sheet-emoji:active { background: var(--bubble-them); }
1255.msg-sheet-menu {
1256 min-width: 200px;
1257 border-radius: 14px;
1258 overflow: hidden;
1259}
1260.msg-sheet-item {
1261 display: flex;
1262 align-items: center;
1263 justify-content: space-between;
1264 gap: 18px;
1265 width: 100%;
1266 padding: 13px 16px;
1267 background: transparent;
1268 font-size: 16px;
1269 text-align: left;
1270 color: var(--ink);
1271}
1272.msg-sheet-item:active { background: var(--bubble-them); }
1273.msg-sheet-item.danger { border: none; color: var(--danger); } /* flat row, no pill border (see .menu-item.danger) */
1274.msg-sheet-icon { display: inline-flex; color: var(--muted); flex: none; }
1275.msg-sheet-item.danger .msg-sheet-icon { color: var(--danger); }
1276.msg-sheet-icon svg { width: 20px; height: 20px; }
1277
1278/* a reply: a tappable quote of the parent sits above the bubble (à la Messages),
1279 and the parent gets a "N Replies" tag below it. */
1280.reply-quote {
1281 align-self: stretch;
1282 text-align: left;
1283 max-width: 100%;
1284 background: transparent;
1285 color: var(--muted);
1286 border: 1px solid var(--border);
1287 border-radius: 14px;
1288 padding: 6px 11px;
1289 margin-bottom: 1px;
1290 font-weight: 400;
1291 white-space: normal;
1292}
1293.msg.me .reply-quote { text-align: right; }
1294.reply-quote-name { font-size: 11px; color: var(--muted); margin-bottom: 1px; }
1295.reply-quote-text {
1296 /* two notches under the bubble text at every size (15→13, touch 17→15) */
1297 font-size: calc(var(--msg-font-size) - 2px);
1298 color: var(--muted);
1299 max-width: 220px;
1300 overflow: hidden;
1301 text-overflow: ellipsis;
1302 white-space: nowrap;
1303}
1304.msg.me .reply-quote-text { margin-left: auto; }
1305/* The card's thread link: when the thread behind a quoted reply holds more than
1306 that one reply, the card wears the same link-coloured "N Replies" the root
1307 wears — appended inside the card (not a trailing chip, which would fight the
1308 name line and the ellipsized snippet for the card's one narrow row), so the
1309 whole card reads as one way in rather than a quote with a badge stuck on. */
1310.reply-quote-thread {
1311 font-size: 11px;
1312 font-weight: 600;
1313 color: var(--link);
1314 margin-top: 2px;
1315 font-variant-numeric: tabular-nums;
1316}
1317/* The card has always been the thread's other opener — give it the same visible
1318 focus ring the tally has, so keyboard travel through the feed shows it. */
1319.reply-quote:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
1320/* "N Replies" tag under a message that's been replied to (opens the thread view).
1321 A <button> like the quote card — keyboard-reachable, and the opener the view
1322 hands focus back to — so the base pill chrome is reset to the old tag look. */
1323.reply-count {
1324 font-size: 11px;
1325 font-weight: 600;
1326 color: var(--link);
1327 margin: 3px 6px 0;
1328 cursor: pointer;
1329 background: transparent;
1330 border: none;
1331 border-radius: 4px; /* a tidy focus ring, not the pill's */
1332 padding: 0;
1333 text-align: left; /* stretched under a .them bubble; me shrinks to content */
1334 line-height: inherit;
1335}
1336.reply-count[hidden] { display: none; }
1337.reply-count:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
1338/* flash a message when you jump to it from a quote */
1339.msg.flash { animation: flash-msg 1.2s ease; border-radius: var(--radius); }
1340@keyframes flash-msg {
1341 0%, 100% { background: transparent; }
1342 25% { background: color-mix(in srgb, var(--accent) 16%, transparent); }
1343}
1344/* Search landing (searchui.js): the found bubble gives a little wave — a
1345 gentle rock in place, no lift (a lift exposes .flash's row tint beneath
1346 and reads as a stray shadow). Pivot at the bubble's foot so it waves
1347 rather than spins. */
1348.msg.search-wave .bubble {
1349 animation: search-wave 0.85s ease-in-out;
1350 transform-origin: 50% 100%;
1351}
1352@keyframes search-wave {
1353 0%, 100% { transform: none; }
1354 20% { transform: rotate(-2.5deg); }
1355 45% { transform: rotate(2.5deg); }
1356 70% { transform: rotate(-1.5deg); }
1357 85% { transform: rotate(0.5deg); }
1358}
1359/* Reduced motion: no movement — the dashed ring the wave replaced, fading
1360 away on its own. (After the base rule so it wins on equal specificity.) */
1361@media (prefers-reduced-motion: reduce) {
1362 .msg.search-wave .bubble { animation: search-found 2s ease forwards; }
1363 @keyframes search-found {
1364 0%, 50% { outline: 2px dashed var(--accent); outline-offset: 3px; }
1365 100% { outline: 2px dashed transparent; outline-offset: 3px; }
1366 }
1367}
1368/* ---- reply-thread view (apps/chat.js): tap a quote or a "N Replies" tally and
1369 the thread lifts over the transcript, iMessage-style — one scrolling list
1370 whose FIRST message is the one the thread is about, its replies under it,
1371 the real composer beneath staged to the root. (No pinned root box: a thread
1372 is a small chat window, and pinning its opening message split the panel in
1373 two — a message, a rule, and an empty pane whenever nobody had replied yet.)
1374 The overlay lives inside .chatapp-feedwrap, so it covers exactly the feed's
1375 box and never the composer. All colors are theme vars → light/dark for free. */
1376.chatapp-thread { position: absolute; inset: 0; z-index: 30; display: flex; }
1377.chatapp-thread-dim {
1378 position: absolute;
1379 inset: 0;
1380 /* lighter than the msg-sheet dim: the frosted panel reads the transcript
1381 through itself, so the backdrop only needs to recede, not vanish */
1382 background: rgba(0, 0, 0, 0.25);
1383 animation: fade-in 0.15s ease;
1384}
1385.chatapp-thread-panel {
1386 position: relative; /* above the dim */
1387 margin: 10px;
1388 flex: 1;
1389 min-height: 0;
1390 min-width: 0;
1391 display: flex;
1392 flex-direction: column;
1393 background: var(--panel);
1394 border: 1px solid var(--border);
1395 border-radius: 16px;
1396 box-shadow: 0 12px 40px var(--shadow);
1397 overflow: hidden;
1398 animation: thread-rise 0.18s cubic-bezier(0.2, 0.8, 0.2, 1);
1399}
1400@keyframes thread-rise { from { opacity: 0; transform: translateY(12px); } }
1401/* Frosted glass: the conversation stays visible under the lifted thread — a
1402 translucent panel over a backdrop blur, both themes via --panel. The solid
1403 var(--panel) above is the fallback where backdrop-filter/color-mix aren't
1404 supported (a see-through panel with NO blur would be unreadable). */
1405@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) and (background: color-mix(in srgb, red 50%, transparent)) {
1406 .chatapp-thread-panel {
1407 background: color-mix(in srgb, var(--panel) 72%, transparent);
1408 -webkit-backdrop-filter: blur(18px) saturate(1.15);
1409 backdrop-filter: blur(18px) saturate(1.15);
1410 }
1411}
1412@media (prefers-reduced-motion: reduce) {
1413 .chatapp-thread-dim,
1414 .chatapp-thread-panel { animation: none; }
1415}
1416/* embedded in the native shells: keep the panel clear of the notch/home bar */
1417.native-embed .chatapp-thread-panel {
1418 margin-top: calc(10px + var(--native-top-inset, 0px));
1419 margin-bottom: calc(10px + var(--native-bottom-inset, 0px));
1420}
1421.chatapp-thread-head {
1422 display: flex;
1423 align-items: baseline;
1424 gap: 8px;
1425 padding: 12px 14px 10px;
1426 border-bottom: 1px solid var(--border);
1427}
1428.chatapp-thread-title { font-weight: 700; font-size: 15px; color: var(--ink); }
1429.chatapp-thread-count { flex: 1; min-width: 0; font-size: 12px; color: var(--muted); }
1430.chatapp-thread-x {
1431 flex: none;
1432 align-self: center;
1433 width: 28px;
1434 height: 28px;
1435 padding: 0;
1436 border-radius: 50%;
1437 background: transparent;
1438 color: var(--muted);
1439 display: inline-flex;
1440 align-items: center;
1441 justify-content: center;
1442}
1443.chatapp-thread-x svg { width: 16px; height: 16px; }
1444.chatapp-thread-x:hover { background: var(--bubble-them); }
1445.chatapp-thread-note {
1446 padding: 8px 16px;
1447 font-size: 12px;
1448 color: var(--muted);
1449 border-bottom: 1px solid var(--border);
1450}
1451.chatapp-thread-note.hidden { display: none; }
1452.chatapp-thread-msgs {
1453 flex: 1;
1454 min-height: 0;
1455 overflow-y: auto;
1456 overflow-x: hidden; /* clip mid swipe-to-reply, like the feed */
1457 padding: 12px 16px 16px;
1458 display: flex;
1459 flex-direction: column;
1460 gap: 4px;
1461}
1462/* A short thread sits at the BOTTOM of the list, against the composer, the way
1463 a short conversation does in the feed (.chatapp-feed's identical rule) and on
1464 native (.defaultScrollAnchor(.bottom)) — the thread is a chat window, so it
1465 fills like one. */
1466.chatapp-thread-msgs::before { content: ""; margin-top: auto; }
1467/* inside the view, every row is already the thread — a per-row quote card, a
1468 tally, or a "Reply in thread" icon would only point back at where you're
1469 standing (the verb resolves to this same root, so the button is a no-op that
1470 still costs a slot in a four-icon strip). display:none also drops it from
1471 the tab order, so keyboard users don't hit a dead control either. */
1472.chatapp-thread .reply-quote,
1473.chatapp-thread .reply-count,
1474.chatapp-thread .thread-hover { display: none; }
1475/* ---- docked presentation (apps/chat.js): at desktop widths the thread parks
1476 as a flush right column beside the transcript instead of lifting over it —
1477 the command panel's side-dock technique (apps.js .cmd-panel--side) under
1478 thread-owned class names. The root reserves the gutter with padding so the
1479 column takes real layout space; the pane carries its own root-pinned
1480 composer at the bottom (.chatapp-thread-composer). Solid var(--panel), not
1481 the overlay's frosted glass — nothing shows through a flush column — with
1482 the frosted @supports rule above (same specificity) overridden by source
1483 order; light-dark() tokens hold both themes for free. Below 721px these
1484 rules stand down and a still-mounted docked panel degrades to an in-flow
1485 band, the same accepted trade-off as .cmd-panel--side (width is decided at
1486 open time; no live resize listeners anywhere). */
1487.chatapp-thread-panel--docked .reply-quote,
1488.chatapp-thread-panel--docked .reply-count,
1489.chatapp-thread-panel--docked .thread-hover { display: none; } /* same self-pointing-row rule as the overlay (no .chatapp-thread wrapper here) */
1490@media (min-width: 721px) {
1491 .chatapp-root.has-thread-pane { padding-right: min(340px, 40%); }
1492 .chatapp-thread-panel--docked {
1493 position: absolute; top: 0; right: 0; bottom: 0; width: min(340px, 40%);
1494 margin: 0; border-radius: 0; box-shadow: none;
1495 border: none; border-left: 1px solid var(--border);
1496 background: var(--panel);
1497 -webkit-backdrop-filter: none;
1498 backdrop-filter: none;
1499 }
1500 .chatapp-thread-composer { flex: 0 0 auto; border-top: 1px solid var(--border); }
1501}
1502@media (pointer: coarse) {
1503 /* long-press opens the message menu inside the pane too — suppress the
1504 native selection/callout the same way the overlay's bubbles do */
1505 .chatapp-thread-panel--docked .bubble { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
1506}
1507
1508/* ---- the ▤ Threads list (apps/chat.js threadListItems): one row per thread
1509 in this chat, rendered into an ordinary side command panel. Rows are real
1510 buttons — keyboard-reachable, with a visible ring — and the tally sits in
1511 its own column so the numbers line up down the list rather than trailing
1512 ragged after snippets of every length. */
1513.thread-list { display: flex; flex-direction: column; gap: 2px; }
1514.thread-list-empty { margin: 4px 0; font-size: 13px; color: var(--muted); line-height: 1.45; }
1515.thread-list-row {
1516 display: flex; align-items: baseline; gap: 10px; width: 100%;
1517 min-height: 34px; padding: 7px 10px;
1518 border: none; border-radius: 10px; background: none;
1519 color: var(--ink); font: inherit; text-align: left; cursor: pointer;
1520}
1521.thread-list-row:hover { background: var(--bubble-them); }
1522.thread-list-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; background: var(--bubble-them); }
1523/* One line per row: the author's name leads, the snippet trails off. */
1524.thread-list-line {
1525 flex: 1 1 auto; min-width: 0; font-size: 13px; line-height: 1.35;
1526 overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
1527}
1528.thread-list-who { font-weight: 600; }
1529.thread-list-snippet { color: var(--muted); }
1530.thread-list-count { flex: 0 0 auto; font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }
1531
1532/* composer "Replying to …" / "Editing message" banners, above the composer
1533 (and any attachment preview). They share the .reply-bar inner markup. */
1534.composer-reply,
1535.composer-edit {
1536 padding: 8px 14px;
1537 border-top: 1px solid var(--border);
1538 background: var(--panel);
1539}
1540.composer-reply.hidden,
1541.composer-edit.hidden { display: none; }
1542.composer-edit { display: flex; align-items: center; justify-content: space-between; color: var(--muted); font-size: 14px; }
1543.banner-x { background: transparent; color: var(--muted); font-size: 20px; line-height: 1; padding: 0 6px; }
1544.reply-bar { display: flex; align-items: center; gap: 10px; }
1545.reply-bar-icon { flex: none; color: var(--link); display: inline-flex; }
1546.reply-bar-icon svg { width: 18px; height: 18px; }
1547.reply-bar-body {
1548 flex: 1;
1549 min-width: 0;
1550 border-left: 2px solid var(--accent);
1551 padding-left: 10px;
1552}
1553.reply-bar-name { font-size: 12px; font-weight: 600; color: var(--ink); }
1554.reply-bar-text {
1555 font-size: 13px;
1556 color: var(--muted);
1557 overflow: hidden;
1558 text-overflow: ellipsis;
1559 white-space: nowrap;
1560}
1561.reply-bar-cancel {
1562 flex: none;
1563 width: 26px;
1564 height: 26px;
1565 padding: 0;
1566 border-radius: 50%;
1567 background: transparent;
1568 color: var(--muted);
1569 display: inline-flex;
1570 align-items: center;
1571 justify-content: center;
1572}
1573.reply-bar-cancel svg { width: 15px; height: 15px; }
1574.reply-bar-cancel:hover { background: var(--bubble-them); }
1575
1576/* Subtle "… is typing" line. It renders INSIDE the feed, below the newest
1577 message (shell.js appends/removes it), not in a band between feed and
1578 composer: a band resized the feed as it toggled — jolting the stream — and
1579 the native shells float their composer over the page bottom, which covered
1580 it entirely. In-stream it scrolls with the messages, like an incoming one. */
1581.typing {
1582 align-self: flex-start;
1583 max-width: 78%;
1584 margin-top: 10px;
1585 padding: 0 6px;
1586 font-size: 12px;
1587 font-style: italic;
1588 color: var(--muted);
1589 overflow: hidden;
1590 text-overflow: ellipsis;
1591 white-space: nowrap;
1592 /* Make the row behave like an incoming .msg — the feed is a flex column, so a
1593 bare row misbehaves two ways. flex-shrink:0: with overflow:hidden a flex
1594 item's auto min-height is 0, so the feed (once it overflows) shrinks this
1595 row to nothing — the group "X is typing…" line was present but 0px tall.
1596 position:relative: like .msg, it makes the row its own containing block so
1597 the bubble's overflow:visible (the dots bobbing out of the box) can't
1598 propagate up and give the whole app a second scrollbar. */
1599 flex-shrink: 0;
1600 position: relative;
1601}
1602/* DM variant: an iMessage-style incoming bubble with pulsing dots — the typer
1603 can only be the one other person, so no name (groups keep the line above). */
1604.typing.typing-bubble {
1605 display: flex;
1606 align-items: center;
1607 gap: 3px;
1608 padding: 13px 14px;
1609 border-radius: var(--radius);
1610 border-bottom-left-radius: 6px;
1611 background: var(--bubble-them);
1612 overflow: visible; /* the dots bob upward, out of the content box */
1613}
1614.typing-bubble .dot {
1615 width: 8px;
1616 height: 8px;
1617 border-radius: 50%;
1618 background: color-mix(in srgb, var(--ink) 45%, transparent);
1619 animation: typing-dot 1.3s ease-in-out infinite;
1620}
1621.typing-bubble .dot:nth-child(2) { animation-delay: 0.18s; }
1622.typing-bubble .dot:nth-child(3) { animation-delay: 0.36s; }
1623@keyframes typing-dot {
1624 0%, 60%, 100% { opacity: 0.35; transform: translateY(0); }
1625 30% { opacity: 1; transform: translateY(-3px); }
1626}
1627@media (prefers-reduced-motion: reduce) {
1628 .typing-bubble .dot { animation: none; opacity: 0.5; }
1629}
1630/* screen-reader-only text (the bubble's dots carry no words) */
1631.sr-only {
1632 position: absolute;
1633 width: 1px;
1634 height: 1px;
1635 overflow: hidden;
1636 clip-path: inset(50%);
1637 white-space: nowrap;
1638}
1639
1640/* composer autocomplete (:emoji / @mention / slash) — a drop-UP that OVERLAYS the
1641 feed rather than a flex band that shoves the messages up as you type. It's
1642 anchored to .composer-float (the zero-height anchor at the top of the composer
1643 stack), so it floats over the messages and rises with any reply/edit banner. */
1644.typeahead {
1645 position: absolute;
1646 bottom: 6px;
1647 left: 8px;
1648 right: 8px;
1649 max-height: 216px;
1650 overflow-y: auto;
1651 border: 1px solid var(--border);
1652 border-radius: 14px;
1653 background: var(--panel);
1654 box-shadow: var(--shadow);
1655 /* Clip the full-bleed active row to the rounded corners (overflow-y already
1656 makes this a clipping container). */
1657 overflow-x: hidden;
1658}
1659.typeahead.hidden { display: none; }
1660.ta-item {
1661 display: flex;
1662 align-items: center;
1663 gap: 10px;
1664 width: 100%;
1665 text-align: left;
1666 background: transparent;
1667 color: var(--ink);
1668 border: none;
1669 border-radius: 0; /* override the global pill button radius — the active row fills edge to edge */
1670 padding: 8px 12px;
1671 font-size: 15px;
1672}
1673.ta-item.active { background: var(--bubble-them); }
1674.ta-item .avatar { width: 28px; height: 28px; font-size: 13px; flex: none; }
1675.ta-emoji { font-size: 20px; width: 24px; text-align: center; flex: none; }
1676.ta-code { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1677.ta-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1678/* Command typeahead: the "/todo" name, then a muted one-line description after it. */
1679.ta-desc { color: var(--muted); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1680/* an @mention inside a message — just bold; it inherits the bubble ink, so it
1681 stays legible in both the grey "them" and accent "me" bubbles. */
1682.mention { font-weight: 600; }
1683.mention-link { cursor: pointer; }
1684.mention-link:hover { text-decoration: underline; text-underline-offset: 2px; }
1685
1686.composer {
1687 display: flex;
1688 gap: 8px;
1689 align-items: flex-end;
1690 /* Desktop (fine pointer): the input hugs the bottom — env() is 0, so no fixed
1691 bottom padding. Touch devices get breathing room + the safe-area inset below
1692 (see the pointer:coarse rule), which desktop never picks up. */
1693 padding: 12px 16px;
1694 padding-bottom: env(safe-area-inset-bottom, 0px);
1695 background: var(--panel);
1696}
1697/* Touch: 12px of breathing room ABOVE the Home indicator / swipe bar (the inset),
1698 so the input never sits flush against the bottom edge or a browser toolbar.
1699 Scoped to coarse pointers so desktop stays at the bare inset (≈0). */
1700@media (pointer: coarse) {
1701 .composer { padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px)); }
1702 /* While typing, the keyboard covers the home-indicator inset, so drop it entirely
1703 (the composer sits directly on the keyboard) — .editing is toggled on focus. */
1704 .composer.editing { padding-bottom: 0; }
1705 /* Long-press opens the message menu, so suppress the native selection/callout that
1706 would otherwise fight it (Copy lives in the menu instead). */
1707 .feed .bubble, .chatapp-feed .bubble, .chatapp-thread .bubble { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
1708 /* A phone is held further from the eye than a laptop screen sits — bump the
1709 core reading + typing text up a notch so messages are comfortable. Desktop
1710 (fine pointer) keeps its sizes. Composer stays ≥16px so iOS doesn't
1711 auto-zoom the page when the field is focused. */
1712 :root { --msg-font-size: 17px; }
1713 /* iOS/iPadOS honour the system Text Size (Dynamic Type): -apple-system-body
1714 tracks it, so a member who's set large type gets large messages. Browsers
1715 without the keyword (Android) drop the whole shorthand and keep the 17px
1716 variable from the base rule; the two declarations after it restore what
1717 the shorthand resets. Higher-specificity sizes (.jumbo-emoji) still win. */
1718 .msg .bubble { font: -apple-system-body; font-family: inherit; line-height: 1.35; }
1719 .composer .composer-input { font-size: 17px; }
1720 .thread-row-name { font-size: 17px; }
1721 .thread-row-preview { font-size: 14px; }
1722}
1723.composer-field {
1724 position: relative;
1725 flex: 1;
1726 display: flex;
1727}
1728/* The inset field label ("Your name:") — the Mail/Messages "To:" pattern: a
1729 quiet accent pill pinned INSIDE the input on its first line; chat.js indents
1730 the textarea's text past it. Absolute (over the textarea, which owns the box
1731 visuals) and inert to the pointer; top 9px centres it in the 40px rest
1732 height and keeps it on line one as the box grows. */
1733.composer-label {
1734 position: absolute;
1735 /* Above the input: the rich composer's contenteditable is position:relative
1736 (unlike the classic textarea), so without a z-index its opaque pill paints
1737 over this label in DOM order. */
1738 z-index: 1;
1739 left: 12px;
1740 top: 9px;
1741 padding: 1px 9px;
1742 border-radius: 999px;
1743 /* The theme's designed on-accent pairing — the one combination every theme
1744 guarantees AA contrast for (a tinted-text pill fails on amber). */
1745 background: var(--accent);
1746 color: var(--accent-ink);
1747 font-size: 12.5px;
1748 font-weight: 600;
1749 line-height: 20px;
1750 white-space: nowrap;
1751 pointer-events: none;
1752 opacity: 1;
1753 transition: opacity 120ms ease;
1754}
1755.composer-label[hidden] {
1756 display: block; /* keep it measurable/animatable; hidden = faded out */
1757 opacity: 0;
1758}
1759@media (prefers-reduced-motion: reduce) {
1760 .composer-label { transition: none; }
1761}
1762/* The labelled ask's dismiss X (#1375): sits in the send button's slot while
1763 that's free — same geometry, quiet ink instead of accent fill. Focusable,
1764 ≥24px target (34px box, and the same rightward tap-target extension as
1765 Send would be overkill: the X is a secondary action). */
1766.composer-label-x {
1767 position: absolute;
1768 right: 4px;
1769 bottom: 3px;
1770 width: 34px;
1771 height: 34px;
1772 padding: 0;
1773 border-radius: 50%;
1774 display: inline-flex;
1775 align-items: center;
1776 justify-content: center;
1777 border: 0;
1778 background: none;
1779 color: inherit;
1780 opacity: 0.55;
1781 cursor: pointer;
1782}
1783.composer-label-x:hover,
1784.composer-label-x:focus-visible {
1785 opacity: 1;
1786 background: var(--input-bg);
1787}
1788.composer-label-x[hidden] { display: none; }
1789.composer .composer-input {
1790 flex: 1;
1791 resize: none;
1792 max-height: 140px;
1793 /* 8+8 padding + 22 line + 2 border = 40px at rest. Symmetric so the text is
1794 vertically centred in the box; right padding leaves room for the send button.
1795 Type at the reading size; past 16px the 22px line would get cramped, so the
1796 line grows with the font from there (and the box grows with it). */
1797 padding: 8px 46px 8px 14px;
1798 font-size: var(--msg-font-size);
1799 line-height: max(22px, 1.35em);
1800 min-height: 40px;
1801 border-radius: 20px;
1802 font-family: inherit;
1803 /* Autogrow caps at max-height (see the JS); past that the field must scroll its
1804 own overflow rather than swallow the earlier lines — so overflow-y is auto,
1805 not hidden. overflow-x stays hidden since the text wraps. */
1806 overflow-x: hidden;
1807 overflow-y: auto;
1808 /* The shared lifted-panel input fill (see --input-bg / .eleven-input). */
1809 background: var(--input-bg);
1810}
1811/* The rich composer (composer-pm.js): a ProseMirror contenteditable wearing
1812 the same skin. Auto-grow is pure CSS here — min/max-height + overflow — so
1813 there is no JS measuring to go stale (the lesson from the reverted native
1814 attempt). The textarea's implicit border comes back explicitly. */
1815.composer .composer-input[contenteditable] {
1816 position: relative;
1817 border: 1px solid var(--border);
1818 white-space: pre-wrap;
1819 overflow-wrap: break-word;
1820 color: var(--ink);
1821 caret-color: var(--accent);
1822}
1823.composer .composer-input[contenteditable] p { margin: 0; }
1824/* In-composer blocks: the bubble treatments at composer scale. */
1825.composer .composer-input[contenteditable] ul,
1826.composer .composer-input[contenteditable] ol { margin: 2px 0; padding-left: 22px; }
1827.composer .composer-input[contenteditable] blockquote {
1828 margin: 2px 0;
1829 padding: 1px 0 1px 10px;
1830 border-left: 3px solid color-mix(in srgb, var(--ink) 30%, transparent);
1831 color: color-mix(in srgb, var(--ink) 72%, transparent);
1832}
1833.composer .composer-input[contenteditable] pre {
1834 margin: 2px 0;
1835 padding: 6px 8px;
1836 border-radius: 8px;
1837 background: color-mix(in srgb, var(--ink) 8%, transparent);
1838 font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1839 font-size: 0.92em;
1840 overflow-x: auto;
1841 white-space: pre;
1842}
1843.composer .composer-input[contenteditable] code {
1844 font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1845 font-size: 0.92em;
1846 background: color-mix(in srgb, var(--ink) 8%, transparent);
1847 padding: 1px 5px;
1848 border-radius: 5px;
1849}
1850.composer .composer-input[contenteditable] pre code { background: none; padding: 0; font-size: 1em; }
1851/* Placeholder: contenteditable has no native one — the pm-empty class is
1852 maintained by the backend; float keeps the caret on the first line. */
1853/* Theme-sheet composer preference rows (#531) */
1854.composer-pref-row {
1855 display: flex;
1856 align-items: center;
1857 gap: 10px;
1858 margin: 8px 0;
1859 font-size: 14px;
1860 color: var(--ink);
1861 cursor: pointer;
1862}
1863.composer-pref-row input[type="checkbox"] { accent-color: var(--accent); width: 18px; height: 18px; margin: 0; }
1864.composer-pref-hint { margin: -4px 0 8px 28px; font-size: 12px; color: var(--muted); }
1865.composer .composer-input.pm-empty::before {
1866 content: attr(data-placeholder);
1867 color: var(--muted);
1868 float: left;
1869 height: 0;
1870 pointer-events: none;
1871}
1872
1873/* eleven-input — the reusable text-input surface: the same lifted-panel fill the
1874 chat composer uses, so inputs across the app and its mini-apps read as one.
1875 Layer your own shape (radius override, min-height, monospace, …) on top. */
1876.eleven-input {
1877 background: var(--input-bg);
1878 color: var(--ink);
1879 border: 1px solid var(--border);
1880 border-radius: var(--radius);
1881 font-family: inherit;
1882}
1883.eleven-input:focus { outline: none; border-color: var(--accent); }
1884/* circular up-arrow send button, tucked into the bottom-right of the field */
1885.send-btn {
1886 position: absolute;
1887 right: 4px;
1888 bottom: 3px;
1889 width: 34px;
1890 height: 34px;
1891 padding: 0;
1892 border-radius: 50%;
1893 display: inline-flex;
1894 align-items: center;
1895 justify-content: center;
1896 background: var(--accent);
1897 color: var(--accent-ink);
1898}
1899.send-btn[hidden] { display: none; }
1900.send-btn:disabled { opacity: 0.45; cursor: default; }
1901/* A send queued behind an in-flight upload (Enter mid-upload — see chat.js
1902 sendQueued): swap the arrow for a spinner, at full strength so it reads as
1903 "working", not the greyed can't-send state. */
1904.send-btn.send-waiting svg { display: none; }
1905.send-btn.send-waiting:disabled { opacity: 1; }
1906.send-btn.send-waiting::before {
1907 content: "";
1908 width: 16px; height: 16px; border-radius: 50%;
1909 border: 2px solid color-mix(in srgb, var(--accent-ink) 35%, transparent);
1910 border-top-color: var(--accent-ink);
1911 animation: spin 0.8s linear infinite; /* @keyframes spin, top of file */
1912}
1913/* Enlarge the send button's tap target — mostly to the right, into the composer's
1914 padding — so a near-miss on touch triggers Send instead of falling through to the
1915 field/feed and dismissing the keyboard. */
1916.send-btn::after {
1917 content: "";
1918 position: absolute;
1919 top: -10px;
1920 right: -20px;
1921 bottom: -10px;
1922 left: -6px;
1923}
1924/* left-side attach (+) button */
1925.attach-btn {
1926 flex: none;
1927 width: 40px;
1928 height: 40px;
1929 padding: 0;
1930 border-radius: 50%;
1931 display: inline-flex;
1932 align-items: center;
1933 justify-content: center;
1934 background: var(--bubble-them);
1935 color: var(--ink);
1936 font-size: 24px;
1937 line-height: 1;
1938}
1939
1940
1941/* invite QR (admin) — full-screen opaque takeover so nothing else is on screen */
1942.qr-screen {
1943 position: fixed;
1944 inset: 0;
1945 z-index: 50;
1946 background: var(--bg);
1947 overflow: auto;
1948}
1949.qr-modal {
1950 margin: 8vh auto;
1951 max-width: 360px;
1952 text-align: center;
1953 border: none;
1954 box-shadow: none;
1955}
1956.qr-modal h2 { margin: 0 0 6px; font-size: 20px; }
1957.qr-wrap {
1958 display: flex;
1959 justify-content: center;
1960 margin: 18px 0;
1961}
1962.qr-wrap canvas {
1963 max-width: 100%;
1964 height: auto;
1965 image-rendering: pixelated; /* keep modules crisp when CSS scales the canvas down */
1966 border-radius: 8px;
1967}
1968.qr-modal .row { justify-content: center; }
1969
1970/* input + button on one row (e.g. Create invite) */
1971.inline-form {
1972 display: flex;
1973 gap: 10px;
1974 align-items: stretch;
1975}
1976.inline-form input {
1977 flex: 1;
1978 min-width: 0;
1979}
1980
1981.banner {
1982 text-align: center;
1983 font-size: 13px;
1984 color: var(--muted);
1985 padding: 6px;
1986}
1987.banner.warn { color: var(--warn); }
1988
1989
1990/* ---- admin ---- */
1991.admin { max-width: 760px; margin: 0 auto; padding: 24px; }
1992.admin h1 { margin: 0 0 4px; }
1993.admin .sub { color: var(--muted); margin: 0 0 24px; }
1994.admin section {
1995 background: var(--panel);
1996 border: 1px solid var(--border);
1997 border-radius: 18px;
1998 padding: 18px;
1999 margin-bottom: 18px;
2000}
2001.admin section h2 { margin: 0 0 12px; font-size: 16px; }
2002.invite {
2003 display: flex;
2004 align-items: center;
2005 gap: 10px;
2006 padding: 12px 0;
2007 border-top: 1px solid var(--border);
2008}
2009.invite:first-of-type { border-top: none; }
2010.invite .meta { flex: 1; min-width: 0; }
2011.invite .meta .label { font-weight: 600; }
2012.invite .meta .status { font-size: 12px; color: var(--muted); }
2013.invite .device-note { flex: none; max-width: 220px; font-size: 12px; color: var(--muted); text-align: right; }
2014/* custom-app rows: the name is an identifier, so it's mono — but row-title sized,
2015 not .mono's 12px */
2016.invite .label.mono { font-size: 14px; }
2017.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
2018.key-box {
2019 background: var(--bubble-them);
2020 border-radius: 12px;
2021 padding: 12px;
2022 word-break: break-all;
2023 margin: 10px 0;
2024}
2025.hidden { display: none !important; }
2026.toast {
2027 position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%);
2028 background: var(--pill-bg); color: var(--pill-ink); padding: 10px 16px; border-radius: 999px;
2029 font-size: 14px; opacity: 0; transition: opacity .2s; pointer-events: none;
2030 z-index: 100; /* above full-screen overlays (qr-screen) so it's never hidden */
2031}
2032.toast.show { opacity: 1; }
2033
2034
2035/* full-screen onboarding cards */
2036.card.onboard { text-align: center; }
2037.card.onboard .row { justify-content: center; }
2038.card.onboard p { margin-bottom: 14px; }
2039.onboard-icon { font-size: 46px; line-height: 1; margin-bottom: 10px; }
2040/* the instance branding photo, when set — replaces the emoji on onboarding cards */
2041.onboard-photo {
2042 width: 72px; height: 72px; border-radius: 18px; object-fit: cover;
2043 margin: 0 auto 12px; display: block;
2044}
2045/* the browser-only caveat on the welcome card: present but quiet, with clear
2046 air between it and the button it annotates. (.card selectors so this outranks
2047 the generic .card p margin rule.) */
2048.card .welcome-caveat { font-size: 13px; color: var(--muted); margin: 20px auto 4px; max-width: 42ch; }
2049/* A quiet escape hatch on the onboarding cards (e.g. the prejoin card's
2050 "Continue without signing in"): small print that acts, not a competing
2051 button — a real <button> so it stays keyboard-operable and focusable. */
2052button.link-quiet {
2053 background: none;
2054 color: var(--muted);
2055 font-size: 13px;
2056 font-weight: 400;
2057 text-decoration: underline;
2058 padding: 6px 8px; /* keeps the tap target ≥24px despite the small type */
2059}
2060/* the space's own description on the welcome card: the admin's note to the
2061 person joining — content to read, not chrome. A tinted panel with
2062 full-strength text sets it apart from the muted boilerplate around it. */
2063.card .welcome-desc {
2064 font-size: 15px; color: var(--ink); text-align: left; line-height: 1.5;
2065 background: color-mix(in srgb, var(--accent) 8%, transparent);
2066 border-radius: 12px; padding: 12px 16px; margin: 2px auto 18px; max-width: 46ch;
2067 white-space: pre-wrap; overflow-wrap: anywhere;
2068}
2069.steps {
2070 text-align: left;
2071 color: var(--muted);
2072 line-height: 1.6;
2073 padding-left: 20px;
2074 margin: 4px 0 18px;
2075}
2076.steps li { margin-bottom: 6px; }
2077.steps strong { color: var(--ink); }
2078
2079/* admin: a labelled checkbox row (e.g. "let members invite people") */
2080.toggle-row { display: flex; align-items: center; gap: 10px; cursor: pointer; font-weight: 600; }
2081/* profile sheet: the Away toggle + its inline error line (never a toast) */
2082.away-row { margin-top: 14px; font-size: 14px; justify-content: space-between; }
2083.away-note { color: var(--muted); font-size: 13px; min-height: 1em; margin: 4px 0 0; }
2084/* A slider switch (settings idiom) — a styled checkbox, so behaviour/forms
2085 stay stock. input.switch outranks the generic .toggle-row input sizing. */
2086input.switch {
2087 -webkit-appearance: none; appearance: none; margin: 0; flex: none;
2088 width: 42px; height: 26px; border-radius: 13px; position: relative;
2089 background: var(--bubble-them); cursor: pointer; transition: background 0.15s;
2090}
2091input.switch::after {
2092 content: ""; position: absolute; top: 2px; left: 2px; width: 22px; height: 22px;
2093 border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
2094 transition: left 0.15s;
2095}
2096input.switch:checked { background: var(--accent); }
2097input.switch:checked::after { left: 18px; }
2098input.switch:disabled { opacity: 0.5; cursor: default; }
2099/* BOT badge on admin People rows (shell.js has its own inline-styled agentTag) */
2100.bot-tag {
2101 margin-left: 6px; font-size: 0.7em; font-weight: 600; letter-spacing: 0.03em;
2102 padding: 1px 5px; border-radius: 6px; background: var(--accent, #888); color: #fff; vertical-align: middle;
2103}
2104.toggle-row input { width: 18px; height: 18px; }
2105.toggle-row input.switch { width: 42px; height: 26px; } /* the switch keeps its own size */
2106
2107/* pending DM: waiting for an invited person to join (web/static/shell.js). While
2108 waiting there's no one to talk to, so the feed + composer are hidden and a single
2109 centered card fills the pane. It clears once the invitee joins and chats.
2110 .app-pane is included so the chat mini-app's full-pane feed/composer (which
2111 mount there instead of .feed/.composer) get the same treatment. */
2112.thread-view.waiting .feed,
2113.thread-view.waiting .app-pane,
2114.thread-view.waiting .composer,
2115.thread-view.waiting .composer-reply,
2116.thread-view.waiting .composer-edit,
2117.thread-view.waiting .composer-preview,
2118.thread-view.waiting .typing { display: none !important; }
2119.pending-banner {
2120 flex: 1;
2121 min-height: 0;
2122 display: flex;
2123 align-items: center;
2124 justify-content: center;
2125 padding: 24px;
2126 overflow-y: auto;
2127}
2128.pending-card {
2129 width: 100%;
2130 max-width: 580px; /* wide enough that a typical invite link fits on one line */
2131 text-align: center;
2132 padding: 28px 24px;
2133 border: 1px solid var(--border);
2134 border-radius: 24px; /* the onboarding-card radius — same family as join */
2135 background: var(--bubble-them);
2136}
2137.pending-card .pending-avatar { margin: 0 auto 4px; }
2138.pending-card .pending-title {
2139 font-family: var(--font-display);
2140 font-weight: 700;
2141 font-size: 20px;
2142 margin: 8px 0 6px;
2143}
2144.pending-card p { margin: 0 0 16px; font-size: 15px; color: var(--muted); }
2145/* The link well hugs the link (centered), so it wraps only when the pane is
2146 genuinely too narrow for one line. */
2147.pending-card .key-box {
2148 width: fit-content; max-width: 100%;
2149 margin: 0 auto 16px; text-align: left; background: var(--panel);
2150}
2151/* display: flex is load-bearing: this card is .pending-card, not .card, so it
2152 never inherits .card .row's flex — without it the gap and centering are
2153 no-ops and the buttons sit flush. */
2154.pending-card .row { display: flex; justify-content: center; gap: 10px; flex-wrap: wrap; }
2155.linkish.danger { color: var(--danger); }
2156
2157/* Per-viewer names (#577): a DM partner renamed themself while you hold your
2158 own name for them. A slim inline row between header and feed — it occupies
2159 layout space (never floats over content) and clears once you choose. The
2160 choices wear the action-chip idiom (the app's one-tap-decision shape, same
2161 as a helper's chips), so "the app is asking you something" reads instantly;
2162 the two NAMES carry the emphasis, the frame stays quiet. */
2163.rename-banner {
2164 flex: none;
2165 display: flex;
2166 align-items: center;
2167 justify-content: center;
2168 gap: 8px;
2169 flex-wrap: wrap;
2170 padding: 8px 12px;
2171 font-size: 14px;
2172 color: var(--ink);
2173 background: var(--panel);
2174 border-bottom: 1px solid var(--border);
2175}
2176.rename-banner .rename-msg { margin-right: 2px; }
2177.rename-name { font-weight: 600; }
2178.rename-banner .action-chip:focus-visible,
2179.rename-note .action-chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
2180/* The same choice on a profile card (the group-chat path to it) — identical
2181 chips, so both surfaces of the one decision look like the one decision. */
2182.rename-note {
2183 display: flex;
2184 align-items: center;
2185 justify-content: center;
2186 gap: 8px;
2187 flex-wrap: wrap;
2188 margin: 2px 0 8px;
2189 font-size: 14px;
2190 color: var(--ink);
2191}
2192/* The profile card's "what I call them" block: the Goes-by fact, the rename
2193 entry point, and the inline editor. */
2194.nick-area { text-align: center; margin: 2px 0 4px; }
2195.nick-area .nick-input {
2196 padding: 8px 12px;
2197 width: 240px;
2198 max-width: 100%;
2199 text-align: center;
2200 font-size: 15px;
2201}
2202.nick-area .row { display: flex; justify-content: center; gap: 10px; margin-top: 8px; }
2203
2204/* ---- multi-thread layout (docs/threads-and-dms.md) ---- */
2205.tt { display: flex; flex-direction: column; height: 100%; width: 100%; background: var(--panel); }
2206/* The sidebar + thread row lives below the (usually absent) integrity banner. */
2207.tt-body { display: flex; flex: 1; min-height: 0; width: 100%; }
2208
2209/* Tamper-evidence failure banner: a full-width band across the top of the whole
2210 shell, shown ONLY on an unresolved integrity alert (fork/rollback/withheld/
2211 bad-signature). Amber, with a ⚠️ glyph so it's never colour-alone; occupies
2212 layout (pushes the shell down), never a floating overlay. */
2213.integrity-banner {
2214 flex: none;
2215 display: flex;
2216 align-items: flex-start;
2217 gap: 10px;
2218 padding: 12px 16px calc(12px + env(safe-area-inset-top, 0px));
2219 background: color-mix(in srgb, #d97706 16%, var(--panel));
2220 border-bottom: 1px solid color-mix(in srgb, #d97706 45%, transparent);
2221 color: var(--ink);
2222 font-size: 13.5px;
2223 line-height: 1.45;
2224}
2225.integrity-banner.hidden { display: none; }
2226.integrity-banner .ib-icon { font-size: 18px; line-height: 1.2; flex: none; }
2227.integrity-banner .ib-text { flex: 1; min-width: 0; }
2228.integrity-banner .ib-title { font-weight: 700; }
2229.integrity-banner .ib-actions { display: flex; gap: 8px; flex: none; }
2230.integrity-banner button {
2231 font: inherit;
2232 font-weight: 600;
2233 border-radius: 8px;
2234 padding: 6px 12px;
2235 min-height: 32px;
2236 cursor: pointer;
2237 border: 1px solid color-mix(in srgb, #d97706 55%, transparent);
2238 background: transparent;
2239 color: var(--ink);
2240}
2241.integrity-banner button.ib-primary {
2242 background: color-mix(in srgb, #d97706 85%, black 5%);
2243 color: #fff;
2244 border-color: transparent;
2245}
2246.integrity-banner button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
2247@media (prefers-color-scheme: dark) {
2248 .integrity-banner { background: color-mix(in srgb, #f59e0b 22%, var(--panel)); }
2249}
2250@media (max-width: 640px) {
2251 .integrity-banner { flex-wrap: wrap; }
2252 .integrity-banner .ib-actions { width: 100%; }
2253}
2254
2255/* Demo countdown band: same full-width top-band idiom as the integrity banner,
2256 but informational, not an alarm — accent-tinted while the demo runs, amber
2257 only once it has ended. Occupies layout, never overlays. */
2258.demo-banner {
2259 flex: none;
2260 display: flex;
2261 align-items: center;
2262 justify-content: center;
2263 flex-wrap: wrap;
2264 gap: 8px 14px;
2265 padding: 9px 16px;
2266 padding-top: calc(9px + env(safe-area-inset-top, 0px));
2267 background: color-mix(in srgb, var(--accent) 12%, var(--panel));
2268 border-bottom: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
2269 color: var(--ink);
2270 font-size: 13.5px;
2271 line-height: 1.45;
2272 text-align: center;
2273}
2274.demo-banner.hidden { display: none; }
2275.demo-banner .demo-banner-text { font-variant-numeric: tabular-nums; }
2276.demo-banner .demo-banner-keep {
2277 font: inherit;
2278 font-weight: 600;
2279 border-radius: 8px;
2280 padding: 4px 12px;
2281 min-height: 24px;
2282 display: inline-flex;
2283 align-items: center;
2284 text-decoration: none;
2285 background: var(--accent);
2286 color: #fff;
2287}
2288.demo-banner .demo-banner-keep:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
2289.demo-banner.demo-over {
2290 background: color-mix(in srgb, #d97706 16%, var(--panel));
2291 border-bottom-color: color-mix(in srgb, #d97706 45%, transparent);
2292}
2293@media (prefers-color-scheme: dark) {
2294 .demo-banner.demo-over { background: color-mix(in srgb, #f59e0b 22%, var(--panel)); }
2295}
2296/* floating connection-status pill (only shown during a sustained outage) */
2297.conn-status {
2298 position: fixed;
2299 top: calc(10px + env(safe-area-inset-top, 0px));
2300 left: 50%;
2301 transform: translateX(-50%);
2302 z-index: 90;
2303 background: var(--pill-bg);
2304 color: var(--pill-ink);
2305 font-size: 13px;
2306 font-weight: 600;
2307 padding: 6px 14px;
2308 border-radius: 999px;
2309 box-shadow: 0 6px 20px var(--shadow);
2310}
2311.conn-status.hidden { display: none; }
2312.sidebar { width: 320px; flex: none; display: flex; flex-direction: column; background: var(--sidebar); border-right: 1px solid var(--border); min-height: 0; }
2313/* Inline passkey-backup nudge: sits in the sidebar between the search box and the
2314 chat list, occupying its own layout band (never a floating toast). A subtle
2315 accent tint sets it apart without shouting. Contents are built in backup.js. */
2316.backup-banner {
2317 flex: none;
2318 margin: 8px 12px;
2319 padding: 12px 14px;
2320 border: 1px solid var(--border);
2321 border-radius: 12px;
2322 background: color-mix(in srgb, var(--accent) 12%, transparent);
2323 display: flex;
2324 flex-direction: column;
2325 gap: 8px;
2326}
2327.backup-banner.hidden { display: none; }
2328.backup-banner-title { font-weight: 700; font-size: 14px; color: var(--ink); }
2329.backup-banner-body { font-size: 13px; line-height: 1.4; color: var(--muted); }
2330
2331/* The "a new app is available" offer — a slim inline sidebar nudge, not a
2332 forced reload. Deliberately quieter than the backup banner. */
2333.update-banner {
2334 flex: none;
2335 margin: 4px 12px 8px;
2336 padding: 10px 12px;
2337 border: 1px solid var(--border);
2338 border-radius: 12px;
2339 background: var(--bubble-them);
2340 display: flex;
2341 flex-direction: column;
2342 gap: 8px;
2343}
2344.update-banner.hidden { display: none; }
2345.update-banner-msg { font-size: 13px; color: var(--ink); }
2346.update-banner-row { display: flex; gap: 8px; }
2347.update-banner-btn {
2348 cursor: var(--ui-cursor, pointer);
2349 font-size: 13px;
2350 padding: 5px 12px;
2351 border-radius: 999px;
2352 border: 1px solid var(--border);
2353 background: var(--panel);
2354 color: var(--ink);
2355}
2356.update-banner-btn.primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
2357.backup-banner-cta { align-self: flex-start; padding: 8px 14px; font-size: 13px; }
2358/* Full-width + left-aligned so the escalated "I can lose access…" label wraps
2359 inside the banner instead of overflowing its right edge. */
2360.backup-banner-dismiss {
2361 align-self: stretch;
2362 text-align: left;
2363 font-size: 13px;
2364 white-space: normal;
2365 overflow-wrap: anywhere;
2366}
2367/* Once escalated (.danger) the label wraps to two lines, and .linkish's
2368 6px/8px padding plus the default button's 999px pill radius crowd the text
2369 against the curved corners. Scoped to .danger (beats .linkish's padding at
2370 higher specificity) rather than .linkish itself, so the single-line
2371 "Remind me later" stage keeps its original tight link-style padding. */
2372.backup-banner-dismiss.danger {
2373 padding: 12px 16px;
2374 border-radius: 16px;
2375}
2376/* Both headers share a height so the thread pane's header lines up with the
2377 sidebar's. Only the thread header carries a divider — the sidebar's "Chats"
2378 header flows straight into the chat list (with the search field below it). */
2379.side-head, .thread-head { box-sizing: border-box; min-height: 62px; padding: 8px 16px; }
2380.thread-head { border-bottom: 1px solid var(--border); }
2381.side-head { display: flex; align-items: center; gap: 8px; }
2382.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; }
2383.head-actions { display: flex; align-items: center; gap: 2px; flex: none; }
2384.avatar {
2385 display: inline-flex; align-items: center; justify-content: center; flex: none;
2386 width: 34px; height: 34px; border-radius: 50%; color: #fff; font-weight: 700; font-size: 15px; line-height: 1;
2387}
2388.avatar-btn { background: transparent; padding: 0; border-radius: 50%; flex: none; }
2389.avatar-lg { width: 64px; height: 64px; font-size: 28px; }
2390.avatar.zoomable { cursor: pointer; } /* header avatar with a real photo: tap to zoom */
2391.field-label { display: block; font-size: 13px; color: var(--muted); margin: 2px 0 4px; }
2392/* Filter the chat list. Sits between the header and the list; hidden when there
2393 are no chats (renderThreadList toggles .hidden). */
2394.chat-search { flex: none; padding: 4px 12px 6px; display: flex; gap: 8px; }
2395.chat-search input {
2396 flex: 1; min-width: 0; box-sizing: border-box; font: inherit; font-size: 14px;
2397 padding: 8px 12px; border: 1px solid transparent; border-radius: 12px;
2398 background: var(--bubble-them); color: var(--ink); -webkit-appearance: none; appearance: none;
2399}
2400/* The funnel beside search: opens the list-filter menu (online-only +
2401 People/Bots/Apps). Highlights while any filter is narrowing the list.
2402 Stretched to the search field's height so the pair reads as one control. */
2403.chat-search .menu-wrap { display: flex; align-items: stretch; } /* so the funnel can match the field's height */
2404.filter-btn { color: var(--muted); padding: 0; width: 38px; align-self: stretch; justify-content: center; }
2405.filter-btn.active { color: var(--accent); }
2406.filter-menu { display: flex; flex-direction: column; gap: 2px; min-width: 180px; padding: 6px; }
2407/* Rows read like menu items: box, label, hover wash — mirroring .menu-item. */
2408.filter-row {
2409 display: flex; align-items: center; gap: 10px; cursor: pointer;
2410 font-size: 14px; font-weight: 500; padding: 8px 10px; border-radius: 8px;
2411}
2412.filter-row:hover { background: var(--bubble-them); }
2413.filter-sep { height: 1px; background: var(--border); margin: 4px 8px; }
2414/* The checkbox is drawn entirely here: the app-wide `input` rule (width:100%,
2415 panel fill, field padding) mangles a native checkbox into a blank pill, so
2416 appearance:none and take over — accent fill + a white tick when checked. */
2417input.filter-check {
2418 -webkit-appearance: none; appearance: none; margin: 0; padding: 0; flex: none;
2419 width: 18px; height: 18px; border-radius: 6px; cursor: pointer;
2420 border: 1.5px solid var(--border); background: var(--panel);
2421 display: inline-grid; place-content: center;
2422}
2423input.filter-check::before {
2424 content: ""; width: 10px; height: 10px; background: #fff;
2425 clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
2426 transform: scale(0); transition: transform 0.1s;
2427}
2428input.filter-check:checked { background: var(--accent); border-color: var(--accent); }
2429input.filter-check:checked::before { transform: scale(1); }
2430/* Keyboard focus keeps the app's accent ring; pointer clicks stay quiet. */
2431input.filter-check:focus { outline: none; }
2432input.filter-check:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
2433.chat-search input::placeholder { color: var(--muted); }
2434/* Message search results (#586): a dropdown under the search box, floating
2435 over the thread list (same treatment as the filter menu — a user-invoked,
2436 transient surface, not a toast) — rows read like slim thread rows;
2437 everything keyboard-reachable. The zero-height anchor keeps the sidebar
2438 column's flow untouched, so nothing below shifts while searching. */
2439.msg-results-anchor { position: relative; flex: none; }
2440.msg-results {
2441 position: absolute; top: 2px; left: 12px; right: 12px;
2442 z-index: 15; /* over the thread list, under the filter menu's 20 */
2443 background: var(--panel); border: 1px solid var(--border);
2444 border-radius: 14px; box-shadow: 0 12px 40px var(--shadow);
2445 overflow-y: auto; overflow-x: hidden;
2446 max-height: 60vh; max-height: 60dvh; /* the anchor has no height, so sidebar-relative % can't work */
2447 padding: 0 8px 6px;
2448}
2449.msg-results-count { font-size: 12px; font-weight: 600; color: var(--muted); padding: 6px 8px 4px; }
2450.msg-result {
2451 display: block; width: 100%; text-align: left; box-sizing: border-box;
2452 font: inherit; color: var(--ink); background: none; border: none;
2453 padding: 8px 10px; border-radius: 10px; cursor: pointer;
2454}
2455.msg-result:hover { background: var(--bubble-them); }
2456.msg-result:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
2457/* the keyboard-highlighted row — the top hit by default, so Enter just opens it */
2458.msg-result.active { background: color-mix(in srgb, var(--accent) 16%, transparent); }
2459/* Conversations section: matching chats as avatar + name rows. The dropdown
2460 floats over the chat list, so the chats a name-search finds must be
2461 clickable in here — same consolidation as the native apps' search. */
2462.msg-result.conv-result { display: flex; align-items: center; gap: 10px; }
2463.conv-result-name { font-size: 14px; font-weight: 600; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2464.msg-result-top { display: flex; justify-content: space-between; gap: 8px; font-size: 12px; }
2465.msg-result-who { font-weight: 600; }
2466.msg-result-where { color: var(--muted); flex: none; max-width: 55%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2467.msg-result-snippet { font-size: 13px; color: var(--muted); margin-top: 2px; overflow-wrap: anywhere; }
2468/* The deeper-search affordance + its progress line ("Searching back to…"). */
2469.msg-result-deeper, .msg-result-cancel {
2470 font: inherit; font-size: 13px; font-weight: 600; color: var(--accent);
2471 background: none; border: none; cursor: pointer; padding: 8px 10px; border-radius: 10px;
2472}
2473.msg-result-deeper:hover, .msg-result-cancel:hover { background: var(--bubble-them); }
2474.msg-result-deeper:focus-visible, .msg-result-cancel:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
2475.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; }
2476/* Keep the app's standard focus ring (global input:focus); just lift the fill. */
2477.chat-search input:focus { background: var(--panel); }
2478.thread-list { flex: 1; overflow-y: auto; min-height: 0; padding: 6px; display: flex; flex-direction: column; gap: 2px; }
2479.thread-empty-note { color: var(--muted); padding: 16px; font-size: 14px; }
2480.thread-row {
2481 display: flex; align-items: center; gap: 12px; width: 100%; text-align: left;
2482 background: transparent; color: var(--ink); border-radius: 14px; padding: 9px 14px;
2483}
2484.thread-row:hover { background: var(--bubble-them); }
2485.thread-row.active { background: var(--row-active); }
2486/* Bigger avatar in the chat list (à la WhatsApp/iMessage); scoped to the list so
2487 header/profile/reaction avatars keep their sizes. */
2488.thread-row .avatar { width: 52px; height: 52px; font-size: 21px; }
2489/* Presence: a coloured dot + status text (the DM header line, the member
2490 sheet's note) — never a pip on an avatar. Green/yellow/red per Paul's call. */
2491:root { --online: #34c759; --away: #f5b400; --offline: #ff3b30; }
2492.presence-line { display: inline-flex; align-items: center; gap: 6px; } /* dot truly centred on the text */
2493.p-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
2494.p-dot.online { background: var(--online); }
2495.p-dot.away { background: var(--away); }
2496.p-dot.offline { background: var(--offline); }
2497/* Member sheet: the muted status note beside a member's name. */
2498.member-presence { margin-left: 8px; font-size: 12px; color: var(--muted); flex: none; }
2499/* Access-level chips on the profile card — the person's levels ("Coach",
2500 "VIP"), a quiet accent-tinted badge (names are plaintext space policy). */
2501.level-chips { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; margin: 8px 0 4px; }
2502.level-chip {
2503 font-size: 12px; font-weight: 600; line-height: 1;
2504 color: var(--accent-ink); background: var(--accent);
2505 border-radius: 999px; padding: 5px 10px;
2506}
2507
2508/* Membership sheet (paid access levels). Quiet cards in the app's own
2509 vocabulary; the price is the one emphasized number, one accent, and the
2510 state line ("Renews …" / "Access ends …") is the reassuring signature —
2511 always dated, never color alone. */
2512.plan-card {
2513 border: 1px solid var(--border); border-radius: 14px;
2514 padding: 14px 16px; margin: 10px 0; display: flex; flex-direction: column; gap: 8px;
2515}
2516.plan-card.current { border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); }
2517.plan-head { display: flex; align-items: baseline; gap: 10px; }
2518.plan-name { font-size: 16px; font-weight: 650; color: var(--ink); flex: 1; min-width: 0; }
2519.plan-price { font-size: 15px; font-weight: 700; color: var(--ink); white-space: nowrap; }
2520.plan-price .per { font-size: 12px; font-weight: 500; color: var(--muted); }
2521/* The dated status line — the one thing a member always reads. Icon + words,
2522 so a state is never carried by colour alone (WCAG 2.2 AA). */
2523.plan-state { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--muted); }
2524.plan-state.warn { color: light-dark(#9a6a00, #f0b84a); } /* payment-failed, still icon+words */
2525.plan-state .dot { width: 7px; height: 7px; border-radius: 999px; background: currentColor; flex: none; }
2526.plan-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 2px; }
2527.plan-actions .pill-btn { font-size: 13px; }
2528.membership-note { font-size: 13px; color: var(--muted); margin: 4px 0 10px; }
2529.membership-sec { font-size: 11px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); margin: 16px 0 2px; }
2530.thread-row-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
2531.thread-row-name { font-size: 16px; font-weight: 600; display: flex; align-items: center; min-width: 0; }
2532.thread-row-name-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2533/* Up to two lines of the latest message, then ellipsize (the spacious look).
2534 white-space:normal undoes the nowrap inherited from the global `button` rule
2535 (.thread-row is a <button>) — without it the line-clamp can't wrap and the
2536 preview gets hard-clipped on a single line. overflow-wrap lets long unbroken
2537 tokens (e.g. a pasted URL) wrap too. */
2538.thread-row-preview {
2539 font-size: 13px; font-weight: 400; color: var(--muted); line-height: 1.3;
2540 white-space: normal; overflow-wrap: anywhere;
2541 display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
2542}
2543/* Time sits top-right, aligned with the name, even when the preview wraps to two lines. */
2544.thread-row-meta { display: flex; flex-direction: column; align-items: flex-end; gap: 5px; flex: none; align-self: flex-start; padding-top: 3px; }
2545.thread-row-time { font-size: 11px; font-weight: 400; color: var(--muted); white-space: nowrap; }
2546.thread-row-pin { color: var(--muted); flex: none; }
2547.thread-row-pin svg { width: 15px; height: 15px; display: block; transform: rotate(45deg); }
2548.thread-row-mute { color: var(--muted); flex: none; }
2549.thread-row-mute svg { width: 14px; height: 14px; display: block; }
2550/* A muted member's folded message (issue #578): a quiet, obviously-interactive
2551 stub occupying the bubble's place — never a silent gap in the conversation. */
2552.muted-stub {
2553 display: inline-flex;
2554 align-items: center;
2555 gap: 10px;
2556 padding: 8px 14px;
2557 border-radius: 16px;
2558 border: 1px dashed var(--border);
2559 color: var(--muted);
2560 font-size: 13px;
2561 font-style: italic;
2562}
2563.muted-stub-show {
2564 all: unset;
2565 cursor: pointer;
2566 font-style: normal;
2567 font-weight: 600;
2568 font-size: 13px;
2569 color: var(--accent);
2570 padding: 4px 8px; /* padding keeps the tap target comfortably ≥24px tall */
2571 border-radius: 6px;
2572}
2573.muted-stub-show:hover { text-decoration: underline; }
2574.muted-stub-show:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
2575/* A closed DM's banner (issue #578): sits where the composer was — inline,
2576 occupying layout space, never floating over the feed. */
2577.dm-closed-bar {
2578 display: flex;
2579 align-items: center;
2580 justify-content: center;
2581 gap: 12px;
2582 padding: 14px 16px;
2583 border-top: 1px solid var(--border);
2584 color: var(--muted);
2585 font-size: 14px;
2586}
2587/* Small helper line under a settings field (e.g. the DM-privacy select). */
2588.field-note { margin-top: 6px; font-size: 12px; color: var(--muted); }
2589/* Destructive button — the moderation ladder's remove/ban rungs. */
2590button.danger { background: transparent; border: 1px solid var(--danger); color: var(--danger); }
2591/* Report sheet (issue #578): category chips + quoted evidence. */
2592/* The send button's in-flight spinner: the join loader's ring at button
2593 scale, inheriting the button's text color so it reads on the filled bg. */
2594.btn-spinner {
2595 display: inline-block;
2596 width: 14px;
2597 height: 14px;
2598 vertical-align: -2px;
2599 border-radius: 50%;
2600 border: 2px solid color-mix(in srgb, currentColor 30%, transparent);
2601 border-top-color: currentColor;
2602 animation: spin 0.7s linear infinite;
2603}
2604@media (prefers-reduced-motion: reduce) {
2605 .btn-spinner { animation: none; opacity: 0.5; }
2606}
2607.report-cats { display: flex; gap: 8px; margin: 10px 0; flex-wrap: wrap; }
2608.report-cat {
2609 background: transparent;
2610 border: 1px solid var(--border);
2611 color: var(--ink);
2612 border-radius: 999px;
2613 padding: 6px 14px;
2614 font-size: 13px;
2615}
2616.report-cat.on { border-color: var(--accent); color: var(--accent); font-weight: 600; }
2617.report-quote {
2618 margin: 10px 0;
2619 padding: 8px 12px;
2620 border-left: 3px solid var(--border);
2621 font-size: 13px;
2622 color: var(--muted);
2623 overflow-wrap: anywhere;
2624}
2625.report-mute-row { display: flex; align-items: center; gap: 6px; font-size: 13px; margin-top: 10px; cursor: pointer; }
2626/* The sent confirmation that replaces the report form. */
2627.report-done { text-align: center; padding: 30px 10px; font-size: 15px; }
2628/* Escape the app-wide `input { width:100% }` rule (the #761 fix's pattern) —
2629 a full-width checkbox becomes the whole flex row and shoves its label text
2630 clean out of the sheet. */
2631.report-mute-row input { width: 18px; height: 18px; flex: none; margin: 0; }
2632/* An admin warning's framing on the bubble (issue #578). */
2633.warn-tag { font-size: 12px; font-weight: 700; color: var(--danger); margin-bottom: 4px; }
2634/* Admin app: report queue + audit log (issue #578). */
2635.admin-report { border: 1px solid var(--border); border-radius: 12px; padding: 12px 14px; margin: 10px 0; }
2636.admin-report.resolved { opacity: 0.65; }
2637.admin-report-head { display: flex; justify-content: space-between; gap: 10px; align-items: baseline; }
2638.admin-report-when { font-size: 11px; color: var(--muted); white-space: nowrap; }
2639.admin-report-byline { font-size: 12px; color: var(--muted); margin-top: 2px; }
2640.admin-report-words { font-size: 13px; margin: 8px 0 0; overflow-wrap: anywhere; }
2641.admin-report-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
2642.admin-report-detail { margin-top: 8px; display: flex; flex-direction: column; gap: 8px; }
2643.admin-audit-row {
2644 display: flex;
2645 justify-content: space-between;
2646 gap: 10px;
2647 font-size: 13px;
2648 padding: 6px 0;
2649 border-bottom: 1px solid var(--border);
2650}
2651.admin-audit-row:last-child { border-bottom: none; }
2652.unread-dot { width: 8px; height: 8px; border-radius: 50%; background: transparent; flex: none; }
2653.unread-dot.on { background: var(--accent); }
2654/* Mobile list treatment (à la Messages/WhatsApp): rows run edge-to-edge, the active
2655 state is a full-width fill (no rounded bubble), and a hairline divider separates
2656 rows starting just after the avatar (not under it). */
2657@media (pointer: coarse) {
2658 .thread-list { padding: 0; gap: 0; }
2659 .thread-row { border-radius: 0; padding: 11px 16px; position: relative; }
2660 .thread-row::after {
2661 content: "";
2662 position: absolute;
2663 left: calc(16px + 52px + 12px); /* row padding-left + avatar width + gap */
2664 right: 0;
2665 bottom: 0;
2666 height: 1px;
2667 background: var(--border);
2668 }
2669 /* No divider under the last row, nor under the active row (its fill owns the edge). */
2670 .thread-row:last-child::after,
2671 .thread-row.active::after { display: none; }
2672}
2673.thread-pane { flex: 1; display: flex; min-height: 0; min-width: 0; position: relative; }
2674.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--muted); gap: 6px; text-align: center; padding: 24px; }
2675.empty-art { font-size: 42px; }
2676/* min-width:0 lets these panes shrink below their content so a long unbroken
2677 URL wraps inside a bubble instead of widening the whole column. */
2678.thread-view { flex: 1; display: flex; flex-direction: column; min-height: 0; min-width: 0; width: 100%; position: relative; }
2679.thread-head { display: flex; align-items: center; gap: 6px; }
2680.thread-head-title { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
2681/* App-thread view toggle: a small segmented [💬 | ⏰] control beside the kebab —
2682 Chat (the DM with the app's bot) vs App (the interactive UI). */
2683.view-toggle { display: flex; gap: 2px; flex: none; background: var(--bubble-them); border-radius: 10px; padding: 2px; }
2684.view-toggle-btn {
2685 display: inline-flex; align-items: center; justify-content: center;
2686 width: 32px; height: 30px; border: none; border-radius: 8px; background: transparent;
2687 font-size: 15px; line-height: 1; color: var(--ink); cursor: var(--ui-cursor, pointer);
2688}
2689.view-toggle-btn.active { background: var(--panel); box-shadow: 0 1px 4px var(--shadow); }
2690
2691/* App-thread Views: the Chat pane (#app-pane) + the Interface pane
2692 (#interface-pane). A normal chat thread has only the chat pane — the interface
2693 pane stays empty and hidden, so it fills the width unchanged. Shown only in
2694 .app-mode; the framework CSS already gives each .app-pane its display/flex. */
2695.app-split { display: none; flex: 1 1 auto; min-height: 0; }
2696.thread-view.app-mode #app-split { display: flex; }
2697#interface-pane:empty { display: none; } /* a normal chat has no interface pane */
2698.view-toggle-btn.split-only { display: none; } /* Split is desktop-only (revealed below) */
2699.app-split-divider { display: none; } /* only ever shown in split view on desktop, below */
2700@media (min-width: 721px) {
2701 /* Desktop: three views — chat | split (both, the default) | interface. */
2702 #app-split { flex-direction: row; }
2703 #interface-pane { border-left: 1px solid var(--border); }
2704 .view-toggle-btn.split-only { display: inline-flex; }
2705 .thread-view[data-app-view="chat"] #interface-pane { display: none; }
2706 .thread-view[data-app-view="interface"] #app-pane { display: none; }
2707 /* Split: the divider between the panes is draggable (mouse/touch/pen) and
2708 keyboard-operable (arrow keys), and owns the separating line — so the
2709 plain border above is switched off here in favour of the divider's own. */
2710 .thread-view[data-app-view="split"] #app-pane {
2711 flex: 0 0 auto;
2712 width: calc(var(--split-ratio, 0.5) * 100%);
2713 }
2714 .thread-view[data-app-view="split"] #interface-pane {
2715 flex: 1 1 auto;
2716 border-left: none;
2717 }
2718 .thread-view[data-app-view="split"] #app-split-divider {
2719 display: flex; align-items: center; justify-content: center;
2720 flex: 0 0 11px; width: 11px; min-height: 0;
2721 cursor: col-resize; touch-action: none; position: relative;
2722 }
2723 .app-split-divider::before {
2724 content: ""; position: absolute; top: 0; bottom: 0; left: 50%;
2725 width: 1px; background: var(--border);
2726 }
2727 .app-split-divider::after {
2728 content: ""; width: 3px; height: 32px; border-radius: 2px;
2729 background: var(--border);
2730 }
2731 .app-split-divider:hover::after,
2732 .app-split-divider:focus-visible::after,
2733 .app-split-divider.dragging::after { background: var(--muted); }
2734 .app-split-divider:focus-visible { outline: none; } /* the grip itself shows focus */
2735 .app-split-divider:focus-visible::after { box-shadow: 0 0 0 2px var(--accent); }
2736}
2737@media (max-width: 720px) {
2738 /* Mobile: one pane, no split — chat/split → Chat, interface → Interface. */
2739 .thread-view.app-mode #interface-pane { display: none; }
2740 .thread-view[data-app-view="interface"] #app-pane { display: none; }
2741 .thread-view[data-app-view="interface"] #interface-pane { display: flex; }
2742}
2743.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; }
2744.thread-sub { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2745.back-btn { display: none; font-size: 24px; padding: 0 8px; }
2746.feed { flex: 1; overflow-y: auto; overflow-x: hidden; min-height: 0; min-width: 0; padding: 16px; display: flex; flex-direction: column; gap: 4px; }
2747.feed > .msg { max-width: 78%; }
2748.feed::before { content: ""; margin-top: auto; }
2749
2750/* modal sheet (new chat / add device) — centered overlay */
2751.overlay {
2752 position: fixed; inset: 0; z-index: 60; background: rgba(20, 30, 80, 0.45);
2753 display: flex; align-items: center; justify-content: center; padding: 20px;
2754}
2755.sheet { max-width: 460px; width: 100%; max-height: 82vh; overflow-y: auto; }
2756.sheet-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
2757/* A sheet's title spaces the same whether or not it's wrapped in a .sheet-head:
2758 most sheets write a bare <h2>, and the browser's default heading margin then
2759 pushed it far off the card's top padding. Both shapes now leave 10px below. */
2760.sheet-head h2 { margin: 0; }
2761.sheet > h2:first-child { margin: 0 0 10px; }
2762.linkish { background: transparent; color: var(--link); padding: 6px 8px; font-weight: 600; }
2763.sheet-input { margin-bottom: 10px; }
2764/* The quick forms ("Fill in a form instead"): labelled fields over the shared
2765 sheet inputs, an Extras group heading, and inline (never floating) errors. */
2766.qf-label { display: block; font-size: 13px; font-weight: 600; color: var(--ink); margin: 6px 2px 4px; }
2767.qf-note { margin: 2px 2px 0; }
2768.qf-error { margin: 8px 2px 0; font-size: 14px; color: var(--danger); }
2769.qf-error[hidden] { display: none; }
2770/* full-bleed list inside the sheet: rows span the card edges (which pads 28px) */
2771.pick-list { margin: 4px -28px 0; max-height: 56vh; overflow-y: auto; }
2772/* Picker sheets (new chat / add people) had nested scrolling — the sheet AND the
2773 .pick-list both scrolled — plus a stray horizontal scrollbar (overflow-y:auto
2774 promotes overflow-x to auto, so a sub-pixel overflow showed a bar). Make the
2775 sheet a clipped flex column so its head, search, and Apps footer stay put and
2776 ONLY the list scrolls: one clean scroll region. :has() scopes this to sheets
2777 that actually hold a pick-list, leaving every other modal untouched. */
2778.sheet:has(.pick-list) { display: flex; flex-direction: column; overflow: hidden; }
2779.sheet:has(.pick-list) > * { flex: none; }
2780.sheet:has(.pick-list) .pick-list {
2781 flex: 0 1 auto; min-height: 0; max-height: none;
2782 overflow-y: auto; overflow-x: hidden;
2783}
2784.list-row {
2785 display: flex; align-items: center; gap: 12px; width: 100%; text-align: left;
2786 background: transparent; color: var(--ink); border: none; border-bottom: 1px solid var(--border);
2787 padding: 14px 28px; border-radius: 0; font-weight: 500; font-size: 16px; cursor: pointer;
2788}
2789.list-row:hover:not(.static) { background: var(--bubble-them); }
2790.list-row.static { cursor: default; }
2791.list-row input { width: auto; }
2792/* The border is a BETWEEN-rows divider — the last row's would just float under
2793 the list (same rule the thread list applies to its rows). */
2794.pick-list .list-row:last-child { border-bottom: none; }
2795/* The Apps sheet's focus-and-explain flow: › focuses one app (the other rows
2796 drop away), then Open | Add to chat | Add to space over an explanation
2797 panel. Dimmed options stay CLICKABLE — tapping one is how you learn why it
2798 isn't available here; the panel explains, never a dead control. */
2799.app-entry .list-row { border-bottom: none; }
2800/* Focusing an app EASES the others away instead of jump-cutting: each entry
2801 is a one-row grid whose track collapses 1fr → 0fr (height animates without
2802 measuring) while it fades, so the sheet visibly shrinks to the chosen app.
2803 The inner wrapper is the clipped track child. visibility flips only after
2804 the collapse (the delayed 0s transition), taking hidden rows out of the
2805 tab order and accessibility tree without cutting the animation short. */
2806.app-entry {
2807 display: grid; grid-template-rows: 1fr;
2808 border-bottom: 1px solid var(--border);
2809 transition:
2810 grid-template-rows 0.28s cubic-bezier(0.2, 0.8, 0.2, 1),
2811 opacity 0.2s ease, border-bottom-width 0.28s, visibility 0s linear 0s;
2812}
2813.app-entry-inner { min-height: 0; overflow: hidden; }
2814.app-entry.collapsed {
2815 grid-template-rows: 0fr; opacity: 0; border-bottom-width: 0; visibility: hidden;
2816 transition:
2817 grid-template-rows 0.28s cubic-bezier(0.2, 0.8, 0.2, 1),
2818 opacity 0.2s ease, border-bottom-width 0.28s, visibility 0s linear 0.28s;
2819}
2820/* The between-rows divider disappears when nothing follows it — the genuine
2821 last row, but also a FOCUSED entry once every row after it has collapsed
2822 (a lone visible entry shouldn't underline itself). border-bottom-width is
2823 already in the entry's transition list, so the divider fades out in the
2824 same motion as the collapse. */
2825.pick-list .app-entry:last-child { border-bottom: none; }
2826.pick-list .app-entry:not(:has(~ .app-entry:not(.collapsed))) { border-bottom-width: 0; }
2827/* The focused app's places drawer grows in the same motion the rows leave. */
2828.app-places {
2829 display: grid; grid-template-rows: 0fr; opacity: 0;
2830 transition: grid-template-rows 0.28s cubic-bezier(0.2, 0.8, 0.2, 1), opacity 0.2s ease;
2831}
2832.app-places.open { grid-template-rows: 1fr; opacity: 1; }
2833.app-places-inner { min-height: 0; overflow: hidden; }
2834@media (prefers-reduced-motion: reduce) {
2835 .app-entry, .app-places { transition: none; }
2836}
2837.app-go {
2838 flex: 0 0 auto; width: 32px; height: 32px; padding: 0;
2839 font: inherit; font-size: 20px; line-height: 1; font-weight: 400;
2840 color: var(--muted); background: transparent; border: none; border-radius: 8px;
2841 cursor: var(--ui-cursor, pointer);
2842}
2843.app-go:hover { color: var(--ink); background: var(--bubble-them); }
2844.place-options { display: flex; gap: 6px; padding: 2px 28px 0; }
2845.place-option {
2846 flex: 1 1 0; min-width: 0; padding: 7px 4px;
2847 font: inherit; font-size: 13.5px; font-weight: 600; white-space: nowrap;
2848 color: var(--ink); background: var(--bubble-them);
2849 border: 1px solid var(--border); border-radius: 8px;
2850 cursor: var(--ui-cursor, pointer);
2851}
2852.place-option[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); }
2853/* Dimmed, not disabled — the words in the panel carry the "why", never the
2854 fade alone (state is never color alone). */
2855.place-option.dim { opacity: 0.45; }
2856/* The places sentence, shown with the choice it explains (under the focused
2857 app's name, above its place buttons). */
2858.place-grammar { margin: 0; padding: 0 28px 10px; font-size: 13px; color: var(--muted); line-height: 1.45; }
2859.place-panel { padding: 10px 28px 14px; }
2860.place-panel p { margin: 0; font-size: 13.5px; color: var(--muted); line-height: 1.45; }
2861/* The selected place's action, right-aligned at the sheet's bottom (rides
2862 .sheet-foot). min-height keeps the sheet from jittering as selections
2863 change; :empty removes it entirely in the list state (no app focused →
2864 no button rendered → no unexplained strip under the list). */
2865.place-foot { min-height: 38px; margin-top: 0; }
2866.place-foot:empty { display: none; }
2867.place-foot button { font-size: 14px; }
2868/* Helpers catalog: bots sit under their own picker heading, with a one-line blurb */
2869.pick-section {
2870 padding: 16px 28px 6px; font-size: 12px; font-weight: 700; color: var(--muted);
2871 text-transform: uppercase; letter-spacing: 0.06em;
2872}
2873.helper-desc { display: block; font-size: 13px; font-weight: 400; color: var(--muted); margin-top: 2px; }
2874/* member row: tappable identity on the left, a Remove action on the right */
2875.member-main {
2876 flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px;
2877 background: transparent; color: inherit; border: none; text-align: left; font: inherit; padding: 0; cursor: pointer;
2878}
2879.member-main span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2880.member-remove { flex: none; background: transparent; color: var(--danger); font-weight: 600; font-size: 14px; padding: 4px 8px; }
2881.sheet-foot { display: flex; justify-content: flex-end; gap: 10px; margin-top: 16px; }
2882/* An inline failure reason in a sheet (#786) — occupies layout space above
2883 the buttons, never a floating toast. Colour is not the only signal: the
2884 role=alert announcement + the wording carry it. */
2885.sheet-error { color: var(--danger, #c0392b); font-size: 14px; margin: 10px 0 0; }
2886
2887/* Open groups. Two pieces of vocabulary, both built from the sheet's existing
2888 language so community UI reads as native, not bolted on:
2889 - .opt-row: a choice with its consequence spelled out underneath (label sets
2890 the what, .opt-desc the so-what) — checkboxes stop needing run-on labels.
2891 - Discover rows: roster-style avatar + name + quiet facts, action on the
2892 right. Join is the sheet's ONE loud control (accent pill); everything a
2893 member already has (Open) stays quiet. */
2894.opt-row {
2895 display: flex; align-items: flex-start; gap: 12px;
2896 padding: 10px 2px; cursor: pointer; font-weight: 550;
2897}
2898.opt-row input { width: auto; flex: none; margin-top: 4px; accent-color: var(--accent); }
2899.opt-desc { display: block; font-size: 13px; font-weight: 400; color: var(--muted); margin-top: 1px; }
2900/* A dependent option that can't apply right now (default needs open) dims as a
2901 unit — state is the row's opacity AND the disabled input, never color alone. */
2902.opt-row:has(input:disabled) { opacity: 0.45; cursor: default; }
2903.pill-btn {
2904 flex: none; background: var(--accent); color: var(--accent-ink);
2905 border-radius: 999px; padding: 7px 16px; font-weight: 650; font-size: 14px;
2906}
2907.pill-btn.quiet { background: var(--bubble-them); color: var(--ink); }
2908.discover-main { flex: 1; min-width: 0; }
2909.discover-main > div:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2910
2911/* theme picker (kebab → Theme) */
2912.appearance-seg { display: flex; gap: 6px; background: var(--bubble-them); border-radius: 12px; padding: 4px; }
2913.appearance-seg button { flex: 1; background: transparent; color: var(--muted); border-radius: 9px; padding: 9px; font-weight: 600; }
2914.appearance-seg button[aria-pressed="true"] { background: var(--panel); color: var(--ink); box-shadow: 0 1px 4px var(--shadow); }
2915.theme-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
2916.theme-swatch {
2917 display: flex; align-items: center; gap: 11px; text-align: left;
2918 background: var(--panel); color: var(--ink);
2919 border: 1px solid var(--border); border-radius: 14px; padding: 12px;
2920 font-weight: 600; font-family: var(--font-display);
2921}
2922.theme-swatch:hover { background: var(--bubble-them); }
2923.theme-swatch.active { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
2924.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); }
2925/* "Text size" slider in the theme sheet (desktop only): A ——◦—— A */
2926.text-size-row { display: flex; align-items: center; gap: 12px; }
2927.text-size-row input[type="range"] { flex: 1; margin: 0; accent-color: var(--accent); }
2928.text-size-a { color: var(--muted); font-weight: 600; line-height: 1; }
2929.text-size-a.small { font-size: 12px; }
2930.text-size-a.big { font-size: 20px; }
2931
2932/* mobile: show one pane at a time, toggled by .show-thread */
2933@media (max-width: 720px) {
2934 .sidebar { width: 100%; border-right: none; }
2935 .thread-pane { display: none; }
2936 .tt.show-thread .sidebar { display: none; }
2937 .tt.show-thread .thread-pane { display: flex; }
2938 .back-btn { display: inline-flex; }
2939}
2940
2941/* Native wrapper (html.native, set from the "lchatMac" user agent): app chrome uses
2942 the normal arrow, not the web "hand" cursor. Content is untouched — message URLs and
2943 link-preview cards keep the pointer, images keep zoom-in.
2944 Buttons switch through the --ui-cursor variable so each button has just ONE cursor
2945 declaration (resolving to default) rather than a base `pointer` + an override — a
2946 WKWebView flickers when it has two competing values to choose between. The remaining
2947 non-button pointer bits still override directly (!important because #thread-sub sets
2948 its cursor via an inline JS style). Mirror new chrome cursors here. */
2949html.native { --ui-cursor: default; }
2950html.native #thread-sub,
2951html.native .toggle-row,
2952html.native .reply-count,
2953html.native .mention-link,
2954html.native .avatar.zoomable { cursor: default !important; }
2955
2956/* The header is a window-drag region in the native wrapper, so its title text isn't
2957 selectable — don't show the text I-beam or a selection affordance on it. */
2958html.native .side-head,
2959html.native .thread-head {
2960 cursor: default;
2961 -webkit-user-select: none;
2962 user-select: none;
2963}
2964
2965/* Native shell (iOS/macOS) embeds ONE thread's feed — the app draws its own
2966 sidebar, title bar, and composer. Hide the web sidebar / thread header / composer
2967 and let the feed fill the view (overriding the mobile one-pane-at-a-time rules).
2968 Gated on body.native-embed, which only shell.js sets when window.__lchatEmbed is
2969 injected; browsers are unaffected. */
2970.native-embed .sidebar { display: none !important; }
2971.native-embed .thread-pane { display: flex !important; }
2972.native-embed .thread-head,
2973.native-embed .composer,
2974.native-embed .composer-reply,
2975.native-embed .composer-edit {
2976 display: none;
2977}
2978/* Keep the attachment preview visible: it's how a staged photo shows up just above
2979 the native composer (drag-drop or the native + button stage into it). The composer
2980 below is hidden, so clear the native bar's height (plus the old 10px breathing room). */
2981.native-embed .composer-preview { padding-bottom: calc(10px + var(--native-bottom-inset, 0px)); }
2982/* The Interface view AND the Chat/takeover pane (both .app-pane scrollers —
2983 #interface-pane and #app-pane) run under the same floating native bars as
2984 the feed: pad their top with the injected inset so the app's own UI (To-do's
2985 add row, Reminders' form, a takeover app like Vault) starts clear of the
2986 header, and end with a real in-flow spacer — not padding-bottom, which
2987 WebKit drops at a scroller's end — so the last row clears the home
2988 indicator/keyboard. #app-pane hosting a nested chat mini-app instead
2989 (:has(> .chatapp-root) — a companion app's Chat view) is excluded: that
2990 nested .chatapp-feed already pads itself with the same vars, so double-
2991 padding the outer pane too would push it down/shrink it twice over. */
2992.native-embed #interface-pane,
2993.native-embed #app-pane:not(:has(> .chatapp-root)) {
2994 padding-top: calc(16px + var(--native-top-inset, 0px));
2995}
2996.native-embed #interface-pane::after,
2997.native-embed #app-pane:not(:has(> .chatapp-root))::after {
2998 content: "";
2999 flex: none;
3000 height: calc(16px + var(--native-bottom-inset, 0px));
3001}
3002/* A sandboxed member app (.sandbox-pane — the pane itself is tagged) is an
3003 iframe surface that owns its own padding, so the 16px breathing room above
3004 reads as a bare strip around it. Keep only the native-bar insets: the app
3005 still starts clear of the floating bars, but runs edge-to-edge otherwise. */
3006.native-embed #interface-pane.sandbox-pane,
3007.native-embed #app-pane.sandbox-pane {
3008 padding-top: var(--native-top-inset, 0px);
3009}
3010.native-embed #interface-pane.sandbox-pane::after,
3011.native-embed #app-pane.sandbox-pane::after {
3012 height: var(--native-bottom-inset, 0px);
3013}
3014
3015/* Command-host embed (embed.mode === "command"): the page the native Mac shell
3016 keeps beside each thread purely to run slash commands — everything except a
3017 command's panel collapses, so the webview the shell reveals IS the panel.
3018 The shell owns the frame (and caps the height via the posted measure). */
3019body.command-host .chatapp-root > :not(.cmd-panel) { display: none !important; }
3020body.command-host .cmd-panel { border: none; box-shadow: none; } /* the native frame draws the border + shadow */
3021/* The shell grants the frame; the body scrolls within it (100vh = the frame). */
3022body.command-host .cmd-panel-body { max-height: calc(100vh - 40px); }
3023
3024/* ---- Media, links & docs overlay (mediaview.js) ---- */
3025.media-overlay {
3026 position: absolute;
3027 inset: 0;
3028 z-index: 30; /* same layer as the reply-thread overlay (.chatapp-thread) */
3029 display: flex;
3030 flex-direction: column;
3031 background: var(--bg);
3032}
3033/* .thread-view already carries position: relative (base rule above); the
3034 overlay bounds to it without a duplicate rule here. */
3035.media-head { display: flex; align-items: center; gap: 8px; padding: 10px 12px; }
3036.media-title { font-weight: 600; }
3037.media-tabs { display: flex; gap: 6px; padding: 0 12px 10px; }
3038/* Search within the open tab — the sidebar's .chat-search field, one row down. */
3039.media-search { display: flex; padding: 0 12px 8px; }
3040.media-search input {
3041 flex: 1; min-width: 0; box-sizing: border-box; font: inherit; font-size: 14px;
3042 padding: 8px 12px; border: 1px solid transparent; border-radius: 12px;
3043 background: var(--bubble-them); color: var(--ink); -webkit-appearance: none; appearance: none;
3044}
3045.media-search input::placeholder { color: var(--muted); }
3046.media-search input:focus { background: var(--panel); }
3047.media-tab {
3048 padding: 6px 12px;
3049 border-radius: 999px;
3050 border: 1px solid var(--border);
3051 background: none;
3052 cursor: pointer;
3053 min-height: 32px;
3054}
3055.media-tab.on { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
3056.media-progress { display: flex; align-items: center; gap: 10px; padding: 6px 14px; font-size: 0.85em; opacity: 0.8; }
3057.media-stop { border: none; background: none; text-decoration: underline; cursor: pointer; padding: 4px 8px; min-height: 24px; }
3058.media-body { flex: 1; overflow-y: auto; padding: 0 12px 16px; }
3059.media-month { font-size: 0.8em; opacity: 0.65; margin: 14px 2px 6px; }
3060.media-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; }
3061@media (min-width: 900px) { .media-grid { grid-template-columns: repeat(4, 1fr); } }
3062/* .media-item is the non-interactive wrapper every renderer builds around its
3063 primary control (tile button, or link/doc anchor) so the "Show in chat"
3064 jump button can sit beside it as a SIBLING rather than nested inside it —
3065 a button inside a button/anchor is invalid HTML (nested-interactive) and
3066 axe/screen readers flatten or hide the inner control. It owns the
3067 positioning context .media-jump anchors to in both layouts. */
3068.media-item { position: relative; }
3069.media-grid .media-item { aspect-ratio: 1; } /* the grid cell; the tile fills it */
3070.media-tile { position: relative; border: none; padding: 0; background: var(--bubble-them); border-radius: 6px; overflow: hidden; cursor: zoom-in; width: 100%; height: 100%; display: block; }
3071.media-tile img { width: 100%; height: 100%; object-fit: cover; display: block; }
3072.media-dur { position: absolute; right: 6px; bottom: 6px; background: rgba(0, 0, 0, 0.65); color: #fff; font-size: 0.72em; padding: 1px 6px; border-radius: 4px; }
3073.media-list { display: flex; flex-direction: column; gap: 6px; }
3074.media-link-row, .media-doc-row { display: flex; flex-direction: column; gap: 2px; padding: 10px 44px 10px 12px; border: 1px solid var(--border); border-radius: 10px; text-decoration: none; color: inherit; }
3075.media-link-title { font-weight: 600; }
3076.media-link-url { font-size: 0.85em; opacity: 0.75; overflow-wrap: anywhere; }
3077.media-doc-name { font-weight: 500; overflow-wrap: anywhere; }
3078.media-item-who { font-size: 0.78em; opacity: 0.6; }
3079.media-jump { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); min-width: 28px; min-height: 28px; border: none; background: none; cursor: pointer; opacity: 0.6; border-radius: 6px; }
3080.media-grid .media-jump { top: auto; bottom: 4px; transform: none; background: rgba(0, 0, 0, 0.5); color: #fff; opacity: 0; }
3081.media-grid .media-item:hover .media-jump, .media-grid .media-item:focus-within .media-jump { opacity: 1; }
3082.media-jump:hover, .media-jump:focus-visible { opacity: 1; }
3083.media-empty { text-align: center; opacity: 0.6; padding: 40px 0; }
3084