| 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; } |
| 47 | html, body { height: 100%; margin: 0; } |
| 48 | body { |
| 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. */ |
| 67 | header { |
| 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 | |
| 190 | button.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 | } |
| 202 | button.session-tab .name { overflow: hidden; text-overflow: ellipsis; min-width: 0; } |
| 203 | button.session-tab:hover { background: rgba(128, 128, 128, 0.16); color: var(--fg); } |
| 204 | button.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. */ |
| 206 | button.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. */ |
| 209 | button.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 | } |
| 215 | button.session-tab[aria-selected="true"]:hover { background: var(--surface); } |
| 216 | button.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 | |
| 288 | button.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 | } |
| 299 | button.tab:hover { background: rgba(128, 128, 128, 0.16); color: var(--fg); } |
| 300 | button.tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } |
| 301 | button.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. */ |
| 306 | button.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". */ |
| 310 | button.tab.activity { color: var(--fg); } |
| 311 | button.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. */ |
| 329 | button.tab .glyph, |
| 330 | button.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 | } |
| 337 | button.tab .glyph.none, |
| 338 | button.session-tab .glyph.none { display: none; } |
| 339 | button.tab .glyph.working, |
| 340 | button.session-tab .glyph.working { color: var(--warn); opacity: 1; } |
| 341 | button.tab .glyph.waiting, |
| 342 | button.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. */ |
| 348 | button.tab .glyph.ready, |
| 349 | button.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. */ |
| 357 | button.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 | } |
| 363 | button.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. */ |
| 393 | button.session-tab[aria-selected="true"], |
| 394 | button.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, |
| 440 | button.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, |
| 458 | button.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, |
| 476 | button.session-tab[aria-selected="true"]::before, |
| 477 | button.session-tab:hover::before, |
| 478 | button.session-tab[aria-selected="true"] + button.session-tab::before, |
| 479 | button.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. */ |
| 517 | body.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. */ |
| 521 | body.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. */ |
| 524 | body.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 | /* The row the browser tab on screen is pinned to. |
| 623 | |
| 624 | A dot rather than a border or a tint: the tab silhouette is built out of |
| 625 | masks and backgrounds (see the run above), and both of the obvious markings |
| 626 | would have to be drawn through that. |
| 627 | |
| 628 | ::after, and absolutely positioned, for two separate reasons. ::before on |
| 629 | these three is already the tab separator, and a flex item here would push the |
| 630 | name along and collide with the ✕ that overlays the trailing edge. Riding in |
| 631 | the leading padding instead costs no layout: every one of the three keeps at |
| 632 | least 6px there (7px on a pinned tab and on a chip, before its caret), and |
| 633 | the dot is 4px wide starting 1px in. Vertically centred, which is also the |
| 634 | one height at which a chip's 9px pill edge is straight. |
| 635 | |
| 636 | Small and in the accent, because this is a "why did it move" answer read once |
| 637 | and then ignored — the tooltip carries the sentence. */ |
| 638 | .tab-slot.tab-linked > button.tab::after, |
| 639 | button.session-tab.tab-linked::after, |
| 640 | button.group-chip.tab-linked::after { |
| 641 | content: ""; |
| 642 | position: absolute; top: 50%; translate: 0 -50%; |
| 643 | left: 1px; |
| 644 | width: 4px; height: 4px; |
| 645 | border-radius: 50%; |
| 646 | background: var(--accent-hover); |
| 647 | pointer-events: none; |
| 648 | } |
| 649 | |
| 650 | /* --- the omnibar ----------------------------------------------------------- |
| 651 | Groups mode's second row: a browser's address bar, under the tabs, in the |
| 652 | same place and doing the same job — search what you have, or create what you |
| 653 | named. The picker leads it and jump follows the box, the way a browser puts |
| 654 | what acts on the page before the bar and what takes you onward after it. */ |
| 655 | #omni-strip { |
| 656 | background: var(--bg); |
| 657 | /* Less on the right than the left now that a button ends the row rather than |
| 658 | the pill: an icon's box is already wider than its glyph, so the same 6px |
| 659 | past it reads as a bigger gap than the pill's edge did. */ |
| 660 | padding: 3px calc(var(--corner-w) + 2px) 4px 4px; |
| 661 | align-items: center; |
| 662 | gap: 2px; |
| 663 | } |
| 664 | /* The pill is the wrapper, not the input: the location sits inside it, so the |
| 665 | border and the focus ring have to go around both. */ |
| 666 | #omni-box { |
| 667 | flex: 1 1 auto; min-width: 0; |
| 668 | /* Aligned to the top rather than centred, because the box is only sometimes |
| 669 | one line tall: once the text wraps, the info button and the directory stay |
| 670 | on the first line — beside the start of what you typed — instead of |
| 671 | drifting to the middle of a paragraph. At one line the two are the same |
| 672 | thing, since every child is a line-box tall. */ |
| 673 | display: flex; align-items: flex-start; gap: 6px; |
| 674 | /* Nothing inside may paint past the pill: at the narrowest the directory is |
| 675 | capped rather than shrunk, so it can want a few pixels more than are left. */ |
| 676 | overflow: hidden; |
| 677 | /* Height comes from the text now: one line is the old 24px (18px of line box |
| 678 | plus this padding) and every wrapped line adds 18 more, up to the cap on |
| 679 | #omni itself. */ |
| 680 | min-height: 24px; |
| 681 | padding: 3px 9px; |
| 682 | border: 1px solid var(--border); border-radius: 12px; |
| 683 | background: var(--bg-input); |
| 684 | } |
| 685 | #omni-box:focus-within { |
| 686 | border-color: var(--accent); |
| 687 | /* The ring is the border thickening inwards, so the row's height does not |
| 688 | change when the box takes focus. */ |
| 689 | box-shadow: inset 0 0 0 1px var(--accent); |
| 690 | } |
| 691 | #omni { |
| 692 | /* Enough left for a few characters of the name and the caret: the directory |
| 693 | beside it shrinks first, because a session you cannot read the name of is |
| 694 | worse than a path missing its front. */ |
| 695 | flex: 1 1 auto; min-width: 4rem; |
| 696 | font: inherit; font-size: 11px; line-height: 18px; |
| 697 | padding: 0; border: 0; background: none; color: var(--fg); |
| 698 | /* A textarea's own affordances, all of them wrong here: it is a control in a |
| 699 | chrome strip, not a form field, so it neither drags to resize nor keeps a |
| 700 | scrollbar gutter it is not using. */ |
| 701 | resize: none; |
| 702 | /* One line to start with; JS sets the height to the content's on every |
| 703 | change, and this caps how far that can go — past about six lines the box |
| 704 | is eating the terminal, so it scrolls inside itself instead. */ |
| 705 | height: 18px; max-height: 108px; |
| 706 | overflow-y: auto; |
| 707 | /* Wrap long words rather than let one run of characters — a path, a URL — |
| 708 | decide the panel's minimum width. */ |
| 709 | overflow-wrap: anywhere; |
| 710 | } |
| 711 | #omni:focus, #omni:focus-visible { outline: none; } |
| 712 | #omni::placeholder { color: var(--fg-muted); } |
| 713 | |
| 714 | /* The directory, at the far end of the box. Dimmed and never wider than half |
| 715 | of it: it is context for the name, not a second thing competing with it, and |
| 716 | a deep path must not push the name it belongs to out of view. Read from its |
| 717 | end like every path here, so what goes when it does not fit is the front. */ |
| 718 | #omni-cwd { |
| 719 | /* It neither grows nor shrinks: a text input's flex base size is far wider |
| 720 | than the text in it, so a shrinkable directory beside one is squeezed |
| 721 | against a phantom — the box reads as half empty and the path still ends in |
| 722 | an ellipsis. Fixed at its content width, the input absorbs the slack |
| 723 | instead, and the only thing that can cut the path is the cap. */ |
| 724 | flex: 0 0 auto; min-width: 0; max-width: 72%; |
| 725 | /* The same line box as the text it sits beside, so it rides the first line |
| 726 | of a wrapped query rather than a half-step off it. */ |
| 727 | font-size: 10px; line-height: 18px; |
| 728 | color: var(--fg-muted); |
| 729 | overflow: hidden; text-overflow: ellipsis; white-space: nowrap; |
| 730 | direction: rtl; text-align: right; |
| 731 | } |
| 732 | #omni-cwd > span { direction: ltr; unicode-bidi: embed; } |
| 733 | |
| 734 | /* The slot a browser keeps its site-info button in, doing the same job for the |
| 735 | session. The hit area is a finger's worth of button and the glyph inside it |
| 736 | stays small — muted until you go for it, the way a chrome control that is not |
| 737 | asking for anything should sit. */ |
| 738 | #omni-here { |
| 739 | flex: none; |
| 740 | display: flex; align-items: center; justify-content: center; |
| 741 | width: 18px; height: 18px; margin-left: -4px; |
| 742 | padding: 0; border: 0; border-radius: 50%; |
| 743 | background: none; color: var(--fg-muted); cursor: pointer; |
| 744 | } |
| 745 | #omni-here svg { width: 13px; height: 13px; display: block; } |
| 746 | #omni-here:hover { background: var(--bg-panel); color: var(--fg); } |
| 747 | #omni-here[aria-expanded="true"] { background: var(--bg-panel); color: var(--fg); } |
| 748 | #omni-here:focus-visible { outline: 1px solid var(--accent); outline-offset: -1px; } |
| 749 | |
| 750 | /* --- session info ---------------------------------------------------------- |
| 751 | The panel behind the dot. A browser's site-info bubble in the same place and |
| 752 | with the same shape: rows of label and value, the ones you might want to |
| 753 | carry elsewhere copyable, and nothing modal about it. */ |
| 754 | .info-pop { |
| 755 | position: fixed; z-index: 20; |
| 756 | min-width: 15rem; max-width: min(22rem, calc(100vw - 12px)); |
| 757 | display: flex; flex-direction: column; gap: 5px; |
| 758 | padding: 8px 10px; |
| 759 | font-size: 11px; line-height: 1.45; |
| 760 | background: var(--bg-panel); color: var(--fg); |
| 761 | border: 1px solid var(--border); border-radius: 8px; |
| 762 | box-shadow: 0 6px 20px var(--shadow); |
| 763 | } |
| 764 | .info-pop .info-head { |
| 765 | display: flex; align-items: center; gap: 6px; |
| 766 | padding-bottom: 5px; |
| 767 | border-bottom: 1px solid var(--border); |
| 768 | } |
| 769 | .info-pop .info-head .dot { |
| 770 | flex: none; width: 8px; height: 8px; border-radius: 50%; |
| 771 | background: hsl(var(--group-h, 210) 60% 50%); |
| 772 | } |
| 773 | .info-pop .info-head .dot.grey { background: var(--fg-muted); } |
| 774 | .info-pop .info-head .name { |
| 775 | font-weight: 600; |
| 776 | overflow: hidden; text-overflow: ellipsis; white-space: nowrap; |
| 777 | } |
| 778 | .info-pop .info-head .sub { color: var(--fg-muted); font-size: 10px; } |
| 779 | .info-row { display: flex; align-items: baseline; gap: 8px; } |
| 780 | .info-row .k { flex: none; width: 4.5rem; color: var(--fg-muted); } |
| 781 | .info-row .v { |
| 782 | flex: 1 1 auto; min-width: 0; |
| 783 | overflow: hidden; text-overflow: ellipsis; white-space: nowrap; |
| 784 | } |
| 785 | /* A path is read from its end — the leaf is the part that identifies it — so |
| 786 | when it does not fit, the front is what goes. */ |
| 787 | .info-row .v.path { |
| 788 | font-family: ui-monospace, monospace; direction: rtl; text-align: left; |
| 789 | } |
| 790 | .info-row .v.path > span { direction: ltr; unicode-bidi: embed; } |
| 791 | .info-pop .info-agents { |
| 792 | display: flex; flex-direction: column; gap: 3px; |
| 793 | padding-top: 5px; border-top: 1px solid var(--border); |
| 794 | } |
| 795 | .info-pop .info-agent { display: flex; align-items: baseline; gap: 6px; } |
| 796 | .info-pop .info-agent .glyph { |
| 797 | flex: none; width: 10px; text-align: center; |
| 798 | color: var(--fg-muted); opacity: .6; |
| 799 | } |
| 800 | .info-pop .info-agent .glyph.working { color: var(--warn); opacity: 1; } |
| 801 | .info-pop .info-agent .glyph.waiting { color: var(--accent-hover); opacity: 1; } |
| 802 | /* No pulse in the panel or the omnibox: both are lists you are already reading |
| 803 | top to bottom, and a light that breathes in a list you are scanning is the |
| 804 | kind of motion the strip earns by being glanced at and these do not. */ |
| 805 | .info-pop .info-agent .glyph.ready { color: var(--ok); opacity: 1; } |
| 806 | .info-pop .info-agent .what { |
| 807 | flex: 1 1 auto; min-width: 0; |
| 808 | overflow: hidden; text-overflow: ellipsis; white-space: nowrap; |
| 809 | } |
| 810 | .info-pop .info-agent .where { flex: none; color: var(--fg-muted); font-size: 10px; } |
| 811 | .info-pop .info-empty { color: var(--fg-muted); } |
| 812 | .info-pop .info-copy { |
| 813 | align-self: flex-start; |
| 814 | padding: 2px 6px; |
| 815 | font: inherit; font-size: 10px; |
| 816 | color: var(--fg-muted); background: none; |
| 817 | border: 1px solid var(--border); border-radius: 5px; |
| 818 | cursor: pointer; |
| 819 | } |
| 820 | .info-pop .info-copy:hover { color: var(--fg); border-color: var(--accent); } |
| 821 | |
| 822 | body.compact #omni-strip { padding: 2px calc(var(--corner-w) + 4px) 3px 4px; } |
| 823 | body.compact #omni-box { height: 21px; } |
| 824 | |
| 825 | /* Over the terminal, not in the header: the header is a flex column that sizes |
| 826 | the terminal, so a list that grew inside it would reflow the terminal — and |
| 827 | re-fit xterm.js — on every keystroke. `top` is set in JS from the row's own |
| 828 | box, so it stays under the box at any density. */ |
| 829 | #omni-list { |
| 830 | position: fixed; left: 0; right: 0; |
| 831 | z-index: 5; |
| 832 | max-height: min(52vh, 320px); |
| 833 | overflow-y: auto; |
| 834 | padding: 3px 0; |
| 835 | background: var(--bg-panel); |
| 836 | border-bottom: 1px solid var(--border); |
| 837 | box-shadow: 0 6px 16px var(--shadow); |
| 838 | } |
| 839 | .omni-row { |
| 840 | display: flex; align-items: baseline; gap: 6px; |
| 841 | padding: 4px 10px; |
| 842 | font-size: 11px; line-height: 1.5; |
| 843 | color: var(--fg-muted); |
| 844 | cursor: pointer; |
| 845 | } |
| 846 | .omni-row .label { |
| 847 | color: var(--fg); |
| 848 | flex: 0 1 auto; min-width: 0; |
| 849 | overflow: hidden; text-overflow: ellipsis; white-space: nowrap; |
| 850 | } |
| 851 | /* The part you typed. The rest of the name is what tells you it is not the one |
| 852 | you meant, so the emphasis goes on the match rather than on the difference. */ |
| 853 | .omni-row .label b { color: var(--accent-hover); font-weight: 700; } |
| 854 | .omni-row .meta { |
| 855 | flex: 1 1 auto; min-width: 0; |
| 856 | text-align: right; |
| 857 | font-size: 10px; |
| 858 | overflow: hidden; text-overflow: ellipsis; white-space: nowrap; |
| 859 | } |
| 860 | /* Keyboard and pointer land on the same highlight — the row under the cursor |
| 861 | *is* the chosen one, so Enter and a click can never disagree. */ |
| 862 | .omni-row.active { background: var(--accent); } |
| 863 | .omni-row.active .label, |
| 864 | .omni-row.active .meta { color: var(--accent-fg); } |
| 865 | .omni-row.active .label b { color: var(--accent-fg); } |
| 866 | /* The one row that makes something rather than going somewhere. */ |
| 867 | .omni-row.create .label::before { |
| 868 | content: "+ "; |
| 869 | color: var(--ok); |
| 870 | font-weight: 700; |
| 871 | } |
| 872 | /* The row that reads the box as a question. Claude's own asterisk, the same |
| 873 | one the agent glyphs use, so the row is recognisable before it is read. */ |
| 874 | .omni-row.claude .label::before { |
| 875 | content: "✻ "; |
| 876 | color: var(--accent-hover); |
| 877 | font-weight: 700; |
| 878 | } |
| 879 | .omni-row.claude.active .label::before { color: var(--accent-fg); } |
| 880 | /* The row that reads the box as a shell command. A prompt's own mark, because |
| 881 | that is what the pane it opens will be showing. */ |
| 882 | .omni-row.run .label::before { |
| 883 | content: "$ "; |
| 884 | color: var(--ok); |
| 885 | font-weight: 700; |
| 886 | } |
| 887 | .omni-row.run .label { font-family: ui-monospace, monospace; } |
| 888 | /* The row that goes to another machine. An arrow leaving, because that is the |
| 889 | one thing about it that is not like every other row: the window it opens is |
| 890 | here, but what you end up talking to is somewhere else. */ |
| 891 | .omni-row.ssh .label::before { |
| 892 | content: "⇢ "; |
| 893 | color: var(--accent-hover); |
| 894 | font-weight: 700; |
| 895 | } |
| 896 | .omni-row.ssh.active .label::before { color: var(--accent-fg); } |
| 897 | .omni-row.ssh .label { font-family: ui-monospace, monospace; } |
| 898 | /* A directory, in both of the rows that are about one: what the box holds, and |
| 899 | the names it could be completed to. Monospaced for the reason the ssh and |
| 900 | shell rows are — a path is read character by character, and the column the |
| 901 | slashes line up in is how you read it. The completion's mark is a slash |
| 902 | because that is the key it stands in for. */ |
| 903 | .omni-row.project .label { font-family: ui-monospace, monospace; } |
| 904 | .omni-row.path .label { font-family: ui-monospace, monospace; } |
| 905 | .omni-row.path .label::before { |
| 906 | content: "/ "; |
| 907 | color: var(--fg-muted); |
| 908 | font-weight: 700; |
| 909 | } |
| 910 | .omni-row.path.active .label::before { color: var(--accent-fg); } |
| 911 | /* The rows that make something in this session rather than going anywhere. The |
| 912 | mark is per-row rather than per-kind — see `omniActions`. */ |
| 913 | .omni-row .mark { |
| 914 | flex: none; |
| 915 | color: var(--accent-hover); |
| 916 | font-weight: 700; |
| 917 | } |
| 918 | .omni-row.active .mark { color: var(--accent-fg); } |
| 919 | /* A row that is a label rather than an offer: the bare `!`, before there is a |
| 920 | command to run. Dimmed, and not styled as clickable, because it isn't. */ |
| 921 | .omni-row.hint { cursor: default; } |
| 922 | .omni-row.hint .label { color: var(--fg-muted); font-style: italic; } |
| 923 | .omni-row.run.active .label::before { color: var(--accent-fg); } |
| 924 | .omni-row .glyph { |
| 925 | flex: none; |
| 926 | width: 10px; height: 11px; |
| 927 | display: inline-flex; align-items: center; justify-content: center; |
| 928 | font-size: 10px; line-height: 1; |
| 929 | color: var(--fg-muted); opacity: .5; |
| 930 | align-self: center; |
| 931 | } |
| 932 | /* Unlike a tab's, this slot is always there even when it is empty: a list is |
| 933 | read down its left edge, and a glyph that collapses steps every row without |
| 934 | an agent out of line with the rows that have one. */ |
| 935 | .omni-row .glyph.working { color: var(--warn); opacity: 1; } |
| 936 | .omni-row .glyph.waiting { color: var(--accent-hover); opacity: 1; } |
| 937 | .omni-row .glyph.ready { color: var(--ok); opacity: 1; } |
| 938 | .omni-row.active .glyph { color: var(--accent-fg); opacity: 1; } |
| 939 | /* A finger is not a cursor: an 11px row is a comfortable read and a bad target, |
| 940 | so where the pointer is coarse the rows grow to something you can hit without |
| 941 | aiming. Nothing else about the list changes — the same rows, further apart. */ |
| 942 | @media (pointer: coarse) { |
| 943 | .omni-row { padding: 9px 10px; } |
| 944 | #omni-list { max-height: min(60vh, 420px); } |
| 945 | } |
| 946 | |
| 947 | /* Right-click menu. A plain positioned element: an extension page would |
| 948 | otherwise get the browser's own menu, which has nothing to say about tmux. */ |
| 949 | .tab-menu { |
| 950 | position: fixed; z-index: 20; min-width: 8rem; |
| 951 | display: flex; flex-direction: column; padding: 3px; |
| 952 | background: var(--bg-panel); color: var(--fg); |
| 953 | border: 1px solid var(--border); border-radius: 6px; |
| 954 | box-shadow: 0 6px 20px var(--shadow); |
| 955 | } |
| 956 | .tab-menu button { |
| 957 | background: transparent; color: var(--fg); |
| 958 | border: 0; border-radius: 4px; padding: 5px 8px; |
| 959 | font: inherit; font-size: 11px; text-align: left; cursor: pointer; |
| 960 | } |
| 961 | .tab-menu button:hover { background: var(--border); } |
| 962 | .tab-menu button.danger { color: var(--err); } |
| 963 | .tab-menu button.danger:hover { background: var(--err); color: var(--accent-fg); } |
| 964 | .tab-menu button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } |
| 965 | |
| 966 | /* The group's name, edited in place at the top of its menu — the same place a |
| 967 | browser puts a tab group's name. No separator under it: it and the palette |
| 968 | below are one block of "what this group is", divided from the actions. */ |
| 969 | .tab-menu .rename { |
| 970 | padding: 6px 8px 2px; |
| 971 | } |
| 972 | .tab-menu .rename input { |
| 973 | width: 100%; box-sizing: border-box; |
| 974 | padding: 4px 6px; |
| 975 | background: var(--bg); color: var(--fg); |
| 976 | border: 1px solid var(--border); border-radius: 4px; |
| 977 | font: inherit; font-size: 11px; |
| 978 | } |
| 979 | .tab-menu .rename input:focus { outline: none; border-color: var(--accent); } |
| 980 | /* A name tmux will not take. Said while it is being typed rather than after it |
| 981 | is sent, since the daemon's answer to one is silence. */ |
| 982 | .tab-menu .rename.bad input, |
| 983 | .tab-menu .rename.bad input:focus { border-color: var(--err); } |
| 984 | |
| 985 | /* The group palette, across the top of a group's menu. A row rather than nine |
| 986 | more menu items: these are a palette, and the eye finds a colour in a palette |
| 987 | faster than a colour name in a list. */ |
| 988 | .tab-menu .colours { |
| 989 | display: flex; gap: 5px; |
| 990 | padding: 7px 8px; |
| 991 | border-bottom: 1px solid var(--border); |
| 992 | margin-bottom: 3px; |
| 993 | } |
| 994 | .tab-menu .swatch { |
| 995 | flex: none; |
| 996 | width: 15px; height: 15px; padding: 0; |
| 997 | border-radius: 50%; border: 0; |
| 998 | background: hsl(var(--group-h, 210) 60% 50%); |
| 999 | cursor: pointer; |
| 1000 | } |
| 1001 | .tab-menu .swatch.grey { background: var(--fg-muted); } |
| 1002 | .tab-menu .swatch:hover { transform: scale(1.15); } |
| 1003 | /* The one showing now, ringed in the panel's own background so the ring reads |
| 1004 | as a gap around the colour rather than as a second colour on top of it. */ |
| 1005 | .tab-menu .swatch.on { |
| 1006 | box-shadow: 0 0 0 2px var(--bg-panel), 0 0 0 3.5px var(--fg-muted); |
| 1007 | } |
| 1008 | .tab-menu .swatch:focus-visible { |
| 1009 | outline: 2px solid var(--accent); outline-offset: 2px; |
| 1010 | } |
| 1011 | |
| 1012 | button.tab-close { |
| 1013 | position: absolute; right: calc(4px + var(--tab-foot)); top: 5px; |
| 1014 | width: 15px; height: 15px; padding: 0; |
| 1015 | display: inline-flex; align-items: center; justify-content: center; |
| 1016 | border: 0; border-radius: 4px; |
| 1017 | background: transparent; color: var(--fg-muted); cursor: pointer; |
| 1018 | opacity: .45; transition: opacity 90ms linear; |
| 1019 | } |
| 1020 | button.tab-close svg { width: 9px; height: 9px; display: block; } |
| 1021 | /* On every tab that has one, not just the active tab and the one under the |
| 1022 | pointer: a ✕ you have to go looking for is a ✕ you do not know is there, and |
| 1023 | the room for it is reserved either way — `.tab-slot.closable` pads for it |
| 1024 | whether or not it is painted, so showing it costs no layout. |
| 1025 | Dim until the tab is the one you are on or pointing at, which is what keeps a |
| 1026 | full strip of them from reading as noise. Every one of them is destructive, |
| 1027 | so the quiet default matters more here than in a browser. */ |
| 1028 | .tab-slot.active button.tab-close, |
| 1029 | .tab-slot:hover button.tab-close, |
| 1030 | .tab-slot:focus-within button.tab-close { opacity: 1; } |
| 1031 | /* Red on hover rather than the usual grey: this is the one control in the |
| 1032 | panel that destroys something, and it takes effect on the first click. */ |
| 1033 | button.tab-close:hover { background: var(--err); color: var(--accent-fg); } |
| 1034 | button.tab-close:focus-visible { outline: 2px solid var(--accent); outline-offset: 0; } |
| 1035 | |
| 1036 | |
| 1037 | button.icon { |
| 1038 | flex: none; |
| 1039 | width: 26px; height: 26px; padding: 0; |
| 1040 | display: inline-flex; align-items: center; justify-content: center; |
| 1041 | background: transparent; border: 0; border-radius: 5px; |
| 1042 | color: var(--fg-muted); cursor: pointer; |
| 1043 | } |
| 1044 | button.icon svg { width: 15px; height: 15px; display: block; } |
| 1045 | button.icon:hover { background: var(--border); color: var(--fg); } |
| 1046 | button.icon:active { transform: translateY(0.5px); } |
| 1047 | button.icon:focus-visible { outline: 2px solid var(--accent); outline-offset: -1px; } |
| 1048 | /* A window tab is hovering over "+", and letting go gives it a session of its |
| 1049 | own. Lit rather than outlined, because the pointer is over the button and a |
| 1050 | ring under a dragged tab reads as a gap. */ |
| 1051 | button.icon.drop { |
| 1052 | background: var(--accent); |
| 1053 | color: var(--accent-fg); |
| 1054 | } |
| 1055 | #pick.active { background: var(--accent); color: var(--accent-fg); } |
| 1056 | |
| 1057 | /* Jump to the next pane an agent is waiting in. Tinted by the state it would |
| 1058 | take you to, in the same three colours the tabs' glyphs use — the button is |
| 1059 | the one place in the header that says what is loudest *anywhere* on the |
| 1060 | server, so it has to answer "is there anything for me" before it is pressed |
| 1061 | and without being read as a fourth vocabulary. |
| 1062 | |
| 1063 | No pulse: the tab whose glyph is pulsing is already saying that, and a second |
| 1064 | thing moving in the same row for the same reason is noise. Dimmed and |
| 1065 | unpressable when no pane has an agent in it — there is nowhere to go, and the |
| 1066 | button that goes nowhere should not look like it does. */ |
| 1067 | #jump[data-state="waiting"] { color: var(--accent-hover); } |
| 1068 | #jump[data-state="ready"] { color: var(--ok); } |
| 1069 | #jump[data-state="working"] { color: var(--warn); } |
| 1070 | button.icon:disabled { opacity: 0.4; cursor: default; } |
| 1071 | button.icon:disabled:hover { background: transparent; color: var(--fg-muted); } |
| 1072 | button.icon:disabled:active { transform: none; } |
| 1073 | |
| 1074 | /* Push to talk, while held. Red rather than the accent: this one is putting |
| 1075 | bytes into the pane for as long as it looks like this, and it needs to be |
| 1076 | obvious at a glance that it is still down — a key stuck open in a terminal is |
| 1077 | not a small mistake. The ring is what carries that at 26px, where a fill |
| 1078 | alone reads much like the picker's. |
| 1079 | |
| 1080 | `:active` is deliberately not the hook. The hold survives the pointer sliding |
| 1081 | off the button (it is captured), and :active does not. */ |
| 1082 | /* Much bigger than its neighbours, and round. Every other icon here is a tap, |
| 1083 | which forgives a near miss; this one is held for the length of a sentence, and |
| 1084 | sliding off it mid-word is the failure that matters. The size is the fix, and |
| 1085 | the circle is what says "hold" rather than "click" at a glance. |
| 1086 | |
| 1087 | It takes most of the corner, which is what the corner is for: a row can only |
| 1088 | ever give an icon its own 26px, and a thumb wants more than that. It is sized |
| 1089 | from the height it is given rather than from the corner's width, because the |
| 1090 | corner shares that height with the Enter key under it and the header is a row |
| 1091 | taller in one mode than the other — a circle fixed at the full width would |
| 1092 | push the header taller in the mode that cannot spare it. The floor is 40px |
| 1093 | and the ceiling 48; both platforms' touch guidance names 44 and the band |
| 1094 | straddles it. |
| 1095 | |
| 1096 | A lifted surface rather than the recessed one an input gets: this is the one |
| 1097 | control in the panel you aim a thumb at, and on the header's own background |
| 1098 | a well would read as a hole rather than a button. */ |
| 1099 | #talk.big { |
| 1100 | flex: 1 1 auto; |
| 1101 | width: auto; height: auto; |
| 1102 | min-height: 40px; max-height: 48px; |
| 1103 | aspect-ratio: 1; |
| 1104 | border-radius: 50%; |
| 1105 | border: 1px solid var(--border); |
| 1106 | background: var(--bg-panel); |
| 1107 | box-shadow: 0 1px 2px var(--shadow); |
| 1108 | } |
| 1109 | #talk.big:hover { |
| 1110 | background: color-mix(in srgb, var(--fg) 8%, var(--bg-panel)); |
| 1111 | color: var(--fg); |
| 1112 | } |
| 1113 | #talk.big svg { width: 21px; height: 21px; } |
| 1114 | |
| 1115 | #talk.talking { |
| 1116 | background: var(--err); |
| 1117 | color: var(--accent-fg); |
| 1118 | border-color: var(--err); |
| 1119 | box-shadow: 0 0 0 3px color-mix(in srgb, var(--err) 30%, transparent); |
| 1120 | } |
| 1121 | #talk.talking svg { animation: talk-pulse 1.1s ease-in-out infinite; } |
| 1122 | @keyframes talk-pulse { |
| 1123 | 50% { opacity: .45; } |
| 1124 | } |
| 1125 | @media (prefers-reduced-motion: reduce) { |
| 1126 | #talk.talking svg { animation: none; } |
| 1127 | } |
| 1128 | /* Let go and the Return is queued, not sent: for the length of that window the |
| 1129 | button says so, by fading the hold's own colour out over exactly as long as |
| 1130 | you have to press it again. Nothing is sent while the ring is visible, so the |
| 1131 | cue and the thing it reports on end together. The duration comes from JS so |
| 1132 | there is one number for it; the fallback matches TALK_SUBMIT_MS. */ |
| 1133 | #talk.pending { |
| 1134 | animation: talk-arm var(--talk-submit, 1s) linear forwards; |
| 1135 | } |
| 1136 | @keyframes talk-arm { |
| 1137 | from { box-shadow: 0 0 0 3px color-mix(in srgb, var(--err) 30%, transparent); } |
| 1138 | to { box-shadow: 0 0 0 3px transparent; } |
| 1139 | } |
| 1140 | @media (prefers-reduced-motion: reduce) { |
| 1141 | #talk.pending { animation: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--err) 15%, transparent); } |
| 1142 | } |
| 1143 | |
| 1144 | /* Held with a finger: the browser's own long-press behaviours — text selection, |
| 1145 | the callout menu — would otherwise interrupt the gesture. */ |
| 1146 | #talk { touch-action: none; -webkit-user-select: none; user-select: none; } |
| 1147 | |
| 1148 | /* The session name is a tab of its own once there is one, so the status text |
| 1149 | stands down rather than saying it twice. The dot stays: it is the only thing |
| 1150 | reporting on the connection itself. */ |
| 1151 | body.has-session > header #status-text { display: none; } |
| 1152 | |
| 1153 | /* Compact: lose the status text, keep the targets clickable. */ |
| 1154 | body.compact .strip { padding: 2px calc(var(--corner-w) + 4px) 0 4px; } |
| 1155 | body.compact #status-text { display: none; } |
| 1156 | body.compact button.icon { width: 24px; height: 24px; } |
| 1157 | /* The mic is a touch target rather than a glyph, so density trims it a little |
| 1158 | and then stops: below the platforms' 44px floor it stops being one. */ |
| 1159 | /* On the header, not the corner: it is the rows that reserve this column, and |
| 1160 | they read it from there. */ |
| 1161 | body.compact header { --corner-w: 50px; } |
| 1162 | body.compact #corner { padding: 3px 3px 4px; } |
| 1163 | body.compact #talk.big { min-height: 38px; max-height: 44px; } |
| 1164 | body.compact #talk.big svg { width: 19px; height: 19px; } |
| 1165 | body.compact .tab-slot { max-width: calc(8rem + 2 * var(--tab-foot)); } |
| 1166 | body.compact button.tab { padding: 3px 6px 4px; } |
| 1167 | body.compact button.session-tab { padding: 2px 6px 3px; max-width: calc(8rem + 2 * var(--tab-foot)); } |
| 1168 | body.compact .tab-slot.closable button.tab { padding-right: 19px; } |
| 1169 | body.compact button.tab[aria-selected="true"] { padding-bottom: 9px; } |
| 1170 | body.compact button.tab-close { top: 4px; } |
| 1171 | body.compact .group-chip { padding: 1px 6px 2px; margin-left: 6px; font-size: 10px; } |
| 1172 | /* The count is what a folded chip has instead of tabs, so unlike a session |
| 1173 | tab's it survives compact. */ |
| 1174 | body.compact #term { padding: 2px; } |
| 1175 | |
| 1176 | /* Hidden: no header at all. A 4px strip along the top brings it back on |
| 1177 | hover, so the settings panel is never unreachable. */ |
| 1178 | body.chromeless header { |
| 1179 | position: absolute; top: 0; left: 0; right: 0; z-index: 5; |
| 1180 | opacity: 0; pointer-events: none; transition: opacity 90ms linear; |
| 1181 | } |
| 1182 | body.chromeless #reveal-strip { |
| 1183 | display: block; position: absolute; top: 0; left: 0; right: 0; |
| 1184 | height: 4px; z-index: 6; |
| 1185 | } |
| 1186 | body.chromeless #reveal-strip:hover ~ header, |
| 1187 | body.chromeless header:hover { opacity: 1; pointer-events: auto; } |
| 1188 | body.chromeless #term { padding: 2px; } |
| 1189 | #reveal-strip { display: none; } |
| 1190 | |
| 1191 | @keyframes pulse { 50% { opacity: .25; } } |
| 1192 | /* The spinner is driven from script, which parks it on a single frame when |
| 1193 | motion is reduced; this is only the resting glyphs' own breathing. They keep |
| 1194 | their colour, which is what carries the meaning — the pulse only ever made it |
| 1195 | easier to catch out of the corner of an eye. */ |
| 1196 | @media (prefers-reduced-motion: reduce) { |
| 1197 | button.tab .glyph.waiting, |
| 1198 | button.session-tab .glyph.waiting, |
| 1199 | .group-chip .glyph.waiting, |
| 1200 | button.tab .glyph.ready, |
| 1201 | button.session-tab .glyph.ready, |
| 1202 | .group-chip .glyph.ready { animation: none; } |
| 1203 | } |
| 1204 | /* `touch-action: none` because the terminal scrolls itself. Without it the |
| 1205 | browser claims a vertical drag as a pan of the panel, and having claimed it, |
| 1206 | stops sending the touchmove events sidebar.js turns into wheel events — the |
| 1207 | gesture would be swallowed before the terminal ever heard about it. A tap is |
| 1208 | untouched by this and still becomes a click. */ |
| 1209 | #term { |
| 1210 | flex: 1; min-height: 0; padding: 4px; overflow: hidden; |
| 1211 | touch-action: none; |
| 1212 | } |
| 1213 | #term .xterm { height: 100%; } |
| 1214 | /* --- settings, as a popup --------------------------------------------------- |
| 1215 | A floating card under the chevron, the way Chrome hangs its tab-search popup |
| 1216 | off the chevron in the same corner: rounded, lifted off the page by a shadow |
| 1217 | rather than joined to an edge, and scrolling inside itself so the terminal |
| 1218 | underneath never moves. `left` and `top` are set in JS from the chevron's own |
| 1219 | box, so it follows the button at any density. */ |
| 1220 | aside { |
| 1221 | position: fixed; z-index: 30; |
| 1222 | width: min(21rem, calc(100vw - 12px)); |
| 1223 | max-height: min(70vh, 34rem); overflow-y: auto; overscroll-behavior: contain; |
| 1224 | padding: 10px 12px 12px; |
| 1225 | background: var(--bg-panel); color: var(--fg); |
| 1226 | border: 1px solid var(--border); border-radius: 12px; |
| 1227 | box-shadow: 0 10px 30px var(--shadow), 0 2px 6px var(--shadow); |
| 1228 | } |
| 1229 | /* The card's own title bar, and a ✕ in it: a popup that floats has no edge to |
| 1230 | read as "this is a panel", so it says what it is and how to put it away. */ |
| 1231 | .settings-head { |
| 1232 | position: sticky; top: -10px; z-index: 1; |
| 1233 | display: flex; align-items: center; gap: 6px; |
| 1234 | margin: -10px -12px 8px; padding: 8px 8px 8px 12px; |
| 1235 | background: var(--bg-panel); |
| 1236 | border-bottom: 1px solid var(--border); |
| 1237 | border-radius: 12px 12px 0 0; |
| 1238 | } |
| 1239 | .settings-head button.icon { flex: none; width: 22px; height: 22px; } |
| 1240 | .settings-head button.icon svg { width: 12px; height: 12px; } |
| 1241 | .settings-head .title { |
| 1242 | flex: 1 1 auto; |
| 1243 | font-size: 11px; font-weight: 600; letter-spacing: .02em; |
| 1244 | } |
| 1245 | h2 { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; |
| 1246 | color: var(--fg-muted); margin: 12px 0 4px; font-weight: 600; } |
| 1247 | h2:first-child { margin-top: 0; } |
| 1248 | /* The chevron points at what it opened, and turns over while it is open. */ |
| 1249 | #settings-toggle svg { transition: transform 120ms ease; } |
| 1250 | #settings-toggle[aria-expanded="true"] svg { transform: rotate(180deg); } |
| 1251 | #settings-toggle[aria-expanded="true"] { background: var(--border); color: var(--fg); } |
| 1252 | @media (prefers-reduced-motion: reduce) { |
| 1253 | #settings-toggle svg { transition: none; } |
| 1254 | } |
| 1255 | .hint { color: var(--fg-muted); margin: 0 0 6px; font-size: 11px; } |
| 1256 | .row { display: flex; gap: 6px; align-items: center; } |
| 1257 | input[type=text], input[type=password], input:not([type]), #url { |
| 1258 | width: 100%; background: var(--bg-input); color: var(--fg); |
| 1259 | border: 1px solid var(--border); border-radius: 4px; padding: 5px 7px; |
| 1260 | font-family: ui-monospace, monospace; font-size: 11px; |
| 1261 | } |
| 1262 | button { |
| 1263 | background: var(--accent); color: var(--accent-fg); border: 0; border-radius: 4px; |
| 1264 | padding: 5px 10px; font-size: 11px; cursor: pointer; |
| 1265 | } |
| 1266 | button:hover { background: var(--accent-hover); } |
| 1267 | button.ghost { background: transparent; border: 1px solid var(--border); |
| 1268 | color: var(--fg-muted); padding: 3px 7px; } |
| 1269 | button.ghost:hover { background: var(--border); } |
| 1270 | pre { |
| 1271 | background: var(--bg-input); border: 1px solid var(--border); border-radius: 4px; |
| 1272 | padding: 6px; margin: 0 0 6px; overflow-x: auto; |
| 1273 | font-family: ui-monospace, monospace; font-size: 10px; |
| 1274 | white-space: pre-wrap; word-break: break-all; |
| 1275 | } |
| 1276 | #log { max-height: 120px; overflow-y: auto; color: var(--fg-muted); } |
| 1277 | code { font-family: ui-monospace, monospace; background: var(--bg-input); |
| 1278 | padding: 1px 3px; border-radius: 3px; font-size: 10px; } |
| 1279 | label.row { color: var(--fg-muted); font-size: 11px; margin-top: 5px; } |
| 1280 | label.row input { width: auto; } |
| 1281 | |
| 1282 | #trust-hint { margin-top: 8px; padding: 8px; border: 1px solid var(--warn-border); |
| 1283 | background: var(--warn-bg); border-radius: 4px; } |
| 1284 | .hint.warn { color: var(--warn); } |
| 1285 | |
| 1286 | /* Picked-element panel. A results card, not a toolbar: one primary action, |
| 1287 | the rest reduced to icons so the value itself is what draws the eye. */ |
| 1288 | #picked { |
| 1289 | flex: none; |
| 1290 | background: var(--bg-panel); |
| 1291 | border-bottom: 1px solid var(--border); |
| 1292 | border-top: 2px solid var(--accent); |
| 1293 | padding: 7px 8px 9px; |
| 1294 | } |
| 1295 | |
| 1296 | .picked-top { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; } |
| 1297 | .chip { |
| 1298 | font-family: ui-monospace, monospace; font-size: 11px; |
| 1299 | color: var(--accent); background: var(--bg-input); |
| 1300 | border: 1px solid var(--border); border-radius: 4px; |
| 1301 | padding: 1px 6px; flex: none; |
| 1302 | } |
| 1303 | #picked-meta { |
| 1304 | flex: 1; min-width: 0; font-size: 11px; color: var(--fg-muted); |
| 1305 | white-space: nowrap; overflow: hidden; text-overflow: ellipsis; |
| 1306 | } |
| 1307 | .picked-top button.icon { width: 22px; height: 22px; } |
| 1308 | .picked-top button.icon svg { width: 13px; height: 13px; } |
| 1309 | |
| 1310 | /* Segmented control instead of a native <select>: the options are few, short, |
| 1311 | and worth seeing all at once. */ |
| 1312 | .segmented { |
| 1313 | display: flex; gap: 2px; margin-bottom: 6px; |
| 1314 | background: var(--bg-input); |
| 1315 | border: 1px solid var(--border); border-radius: 6px; padding: 2px; |
| 1316 | } |
| 1317 | .segmented button { |
| 1318 | flex: 1 1 0; min-width: 0; |
| 1319 | background: transparent; color: var(--fg-muted); |
| 1320 | border: 0; border-radius: 4px; padding: 3px 4px; |
| 1321 | font-size: 10px; font-weight: 500; letter-spacing: .02em; |
| 1322 | cursor: pointer; white-space: nowrap; |
| 1323 | overflow: hidden; text-overflow: ellipsis; |
| 1324 | } |
| 1325 | .segmented button:hover { color: var(--fg); background: var(--border); } |
| 1326 | .segmented button[aria-selected="true"] { |
| 1327 | background: var(--accent); color: var(--accent-fg); |
| 1328 | } |
| 1329 | .segmented button[aria-selected="true"]:hover { background: var(--accent-hover); } |
| 1330 | |
| 1331 | #picked-value { |
| 1332 | background: var(--bg-input); |
| 1333 | border: 1px solid var(--border); border-radius: 6px; |
| 1334 | padding: 7px 8px; |
| 1335 | font-family: ui-monospace, monospace; font-size: 11px; line-height: 1.45; |
| 1336 | color: var(--fg); |
| 1337 | word-break: break-all; white-space: pre-wrap; |
| 1338 | max-height: 84px; overflow-y: auto; |
| 1339 | user-select: all; |
| 1340 | } |
| 1341 | #picked-value.empty { color: var(--fg-muted); font-style: italic; user-select: none; } |
| 1342 | |
| 1343 | #picked-insert { width: 100%; margin-top: 7px; padding: 6px 10px; font-size: 11px; } |
| 1344 | #picked-warn { margin: 6px 0 0; font-size: 11px; } |
| 1345 | #picked-grant { width: 100%; margin-top: 6px; } |
| 1346 | |
| 1347 | select { font-family: inherit; } |
| 1348 | |
| 1349 | #density-hint { margin-top: 6px; } |
| 1350 | |
| 1351 | #font-row { margin-top: 7px; } |
| 1352 | #font-row .hint { margin: 0; margin-right: auto; } |
| 1353 | #font-row button.icon { width: 24px; height: 24px; border: 1px solid var(--border); } |
| 1354 | #font-size { font-variant-numeric: tabular-nums; font-size: 11px; min-width: 2ch; |
| 1355 | text-align: center; color: var(--fg); } |
| 1356 | #font-reset { padding: 4px 8px; font-size: 11px; } |
| 1357 | #theme-select, #density-select, #tabmode-select { width: auto; background: var(--bg-input); color: var(--fg); |
| 1358 | border: 1px solid var(--border); border-radius: 4px; padding: 5px 7px; font-size: 11px; } |
| 1359 | |
| 1360 | /* --- offline overlay -------------------------------------------------------- |
| 1361 | The connection's state, said in the middle of the panel rather than in the |
| 1362 | corner of the header. Fixed and full-bleed so the card lands in the optical |
| 1363 | centre at any density, but the layer is click-through: the header underneath |
| 1364 | it is still how you switch sessions, and a disconnect must not take that |
| 1365 | away. z-index sits under the omnibar list (5) and the settings card (30), so |
| 1366 | either of those covers it rather than fighting it. */ |
| 1367 | #offline { |
| 1368 | position: fixed; inset: 0; z-index: 4; |
| 1369 | display: flex; align-items: center; justify-content: center; |
| 1370 | padding: 12px; |
| 1371 | pointer-events: none; |
| 1372 | } |
| 1373 | /* The one thing on the layer that takes the pointer. Opaque rather than a scrim |
| 1374 | over the whole panel: dimming everything would dim the header too, and the |
| 1375 | header is the half that still works. */ |
| 1376 | .offline-card { |
| 1377 | pointer-events: auto; |
| 1378 | width: min(15rem, 100%); |
| 1379 | padding: 14px 14px 12px; |
| 1380 | text-align: center; |
| 1381 | background: var(--bg-panel); color: var(--fg); |
| 1382 | border: 1px solid var(--border); border-radius: 12px; |
| 1383 | box-shadow: 0 10px 30px var(--shadow), 0 2px 6px var(--shadow); |
| 1384 | } |
| 1385 | #offline-icon { display: block; width: 26px; height: 26px; margin: 0 auto 6px; |
| 1386 | color: var(--fg-muted); } |
| 1387 | #offline-title { margin: 0; font-size: 12px; font-weight: 600; letter-spacing: .01em; } |
| 1388 | #offline-msg { margin: 4px 0 0; font-size: 11px; line-height: 1.45; color: var(--fg-muted); } |
| 1389 | #offline-msg:empty { display: none; } |
| 1390 | #offline-msg code { font-size: 10px; } |
| 1391 | .offline-actions { display: flex; gap: 6px; justify-content: center; margin-top: 10px; } |
| 1392 | .offline-actions button { padding: 5px 12px; } |
| 1393 | |
| 1394 | /* While a connection is in flight there is nothing to press — pressing |
| 1395 | Reconnect again would only replace the socket that is already dialling — so |
| 1396 | the card swaps its buttons and its broken link for a ring that says "wait". */ |
| 1397 | #offline-spinner { display: none; width: 22px; height: 22px; margin: 2px auto 8px; |
| 1398 | border: 2px solid var(--border); border-top-color: var(--accent); |
| 1399 | border-radius: 50%; animation: offline-spin 700ms linear infinite; } |
| 1400 | #offline[data-state="pending"] #offline-spinner { display: block; } |
| 1401 | #offline[data-state="pending"] #offline-icon, |
| 1402 | #offline[data-state="pending"] .offline-actions { display: none; } |
| 1403 | @keyframes offline-spin { to { transform: rotate(360deg); } } |
| 1404 | @media (prefers-reduced-motion: reduce) { |
| 1405 | #offline-spinner { animation-duration: 2s; } |
| 1406 | } |