anvilsign in

collin/browser-terminal-extension

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