feat(maestro): colour the previewed pane and its sidebar row

A preview was distinguished only by the chip and a dashed ring, both easy to
miss, and the sidebar's hover reused the neutral surface — so a hovered row
said nothing about what the content pane was showing.

Adds a `--preview` token family on a hue of its own, deliberately not the
accent: the accent means "this is the one you picked", which a preview is not.
The previewed pane's titlebar, the chip, the throwaway tab, the previewed
sidebar row and hover all carry it. Palettes whose accent is already violet
(periwinkle, orchid) move the hue via `--preview-h`.
This commit is contained in:
Miguel Palhas
2026-09-03 13:35:55 +01:00
parent f443f625a8
commit 7a3287f231
7 changed files with 137 additions and 8 deletions
+12
View File
@@ -342,6 +342,9 @@ export interface SidebarHandle {
setActive(id: string | null): void;
/** Apply the full open/focused highlight state. */
setHighlights(h: SidebarHighlights): void;
/** Mark the row the content pane is previewing (docs/PREVIEW.md), or clear it
* with `null`. A preview is a view, not state, so this only paints. */
setPreview(sessionId: string | null): void;
/** Hand the keyboard to the session list, highlighting a row to walk from. */
focusList(): void;
/** Whether the session list currently holds DOM focus. */
@@ -501,6 +504,10 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
});
let highlights: SidebarHighlights = { open: new Set(), focused: null };
// The previewed row is painted alongside the open/focused tiers so a
// re-render can't drop it, but it is never part of `highlights`: a preview
// is a view, and nothing about it is durable (docs/PREVIEW.md).
let previewing: string | null = null;
const applyHighlights = () => {
for (const n of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
const id = n.dataset.sessionId;
@@ -509,6 +516,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
const open = highlights.open.has(id);
n.classList.toggle("is-active", focused);
n.classList.toggle("is-open", open && !focused);
n.classList.toggle("is-previewing", previewing === id);
}
};
@@ -692,6 +700,10 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
highlights = h;
applyHighlights();
},
setPreview(sessionId: string | null) {
previewing = sessionId;
applyHighlights();
},
focusList() {
// Start from the session on screen — that is where the user's attention
// already is — falling back to the standing highlight, then the top row.
+1
View File
@@ -129,6 +129,7 @@ export function renderKit(): HTMLElement {
"--muted",
"--faint",
"--accent",
"--preview",
"--danger",
"--warn",
];
+1
View File
@@ -36,6 +36,7 @@ function fakeSidebar(): SidebarHandle {
el: document.createElement("aside"),
setActive() {},
setHighlights() {},
setPreview() {},
focusList() {},
hasListFocus() {
return false;
+12
View File
@@ -221,6 +221,12 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
const tab = activeTab(layout);
for (const pane of canvas.querySelectorAll<HTMLElement>(".mv-pane")) {
pane.classList.toggle("is-focused", pane.dataset.leafId === tab.focusedLeafId);
// The previewed pane's titlebar wears the preview colour instead of the
// accent tint a focused pane gets (docs/PREVIEW.md).
pane.classList.toggle(
"is-preview",
preview !== null && pane.dataset.sessionId === preview.sessionId,
);
}
// A preview is not a focused pane: reporting it would clear the session's
// `AwaitingInput` glow just because the operator scrolled past it.
@@ -229,6 +235,9 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
open: realOpenSessions(),
focused: focusedLeafSession(),
});
// The row the preview came from wears the same colour, so the sidebar and
// the pane read as one thing rather than two unrelated highlights.
sidebar.setPreview(preview?.sessionId ?? null);
}
function focusLeaf(leafId: string): void {
@@ -549,6 +558,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
if (!preview) pool.setFocus(null);
canvas.appendChild(renderWelcome());
sidebar.setHighlights({ open: realOpenSessions(), focused: null });
sidebar.setPreview(preview?.sessionId ?? null);
persist();
return;
}
@@ -588,6 +598,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
const pane = buildPane(focused);
pane.el.classList.add("is-focused"); // the only pane — never dimmed
if (preview && focused.sessionId === preview.sessionId) pane.el.classList.add("is-preview");
wrap.appendChild(pane.el);
canvas.appendChild(wrap);
@@ -598,6 +609,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
);
if (!preview) pool.setFocus(focused.sessionId ?? null);
sidebar.setHighlights({ open: realOpenSessions(), focused: focused.sessionId ?? null });
sidebar.setPreview(preview?.sessionId ?? null);
}
function closeSession(sessionId: string): void {
+24
View File
@@ -45,11 +45,17 @@ function fakePool(): Spy {
return { pool, visible: () => visible, focusCalls, mountedWithFocus };
}
/** Last value the controller pushed to `SidebarHandle.setPreview`. */
const previewRow: { id: string | null } = { id: null };
function fakeSidebar(): SidebarHandle {
return {
el: document.createElement("aside"),
setActive() {},
setHighlights() {},
setPreview(id: string | null) {
previewRow.id = id;
},
focusList() {},
hasListFocus() {
return false;
@@ -88,6 +94,7 @@ describe("sidebar preview", () => {
beforeEach(() => {
stubFetch();
document.body.replaceChildren();
previewRow.id = null;
});
afterEach(() => {
@@ -110,6 +117,23 @@ describe("sidebar preview", () => {
expect(serializeLayout(layout)).toEqual(before);
});
it("marks the previewed pane and its sidebar row, and unmarks them after", () => {
const layout = twoTabs();
const spy = fakePool();
const mv = makeMv(layout, spy.pool);
mv.previewSession("s1");
expect(previewRow.id).toBe("s1");
expect(mv.el.querySelectorAll(".mv-pane.is-preview")).toHaveLength(1);
expect(
(mv.el.querySelector(".mv-pane.is-preview") as HTMLElement).dataset.sessionId,
).toBe("s1");
mv.previewSession(null);
expect(previewRow.id).toBeNull();
expect(mv.el.querySelectorAll(".mv-pane.is-preview")).toHaveLength(0);
});
it("shows a session no pane holds in a throwaway tab that leaves no trace", () => {
const layout = twoTabs();
const before = serializeLayout(layout);
+64 -8
View File
@@ -87,6 +87,29 @@
--accent-tint: oklch(0.92 0.005 256 / 0.1);
--accent-ring: oklch(0.92 0.005 256 / 0.5);
/* preview (docs/PREVIEW.md): a provisional view needs a colour that is not
the accent, because the accent is what says "this is the one you picked".
Defined once, off a single hue variable, so a palette whose accent already
sits on that hue can move it (see periwinkle/orchid below); `light-dark()`
keeps the light pair here instead of in all eighteen theme blocks. */
--preview-h: 305;
--preview: light-dark(
oklch(0.55 0.16 var(--preview-h)),
oklch(0.78 0.14 var(--preview-h))
);
--preview-ink: light-dark(
oklch(0.99 0.005 var(--preview-h)),
oklch(0.19 0.045 var(--preview-h))
);
--preview-tint: light-dark(
oklch(0.55 0.16 var(--preview-h) / 0.12),
oklch(0.78 0.14 var(--preview-h) / 0.16)
);
--preview-ring: light-dark(
oklch(0.55 0.16 var(--preview-h) / 0.5),
oklch(0.78 0.14 var(--preview-h) / 0.6)
);
--live: oklch(0.84 0.17 152);
--live-glow: oklch(0.84 0.17 152 / 0.55);
@@ -256,6 +279,9 @@
--accent-ink: oklch(0.18 0.04 278);
--accent-tint: oklch(0.75 0.125 275 / 0.14);
--accent-ring: oklch(0.75 0.125 275 / 0.55);
/* This accent is already violet, so preview moves to cyan to stay legible
as "not the accent". Set once here: the light blocks below don't touch it. */
--preview-h: 195;
--term-bg: oklch(0.155 0.018 278);
}
:root[data-palette="periwinkle"][data-theme="light"],
@@ -437,6 +463,8 @@
--accent-ink: oklch(0.22 0.05 310);
--accent-tint: oklch(0.78 0.13 310 / 0.14);
--accent-ring: oklch(0.78 0.13 310 / 0.55);
/* Same collision as periwinkle: an orchid accent needs a non-purple preview. */
--preview-h: 195;
--term-bg: oklch(0.155 0.014 305);
}
:root[data-palette="orchid"][data-theme="light"],
@@ -1125,12 +1153,14 @@ textarea {
background: color-mix(in oklab, var(--accent) 6%, transparent);
}
/* Hovering a row is what previews it (docs/PREVIEW.md), so hover reads in the
preview hue — never the accent, which belongs to the row actually open. */
.tree-leaf:hover {
background: var(--surface-2);
background: color-mix(in oklab, var(--preview) 12%, var(--surface-2));
}
.tree-leaf[data-status="running"]:hover {
background: color-mix(in oklab, var(--accent) 13%, var(--surface-2));
background: color-mix(in oklab, var(--preview) 18%, var(--surface-2));
}
.tree-leaf:focus-visible {
@@ -1141,7 +1171,10 @@ textarea {
/* Where the keyboard walk (j/k) is standing. Distinct from `.is-active`: this
is a cursor, not a statement about what is mounted, so it reads as a ring
rather than a fill and the two can sit on the same row. */
.tree-leaf.is-highlighted,
.tree-leaf.is-highlighted {
box-shadow: inset 0 0 0 2px var(--preview-ring);
}
.tree-folder-header.is-highlighted {
box-shadow: inset 0 0 0 2px var(--accent-ring);
}
@@ -1161,6 +1194,15 @@ textarea {
color: var(--ink);
}
/* The row the content pane is previewing right now — after `.is-active` so a
preview of an already-open session still reads as provisional. */
.tree-leaf.is-previewing,
.tree-leaf.is-previewing[data-status="running"],
.tree-leaf.is-previewing.is-active {
background: var(--preview-tint);
box-shadow: inset 0 0 0 2px var(--preview-ring);
}
/* 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"] {
@@ -2884,10 +2926,14 @@ input.leaf-rename:focus {
}
/* Preview (docs/PREVIEW.md): the content pane is showing a session the
operator only highlighted, so it reads as provisional — a dashed tab and a
dashed inset ring on the canvas — and reverts when the highlight moves. */
operator only highlighted, so it reads as provisional — the preview hue on
the pane's titlebar, a dashed tab, a dashed inset ring on the canvas — and
reverts when the highlight moves. The hue is deliberately not the accent:
the accent means "this is what you picked", which a preview is not. */
.mv-tab.is-preview {
border-style: dashed;
border-color: var(--preview-ring);
color: var(--preview);
font-style: italic;
}
.mv.mv-previewing .mv-canvas::before {
@@ -2900,8 +2946,8 @@ input.leaf-rename:focus {
border-radius: var(--r-1);
font: 500 var(--fs-11) / 1.5 var(--font-mono);
letter-spacing: 0.04em;
color: var(--accent-ink);
background: var(--accent);
color: var(--preview-ink);
background: var(--preview);
opacity: 0.85;
pointer-events: none;
}
@@ -2910,7 +2956,7 @@ input.leaf-rename:focus {
position: absolute;
inset: 0;
pointer-events: none;
border: 1px dashed var(--accent-ring);
border: 1px dashed var(--preview-ring);
border-radius: var(--r-1);
z-index: var(--z-sticky);
}
@@ -3028,6 +3074,16 @@ input.leaf-rename:focus {
.mv-pane:not(.is-focused) .attach-header {
opacity: 0.7;
}
/* The previewed pane's titlebar carries the preview hue, so the chip is not the
only thing saying the pane is provisional — and so it is never mistaken for
the accent tint of the pane the operator actually focused. It keeps full
strength even though a preview never takes focus. */
.mv-pane.is-preview .attach-header,
.mv-pane.is-preview.is-focused .attach-header {
background: color-mix(in oklab, var(--preview) 34%, var(--surface));
box-shadow: inset 0 -1px 0 var(--preview-ring);
opacity: 1;
}
/* A pane with no session yet: a quiet hint rather than blank terminal black. */
.mv-pane-empty {
flex: 1;