feat(ui): split view returns to the L-shell — PaneManager layout tree

Revives the split-pane feature retired with ui/static (step 6), rebuilt
on PaneManager: an optional binary layout tree (null = the one-pane-per-
tab behaviour, unchanged) renders visible panes as %-inset cells — no
reparenting, so live stream DOM, scroll state and media survive layout
changes. Tabs stay global: the active tab is the focused cell, a
backgrounded tab swaps into it, clicking inside a visible pane focuses
its cell, .shown marks visible-unfocused tabs. Separators resize by
pointer-capture drag and arrow keys (role=separator + aria-value*); the
tree persists in the working-set blob and rehydrate prunes leaves whose
pane did not restore. Limits: 6 cells, 200x150 cell minimums, denials
toast the manager's reason.

Affordance: Split right / Split down / Unsplit buttons in the tab-bar
tail replace the redundant [+] (the permanent Dashboard tab is the
launcher) — deliberately no contextmenu override this time. The dead
TS_APP.focusLauncher seam goes with it.

Measured chrome: the focused cell wears a 2px accent top bar (no thin
tinted ring clears 3:1 in both themes) plus a 55%-mix inset ring;
separators rest at --ink-4 with solid-accent hover/drag/focus; .shown
tabs carry an accent underline; the tail cluster is fenced and lifted
to --ink-3.

