Files
turnstone/scripts/livepass.py
Patrick Buckley 9ad447ca33 fix(attachments): design-review polish for preview chips/pills
Two-reviewer + sanity pass over the attachment previews:

- composer audio chip is icon+name+size only; the native <audio> player
  renders on the sent message, not the staging chip (too heavy at chip scale)
- cap sent-message pills (+ in-pill audio/snippet) so they no longer overflow
  the bubble at narrow widths; player and snippet drop to their own row
- clamp the chip filename in shared chat.css so long names ellipsize instead
  of wrapping (console main + coordinator previously left it unclamped)
- merge the duplicated .composer-chip rule; drop unused kind-modifier classes
  and inert vertical-align / inline-block declarations
- fix undefined var(--bg-base) -> var(--bg-surface) thumbnail backing
- label the <audio> control (aria-label) and drop the decorative snippet from
  the a11y tree

scripts/livepass.py: add an attachments harness that drives the real
createAttachmentController + Pane.addUserMessage so these surfaces render
headlessly for review.
2026-06-16 00:48:14 -07:00

920 lines
40 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Build the livepass harnesses — render real hatch dialogs/shelves headlessly.
The livepass is how converted modal surfaces get verified without booting a
server: a minimal page that symlinks the REAL stylesheets and scripts, embeds
the REAL markup (extracted fresh from the index files at build time), stubs
``window.authFetch`` with canned fixtures, and drives surfaces via ``?open=``
query params — including click-driving submits so dead buttons can't hide
(the model-Save bug class).
Usage:
python3 scripts/livepass.py # build into /tmp/livepass/
python3 scripts/livepass.py --out DIR # build elsewhere
python3 scripts/livepass.py --serve 8950 # build + serve (Ctrl+C stops)
Then screenshot states (file:// blocks ES modules — always serve over http;
the reduced-motion flag is REQUIRED, entrance animations race the capture):
google-chrome --headless --disable-gpu --hide-scrollbars \\
--force-prefers-reduced-motion --window-size=1440,900 \\
--virtual-time-budget=9000 --screenshot=out.png \\
"http://localhost:8950/ui/livepass.html?open=new-ws&theme=light"
UI harness (?open=): new-ws · new-ws-fork · edit-title · delete-ws ·
revoke-mcp · ws-delete · ws-delete-results (+ &theme=light, &busy=1)
Console harness (?open=): schedule-create · schedule-edit · model-create ·
model-edit · model-save (drives a Save click; document.title becomes
PUT-OK-<n> on success) · policy · confirm · token
Plus &tall=1 (90-row users panel — the .admin-content scroll state; the
synthetic rows wrap to two lines, so judge overflow geometry, not row
cadence) · &scrolled=1 lands mid-list, &scrolled=bottom shows the 24px
scroll tail · &focuslast=1 focuses the last shelf-body control (the
displaced-dock regression probe: only .sh-body may scroll; head/foot stay
pinned). All combinable with ?open=. The console page wraps the fragment
in the REAL L-shell chain — pane-pinned height, interior scroller — so
scroll/dock geometry matches production; keep it that way. Body-level
dialogs (confirm/install/coord-delete) are injected as riders; a driven
?open= that ends with no open dialog stamps OPEN-FAILED-<state> into the
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.
Attachments harness (/attachments/livepass.html): the composer attachment
chips + the sent-message attachment pills, both driven through the REAL
code paths — createAttachmentController.rehydrate() builds the chips and
Pane.addUserMessage() builds the pills, so the preview nodes (image/pdf
thumbnail, <audio> player, lazy text snippet) render exactly as in
production. Committed fixtures cover every kind plus a long filename;
thumbnails + the audio clip are served by an in-process fixture route
(--serve only), the text snippet flows through the stubbed authFetch.
+ &theme=light. document.title stamps ATTACH-READY-<chips>-<pills> on
success, ATTACH-FAILED-c<n>-p<n> when a surface came up empty. Judge the
thumbnail crop/size, the native audio-control fit at the constrained
height, the snippet contrast, and how a long filename behaves at the
340px chip cap.
Rebuild after ANY markup change: the dialog blocks are embedded at build
time. Assets are symlinked, so CSS/JS edits are live on refresh.
"""
from __future__ import annotations
import argparse
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
UI_INDEX = ROOT / "turnstone/ui/static/index.html"
CONSOLE_INDEX = ROOT / "turnstone/console/static/index.html"
def extract_dialogs(index: Path, only_id: str | None = None) -> list[str]:
"""Every <dialog class="hatch ..."> block, verbatim from the tree."""
html = index.read_text(encoding="utf-8")
blocks = []
for m in re.finditer(r"[ \t]*<dialog\s[^>]*class=\"[^\"]*\bhatch\b[^\"]*\"", html):
end = html.index("</dialog>", m.start()) + len("</dialog>")
block = html[m.start() : end]
if only_id and f'id="{only_id}"' not in block:
continue
blocks.append(block)
if not blocks:
raise SystemExit(f"no dialog.hatch blocks found in {index}")
return blocks
def extract_admin_fragment() -> str:
"""The console admin pane — the hatch-host all shelves live inside."""
html = CONSOLE_INDEX.read_text(encoding="utf-8")
start = html.index('<div id="admin-layout"')
end = html.index("<!-- /admin-layout -->") + len("<!-- /admin-layout -->")
return html[start:end]
def inject(template: str, marker: str, payload: str) -> str:
begin = template.index(f"<!-- {marker}:BEGIN -->") + len(f"<!-- {marker}:BEGIN -->")
end = template.index(f"<!-- {marker}:END -->")
return template[:begin] + "\n" + payload + "\n" + template[end:]
def symlink(link: Path, target: Path) -> None:
if link.is_symlink() or link.exists():
link.unlink()
link.symlink_to(target)
# --------------------------------------------------------------------------
# UI harness — the standalone app's dialog tier. Drives the REAL cards.js
# controller for the batch surfaces so the production code path renders.
# --------------------------------------------------------------------------
UI_TEMPLATE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ui 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" />
<link rel="stylesheet" href="shared/hatch.css" />
</head>
<body>
<!-- DIALOGS:BEGIN -->
<!-- DIALOGS:END -->
<div id="toast" role="status" aria-live="polite"></div>
<script>
window.authFetch = function (url) {
// One canned failure so the results view shows the mixed state.
var fail = url && url.indexOf("c3d4e5f6a1b2") !== -1;
return Promise.resolve({
ok: !fail,
status: fail ? 409 : 200,
headers: { get: function () { return "application/json"; } },
json: function () { return Promise.resolve({}); },
text: function () {
return Promise.resolve(
fail ? '{"error": "workstream is still running"}' : "",
);
},
});
};
window.showToast = function (msg) { console.log("toast:", msg); };
</script>
<script type="module">
import { openDialog, setBusy } from "./shared/hatch.js";
const q = new URLSearchParams(location.search);
if (q.get("theme") === "light")
document.documentElement.dataset.theme = "light";
const open = q.get("open") || "";
function fill(id, text) {
const el = document.getElementById(id);
if (el) el.textContent = text;
}
if (open === "new-ws" || open === "new-ws-fork") {
const dlg = document.getElementById("new-ws-dialog");
const canned = {
"new-ws-model": ["sonnet-4-6", "gpt-5-2", "qwen3-32b"],
"new-ws-judge-model": ["sonnet-4-6", "qwen3-32b"],
"new-ws-skill": ["code-review (default)", "deep-research"],
};
for (const id in canned) {
const s = document.getElementById(id);
for (const n of canned[id]) {
const o = document.createElement("option");
o.value = n;
o.textContent = n;
s.appendChild(o);
}
}
if (open === "new-ws-fork") {
fill("new-ws-title", "Fork workstream");
fill("new-ws-tag", "WS-FORK");
document.getElementById("new-ws-submit").textContent = "Fork";
const skillLabel = document.querySelector('label[for="new-ws-skill"]');
if (skillLabel) skillLabel.hidden = true;
document.getElementById("new-ws-skill").hidden = true;
document.getElementById("new-ws-attach-row").hidden = true;
}
openDialog(dlg);
} else if (open === "edit-title") {
document.getElementById("edit-title-input").value =
"lshell renovation pass 3";
openDialog(document.getElementById("edit-title-dialog"));
} else if (open === "delete-ws") {
fill(
"delete-ws-message",
'Delete "lshell renovation pass 3"? This cannot be undone.',
);
openDialog(document.getElementById("delete-ws-dialog"));
} else if (open === "revoke-mcp") {
fill(
"revoke-mcp-message",
"Revoke the connection to github? Tools that need this server will require re-consent.",
);
openDialog(document.getElementById("revoke-mcp-dialog"));
} else if (open === "ws-delete" || open === "ws-delete-results") {
// Drive the REAL shared controller so the dialog renders through
// the production code path (cards.js confirmSelection/confirm).
const mod = await import("./shared/cards.js");
const c = mod.createSavedCardsController({
idPrefix: "ws-delete",
buttonId: "ws-delete-btn",
noun: "workstream",
activateLabel: (s) => "Resume: " + (s.title || s.ws_id),
render: () => {},
buildDeleteRequest: (wsId) => ({
url: "/v1/api/workstreams/" + encodeURIComponent(wsId) + "/delete",
options: { method: "POST" },
}),
});
c.setItems([
{ ws_id: "a1b2c3d4e5f6", title: "lshell renovation pass 3" },
{ ws_id: "b2c3d4e5f6a1", title: "canonical trajectory spike" },
{
ws_id: "c3d4e5f6a1b2",
title:
"a very long workstream title that should wrap " +
"rather than punch out of the dialog box entirely",
},
]);
c.toggleAll();
c.confirmSelection();
if (open === "ws-delete-results") c.confirm();
}
if (q.get("busy")) {
const d = document.querySelector("dialog[open]");
if (d) setBusy(d, true);
}
</script>
</body>
</html>
"""
# --------------------------------------------------------------------------
# Console harness — the admin pane fragment hosts the shelves (token-created
# included); dialog-tier markup outside the fragment (confirm/install/
# coord-delete) is injected via the RIDERS marker in build().
# model-save click-drives the submit: document.title flips to PUT-OK-<n>.
# --------------------------------------------------------------------------
CONSOLE_TEMPLATE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>console livepass</title>
<link rel="stylesheet" href="shared/base.css" />
<link rel="stylesheet" href="shared/ui-base.css" />
<link rel="stylesheet" href="console-static/style.css" />
<link rel="stylesheet" href="shared/shell.css" />
<link rel="stylesheet" href="shared/hatch.css" />
</head>
<body>
<!-- The REAL L-shell chain (shell.js buildShell + pane.js DOM, verbatim
class names) so the harness inherits production scroll geometry:
.pane-body > #view-admin > .admin-layout height-pin the hatch-host
and .admin-content is the pane's interior scroller. Never replace
this with bespoke height overrides — the clipped-pane / displaced-
shelf regressions were invisible to the harness precisely because
it used to pin #admin-layout with its own CSS. -->
<div class="app">
<aside class="rail" id="shell-rail">
<div class="rail-brand">
<button class="brand-home" type="button">
<div class="brand-mark"></div>
<span class="brand-name">turnstone</span>
<span class="brand-sub">console</span>
</button>
</div>
</aside>
<main class="content">
<div class="tabbar"></div>
<div class="panes">
<section class="pane">
<!-- no .pane-head: PaneManager._mount builds section.pane >
div.pane-body only -->
<div class="pane-body">
<div id="view-admin">
<!-- FRAGMENT:BEGIN -->
<!-- FRAGMENT:END -->
</div>
</div>
</section>
</div>
</main>
</div>
<!-- Body-level dialog tier (confirm / install / coord-delete): their
markup sits OUTSIDE #admin-layout in index.html, so the fragment
extraction misses them — build() injects every hatch dialog the
fragment does not already contain. -->
<!-- RIDERS:BEGIN -->
<!-- RIDERS:END -->
<div id="toast" role="status" aria-live="polite"></div>
<script>
(function () {
function reply(data) {
return Promise.resolve({
ok: true,
status: 200,
headers: { get: function () { return "application/json"; } },
json: function () { return Promise.resolve(data); },
text: function () { return Promise.resolve(JSON.stringify(data)); },
});
}
var SCHED = {
task_id: "t1", name: "nightly-digest", description: "Morning digest",
schedule_type: "cron", cron_expr: "0 6 * * 1,3,5", at_time: "",
target_mode: "auto", model: "fable-5", skill: "daily-digest",
initial_message: "Summarize overnight cluster activity.",
auto_approve: false, enabled: true,
notify_targets: [{ channel_type: "discord", channel_id: "8675309" }],
next_run: "2026-06-10T06:00:00",
};
var MODEL = {
definition_id: "def1", alias: "fable-5", model: "claude-fable-5",
provider: "anthropic", base_url: "", context_window: 200000,
capabilities: JSON.stringify({ supports_vision: true }),
enabled: true, temperature: null, max_tokens: null,
reasoning_effort: null, surface_persisted_reasoning: true,
replay_reasoning_to_model: false,
};
window.__putCount = 0;
window.authFetch = function (url, opts) {
var method = (opts && opts.method) || "GET";
if (method === "PUT" && url.indexOf("/model-definitions/def1") >= 0) {
window.__putCount++;
document.title = "PUT-OK-" + window.__putCount;
return reply({ ok: true });
}
if (url.indexOf("/schedules/preview") >= 0)
return reply({
valid: true, error: "",
next: [
"2026-06-10T06:00:00+00:00",
"2026-06-12T06:00:00+00:00",
"2026-06-15T06:00:00+00:00",
],
});
if (url.indexOf("/schedules/t1") >= 0) return reply(SCHED);
if (url.indexOf("/schedules") >= 0) return reply({ schedules: [SCHED] });
if (url.indexOf("/model-capabilities/known") >= 0)
return reply({ models: ["claude-fable-5", "claude-opus-4-8"] });
if (url.indexOf("/model-capabilities?") >= 0)
return reply({
known: true,
capabilities: {
context_window: 200000, supports_tools: true,
supports_streaming: true, supports_vision: true,
supports_web_search: true, supports_temperature: true,
supports_effort: true,
},
});
if (url.indexOf("/model-definitions/def1") >= 0) return reply(MODEL);
if (url.indexOf("/model-definitions") >= 0) return reply({ models: [] });
if (url.indexOf("/api/models") >= 0)
return reply({ models: [
{ alias: "fable-5", model: "claude-fable-5" },
{ alias: "gpt-5.2", model: "gpt-5.2" },
] });
if (url.indexOf("/skills") >= 0)
return reply({ skills: [{ name: "daily-digest" }, { name: "ops-runbook" }] });
if (url.indexOf("/policies") >= 0)
return reply({ policies: [
{ policy_id: "p1", name: "deny-rm", tool_pattern: "bash*rm*",
action: "deny", priority: 900, enabled: true },
{ policy_id: "p2", name: "default-ask", tool_pattern: "*",
action: "ask", priority: 0, enabled: true },
] });
return reply({});
};
window.showToast = function (m) {
console.log("toast:", m);
var t = document.getElementById("toast");
t.textContent = m;
t.classList.add("show");
};
})();
</script>
<script type="module" src="shared/utils.js"></script>
<script type="module" src="shared/hatch.js"></script>
<script src="console-static/admin.js"></script>
<script src="console-static/governance.js"></script>
<script>
window.addEventListener("load", function () {
var q = new URLSearchParams(location.search);
if (q.get("theme") === "light")
document.documentElement.dataset.theme = "light";
var open = q.get("open") || "";
// ?tall=1 — the scroll state: one panel visible with enough rows to
// overflow the pane, so a screenshot shows .admin-content scrolling
// (and a shelf staying docked above it). Mirrors switchAdminTab's
// one-panel-visible invariant without booting the tab loaders.
if (q.get("tall")) {
var panels = document.querySelectorAll(".admin-panel");
for (var i = 0; i < panels.length; i++)
panels[i].style.display =
panels[i].id === "admin-users" ? "" : "none";
// No fallback: a fragment rename must fail loudly, not misplace rows.
var rowHost = document.querySelector("#admin-users [role=list]");
rowHost.textContent = ""; // drop the static "Loading users…" stub
for (var r = 0; r < 90; r++) {
var row = document.createElement("div");
row.className = "admin-row"; // real row chrome — geometry tracks production
row.textContent =
"user-" + String(r).padStart(3, "0") + " \\u00b7 synthetic row";
rowHost.appendChild(row);
}
var content = document.getElementById("admin-content");
if (content && q.get("scrolled"))
content.scrollTop =
q.get("scrolled") === "bottom"
? content.scrollHeight // the 24px scroll-tail state
: content.scrollHeight / 2; // land mid-list
}
setTimeout(function () {
if (open === "schedule-create") showCreateScheduleModal();
else if (open === "schedule-edit") showEditScheduleModal("t1");
else if (open === "model-create") showCreateModelModal();
else if (open === "model-edit" || open === "model-save")
showEditModelModal("def1");
else if (open === "policy") {
window._govPolicies && _govPolicies.length === 0 &&
loadGovPolicies && loadGovPolicies();
showCreatePolicyModal();
} else if (open === "confirm")
showConfirmModal(
"Delete schedule",
"Delete nightly-digest? Its run history is removed with it. This cannot be undone.",
"Delete",
function () {},
);
else if (open === "token")
showTokenCreatedModal(
"tsk_9f2e41c7a8b35d60e1f4a2b89c7d3e5f6a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d",
);
if (open === "model-save")
setTimeout(function () {
document.getElementById("model-create-submit").click();
}, 900);
if (q.get("busy"))
setTimeout(function () {
var d = document.querySelector("dialog[open]");
if (d) window.TurnstoneHatch.setBusy(d, true);
}, 400);
// A driven state that ends with nothing open must fail LOUDLY in
// the screenshot pipeline, not render a quietly dialog-less page.
setTimeout(function () {
var top = document.querySelector("dialog[open]");
if (open && !top) document.title = "OPEN-FAILED-" + open;
// &focuslast=1 — the displaced-dock regression probe: focus the
// last form control in the shelf BODY (the visually-hidden
// toggle/radio inputs live there). Only .sh-body may scroll;
// the head/foot strips must stay pinned in the screenshot.
if (top && q.get("focuslast")) {
var els = top.querySelectorAll(
".sh-body input, .sh-body select, .sh-body textarea",
);
if (els.length) els[els.length - 1].focus();
}
}, 600);
}, 150);
});
</script>
</body>
</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 =
"flex:1;min-height:0;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);
}
// Edge-touching opaque chrome — the strip that occluded the
// focus ring before the ::after overlay; keeps the bug class
// visible in every future pass.
const sb = document.createElement("div");
sb.className = "ws-status-bar";
sb.textContent = "17,418 / 393,216 (4.4%) · max 9 tools";
this.bodyEl.append(wrap, sb);
};
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>
"""
# --------------------------------------------------------------------------
# Attachments harness — the composer attachment chips + the sent-message
# attachment pills. Both are driven through the REAL code paths so the preview
# nodes render exactly as production builds them: createAttachmentController's
# rehydrate() renders the chips (renderChip -> _applyPreview ->
# buildAttachmentPreview), and Pane.addUserMessage() renders the pills (which
# call the same window.buildAttachmentPreview). The page frame is harness-only
# chrome and not under review; the chips row and the pill row are.
# --------------------------------------------------------------------------
ATTACH_TEMPLATE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>attachments livepass</title>
<link rel="stylesheet" href="shared/base.css" />
<link rel="stylesheet" href="shared/ui-base.css" />
<link rel="stylesheet" href="shared/interactive.css" />
<link rel="stylesheet" href="shared/chat.css" />
<style>
/* Harness-only framing (NOT under review) — gives the two real surfaces
a plausible page context at a realistic pane width. */
body {
padding: 24px; margin: 0; display: flex; flex-direction: column;
gap: 28px; background: var(--bg); color: var(--fg);
font-family: var(--font-sans, system-ui, sans-serif);
}
.demo-label {
font: 11px var(--font-mono, monospace); color: var(--fg-dim);
text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px;
}
.demo-frame {
width: 560px; max-width: 100%; border: 1px solid var(--border);
border-radius: var(--radius-sm); overflow: hidden;
background: var(--bg-surface);
}
.messages { padding: 16px; }
/* Static composer chrome for context; the chips row is built by the
REAL createAttachmentController. */
.demo-textarea {
width: 100%; min-height: 44px; resize: none; background: var(--bg-elevated);
color: var(--fg); border: 1px solid var(--border);
border-radius: var(--radius-sm); padding: 8px; font: inherit;
}
</style>
</head>
<body>
<div>
<div class="demo-label">composer — attachment chips (real createAttachmentController)</div>
<div class="demo-frame">
<div class="composer">
<div class="composer-chips" id="chips" role="list" aria-label="Attachments"></div>
<div class="composer-row">
<textarea class="demo-textarea" placeholder="Message…"></textarea>
</div>
</div>
</div>
</div>
<div>
<div class="demo-label">conversation — sent-message attachment pills (real Pane.addUserMessage)</div>
<div class="demo-frame">
<div class="messages" id="messages"></div>
</div>
</div>
<div id="toast" role="status" aria-live="polite"></div>
<script>
// Committed-attachment fixtures (no `uploading`, real ids) — one of every
// kind plus a deliberately long filename to probe chip truncation/wrap.
window.__ATTACH = [
{ attachment_id: "att-image", kind: "image",
filename: "observatory-dome.jpg", size_bytes: 184320 },
{ attachment_id: "att-pdf", kind: "pdf",
filename: "q2-cluster-report.pdf", size_bytes: 529408 },
{ attachment_id: "att-audio", kind: "audio",
filename: "standup-2026-06-15.m4a", size_bytes: 2202009 },
{ attachment_id: "att-text", kind: "text",
filename: "release-notes-1.7.0a2.md", size_bytes: 4317 },
{ attachment_id: "att-longname", kind: "text",
filename: "a-deliberately-long-attachment-filename-that-truncates.md",
size_bytes: 8214 },
];
window.__SNIPPET =
"# Release notes \\u2014 1.7.0a2\\n\\nN-sample consensus voting lands behind " +
"a flag; reranker-primary retrieval replaces RRF as the default; " +
"coordinator memory is now keyed per user.";
// image/pdf thumbnails + the audio clip load via element .src and are
// served by the livepass fixture route; the text snippet is the only
// preview that flows through authFetch, so the stub answers .../content.
// Held under a private name: auth.js's legacy window bridge
// (Object.assign(window, {authFetch})) runs at module-import time and
// would clobber a plain window.authFetch — the module reinstates it
// below, after the imports have evaluated.
window.__attachFetch = function (url) {
var path = (url || "").split("?")[0];
function reply(ok, body, asText) {
return Promise.resolve({
ok: ok, status: ok ? 200 : 404,
json: function () { return Promise.resolve(body || {}); },
text: function () {
return Promise.resolve(asText != null ? asText : "");
},
});
}
if (/\\/attachments$/.test(path))
return reply(true, { attachments: window.__ATTACH });
// Any text /content gets the snippet (audio /content is served as
// bytes by the fixture route, never through authFetch).
if (path.indexOf("/content") !== -1)
return reply(true, {}, window.__SNIPPET);
return reply(true, {});
};
window.toast = { error: function (m) { console.log("toast:", m); } };
</script>
<script type="module">
import { createAttachmentController } from "./shared/composer_attachments.js";
import { InteractivePane } from "./shared/interactive.js";
const q = new URLSearchParams(location.search);
if (q.get("theme") === "light")
document.documentElement.dataset.theme = "light";
// Reinstate the fixture fetch now the imports (and auth.js's window
// bridge) have run — the pills' buildAttachmentPreview reads
// window.authFetch directly for the text snippet.
window.authFetch = window.__attachFetch;
// composer chips — drive the REAL controller. Pass the stub explicitly
// so the chip path never depends on window.authFetch timing.
const ctl = createAttachmentController({
chipsEl: document.getElementById("chips"),
getWsId: () => "demo-ws",
authFetch: window.__attachFetch,
});
await ctl.rehydrate();
// message pills — drive the REAL Pane.addUserMessage; stub only the
// host seams (scroll/empty-state/action-row) that need a mounted pane.
const pane = new InteractivePane("demo-ws");
pane.messagesEl = document.getElementById("messages");
pane.removeEmptyState = () => {};
pane._addUserMsgActions = () => {};
pane.scrollToBottom = () => {};
pane.addUserMessage(
"Please review the attached report, the dome photo, the standup " +
"recording, and the release notes.",
window.__ATTACH.slice(0, 4),
);
// Loud failure — a broken harness must not screenshot green.
setTimeout(function () {
const chips = document.querySelectorAll("#chips .composer-chip").length;
const pills = document.querySelectorAll(
"#messages .msg-user-attach-pill",
).length;
document.title =
chips && pills
? "ATTACH-READY-" + chips + "-" + pills
: "ATTACH-FAILED-c" + chips + "-p" + pills;
}, 800);
</script>
</body>
</html>
"""
# Fixture media for the attachments harness. image/pdf thumbnails and the
# audio clip load via element .src (NOT authFetch), so the --serve dev server
# answers those paths directly with representative bytes: a photo-like image,
# a document-page-like image for the PDF thumbnail, and a short WAV so the
# native <audio> control chrome renders against the constrained CSS height.
_FIXTURE_CACHE: dict[str, bytes] = {}
def _png_photo() -> bytes:
from io import BytesIO
from PIL import Image, ImageDraw
img = Image.new("RGB", (320, 320), (24, 27, 31))
d = ImageDraw.Draw(img)
for y in range(320): # warm vertical wash so object-fit crop is legible
t = y / 320
d.line([(0, y), (320, y)], fill=(int(20 + t * 60), int(18 + t * 40), int(26 + t * 70)))
d.ellipse([180, 36, 300, 156], fill=(229, 160, 66)) # amber "sun"
d.polygon([(0, 320), (130, 170), (250, 320)], fill=(38, 66, 58)) # hill
buf = BytesIO()
img.save(buf, "PNG")
return buf.getvalue()
def _png_page() -> bytes:
from io import BytesIO
from PIL import Image, ImageDraw
img = Image.new("RGB", (320, 414), (250, 250, 248)) # paper white, A4-ish
d = ImageDraw.Draw(img)
d.rectangle([0, 0, 320, 9], fill=(140, 94, 27)) # header rule
y = 30
for i, w in enumerate([260, 240, 280, 200, 250, 230, 270, 180, 255, 210, 240]):
shade = (40, 44, 54) if i == 0 else (150, 154, 164)
d.rectangle([28, y, 28 + w, y + 10], fill=shade)
y += 30
buf = BytesIO()
img.save(buf, "PNG")
return buf.getvalue()
def _wav() -> bytes:
import math
import struct
import wave
from io import BytesIO
buf = BytesIO()
frames = b"".join(
struct.pack("<h", int(2600 * math.sin(2 * math.pi * 440 * i / 8000))) for i in range(8000)
)
with wave.open(buf, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(8000)
w.writeframes(frames)
return buf.getvalue()
def _fixture_for(path: str) -> tuple[bytes, str] | None:
"""Map a media request path to (bytes, content-type), or None to fall through."""
if path.endswith("/thumbnail"):
key = "page" if "att-pdf" in path else "photo"
if key not in _FIXTURE_CACHE:
_FIXTURE_CACHE[key] = _png_page() if key == "page" else _png_photo()
return _FIXTURE_CACHE[key], "image/png"
if path.endswith("/content") and "att-audio" in path:
if "wav" not in _FIXTURE_CACHE:
_FIXTURE_CACHE["wav"] = _wav()
return _FIXTURE_CACHE["wav"], "audio/wav"
return None
def build(out: Path) -> None:
ui = out / "ui"
con = out / "console"
ui.mkdir(parents=True, exist_ok=True)
con.mkdir(parents=True, exist_ok=True)
symlink(ui / "shared", ROOT / "turnstone/shared_static")
symlink(ui / "static", ROOT / "turnstone/ui/static")
blocks = extract_dialogs(UI_INDEX)
# the coordinator batch dialog shares the cards.js builder — ride along
blocks += extract_dialogs(CONSOLE_INDEX, only_id="coord-delete-dialog")
(ui / "livepass.html").write_text(
inject(UI_TEMPLATE, "DIALOGS", "\n".join(blocks)), encoding="utf-8"
)
print(f"{ui}/livepass.html — {len(blocks)} dialogs")
symlink(con / "shared", ROOT / "turnstone/shared_static")
symlink(con / "console-static", ROOT / "turnstone/console/static")
frag = extract_admin_fragment()
# Dialog-tier markup living OUTSIDE #admin-layout (confirm, install,
# coord-delete) would otherwise be silently absent — and ?open=confirm
# would screenshot a dialog-less page while the gate stayed green.
riders = [b for b in extract_dialogs(CONSOLE_INDEX) if b not in frag]
page = inject(CONSOLE_TEMPLATE, "FRAGMENT", frag)
page = inject(page, "RIDERS", "\n".join(riders))
(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")
att = out / "attachments"
att.mkdir(parents=True, exist_ok=True)
symlink(att / "shared", ROOT / "turnstone/shared_static")
(att / "livepass.html").write_text(ATTACH_TEMPLATE, encoding="utf-8")
print(f"{att}/livepass.html — composer chips + message attachment pills")
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--out", type=Path, default=Path("/tmp/livepass"))
ap.add_argument("--serve", type=int, metavar="PORT")
args = ap.parse_args()
build(args.out)
if args.serve:
import functools
import http.server
class _FixtureHandler(http.server.SimpleHTTPRequestHandler):
# The attachments harness loads thumbnails + the audio clip via
# element .src; serve those from generated fixtures, fall through
# to static for everything else.
def do_GET(self) -> None: # noqa: N802 (stdlib casing)
blob = _fixture_for(self.path.split("?")[0])
if blob is None:
super().do_GET()
return
data, ctype = blob
self.send_response(200)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
handler = functools.partial(_FixtureHandler, directory=str(args.out))
print(f"serving {args.out} on http://localhost:{args.serve}/ — Ctrl+C stops")
http.server.ThreadingHTTPServer(("127.0.0.1", args.serve), handler).serve_forever()
if __name__ == "__main__":
main()