typebulb 0.10.6 → 0.11.0

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.
@@ -0,0 +1,928 @@
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
+ --bg: rgb(255, 255, 255);
5
+ --fg: rgb(28, 28, 30);
6
+ --muted: rgb(96, 98, 106);
7
+ --panel: rgb(248, 249, 251);
8
+ --border: rgb(224, 224, 228);
9
+ --accent: rgb(58, 125, 232);
10
+ --user-bg: rgb(243, 243, 245);
11
+ --tool-bg: rgb(245, 246, 248);
12
+ --err: rgb(206, 60, 60);
13
+ --diff-add: rgb(40, 140, 70);
14
+ /* Prose measure for text (bubbles + notes) — kept to a readable line length.
15
+ Diagrams break out wider than this; see .md .mermaid. */
16
+ --content-max: 800px;
17
+ color-scheme: light;
18
+ }
19
+
20
+ html[data-theme="dark"] {
21
+ --bg: rgb(20, 20, 22);
22
+ --fg: rgb(232, 232, 236);
23
+ --muted: rgb(150, 152, 160);
24
+ --panel: rgb(30, 30, 32);
25
+ --border: rgb(54, 54, 60);
26
+ --accent: rgb(122, 162, 250);
27
+ --user-bg: rgb(40, 40, 44);
28
+ --tool-bg: rgb(30, 31, 36);
29
+ --err: rgb(240, 120, 120);
30
+ --diff-add: rgb(120, 210, 140);
31
+ color-scheme: dark;
32
+ }
33
+
34
+ /* Aliases for beautiful-mermaid, indirecting around a var-name collision with the
35
+ bulb's theme (see the renderMermaidSVG call). They still resolve to the live
36
+ theme at the use site. */
37
+ :root {
38
+ --mm-bg: var(--bg);
39
+ --mm-fg: var(--fg);
40
+ --mm-line: var(--muted);
41
+ --mm-accent: var(--accent);
42
+ --mm-muted: var(--muted);
43
+ --mm-surface: var(--tool-bg);
44
+ --mm-border: var(--border);
45
+ }
46
+
47
+ * { box-sizing: border-box; }
48
+
49
+ body {
50
+ margin: 0;
51
+ height: 100vh;
52
+ overflow: hidden;
53
+ color: var(--fg);
54
+ background: var(--bg);
55
+ font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
56
+ font-size: 15px;
57
+ line-height: 1.55;
58
+ }
59
+
60
+ /* position:relative anchors the overlaid (absolute) statusbar to the viewport box. */
61
+ .app { position: relative; display: flex; flex-direction: column; height: 100vh; }
62
+
63
+ /* Bottom status strip, overlaid (not in flow) on the chat — which runs
64
+ full-height to the very bottom. The strip itself has NO fill, so the message
65
+ text shows through everywhere, right to the bottom edge. Only the items (the
66
+ pills) are opaque, so they overlay the text with visual precedence rather than
67
+ the strip cutting it off with a band. No border — the typebulb host's rounded
68
+ card is the only frame. */
69
+ .statusbar {
70
+ position: absolute;
71
+ left: 0;
72
+ right: 0;
73
+ bottom: 0;
74
+ display: flex;
75
+ align-items: center;
76
+ gap: .65rem;
77
+ /* .4rem inset on top/left/bottom; the right is larger to clear the scrollbar
78
+ the statusbar overlays — so the *visible* gap to the content edge matches the
79
+ other sides instead of the pills sitting flush against the scrollbar. */
80
+ padding: .4rem 1.35rem .4rem .4rem;
81
+ font-size: .8rem;
82
+ /* Drop the inherited 1.55 line-height so the bar's own strut doesn't add
83
+ phantom ascender/descender room above the pills. Everything in the bar
84
+ is a fixed-height pill or a shimmer text line — both look right at 1. */
85
+ line-height: 1;
86
+ min-width: 0;
87
+ }
88
+ .statusbar-actions {
89
+ margin-left: auto;
90
+ display: flex;
91
+ gap: .4rem;
92
+ align-items: center;
93
+ }
94
+ /* Chip shape shared by the status-bar pills (interactive: session chip, token
95
+ counter) and the working indicator (passive). One rule = one source of vertical
96
+ alignment, no element-type drift between siblings; inline-flex + line-height:1
97
+ + fixed height centers the glyph run by the box, not by the inherited line-box.
98
+ Opaque so they read cleanly over the message text the transparent strip lets
99
+ through. */
100
+ .pill, .working {
101
+ display: inline-flex;
102
+ align-items: center;
103
+ justify-content: center;
104
+ line-height: 1;
105
+ height: 1.7rem;
106
+ padding: 0 .7rem;
107
+ border-radius: 7px;
108
+ background: var(--panel);
109
+ border: 1px solid var(--border);
110
+ color: var(--muted);
111
+ font-size: .8rem;
112
+ white-space: nowrap;
113
+ user-select: none;
114
+ }
115
+ /* Pills are <button>s: reset UA chrome (font/appearance/margin) and add the
116
+ interactive affordance. Slightly larger text than the passive indicator. */
117
+ .pill {
118
+ font: inherit;
119
+ font-size: .85rem;
120
+ appearance: none;
121
+ margin: 0;
122
+ cursor: pointer;
123
+ }
124
+ .pill:hover { border-color: var(--accent); color: var(--accent); }
125
+
126
+ /* The two status-bar chips each anchor an upward popover (token breakdown /
127
+ session list). Wrap = positioning context; the popover anchors bottom/right and
128
+ grows up-and-left — the chips sit in the right-aligned cluster, so left:0 would
129
+ run them off the viewport's right edge. */
130
+ .token-wrap, .sid-wrap, .servers-wrap { position: relative; display: inline-block; }
131
+ /* Each pill renders its wrap unconditionally, emitting an empty div when it has
132
+ nothing to show (no tokens / no session / nothing running). An empty wrap is
133
+ still a flex child, so .statusbar-actions' gap lands on both its sides —
134
+ doubling the visible gap between its neighbours. Collapse the empties so the
135
+ cluster spaces evenly regardless of which pills are present. */
136
+ .token-wrap:empty, .sid-wrap:empty, .servers-wrap:empty { display: none; }
137
+ .token-pop, .picker, .servers-pop {
138
+ position: absolute;
139
+ bottom: calc(100% + .35rem);
140
+ right: 0;
141
+ z-index: 10;
142
+ background: var(--panel);
143
+ border: 1px solid var(--border);
144
+ border-radius: 10px;
145
+ box-shadow: 0 8px 30px rgba(0, 0, 0, .45);
146
+ }
147
+ .token-pop {
148
+ min-width: 360px;
149
+ max-width: 92vw;
150
+ padding: .35rem;
151
+ font-size: .85rem;
152
+ }
153
+ /* Each row: left text block, number pushed to the right edge. Sizing/rhythm matches .picker-row
154
+ (font .85rem, row padding .45rem .55rem) so the three status-bar popovers read as one family. */
155
+ .token-line {
156
+ display: flex;
157
+ justify-content: space-between;
158
+ align-items: baseline;
159
+ gap: .6rem;
160
+ padding: .45rem .55rem;
161
+ }
162
+ .token-line-title { font-weight: 600; color: var(--fg); margin-right: .5rem; }
163
+ .token-line-lbl { color: var(--muted); font-size: .72rem; }
164
+ .token-line-num { color: var(--fg); font-variant-numeric: tabular-nums; white-space: nowrap; }
165
+
166
+ /* "working…" indicator: CC is mid-turn. Passive — driven by the live-chain leaf,
167
+ not a live token stream (the JSONL is flushed in batches, so this can sit lit
168
+ through a multi-minute buffering window). Shares the chip shape above; only adds
169
+ room for the pulsing dot. Non-interactive — no cursor / hover accent. */
170
+ .working { gap: .45rem; }
171
+ .working-dot {
172
+ width: 7px;
173
+ height: 7px;
174
+ border-radius: 50%;
175
+ background: var(--accent);
176
+ animation: working-pulse 1.4s ease-in-out infinite;
177
+ }
178
+ @keyframes working-pulse { 0%, 100% { opacity: .25; } 50% { opacity: 1; } }
179
+ @media (prefers-reduced-motion: reduce) { .working-dot { animation: none; opacity: .8; } }
180
+
181
+ /* Session list — shares the popover chrome (.token-pop, .picker above); these are
182
+ just its own dimensions. */
183
+ .picker {
184
+ width: min(440px, calc(100vw - 2rem));
185
+ max-height: 60vh;
186
+ overflow: hidden;
187
+ padding: .35rem;
188
+ display: flex;
189
+ flex-direction: column;
190
+ gap: .15rem;
191
+ }
192
+ /* The filter (.bulb-filter-control) pins at the top; the row list scrolls inside the capped box —
193
+ same shape as the launcher's .servers-pop / .bulb-list. */
194
+ .picker-list { display: flex; flex-direction: column; gap: .15rem; flex: 1; min-height: 0; overflow-y: auto; padding-right: 4px; }
195
+ .picker-empty { padding: .8rem; color: var(--muted); font-size: .85rem; text-align: center; }
196
+ .picker-row {
197
+ display: flex;
198
+ gap: .6rem;
199
+ align-items: baseline;
200
+ padding: .45rem .55rem;
201
+ border-radius: 6px;
202
+ cursor: pointer;
203
+ border: 1px solid transparent;
204
+ font-size: .85rem;
205
+ }
206
+ .picker:focus { outline: none; } /* the list owns its own highlight; no focus ring */
207
+ .picker-row:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); }
208
+ /* Keyboard cursor (arrow keys / hover) — matches the bulb picker's .active row. */
209
+ .picker-row.active {
210
+ background: color-mix(in srgb, var(--accent) 18%, transparent);
211
+ border-color: color-mix(in srgb, var(--accent) 40%, transparent);
212
+ }
213
+ /* The attached session — a leading accent dot in its own gutter slot, reserved (transparent) on
214
+ every row so titles align whether or not it's shown, and filled only on the attached row so it
215
+ reads as "you are here" even when the cursor has moved off. */
216
+ .picker-dot {
217
+ flex: none;
218
+ align-self: center;
219
+ width: 7px;
220
+ height: 7px;
221
+ border-radius: 50%;
222
+ background: transparent;
223
+ }
224
+ .picker-row.current .picker-dot { background: var(--accent); }
225
+ .picker-preview {
226
+ flex: 1; min-width: 0;
227
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
228
+ color: var(--fg);
229
+ }
230
+ .picker-time {
231
+ color: var(--muted);
232
+ font-size: .72rem;
233
+ min-width: 2.6rem; text-align: right;
234
+ }
235
+
236
+ /* Running-breakouts list — shares the popover chrome (.servers-pop above); its own dimensions +
237
+ per-row layout: the play/stop button + name read together on the left (▶/■ Title, name flex:1),
238
+ and the metadata/controls form a right-aligned cluster of fixed-width columns (logs · trust ·
239
+ time/port) that line up row-to-row — right-anchored, so the rightmost (time/port) always aligns and
240
+ the rest stack inward (logs and time/port share a width; trust is wider, to fit "restricted"). The
241
+ box is a FIXED size shared with the drilled-in console (.log-mode below): flipping list⇄console
242
+ swaps only the contents, not the frame, so it doesn't jump. The filter pins to the top and the list
243
+ scrolls inside (see .bulb-list). */
244
+ .servers-pop {
245
+ width: min(560px, calc(100vw - 2rem));
246
+ height: min(66vh, 560px);
247
+ overflow: hidden;
248
+ padding: .35rem;
249
+ display: flex;
250
+ flex-direction: column;
251
+ gap: .15rem;
252
+ }
253
+ .server-row {
254
+ /* Subgrid: all rows share ONE set of column tracks (defined on .bulb-list). A per-row grid sizes
255
+ its columns independently — which is exactly what broke alignment (a stopped row with no
256
+ logs/port sized its tracks differently and its trust toggle drifted right). Spanning the parent's
257
+ tracks instead makes trust/logs/time line up row-to-row, while the parent's auto columns size to
258
+ the widest content so the cluster stays tight (no reserved min-width slack). Absent trust/logs
259
+ render an empty cell that still holds its column. */
260
+ display: grid;
261
+ grid-template-columns: subgrid;
262
+ grid-column: 1 / -1;
263
+ align-items: center;
264
+ /* Inset rounded highlight, matching the session picker. The background is the subgrid track area
265
+ (not bled out to the edge — that ran it under the scrollbar on the right while staying clear on
266
+ the left, an asymmetry). Text gets its breathing room inside the highlight via end-item margins
267
+ (.server-stop/.bulb-launch on the left, .server-port/.bulb-time on the right) rather than row
268
+ padding, which would shift the subgrid tracks and break alignment. */
269
+ padding: .15rem 0;
270
+ border-radius: 6px;
271
+ font-size: .85rem;
272
+ }
273
+ .server-row:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); }
274
+ /* Keyboard-highlighted row (arrow-key cursor) — a touch stronger than hover so it stands out. */
275
+ .server-row.active { background: color-mix(in srgb, var(--accent) 16%, transparent); }
276
+ .server-name {
277
+ flex: 1; min-width: 0;
278
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
279
+ color: var(--accent); text-decoration: none; cursor: pointer;
280
+ }
281
+ .server-name:hover { text-decoration: underline; }
282
+ /* Port is a link to the running server (localhost:<port>), same target as the name. */
283
+ .server-port { color: var(--muted); font-size: .76rem; font-variant-numeric: tabular-nums; text-decoration: none; text-align: right; margin-right: .4rem; }
284
+ a.server-port:hover { color: var(--accent); text-decoration: underline; }
285
+ /* Logs reads like a link (flips to this server's console), not a button. */
286
+ .server-logs { color: var(--muted); font-size: .76rem; text-decoration: none; cursor: pointer; margin-left: .5rem; }
287
+ .server-logs:hover, .server-logs.on { color: var(--accent); text-decoration: underline; }
288
+ /* Launcher filter box — pinned above the list; type to narrow (matches name + path). The control
289
+ wrapper pins to the top and anchors the clear (×) at the far right (position:relative + the
290
+ input's right padding reserves its room). */
291
+ .bulb-filter-control { position: relative; flex: none; margin-bottom: .35rem; }
292
+ .bulb-filter {
293
+ font: inherit;
294
+ font-size: .82rem;
295
+ width: 100%;
296
+ box-sizing: border-box;
297
+ padding: .35rem 1.7rem .35rem .5rem;
298
+ border-radius: 6px;
299
+ background: var(--bg);
300
+ border: 1px solid var(--border);
301
+ color: var(--fg);
302
+ }
303
+ .bulb-filter::placeholder { color: var(--muted); }
304
+ .bulb-filter:focus { outline: none; border-color: var(--accent); }
305
+ /* Clear (×) — only rendered when the filter is non-empty; wipes it and refocuses the box. */
306
+ .bulb-filter-clear {
307
+ position: absolute; right: .3rem; top: 50%; transform: translateY(-50%);
308
+ appearance: none; border: none; background: transparent; cursor: pointer;
309
+ color: var(--muted); font-size: 1.05rem; line-height: 1; padding: 0 .3rem; border-radius: 4px;
310
+ }
311
+ .bulb-filter-clear:hover { color: var(--accent); }
312
+ /* Scrolls within the fixed-height popover so the filter stays put and the box never grows. */
313
+ /* The shared grid for the launcher rows: each .server-row is a subgrid spanning these tracks, so
314
+ columns line up row-to-row. auto cols size to their widest content (tight, no slack); 1fr is the
315
+ name. A small right padding keeps the selected-row highlight off the scrollbar. */
316
+ .bulb-list { display: grid; grid-template-columns: auto 1fr auto auto auto; column-gap: .6rem; row-gap: .05rem; align-items: center; align-content: start; padding-right: 4px; flex: 1; min-height: 0; overflow-y: auto; }
317
+ /* A stopped bulb's name isn't a link (no running URL), so it stays plain foreground. */
318
+ .server-name.stopped { color: var(--fg); }
319
+ .bulb-time { color: var(--muted); font-size: .72rem; font-variant-numeric: tabular-nums; text-align: right; margin-right: .4rem; }
320
+ /* The row's chrome buttons share one reset; sizing + colour differ below. Play/stop are round icon
321
+ buttons (friendlier, space-economic); the trust switch is text. Logs is a link, not a button
322
+ (see .server-logs above), so it's not in this group. */
323
+ .server-stop, .bulb-launch, .trust-toggle {
324
+ font: inherit; appearance: none; margin: 0; cursor: pointer; box-sizing: border-box;
325
+ background: transparent; border: 1px solid var(--border); border-radius: 5px;
326
+ height: 1.7rem; display: inline-flex; align-items: center; justify-content: center;
327
+ }
328
+ .trust-toggle { font-size: .76rem; padding: 0 .5rem; color: var(--muted); justify-self: center; }
329
+ .trust-toggle:hover, .trust-toggle.on { color: var(--accent); border-color: var(--accent); }
330
+ .server-stop, .bulb-launch { width: 1.7rem; padding: 0; border-radius: 50%; margin-left: .4rem; }
331
+ .btn-icon { display: block; }
332
+ /* A stopped bulb's play button is dormant — the title is the primary launch target — so it's
333
+ hidden until the row is hovered or keyboard-selected. visibility (not display) reserves its slot,
334
+ so the name column doesn't shift when it appears. */
335
+ .bulb-launch { color: var(--accent); visibility: hidden; }
336
+ .server-row:hover .bulb-launch, .server-row.active .bulb-launch { visibility: visible; }
337
+ .bulb-launch:hover { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); }
338
+ /* Launching: stays visible (hover-independent) and shimmers until the row flips to a stop button,
339
+ so the ~2s wait reads as "working" rather than a dead click. Transient — driven by the in-memory
340
+ launching set, never persisted. */
341
+ .bulb-launch.launching {
342
+ visibility: visible; border-color: var(--accent);
343
+ background: linear-gradient(100deg, transparent 30%, color-mix(in srgb, var(--accent) 35%, transparent) 50%, transparent 70%);
344
+ background-size: 250% 100%;
345
+ animation: bulb-shimmer 1.1s linear infinite;
346
+ }
347
+ @keyframes bulb-shimmer { from { background-position: 250% 0; } to { background-position: -250% 0; } }
348
+ @media (prefers-reduced-motion: reduce) {
349
+ .bulb-launch.launching { animation: none; background: color-mix(in srgb, var(--accent) 18%, transparent); }
350
+ }
351
+ .server-stop { color: var(--muted); }
352
+ .server-stop:hover { color: var(--err); border-color: var(--err); }
353
+ /* Elevation prompt (VS-Code-Workspace-Trust style) — a modal over the whole view, since a
354
+ denied capability is worth interrupting for. Backdrop dims; the card holds the choice. */
355
+ .trust-back {
356
+ position: fixed; inset: 0; z-index: 50;
357
+ background: rgba(0, 0, 0, .5);
358
+ display: flex; align-items: center; justify-content: center;
359
+ padding: 1rem;
360
+ }
361
+ .trust-modal {
362
+ width: min(420px, 100%);
363
+ background: var(--panel); border: 1px solid var(--border); border-radius: 12px;
364
+ box-shadow: 0 12px 48px rgba(0, 0, 0, .5);
365
+ padding: 1rem 1.1rem; display: flex; flex-direction: column; gap: .6rem;
366
+ }
367
+ .trust-modal-h { font-size: 1rem; font-weight: 600; color: var(--fg); }
368
+ .trust-modal-b { font-size: .85rem; color: var(--fg); line-height: 1.45; }
369
+ .trust-modal-warn { font-size: .78rem; color: var(--muted); line-height: 1.4; }
370
+ .trust-modal-acts { display: flex; justify-content: flex-end; gap: .5rem; margin-top: .2rem; }
371
+ .trust-no, .trust-yes {
372
+ font: inherit; font-size: .82rem; appearance: none; margin: 0; cursor: pointer;
373
+ padding: .35rem .8rem; border-radius: 7px; border: 1px solid var(--border); background: transparent; color: var(--fg);
374
+ }
375
+ .trust-no:hover { border-color: var(--fg); }
376
+ .trust-yes {
377
+ color: var(--accent);
378
+ border-color: color-mix(in srgb, var(--accent) 55%, transparent);
379
+ background: color-mix(in srgb, var(--accent) 14%, transparent);
380
+ }
381
+ .trust-yes:hover { background: color-mix(in srgb, var(--accent) 22%, transparent); }
382
+ /* Drilled-in streaming console for one running server. It takes over the whole bulbs
383
+ popover (the list flips to it; ← back flips home), so it's wider than the list and its
384
+ body fills the popover's height. overflow:hidden lets the rounded popover border clip the
385
+ square head/body; the body scrolls internally. */
386
+ .servers-pop.log-mode {
387
+ padding: 0; /* same fixed width/height as the list (.servers-pop); only the chrome differs */
388
+ }
389
+ .servers-pop.log-mode:focus { outline: none; }
390
+ .bulb-log-head {
391
+ display: flex; align-items: center; justify-content: flex-end; gap: .5rem;
392
+ padding: .3rem .5rem; background: var(--panel);
393
+ border-bottom: 1px solid var(--border);
394
+ }
395
+ .bulb-log-back {
396
+ font: inherit; font-size: .76rem; appearance: none; margin: 0;
397
+ padding: .15rem .5rem; border-radius: 5px;
398
+ background: transparent; border: 1px solid var(--border);
399
+ color: var(--muted); cursor: pointer; white-space: nowrap;
400
+ }
401
+ .bulb-log-back:hover { color: var(--accent); border-color: var(--accent); }
402
+ .bulb-log-name { min-width: 0; color: var(--muted); font-size: .72rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
403
+ .bulb-log-body {
404
+ flex: 1; min-height: 0;
405
+ margin: 0; padding: .5rem .6rem;
406
+ overflow: auto; /* top-anchored normal flow — output reads top→down */
407
+ background: var(--bg);
408
+ font: .72rem/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
409
+ color: var(--fg);
410
+ }
411
+ .bulb-log-text { white-space: pre-wrap; word-break: break-word; }
412
+ /* messages */
413
+ .messages {
414
+ flex: 1;
415
+ overflow-y: auto;
416
+ /* Content runs under the overlaid statusbar rather than stopping above it.
417
+ Gutters are symmetric: the turn stripe is absolutely positioned, so it claims no layout
418
+ width and the left gutter needs no extra room for it (see Invariants). The bottom is
419
+ larger to clear the overlaid statusbar pills (~2.1rem:
420
+ .4rem inset + 1.7rem pill) so the last line isn't hidden behind them. */
421
+ padding: 1.25rem 1.75rem 2.5rem 1.75rem;
422
+ display: flex;
423
+ flex-direction: column;
424
+ gap: 1rem;
425
+ }
426
+ .note {
427
+ display: flex;
428
+ align-items: baseline;
429
+ gap: .5rem;
430
+ color: var(--muted);
431
+ font-size: .9rem;
432
+ max-width: var(--content-max);
433
+ width: 100%;
434
+ margin: 0 auto;
435
+ }
436
+ .bubble {
437
+ position: relative;
438
+ max-width: var(--content-max);
439
+ width: 100%;
440
+ margin: 0 auto;
441
+ }
442
+ .bubble.user {
443
+ background: var(--user-bg);
444
+ border: 1px solid var(--border);
445
+ border-radius: 12px;
446
+ padding: .7rem .9rem;
447
+ }
448
+ /* Consecutive tool-only bubbles sit tight via negative margin (flex `gap`
449
+ can't be overridden per sibling pair). The adjacent-sibling selector leaves
450
+ the first tools-only bubble's gap intact, preserving the turn boundary. */
451
+ .bubble.tools-only + .bubble.tools-only { margin-top: -.9rem; }
452
+
453
+ /* Turn stripe — a 2px colored bar to the left of each bubble. All bubbles in
454
+ one user→assistant turn share a color; the palette cycles through 5 so
455
+ adjacent turns are visually distinct when scrolling. Sits outside the
456
+ bubble's left edge in the messages container's padding gutter.
457
+ By default top/bottom extend half a gap beyond the bubble so same-turn
458
+ neighbours' stripes meet seamlessly across the 1rem flex gap. The two
459
+ turn-boundary overrides below clip those extensions so a turn's stripe
460
+ starts at the top of its user bubble and ends at the bottom of its last
461
+ bubble — leaving the full 2rem inter-turn gap stripe-free as a separator.
462
+ --border-w compensates the 1px padding-edge offset that user bubbles' border
463
+ would otherwise introduce — without it, stripes alternate 1px inset between
464
+ user/assistant rows. */
465
+ .bubble { --border-w: 0px; }
466
+ .bubble.user { --border-w: 1px; }
467
+ .bubble::before {
468
+ content: '';
469
+ position: absolute;
470
+ /* Offsets subtract --border-w: absolute positioning anchors to the
471
+ padding-edge (inside the border), so a user bubble's 1px border would
472
+ otherwise shave 1px off each stripe edge. */
473
+ /* Stripe hugs the column (1rem into the left gutter); absolute, so it consumes no layout width —
474
+ which is why the gutters can be symmetric (no asymmetric padding needed to "make room"), and
475
+ thus why breakouts need no nudge. - var(--border-w): a user bubble's 1px border pushes the
476
+ padding edge (the abs-pos anchor) 1px right, so without it that stripe sits 1px right of the
477
+ assistant bubbles' — compensate. */
478
+ left: calc(-1rem - var(--border-w));
479
+ top: calc(-.5rem - var(--border-w));
480
+ bottom: calc(-.5rem - var(--border-w));
481
+ width: 1px;
482
+ background: var(--turn-color, transparent);
483
+ }
484
+ /* Clip stripe at turn boundaries. A user bubble always starts a turn → no
485
+ upward extension. A bubble followed by a user bubble is the last of its
486
+ turn → no downward extension. Same for the very last bubble in the list
487
+ (an in-flight turn whose last assistant message is the end of the stream).
488
+ Same `0 - var(--border-w)` form as the default top/bottom so user bubbles'
489
+ 1px border still aligns with the stripe edge. */
490
+ .bubble.user::before { top: calc(0px - var(--border-w)); }
491
+ .bubble:has(+ .bubble.user)::before,
492
+ .bubble:last-child::before { bottom: calc(0px - var(--border-w)); }
493
+ /* Extra gap before each new turn (user bubble preceded by anything). Combined
494
+ with the container's 1rem gap → ~2rem visible between turns vs ~1rem within
495
+ a turn. Doesn't fire on the very first bubble (no preceding sibling). */
496
+ .bubble + .bubble.user { margin-top: 1rem; }
497
+ .bubble.turn-0 { --turn-color: #4cb35a; } /* green */
498
+ .bubble.turn-1 { --turn-color: #e89a3c; } /* amber */
499
+ .bubble.turn-2 { --turn-color: #d36b9a; } /* pink */
500
+ .bubble.turn-3 { --turn-color: #9970d6; } /* purple */
501
+ .bubble.turn-4 { --turn-color: #3cb8b3; } /* teal */
502
+
503
+ /* rendered markdown */
504
+ .md { word-break: break-word; }
505
+ /* Markdown links (often many file:line citations per message) — restrained like
506
+ the rest of the bulb's links: accent, no persistent underline, reveal it only
507
+ on hover. Without this they'd carry the browser-default underline-on-every-one. */
508
+ .md a { color: var(--accent); text-decoration: none; }
509
+ .md a:hover { text-decoration: underline; }
510
+ .md :first-child { margin-top: 0; }
511
+ .md :last-child { margin-bottom: 0; }
512
+ .md p { margin: .5rem 0; }
513
+ /* Thematic break — also the divider between merged consecutive user sends (see
514
+ applyUser); faint so the bubble still reads as one turn. */
515
+ .md hr {
516
+ border: none;
517
+ border-top: 1px solid var(--border);
518
+ margin: .55rem 0;
519
+ opacity: .5;
520
+ }
521
+ .md pre {
522
+ background: var(--tool-bg);
523
+ border: 1px solid var(--border);
524
+ border-radius: 8px;
525
+ padding: .7rem .85rem;
526
+ overflow-x: auto;
527
+ }
528
+ /* Blockquote: left bar + faint tint + muted text — a quiet "quoted" callout. The bar
529
+ is mixed toward --border so it stays distinct from the bright turn stripe in the
530
+ gutter, and the tint is light enough not to read as a code/output panel. */
531
+ .md blockquote {
532
+ margin: .5rem 0;
533
+ padding: .35rem .85rem;
534
+ border-left: 3px solid color-mix(in srgb, var(--accent) 50%, var(--border));
535
+ background: color-mix(in srgb, var(--accent) 6%, transparent);
536
+ border-radius: 0 6px 6px 0;
537
+ color: var(--muted);
538
+ }
539
+ .md blockquote :first-child { margin-top: 0; }
540
+ .md blockquote :last-child { margin-bottom: 0; }
541
+ .md code {
542
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
543
+ font-size: .88em;
544
+ }
545
+ .md :not(pre) > code {
546
+ background: var(--tool-bg);
547
+ padding: .1em .35em;
548
+ border-radius: 5px;
549
+ }
550
+ .md table { border-collapse: collapse; }
551
+ .md th, .md td { border: 1px solid var(--border); padding: .3rem .55rem; }
552
+
553
+ /* The full-lane breakout geometry, defined ONCE and shared by the two breakouts (the mermaid
554
+ embed and a spread bulb). `100%` in --lane-ml resolves against each user's own containing block
555
+ (prose width) at point of use, so both centre on the column axis — which is the viewport centre,
556
+ since the gutters are symmetric. One home so the geometry can't drift between the two (the bug
557
+ that let an off-centre nudge survive in one rule after being removed from the other). */
558
+ .md { --lane-w: calc(100vw - 2rem); --lane-ml: calc((100% - var(--lane-w)) / 2); }
559
+
560
+ /* Full-lane breakout — the mermaid embed (whole element) and a SPREAD bulb's iframe step out of
561
+ the prose column to the transcript width so wide content gets room. Both use --lane-w/--lane-ml
562
+ and extend symmetrically from the column, which is viewport-centred (symmetric gutters), so
563
+ neither needs an off-centre nudge (see Invariants in Specs-Bulbs/Claude-Bulb/Claude-Bulb.md). A
564
+ bulb's *wrapper* never breaks out — only its inner surface does — so the wrapper and its controls
565
+ stay at prose-right. position + z-index:0 lift the box over the turn stripe it crosses; the
566
+ mermaid SVG is transparent so a bg backs it, the bulb iframe is opaque so it occludes the stripe. */
567
+ .md .mermaid {
568
+ width: var(--lane-w);
569
+ margin: .6rem 0;
570
+ margin-left: var(--lane-ml);
571
+ margin-right: auto;
572
+ position: relative;
573
+ z-index: 0;
574
+ background: var(--bg);
575
+ }
576
+
577
+ /* Diagram specifics: scroll when wider than the lane; `safe center` centres one that fits and
578
+ left-aligns one that overflows, so a wide flowchart scrolls from its start node. The SVG is
579
+ transparent, so the turn stripe the breakout crosses shows through it — a bg box-shadow backs
580
+ the diagram to occlude it (and clears the h-scrollbar edge). The bulb embed is opaque (its
581
+ iframe fills the box), so it occludes the stripe itself and gets NO shadow — a shadow there
582
+ would paint its 1rem ring over the half-line of prose just above the embed (the gap is .6rem). */
583
+ .md .mermaid {
584
+ overflow-x: auto;
585
+ display: flex;
586
+ justify-content: safe center;
587
+ box-shadow: 0 0 0 1rem var(--bg);
588
+ }
589
+ /* flex-shrink:0: otherwise the SVG shrinks to the flex container and its viewBox
590
+ scales it down — no overflow, no scrollbar. Natural width is what scrolls. */
591
+ .md .mermaid svg { max-width: none; height: auto; flex: 0 0 auto; }
592
+
593
+ /* Raw ```svg``` embeds: no breakout — these are drawings (smiley, plot), not wide
594
+ diagrams, so keep them in the prose column, centred and capped at its width. They
595
+ share the `.embed` stripe-chop below (a drawing earns its own row like a bulb). */
596
+ .md .svg-embed { justify-content: center; }
597
+ .md .svg-embed svg { max-width: 100%; height: auto; }
598
+
599
+ /* ````bulb```` embeds: a sandboxed nested app. createBulbFrame owns the iframe (auto-height,
600
+ borderless). No frame of our own — like the mermaid/svg embeds, it sits in the flow rather
601
+ than in a box, so it reads as part of the transcript; the rounded clip just tidies the corners
602
+ of a bulb that paints its own background. `inline` (default) keeps it in the prose column and
603
+ caps its height (below); `spread` adds full-lane breakout + spacing from the shared rule above.
604
+ position:relative anchors the controls overlay in BOTH modes (spread re-declares it for the
605
+ z-index lift). .err is the compile-failure fallback; .bulb-err-strip is a runtime-error strip
606
+ under a live embed (both monospace, muted red). */
607
+ /* The stripe-chop, shared by every opaque in-column visual artifact (the inline bulb and a raw
608
+ `svg` — each opts in by carrying `.embed`). An opaque bg over position:relative + z-index:0
609
+ paints above .bubble::before; a -1rem left extension reaches the stripe in the gutter without a
610
+ full breakout (padding-left:1rem keeps the content in the prose column, and the box's right edge
611
+ stays at prose-right, so a bulb's controls anchored there don't move). The vertical gap is opaque
612
+ padding, not margin: it occludes the stripe top and bottom so the cut is flush with the prose (no
613
+ stub in a transparent gap), symmetric above/below to separate the artifact from surrounding text.
614
+ margin top/bottom are zeroed — and the abutting prose margins too (below) — so the gap butts the
615
+ text. Mermaid is NOT on this path: it's transparent and breaks out, occluding via box-shadow.
616
+ Per-artifact layout (flex direction, centring) lives on .bulb-embed / .svg-embed. */
617
+ .md .embed {
618
+ display: flex;
619
+ position: relative;
620
+ z-index: 0;
621
+ background: var(--bg);
622
+ margin: 0 0 0 -1rem;
623
+ padding: 1.1rem 0 1.1rem 1rem;
624
+ }
625
+ /* A bulb stacks its controls/frame/code vertically; in spread mode the inner iframe's own
626
+ breakout covers the stripe too (the wrapper still chops in column). */
627
+ .md .bulb-embed { flex-direction: column; }
628
+ /* Inline (default): cap the embed's height so a tall bulb doesn't run away down the transcript.
629
+ Past the cap the embed scrolls internally (its own overflow, set by the host↔embed protocol —
630
+ the iframe element can't scroll its srcdoc from out here). spread removes the cap. */
631
+ .md .bulb-embed.inline iframe { max-height: 80dvh; }
632
+ /* The frame host is a layout-less wrapper (domeleon owns the node; we parent the live iframe into
633
+ it once) — display:contents lifts the iframe to be a flex child of .bulb-embed, so the sizing and
634
+ spread-breakout rules target it directly, exactly as when it was appended as a direct child. The
635
+ code toggle hides it via .code-open rather than unmounting, so the bulb keeps its state. */
636
+ .bulb-frame { display: contents; }
637
+ .md .bulb-embed.code-open .bulb-frame { display: none; }
638
+ /* Spread: the inner surface (live iframe, or the code view behind the toggle) breaks out to the
639
+ lane while the wrapper stays in the prose column, so the controls hold at prose-right. Shares the
640
+ --lane-* geometry; centred on the column axis so toggling spread doesn't shift a column-fitting
641
+ bulb sideways. `width !important` beats createBulbFrame's inline width:100% on the iframe
642
+ (harmless on the code view, which has none); both opaque, so position/z-index:0 lift them over
643
+ the turn stripe they now cross. */
644
+ .md .bulb-embed.spread iframe,
645
+ .md .bulb-embed.spread > .bulb-code {
646
+ width: var(--lane-w) !important;
647
+ margin-left: var(--lane-ml);
648
+ margin-right: auto;
649
+ position: relative;
650
+ z-index: 0;
651
+ }
652
+ /* Kill the transparent margin between an embed and the prose it abuts (top and bottom):
653
+ the embed's own opaque padding is the gap, and a leftover prose margin would let the
654
+ turn stripe show through it as a stub past the last/next line of text. */
655
+ .md :has(+ .embed) { margin-bottom: 0; }
656
+ .md .embed + * { margin-top: 0; }
657
+ /* The rounded clip lives on the inner surfaces, not the embed (whose padding is now the
658
+ gap), so the bulb's own card — the iframe, or the code view behind the toggle — is
659
+ what carries the corners. */
660
+ .md .bulb-embed iframe,
661
+ .md .bulb-embed > .bulb-code {
662
+ border-radius: 8px;
663
+ }
664
+ /* Overlay pill: a small hover-revealed control floated at a container's
665
+ bottom-right, shared by the embed breakout button and the message copy button.
666
+ Muted at rest, accent on hover (instant, matching the status pills). The
667
+ container supplies the reveal; per-button bits layer on top. */
668
+ .overlay-pill {
669
+ position: absolute;
670
+ bottom: .5rem;
671
+ right: .5rem;
672
+ font: inherit;
673
+ font-size: .72rem;
674
+ background: var(--panel);
675
+ border: 1px solid var(--border);
676
+ border-radius: 6px;
677
+ color: var(--muted);
678
+ padding: .12rem .45rem;
679
+ cursor: pointer;
680
+ opacity: 0;
681
+ transition: opacity .2s ease;
682
+ }
683
+ .overlay-pill:hover { color: var(--accent); border-color: var(--accent); }
684
+
685
+ /* Per-fence copy pill (code / svg / mermaid) — one reveal rule for every copyable block, since the
686
+ fence rule stamps `.copyable` on each. The svg/mermaid containers are already position:relative for
687
+ the chop/breakout; the code-block wrapper needs it for the absolute pill. The pill is positioned
688
+ (absolute), so it paints over the static fence content regardless of DOM order. */
689
+ .md .copyable { position: relative; }
690
+ .md .copyable:hover .copy-src,
691
+ .md .copy-src:focus-visible { opacity: 1; }
692
+ .md .copy-src.done { color: var(--accent); border-color: var(--accent); }
693
+ /* The wrapper carries the code block's vertical rhythm (it, not the <pre>, is the `.md` flow child
694
+ now, so the first/last-child margin resets land on it); the <pre> sits flush inside. */
695
+ .md .code-block { margin: .6rem 0; }
696
+ .md .code-block pre { margin: 0; }
697
+
698
+ /* Controls (code ⇄ run, then copy, then breakout) — a centered overlay straddling the
699
+ bulb's top edge: clear of the running bulb below, free to overlap the prose above.
700
+ Absolute, so toggling run↔code (which resizes the bulb below) never moves it, and so
701
+ the bulb's vertical gap stays small. Revealed on embed-hover or focus; inside the row
702
+ the shared .overlay-pill drops its own corner-anchoring. */
703
+ .md .bulb-controls {
704
+ position: absolute;
705
+ top: 0;
706
+ right: .5rem;
707
+ z-index: 2;
708
+ display: flex;
709
+ gap: .4rem;
710
+ transform: translateY(-50%);
711
+ }
712
+ .md .bulb-controls .overlay-pill { position: static; }
713
+ .md .bulb-embed:hover .bulb-controls .overlay-pill,
714
+ .md .bulb-controls .overlay-pill:focus-visible { opacity: 1; }
715
+ .md .bulb-breakout:disabled { cursor: default; opacity: 1; }
716
+ /* Breakout in flight: shimmer through the ~2s write+spawn wait so the click reads as "working"
717
+ rather than dead — the same treatment (and shared keyframes) as the launcher's play button
718
+ (.bulb-launch.launching). :disabled already keeps it visible if the pointer leaves. */
719
+ .md .bulb-breakout.launching {
720
+ color: var(--accent); border-color: var(--accent);
721
+ background: linear-gradient(100deg, transparent 30%, color-mix(in srgb, var(--accent) 35%, transparent) 50%, transparent 70%);
722
+ background-size: 250% 100%;
723
+ animation: bulb-shimmer 1.1s linear infinite;
724
+ }
725
+ @media (prefers-reduced-motion: reduce) {
726
+ .md .bulb-breakout.launching { animation: none; background: color-mix(in srgb, var(--accent) 18%, transparent); }
727
+ }
728
+ /* Copy pill "copied" flash — mirrors the message .copy.done accent cue, scoped to the
729
+ embed control so it doesn't pick up the bubble-hover reveal. */
730
+ .md .bulb-copy.done { color: var(--accent); border-color: var(--accent); }
731
+ /* Source view behind the toggle — the bulb's .bulb.md rendered as markdown (file
732
+ labels + fenced code panels via the shared .md rules). Just a scroll container
733
+ here, capped so a long bulb doesn't blow out the transcript. No `display` — the
734
+ [hidden] attribute the toggle flips drives visibility. */
735
+ /* Code view: a streamlined source listing — one labeled bar per file with the code
736
+ flush beneath, not a stack of bordered panels. The container is just the scroll box;
737
+ the file-label bar is the only fill (overriding .md pre's panel chrome below). */
738
+ .md .bulb-code {
739
+ margin: 0;
740
+ padding: 0;
741
+ max-height: 480px;
742
+ overflow: auto;
743
+ font-size: .85rem;
744
+ /* A distinct surface from the transcript: the live bulb blends into the flow, but the
745
+ code view reads as an inspector panel. Code panels (.md pre below) are transparent, so
746
+ they show this through. */
747
+ background: var(--tool-bg);
748
+ }
749
+ /* **file.ext** labels render as <p><strong>…</strong></p>; a bulb's source has no other
750
+ top-level prose, so styling the paragraph as a header bar only ever hits a label. */
751
+ .md .bulb-code p {
752
+ margin: 0;
753
+ padding: .3rem .85rem;
754
+ /* A touch darker than the code surface so the file-label header stays distinct now
755
+ that the whole code view shares one background. */
756
+ background: color-mix(in srgb, var(--fg) 6%, var(--tool-bg));
757
+ border-top: 1px solid var(--border);
758
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
759
+ font-size: .78rem;
760
+ color: var(--muted);
761
+ }
762
+ .md .bulb-code p:first-child { border-top: none; }
763
+ .md .bulb-code p strong { font-weight: 600; color: var(--fg); }
764
+ /* Code flush on the embed bg — drop the inherited .md pre box (border/bg/radius). */
765
+ .md .bulb-code pre {
766
+ margin: 0;
767
+ padding: .5rem .85rem .85rem;
768
+ border: none;
769
+ border-radius: 0;
770
+ background: transparent;
771
+ overflow-x: auto;
772
+ }
773
+ /* hljs token colors for the code view, mapped to the bulb's own theme vars (no hljs
774
+ theme stylesheet) so highlighting follows light/dark rather than baking a palette
775
+ that would clash in dark mode. Minimal token set — enough for legible TS/HTML/CSS/
776
+ JSON/markdown without chasing every grammar scope. */
777
+ .bulb-code .hljs-comment, .bulb-code .hljs-quote { color: var(--muted); font-style: italic; }
778
+ .bulb-code .hljs-keyword, .bulb-code .hljs-literal, .bulb-code .hljs-built_in { color: var(--accent); }
779
+ .bulb-code .hljs-tag, .bulb-code .hljs-name, .bulb-code .hljs-selector-tag { color: var(--accent); }
780
+ .bulb-code .hljs-string, .bulb-code .hljs-regexp, .bulb-code .hljs-meta .hljs-string {
781
+ color: color-mix(in srgb, var(--diff-add) 85%, var(--fg));
782
+ }
783
+ .bulb-code .hljs-number { color: color-mix(in srgb, var(--accent) 55%, var(--fg)); }
784
+ .bulb-code .hljs-attr, .bulb-code .hljs-attribute, .bulb-code .hljs-property, .bulb-code .hljs-type {
785
+ color: color-mix(in srgb, var(--accent) 75%, var(--fg));
786
+ }
787
+ .bulb-code .hljs-title, .bulb-code .hljs-title.function_, .bulb-code .hljs-section {
788
+ color: var(--fg); font-weight: 600;
789
+ }
790
+ .bulb-code .hljs-symbol, .bulb-code .hljs-bullet, .bulb-code .hljs-punctuation { color: var(--muted); }
791
+ .md .bulb-embed.err {
792
+ padding: .6rem .8rem;
793
+ font-family: ui-monospace, monospace;
794
+ font-size: .85em;
795
+ color: color-mix(in srgb, #e23 70%, var(--fg));
796
+ white-space: pre-wrap;
797
+ }
798
+ .md .bulb-err-strip {
799
+ padding: .45rem .7rem;
800
+ border-top: 1px solid var(--border);
801
+ background: color-mix(in srgb, #e23 8%, var(--bg));
802
+ font-family: ui-monospace, monospace;
803
+ font-size: .8em;
804
+ color: color-mix(in srgb, #e23 75%, var(--fg));
805
+ white-space: pre-wrap;
806
+ }
807
+
808
+ /* tool calls */
809
+ /* Closed tool: just a single-line row, no chrome — a tool-only turn stays a
810
+ tight list, not N stacked panels. Open tools get the panel treatment below. */
811
+ .tool {
812
+ font-size: .85rem;
813
+ }
814
+ /* Clickable caret row, shared by tool heads and the collapsed-turn summary: a flex
815
+ row that's an unselectable click target. align-items:center centers the caret
816
+ against the label box (verb↔summary alignment is handled inside .tool-label). */
817
+ .tool-head, .turn-summary {
818
+ display: flex;
819
+ align-items: center;
820
+ gap: .5rem;
821
+ padding: .15rem 0;
822
+ cursor: pointer;
823
+ user-select: none;
824
+ }
825
+ .tool.open {
826
+ margin-top: .55rem;
827
+ background: var(--tool-bg);
828
+ border: 1px solid var(--border);
829
+ border-radius: 8px;
830
+ }
831
+ .tool.open .tool-head {
832
+ padding: .4rem .6rem;
833
+ }
834
+ /* line-height: 1 keeps the oversized caret's box from inflating the row height. */
835
+ .tool-caret { color: var(--muted); font-size: 1.4rem; line-height: 1; width: 1ch; }
836
+ .tool-name { font-weight: 600; color: var(--accent); }
837
+ /* Errors here are routine (a first-read miss the retry fixes), so a failed tool
838
+ recolors just its verb rather than boxing the whole row — a quiet signal. */
839
+ .tool.err .tool-name { color: var(--err); }
840
+
841
+ /* Collapsed-turn summary strip (intermediate steps hidden behind the final
842
+ answer). Same caret/row shape as a tool head, italic muted label. */
843
+ .turn-summary { color: var(--muted); font-size: .85rem; }
844
+ .turn-summary-text { font-style: italic; }
845
+ .turn-summary:hover .turn-summary-text { color: var(--fg); }
846
+ /* Verb + summary are inline siblings sharing one line box and font, so they sit
847
+ on a common baseline. Ellipsis truncation lives on the wrapper, not the
848
+ children — overflow:hidden on the summary itself skews its baseline. */
849
+ .tool-label {
850
+ flex: 1;
851
+ min-width: 0;
852
+ white-space: nowrap;
853
+ overflow: hidden;
854
+ text-overflow: ellipsis;
855
+ }
856
+ .tool-sum {
857
+ margin-left: .5rem;
858
+ color: var(--muted);
859
+ /* Re-enable selection the .tool-head blankets off: the verb/caret stay an
860
+ unselectable click target, but the summary is real data (path, pattern,
861
+ command) worth copying. A drag selects; a plain click still toggles. */
862
+ user-select: text;
863
+ }
864
+ .tool-run { color: var(--muted); }
865
+
866
+ /* Code-display panels inside an open tool card: raw input/output (.tool-in,
867
+ .tool-out) and per-hunk diff halves (.diff-old, .diff-new). All four share
868
+ the same monospace block geometry — padding, max-height with scroll,
869
+ pre-wrap with word-break, top-divider — so consolidating here means edits
870
+ to "how a code panel looks" land in one place. Color treatment is the only
871
+ per-variant difference. */
872
+ .tool-in, .tool-out, .diff-old, .diff-new {
873
+ margin: 0;
874
+ padding: .55rem .7rem;
875
+ border-top: 1px solid var(--border);
876
+ white-space: pre-wrap;
877
+ word-break: break-word;
878
+ font-family: ui-monospace, Menlo, monospace;
879
+ font-size: .8rem;
880
+ max-height: 320px;
881
+ overflow: auto;
882
+ }
883
+ .tool-out { color: var(--muted); }
884
+ .diff-old {
885
+ background: color-mix(in srgb, var(--err) 14%, transparent);
886
+ color: var(--err);
887
+ }
888
+ .diff-new {
889
+ background: color-mix(in srgb, var(--diff-add) 16%, transparent);
890
+ color: var(--diff-add);
891
+ }
892
+
893
+ /* Path link: plain accent, underline only on hover. skip-ink:none so the
894
+ underline stays solid under a path's descenders and slashes. */
895
+ .tool-sum.link {
896
+ color: var(--accent);
897
+ text-decoration: none;
898
+ }
899
+ .tool-sum.link:hover { text-decoration: underline; text-decoration-skip-ink: none; }
900
+
901
+ .diff { display: flex; flex-direction: column; }
902
+ .diff-step {
903
+ padding: .35rem .7rem;
904
+ border-top: 1px solid var(--border);
905
+ color: var(--muted);
906
+ font-size: .72rem;
907
+ text-transform: uppercase;
908
+ letter-spacing: .06em;
909
+ }
910
+
911
+ .thinking { margin-bottom: .4rem; font-size: .82rem; color: var(--muted); }
912
+ .thinking pre { white-space: pre-wrap; }
913
+
914
+ /* Copy button: an .overlay-pill revealed on bubble-hover. */
915
+ .bubble:hover .copy { opacity: 1; }
916
+ /* "copied" flash: eases into accent on click (CopyButton is a Component, so its
917
+ node persists across re-renders and the transition actually runs). The color
918
+ transition lives only on .done, not the base pill, so hover stays instant — the
919
+ trade is the revert snaps back rather than easing out. */
920
+ .copy.done {
921
+ opacity: 1;
922
+ color: var(--accent);
923
+ border-color: var(--accent);
924
+ transition: opacity .2s ease, color .5s ease, border-color .5s ease;
925
+ }
926
+ /* Assistant bubbles carry no padding, so the shared .5rem bottom would float the
927
+ pill above the last line — pin it to the text's bottom edge. */
928
+ .bubble.assistant .copy { bottom: 0; }