scripts/livepass.py grows a third surface: shell/livepass.html boots
the real shell.js + pane.js and drives ?split=right|down|three|none
(+ &theme=light), stamping SPLIT-READY-<cells> / SPLIT-FAILED-<reason>.
This commit is contained in:
Patrick Buckley
2026-06-11 23:06:23 -07:00
parent 3e5f2c3870
commit f8f7152d63
6 changed files with 918 additions and 55 deletions
+136
View File
@@ -39,6 +39,12 @@ Console harness (?open=): schedule-create · schedule-edit · model-create ·
title instead of passing silently.
Governance surfaces (roles/HR/OGP/memory/skill) need fixtures that are not
canned yet — add a fixture + driver branch below when you need one.
Shell harness (?split=): right (default) · down · three · none — boots the
REAL shell.js + pane.js split-view engine over stubbed seams (two demo
conversational panes; ?split=three adds the Dashboard cell). + &theme=light.
document.title stamps SPLIT-READY-<visible cells> on success and
SPLIT-FAILED-<reason> when a driven split was denied — judge the focused
cell's top accent bar, the separators, and the .shown tab marker.
Rebuild after ANY markup change: the dialog blocks are embedded at build
time. Assets are symlinked, so CSS/JS edits are live on refresh.
@@ -455,6 +461,129 @@ CONSOLE_TEMPLATE = """<!doctype html>
"""
# --------------------------------------------------------------------------
# Shell harness — the SPLIT-VIEW surface. Unlike the ui/console pages (which
# embed extracted markup), this one boots the REAL shell.js + pane.js over
# stubbed classic seams and drives the split engine via ?split=. Two demo
# conversational panes give the cells plausible content; the Dashboard pane
# (registered by the shell itself) fills the third cell in ?split=three.
# Loud-failure rule: the title stamps SPLIT-READY-<cells> only when the built
# state matches the request — a denied/failed split stamps SPLIT-FAILED-<why>.
# --------------------------------------------------------------------------
SHELL_TEMPLATE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>shell livepass</title>
<link rel="stylesheet" href="shared/base.css" />
<link rel="stylesheet" href="shared/ui-base.css" />
<link rel="stylesheet" href="shared/chat.css" />
<link rel="stylesheet" href="shared/conversation.css" />
<link rel="stylesheet" href="shared/cards.css" />
<link rel="stylesheet" href="static/style.css" />
<link rel="stylesheet" href="shared/shell.css" />
<link rel="stylesheet" href="shared/interactive.css" />
</head>
<body>
<div id="header"><div id="status-bar"></div><button id="theme-toggle">☾</button></div>
<div id="breadcrumb"></div>
<div id="main" style="padding: 18px">
<h2 style="margin: 0 0 8px">Dashboard</h2>
<p style="color: var(--ink-3)">
Launcher + workstreams table live here (livepass stub).
</p>
</div>
<div id="view-admin" style="display: none"></div>
<script>
window.TURNSTONE_SHELL_CAPS = { cluster: false, brandSub: "console" };
window.TS_APP = {
boot() {},
getClusterState() { return { nodes: {} }; },
onRender() {},
};
window.TS_ADMIN = {};
var q = new URLSearchParams(location.search);
if (q.get("theme") === "light")
document.documentElement.dataset.theme = "light";
</script>
<script type="module" src="shared/shell.js"></script>
<script type="module">
const q = new URLSearchParams(location.search);
for (let i = 0; i < 100 && !window.TS_SHELL; i++)
await new Promise((r) => setTimeout(r, 20));
if (!window.TS_SHELL) {
document.title = "SPLIT-FAILED-no-shell";
} else {
sessionStorage.clear();
const pm = window.TS_SHELL.panes;
const { ShellPane } = await import("./shared/pane.js");
const mkConv = (type, title, lines) => {
pm.registerType(type, () => {
const p = new ShellPane({ type, title });
p.tabMenu = () => [
{ label: "Close pane", action: () => pm.close(p.id) },
];
p.onMount = function () {
const wrap = document.createElement("div");
wrap.style.cssText =
"padding:16px;display:flex;flex-direction:column;gap:10px;overflow:auto;";
for (const [role, text] of lines) {
const d = document.createElement("div");
d.className = "msg " + role;
d.textContent = text;
wrap.append(d);
}
this.bodyEl.append(wrap);
};
return p;
});
};
mkConv("repro", "repro-flaky-suite", [
["user", "Track down the flaky retry in the channel gateway tests."],
[
"assistant",
"Three suspects so far — the debounce window in mcp_client, the " +
"circuit-breaker reset, and the socket-mode reconnect. Bisecting now.",
],
[
"assistant",
"Found it: the breaker reset races the stream pre-close. Patch incoming.",
],
]);
mkConv("relnotes", "draft-1.6.2-notes", [
["user", "Draft the 1.6.2 patch notes from the merged PR list."],
[
"assistant",
"Pulling #657#662. Consent badge, orphan verb, MCP task hygiene, " +
"the anthropic-compatible lane, and the mcp<2 cap.",
],
]);
pm.openPane("repro");
pm.openPane("relnotes");
const want = q.get("split") || "right";
let failed = null;
if (want !== "none") {
const r1 = pm.splitFocused("right");
if (!r1.ok) failed = r1.reason;
if (!failed && (want === "three" || want === "down")) {
const r2 = pm.splitFocused("down");
if (!r2.ok) failed = r2.reason;
}
}
const cells = document.querySelectorAll(
".panes > section.pane:not([hidden])",
).length;
document.title = failed
? "SPLIT-FAILED-" + failed
: "SPLIT-READY-" + cells;
}
</script>
</body>
</html>
"""
def build(out: Path) -> None:
ui = out / "ui"
con = out / "console"
@@ -483,6 +612,13 @@ def build(out: Path) -> None:
(con / "livepass.html").write_text(page, encoding="utf-8")
print(f"{con}/livepass.html — admin fragment + {len(riders)} rider dialogs")
sh = out / "shell"
sh.mkdir(parents=True, exist_ok=True)
symlink(sh / "shared", ROOT / "turnstone/shared_static")
symlink(sh / "static", ROOT / "turnstone/console/static")
(sh / "livepass.html").write_text(SHELL_TEMPLATE, encoding="utf-8")
print(f"{sh}/livepass.html — split-view shell surface")
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
+62 -9
View File
@@ -686,17 +686,70 @@ def test_step7_live_tab_state_glyphs() -> None:
assert ".tab .tab-glyph" in css, "the tab-glyph spacing rule must apply to static + live glyphs"
def test_step7_new_tab_launcher_button() -> None:
"""Step 7 #3: the tab bar's right tail carries a [+] new-session button that
focuses the persona launcher (the Dashboard pane hosts it; a new session needs
a task prompt so it composes there). Cross-deployment via showHome with an
openPane fallback; reuses the scaffold's .tab-add styling."""
def test_split_view_controls() -> None:
"""The revived split-view's affordance surface: Split right / Split down /
Unsplit buttons in the tab-bar tail (they REPLACED the redundant [+] — the
permanent Dashboard tab is the launcher). Deliberately NO contextmenu
override (the pre-L-shell split UI hijacked right-click); denials surface
as a toast with the manager's reason."""
shell = _SHELL_JS.read_text(encoding="utf-8")
assert 'make("button", "tab-add")' in shell, "the [+] new-tab button must exist"
assert "shell.tail.append(addTab)" in shell, "the [+] lives in the right-floated tail slot"
assert "window.showHome()" in shell, "[+] must focus the persona launcher (showHome)"
assert 'tbBtn("tb-split", "", "Split right")' in shell
assert 'tbBtn("tb-split tb-split--down", "", "Split down")' in shell
assert 'pm.splitFocused("right")' in shell and 'pm.splitFocused("down")' in shell
assert "pm.unsplit()" in shell, "the Unsplit button must call pm.unsplit"
assert "unsplitBtn.hidden = !pm.isSplit()" in shell, (
"Unsplit only shows while split (synced via onActiveChange)"
)
assert "shell.tail.append(splitRightBtn, splitDownBtn, unsplitBtn)" in shell
# The [+] is gone with its showHome/focusLauncher plumbing kept out.
assert "tab-add" not in shell, "the [+] new-tab button was replaced by the split controls"
css = _SHELL_CSS.read_text(encoding="utf-8")
assert ".tab-add" in css, "the .tab-add button style must exist (from the scaffold)"
assert ".tb-split" in css and ".tb-split--down .tb-glyph" in css, (
"split buttons styled; the down variant rotates the GLYPH (not the button)"
)
assert "tab-add" not in css, "the dead .tab-add style must not survive"
def test_pane_manager_split_engine() -> None:
"""The split-view engine in PaneManager: an optional binary layout tree
(null = the pre-feature single-pane behaviour, bit-for-bit). Visible panes
are positioned by inline % insets — NEVER reparented, so live stream DOM,
scroll state and media survive every layout change. Tabs stay global:
active = the focused cell, a backgrounded tab swaps into it, a click inside
a visible pane focuses its cell. Separators resize by drag AND keyboard
(role=separator + aria-value*); the tree persists in the working-set blob
and rehydrate prunes leaves whose pane did not restore."""
pane = _PANE_JS.read_text(encoding="utf-8")
# public surface
assert "splitFocused(dir)" in pane and "unsplit()" in pane and "isSplit()" in pane
# no reparenting: layout is applied as % insets on the pane elements
assert 'p.el.style.left = r.x * 100 + "%"' in pane
# the focused-cell swap + pure focus move both live in activate()
assert "this._leafFor(paneId)" in pane and "target.paneId = paneId" in pane
# close() collapses the cell and prefers the absorbing sibling as fallback
assert "_collapseLeaf(leaf)" in pane and "preferFallback" in pane
# auto-fill source: most-recently-focused backgrounded pane
assert "_nextBackgroundPane()" in pane and "this._mru" in pane
# separators: ARIA + keyboard + pointer-capture drag, ratio bounds from the
# split node's OWN px region (nested splits clamp against their own space)
assert 'setAttribute("role", "separator")' in pane
assert "setPointerCapture" in pane and "_ratioBounds(node)" in pane
assert '"aria-valuenow"' in pane and "ArrowRight" in pane
# limits: cell minimums + cap (the old ui/static ceiling, kept)
assert "SPLIT_MAX_CELLS = 6" in pane
assert "SPLIT_MIN_W = 200" in pane and "SPLIT_MIN_H = 150" in pane
# persistence: layout rides the working-set blob; restore prunes dead leaves
assert "state.layout = this._serializeLayout(this._layout)" in pane
assert "_restoreLayout(data)" in pane and "seen.has(d.paneId)" in pane
# the visible-but-unfocused tab marker
assert 'classList.toggle("shown"' in pane
css = _SHELL_CSS.read_text(encoding="utf-8")
assert ".panes--split > section.pane" in css, (
"split cells must target section.pane ONLY — the interactive pane's inner "
"div also carries .pane (the step-5b lesson)"
)
assert ".split-handle" in css and "col-resize" in css and "row-resize" in css
assert ".tab.shown:not(.active)" in css, "the visible-but-unfocused tab marker"
def test_step7_auth_gated_open_pane() -> None:
-8
View File
@@ -1962,14 +1962,6 @@ window.TS_APP.bucketByParent = function (list) {
window.TS_APP.buildNodeInfo = function (node) {
return buildNodeInfoFromSnapshot(node);
};
// Focus the persona launcher's composer — the [+] new-session button calls this
// after showHome so "new session" lands you ready to type (showHome alone, on the
// already-active Dashboard, is a no-op). _ensureHomeComposerInit (run by
// showHome) has set _homeCoordComposer by the time this fires.
window.TS_APP.focusLauncher = function () {
if (_homeCoordComposer && typeof _homeCoordComposer.focus === "function")
_homeCoordComposer.focus();
};
// Resolve the cluster node that should host an interactive pane's session AND
// ensure the session is loaded there before the pane streams — the node /events
// stream 404s on a ws not loaded on that node, and /history alone won't load it.
+539 -12
View File
@@ -6,7 +6,9 @@
host is generic and every surface is a registered factory. This is the spine
the rest of the renovation hangs off — step 1 exercises it with a single
`dashboard` pane that adopts the legacy `#main`; richer pane types arrive in
steps 2-5.
steps 2-5. The split-view section lets the host show several open panes at
once (the revived split-pane feature — see "Split view" below); without an
active split tree the manager is strictly one-pane-per-tab.
House style: ES module, programmatic DOM (createElement / textContent /
append), NO innerHTML. Panes scope all queries to their own `bodyEl` so they
@@ -19,6 +21,15 @@ function cssId(s) {
return String(s).replace(/[^a-zA-Z0-9_-]/g, "-");
}
/* ----- Split view limits (the revived split-pane feature) -----
A split cell below ~200×150 can't render a usable conversation (the composer
alone needs ~150px of width headroom); 6 cells is the old ui/static ceiling,
kept — past it the cells fall under the minimums on any sane viewport. */
const SPLIT_MAX_CELLS = 6;
const SPLIT_MIN_W = 200;
const SPLIT_MIN_H = 150;
const SPLIT_HANDLE_PX = 7; // keep in sync with shell.css .split-handle--row/--col
/**
* A pane: a typed window with its own scoped root. Subtypes (or callers that
* patch the lifecycle hooks) build content into `bodyEl` on first mount and
@@ -221,6 +232,23 @@ export class PaneManager {
this._activeId = null;
this._activeSubs = []; // active-pane-change listeners (e.g. the rail marker)
this._openMenu = null; // the currently-open tab-action dropdown, if any
// Split view: a binary layout tree ({type:"leaf",paneId} | {type:"split",
// dir:"row"|"col", ratio, children:[2]}), or null — null is single-pane
// mode, where every code path below behaves exactly as before the feature.
// Visible panes are positioned by inline % insets (no reparenting: a pane's
// live SSE DOM and media elements are never detached).
this._layout = null;
this._handleEls = []; // live .split-handle separators (rebuilt per layout change)
this._mru = []; // paneId[], most-recently-focused first (split auto-fill order)
// Clicking anywhere inside a visible-but-unfocused pane focuses its cell
// (capture phase — pane content may stopPropagation on bubbled events).
if (this.panesEl) {
this.panesEl.addEventListener(
"pointerdown",
(e) => this._onPanesPointerdown(e),
true,
);
}
// The tab bar is a WAI-ARIA tablist; arrow keys rove focus across the open
// tabs (delegated, so it survives tab reconciliation).
if (this.tabbarEl) {
@@ -394,31 +422,54 @@ export class PaneManager {
this._renderTabs();
}
/** Show one pane, hide the rest, fire deactivate/activate hooks. */
/** Show one pane (single-pane mode: hide the rest) or focus it (split mode),
* firing deactivate/activate hooks. In split mode "active" means FOCUSED:
* a pane already in a cell keeps every cell as-is (pure focus move); a
* backgrounded pane swaps into the focused cell, parking that cell's
* current pane. onDeactivate therefore means "lost focus", not necessarily
* "hidden" — which matches the panes' contract (interactive panes only stop
* focus-stealing and keep streaming: exactly what a visible-but-unfocused
* cell wants). */
activate(paneId) {
// Re-activating the already-active pane is a cheap no-op (it still re-renders
// tabs / re-persists / re-notifies below, just no onDeactivate/onActivate).
if (!this._panes.has(paneId)) return;
const prev = this._activeId ? this._panes.get(this._activeId) : null;
if (prev && prev.id !== paneId) {
const next = this._panes.get(paneId);
const changed = this._activeId !== paneId;
if (this._layout) {
if (changed && !this._leafFor(paneId)) {
// Swap the backgrounded pane into the focused cell.
const target = this._leafFor(this._activeId) || this._firstLeaf();
const old = target ? this._panes.get(target.paneId) : null;
if (target) target.paneId = paneId;
if (old && old !== next) {
old.el.hidden = true;
this._clearCellStyle(old);
}
}
} else if (prev && prev.id !== paneId) {
prev.el.hidden = true;
}
if (changed && prev) {
try {
prev.onDeactivate();
} catch (e) {
console.error("PaneManager: onDeactivate failed", prev.id, e);
}
}
const next = this._panes.get(paneId);
next.el.hidden = false;
const changed = this._activeId !== paneId;
this._activeId = paneId;
if (changed) {
// Most-recently-focused order — the split auto-fill source.
this._mru = [paneId].concat(this._mru.filter((p) => p !== paneId));
try {
next.onActivate();
} catch (e) {
console.error("PaneManager: onActivate failed", paneId, e);
}
}
if (this._layout) this._applyLayout();
this._renderTabs();
this._persist();
this._notifyActive();
@@ -434,11 +485,19 @@ export class PaneManager {
}
}
/** Drop a pane (tab + content), release it, focus a neighbour. */
/** Drop a pane (tab + content), release it, focus a neighbour. In split
* mode a visible pane's cell collapses first (its sibling takes the space),
* and the sibling is preferred as the fallback focus target so closing a
* cell lands you on the pane that absorbed it. */
close(paneId) {
const pane = this._panes.get(paneId);
if (!pane || pane.closable === false) return;
this._closeTabMenu(); // a dropdown anchored on the closing tab must not strand
let preferFallback = null;
if (this._layout) {
const leaf = this._leafFor(paneId);
if (leaf) preferFallback = this._collapseLeaf(leaf);
}
try {
pane.onClose();
} catch (e) {
@@ -447,17 +506,478 @@ export class PaneManager {
if (pane.el && pane.el.parentNode) pane.el.parentNode.removeChild(pane.el);
this._panes.delete(paneId);
this._order = this._order.filter((p) => p !== paneId);
this._mru = this._mru.filter((p) => p !== paneId);
if (this._activeId === paneId) {
this._activeId = null;
const fallback = this._order[this._order.length - 1];
const fallback = preferFallback || this._order[this._order.length - 1];
if (fallback)
this.activate(fallback); // fires _notifyActive itself
else this._notifyActive(); // last pane closed — clear the marker
} else {
this._notifyActive(); // split state may have changed (a cell collapsed)
}
this._renderTabs();
this._persist();
}
/* ===== Split view ============================================================
The layout tree shows MORE than one open pane at once. Tabs stay global:
the active tab is the FOCUSED cell, clicking a backgrounded tab swaps that
pane into the focused cell, clicking inside a visible pane focuses its
cell. Cells are rendered as inline % insets on the pane elements
themselves — a pane is NEVER reparented or detached, so its live stream
DOM, scroll positions and media elements are untouched by layout changes.
With `_layout === null` (the default) every path above behaves exactly as
it did before this feature existed.
========================================================================== */
/** Is the pane host currently showing more than one cell? */
isSplit() {
return !!this._layout;
}
/** Split the focused pane's cell — `dir` "right" puts the new cell beside
* it, "down" below it. The new cell shows the most-recently-focused
* backgrounded pane: splitting never duplicates a pane (panes are keyed
* singletons — two mounts of one session would race its stream). Returns
* `{ok:true}` or `{ok:false, reason}`; the CALLER owns user feedback (the
* shell toasts the reason — PaneManager stays chrome-free). */
splitFocused(dir) {
const activeId = this._activeId;
if (!activeId || !this._panes.has(activeId))
return { ok: false, reason: "Nothing to split" };
if (this._leafCount() >= SPLIT_MAX_CELLS)
return {
ok: false,
reason: "Pane limit reached (" + SPLIT_MAX_CELLS + ")",
};
const fillId = this._nextBackgroundPane();
if (!fillId)
return {
ok: false,
reason: "Every open tab is already visible — open another one first",
};
// Space guard: the focused cell must fit two cells plus the divider.
const rect = this._cellRect(activeId);
const need =
(dir === "down" ? SPLIT_MIN_H : SPLIT_MIN_W) * 2 + SPLIT_HANDLE_PX;
if ((dir === "down" ? rect.h : rect.w) < need)
return { ok: false, reason: "Not enough space to split" };
const leaf = this._layout ? this._leafFor(activeId) : null;
const node = {
type: "split",
dir: dir === "down" ? "col" : "row",
ratio: 0.5,
children: [
leaf || { type: "leaf", paneId: activeId },
{ type: "leaf", paneId: fillId },
],
};
if (!leaf) {
this._layout = node;
} else {
const found = this._findParent(this._layout, leaf);
if (found) found.parent.children[found.index] = node;
else this._layout = node; // the leaf was the root (defensive — see _collapseLeaf)
}
this.panesEl.classList.add("panes--split");
this._applyLayout(true);
this.activate(fillId); // focus the new cell (already a leaf → pure focus move)
return { ok: true };
}
/** Collapse back to a single pane — the focused one. The other panes stay
* open as tabs (they just stop being visible); nothing is closed. */
unsplit() {
if (!this._layout) return;
this._exitLayout(this._activeId);
this._renderTabs();
this._persist();
this._notifyActive();
}
// ----- tree helpers -----
_leaves(node, out) {
out = out || [];
node = node || this._layout;
if (!node) return out;
if (node.type === "leaf") out.push(node);
else {
this._leaves(node.children[0], out);
this._leaves(node.children[1], out);
}
return out;
}
_leafCount() {
return this._layout ? this._leaves().length : 1;
}
_leafFor(paneId) {
if (paneId == null || !this._layout) return null;
return this._leaves().find((l) => l.paneId === paneId) || null;
}
_firstLeaf(node) {
node = node || this._layout;
if (!node) return null;
return node.type === "leaf" ? node : this._firstLeaf(node.children[0]);
}
_findParent(node, target) {
if (!node || node.type === "leaf") return null;
for (let i = 0; i < 2; i++) {
if (node.children[i] === target) return { parent: node, index: i };
const found = this._findParent(node.children[i], target);
if (found) return found;
}
return null;
}
/** Remove a leaf: its sibling subtree takes the parent's place. Exits split
* mode when one cell remains. Returns the sibling's first pane id — the
* natural focus target for a close() that emptied the focused cell. */
_collapseLeaf(leaf) {
const found = this._findParent(this._layout, leaf);
if (!found) {
// The leaf IS the root — a tree this small should already have exited
// split mode; recover rather than strand a stale layout.
this._exitLayout(null);
return null;
}
const sibling = found.parent.children[found.index === 0 ? 1 : 0];
const grand = this._findParent(this._layout, found.parent);
if (grand) grand.parent.children[grand.index] = sibling;
else this._layout = sibling;
const first = this._firstLeaf(sibling);
if (this._layout.type === "leaf") this._exitLayout(this._layout.paneId);
else this._applyLayout(true);
return first ? first.paneId : null;
}
/** The most-recently-focused open pane that is not currently visible —
* what a fresh split cell shows. Null when every open pane is visible. */
_nextBackgroundPane() {
const visible = new Set(
this._layout
? this._leaves().map((l) => l.paneId)
: [this._activeId].filter(Boolean),
);
for (const pid of this._mru) {
if (this._panes.has(pid) && !visible.has(pid)) return pid;
}
for (const pid of this._order) {
if (!visible.has(pid)) return pid;
}
return null;
}
// ----- geometry + rendering -----
/** The px rect of a pane's current cell (the whole host when unsplit). */
_cellRect(paneId) {
const W = this.panesEl.clientWidth;
const H = this.panesEl.clientHeight;
if (!this._layout) return { x: 0, y: 0, w: W, h: H };
let hit = null;
const walk = (node, x, y, w, h) => {
if (hit) return;
if (node.type === "leaf") {
if (node.paneId === paneId) hit = { x, y, w, h };
return;
}
const r = node.ratio;
if (node.dir === "row") {
walk(node.children[0], x, y, w * r, h);
walk(node.children[1], x + w * r, y, w * (1 - r), h);
} else {
walk(node.children[0], x, y, w, h * r);
walk(node.children[1], x, y + h * r, w, h * (1 - r));
}
};
walk(this._layout, 0, 0, W, H);
return hit || { x: 0, y: 0, w: W, h: H };
}
/** Lay the visible panes out as % insets and place the separators.
* `rebuild` re-creates the handle ELEMENTS (tree structure changed);
* without it only styles update, so a mid-drag handle keeps its pointer
* capture. % insets make window resizes free — no JS resize listener. */
_applyLayout(rebuild) {
if (!this._layout) return;
const rects = new Map();
const handles = [];
const walk = (node, x, y, w, h) => {
if (node.type === "leaf") {
rects.set(node.paneId, { x, y, w, h });
return;
}
const r = node.ratio;
if (node.dir === "row") {
walk(node.children[0], x, y, w * r, h);
handles.push({ node, x: x + w * r, y, span: h });
walk(node.children[1], x + w * r, y, w * (1 - r), h);
} else {
walk(node.children[0], x, y, w, h * r);
handles.push({ node, x, y: y + h * r, span: w });
walk(node.children[1], x, y + h * r, w, h * (1 - r));
}
};
walk(this._layout, 0, 0, 1, 1);
const multi = rects.size > 1;
for (const p of this._panes.values()) {
const r = rects.get(p.id);
if (r) {
p.el.hidden = false;
p.el.style.left = r.x * 100 + "%";
p.el.style.top = r.y * 100 + "%";
p.el.style.width = r.w * 100 + "%";
p.el.style.height = r.h * 100 + "%";
// The focus ring only means something with 2+ cells on screen.
p.el.classList.toggle(
"split-focused",
multi && p.id === this._activeId,
);
} else {
p.el.hidden = true;
this._clearCellStyle(p);
}
}
if (rebuild) {
for (const h of this._handleEls) h.remove();
this._handleEls = handles.map((h) => this._buildHandle(h.node));
}
// Position fresh AND surviving handles from the same walk (identical
// traversal order, so index pairing is stable while the tree shape is).
for (let i = 0; i < handles.length && i < this._handleEls.length; i++) {
const h = handles[i];
const el = this._handleEls[i];
el.style.left = h.x * 100 + "%";
el.style.top = h.y * 100 + "%";
if (h.node.dir === "row") el.style.height = h.span * 100 + "%";
else el.style.width = h.span * 100 + "%";
el.setAttribute("aria-valuenow", String(Math.round(h.node.ratio * 100)));
}
}
/** Leave split mode. `keepId` (usually the focused pane) stays visible and
* the other panes hide; null leaves visibility to the caller (the close()
* fallback re-activates). No extra deactivate hooks fire: a pane hidden
* here already lost focus — and with it its onDeactivate — earlier. */
_exitLayout(keepId) {
this._layout = null;
this.panesEl.classList.remove("panes--split");
for (const h of this._handleEls) h.remove();
this._handleEls = [];
for (const p of this._panes.values()) {
this._clearCellStyle(p);
if (keepId) p.el.hidden = p.id !== keepId;
}
}
_clearCellStyle(pane) {
pane.el.style.left = "";
pane.el.style.top = "";
pane.el.style.width = "";
pane.el.style.height = "";
pane.el.classList.remove("split-focused");
}
/** Focus follows the pointer between cells: a click anywhere inside a
* visible-but-unfocused pane focuses it (capture phase — pane content may
* stop propagation of bubbled events). */
_onPanesPointerdown(e) {
if (!this._layout) return;
let el = e.target;
// The ShellPane <section> is the DIRECT child of the host (the interactive
// pane's inner <div> also carries .pane — walking to the direct child
// disambiguates without knowing any pane-type internals).
while (el && el.parentElement !== this.panesEl) el = el.parentElement;
if (!el || !el.classList || !el.classList.contains("pane")) return;
for (const p of this._panes.values()) {
if (p.el === el) {
if (p.id !== this._activeId && this._leafFor(p.id)) this.activate(p.id);
return;
}
}
}
// ----- separators (drag + keyboard resize) -----
_buildHandle(node) {
const el = document.createElement("div");
el.className = "split-handle split-handle--" + node.dir;
el.setAttribute("role", "separator");
el.tabIndex = 0;
el.setAttribute(
"aria-orientation",
node.dir === "row" ? "vertical" : "horizontal",
);
el.setAttribute("aria-valuemin", "10");
el.setAttribute("aria-valuemax", "90");
el.setAttribute("aria-valuenow", String(Math.round(node.ratio * 100)));
el.setAttribute(
"aria-label",
node.dir === "row"
? "Resize panes horizontally"
: "Resize panes vertically",
);
this._wireHandle(el, node);
this.panesEl.append(el);
return el;
}
/** Ratio bounds that keep both children of a split above the cell minimums,
* derived from the split node's CURRENT px region (so nested splits clamp
* against their own space, not the whole host). */
_ratioBounds(node) {
const W = this.panesEl.clientWidth;
const H = this.panesEl.clientHeight;
let region = null;
const walk = (n, x, y, w, h) => {
if (region) return;
if (n === node) {
region = { w, h };
return;
}
if (n.type === "leaf") return;
const r = n.ratio;
if (n.dir === "row") {
walk(n.children[0], x, y, w * r, h);
walk(n.children[1], x + w * r, y, w * (1 - r), h);
} else {
walk(n.children[0], x, y, w, h * r);
walk(n.children[1], x, y + h * r, w, h * (1 - r));
}
};
walk(this._layout, 0, 0, W, H);
const px = region ? (node.dir === "row" ? region.w : region.h) : 0;
const minPx = node.dir === "row" ? SPLIT_MIN_W : SPLIT_MIN_H;
return {
min: px > 0 ? Math.max(0.05, minPx / px) : 0.1,
max: px > 0 ? Math.min(0.95, 1 - minPx / px) : 0.9,
px: px || 1,
};
}
_wireHandle(el, node) {
el.addEventListener("pointerdown", (e) => {
// Touch/pen carry no primary-button semantics — only filter MOUSE
// non-primary buttons (a bare `e.button !== 0` would break touch).
if (e.button !== 0 && e.pointerType === "mouse") return;
e.preventDefault();
el.setPointerCapture(e.pointerId);
el.classList.add("dragging");
const bounds = this._ratioBounds(node);
const startRatio = node.ratio;
const horiz = node.dir === "row";
const startPos = horiz ? e.clientX : e.clientY;
document.body.style.cursor = horiz ? "col-resize" : "row-resize";
document.body.style.userSelect = "none";
const onMove = (e2) => {
const delta = (horiz ? e2.clientX : e2.clientY) - startPos;
node.ratio = Math.max(
bounds.min,
Math.min(bounds.max, startRatio + delta / bounds.px),
);
this._applyLayout();
};
const onUp = () => {
el.classList.remove("dragging");
document.body.style.cursor = "";
document.body.style.userSelect = "";
document.removeEventListener("pointermove", onMove);
document.removeEventListener("pointerup", onUp);
document.removeEventListener("pointercancel", onUp);
this._persist(); // the settled ratio is part of the working set
};
// The move/up listeners live on DOCUMENT, not the handle: a layout
// rebuild can remove the handle MID-DRAG (e.g. a server-side ws_closed
// collapses a cell), and handle-bound listeners would die with it,
// stranding the body-wide cursor/user-select overrides. Capture loss on
// removal just redirects the events to the hit-test chain — document
// still hears them and onUp always runs.
document.addEventListener("pointermove", onMove);
document.addEventListener("pointerup", onUp);
document.addEventListener("pointercancel", onUp);
});
// Keyboard resize: arrows nudge (Shift = coarse), Home/End to the bounds.
el.addEventListener("keydown", (e) => {
const bounds = this._ratioBounds(node);
const step = e.shiftKey ? 0.1 : 0.02;
let delta = 0;
if (e.key === "ArrowRight" || e.key === "ArrowDown") delta = step;
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") delta = -step;
else if (e.key === "Home") delta = bounds.min - node.ratio;
else if (e.key === "End") delta = bounds.max - node.ratio;
else return;
e.preventDefault();
node.ratio = Math.max(
bounds.min,
Math.min(bounds.max, node.ratio + delta),
);
this._applyLayout();
this._persist();
});
}
// ----- persistence -----
_serializeLayout(node) {
if (node.type === "leaf") return { type: "leaf", paneId: node.paneId };
return {
type: "split",
dir: node.dir,
ratio: node.ratio,
children: [
this._serializeLayout(node.children[0]),
this._serializeLayout(node.children[1]),
],
};
}
/** Re-apply a persisted layout after rehydrate re-opened the panes. Leaves
* whose pane did not restore (skipped type, auth-denied, duplicate) prune
* away and their sibling absorbs the space — the same degrade-don't-error
* stance rehydrate takes on pane types. */
_restoreLayout(data) {
const seen = new Set();
const prune = (d) => {
if (!d || typeof d !== "object") return null;
if (d.type === "leaf") {
if (!this._panes.has(d.paneId) || seen.has(d.paneId)) return null;
seen.add(d.paneId);
return { type: "leaf", paneId: d.paneId };
}
if (d.type !== "split" || !Array.isArray(d.children)) return null;
const a = prune(d.children[0]);
const b = prune(d.children[1]);
if (!a || !b) return a || b;
return {
type: "split",
dir: d.dir === "col" ? "col" : "row",
ratio:
typeof d.ratio === "number" && d.ratio >= 0.05 && d.ratio <= 0.95
? d.ratio
: 0.5,
children: [a, b],
};
};
const tree = prune(data);
if (!tree || tree.type === "leaf") return; // 0-1 cells — stay single-pane
this._layout = tree;
this.panesEl.classList.add("panes--split");
this._applyLayout(true);
// The persisted active pane may have failed to restore — focus the first
// cell rather than leaving a hidden pane active.
if (!this._leafFor(this._activeId)) {
const first = this._firstLeaf(tree);
if (first) this.activate(first.paneId);
}
this._renderTabs(); // pick up the .shown markers
}
_renderTabs() {
// Reconcile the managed tabs IN PLACE — never destroy + recreate. Keeps
// keyboard focus and click/keydown listeners stable across activate / open /
@@ -538,10 +1058,13 @@ export class PaneManager {
}
/** Refresh a tab's selection state: roving tabindex (only the selected tab is
* in the Tab order) + aria-selected + the `.active` style hook. */
* in the Tab order) + aria-selected + the `.active` style hook. `.shown`
* marks a pane that is VISIBLE in a split cell without being the focused
* one (aria-selected stays single — selection means focus). */
_refreshTab(tab, pane) {
const active = pane.id === this._activeId;
tab.classList.toggle("active", active);
tab.classList.toggle("shown", !active && !!this._leafFor(pane.id));
tab.setAttribute("aria-selected", active ? "true" : "false");
tab.tabIndex = active ? 0 : -1;
}
@@ -625,10 +1148,11 @@ export class PaneManager {
if (p.meta != null) entry.meta = p.meta;
return entry;
});
sessionStorage.setItem(
this.storageKey,
JSON.stringify({ order, active: this._activeId }),
);
const state = { order, active: this._activeId };
// The split tree rides along (leaves reference pane ids, which are
// deterministic type[:id] strings — rehydrate recomputes the same ones).
if (this._layout) state.layout = this._serializeLayout(this._layout);
sessionStorage.setItem(this.storageKey, JSON.stringify(state));
} catch (e) {
/* sessionStorage may be unavailable (private mode / disabled) — non-fatal */
}
@@ -660,6 +1184,9 @@ export class PaneManager {
if (state.active && this._panes.has(state.active)) {
this.activate(state.active);
}
// Layout LAST: every pane it references is open (or pruned), and the
// active pane is settled — the restore just re-applies the cells.
if (restored && state.layout) this._restoreLayout(state.layout);
return restored;
}
}
+142 -6
View File
@@ -559,29 +559,50 @@
.tab-caret:hover {
background: var(--hair-2);
}
.tab-add {
/* Split controls in the tab-bar tail (they replaced the redundant [+] — the
permanent Dashboard tab IS the launcher). One ◫ glyph for both directions;
the down variant rotates the glyph span, not the button (a rotated button
would rotate its focus ring too). */
.tb-split {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
color: var(--ink-4);
/* --ink-3, not the chrome-ghost --ink-4: these are available CONTROLS and
the [+] convention they replaced is gone — they must read as present. */
color: var(--ink-3);
border-radius: var(--r-sm);
cursor: pointer;
font-size: 16px;
font-size: 14px;
line-height: 1;
border: none;
background: none;
font-family: var(--font-ui);
}
.tab-add:hover {
.tb-split:hover {
background: var(--panel-2);
color: var(--ink-2);
}
.tb-split:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.tb-split .tb-glyph {
display: inline-block;
}
.tb-split--down .tb-glyph {
transform: rotate(90deg);
}
.tabbar-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
color: var(--ink-4);
/* hairline fence so the split cluster reads as one "layout" control group */
border-left: 1px solid var(--hair);
padding-left: 10px;
}
/* Drawer toggle + backdrop — desktop keeps the rail in the grid, so both are
dormant here; the mobile block at the end of this sheet brings them up. */
@@ -729,8 +750,8 @@
}
}
/* pane host — ONE pane visible per tab (no split; the mock's 2-up was a
display device only, so this is a single column, not `1fr 1fr`). */
/* pane host — ONE pane visible per tab by default; the split view below can
show several at once (PaneManager toggles .panes--split). */
.panes {
flex: 1;
display: grid;
@@ -746,6 +767,111 @@
.pane[hidden] {
display: none;
}
/* ===== Split view — visible panes become absolutely-positioned cells (inline
% insets from PaneManager._applyLayout; % keeps window resizes free). The
panes stay DIRECT children of .panes and are never reparented, so a live
stream's DOM, scroll state and media playback ride out every layout change.
Only `section.pane` (the ShellPane host) is targeted — the interactive
pane's INNER div also carries .pane (the 5b lesson) and must not match. ===== */
.panes--split {
position: relative;
}
.panes--split > section.pane {
position: absolute;
overflow: hidden;
}
/* the focused cell — only meaningful with 2+ cells. A quiet inset ring PLUS
a 2px top accent bar (the rail's .row.open::before vocabulary): the bar is
the load-bearing cue — designer-measured, no thin tinted ring clears WCAG
1.4.11's 3:1 on the light theme, and 38%-mix was ~invisible on dark. */
.panes--split > section.pane.split-focused {
box-shadow: inset 0 0 0 1px
color-mix(in srgb, var(--accent) 55%, var(--hair-2));
}
.panes--split > section.pane.split-focused::before {
content: "";
position: absolute;
inset: 0 0 auto 0; /* top edge, full cell width */
height: 2px;
background: var(--accent);
z-index: 6; /* above the separators (z:5) where they meet the corner */
pointer-events: none;
}
/* separator — a 7px hit area straddling the cell boundary with a 1px visual
line; drag (pointer capture) or arrow keys resize, see pane.js _wireHandle.
Resting line is --ink-4, the lightest token clearing 3:1 in BOTH themes
(--hair measured ~1.3:1 — an undiscoverable drag target); hover/drag goes
SOLID accent and thickens (--accent-dim was a luminance DROP on hover). */
.split-handle {
position: absolute;
z-index: 5;
}
.split-handle--row {
width: 7px;
cursor: col-resize;
transform: translateX(-50%);
}
.split-handle--col {
height: 7px;
cursor: row-resize;
transform: translateY(-50%);
}
.split-handle::after {
content: "";
position: absolute;
background: var(--ink-4);
}
.split-handle--row::after {
left: 3px;
top: 0;
bottom: 0;
width: 1px;
}
.split-handle--col::after {
top: 3px;
left: 0;
right: 0;
height: 1px;
}
.split-handle:hover::after,
.split-handle.dragging::after,
.split-handle:focus-visible::after {
background: var(--accent);
}
.split-handle:focus-visible {
outline: none;
}
.split-handle--row:hover::after,
.split-handle--row.dragging::after,
.split-handle--row:focus-visible::after {
left: 2px;
width: 3px;
}
.split-handle--col:hover::after,
.split-handle--col.dragging::after,
.split-handle--col:focus-visible::after {
top: 2px;
height: 3px;
}
/* a visible-but-unfocused pane's tab — the three tab states must each read:
rest (bare) → shown (accent UNDERLINE — a shape, not a fill delta; rhymes
with the focused cell's TOP bar) → active (panel fill). Designer-measured:
a border/fill-only delta between shown and active was ≤1.1:1. */
.tab.shown:not(.active) {
border-color: var(--hair-2);
color: var(--ink-2);
}
.tab.shown:not(.active)::after {
content: "";
position: absolute;
left: 8px;
right: 8px;
bottom: 2px;
height: 2px;
border-radius: 2px;
background: color-mix(in srgb, var(--accent) 55%, transparent);
}
.pane-head {
display: flex;
align-items: center;
@@ -1219,6 +1345,16 @@
.tab {
max-width: 48vw;
}
/* splits are a desktop affordance (a phone viewport is below the cell
minimums before the first divider lands) — and with the buttons gone the
tail's group fence has nothing to fence */
.tb-split {
display: none;
}
.tabbar-right {
border-left: 0;
padding-left: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.rail {
+39 -20
View File
@@ -510,26 +510,45 @@ async function mountShell() {
});
pm.onActiveChange(() => setDrawer(false));
// [+] new-tab (step 7): a shortcut to the persona launcher. The Dashboard pane
// hosts the unified coordinator/interactive launcher (a new session needs a task
// prompt, so it composes there) — "new session" focuses it. showHome is exposed
// by both deployments; openPane is the fallback. Lives in the right-floated tail
// slot per the brief. (Auth is the launcher's own concern — it gates each
// persona option; focusing it is always safe.)
const addTab = make("button", "tab-add");
addTab.type = "button";
addTab.setAttribute("aria-label", "New session");
addTab.title = "New session";
addTab.textContent = "+";
addTab.addEventListener("click", () => {
if (typeof window.showHome === "function") window.showHome();
else pm.openPane("dashboard");
// Land in the launcher composer so "new session" is immediately typeable —
// showHome on the already-active Dashboard is otherwise a no-op.
if (window.TS_APP && typeof window.TS_APP.focusLauncher === "function")
window.TS_APP.focusLauncher();
});
shell.tail.append(addTab);
// Split controls (the revived split-view): they act on the FOCUSED pane.
// Split right / split down open a second cell beside/below it, filled with
// the most-recently-used backgrounded tab; Unsplit returns to one pane and
// only shows while split. They replaced the old [+] new-session button —
// the permanent Dashboard tab IS the launcher, so [+] duplicated one click.
// Deliberately NO contextmenu override anywhere (the pre-L-shell split UI
// hijacked right-click): these buttons are the whole affordance surface.
const tbBtn = (cls, glyph, label) => {
const b = make("button", cls);
b.type = "button";
b.setAttribute("aria-label", label);
b.title = label;
const g = make("span", "tb-glyph", glyph);
g.setAttribute("aria-hidden", "true"); // the button's aria-label speaks
b.append(g);
return b;
};
// Denials surface as a toast (space / pane-limit / nothing to show) — the
// manager stays chrome-free and just returns the reason.
const splitFeedback = (r) => {
if (r && !r.ok && r.reason && typeof window.showToast === "function")
window.showToast(r.reason, "warning");
};
const splitRightBtn = tbBtn("tb-split", "◫", "Split right");
splitRightBtn.addEventListener("click", () =>
splitFeedback(pm.splitFocused("right")),
);
const splitDownBtn = tbBtn("tb-split tb-split--down", "◫", "Split down");
splitDownBtn.addEventListener("click", () =>
splitFeedback(pm.splitFocused("down")),
);
const unsplitBtn = tbBtn("tb-split", "□", "Unsplit — keep the focused pane");
unsplitBtn.addEventListener("click", () => pm.unsplit());
const syncSplitControls = () => {
unsplitBtn.hidden = !pm.isSplit();
};
pm.onActiveChange(syncSplitControls);
syncSplitControls();
shell.tail.append(splitRightBtn, splitDownBtn, unsplitBtn);
// Dashboard pane (step 1): a singleton that ADOPTS the legacy #main so the
// console renders unchanged inside the new shell. Real pane types (admin,