feat(maestro): terminal-native web UI pass + design kit
ci / rust (push) Successful in 4m26s

Live-review iteration on the browser SPA, driven by crit feedback.

Sidebar / session tree
- Compact single-line leaf rows; status shown as a faint "alive" row wash
  instead of per-row dots (exited rows dim + keep the "exited" badge).
- Host is now the first level of nesting (host → label path → session); the
  inline @daemon is dropped from rows.
- Flush, borderless search input spanning the sidebar; drag-to-resize grip on
  the sidebar edge (width persisted to localStorage); narrower default.
- Per-row clone action (opens the spawn modal prefilled); right-pointing
  chevron when collapsed.

Chrome / layout
- Buttons swapped to a bracket terminal style ([ + Spawn ]); ghost/icon drop
  the brackets. Old filled styles removed.
- Squared corners globally (radius tokens) and tighter, denser spacing.
- Session tally + daemon chips moved into the centered app header.
- Shorter control height; shared --bar-h aligns the search and attach bars.

Attach / terminal
- Useful attach title (name · command @ host) over a bare UUID.
- Robust fit: compute columns from the viewport's scrollbar-excluded
  clientWidth (fixes mobile under-fit and desktop overflow); ResizeObserver +
  font-load refit; full-bleed terminal on mobile.

Spawn / empty states
- Spawn modal accepts a prefill (clone); real "+ Spawn" buttons in welcome and
  no-sessions empty states.

