typebulb 0.61.2 → 0.61.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,2140 +1,2163 @@
1
- :root {
2
- /* Light is the default; data-theme="dark" is set by the host (typebulb.com
3
- or the typebulb CLI, sourced from prefers-color-scheme).
4
- GRAY RAMP — every gray rides ONE neutral tint (R=G=B), like the main app's
5
- bg1–bg5 ramp. Lightness may step; the HUE never changes. The bug to avoid is a
6
- gray in a *different* tint (a cool/blue panel beside a neutral one), not the
7
- number of steps. App chrome (statusbar, picker, modals) sits on the fixed
8
- --panel. Document panels — code, callouts, tables, user bubbles, open tools —
9
- paint --veil instead: a translucent neutral that matches --panel when alone on
10
- the page but COMPOUNDS when panels nest (a code block in a callout, a panel in a
11
- user bubble), so every depth separates on its own — no level-counting, no cap. */
12
- --bg: rgb(255, 255, 255);
13
- --fg: rgb(28, 28, 30);
14
- --muted: rgb(96, 96, 100);
15
- --panel: rgb(242, 242, 242);
16
- --border: rgb(221, 221, 221);
17
- --accent: rgb(58, 125, 232);
18
- --veil: rgba(0, 0, 0, 0.05); /* one shade step; alpha-stacks as panels nest */
19
- --tool-bg: var(--panel);
20
- --err: rgb(206, 60, 60);
21
- --warn: rgb(176, 120, 12); /* amber — the cache-leak (cost-caution) tier, milder than --err */
22
- --diff-add: rgb(40, 140, 70);
23
- --fold-marker: rgb(24, 145, 72);
24
- /* Prose measure for text (bubbles + notes) — kept to a readable line length.
25
- Diagrams break out wider than this; see .md .mermaid. */
26
- --content-max: 800px;
27
- /* One height for every word-pill control — the statusbar pills (bulbs/model/session/token) and
28
- the floating overlay pills (copy, breakout). Set once here so they can't drift apart again;
29
- chosen a touch under the old statusbar 1.7rem and a touch over the overlay pills' ~1.35rem. */
30
- --pill-h: 1.5rem;
31
- /* One text size for every word-pill too (statusbar pills + overlay copy/breakout). Was drifting:
32
- statusbar .85rem vs overlay .72rem read as amateurish. The emoji glyph pill keeps its own size. */
33
- --pill-font: .8rem;
34
- color-scheme: light;
35
- }
36
-
37
- html[data-theme="dark"] {
38
- /* Neutral ramp, same tint as the main app: page 18 → surface 32 → border 52. */
39
- --bg: rgb(18, 19, 20);
40
- --fg: rgb(190, 190, 190);
41
- --muted: rgb(150, 150, 150);
42
- --panel: rgb(32, 32, 32);
43
- --border: rgb(52, 52, 52);
44
- --accent: rgb(122, 162, 250);
45
- --veil: rgba(255, 255, 255, 0.06);
46
- --tool-bg: var(--panel);
47
- --err: rgb(230, 60, 60);
48
- --warn: rgb(232, 184, 80); /* amber — the cache-leak (cost-caution) tier, milder than --err */
49
- --diff-add: rgb(100, 210, 130);
50
- --fold-marker: rgb(0, 255, 0);
51
- color-scheme: dark;
52
- }
53
-
54
- /* Aliases for beautiful-mermaid, indirecting around a var-name collision with the
55
- bulb's theme (see the renderMermaidSVG call). They still resolve to the live
56
- theme at the use site. */
57
- :root {
58
- --mm-bg: var(--bg);
59
- --mm-fg: var(--fg);
60
- --mm-line: var(--muted);
61
- --mm-accent: var(--accent);
62
- --mm-muted: var(--muted);
63
- --mm-surface: var(--tool-bg);
64
- --mm-border: var(--border);
65
- }
66
-
67
- * { box-sizing: border-box; }
68
-
69
- body {
70
- margin: 0;
71
- height: 100vh;
72
- overflow: hidden;
73
- color: var(--fg);
74
- background: var(--bg);
75
- font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
76
- font-size: 15px;
77
- line-height: 1.55;
78
- }
79
-
80
- .app { display: flex; flex-direction: column; height: 100vh; }
81
- /* The transcript region. position:relative anchors the overlaid (absolute) statusbar and banners to
82
- THIS box, not the app: with a composer below the pills stay over the transcript's bottom edge
83
- instead of landing on the input panel. */
84
- .chat { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; }
85
-
86
- /* Bottom status strip, overlaid (not in flow) on the chat — which runs
87
- full-height to the very bottom. The strip itself has NO fill, so the message
88
- text shows through everywhere, right to the bottom edge. Only the items (the
89
- pills) are opaque, so they overlay the text with visual precedence rather than
90
- the strip cutting it off with a band. No border — the typebulb host's rounded
91
- card is the only frame. */
92
- .statusbar {
93
- position: absolute;
94
- left: 0;
95
- right: 0;
96
- bottom: 0;
97
- /* Same column as .messages and the composer: a --content-max band centred in a 1.75rem gutter
98
- (max-width carries the two gutters so the content box lands on --content-max). The pills are
99
- right-aligned within it, so the cluster's right edge tracks the prose column's — consistent
100
- with the input above and the transcript behind. The gutter also clears the overlaid scrollbar
101
- that the old asymmetric right padding was there to dodge. */
102
- margin-inline: auto;
103
- max-width: calc(var(--content-max) + 3.5rem);
104
- display: flex;
105
- align-items: center;
106
- gap: .65rem;
107
- padding: .4rem 1.75rem;
108
- font-size: .8rem;
109
- /* Drop the inherited 1.55 line-height so the bar's own strut doesn't add
110
- phantom ascender/descender room above the pills. Everything in the bar
111
- is a fixed-height pill, which looks right at 1. */
112
- line-height: 1;
113
- min-width: 0;
114
- }
115
- .statusbar-actions {
116
- margin-left: auto;
117
- display: flex;
118
- gap: .4rem;
119
- align-items: center;
120
- }
121
- /* THE CENTERING RECIPE for EVERY word-label control on any surface — the status-bar pills and
122
- floating overlay pills below, and the launcher row's trust switch. One rule = one
123
- source of vertical alignment; when these drift apart it reads as amateurish, so a new word
124
- control joins this selector rather than centering itself (don't re-tweak per button — fix it
125
- here or nowhere). Icon buttons opt back out, keeping flex (see .pill.glyph). Segoe UI's font
126
- ascent (~1.08em) dwarfs its descent (~0.25em), so anything that centers the LINE BOX —
127
- flex, line-height, padding — parks the glyphs ~2px low at pill sizes. `text-box` trims the
128
- line box to the glyphs (cap→baseline) so centering centers the letters themselves. It only
129
- applies to block containers (not flex), hence inline-block + align-content, not inline-flex
130
- + align-items. Browsers without text-box (Firefox) fall back to plain line-box centering. */
131
- .pill, .token, .overlay-pill, .trust-toggle {
132
- display: inline-block;
133
- align-content: center;
134
- text-align: center;
135
- text-box: trim-both cap alphabetic;
136
- }
137
- /* Chip shape shared by the status-bar pills (interactive and passive) AND the floating overlay
138
- pills (copy, breakout, code⇄run) — opaque, so they read cleanly over the message text the
139
- transparent strip lets through. The launcher row's pills take only the recipe above; their
140
- shape is the row's (.trust-toggle). */
141
- .pill, .token, .overlay-pill {
142
- height: var(--pill-h);
143
- padding: 0 .7rem;
144
- border-radius: 7px;
145
- background: var(--panel);
146
- border: 1px solid var(--border);
147
- color: var(--muted);
148
- font-size: var(--pill-font);
149
- white-space: nowrap;
150
- user-select: none;
151
- }
152
- /* Pills are <button>s: reset UA chrome (font/appearance/margin) and add the
153
- interactive affordance. Slightly larger text than the passive indicator. */
154
- .pill {
155
- font: inherit;
156
- font-size: var(--pill-font);
157
- appearance: none;
158
- margin: 0;
159
- cursor: pointer;
160
- }
161
- /* `.on` latches the hover accent — the bar's only persistent state cue (every other
162
- pill accents on hover alone). */
163
- .pill:hover, .pill.on { border-color: var(--accent); color: var(--accent); }
164
- /* Square glyph pill: width pinned to the pill height rather than padding-driven. Glyph content
165
- (emoji span / the Anthropic SVG) is vertically symmetric, so plain flex centering is exact for it
166
- — and cap/alphabetic trim mis-seats a replaced SVG (it trims to TEXT metrics), so opt glyph pills
167
- back out of the word-pill recipe. */
168
- .pill.glyph {
169
- display: inline-flex;
170
- align-items: center;
171
- justify-content: center;
172
- text-box: normal;
173
- width: var(--pill-h);
174
- padding: 0;
175
- font-size: .9rem;
176
- }
177
- .pill.glyph .glyph-img { filter: grayscale(1); opacity: .75; }
178
- .pill.glyph:hover .glyph-img, .pill.glyph.on .glyph-img { filter: none; opacity: 1; }
179
-
180
- /* The two status-bar chips each anchor an upward popover (token breakdown /
181
- session list). Wrap = positioning context; the popover anchors bottom/right and
182
- grows up-and-left — the chips sit in the right-aligned cluster, so left:0 would
183
- run them off the viewport's right edge. */
184
- .token-wrap, .sid-wrap, .servers-wrap, .model-wrap, .gitdiff-wrap, .children-wrap { position: relative; display: inline-block; }
185
- /* Each pill renders its wrap unconditionally, emitting an empty div when it has
186
- nothing to show (no tokens / no session / nothing running). An empty wrap is
187
- still a flex child, so .statusbar-actions' gap lands on both its sides —
188
- doubling the visible gap between its neighbours. Collapse the empties so the
189
- cluster spaces evenly regardless of which pills are present. */
190
- .token-wrap:empty, .sid-wrap:empty, .servers-wrap:empty, .model-wrap:empty, .gitdiff-wrap:empty, .children-wrap:empty { display: none; }
191
- .picker, .servers-pop {
192
- --menu-h: 428px; /* one height for both statusbar menus — they sit side by side, so a
193
- mismatched cap reads as a glitch; .picker grows to it, .servers-pop
194
- too (the drilled-in console is the one fixed-height exception, see
195
- .log-mode). Sized to show exactly 12 rows
196
- (12 × --menu-row-h + 11 gaps + filter + padding) — recognition fades
197
- past ~12 and search owns the tail. Plain px: desktop-only surface,
198
- every desktop window fits it. 428, not 427: at 427 the two menus land
199
- on different device-pixel roundings, a 1px sibling mismatch — the
200
- lesser evil is 1px off the ideal height. */
201
- --menu-row-h: 29px; /* one row height for both menus' lists — rows must match pitch exactly
202
- or the side-by-side menus read as two designs. Whole px, eyeball-tuned;
203
- the row paddings are kept below it so this floor is what governs. */
204
- /* ONE geometry contract for both menus — sizing policy, padding, stacking. Stating it twice is
205
- how every sibling discrepancy of June '26 happened (row pitch, gaps, fixed-vs-grow). max-height,
206
- not height: a menu hugs its content and caps at the 12 rows above — a fixed height left a
207
- 1-bulb project with a row floating atop a 400px void. Per-menu rules keep only width and
208
- alignment (.pop-center / .pop-start below). */
209
- max-height: var(--menu-h);
210
- overflow: hidden;
211
- padding: .35rem;
212
- /* The bar's line-height:1 is for its fixed-height pills; a menu is a text surface, so restore
213
- descender room HERE, once. Inherited, the 1 crops the tails of "g"/"j"/"p" under every row's
214
- overflow:hidden (the ellipsis clip) — a leaf-by-leaf fix that had already been pasted three
215
- times. Rows are floored at --menu-row-h with their paddings kept under it, so pitch is unchanged. */
216
- line-height: 1.35;
217
- display: flex;
218
- flex-direction: column;
219
- gap: .15rem;
220
- position: absolute;
221
- bottom: calc(100% + .35rem);
222
- right: 0;
223
- z-index: 10;
224
- background: var(--panel);
225
- border: 1px solid var(--border);
226
- border-radius: 10px;
227
- box-shadow: 0 8px 30px rgba(0, 0, 0, .45);
228
- }
229
- /* Menu-list scrollbars: the channel is ALWAYS reserved (overflow-y: scroll on all three lists) and
230
- the scrollbar is owned — no arrow buttons, transparent track, a floating thumb only when the list
231
- actually overflows. Native auto scrollbars made a row band's right inset depend on list length: a
232
- project with no bulbs of its own (one collapsed fold row) ran its band 15px nearer the popover
233
- edge than the filter box under it, and bare overflow:scroll parked a vestigial arrow-stub beside
234
- the short list. Width stays 15px, the native width .model-head's alignment padding hardcodes.
235
- Chromium pseudos on purpose: setting the standard scrollbar-color would disable them. */
236
- :is(.picker-list, .bulb-list, .gitdiff-list)::-webkit-scrollbar { width: 15px; }
237
- :is(.picker-list, .bulb-list, .gitdiff-list)::-webkit-scrollbar-button { display: none; }
238
- :is(.picker-list, .bulb-list, .gitdiff-list)::-webkit-scrollbar-track { background: transparent; }
239
- :is(.picker-list, .bulb-list, .gitdiff-list)::-webkit-scrollbar-thumb {
240
- background-color: color-mix(in srgb, var(--fg) 28%, transparent);
241
- border-radius: 8px;
242
- /* Transparent border + padding-box clip floats a slim pill in the 15px channel. */
243
- border: 4px solid transparent;
244
- background-clip: padding-box;
245
- }
246
- :is(.picker-list, .bulb-list, .gitdiff-list)::-webkit-scrollbar-thumb:hover { background-color: color-mix(in srgb, var(--fg) 45%, transparent); }
247
-
248
- /* The overview ruler (overviewRuler.ts): a scroll container's bar, replaced. .oruler-host is applied
249
- by the widget at mount — never statically — so the native bar only disappears once its replacement
250
- is painted. BOTH hiding routes: the standard property covers Gecko, the pseudo covers WebKit, and
251
- Blink honours either, so no engine is left showing two scrollbars. Window and marks are % of a track
252
- that spans the pane, so they share the container's content space. Marks are pointer-inert; the track
253
- takes the press, the drag and the wheel. Colour and the track's inset are the host's (.diff-ruler).
254
- Cursor stays the plain arrow — that's what a scrollbar shows, and `grab` would claim otherwise. */
255
- .oruler-host { scrollbar-width: none; }
256
- .oruler-host::-webkit-scrollbar { width: 0; height: 0; }
257
- .oruler { position: absolute; inset-block: 0; width: 6px; z-index: 5; cursor: default; touch-action: none; }
258
- .oruler-mark { position: absolute; left: 0; right: 0; min-height: 3px; border-radius: 1px; pointer-events: none; }
259
- /* Wider than the track and above the marks, so what's on screen reads as a thumb laid over the map. */
260
- .oruler-win {
261
- position: absolute; left: -3px; right: -3px; z-index: 1; min-height: 6px; border-radius: 3px;
262
- pointer-events: none; background: color-mix(in srgb, var(--fg) 28%, transparent);
263
- }
264
- /* The menu thumbs' own two steps (28/45) — same surface, same weights. Still a wash rather than a
265
- solid fill: the marks it covers are the ones you're about to read. */
266
- .oruler:hover .oruler-win { background: color-mix(in srgb, var(--fg) 45%, transparent); }
267
- /* A finger needs a target a 6px track can't give; the map is worth more widened than dropped. */
268
- @media (pointer: coarse) {
269
- .oruler { width: 14px; }
270
- .oruler-win { left: -2px; right: -2px; }
271
- }
272
- /* Forced colours drop our tints — the window would vanish outright, and the marks' red/green IS the
273
- information, so it keeps its own palette rather than collapsing to one system colour. */
274
- @media (forced-colors: active) {
275
- .oruler-win { background: transparent; border: 1px solid Highlight; }
276
- .oruler-mark { forced-color-adjust: none; }
277
- }
278
- /* Popover alignment — which edge of the pill the menu aligns to (floating-UI's start/center/end
279
- vocabulary; "end", right edges flush and growing left, is the unnamed default above). Tagged on
280
- the pill's WRAP, not each popover root: a menu with several drilled-in views (the model pill's
281
- list/info/diag/key modes) aligns once, and a future mode can't forget. The cluster's left-side
282
- pills (diff, model) center — end-anchored, their menus run out of room growing left. No viewport
283
- clamping: desktop-only; revisit if a narrow window ever clips one. */
284
- .pop-center > :is(.picker, .servers-pop) { right: auto; left: 50%; transform: translateX(-50%); }
285
- .pop-start > :is(.picker, .servers-pop) { right: auto; left: 0; }
286
-
287
- /* ---- Shimmer — the one "working" cue. One gradient, one keyframes, composed via class, never
288
- re-pasted (paste-drift is how each new shimmer kept landing at its own speed). `.shimmer` sweeps
289
- the element's background; `.shimmer-text` clips the sweep to the glyphs alone. Knobs, set by the
290
- using rule: --shimmer-base (the gradient's resting color — transparent for see-through chrome, an
291
- opaque surface for pills that must cover content) and --shimmer-mix (accent strength). Pick the
292
- cadence by the band's LINEAR speed, which is what reads consistent: travel is proportional to
293
- element width (background-size 250%), and ~2rem/s is the calibrated pace — so narrow chrome
294
- (a 1.7rem play button) takes the 2.2s default and anything text-width or wider takes
295
- `.shimmer-slow` (7s). A fast sweep reads as urgency, not progress. */
296
- /* The state longhands are !important: this class composes onto elements whose resting rules set a
297
- `background:` shorthand at arbitrary specificity (button resets, .overlay-pill, :hover tints) —
298
- any of which would silently kill the gradient (the bug that ate the play button's shimmer). A
299
- working cue must win while it's on; resting background-color still shows beneath the sweep. */
300
- .shimmer, .shimmer-text { background-size: 250% 100% !important; animation: bulb-shimmer var(--shimmer-speed, 2.2s) linear infinite !important; }
301
- .shimmer {
302
- background-image: linear-gradient(100deg, var(--shimmer-base, transparent) 30%, color-mix(in srgb, var(--accent) var(--shimmer-mix, 35%), var(--shimmer-base, transparent)) 50%, var(--shimmer-base, transparent) 70%) !important;
303
- background-color: var(--shimmer-base, transparent);
304
- }
305
- .shimmer-text {
306
- /* Shrink-to-fit, so the sweep paces the text's own length, not the container's — on a full-width
307
- box the band spends most of its run off-glyph (same move as typebulb.com's shimmer.ts). */
308
- display: inline-block;
309
- /* background-clip:text paints the glyphs through this element's own box, so the box must be tall
310
- enough to cover descenders — inheriting the statusbar's line-height:1 clips the "g" in "probing"/
311
- "working…" at the baseline. A descender-clearing line-height restores the paint area; the button
312
- centres this box, so the extra height never unbalances the row. */
313
- line-height: 1.3;
314
- background-image: linear-gradient(100deg, currentColor 35%, var(--accent) 50%, currentColor 65%) !important;
315
- -webkit-background-clip: text;
316
- background-clip: text;
317
- -webkit-text-fill-color: transparent;
318
- }
319
- .shimmer-slow { --shimmer-speed: 7s; }
320
- /* Summary's text-only working cue sits on a white surface in the light theme. Widen and deepen its
321
- blue sweep there so the seven-second motion remains plainly visible; dark's calibrated treatment
322
- stays untouched. */
323
- html:not([data-theme="dark"]) .turn-view > .shimmer-text {
324
- background-image: linear-gradient(100deg, currentColor 20%, var(--accent) 40%, color-mix(in srgb, var(--accent) 70%, var(--fg)) 50%, var(--accent) 60%, currentColor 80%) !important;
325
- }
326
- @keyframes bulb-shimmer { from { background-position: 250% 0; } to { background-position: -250% 0; } }
327
- @media (prefers-reduced-motion: reduce) {
328
- .shimmer { animation: none !important; background: color-mix(in srgb, var(--accent) 15%, var(--shimmer-base, transparent)) !important; }
329
- .shimmer-text { animation: none !important; background: none !important; -webkit-text-fill-color: currentColor; }
330
- }
331
-
332
- /* THE WORKING STATE for an opaque word pill — the token chip mid-turn. Only the shimmer's knobs live
333
- here; the sweep itself is the shared .shimmer above, composed by `busyPill()` (ui.ts) so a new busy
334
- pill cannot land at its own speed or its own base colour. The sweep blends with --panel, not
335
- transparent like the launch buttons': a word pill sits over message text and must keep covering it.
336
- shimmer-slow because these can sit lit for minutes (the JSONL flushes in batches, so the token chip
337
- rides a whole buffering window). */
338
- .pill-busy { border-color: var(--accent); --shimmer-base: var(--panel); --shimmer-mix: 25%; }
339
-
340
- /* ---- Boot overlay (index.html, markup and script both) — what the window shows between the HTML
341
- landing and the client having a transcript. Opaque and over #app, so the empty
342
- chrome never flashes through first. The 1.2s animation delay is the whole don't-flash-on-a-fast-load
343
- mechanism: a normal boot is retired before this fades in, so the wait is only ever announced when
344
- there is one. Measured, not guessed — a warm local boot lands at 590-930ms (dev, where the client
345
- bundle is unminified and slowest), so the delay has to clear that, not merely beat a blank screen.
346
- Geometry follows .cwd-hint, the mirror's other centred whole-window message. */
347
- .boot {
348
- position: fixed;
349
- inset: 0;
350
- background: var(--bg);
351
- display: grid;
352
- place-content: center;
353
- justify-items: center;
354
- gap: .5rem;
355
- padding: 2rem;
356
- text-align: center;
357
- animation: boot-in .35s ease 1.2s both;
358
- }
359
- .boot-title { font-size: 1.15rem; }
360
- /* min-height reserves the line the seconds count lands on, so naming the wait doesn't shift the title. */
361
- .boot-why { color: var(--muted); font-size: .95rem; max-width: 32rem; min-height: 1.4em; }
362
- @keyframes boot-in { from { opacity: 0; } to { opacity: 1; } }
363
-
364
- /* ── Tooltip — the styled stand-in for native `title`, and only on FIXED EXPLANATORY copy: text
365
- that teaches what a control does. Dynamic identifiers (a path, a model id, an mcp tool's full
366
- name) keep the native attribute, which is better at them: they run long, they want to wrap, and
367
- `title` is the reliable "show me the whole string" affordance a bounded box is worse at. One
368
- attribute opts a control in, no wrapper component and no JS, and it shows on :focus-visible,
369
- which native `title` never does for a keyboard user. `data-tip` is not an accessible name, so a
370
- glyph-only control still needs its own ariaLabel.
371
- Three non-obvious bits. :where() zeroes the position rule's specificity, so a control that is
372
- ALREADY positioned (the filter's inset × and 🔬) keeps its own and is its own containing block
373
- anyway. display:none at rest rather than opacity:0, because an absolutely positioned box still
374
- counts toward a scroll container's scrollable overflow and .messages' stuck-to-bottom test is
375
- measured in pixels. The open delay therefore rides an animation (fill-mode both holds the
376
- from-state through the delay), since a display switch cannot transition. ── */
377
- :where([data-tip]) { position: relative; }
378
- [data-tip]::after {
379
- content: attr(data-tip);
380
- display: none;
381
- position: absolute;
382
- z-index: 40;
383
- top: calc(100% + 7px);
384
- left: 50%;
385
- transform: translateX(-50%);
386
- width: max-content;
387
- max-width: min(22rem, 60vw);
388
- padding: .3rem .55rem;
389
- background: var(--panel);
390
- color: var(--fg);
391
- border: 1px solid var(--border);
392
- border-radius: 8px;
393
- box-shadow: 0 8px 30px rgba(0, 0, 0, .45);
394
- /* Its own typography, never the host's (a stub label is italic, a pill is not) — but the same
395
- SIZE as the chrome it explains, .8rem, which is both the statusbar's and the tab row's. A tip
396
- set larger than the control it belongs to reads as shouting. Full --fg carries it instead. */
397
- font-size: .8rem;
398
- font-style: normal;
399
- font-weight: 400;
400
- line-height: 1.4;
401
- /* Centred and balanced, because the box CANNOT hug wrapped text: `width: max-content` capped by
402
- max-width means any tip too long for one line sits at exactly max-width, whatever it wraps to.
403
- Left-aligned, the leftover on the last line then reads as dead space (the model pill's tip, the
404
- only one long enough to wrap). Balancing evens the lines and centring splits what's left to both
405
- sides, so a two-line tip reads as a block instead of a full line plus a stub. */
406
- text-align: center;
407
- text-wrap: balance;
408
- /* pre-line, not normal: a tip built from two lines (the session pill's cwd over its id) keeps the
409
- break native `title` gave it, and everything else still wraps at the max-width. */
410
- white-space: pre-line;
411
- pointer-events: none;
412
- }
413
- [data-tip]:hover::after,
414
- [data-tip]:focus-visible::after { display: block; animation: tip-in .12s ease .4s both; }
415
- @keyframes tip-in { from { opacity: 0; } to { opacity: 1; } }
416
- /* One tip nested in another (the diff pill's × sits inside the pill body): hovering the inner one
417
- hovers the outer too, so both boxes would open. Only the innermost shows, which is how native
418
- `title` resolves it. */
419
- [data-tip]:has([data-tip]:hover)::after { display: none; }
420
- /* Pressed: tooltips are dismissed until the pointer reaches a different one (ui.ts
421
- armTooltipDismiss). Out-specifies the :hover rule on its own, no !important needed. */
422
- body.tips-off [data-tip]::after { display: none; }
423
- /* Bottom-anchored surfaces open upward, like their own menus: under the statusbar and under the
424
- composer's action row there is no room to drop into. */
425
- :is(.statusbar, .composer) [data-tip]::after { top: auto; bottom: calc(100% + 7px); }
426
- /* ...and grow LEFT from the control's right edge, which is the "end" alignment the statusbar menus
427
- already take by default (.pop-center / .pop-start above are their opt-outs). Every one of these
428
- clusters is right-aligned, so a centred box runs past the viewport — the bulbs pill's tip was
429
- clipped — and inside a menu it is also the direction that stays within its overflow:hidden for a
430
- control inset at the right edge. The transcript's own tips stay centred: they sit mid-column. */
431
- :is(.statusbar, .composer, .picker, .servers-pop) [data-tip]::after { left: auto; right: 0; transform: none; }
432
- /* ...except where the pill's WRAP already names an alignment for its menu. A tip and the menu it
433
- belongs to must open the same way, so both read off the same tag (.pop-center / .pop-start above)
434
- and neither can be set on its own. Direct child, like the menu rules: a control nested inside a
435
- pill (the diff pill's ×) keeps the cluster's end alignment, which is right at a pill's far edge. */
436
- .pop-center > [data-tip]::after { right: auto; left: 50%; transform: translateX(-50%); }
437
- .pop-start > [data-tip]::after { right: auto; left: 0; transform: none; }
438
-
439
- /* ── The composer (TB-Agent-Composer.md): the prompt panel under the transcript. In flow (not
440
- overlaid like the statusbar) — it owns real height and must never cover content. ── */
441
- .composer { position: relative; flex: none; background: var(--bg); padding-inline: 1.75rem; }
442
- /* Keep the composer's rows on the same prose column as the transcript (--content-max, centered like
443
- .messages) — full-mirror-width input reads badly, the eye has to travel to wrap long lines. The
444
- 1.75rem inline padding above matches .messages' gutter so the two columns align at every width. */
445
- .composer .splitter,
446
- .composer .input-wrap,
447
- .composer-err,
448
- .composer-status,
449
- .composer-queue {
450
- max-width: var(--content-max);
451
- margin-inline: auto;
452
- }
453
- /* A 4px drag zone centered on the composer's top border; z-index lifts it above the input-wrap
454
- border so the line itself is grabbable. */
455
- .splitter {
456
- position: relative;
457
- height: 4px;
458
- margin-top: -2px;
459
- margin-bottom: -2px;
460
- z-index: 5;
461
- cursor: row-resize;
462
- background: transparent;
463
- flex-shrink: 0;
464
- }
465
- .splitter:hover { background: color-mix(in srgb, var(--accent) 25%, transparent); }
466
- .input-wrap { position: relative; border-top: 1px solid var(--border); }
467
- .composer-input {
468
- width: 100%;
469
- display: block;
470
- resize: none; /* height is splitter-driven (inline style), not the native handle */
471
- border: none;
472
- border-radius: 0;
473
- background: var(--bg);
474
- color: var(--fg);
475
- font: inherit;
476
- font-size: .9rem;
477
- line-height: 1.45;
478
- /* Zero horizontal padding so the text sits flush on the --content-max column — its left and right
479
- edges line up with the transcript prose (which has no inset) and the splitter. Buttons live in
480
- .composer-actions below, so the text needs no right gutter either. */
481
- padding: .5rem 0;
482
- }
483
- .composer-input:focus { outline: none; }
484
- .composer-input:disabled { opacity: .6; }
485
- .composer-input::placeholder { color: var(--muted); }
486
- /* The icons.ts shell: one span, sized HERE (never by svg attrs), the material/custom svg filling
487
- it. 14px default; context classes (.btn-icon, .caret-tri, …) override. */
488
- .icon { display: inline-flex; align-items: center; justify-content: center; width: 14px; height: 14px; flex: none; }
489
- .icon svg { width: 100%; height: 100%; display: block; }
490
- /* The action bar under the textarea, right-aligned: send/stop is the primary CTA; + (new
491
- conversation) and the clipboard clip are the quiet secondaries to its left. Below the text (not
492
- overlaid) so the textarea keeps the full prose column. */
493
- .composer-actions {
494
- display: flex;
495
- justify-content: flex-end;
496
- align-items: center;
497
- gap: .15rem;
498
- padding: 0 0 .4rem; /* flush right, so Send lines up with the copy pill / statusbar pills */
499
- }
500
- .composer-act, .composer-new, .composer-clip {
501
- width: 28px;
502
- height: 28px;
503
- display: flex;
504
- align-items: center;
505
- justify-content: center;
506
- padding: 0;
507
- margin: 0;
508
- appearance: none;
509
- border: none;
510
- border-radius: 50%;
511
- cursor: pointer;
512
- transition: background .15s, color .15s, transform .08s;
513
- }
514
- /* Send/stop are CIRCLE GLYPHS (play_circle / stop_circle — typebulb.com's play look): the icon IS
515
- the button, no painted disc. One size for all three — the circles' diameter and the paperclip's
516
- ink height are both 800/960 of the material viewBox, so equal boxes = equal visual height. */
517
- .composer-act .icon, .composer-new .icon, .composer-clip .icon { width: 24px; height: 24px; }
518
- .composer-act { background: transparent; }
519
- .composer-act.send { color: var(--accent); }
520
- .composer-act.send:hover:not(:disabled) { color: color-mix(in srgb, var(--accent) 80%, white); background: color-mix(in srgb, var(--accent) 10%, transparent); }
521
- .composer-act.stop { color: var(--err); }
522
- .composer-act.stop:hover { color: color-mix(in srgb, var(--err) 80%, white); background: color-mix(in srgb, var(--err) 10%, transparent); }
523
- .composer-act:active, .composer-new:active, .composer-clip:active { transform: scale(.94); }
524
- .composer-act:disabled, .composer-new:disabled, .composer-clip:disabled { opacity: .5; cursor: default; }
525
- .composer-new {
526
- background: transparent;
527
- color: var(--muted);
528
- }
529
- .composer-new:hover:not(:disabled) { color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, transparent); }
530
- .composer-clip {
531
- background: transparent;
532
- color: var(--muted);
533
- }
534
- .composer-clip:hover:not(:disabled) { color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, transparent); }
535
- /* Zero horizontal padding (like .composer-input): these rows sit flush on the content column. */
536
- .composer-err { padding: .3rem 0; color: var(--err); font-size: .78rem; }
537
- /* The ambient status line (TB-Agent-Composer-Toolkit.md Piece 2): one slim kind-tinted row above the
538
- input — a retry/compaction in progress, an extension notice, a recipe's transient result. */
539
- .composer-status { padding: .3rem 0; font-size: .78rem; color: var(--accent); }
540
- .composer-status.warning { color: var(--warn); }
541
- .composer-status.error { color: var(--err); }
542
- /* The pending-queue strip (parity #2): queued steer/follow-up texts above the input. Block rows,
543
- not a flex column — .queue-row's overflow:hidden would zero flex min-heights (the .dlg-opts
544
- lesson). */
545
- .composer-queue { padding: .15rem 0 0; }
546
- .queue-row { font-size: .78rem; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
547
- .queue-tag { color: var(--accent); font-size: .68rem; text-transform: uppercase; letter-spacing: .05em; margin-right: .5rem; }
548
- /* The @-file-mention popup, anchored above the composer. Tall enough for the pickers' full row
549
- caps (10 files / 12 commands); the vh guard + arrow-key scroll-into-view cover short windows. */
550
- .at-popup {
551
- position: absolute;
552
- bottom: calc(100% + .25rem);
553
- left: 0;
554
- right: 0;
555
- max-width: 600px;
556
- margin-inline: auto;
557
- max-height: min(24rem, 50vh);
558
- overflow-y: auto;
559
- background: var(--panel);
560
- border: 1px solid var(--border);
561
- border-radius: 10px;
562
- box-shadow: 0 8px 30px rgba(0, 0, 0, .35);
563
- padding: .25rem;
564
- z-index: 10;
565
- }
566
- .at-row {
567
- padding: .35rem .55rem;
568
- border-radius: 6px;
569
- cursor: pointer;
570
- font-family: ui-monospace, Menlo, monospace;
571
- font-size: .82rem;
572
- white-space: nowrap;
573
- overflow: hidden;
574
- text-overflow: ellipsis;
575
- }
576
- .at-row.active { background: color-mix(in srgb, var(--accent) 18%, transparent); }
577
- .at-row:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); }
578
- /* The '/' palette rides the same popup chrome; its rows pair a command with a muted description. */
579
- .slash-desc { color: var(--muted); font-size: .74rem; margin-left: .6rem; }
580
- /* The composer's dialog fields (TB-Agent-Composer-Toolkit.md Piece 3), shared by both homes: the
581
- trust-modal chrome (server dialogs, confirms) and the anchored .dlg-popup (local select/input).
582
- Select options reuse .at-row inside a scroll box. */
583
- /* Block, NOT flex: .at-row's overflow:hidden zeroes flex min-heights, squashing a long option
584
- list (pi can return the whole OpenRouter catalog); block rows keep their natural height and
585
- the box scrolls. */
586
- .dlg-opts {
587
- max-height: 40vh; overflow-y: auto;
588
- border: 1px solid var(--border); border-radius: 8px; padding: .25rem;
589
- }
590
- .dlg-input, .dlg-editor {
591
- font: inherit; font-size: .85rem; color: var(--fg); background: transparent;
592
- border: 1px solid var(--border); border-radius: 7px; padding: .4rem .6rem; width: 100%;
593
- }
594
- .dlg-input:focus, .dlg-editor:focus { outline: none; border-color: var(--accent); }
595
- .dlg-editor { min-height: 8rem; resize: vertical; }
596
- .dlg-filter { margin-top: .4rem; }
597
- /* A local (recipe) select/input rides the @-popup shell — the gesture register, not the modal's
598
- (composer.ts #dlgAnchored). The popup itself stops scrolling (its .dlg-opts scrolls instead, so
599
- the filter stays pinned at the anchored bottom edge); the opts box drops its border, the popup
600
- frame already provides one. The catcher is the invisible click-away target under the popup. */
601
- .dlg-popup { max-height: none; overflow: visible; }
602
- .dlg-popup .dlg-opts { border: none; padding: 0; }
603
- .dlg-label { font-size: .78rem; color: var(--muted); padding: .1rem .25rem .3rem; }
604
- .dlg-catcher { position: fixed; inset: 0; z-index: 9; }
605
- .at-empty { padding: .6rem; color: var(--muted); font-size: .85rem; text-align: center; }
606
- /* The driver's in-flight assistant message — visibly provisional next to landed bubbles; the
607
- durable row replaces it when the entry hits the transcript. */
608
- .bubble.draft { opacity: .9; }
609
- .draft-wait { color: var(--muted); }
610
- /* Pasted-image thumbnails under a user message that @-mentions .typebulb/paste files. */
611
- .paste-thumbs { margin-top: .4rem; }
612
- .paste-thumb { display: block; max-width: min(360px, 100%); max-height: 240px; margin-top: .3rem; border: 1px solid var(--border); border-radius: 8px; }
613
-
614
- /* Session list — shares the popover chrome (.picker above); these are
615
- just its own dimensions. */
616
- .picker { width: min(440px, calc(100vw - 2rem)); }
617
- /* The filter (.bulb-filter-control) pins at the bottom — the popover's anchored edge, so it never
618
- jumps as the filtered list changes height; the row list scrolls inside the capped box, resting at
619
- its end (newest-at-bottom) — same shape as the launcher's .servers-pop / .bulb-list. */
620
- .picker-list { display: flex; flex-direction: column; gap: .15rem; flex: 1; min-height: 0; overflow-y: scroll; padding-right: 4px; }
621
- /* Positioned wrapper around just the list (not the filter) so the hover-peek card anchors to the
622
- list's bottom edge, sitting above the filter input. */
623
- .picker-body { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; }
624
- .picker-empty { padding: .8rem; color: var(--muted); font-size: .85rem; text-align: center; }
625
- .picker-row {
626
- display: flex;
627
- flex-direction: column;
628
- /* flex:none — the explicit min-height below replaces flexbox's implicit min-height:auto floor,
629
- which would otherwise let an overflowing list squash rows below their content (snippet rows
630
- rendered as overlapping text). The list scrolls; rows never compress. */
631
- flex: none;
632
- gap: .2rem;
633
- /* min-height, not height: a full-text result row grows for its snippet line. */
634
- padding: .35rem .55rem;
635
- min-height: var(--menu-row-h);
636
- box-sizing: border-box;
637
- justify-content: center;
638
- border-radius: 6px;
639
- cursor: pointer;
640
- font-size: .85rem;
641
- }
642
- .picker-row-main { display: flex; gap: .6rem; align-items: baseline; }
643
- .picker:focus { outline: none; } /* the list owns its own highlight; no focus ring */
644
- .picker-row:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); }
645
- /* Keyboard cursor (arrow keys / hover) — same borderless tint as the launcher's .server-row.active. */
646
- .picker-row.active { background: color-mix(in srgb, var(--accent) 16%, transparent); }
647
- /* One dot in a reserved gutter slot, transparent on every row so titles align whether or not it
648
- shows. Green = the harness reports this session mid-turn (the live-app axis, the launcher's
649
- running-:port green); accent = "you are here", and it wins, because the row you are reading is
650
- self-evidently working and a second mark at 7px would say nothing new. Deliberately NOT process
651
- liveness, which is true of every window left open — see TB-Agent-Mirror-Ready.md. */
652
- .picker-dot {
653
- flex: none;
654
- align-self: center;
655
- width: 7px;
656
- height: 7px;
657
- border-radius: 50%;
658
- background: transparent;
659
- }
660
- .picker-dot.working { background: var(--fold-marker); }
661
- /* Higher specificity than .working on purpose: attachment outranks the turn state. */
662
- .picker-row.current .picker-dot { background: var(--accent); }
663
- .picker-preview {
664
- flex: 1; min-width: 0;
665
- white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
666
- color: var(--fg);
667
- }
668
- .picker-time {
669
- color: var(--muted);
670
- font-size: .72rem;
671
- min-width: 2.6rem; text-align: right;
672
- }
673
- /* Unread cue (TB-Agent-Mirror-Ready.md): the row's time turns the launcher's running-:port green
674
- (--fold-marker) when the session changed since the mirror opened and hasn't been viewed. */
675
- .picker-time.unseen { color: var(--fold-marker); }
676
- .picker-hits { color: var(--muted); font-size: .72rem; white-space: nowrap; }
677
- /* A full-text hit inside a session's child transcript (TB-Agent-Children.md): indented under its
678
- parent's row and muted, at the SAME size — the row's distinguishing content is the agent's
679
- description, so shrinking it is the last thing that should happen to it. */
680
- .picker-row.child { padding-left: 1.9rem; }
681
- .picker-row.child .picker-preview { color: var(--muted); }
682
- .picker-child-kind { flex: none; max-width: 7rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); opacity: .8; }
683
- /* Full-text result context: first matching turn, indented past the dot gutter into the title column. */
684
- .picker-snippet {
685
- color: var(--muted);
686
- font-size: .78rem;
687
- line-height: 1.4;
688
- margin-left: calc(7px + .6rem);
689
- overflow-wrap: anywhere;
690
- }
691
- .picker-mark {
692
- background: color-mix(in srgb, var(--accent) 30%, transparent);
693
- border-radius: 2px;
694
- color: var(--fg);
695
- }
696
- /* Hover-peek (TB-Agent-Mirror-Ready.md): the session's last output, overlaid over the TOP of the
697
- list — not a floating card but part of the menu, opaque over the rows it covers and set off from
698
- the rows below by one thick divider. Stops at the left edge of the time column (right), so the
699
- elapsed column stays an uninterrupted strip. The list rests newest-at-bottom, so a top overlay
700
- rarely sits on the row being hovered. Out of flow (no reflow); pointer-events:none. */
701
- .picker-peek {
702
- position: absolute;
703
- top: 0; left: 0; right: 3.4rem;
704
- z-index: 5;
705
- max-height: 18em;
706
- overflow: hidden;
707
- padding: .5rem .6rem .6rem;
708
- border-radius: 8px 8px 0 0; /* top rounded to match the menu; bottom square where it meets the rows */
709
- /* One veil step over the opaque panel — the mirror's nested-panel shade (a hair off the menu bg,
710
- still opaque over the rows it covers). */
711
- background: linear-gradient(var(--veil), var(--veil)), var(--panel);
712
- border-bottom: 3px solid color-mix(in srgb, var(--fg) 35%, transparent);
713
- color: var(--fg);
714
- font-size: .8rem; line-height: 1.5;
715
- overflow-wrap: anywhere;
716
- pointer-events: none;
717
- }
718
- .picker-peek.muted { color: var(--muted); font-style: italic; }
719
-
720
- /* Agent switcher (TB-Agent-Switcher.md) — reuses the .picker popover + rows; just its own width and
721
- the no-key entry form. The active model is marked with .picker-row.current (the accent dot), like
722
- the attached session; the "active" tag rides .picker-time's muted right-aligned slot. */
723
- .model-picker { width: min(380px, calc(100vw - 2rem)); }
724
- .model-err { color: var(--danger, #f87171); font-size: .76rem; padding: .3rem .55rem; }
725
- /* Key-entry form: the popover flips to this in place of the list when no key is set yet. */
726
- .key-entry { gap: .5rem; padding: .7rem; }
727
- .key-entry-head { font-size: .85rem; color: var(--fg); }
728
- .key-entry .bulb-filter { width: 100%; }
729
- .key-entry-acts { display: flex; gap: .4rem; justify-content: flex-end; }
730
- .key-entry-note { font-size: .72rem; color: var(--muted); line-height: 1.4; }
731
-
732
- /* Agent switcher watchdog (TB-Agent-Switcher.md). TWO tiers, both surfaced live while the menu is
733
- closed: RED when an Anthropic model is riding the OpenRouter route (the $-per-turn Opus footgun), and
734
- AMBER when the route served a turn uncached (the cache flip — a cost leak, pennies-scale, so quieter
735
- than red so the red stays meaningful). The latch outranks the .on accent (equal specificity → source
736
- order wins): coloured border + text, the bar's loudest states. */
737
- .pill.danger, .pill.danger:hover { border-color: var(--err); color: var(--err); }
738
- .pill.warn, .pill.warn:hover { border-color: var(--warn); color: var(--warn); }
739
- /* The watchdog banner: rendered by Root just above the statusbar, anchored to the .app box. Centered,
740
- capped width, opaque so it reads over the message text the strip lets through. Only present when a
741
- tier is live, so it never costs layout in the common case. .danger is red + a glow ring (loud);
742
- .warn is amber, no ring (a quieter cost cue). */
743
- .model-banner {
744
- position: absolute;
745
- left: 0; right: 0; bottom: 2.6rem;
746
- margin: 0 auto;
747
- max-width: min(640px, calc(100vw - 2rem));
748
- display: flex;
749
- align-items: flex-start;
750
- gap: .5rem;
751
- padding: .55rem .75rem;
752
- border-radius: 8px;
753
- background: var(--bg);
754
- color: var(--fg);
755
- font-size: .8rem;
756
- line-height: 1.4;
757
- }
758
- .model-banner.danger { border: 1px solid var(--err); box-shadow: 0 0 0 2px color-mix(in srgb, var(--err) 22%, transparent), 0 4px 14px rgba(0,0,0,.25); }
759
- .model-banner.warn { border: 1px solid var(--warn); box-shadow: 0 4px 14px rgba(0,0,0,.22); }
760
- .model-banner-icon { flex: none; }
761
- .model-banner-text { color: var(--fg); }
762
- /* L2 predictor: a status line at the top of the model popover saying what the next send will bill.
763
- Muted by default; red when the live model is the Anthropic-via-OpenRouter footgun. */
764
- /* The model menu's top section: ONE row — the route headline (ellipsizing) + the reasoning dial pinned
765
- right. Height is one model-row minus its own border, so the row + border + the popover gap to the list
766
- equal one list-row pitch — the section subtracts a whole row, never a fraction, so no model is clipped
767
- under the scroll. (Eyeball-tune the height; exact pixel math isn't worth it.) The headline carries only
768
- what the user cares about; reroute/uncached mechanics live in its title, not on the line. */
769
- .model-head {
770
- display: flex; align-items: center; justify-content: space-between; gap: .5rem; flex: none;
771
- height: calc(var(--menu-row-h) - 1px); box-sizing: border-box;
772
- /* left padding lines "More info" up with the model titles (row padding .55rem + dot 7px + gap .6rem);
773
- right padding lines the dial up with the per-row "probe" column (row padding .55rem + list
774
- padding-right 4px + scrollbar ~15px — the scrollbar is effectively always present, so it's accounted
775
- for unconditionally; tweak the 15px by eye). */
776
- padding: 0 calc(.55rem + 4px + 15px) 0 calc(.55rem + 7px + .6rem);
777
- border-bottom: 1px solid var(--border);
778
- font-size: .74rem; color: var(--muted);
779
- }
780
- /* The external-override warning is the one case that still puts words on this row. */
781
- .model-route { flex: 0 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
782
- .model-route.warn { color: var(--warn); }
783
- /* "More info" — a quiet text link (not a pill); drills into the routing/reasoning explainer (diag chrome). */
784
- .model-info-toggle {
785
- appearance: none; background: none; border: 0; margin: 0; padding: 0;
786
- font: inherit; font-size: .74rem; color: var(--accent); cursor: pointer; flex: none;
787
- }
788
- .model-info-toggle:hover { text-decoration: underline; }
789
- /* The reasoning label sits a clear gap to the left of the dial; the pair hugs the right edge. */
790
- .reasoning-ctl { display: flex; align-items: center; gap: .5rem; flex: none; }
791
- .reasoning-label { flex: none; }
792
-
793
- /* The reasoning dial — a Min/Low/Med/High native select styled as an accent pill (a full combobox is
794
- over-powered for four fixed levels), pinned to the right of the headline so the dropdown is the row's
795
- last element. */
796
- .reasoning-select {
797
- appearance: none; margin: 0; font: inherit; font-size: .72rem;
798
- width: 4rem; /* match the probe button so the dial shares its column */
799
- /* centred text (symmetric padding) so the label sits like the probe button's centred caption; the
800
- chevron still rides the right padding zone. */
801
- height: 1.5rem; padding: 0 1.1rem; text-align: center; text-align-last: center; border-radius: 999px;
802
- border: 1px solid color-mix(in srgb, var(--accent) 50%, transparent);
803
- /* OPAQUE (mixed over --panel, not transparent) so the OPEN native option list matches the CLOSED
804
- pill — a transparent fill left the open list on the browser's default bg, a jarring mismatch.
805
- The option rows reuse the same --rs-bg so the two states are identical. */
806
- --rs-bg: color-mix(in srgb, var(--accent) 14%, var(--panel));
807
- background-color: var(--rs-bg);
808
- color: var(--accent); cursor: pointer;
809
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath fill='none' stroke='%237aa2fa' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M1 1l4 4 4-4'/%3E%3C/svg%3E");
810
- background-repeat: no-repeat; background-position: right .5rem center;
811
- }
812
- .reasoning-select option { background-color: var(--rs-bg); color: var(--fg); }
813
- .reasoning-select:hover { border-color: var(--accent); }
814
- .reasoning-select:focus { outline: none; border-color: var(--accent); }
815
-
816
- /* The Anthropic brand glyph stands in for the word on the default pill. currentColor tracks the
817
- pill's muted→accent state; grayscale at rest like the other compact glyph controls. */
818
- .anthropic-glyph { display: block; }
819
- .pill.glyph .anthropic-glyph { filter: grayscale(1); opacity: .8; }
820
- .pill.glyph:hover .anthropic-glyph { filter: none; opacity: 1; }
821
-
822
- .model-row .picker-row-main { align-items: center; }
823
- /* Hold the shared --menu-row-h pitch (TB-Agent-Mirror.md: "rows must match pitch exactly or the
824
- side-by-side menus read as two designs"). The trailing control is the tallest per-row element; like
825
- the launcher's .server-row, trim the inherited .picker-row vertical padding so it stays under the
826
- --menu-row-h floor and that floor — not the padded content — governs the row height. The highlight
827
- still fills the full 29px (it paints the row box, not the padding). */
828
- .model-row { padding-top: .05rem; padding-bottom: .05rem; }
829
- /* The Anthropic (default) row is its own group below the OpenRouter models — it's the distinct "off the
830
- proxy, back to your subscription" choice, not just another model. Marked with a subtle background wash
831
- rather than a divider line: a line fought the rows' rounded corners + inset highlights (poking corners,
832
- uneven gaps), whereas the wash inherits the row's own 6px radius, so it reads as a contained band that
833
- sits naturally. Zero layout impact; lower specificity than :hover/.active, so the row still gives its
834
- normal accent feedback on top. */
835
- .model-default-row { background: color-mix(in srgb, var(--fg) 6%, transparent); }
836
- /* Right-aligned trailing cluster: the green `active` marker (only on the current row) then the
837
- fixed-width status control. The status control is rightmost + fixed width, so its column never moves
838
- as rows probe — least musical chairs. */
839
- .model-trailing { flex: none; display: flex; gap: .35rem; justify-content: flex-end; align-items: center; }
840
- /* ONE button look AND width shared by every trailing pill — probe, probing, the measured status, and
841
- the active marker — so they all line up and a transition only ever changes colour/text, never the
842
- shape. The width is snug to the widest short label ("uncached"); the labels are kept terse precisely
843
- so this stays tight rather than gutter-heavy. */
844
- .model-btn {
845
- appearance: none; margin: 0; font: inherit; font-size: .72rem;
846
- width: 4rem; height: 1.5rem; padding: 0 .25rem; border-radius: 999px;
847
- display: inline-flex; align-items: center; justify-content: center;
848
- border: 1px solid var(--border); background: transparent; color: var(--muted);
849
- white-space: nowrap; cursor: pointer;
850
- }
851
- .model-status.probe:hover { color: var(--accent); border-color: var(--accent); }
852
- /* Measured states. cached = the blue accent (the positive "good route" cue); uncached/unavailable =
853
- red (the states that bite on cost); cache-unconfirmed = neutral. All reuse existing theme colours. */
854
- .model-status.ok { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 50%, transparent); background: color-mix(in srgb, var(--accent) 12%, transparent); }
855
- .model-status.bad { color: var(--err); border-color: color-mix(in srgb, var(--err) 50%, transparent); background: color-mix(in srgb, var(--err) 12%, transparent); }
856
- .model-status.warn { color: var(--muted); border-color: var(--border); }
857
- .model-status.ok:hover, .model-status.bad:hover, .model-status.warn:hover { filter: brightness(1.12); text-decoration: underline; }
858
- /* Same pill as every other state — only the text/colour change across probe → probing → result, never
859
- the box. Non-interactive (cursor default); the accent-tinted border holds against the row's accent
860
- hover tint, where the faint default --border washed out. */
861
- .model-status.probing { color: var(--muted); border-color: color-mix(in srgb, var(--accent) 35%, transparent); cursor: default; }
862
- /* The active marker — the lime/green live-axis colour (--fold-marker), matching the launcher's running
863
- :port. A cue, not a control: no hover, default cursor. */
864
- .model-active { color: var(--fold-marker); border-color: color-mix(in srgb, var(--fold-marker) 50%, transparent); background: color-mix(in srgb, var(--fold-marker) 12%, transparent); cursor: default; }
865
- /* Don't double-mark the current row: the green `active` pill is the marker now, so drop the accent dot
866
- fill (keep the gutter transparent for name alignment). */
867
- .model-picker .picker-row.current .picker-dot { background: transparent; }
868
-
869
- /* Drill-in surface — the probe diagnostics and the "More info" routing/reasoning explainer (the
870
- launcher's logs analogue). Flips the whole popover; reuses the console head/back chrome. Held at the
871
- FULL menu height (not content-sized) so drilling in from the list never resizes the popover — the body
872
- fills and scrolls if the content is long, leaving quiet space if it's short. */
873
- .model-picker.diag-mode { padding: 0; gap: 0; height: var(--menu-h); }
874
- .model-picker.diag-mode .diag-body { flex: 1; min-height: 0; overflow-y: auto; }
875
- .model-picker.diag-mode:focus { outline: none; }
876
- .diag-head { display: flex; align-items: center; justify-content: space-between; gap: .5rem; padding: .35rem .5rem; background: var(--panel); border-bottom: 1px solid var(--border); }
877
- .diag-head-acts { display: flex; gap: .4rem; align-items: center; flex: none; }
878
- .diag-back:disabled { opacity: .5; cursor: default; }
879
- .diag-name { min-width: 0; color: var(--fg); font-size: .8rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
880
- .diag-back { font: inherit; font-size: .76rem; appearance: none; margin: 0; padding: .15rem .5rem; border-radius: 5px; background: transparent; border: 1px solid var(--border); color: var(--muted); cursor: pointer; white-space: nowrap; }
881
- .diag-back:hover { color: var(--accent); border-color: var(--accent); }
882
- .diag-body { display: flex; flex-direction: column; gap: .4rem; padding: .6rem; background: var(--bg); }
883
- .diag-line { font-size: .8rem; line-height: 1.45; overflow-wrap: anywhere; }
884
- /* Hanging-indent bullet under a lead-in line — wrapped lines align past the “• ”. */
885
- .diag-bullet { padding-left: 1rem; text-indent: -0.55rem; }
886
- .diag-line.ok { color: var(--diff-add); }
887
- .diag-line.bad { color: var(--err); }
888
- .diag-line.warn { color: var(--fg); }
889
- .diag-line.muted { color: var(--muted); }
890
- .diag-line.small { font-size: .72rem; }
891
-
892
- /* Running-breakouts list — shares the popover chrome (.servers-pop above); its own dimensions +
893
- per-row layout: the play/stop button + name read together on the left (▶/■ Title, name flex:1),
894
- and the metadata/controls form a right-aligned cluster of fixed-width columns (logs · trust ·
895
- time/port) that line up row-to-row — right-anchored, so the rightmost (time/port) always aligns and
896
- the rest stack inward (logs and time/port share a width; trust is wider, to fit "restricted"). The
897
- filter pins at the bottom and the list scrolls inside (see .bulb-list). */
898
- .servers-pop { width: min(560px, calc(100vw - 2rem)); }
899
- .server-row {
900
- /* Subgrid: all rows share ONE set of column tracks (defined on .bulb-list). A per-row grid sizes
901
- its columns independently — which is exactly what broke alignment (a stopped row with no
902
- logs/port sized its tracks differently and its trust toggle drifted right). Spanning the parent's
903
- tracks instead makes trust/logs/time line up row-to-row, while the parent's auto columns size to
904
- the widest content so the cluster stays tight (no reserved min-width slack). Absent trust/logs
905
- render an empty cell that still holds its column. */
906
- display: grid;
907
- grid-template-columns: subgrid;
908
- grid-column: 1 / -1;
909
- align-items: center;
910
- /* Inset rounded highlight, matching the session picker. The background is the subgrid track area
911
- (not bled out to the edge — that ran it under the scrollbar on the right while staying clear on
912
- the left, an asymmetry). Text gets its breathing room inside the highlight via end-item margins
913
- (.server-stop/.bulb-launch on the left, .server-port/.bulb-time on the right) rather than row
914
- padding, which would shift the subgrid tracks and break alignment. */
915
- padding: .05rem 0;
916
- min-height: var(--menu-row-h);
917
- border-radius: 6px;
918
- font-size: .85rem;
919
- }
920
- .server-row:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); }
921
- /* Keyboard-highlighted row (arrow-key cursor) — a touch stronger than hover so it stands out. */
922
- .server-row.active { background: color-mix(in srgb, var(--accent) 16%, transparent); }
923
- /* Breakout spotlight (bulbsPill.ts): the new bulb's row stays bright while the rest dim, until the
924
- menu closes or that row is hovered. The transition eases the dim in and back out. */
925
- .server-row { transition: opacity .3s ease; }
926
- .server-row.dimmed { opacity: .3; }
927
- .server-name {
928
- flex: 1; min-width: 0;
929
- white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
930
- color: var(--accent); text-decoration: none; cursor: pointer;
931
- }
932
- .server-name:hover { text-decoration: underline; }
933
- /* Port links to the running server (localhost:<port>) — painted the bright fold-marker green
934
- (lime in dark, deep green in light), not the name's blue accent: the :port sits at the row's
935
- far right, away from the play button, so making it the most prominent thing in the row lets the
936
- eye snap to it the moment a just-launched row lands. The green also distinguishes "open the live
937
- app" (port) from "open the source" (the blue name), and echoes the fold-marker on collapsed bulbs. */
938
- .server-port { color: var(--fold-marker); font-size: .76rem; font-variant-numeric: tabular-nums; text-decoration: none; text-align: right; margin-right: .4rem; }
939
- a.server-port:hover { text-decoration: underline; }
940
- /* Logs reads like a link (flips to this server's console), not a button. */
941
- .server-logs { color: var(--muted); font-size: .76rem; text-decoration: none; cursor: pointer; margin-left: .5rem; }
942
- .server-logs:hover, .server-logs.on { color: var(--accent); text-decoration: underline; }
943
- /* Launcher filter box — pinned above the list; type to narrow (matches name + path). The control
944
- wrapper pins to the top and anchors the clear (×) at the far right (position:relative + the
945
- input's right padding reserves its room). */
946
- .bulb-filter-control { position: relative; flex: none; margin-top: .35rem; }
947
- .bulb-filter {
948
- font: inherit;
949
- font-size: .82rem;
950
- width: 100%;
951
- box-sizing: border-box;
952
- padding: .35rem 1.7rem .35rem .5rem;
953
- border-radius: 6px;
954
- background: var(--bg);
955
- border: 1px solid var(--border);
956
- color: var(--fg);
957
- }
958
- .bulb-filter::placeholder { color: var(--muted); }
959
- .bulb-filter:focus { outline: none; border-color: var(--accent); }
960
- /* The inset controls (clear ×, then the mode toggle) flow in one cluster flush to the input's
961
- top/right/bottom, inside its 1px border — so neither carries an offset for the other's width. */
962
- .bulb-filter-inset { position: absolute; right: 1px; top: 1px; bottom: 1px; display: flex; align-items: stretch; }
963
- /* Clear (×) — only rendered when the filter is non-empty; wipes it and refocuses the box. */
964
- .bulb-filter-clear {
965
- appearance: none; border: none; background: transparent; cursor: pointer;
966
- color: var(--muted); font-size: 1.05rem; line-height: 1; padding: 0 .3rem; border-radius: 4px;
967
- }
968
- .bulb-filter-clear:hover { color: var(--accent); }
969
- /* Filter-mode toggle (full-text search) — the cluster's right end. Both comboboxes render it
970
- (ComboboxPill's modeToggle). */
971
- .bulb-filter-control.has-trailing .bulb-filter { padding-right: 3.8rem; }
972
- .bulb-filter-mode {
973
- /* The right corners match the input's 6px radius so the latched fill reads as part of the box. */
974
- appearance: none; border: none; background: transparent; cursor: pointer;
975
- font-size: .8rem; line-height: 1; padding: 0 .45rem;
976
- border-radius: 0 5px 5px 0;
977
- }
978
- /* Same state cue as the prose pill's glyph: the emoji can't take currentColor, so it goes
979
- grayscale at rest, color while hovered or on — filtered on the inner span, never the button. */
980
- .bulb-filter-mode .glyph-img { filter: grayscale(1); opacity: .75; }
981
- .bulb-filter-mode:hover .glyph-img, .bulb-filter-mode.on .glyph-img { filter: none; opacity: 1; }
982
- /* Latched: a --panel fill — the popover's own surface, one step off the input's --bg. */
983
- .bulb-filter-mode.on { background: var(--panel); }
984
- /* Scrolls within the fixed-height popover so the filter stays put and the box never grows. */
985
- /* The shared grid for the launcher rows: each .server-row is a subgrid spanning these tracks, so
986
- columns line up row-to-row. auto cols size to their widest content (tight, no slack); 1fr is the
987
- name. A small right padding keeps the selected-row highlight off the scrollbar. */
988
- /* grid-auto-rows: max-content — the grid twin of .picker-row's flex:none: when the list overflows
989
- the popover cap, plain auto rows compress to each row's min-height contribution, so a full-text
990
- row's snippet line bleeds under the next row as overlapping text. max-content tracks never
991
- compress; the list scrolls, rows keep their content height. */
992
- /* overflow-y: scroll, not auto — the scrollbar channel is 15px of the row band's right inset, so a
993
- list short enough not to scroll (a project with no bulbs of its own: one collapsed fold row) had
994
- its rows run 15px nearer the edge than the filter box below them. Keeping the channel always
995
- present makes that inset one number — the same "effectively always present" the model menu's
996
- header padding already hardcodes. (scrollbar-gutter is the stated fix and is ignored here.) */
997
- .bulb-list { display: grid; grid-template-columns: auto 1fr auto auto auto auto; grid-auto-rows: max-content; column-gap: .6rem; row-gap: .15rem; align-items: center; align-content: start; padding-right: 4px; flex: 1; min-height: 0; overflow-y: scroll; }
998
- /* A stopped bulb's name isn't a link (no running URL), so it stays plain foreground. */
999
- .server-name.stopped { color: var(--fg); }
1000
- /* Downloaded-bulb rows (TB-Agent-Mirror.md): the user link — the folder/user slug (`samples`) in
1001
- the logs-link register — marks the category and opens the bulb's published page. A remote
1002
- (not-yet-downloaded) row's name is additionally muted and inert (no file to open), and its
1003
- time column stays empty (no mtime to show). */
1004
- .server-name.remote { cursor: default; }
1005
- .server-name.remote:hover { text-decoration: none; }
1006
- .sample-link { color: var(--muted); font-size: .76rem; text-decoration: none; cursor: pointer; flex: none; }
1007
- .sample-link:hover { color: var(--accent); text-decoration: underline; }
1008
- /* A remote pull's visible failure (bulbsPill.ts pullErrors) — the sample-link register, error-tinted. */
1009
- .pull-error { color: var(--err); font-size: .76rem; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1010
- /* The remote fold (bulbsPill.ts groupRow / fold-row): the header and the remote rows within share
1011
- one veil step over the menu's panel bg (the picker-peek shade) as ONE contiguous band — each
1012
- row's ::before bridges the grid row-gap up to the row above, junction corners square off, and
1013
- only the band's outer corners round (the header's top; the last row's bottom, via :has). The
1014
- accent label marks the header. Single-class selectors on purpose: .server-row:hover/.active
1015
- outrank them, so the highlight tints still win. */
1016
- .sample-group { cursor: pointer; color: var(--muted); }
1017
- .sample-group, .fold-row { background: linear-gradient(var(--veil), var(--veil)), var(--panel); }
1018
- .sample-group.open { border-radius: 6px 6px 0 0; }
1019
- .fold-row { position: relative; border-radius: 0; }
1020
- .fold-row::before {
1021
- content: ''; position: absolute; left: 0; right: 0; top: -.15rem; height: .15rem;
1022
- background: linear-gradient(var(--veil), var(--veil)), var(--panel);
1023
- }
1024
- .fold-row:not(:has(+ .fold-row)) { border-radius: 0 0 6px 6px; }
1025
- .sample-group-label { grid-column: 2 / -1; color: var(--accent); }
1026
- /* The 1fr name column: name + (in full-text mode) its hit count read together, the badge
1027
- right-aligned at the column's end by the name's flex:1 — the session row's preview/hits layout. */
1028
- .bulb-name-cell { display: flex; align-items: baseline; gap: .6rem; min-width: 0; }
1029
- /* Full-text snippet in the launcher: a second internal row of the row's subgrid, aligned under the
1030
- name column (the session picker indents past its dot gutter instead — override that margin). */
1031
- .bulb-list .picker-snippet { grid-column: 2 / -1; margin: 0 .4rem .2rem 0; }
1032
- .bulb-time { color: var(--muted); font-size: .72rem; font-variant-numeric: tabular-nums; text-align: right; margin-right: .4rem; }
1033
- /* The row's chrome buttons share one reset; sizing + colour differ below. Play/stop are round icon
1034
- buttons (friendlier, space-economic) and stay flex — a replaced SVG mis-seats under the
1035
- word-pill trim, the .pill.glyph case; the trust switch is a word, so it seats via the shared
1036
- recipe. Logs is a link, not a button (see .server-logs above), so it's not in this group. */
1037
- .server-stop, .bulb-launch, .trust-toggle {
1038
- font: inherit; appearance: none; margin: 0; cursor: pointer; box-sizing: border-box;
1039
- background: transparent; border: 1px solid var(--border); border-radius: 5px;
1040
- height: 1.7rem;
1041
- }
1042
- /* Shorter than the icon buttons: a bordered text pill at full row height reads as cramped, where
1043
- the round play/stop icons are meant to fill it. The shared pill box + font, not a bespoke pair —
1044
- its own .76rem/1.45rem seated the word a full 1px below every statusbar pill, which is exactly how
1045
- this drifts (measured: a per-control size is a per-control seating). */
1046
- .trust-toggle { font-size: var(--pill-font); height: var(--pill-h); padding: 0 .5rem; color: var(--muted); justify-self: center; }
1047
- .trust-toggle:hover, .trust-toggle.on { color: var(--accent); border-color: var(--accent); }
1048
- /* .fold-caret shares only this geometry line (not the bordered-button rule above): the fold header's
1049
- caret rides an invisible play-button-sized box, so a samples-only list sizes the button column
1050
- exactly as a list with real buttons does, and the caret centres where the play glyphs do. */
1051
- .server-stop, .bulb-launch, .fold-caret { display: inline-flex; align-items: center; justify-content: center; width: 1.7rem; padding: 0; border-radius: 50%; margin-left: .4rem; }
1052
- .btn-icon { width: 16px; height: 16px; }
1053
- /* A stopped bulb's play button is dormant — the title is the primary launch target — so it's
1054
- hidden until the row is hovered or keyboard-selected. visibility (not display) reserves its slot,
1055
- so the name column doesn't shift when it appears. Painted the fold-marker green, matching the
1056
- :port link: green is the live-app axis (play to launch → :port to open it), distinct from the
1057
- blue source/name. The green even travels left→right as a bulb goes stopped→running. */
1058
- .bulb-launch { color: var(--fold-marker); visibility: hidden; }
1059
- /* No optical nudge on the play glyph, deliberately: Material's play_arrow already carries one (its
1060
- path spans 320→760 in a 0→960 frame — ~1px right at icon size, compensating a right triangle's
1061
- left-leaning mass). Our own translateX(1px) stacked a second correction and measured 2px right of
1062
- the button's centre. Any glyph that reads off-centre here is the frame's business, not the app's. */
1063
- .server-row:hover .bulb-launch, .server-row.active .bulb-launch { visibility: visible; }
1064
- .bulb-launch:hover { border-color: var(--fold-marker); background: color-mix(in srgb, var(--fold-marker) 12%, transparent); }
1065
- /* Launching: stays visible (hover-independent) and shimmers until the row flips to a stop button,
1066
- so the ~2s wait reads as "working" rather than a dead click. Transient — driven by the in-memory
1067
- launching set, never persisted. */
1068
- .bulb-launch.launching { visibility: visible; border-color: var(--fold-marker); }
1069
- .server-stop { color: var(--muted); }
1070
- .server-stop:hover { color: var(--err); border-color: var(--err); }
1071
- /* push/pull verbs (TB-Push-Pull.md, Mirror surface): borderless quiet icons in one cell —
1072
- frequency earns mutedness, so like the play button they're hover/keyboard-revealed
1073
- (visibility reserves the slot; columns never shift). Accent on hover marks the live control. */
1074
- .sync-cell { display: inline-flex; gap: .15rem; justify-self: center; }
1075
- .sync-btn {
1076
- font: inherit; appearance: none; margin: 0; padding: 0 .1rem; cursor: pointer;
1077
- background: transparent; border: none; color: var(--muted);
1078
- display: inline-flex; align-items: center; visibility: hidden;
1079
- }
1080
- .server-row:hover .sync-btn, .server-row.active .sync-btn { visibility: visible; }
1081
- .sync-btn:hover { color: var(--accent); }
1082
- /* In-flight: stays visible (hover-independent) and shimmers, same contract as .bulb-launch.launching;
1083
- the ✓ beat after success likewise holds its slot so the arrow's return doesn't shift columns. */
1084
- .sync-btn.busy, .sync-btn.done { visibility: visible; }
1085
- .sync-btn.done { color: var(--fold-marker); }
1086
- .btn-icon-sm { width: 13px; height: 13px; }
1087
- /* Elevation prompt (VS-Code-Workspace-Trust style) — a modal over the whole view, since a
1088
- denied capability is worth interrupting for. Backdrop dims; the card holds the choice. */
1089
- .trust-back {
1090
- position: fixed; inset: 0; z-index: 50;
1091
- background: rgba(0, 0, 0, .5);
1092
- display: flex; align-items: center; justify-content: center;
1093
- padding: 1rem;
1094
- }
1095
- .trust-modal {
1096
- width: min(420px, 100%);
1097
- background: var(--panel); border: 1px solid var(--border); border-radius: 12px;
1098
- box-shadow: 0 12px 48px rgba(0, 0, 0, .5);
1099
- padding: 1rem 1.1rem; display: flex; flex-direction: column; gap: .6rem;
1100
- }
1101
- .trust-modal-h { font-size: 1rem; font-weight: 600; color: var(--fg); }
1102
- .trust-modal-b { font-size: .85rem; color: var(--fg); line-height: 1.45; }
1103
- .trust-modal-warn { font-size: .78rem; color: var(--muted); line-height: 1.4; }
1104
- /* Summary’s first-use setup is deliberately informational: the normal interruption chrome makes
1105
- the compact .env recipe legible without pretending the reader made a risky choice. */
1106
- .summary-setup { width: min(390px, 100%); }
1107
- .summary-setup-keys {
1108
- padding: .55rem .65rem;
1109
- border: 1px solid var(--border);
1110
- border-radius: 7px;
1111
- background: var(--bg);
1112
- color: var(--fg);
1113
- font: .78rem/1.6 ui-monospace, Menlo, Consolas, monospace;
1114
- white-space: pre-line;
1115
- user-select: text;
1116
- }
1117
- .trust-modal-acts { display: flex; justify-content: flex-end; gap: .5rem; margin-top: .2rem; }
1118
- .trust-no, .trust-yes {
1119
- font: inherit; font-size: .82rem; appearance: none; margin: 0; cursor: pointer;
1120
- padding: .35rem .8rem; border-radius: 7px; border: 1px solid var(--border); background: transparent; color: var(--fg);
1121
- }
1122
- .trust-no:hover { border-color: var(--fg); }
1123
- .trust-yes {
1124
- color: var(--accent);
1125
- border-color: color-mix(in srgb, var(--accent) 55%, transparent);
1126
- background: color-mix(in srgb, var(--accent) 14%, transparent);
1127
- }
1128
- .trust-yes:hover { background: color-mix(in srgb, var(--accent) 22%, transparent); }
1129
- /* Drilled-in streaming console for one running server. It takes over the whole bulbs
1130
- popover (the list flips to it; ← back flips home), so it's wider than the list and its
1131
- body fills the popover's height. overflow:hidden lets the rounded popover border clip the
1132
- square head/body; the body scrolls internally. */
1133
- .servers-pop.log-mode {
1134
- padding: 0;
1135
- /* The one fixed-height surface: a console wants its full frame regardless of how little it has
1136
- logged yet — a height that tracked the log would grow line by line. */
1137
- height: var(--menu-h);
1138
- }
1139
- .servers-pop.log-mode:focus { outline: none; }
1140
- .bulb-log-head {
1141
- display: flex; align-items: center; justify-content: flex-end; gap: .5rem;
1142
- padding: .3rem .5rem; background: var(--panel);
1143
- border-bottom: 1px solid var(--border);
1144
- }
1145
- .bulb-log-back {
1146
- font: inherit; font-size: .76rem; appearance: none; margin: 0;
1147
- padding: .15rem .5rem; border-radius: 5px;
1148
- background: transparent; border: 1px solid var(--border);
1149
- color: var(--muted); cursor: pointer; white-space: nowrap;
1150
- }
1151
- .bulb-log-back:hover { color: var(--accent); border-color: var(--accent); }
1152
- .bulb-log-name { min-width: 0; color: var(--muted); font-size: .72rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1153
- .bulb-log-body {
1154
- flex: 1; min-height: 0;
1155
- margin: 0; padding: .5rem .6rem;
1156
- overflow: auto; /* top-anchored normal flow — output reads top→down */
1157
- background: var(--bg);
1158
- font: .72rem/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
1159
- color: var(--fg);
1160
- }
1161
- .bulb-log-text { white-space: pre-wrap; word-break: break-word; }
1162
-
1163
- /* ── Git diff pill + menu (TB-Agent-Mirror.md): a `± N` chip (custom two-color glyph, icons.ts, +
1164
- changed-file count) that exists only when the tree is dirty — presence is the signal, so the glyph
1165
- keeps its red/green at rest (no grayscale: the chip can't be "idle", a clean tree removes it) and
1166
- the accent border stays reserved for latched, like every pill. Picking a menu row swaps the
1167
- transcript for a full-context unified diff. Flex like the square glyph pills (an svg mis-seats
1168
- under the word-pill text-box trim), width freed for the count. ── */
1169
- /* Three classes to out-specify .pill.glyph's fixed square (width: var(--pill-h); padding: 0) —
1170
- this is the one glyph pill that carries content beside its icon, so its width must be free. */
1171
- .pill.glyph.gitdiff-pill { width: auto; padding: 0 .45rem; gap: .3rem; }
1172
- /* 2:3, the cropped viewBox's aspect — a square box would re-add the side margins. */
1173
- .gitdiff-pill .icon { width: 8px; height: 12px; }
1174
- .gitdiff-count { font-size: var(--pill-font); font-variant-numeric: tabular-nums; }
1175
- .gitdiff-pop { width: min(480px, calc(100vw - 2rem)); }
1176
- /* The single-line menu-row geometry both swap-pills use (the session picker's rows are a column —
1177
- they grow for a search snippet — so they keep their own). Merged selectors, the convention the
1178
- .*-wrap rules above already follow: a third pill adds its name here rather than a fourth copy. */
1179
- .gitdiff-list, .children-list { display: flex; flex-direction: column; gap: .15rem; flex: 1; min-height: 0; overflow-y: scroll; padding-right: 4px; }
1180
- .gitdiff-row, .children-row { display: flex; align-items: center; gap: .55rem; flex: none; min-height: var(--menu-row-h); padding: .05rem .45rem; border-radius: 6px; font-size: .85rem; cursor: pointer; }
1181
- .gitdiff-row:hover, .children-row:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); }
1182
- .gitdiff-row.active, .children-row.active { background: color-mix(in srgb, var(--accent) 16%, transparent); }
1183
- .gitdiff-row.viewing .gitdiff-path { color: var(--accent); }
1184
- /* One status letter, VS Code's palette: modified/renamed amber, added/untracked green, deleted red. */
1185
- .gitdiff-status { flex: none; width: 1rem; text-align: center; font: 600 .74rem ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--muted); }
1186
- .gitdiff-status.s-M, .gitdiff-status.s-R { color: var(--warn); }
1187
- .gitdiff-status.s-A, .gitdiff-status.s-U { color: var(--diff-add); }
1188
- .gitdiff-status.s-D { color: var(--err); }
1189
- .gitdiff-path { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1190
- .gitdiff-counts { flex: none; display: inline-flex; gap: .35rem; font-size: .72rem; font-variant-numeric: tabular-nums; }
1191
- .gitdiff-counts .count-add { color: var(--diff-add); }
1192
- .gitdiff-counts .count-del { color: var(--err); }
1193
- /* The pill's viewing form: the doc's identity in place of the icon+count — status letter, path,
1194
- ±counts, × (the doc itself is chrome-free). Width capped so a deep path can't crowd the cluster;
1195
- rtl clip keeps the path's tail — the filename — visible, full path in the title. */
1196
- .pill.glyph.gitdiff-pill.viewing { max-width: min(30rem, 45vw); gap: .45rem; padding: 0 .35rem 0 .5rem; }
1197
- /* Same text seating as the word pills (.pill's text-box note): .pill.glyph opts out of the trim
1198
- (right for pure glyphs, low for letters), and the viewing form keeps flex for the path's
1199
- ellipsis — so the trim rides each text item instead of the button. Flex items are block
1200
- containers, so text-box applies; the × icon stays untrimmed (symmetric, flex-exact). */
1201
- .gitdiff-pill.viewing :is(.gitdiff-status, .gitdiff-doc-path, .gitdiff-counts span) { text-box: trim-both cap alphabetic; }
1202
- .gitdiff-doc-path { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl; text-align: left; font-size: var(--pill-font); color: var(--accent); }
1203
- .gitdiff-doc-path:hover { text-decoration: underline; }
1204
- /* The × on a pill holding the transcript's slot (ui.ts closeChip) — one rule, both swap-pills. */
1205
- .pill-close { flex: none; display: inline-flex; padding: 0 .15rem; color: var(--muted); }
1206
- .pill-close .icon { width: 10px; height: 10px; }
1207
- .pill-close:hover { color: var(--accent); }
1208
- /* Doc open ⇒ transcript-scoped pills hide: model switcher and session picker act on a conversation
1209
- that is not on screen (the token pill stays — its shimmer says the live diff may still grow).
1210
- Display only, never unmount — the model pill's superSelect must keep its mount. */
1211
- .statusbar-actions.doc-open :is(.model-wrap, .sid-wrap, .children-wrap) { display: none; }
1212
-
1213
- /* ── the agents pill (TB-Agent-Children.md): this session's child transcripts. The git-diff pill's
1214
- shape throughout — presence is the signal (a session that spawned nothing has no pill), picking a
1215
- row swaps the transcript for that child's conversation, and the pill then wears its identity so
1216
- the child view carries no chrome of its own. ── */
1217
- /* Only the VIEWING form is a glyph pill (it needs the fixed square's width freed); the chip is an
1218
- ordinary word pill, `🤖${n}🤖`, so it takes .pill's centering recipe untouched. */
1219
- .pill.glyph.children-pill { width: auto; padding: 0 .45rem; gap: .3rem; }
1220
- /* The chip is a word pill, so it sits outside `.pill.glyph` twice over: its emoji keep their colour
1221
- at rest (they read as the agents themselves, not as chrome), and they take none of the glyph
1222
- padding. `.pill`'s .7rem is measured for letters; an emoji's advance already carries its own side
1223
- bearings, so that padding lands outside whitespace the glyph brought with it and reads as a gap.
1224
- The margin then goes on each glyph's INNER side only: the padding already owns the outer gap, so a
1225
- symmetric one doubles up exactly where it shows. Chip only, since the viewing form is a flex row
1226
- with a gap of its own. */
1227
- .children-pill { padding: 0 .3rem; }
1228
- .children-pill:not(.glyph) .glyph-img:first-child { margin-right: .12rem; }
1229
- .children-pill:not(.glyph) .glyph-img:last-child { margin-left: .12rem; }
1230
- .children-pop { width: min(520px, calc(100vw - 2rem)); }
1231
- .children-row.viewing .children-label { color: var(--accent); }
1232
- /* One dot carries the state: still working (the fold-marker green), finished (dim), killed by the
1233
- user (warn). Green because running is the LIVE-APP axis, the same one the launcher's running
1234
- :port and play glyph sit on; the blue accent is the source/name axis and would read as "selected"
1235
- here, which the row already says by other means. */
1236
- .children-dot { flex: none; align-self: center; width: 7px; height: 7px; border-radius: 50%; background: var(--muted); opacity: .4; }
1237
- .children-dot.running { background: var(--fold-marker); opacity: 1; }
1238
- .children-dot.stopped { background: var(--warn); opacity: .85; }
1239
- /* The agent's type, capped so the common long one (general-purpose) can't crowd out the
1240
- description — which is the only thing telling eleven siblings apart. Muted by colour, not size. */
1241
- .children-kind { flex: none; max-width: 7rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); }
1242
- .children-label { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1243
- .children-model { flex: none; color: var(--muted); opacity: .8; }
1244
- .children-time, .children-tokens { flex: none; color: var(--muted); font-size: .72rem; min-width: 2.6rem; text-align: right; }
1245
- /* The viewing form: the open child's identity in place of the icon+count, with the × that returns.
1246
- Same seating rule as the diff pill's — .pill.glyph opts out of the text-box trim, so each TEXT
1247
- flex item takes it back or the words sit low against the icon. */
1248
- .pill.glyph.children-pill.viewing { max-width: min(18rem, 35vw); gap: .45rem; padding: 0 .35rem 0 .5rem; }
1249
- .children-pill.viewing .children-doc-label { text-box: trim-both cap alphabetic; }
1250
- .children-doc-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--pill-font); color: var(--accent); }
1251
-
1252
- /* The diff doc — takes the transcript's slot in .chat (same flex geometry as .messages). Full-bleed
1253
- like a code surface (no --content-max column: diff lines want the width); the body is the scroll
1254
- surface, its bottom padding clearing the overlaid statusbar like .messages' does. The ± markers and
1255
- red/green bands are the shared .udiff-* rules the tool cards use. */
1256
- .diff-doc { flex: 1; min-height: 0; display: flex; flex-direction: column; outline: none; }
1257
- .diff-doc-body {
1258
- flex: 1; min-height: 0; overflow: auto;
1259
- /* No scrollbar-gutter: the native bar is hidden (.oruler-host), so the 15px it used to reserve on
1260
- each edge rides in the padding instead — same metrics, and the right one is the ruler's channel. */
1261
- padding: .75rem calc(1.75rem + 15px) 2.5rem;
1262
- font: .78rem/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
1263
- white-space: pre-wrap; word-break: break-word;
1264
- }
1265
- /* The scroller is the focusable element (it owns the paging keys), but a ring round a full-bleed pane
1266
- is a box round the whole screen. Keyboard focus shows on the ruler's window instead — the thing the
1267
- paging keys actually move, and a real indicator rather than none at all. */
1268
- .diff-doc-body:focus { outline: none; }
1269
- .diff-doc-body:focus-visible ~ .diff-ruler .oruler-win { background: color-mix(in srgb, var(--accent) 55%, transparent); }
1270
- .diff-doc-empty, .diff-doc-trunc { color: var(--muted); padding: .4rem 0; }
1271
- /* The wrong-cwd empty state (Root.#cwdHint): centered over the empty transcript. */
1272
- .cwd-hint { position: absolute; inset: 0; display: grid; place-content: center; text-align: center; gap: .5rem; padding: 2rem; color: var(--muted); font-size: .9rem; max-width: 40rem; margin-inline: auto; }
1273
- .cwd-hint-head { font-weight: 600; color: var(--fg); }
1274
- .cwd-hint-dir { color: var(--accent); }
1275
- /* With token colors under them, the full-strength (24%) bands compete — red tokens on a red band
1276
- turn to mud — so the doc view's bands drop to a tint: polarity is still carried per-line by the
1277
- gutter ± glyphs and at file scale by the ruler. Scoped: the tool-card hunks keep 24%, where the
1278
- unhighlighted band is the only signal. Del a touch stronger than add — red-leaning tokens
1279
- camouflage against red tint more than anything does against green. */
1280
- .diff-doc-body .udiff-add { background: color-mix(in srgb, var(--diff-add) 15%, transparent); }
1281
- .diff-doc-body .udiff-del { background: color-mix(in srgb, var(--err) 18%, transparent); }
1282
- /* The ruler's diff clothing — red/green per change run; it rides in the channel the body's right
1283
- padding reserves, where the scrollbar it replaces used to be. Geometry, the window and the click
1284
- are the shared widget's. The wrapper keeps it overlaid rather than scrolling with the body. */
1285
- .diff-doc-scroll { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; }
1286
- .diff-ruler { right: 4px; }
1287
- .diff-ruler .oruler-mark.add { background: var(--diff-add); opacity: .8; }
1288
- .diff-ruler .oruler-mark.del { background: var(--err); opacity: .8; }
1289
- /* messages */
1290
- .messages {
1291
- flex: 1;
1292
- overflow-y: auto;
1293
- /* Reserve the scrollbar gutter on BOTH edges so the centred --content-max column stays on the
1294
- true axis (window centre) whether or not a scrollbar is showing. Without this the scrollbar
1295
- shrinks the column and shifts it ~half a scrollbar left of the composer/statusbar columns
1296
- (which never scroll) — the misalignment between the prose and the composer's left edge. */
1297
- scrollbar-gutter: stable both-edges;
1298
- /* Content runs under the overlaid statusbar rather than stopping above it.
1299
- Gutters are symmetric: the turn stripe is absolutely positioned, so it claims no layout
1300
- width and the left gutter needs no extra room for it (see Invariants). The bottom is
1301
- larger to clear the overlaid statusbar pills (~2.1rem:
1302
- .4rem inset + 1.7rem pill) so the last line isn't hidden behind them. */
1303
- padding: 1.25rem 1.75rem 2.5rem 1.75rem;
1304
- display: flex;
1305
- flex-direction: column;
1306
- gap: 1rem;
1307
- }
1308
- .note {
1309
- display: flex;
1310
- align-items: baseline;
1311
- gap: .5rem;
1312
- color: var(--muted);
1313
- font-size: .9rem;
1314
- max-width: var(--content-max);
1315
- width: 100%;
1316
- margin: 0 auto;
1317
- }
1318
- /* Top-of-transcript wordmark + tagline (messageList.ts #masthead). Centred on the prose column,
1319
- scrolls with the conversation. Both logos render; theme picks which shows. */
1320
- .masthead {
1321
- max-width: var(--content-max);
1322
- width: 100%;
1323
- margin: 0 auto .25rem auto;
1324
- padding: .15rem 0;
1325
- display: flex;
1326
- flex-direction: column;
1327
- align-items: flex-start;
1328
- gap: .5rem;
1329
- text-align: left;
1330
- user-select: none;
1331
- }
1332
- .masthead-logo {
1333
- width: 150px;
1334
- max-width: 55%;
1335
- height: auto;
1336
- opacity: .92;
1337
- }
1338
- .masthead-tagline {
1339
- color: var(--muted);
1340
- font-size: .85rem;
1341
- }
1342
- /* Black wordmark on light, outline on dark. */
1343
- .masthead-logo.dark { display: none; }
1344
- html[data-theme="dark"] .masthead-logo.light { display: none; }
1345
- html[data-theme="dark"] .masthead-logo.dark { display: block; }
1346
- .bubble {
1347
- position: relative;
1348
- max-width: var(--content-max);
1349
- width: 100%;
1350
- margin: 0 auto;
1351
- }
1352
- .bubble.user {
1353
- background: var(--veil);
1354
- border: 1px solid var(--border);
1355
- border-radius: 12px;
1356
- padding: .7rem .9rem;
1357
- }
1358
- /* A user bubble is a panel, so its hover copy pill clears the border instead of sitting on it.
1359
- .5rem matches the controls' own `bottom`, so the pill stands equally clear on both axes.
1360
- Inherited by the agent variant below, and by any later bubble that grows a surface. */
1361
- .bubble.user { --pill-inset: .5rem; }
1362
- /* A sub-agent's hand-back (TB-Agent-Children.md): a user turn the harness delivered on an agent's
1363
- behalf, never one the user typed, so it wears its own outline instead of the plain send's. The
1364
- accent is the source/name axis (the same one the agents pill's open-child label sits on); green
1365
- belongs to the live axis and would read as "still running", which a report never is. */
1366
- .bubble.user.agent {
1367
- border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
1368
- background: color-mix(in srgb, var(--accent) 7%, transparent);
1369
- }
1370
- /* The frame's header: the agents pill's glyph, the agent's type, then its description, which is the
1371
- link into its transcript. Muted by colour and NOT by size — the description is the one thing that
1372
- tells eleven siblings apart, so it stays at the body's size (the picker's child-row rule). */
1373
- .agent-head { display: flex; align-items: baseline; gap: .4rem; margin-bottom: .55rem; }
1374
- .agent-head .glyph-img { flex: none; align-self: center; }
1375
- .agent-kind { flex: none; color: var(--muted); }
1376
- .agent-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1377
- a.agent-name { color: var(--accent); cursor: pointer; }
1378
- a.agent-name:hover { text-decoration: underline; }
1379
- /* Consecutive tool-only bubbles sit tight via negative margin (flex `gap`
1380
- can't be overridden per sibling pair). The adjacent-sibling selector leaves
1381
- the first tools-only bubble's gap intact, preserving the turn boundary. */
1382
- .bubble.tools-only + .bubble.tools-only { margin-top: -.9rem; }
1383
-
1384
- /* Turn stripe — a 2px colored bar to the left of each bubble. All bubbles in
1385
- one user→assistant turn share a color; the palette cycles through 5 so
1386
- adjacent turns are visually distinct when scrolling. Sits outside the
1387
- bubble's left edge in the messages container's padding gutter.
1388
- By default top/bottom extend half a gap beyond the bubble so same-turn
1389
- neighbours' stripes meet seamlessly across the 1rem flex gap. The two
1390
- turn-boundary overrides below clip those extensions so a turn's stripe
1391
- starts at the top of its user bubble and ends at the bottom of its last
1392
- bubble — leaving the full 2rem inter-turn gap stripe-free as a separator.
1393
- --border-w compensates the 1px padding-edge offset that user bubbles' border
1394
- would otherwise introduce — without it, stripes alternate 1px inset between
1395
- user/assistant rows. */
1396
- .bubble { --border-w: 0px; }
1397
- .bubble.user { --border-w: 1px; }
1398
- .bubble::before {
1399
- content: '';
1400
- position: absolute;
1401
- /* Offsets subtract --border-w: absolute positioning anchors to the
1402
- padding-edge (inside the border), so a user bubble's 1px border would
1403
- otherwise shave 1px off each stripe edge. */
1404
- /* Stripe hugs the column (1rem into the left gutter); absolute, so it consumes no layout width —
1405
- which is why the gutters can be symmetric (no asymmetric padding needed to "make room"), and
1406
- thus why breakouts need no nudge. - var(--border-w): a user bubble's 1px border pushes the
1407
- padding edge (the abs-pos anchor) 1px right, so without it that stripe sits 1px right of the
1408
- assistant bubbles' — compensate. */
1409
- left: calc(-1rem - var(--border-w));
1410
- top: calc(-.5rem - var(--border-w));
1411
- bottom: calc(-.5rem - var(--border-w));
1412
- width: 1px;
1413
- background: var(--turn-color, transparent);
1414
- }
1415
- /* Clip stripe at turn boundaries. A user bubble always starts a turn → no
1416
- upward extension. A bubble followed by a user bubble is the last of its
1417
- turn → no downward extension. Same for the very last bubble in the list
1418
- (an in-flight turn whose last assistant message is the end of the stream).
1419
- Same `0 - var(--border-w)` form as the default top/bottom so user bubbles'
1420
- 1px border still aligns with the stripe edge. */
1421
- .bubble.user::before { top: calc(0px - var(--border-w)); }
1422
- .bubble:has(+ .bubble.user)::before,
1423
- .bubble:last-child::before { bottom: calc(0px - var(--border-w)); }
1424
- /* Extra gap before each new turn (user bubble preceded by anything). Combined
1425
- with the container's 1rem gap → ~2rem visible between turns vs ~1rem within
1426
- a turn. Doesn't fire on the very first bubble (no preceding sibling). */
1427
- .bubble + .bubble.user { margin-top: 1rem; }
1428
- .bubble.turn-0 { --turn-color: #4cb35a; } /* green */
1429
- .bubble.turn-1 { --turn-color: #e89a3c; } /* amber */
1430
- .bubble.turn-2 { --turn-color: #d36b9a; } /* pink */
1431
- .bubble.turn-3 { --turn-color: #9970d6; } /* purple */
1432
- .bubble.turn-4 { --turn-color: #3cb8b3; } /* teal */
1433
-
1434
- /* rendered markdown */
1435
- .md { word-break: break-word; }
1436
- /* Markdown links (often many file:line citations per message) — restrained like
1437
- the rest of the bulb's links: accent, no persistent underline, reveal it only
1438
- on hover. Without this they'd carry the browser-default underline-on-every-one. */
1439
- .md a { color: var(--accent); text-decoration: none; }
1440
- .md a:hover { text-decoration: underline; }
1441
- .md :first-child { margin-top: 0; }
1442
- .md :last-child { margin-bottom: 0; }
1443
- .md p { margin: .5rem 0; }
1444
- /* Thematic break — also the divider between merged consecutive user sends (see applyUser): faint
1445
- so the bubble still reads as one turn, but legible in both themes. Mixed off --fg (text contrast)
1446
- rather than --border + opacity: --border is itself dim in dark mode, so dimming it again left the
1447
- line invisible there — and the faintness is a property of the line's color, not the element. */
1448
- .md hr {
1449
- border: none;
1450
- border-top: 1px solid color-mix(in srgb, var(--fg) 12%, transparent);
1451
- margin: .55rem 0;
1452
- }
1453
- .md pre {
1454
- background: var(--veil);
1455
- border: 1px solid var(--border);
1456
- border-radius: 8px;
1457
- padding: .7rem .85rem;
1458
- overflow-x: auto;
1459
- }
1460
- /* A pasted image (server renders an <img> from a base64 block, see blockToMarkdown) is capped to the
1461
- column and never blows the layout — height follows the width. */
1462
- .md img { max-width: 100%; height: auto; border-radius: 6px; }
1463
- /* Blockquote: left bar + faint tint + muted text — a quiet "quoted" callout. The bar
1464
- is mixed toward --border so it stays distinct from the bright turn stripe in the
1465
- gutter, and the tint is light enough not to read as a code/output panel. */
1466
- .md blockquote {
1467
- margin: .5rem 0;
1468
- padding: .35rem .85rem;
1469
- border-left: 3px solid var(--border);
1470
- background: var(--veil);
1471
- border-radius: 0 6px 6px 0;
1472
- color: var(--muted);
1473
- }
1474
- .md blockquote :first-child { margin-top: 0; }
1475
- .md blockquote :last-child { margin-bottom: 0; }
1476
- .md code {
1477
- font-family: ui-monospace, "SF Mono", Menlo, monospace;
1478
- font-size: .88em;
1479
- }
1480
- .md :not(pre) > code {
1481
- background: var(--veil);
1482
- padding: .1em .35em;
1483
- border-radius: 5px;
1484
- }
1485
- .md table { border-collapse: collapse; }
1486
- .md th, .md td { border: 1px solid var(--border); padding: .3rem .55rem; }
1487
- /* A faint header band breaks the grid's monotony and gives the table a head/body structure — tonal
1488
- (`--veil`, the document-panel shade), never a hue, so it reads as structure not opinion and follows
1489
- the host light/dark theme. As a panel the veil steps one shade above the table's container, so the
1490
- header still reads when the table sits inside a bubble or callout. The header text stays `--fg`:
1491
- weight + the fill carry it, a tint would be the opinionated bit. Left-align to the body cells. */
1492
- .md th { background: var(--veil); font-weight: 600; text-align: left; }
1493
- /* Disclosure (<details>/<summary>) from the safe-HTML allowlist (markdown.ts): a quiet --veil container,
1494
- subtler than a code block — assistant prose sits directly on the page, so a fill stands out; keep it
1495
- restrained and let the border define it. Summary is the clickable caret row (native marker), accent
1496
- on hover like a link. */
1497
- .md details {
1498
- margin: .5rem 0;
1499
- padding: .35rem .7rem;
1500
- background: var(--veil);
1501
- border: 1px solid var(--border);
1502
- border-radius: 8px;
1503
- }
1504
- .md summary { cursor: pointer; font-weight: 600; }
1505
- .md summary:hover { color: var(--accent); }
1506
-
1507
- /* The full-lane breakout geometry, defined ONCE and shared by the two breakouts (the mermaid
1508
- inline bulb and a spread bulb). `100%` in --lane-ml resolves against each user's own containing block
1509
- (prose width) at point of use, so both centre on the column axis — which is the viewport centre,
1510
- since the gutters are symmetric. One home so the geometry can't drift between the two (the bug
1511
- that let an off-centre nudge survive in one rule after being removed from the other).
1512
- --chop-inset (the stripe-chop's reach into the gutter) and --chop-bg (the fill that covers the
1513
- stripe) live here too: all four describe the transcript column, so all four are the container's
1514
- to declare rather than the artifact's. */
1515
- .md {
1516
- --lane-w: calc(100vw - 2rem); --lane-ml: calc((100% - var(--lane-w)) / 2);
1517
- --chop-inset: 1rem; --chop-bg: var(--bg);
1518
- }
1519
-
1520
- /* A padded panel that holds rendered markdown redeclares all four as its own box: a sub-agent
1521
- hand-back frame (TB-Agent-Children.md), a disclosure, a blockquote. Inside one there is no turn
1522
- stripe to reach for or to cover, so the reach and the fill go to zero and an embed keeps only its
1523
- vertical band, while the lane collapses to the panel: a wide table then shares the panel's width
1524
- through its two ceilings and scrolls in .table-scroll, instead of a band that bleeds over the
1525
- card's border and paints page colour across its tint. The reset must land on the `.md` element
1526
- itself, since `.md` declares the defaults on itself and would beat a rule on an ancestor. The
1527
- next panel is one more selector here. */
1528
- .bubble.user > .md, .md details, .md blockquote {
1529
- --lane-w: 100%; --lane-ml: 0px; --chop-inset: 0px; --chop-bg: transparent;
1530
- }
1531
-
1532
- /* Full-lane breakout — the mermaid embed (whole element) and a SPREAD bulb's iframe step out of
1533
- the prose column to the transcript width so wide content gets room. Both use --lane-w/--lane-ml
1534
- and extend symmetrically from the column, which is viewport-centred (symmetric gutters), so
1535
- neither needs an off-centre nudge (see Invariants in Specs-Bulbs/Claude-Bulb/Claude-Bulb.md). A
1536
- bulb's *wrapper* never breaks out — only its inner surface does — so the wrapper and its controls
1537
- stay at prose-right. position + z-index:0 lift the box over the turn stripe it crosses; the
1538
- mermaid SVG is transparent so a bg backs it, the bulb iframe is opaque so it occludes the stripe. */
1539
- .md .mermaid {
1540
- width: var(--lane-w);
1541
- /* Vertical gap must be >= the box-shadow spread (1rem below): a smaller gap lets the bg ring
1542
- paint over the half-line of prose above/below — the trap the opaque bulb inline bulb dodges by
1543
- having no shadow, which the transparent mermaid can't. */
1544
- margin: 1rem 0;
1545
- margin-left: var(--lane-ml);
1546
- margin-right: auto;
1547
- position: relative;
1548
- z-index: 0;
1549
- background: var(--bg);
1550
- }
1551
-
1552
- /* Diagram specifics: scroll when wider than the lane; `safe center` centres one that fits and
1553
- left-aligns one that overflows, so a wide flowchart scrolls from its start node. The SVG is
1554
- transparent, so the turn stripe the breakout crosses shows through it — a bg box-shadow backs
1555
- the diagram to occlude it (and clears the h-scrollbar edge). The bulb inline bulb is opaque (its
1556
- iframe fills the box), so it occludes the stripe itself and gets NO shadow — a shadow there
1557
- would paint its 1rem ring over the half-line of prose just above the inline bulb (the gap is .6rem). */
1558
- .md .mermaid {
1559
- overflow-x: auto;
1560
- display: flex;
1561
- justify-content: safe center;
1562
- box-shadow: 0 0 0 1rem var(--chop-bg);
1563
- }
1564
- /* flex-shrink:0: otherwise the SVG shrinks to the flex container and its viewBox
1565
- scales it down — no overflow, no scrollbar. Natural width is what scrolls. */
1566
- .md .mermaid svg { max-width: none; height: auto; flex: 0 0 auto; }
1567
-
1568
- /* Raw ```svg``` embeds: no breakout — these are drawings (smiley, plot), not wide
1569
- diagrams, so keep them in the prose column, centred and capped at its width. They
1570
- share the `.embed` stripe-chop below (a drawing earns its own row like a bulb). */
1571
- .md .svg-embed { justify-content: center; }
1572
- .md .svg-embed svg { max-width: 100%; height: auto; }
1573
-
1574
- /* Markdown tables: chop in-column (the `.embed` rule below). An IN-COLUMN table starts where the prose
1575
- starts — it is part of the paragraph flow, and a narrow one centred in the column reads as shifted
1576
- right against the left-aligned text above and below it, which is the whole of that bug. Only a
1577
- SPREAD table centres, and it has earned it: spreading means it is already wider than the column, so
1578
- centring keeps it symmetric about the column axis exactly as the mermaid lane does. `safe` on both
1579
- means one that overflows its box falls back to the start and scrolls from its first column rather
1580
- than clipping. The scroll lives on the inner `.table-scroll`, not the `.embed`
1581
- wrapper, so a horizontal scrollbar rides above the chop's bottom padding instead of flush against the
1582
- next line (a scrollbar sits at its own container's bottom edge). It's the centred flex child, capped at
1583
- the box so it shrinks-to-fit and scrolls only what overflows; `max-width:100%` + the overflow-induced
1584
- 0 min-width let it shrink below the table's natural width. `spread` widens the wrapper to the lane (the
1585
- mermaid geometry) for a table fitTableEmbeds found too wide for the column. The opaque `.embed`
1586
- padding/z-index still occludes the turn stripe the spread crosses — no box-shadow needed. */
1587
- .md .table-embed { justify-content: safe flex-start; }
1588
- .md .table-embed .table-scroll { overflow-x: auto; max-width: 100%; }
1589
- /* max-content keeps the table at its natural (unwrapped) width so it overflows `.table-scroll` and earns
1590
- a scrollbar, rather than wrapping its cells to fit — the bar is the point, and it now rides inside the
1591
- chop padding. */
1592
- /* Two ceilings work together so a prose-heavy table neither scrolls forever nor squashes to mush:
1593
- - per-column (the cell `max-width` — in an auto-layout table every cell in a column shares one width,
1594
- so a cell cap *is* a column cap): no single column hogs; long text wraps at a readable measure.
1595
- - whole-table (`max-width: var(--lane-w)`): the table never grows past the lane, so when columns don't
1596
- all fit, the browser's auto-layout *shares* the lane among them — wrapping each to its slice rather
1597
- than scrolling. It only scrolls once even longest-word-only columns can't fit the lane (genuinely too
1598
- many columns). `width: max-content` is still the *preferred* width, so a small table stays natural
1599
- and a wide one spreads; the lane cap just bounds the worst case. The floor lever (a per-cell
1600
- `min-width`) — turn squash into scroll past some readable minimum — is deliberately left off until a
1601
- real table needs it. `word-break: normal` resets the global `.md { word-break: break-word }` (which
1602
- behaves like overflow-wrap:anywhere): left inherited, that global lets the lane cap squeeze a column
1603
- below its longest word and snap it mid-character ("status" → "statu·s"). Reset to word-boundary
1604
- wrapping so each column floors at its longest whole word and a too-tight table scrolls instead of
1605
- shattering text; a pathological no-space token (a long URL) then scrolls rather than breaking. */
1606
- .md .table-embed table { margin: 0; width: max-content; max-width: var(--lane-w); }
1607
- .md .table-embed th, .md .table-embed td { max-width: 40ch; word-break: normal; }
1608
- /* The spread band's comfort goes on the RIGHT only, which looks asymmetric written down and is what
1609
- reads as even on screen. `.messages` reserves a scrollbar gutter at BOTH edges (to hold the column
1610
- on the window axis), but only the right one ever contains a scrollbar: the left gutter is dead
1611
- space that already reads as margin, while on the right the lane's own 1rem inset is all there is
1612
- between the table and the bar. Inset both sides equally and the left gap measures a gutter wider
1613
- than the right; inset neither and the table sits flush against the bar, which is the bug. So the
1614
- band adds back one inset on the right, and the table's cap comes down by that same inset or it
1615
- would overflow the band's content box by exactly that at every width — the permanent scrollbar the
1616
- zeroed inset was avoiding. */
1617
- .md .table-embed.spread {
1618
- justify-content: safe center;
1619
- width: var(--lane-w);
1620
- margin-left: var(--lane-ml);
1621
- margin-right: auto;
1622
- padding-left: 0;
1623
- padding-right: var(--chop-inset);
1624
- }
1625
- .md .table-embed.spread table { max-width: calc(var(--lane-w) - var(--chop-inset)); }
1626
-
1627
- /* ````bulb```` inline bulbs: a sandboxed nested app. createBulbFrame owns the iframe (auto-height,
1628
- borderless). No frame of our own — like the mermaid/svg embeds, it sits in the flow rather
1629
- than in a box, so it reads as part of the transcript; the rounded clip just tidies the corners
1630
- of a bulb that paints its own background. `fit` (default) keeps it in the prose column and
1631
- caps its height (below); `spread` adds full-lane breakout + spacing from the shared rule above.
1632
- position:relative anchors the controls overlay in BOTH modes (spread re-declares it for the
1633
- z-index lift). .err is the compile-failure fallback; .bulb-err-strip is a runtime-error strip
1634
- under a live inline bulb (both monospace, muted red). */
1635
- /* The stripe-chop, shared by every opaque in-column visual artifact (the fit bulb and a raw
1636
- `svg` — each opts in by carrying `.embed`). An opaque bg over position:relative + z-index:0
1637
- paints above .bubble::before; a --chop-inset left extension reaches the stripe in the gutter
1638
- without a full breakout (the matching padding-left keeps the content in the prose column, and the
1639
- box's right edge stays at prose-right, so a bulb's controls anchored there don't move); inside a
1640
- panel both zero out (the nesting reset above). The vertical gap is opaque
1641
- padding, not margin: it occludes the stripe top and bottom so the cut is flush with the prose (no
1642
- stub in a transparent gap), symmetric above/below to separate the artifact from surrounding text.
1643
- margin top/bottom are zeroed — and the abutting prose margins too (below) — so the gap butts the
1644
- text. Mermaid is NOT on this path: it's transparent and breaks out, occluding via box-shadow.
1645
- Per-artifact layout (flex direction, centring) lives on .bulb-inline / .svg-embed. */
1646
- .md .embed {
1647
- display: flex;
1648
- position: relative;
1649
- z-index: 0;
1650
- background: var(--chop-bg);
1651
- margin: 0 0 0 calc(-1 * var(--chop-inset));
1652
- padding: 1.1rem 0 1.1rem var(--chop-inset);
1653
- }
1654
- /* A bulb stacks its controls/frame/code vertically; in spread mode the inner iframe's own
1655
- breakout covers the stripe too (the wrapper still chops in column). */
1656
- .md .bulb-inline { flex-direction: column; }
1657
- /* Inline (default): cap the inline bulb's height so a tall bulb doesn't run away down the transcript.
1658
- Past the cap the inline bulb scrolls internally (its own overflow, set by the host↔inline bulb protocol —
1659
- the iframe element can't scroll its srcdoc from out here). spread removes the cap. */
1660
- .md .bulb-inline.fit iframe { max-height: 80dvh; }
1661
- /* The frame host is a layout-less wrapper (domeleon owns the node; we parent the live iframe into
1662
- it once) — display:contents lifts the iframe to be a flex child of .bulb-inline, so the sizing and
1663
- spread-breakout rules target it directly, exactly as when it was appended as a direct child. The
1664
- code toggle hides it via .code-open rather than unmounting, so the bulb keeps its state. */
1665
- .bulb-frame { display: contents; }
1666
- .md .bulb-inline.code-open .bulb-frame { display: none; }
1667
- /* Spread: the inner surface (live iframe, or the code view behind the toggle) breaks out to the
1668
- lane while the wrapper stays in the prose column, so the controls hold at prose-right. Shares the
1669
- --lane-* geometry; centred on the column axis so toggling spread doesn't shift a column-fitting
1670
- bulb sideways. `width !important` beats createBulbFrame's inline width:100% on the iframe
1671
- (harmless on the code view, which has none); both opaque, so position/z-index:0 lift them over
1672
- the turn stripe they now cross. */
1673
- .md .bulb-inline.spread iframe,
1674
- .md .bulb-inline.spread > .bulb-code {
1675
- width: var(--lane-w) !important;
1676
- margin-left: var(--lane-ml);
1677
- margin-right: auto;
1678
- position: relative;
1679
- z-index: 0;
1680
- }
1681
- /* Kill the transparent margin between an inline bulb and the prose it abuts (top and bottom):
1682
- the inline bulb's own opaque padding is the gap, and a leftover prose margin would let the
1683
- turn stripe show through it as a stub past the last/next line of text. */
1684
- .md :has(+ .embed) { margin-bottom: 0; }
1685
- .md .embed + * { margin-top: 0; }
1686
- /* The rounded clip lives on the inner surfaces, not the inline bulb (whose padding is now the
1687
- gap), so the bulb's own card — the iframe, or the code view behind the toggle — is
1688
- what carries the corners. */
1689
- .md .bulb-inline iframe,
1690
- .md .bulb-inline > .bulb-code {
1691
- border-radius: 8px;
1692
- }
1693
- /* Overlay pill: a small hover-revealed control floated at a container's
1694
- bottom-right, shared by the inline bulb breakout button and the message copy button.
1695
- Muted at rest, accent on hover (instant, matching the status pills). The
1696
- container supplies the reveal; per-button bits layer on top. Box shape, colors
1697
- and vertical centering come from the shared word-pill rule (.pill, .token,
1698
- .overlay-pill) — only positioning, reveal and sizing quirks live here. */
1699
- .overlay-pill {
1700
- position: absolute;
1701
- bottom: .5rem;
1702
- /* Flush with the content column's right edge (prose / statusbar pills) when the pill sits on the
1703
- page's own background. A PANEL sets --pill-inset instead, so the pill clears its edge rather
1704
- than landing on the border: `right: 0` resolves against the containing block's padding box, so
1705
- inside a bordered box it means "touching the border". Only this axis needed it — `bottom` has
1706
- always stood the pill off, which is why the defect read as a right-edge one. */
1707
- right: var(--pill-inset, 0px);
1708
- font: inherit;
1709
- font-size: var(--pill-font);
1710
- padding: 0 .45rem;
1711
- /* Word buttons that swap labels in place (fit⇄spread, code⇄run, copy⇄copied), so a min-width
1712
- keeps the button from jumping as the word's length changes — "fit" is much narrower than
1713
- "spread". */
1714
- min-width: 3.5rem;
1715
- cursor: pointer;
1716
- opacity: 0;
1717
- transition: opacity .2s ease;
1718
- }
1719
- .overlay-pill:hover { color: var(--accent); border-color: var(--accent); }
1720
-
1721
- /* Per-fence copy pill (code / svg / mermaid) — one reveal rule for every copyable block, since the
1722
- fence rule stamps `.copyable` on each. The svg/mermaid containers are already position:relative for
1723
- the chop/breakout; the code-block wrapper needs it for the absolute pill. The pill is positioned
1724
- (absolute), so it paints over the static fence content regardless of DOM order. */
1725
- .md .copyable { position: relative; }
1726
- /* A code fence IS a panel (the <pre> carries the border and background, the wrapper is the
1727
- positioning context), so its copy pill takes the inset. The svg and mermaid embeds are
1728
- transparent bands on the page background and keep the flush edge. */
1729
- .md .code-block { --pill-inset: .5rem; }
1730
- .md .copyable:hover .copy-src,
1731
- .md .copy-src:focus-visible { opacity: 1; }
1732
- .md .copy-src.done { color: var(--accent); border-color: var(--accent); }
1733
- /* The wrapper carries the code block's vertical rhythm (it, not the <pre>, is the `.md` flow child
1734
- now, so the first/last-child margin resets land on it); the <pre> sits flush inside. */
1735
- .md .code-block { margin: .6rem 0; }
1736
- .md .code-block pre { margin: 0; }
1737
-
1738
- /* Controls (code ⇄ run, then copy, then breakout) — a centered overlay straddling the
1739
- bulb's top edge: clear of the running bulb below, free to overlap the prose above.
1740
- Absolute, so toggling run↔code (which resizes the bulb below) never moves it, and so
1741
- the bulb's vertical gap stays small. Revealed on inline bulb-hover or focus; inside the row
1742
- the shared .overlay-pill drops its own corner-anchoring. */
1743
- .md .bulb-controls {
1744
- position: absolute;
1745
- top: 0;
1746
- right: 0; /* flush with the content column's right edge (prose / statusbar pills) */
1747
- z-index: 2;
1748
- display: flex;
1749
- gap: .4rem;
1750
- transform: translateY(-50%);
1751
- }
1752
- .md .bulb-controls .overlay-pill { position: static; }
1753
- .md .bulb-inline:hover .bulb-controls .overlay-pill,
1754
- .md .bulb-controls .overlay-pill:focus-visible { opacity: 1; }
1755
- .md .bulb-breakout:disabled { cursor: default; opacity: 1; }
1756
- /* Breakout in flight: shimmers through the ~2s write+spawn wait so the click reads as "working"
1757
- rather than dead — same treatment as the launcher's play button (.bulb-launch.launching).
1758
- :disabled already keeps it visible if the pointer leaves. */
1759
- .md .bulb-breakout.launching { color: var(--accent); border-color: var(--accent); }
1760
- /* Copy pill "copied" flash — mirrors the message .copy.done accent cue, scoped to the
1761
- inline bulb control so it doesn't pick up the bubble-hover reveal. */
1762
- .md .bulb-copy.done { color: var(--accent); border-color: var(--accent); }
1763
- /* Source view behind the toggle — the bulb's .bulb.md rendered as markdown (file
1764
- labels + fenced code panels via the shared .md rules). Just a scroll container
1765
- here, capped so a long bulb doesn't blow out the transcript. No `display` — the
1766
- [hidden] attribute the toggle flips drives visibility. */
1767
- /* Code view: a streamlined source listing — one labeled bar per file with the code
1768
- flush beneath, not a stack of bordered panels. The container is just the scroll box;
1769
- the file-label bar is the only fill (overriding .md pre's panel chrome below). */
1770
- .md .bulb-code {
1771
- margin: 0;
1772
- padding: 0;
1773
- max-height: 480px;
1774
- overflow: auto;
1775
- font-size: .85rem;
1776
- /* A distinct surface from the transcript: the live bulb blends into the flow, but the
1777
- code view reads as an inspector panel. Code panels (.md pre below) are transparent, so
1778
- they show this through. */
1779
- background: var(--tool-bg);
1780
- }
1781
- /* **file.ext** labels render as <p><strong>…</strong></p>; a bulb's source has no other
1782
- top-level prose, so styling the paragraph as a header bar only ever hits a label. */
1783
- .md .bulb-code p {
1784
- margin: 0;
1785
- padding: .3rem .85rem;
1786
- /* A touch darker than the code surface so the file-label header stays distinct now
1787
- that the whole code view shares one background. */
1788
- background: color-mix(in srgb, var(--fg) 6%, var(--tool-bg));
1789
- border-top: 1px solid var(--border);
1790
- font-family: ui-monospace, "SF Mono", Menlo, monospace;
1791
- font-size: .78rem;
1792
- color: var(--muted);
1793
- }
1794
- .md .bulb-code p:first-child { border-top: none; }
1795
- .md .bulb-code p strong { font-weight: 600; color: var(--fg); }
1796
- /* Code flush on the inline bulb bg — drop the inherited .md pre box (border/bg/radius). */
1797
- .md .bulb-code pre {
1798
- margin: 0;
1799
- padding: .5rem .85rem .85rem;
1800
- border: none;
1801
- border-radius: 0;
1802
- background: transparent;
1803
- overflow-x: auto;
1804
- }
1805
- /* hljs token colors for the code view AND the diff doc (one palette, or the same file reads as two
1806
- languages across surfaces), mapped to the bulb's own theme vars (no hljs
1807
- theme stylesheet) so highlighting follows light/dark rather than baking a palette
1808
- that would clash in dark mode. Minimal token set — enough for legible TS/HTML/CSS/
1809
- JSON/markdown without chasing every grammar scope. */
1810
- .bulb-code .hljs-comment, .diff-doc-body .hljs-comment, .bulb-code .hljs-quote, .diff-doc-body .hljs-quote { color: var(--muted); font-style: italic; }
1811
- .bulb-code .hljs-keyword, .diff-doc-body .hljs-keyword, .bulb-code .hljs-literal, .diff-doc-body .hljs-literal, .bulb-code .hljs-built_in, .diff-doc-body .hljs-built_in { color: var(--accent); }
1812
- .bulb-code .hljs-tag, .diff-doc-body .hljs-tag, .bulb-code .hljs-name, .diff-doc-body .hljs-name, .bulb-code .hljs-selector-tag, .diff-doc-body .hljs-selector-tag { color: var(--accent); }
1813
- .bulb-code .hljs-string, .diff-doc-body .hljs-string, .bulb-code .hljs-regexp, .diff-doc-body .hljs-regexp, .bulb-code .hljs-meta .hljs-string, .diff-doc-body .hljs-meta .hljs-string {
1814
- color: color-mix(in srgb, var(--diff-add) 85%, var(--fg));
1815
- }
1816
- .bulb-code .hljs-number, .diff-doc-body .hljs-number { color: color-mix(in srgb, var(--accent) 55%, var(--fg)); }
1817
- .bulb-code .hljs-attr, .diff-doc-body .hljs-attr, .bulb-code .hljs-attribute, .diff-doc-body .hljs-attribute, .bulb-code .hljs-property, .diff-doc-body .hljs-property, .bulb-code .hljs-type, .diff-doc-body .hljs-type {
1818
- color: color-mix(in srgb, var(--accent) 75%, var(--fg));
1819
- }
1820
- .bulb-code .hljs-title, .diff-doc-body .hljs-title, .bulb-code .hljs-title.function_, .diff-doc-body .hljs-title.function_, .bulb-code .hljs-section, .diff-doc-body .hljs-section {
1821
- color: var(--fg); font-weight: 600;
1822
- }
1823
- .bulb-code .hljs-symbol, .diff-doc-body .hljs-symbol, .bulb-code .hljs-bullet, .diff-doc-body .hljs-bullet, .bulb-code .hljs-punctuation, .diff-doc-body .hljs-punctuation { color: var(--muted); }
1824
- /* Draft-streaming bulb card (#draftBulbCard): the code view wearing a progress header — never a
1825
- live mount; the durable row's InlineBulb takes this slot when the entry lands. Cap tighter than
1826
- .bulb-code's resting 480px: ambient progress, not reading material. */
1827
- .md .bulb-streaming { flex-direction: column; }
1828
- .md .bulb-streaming > .bulb-code { max-height: 220px; border-radius: 0 0 8px 8px; }
1829
- .bulb-streaming-head {
1830
- display: flex;
1831
- align-items: baseline;
1832
- gap: .45rem;
1833
- padding: .3rem .85rem;
1834
- border-radius: 8px 8px 0 0;
1835
- /* A notch above the file-label bars inside the listing, so the header owns the card. */
1836
- background: color-mix(in srgb, var(--fg) 10%, var(--tool-bg));
1837
- font-size: .78rem;
1838
- color: var(--muted);
1839
- }
1840
- .bulb-streaming-count { margin-left: auto; font-variant-numeric: tabular-nums; }
1841
- .md .bulb-inline.err {
1842
- padding: .6rem .8rem;
1843
- font-family: ui-monospace, monospace;
1844
- font-size: .85em;
1845
- color: color-mix(in srgb, var(--err) 70%, var(--fg));
1846
- white-space: pre-wrap;
1847
- }
1848
- .md .bulb-err-strip {
1849
- padding: .45rem .7rem;
1850
- border-top: 1px solid var(--border);
1851
- background: color-mix(in srgb, var(--err) 8%, var(--bg));
1852
- font-family: ui-monospace, monospace;
1853
- font-size: .8em;
1854
- color: color-mix(in srgb, var(--err) 75%, var(--fg));
1855
- white-space: pre-wrap;
1856
- }
1857
-
1858
- /* Structural-defect strip (a malformed bulb that still rendered) — same shape as the error strip but
1859
- amber: a warning, not a failure. */
1860
- .md .bulb-warn-strip {
1861
- padding: .45rem .7rem;
1862
- border-top: 1px solid var(--border);
1863
- background: color-mix(in srgb, rgb(200 140 30) 10%, var(--bg));
1864
- font-family: ui-monospace, monospace;
1865
- font-size: .8em;
1866
- color: color-mix(in srgb, rgb(200 140 30) 80%, var(--fg));
1867
- white-space: pre-wrap;
1868
- }
1869
-
1870
- /* tool calls */
1871
- /* Closed tool: just a single-line row, no chrome — a tool-only turn stays a
1872
- tight list, not N stacked panels. Open tools get the panel treatment below. */
1873
- .tool {
1874
- font-size: .85rem;
1875
- }
1876
- /* Row shape shared by tool heads and the collapsed-turn summary; only the latter is a click
1877
- target (a tool's toggle is its digest row). */
1878
- .tool-head, .turn-summary {
1879
- display: flex;
1880
- align-items: center;
1881
- gap: .5rem;
1882
- padding: .15rem 0;
1883
- }
1884
- .turn-summary {
1885
- cursor: pointer;
1886
- user-select: none;
1887
- }
1888
- /* The shared disclosure triangle (caret() in messageList.ts). flex:none so a squeezed row can't
1889
- crush it; the digest row has no flex gap, so its spacing rides the marker. */
1890
- .caret-tri { width: 10px; height: 10px; transition: transform .15s ease; }
1891
- .caret-tri.open { transform: rotate(90deg); }
1892
- .tool-digest .caret-tri { margin-right: .5rem; }
1893
- /* Plain bold — an accent verb reads as a hyperlink; accent is reserved for the file-path link. */
1894
- .tool-name { font-weight: 600; }
1895
- /* Errors here are routine (a first-read miss the retry fixes), so a failed tool
1896
- recolors just its verb rather than boxing the whole row — a quiet signal. */
1897
- .tool.err .tool-name { color: var(--err); }
1898
-
1899
- /* Collapsed-turn summary strip (intermediate steps hidden behind the final
1900
- answer). Same caret/row shape as a tool head, italic muted label. */
1901
- .turn-summary { color: var(--muted); font-size: .85rem; }
1902
- .turn-summary-text { font-style: italic; }
1903
- .fork-icon { width: 11px; height: 11px; vertical-align: -1px; margin-right: .15rem; }
1904
- .turn-summary:hover .turn-summary-text { color: var(--fg); }
1905
- /* Reply's stand-in for a turn that said nothing (messageList #noProseBubble). Same voice as the stub
1906
- above, minus its row shape and cursor — this one is a label, not a toggle. */
1907
- .no-prose { color: var(--muted); font-size: .85rem; font-style: italic; padding: .15rem 0; }
1908
-
1909
- /* An expanded abandoned-branch (fork) stub renders its orphan messages here — set apart from the live
1910
- transcript with a left rule and slight fade, signalling "this is not the active conversation"
1911
- (TB-LostMessage.md). The stub header itself is a .turn-summary row above. */
1912
- .fork-body {
1913
- margin-top: .4rem;
1914
- padding-left: .6rem;
1915
- border-left: 2px solid color-mix(in srgb, var(--fg) 18%, transparent);
1916
- opacity: .82;
1917
- /* Same column geometry as .messages — the tools-only negative-margin rule assumes a 1rem gap. */
1918
- display: flex;
1919
- flex-direction: column;
1920
- gap: 1rem;
1921
- /* --turn-color is a custom property and INHERITS — the stub's own .bubble.turn-N sets it, so without
1922
- this reset every nested orphan bubble inherits the parent turn's color and redraws the stripe
1923
- (the recursive-line bug). Reset to transparent so .bubble::before falls back to no stripe here. */
1924
- --turn-color: transparent;
1925
- }
1926
-
1927
- /* A superseded inline bulb (an earlier same-name version) folds to this "💡 <Name> — Version N" stub — it
1928
- reclaims the bulb's full height. Marker-green text (--fold-marker: lime in dark, deep green in light)
1929
- on a neutral gray fill (--panel) that hugs the text (width:max-content), no border: text-hugging and
1930
- borderless keeps it distinct from the page's full-width bordered panels and clear of the live bulb's
1931
- hover pills, and the colored title rides a gray pill rather than a green block. Its look never depends
1932
- on outcome: errors show only inside the inline bulb when expanded, so the title never changes. Click to
1933
- expand the older bulb (rerun fresh) back in. */
1934
- .bulb-fold {
1935
- display: flex;
1936
- width: max-content;
1937
- max-width: 100%;
1938
- align-items: center;
1939
- gap: .5rem;
1940
- padding: .3rem .55rem;
1941
- border-radius: 6px;
1942
- background: var(--panel);
1943
- color: var(--fold-marker);
1944
- font-size: .9rem;
1945
- font-weight: 600;
1946
- cursor: pointer;
1947
- user-select: none;
1948
- }
1949
- /* The fold is the sole child of its `.md` wrapper, so the `.md :first-child`/`:last-child` margin
1950
- reset (above) would zero a plain `margin` here. Scope it under `.md` so it outweighs that reset
1951
- (equal specificity, later in source) and the stub actually gets its breathing room. */
1952
- .md .bulb-fold { margin: .7rem 0; }
1953
- .bulb-fold:hover { background: color-mix(in srgb, var(--fg) 8%, var(--panel)); }
1954
- /* Verb + summary are inline siblings sharing one line box and font, so they sit
1955
- on a common baseline. Ellipsis truncation lives on the wrapper, not the
1956
- children — overflow:hidden on the summary itself skews its baseline. */
1957
- /* flex: 0 1 auto (not 1) so the pending '…' sits right after the label instead of at the row's
1958
- far edge; the label shrinks with ellipsis when the row is tight. */
1959
- .tool-label {
1960
- flex: 0 1 auto;
1961
- min-width: 0;
1962
- white-space: nowrap;
1963
- overflow: hidden;
1964
- text-overflow: ellipsis;
1965
- }
1966
- .tool-sum {
1967
- margin-left: .5rem;
1968
- color: var(--muted);
1969
- }
1970
- .tool-run { color: var(--muted); }
1971
-
1972
- /* The OUT line ("463 lines", the first stdout line): a tool's second row and its only toggle.
1973
- Text stays selectable — a drag copies, a plain click toggles. */
1974
- .tool-digest {
1975
- padding: 0 0 .15rem .6rem;
1976
- color: var(--muted);
1977
- white-space: nowrap;
1978
- overflow: hidden;
1979
- text-overflow: ellipsis;
1980
- cursor: pointer;
1981
- user-select: text;
1982
- }
1983
- .tool-digest:hover { color: var(--fg); }
1984
- /* Expanded = nothing readable hidden: the digest wraps to its full text (it may be the whole
1985
- result — suppressed patch one-liners rely on this). */
1986
- .tool-digest.open { white-space: normal; }
1987
-
1988
- /* Expanded input/output card beneath the digest row — additive only, the rows above keep their
1989
- collapsed look. First child sheds its top divider (nothing above it inside the card). */
1990
- .tool-card {
1991
- margin: .15rem 0 .55rem .6rem;
1992
- background: var(--veil);
1993
- border: 1px solid var(--border);
1994
- border-radius: 8px;
1995
- overflow: hidden;
1996
- }
1997
- .tool-card > :first-child, .tool-card > .diff:first-child > :first-child { border-top: none; }
1998
-
1999
- /* TodoWrite checklist (CC's treatment: struck-through done, bold in-progress, plain pending). */
2000
- .todo-list { padding: .45rem .7rem; display: grid; gap: .25rem; font-size: .85rem; }
2001
- .todo { display: flex; gap: .5rem; align-items: baseline; }
2002
- .todo-icon { flex: none; }
2003
- .todo.completed .todo-icon { color: var(--diff-add); }
2004
- .todo.completed .todo-text { text-decoration: line-through; color: var(--muted); }
2005
- .todo.in_progress .todo-text { font-weight: 600; }
2006
- .todo.in_progress .todo-icon { color: var(--accent); }
2007
-
2008
- /* Code-display panels inside an open tool card: raw input/output (.tool-in,
2009
- .tool-out) and per-hunk diff halves (.diff-old, .diff-new). All four share
2010
- the same monospace block geometry — padding, max-height with scroll,
2011
- pre-wrap with word-break, top-divider — so consolidating here means edits
2012
- to "how a code panel looks" land in one place. Color treatment is the only
2013
- per-variant difference. */
2014
- .tool-in, .tool-out, .diff-old, .diff-new {
2015
- margin: 0;
2016
- padding: .55rem .7rem;
2017
- border-top: 1px solid var(--border);
2018
- white-space: pre-wrap;
2019
- word-break: break-word;
2020
- font-family: ui-monospace, Menlo, monospace;
2021
- font-size: .8rem;
2022
- max-height: 320px;
2023
- overflow: auto;
2024
- }
2025
- .tool-out { color: var(--muted); }
2026
- /* Structured input fields inside an open tool card (one row per input key), replacing the old raw
2027
- JSON dump. The key is a muted inline label; the value keeps the shared mono geometry, flowing on
2028
- the same line and wrapping/multi-lining as pre-wrap dictates. */
2029
- .tool-field + .tool-field { margin-top: .5rem; }
2030
- .tool-key { color: var(--muted); margin-right: .6rem; user-select: text; }
2031
- .tool-val { display: inline; margin: 0; font: inherit; white-space: pre-wrap; word-break: break-word; }
2032
- /* Line-banded unified-diff view (± lines only): the same add/remove band colors as
2033
- .diff-new/.diff-old; a muted ⋯ row separates hunks. The ± marker is ::before content in a
2034
- reserved gutter — visible (the non-color cue colorblind readers need) but outside the DOM
2035
- text, so selection-copy grabs only the code. min-height keeps blank changed lines from
2036
- collapsing now that they carry no marker char. */
2037
- .udiff { white-space: pre-wrap; word-break: break-word; }
2038
- .udiff-line { position: relative; padding: 0 .2rem 0 1.05rem; min-height: 1lh; }
2039
- .udiff-line::before { position: absolute; left: .25rem; }
2040
- .udiff-add::before { content: '+'; }
2041
- .udiff-del::before { content: '-'; }
2042
- .udiff-add { background: color-mix(in srgb, var(--diff-add) 24%, transparent); }
2043
- .udiff-del { background: color-mix(in srgb, var(--err) 24%, transparent); }
2044
- .udiff-gap { color: var(--muted); user-select: none; }
2045
- /* The band carries the add/remove signal; text stays --fg so it's readable. Colouring the text the same
2046
- hue as its fill (red-on-red) is what washes a diff out — the band alone is the convention. */
2047
- .diff-old {
2048
- background: color-mix(in srgb, var(--err) 24%, transparent);
2049
- color: var(--fg);
2050
- }
2051
- .diff-new {
2052
- background: color-mix(in srgb, var(--diff-add) 24%, transparent);
2053
- color: var(--fg);
2054
- }
2055
-
2056
- /* Path link: plain accent, underline only on hover. skip-ink:none so the underline stays solid
2057
- under a path's descenders and slashes; the cursor is explicit because the anchor carries no href
2058
- (the click is a server RPC, not navigation) and so defaults to the surrounding prose's caret. */
2059
- .tool-sum.link {
2060
- color: var(--accent);
2061
- text-decoration: none;
2062
- cursor: pointer;
2063
- }
2064
- .tool-sum.link:hover { text-decoration: underline; text-decoration-skip-ink: none; }
2065
-
2066
- .diff { display: flex; flex-direction: column; }
2067
- .diff-step {
2068
- padding: .35rem .7rem;
2069
- border-top: 1px solid var(--border);
2070
- color: var(--muted);
2071
- font-size: .72rem;
2072
- text-transform: uppercase;
2073
- letter-spacing: .06em;
2074
- }
2075
-
2076
- .thinking { margin-bottom: .4rem; font-size: .82rem; color: var(--muted); }
2077
- /* The <summary> toggle defaults to a text cursor — give it the pointer every other expandable uses. */
2078
- .thinking summary { cursor: pointer; }
2079
- .thinking pre { white-space: pre-wrap; }
2080
- /* Streaming (draft) thinking: capped + scrollable at the bulb-streaming card's height — ambient
2081
- progress, not reading material — stuck to the live edge unless the user scrolls up
2082
- (#draftThinkingPre). Durable rows stay uncapped: finished thinking IS reading material. */
2083
- .draft .thinking pre { max-height: 220px; overflow-y: auto; }
2084
-
2085
- /* The bubble's hover-revealed copy-pill row. The direct-child reveal avoids firing an inline bulb's
2086
- own controls when the reader merely hovers the outer assistant bubble. */
2087
- .bubble-controls {
2088
- position: absolute;
2089
- bottom: .5rem;
2090
- right: var(--pill-inset, 0px);
2091
- display: flex;
2092
- gap: .4rem;
2093
- }
2094
- .bubble-controls .overlay-pill { position: static; }
2095
- .bubble:hover > .bubble-controls .overlay-pill,
2096
- .bubble-controls .overlay-pill:focus-visible { opacity: 1; }
2097
- /* "copied" flash: eases into accent on click (CopyButton is a Component, so its
2098
- node persists across re-renders and the transition actually runs). The color
2099
- transition lives only on .done, not the base pill, so hover stays instant — the
2100
- trade is the revert snaps back rather than easing out. */
2101
- .copy.done {
2102
- opacity: 1;
2103
- color: var(--accent);
2104
- border-color: var(--accent);
2105
- transition: opacity .2s ease, color .5s ease, border-color .5s ease;
2106
- }
2107
- /* Assistant bubbles carry no padding, so the shared .5rem bottom would float the
2108
- pill above the last line — pin it to the text's bottom edge. */
2109
- .bubble.assistant > .bubble-controls { bottom: 0; }
2110
-
2111
- /* A turn's local representation selector (turnView.ts). A standard tab bar: a continuous bottom
2112
- line under all tabs, the selected tab's thicker line indicating the active view. Raw is the full
2113
- chronological trace for one turn, Reply is its exact prose, and Summary is the generated
2114
- compression. Normal flow above the selected body means every switch resizes only below itself. */
2115
- .turn-views-row {
2116
- display: flex;
2117
- align-items: baseline;
2118
- gap: 0;
2119
- width: fit-content;
2120
- margin: .7rem auto;
2121
- font-size: .8rem;
2122
- line-height: 1.3;
2123
- border-bottom: 1px solid var(--border);
2124
- }
2125
- .turn-view {
2126
- appearance: none;
2127
- margin: 0 0 -1px 0;
2128
- padding: 0 .5rem .1rem;
2129
- border: 0;
2130
- border-bottom: 2px solid transparent;
2131
- background: none;
2132
- color: var(--muted);
2133
- font: inherit;
2134
- cursor: pointer;
2135
- text-align: center;
2136
- }
2137
- .turn-view:hover, .turn-view.on { color: var(--accent); }
2138
- .turn-view.on { border-bottom-color: var(--accent); }
2139
- .turn-view.err { color: var(--err); }
1
+ :root {
2
+ /* Light is the default; data-theme="dark" is set by the host (typebulb.com
3
+ or the typebulb CLI, sourced from prefers-color-scheme).
4
+ GRAY RAMP — every gray rides ONE neutral tint (R=G=B), like the main app's
5
+ bg1–bg5 ramp. Lightness may step; the HUE never changes. The bug to avoid is a
6
+ gray in a *different* tint (a cool/blue panel beside a neutral one), not the
7
+ number of steps. App chrome (statusbar, picker, modals) sits on the fixed
8
+ --panel. Document panels — code, callouts, tables, user bubbles, open tools —
9
+ paint --veil instead: a translucent neutral that matches --panel when alone on
10
+ the page but COMPOUNDS when panels nest (a code block in a callout, a panel in a
11
+ user bubble), so every depth separates on its own — no level-counting, no cap. */
12
+ --bg: rgb(255, 255, 255);
13
+ --fg: rgb(28, 28, 30);
14
+ --muted: rgb(96, 96, 100);
15
+ --panel: rgb(242, 242, 242);
16
+ --border: rgb(221, 221, 221);
17
+ --accent: rgb(58, 125, 232);
18
+ --veil: rgba(0, 0, 0, 0.05); /* one shade step; alpha-stacks as panels nest */
19
+ --tool-bg: var(--panel);
20
+ --err: rgb(206, 60, 60);
21
+ --warn: rgb(176, 120, 12); /* amber — the cache-leak (cost-caution) tier, milder than --err */
22
+ --diff-add: rgb(40, 140, 70);
23
+ --fold-marker: rgb(24, 145, 72);
24
+ /* Prose measure for text (bubbles + notes) — kept to a readable line length.
25
+ Diagrams break out wider than this; see .md .mermaid. */
26
+ --content-max: 800px;
27
+ /* One height for every word-pill control — the statusbar pills (bulbs/model/session/token) and
28
+ the floating overlay pills (copy, breakout). Set once here so they can't drift apart again;
29
+ chosen a touch under the old statusbar 1.7rem and a touch over the overlay pills' ~1.35rem. */
30
+ --pill-h: 1.5rem;
31
+ /* One text size for every word-pill too (statusbar pills + overlay copy/breakout). Was drifting:
32
+ statusbar .85rem vs overlay .72rem read as amateurish. The emoji glyph pill keeps its own size. */
33
+ --pill-font: .8rem;
34
+ color-scheme: light;
35
+ }
36
+
37
+ html[data-theme="dark"] {
38
+ /* Neutral ramp, same tint as the main app: page 18 → surface 32 → border 52. */
39
+ --bg: rgb(18, 19, 20);
40
+ --fg: rgb(190, 190, 190);
41
+ --muted: rgb(150, 150, 150);
42
+ --panel: rgb(32, 32, 32);
43
+ --border: rgb(52, 52, 52);
44
+ --accent: rgb(122, 162, 250);
45
+ --veil: rgba(255, 255, 255, 0.06);
46
+ --tool-bg: var(--panel);
47
+ --err: rgb(230, 60, 60);
48
+ --warn: rgb(232, 184, 80); /* amber — the cache-leak (cost-caution) tier, milder than --err */
49
+ --diff-add: rgb(100, 210, 130);
50
+ --fold-marker: rgb(0, 255, 0);
51
+ color-scheme: dark;
52
+ }
53
+
54
+ /* Aliases for beautiful-mermaid, indirecting around a var-name collision with the
55
+ bulb's theme (see the renderMermaidSVG call). They still resolve to the live
56
+ theme at the use site. */
57
+ :root {
58
+ --mm-bg: var(--bg);
59
+ --mm-fg: var(--fg);
60
+ --mm-line: var(--muted);
61
+ --mm-accent: var(--accent);
62
+ --mm-muted: var(--muted);
63
+ --mm-surface: var(--tool-bg);
64
+ --mm-border: var(--border);
65
+ }
66
+
67
+ * { box-sizing: border-box; }
68
+
69
+ body {
70
+ margin: 0;
71
+ height: 100vh;
72
+ overflow: hidden;
73
+ color: var(--fg);
74
+ background: var(--bg);
75
+ font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
76
+ font-size: 15px;
77
+ line-height: 1.55;
78
+ }
79
+
80
+ .app { display: flex; flex-direction: column; height: 100vh; }
81
+ /* The transcript region. position:relative anchors the overlaid (absolute) statusbar and banners to
82
+ THIS box, not the app: with a composer below the pills stay over the transcript's bottom edge
83
+ instead of landing on the input panel. */
84
+ .chat { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; }
85
+
86
+ /* Bottom status strip, overlaid (not in flow) on the chat — which runs
87
+ full-height to the very bottom. The strip itself has NO fill, so the message
88
+ text shows through everywhere, right to the bottom edge. Only the items (the
89
+ pills) are opaque, so they overlay the text with visual precedence rather than
90
+ the strip cutting it off with a band. No border — the typebulb host's rounded
91
+ card is the only frame. */
92
+ .statusbar {
93
+ position: absolute;
94
+ left: 0;
95
+ right: 0;
96
+ bottom: 0;
97
+ /* Same column as .messages and the composer: a --content-max band centred in a 1.75rem gutter
98
+ (max-width carries the two gutters so the content box lands on --content-max). The pills are
99
+ right-aligned within it, so the cluster's right edge tracks the prose column's — consistent
100
+ with the input above and the transcript behind. The gutter also clears the overlaid scrollbar
101
+ that the old asymmetric right padding was there to dodge. */
102
+ margin-inline: auto;
103
+ max-width: calc(var(--content-max) + 3.5rem);
104
+ display: flex;
105
+ align-items: center;
106
+ gap: .65rem;
107
+ padding: .4rem 1.75rem;
108
+ font-size: .8rem;
109
+ /* Drop the inherited 1.55 line-height so the bar's own strut doesn't add
110
+ phantom ascender/descender room above the pills. Everything in the bar
111
+ is a fixed-height pill, which looks right at 1. */
112
+ line-height: 1;
113
+ min-width: 0;
114
+ }
115
+ .statusbar-actions {
116
+ margin-left: auto;
117
+ display: flex;
118
+ gap: .4rem;
119
+ align-items: center;
120
+ }
121
+ /* THE CENTERING RECIPE for EVERY word-label control on any surface — the status-bar pills and
122
+ floating overlay pills below, and the launcher row's trust switch. One rule = one
123
+ source of vertical alignment; when these drift apart it reads as amateurish, so a new word
124
+ control joins this selector rather than centering itself (don't re-tweak per button — fix it
125
+ here or nowhere). Icon buttons opt back out, keeping flex (see .pill.glyph). Segoe UI's font
126
+ ascent (~1.08em) dwarfs its descent (~0.25em), so anything that centers the LINE BOX —
127
+ flex, line-height, padding — parks the glyphs ~2px low at pill sizes. `text-box` trims the
128
+ line box to the glyphs (cap→baseline) so centering centers the letters themselves. It only
129
+ applies to block containers (not flex), hence inline-block + align-content, not inline-flex
130
+ + align-items. Browsers without text-box (Firefox) fall back to plain line-box centering. */
131
+ .pill, .token, .overlay-pill, .trust-toggle {
132
+ display: inline-block;
133
+ align-content: center;
134
+ text-align: center;
135
+ text-box: trim-both cap alphabetic;
136
+ }
137
+ /* Chip shape shared by the status-bar pills (interactive and passive) AND the floating overlay
138
+ pills (copy, breakout, code⇄run) — opaque, so they read cleanly over the message text the
139
+ transparent strip lets through. The launcher row's pills take only the recipe above; their
140
+ shape is the row's (.trust-toggle). */
141
+ .pill, .token, .overlay-pill {
142
+ height: var(--pill-h);
143
+ padding: 0 .7rem;
144
+ border-radius: 7px;
145
+ background: var(--panel);
146
+ border: 1px solid var(--border);
147
+ color: var(--muted);
148
+ font-size: var(--pill-font);
149
+ white-space: nowrap;
150
+ user-select: none;
151
+ }
152
+ /* Pills are <button>s: reset UA chrome (font/appearance/margin) and add the
153
+ interactive affordance. Slightly larger text than the passive indicator. */
154
+ .pill {
155
+ font: inherit;
156
+ font-size: var(--pill-font);
157
+ appearance: none;
158
+ margin: 0;
159
+ cursor: pointer;
160
+ }
161
+ /* `.on` latches the hover accent — the bar's only persistent state cue (every other
162
+ pill accents on hover alone). */
163
+ .pill:hover, .pill.on { border-color: var(--accent); color: var(--accent); }
164
+ /* Square glyph pill: width pinned to the pill height rather than padding-driven. Glyph content
165
+ (emoji span / the Anthropic SVG) is vertically symmetric, so plain flex centering is exact for it
166
+ — and cap/alphabetic trim mis-seats a replaced SVG (it trims to TEXT metrics), so opt glyph pills
167
+ back out of the word-pill recipe. */
168
+ .pill.glyph {
169
+ display: inline-flex;
170
+ align-items: center;
171
+ justify-content: center;
172
+ text-box: normal;
173
+ width: var(--pill-h);
174
+ padding: 0;
175
+ font-size: .9rem;
176
+ }
177
+ .pill.glyph .glyph-img { filter: grayscale(1); opacity: .75; }
178
+ .pill.glyph:hover .glyph-img, .pill.glyph.on .glyph-img { filter: none; opacity: 1; }
179
+
180
+ /* The two status-bar chips each anchor an upward popover (token breakdown /
181
+ session list). Wrap = positioning context; the popover anchors bottom/right and
182
+ grows up-and-left — the chips sit in the right-aligned cluster, so left:0 would
183
+ run them off the viewport's right edge. */
184
+ .token-wrap, .sid-wrap, .servers-wrap, .model-wrap, .gitdiff-wrap, .children-wrap { position: relative; display: inline-block; }
185
+ /* Each pill renders its wrap unconditionally, emitting an empty div when it has
186
+ nothing to show (no tokens / no session / nothing running). An empty wrap is
187
+ still a flex child, so .statusbar-actions' gap lands on both its sides —
188
+ doubling the visible gap between its neighbours. Collapse the empties so the
189
+ cluster spaces evenly regardless of which pills are present. */
190
+ .token-wrap:empty, .sid-wrap:empty, .servers-wrap:empty, .model-wrap:empty, .gitdiff-wrap:empty, .children-wrap:empty { display: none; }
191
+ .picker, .servers-pop {
192
+ --menu-h: 428px; /* one height for both statusbar menus — they sit side by side, so a
193
+ mismatched cap reads as a glitch; .picker grows to it, .servers-pop
194
+ too (the drilled-in console is the one fixed-height exception, see
195
+ .log-mode). Sized to show exactly 12 rows
196
+ (12 × --menu-row-h + 11 gaps + filter + padding) — recognition fades
197
+ past ~12 and search owns the tail. Plain px: desktop-only surface,
198
+ every desktop window fits it. 428, not 427: at 427 the two menus land
199
+ on different device-pixel roundings, a 1px sibling mismatch — the
200
+ lesser evil is 1px off the ideal height. */
201
+ --menu-row-h: 29px; /* one row height for both menus' lists — rows must match pitch exactly
202
+ or the side-by-side menus read as two designs. Whole px, eyeball-tuned;
203
+ the row paddings are kept below it so this floor is what governs. */
204
+ /* ONE geometry contract for both menus — sizing policy, padding, stacking. Stating it twice is
205
+ how every sibling discrepancy of June '26 happened (row pitch, gaps, fixed-vs-grow). max-height,
206
+ not height: a menu hugs its content and caps at the 12 rows above — a fixed height left a
207
+ 1-bulb project with a row floating atop a 400px void. Per-menu rules keep only width and
208
+ alignment (.pop-center / .pop-start below). */
209
+ max-height: var(--menu-h);
210
+ overflow: hidden;
211
+ padding: .35rem;
212
+ /* The bar's line-height:1 is for its fixed-height pills; a menu is a text surface, so restore
213
+ descender room HERE, once. Inherited, the 1 crops the tails of "g"/"j"/"p" under every row's
214
+ overflow:hidden (the ellipsis clip) — a leaf-by-leaf fix that had already been pasted three
215
+ times. Rows are floored at --menu-row-h with their paddings kept under it, so pitch is unchanged. */
216
+ line-height: 1.35;
217
+ display: flex;
218
+ flex-direction: column;
219
+ gap: .15rem;
220
+ position: absolute;
221
+ bottom: calc(100% + .35rem);
222
+ right: 0;
223
+ z-index: 10;
224
+ background: var(--panel);
225
+ border: 1px solid var(--border);
226
+ border-radius: 10px;
227
+ box-shadow: 0 8px 30px rgba(0, 0, 0, .45);
228
+ }
229
+ /* Menu-list scrollbars: the channel is ALWAYS reserved (overflow-y: scroll on all three lists) and
230
+ the scrollbar is owned — no arrow buttons, transparent track, a floating thumb only when the list
231
+ actually overflows. Native auto scrollbars made a row band's right inset depend on list length: a
232
+ project with no bulbs of its own (one collapsed fold row) ran its band 15px nearer the popover
233
+ edge than the filter box under it, and bare overflow:scroll parked a vestigial arrow-stub beside
234
+ the short list. Width stays 15px, the native width .model-head's alignment padding hardcodes.
235
+ Chromium pseudos on purpose: setting the standard scrollbar-color would disable them. */
236
+ :is(.picker-list, .bulb-list, .gitdiff-list)::-webkit-scrollbar { width: 15px; }
237
+ :is(.picker-list, .bulb-list, .gitdiff-list)::-webkit-scrollbar-button { display: none; }
238
+ :is(.picker-list, .bulb-list, .gitdiff-list)::-webkit-scrollbar-track { background: transparent; }
239
+ :is(.picker-list, .bulb-list, .gitdiff-list)::-webkit-scrollbar-thumb {
240
+ background-color: color-mix(in srgb, var(--fg) 28%, transparent);
241
+ border-radius: 8px;
242
+ /* Transparent border + padding-box clip floats a slim pill in the 15px channel. */
243
+ border: 4px solid transparent;
244
+ background-clip: padding-box;
245
+ }
246
+ :is(.picker-list, .bulb-list, .gitdiff-list)::-webkit-scrollbar-thumb:hover { background-color: color-mix(in srgb, var(--fg) 45%, transparent); }
247
+
248
+ /* The overview ruler (overviewRuler.ts): a scroll container's bar, replaced. .oruler-host is applied
249
+ by the widget at mount — never statically — so the native bar only disappears once its replacement
250
+ is painted. BOTH hiding routes: the standard property covers Gecko, the pseudo covers WebKit, and
251
+ Blink honours either, so no engine is left showing two scrollbars. Window and marks are % of a track
252
+ that spans the pane, so they share the container's content space. Marks are pointer-inert; the track
253
+ takes the press, the drag and the wheel. Colour and the track's inset are the host's (.diff-ruler).
254
+ Cursor stays the plain arrow — that's what a scrollbar shows, and `grab` would claim otherwise. */
255
+ .oruler-host { scrollbar-width: none; }
256
+ .oruler-host::-webkit-scrollbar { width: 0; height: 0; }
257
+ .oruler { position: absolute; inset-block: 0; width: 6px; z-index: 5; cursor: default; touch-action: none; }
258
+ .oruler-mark { position: absolute; left: 0; right: 0; min-height: 3px; border-radius: 1px; pointer-events: none; }
259
+ /* Wider than the track and above the marks, so what's on screen reads as a thumb laid over the map. */
260
+ .oruler-win {
261
+ position: absolute; left: -3px; right: -3px; z-index: 1; min-height: 6px; border-radius: 3px;
262
+ pointer-events: none; background: color-mix(in srgb, var(--fg) 28%, transparent);
263
+ }
264
+ /* The menu thumbs' own two steps (28/45) — same surface, same weights. Still a wash rather than a
265
+ solid fill: the marks it covers are the ones you're about to read. */
266
+ .oruler:hover .oruler-win { background: color-mix(in srgb, var(--fg) 45%, transparent); }
267
+ /* A finger needs a target a 6px track can't give; the map is worth more widened than dropped. */
268
+ @media (pointer: coarse) {
269
+ .oruler { width: 14px; }
270
+ .oruler-win { left: -2px; right: -2px; }
271
+ }
272
+ /* Forced colours drop our tints — the window would vanish outright, and the marks' red/green IS the
273
+ information, so it keeps its own palette rather than collapsing to one system colour. */
274
+ @media (forced-colors: active) {
275
+ .oruler-win { background: transparent; border: 1px solid Highlight; }
276
+ .oruler-mark { forced-color-adjust: none; }
277
+ }
278
+ /* Popover alignment — which edge of the pill the menu aligns to (floating-UI's start/center/end
279
+ vocabulary; "end", right edges flush and growing left, is the unnamed default above). Tagged on
280
+ the pill's WRAP, not each popover root: a menu with several drilled-in views (the model pill's
281
+ list/info/diag/key modes) aligns once, and a future mode can't forget. The cluster's left-side
282
+ pills (diff, model) center — end-anchored, their menus run out of room growing left. No viewport
283
+ clamping: desktop-only; revisit if a narrow window ever clips one. */
284
+ .pop-center > :is(.picker, .servers-pop) { right: auto; left: 50%; transform: translateX(-50%); }
285
+ .pop-start > :is(.picker, .servers-pop) { right: auto; left: 0; }
286
+
287
+ /* ---- Shimmer — the one "working" cue. One gradient, one keyframes, composed via class, never
288
+ re-pasted (paste-drift is how each new shimmer kept landing at its own speed). `.shimmer` sweeps
289
+ the element's background; `.shimmer-text` clips the sweep to the glyphs alone. Knobs, set by the
290
+ using rule: --shimmer-base (the gradient's resting color — transparent for see-through chrome, an
291
+ opaque surface for pills that must cover content) and --shimmer-mix (accent strength). Pick the
292
+ cadence by the band's LINEAR speed, which is what reads consistent: travel is proportional to
293
+ element width (background-size 250%), and ~2rem/s is the calibrated pace — so narrow chrome
294
+ (a 1.7rem play button) takes the 2.2s default and anything text-width or wider takes
295
+ `.shimmer-slow` (7s). A fast sweep reads as urgency, not progress. */
296
+ /* The state longhands are !important: this class composes onto elements whose resting rules set a
297
+ `background:` shorthand at arbitrary specificity (button resets, .overlay-pill, :hover tints) —
298
+ any of which would silently kill the gradient (the bug that ate the play button's shimmer). A
299
+ working cue must win while it's on; resting background-color still shows beneath the sweep. */
300
+ .shimmer, .shimmer-text { background-size: 250% 100% !important; animation: bulb-shimmer var(--shimmer-speed, 2.2s) linear infinite !important; }
301
+ .shimmer {
302
+ background-image: linear-gradient(100deg, var(--shimmer-base, transparent) 30%, color-mix(in srgb, var(--accent) var(--shimmer-mix, 35%), var(--shimmer-base, transparent)) 50%, var(--shimmer-base, transparent) 70%) !important;
303
+ background-color: var(--shimmer-base, transparent);
304
+ }
305
+ .shimmer-text {
306
+ /* Shrink-to-fit, so the sweep paces the text's own length, not the container's — on a full-width
307
+ box the band spends most of its run off-glyph (same move as typebulb.com's shimmer.ts). */
308
+ display: inline-block;
309
+ /* background-clip:text paints the glyphs through this element's own box, so the box must be tall
310
+ enough to cover descenders — inheriting the statusbar's line-height:1 clips the "g" in "probing"/
311
+ "working…" at the baseline. A descender-clearing line-height restores the paint area; the button
312
+ centres this box, so the extra height never unbalances the row. */
313
+ line-height: 1.3;
314
+ background-image: linear-gradient(100deg, currentColor 35%, var(--accent) 50%, currentColor 65%) !important;
315
+ -webkit-background-clip: text;
316
+ background-clip: text;
317
+ -webkit-text-fill-color: transparent;
318
+ }
319
+ .shimmer-slow { --shimmer-speed: 7s; }
320
+ /* Summary's text-only working cue sits on a white surface in the light theme. Widen and deepen its
321
+ blue sweep there so the seven-second motion remains plainly visible; dark's calibrated treatment
322
+ stays untouched. */
323
+ html:not([data-theme="dark"]) .turn-view > .shimmer-text {
324
+ background-image: linear-gradient(100deg, currentColor 20%, var(--accent) 40%, color-mix(in srgb, var(--accent) 70%, var(--fg)) 50%, var(--accent) 60%, currentColor 80%) !important;
325
+ }
326
+ @keyframes bulb-shimmer { from { background-position: 250% 0; } to { background-position: -250% 0; } }
327
+ @media (prefers-reduced-motion: reduce) {
328
+ .shimmer { animation: none !important; background: color-mix(in srgb, var(--accent) 15%, var(--shimmer-base, transparent)) !important; }
329
+ .shimmer-text { animation: none !important; background: none !important; -webkit-text-fill-color: currentColor; }
330
+ }
331
+
332
+ /* THE WORKING STATE for an opaque word pill — the token chip mid-turn. Only the shimmer's knobs live
333
+ here; the sweep itself is the shared .shimmer above, composed by `busyPill()` (ui.ts) so a new busy
334
+ pill cannot land at its own speed or its own base colour. The sweep blends with --panel, not
335
+ transparent like the launch buttons': a word pill sits over message text and must keep covering it.
336
+ shimmer-slow because these can sit lit for minutes (the JSONL flushes in batches, so the token chip
337
+ rides a whole buffering window). */
338
+ .pill-busy { border-color: var(--accent); --shimmer-base: var(--panel); --shimmer-mix: 25%; }
339
+
340
+ /* ---- Boot overlay (index.html, markup and script both) — what the window shows between the HTML
341
+ landing and the client having a transcript. Opaque and over #app, so the empty
342
+ chrome never flashes through first. The 1.2s animation delay is the whole don't-flash-on-a-fast-load
343
+ mechanism: a normal boot is retired before this fades in, so the wait is only ever announced when
344
+ there is one. Measured, not guessed — a warm local boot lands at 590-930ms (dev, where the client
345
+ bundle is unminified and slowest), so the delay has to clear that, not merely beat a blank screen.
346
+ Geometry follows .cwd-hint, the mirror's other centred whole-window message. */
347
+ .boot {
348
+ position: fixed;
349
+ inset: 0;
350
+ background: var(--bg);
351
+ display: grid;
352
+ place-content: center;
353
+ justify-items: center;
354
+ gap: .5rem;
355
+ padding: 2rem;
356
+ text-align: center;
357
+ animation: boot-in .35s ease 1.2s both;
358
+ }
359
+ .boot-title { font-size: 1.15rem; }
360
+ /* min-height reserves the line the seconds count lands on, so naming the wait doesn't shift the title. */
361
+ .boot-why { color: var(--muted); font-size: .95rem; max-width: 32rem; min-height: 1.4em; }
362
+ @keyframes boot-in { from { opacity: 0; } to { opacity: 1; } }
363
+
364
+ /* ── Tooltip — the styled stand-in for native `title`, and only on FIXED EXPLANATORY copy: text
365
+ that teaches what a control does. Dynamic identifiers (a path, a model id, an mcp tool's full
366
+ name) keep the native attribute, which is better at them: they run long, they want to wrap, and
367
+ `title` is the reliable "show me the whole string" affordance a bounded box is worse at. One
368
+ attribute opts a control in, no wrapper component and no JS, and it shows on :focus-visible,
369
+ which native `title` never does for a keyboard user. `data-tip` is not an accessible name, so a
370
+ glyph-only control still needs its own ariaLabel.
371
+ Three non-obvious bits. :where() zeroes the position rule's specificity, so a control that is
372
+ ALREADY positioned (the filter's inset × and 🔬) keeps its own and is its own containing block
373
+ anyway. display:none at rest rather than opacity:0, because an absolutely positioned box still
374
+ counts toward a scroll container's scrollable overflow and .messages' stuck-to-bottom test is
375
+ measured in pixels. The open delay therefore rides an animation (fill-mode both holds the
376
+ from-state through the delay), since a display switch cannot transition. ── */
377
+ :where([data-tip]) { position: relative; }
378
+ [data-tip]::after {
379
+ content: attr(data-tip);
380
+ display: none;
381
+ position: absolute;
382
+ z-index: 40;
383
+ top: calc(100% + 7px);
384
+ left: 50%;
385
+ transform: translateX(-50%);
386
+ width: max-content;
387
+ max-width: min(22rem, 60vw);
388
+ padding: .3rem .55rem;
389
+ background: var(--panel);
390
+ color: var(--fg);
391
+ border: 1px solid var(--border);
392
+ border-radius: 8px;
393
+ box-shadow: 0 8px 30px rgba(0, 0, 0, .45);
394
+ /* Its own typography, never the host's (a stub label is italic, a pill is not) — but the same
395
+ SIZE as the chrome it explains, .8rem, which is both the statusbar's and the tab row's. A tip
396
+ set larger than the control it belongs to reads as shouting. Full --fg carries it instead. */
397
+ font-size: .8rem;
398
+ font-style: normal;
399
+ font-weight: 400;
400
+ line-height: 1.4;
401
+ /* Centred and balanced, because the box CANNOT hug wrapped text: `width: max-content` capped by
402
+ max-width means any tip too long for one line sits at exactly max-width, whatever it wraps to.
403
+ Left-aligned, the leftover on the last line then reads as dead space (the model pill's tip, the
404
+ only one long enough to wrap). Balancing evens the lines and centring splits what's left to both
405
+ sides, so a two-line tip reads as a block instead of a full line plus a stub. */
406
+ text-align: center;
407
+ text-wrap: balance;
408
+ /* pre-line, not normal: a tip built from two lines (the session pill's cwd over its id) keeps the
409
+ break native `title` gave it, and everything else still wraps at the max-width. */
410
+ white-space: pre-line;
411
+ pointer-events: none;
412
+ }
413
+ [data-tip]:hover::after,
414
+ [data-tip]:focus-visible::after { display: block; animation: tip-in .12s ease .4s both; }
415
+ @keyframes tip-in { from { opacity: 0; } to { opacity: 1; } }
416
+ /* One tip nested in another (the diff pill's × sits inside the pill body): hovering the inner one
417
+ hovers the outer too, so both boxes would open. Only the innermost shows, which is how native
418
+ `title` resolves it. */
419
+ [data-tip]:has([data-tip]:hover)::after { display: none; }
420
+ /* Pressed: tooltips are dismissed until the pointer reaches a different one (ui.ts
421
+ armTooltipDismiss). Out-specifies the :hover rule on its own, no !important needed. */
422
+ body.tips-off [data-tip]::after { display: none; }
423
+ /* Bottom-anchored surfaces open upward, like their own menus: under the statusbar and under the
424
+ composer's action row there is no room to drop into. */
425
+ :is(.statusbar, .composer) [data-tip]::after { top: auto; bottom: calc(100% + 7px); }
426
+ /* ...and grow LEFT from the control's right edge, which is the "end" alignment the statusbar menus
427
+ already take by default (.pop-center / .pop-start above are their opt-outs). Every one of these
428
+ clusters is right-aligned, so a centred box runs past the viewport — the bulbs pill's tip was
429
+ clipped — and inside a menu it is also the direction that stays within its overflow:hidden for a
430
+ control inset at the right edge. The transcript's own tips stay centred: they sit mid-column. */
431
+ :is(.statusbar, .composer, .picker, .servers-pop) [data-tip]::after { left: auto; right: 0; transform: none; }
432
+ /* ...except where the pill's WRAP already names an alignment for its menu. A tip and the menu it
433
+ belongs to must open the same way, so both read off the same tag (.pop-center / .pop-start above)
434
+ and neither can be set on its own. Direct child, like the menu rules: a control nested inside a
435
+ pill (the diff pill's ×) keeps the cluster's end alignment, which is right at a pill's far edge. */
436
+ .pop-center > [data-tip]::after { right: auto; left: 50%; transform: translateX(-50%); }
437
+ .pop-start > [data-tip]::after { right: auto; left: 0; transform: none; }
438
+
439
+ /* ── The composer (TB-Agent-Composer.md): the prompt panel under the transcript. In flow (not
440
+ overlaid like the statusbar) — it owns real height and must never cover content. ── */
441
+ .composer { position: relative; flex: none; background: var(--bg); padding-inline: 1.75rem; }
442
+ /* Keep the composer's rows on the same prose column as the transcript (--content-max, centered like
443
+ .messages) — full-mirror-width input reads badly, the eye has to travel to wrap long lines. The
444
+ 1.75rem inline padding above matches .messages' gutter so the two columns align at every width. */
445
+ .composer .splitter,
446
+ .composer .input-wrap,
447
+ .composer-err,
448
+ .composer-status,
449
+ .composer-queue {
450
+ max-width: var(--content-max);
451
+ margin-inline: auto;
452
+ }
453
+ /* A 4px drag zone centered on the composer's top border; z-index lifts it above the input-wrap
454
+ border so the line itself is grabbable. */
455
+ .splitter {
456
+ position: relative;
457
+ height: 4px;
458
+ margin-top: -2px;
459
+ margin-bottom: -2px;
460
+ z-index: 5;
461
+ cursor: row-resize;
462
+ background: transparent;
463
+ flex-shrink: 0;
464
+ }
465
+ .splitter:hover { background: color-mix(in srgb, var(--accent) 25%, transparent); }
466
+ .input-wrap { position: relative; border-top: 1px solid var(--border); }
467
+ .composer-input {
468
+ width: 100%;
469
+ display: block;
470
+ resize: none; /* height is splitter-driven (inline style), not the native handle */
471
+ border: none;
472
+ border-radius: 0;
473
+ background: var(--bg);
474
+ color: var(--fg);
475
+ font: inherit;
476
+ font-size: .9rem;
477
+ line-height: 1.45;
478
+ /* Zero horizontal padding so the text sits flush on the --content-max column — its left and right
479
+ edges line up with the transcript prose (which has no inset) and the splitter. Buttons live in
480
+ .composer-actions below, so the text needs no right gutter either. */
481
+ padding: .5rem 0;
482
+ }
483
+ .composer-input:focus { outline: none; }
484
+ .composer-input:disabled { opacity: .6; }
485
+ .composer-input::placeholder { color: var(--muted); }
486
+ /* The icons.ts shell: one span, sized HERE (never by svg attrs), the material/custom svg filling
487
+ it. 14px default; context classes (.btn-icon, .caret-tri, …) override. */
488
+ .icon { display: inline-flex; align-items: center; justify-content: center; width: 14px; height: 14px; flex: none; }
489
+ .icon svg { width: 100%; height: 100%; display: block; }
490
+ /* The action bar under the textarea, right-aligned: send/stop is the primary CTA; + (new
491
+ conversation) and the clipboard clip are the quiet secondaries to its left. Below the text (not
492
+ overlaid) so the textarea keeps the full prose column. */
493
+ .composer-actions {
494
+ display: flex;
495
+ justify-content: flex-end;
496
+ align-items: center;
497
+ gap: .15rem;
498
+ padding: 0 0 .4rem; /* flush right, so Send lines up with the copy pill / statusbar pills */
499
+ }
500
+ .composer-act, .composer-new, .composer-clip {
501
+ width: 28px;
502
+ height: 28px;
503
+ display: flex;
504
+ align-items: center;
505
+ justify-content: center;
506
+ padding: 0;
507
+ margin: 0;
508
+ appearance: none;
509
+ border: none;
510
+ border-radius: 50%;
511
+ cursor: pointer;
512
+ transition: background .15s, color .15s, transform .08s;
513
+ }
514
+ /* Send/stop are CIRCLE GLYPHS (play_circle / stop_circle — typebulb.com's play look): the icon IS
515
+ the button, no painted disc. One size for all three — the circles' diameter and the paperclip's
516
+ ink height are both 800/960 of the material viewBox, so equal boxes = equal visual height. */
517
+ .composer-act .icon, .composer-new .icon, .composer-clip .icon { width: 24px; height: 24px; }
518
+ .composer-act { background: transparent; }
519
+ .composer-act.send { color: var(--accent); }
520
+ .composer-act.send:hover:not(:disabled) { color: color-mix(in srgb, var(--accent) 80%, white); background: color-mix(in srgb, var(--accent) 10%, transparent); }
521
+ .composer-act.stop { color: var(--err); }
522
+ .composer-act.stop:hover { color: color-mix(in srgb, var(--err) 80%, white); background: color-mix(in srgb, var(--err) 10%, transparent); }
523
+ .composer-act:active, .composer-new:active, .composer-clip:active { transform: scale(.94); }
524
+ .composer-act:disabled, .composer-new:disabled, .composer-clip:disabled { opacity: .5; cursor: default; }
525
+ .composer-new {
526
+ background: transparent;
527
+ color: var(--muted);
528
+ }
529
+ .composer-new:hover:not(:disabled) { color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, transparent); }
530
+ .composer-clip {
531
+ background: transparent;
532
+ color: var(--muted);
533
+ }
534
+ .composer-clip:hover:not(:disabled) { color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, transparent); }
535
+ /* Zero horizontal padding (like .composer-input): these rows sit flush on the content column. */
536
+ .composer-err { padding: .3rem 0; color: var(--err); font-size: .78rem; }
537
+ /* The ambient status line (TB-Agent-Composer-Toolkit.md Piece 2): one slim kind-tinted row above the
538
+ input — a retry/compaction in progress, an extension notice, a recipe's transient result. */
539
+ .composer-status { padding: .3rem 0; font-size: .78rem; color: var(--accent); }
540
+ .composer-status.warning { color: var(--warn); }
541
+ .composer-status.error { color: var(--err); }
542
+ /* The pending-queue strip (parity #2): queued steer/follow-up texts above the input. Block rows,
543
+ not a flex column — .queue-row's overflow:hidden would zero flex min-heights (the .dlg-opts
544
+ lesson). */
545
+ .composer-queue { padding: .15rem 0 0; }
546
+ .queue-row { font-size: .78rem; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
547
+ .queue-tag { color: var(--accent); font-size: .68rem; text-transform: uppercase; letter-spacing: .05em; margin-right: .5rem; }
548
+ /* The @-file-mention popup, anchored above the composer. Tall enough for the pickers' full row
549
+ caps (10 files / 12 commands); the vh guard + arrow-key scroll-into-view cover short windows. */
550
+ .at-popup {
551
+ position: absolute;
552
+ bottom: calc(100% + .25rem);
553
+ left: 0;
554
+ right: 0;
555
+ max-width: 600px;
556
+ margin-inline: auto;
557
+ max-height: min(24rem, 50vh);
558
+ overflow-y: auto;
559
+ background: var(--panel);
560
+ border: 1px solid var(--border);
561
+ border-radius: 10px;
562
+ box-shadow: 0 8px 30px rgba(0, 0, 0, .35);
563
+ padding: .25rem;
564
+ z-index: 10;
565
+ }
566
+ .at-row {
567
+ padding: .35rem .55rem;
568
+ border-radius: 6px;
569
+ cursor: pointer;
570
+ font-family: ui-monospace, Menlo, monospace;
571
+ font-size: .82rem;
572
+ white-space: nowrap;
573
+ overflow: hidden;
574
+ text-overflow: ellipsis;
575
+ }
576
+ .at-row.active { background: color-mix(in srgb, var(--accent) 18%, transparent); }
577
+ .at-row:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); }
578
+ /* The '/' palette rides the same popup chrome; its rows pair a command with a muted description. */
579
+ .slash-desc { color: var(--muted); font-size: .74rem; margin-left: .6rem; }
580
+ /* The composer's dialog fields (TB-Agent-Composer-Toolkit.md Piece 3), shared by both homes: the
581
+ trust-modal chrome (server dialogs, confirms) and the anchored .dlg-popup (local select/input).
582
+ Select options reuse .at-row inside a scroll box. */
583
+ /* Block, NOT flex: .at-row's overflow:hidden zeroes flex min-heights, squashing a long option
584
+ list (pi can return the whole OpenRouter catalog); block rows keep their natural height and
585
+ the box scrolls. */
586
+ .dlg-opts {
587
+ max-height: 40vh; overflow-y: auto;
588
+ border: 1px solid var(--border); border-radius: 8px; padding: .25rem;
589
+ }
590
+ .dlg-input, .dlg-editor {
591
+ font: inherit; font-size: .85rem; color: var(--fg); background: transparent;
592
+ border: 1px solid var(--border); border-radius: 7px; padding: .4rem .6rem; width: 100%;
593
+ }
594
+ .dlg-input:focus, .dlg-editor:focus { outline: none; border-color: var(--accent); }
595
+ .dlg-editor { min-height: 8rem; resize: vertical; }
596
+ .dlg-filter { margin-top: .4rem; }
597
+ /* A local (recipe) select/input rides the @-popup shell — the gesture register, not the modal's
598
+ (composer.ts #dlgAnchored). The popup itself stops scrolling (its .dlg-opts scrolls instead, so
599
+ the filter stays pinned at the anchored bottom edge); the opts box drops its border, the popup
600
+ frame already provides one. The catcher is the invisible click-away target under the popup. */
601
+ .dlg-popup { max-height: none; overflow: visible; }
602
+ .dlg-popup .dlg-opts { border: none; padding: 0; }
603
+ .dlg-label { font-size: .78rem; color: var(--muted); padding: .1rem .25rem .3rem; }
604
+ .dlg-catcher { position: fixed; inset: 0; z-index: 9; }
605
+ .at-empty { padding: .6rem; color: var(--muted); font-size: .85rem; text-align: center; }
606
+ /* The driver's in-flight assistant message — visibly provisional next to landed bubbles; the
607
+ durable row replaces it when the entry hits the transcript. */
608
+ .bubble.draft { opacity: .9; }
609
+ .draft-wait { color: var(--muted); }
610
+ /* Pasted-image thumbnails under a user message that @-mentions .typebulb/paste files. */
611
+ .paste-thumbs { margin-top: .4rem; }
612
+ .paste-thumb { display: block; max-width: min(360px, 100%); max-height: 240px; margin-top: .3rem; border: 1px solid var(--border); border-radius: 8px; }
613
+
614
+ /* Session list — shares the popover chrome (.picker above); these are
615
+ just its own dimensions. */
616
+ .picker { width: min(440px, calc(100vw - 2rem)); }
617
+ /* The filter (.bulb-filter-control) pins at the bottom — the popover's anchored edge, so it never
618
+ jumps as the filtered list changes height; the row list scrolls inside the capped box, resting at
619
+ its end (newest-at-bottom) — same shape as the launcher's .servers-pop / .bulb-list. */
620
+ .picker-list { display: flex; flex-direction: column; gap: .15rem; flex: 1; min-height: 0; overflow-y: scroll; padding-right: 4px; }
621
+ /* Wrapper around just the list (not the filter), so the list takes the space above the input. */
622
+ .picker-body { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; }
623
+ .picker-empty { padding: .8rem; color: var(--muted); font-size: .85rem; text-align: center; }
624
+ .picker-row {
625
+ display: flex;
626
+ flex-direction: column;
627
+ /* flex:none — the explicit min-height below replaces flexbox's implicit min-height:auto floor,
628
+ which would otherwise let an overflowing list squash rows below their content (snippet rows
629
+ rendered as overlapping text). The list scrolls; rows never compress. */
630
+ flex: none;
631
+ gap: .2rem;
632
+ /* min-height, not height: a full-text result row grows for its snippet line. */
633
+ padding: .35rem .55rem;
634
+ min-height: var(--menu-row-h);
635
+ box-sizing: border-box;
636
+ justify-content: center;
637
+ border-radius: 6px;
638
+ cursor: pointer;
639
+ font-size: .85rem;
640
+ }
641
+ .picker-row-main { display: flex; gap: .6rem; align-items: baseline; }
642
+ .picker:focus { outline: none; } /* the list owns its own highlight; no focus ring */
643
+ .picker-row:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); }
644
+ /* Keyboard cursor (arrow keys / hover) — same borderless tint as the launcher's .server-row.active. */
645
+ .picker-row.active { background: color-mix(in srgb, var(--accent) 16%, transparent); }
646
+ /* One dot in a reserved gutter slot, transparent on every row so titles align whether or not it
647
+ shows. Green = the harness reports this session mid-turn (the live-app axis, the launcher's
648
+ running-:port green); accent = "you are here", and it wins, because the row you are reading is
649
+ self-evidently working and a second mark at 7px would say nothing new. Deliberately NOT process
650
+ liveness, which is true of every window left open — see TB-Agent-Mirror-Ready.md. */
651
+ .picker-dot {
652
+ flex: none;
653
+ align-self: center;
654
+ width: 7px;
655
+ height: 7px;
656
+ border-radius: 50%;
657
+ background: transparent;
658
+ }
659
+ .picker-dot.working { background: var(--fold-marker); }
660
+ /* Higher specificity than .working on purpose: attachment outranks the turn state. */
661
+ .picker-row.current .picker-dot { background: var(--accent); }
662
+ .picker-preview {
663
+ flex: 1; min-width: 0;
664
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
665
+ color: var(--fg);
666
+ }
667
+ .picker-time {
668
+ color: var(--muted);
669
+ font-size: .72rem;
670
+ min-width: 2.6rem; text-align: right;
671
+ }
672
+ /* Unread cue (TB-Agent-Mirror-Ready.md): the row's time turns the launcher's running-:port green
673
+ (--fold-marker) when the session changed since the mirror opened and hasn't been viewed. */
674
+ .picker-time.unseen { color: var(--fold-marker); }
675
+ .picker-hits { color: var(--muted); font-size: .72rem; white-space: nowrap; }
676
+ /* A full-text hit inside a session's child transcript (TB-Agent-Children.md): indented under its
677
+ parent's row and muted, at the SAME size — the row's distinguishing content is the agent's
678
+ description, so shrinking it is the last thing that should happen to it. */
679
+ .picker-row.child { padding-left: 1.9rem; }
680
+ .picker-row.child .picker-preview { color: var(--muted); }
681
+ .picker-child-kind { flex: none; max-width: 7rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); opacity: .8; }
682
+ /* Full-text result context: first matching turn, indented past the dot gutter into the title column. */
683
+ .picker-snippet {
684
+ color: var(--muted);
685
+ font-size: .78rem;
686
+ line-height: 1.4;
687
+ margin-left: calc(7px + .6rem);
688
+ overflow-wrap: anywhere;
689
+ }
690
+ .picker-mark {
691
+ background: color-mix(in srgb, var(--accent) 30%, transparent);
692
+ border-radius: 2px;
693
+ color: var(--fg);
694
+ }
695
+ /* Agent switcher (TB-Agent-Switcher.md) — reuses the .picker popover + rows; just its own width and
696
+ the no-key entry form. The active model is marked with .picker-row.current (the accent dot), like
697
+ the attached session; the "active" tag rides .picker-time's muted right-aligned slot. */
698
+ .model-picker { width: min(380px, calc(100vw - 2rem)); }
699
+ .model-err { color: var(--danger, #f87171); font-size: .76rem; padding: .3rem .55rem; }
700
+ /* Key-entry form: the popover flips to this in place of the list when no key is set yet. */
701
+ .key-entry { gap: .5rem; padding: .7rem; }
702
+ .key-entry-head { font-size: .85rem; color: var(--fg); }
703
+ .key-entry .bulb-filter { width: 100%; }
704
+ .key-entry-acts { display: flex; gap: .4rem; justify-content: flex-end; }
705
+ .key-entry-note { font-size: .72rem; color: var(--muted); line-height: 1.4; }
706
+
707
+ /* Agent switcher watchdog (TB-Agent-Switcher.md). TWO tiers, both surfaced live while the menu is
708
+ closed: RED when an Anthropic model is riding the OpenRouter route (the $-per-turn Opus footgun), and
709
+ AMBER when the route served a turn uncached (the cache flip — a cost leak, pennies-scale, so quieter
710
+ than red so the red stays meaningful). The latch outranks the .on accent (equal specificity → source
711
+ order wins): coloured border + text, the bar's loudest states. */
712
+ .pill.danger, .pill.danger:hover { border-color: var(--err); color: var(--err); }
713
+ .pill.warn, .pill.warn:hover { border-color: var(--warn); color: var(--warn); }
714
+ /* The watchdog banner: rendered by Root just above the statusbar, anchored to the .app box. Centered,
715
+ capped width, opaque so it reads over the message text the strip lets through. Only present when a
716
+ tier is live, so it never costs layout in the common case. .danger is red + a glow ring (loud);
717
+ .warn is amber, no ring (a quieter cost cue). */
718
+ .model-banner {
719
+ position: absolute;
720
+ left: 0; right: 0; bottom: 2.6rem;
721
+ margin: 0 auto;
722
+ max-width: min(640px, calc(100vw - 2rem));
723
+ display: flex;
724
+ align-items: flex-start;
725
+ gap: .5rem;
726
+ padding: .55rem .75rem;
727
+ border-radius: 8px;
728
+ background: var(--bg);
729
+ color: var(--fg);
730
+ font-size: .8rem;
731
+ line-height: 1.4;
732
+ }
733
+ .model-banner.danger { border: 1px solid var(--err); box-shadow: 0 0 0 2px color-mix(in srgb, var(--err) 22%, transparent), 0 4px 14px rgba(0,0,0,.25); }
734
+ .model-banner.warn { border: 1px solid var(--warn); box-shadow: 0 4px 14px rgba(0,0,0,.22); }
735
+ .model-banner-icon { flex: none; }
736
+ .model-banner-text { color: var(--fg); }
737
+ /* L2 predictor: a status line at the top of the model popover saying what the next send will bill.
738
+ Muted by default; red when the live model is the Anthropic-via-OpenRouter footgun. */
739
+ /* The model menu's top section: ONE row — the route headline (ellipsizing) + the reasoning dial pinned
740
+ right. Height is one model-row minus its own border, so the row + border + the popover gap to the list
741
+ equal one list-row pitch — the section subtracts a whole row, never a fraction, so no model is clipped
742
+ under the scroll. (Eyeball-tune the height; exact pixel math isn't worth it.) The headline carries only
743
+ what the user cares about; reroute/uncached mechanics live in its title, not on the line. */
744
+ .model-head {
745
+ display: flex; align-items: center; justify-content: space-between; gap: .5rem; flex: none;
746
+ height: calc(var(--menu-row-h) - 1px); box-sizing: border-box;
747
+ /* left padding lines "More info" up with the model titles (row padding .55rem + dot 7px + gap .6rem);
748
+ right padding lines the dial up with the per-row "probe" column (row padding .55rem + list
749
+ padding-right 4px + scrollbar ~15px — the scrollbar is effectively always present, so it's accounted
750
+ for unconditionally; tweak the 15px by eye). */
751
+ padding: 0 calc(.55rem + 4px + 15px) 0 calc(.55rem + 7px + .6rem);
752
+ border-bottom: 1px solid var(--border);
753
+ font-size: .74rem; color: var(--muted);
754
+ }
755
+ /* The external-override warning is the one case that still puts words on this row. */
756
+ .model-route { flex: 0 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
757
+ .model-route.warn { color: var(--warn); }
758
+ /* "More info" — a quiet text link (not a pill); drills into the routing/reasoning explainer (diag chrome). */
759
+ .model-info-toggle {
760
+ appearance: none; background: none; border: 0; margin: 0; padding: 0;
761
+ font: inherit; font-size: .74rem; color: var(--accent); cursor: pointer; flex: none;
762
+ }
763
+ .model-info-toggle:hover { text-decoration: underline; }
764
+ /* The reasoning label sits a clear gap to the left of the dial; the pair hugs the right edge. */
765
+ .reasoning-ctl { display: flex; align-items: center; gap: .5rem; flex: none; }
766
+ .reasoning-label { flex: none; }
767
+
768
+ /* The reasoning dial — a Min/Low/Med/High native select styled as an accent pill (a full combobox is
769
+ over-powered for four fixed levels), pinned to the right of the headline so the dropdown is the row's
770
+ last element. */
771
+ .reasoning-select {
772
+ appearance: none; margin: 0; font: inherit; font-size: .72rem;
773
+ width: 4rem; /* match the probe button so the dial shares its column */
774
+ /* centred text (symmetric padding) so the label sits like the probe button's centred caption; the
775
+ chevron still rides the right padding zone. */
776
+ height: 1.5rem; padding: 0 1.1rem; text-align: center; text-align-last: center; border-radius: 999px;
777
+ border: 1px solid color-mix(in srgb, var(--accent) 50%, transparent);
778
+ /* OPAQUE (mixed over --panel, not transparent) so the OPEN native option list matches the CLOSED
779
+ pill — a transparent fill left the open list on the browser's default bg, a jarring mismatch.
780
+ The option rows reuse the same --rs-bg so the two states are identical. */
781
+ --rs-bg: color-mix(in srgb, var(--accent) 14%, var(--panel));
782
+ background-color: var(--rs-bg);
783
+ color: var(--accent); cursor: pointer;
784
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath fill='none' stroke='%237aa2fa' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M1 1l4 4 4-4'/%3E%3C/svg%3E");
785
+ background-repeat: no-repeat; background-position: right .5rem center;
786
+ }
787
+ .reasoning-select option { background-color: var(--rs-bg); color: var(--fg); }
788
+ .reasoning-select:hover { border-color: var(--accent); }
789
+ .reasoning-select:focus { outline: none; border-color: var(--accent); }
790
+
791
+ /* The Anthropic brand glyph stands in for the word on the default pill. currentColor tracks the
792
+ pill's muted→accent state; grayscale at rest like the other compact glyph controls. */
793
+ .anthropic-glyph { display: block; }
794
+ .pill.glyph .anthropic-glyph { filter: grayscale(1); opacity: .8; }
795
+ .pill.glyph:hover .anthropic-glyph { filter: none; opacity: 1; }
796
+
797
+ .model-row .picker-row-main { align-items: center; }
798
+ /* Hold the shared --menu-row-h pitch (TB-Agent-Mirror.md: "rows must match pitch exactly or the
799
+ side-by-side menus read as two designs"). The trailing control is the tallest per-row element; like
800
+ the launcher's .server-row, trim the inherited .picker-row vertical padding so it stays under the
801
+ --menu-row-h floor and that floor — not the padded content — governs the row height. The highlight
802
+ still fills the full 29px (it paints the row box, not the padding). */
803
+ .model-row { padding-top: .05rem; padding-bottom: .05rem; }
804
+ /* The Anthropic (default) row is its own group below the OpenRouter models — it's the distinct "off the
805
+ proxy, back to your subscription" choice, not just another model. Marked with a subtle background wash
806
+ rather than a divider line: a line fought the rows' rounded corners + inset highlights (poking corners,
807
+ uneven gaps), whereas the wash inherits the row's own 6px radius, so it reads as a contained band that
808
+ sits naturally. Zero layout impact; lower specificity than :hover/.active, so the row still gives its
809
+ normal accent feedback on top. */
810
+ .model-default-row { background: color-mix(in srgb, var(--fg) 6%, transparent); }
811
+ /* Right-aligned trailing cluster: the green `active` marker (only on the current row) then the
812
+ fixed-width status control. The status control is rightmost + fixed width, so its column never moves
813
+ as rows probe — least musical chairs. */
814
+ .model-trailing { flex: none; display: flex; gap: .35rem; justify-content: flex-end; align-items: center; }
815
+ /* ONE button look AND width shared by every trailing pill — probe, probing, the measured status, and
816
+ the active marker — so they all line up and a transition only ever changes colour/text, never the
817
+ shape. The width is snug to the widest short label ("uncached"); the labels are kept terse precisely
818
+ so this stays tight rather than gutter-heavy. */
819
+ .model-btn {
820
+ appearance: none; margin: 0; font: inherit; font-size: .72rem;
821
+ width: 4rem; height: 1.5rem; padding: 0 .25rem; border-radius: 999px;
822
+ display: inline-flex; align-items: center; justify-content: center;
823
+ border: 1px solid var(--border); background: transparent; color: var(--muted);
824
+ white-space: nowrap; cursor: pointer;
825
+ }
826
+ .model-status.probe:hover { color: var(--accent); border-color: var(--accent); }
827
+ /* Measured states. cached = the blue accent (the positive "good route" cue); uncached/unavailable =
828
+ red (the states that bite on cost); cache-unconfirmed = neutral. All reuse existing theme colours. */
829
+ .model-status.ok { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 50%, transparent); background: color-mix(in srgb, var(--accent) 12%, transparent); }
830
+ .model-status.bad { color: var(--err); border-color: color-mix(in srgb, var(--err) 50%, transparent); background: color-mix(in srgb, var(--err) 12%, transparent); }
831
+ .model-status.warn { color: var(--muted); border-color: var(--border); }
832
+ .model-status.ok:hover, .model-status.bad:hover, .model-status.warn:hover { filter: brightness(1.12); text-decoration: underline; }
833
+ /* Same pill as every other state — only the text/colour change across probe → probing → result, never
834
+ the box. Non-interactive (cursor default); the accent-tinted border holds against the row's accent
835
+ hover tint, where the faint default --border washed out. */
836
+ .model-status.probing { color: var(--muted); border-color: color-mix(in srgb, var(--accent) 35%, transparent); cursor: default; }
837
+ /* The active marker — the lime/green live-axis colour (--fold-marker), matching the launcher's running
838
+ :port. A cue, not a control: no hover, default cursor. */
839
+ .model-active { color: var(--fold-marker); border-color: color-mix(in srgb, var(--fold-marker) 50%, transparent); background: color-mix(in srgb, var(--fold-marker) 12%, transparent); cursor: default; }
840
+ /* Don't double-mark the current row: the green `active` pill is the marker now, so drop the accent dot
841
+ fill (keep the gutter transparent for name alignment). */
842
+ .model-picker .picker-row.current .picker-dot { background: transparent; }
843
+
844
+ /* Drill-in surface — the probe diagnostics and the "More info" routing/reasoning explainer (the
845
+ launcher's logs analogue). Flips the whole popover; reuses the console head/back chrome. Held at the
846
+ FULL menu height (not content-sized) so drilling in from the list never resizes the popover — the body
847
+ fills and scrolls if the content is long, leaving quiet space if it's short. */
848
+ .model-picker.diag-mode { padding: 0; gap: 0; height: var(--menu-h); }
849
+ .model-picker.diag-mode .diag-body { flex: 1; min-height: 0; overflow-y: auto; }
850
+ .model-picker.diag-mode:focus { outline: none; }
851
+ .diag-head { display: flex; align-items: center; justify-content: space-between; gap: .5rem; padding: .35rem .5rem; background: var(--panel); border-bottom: 1px solid var(--border); }
852
+ .diag-head-acts { display: flex; gap: .4rem; align-items: center; flex: none; }
853
+ .diag-back:disabled { opacity: .5; cursor: default; }
854
+ .diag-name { min-width: 0; color: var(--fg); font-size: .8rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
855
+ .diag-back { font: inherit; font-size: .76rem; appearance: none; margin: 0; padding: .15rem .5rem; border-radius: 5px; background: transparent; border: 1px solid var(--border); color: var(--muted); cursor: pointer; white-space: nowrap; }
856
+ .diag-back:hover { color: var(--accent); border-color: var(--accent); }
857
+ .diag-body { display: flex; flex-direction: column; gap: .4rem; padding: .6rem; background: var(--bg); }
858
+ .diag-line { font-size: .8rem; line-height: 1.45; overflow-wrap: anywhere; }
859
+ /* Hanging-indent bullet under a lead-in line — wrapped lines align past the “• ”. */
860
+ .diag-bullet { padding-left: 1rem; text-indent: -0.55rem; }
861
+ .diag-line.ok { color: var(--diff-add); }
862
+ .diag-line.bad { color: var(--err); }
863
+ .diag-line.warn { color: var(--fg); }
864
+ .diag-line.muted { color: var(--muted); }
865
+ .diag-line.small { font-size: .72rem; }
866
+
867
+ /* Running-breakouts list — shares the popover chrome (.servers-pop above); its own dimensions +
868
+ per-row layout: the play/stop button + name read together on the left (▶/■ Title, name flex:1),
869
+ and the metadata/controls form a right-aligned cluster of fixed-width columns (logs · trust ·
870
+ time/port) that line up row-to-row — right-anchored, so the rightmost (time/port) always aligns and
871
+ the rest stack inward (logs and time/port share a width; trust is wider, to fit "restricted"). The
872
+ filter pins at the bottom and the list scrolls inside (see .bulb-list). */
873
+ .servers-pop { width: min(560px, calc(100vw - 2rem)); }
874
+ .server-row {
875
+ /* Subgrid: all rows share ONE set of column tracks (defined on .bulb-list). A per-row grid sizes
876
+ its columns independently — which is exactly what broke alignment (a stopped row with no
877
+ logs/port sized its tracks differently and its trust toggle drifted right). Spanning the parent's
878
+ tracks instead makes trust/logs/time line up row-to-row, while the parent's auto columns size to
879
+ the widest content so the cluster stays tight (no reserved min-width slack). Absent trust/logs
880
+ render an empty cell that still holds its column. */
881
+ display: grid;
882
+ grid-template-columns: subgrid;
883
+ grid-column: 1 / -1;
884
+ align-items: center;
885
+ /* Inset rounded highlight, matching the session picker. The background is the subgrid track area
886
+ (not bled out to the edge — that ran it under the scrollbar on the right while staying clear on
887
+ the left, an asymmetry). Text gets its breathing room inside the highlight via end-item margins
888
+ (.server-stop/.bulb-launch on the left, .server-port/.bulb-time on the right) rather than row
889
+ padding, which would shift the subgrid tracks and break alignment. */
890
+ padding: .05rem 0;
891
+ min-height: var(--menu-row-h);
892
+ border-radius: 6px;
893
+ font-size: .85rem;
894
+ }
895
+ .server-row:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); }
896
+ /* Keyboard-highlighted row (arrow-key cursor) — a touch stronger than hover so it stands out. */
897
+ .server-row.active { background: color-mix(in srgb, var(--accent) 16%, transparent); }
898
+ /* Breakout spotlight (bulbsPill.ts): the new bulb's row stays bright while the rest dim, until the
899
+ menu closes or that row is hovered. The transition eases the dim in and back out. */
900
+ .server-row { transition: opacity .3s ease; }
901
+ .server-row.dimmed { opacity: .3; }
902
+ .server-name {
903
+ flex: 1; min-width: 0;
904
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
905
+ color: var(--accent); text-decoration: none; cursor: pointer;
906
+ }
907
+ .server-name:hover { text-decoration: underline; }
908
+ /* Port links to the running server (localhost:<port>) — painted the bright fold-marker green
909
+ (lime in dark, deep green in light), not the name's blue accent: the :port sits at the row's
910
+ far right, away from the play button, so making it the most prominent thing in the row lets the
911
+ eye snap to it the moment a just-launched row lands. The green also distinguishes "open the live
912
+ app" (port) from "open the source" (the blue name), and echoes the fold-marker on collapsed bulbs. */
913
+ .server-port { color: var(--fold-marker); font-size: .76rem; font-variant-numeric: tabular-nums; text-decoration: none; text-align: right; margin-right: .4rem; }
914
+ a.server-port:hover { text-decoration: underline; }
915
+ /* Logs reads like a link (flips to this server's console), not a button. */
916
+ .server-logs { color: var(--muted); font-size: .76rem; text-decoration: none; cursor: pointer; margin-left: .5rem; }
917
+ .server-logs:hover, .server-logs.on { color: var(--accent); text-decoration: underline; }
918
+ /* Launcher filter box — pinned above the list; type to narrow (matches name + path). The control
919
+ wrapper pins to the top and anchors the clear (×) at the far right (position:relative + the
920
+ input's right padding reserves its room). */
921
+ .bulb-filter-control { position: relative; flex: none; margin-top: .35rem; }
922
+ .bulb-filter {
923
+ font: inherit;
924
+ font-size: .82rem;
925
+ width: 100%;
926
+ box-sizing: border-box;
927
+ padding: .35rem 1.7rem .35rem .5rem;
928
+ border-radius: 6px;
929
+ background: var(--bg);
930
+ border: 1px solid var(--border);
931
+ color: var(--fg);
932
+ }
933
+ .bulb-filter::placeholder { color: var(--muted); }
934
+ .bulb-filter:focus { outline: none; border-color: var(--accent); }
935
+ /* The inset controls (clear ×, then the mode toggle) flow in one cluster flush to the input's
936
+ top/right/bottom, inside its 1px border — so neither carries an offset for the other's width. */
937
+ .bulb-filter-inset { position: absolute; right: 1px; top: 1px; bottom: 1px; display: flex; align-items: stretch; }
938
+ /* Clear (×) — only rendered when the filter is non-empty; wipes it and refocuses the box. */
939
+ .bulb-filter-clear {
940
+ appearance: none; border: none; background: transparent; cursor: pointer;
941
+ color: var(--muted); font-size: 1.05rem; line-height: 1; padding: 0 .3rem; border-radius: 4px;
942
+ }
943
+ .bulb-filter-clear:hover { color: var(--accent); }
944
+ /* Filter-mode toggle (full-text search) — the cluster's right end. Both comboboxes render it
945
+ (ComboboxPill's modeToggle). */
946
+ .bulb-filter-control.has-trailing .bulb-filter { padding-right: 3.8rem; }
947
+ .bulb-filter-mode {
948
+ /* The right corners match the input's 6px radius so the latched fill reads as part of the box. */
949
+ appearance: none; border: none; background: transparent; cursor: pointer;
950
+ font-size: .8rem; line-height: 1; padding: 0 .45rem;
951
+ border-radius: 0 5px 5px 0;
952
+ }
953
+ /* Same state cue as the prose pill's glyph: the emoji can't take currentColor, so it goes
954
+ grayscale at rest, color while hovered or on — filtered on the inner span, never the button. */
955
+ .bulb-filter-mode .glyph-img { filter: grayscale(1); opacity: .75; }
956
+ .bulb-filter-mode:hover .glyph-img, .bulb-filter-mode.on .glyph-img { filter: none; opacity: 1; }
957
+ /* Latched: a --panel fill — the popover's own surface, one step off the input's --bg. */
958
+ .bulb-filter-mode.on { background: var(--panel); }
959
+ /* Scrolls within the fixed-height popover so the filter stays put and the box never grows. */
960
+ /* The shared grid for the launcher rows: each .server-row is a subgrid spanning these tracks, so
961
+ columns line up row-to-row. auto cols size to their widest content (tight, no slack); 1fr is the
962
+ name. A small right padding keeps the selected-row highlight off the scrollbar. */
963
+ /* grid-auto-rows: max-content — the grid twin of .picker-row's flex:none: when the list overflows
964
+ the popover cap, plain auto rows compress to each row's min-height contribution, so a full-text
965
+ row's snippet line bleeds under the next row as overlapping text. max-content tracks never
966
+ compress; the list scrolls, rows keep their content height. */
967
+ /* overflow-y: scroll, not auto — the scrollbar channel is 15px of the row band's right inset, so a
968
+ list short enough not to scroll (a project with no bulbs of its own: one collapsed fold row) had
969
+ its rows run 15px nearer the edge than the filter box below them. Keeping the channel always
970
+ present makes that inset one number — the same "effectively always present" the model menu's
971
+ header padding already hardcodes. (scrollbar-gutter is the stated fix and is ignored here.) */
972
+ .bulb-list { display: grid; grid-template-columns: auto 1fr auto auto auto auto; grid-auto-rows: max-content; column-gap: .6rem; row-gap: .15rem; align-items: center; align-content: start; padding-right: 4px; flex: 1; min-height: 0; overflow-y: scroll; }
973
+ /* A stopped bulb's name isn't a link (no running URL), so it stays plain foreground. */
974
+ .server-name.stopped { color: var(--fg); }
975
+ /* Downloaded-bulb rows (TB-Agent-Mirror.md): the user link — the folder/user slug (`samples`) in
976
+ the logs-link register — marks the category and opens the bulb's published page. A remote
977
+ (not-yet-downloaded) row's name is additionally muted and inert (no file to open), and its
978
+ time column stays empty (no mtime to show). */
979
+ .server-name.remote { cursor: default; }
980
+ .server-name.remote:hover { text-decoration: none; }
981
+ .sample-link { color: var(--muted); font-size: .76rem; text-decoration: none; cursor: pointer; flex: none; }
982
+ .sample-link:hover { color: var(--accent); text-decoration: underline; }
983
+ /* A remote pull's visible failure (bulbsPill.ts pullErrors) — the sample-link register, error-tinted. */
984
+ .pull-error { color: var(--err); font-size: .76rem; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
985
+ /* The remote fold (bulbsPill.ts groupRow / fold-row): the header and the remote rows within share
986
+ one veil step over the menu's panel bg as ONE contiguous band — each
987
+ row's ::before bridges the grid row-gap up to the row above, junction corners square off, and
988
+ only the band's outer corners round (the header's top; the last row's bottom, via :has). The
989
+ accent label marks the header. Single-class selectors on purpose: .server-row:hover/.active
990
+ outrank them, so the highlight tints still win. */
991
+ .sample-group { cursor: pointer; color: var(--muted); }
992
+ .sample-group, .fold-row { background: linear-gradient(var(--veil), var(--veil)), var(--panel); }
993
+ .sample-group.open { border-radius: 6px 6px 0 0; }
994
+ .fold-row { position: relative; border-radius: 0; }
995
+ .fold-row::before {
996
+ content: ''; position: absolute; left: 0; right: 0; top: -.15rem; height: .15rem;
997
+ background: linear-gradient(var(--veil), var(--veil)), var(--panel);
998
+ }
999
+ .fold-row:not(:has(+ .fold-row)) { border-radius: 0 0 6px 6px; }
1000
+ .sample-group-label { grid-column: 2 / -1; color: var(--accent); }
1001
+ /* The 1fr name column: name + (in full-text mode) its hit count read together, the badge
1002
+ right-aligned at the column's end by the name's flex:1 — the session row's preview/hits layout. */
1003
+ .bulb-name-cell { display: flex; align-items: baseline; gap: .6rem; min-width: 0; }
1004
+ /* Full-text snippet in the launcher: a second internal row of the row's subgrid, aligned under the
1005
+ name column (the session picker indents past its dot gutter instead — override that margin). */
1006
+ .bulb-list .picker-snippet { grid-column: 2 / -1; margin: 0 .4rem .2rem 0; }
1007
+ .bulb-time { color: var(--muted); font-size: .72rem; font-variant-numeric: tabular-nums; text-align: right; margin-right: .4rem; }
1008
+ /* The row's chrome buttons share one reset; sizing + colour differ below. Play/stop are round icon
1009
+ buttons (friendlier, space-economic) and stay flex — a replaced SVG mis-seats under the
1010
+ word-pill trim, the .pill.glyph case; the trust switch is a word, so it seats via the shared
1011
+ recipe. Logs is a link, not a button (see .server-logs above), so it's not in this group. */
1012
+ .server-stop, .bulb-launch, .trust-toggle {
1013
+ font: inherit; appearance: none; margin: 0; cursor: pointer; box-sizing: border-box;
1014
+ background: transparent; border: 1px solid var(--border); border-radius: 5px;
1015
+ height: 1.7rem;
1016
+ }
1017
+ /* Shorter than the icon buttons: a bordered text pill at full row height reads as cramped, where
1018
+ the round play/stop icons are meant to fill it. The shared pill box + font, not a bespoke pair —
1019
+ its own .76rem/1.45rem seated the word a full 1px below every statusbar pill, which is exactly how
1020
+ this drifts (measured: a per-control size is a per-control seating). */
1021
+ .trust-toggle { font-size: var(--pill-font); height: var(--pill-h); padding: 0 .5rem; color: var(--muted); justify-self: center; }
1022
+ .trust-toggle:hover, .trust-toggle.on { color: var(--accent); border-color: var(--accent); }
1023
+ /* .fold-caret shares only this geometry line (not the bordered-button rule above): the fold header's
1024
+ caret rides an invisible play-button-sized box, so a samples-only list sizes the button column
1025
+ exactly as a list with real buttons does, and the caret centres where the play glyphs do. */
1026
+ .server-stop, .bulb-launch, .fold-caret { display: inline-flex; align-items: center; justify-content: center; width: 1.7rem; padding: 0; border-radius: 50%; margin-left: .4rem; }
1027
+ .btn-icon { width: 16px; height: 16px; }
1028
+ /* A stopped bulb's play button is dormant — the title is the primary launch target — so it's
1029
+ hidden until the row is hovered or keyboard-selected. visibility (not display) reserves its slot,
1030
+ so the name column doesn't shift when it appears. Painted the fold-marker green, matching the
1031
+ :port link: green is the live-app axis (play to launch → :port to open it), distinct from the
1032
+ blue source/name. The green even travels left→right as a bulb goes stopped→running. */
1033
+ .bulb-launch { color: var(--fold-marker); visibility: hidden; }
1034
+ /* No optical nudge on the play glyph, deliberately: Material's play_arrow already carries one (its
1035
+ path spans 320→760 in a 0→960 frame — ~1px right at icon size, compensating a right triangle's
1036
+ left-leaning mass). Our own translateX(1px) stacked a second correction and measured 2px right of
1037
+ the button's centre. Any glyph that reads off-centre here is the frame's business, not the app's. */
1038
+ .server-row:hover .bulb-launch, .server-row.active .bulb-launch { visibility: visible; }
1039
+ .bulb-launch:hover { border-color: var(--fold-marker); background: color-mix(in srgb, var(--fold-marker) 12%, transparent); }
1040
+ /* Launching: stays visible (hover-independent) and shimmers until the row flips to a stop button,
1041
+ so the ~2s wait reads as "working" rather than a dead click. Transient — driven by the in-memory
1042
+ launching set, never persisted. */
1043
+ .bulb-launch.launching { visibility: visible; border-color: var(--fold-marker); }
1044
+ .server-stop { color: var(--muted); }
1045
+ .server-stop:hover { color: var(--err); border-color: var(--err); }
1046
+ /* push/pull verbs (TB-Push-Pull.md, Mirror surface): borderless quiet icons in one cell —
1047
+ frequency earns mutedness, so like the play button they're hover/keyboard-revealed
1048
+ (visibility reserves the slot; columns never shift). Accent on hover marks the live control. */
1049
+ .sync-cell { display: inline-flex; gap: .15rem; justify-self: center; }
1050
+ .sync-btn {
1051
+ font: inherit; appearance: none; margin: 0; padding: 0 .1rem; cursor: pointer;
1052
+ background: transparent; border: none; color: var(--muted);
1053
+ display: inline-flex; align-items: center; visibility: hidden;
1054
+ }
1055
+ .server-row:hover .sync-btn, .server-row.active .sync-btn { visibility: visible; }
1056
+ .sync-btn:hover { color: var(--accent); }
1057
+ /* In-flight: stays visible (hover-independent) and shimmers, same contract as .bulb-launch.launching;
1058
+ the ✓ beat after success likewise holds its slot so the arrow's return doesn't shift columns. */
1059
+ .sync-btn.busy, .sync-btn.done { visibility: visible; }
1060
+ .sync-btn.done { color: var(--fold-marker); }
1061
+ .btn-icon-sm { width: 13px; height: 13px; }
1062
+ /* Elevation prompt (VS-Code-Workspace-Trust style) — a modal over the whole view, since a
1063
+ denied capability is worth interrupting for. Backdrop dims; the card holds the choice. */
1064
+ .trust-back {
1065
+ position: fixed; inset: 0; z-index: 50;
1066
+ background: rgba(0, 0, 0, .5);
1067
+ display: flex; align-items: center; justify-content: center;
1068
+ padding: 1rem;
1069
+ }
1070
+ .trust-modal {
1071
+ width: min(420px, 100%);
1072
+ background: var(--panel); border: 1px solid var(--border); border-radius: 12px;
1073
+ box-shadow: 0 12px 48px rgba(0, 0, 0, .5);
1074
+ padding: 1rem 1.1rem; display: flex; flex-direction: column; gap: .6rem;
1075
+ }
1076
+ .trust-modal-h { font-size: 1rem; font-weight: 600; color: var(--fg); }
1077
+ .trust-modal-b { font-size: .85rem; color: var(--fg); line-height: 1.45; }
1078
+ .trust-modal-warn { font-size: .78rem; color: var(--muted); line-height: 1.4; }
1079
+ /* Summary’s first-use setup is deliberately informational: the normal interruption chrome makes
1080
+ the compact .env recipe legible without pretending the reader made a risky choice. */
1081
+ .summary-setup { width: min(390px, 100%); }
1082
+ .summary-setup-keys {
1083
+ padding: .55rem .65rem;
1084
+ border: 1px solid var(--border);
1085
+ border-radius: 7px;
1086
+ background: var(--bg);
1087
+ color: var(--fg);
1088
+ font: .78rem/1.6 ui-monospace, Menlo, Consolas, monospace;
1089
+ white-space: pre-line;
1090
+ user-select: text;
1091
+ }
1092
+ .trust-modal-acts { display: flex; justify-content: flex-end; gap: .5rem; margin-top: .2rem; }
1093
+ .trust-no, .trust-yes {
1094
+ font: inherit; font-size: .82rem; appearance: none; margin: 0; cursor: pointer;
1095
+ padding: .35rem .8rem; border-radius: 7px; border: 1px solid var(--border); background: transparent; color: var(--fg);
1096
+ }
1097
+ .trust-no:hover { border-color: var(--fg); }
1098
+ .trust-yes {
1099
+ color: var(--accent);
1100
+ border-color: color-mix(in srgb, var(--accent) 55%, transparent);
1101
+ background: color-mix(in srgb, var(--accent) 14%, transparent);
1102
+ }
1103
+ .trust-yes:hover { background: color-mix(in srgb, var(--accent) 22%, transparent); }
1104
+ /* Drilled-in streaming console for one running server. It takes over the whole bulbs
1105
+ popover (the list flips to it; ← back flips home), so it's wider than the list and its
1106
+ body fills the popover's height. overflow:hidden lets the rounded popover border clip the
1107
+ square head/body; the body scrolls internally. */
1108
+ .servers-pop.log-mode {
1109
+ padding: 0;
1110
+ /* The one fixed-height surface: a console wants its full frame regardless of how little it has
1111
+ logged yet — a height that tracked the log would grow line by line. */
1112
+ height: var(--menu-h);
1113
+ }
1114
+ .servers-pop.log-mode:focus { outline: none; }
1115
+ .bulb-log-head {
1116
+ display: flex; align-items: center; justify-content: flex-end; gap: .5rem;
1117
+ padding: .3rem .5rem; background: var(--panel);
1118
+ border-bottom: 1px solid var(--border);
1119
+ }
1120
+ .bulb-log-back {
1121
+ font: inherit; font-size: .76rem; appearance: none; margin: 0;
1122
+ padding: .15rem .5rem; border-radius: 5px;
1123
+ background: transparent; border: 1px solid var(--border);
1124
+ color: var(--muted); cursor: pointer; white-space: nowrap;
1125
+ }
1126
+ .bulb-log-back:hover { color: var(--accent); border-color: var(--accent); }
1127
+ .bulb-log-name { min-width: 0; color: var(--muted); font-size: .72rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1128
+ .bulb-log-body {
1129
+ flex: 1; min-height: 0;
1130
+ margin: 0; padding: .5rem .6rem;
1131
+ overflow: auto; /* top-anchored normal flow — output reads top→down */
1132
+ background: var(--bg);
1133
+ font: .72rem/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
1134
+ color: var(--fg);
1135
+ }
1136
+ .bulb-log-text { white-space: pre-wrap; word-break: break-word; }
1137
+
1138
+ /* ── Git diff pill + menu (TB-Agent-Mirror.md): a `± N` chip (custom two-color glyph, icons.ts, +
1139
+ changed-file count) that exists only when the tree is dirty — presence is the signal, so the glyph
1140
+ keeps its red/green at rest (no grayscale: the chip can't be "idle", a clean tree removes it) and
1141
+ the accent border stays reserved for latched, like every pill. Picking a menu row swaps the
1142
+ transcript for a full-context unified diff. Flex like the square glyph pills (an svg mis-seats
1143
+ under the word-pill text-box trim), width freed for the count. ── */
1144
+ /* Three classes to out-specify .pill.glyph's fixed square (width: var(--pill-h); padding: 0) —
1145
+ this is the one glyph pill that carries content beside its icon, so its width must be free. */
1146
+ .pill.glyph.gitdiff-pill { width: auto; padding: 0 .45rem; gap: .3rem; }
1147
+ /* 2:3, the cropped viewBox's aspect — a square box would re-add the side margins. */
1148
+ .gitdiff-pill .icon { width: 8px; height: 12px; }
1149
+ .gitdiff-count { font-size: var(--pill-font); font-variant-numeric: tabular-nums; }
1150
+ .gitdiff-pop { width: min(480px, calc(100vw - 2rem)); }
1151
+ /* The single-line menu-row geometry both swap-pills use (the session picker's rows are a column —
1152
+ they grow for a search snippet — so they keep their own). Merged selectors, the convention the
1153
+ .*-wrap rules above already follow: a third pill adds its name here rather than a fourth copy. */
1154
+ .gitdiff-list, .children-list { display: flex; flex-direction: column; gap: .15rem; flex: 1; min-height: 0; overflow-y: scroll; padding-right: 4px; }
1155
+ .gitdiff-row, .children-row { display: flex; align-items: center; gap: .55rem; flex: none; min-height: var(--menu-row-h); padding: .05rem .45rem; border-radius: 6px; font-size: .85rem; cursor: pointer; }
1156
+ .gitdiff-row:hover, .children-row:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); }
1157
+ .gitdiff-row.active, .children-row.active { background: color-mix(in srgb, var(--accent) 16%, transparent); }
1158
+ .gitdiff-row.viewing .gitdiff-path { color: var(--accent); }
1159
+ /* One status letter, VS Code's palette: modified/renamed amber, added/untracked green, deleted red. */
1160
+ .gitdiff-status { flex: none; width: 1rem; text-align: center; font: 600 .74rem ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--muted); }
1161
+ .gitdiff-status.s-M, .gitdiff-status.s-R { color: var(--warn); }
1162
+ .gitdiff-status.s-A, .gitdiff-status.s-U { color: var(--diff-add); }
1163
+ .gitdiff-status.s-D { color: var(--err); }
1164
+ .gitdiff-path { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1165
+ .gitdiff-counts { flex: none; display: inline-flex; gap: .35rem; font-size: .72rem; font-variant-numeric: tabular-nums; }
1166
+ .gitdiff-counts .count-add { color: var(--diff-add); }
1167
+ .gitdiff-counts .count-del { color: var(--err); }
1168
+ /* The pill's viewing form: the doc's identity in place of the icon+count — status letter, path,
1169
+ ±counts, × (the doc itself is chrome-free). Width capped so a deep path can't crowd the cluster;
1170
+ rtl clip keeps the path's tail — the filename — visible, full path in the title. */
1171
+ .pill.glyph.gitdiff-pill.viewing { max-width: min(30rem, 45vw); gap: .45rem; padding: 0 .35rem 0 .5rem; }
1172
+ /* Same text seating as the word pills (.pill's text-box note): .pill.glyph opts out of the trim
1173
+ (right for pure glyphs, low for letters), and the viewing form keeps flex for the path's
1174
+ ellipsis — so the trim rides each text item instead of the button. Flex items are block
1175
+ containers, so text-box applies; the × icon stays untrimmed (symmetric, flex-exact). */
1176
+ .gitdiff-pill.viewing :is(.gitdiff-status, .gitdiff-doc-path, .gitdiff-counts span) { text-box: trim-both cap alphabetic; }
1177
+ .gitdiff-doc-path { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl; text-align: left; font-size: var(--pill-font); color: var(--accent); }
1178
+ .gitdiff-doc-path:hover { text-decoration: underline; }
1179
+ /* The × on a pill holding the transcript's slot (ui.ts closeChip) — one rule, both swap-pills. */
1180
+ .pill-close { flex: none; display: inline-flex; padding: 0 .15rem; color: var(--muted); }
1181
+ .pill-close .icon { width: 10px; height: 10px; }
1182
+ .pill-close:hover { color: var(--accent); }
1183
+ /* Doc open ⇒ transcript-scoped pills hide: model switcher and session picker act on a conversation
1184
+ that is not on screen (the token pill stays — its shimmer says the live diff may still grow).
1185
+ Display only, never unmount — the model pill's superSelect must keep its mount. */
1186
+ .statusbar-actions.doc-open :is(.model-wrap, .sid-wrap, .children-wrap) { display: none; }
1187
+
1188
+ /* ── the agents pill (TB-Agent-Children.md): this session's child transcripts. The git-diff pill's
1189
+ shape throughout — presence is the signal (a session that spawned nothing has no pill), picking a
1190
+ row swaps the transcript for that child's conversation, and the pill then wears its identity so
1191
+ the child view carries no chrome of its own. ── */
1192
+ /* Only the VIEWING form is a glyph pill (it needs the fixed square's width freed); the chip is an
1193
+ ordinary word pill, `🤖${n}🤖`, so it takes .pill's centering recipe untouched. */
1194
+ .pill.glyph.children-pill { width: auto; padding: 0 .45rem; gap: .3rem; }
1195
+ /* The chip is a word pill, so it sits outside `.pill.glyph` twice over: its emoji keep their colour
1196
+ at rest (they read as the agents themselves, not as chrome), and they take none of the glyph
1197
+ padding. `.pill`'s .7rem is measured for letters; an emoji's advance already carries its own side
1198
+ bearings, so that padding lands outside whitespace the glyph brought with it and reads as a gap.
1199
+ The margin then goes on each glyph's INNER side only: the padding already owns the outer gap, so a
1200
+ symmetric one doubles up exactly where it shows. Chip only, since the viewing form is a flex row
1201
+ with a gap of its own. */
1202
+ .children-pill { padding: 0 .3rem; }
1203
+ .children-pill:not(.glyph) .glyph-img:first-child { margin-right: .12rem; }
1204
+ .children-pill:not(.glyph) .glyph-img:last-child { margin-left: .12rem; }
1205
+ .children-pop { width: min(520px, calc(100vw - 2rem)); }
1206
+ .children-row.viewing .children-label { color: var(--accent); }
1207
+ /* One dot carries the state: still working (the fold-marker green), finished (dim), killed by the
1208
+ user (warn). Green because running is the LIVE-APP axis, the same one the launcher's running
1209
+ :port and play glyph sit on; the blue accent is the source/name axis and would read as "selected"
1210
+ here, which the row already says by other means. */
1211
+ .children-dot { flex: none; align-self: center; width: 7px; height: 7px; border-radius: 50%; background: var(--muted); opacity: .4; }
1212
+ .children-dot.running { background: var(--fold-marker); opacity: 1; }
1213
+ .children-dot.stopped { background: var(--warn); opacity: .85; }
1214
+ /* The agent's type, capped so the common long one (general-purpose) can't crowd out the
1215
+ description — which is the only thing telling eleven siblings apart. Muted by colour, not size. */
1216
+ .children-kind { flex: none; max-width: 7rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); }
1217
+ .children-label { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1218
+ .children-model { flex: none; color: var(--muted); opacity: .8; }
1219
+ .children-time, .children-tokens { flex: none; color: var(--muted); font-size: .72rem; min-width: 2.6rem; text-align: right; }
1220
+ /* The viewing form: the open child's identity in place of the icon+count, with the × that returns.
1221
+ Same seating rule as the diff pill's — .pill.glyph opts out of the text-box trim, so each TEXT
1222
+ flex item takes it back or the words sit low against the icon. */
1223
+ .pill.glyph.children-pill.viewing { max-width: min(18rem, 35vw); gap: .45rem; padding: 0 .35rem 0 .5rem; }
1224
+ .children-pill.viewing .children-doc-label { text-box: trim-both cap alphabetic; }
1225
+ .children-doc-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--pill-font); color: var(--accent); }
1226
+
1227
+ /* The diff doc — takes the transcript's slot in .chat (same flex geometry as .messages). Full-bleed
1228
+ like a code surface (no --content-max column: diff lines want the width); the body is the scroll
1229
+ surface, its bottom padding clearing the overlaid statusbar like .messages' does. The ± markers and
1230
+ red/green bands are the shared .udiff-* rules the tool cards use. */
1231
+ .diff-doc { flex: 1; min-height: 0; display: flex; flex-direction: column; outline: none; }
1232
+ .diff-doc-body {
1233
+ flex: 1; min-height: 0; overflow: auto;
1234
+ /* No scrollbar-gutter: the native bar is hidden (.oruler-host), so the 15px it used to reserve on
1235
+ each edge rides in the padding instead — same metrics, and the right one is the ruler's channel. */
1236
+ padding: .75rem calc(1.75rem + 15px) 2.5rem;
1237
+ font: .78rem/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
1238
+ white-space: pre-wrap; word-break: break-word;
1239
+ }
1240
+ /* The scroller is the focusable element (it owns the paging keys), but a ring round a full-bleed pane
1241
+ is a box round the whole screen. Keyboard focus shows on the ruler's window instead — the thing the
1242
+ paging keys actually move, and a real indicator rather than none at all. */
1243
+ .diff-doc-body:focus { outline: none; }
1244
+ .diff-doc-body:focus-visible ~ .diff-ruler .oruler-win { background: color-mix(in srgb, var(--accent) 55%, transparent); }
1245
+ .diff-doc-empty, .diff-doc-trunc { color: var(--muted); padding: .4rem 0; }
1246
+ /* The wrong-cwd empty state (Root.#cwdHint): centered over the empty transcript. */
1247
+ .cwd-hint { position: absolute; inset: 0; display: grid; place-content: center; text-align: center; gap: .5rem; padding: 2rem; color: var(--muted); font-size: .9rem; max-width: 40rem; margin-inline: auto; }
1248
+ .cwd-hint-head { font-weight: 600; color: var(--fg); }
1249
+ .cwd-hint-dir { color: var(--accent); }
1250
+ /* With token colors under them, the full-strength (24%) bands compete — red tokens on a red band
1251
+ turn to mud — so the doc view's bands drop to a tint: polarity is still carried per-line by the
1252
+ gutter ± glyphs and at file scale by the ruler. Scoped: the tool-card hunks keep 24%, where the
1253
+ unhighlighted band is the only signal. Del a touch stronger than add — red-leaning tokens
1254
+ camouflage against red tint more than anything does against green. */
1255
+ .diff-doc-body .udiff-add { background: color-mix(in srgb, var(--diff-add) 15%, transparent); }
1256
+ .diff-doc-body .udiff-del { background: color-mix(in srgb, var(--err) 18%, transparent); }
1257
+ /* The ruler's diff clothing — red/green per change run; it rides in the channel the body's right
1258
+ padding reserves, where the scrollbar it replaces used to be. Geometry, the window and the click
1259
+ are the shared widget's. The wrapper keeps it overlaid rather than scrolling with the body. */
1260
+ .diff-doc-scroll { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; }
1261
+ .diff-ruler { right: 4px; }
1262
+ .diff-ruler .oruler-mark.add { background: var(--diff-add); opacity: .8; }
1263
+ .diff-ruler .oruler-mark.del { background: var(--err); opacity: .8; }
1264
+ /* messages */
1265
+ .messages {
1266
+ flex: 1;
1267
+ overflow-y: auto;
1268
+ /* Reserve the scrollbar gutter on BOTH edges so the centred --content-max column stays on the
1269
+ true axis (window centre) whether or not a scrollbar is showing. Without this the scrollbar
1270
+ shrinks the column and shifts it ~half a scrollbar left of the composer/statusbar columns
1271
+ (which never scroll) — the misalignment between the prose and the composer's left edge. */
1272
+ scrollbar-gutter: stable both-edges;
1273
+ /* Content runs under the overlaid statusbar rather than stopping above it.
1274
+ Gutters are symmetric: the turn stripe is absolutely positioned, so it claims no layout
1275
+ width and the left gutter needs no extra room for it (see Invariants). The bottom is
1276
+ larger to clear the overlaid statusbar pills (~2.1rem:
1277
+ .4rem inset + 1.7rem pill) so the last line isn't hidden behind them. */
1278
+ padding: 1.25rem 1.75rem 2.5rem 1.75rem;
1279
+ display: flex;
1280
+ flex-direction: column;
1281
+ gap: 1rem;
1282
+ }
1283
+ .note {
1284
+ display: flex;
1285
+ align-items: baseline;
1286
+ gap: .5rem;
1287
+ color: var(--muted);
1288
+ font-size: .9rem;
1289
+ max-width: var(--content-max);
1290
+ width: 100%;
1291
+ margin: 0 auto;
1292
+ }
1293
+ /* Top-of-transcript wordmark + tagline (messageList.ts #masthead). Centred on the prose column,
1294
+ scrolls with the conversation. Both logos render; theme picks which shows. */
1295
+ .masthead {
1296
+ max-width: var(--content-max);
1297
+ width: 100%;
1298
+ margin: 0 auto .25rem auto;
1299
+ padding: .15rem 0;
1300
+ display: flex;
1301
+ flex-direction: column;
1302
+ align-items: flex-start;
1303
+ gap: .5rem;
1304
+ text-align: left;
1305
+ user-select: none;
1306
+ }
1307
+ .masthead-logo {
1308
+ width: 150px;
1309
+ max-width: 55%;
1310
+ height: auto;
1311
+ opacity: .92;
1312
+ }
1313
+ .masthead-tagline {
1314
+ color: var(--muted);
1315
+ font-size: .85rem;
1316
+ }
1317
+ /* Black wordmark on light, outline on dark. */
1318
+ .masthead-logo.dark { display: none; }
1319
+ html[data-theme="dark"] .masthead-logo.light { display: none; }
1320
+ html[data-theme="dark"] .masthead-logo.dark { display: block; }
1321
+ .bubble {
1322
+ position: relative;
1323
+ max-width: var(--content-max);
1324
+ width: 100%;
1325
+ margin: 0 auto;
1326
+ }
1327
+ .bubble.user {
1328
+ background: var(--veil);
1329
+ border: 1px solid var(--border);
1330
+ border-radius: 12px;
1331
+ padding: .7rem .9rem;
1332
+ }
1333
+ /* A user bubble is a panel, so its hover copy pill clears the border instead of sitting on it.
1334
+ .5rem matches the controls' own `bottom`, so the pill stands equally clear on both axes.
1335
+ Inherited by the agent variant below, and by any later bubble that grows a surface. */
1336
+ .bubble.user { --pill-inset: .5rem; }
1337
+ /* A sub-agent's hand-back (TB-Agent-Children.md): a user turn the harness delivered on an agent's
1338
+ behalf, never one the user typed, so it wears its own outline instead of the plain send's. The
1339
+ accent is the source/name axis (the same one the agents pill's open-child label sits on); green
1340
+ belongs to the live axis and would read as "still running", which a report never is. */
1341
+ .bubble.user.agent {
1342
+ border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
1343
+ background: color-mix(in srgb, var(--accent) 7%, transparent);
1344
+ }
1345
+ /* The frame's header: the agents pill's glyph, the agent's type, then its description, which is the
1346
+ link into its transcript. Muted by colour and NOT by size — the description is the one thing that
1347
+ tells eleven siblings apart, so it stays at the body's size (the picker's child-row rule). */
1348
+ .agent-head { display: flex; align-items: baseline; gap: .4rem; margin-bottom: .55rem; }
1349
+ .agent-head .glyph-img { flex: none; align-self: center; }
1350
+ .agent-kind { flex: none; color: var(--muted); }
1351
+ .agent-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1352
+ a.agent-name { color: var(--accent); cursor: pointer; }
1353
+ a.agent-name:hover { text-decoration: underline; }
1354
+ /* Consecutive tool-only bubbles sit tight via negative margin (flex `gap`
1355
+ can't be overridden per sibling pair). The adjacent-sibling selector leaves
1356
+ the first tools-only bubble's gap intact, preserving the turn boundary. */
1357
+ .bubble.tools-only + .bubble.tools-only { margin-top: -.9rem; }
1358
+
1359
+ /* Turn stripe — a 2px colored bar to the left of each bubble. All bubbles in
1360
+ one user→assistant turn share a color; the palette cycles through 5 so
1361
+ adjacent turns are visually distinct when scrolling. Sits outside the
1362
+ bubble's left edge in the messages container's padding gutter.
1363
+ By default top/bottom extend half a gap beyond the bubble so same-turn
1364
+ neighbours' stripes meet seamlessly across the 1rem flex gap. The two
1365
+ turn-boundary overrides below clip those extensions so a turn's stripe
1366
+ starts at the top of its user bubble and ends at the bottom of its last
1367
+ bubble — leaving the full 2rem inter-turn gap stripe-free as a separator.
1368
+ --border-w compensates the 1px padding-edge offset that user bubbles' border
1369
+ would otherwise introduce — without it, stripes alternate 1px inset between
1370
+ user/assistant rows. */
1371
+ .bubble { --border-w: 0px; }
1372
+ .bubble.user { --border-w: 1px; }
1373
+ .bubble::before {
1374
+ content: '';
1375
+ position: absolute;
1376
+ /* Offsets subtract --border-w: absolute positioning anchors to the
1377
+ padding-edge (inside the border), so a user bubble's 1px border would
1378
+ otherwise shave 1px off each stripe edge. */
1379
+ /* Stripe hugs the column (1rem into the left gutter); absolute, so it consumes no layout width —
1380
+ which is why the gutters can be symmetric (no asymmetric padding needed to "make room"), and
1381
+ thus why breakouts need no nudge. - var(--border-w): a user bubble's 1px border pushes the
1382
+ padding edge (the abs-pos anchor) 1px right, so without it that stripe sits 1px right of the
1383
+ assistant bubbles' — compensate. */
1384
+ left: calc(-1rem - var(--border-w));
1385
+ top: calc(-.5rem - var(--border-w));
1386
+ bottom: calc(-.5rem - var(--border-w));
1387
+ width: 1px;
1388
+ background: var(--turn-color, transparent);
1389
+ }
1390
+ /* Clip stripe at turn boundaries. A user bubble always starts a turn → no
1391
+ upward extension. A bubble followed by a user bubble is the last of its
1392
+ turn → no downward extension. Same for the very last bubble in the list
1393
+ (an in-flight turn whose last assistant message is the end of the stream).
1394
+ Same `0 - var(--border-w)` form as the default top/bottom so user bubbles'
1395
+ 1px border still aligns with the stripe edge. */
1396
+ .bubble.user::before { top: calc(0px - var(--border-w)); }
1397
+ .bubble:has(+ .bubble.user)::before,
1398
+ .bubble:last-child::before { bottom: calc(0px - var(--border-w)); }
1399
+ /* Extra gap before each new turn (user bubble preceded by anything). Combined
1400
+ with the container's 1rem gap → ~2rem visible between turns vs ~1rem within
1401
+ a turn. Doesn't fire on the very first bubble (no preceding sibling). */
1402
+ .bubble + .bubble.user { margin-top: 1rem; }
1403
+ .bubble.turn-0 { --turn-color: #4cb35a; } /* green */
1404
+ .bubble.turn-1 { --turn-color: #e89a3c; } /* amber */
1405
+ .bubble.turn-2 { --turn-color: #d36b9a; } /* pink */
1406
+ .bubble.turn-3 { --turn-color: #9970d6; } /* purple */
1407
+ .bubble.turn-4 { --turn-color: #3cb8b3; } /* teal */
1408
+
1409
+ /* rendered markdown */
1410
+ .md { word-break: break-word; }
1411
+ /* Markdown links (often many file:line citations per message) — restrained like
1412
+ the rest of the bulb's links: accent, no persistent underline, reveal it only
1413
+ on hover. Without this they'd carry the browser-default underline-on-every-one. */
1414
+ .md a { color: var(--accent); text-decoration: none; }
1415
+ .md a:hover { text-decoration: underline; }
1416
+ .md :first-child { margin-top: 0; }
1417
+ .md :last-child { margin-bottom: 0; }
1418
+ .md p { margin: .5rem 0; }
1419
+ /* Thematic break — also the divider between merged consecutive user sends (see applyUser): faint
1420
+ so the bubble still reads as one turn, but legible in both themes. Mixed off --fg (text contrast)
1421
+ rather than --border + opacity: --border is itself dim in dark mode, so dimming it again left the
1422
+ line invisible there — and the faintness is a property of the line's color, not the element. */
1423
+ .md hr {
1424
+ border: none;
1425
+ border-top: 1px solid color-mix(in srgb, var(--fg) 12%, transparent);
1426
+ margin: .55rem 0;
1427
+ }
1428
+ .md pre {
1429
+ background: var(--veil);
1430
+ border: 1px solid var(--border);
1431
+ border-radius: 8px;
1432
+ padding: .7rem .85rem;
1433
+ overflow-x: auto;
1434
+ }
1435
+ /* A pasted image (server renders an <img> from a base64 block, see blockToMarkdown) is capped to the
1436
+ column and never blows the layout — height follows the width. */
1437
+ .md img { max-width: 100%; height: auto; border-radius: 6px; }
1438
+ /* Blockquote: left bar + faint tint + muted text — a quiet "quoted" callout. The bar
1439
+ is mixed toward --border so it stays distinct from the bright turn stripe in the
1440
+ gutter, and the tint is light enough not to read as a code/output panel. */
1441
+ .md blockquote {
1442
+ margin: .5rem 0;
1443
+ padding: .35rem .85rem;
1444
+ border-left: 3px solid var(--border);
1445
+ background: var(--veil);
1446
+ border-radius: 0 6px 6px 0;
1447
+ color: var(--muted);
1448
+ }
1449
+ .md blockquote :first-child { margin-top: 0; }
1450
+ .md blockquote :last-child { margin-bottom: 0; }
1451
+ .md code {
1452
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
1453
+ font-size: .88em;
1454
+ }
1455
+ .md :not(pre) > code {
1456
+ background: var(--veil);
1457
+ padding: .1em .35em;
1458
+ border-radius: 5px;
1459
+ }
1460
+ .md table { border-collapse: collapse; }
1461
+ .md th, .md td { border: 1px solid var(--border); padding: .3rem .55rem; }
1462
+ /* A faint header band breaks the grid's monotony and gives the table a head/body structure — tonal
1463
+ (`--veil`, the document-panel shade), never a hue, so it reads as structure not opinion and follows
1464
+ the host light/dark theme. As a panel the veil steps one shade above the table's container, so the
1465
+ header still reads when the table sits inside a bubble or callout. The header text stays `--fg`:
1466
+ weight + the fill carry it, a tint would be the opinionated bit. Left-align to the body cells. */
1467
+ .md th { background: var(--veil); font-weight: 600; text-align: left; }
1468
+ /* Disclosure (<details>/<summary>) from the safe-HTML allowlist (markdown.ts): a quiet --veil container,
1469
+ subtler than a code block — assistant prose sits directly on the page, so a fill stands out; keep it
1470
+ restrained and let the border define it. Summary is the clickable caret row (native marker), accent
1471
+ on hover like a link. */
1472
+ .md details {
1473
+ margin: .5rem 0;
1474
+ padding: .35rem .7rem;
1475
+ background: var(--veil);
1476
+ border: 1px solid var(--border);
1477
+ border-radius: 8px;
1478
+ }
1479
+ .md summary { cursor: pointer; font-weight: 600; }
1480
+ .md summary:hover { color: var(--accent); }
1481
+
1482
+ /* The full-lane breakout geometry, defined ONCE and shared by the two breakouts (the mermaid
1483
+ inline bulb and a spread bulb). `100%` in --lane-ml resolves against each user's own containing block
1484
+ (prose width) at point of use, so both centre on the column axis — which is the viewport centre,
1485
+ since the gutters are symmetric. One home so the geometry can't drift between the two (the bug
1486
+ that let an off-centre nudge survive in one rule after being removed from the other).
1487
+ --chop-inset (the stripe-chop's reach into the gutter) and --chop-bg (the fill that covers the
1488
+ stripe) live here too: all four describe the transcript column, so all four are the container's
1489
+ to declare rather than the artifact's. */
1490
+ .md {
1491
+ --lane-w: calc(100vw - 2rem); --lane-ml: calc((100% - var(--lane-w)) / 2);
1492
+ --chop-inset: 1rem; --chop-bg: var(--bg);
1493
+ }
1494
+
1495
+ /* A padded panel that holds rendered markdown redeclares all four as its own box: a sub-agent
1496
+ hand-back frame (TB-Agent-Children.md), a disclosure, a blockquote. Inside one there is no turn
1497
+ stripe to reach for or to cover, so the reach and the fill go to zero and an embed keeps only its
1498
+ vertical band, while the lane collapses to the panel: a wide table then shares the panel's width
1499
+ through its two ceilings and scrolls in .table-scroll, instead of a band that bleeds over the
1500
+ card's border and paints page colour across its tint. The reset must land on the `.md` element
1501
+ itself, since `.md` declares the defaults on itself and would beat a rule on an ancestor. The
1502
+ next panel is one more selector here. */
1503
+ .bubble.user > .md, .md details, .md blockquote {
1504
+ --lane-w: 100%; --lane-ml: 0px; --chop-inset: 0px; --chop-bg: transparent;
1505
+ }
1506
+
1507
+ /* Full-lane breakout — the mermaid embed (whole element) and a SPREAD bulb's iframe step out of
1508
+ the prose column to the transcript width so wide content gets room. Both use --lane-w/--lane-ml
1509
+ and extend symmetrically from the column, which is viewport-centred (symmetric gutters), so
1510
+ neither needs an off-centre nudge (see Invariants in Specs-Bulbs/Claude-Bulb/Claude-Bulb.md). A
1511
+ bulb's *wrapper* never breaks out — only its inner surface does — so the wrapper and its controls
1512
+ stay at prose-right. position + z-index:0 lift the box over the turn stripe it crosses; the
1513
+ mermaid SVG is transparent so a bg backs it, the bulb iframe is opaque so it occludes the stripe. */
1514
+ .md .mermaid {
1515
+ width: var(--lane-w);
1516
+ /* Vertical gap must be >= the box-shadow spread (1rem below): a smaller gap lets the bg ring
1517
+ paint over the half-line of prose above/below — the trap the opaque bulb inline bulb dodges by
1518
+ having no shadow, which the transparent mermaid can't. */
1519
+ margin: 1rem 0;
1520
+ margin-left: var(--lane-ml);
1521
+ margin-right: auto;
1522
+ position: relative;
1523
+ z-index: 0;
1524
+ background: var(--bg);
1525
+ }
1526
+
1527
+ /* Diagram specifics: scroll when wider than the lane; `safe center` centres one that fits and
1528
+ left-aligns one that overflows, so a wide flowchart scrolls from its start node. The SVG is
1529
+ transparent, so the turn stripe the breakout crosses shows through it — a bg box-shadow backs
1530
+ the diagram to occlude it (and clears the h-scrollbar edge). The bulb inline bulb is opaque (its
1531
+ iframe fills the box), so it occludes the stripe itself and gets NO shadow — a shadow there
1532
+ would paint its 1rem ring over the half-line of prose just above the inline bulb (the gap is .6rem). */
1533
+ .md .mermaid {
1534
+ overflow-x: auto;
1535
+ display: flex;
1536
+ justify-content: safe center;
1537
+ box-shadow: 0 0 0 1rem var(--chop-bg);
1538
+ }
1539
+ /* flex-shrink:0: otherwise the SVG shrinks to the flex container and its viewBox
1540
+ scales it down — no overflow, no scrollbar. Natural width is what scrolls. */
1541
+ .md .mermaid svg { max-width: none; height: auto; flex: 0 0 auto; }
1542
+
1543
+ /* Raw ```svg``` embeds: no breakout — these are drawings (smiley, plot), not wide
1544
+ diagrams, so keep them in the prose column, centred and capped at its width. They
1545
+ share the `.embed` stripe-chop below (a drawing earns its own row like a bulb). */
1546
+ .md .svg-embed { justify-content: center; }
1547
+ .md .svg-embed svg { max-width: 100%; height: auto; }
1548
+
1549
+ /* Markdown tables: chop in-column (the `.embed` rule below). An IN-COLUMN table starts where the prose
1550
+ starts — it is part of the paragraph flow, and a narrow one centred in the column reads as shifted
1551
+ right against the left-aligned text above and below it, which is the whole of that bug. Only a
1552
+ SPREAD table centres, and it has earned it: spreading means it is already wider than the column, so
1553
+ centring keeps it symmetric about the column axis exactly as the mermaid lane does. `safe` on both
1554
+ means one that overflows its box falls back to the start and scrolls from its first column rather
1555
+ than clipping. The scroll lives on the inner `.table-scroll`, not the `.embed`
1556
+ wrapper, so a horizontal scrollbar rides above the chop's bottom padding instead of flush against the
1557
+ next line (a scrollbar sits at its own container's bottom edge). It's the centred flex child, capped at
1558
+ the box so it shrinks-to-fit and scrolls only what overflows; `max-width:100%` + the overflow-induced
1559
+ 0 min-width let it shrink below the table's natural width. `spread` widens the wrapper to the lane (the
1560
+ mermaid geometry) for a table fitTableEmbeds found too wide for the column. The opaque `.embed`
1561
+ padding/z-index still occludes the turn stripe the spread crosses — no box-shadow needed. */
1562
+ .md .table-embed { justify-content: safe flex-start; }
1563
+ .md .table-embed .table-scroll { overflow-x: auto; max-width: 100%; }
1564
+ /* max-content keeps the table at its natural (unwrapped) width so it overflows `.table-scroll` and earns
1565
+ a scrollbar, rather than wrapping its cells to fit — the bar is the point, and it now rides inside the
1566
+ chop padding. */
1567
+ /* Two ceilings work together so a prose-heavy table neither scrolls forever nor squashes to mush:
1568
+ - per-column (the cell `max-width` — in an auto-layout table every cell in a column shares one width,
1569
+ so a cell cap *is* a column cap): no single column hogs; long text wraps at a readable measure.
1570
+ - whole-table (`max-width: var(--lane-w)`): the table never grows past the lane, so when columns don't
1571
+ all fit, the browser's auto-layout *shares* the lane among them — wrapping each to its slice rather
1572
+ than scrolling. It only scrolls once even longest-word-only columns can't fit the lane (genuinely too
1573
+ many columns). `width: max-content` is still the *preferred* width, so a small table stays natural
1574
+ and a wide one spreads; the lane cap just bounds the worst case. The floor lever (a per-cell
1575
+ `min-width`) — turn squash into scroll past some readable minimum — is deliberately left off until a
1576
+ real table needs it. `word-break: normal` resets the global `.md { word-break: break-word }` (which
1577
+ behaves like overflow-wrap:anywhere): left inherited, that global lets the lane cap squeeze a column
1578
+ below its longest word and snap it mid-character ("status" → "statu·s"). Reset to word-boundary
1579
+ wrapping so each column floors at its longest whole word and a too-tight table scrolls instead of
1580
+ shattering text; a pathological no-space token (a long URL) then scrolls rather than breaking. */
1581
+ .md .table-embed table { margin: 0; width: max-content; max-width: var(--lane-w); }
1582
+ .md .table-embed th, .md .table-embed td { max-width: 40ch; word-break: normal; }
1583
+ /* The spread band's comfort goes on the RIGHT only, which looks asymmetric written down and is what
1584
+ reads as even on screen. `.messages` reserves a scrollbar gutter at BOTH edges (to hold the column
1585
+ on the window axis), but only the right one ever contains a scrollbar: the left gutter is dead
1586
+ space that already reads as margin, while on the right the lane's own 1rem inset is all there is
1587
+ between the table and the bar. Inset both sides equally and the left gap measures a gutter wider
1588
+ than the right; inset neither and the table sits flush against the bar, which is the bug. So the
1589
+ band adds back one inset on the right, and the table's cap comes down by that same inset or it
1590
+ would overflow the band's content box by exactly that at every width — the permanent scrollbar the
1591
+ zeroed inset was avoiding. */
1592
+ .md .table-embed.spread {
1593
+ justify-content: safe center;
1594
+ width: var(--lane-w);
1595
+ margin-left: var(--lane-ml);
1596
+ margin-right: auto;
1597
+ padding-left: 0;
1598
+ padding-right: var(--chop-inset);
1599
+ }
1600
+ .md .table-embed.spread table { max-width: calc(var(--lane-w) - var(--chop-inset)); }
1601
+
1602
+ /* ````bulb```` inline bulbs: a sandboxed nested app. createBulbFrame owns the iframe (auto-height,
1603
+ borderless). No frame of our own — like the mermaid/svg embeds, it sits in the flow rather
1604
+ than in a box, so it reads as part of the transcript; the rounded clip just tidies the corners
1605
+ of a bulb that paints its own background. `fit` (default) keeps it in the prose column and
1606
+ caps its height (below); `spread` adds full-lane breakout + spacing from the shared rule above.
1607
+ position:relative anchors the controls overlay in BOTH modes (spread re-declares it for the
1608
+ z-index lift). .err is the compile-failure fallback; .bulb-err-strip is a runtime-error strip
1609
+ under a live inline bulb (both monospace, muted red). */
1610
+ /* The stripe-chop, shared by every opaque in-column visual artifact (the fit bulb and a raw
1611
+ `svg` — each opts in by carrying `.embed`). An opaque bg over position:relative + z-index:0
1612
+ paints above .bubble::before; a --chop-inset left extension reaches the stripe in the gutter
1613
+ without a full breakout (the matching padding-left keeps the content in the prose column, and the
1614
+ box's right edge stays at prose-right, so a bulb's controls anchored there don't move); inside a
1615
+ panel both zero out (the nesting reset above). The vertical gap is opaque
1616
+ padding, not margin: it occludes the stripe top and bottom so the cut is flush with the prose (no
1617
+ stub in a transparent gap), symmetric above/below to separate the artifact from surrounding text.
1618
+ margin top/bottom are zeroed — and the abutting prose margins too (below) — so the gap butts the
1619
+ text. Mermaid is NOT on this path: it's transparent and breaks out, occluding via box-shadow.
1620
+ Per-artifact layout (flex direction, centring) lives on .bulb-inline / .svg-embed. */
1621
+ .md .embed {
1622
+ display: flex;
1623
+ position: relative;
1624
+ z-index: 0;
1625
+ background: var(--chop-bg);
1626
+ margin: 0 0 0 calc(-1 * var(--chop-inset));
1627
+ padding: 1.1rem 0 1.1rem var(--chop-inset);
1628
+ }
1629
+ /* A bulb stacks its controls/frame/code vertically; in spread mode the inner iframe's own
1630
+ breakout covers the stripe too (the wrapper still chops in column). */
1631
+ .md .bulb-inline { flex-direction: column; }
1632
+ /* Inline (default): cap the inline bulb's height so a tall bulb doesn't run away down the transcript.
1633
+ Past the cap the inline bulb scrolls internally (its own overflow, set by the host↔inline bulb protocol —
1634
+ the iframe element can't scroll its srcdoc from out here). spread removes the cap. */
1635
+ .md .bulb-inline.fit iframe { max-height: 80dvh; }
1636
+ /* The frame host is a layout-less wrapper (domeleon owns the node; we parent the live iframe into
1637
+ it once) — display:contents lifts the iframe to be a flex child of .bulb-inline, so the sizing and
1638
+ spread-breakout rules target it directly, exactly as when it was appended as a direct child. The
1639
+ code toggle hides it via .code-open rather than unmounting, so the bulb keeps its state. */
1640
+ .bulb-frame { display: contents; }
1641
+ .md .bulb-inline.code-open .bulb-frame { display: none; }
1642
+ /* Spread: the inner surface (live iframe, or the code view behind the toggle) breaks out to the
1643
+ lane while the wrapper stays in the prose column, so the controls hold at prose-right. Shares the
1644
+ --lane-* geometry; centred on the column axis so toggling spread doesn't shift a column-fitting
1645
+ bulb sideways. `width !important` beats createBulbFrame's inline width:100% on the iframe
1646
+ (harmless on the code view, which has none); both opaque, so position/z-index:0 lift them over
1647
+ the turn stripe they now cross. */
1648
+ .md .bulb-inline.spread iframe,
1649
+ .md .bulb-inline.spread > .bulb-code {
1650
+ width: var(--lane-w) !important;
1651
+ margin-left: var(--lane-ml);
1652
+ margin-right: auto;
1653
+ position: relative;
1654
+ z-index: 0;
1655
+ }
1656
+ /* Kill the transparent margin between an inline bulb and the prose it abuts (top and bottom):
1657
+ the inline bulb's own opaque padding is the gap, and a leftover prose margin would let the
1658
+ turn stripe show through it as a stub past the last/next line of text. */
1659
+ .md :has(+ .embed) { margin-bottom: 0; }
1660
+ .md .embed + * { margin-top: 0; }
1661
+ /* The rounded clip lives on the inner surfaces, not the inline bulb (whose padding is now the
1662
+ gap), so the bulb's own card — the iframe, or the code view behind the toggle — is
1663
+ what carries the corners. */
1664
+ .md .bulb-inline iframe,
1665
+ .md .bulb-inline > .bulb-code {
1666
+ border-radius: 8px;
1667
+ }
1668
+ /* Overlay pill: a small hover-revealed control floated at a container's
1669
+ bottom-right, shared by the inline bulb breakout button and the message copy button.
1670
+ Muted at rest, accent on hover (instant, matching the status pills). The
1671
+ container supplies the reveal; per-button bits layer on top. Box shape, colors
1672
+ and vertical centering come from the shared word-pill rule (.pill, .token,
1673
+ .overlay-pill) — only positioning, reveal and sizing quirks live here. */
1674
+ .overlay-pill {
1675
+ position: absolute;
1676
+ bottom: .5rem;
1677
+ /* Flush with the content column's right edge (prose / statusbar pills) when the pill sits on the
1678
+ page's own background. A PANEL sets --pill-inset instead, so the pill clears its edge rather
1679
+ than landing on the border: `right: 0` resolves against the containing block's padding box, so
1680
+ inside a bordered box it means "touching the border". Only this axis needed it — `bottom` has
1681
+ always stood the pill off, which is why the defect read as a right-edge one. */
1682
+ right: var(--pill-inset, 0px);
1683
+ font: inherit;
1684
+ font-size: var(--pill-font);
1685
+ padding: 0 .45rem;
1686
+ /* Word buttons that swap labels in place (fit⇄spread, code⇄run, copy⇄copied), so a min-width
1687
+ keeps the button from jumping as the word's length changes — "fit" is much narrower than
1688
+ "spread". */
1689
+ min-width: 3.5rem;
1690
+ cursor: pointer;
1691
+ opacity: 0;
1692
+ transition: opacity .2s ease;
1693
+ }
1694
+ .overlay-pill:hover { color: var(--accent); border-color: var(--accent); }
1695
+
1696
+ /* Per-fence copy pill (code / svg / mermaid) — one reveal rule for every copyable block, since the
1697
+ fence rule stamps `.copyable` on each. The svg/mermaid containers are already position:relative for
1698
+ the chop/breakout; the code-block wrapper needs it for the absolute pill. The pill is positioned
1699
+ (absolute), so it paints over the static fence content regardless of DOM order. */
1700
+ .md .copyable { position: relative; }
1701
+ /* A code fence IS a panel (the <pre> carries the border and background, the wrapper is the
1702
+ positioning context), so its copy pill takes the inset. The svg and mermaid embeds are
1703
+ transparent bands on the page background and keep the flush edge. */
1704
+ .md .code-block { --pill-inset: .5rem; }
1705
+ .md .copyable:hover .copy-src,
1706
+ .md .copy-src:focus-visible { opacity: 1; }
1707
+ .md .copy-src.done { color: var(--accent); border-color: var(--accent); }
1708
+ /* The wrapper carries the code block's vertical rhythm (it, not the <pre>, is the `.md` flow child
1709
+ now, so the first/last-child margin resets land on it); the <pre> sits flush inside. */
1710
+ .md .code-block { margin: .6rem 0; }
1711
+ .md .code-block pre { margin: 0; }
1712
+
1713
+ /* Controls (code ⇄ run, then copy, then breakout) — a centered overlay straddling the
1714
+ bulb's top edge: clear of the running bulb below, free to overlap the prose above.
1715
+ Absolute, so toggling run↔code (which resizes the bulb below) never moves it, and so
1716
+ the bulb's vertical gap stays small. Revealed on inline bulb-hover or focus; inside the row
1717
+ the shared .overlay-pill drops its own corner-anchoring. */
1718
+ .md .bulb-controls {
1719
+ position: absolute;
1720
+ top: 0;
1721
+ right: 0; /* flush with the content column's right edge (prose / statusbar pills) */
1722
+ z-index: 2;
1723
+ display: flex;
1724
+ gap: .4rem;
1725
+ transform: translateY(-50%);
1726
+ }
1727
+ .md .bulb-controls .overlay-pill { position: static; }
1728
+ .md .bulb-inline:hover .bulb-controls .overlay-pill,
1729
+ .md .bulb-controls .overlay-pill:focus-visible { opacity: 1; }
1730
+ .md .bulb-breakout:disabled { cursor: default; opacity: 1; }
1731
+ /* Breakout in flight: shimmers through the ~2s write+spawn wait so the click reads as "working"
1732
+ rather than dead — same treatment as the launcher's play button (.bulb-launch.launching).
1733
+ :disabled already keeps it visible if the pointer leaves. */
1734
+ .md .bulb-breakout.launching { color: var(--accent); border-color: var(--accent); }
1735
+ /* Copy pill "copied" flash — mirrors the message .copy.done accent cue, scoped to the
1736
+ inline bulb control so it doesn't pick up the bubble-hover reveal. */
1737
+ .md .bulb-copy.done { color: var(--accent); border-color: var(--accent); }
1738
+ /* Source view behind the toggle — the bulb's .bulb.md rendered as markdown (file
1739
+ labels + fenced code panels via the shared .md rules). Just a scroll container
1740
+ here, capped so a long bulb doesn't blow out the transcript. No `display` — the
1741
+ [hidden] attribute the toggle flips drives visibility. */
1742
+ /* Code view: a streamlined source listing — one labeled bar per file with the code
1743
+ flush beneath, not a stack of bordered panels. The container is just the scroll box;
1744
+ the file-label bar is the only fill (overriding .md pre's panel chrome below). */
1745
+ .md .bulb-code {
1746
+ margin: 0;
1747
+ padding: 0;
1748
+ max-height: 480px;
1749
+ overflow: auto;
1750
+ font-size: .85rem;
1751
+ /* A distinct surface from the transcript: the live bulb blends into the flow, but the
1752
+ code view reads as an inspector panel. Code panels (.md pre below) are transparent, so
1753
+ they show this through. */
1754
+ background: var(--tool-bg);
1755
+ }
1756
+ /* **file.ext** labels render as <p><strong>…</strong></p>; a bulb's source has no other
1757
+ top-level prose, so styling the paragraph as a header bar only ever hits a label. */
1758
+ .md .bulb-code p {
1759
+ margin: 0;
1760
+ padding: .3rem .85rem;
1761
+ /* A touch darker than the code surface so the file-label header stays distinct now
1762
+ that the whole code view shares one background. */
1763
+ background: color-mix(in srgb, var(--fg) 6%, var(--tool-bg));
1764
+ border-top: 1px solid var(--border);
1765
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
1766
+ font-size: .78rem;
1767
+ color: var(--muted);
1768
+ }
1769
+ .md .bulb-code p:first-child { border-top: none; }
1770
+ .md .bulb-code p strong { font-weight: 600; color: var(--fg); }
1771
+ /* Code flush on the inline bulb bg — drop the inherited .md pre box (border/bg/radius). */
1772
+ .md .bulb-code pre {
1773
+ margin: 0;
1774
+ padding: .5rem .85rem .85rem;
1775
+ border: none;
1776
+ border-radius: 0;
1777
+ background: transparent;
1778
+ overflow-x: auto;
1779
+ }
1780
+ /* hljs token colors for the code view AND the diff doc (one palette, or the same file reads as two
1781
+ languages across surfaces), mapped to the bulb's own theme vars (no hljs
1782
+ theme stylesheet) so highlighting follows light/dark rather than baking a palette
1783
+ that would clash in dark mode. Minimal token set — enough for legible TS/HTML/CSS/
1784
+ JSON/markdown without chasing every grammar scope. */
1785
+ .bulb-code .hljs-comment, .diff-doc-body .hljs-comment, .bulb-code .hljs-quote, .diff-doc-body .hljs-quote { color: var(--muted); font-style: italic; }
1786
+ .bulb-code .hljs-keyword, .diff-doc-body .hljs-keyword, .bulb-code .hljs-literal, .diff-doc-body .hljs-literal, .bulb-code .hljs-built_in, .diff-doc-body .hljs-built_in { color: var(--accent); }
1787
+ .bulb-code .hljs-tag, .diff-doc-body .hljs-tag, .bulb-code .hljs-name, .diff-doc-body .hljs-name, .bulb-code .hljs-selector-tag, .diff-doc-body .hljs-selector-tag { color: var(--accent); }
1788
+ .bulb-code .hljs-string, .diff-doc-body .hljs-string, .bulb-code .hljs-regexp, .diff-doc-body .hljs-regexp, .bulb-code .hljs-meta .hljs-string, .diff-doc-body .hljs-meta .hljs-string {
1789
+ color: color-mix(in srgb, var(--diff-add) 85%, var(--fg));
1790
+ }
1791
+ .bulb-code .hljs-number, .diff-doc-body .hljs-number { color: color-mix(in srgb, var(--accent) 55%, var(--fg)); }
1792
+ .bulb-code .hljs-attr, .diff-doc-body .hljs-attr, .bulb-code .hljs-attribute, .diff-doc-body .hljs-attribute, .bulb-code .hljs-property, .diff-doc-body .hljs-property, .bulb-code .hljs-type, .diff-doc-body .hljs-type {
1793
+ color: color-mix(in srgb, var(--accent) 75%, var(--fg));
1794
+ }
1795
+ .bulb-code .hljs-title, .diff-doc-body .hljs-title, .bulb-code .hljs-title.function_, .diff-doc-body .hljs-title.function_, .bulb-code .hljs-section, .diff-doc-body .hljs-section {
1796
+ color: var(--fg); font-weight: 600;
1797
+ }
1798
+ .bulb-code .hljs-symbol, .diff-doc-body .hljs-symbol, .bulb-code .hljs-bullet, .diff-doc-body .hljs-bullet, .bulb-code .hljs-punctuation, .diff-doc-body .hljs-punctuation { color: var(--muted); }
1799
+ /* Draft-streaming bulb card (#draftBulbCard): the code view wearing a progress header — never a
1800
+ live mount; the durable row's InlineBulb takes this slot when the entry lands. Cap tighter than
1801
+ .bulb-code's resting 480px: ambient progress, not reading material. */
1802
+ .md .bulb-streaming { flex-direction: column; }
1803
+ .md .bulb-streaming > .bulb-code { max-height: 220px; border-radius: 0 0 8px 8px; }
1804
+ .bulb-streaming-head {
1805
+ display: flex;
1806
+ align-items: baseline;
1807
+ gap: .45rem;
1808
+ padding: .3rem .85rem;
1809
+ border-radius: 8px 8px 0 0;
1810
+ /* A notch above the file-label bars inside the listing, so the header owns the card. */
1811
+ background: color-mix(in srgb, var(--fg) 10%, var(--tool-bg));
1812
+ font-size: .78rem;
1813
+ color: var(--muted);
1814
+ }
1815
+ .bulb-streaming-count { margin-left: auto; font-variant-numeric: tabular-nums; }
1816
+ .md .bulb-inline.err {
1817
+ padding: .6rem .8rem;
1818
+ font-family: ui-monospace, monospace;
1819
+ font-size: .85em;
1820
+ color: color-mix(in srgb, var(--err) 70%, var(--fg));
1821
+ white-space: pre-wrap;
1822
+ }
1823
+ .md .bulb-err-strip {
1824
+ padding: .45rem .7rem;
1825
+ border-top: 1px solid var(--border);
1826
+ background: color-mix(in srgb, var(--err) 8%, var(--bg));
1827
+ font-family: ui-monospace, monospace;
1828
+ font-size: .8em;
1829
+ color: color-mix(in srgb, var(--err) 75%, var(--fg));
1830
+ white-space: pre-wrap;
1831
+ }
1832
+
1833
+ /* Structural-defect strip (a malformed bulb that still rendered) — same shape as the error strip but
1834
+ amber: a warning, not a failure. */
1835
+ .md .bulb-warn-strip {
1836
+ padding: .45rem .7rem;
1837
+ border-top: 1px solid var(--border);
1838
+ background: color-mix(in srgb, rgb(200 140 30) 10%, var(--bg));
1839
+ font-family: ui-monospace, monospace;
1840
+ font-size: .8em;
1841
+ color: color-mix(in srgb, rgb(200 140 30) 80%, var(--fg));
1842
+ white-space: pre-wrap;
1843
+ }
1844
+
1845
+ /* tool calls */
1846
+ /* Closed tool: just a single-line row, no chrome — a tool-only turn stays a
1847
+ tight list, not N stacked panels. Open tools get the panel treatment below. */
1848
+ .tool {
1849
+ font-size: .85rem;
1850
+ }
1851
+ /* Row shape shared by tool heads and the collapsed-turn summary; only the latter is a click
1852
+ target (a tool's toggle is its digest row). */
1853
+ .tool-head, .turn-summary {
1854
+ display: flex;
1855
+ align-items: center;
1856
+ gap: .5rem;
1857
+ padding: .15rem 0;
1858
+ }
1859
+ .turn-summary {
1860
+ cursor: pointer;
1861
+ user-select: none;
1862
+ }
1863
+ /* The shared disclosure triangle (caret() in messageList.ts). flex:none so a squeezed row can't
1864
+ crush it; the digest row has no flex gap, so its spacing rides the marker. */
1865
+ .caret-tri { width: 10px; height: 10px; transition: transform .15s ease; }
1866
+ .caret-tri.open { transform: rotate(90deg); }
1867
+ .tool-digest .caret-tri { margin-right: .5rem; }
1868
+ /* Plain bold — an accent verb reads as a hyperlink; accent is reserved for the file-path link. */
1869
+ .tool-name { font-weight: 600; }
1870
+ /* Errors here are routine (a first-read miss the retry fixes), so a failed tool
1871
+ recolors just its verb rather than boxing the whole row — a quiet signal. */
1872
+ .tool.err .tool-name { color: var(--err); }
1873
+
1874
+ /* Collapsed-turn summary strip (intermediate steps hidden behind the final
1875
+ answer). Same caret/row shape as a tool head, italic muted label. */
1876
+ .turn-summary { color: var(--muted); font-size: .85rem; }
1877
+ .turn-summary-text { font-style: italic; }
1878
+ .fork-icon { width: 11px; height: 11px; vertical-align: -1px; margin-right: .15rem; }
1879
+ .turn-summary:hover .turn-summary-text { color: var(--fg); }
1880
+ /* Reply's stand-in for a turn that said nothing (messageList #noProseBubble). Same voice as the stub
1881
+ above, minus its row shape and cursor — this one is a label, not a toggle. */
1882
+ .no-prose { color: var(--muted); font-size: .85rem; font-style: italic; padding: .15rem 0; }
1883
+
1884
+ /* A child's Tasks view (childTasks.ts): the parent's request, then the task table. Status reads
1885
+ by colour on the existing roles; anything unusual is --warn, the mirror's amber. */
1886
+ /* The request bubble, then the table under it. */
1887
+ .child-tasks > .bubble.user { margin: .5rem auto .9rem; }
1888
+ /* Under the table, centred: where the table stands in time, then the view's two actions. */
1889
+ .child-tasks-foot { display: flex; flex-direction: column; align-items: center; gap: .6rem; padding: 1rem 0 1.75rem; }
1890
+ .child-tasks-actions { display: flex; gap: 1.4rem; }
1891
+ .child-tasks-actions a { cursor: pointer; color: var(--accent); }
1892
+ .child-tasks-actions a:hover { text-decoration: underline; }
1893
+ .child-tasks-note { color: var(--muted); font-size: .9rem; font-style: italic; }
1894
+ /* Every working text in this view is centred, like the status line under the table. */
1895
+ .child-tasks-pending { text-align: center; color: var(--muted); padding: .3rem 0; }
1896
+ .child-tasks-note.err { color: var(--err); }
1897
+ .child-tasks-note a { cursor: pointer; color: var(--accent); }
1898
+ .cs-table { width: 100%; border-collapse: collapse; }
1899
+ .cs-table th { text-align: left; color: var(--muted); font-weight: 500; font-size: .9rem; padding: .3rem .5rem; border-bottom: 1px solid var(--border); }
1900
+ .cs-table td { padding: .5rem; vertical-align: top; border-bottom: 1px solid var(--border); }
1901
+ .cs-table td:first-child { width: 34%; }
1902
+ .cs-status { white-space: nowrap; font-weight: 600; color: var(--muted); }
1903
+ .cs-status.in_progress { color: var(--accent); }
1904
+ .cs-status.done { color: var(--diff-add); }
1905
+ .cs-status.blocked { color: var(--err); }
1906
+ .cs-status.off { color: var(--warn); }
1907
+ .cs-off td:first-child { color: var(--warn); }
1908
+ .cs-note { color: var(--warn); padding-top: .2rem; }
1909
+ .cs-cite { color: var(--muted); font-size: .9rem; }
1910
+ .cs-cite a { cursor: pointer; color: var(--muted); text-decoration: underline dotted; }
1911
+ .cs-cite a:hover { color: var(--accent); }
1912
+ .cs-time { white-space: nowrap; color: var(--muted); }
1913
+ /* The timeline cell: a fixed track standing for the child's whole run, the row's stretches on it. */
1914
+ .cs-gantt-cell { display: flex; align-items: center; gap: .5rem; }
1915
+ .cs-gantt { position: relative; width: 140px; height: 10px; flex: none; border-radius: 3px; background: var(--veil); }
1916
+ .cs-seg { position: absolute; top: 0; bottom: 0; min-width: 2px; border-radius: 2px; background: var(--muted); }
1917
+ .cs-seg.in_progress { background: var(--accent); }
1918
+ .cs-seg.done { background: var(--diff-add); }
1919
+ .cs-seg.blocked { background: var(--err); }
1920
+ .cs-seg.off { background: var(--warn); }
1921
+ /* A wake (the child was messaged and resumed): a hairline across the track, above the segments. */
1922
+ .cs-wake { position: absolute; top: -3px; bottom: -3px; width: 1px; background: var(--fg); opacity: .45; }
1923
+ .cs-mins { min-width: 2.2rem; text-align: right; }
1924
+ /* A follow-up's bubble spans its row, so the columns under it stay aligned with the rest. */
1925
+ .cs-table .cs-group td { padding: 1.2rem 0 .6rem; border-bottom: 0; }
1926
+ /* The agents menu's per-row way straight into a child's Tasks view. */
1927
+ .children-tasks-link { flex: none; margin-left: .6rem; color: var(--muted); cursor: pointer; }
1928
+ .children-tasks-link:hover { color: var(--accent); text-decoration: underline; }
1929
+ /* A citation's landing row, marked briefly so the eye finds it. */
1930
+ .tool.revealed { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; transition: outline-color 1s; }
1931
+
1932
+ /* An expanded abandoned-branch (fork) stub renders its orphan messages here — set apart from the live
1933
+ transcript with a left rule and slight fade, signalling "this is not the active conversation"
1934
+ (TB-LostMessage.md). The stub header itself is a .turn-summary row above. */
1935
+ .fork-body {
1936
+ margin-top: .4rem;
1937
+ padding-left: .6rem;
1938
+ border-left: 2px solid color-mix(in srgb, var(--fg) 18%, transparent);
1939
+ opacity: .82;
1940
+ /* Same column geometry as .messages — the tools-only negative-margin rule assumes a 1rem gap. */
1941
+ display: flex;
1942
+ flex-direction: column;
1943
+ gap: 1rem;
1944
+ /* --turn-color is a custom property and INHERITS — the stub's own .bubble.turn-N sets it, so without
1945
+ this reset every nested orphan bubble inherits the parent turn's color and redraws the stripe
1946
+ (the recursive-line bug). Reset to transparent so .bubble::before falls back to no stripe here. */
1947
+ --turn-color: transparent;
1948
+ }
1949
+
1950
+ /* A superseded inline bulb (an earlier same-name version) folds to this "💡 <Name> — Version N" stub — it
1951
+ reclaims the bulb's full height. Marker-green text (--fold-marker: lime in dark, deep green in light)
1952
+ on a neutral gray fill (--panel) that hugs the text (width:max-content), no border: text-hugging and
1953
+ borderless keeps it distinct from the page's full-width bordered panels and clear of the live bulb's
1954
+ hover pills, and the colored title rides a gray pill rather than a green block. Its look never depends
1955
+ on outcome: errors show only inside the inline bulb when expanded, so the title never changes. Click to
1956
+ expand the older bulb (rerun fresh) back in. */
1957
+ .bulb-fold {
1958
+ display: flex;
1959
+ width: max-content;
1960
+ max-width: 100%;
1961
+ align-items: center;
1962
+ gap: .5rem;
1963
+ padding: .3rem .55rem;
1964
+ border-radius: 6px;
1965
+ background: var(--panel);
1966
+ color: var(--fold-marker);
1967
+ font-size: .9rem;
1968
+ font-weight: 600;
1969
+ cursor: pointer;
1970
+ user-select: none;
1971
+ }
1972
+ /* The fold is the sole child of its `.md` wrapper, so the `.md :first-child`/`:last-child` margin
1973
+ reset (above) would zero a plain `margin` here. Scope it under `.md` so it outweighs that reset
1974
+ (equal specificity, later in source) and the stub actually gets its breathing room. */
1975
+ .md .bulb-fold { margin: .7rem 0; }
1976
+ .bulb-fold:hover { background: color-mix(in srgb, var(--fg) 8%, var(--panel)); }
1977
+ /* Verb + summary are inline siblings sharing one line box and font, so they sit
1978
+ on a common baseline. Ellipsis truncation lives on the wrapper, not the
1979
+ children — overflow:hidden on the summary itself skews its baseline. */
1980
+ /* flex: 0 1 auto (not 1) so the pending '…' sits right after the label instead of at the row's
1981
+ far edge; the label shrinks with ellipsis when the row is tight. */
1982
+ .tool-label {
1983
+ flex: 0 1 auto;
1984
+ min-width: 0;
1985
+ white-space: nowrap;
1986
+ overflow: hidden;
1987
+ text-overflow: ellipsis;
1988
+ }
1989
+ .tool-sum {
1990
+ margin-left: .5rem;
1991
+ color: var(--muted);
1992
+ }
1993
+ .tool-run { color: var(--muted); }
1994
+
1995
+ /* The OUT line ("463 lines", the first stdout line): a tool's second row and its only toggle.
1996
+ Text stays selectable — a drag copies, a plain click toggles. */
1997
+ .tool-digest {
1998
+ padding: 0 0 .15rem .6rem;
1999
+ color: var(--muted);
2000
+ white-space: nowrap;
2001
+ overflow: hidden;
2002
+ text-overflow: ellipsis;
2003
+ cursor: pointer;
2004
+ user-select: text;
2005
+ }
2006
+ .tool-digest:hover { color: var(--fg); }
2007
+ /* Expanded = nothing readable hidden: the digest wraps to its full text (it may be the whole
2008
+ result — suppressed patch one-liners rely on this). */
2009
+ .tool-digest.open { white-space: normal; }
2010
+
2011
+ /* Expanded input/output card beneath the digest row — additive only, the rows above keep their
2012
+ collapsed look. First child sheds its top divider (nothing above it inside the card). */
2013
+ .tool-card {
2014
+ margin: .15rem 0 .55rem .6rem;
2015
+ background: var(--veil);
2016
+ border: 1px solid var(--border);
2017
+ border-radius: 8px;
2018
+ overflow: hidden;
2019
+ }
2020
+ .tool-card > :first-child, .tool-card > .diff:first-child > :first-child { border-top: none; }
2021
+
2022
+ /* TodoWrite checklist (CC's treatment: struck-through done, bold in-progress, plain pending). */
2023
+ .todo-list { padding: .45rem .7rem; display: grid; gap: .25rem; font-size: .85rem; }
2024
+ .todo { display: flex; gap: .5rem; align-items: baseline; }
2025
+ .todo-icon { flex: none; }
2026
+ .todo.completed .todo-icon { color: var(--diff-add); }
2027
+ .todo.completed .todo-text { text-decoration: line-through; color: var(--muted); }
2028
+ .todo.in_progress .todo-text { font-weight: 600; }
2029
+ .todo.in_progress .todo-icon { color: var(--accent); }
2030
+
2031
+ /* Code-display panels inside an open tool card: raw input/output (.tool-in,
2032
+ .tool-out) and per-hunk diff halves (.diff-old, .diff-new). All four share
2033
+ the same monospace block geometry — padding, max-height with scroll,
2034
+ pre-wrap with word-break, top-divider — so consolidating here means edits
2035
+ to "how a code panel looks" land in one place. Color treatment is the only
2036
+ per-variant difference. */
2037
+ .tool-in, .tool-out, .diff-old, .diff-new {
2038
+ margin: 0;
2039
+ padding: .55rem .7rem;
2040
+ border-top: 1px solid var(--border);
2041
+ white-space: pre-wrap;
2042
+ word-break: break-word;
2043
+ font-family: ui-monospace, Menlo, monospace;
2044
+ font-size: .8rem;
2045
+ max-height: 320px;
2046
+ overflow: auto;
2047
+ }
2048
+ .tool-out { color: var(--muted); }
2049
+ /* Structured input fields inside an open tool card (one row per input key), replacing the old raw
2050
+ JSON dump. The key is a muted inline label; the value keeps the shared mono geometry, flowing on
2051
+ the same line and wrapping/multi-lining as pre-wrap dictates. */
2052
+ .tool-field + .tool-field { margin-top: .5rem; }
2053
+ .tool-key { color: var(--muted); margin-right: .6rem; user-select: text; }
2054
+ .tool-val { display: inline; margin: 0; font: inherit; white-space: pre-wrap; word-break: break-word; }
2055
+ /* Line-banded unified-diff view (± lines only): the same add/remove band colors as
2056
+ .diff-new/.diff-old; a muted ⋯ row separates hunks. The ± marker is ::before content in a
2057
+ reserved gutter — visible (the non-color cue colorblind readers need) but outside the DOM
2058
+ text, so selection-copy grabs only the code. min-height keeps blank changed lines from
2059
+ collapsing now that they carry no marker char. */
2060
+ .udiff { white-space: pre-wrap; word-break: break-word; }
2061
+ .udiff-line { position: relative; padding: 0 .2rem 0 1.05rem; min-height: 1lh; }
2062
+ .udiff-line::before { position: absolute; left: .25rem; }
2063
+ .udiff-add::before { content: '+'; }
2064
+ .udiff-del::before { content: '-'; }
2065
+ .udiff-add { background: color-mix(in srgb, var(--diff-add) 24%, transparent); }
2066
+ .udiff-del { background: color-mix(in srgb, var(--err) 24%, transparent); }
2067
+ .udiff-gap { color: var(--muted); user-select: none; }
2068
+ /* The band carries the add/remove signal; text stays --fg so it's readable. Colouring the text the same
2069
+ hue as its fill (red-on-red) is what washes a diff out — the band alone is the convention. */
2070
+ .diff-old {
2071
+ background: color-mix(in srgb, var(--err) 24%, transparent);
2072
+ color: var(--fg);
2073
+ }
2074
+ .diff-new {
2075
+ background: color-mix(in srgb, var(--diff-add) 24%, transparent);
2076
+ color: var(--fg);
2077
+ }
2078
+
2079
+ /* Path link: plain accent, underline only on hover. skip-ink:none so the underline stays solid
2080
+ under a path's descenders and slashes; the cursor is explicit because the anchor carries no href
2081
+ (the click is a server RPC, not navigation) and so defaults to the surrounding prose's caret. */
2082
+ .tool-sum.link {
2083
+ color: var(--accent);
2084
+ text-decoration: none;
2085
+ cursor: pointer;
2086
+ }
2087
+ .tool-sum.link:hover { text-decoration: underline; text-decoration-skip-ink: none; }
2088
+
2089
+ .diff { display: flex; flex-direction: column; }
2090
+ .diff-step {
2091
+ padding: .35rem .7rem;
2092
+ border-top: 1px solid var(--border);
2093
+ color: var(--muted);
2094
+ font-size: .72rem;
2095
+ text-transform: uppercase;
2096
+ letter-spacing: .06em;
2097
+ }
2098
+
2099
+ .thinking { margin-bottom: .4rem; font-size: .82rem; color: var(--muted); }
2100
+ /* The <summary> toggle defaults to a text cursor — give it the pointer every other expandable uses. */
2101
+ .thinking summary { cursor: pointer; }
2102
+ .thinking pre { white-space: pre-wrap; }
2103
+ /* Streaming (draft) thinking: capped + scrollable at the bulb-streaming card's height — ambient
2104
+ progress, not reading material — stuck to the live edge unless the user scrolls up
2105
+ (#draftThinkingPre). Durable rows stay uncapped: finished thinking IS reading material. */
2106
+ .draft .thinking pre { max-height: 220px; overflow-y: auto; }
2107
+
2108
+ /* The bubble's hover-revealed copy-pill row. The direct-child reveal avoids firing an inline bulb's
2109
+ own controls when the reader merely hovers the outer assistant bubble. */
2110
+ .bubble-controls {
2111
+ position: absolute;
2112
+ bottom: .5rem;
2113
+ right: var(--pill-inset, 0px);
2114
+ display: flex;
2115
+ gap: .4rem;
2116
+ }
2117
+ .bubble-controls .overlay-pill { position: static; }
2118
+ .bubble:hover > .bubble-controls .overlay-pill,
2119
+ .bubble-controls .overlay-pill:focus-visible { opacity: 1; }
2120
+ /* "copied" flash: eases into accent on click (CopyButton is a Component, so its
2121
+ node persists across re-renders and the transition actually runs). The color
2122
+ transition lives only on .done, not the base pill, so hover stays instant — the
2123
+ trade is the revert snaps back rather than easing out. */
2124
+ .copy.done {
2125
+ opacity: 1;
2126
+ color: var(--accent);
2127
+ border-color: var(--accent);
2128
+ transition: opacity .2s ease, color .5s ease, border-color .5s ease;
2129
+ }
2130
+ /* Assistant bubbles carry no padding, so the shared .5rem bottom would float the
2131
+ pill above the last line — pin it to the text's bottom edge. */
2132
+ .bubble.assistant > .bubble-controls { bottom: 0; }
2133
+
2134
+ /* A turn's local representation selector (turnView.ts). A standard tab bar: a continuous bottom
2135
+ line under all tabs, the selected tab's thicker line indicating the active view. Raw is the full
2136
+ chronological trace for one turn, Reply is its exact prose, and Summary is the generated
2137
+ compression. Normal flow above the selected body means every switch resizes only below itself. */
2138
+ .turn-views-row {
2139
+ display: flex;
2140
+ align-items: baseline;
2141
+ gap: 0;
2142
+ width: fit-content;
2143
+ margin: .7rem auto;
2144
+ font-size: .8rem;
2145
+ line-height: 1.3;
2146
+ border-bottom: 1px solid var(--border);
2147
+ }
2148
+ .turn-view {
2149
+ appearance: none;
2150
+ margin: 0 0 -1px 0;
2151
+ padding: 0 .5rem .1rem;
2152
+ border: 0;
2153
+ border-bottom: 2px solid transparent;
2154
+ background: none;
2155
+ color: var(--muted);
2156
+ font: inherit;
2157
+ cursor: pointer;
2158
+ text-align: center;
2159
+ }
2160
+ .turn-view:hover, .turn-view.on { color: var(--accent); }
2161
+ .turn-view.on { border-bottom-color: var(--accent); }
2162
+ .turn-view.err { color: var(--err); }
2140
2163
  .turn-view:focus-visible { outline: 1px solid var(--accent); outline-offset: 2px; border-radius: 2px; }