feat(grid): desktop split-grid watch board — v1 (single/1×2/2×2)
When several sessions are open on a large screen (≥1024px), the terminal
area can split into 1×2 or 2×2 so multiple LIVE, interactive terminals show
at once — a monitoring convenience for vibe-coding several Claude sessions.
Design keeps activeIndex as the single focused pane, so keybar/voice/approval
routing is unchanged; a new gridLayout + derived visible-set lets several
.term-cell wrappers show together inside a CSS-grid #term. The server and WS
protocol are untouched.
- public/grid-layout.ts (new): layout types/capacity, visibleIndices,
matchMedia desktop gate (GRID_MIN_WIDTH=1024), persistence, toolbar toggle.
- tabs.ts: pane→.term-cell wrapper (header + terminal + inline-approve footer);
applyLayout() owns show/hide + grid class + cell order + placeholders;
board-aware activate() (never focuses a hidden pane); setFocused/setGridLayout
delegate to it; renderCell/renderInlineApprove; refitVisible; notification
suppression for on-screen panes (factoring in the home overlay).
- terminal-session.ts: show({focus}) so non-focused quadrants don't steal
keyboard focus; onFocus callback (capture-phase pointerdown).
- main.ts: mount the toggle; window-focus refit → refitVisible.
- style.css: cell/grid model (.term-pane → relative flex child), focus ring,
pending pulse, inline approve, placeholder, toggle + coarse-pointer targets.
- tests: grid-layout.test.ts + split-grid block in tabs.test.ts (+33).
Adversarial review (4 lenses → per-finding verify) caught and fixed a HIGH:
activate() was not board-aware, so opening a tab on a full grid focused a
hidden pane (typing into an invisible session). Verified: typecheck + build:web
clean, 1566 tests pass, grid-layout 95% / tabs 94% coverage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
264
public/style.css
264
public/style.css
@@ -298,14 +298,276 @@ body {
|
||||
background: var(--bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.term-pane {
|
||||
/* Each pane lives in a .term-cell wrapper (header + terminal + optional inline
|
||||
* approve). In single mode the cell fills #term absolutely (only the focused one
|
||||
* is shown) so the classic one-pane look is unchanged; split layouts turn #term
|
||||
* into a CSS grid and the cells flow into tracks. */
|
||||
.term-cell {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.term-pane {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 6px 8px 0;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Cell header + inline-approve footer are hidden in single mode. */
|
||||
.cell-head,
|
||||
.cell-approve {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Split-grid layouts (desktop watch board) ────────────────────────── */
|
||||
#term.lay-split-2,
|
||||
#term.lay-grid-4 {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
}
|
||||
#term.lay-split-2 {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
#term.lay-grid-4 {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
}
|
||||
#term.lay-split-2 .term-cell,
|
||||
#term.lay-grid-4 .term-cell {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
}
|
||||
#term.lay-split-2 .cell-head,
|
||||
#term.lay-grid-4 .cell-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 9px;
|
||||
background: linear-gradient(180deg, var(--surface-2), var(--surface-1));
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.cell-name {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.cell-status {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cell-status.cs-working {
|
||||
color: var(--amber);
|
||||
}
|
||||
.cell-status.cs-waiting {
|
||||
color: var(--accent);
|
||||
}
|
||||
.cell-status.cs-idle {
|
||||
color: var(--green);
|
||||
}
|
||||
.cell-status.cs-stuck {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
/* Focus ring — the quadrant that owns keyboard / keybar / voice / approvals. */
|
||||
#term.lay-split-2 .term-cell.focused,
|
||||
#term.lay-grid-4 .term-cell.focused {
|
||||
border-color: var(--accent);
|
||||
box-shadow:
|
||||
0 0 0 1px var(--accent),
|
||||
var(--shadow);
|
||||
}
|
||||
/* Pending glow — a non-focused quadrant is waiting for approval. */
|
||||
#term.lay-split-2 .term-cell.pending:not(.focused),
|
||||
#term.lay-grid-4 .term-cell.pending:not(.focused) {
|
||||
border-color: var(--amber);
|
||||
animation: cell-pending 2.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes cell-pending {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 1px rgba(245, 177, 76, 0.35);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(245, 177, 76, 0.7),
|
||||
0 0 26px -8px rgba(245, 177, 76, 0.6);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#term.lay-split-2 .term-cell.pending,
|
||||
#term.lay-grid-4 .term-cell.pending {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Inline per-quadrant approve footer. */
|
||||
#term.lay-split-2 .cell-approve,
|
||||
#term.lay-grid-4 .cell-approve {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 9px;
|
||||
background: rgba(245, 177, 76, 0.08);
|
||||
border-top: 1px solid rgba(245, 177, 76, 0.35);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.cell-approve-label {
|
||||
color: var(--amber);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.cell-approve-btns {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.cell-approve-yes,
|
||||
.cell-approve-no {
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
padding: 3px 11px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cell-approve-yes {
|
||||
background: var(--green);
|
||||
color: #06231a;
|
||||
}
|
||||
.cell-approve-no {
|
||||
background: var(--surface-3);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.cell-approve-yes:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
.cell-approve-no:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Empty-slot placeholder (fewer sessions than the layout holds). */
|
||||
.term-cell.slot-empty {
|
||||
border: 1px dashed var(--border-strong);
|
||||
background: repeating-linear-gradient(
|
||||
-45deg,
|
||||
transparent,
|
||||
transparent 10px,
|
||||
rgba(255, 255, 255, 0.02) 10px,
|
||||
rgba(255, 255, 255, 0.02) 20px
|
||||
);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.term-cell.slot-empty:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.slot-new {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--text-faint);
|
||||
font-size: 12px;
|
||||
font-family: var(--ui-font);
|
||||
cursor: pointer;
|
||||
}
|
||||
.slot-new b {
|
||||
font-size: 26px;
|
||||
font-weight: 300;
|
||||
line-height: 1;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.term-cell.slot-empty:hover .slot-new {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* ── Grid-toggle segmented control (toolbar) ─────────────────────────── */
|
||||
.grid-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-left: 4px;
|
||||
padding: 2px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.grid-toggle-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-faint);
|
||||
cursor: pointer;
|
||||
}
|
||||
.grid-toggle-btn:hover {
|
||||
color: var(--text);
|
||||
background: var(--surface-3);
|
||||
}
|
||||
.grid-toggle-btn[aria-pressed='true'] {
|
||||
color: var(--on-accent);
|
||||
background: var(--accent);
|
||||
}
|
||||
.grid-glyph {
|
||||
display: grid;
|
||||
gap: 1.5px;
|
||||
width: 14px;
|
||||
height: 12px;
|
||||
}
|
||||
.grid-glyph.gl-single {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.grid-glyph.gl-split-2 {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.grid-glyph.gl-grid-4 {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
}
|
||||
.grid-glyph i {
|
||||
background: currentColor;
|
||||
border-radius: 1px;
|
||||
}
|
||||
/* Touch tablets clear the 1024px width gate but need bigger tap targets — mirror
|
||||
* the project's existing coarse-pointer bump for the tab/key bars. */
|
||||
@media (pointer: coarse) {
|
||||
.grid-toggle {
|
||||
gap: 3px;
|
||||
}
|
||||
.grid-toggle-btn {
|
||||
width: 36px;
|
||||
height: 34px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Key bar (rounded chips) ─────────────────────────────────────── */
|
||||
#keybar {
|
||||
position: fixed;
|
||||
|
||||
Reference in New Issue
Block a user