Dev
- New ?kit design-kit page (kit.ts/kit.css, DEV-only) showcasing components.
- Richer ?mock fixture with deeper label nesting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-20 12:11:43 +00:00
co-authored by Claude Opus 4.8
parent eb2162889e
commit c02ff02849
9 changed files with 1130 additions and 164 deletions
+286 -120
View File
@@ -31,16 +31,22 @@
--sp-6: 24px;
--sp-8: 32px;
/* ---- radii ---- */
--r-1: 6px;
--r-2: 9px;
--r-3: 14px;
--r-pill: 999px;
/* ---- radii ----
Terminal-native: crisp, near-square corners. Round dots (status, brand)
use border-radius:50% directly and are unaffected; these tokens govern
rectangles (buttons, inputs, cards, tags) and stay sharp on purpose. */
--r-1: 2px;
--r-2: 3px;
--r-3: 4px;
--r-pill: 3px;
/* ---- sizing ---- */
--row-h: 46px;
--control-h: 38px;
--control-h: 30px;
--tap: 40px;
/* Height of the two top-of-pane bars (sidebar search + attach header) — shared
so they line up across the split. */
--bar-h: 34px;
/* ---- motion ---- */
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
@@ -248,18 +254,18 @@ textarea {
border-radius: var(--r-1);
}
/* ---------- buttons ---------- */
/* ---------- buttons ----------
Terminal-native: the brackets are the chrome. Default + primary wear
[ brackets ]; ghost/icon drop them for quieter nav and single glyphs. */
.btn {
--_bg: var(--surface-2);
appearance: none;
border: 1px solid var(--border);
background: var(--_bg);
border: 1px solid transparent;
background: transparent;
color: var(--ink);
padding: 0 var(--sp-4);
padding: 0 var(--sp-1);
height: var(--control-h);
min-height: var(--control-h);
border-radius: var(--r-2);
cursor: pointer;
font-family: var(--font-mono);
font-size: var(--fs-13);
@@ -268,17 +274,31 @@ textarea {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--sp-2);
gap: 4px;
white-space: nowrap;
transition:
color var(--dur-fast) var(--ease-out),
background var(--dur-fast) var(--ease-out),
border-color var(--dur-fast) var(--ease-out),
transform var(--dur-fast) var(--ease-out);
}
.btn::before {
content: "[";
color: var(--faint);
}
.btn::after {
content: "]";
color: var(--faint);
}
.btn:hover {
background: var(--surface-3);
border-color: var(--border-strong);
color: var(--accent);
}
.btn:hover::before,
.btn:hover::after {
color: var(--accent);
}
.btn:active {
@@ -292,25 +312,32 @@ textarea {
}
.btn-primary {
--_bg: var(--accent);
border-color: transparent;
color: var(--accent-ink);
color: var(--accent);
font-weight: 600;
}
.btn-primary:hover {
background: var(--accent-strong);
border-color: transparent;
.btn-primary::before,
.btn-primary::after {
color: var(--accent);
}
.btn-primary:hover {
background: var(--accent-tint);
}
/* Ghost: no brackets — quiet nav (back, disconnect, theme, kit) and icons. */
.btn-ghost {
--_bg: transparent;
border-color: transparent;
color: var(--muted);
}
.btn-ghost::before,
.btn-ghost::after {
content: none;
}
.btn-ghost:hover {
color: var(--ink);
background: var(--surface-2);
border-color: var(--border);
}
.btn-icon {
@@ -319,6 +346,15 @@ textarea {
font-size: var(--fs-15);
}
.btn-icon::before,
.btn-icon::after {
content: none;
}
.btn-icon:hover {
background: var(--surface-2);
}
/* ---------- app shell: sidebar + content ---------- */
/* Global app header: brand + app-level actions (spawn, theme toggle), spanning
@@ -328,17 +364,45 @@ textarea {
align-items: center;
gap: var(--sp-3);
flex: 0 0 auto;
padding: var(--sp-3) var(--sp-4);
padding: var(--sp-1) var(--sp-3);
border-bottom: 1px solid var(--border);
background: color-mix(in oklab, var(--surface) 82%, transparent);
backdrop-filter: blur(8px);
}
/* Three-part header: brand (left), status (centered), actions (right). Equal
flex on the outer two keeps the status group truly centered. */
.app-header .brand {
flex: 1 1 0;
min-width: 0;
margin-right: 0;
}
.app-status {
flex: 0 1 auto;
display: flex;
justify-content: center;
min-width: 0;
overflow: hidden;
}
.app-status .statusbar {
border-bottom: 0;
background: transparent;
padding: 0;
flex-wrap: nowrap;
}
.app-status .statusbar .spacer {
display: none;
}
.app-actions {
display: flex;
align-items: center;
gap: var(--sp-2);
margin-left: auto;
flex: 1 1 0;
justify-content: flex-end;
}
.app-shell {
@@ -351,7 +415,8 @@ textarea {
@media (min-width: 860px) {
.app-shell {
display: grid;
grid-template-columns: clamp(280px, 26vw, 360px) minmax(0, 1fr);
grid-template-columns:
var(--sidebar-w, clamp(220px, 22vw, 320px)) minmax(0, 1fr);
}
}
@@ -361,21 +426,64 @@ textarea {
min-height: 0;
height: 100%;
overflow: hidden;
position: relative;
background: color-mix(in oklab, var(--surface) 26%, var(--bg));
border-right: 1px solid var(--border);
}
/* Drag-to-resize grip on the sidebar's right edge (desktop split only). */
.sidebar-resize {
position: absolute;
top: 0;
right: -3px;
width: 6px;
height: 100%;
cursor: col-resize;
z-index: var(--z-sticky);
touch-action: none;
}
.sidebar-resize:hover,
.sidebar-resize:active {
background: var(--accent-ring);
}
@media (max-width: 859px) {
.sidebar-resize {
display: none;
}
.app-status {
display: none;
}
}
/* Sidebar's own slim header: just the session search (app-level actions live
in the global top bar). */
/* The search input IS the header — it spans edge to edge, flush, with just a
bottom rule for separation. No wrapper padding, no boxed border/radius. */
.sidebar-search {
flex: 0 0 auto;
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--border);
}
.sidebar-search .search {
width: 100%;
max-width: none;
height: var(--bar-h);
border: 0;
border-bottom: 1px solid var(--border);
border-radius: 0;
background: transparent;
}
.sidebar-search .search:hover {
background: var(--surface-2);
border-bottom-color: var(--border-strong);
}
.sidebar-search .search:focus {
background: var(--surface-2);
border-bottom-color: var(--accent);
box-shadow: none;
}
.content {
@@ -419,7 +527,7 @@ textarea {
flex-wrap: wrap;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
padding: var(--sp-2) var(--sp-3);
border-bottom: 1px solid var(--border);
background: color-mix(in oklab, var(--surface) 82%, transparent);
backdrop-filter: blur(8px);
@@ -497,7 +605,7 @@ textarea {
align-items: center;
flex-wrap: wrap;
gap: var(--sp-2) var(--sp-3);
padding: var(--sp-2) var(--sp-4);
padding: var(--sp-2) var(--sp-3);
border-bottom: 1px solid var(--border);
background: var(--surface);
font-size: var(--fs-12);
@@ -588,7 +696,7 @@ textarea {
.tree {
flex: 1 1 auto;
overflow: auto;
padding: var(--sp-2) 0 var(--sp-8);
padding: var(--sp-1) 0 var(--sp-4);
scrollbar-width: thin;
scrollbar-color: var(--border-strong) transparent;
}
@@ -659,112 +767,85 @@ textarea {
.tree-leaf {
display: flex;
align-items: center;
gap: var(--sp-3);
min-height: var(--row-h);
padding: var(--sp-2) var(--sp-4) var(--sp-2) var(--sp-3);
border-left: 2px solid transparent;
gap: var(--sp-2);
min-height: 30px;
margin: 1px var(--sp-2);
padding: 4px var(--sp-3);
border-radius: var(--r-1);
color: var(--ink);
position: relative;
transition: background var(--dur-fast) var(--ease-out);
transition:
background var(--dur-fast) var(--ease-out),
box-shadow var(--dur-fast) var(--ease-out);
}
.tree-leaf::after {
content: "";
position: absolute;
left: var(--sp-4);
right: var(--sp-4);
bottom: 0;
height: 1px;
background: var(--border);
opacity: 0.5;
/* "Alive" reads as a faint wash on the row itself — no per-row status icon. */
.tree-leaf[data-status="running"] {
background: color-mix(in oklab, var(--accent) 6%, transparent);
}
.tree-leaf:hover {
background: var(--surface);
background: var(--surface-2);
}
.tree-leaf[data-status="running"]:hover {
background: color-mix(in oklab, var(--accent) 13%, var(--surface-2));
}
.tree-leaf:focus-visible {
outline: none;
background: var(--surface);
border-left-color: var(--accent);
box-shadow: inset 0 0 0 1px var(--accent-ring);
}
/* The session currently mounted in the content pane. */
.tree-leaf.is-active {
.tree-leaf.is-active,
.tree-leaf.is-active[data-status="running"] {
background: var(--accent-tint);
border-left-color: var(--accent);
box-shadow: inset 0 0 0 1px var(--accent-ring);
}
.tree-leaf.is-active .leaf-name {
color: var(--ink);
}
.tree-leaf.is-active::after {
opacity: 0;
}
/* Exited sessions recede: dimmed name + an "exited" badge carry status, so it's
never the running tint's absence alone doing the work. */
.tree-leaf[data-status="dead"] {
color: var(--muted);
}
.leaf-status {
width: 10px;
height: 10px;
flex: 0 0 10px;
border-radius: 50%;
position: relative;
}
[data-status="running"] .leaf-status {
background: var(--live);
box-shadow: 0 0 0 3px var(--live-glow);
}
[data-status="running"] .leaf-status::before {
content: "";
position: absolute;
inset: -3px;
border-radius: 50%;
border: 1px solid var(--live);
opacity: 0.6;
animation: ripple 2.4s var(--ease-out) infinite;
}
[data-status="dead"] .leaf-status {
background: transparent;
border: 1.5px solid var(--faint);
}
.leaf-main {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-width: 0;
gap: 1px;
.tree-leaf[data-status="dead"] .leaf-name {
color: var(--muted);
font-weight: 400;
}
.leaf-name {
font-weight: 500;
font-size: var(--fs-14);
font-size: var(--fs-13);
flex: 0 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.leaf-meta {
font-size: var(--fs-12);
font-size: var(--fs-11);
color: var(--muted);
margin-left: auto;
padding-left: var(--sp-2);
min-width: 0;
flex: 0 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: flex;
align-items: center;
display: inline-flex;
align-items: baseline;
gap: var(--sp-2);
}
.leaf-daemon {
color: var(--faint);
flex: 0 0 auto;
}
.leaf-daemon::before {
@@ -774,6 +855,9 @@ textarea {
.leaf-cmd {
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.leaf-badge {
@@ -787,36 +871,50 @@ textarea {
flex: 0 0 auto;
}
.leaf-kill {
background: transparent;
.leaf-actions {
position: absolute;
right: var(--sp-1);
top: 50%;
transform: translateY(-50%);
display: inline-flex;
gap: 2px;
}
.leaf-act {
background: var(--surface-3);
color: var(--faint);
border: 1px solid transparent;
border-radius: var(--r-2);
width: var(--tap);
height: var(--tap);
border: 1px solid var(--border);
border-radius: var(--r-1);
width: 24px;
height: 24px;
cursor: pointer;
flex: 0 0 var(--tap);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: var(--fs-14);
font-size: var(--fs-12);
transition:
color var(--dur-fast) var(--ease-out),
background var(--dur-fast) var(--ease-out),
border-color var(--dur-fast) var(--ease-out),
opacity var(--dur-fast) var(--ease-out);
border-color var(--dur-fast) var(--ease-out);
}
@media (hover: hover) {
.leaf-kill {
.leaf-actions {
opacity: 0;
transition: opacity var(--dur-fast) var(--ease-out);
}
.tree-leaf:hover .leaf-kill,
.tree-leaf:focus-within .leaf-kill {
.tree-leaf:hover .leaf-actions,
.tree-leaf:focus-within .leaf-actions {
opacity: 1;
}
}
.leaf-clone:hover {
color: var(--accent);
background: var(--accent-tint);
border-color: color-mix(in oklab, var(--accent) 40%, transparent);
}
.leaf-kill:hover {
color: var(--danger);
background: var(--danger-tint);
@@ -826,7 +924,7 @@ textarea {
/* ---------- empty / loading ---------- */
.empty {
padding: var(--sp-8) var(--sp-4);
padding: var(--sp-5) var(--sp-4);
color: var(--muted);
text-align: center;
display: flex;
@@ -871,6 +969,12 @@ textarea {
color: var(--ink);
}
.empty-actions {
display: flex;
gap: var(--sp-2);
margin-top: var(--sp-2);
}
.empty.connecting .empty-glyph {
color: var(--warn);
animation: pulse 1.6s var(--ease-out) infinite;
@@ -888,16 +992,27 @@ textarea {
.attach-header {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-2) var(--sp-3);
gap: var(--sp-2);
height: var(--bar-h);
padding: 0 var(--sp-2);
border-bottom: 1px solid var(--border);
background: var(--surface);
}
/* Compact controls so the bar hugs the title — no tall buttons. */
.attach-header .btn {
height: 26px;
min-height: 26px;
}
.attach-header .btn-icon {
width: 26px;
}
.attach-header .back {
font-size: var(--fs-17);
width: var(--tap);
height: var(--control-h);
font-size: var(--fs-15);
width: 28px;
height: 26px;
}
/* Desktop keeps the session list permanently in view, so the in-header "back"
@@ -932,12 +1047,37 @@ textarea {
color: var(--muted);
}
.attach-title .at-name {
color: var(--ink);
font-weight: 500;
}
.attach-title .at-cmd {
color: var(--muted);
}
.attach-title .at-cmd::before {
content: "·";
margin: 0 6px;
color: var(--faint);
}
.attach-title .at-host {
color: var(--faint);
margin-left: 6px;
}
.attach-title .at-host::before {
content: "@";
opacity: 0.6;
}
.attach-state {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: var(--fs-12);
padding: 4px 10px;
padding: 2px var(--sp-2);
border-radius: var(--r-pill);
background: var(--surface-2);
color: var(--muted);
@@ -979,6 +1119,7 @@ textarea {
min-height: 0;
padding: var(--sp-2);
display: flex;
overflow: hidden;
background: var(--term-bg);
}
@@ -987,9 +1128,34 @@ textarea {
height: 100%;
}
/* xterm hard-codes overflow-y: scroll on its viewport — an always-on bar that
(a) shows even with no scrollback and (b) makes FitAddon subtract its width,
costing a column. Hide it entirely: scroll still works via wheel/touch/keys,
and fit reclaims the full width. */
/* Higher specificity than xterm's own `.xterm .xterm-viewport` (which loads
after this and would otherwise win the tie) so the always-on scrollbar
reservation actually goes away. */
.attach-body .xterm .xterm-viewport {
overflow-y: auto;
scrollbar-width: none;
}
.attach-body .xterm .xterm-viewport::-webkit-scrollbar {
width: 0;
height: 0;
}
/* Mobile: full-bleed terminal — the desktop frame padding wastes space and the
sub-cell remainder it left was triggering a stray scrollbar. */
@media (max-width: 859px) {
.attach-body {
padding: 0;
}
}
.attach-status {
margin: var(--sp-4);
padding: var(--sp-3) var(--sp-4);
padding: var(--sp-2) var(--sp-3);
border-radius: var(--r-2);
background: var(--surface);
border: 1px solid var(--border);
@@ -1048,7 +1214,7 @@ textarea {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-4);
padding: var(--sp-3);
border-bottom: 1px solid var(--border);
}
@@ -1068,11 +1234,11 @@ textarea {
}
.modal-body {
padding: var(--sp-4);
padding: var(--sp-3);
overflow: auto;
display: flex;
flex-direction: column;
gap: var(--sp-3);
gap: var(--sp-2);
scrollbar-width: thin;
scrollbar-color: var(--border-strong) transparent;
}
@@ -1220,7 +1386,7 @@ textarea {
.not-found {
margin: auto;
padding: var(--sp-8) var(--sp-4);
padding: var(--sp-5) var(--sp-4);
display: flex;
flex-direction: column;
gap: var(--sp-3);
@@ -1366,7 +1532,7 @@ textarea {
flex: 1 1 100%;
justify-content: flex-start;
}
.tree-leaf .leaf-kill {
.tree-leaf .leaf-actions {
opacity: 1;
}
}