diff --git a/scripts/livepass.py b/scripts/livepass.py index 25b77b33..51e00c85 100755 --- a/scripts/livepass.py +++ b/scripts/livepass.py @@ -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- on success and + SPLIT-FAILED- 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 = """ """ +# -------------------------------------------------------------------------- +# 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- only when the built +# state matches the request — a denied/failed split stamps SPLIT-FAILED-. +# -------------------------------------------------------------------------- +SHELL_TEMPLATE = """ + + + + + shell livepass + + + + + + + + + + + + +
+

Dashboard

+

+ Launcher + workstreams table live here (livepass stub). +

+
+ + + + + + +""" + + 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]) diff --git a/tests/test_shell_js.py b/tests/test_shell_js.py index d5d2caa1..d6691f18 100644 --- a/tests/test_shell_js.py +++ b/tests/test_shell_js.py @@ -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: diff --git a/turnstone/console/static/app.js b/turnstone/console/static/app.js index 380d323f..1a5a16ec 100644 --- a/turnstone/console/static/app.js +++ b/turnstone/console/static/app.js @@ -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. diff --git a/turnstone/shared_static/pane.js b/turnstone/shared_static/pane.js index 13894755..40b34d8c 100644 --- a/turnstone/shared_static/pane.js +++ b/turnstone/shared_static/pane.js @@ -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
is the DIRECT child of the host (the interactive + // pane's inner
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; } } diff --git a/turnstone/shared_static/shell.css b/turnstone/shared_static/shell.css index 048d7b87..01ecdd32 100644 --- a/turnstone/shared_static/shell.css +++ b/turnstone/shared_static/shell.css @@ -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 { diff --git a/turnstone/shared_static/shell.js b/turnstone/shared_static/shell.js index a87b5c9c..adaaaf32 100644 --- a/turnstone/shared_static/shell.js +++ b/turnstone/shared_static/shell.js @@ -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,