mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
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:
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user