anvilsign in

collin/browser-terminal-extension

1/* Every colour comes from lib/theme.js, applied to :root as custom properties
2 at startup. The fallbacks here are the dark theme, so the panel is never
3 unstyled during the first paint. */
4:root {
5 color-scheme: dark;
6 --bg: #14161a;
7 --bg-panel: #1c1f25;
8 --bg-input: #14161a;
9 --border: #2d3138;
10 --fg: #d7dae0;
11 --fg-muted: #8b93a1;
12 --accent: #3b6ea5;
13 --accent-hover: #4a82bd;
14 --accent-fg: #ffffff;
15 --ok: #6ec177;
16 --err: #e06c75;
17 --warn: #d5a458;
18 --warn-bg: #241f16;
19 --warn-border: #4a3a22;
20 --shadow: rgba(0, 0, 0, 0.4);
21 --bg-strip: #191b21;
22 /* The window row's surface, between the session row's and the terminal's.
23 Named because two things have to agree on it exactly: the row itself, and
24 the selected session tab that merges into it. A tab a shade off the row it
25 sits on shows the join as a visible edge. */
26 --bg-row: color-mix(in srgb, var(--bg-panel) 55%, var(--bg-strip));
27 /* The tab silhouette's two radii, in Chromium's proportions: it draws a 28px
28 tab with a 12px bottom endcap and a 10px top corner (TabStyle::
29 GetBottomCornerRadius / GetTopCornerRadius), so a ~25px tab gets 10 and 8.
30 --tab-foot is also how far the tab's body is inset from its own box: the
31 feet are part of the tab, not something it hangs into the row. */
32 --tab-foot: 10px;
33 --tab-top: 8px;
34 /* 2 * --tab-foot less the slot the separator lives in (Chromium:
35 `2 * GetBottomCornerRadius() - total_separator_width`). */
36 --tab-slot-overlap: 14px;
37 /* What is left between two tab bodies once they overlap: the separator's
38 lane. */
39 --tab-gap: calc(2 * var(--tab-foot) - var(--tab-slot-overlap));
40}
41* { box-sizing: border-box; }
42/* Author `display` beats the UA rule for [hidden] no matter the specificity, so
43 every flex/inline-flex element in here would otherwise ignore .hidden = true.
44 Everything that toggles itself does it through the attribute; this is what
45 makes that work. */
46[hidden] { display: none !important; }
47html, body { height: 100%; margin: 0; }
48body {
49 font: 13px/1.5 system-ui, sans-serif;
50 background: var(--bg); color: var(--fg);
51 display: flex; flex-direction: column; overflow: hidden;
52}
53/* Vertical space is scarce in a sidebar, but not so scarce that controls
54 should become unclickable. 26px is about the smallest comfortable pointer
55 target; the header stays short by having few of them, spread over its two
56 rows rather than crowding either.
57
58 Two rows: sessions, then the windows of the selected one. #rows is the
59 column; each strip lays out the way the single row used to. The header itself
60 is a *row*, because one control — push to talk — sits beside all of them in a
61 corner of its own rather than inside any one of them.
62
63 The header's own background is the terminal's, not the panel's: every strip
64 paints its own surface over it, and what is left uncovered is the corner —
65 which should read as a continuation of the row it sits beside rather than as
66 a slab of a fourth colour laid over the end of them. */
67header {
68 display: flex; align-items: stretch;
69 background: var(--bg); flex: none;
70}
71#rows {
72 display: flex; flex-direction: column;
73 flex: 1; min-width: 0;
74}
75/* A column reserved across every row, holding the one control that types into
76 the pane rather than steering the tabs: hold-to-talk, and nothing else — the
77 hold sends its own Return, so there is no second button to stack under it.
78 Unpainted, so it takes the header's background and reads as an extension of
79 the bottom row instead of a slab of its own — the rows are tmux and their
80 surfaces are the nesting; a fourth colour beside them was one surface too
81 many, and it was the one thing in the header with a hard vertical seam down
82 it.
83
84 Its width is its own — a square is what the mic wants, and the rows have no
85 say in it — while its height is whatever the rows come to. Not the other way
86 round: sizing the button off the rows' height and the corner off the button
87 is a loop, and the browser breaks it by shrinking the strips. Which is why
88 the mic below is sized *from* that height rather than fixed: the header has
89 one row or two depending on the mode, and the corner is as tall as either.
90
91 `gap` and the bottom padding are the mic's own air, not a gutter between
92 stacked controls: it is centred in the corner and the padding keeps its ring
93 off the terminal's edge while it is held. */
94#corner {
95 flex: none; width: 56px;
96 display: flex; flex-direction: column;
97 align-items: center; justify-content: center; gap: 3px;
98 padding: 3px 4px 4px;
99 /* Not a flat fill: beside the top row the header's surface is the strips',
100 and by the bottom it is the terminal's, so the corner falls from one to the
101 other instead of meeting both with the same edge. The stops are soft on
102 purpose — the exact row heights are not knowable here, and a gradient that
103 tried to line up with them would be wrong in the mode that has one row. */
104 background: linear-gradient(
105 to bottom,
106 var(--bg-row),
107 color-mix(in srgb, var(--bg-row) 35%, var(--bg)) 55%,
108 var(--bg) 85%
109 );
110}
111/* Stretch, not centre: the active window tab has to reach the bottom edge to
112 sit on the seam. Everything else in the row re-centres itself. */
113.strip {
114 display: flex; align-items: stretch; min-width: 0;
115 /* No gap: an icon button already carries its own padding around a 15px glyph,
116 so a gap on top of that reads as a hole between the controls and the tabs.
117 The buttons sit against the tab strip the way a browser's do. */
118 gap: 0;
119 padding: 4px 6px 0 4px;
120}
121/* The controls that aren't buttons still need their own air. */
122.strip > #status-text { margin-left: 6px; }
123.strip > #session-name { margin-left: 4px; }
124/* Three surfaces, deepest at the top: the session row is recessed behind the
125 window row, which is recessed behind the terminal. That is what makes the
126 nesting readable without a single dividing line — the selected tab of each
127 row is painted in the surface of the row *below* it, so it merges into what
128 it contains, the way a browser's active tab merges into its toolbar.
129 How deep that recession reads is a theme's call, not a formula's: mixing it
130 from --bg-panel put dark's three surfaces within a few units of each other
131 while light's stayed obvious, so it is a token both themes name. */
132#session-strip { background: var(--bg-strip); }
133#window-strip { background: var(--bg-row); }
134#status-text {
135 flex: 1; font-size: 11px; color: var(--fg-muted); min-width: 0;
136 white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
137}
138
139/* The tabs are the only thing in a strip that reaches its bottom edge;
140 everything else is centred on the same 22px band, so the status text and the
141 icons sit on one line rather than each on its own accidental one. The 4px
142 bottom margin is the tabs' own gap above that edge, matched. */
143.strip > #status-text,
144.strip > #session-name,
145.strip > button.icon {
146 align-self: center;
147 margin-bottom: 4px;
148}
149.strip > #status-text { height: 22px; line-height: 20px; }
150
151/* Naming a new session, inline in the row the tab will appear in. A prompt or
152 a dialog would block the socket's message handler for as long as it is up. */
153#session-name {
154 flex: 0 1 9rem; min-width: 4rem; height: 22px;
155 font: inherit; font-size: 11px;
156 padding: 0 6px; border: 1px solid var(--accent); border-radius: 6px;
157 background: var(--bg-input); color: var(--fg);
158}
159#session-name:focus-visible { outline: none; }
160
161/* --- session tabs ----------------------------------------------------------
162 A tab is a whole tmux session. Clicking one is a switch-client: it moves the
163 client this panel already holds, so nothing is spawned and nothing is lost.
164 The selected one lifts onto the window row below it, which is the same move
165 the window row makes onto the terminal — one gesture, applied twice, so the
166 nesting is legible from the shapes alone. */
167#sessions {
168 display: flex; align-items: stretch; gap: 0;
169 flex: 0 1 auto; min-width: 0;
170 overflow-x: auto; scrollbar-width: none;
171}
172#sessions::-webkit-scrollbar { display: none; }
173/* Sized to its tabs the way the window row's "+" is: the row gives up width to
174 scrolling before it gives up the button. */
175#session-new { margin-right: auto; }
176
177button.session-tab {
178 position: relative;
179 margin-inline: calc(-0.5 * var(--tab-slot-overlap));
180 flex: 0 1 auto; min-width: 0; max-width: calc(10rem + 2 * var(--tab-foot));
181 display: inline-flex; align-items: center; gap: 5px;
182 padding: 3px 8px 4px; margin-bottom: 4px;
183 border: 0 solid transparent; border-width: 0 var(--tab-foot);
184 border-radius: var(--tab-top) var(--tab-top) 0 0;
185 background: transparent; color: var(--fg-muted);
186 font: inherit; font-size: 11px; line-height: 1.4;
187 white-space: nowrap; cursor: pointer; text-align: left;
188}
189button.session-tab .name { overflow: hidden; text-overflow: ellipsis; min-width: 0; }
190button.session-tab:hover { background: rgba(128, 128, 128, 0.16); color: var(--fg); }
191button.session-tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
192/* Attached by some other client: a terminal somewhere is on this session too. */
193button.session-tab.attached { color: var(--fg); }
194/* The selected session is drawn in the window row's own surface and reaches
195 down to meet it, so the windows below read as this session's contents. */
196button.session-tab[aria-selected="true"] {
197 --surface: var(--bg-row);
198 background: var(--surface); color: var(--fg);
199 margin-bottom: 0; padding-bottom: 8px;
200 cursor: default;
201}
202button.session-tab[aria-selected="true"]:hover { background: var(--surface); }
203button.session-tab[aria-selected="true"] .name { font-weight: 600; }
204
205/* --- window tabs -----------------------------------------------------------
206 A tab is a window of the attached session — what `prefix 2` selects. Clicking
207 one is a select-window: nothing in this strip spawns or destroys a shell
208 except "+", which opens a window. */
209/* Sized to its tabs, not to the row: "+" sits against the last tab the way a
210 browser's does, and the strip gives up width to scrolling before the row
211 gives up its buttons. */
212/* The strip has to reach over the header's bottom border, because the active
213 tab's whole trick is covering the 1px of it that it sits on. `overflow-x:
214 auto` computes overflow-y to `auto` as well, so a scroll container clips its
215 children at the padding box no matter which axis you asked for: without the
216 margin/padding pair below, the tab's overhang is cut off at the seam and the
217 line runs straight through the one tab that is meant to break it.
218 The margin grows the box downwards onto the border, the padding puts the
219 tabs' own bottom edge back where it was, and the 1px between the two is room
220 the overhang is allowed to paint in. */
221#tabs {
222 display: flex; align-items: stretch; gap: 0;
223 flex: 0 1 auto; min-width: 0;
224 margin-bottom: -1px; padding-bottom: 1px;
225 overflow-x: auto; scrollbar-width: none;
226}
227#tabs::-webkit-scrollbar { display: none; }
228/* Nothing follows the tabs, so this is just what stops "+" from being stretched
229 away from the last one. */
230#tab-new { margin-right: auto; }
231
232/* No extra separation between the last control and the tab strip: the strip's
233 own edge padding — the room the first tab's foot needs — already reads as the
234 gap between the two groups, and adding to it pushes the tabs visibly away
235 from the button for no reason. */
236
237/* The tab and its close ✕ are siblings — a button cannot contain a button —
238 so the slot is what the strip lays out, and the ✕ is positioned over the
239 tab's right edge.
240
241 Past the min width a name is unreadable, so the strip scrolls instead of
242 shrinking further: the same trade browsers make when the bar fills up. */
243/* Tabs overlap, as Chromium's do: it lays tab n+1's edge `2R - separator_width`
244 left of tab n's, so the slot left between two bodies is exactly the width the
245 separator is centred in. Ours is 6px, from 20px of feet less 14px of overlap.
246 The outermost edges stay flush, or the row would clip the first tab's foot. */
247.tab-slot {
248 position: relative; display: flex; align-items: stretch;
249 margin-inline: calc(-0.5 * var(--tab-slot-overlap));
250 flex: 0 1 auto;
251 min-width: calc(5.5rem + 2 * var(--tab-foot));
252 max-width: calc(11rem + 2 * var(--tab-foot));
253}
254/* The window you are on gives up its characters last. */
255.tab-slot.active { min-width: calc(7rem + 2 * var(--tab-foot)); }
256
257/* Room for the ✕ is reserved rather than made on hover, so a name does not
258 re-ellipsis under the pointer. */
259.tab-slot.closable button.tab { padding-right: 20px; }
260
261/* Pinned: down to the dot and the index, sized to its content and sitting at
262 the head of the strip. The index is what stays visible rather than the name,
263 because the index is the thing you can type — `prefix 3` still selects it,
264 pinned or not. */
265.tab-slot.pinned { flex: 0 0 auto; min-width: 0; max-width: none; }
266.tab-slot.pinned button.tab { padding-left: 7px; padding-right: 7px; gap: 4px; }
267.tab-slot.pinned button.tab .index { opacity: 1; font-size: 11px; }
268/* A hairline against the unpinned run, the way a browser separates them. */
269.tab-slot.pinned + .tab-slot:not(.pinned) { margin-left: 5px; }
270.tab-slot.pinned + .tab-slot:not(.pinned)::before {
271 content: ""; position: absolute; left: -3px; top: 6px; bottom: 6px;
272 border-left: 1px solid var(--border);
273}
274
275button.tab {
276 position: relative;
277 flex: 1 1 auto; min-width: 0;
278 display: inline-flex; align-items: center; gap: 5px;
279 padding: 4px 8px 5px; margin-bottom: 4px;
280 border: 0 solid transparent; border-width: 0 var(--tab-foot);
281 border-radius: var(--tab-top) var(--tab-top) 0 0;
282 background: transparent; color: var(--fg-muted);
283 font: inherit; font-size: 11px; line-height: 1.4;
284 white-space: nowrap; cursor: pointer; text-align: left;
285}
286button.tab:hover { background: rgba(128, 128, 128, 0.16); color: var(--fg); }
287button.tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
288button.tab .name { overflow: hidden; text-overflow: ellipsis; min-width: 0; }
289/* Only a pinned tab carries one — it is what a tab that has given up its name
290 has left to be told apart by. An unpinned tab shows the name instead: in a
291 sidebar the number was spending room on something the tooltip and tmux's own
292 status line both already say. */
293button.tab .index {
294 font-size: 10.5px; opacity: .75; font-variant-numeric: tabular-nums; flex: none;
295}
296/* tmux's own "something happened here while you were away". */
297button.tab.activity { color: var(--fg); }
298button.tab.activity .name { font-weight: 600; }
299
300/* The favicon slot: what Claude Code is doing in that window, and nothing at
301 all for a window that is just a shell — an always-lit glyph would be a status
302 light reporting on nothing.
303
304 The glyph is Claude Code's own asterisk spinner, so a working window in the
305 strip and the transcript in the pane are saying the same thing the same way.
306 The box is sized and centred once, for the widest frame, so the name beside
307 it does not step sideways as the animation cycles — the frames come from
308 whatever font the platform falls back to for each one, and their advance
309 widths differ by several pixels.
310
311 An *unselected* session tab wears the same glyph for the loudest agent
312 anywhere in the session, so one rule covers both rows: a waiting Claude looks
313 the same whether you can see its window or only the session that holds it.
314 The selected tab has no glyph to style — the windows it summarises are the
315 row underneath, already saying it one at a time. */
316button.tab .glyph,
317button.session-tab .glyph {
318 flex: none;
319 width: 10px; height: 11px;
320 display: inline-flex; align-items: center; justify-content: center;
321 font-size: 10px; line-height: 1;
322 color: var(--fg-muted); opacity: .5;
323}
324button.tab .glyph.none,
325button.session-tab .glyph.none { display: none; }
326button.tab .glyph.working,
327button.session-tab .glyph.working { color: var(--warn); opacity: 1; }
328button.tab .glyph.waiting,
329button.session-tab .glyph.waiting { color: var(--accent-hover); opacity: 1; animation: pulse 1.4s ease-in-out infinite; }
330/* Claude has stopped with something for you in a window you have not been
331 shown since. Same glyph as idle, lit: grey is "nothing to do here", and the
332 only thing that changed is that there now is. Slower than the waiting pulse
333 and a different hue, because it is the softer of the two asks — nothing is
334 blocked on it, it is just done. */
335button.tab .glyph.ready,
336button.session-tab .glyph.ready { color: var(--ok); opacity: 1; animation: pulse 2.2s ease-in-out infinite; }
337
338/* The lift: the terminal's background, and a negative bottom margin that
339 punches through the header's seam so the tab and the terminal read as one
340 surface. */
341/* Clicking the tab you are already on does nothing, so it does not offer the
342 hand — the same thing a browser's own active tab does. The ✕ is a sibling
343 button and keeps its own pointer. */
344button.tab[aria-selected="true"] {
345 --surface: var(--bg);
346 background: var(--surface); color: var(--fg);
347 margin-bottom: -1px; padding-bottom: 10px;
348 cursor: default;
349}
350button.tab[aria-selected="true"]:hover { background: var(--surface); }
351
352/* --- the tab silhouette ----------------------------------------------------
353 A browser tab is not a rounded rectangle. Chromium walks the outline in
354 TabStyleViews::GetPath: up from the floor through a *concave* quarter circle
355 (the endcap, radius R), straight up the body's side, over a *convex* top
356 corner (radius r), across, and back down the mirror image. Two consequences
357 worth naming, because they are what makes the shape read as a tab:
358
359 - the body is inset from the tab's own width by R on each side, so the top
360 is narrower than the bottom (`tab_left = left + extension`, where the
361 extension is exactly the bottom corner radius);
362 - each foot is tangent to both the floor and the body's side — its arc is
363 centred on the body's edge line, R above the floor — so the tab leaves
364 the floor horizontally rather than meeting it at a corner.
365
366 Drawn as one additive mask over the tab's own box, which the transparent
367 side borders above make R wider than the body on each side. Not as
368 pseudo-elements hanging past the tab's edges: the rows are scroll containers
369 and clip at their padding box, so hanging feet only survive if the row
370 reserves a foot's width before its first tab, and that reserve is dead space
371 in the row whenever that tab isn't the selected one.
372
373 Six layers, every one of them overlapping its neighbours, all unioned. An
374 earlier version tiled layers edge to edge and intersected them; the joins
375 landed on fractional device pixels and the surface behind the tab showed
376 through as hairlines, right where the tab is meant to be merging with it.
377 Overlapping unions have no join to miss. The half-pixel colour stops are
378 there for the same reason: a hard stop gives an unantialiased, stair-stepped
379 arc. */
380button.session-tab[aria-selected="true"],
381button.tab[aria-selected="true"] {
382 /* The flare: an R square of surface with a quarter disc bitten out of its
383 outer top corner, which is the concave arc. 1px wider than the foot so it
384 laps under the body rather than abutting it. */
385 --foot-l: radial-gradient(circle var(--tab-foot) at top left,
386 #0000 calc(var(--tab-foot) - 0.5px), #000 calc(var(--tab-foot) + 0.5px));
387 --foot-r: radial-gradient(circle var(--tab-foot) at top right,
388 #0000 calc(var(--tab-foot) - 0.5px), #000 calc(var(--tab-foot) + 0.5px));
389 --fill: linear-gradient(#000, #000);
390 /* The body's rounded shoulders, as discs the straight parts lap onto. */
391 --top-l: radial-gradient(circle var(--tab-top) at
392 calc(var(--tab-foot) + var(--tab-top)) var(--tab-top),
393 #000 calc(var(--tab-top) - 0.5px), #0000 calc(var(--tab-top) + 0.5px));
394 --top-r: radial-gradient(circle var(--tab-top) at
395 calc(100% - var(--tab-foot) - var(--tab-top)) var(--tab-top),
396 #000 calc(var(--tab-top) - 0.5px), #0000 calc(var(--tab-top) + 0.5px));
397 --mask-layers:
398 var(--foot-l) left bottom / calc(var(--tab-foot) + 1px) var(--tab-foot) no-repeat,
399 var(--foot-r) right bottom / calc(var(--tab-foot) + 1px) var(--tab-foot) no-repeat,
400 var(--top-l) 0 0 / 100% 100% no-repeat,
401 var(--top-r) 0 0 / 100% 100% no-repeat,
402 /* the body below its shoulders, and the strip between them */
403 var(--fill) center bottom / calc(100% - 2 * var(--tab-foot))
404 calc(100% - var(--tab-top)) no-repeat,
405 var(--fill) center top /
406 calc(100% - 2 * var(--tab-foot) - 2 * var(--tab-top)) 100% no-repeat;
407 -webkit-mask: var(--mask-layers);
408 mask: var(--mask-layers);
409}
410
411/* --- separators ------------------------------------------------------------
412 Chromium draws a 2x16 pill (radius 1, so it is a stadium and not a hairline)
413 centred in the slot between two tab bodies, and hides it whenever either
414 neighbour is active, selected or hovered — `has_visible_background` in
415 TabStyleViews::GetSeparatorOpacity — crossfading over the hover animation so
416 that scrubbing the pointer along the row doesn't flicker.
417
418 One per boundary, drawn on the right-hand tab of each pair: Chromium computes
419 it on both facing tabs and relies on them landing on the same coordinate,
420 which in CSS would just double-paint. Absolutely positioned, never a border,
421 or hovering a tab would shift every tab after it by a pixel.
422
423 The colour is --border rather than Chromium's "sliver of the toolbar showing
424 through": that trick needs the surface below to contrast with the strip, and
425 in the dark theme ours are two units apart. */
426.tab-slot + .tab-slot > button.tab::before,
427button.session-tab + button.session-tab::before,
428#tab-new::before,
429#session-new::before {
430 content: "";
431 position: absolute; top: 50%; translate: 0 -50%;
432 /* Chromium's is 2 DIP on a 34 DIP tab; ours is a hairline on a 26px one,
433 which at this size reads as a divider rather than as a bar. */
434 width: 1px; height: 11px;
435 border-radius: 1px;
436 background: var(--border);
437 pointer-events: none;
438 transition: opacity 160ms ease-out;
439}
440/* Centred in the lane between the two bodies. Offsets on an absolutely
441 positioned child are measured from its parent's *padding* box, which here
442 starts a whole foot inside the tab's own edge — so this counts left from the
443 body's leading edge rather than right from the tab's. */
444.tab-slot + .tab-slot > button.tab::before,
445button.session-tab + button.session-tab::before {
446 left: calc(-0.5 * var(--tab-gap) - 0.5px);
447}
448/* The trailing one, between the last tab and "+", is the same boundary seen
449 from the other side. Chromium keeps this one and drops the leading one
450 outright — `leading ? 0.0f` in GetSeparatorOpacity — so there is nothing
451 between the controls ahead of the strip and the first tab. */
452/* Between the last body's edge (a foot to the button's left) and the button's
453 own glyph (about 5.5px inside it) — centred in what the eye sees as the gap,
454 which is wider here than the lane between two tabs. */
455#tab-new::before, #session-new::before { left: -2.5px; }
456
457/* Hidden next to anything with a background of its own: the tab itself, and
458 the boundary on its far side. */
459.tab-slot.active > button.tab::before,
460.tab-slot:hover > button.tab::before,
461.tab-slot.active + .tab-slot > button.tab::before,
462.tab-slot:hover + .tab-slot > button.tab::before,
463button.session-tab[aria-selected="true"]::before,
464button.session-tab:hover::before,
465button.session-tab[aria-selected="true"] + button.session-tab::before,
466button.session-tab:hover + button.session-tab::before { opacity: 0; }
467/* Same rule for the "+" boundary, which needs :has() to look backwards. */
468#tabs:has(.tab-slot:last-child:is(.active, :hover)) ~ #tab-new::before,
469#sessions:has(button.session-tab:last-child:is([aria-selected="true"], :hover)) ~ #session-new::before {
470 opacity: 0;
471}
472/* The selected tab paints over its neighbours, feet included. */
473.tab-slot.active, button.session-tab[aria-selected="true"] { z-index: 1; }
474/* The "+" buttons carry a separator, so they contain it. */
475#tab-new, #session-new { position: relative; }
476
477/* The overlap is between tabs, not around the run of them: the outermost edges
478 pull back to flush, or half a foot hangs outside the scroll container and is
479 clipped away — the row's ends are exactly where a foot needs its room. */
480#tabs > .tab-slot:first-child,
481#sessions > button.session-tab:first-child { margin-left: 0; }
482#tabs > .tab-slot:last-child,
483#sessions > button.session-tab:last-child { margin-right: 0; }
484
485/* The run as a whole sits a little further left than the control before it,
486 so the first tab's foot laps over that button's padding rather than leaving
487 a notch beside it. On the run and not on the first tab: pulling the tab
488 alone would take its outer foot past the scroll container's edge, and the
489 container would clip exactly the part of the curve this is about. */
490#tabs, #sessions { margin-left: -4px; }
491
492/* Being dragged: the tab keeps its place in the row (it is the element being
493 moved through it), so the only cue needed is that it is the one in hand. */
494.session-tab.dragging, .tab-slot.dragging { opacity: .45; }
495.tab-slot.dragging button.tab-close { opacity: 0; pointer-events: none; }
496
497/* --- tab groups ------------------------------------------------------------
498 The single-row layout. One strip holds every window on the server, and each
499 session's run of them is introduced by a chip.
500
501 With the session row gone there are two surfaces rather than three, so the
502 remaining one takes the deepest of them and the selected tab still lifts all
503 the way to the terminal — the same gesture, one level shallower. */
504body.groups #window-strip { background: var(--bg-strip); }
505/* Two "+"s in one row now — a window in the session we are on, then a session.
506 Only the trailing one takes the row's leftover width, or the pair would be
507 pushed apart by it and read as belonging to different things. */
508body.groups #tab-new { margin-right: 0; }
509/* The run starts with a chip, which has no foot to lap over the control before
510 it, so it does not get the pull the tabs need. */
511body.groups #tabs { margin-left: 0; }
512
513/* The chip is not a tab and is deliberately not shaped like one: no feet, no
514 top corners, nothing that reaches the strip's bottom edge. It is a label with
515 a fold, sitting on the row rather than rising out of it, which is what keeps
516 a row of mixed chips and tabs readable as "groups of tabs" rather than as two
517 kinds of tab.
518
519 --group-h is set per chip in JS, hashed from the session name: one hue for
520 that session everywhere, with nothing stored and nothing to assign. Both the
521 text and the fill are mixed *towards the theme's own colours* rather than
522 given fixed lightnesses, so one rule covers light and dark — the same hue
523 darkens on white and lightens on the dark strip. */
524.group-chip {
525 --group-c: hsl(var(--group-h, 210) 60% 50%);
526 position: relative;
527 flex: none;
528 align-self: center;
529 display: inline-flex; align-items: center; gap: 4px;
530 margin: 0 2px 4px 8px;
531 padding: 2px 7px 3px;
532 border: 0; border-radius: 9px;
533 background: color-mix(in srgb, var(--group-c) 15%, transparent);
534 color: color-mix(in srgb, var(--group-c) 60%, var(--fg));
535 font: inherit; font-size: 10.5px; line-height: 1.4; font-weight: 600;
536 white-space: nowrap; cursor: pointer;
537}
538/* Nothing precedes the first chip, so it takes the same flush edge the first
539 tab does. */
540#tabs > .group-chip:first-child { margin-left: 0; }
541/* Grey: the home session's default, and a colour you can pick outright.
542 Overriding the one custom property is enough; every rule below is written
543 against it. */
544.group-chip.grey { --group-c: var(--fg-muted); }
545.group-chip:hover { background: color-mix(in srgb, var(--group-c) 26%, transparent); }
546.group-chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
547/* The group this panel's client is in. A ring rather than more fill: the fill
548 is already carrying the identity, and a second use of it would say "louder
549 session" instead of "you are here". */
550.group-chip.current {
551 box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--group-c) 70%, transparent);
552 color: color-mix(in srgb, var(--group-c) 45%, var(--fg));
553}
554/* Some other tmux client is on this session — the same thing the nested
555 layout's session tab says by going full-strength. */
556.group-chip.attached .name { text-decoration: underline; text-underline-offset: 2px; }
557/* Mid-drag, with a window hovering over it: the drop moves that window into
558 this session. */
559.group-chip.drop {
560 background: color-mix(in srgb, var(--group-c) 45%, transparent);
561 color: var(--fg);
562}
563
564/* A triangle rather than a chevron glyph: at 4px a font's chevron is at the
565 mercy of whichever fallback the platform picks, and this one is drawn. */
566.group-chip .caret {
567 flex: none;
568 width: 0; height: 0;
569 border: 3.5px solid transparent;
570 border-top: 4px solid currentColor;
571 /* Borders build the triangle from the box's centre, so the bottom half of it
572 is empty space; this puts the visible half back on the text's midline. */
573 margin-top: 2px;
574}
575.group-chip.folded .caret {
576 border: 3.5px solid transparent;
577 border-left: 4px solid currentColor;
578 margin-top: 0; margin-left: 1px;
579}
580.group-chip .count {
581 font-size: 9.5px; opacity: .7; font-variant-numeric: tabular-nums; flex: none;
582 font-weight: 400;
583}
584/* A folded group's windows have no tabs to wear their status, so the chip wears
585 the loudest of them. Same sizes as a tab's favicon slot. */
586.group-chip .glyph {
587 flex: none;
588 width: 10px; height: 11px;
589 display: inline-flex; align-items: center; justify-content: center;
590 font-size: 10px; line-height: 1;
591 opacity: .8;
592}
593.group-chip .glyph.working { color: var(--warn); opacity: 1; }
594.group-chip .glyph.waiting { color: var(--accent-hover); opacity: 1; animation: pulse 1.4s ease-in-out infinite; }
595.group-chip .glyph.ready { color: var(--ok); opacity: 1; animation: pulse 2.2s ease-in-out infinite; }
596
597/* The run's ends are flush against the chips either side of it, for the same
598 reason the strip's own ends are: half a foot hanging under a chip reads as a
599 tab starting behind it. */
600#tabs > .group-chip + .tab-slot { margin-left: 0; }
601#tabs > .tab-slot:has(+ .group-chip) { margin-right: 0; }
602
603/* The current window of a session this panel is *not* on. tmux marks one per
604 session, and in this row they are all visible at once — worth saying, but it
605 is not the tab you are looking at and must not be drawn as one. */
606.tab-slot.elsewhere > button.tab { color: var(--fg); }
607.tab-slot.elsewhere > button.tab .name { font-weight: 600; }
608
609/* --- the omnibar -----------------------------------------------------------
610 Groups mode's second row: a browser's address bar, under the tabs, in the
611 same place and doing the same job — search what you have, or create what you
612 named. The picker and the jump button lead it, in that order, and Enter and
613 push-to-talk end it — the same pair, in the same order, that leads the window
614 row in the nested layout. */
615#omni-strip {
616 background: var(--bg);
617 padding: 3px 6px 4px 4px;
618 align-items: center;
619 gap: 2px;
620}
621/* The pill is the wrapper, not the input: the location sits inside it, so the
622 border and the focus ring have to go around both. */
623#omni-box {
624 flex: 1 1 auto; min-width: 0;
625 display: flex; align-items: center; gap: 6px;
626 /* Nothing inside may paint past the pill: at the narrowest the directory is
627 capped rather than shrunk, so it can want a few pixels more than are left. */
628 overflow: hidden;
629 height: 24px;
630 padding: 0 9px;
631 border: 1px solid var(--border); border-radius: 12px;
632 background: var(--bg-input);
633}
634#omni-box:focus-within {
635 border-color: var(--accent);
636 /* The ring is the border thickening inwards, so the row's height does not
637 change when the box takes focus. */
638 box-shadow: inset 0 0 0 1px var(--accent);
639}
640#omni {
641 /* Enough left for a few characters of the name and the caret: the directory
642 beside it shrinks first, because a session you cannot read the name of is
643 worse than a path missing its front. */
644 flex: 1 1 auto; min-width: 4rem;
645 height: 100%;
646 font: inherit; font-size: 11px;
647 padding: 0; border: 0; background: none; color: var(--fg);
648}
649#omni:focus, #omni:focus-visible { outline: none; }
650#omni::placeholder { color: var(--fg-muted); }
651
652/* The directory, at the far end of the box. Dimmed and never wider than half
653 of it: it is context for the name, not a second thing competing with it, and
654 a deep path must not push the name it belongs to out of view. Read from its
655 end like every path here, so what goes when it does not fit is the front. */
656#omni-cwd {
657 /* It neither grows nor shrinks: a text input's flex base size is far wider
658 than the text in it, so a shrinkable directory beside one is squeezed
659 against a phantom — the box reads as half empty and the path still ends in
660 an ellipsis. Fixed at its content width, the input absorbs the slack
661 instead, and the only thing that can cut the path is the cap. */
662 flex: 0 0 auto; min-width: 0; max-width: 72%;
663 font-size: 10px;
664 color: var(--fg-muted);
665 overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
666 direction: rtl; text-align: right;
667}
668#omni-cwd > span { direction: ltr; unicode-bidi: embed; }
669
670/* The slot a browser keeps its site-info button in, doing the same job for the
671 session. The hit area is a finger's worth of button and the glyph inside it
672 stays small — muted until you go for it, the way a chrome control that is not
673 asking for anything should sit. */
674#omni-here {
675 flex: none;
676 display: flex; align-items: center; justify-content: center;
677 width: 18px; height: 18px; margin-left: -4px;
678 padding: 0; border: 0; border-radius: 50%;
679 background: none; color: var(--fg-muted); cursor: pointer;
680}
681#omni-here svg { width: 13px; height: 13px; display: block; }
682#omni-here:hover { background: var(--bg-panel); color: var(--fg); }
683#omni-here[aria-expanded="true"] { background: var(--bg-panel); color: var(--fg); }
684#omni-here:focus-visible { outline: 1px solid var(--accent); outline-offset: -1px; }
685
686/* --- session info ----------------------------------------------------------
687 The panel behind the dot. A browser's site-info bubble in the same place and
688 with the same shape: rows of label and value, the ones you might want to
689 carry elsewhere copyable, and nothing modal about it. */
690.info-pop {
691 position: fixed; z-index: 20;
692 min-width: 15rem; max-width: min(22rem, calc(100vw - 12px));
693 display: flex; flex-direction: column; gap: 5px;
694 padding: 8px 10px;
695 font-size: 11px; line-height: 1.45;
696 background: var(--bg-panel); color: var(--fg);
697 border: 1px solid var(--border); border-radius: 8px;
698 box-shadow: 0 6px 20px var(--shadow);
699}
700.info-pop .info-head {
701 display: flex; align-items: center; gap: 6px;
702 padding-bottom: 5px;
703 border-bottom: 1px solid var(--border);
704}
705.info-pop .info-head .dot {
706 flex: none; width: 8px; height: 8px; border-radius: 50%;
707 background: hsl(var(--group-h, 210) 60% 50%);
708}
709.info-pop .info-head .dot.grey { background: var(--fg-muted); }
710.info-pop .info-head .name {
711 font-weight: 600;
712 overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
713}
714.info-pop .info-head .sub { color: var(--fg-muted); font-size: 10px; }
715.info-row { display: flex; align-items: baseline; gap: 8px; }
716.info-row .k { flex: none; width: 4.5rem; color: var(--fg-muted); }
717.info-row .v {
718 flex: 1 1 auto; min-width: 0;
719 overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
720}
721/* A path is read from its end — the leaf is the part that identifies it — so
722 when it does not fit, the front is what goes. */
723.info-row .v.path {
724 font-family: ui-monospace, monospace; direction: rtl; text-align: left;
725}
726.info-row .v.path > span { direction: ltr; unicode-bidi: embed; }
727.info-pop .info-agents {
728 display: flex; flex-direction: column; gap: 3px;
729 padding-top: 5px; border-top: 1px solid var(--border);
730}
731.info-pop .info-agent { display: flex; align-items: baseline; gap: 6px; }
732.info-pop .info-agent .glyph {
733 flex: none; width: 10px; text-align: center;
734 color: var(--fg-muted); opacity: .6;
735}
736.info-pop .info-agent .glyph.working { color: var(--warn); opacity: 1; }
737.info-pop .info-agent .glyph.waiting { color: var(--accent-hover); opacity: 1; }
738/* No pulse in the panel or the omnibox: both are lists you are already reading
739 top to bottom, and a light that breathes in a list you are scanning is the
740 kind of motion the strip earns by being glanced at and these do not. */
741.info-pop .info-agent .glyph.ready { color: var(--ok); opacity: 1; }
742.info-pop .info-agent .what {
743 flex: 1 1 auto; min-width: 0;
744 overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
745}
746.info-pop .info-agent .where { flex: none; color: var(--fg-muted); font-size: 10px; }
747.info-pop .info-empty { color: var(--fg-muted); }
748.info-pop .info-copy {
749 align-self: flex-start;
750 padding: 2px 6px;
751 font: inherit; font-size: 10px;
752 color: var(--fg-muted); background: none;
753 border: 1px solid var(--border); border-radius: 5px;
754 cursor: pointer;
755}
756.info-pop .info-copy:hover { color: var(--fg); border-color: var(--accent); }
757
758body.compact #omni-strip { padding: 2px 4px 3px 4px; }
759body.compact #omni-box { height: 21px; }
760
761/* Over the terminal, not in the header: the header is a flex column that sizes
762 the terminal, so a list that grew inside it would reflow the terminal — and
763 re-fit xterm.js — on every keystroke. `top` is set in JS from the row's own
764 box, so it stays under the box at any density. */
765#omni-list {
766 position: fixed; left: 0; right: 0;
767 z-index: 5;
768 max-height: min(52vh, 320px);
769 overflow-y: auto;
770 padding: 3px 0;
771 background: var(--bg-panel);
772 border-bottom: 1px solid var(--border);
773 box-shadow: 0 6px 16px var(--shadow);
774}
775.omni-row {
776 display: flex; align-items: baseline; gap: 6px;
777 padding: 4px 10px;
778 font-size: 11px; line-height: 1.5;
779 color: var(--fg-muted);
780 cursor: pointer;
781}
782.omni-row .label {
783 color: var(--fg);
784 flex: 0 1 auto; min-width: 0;
785 overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
786}
787/* The part you typed. The rest of the name is what tells you it is not the one
788 you meant, so the emphasis goes on the match rather than on the difference. */
789.omni-row .label b { color: var(--accent-hover); font-weight: 700; }
790.omni-row .meta {
791 flex: 1 1 auto; min-width: 0;
792 text-align: right;
793 font-size: 10px;
794 overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
795}
796/* Keyboard and pointer land on the same highlight — the row under the cursor
797 *is* the chosen one, so Enter and a click can never disagree. */
798.omni-row.active { background: var(--accent); }
799.omni-row.active .label,
800.omni-row.active .meta { color: var(--accent-fg); }
801.omni-row.active .label b { color: var(--accent-fg); }
802/* The one row that makes something rather than going somewhere. */
803.omni-row.create .label::before {
804 content: "+ ";
805 color: var(--ok);
806 font-weight: 700;
807}
808/* The row that reads the box as a question. Claude's own asterisk, the same
809 one the agent glyphs use, so the row is recognisable before it is read. */
810.omni-row.claude .label::before {
811 content: "✻ ";
812 color: var(--accent-hover);
813 font-weight: 700;
814}
815.omni-row.claude.active .label::before { color: var(--accent-fg); }
816/* The row that reads the box as a shell command. A prompt's own mark, because
817 that is what the pane it opens will be showing. */
818.omni-row.run .label::before {
819 content: "$ ";
820 color: var(--ok);
821 font-weight: 700;
822}
823.omni-row.run .label { font-family: ui-monospace, monospace; }
824/* The row that goes to another machine. An arrow leaving, because that is the
825 one thing about it that is not like every other row: the window it opens is
826 here, but what you end up talking to is somewhere else. */
827.omni-row.ssh .label::before {
828 content: "⇢ ";
829 color: var(--accent-hover);
830 font-weight: 700;
831}
832.omni-row.ssh.active .label::before { color: var(--accent-fg); }
833.omni-row.ssh .label { font-family: ui-monospace, monospace; }
834/* A directory, in both of the rows that are about one: what the box holds, and
835 the names it could be completed to. Monospaced for the reason the ssh and
836 shell rows are — a path is read character by character, and the column the
837 slashes line up in is how you read it. The completion's mark is a slash
838 because that is the key it stands in for. */
839.omni-row.project .label { font-family: ui-monospace, monospace; }
840.omni-row.path .label { font-family: ui-monospace, monospace; }
841.omni-row.path .label::before {
842 content: "/ ";
843 color: var(--fg-muted);
844 font-weight: 700;
845}
846.omni-row.path.active .label::before { color: var(--accent-fg); }
847/* The rows that make something in this session rather than going anywhere. The
848 mark is per-row rather than per-kind — see `omniActions`. */
849.omni-row .mark {
850 flex: none;
851 color: var(--accent-hover);
852 font-weight: 700;
853}
854.omni-row.active .mark { color: var(--accent-fg); }
855/* A row that is a label rather than an offer: the bare `!`, before there is a
856 command to run. Dimmed, and not styled as clickable, because it isn't. */
857.omni-row.hint { cursor: default; }
858.omni-row.hint .label { color: var(--fg-muted); font-style: italic; }
859.omni-row.run.active .label::before { color: var(--accent-fg); }
860.omni-row .glyph {
861 flex: none;
862 width: 10px; height: 11px;
863 display: inline-flex; align-items: center; justify-content: center;
864 font-size: 10px; line-height: 1;
865 color: var(--fg-muted); opacity: .5;
866 align-self: center;
867}
868/* Unlike a tab's, this slot is always there even when it is empty: a list is
869 read down its left edge, and a glyph that collapses steps every row without
870 an agent out of line with the rows that have one. */
871.omni-row .glyph.working { color: var(--warn); opacity: 1; }
872.omni-row .glyph.waiting { color: var(--accent-hover); opacity: 1; }
873.omni-row .glyph.ready { color: var(--ok); opacity: 1; }
874.omni-row.active .glyph { color: var(--accent-fg); opacity: 1; }
875/* A finger is not a cursor: an 11px row is a comfortable read and a bad target,
876 so where the pointer is coarse the rows grow to something you can hit without
877 aiming. Nothing else about the list changes — the same rows, further apart. */
878@media (pointer: coarse) {
879 .omni-row { padding: 9px 10px; }
880 #omni-list { max-height: min(60vh, 420px); }
881}
882
883/* Right-click menu. A plain positioned element: an extension page would
884 otherwise get the browser's own menu, which has nothing to say about tmux. */
885.tab-menu {
886 position: fixed; z-index: 20; min-width: 8rem;
887 display: flex; flex-direction: column; padding: 3px;
888 background: var(--bg-panel); color: var(--fg);
889 border: 1px solid var(--border); border-radius: 6px;
890 box-shadow: 0 6px 20px var(--shadow);
891}
892.tab-menu button {
893 background: transparent; color: var(--fg);
894 border: 0; border-radius: 4px; padding: 5px 8px;
895 font: inherit; font-size: 11px; text-align: left; cursor: pointer;
896}
897.tab-menu button:hover { background: var(--border); }
898.tab-menu button.danger { color: var(--err); }
899.tab-menu button.danger:hover { background: var(--err); color: var(--accent-fg); }
900.tab-menu button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
901
902/* The group's name, edited in place at the top of its menu — the same place a
903 browser puts a tab group's name. No separator under it: it and the palette
904 below are one block of "what this group is", divided from the actions. */
905.tab-menu .rename {
906 padding: 6px 8px 2px;
907}
908.tab-menu .rename input {
909 width: 100%; box-sizing: border-box;
910 padding: 4px 6px;
911 background: var(--bg); color: var(--fg);
912 border: 1px solid var(--border); border-radius: 4px;
913 font: inherit; font-size: 11px;
914}
915.tab-menu .rename input:focus { outline: none; border-color: var(--accent); }
916/* A name tmux will not take. Said while it is being typed rather than after it
917 is sent, since the daemon's answer to one is silence. */
918.tab-menu .rename.bad input,
919.tab-menu .rename.bad input:focus { border-color: var(--err); }
920
921/* The group palette, across the top of a group's menu. A row rather than nine
922 more menu items: these are a palette, and the eye finds a colour in a palette
923 faster than a colour name in a list. */
924.tab-menu .colours {
925 display: flex; gap: 5px;
926 padding: 7px 8px;
927 border-bottom: 1px solid var(--border);
928 margin-bottom: 3px;
929}
930.tab-menu .swatch {
931 flex: none;
932 width: 15px; height: 15px; padding: 0;
933 border-radius: 50%; border: 0;
934 background: hsl(var(--group-h, 210) 60% 50%);
935 cursor: pointer;
936}
937.tab-menu .swatch.grey { background: var(--fg-muted); }
938.tab-menu .swatch:hover { transform: scale(1.15); }
939/* The one showing now, ringed in the panel's own background so the ring reads
940 as a gap around the colour rather than as a second colour on top of it. */
941.tab-menu .swatch.on {
942 box-shadow: 0 0 0 2px var(--bg-panel), 0 0 0 3.5px var(--fg-muted);
943}
944.tab-menu .swatch:focus-visible {
945 outline: 2px solid var(--accent); outline-offset: 2px;
946}
947
948button.tab-close {
949 position: absolute; right: calc(4px + var(--tab-foot)); top: 5px;
950 width: 15px; height: 15px; padding: 0;
951 display: inline-flex; align-items: center; justify-content: center;
952 border: 0; border-radius: 4px;
953 background: transparent; color: var(--fg-muted); cursor: pointer;
954 opacity: .45; transition: opacity 90ms linear;
955}
956button.tab-close svg { width: 9px; height: 9px; display: block; }
957/* On every tab that has one, not just the active tab and the one under the
958 pointer: a ✕ you have to go looking for is a ✕ you do not know is there, and
959 the room for it is reserved either way — `.tab-slot.closable` pads for it
960 whether or not it is painted, so showing it costs no layout.
961 Dim until the tab is the one you are on or pointing at, which is what keeps a
962 full strip of them from reading as noise. Every one of them is destructive,
963 so the quiet default matters more here than in a browser. */
964.tab-slot.active button.tab-close,
965.tab-slot:hover button.tab-close,
966.tab-slot:focus-within button.tab-close { opacity: 1; }
967/* Red on hover rather than the usual grey: this is the one control in the
968 panel that destroys something, and it takes effect on the first click. */
969button.tab-close:hover { background: var(--err); color: var(--accent-fg); }
970button.tab-close:focus-visible { outline: 2px solid var(--accent); outline-offset: 0; }
971
972
973button.icon {
974 flex: none;
975 width: 26px; height: 26px; padding: 0;
976 display: inline-flex; align-items: center; justify-content: center;
977 background: transparent; border: 0; border-radius: 5px;
978 color: var(--fg-muted); cursor: pointer;
979}
980button.icon svg { width: 15px; height: 15px; display: block; }
981button.icon:hover { background: var(--border); color: var(--fg); }
982button.icon:active { transform: translateY(0.5px); }
983button.icon:focus-visible { outline: 2px solid var(--accent); outline-offset: -1px; }
984/* A window tab is hovering over "+", and letting go gives it a session of its
985 own. Lit rather than outlined, because the pointer is over the button and a
986 ring under a dragged tab reads as a gap. */
987button.icon.drop {
988 background: var(--accent);
989 color: var(--accent-fg);
990}
991#pick.active { background: var(--accent); color: var(--accent-fg); }
992
993/* Jump to the next pane an agent is waiting in. Tinted by the state it would
994 take you to, in the same three colours the tabs' glyphs use — the button is
995 the one place in the header that says what is loudest *anywhere* on the
996 server, so it has to answer "is there anything for me" before it is pressed
997 and without being read as a fourth vocabulary.
998
999 No pulse: the tab whose glyph is pulsing is already saying that, and a second
1000 thing moving in the same row for the same reason is noise. Dimmed and
1001 unpressable when no pane has an agent in it — there is nowhere to go, and the
1002 button that goes nowhere should not look like it does. */
1003#jump[data-state="waiting"] { color: var(--accent-hover); }
1004#jump[data-state="ready"] { color: var(--ok); }
1005#jump[data-state="working"] { color: var(--warn); }
1006button.icon:disabled { opacity: 0.4; cursor: default; }
1007button.icon:disabled:hover { background: transparent; color: var(--fg-muted); }
1008button.icon:disabled:active { transform: none; }
1009
1010/* Push to talk, while held. Red rather than the accent: this one is putting
1011 bytes into the pane for as long as it looks like this, and it needs to be
1012 obvious at a glance that it is still down — a key stuck open in a terminal is
1013 not a small mistake. The ring is what carries that at 26px, where a fill
1014 alone reads much like the picker's.
1015
1016 `:active` is deliberately not the hook. The hold survives the pointer sliding
1017 off the button (it is captured), and :active does not. */
1018/* Much bigger than its neighbours, and round. Every other icon here is a tap,
1019 which forgives a near miss; this one is held for the length of a sentence, and
1020 sliding off it mid-word is the failure that matters. The size is the fix, and
1021 the circle is what says "hold" rather than "click" at a glance.
1022
1023 It takes most of the corner, which is what the corner is for: a row can only
1024 ever give an icon its own 26px, and a thumb wants more than that. It is sized
1025 from the height it is given rather than from the corner's width, because the
1026 corner shares that height with the Enter key under it and the header is a row
1027 taller in one mode than the other — a circle fixed at the full width would
1028 push the header taller in the mode that cannot spare it. The floor is 40px
1029 and the ceiling 48; both platforms' touch guidance names 44 and the band
1030 straddles it.
1031
1032 A lifted surface rather than the recessed one an input gets: this is the one
1033 control in the panel you aim a thumb at, and on the header's own background
1034 a well would read as a hole rather than a button. */
1035#talk.big {
1036 flex: 1 1 auto;
1037 width: auto; height: auto;
1038 min-height: 40px; max-height: 48px;
1039 aspect-ratio: 1;
1040 border-radius: 50%;
1041 border: 1px solid var(--border);
1042 background: var(--bg-panel);
1043 box-shadow: 0 1px 2px var(--shadow);
1044}
1045#talk.big:hover {
1046 background: color-mix(in srgb, var(--fg) 8%, var(--bg-panel));
1047 color: var(--fg);
1048}
1049#talk.big svg { width: 21px; height: 21px; }
1050
1051#talk.talking {
1052 background: var(--err);
1053 color: var(--accent-fg);
1054 border-color: var(--err);
1055 box-shadow: 0 0 0 3px color-mix(in srgb, var(--err) 30%, transparent);
1056}
1057#talk.talking svg { animation: talk-pulse 1.1s ease-in-out infinite; }
1058@keyframes talk-pulse {
1059 50% { opacity: .45; }
1060}
1061@media (prefers-reduced-motion: reduce) {
1062 #talk.talking svg { animation: none; }
1063}
1064/* Let go and the Return is queued, not sent: for the length of that window the
1065 button says so, by fading the hold's own colour out over exactly as long as
1066 you have to press it again. Nothing is sent while the ring is visible, so the
1067 cue and the thing it reports on end together. The duration comes from JS so
1068 there is one number for it; the fallback matches TALK_SUBMIT_MS. */
1069#talk.pending {
1070 animation: talk-arm var(--talk-submit, 1s) linear forwards;
1071}
1072@keyframes talk-arm {
1073 from { box-shadow: 0 0 0 3px color-mix(in srgb, var(--err) 30%, transparent); }
1074 to { box-shadow: 0 0 0 3px transparent; }
1075}
1076@media (prefers-reduced-motion: reduce) {
1077 #talk.pending { animation: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--err) 15%, transparent); }
1078}
1079
1080/* Held with a finger: the browser's own long-press behaviours — text selection,
1081 the callout menu — would otherwise interrupt the gesture. */
1082#talk { touch-action: none; -webkit-user-select: none; user-select: none; }
1083
1084/* The session name is a tab of its own once there is one, so the status text
1085 stands down rather than saying it twice. The dot stays: it is the only thing
1086 reporting on the connection itself. */
1087body.has-session > header #status-text { display: none; }
1088
1089/* Compact: lose the status text, keep the targets clickable. */
1090body.compact .strip { padding: 2px 4px 0; }
1091body.compact #status-text { display: none; }
1092body.compact button.icon { width: 24px; height: 24px; }
1093/* The mic is a touch target rather than a glyph, so density trims it a little
1094 and then stops: below the platforms' 44px floor it stops being one. */
1095body.compact #corner { width: 50px; padding: 3px 3px 4px; }
1096body.compact #talk.big { min-height: 38px; max-height: 44px; }
1097body.compact #talk.big svg { width: 19px; height: 19px; }
1098body.compact .tab-slot { max-width: calc(8rem + 2 * var(--tab-foot)); }
1099body.compact button.tab { padding: 3px 6px 4px; }
1100body.compact button.session-tab { padding: 2px 6px 3px; max-width: calc(8rem + 2 * var(--tab-foot)); }
1101body.compact .tab-slot.closable button.tab { padding-right: 19px; }
1102body.compact button.tab[aria-selected="true"] { padding-bottom: 9px; }
1103body.compact button.tab-close { top: 4px; }
1104body.compact .group-chip { padding: 1px 6px 2px; margin-left: 6px; font-size: 10px; }
1105/* The count is what a folded chip has instead of tabs, so unlike a session
1106 tab's it survives compact. */
1107body.compact #term { padding: 2px; }
1108
1109/* Hidden: no header at all. A 4px strip along the top brings it back on
1110 hover, so the settings panel is never unreachable. */
1111body.chromeless header {
1112 position: absolute; top: 0; left: 0; right: 0; z-index: 5;
1113 opacity: 0; pointer-events: none; transition: opacity 90ms linear;
1114}
1115body.chromeless #reveal-strip {
1116 display: block; position: absolute; top: 0; left: 0; right: 0;
1117 height: 4px; z-index: 6;
1118}
1119body.chromeless #reveal-strip:hover ~ header,
1120body.chromeless header:hover { opacity: 1; pointer-events: auto; }
1121body.chromeless #term { padding: 2px; }
1122#reveal-strip { display: none; }
1123
1124@keyframes pulse { 50% { opacity: .25; } }
1125/* The spinner is driven from script, which parks it on a single frame when
1126 motion is reduced; this is only the resting glyphs' own breathing. They keep
1127 their colour, which is what carries the meaning — the pulse only ever made it
1128 easier to catch out of the corner of an eye. */
1129@media (prefers-reduced-motion: reduce) {
1130 button.tab .glyph.waiting,
1131 button.session-tab .glyph.waiting,
1132 .group-chip .glyph.waiting,
1133 button.tab .glyph.ready,
1134 button.session-tab .glyph.ready,
1135 .group-chip .glyph.ready { animation: none; }
1136}
1137/* `touch-action: none` because the terminal scrolls itself. Without it the
1138 browser claims a vertical drag as a pan of the panel, and having claimed it,
1139 stops sending the touchmove events sidebar.js turns into wheel events — the
1140 gesture would be swallowed before the terminal ever heard about it. A tap is
1141 untouched by this and still becomes a click. */
1142#term {
1143 flex: 1; min-height: 0; padding: 4px; overflow: hidden;
1144 touch-action: none;
1145}
1146#term .xterm { height: 100%; }
1147/* --- settings, as a popup ---------------------------------------------------
1148 A floating card under the chevron, the way Chrome hangs its tab-search popup
1149 off the chevron in the same corner: rounded, lifted off the page by a shadow
1150 rather than joined to an edge, and scrolling inside itself so the terminal
1151 underneath never moves. `left` and `top` are set in JS from the chevron's own
1152 box, so it follows the button at any density. */
1153aside {
1154 position: fixed; z-index: 30;
1155 width: min(21rem, calc(100vw - 12px));
1156 max-height: min(70vh, 34rem); overflow-y: auto; overscroll-behavior: contain;
1157 padding: 10px 12px 12px;
1158 background: var(--bg-panel); color: var(--fg);
1159 border: 1px solid var(--border); border-radius: 12px;
1160 box-shadow: 0 10px 30px var(--shadow), 0 2px 6px var(--shadow);
1161}
1162/* The card's own title bar, and a ✕ in it: a popup that floats has no edge to
1163 read as "this is a panel", so it says what it is and how to put it away. */
1164.settings-head {
1165 position: sticky; top: -10px; z-index: 1;
1166 display: flex; align-items: center; gap: 6px;
1167 margin: -10px -12px 8px; padding: 8px 8px 8px 12px;
1168 background: var(--bg-panel);
1169 border-bottom: 1px solid var(--border);
1170 border-radius: 12px 12px 0 0;
1171}
1172.settings-head button.icon { flex: none; width: 22px; height: 22px; }
1173.settings-head button.icon svg { width: 12px; height: 12px; }
1174.settings-head .title {
1175 flex: 1 1 auto;
1176 font-size: 11px; font-weight: 600; letter-spacing: .02em;
1177}
1178h2 { font-size: 11px; text-transform: uppercase; letter-spacing: .06em;
1179 color: var(--fg-muted); margin: 12px 0 4px; font-weight: 600; }
1180h2:first-child { margin-top: 0; }
1181/* The chevron points at what it opened, and turns over while it is open. */
1182#settings-toggle svg { transition: transform 120ms ease; }
1183#settings-toggle[aria-expanded="true"] svg { transform: rotate(180deg); }
1184#settings-toggle[aria-expanded="true"] { background: var(--border); color: var(--fg); }
1185@media (prefers-reduced-motion: reduce) {
1186 #settings-toggle svg { transition: none; }
1187}
1188.hint { color: var(--fg-muted); margin: 0 0 6px; font-size: 11px; }
1189.row { display: flex; gap: 6px; align-items: center; }
1190input[type=text], input[type=password], input:not([type]), #url {
1191 width: 100%; background: var(--bg-input); color: var(--fg);
1192 border: 1px solid var(--border); border-radius: 4px; padding: 5px 7px;
1193 font-family: ui-monospace, monospace; font-size: 11px;
1194}
1195button {
1196 background: var(--accent); color: var(--accent-fg); border: 0; border-radius: 4px;
1197 padding: 5px 10px; font-size: 11px; cursor: pointer;
1198}
1199button:hover { background: var(--accent-hover); }
1200button.ghost { background: transparent; border: 1px solid var(--border);
1201 color: var(--fg-muted); padding: 3px 7px; }
1202button.ghost:hover { background: var(--border); }
1203pre {
1204 background: var(--bg-input); border: 1px solid var(--border); border-radius: 4px;
1205 padding: 6px; margin: 0 0 6px; overflow-x: auto;
1206 font-family: ui-monospace, monospace; font-size: 10px;
1207 white-space: pre-wrap; word-break: break-all;
1208}
1209#log { max-height: 120px; overflow-y: auto; color: var(--fg-muted); }
1210code { font-family: ui-monospace, monospace; background: var(--bg-input);
1211 padding: 1px 3px; border-radius: 3px; font-size: 10px; }
1212label.row { color: var(--fg-muted); font-size: 11px; margin-top: 5px; }
1213label.row input { width: auto; }
1214
1215#trust-hint { margin-top: 8px; padding: 8px; border: 1px solid var(--warn-border);
1216 background: var(--warn-bg); border-radius: 4px; }
1217.hint.warn { color: var(--warn); }
1218
1219/* Picked-element panel. A results card, not a toolbar: one primary action,
1220 the rest reduced to icons so the value itself is what draws the eye. */
1221#picked {
1222 flex: none;
1223 background: var(--bg-panel);
1224 border-bottom: 1px solid var(--border);
1225 border-top: 2px solid var(--accent);
1226 padding: 7px 8px 9px;
1227}
1228
1229.picked-top { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
1230.chip {
1231 font-family: ui-monospace, monospace; font-size: 11px;
1232 color: var(--accent); background: var(--bg-input);
1233 border: 1px solid var(--border); border-radius: 4px;
1234 padding: 1px 6px; flex: none;
1235}
1236#picked-meta {
1237 flex: 1; min-width: 0; font-size: 11px; color: var(--fg-muted);
1238 white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
1239}
1240.picked-top button.icon { width: 22px; height: 22px; }
1241.picked-top button.icon svg { width: 13px; height: 13px; }
1242
1243/* Segmented control instead of a native <select>: the options are few, short,
1244 and worth seeing all at once. */
1245.segmented {
1246 display: flex; gap: 2px; margin-bottom: 6px;
1247 background: var(--bg-input);
1248 border: 1px solid var(--border); border-radius: 6px; padding: 2px;
1249}
1250.segmented button {
1251 flex: 1 1 0; min-width: 0;
1252 background: transparent; color: var(--fg-muted);
1253 border: 0; border-radius: 4px; padding: 3px 4px;
1254 font-size: 10px; font-weight: 500; letter-spacing: .02em;
1255 cursor: pointer; white-space: nowrap;
1256 overflow: hidden; text-overflow: ellipsis;
1257}
1258.segmented button:hover { color: var(--fg); background: var(--border); }
1259.segmented button[aria-selected="true"] {
1260 background: var(--accent); color: var(--accent-fg);
1261}
1262.segmented button[aria-selected="true"]:hover { background: var(--accent-hover); }
1263
1264#picked-value {
1265 background: var(--bg-input);
1266 border: 1px solid var(--border); border-radius: 6px;
1267 padding: 7px 8px;
1268 font-family: ui-monospace, monospace; font-size: 11px; line-height: 1.45;
1269 color: var(--fg);
1270 word-break: break-all; white-space: pre-wrap;
1271 max-height: 84px; overflow-y: auto;
1272 user-select: all;
1273}
1274#picked-value.empty { color: var(--fg-muted); font-style: italic; user-select: none; }
1275
1276#picked-insert { width: 100%; margin-top: 7px; padding: 6px 10px; font-size: 11px; }
1277#picked-warn { margin: 6px 0 0; font-size: 11px; }
1278#picked-grant { width: 100%; margin-top: 6px; }
1279
1280select { font-family: inherit; }
1281
1282#density-hint { margin-top: 6px; }
1283
1284#font-row { margin-top: 7px; }
1285#font-row .hint { margin: 0; margin-right: auto; }
1286#font-row button.icon { width: 24px; height: 24px; border: 1px solid var(--border); }
1287#font-size { font-variant-numeric: tabular-nums; font-size: 11px; min-width: 2ch;
1288 text-align: center; color: var(--fg); }
1289#font-reset { padding: 4px 8px; font-size: 11px; }
1290#theme-select, #density-select, #tabmode-select { width: auto; background: var(--bg-input); color: var(--fg);
1291 border: 1px solid var(--border); border-radius: 4px; padding: 5px 7px; font-size: 11px; }