mirror of
https://github.com/mvschwarz/openrig-studio.git
synced 2026-11-07 05:56:21 -10:00
Founder-directed, prototyped and measured by studio-impl, routed here rather than landed because app/shell.html is this repo's. The header no longer spans the full width; the rail goes to the top of the page; the collapse control moves onto the rail. #app becomes a row with the rail as its direct child, and a new #maincol holds the header above #shellframes beside it. The launcher is now one continuous edge from the top of the window, and the header belongs to the surface it sits above rather than reading as a second band across everything. The toggle moves for a reason beyond layout: among MARKUP and AGENTS it read as a studio-level control when it is only ever about the launcher. A control belongs to the thing it acts on. ONE CHANGE TO WHAT WAS HANDED OVER, which studio-impl offered: their patch added a second #rail-head block beside the original rather than restructuring, and the same would have applied to body.rail-collapsed #rail-head. Two rules for one selector is two places computing one property — the shape this repo has already paid for three times — so they are merged. The `font-size: 0` that used to hide the head text goes with it: a .headtext wrapper hides explicitly, and an inherited-zero trick sitting beside a display:none that already does the job is one of the two that drifts. MEASURED IN A LIVE SHELL, both states: expanded : rail 212x720 (full viewport height), #topbar.left = 212 collapsed : body.rail-collapsed, rail 52x720, #topbar.left = 52, glyphs intact Every header control hit-tested at its own centre and reachable — box placement and "is this the element a click reaches" are different claims, and an overlap in this same file was found that way before. AND THE THING THE RESTRUCTURE COULD MOST PLAUSIBLY HAVE BROKEN, checked rather than assumed: an agent mark anchored to an element in the surface landed with a gap of 0x0 after #stage moved 212px right. The overlay mounts on #stage, not on #app or #shellframes, so the outer restructure does not reach it — studio-impl said so and it is now measured and pinned. 309 tests. The committed checks are structural and say so.
772 lines
43 KiB
HTML
772 lines
43 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>openrig studio</title>
|
|
<style>
|
|
:root {
|
|
--ink: #121214; --panel: rgba(255,255,255,.025); --edge: rgba(255,255,255,.10);
|
|
--edge2: rgba(255,255,255,.16); --bone: rgba(255,255,255,.95); --dim: rgba(255,255,255,.52);
|
|
--body: rgba(255,255,255,.68); --faint: rgba(255,255,255,.38); --red: #ff6b5a;
|
|
--sans: Helvetica, "Helvetica Neue", Arial, sans-serif;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
html { color-scheme: dark; }
|
|
html, body { height: 100%; margin: 0; background: var(--ink); font-family: var(--sans); }
|
|
/* THE RAIL IS FULL HEIGHT AND THE HEADER STARTS BESIDE IT. The header used to
|
|
span the whole width with the rail beneath it, which read as two separate
|
|
bands and put the launcher's collapse control in the header rather than on
|
|
the thing it collapses. Now the launcher is one continuous edge from the top
|
|
of the window, and the header belongs to the surface it sits above. */
|
|
#app { display: flex; flex-direction: row; height: 100vh; }
|
|
#maincol { display: flex; flex-direction: column; flex: 1; min-width: 0; min-height: 0; }
|
|
/* THE TWO PERSISTENT FRAMES. Every surface gets the same launcher on the left
|
|
and the same header on top, so the studio reads as one application rather
|
|
than a set of pages that each invented their own chrome. A surface renders
|
|
inside them and does not draw either. */
|
|
#shellframes { display: flex; flex: 1; min-height: 0; }
|
|
#topbar { display: flex; align-items: center; gap: 12px; padding: 0 14px; height: 46px;
|
|
background: var(--panel); border-bottom: 1px solid var(--edge); flex: none; }
|
|
#topbar .title { color: var(--bone); font-weight: 700; letter-spacing: .22em; font-size: 12px; }
|
|
#topbar .crumb { color: var(--faint); font-size: 11px; overflow: hidden;
|
|
text-overflow: ellipsis; white-space: nowrap; }
|
|
/* THE HEADER IS SPLIT: the left half is the APP's, the right half is the SHELL's.
|
|
An app declares what goes in its half; it never draws chrome, and it cannot
|
|
reach into the shell's. That boundary is what closes the two-headers problem
|
|
in the contract rather than by asking every app to delete something. */
|
|
#appHalf { display: flex; align-items: center; gap: 12px; flex: 1; min-width: 0; }
|
|
#shellHalf { display: flex; align-items: center; gap: 8px; flex: none; }
|
|
#appActions { display: flex; align-items: center; gap: 6px; flex: none; }
|
|
#topbar button { background: none; border: 1px solid var(--edge2); border-radius: 2px;
|
|
color: var(--body); font: inherit; font-size: 9px; letter-spacing: .18em; text-transform: uppercase;
|
|
font-weight: 600; padding: 7px 12px; cursor: pointer; }
|
|
#topbar button:hover { color: var(--bone); border-color: var(--bone); }
|
|
#topbar button.on { background: var(--bone); border-color: var(--bone); color: #111; }
|
|
/* Lives on the rail now, not in the header: a control belongs to the thing it
|
|
acts on, and in the header it read as a studio-level control beside MARKUP
|
|
and AGENTS when it is only ever about the launcher. */
|
|
#railToggle { flex: none; width: 28px; height: 24px; padding: 0; text-align: center;
|
|
background: none; border: 1px solid var(--edge2); border-radius: 2px; color: var(--faint);
|
|
cursor: pointer; font: inherit; font-size: 12px; line-height: 1; }
|
|
#railToggle:hover { color: var(--bone); border-color: var(--bone); }
|
|
#rail { width: 212px; min-width: 212px; background: var(--panel); border-right: 1px solid var(--edge);
|
|
display: flex; flex-direction: column; }
|
|
/* COLLAPSE TO A TOOLSTRIP, not to nothing. Small screens and wide work want the
|
|
room back, but a launcher you cannot see is one you cannot use — the glyphs
|
|
stay, and they are already in the markup, so this is presentation only. */
|
|
body.rail-collapsed #rail { width: 52px; min-width: 52px; }
|
|
body.rail-collapsed #rail-head small,
|
|
body.rail-collapsed #rail-foot,
|
|
body.rail-collapsed #manifest-health,
|
|
body.rail-collapsed .tab .name,
|
|
body.rail-collapsed .tab .dot { display: none; }
|
|
/* Collapsed, the toggle is the only thing left in the head, so it centres
|
|
rather than sitting against a title that is no longer rendered. */
|
|
body.rail-collapsed #rail-head { justify-content: center; padding: 16px 0 10px; }
|
|
body.rail-collapsed #rail-head .headtext { display: none; }
|
|
body.rail-collapsed #tabs { padding: 6px 4px; }
|
|
body.rail-collapsed .tab { justify-content: center; padding: 9px 0; }
|
|
body.rail-collapsed .tab .glyph { width: auto; }
|
|
#rail-head { display: flex; align-items: flex-start; gap: 10px;
|
|
padding: 20px 16px 10px; color: var(--bone); font-weight: 700; letter-spacing: 4px; font-size: 13px; }
|
|
#rail-head .headtext { flex: 1; min-width: 0; display: block; }
|
|
#rail-head small { display: block; color: var(--faint); font-weight: 400; letter-spacing: .14em;
|
|
font-size: 9.5px; margin-top: 5px; text-transform: uppercase; }
|
|
#tabs { flex: 1; overflow-y: auto; padding: 6px 8px; }
|
|
.tab { display: flex; align-items: center; gap: 10px; width: 100%; padding: 9px 10px; margin: 2px 0;
|
|
border: 1px solid transparent; border-radius: 2px; background: none; color: var(--body); cursor: pointer;
|
|
font: inherit; font-size: 12.5px; text-align: left; }
|
|
.tab:hover { color: var(--bone); border-color: var(--edge2); }
|
|
.tab.active { color: #111; background: var(--bone); border-color: var(--bone); font-weight: 600; }
|
|
.tab .glyph { font-size: 14px; width: 18px; text-align: center; }
|
|
.tab .dot { margin-left: auto; width: 6px; height: 6px; border-radius: 50%; background: var(--edge2); flex: none; }
|
|
.tab .dot.live { background: var(--bone); box-shadow: 0 0 6px rgba(255,255,255,.5); }
|
|
.tab.active .dot.live { background: #111; }
|
|
.tab .dot.down { background: var(--red); }
|
|
#manifest-health { padding: 8px 16px; font-size: 10px; letter-spacing: .06em; color: var(--faint); }
|
|
#manifest-health.bad { color: var(--red); }
|
|
#rail-foot { padding: 12px 16px; border-top: 1px solid var(--edge); color: var(--faint);
|
|
font-size: 10px; line-height: 1.6; }
|
|
#stage { flex: 1; position: relative; background: var(--ink); }
|
|
#stage iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; background: var(--ink); }
|
|
#stage iframe.hidden { visibility: hidden; pointer-events: none; }
|
|
/* An idle overlay must not eat clicks meant for the surface. A feature that
|
|
wants input sets pointer-events on its own node inside this host. */
|
|
#shell-overlay { position: absolute; inset: 0; z-index: 30; pointer-events: none; }
|
|
#empty { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
|
color: var(--faint); font-size: 11px; letter-spacing: .2em; text-transform: uppercase; }
|
|
/* ---- the agent sidebar ----
|
|
Measurements ported from the shell this was extracted from, where they
|
|
survived a week of daily use. 400px is not arbitrary: a terminal at this
|
|
font size needs it to avoid wrapping an agent's output into noise, and the
|
|
earlier 320px guess is what made the pane feel cramped. */
|
|
#agents { width: 400px; min-width: 400px; display: none; flex-direction: column;
|
|
border-left: 1px solid var(--edge); background: var(--panel); backdrop-filter: blur(14px);
|
|
min-height: 0; }
|
|
#agents.open { display: flex; }
|
|
#agents-head { display: flex; align-items: center; gap: 8px; padding: 9px 10px;
|
|
border-bottom: 1px solid var(--edge); flex: 0 0 auto; }
|
|
#agents-head .word { color: var(--bone); font-size: 9.5px; letter-spacing: .22em;
|
|
font-weight: 700; cursor: pointer; }
|
|
#agents-head .word:hover { color: var(--dim); }
|
|
#chatRigSel { background: rgba(255,255,255,.04); color: var(--dim); border: 1px solid var(--edge);
|
|
border-radius: 2px; font: 9px var(--sans); letter-spacing: .14em; text-transform: uppercase;
|
|
padding: 3px 6px; cursor: pointer; appearance: none; }
|
|
#chatRigSel:hover { color: var(--bone); border-color: var(--edge2); }
|
|
#seatName { flex: 1; color: var(--faint); font-size: 10px; letter-spacing: .04em;
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: right; }
|
|
#seatTiles { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px;
|
|
padding: 6px 8px 8px; border-bottom: 1px solid var(--edge); flex: 0 0 auto; }
|
|
#seatTiles:empty { display: none; }
|
|
.seatTile { background: rgba(255,255,255,.04); border: 1px solid var(--edge2);
|
|
border-radius: 2px; color: var(--body); font: inherit; font-size: 9px;
|
|
letter-spacing: .1em; padding: 7px 2px; cursor: pointer; text-align: center;
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
text-transform: uppercase; }
|
|
.seatTile:hover { color: var(--bone); border-color: rgba(255,255,255,.35); }
|
|
.seatTile.active { color: #111; background: var(--bone); border-color: var(--bone);
|
|
font-weight: 600; }
|
|
/* Configured but not running. Dimmed and not-allowed rather than hidden: the
|
|
roster is what this box is CONFIGURED with, so a stopped member disappearing
|
|
would turn that into "who happens to be alive this second" and make the
|
|
panel shrink silently when something dies. It stays visible and says why. */
|
|
.seatTile.down { color: var(--faint); border-style: dashed; cursor: not-allowed; }
|
|
.seatTile.down:hover { color: var(--faint); border-color: var(--edge2); }
|
|
.seatTile .st { display: block; font-size: 7.5px; letter-spacing: .08em; opacity: .7;
|
|
margin-top: 3px; }
|
|
/* flex:1 with min-height:0 is what actually bounds the terminal. Without the
|
|
min-height a flex child refuses to shrink below its content, so the iframe
|
|
grew to whatever height xterm asked for and pushed the prompt off-screen —
|
|
the pane looked pinned tall and the input was unreachable. */
|
|
#chatframe { flex: 1 1 auto; min-height: 0; width: 100%; border: 0; background: #0a0a0c; }
|
|
#chatframe:not([src]) { display: none; }
|
|
#agents-body { padding: 14px; color: var(--body); font-size: 12px; line-height: 1.6;
|
|
overflow: auto; flex: 0 1 auto; }
|
|
#agents-body:empty { display: none; }
|
|
#agents-body .seatrow { padding: 8px 10px; border: 1px solid var(--edge2); border-radius: 2px; margin-bottom: 6px; }
|
|
#agents-body .honest { color: var(--faint); font-size: 10.5px; margin-top: 10px; }
|
|
/* Lives in the header now. It used to float in the stage at position:absolute,
|
|
which is why it sat ON TOP of the MARKUP button the first time this rendered —
|
|
an absolutely positioned child does not participate in the flex row it was
|
|
moved into. Styling comes from #topbar button; only the emphasis is local. */
|
|
#agentToggle { flex: none; background: var(--bone); border-color: var(--bone); color: #111; }
|
|
#agentToggle:hover { background: rgba(255,255,255,.8); border-color: var(--bone); color: #111; }
|
|
/* hideAgents rows suppress the sidebar toggle while active (shell-protocol) */
|
|
body.hide-agents #agentToggle { display: none !important; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="app">
|
|
<nav id="rail">
|
|
<div id="rail-head">
|
|
<button id="railToggle" title="collapse the launcher to a toolstrip">☰</button>
|
|
<span class="headtext">STUDIO<small>openrig studio · contract v0.1</small></span>
|
|
</div>
|
|
<div id="tabs"></div>
|
|
<div id="manifest-health"></div>
|
|
<div id="rail-foot">surfaces are rooms.<br/>this is the hallway.</div>
|
|
</nav>
|
|
<div id="maincol">
|
|
<header id="topbar">
|
|
<div id="appHalf">
|
|
<span class="title" id="topTitle">STUDIO</span>
|
|
<span class="crumb" id="topCrumb"></span>
|
|
<span id="appActions"></span>
|
|
</div>
|
|
<div id="shellHalf">
|
|
<button id="markupToggle" title="annotate this surface">MARKUP</button>
|
|
<button id="agentToggle" title="the seat roster">AGENTS ▸</button>
|
|
</div>
|
|
</header>
|
|
<div id="shellframes">
|
|
<main id="stage"><div id="empty">no surfaces in the manifest yet</div>
|
|
</main>
|
|
<aside id="agents">
|
|
<div id="agents-head">
|
|
<span class="word" id="agentsWord">AGENTS ▾</span>
|
|
<select id="chatRigSel" title="which rig's seats fill the launcher"></select>
|
|
<span id="seatName"></span>
|
|
</div>
|
|
<div id="seatTiles"></div>
|
|
<div id="agents-body"></div>
|
|
<iframe allow="clipboard-read; clipboard-write" id="chatframe" title="agent seat"></iframe>
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
(async () => {
|
|
// ============================================================================
|
|
// SHELL STATE. EVERY MODULE-LEVEL const/let THIS FILE ADDS GOES HERE, ABOVE
|
|
// EVERYTHING THAT COULD READ IT. THIS IS NOT A STYLE PREFERENCE.
|
|
//
|
|
// Four temporal dead zones have now shipped from this one file, all identical:
|
|
// a binding declared next to the code that FIRES it — which is where it reads
|
|
// most naturally — while a function defined earlier READS it during boot.
|
|
// activate() runs while the shell starts, so anything it touches must already
|
|
// exist by then.
|
|
//
|
|
// THE FAILURE IS WHY THIS DESERVES A RULE RATHER THAN CARE: it does not look
|
|
// like a crash. The rail renders, the tabs render, the page is plainly alive —
|
|
// and the header stays blank, because activate() threw halfway through. Three
|
|
// of the four were found by something else being missing, never by the page
|
|
// looking wrong.
|
|
//
|
|
// Declarations above their readers; startup work at the very bottom, which is
|
|
// why the init block is where it is.
|
|
//
|
|
// MECHANISED, PARTLY, AND THE LIMIT IS STATED: test/shell-boot.test.mjs fails
|
|
// when a module-level Map or Set is declared outside this block — which is what
|
|
// all four faults were. It is NOT a TDZ prover and does not run the shell; a
|
|
// scalar read early still slips past it. The genuine check is a browser that
|
|
// fails when this page does not finish booting, and this SDK has no browser
|
|
// dependency to do it with.
|
|
// ============================================================================
|
|
const surfaceListeners = new Set();
|
|
const markupListeners = new Set();
|
|
// "My layout moved — re-measure, but do NOT reload." A separate channel from
|
|
// surfaceListeners on purpose: that one means the board CHANGED and reloads
|
|
// records, which resets undo history. A surface whose canvas item moved wants
|
|
// neither of those, it wants the geometry recomputed.
|
|
const refreshListeners = new Set();
|
|
// What each surface declared for its half of the header. The shell holds it, so
|
|
// a surface that declares once on load keeps its header across a switch away and
|
|
// back — it has no reason to speak twice, and reverting to manifest defaults
|
|
// showed a stale crumb and no actions while the surface was unchanged.
|
|
const declaredHeaders = new Map();
|
|
// id -> the iframe showing that surface. Pre-existing shell state that lived
|
|
// beside its first use; it belongs here with the rest for the same reason.
|
|
const frames = new Map();
|
|
// surface id -> the sub-context that surface last declared, or absent.
|
|
//
|
|
// WHY THIS EXISTS: a surface id is not always fine enough. ARTIFACTS holds many
|
|
// independent documents behind ONE id, so marks keyed on the id alone either
|
|
// sit over the wrong document after a page change or collapse every document's
|
|
// thread into one. The shell cannot work out what a surface's documents are —
|
|
// only the surface knows — so the surface may refine the default, and the
|
|
// default is the surface id for everything that says nothing.
|
|
const declaredContexts = new Map();
|
|
// surface id -> the id of a SAME-ORIGIN nested frame that surface wants marks
|
|
// anchored inside. Separate from the context above and deliberately so: the
|
|
// context says WHICH BOARD, this says WHERE TO LOOK. A surface can need one
|
|
// without the other, and folding them into one field would force a surface with
|
|
// a nested frame to invent document identities it does not have.
|
|
const declaredTargets = new Map();
|
|
|
|
// --- contract meta first: the shell itself is a contract consumer -------
|
|
let meta = null;
|
|
try { meta = await (await fetch("/api/contract", { cache: "no-store" })).json(); } catch {}
|
|
const health = document.getElementById("manifest-health");
|
|
if (!meta) { health.className = "bad"; health.textContent = "runtime unreachable"; }
|
|
else if (!meta.manifest.ok) {
|
|
health.className = "bad";
|
|
health.textContent = `manifest: ${meta.manifest.errors.length} error(s) — see /api/contract`;
|
|
} else if (meta.manifest.warnings.length) {
|
|
health.textContent = `manifest ok · ${meta.manifest.warnings.length} warning(s)`;
|
|
} else { health.textContent = "manifest ok"; }
|
|
|
|
// --- surfaces from the manifest (valid rows only are served) ------------
|
|
let reg = { surfaces: [] };
|
|
try { reg = await (await fetch("/surfaces.json", { cache: "no-store" })).json(); } catch {}
|
|
// defense in depth: the runtime already serves valid rows only; still never
|
|
// render a row that lacks a target (a path-less row would recurse into "/")
|
|
const surfaces = (reg.surfaces || [])
|
|
.filter((s) => s && s.id && (s.url || (typeof s.path === "string" && s.path.startsWith("/"))))
|
|
.map((s) => s.url ? s : ({ ...s, url: location.origin + s.path }));
|
|
|
|
const tabsEl = document.getElementById("tabs");
|
|
const stage = document.getElementById("stage");
|
|
let activeId = null;
|
|
const byId = (id) => surfaces.find((s) => s.id === id);
|
|
|
|
function activate(id, fromNav) {
|
|
const s = byId(id); if (!s) return;
|
|
if (!fromNav && activeId !== id) { try { history.pushState({ s: id }, "", "?s=" + encodeURIComponent(id)); } catch {} }
|
|
if (s.popout) { window.open(s.url, "os-" + s.id); return; }
|
|
activeId = id;
|
|
document.getElementById("empty").style.display = "none";
|
|
// unmountOnBlur rows release their frame when backgrounded (terminal-
|
|
// style surfaces must not keep hidden attached clients alive)
|
|
for (const [fid, f] of frames) {
|
|
if (fid !== id && (byId(fid) || {}).unmountOnBlur) { f.remove(); frames.delete(fid); }
|
|
}
|
|
if (!frames.has(id)) {
|
|
const f = document.createElement("iframe");
|
|
f.src = s.url; f.title = s.name;
|
|
stage.appendChild(f); frames.set(id, f);
|
|
}
|
|
for (const [fid, f] of frames) f.classList.toggle("hidden", fid !== id);
|
|
for (const el of tabsEl.children) el.classList.toggle("active", el.dataset.id === id);
|
|
document.body.classList.toggle("hide-agents", !!s.hideAgents);
|
|
document.title = "openrig studio · " + s.name.toLowerCase();
|
|
// The header names what you are looking at. It is the shell speaking, not the
|
|
// surface — a surface that drew its own title bar would disagree with the rail
|
|
// the moment either changed.
|
|
// The previous surface's actions are meaningless here, and a button that
|
|
// still LOOKS live is worse than an empty space. Cleared first, then this
|
|
// surface's own declaration is restored if it made one — so the reset is
|
|
// never visible as a flash of the wrong header.
|
|
document.getElementById("appActions").replaceChildren();
|
|
for (const fn of surfaceListeners) { try { fn(s.id); } catch {} }
|
|
document.getElementById("topTitle").textContent = s.name;
|
|
document.getElementById("topCrumb").textContent = s.hint || "";
|
|
// ORDER IS LOAD-BEARING: the manifest defaults land first, then whatever this
|
|
// surface declared overrides them. Restoring before the defaults meant the
|
|
// defaults won — the actions came back but the declared crumb was silently
|
|
// replaced by the manifest hint, which reads as correct and is not.
|
|
const remembered = declaredHeaders.get(s.id);
|
|
if (remembered) renderAppHeader({ actions: [], ...remembered });
|
|
}
|
|
|
|
function renderTabs() {
|
|
tabsEl.innerHTML = "";
|
|
for (const s of surfaces) {
|
|
const b = document.createElement("button");
|
|
b.className = "tab"; b.dataset.id = s.id;
|
|
b.innerHTML = `<span class="glyph">${s.glyph || "□"}</span><span class="name">${s.name}</span><span class="dot" id="dot-${s.id}"></span>`;
|
|
b.title = s.hint || "";
|
|
b.onclick = () => activate(s.id);
|
|
if (s.id === activeId) b.classList.add("active");
|
|
tabsEl.appendChild(b);
|
|
}
|
|
}
|
|
renderTabs();
|
|
|
|
// liveness dots: an opaque no-cors fetch succeeds iff something answers
|
|
async function pulse() {
|
|
await Promise.all(surfaces.map(async (s) => {
|
|
const dot = document.getElementById("dot-" + s.id); if (!dot) return;
|
|
try { await fetch(s.url, { mode: "no-cors", cache: "no-store", signal: AbortSignal.timeout(2500) });
|
|
dot.className = "dot live"; } catch { dot.className = "dot down"; }
|
|
}));
|
|
}
|
|
pulse(); setInterval(pulse, 15000);
|
|
|
|
// deep link
|
|
const qs = new URLSearchParams(location.search).get("s");
|
|
if (byId(qs)) activate(qs, true); else if (surfaces[0]) activate(surfaces[0].id, true);
|
|
addEventListener("popstate", (e) => {
|
|
const id = (e.state && e.state.s) || new URLSearchParams(location.search).get("s");
|
|
if (id && byId(id)) activate(id, true);
|
|
});
|
|
|
|
// --- the shell protocol: the ONLY surface->shell API --------------------
|
|
addEventListener("message", (e) => {
|
|
if (!e.data || typeof e.data !== "object") return;
|
|
if (e.data.t === "open-surface" && byId(e.data.id)) activate(e.data.id);
|
|
if (e.data.t === "open-agent") openAgents(e.data.seat);
|
|
if (e.data.t === "annotation-refresh") {
|
|
// CHEAP AND EXPLICIT. DOM motion inside a surface is invisible from out
|
|
// here: an element that MOVES changes neither the frame's size nor its
|
|
// scroll, so nothing fires and anchored marks sit at the old geometry until
|
|
// something unrelated forces a repaint. Measured by studio-qa: a canvas item
|
|
// moved (220,170) -> (760,430) and the mark only snapped on a window resize.
|
|
//
|
|
// This does not reload the board and does not touch undo history. Re-posting
|
|
// annotation-target with the SAME frame id stays a no-op deliberately —
|
|
// overloading it as a nudge would make every redundant declaration re-anchor
|
|
// and reset undo, so the nudge gets its own verb.
|
|
const current = frames.get(activeId);
|
|
if (current && e.source === current.contentWindow) {
|
|
for (const fn of refreshListeners) { try { fn(); } catch {} }
|
|
}
|
|
}
|
|
if (e.data.t === "annotation-target") {
|
|
// Same active-surface guard as the header and the context, for the same
|
|
// reason: a backgrounded surface still holds a live channel.
|
|
const current = frames.get(activeId);
|
|
if (current && e.source === current.contentWindow) {
|
|
const frameId = typeof e.data.frame === "string" && e.data.frame ? e.data.frame : null;
|
|
const had = declaredTargets.get(activeId) ?? null;
|
|
if (had !== frameId) {
|
|
if (frameId) declaredTargets.set(activeId, frameId); else declaredTargets.delete(activeId);
|
|
// Re-anchor: every mark's geometry was computed against the previous
|
|
// document, so leaving them would draw them at coordinates that mean
|
|
// nothing in the new one.
|
|
for (const fn of surfaceListeners) { try { fn(activeId); } catch {} }
|
|
}
|
|
}
|
|
}
|
|
if (e.data.t === "annotation-context") {
|
|
// Only the ACTIVE surface may speak, for the same reason as the header: a
|
|
// backgrounded surface still holds a live channel, and without this the
|
|
// scope would follow whichever surface spoke last rather than the one you
|
|
// are looking at.
|
|
const current = frames.get(activeId);
|
|
if (current && e.source === current.contentWindow) {
|
|
const id = typeof e.data.id === "string" && e.data.id ? e.data.id : null;
|
|
const had = declaredContexts.get(activeId) ?? null;
|
|
if (had !== id) {
|
|
if (id) declaredContexts.set(activeId, id); else declaredContexts.delete(activeId);
|
|
// A context change is the same event as a surface change to anything
|
|
// drawing over the stage: save what you had, load what belongs here,
|
|
// clear and re-anchor. Reusing the one channel is what keeps a feature
|
|
// from needing to know that sub-contexts exist at all.
|
|
for (const fn of surfaceListeners) { try { fn(activeId); } catch {} }
|
|
}
|
|
}
|
|
}
|
|
if (e.data.t === "header") {
|
|
const active = frames.get(activeId);
|
|
if (active && e.source === active.contentWindow) {
|
|
// Merge, so a surface may update one field without restating the rest.
|
|
declaredHeaders.set(activeId, { ...(declaredHeaders.get(activeId) || {}), ...e.data });
|
|
renderAppHeader(e.data);
|
|
}
|
|
}
|
|
});
|
|
|
|
// --- agent sidebar: a real seat's session in a frame -------------------
|
|
// The multiplayer surface — a human and the agents that wrote the app, on the
|
|
// same screen — is the point of this SDK, so it lives here in the shell
|
|
// rather than inside any one app's provider.
|
|
//
|
|
// Degrades honestly in three distinguishable ways, because "no seats" has
|
|
// more than one cause and a surface can only act on the difference if it is
|
|
// told which: no roster declared at all, a roster with no terminal endpoint
|
|
// (so seats are listed but not attachable), and a working attach.
|
|
const agents = document.getElementById("agents");
|
|
const agentsBody = document.getElementById("agents-body");
|
|
const seats = (reg.chatSeats || []).filter((s) => s && s.seat);
|
|
const termPort = reg.chatLocalPort;
|
|
// The seat name IS the argument — a terminal is addressed by which seat it
|
|
// is attached to, not by a separate id to keep in sync.
|
|
//
|
|
// ALWAYS same-origin. This used to branch on whether the page was on
|
|
// loopback: direct to the terminal's own port locally, /chat through the
|
|
// origin otherwise. That worked on the machine it was written on and would
|
|
// have broken the moment a studio was reached through anything else, because
|
|
// nothing served /chat — the sidebar would have asked the studio for a
|
|
// terminal it does not host. One shape everywhere means the path that gets
|
|
// tested locally is the same path that runs remotely, and a proxy in front
|
|
// needs exactly one rule instead of one per port.
|
|
const chatUrl = (seat) => `${location.origin}/chat/?arg=${encodeURIComponent(seat)}`;
|
|
const rigOf = (seat) => (String(seat).split("@")[1] || "");
|
|
// Attachable when a roster exists AND the studio advertised a terminal it is
|
|
// actually serving. No loopback special case: the route is the same
|
|
// everywhere now, so the only question is whether a terminal is being served
|
|
// at all.
|
|
const attachable = Boolean(seats.length && termPort);
|
|
|
|
// Default onto something that can actually be attached. Picking seats[0] blind
|
|
// lands on a stopped member whenever the first one happens to be down, and the
|
|
// panel then opens on a tile that cannot be clicked.
|
|
const canAttach = (s) => s.status === undefined || s.status === "running";
|
|
const firstUp = seats.find(canAttach) || seats[0];
|
|
let seat = seats.length ? firstUp.seat : null;
|
|
try { seat = localStorage.getItem("shell.chatSeat") || seat; } catch {}
|
|
const chosen = seats.find((s) => s.seat === seat);
|
|
if (!chosen || !canAttach(chosen)) seat = seats.length ? firstUp.seat : null;
|
|
const rigs = [...new Set(seats.map((s) => rigOf(s.seat)))].filter(Boolean);
|
|
// A box may declare which rig the launcher opens on. Where one rig is the
|
|
// reason someone opens the launcher and the others are plumbing, discovery
|
|
// order buries it; `primaryRig` lets the box say so. Absent, order is left
|
|
// alone — no rig name is built into the shell.
|
|
const primary = reg.primaryRig;
|
|
if (primary && rigs.includes(primary)) {
|
|
rigs.sort((a, b) => (a === primary ? -1 : b === primary ? 1 : 0));
|
|
}
|
|
let chatRig = rigOf(seat) || rigs[0];
|
|
|
|
function renderTiles() {
|
|
const sel = document.getElementById("chatRigSel");
|
|
const tiles = document.getElementById("seatTiles");
|
|
// The launcher is RIG-SCOPED: pick a rig, get its whole team as tiles.
|
|
sel.innerHTML = rigs.map((r) => `<option${r === chatRig ? " selected" : ""}>${r}</option>`).join("");
|
|
sel.style.display = rigs.length > 1 ? "" : "none";
|
|
tiles.innerHTML = "";
|
|
for (const s of seats.filter((s) => !chatRig || rigOf(s.seat) === chatRig)) {
|
|
const b = document.createElement("button");
|
|
// A roster row may carry `status`. Absent means the source did not say, and
|
|
// an unknown status is treated as attachable — the alternative is greying
|
|
// out every seat a consumer declared by hand, which would make the honest
|
|
// path the broken-looking one.
|
|
const running = s.status === undefined || s.status === "running";
|
|
b.className = "seatTile" + (s.seat === seat ? " active" : "") + (running ? "" : " down");
|
|
b.dataset.seat = s.seat;
|
|
b.textContent = s.name || s.seat.split("@")[0];
|
|
if (!running) {
|
|
const st = document.createElement("span");
|
|
st.className = "st"; st.textContent = s.status;
|
|
b.appendChild(st);
|
|
}
|
|
b.title = running ? s.seat : `${s.seat} — ${s.status}, so there is no session to attach to`;
|
|
b.disabled = !running;
|
|
if (running) {
|
|
b.onclick = () => { seat = s.seat; try { localStorage.setItem("shell.chatSeat", seat); } catch {} ; sync(); };
|
|
}
|
|
tiles.appendChild(b);
|
|
}
|
|
sel.onchange = () => {
|
|
chatRig = sel.value;
|
|
const first = seats.find((s) => rigOf(s.seat) === chatRig);
|
|
if (first) seat = first.seat;
|
|
// RE-RENDER the tiles, then sync. Syncing alone only re-marks which tile
|
|
// is active — it leaves the previous rig's team on screen, so switching
|
|
// rigs appeared to do nothing.
|
|
renderTiles(); sync();
|
|
};
|
|
}
|
|
|
|
function sync() {
|
|
document.getElementById("seatName").textContent = seat || "";
|
|
for (const el of document.getElementById("seatTiles").children) {
|
|
el.classList.toggle("active", el.dataset.seat === seat);
|
|
}
|
|
if (!attachable || !seat) return;
|
|
const frame = document.getElementById("chatframe");
|
|
if (frame.dataset.url === chatUrl(seat)) return;
|
|
// REPLACE the frame node rather than navigating it. Navigating leaves the
|
|
// previous terminal attached to its seat; replacing detaches it cleanly.
|
|
const fresh = frame.cloneNode(false);
|
|
fresh.dataset.url = chatUrl(seat); fresh.src = chatUrl(seat);
|
|
frame.replaceWith(fresh);
|
|
}
|
|
|
|
function openAgents(want) {
|
|
if (want && seats.some((s) => s.seat === want)) { seat = want; chatRig = rigOf(want) || chatRig; }
|
|
if (!seats.length) {
|
|
agentsBody.innerHTML = `<div>no seat roster declared</div>`
|
|
+ `<div class="honest">a consumer declares seats as <code>chatSeats</code> in its own surfaces manifest. None is declared here, so there is nothing to attach to.</div>`;
|
|
} else if (!attachable) {
|
|
// Say what is observable and stop there. From here the missing endpoint
|
|
// is all that is visible; whether the terminal binary is absent or simply
|
|
// unconfigured is known at startup, not in the browser. Naming one of
|
|
// them would send someone to fix the wrong thing.
|
|
agentsBody.innerHTML = `<div>${seats.length} seat(s) declared, no terminal endpoint</div>`
|
|
+ `<div class="honest">the roster is real, so this is not an empty rig — but no terminal endpoint was advertised, so these seats cannot be attached. The studio's startup log says why: either the terminal binary is not installed on this box, or none was configured.</div>`;
|
|
} else {
|
|
agentsBody.innerHTML = "";
|
|
renderTiles(); sync();
|
|
}
|
|
agents.classList.add("open");
|
|
document.getElementById("agentToggle").style.display = "none";
|
|
}
|
|
document.getElementById("agentToggle").onclick = () => openAgents();
|
|
|
|
// --- the two shell frames -------------------------------------------------
|
|
// COLLAPSE IS REMEMBERED. A layout choice that resets on every reload is one
|
|
// nobody uses twice; sessionStorage keeps it for the session without making it
|
|
// a setting anyone has to manage.
|
|
const railBtn = document.getElementById("railToggle");
|
|
const setRail = (collapsed) => {
|
|
document.body.classList.toggle("rail-collapsed", collapsed);
|
|
railBtn.textContent = collapsed ? "\u203A" : "\u2630";
|
|
railBtn.title = collapsed ? "expand the launcher" : "collapse the launcher to a toolstrip";
|
|
try { sessionStorage.setItem("shell.rail", collapsed ? "1" : "0"); } catch {}
|
|
};
|
|
setRail((() => { try { return sessionStorage.getItem("shell.rail") === "1"; } catch { return false; } })());
|
|
railBtn.onclick = () => setRail(!document.body.classList.contains("rail-collapsed"));
|
|
|
|
// MARKUP is the shell asking the SURFACE to annotate. The shell does not draw
|
|
// the annotations — it does not know what they would attach to — so this is
|
|
// intent, sent to the active surface, and the surface answers by doing it or by
|
|
// not being able to. A surface that ignores it is unaffected, like every other
|
|
// opt-in here.
|
|
//
|
|
// Deliberately NOT persisted across surfaces: annotate-mode belongs to the thing
|
|
// you are annotating, and carrying it into the next surface would turn a mode you
|
|
// set once into one you keep meeting.
|
|
const markupBtn = document.getElementById("markupToggle");
|
|
let markupOn = false;
|
|
const tellSurface = (msg) => {
|
|
const f = frames.get(activeId);
|
|
if (f && f.contentWindow) { try { f.contentWindow.postMessage(msg, "*"); } catch {} }
|
|
};
|
|
markupBtn.onclick = () => {
|
|
markupOn = !markupOn;
|
|
markupBtn.classList.toggle("on", markupOn);
|
|
tellSurface({ t: "markup", on: markupOn });
|
|
for (const fn of markupListeners) { try { fn(markupOn); } catch {} }
|
|
};
|
|
|
|
// --- the seam a shell-level feature attaches to ---------------------------
|
|
// ANNOTATION AND ANYTHING LIKE IT IS A SHELL FEATURE, NOT A SURFACE ONE, and
|
|
// this seam exists to keep it that way. If annotating required the surface to
|
|
// cooperate then "any surface is annotatable" would be false the moment a
|
|
// surface did not implement it — including every surface that already exists.
|
|
// So the overlay host sits OVER the stage, in the shell, and works on a surface
|
|
// that knows nothing about it.
|
|
//
|
|
// The MARKUP button belongs to the shell and stays there: a feature mounting
|
|
// here SUBSCRIBES to it rather than adding a second control that can disagree
|
|
// with the first.
|
|
// --- the app half of the header ------------------------------------------
|
|
// A surface DECLARES what belongs in its half; the shell renders it. Declaring
|
|
// rather than drawing is what keeps one header on screen: the surface cannot
|
|
// produce a second bar, and the shell cannot be surprised by markup it did not
|
|
// write.
|
|
//
|
|
// Sent over the existing surface->shell channel because that is already the only
|
|
// one, and because a manifest field could not do this job — an action's LABEL and
|
|
// its ON state change while the surface runs, and a static declaration would go
|
|
// stale the first time a surface toggled something.
|
|
//
|
|
// ONLY THE ACTIVE SURFACE MAY WRITE THE HEADER. A background frame is still
|
|
// running and still holds a live channel, so without this check the header shows
|
|
// whichever surface spoke last rather than the one you are looking at.
|
|
const appActionsEl = document.getElementById("appActions");
|
|
// HOW MANY ACTIONS FIT. The left half shares one fixed bar with the title, the
|
|
// crumb and the shell's own half, so this is bounded rather than unbounded — an
|
|
// unlimited list pushes into controls the surface does not own.
|
|
//
|
|
// THE BOUND IS FINE; THE SILENCE WAS THE DEFECT. This used to slice and say
|
|
// nothing, so a surface declaring seven actions rendered six and had no way to
|
|
// find out: not in the DOM it cannot see, not in any response, not anywhere. The
|
|
// seventh button simply never worked, which reads as a bug in the app.
|
|
const HEADER_ACTION_LIMIT = 6;
|
|
function renderAppHeader(decl) {
|
|
if (decl.title !== undefined) document.getElementById("topTitle").textContent = decl.title;
|
|
if (decl.crumb !== undefined) document.getElementById("topCrumb").textContent = decl.crumb;
|
|
if (!Array.isArray(decl.actions)) return;
|
|
// Told twice, because the two channels reach different people: the console
|
|
// reaches whoever is building the app right now, and the message reaches the
|
|
// APP, which is the only one that can actually adapt. Answering "an app cannot
|
|
// see the cap to respect it" needs the second one.
|
|
if (decl.actions.length > HEADER_ACTION_LIMIT) {
|
|
const dropped = decl.actions.slice(HEADER_ACTION_LIMIT).map((a) => a?.id ?? a?.label ?? "(unnamed)");
|
|
console.warn(
|
|
`studio shell: the header shows at most ${HEADER_ACTION_LIMIT} app actions; ` +
|
|
`${dropped.length} were dropped: ${dropped.join(", ")}. ` +
|
|
`Declare fewer, or move the rest into the surface.`);
|
|
tellSurface({ t: "header-overflow", limit: HEADER_ACTION_LIMIT,
|
|
declared: decl.actions.length, dropped });
|
|
}
|
|
appActionsEl.replaceChildren(...decl.actions.slice(0, HEADER_ACTION_LIMIT).map((a) => {
|
|
const btn = document.createElement("button");
|
|
btn.textContent = String(a.label ?? a.id ?? "");
|
|
btn.title = String(a.title ?? "");
|
|
if (a.on) btn.classList.add("on");
|
|
if (a.disabled) btn.disabled = true;
|
|
// The click goes BACK to the surface. The shell does not know what any of
|
|
// these mean and must not start guessing.
|
|
btn.onclick = () => tellSurface({ t: "header-action", id: a.id });
|
|
return btn;
|
|
}));
|
|
}
|
|
|
|
const overlayHost = document.createElement("div");
|
|
overlayHost.id = "shell-overlay";
|
|
document.getElementById("stage").appendChild(overlayHost);
|
|
window.studioShell = {
|
|
// Where to draw. Pointer events are OFF on the host, so an idle overlay
|
|
// cannot swallow clicks meant for the surface underneath; a feature that
|
|
// wants input sets them on its own node.
|
|
overlay: overlayHost,
|
|
// Which surface is showing, and its frame. Read at CALL time — the active
|
|
// surface changes under a long-lived feature.
|
|
active: () => ({ id: activeId, frame: frames.get(activeId) || null,
|
|
context: declaredContexts.get(activeId) ?? null }),
|
|
// The persistence key, composed HERE rather than by each feature. Two features
|
|
// composing it independently is two places computing one property, and they
|
|
// drift silently. NUL cannot occur in a surface id or a declared context, so
|
|
// "a" + "b\0c" and "a\0b" + "c" cannot collide.
|
|
// WHERE A FEATURE SHOULD LOOK FOR ELEMENTS, and the rect of that document in
|
|
// TOP-WINDOW coordinates.
|
|
//
|
|
// Normally the active surface's own frame. When the surface has declared a
|
|
// same-origin nested frame, this resolves the CHAIN — a nested frame's
|
|
// getBoundingClientRect is relative to its parent's viewport, not to the top
|
|
// window, so the offsets have to be added. Resolving it here rather than in
|
|
// each feature keeps that walk in one place; two features doing it
|
|
// independently is two places computing one property.
|
|
//
|
|
// Falls back to the surface frame whenever the target is absent, not an
|
|
// iframe, or CROSS-ORIGIN — a cross-origin document cannot be read for
|
|
// element identity, and pretending otherwise would produce anchors that
|
|
// silently never resolve.
|
|
target: () => {
|
|
const outer = frames.get(activeId);
|
|
if (!outer) return null;
|
|
const outerRect = outer.getBoundingClientRect();
|
|
const fallback = () => {
|
|
try { return outer.contentDocument ? { doc: outer.contentDocument, rect: outerRect } : null; }
|
|
catch { return null; }
|
|
};
|
|
const frameId = declaredTargets.get(activeId) ?? null;
|
|
if (!frameId) return fallback();
|
|
try {
|
|
const nested = outer.contentDocument?.getElementById(frameId);
|
|
const doc = nested && nested.tagName === "IFRAME" ? nested.contentDocument : null;
|
|
if (!doc) return fallback();
|
|
const innerRect = nested.getBoundingClientRect();
|
|
return { doc, rect: {
|
|
left: outerRect.left + innerRect.left, top: outerRect.top + innerRect.top,
|
|
width: innerRect.width, height: innerRect.height } };
|
|
} catch { return fallback(); }
|
|
},
|
|
scope: () => {
|
|
const context = declaredContexts.get(activeId) ?? null;
|
|
return activeId ? (context ? activeId + "\u0000" + context : activeId) : null;
|
|
},
|
|
// Is markup mode on, and tell me when it changes.
|
|
markup: () => markupOn,
|
|
onMarkup: (fn) => { markupListeners.add(fn); return () => markupListeners.delete(fn); },
|
|
// Tell me when the surface changes, so an overlay can clear or re-anchor.
|
|
onSurface: (fn) => { surfaceListeners.add(fn); return () => surfaceListeners.delete(fn); },
|
|
// Re-measure without reloading. Fired when the active surface says its layout
|
|
// moved; a feature drawing over the stage recomputes geometry and repaints.
|
|
onRefresh: (fn) => { refreshListeners.add(fn); return () => refreshListeners.delete(fn); },
|
|
};
|
|
// --- annotations: a SHELL feature, drawn over the stage --------------------
|
|
// Annotation belongs to the shell rather than to each surface because it must
|
|
// work on surfaces that know nothing about it — the ones that already exist.
|
|
// It attaches at the seam like any other feature and gets no privileges.
|
|
//
|
|
// The store is injected from HERE, not built into the layer: the layer must
|
|
// stay free of any transport so it runs against a runtime that has no
|
|
// annotations verb at all. That runtime gets an in-memory board that SAYS
|
|
// "session only" rather than a broken one.
|
|
try {
|
|
const capable = Array.isArray(meta?.capabilities) && meta.capabilities.includes("annotations.write");
|
|
const store = capable ? {
|
|
load: async (scope) => {
|
|
const r = await fetch("/api/annotations?scope=" + encodeURIComponent(scope), { cache: "no-store" });
|
|
const j = await r.json();
|
|
return Array.isArray(j?.records) ? j.records : [];
|
|
},
|
|
save: async (scope, records) => {
|
|
const r = await fetch("/api/annotations", { method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ scope, records }) });
|
|
const j = await r.json().catch(() => null);
|
|
// A save that failed must not look like one that worked. The layer shows
|
|
// "persisted" while a store is present, so a silent failure here would
|
|
// make that label a lie.
|
|
if (!j?.ok) throw new Error(j?.error || "annotations save failed");
|
|
},
|
|
} : null;
|
|
const { attach } = await import("./annotations.js");
|
|
window.studioAnnotations = attach(window.studioShell, { store });
|
|
} catch (err) {
|
|
// The shell keeps working without annotations. It must not take the studio
|
|
// down with it, and it must not pretend it loaded.
|
|
console.error("annotations did not attach:", err);
|
|
}
|
|
|
|
document.getElementById("agentsWord").onclick = () => {
|
|
agents.classList.remove("open");
|
|
document.getElementById("agentToggle").style.display = "";
|
|
// RELEASE the terminal on close. Hiding the panel leaves the connection
|
|
// open, and a hidden viewer still counts as an attached client — it fights
|
|
// the visible one for size, and viewer sessions accumulate for as long as
|
|
// the tab lives. Measured: closing the sidebar left an attached viewer
|
|
// behind; a real disconnect cleaned it up correctly. Costs about a second
|
|
// to re-attach on reopen, which is the trade the original made too.
|
|
// Build a bare replacement rather than cloning: cloneNode copies the src
|
|
// attribute, so the "fresh" frame reconnects immediately and releases
|
|
// nothing. Attributes are re-applied except src, which is what detaches.
|
|
const frame = document.getElementById("chatframe");
|
|
if (frame && frame.getAttribute("src")) {
|
|
const bare = document.createElement("iframe");
|
|
bare.id = "chatframe"; bare.title = "agent seat";
|
|
bare.setAttribute("allow", "clipboard-read; clipboard-write");
|
|
frame.replaceWith(bare);
|
|
}
|
|
};
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|