feat(orchestrator): S2 agent and node popups with leader line, node search

Requirement 001 phase 6, S2:

- Agent popup: a click on an agent opens one fading <dialog> with face,
  name, earlier names, offices, tasks, claims, subagents, history and
  the composer; Escape or a click outside closes it. The agent drawer
  and the Details side panel are gone.
- Node popup: a click on a node in a graph view enlarges it and marks it
  red, and an SVG leader line ties it to the translucent popup; a click
  elsewhere restores the node. popup.js places the popup right, left,
  below or above the node, caps its height to the room there and
  returns the line.
- Search: in the 3D views a search field with suggestions replaces the
  list of every node, one chip per distinct label.
- Fit: body.fit now applies at every width; without the side panel the
  orbit view otherwise grew a page scrollbar at 390 px.
- Two page modules had silently shadowed each other's functions of the
  same name while this was built; test_no_two_page_modules_declare_the_same_function
  now guards that. Artifact ids carry NUL, which CSS.escape turns into
  U+FFFD, so the node lookup compares dataset values.

- Composer: a message goes to the agent whose popup it was written in.
  deliver() read the global ui.agent after awaiting the upload, so
  closing the popup or opening another agent during an upload sent the
  signed message to null or to the other agent (found by the security
  skeptic, present before, easier now that a backdrop click closes).

Limits: the 3D leader line is placed when the popup opens and does not
follow the camera while the layout settles; the search keeps the first
node per label, so a second repository's main branch is found in the
graph, not through the search.

Evidence: make test 667 OK, ruff and markdownlint clean
(/tmp/s2-make-test-1.log); e2e 19 OK in Chromium and Firefox at 1920,
1366 and 390 px, none skipped, rerun after the composer fix
(/tmp/s2-e2e-4.log); screenshots in /tmp/orchestrator-phase4-shots/;
MEDUSA 22 files, 6 findings (a "PLA" pattern in "templates", the UI
names openAgent and drawAgent) accepted after an independent skeptic
that also found no XSS or selector injection in the popup code.

🎯 Fix: 93 %
💥 Break: 4 %
💸 Cost: 100 %
⏱️ Time: –
🛡️ Security: 100 %
🏆 Score: 89
🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 %

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-08 21:18:55 +00:00
1 parent c035373518
commit d89fb616b8
22 files changed
+596 -161

No files matched your search

+3 -3
View File
@@ -594,11 +594,11 @@ flowchart TD
### Phase 6: popups and search (S2) ### Phase 6: popups and search (S2)
- [ ] A click on an agent opens the popup with a fade; Escape and a click - [x] A click on an agent opens the popup with a fade; Escape and a click
outside close it; the Details side panel no longer exists. outside close it; the Details side panel no longer exists.
- [ ] A click on a node enlarges it, marks it red and draws a leader line to - [x] A click on a node enlarges it, marks it red and draws a leader line to
a translucent popup; a second click elsewhere restores the node. a translucent popup; a second click elsewhere restores the node.
- [ ] A search field with suggestions replaces the node chip list; no view - [x] A search field with suggestions replaces the node chip list; no view
renders more than one chip per search hit. renders more than one chip per search hit.
### Phase 6: faces (S3) ### Phase 6: faces (S3)
+25 -24
View File
@@ -28,7 +28,7 @@ body { min-height: 100vh; margin: 0; background: transparent; color: var(--text)
button, input, select { font: inherit; color: inherit; } button, input, select { font: inherit; color: inherit; }
code, pre { font-family: var(--mono); font-size: var(--fs-sm); } code, pre { font-family: var(--mono); font-size: var(--fs-sm); }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; box-shadow: 0 0 0 5px color-mix(in srgb, var(--accent) 22%, transparent); } :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; box-shadow: 0 0 0 5px color-mix(in srgb, var(--accent) 22%, transparent); }
.top, .navbar, main, .drawer, .sheet-box, .palette-box, .toast, .sky-panel, .menu-box { -webkit-backdrop-filter: var(--blur); backdrop-filter: var(--blur); transition: background-color .8s var(--ease), border-color .8s var(--ease); } .top, .navbar, main, .popup-box, .sheet-box, .palette-box, .toast, .sky-panel, .menu-box { -webkit-backdrop-filter: var(--blur); backdrop-filter: var(--blur); transition: background-color .8s var(--ease), border-color .8s var(--ease); }
::view-transition-old(root), ::view-transition-new(root) { animation-duration: .22s; animation-timing-function: var(--ease); } ::view-transition-old(root), ::view-transition-new(root) { animation-duration: .22s; animation-timing-function: var(--ease); }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition-duration: 0s !important; animation-duration: 0s !important; scroll-behavior: auto !important; } *, *::before, *::after { transition-duration: 0s !important; animation-duration: 0s !important; scroll-behavior: auto !important; }
@@ -140,7 +140,7 @@ pre.notes { max-height: 260px; margin: 12px 0 0; padding: 10px 12px; overflow: a
.chip.preset { font-weight: 650; } .chip.preset { font-weight: 650; }
.chip .x { padding: 0 2px; border: 0; background: none; color: inherit; cursor: pointer; } .chip .x { padding: 0 2px; border: 0; background: none; color: inherit; cursor: pointer; }
.split { display: grid; grid-template-columns: minmax(220px, 320px) minmax(0, 1fr); gap: 14px; align-items: start; } .split { display: grid; grid-template-columns: minmax(220px, 320px) minmax(0, 1fr); gap: 14px; align-items: start; }
@media (max-width: 860px) { .split, .graph-body { grid-template-columns: minmax(0, 1fr); } } @media (max-width: 860px) { .split { grid-template-columns: minmax(0, 1fr); } }
.tree { position: sticky; top: 0; max-height: 70vh; padding: 10px 6px; overflow: auto; } .tree { position: sticky; top: 0; max-height: 70vh; padding: 10px 6px; overflow: auto; }
.tree-title { margin: 6px 10px 4px; color: var(--muted); font-size: var(--fs-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; } .tree-title { margin: 6px 10px 4px; color: var(--muted); font-size: var(--fs-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
.tree-list { margin: 0; padding: 0 0 0 10px; list-style: none; } .tree-list { margin: 0; padding: 0 0 0 10px; list-style: none; }
@@ -206,24 +206,17 @@ table.diff tr.more td { padding: 8px 16px; color: var(--muted); font-style: ital
.replay { display: flex; flex: 1; align-items: center; gap: 10px; min-width: 280px; } .replay { display: flex; flex: 1; align-items: center; gap: 10px; min-width: 280px; }
.replay input[type="range"] { flex: 1; accent-color: var(--accent); } .replay input[type="range"] { flex: 1; accent-color: var(--accent); }
.window-label { color: var(--muted); font-size: var(--fs-sm); font-variant-numeric: tabular-nums; white-space: nowrap; } .window-label { color: var(--muted); font-size: var(--fs-sm); font-variant-numeric: tabular-nums; white-space: nowrap; }
.graph-body { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 14px; align-items: start; }
.stage-card { padding: 8px; } .stage-card { padding: 8px; }
.stage svg { display: block; width: 100%; height: auto; max-height: calc(100vh - var(--head) - 120px); } .stage svg { display: block; width: 100%; height: auto; max-height: calc(100vh - var(--head) - 120px); }
@media (min-width: 861px) { body.fit { display: flex; flex-direction: column; height: 100vh; }
body.fit { display: flex; flex-direction: column; height: 100vh; } body.fit .shell, body.fit main, body.fit .graph, body.fit .stage-card, body.fit .stage { display: flex; flex: 1; flex-direction: column; min-height: 0; }
body.fit .shell, body.fit main, body.fit .graph, body.fit .stage-card, body.fit .stage { display: flex; flex: 1; flex-direction: column; min-height: 0; } body.fit main { padding-bottom: var(--sp-5); }
body.fit main { padding-bottom: var(--sp-5); } body.fit .stage svg, body.fit .stage canvas.space, body.fit .graph3d { flex: 1; height: auto; min-height: 0; max-height: none; }
body.fit .graph-body { flex: 1; align-items: stretch; min-height: 0; }
body.fit .stage svg, body.fit .stage canvas.space, body.fit .graph3d { flex: 1; height: auto; min-height: 0; max-height: none; }
body.fit .panel { position: static; max-height: none; min-height: 0; overflow: auto; }
}
.legend { display: flex; flex-wrap: wrap; gap: 6px 16px; padding: 6px 10px 4px; color: var(--muted); font-size: var(--fs-sm); } .legend { display: flex; flex-wrap: wrap; gap: 6px 16px; padding: 6px 10px 4px; color: var(--muted); font-size: var(--fs-sm); }
.legend span { display: inline-flex; align-items: center; gap: 6px; } .legend span { display: inline-flex; align-items: center; gap: 6px; }
.swatch { display: inline-block; width: 18px; height: 4px; border-radius: 2px; background: var(--c); } .swatch { display: inline-block; width: 18px; height: 4px; border-radius: 2px; background: var(--c); }
.panel { position: sticky; top: calc(var(--head) + 12px); max-height: calc(100vh - var(--head) - 24px); overflow: auto; }
.panel-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
.panel-title { margin: 0; font-size: var(--fs-xl); font-weight: 650; overflow-wrap: anywhere; } .panel-title { margin: 0; font-size: var(--fs-xl); font-weight: 650; overflow-wrap: anywhere; }
.panel h3, .drawer-body h3 { display: flex; align-items: center; gap: 8px; margin: 18px 0 8px; color: var(--muted); font-size: var(--fs-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; } .popup-box h3 { display: flex; align-items: center; gap: 8px; margin: 18px 0 8px; color: var(--muted); font-size: var(--fs-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
.mini { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; } .mini { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
.mini li { display: grid; gap: 3px; padding: 9px 10px; border: 1px solid var(--line); border-radius: 10px; background: var(--sunken); font-size: var(--fs-sm); overflow-wrap: anywhere; } .mini li { display: grid; gap: 3px; padding: 9px 10px; border: 1px solid var(--line); border-radius: 10px; background: var(--sunken); font-size: var(--fs-sm); overflow-wrap: anywhere; }
.mini .row { display: flex; align-items: center; justify-content: space-between; gap: 8px; } .mini .row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
@@ -248,7 +241,6 @@ svg .node.k-operator circle { fill: var(--accent); stroke: color-mix(in srgb, va
svg .node.k-chair circle { fill: var(--accent-soft); stroke: var(--accent); stroke-width: 2; } svg .node.k-chair circle { fill: var(--accent-soft); stroke: var(--accent); stroke-width: 2; }
svg .node.k-subagent circle { fill: var(--violet-soft); stroke: var(--violet); } svg .node.k-subagent circle { fill: var(--violet-soft); stroke: var(--violet); }
svg .node.claimed circle { stroke: var(--ok); stroke-width: 2.2; } svg .node.claimed circle { stroke: var(--ok); stroke-width: 2.2; }
svg .node.selected circle { stroke: var(--accent); stroke-width: 3.5; }
svg .label { fill: var(--text); font-size: var(--fs-sm); font-weight: 560; paint-order: stroke; stroke: var(--panel); stroke-width: 4px; stroke-linejoin: round; } svg .label { fill: var(--text); font-size: var(--fs-sm); font-weight: 560; paint-order: stroke; stroke: var(--panel); stroke-width: 4px; stroke-linejoin: round; }
svg .initial { fill: var(--muted); font-size: var(--fs-sm); font-weight: 700; pointer-events: none; } svg .initial { fill: var(--muted); font-size: var(--fs-sm); font-weight: 700; pointer-events: none; }
svg .k-operator .initial { fill: var(--on-accent); font-size: var(--fs-xl); } svg .k-operator .initial { fill: var(--on-accent); font-size: var(--fs-xl); }
@@ -278,7 +270,9 @@ svg .node.tone-warn circle { fill: var(--warn-soft); stroke: var(--warn); }
svg .node.tone-bad circle { fill: var(--bad-soft); stroke: var(--bad); } svg .node.tone-bad circle { fill: var(--bad-soft); stroke: var(--bad); }
svg .node.tone-info circle { fill: var(--info-soft); stroke: var(--info); } svg .node.tone-info circle { fill: var(--info-soft); stroke: var(--info); }
svg .node.tone-violet circle { fill: var(--violet-soft); stroke: var(--violet); } svg .node.tone-violet circle { fill: var(--violet-soft); stroke: var(--violet); }
svg .node.selected circle { stroke: var(--accent); stroke-width: 3.5; } svg .node.picked circle { fill: var(--alarm); stroke: var(--alarm); transform: scale(1.6); transform-box: fill-box; transform-origin: center; }
svg .lane-label.picked { fill: var(--alarm); font-size: var(--fs-lg); font-weight: 750; }
.linkish.picked { color: var(--alarm); transform: scale(1.15); }
svg .edge.merge { stroke-dasharray: 5 5; } svg .edge.merge { stroke-dasharray: 5 5; }
svg .lane-mark { stroke-width: 1.2; } svg .lane-mark { stroke-width: 1.2; }
svg .lane-mark.tone-ok { fill: var(--ok); stroke: var(--ok); } svg .lane-mark.tone-ok { fill: var(--ok); stroke: var(--ok); }
@@ -291,11 +285,16 @@ input[type="datetime-local"] { padding: 3px 6px; border: 1px solid var(--line-st
.avatar.big { width: 52px; height: 52px; font-size: var(--fs-3xl); } .avatar.big { width: 52px; height: 52px; font-size: var(--fs-3xl); }
.linkish { padding: 0; border: 0; background: none; color: inherit; font-weight: 650; text-align: left; cursor: pointer; overflow-wrap: anywhere; } .linkish { padding: 0; border: 0; background: none; color: inherit; font-weight: 650; text-align: left; cursor: pointer; overflow-wrap: anywhere; }
.linkish:hover { color: var(--accent); text-decoration: underline; } .linkish:hover { color: var(--accent); text-decoration: underline; }
.drawer { position: fixed; top: 0; right: 0; bottom: 0; z-index: 20; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; width: min(480px, 100vw); background: var(--glass-card); border-left: 1px solid var(--line); box-shadow: var(--shadow-lift); } .popup { position: fixed; inset: 0; z-index: 25; width: auto; height: auto; max-width: none; max-height: none; margin: 0; padding: 0; overflow: hidden; border: 0; background: transparent; color: var(--text); }
.drawer[hidden] { display: none; } .fade { animation: fade .2s var(--ease); }
.drawer-head { display: flex; align-items: center; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--line); } .leader { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.drawer-body { overflow: auto; padding: 4px 18px 12px; } .leader line { stroke: var(--alarm); stroke-width: 2; stroke-linecap: round; }
.drawer-form { display: grid; gap: 8px; padding: 12px 18px 18px; border-top: 1px solid var(--line); } .popup-box { position: absolute; display: flex; flex-direction: column; gap: var(--sp-2); width: min(440px, calc(100% - 2 * var(--sp-4))); max-height: calc(100% - 2 * var(--sp-4)); padding: var(--sp-4); overflow: auto; background: var(--glass-card); border: 1px solid var(--line-strong); border-radius: var(--radius); box-shadow: var(--shadow-lift); opacity: .94; }
.popup-head { display: flex; flex: none; align-items: center; gap: 12px; }
.popup-body { flex: 0 1 auto; min-height: 7em; overflow: auto; }
.popup-form { display: grid; flex: none; gap: 8px; padding-top: 12px; border-top: 1px solid var(--line); }
.node-search { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.node-search .chips { margin-bottom: 0; }
textarea { width: 100%; min-height: 84px; padding: 8px 10px; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--panel); color: var(--text); font: inherit; resize: vertical; } textarea { width: 100%; min-height: 84px; padding: 8px 10px; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--panel); color: var(--text); font: inherit; resize: vertical; }
.bubble { display: grid; gap: 3px; margin: 0 0 8px; padding: 8px 11px; border-radius: 12px; background: var(--sunken); border: 1px solid var(--line); font-size: var(--fs-sm); overflow-wrap: anywhere; white-space: pre-wrap; } .bubble { display: grid; gap: 3px; margin: 0 0 8px; padding: 8px 11px; border-radius: 12px; background: var(--sunken); border: 1px solid var(--line); font-size: var(--fs-sm); overflow-wrap: anywhere; white-space: pre-wrap; }
.bubble.mine { margin-left: 34px; background: var(--accent-soft); border-color: transparent; } .bubble.mine { margin-left: 34px; background: var(--accent-soft); border-color: transparent; }
@@ -379,8 +378,6 @@ textarea { width: 100%; min-height: 84px; padding: 8px 10px; border: 1px solid v
.stage canvas.space { display: block; width: 100%; height: clamp(360px, 68vh, 820px); border-radius: var(--radius-sm); cursor: grab; touch-action: none; } .stage canvas.space { display: block; width: 100%; height: clamp(360px, 68vh, 820px); border-radius: var(--radius-sm); cursor: grab; touch-action: none; }
.stage canvas.space:active { cursor: grabbing; } .stage canvas.space:active { cursor: grabbing; }
.space-note { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; padding: 8px 10px 0; color: var(--muted); font-size: var(--fs-sm); } .space-note { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; padding: 8px 10px 0; color: var(--muted); font-size: var(--fs-sm); }
.space-list { padding: 6px 10px 2px; }
.space-list summary { margin: 4px 0; color: var(--muted); font-size: var(--fs-sm); cursor: pointer; }
body.sky-look .shell { visibility: hidden; } body.sky-look .shell { visibility: hidden; }
body.sky-look .sky { z-index: 5; pointer-events: auto; cursor: grab; touch-action: none; } body.sky-look .sky { z-index: 5; pointer-events: auto; cursor: grab; touch-action: none; }
body.sky-look .sky:active { cursor: grabbing; } body.sky-look .sky:active { cursor: grabbing; }
@@ -441,7 +438,7 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
<div class="shell"> <div class="shell">
<main id="main"></main> <main id="main"></main>
</div> </div>
<aside class="drawer" id="drawer" aria-label="Agent" hidden></aside> <dialog class="popup fade" id="popup" aria-label="Details"><svg class="leader" aria-hidden="true"><line></line></svg><div class="popup-box"></div></dialog>
<div class="sheet" id="sheet" hidden></div> <div class="sheet" id="sheet" hidden></div>
<div class="toasts" id="toasts" aria-live="polite"></div> <div class="toasts" id="toasts" aria-live="polite"></div>
<div class="palette" id="palette" hidden></div> <div class="palette" id="palette" hidden></div>
@@ -454,6 +451,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
{% include "voice.js" %} {% include "voice.js" %}
{% include "nav.js" %} {% include "nav.js" %}
{% include "popup.js" %}
</script> </script>
<script nonce="{{ nonce }}"> <script nonce="{{ nonce }}">
{# Jinja drops each file's last newline and trim_blocks the one after the tag: the blank lines keep consecutive modules on separate lines #} {# Jinja drops each file's last newline and trim_blocks the one after the tag: the blank lines keep consecutive modules on separate lines #}
@@ -550,6 +549,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
{% include "page-keyboard.js" %} {% include "page-keyboard.js" %}
{% include "page-popup.js" %}
{% include "page-agent.js" %} {% include "page-agent.js" %}
{% include "page-palette.js" %} {% include "page-palette.js" %}
+26 -25
View File
@@ -7,14 +7,15 @@
return [...relayed, ...sent].sort((a, b) => a.ts - b.ts).slice(-80); return [...relayed, ...sent].sort((a, b) => a.ts - b.ts).slice(-80);
} }
function drawDrawer() { function drawAgent() {
if (!drawerDom) { if (!agentDom) {
return; return;
} }
const node = state.graph.nodes.find((entry) => entry.id === ui.agent); const node = state.graph.nodes.find((entry) => entry.id === ui.agent);
const tasks = node ? node.tasks : []; const tasks = node ? node.tasks : [];
const claims = node ? node.claims : []; const claims = node ? node.claims : [];
drawerDom.info.replaceChildren( agentDom.info.replaceChildren(
h("p", {class: "muted"}, claims.length ? `Offices: ${claims.map((claim) => claim.office).join(", ")}` : "No office"),
h("h3", {}, "Tasks", counter(tasks.length)), h("h3", {}, "Tasks", counter(tasks.length)),
tasks.length ? h("ul", {class: "mini"}, tasks.map((task) => h("li", {}, tasks.length ? h("ul", {class: "mini"}, tasks.map((task) => h("li", {},
h("div", {class: "row"}, h("code", {}, task.task), status(task.status)), h("div", {class: "row"}, h("code", {}, task.task), status(task.status)),
@@ -26,9 +27,10 @@
h("span", {}, dash(claim.work)), h("span", {}, dash(claim.work)),
h("span", {class: "muted"}, `${dash(claim.runtime)} · since ${dash(claim.since)}`)))) : h("p", {class: "muted"}, "No claims.")); h("span", {class: "muted"}, `${dash(claim.runtime)} · since ${dash(claim.since)}`)))) : h("p", {class: "muted"}, "No claims."));
const rows = history(ui.agent); const rows = history(ui.agent);
drawerDom.history.replaceChildren(rows.length agentDom.history.replaceChildren(rows.length
? h("div", {}, rows.map((row) => h("div", {class: `bubble ${row.mine ? "mine" : "theirs"}`}, h("span", {class: "muted"}, `${row.who} · `, when(row.ts)), row.text, voiceReplay(row.voice)))) ? h("div", {}, rows.map((row) => h("div", {class: `bubble ${row.mine ? "mine" : "theirs"}`}, h("span", {class: "muted"}, `${row.who} · `, when(row.ts)), row.text, voiceReplay(row.voice))))
: h("p", {class: "muted"}, "No history in the chosen time range.")); : h("p", {class: "muted"}, "No history in the chosen time range."));
placePopup();
} }
async function loadSubagents(key, holder, life) { async function loadSubagents(key, holder, life) {
@@ -56,9 +58,10 @@
item.style.marginLeft = `${(row.depth - 1) * 18}px`; item.style.marginLeft = `${(row.depth - 1) * 18}px`;
return item; return item;
})) : h("p", {class: "muted"}, "No subagents.")); })) : h("p", {class: "muted"}, "No subagents."));
placePopup();
} }
async function deliver(text, file, cls) { async function deliver(key, text, file, cls) {
let upload = ""; let upload = "";
if (file) { if (file) {
if (file.size > UPLOAD_LIMIT) { if (file.size > UPLOAD_LIMIT) {
@@ -72,23 +75,21 @@
} }
upload = stored.data.id; upload = stored.data.id;
} }
return message(nameOf(ui.agent), text, upload, cls); return message(nameOf(key), text, upload, cls);
} }
function message(to, text, upload, cls) { function message(to, text, upload, cls) {
return radioSend({to, text, upload, cls, voice: ""}); return radioSend({to, text, upload, cls, voice: ""});
} }
function openAgent(who) { function openAgent(who, anchor, closed) {
const drawer = document.getElementById("drawer");
const key = agentKey(who); const key = agentKey(who);
const name = nameOf(key); const name = nameOf(key);
ui.agent = key;
const text = h("textarea", {"aria-label": `Message to ${name}`, placeholder: `Message to ${name} …`}); const text = h("textarea", {"aria-label": `Message to ${name}`, placeholder: `Message to ${name} …`});
const file = h("input", {type: "file", class: "field", "aria-label": "Attach file"}); const file = h("input", {type: "file", class: "field", "aria-label": "Attach file"});
const button = h("button", {type: "submit", class: "btn primary icon-only", ...named("Send")}, icon("bi-send")); const button = h("button", {type: "submit", class: "btn primary icon-only", ...named("Send")}, icon("bi-send"));
const classes = choice("to-one", TO_ONE, "post"); const classes = choice("to-one", TO_ONE, "post");
const form = h("form", {class: "drawer-form"}, text, file, classes, const form = h("form", {class: "popup-form"}, text, file, classes,
h("p", {class: "muted"}, "Post arrives at the end of the turn, Call at once with a read receipt; your passkey signs, verifiable with operator_message."), h("p", {class: "muted"}, "Post arrives at the end of the turn, Call at once with a read receipt; your passkey signs, verifiable with operator_message."),
h("div", {class: "actions"}, h("span", {class: "spacer"}), micButton(text), button)); h("div", {class: "actions"}, h("span", {class: "spacer"}), micButton(text), button));
form.addEventListener("submit", async (event) => { form.addEventListener("submit", async (event) => {
@@ -97,7 +98,7 @@
return; return;
} }
button.disabled = true; button.disabled = true;
if (await deliver(text.value, file.files[0] || null, picked(classes))) { if (await deliver(key, text.value, file.files[0] || null, picked(classes))) {
text.value = ""; text.value = "";
file.value = ""; file.value = "";
} }
@@ -108,24 +109,24 @@
const log = h("div", {"aria-live": "polite"}); const log = h("div", {"aria-live": "polite"});
const life = h("span", {class: "muted"}); const life = h("span", {class: "muted"});
const former = (people()[key] || {names: []}).names.filter((entry) => entry !== name); const former = (people()[key] || {names: []}).names.filter((entry) => entry !== name);
drawer.replaceChildren( openPopup(name, [
h("div", {class: "drawer-head"}, face(name, true), life, h("div", {}, h("h2", {class: "panel-title"}, name), h("span", {class: "muted"}, former.length ? `Agent · formerly ${former.join(", ")}` : "Agent")), h("span", {class: "spacer"}), popupHead(face(name, true), name, h("span", {class: "muted"}, former.length ? `Agent · formerly ${former.join(", ")}` : "Agent"), " ", life),
iconBtn("bi-x-lg", "Close", "btn small ghost", closeAgent)), h("div", {class: "popup-body"}, info, h("h3", {}, "Subagents"), subs, h("h3", {}, "History"), log),
h("div", {class: "drawer-body"}, info, h("h3", {}, "Subagents"), subs, h("h3", {}, "History"), log), form,
form); ], anchor, () => {
drawer.hidden = false; agentDom = null;
drawerDom = {info, history: log}; ui.agent = null;
drawDrawer(); if (closed) {
closed();
}
});
ui.agent = key;
agentDom = {info, history: log};
drawAgent();
loadSubagents(key, subs, life); loadSubagents(key, subs, life);
text.focus(); text.focus();
} }
function closeAgent() {
document.getElementById("drawer").hidden = true;
drawerDom = null;
ui.agent = null;
}
function agentNames() { function agentNames() {
const names = new Set([...state.graph.nodes.filter((node) => node.kind === "session" || node.kind === "chair").map((node) => nameOf(node.id)), ...state.agent_order]); const names = new Set([...state.graph.nodes.filter((node) => node.kind === "session" || node.kind === "chair").map((node) => nameOf(node.id)), ...state.agent_order]);
return [...names].sort((a, b) => a.localeCompare(b)); return [...names].sort((a, b) => a.localeCompare(b));
+19 -28
View File
@@ -1,4 +1,4 @@
const art = {repo: "", node: null}; const art = {repo: ""};
const ART_KINDS = {repo: ["Repository", "bi-git"], branch: ["Branch", "bi-diagram-2"], worktree: ["Worktree", "bi-folder2-open"], agent: ["Agent", "bi-robot"], artefact: ["Artifact", "bi-box-seam"], task: ["Task", "bi-card-checklist"]}; const ART_KINDS = {repo: ["Repository", "bi-git"], branch: ["Branch", "bi-diagram-2"], worktree: ["Worktree", "bi-folder2-open"], agent: ["Agent", "bi-robot"], artefact: ["Artifact", "bi-box-seam"], task: ["Task", "bi-card-checklist"]};
const ARTEFACT_ICONS = {image: "bi-box-seam", volume: "bi-hdd", log: "bi-file-text", dir: "bi-folder"}; const ARTEFACT_ICONS = {image: "bi-box-seam", volume: "bi-hdd", log: "bi-file-text", dir: "bi-folder"};
const LANE_MARKS = {commit: ["Commit", "bi-record-circle"], review: ["Review", "bi-eye"], gate: ["Gate", "bi-shield-check"], push: ["Push", "bi-cloud-upload"]}; const LANE_MARKS = {commit: ["Commit", "bi-record-circle"], review: ["Review", "bi-eye"], gate: ["Gate", "bi-shield-check"], push: ["Push", "bi-cloud-upload"]};
@@ -71,16 +71,23 @@
return found; return found;
} }
function artSelect(id) { function artSelect(id, anchor, closed) {
art.node = art.node === id ? null : id; const node = tree.model && tree.model.byId.get(id);
drawArtifacts(); if (!node) {
return;
}
if (node.kind === "agent") {
openAgent(node.data.name, anchor || pickOf(id), closed);
} else {
openPopup(node.label, artPopup(node), anchor || pickOf(id), closed);
}
} }
function artMark(node, at, radius, extra, caption) { function artMark(node, at, radius, extra, caption) {
const [kindName] = ART_KINDS[node.kind]; const [kindName] = ART_KINDS[node.kind];
const classes = ["node", `a-${node.kind}`, extra, art.node === node.id ? "selected" : ""].filter(Boolean).join(" "); const classes = ["node", `a-${node.kind}`, extra].filter(Boolean).join(" ");
return s("g", { return s("g", {
class: classes, transform: `translate(${fixed(at.x)} ${fixed(at.y)})`, tabindex: 0, role: "button", "aria-label": `${node.label}, ${kindName}`, class: classes, transform: `translate(${fixed(at.x)} ${fixed(at.y)})`, tabindex: 0, role: "button", "aria-label": `${node.label}, ${kindName}`, "data-pick": node.id,
on: { on: {
click: () => artSelect(node.id), click: () => artSelect(node.id),
keydown: (event) => { keydown: (event) => {
@@ -178,7 +185,7 @@
const svg = s("svg", {viewBox: `0 0 ${full} ${height}`, class: "chart", role: "img", "aria-label": `Swimlane: commits, reviews, gates and pushes per branch of ${repo.name}`}, defs()); const svg = s("svg", {viewBox: `0 0 ${full} ${height}`, class: "chart", role: "img", "aria-label": `Swimlane: commits, reviews, gates and pushes per branch of ${repo.name}`}, defs());
lanes.forEach((branch, index) => { lanes.forEach((branch, index) => {
svg.append(s("rect", {class: index % 2 ? "lane odd" : "lane", x: 0, y: top + index * lane, width: full, height: lane})); svg.append(s("rect", {class: index % 2 ? "lane odd" : "lane", x: 0, y: top + index * lane, width: full, height: lane}));
svg.append(s("text", {class: "lane-label", x: 14, y: top + index * lane + lane / 2, dy: "0.35em", on: {click: () => artSelect(`branch:${repo.path}\u0000${branch.name}`)}}, short(branch.name).slice(0, 26))); svg.append(s("text", {class: "lane-label", x: 14, y: top + index * lane + lane / 2, dy: "0.35em", "data-pick": `branch:${repo.path}\u0000${branch.name}`, on: {click: () => artSelect(`branch:${repo.path}\u0000${branch.name}`)}}, short(branch.name).slice(0, 26)));
}); });
for (let index = 0; index <= 6; index += 1) { for (let index = 0; index <= 6; index += 1) {
const ts = begin + ((end - begin) * index) / 6; const ts = begin + ((end - begin) * index) / 6;
@@ -209,11 +216,7 @@
return h("div", {class: "legend"}, marks, h("span", {class: "muted"}, words[view])); return h("div", {class: "legend"}, marks, h("span", {class: "muted"}, words[view]));
} }
function artPanel(model) { function artPopup(node) {
const node = model.byId.get(art.node);
if (!node) {
return [h("h2", {class: "panel-title"}, "Details"), h("p", {class: "muted"}, "Click a repository, a branch, a worktree, an agent or an artifact.")];
}
const [kindName, kindIcon] = ART_KINDS[node.kind]; const [kindName, kindIcon] = ART_KINDS[node.kind];
const data = node.data; const data = node.data;
const body = []; const body = [];
@@ -229,31 +232,23 @@
flowDetails(data.flow, data.branch), flowDetails(data.flow, data.branch),
data.workers.length ? h("div", {class: "who"}, data.workers.map((name) => [face(name, false), person(name)])) : h("p", {class: "muted"}, "No agent works here."), data.workers.length ? h("div", {class: "who"}, data.workers.map((name) => [face(name, false), person(name)])) : h("p", {class: "muted"}, "No agent works here."),
data.review ? h("div", {class: "who"}, status(data.review.status), h("span", {}, data.review.title)) : null, h("div", {class: "actions"}, copyButton(data.path))); data.review ? h("div", {class: "who"}, status(data.review.status), h("span", {}, data.review.title)) : null, h("div", {class: "actions"}, copyButton(data.path)));
} else if (node.kind === "agent") {
body.push(h("div", {class: "actions"}, btn("Open agent", "btn small soft", () => openAgent(data.name))));
} else if (node.kind === "artefact") { } else if (node.kind === "artefact") {
body.push(meta([["Ref", data.ref], ["Kind", data.kind], ["Task", data.task], ["Since", data.added]]), data.ended ? pill("Task ended: clean up", "warn") : null); body.push(meta([["Ref", data.ref], ["Kind", data.kind], ["Task", data.task], ["Since", data.added]]), data.ended ? pill("Task ended: clean up", "warn") : null);
} else { } else {
body.push(meta([["Title", data.title], ["Status", data.status]])); body.push(meta([["Title", data.title], ["Status", data.status]]));
} }
return [ return [popupHead(icon(kindIcon), node.label, h("span", {class: "muted"}, kindName)), h("div", {class: "popup-body"}, body)];
h("div", {class: "panel-head"}, h("div", {}, h("h2", {class: "panel-title"}, icon(kindIcon), " ", node.label), h("span", {class: "muted"}, kindName)),
iconBtn("bi-x-lg", "Close", "btn small ghost", () => artSelect(node.id))),
...body,
];
} }
function artifactsScreen() { function artifactsScreen() {
const view = ui.views.artifacts; const view = ui.views.artifacts;
const picker = h("select", {class: "field", "aria-label": "Repository", title: "Repository", on: {change: (event) => { const picker = h("select", {class: "field", "aria-label": "Repository", title: "Repository", on: {change: (event) => {
art.repo = event.target.value; art.repo = event.target.value;
art.node = null;
drawArtifacts(); drawArtifacts();
}}}); }}});
const count = h("span", {class: "muted"}); const count = h("span", {class: "muted"});
const reload = iconBtn("bi-arrow-clockwise", "Reload", "btn small soft", fetchTree, "Reload: read the tree state from pkgmgr"); const reload = iconBtn("bi-arrow-clockwise", "Reload", "btn small soft", fetchTree, "Reload: read the tree state from pkgmgr");
const stageBox = h("div", {class: "stage"}); const stageBox = h("div", {class: "stage"});
const aside = h("aside", {class: "card panel", "aria-live": "polite"});
const legendBox = h("div", {}); const legendBox = h("div", {});
const list = h("div", {class: "repos"}); const list = h("div", {class: "repos"});
const search = view === "tree" ? h("input", {type: "search", placeholder: "Filter repo, branch, path or agent …", value: tree.q, "aria-label": "Filter tree", on: {input: (event) => { const search = view === "tree" ? h("input", {type: "search", placeholder: "Filter repo, branch, path or agent …", value: tree.q, "aria-label": "Filter tree", on: {input: (event) => {
@@ -261,14 +256,14 @@
drawArtifacts(); drawArtifacts();
}}}) : null; }}}) : null;
const removeAll = view === "tree" ? h("span", {class: "delete-all"}) : null; const removeAll = view === "tree" ? h("span", {class: "delete-all"}) : null;
tree.dom = {view, picker, count, stage: stageBox, aside, legend: legendBox, list, removeAll}; tree.dom = {view, picker, count, stage: stageBox, legend: legendBox, list, removeAll};
window.setTimeout(() => { window.setTimeout(() => {
drawArtifacts(); drawArtifacts();
fetchTree(); fetchTree();
}, 0); }, 0);
const controls = h("div", {class: "card controls"}, viewSwitch("artifacts"), view === "3d" ? layoutTools("artifacts") : view === "tree" ? null : picker, const controls = h("div", {class: "card controls"}, viewSwitch("artifacts"), view === "3d" ? layoutTools("artifacts") : view === "tree" ? null : picker,
h("div", {class: "toolbar", role: "toolbar", "aria-label": "Artifacts"}, search, reload, removeAll), count); h("div", {class: "toolbar", role: "toolbar", "aria-label": "Artifacts"}, search, reload, removeAll), count);
const body = view === "tree" ? list : h("div", {class: "graph-body"}, h("div", {class: "card stage-card"}, stageBox, legendBox), aside); const body = view === "tree" ? list : h("div", {class: "card stage-card"}, stageBox, legendBox);
return h("div", {class: "graph"}, controls, body); return h("div", {class: "graph"}, controls, body);
} }
@@ -305,14 +300,9 @@
tree.dom.count.textContent = empty ? "" : `${tree.data.repos.length} repositories · as of ${clock.format(stampOf(tree.data.scanned))}`; tree.dom.count.textContent = empty ? "" : `${tree.data.repos.length} repositories · as of ${clock.format(stampOf(tree.data.scanned))}`;
if (empty) { if (empty) {
tree.dom.stage.replaceChildren(emptyState(tree.busy || !tree.data ? "Reading repositories …" : "No tree", tree.data ? tree.data.error : "pkgmgr writes the tree every minute while this page is open.")); tree.dom.stage.replaceChildren(emptyState(tree.busy || !tree.data ? "Reading repositories …" : "No tree", tree.data ? tree.data.error : "pkgmgr writes the tree every minute while this page is open."));
tree.dom.aside.replaceChildren();
return; return;
} }
const model = tree.model; const model = tree.model;
if (art.node !== null && !model.byId.has(art.node)) {
art.node = null;
}
tree.dom.aside.replaceChildren(...artPanel(model));
if (tree.dom.view === "3d") { if (tree.dom.view === "3d") {
stage3d("artifacts", tree.dom.stage); stage3d("artifacts", tree.dom.stage);
return; return;
@@ -321,4 +311,5 @@
tree.dom.picker.replaceChildren(...tree.data.repos.map((entry) => h("option", {value: entry.path, selected: entry.path === art.repo}, `${entry.name} (${entry.branches.length})`))); tree.dom.picker.replaceChildren(...tree.data.repos.map((entry) => h("option", {value: entry.path, selected: entry.path === art.repo}, `${entry.name} (${entry.branches.length})`)));
tree.dom.legend.replaceChildren(artLegend(tree.dom.view)); tree.dom.legend.replaceChildren(artLegend(tree.dom.view));
tree.dom.stage.replaceChildren(repo ? {orbit: artOrbit, chord: artChord, swimlane: artSwimlane}[tree.dom.view](repo, model) : emptyState("No repository", "pkgmgr names no repository.")); tree.dom.stage.replaceChildren(repo ? {orbit: artOrbit, chord: artChord, swimlane: artSwimlane}[tree.dom.view](repo, model) : emptyState("No repository", "pkgmgr names no repository."));
placePopup();
} }
+6
View File
@@ -8,6 +8,11 @@
closeSheet(); closeSheet();
} }
}); });
popupDialog().addEventListener("click", (event) => {
if (event.target === event.currentTarget) {
closePopup();
}
});
document.getElementById("palette-open").addEventListener("click", openPalette); document.getElementById("palette-open").addEventListener("click", openPalette);
document.getElementById("settings-open").addEventListener("click", settingsSheet); document.getElementById("settings-open").addEventListener("click", settingsSheet);
document.addEventListener("click", (event) => closeMenus(event.target instanceof Element ? event.target.closest("details.menu") : null)); document.addEventListener("click", (event) => closeMenus(event.target instanceof Element ? event.target.closest("details.menu") : null));
@@ -26,6 +31,7 @@
window.addEventListener("resize", measure); window.addEventListener("resize", measure);
window.addEventListener("resize", redrawSky); window.addEventListener("resize", redrawSky);
window.addEventListener("resize", refresh3d); window.addEventListener("resize", refresh3d);
window.addEventListener("resize", placePopup);
skyPanel(); skyPanel();
radio.element = radioElement(); radio.element = radioElement();
drawSky(); drawSky();
+10 -17
View File
@@ -113,7 +113,7 @@
const svg = s("svg", {viewBox: `0 0 ${full} ${height}`, class: "chart", role: "img", "aria-label": "Swimlane: messages over time"}, defs()); const svg = s("svg", {viewBox: `0 0 ${full} ${height}`, class: "chart", role: "img", "aria-label": "Swimlane: messages over time"}, defs());
lanes.forEach((node, index) => { lanes.forEach((node, index) => {
svg.append(s("rect", {class: index % 2 ? "lane odd" : "lane", x: 0, y: top + index * lane, width: full, height: lane})); svg.append(s("rect", {class: index % 2 ? "lane odd" : "lane", x: 0, y: top + index * lane, width: full, height: lane}));
svg.append(s("text", {class: "lane-label", x: 14, y: top + index * lane + lane / 2, dy: "0.35em", "text-anchor": "start", on: {click: () => select(node.id)}}, node.label)); svg.append(s("text", {class: "lane-label", x: 14, y: top + index * lane + lane / 2, dy: "0.35em", "text-anchor": "start", "data-pick": node.id, on: {click: () => select(node.id)}}, node.label));
}); });
for (let index = 0; index <= 6; index += 1) { for (let index = 0; index <= 6; index += 1) {
const ts = begin + ((end - begin) * index) / 6; const ts = begin + ((end - begin) * index) / 6;
@@ -161,21 +161,15 @@
h("span", {class: "muted"}, "Line width = count · closeness = recent collaboration")); h("span", {class: "muted"}, "Line width = count · closeness = recent collaboration"));
} }
function side() { function nodePopup(node) {
const node = state.graph.nodes.find((entry) => entry.id === ui.node);
if (!node) {
return [h("h2", {class: "panel-title"}, "Details"), h("p", {class: "muted"}, "Click a node: its panel shows tasks, claims, subagents and history.")];
}
const messages = state.radio.filter((row) => row.source === node.id || row.targets.includes(node.id)).slice(-10).reverse(); const messages = state.radio.filter((row) => row.source === node.id || row.targets.includes(node.id)).slice(-10).reverse();
return [ return [
h("div", {class: "panel-head"}, popupHead(null, node.label, h("span", {class: "muted"}, NODE_KINDS[node.kind])),
h("div", {}, h("h2", {class: "panel-title"}, node.label), h("span", {class: "muted"}, `${NODE_KINDS[node.kind]}${node.parent ? ` of ${labelOf(node.parent)}` : ""}`)), h("div", {class: "popup-body"}, h("h3", {}, "Latest messages", counter(messages.length)),
iconBtn("bi-x-lg", "Close", "btn small ghost", () => select(node.id))), messages.length ? h("ul", {class: "mini"}, messages.map((row) => h("li", {},
h("h3", {}, "Latest messages", counter(messages.length)), h("div", {class: "row"}, klass(row.class), when(row.ts)),
messages.length ? h("ul", {class: "mini"}, messages.map((row) => h("li", {}, h("span", {class: "muted"}, `${dash(row.from)} → ${targetsOf(row)}`),
h("div", {class: "row"}, klass(row.class), when(row.ts)), h("span", {}, row.text)))) : h("p", {class: "muted"}, "No messages in the chosen time range.")),
h("span", {class: "muted"}, `${dash(row.from)} → ${targetsOf(row)}`),
h("span", {}, row.text)))) : h("p", {class: "muted"}, "No messages in the chosen time range."),
]; ];
} }
@@ -188,9 +182,8 @@
function agentsScreen() { function agentsScreen() {
const view = ui.views.agents; const view = ui.views.agents;
const stageBox = h("div", {class: "stage"}); const stageBox = h("div", {class: "stage"});
const aside = h("aside", {class: "card panel", "aria-live": "polite"});
const toolbar = agentsToolbar(); const toolbar = agentsToolbar();
graphDom = {stage: stageBox, aside, pulses: null, toolbar}; graphDom = {stage: stageBox, pulses: null, toolbar};
const body = view === "tree" ? spawnScreen() : view === "hierarchy" ? hierarchyScreen() : h("div", {class: "graph-body"}, h("div", {class: "card stage-card"}, stageBox, view === "3d" ? null : legend()), aside); const body = view === "tree" ? spawnScreen() : view === "hierarchy" ? hierarchyScreen() : h("div", {class: "card stage-card"}, stageBox, view === "3d" ? null : legend());
return h("div", {class: "graph"}, toolbar.bar, h("div", {class: "card controls"}, viewSwitch("agents"), view === "3d" ? layoutTools("agents") : null), body); return h("div", {class: "graph"}, toolbar.bar, h("div", {class: "card controls"}, viewSwitch("agents"), view === "3d" ? layoutTools("agents") : null), body);
} }
+10 -8
View File
@@ -107,22 +107,24 @@
return s("text", {class: "label", x: fixed(cos * gap), y: fixed(sin * (gap + 3)), dy: "0.35em", "text-anchor": anchor}, node.label); return s("text", {class: "label", x: fixed(cos * gap), y: fixed(sin * (gap + 3)), dy: "0.35em", "text-anchor": anchor}, node.label);
} }
function select(id) { function select(id, anchor, closed) {
ui.node = ui.node === id ? null : id;
stage();
drawSide();
const node = state.graph.nodes.find((entry) => entry.id === id); const node = state.graph.nodes.find((entry) => entry.id === id);
if (ui.node !== null && node && node.kind !== "operator") { if (!node) {
openAgent(node.kind === "subagent" ? node.parent : id); return;
}
if (node.kind === "operator") {
openPopup(node.label, nodePopup(node), anchor || pickOf(id), closed);
} else {
openAgent(node.kind === "subagent" ? node.parent : id, anchor || pickOf(id), closed);
} }
} }
function nodeMark(node, at, gap) { function nodeMark(node, at, gap) {
const r = RADIUS[node.kind]; const r = RADIUS[node.kind];
const classes = ["node", `k-${node.kind}`, ui.node === node.id ? "selected" : "", node.claims.length ? "claimed" : "", speech.current && speech.current.who === node.id ? "speaking" : ""].filter(Boolean).join(" "); const classes = ["node", `k-${node.kind}`, node.claims.length ? "claimed" : "", speech.current && speech.current.who === node.id ? "speaking" : ""].filter(Boolean).join(" ");
const tasks = node.tasks.length ? ` · ${node.tasks.length} tasks` : ""; const tasks = node.tasks.length ? ` · ${node.tasks.length} tasks` : "";
return s("g", { return s("g", {
class: classes, transform: `translate(${fixed(at.x)} ${fixed(at.y)})`, tabindex: 0, role: "button", "aria-label": `${node.label}, ${NODE_KINDS[node.kind]}`, class: classes, transform: `translate(${fixed(at.x)} ${fixed(at.y)})`, tabindex: 0, role: "button", "aria-label": `${node.label}, ${NODE_KINDS[node.kind]}`, "data-pick": node.id,
on: { on: {
click: () => select(node.id), click: () => select(node.id),
keydown: (event) => { keydown: (event) => {
+35 -16
View File
@@ -8,7 +8,7 @@
const SPAWNED = new Set(["twig", "subagent"]); const SPAWNED = new Set(["twig", "subagent"]);
const DEPTH_GAP = 90; const DEPTH_GAP = 90;
const TIME_DEPTH = 640; const TIME_DEPTH = 640;
const g3 = {lib: null, three: null, graph: null, host: null, tab: "", sig: "", planes: [], colors: null, failed: ""}; const g3 = {lib: null, three: null, graph: null, host: null, tab: "", sig: "", planes: [], colors: null, failed: "", picked: null};
const layoutOf = () => space.views.graph.axis; const layoutOf = () => space.views.graph.axis;
@@ -44,7 +44,8 @@
}, icon(name)))); }, icon(name))));
return h("div", {class: "space-tools"}, group, return h("div", {class: "space-tools"}, group,
iconBtn("bi-arrow-counterclockwise", "Reset view", "btn small ghost", () => g3.graph && g3.graph.zoomToFit(calm.matches ? 0 : 600, 40)), iconBtn("bi-arrow-counterclockwise", "Reset view", "btn small ghost", () => g3.graph && g3.graph.zoomToFit(calm.matches ? 0 : 600, 40)),
h("span", {class: "muted"}, "Drag turns · right drag pans · wheel zooms · click opens the panel")); h("span", {class: "muted"}, "Drag turns · right drag pans · wheel zooms · click opens the popup"),
nodeSearch(() => model3d(tab).nodes, (node) => pick3d(tab, node)));
} }
function chooseLayout(tab, id, group) { function chooseLayout(tab, id, group) {
@@ -108,7 +109,7 @@
function colors3d() { function colors3d() {
const css = window.getComputedStyle(document.documentElement); const css = window.getComputedStyle(document.documentElement);
const value = (name) => css.getPropertyValue(`--${name}`).trim() || "#888"; const value = (name) => css.getPropertyValue(`--${name}`).trim() || "#888";
const roles = new Set([...Object.values(KIND_COLORS), ...Object.values(LINK_COLORS), "text"]); const roles = new Set([...Object.values(KIND_COLORS), ...Object.values(LINK_COLORS), "text", "alarm"]);
g3.colors = Object.fromEntries([...roles].map((role) => [role, value(role)])); g3.colors = Object.fromEntries([...roles].map((role) => [role, value(role)]));
return g3.colors; return g3.colors;
} }
@@ -141,19 +142,37 @@
} }
function pick3d(tab, node) { function pick3d(tab, node) {
if (tab === "artifacts") { if (node.kind === "twig") {
artSelect(node.id);
} else if (node.kind === "twig") {
focusSub(node.session, node.sub); focusSub(node.session, node.sub);
} else { return;
select(node.id);
} }
(tab === "artifacts" ? artSelect : select)(node.id, anchor3d(node.id), () => mark3d(null));
mark3d(node.id);
} }
function list3d(tab, data) { function anchor3d(id) {
const pickable = data.nodes.filter((node) => node.kind !== "operator"); return () => {
return h("details", {class: "space-list"}, h("summary", {}, `${pickable.length} nodes as a list`), const node = g3.graph && g3.host && g3.host.isConnected ? g3.graph.graphData().nodes.find((entry) => entry.id === id) : null;
h("div", {class: "chips", role: "group", "aria-label": "Nodes"}, pickable.slice(0, 400).map((node) => h("button", {type: "button", class: "chip", title: node.label, "data-node": node.id, on: {click: () => pick3d(tab, node)}}, node.label)))); if (!node || !Number.isFinite(node.x)) {
return null;
}
const at = g3.graph.graph2ScreenCoords(node.x, node.y, node.z);
const box = g3.host.querySelector(".graph3d").getBoundingClientRect();
return {left: box.left + at.x - 8, top: box.top + at.y - 8, width: 16, height: 16};
};
}
function mark3d(id) {
g3.picked = id;
if (g3.graph) {
g3.graph.nodeColor(g3.graph.nodeColor()).nodeVal(g3.graph.nodeVal());
}
const box = g3.host && g3.host.querySelector(".graph3d");
if (box && id) {
box.dataset.picked = id;
} else if (box) {
delete box.dataset.picked;
}
} }
function signature3d(tab, data) { function signature3d(tab, data) {
@@ -176,7 +195,7 @@
dispose3d(); dispose3d();
g3.host = host; g3.host = host;
g3.tab = tab; g3.tab = tab;
host.replaceChildren(h("div", {class: "graph3d", role: "img", "aria-label": `3D graph of the ${tab === "agents" ? "agents" : "artifacts"}; the nodes are also listed below`}), h("div", {class: "space-note"}, "Loading 3D …")); host.replaceChildren(h("div", {class: "graph3d", role: "img", "aria-label": `3D graph of the ${tab === "agents" ? "agents" : "artifacts"}; the search above finds every node`}), h("div", {class: "space-note"}, "Loading 3D …"));
} }
g3.sig = sig; g3.sig = sig;
const box = host.querySelector(".graph3d"); const box = host.querySelector(".graph3d");
@@ -203,8 +222,8 @@
} }
const colors = colors3d(); const colors = colors3d();
g3.graph.backgroundColor("rgba(0,0,0,0)").showNavInfo(false).nodeRelSize(5).nodeOpacity(0.92).linkOpacity(0.45) g3.graph.backgroundColor("rgba(0,0,0,0)").showNavInfo(false).nodeRelSize(5).nodeOpacity(0.92).linkOpacity(0.45)
.nodeLabel((node) => h("span", {}, node.label)).nodeVal((node) => ({operator: 6, chair: 4, repo: 6, branch: 3, session: 2.5}[node.kind] || 1)) .nodeLabel((node) => h("span", {}, node.label)).nodeVal((node) => ({operator: 6, chair: 4, repo: 6, branch: 3, session: 2.5}[node.kind] || 1) * (node.id === g3.picked ? 4 : 1))
.nodeColor((node) => g3.colors[KIND_COLORS[node.kind]] || colors.text).linkColor((link) => g3.colors[LINK_COLORS[link.kind]] || colors.text) .nodeColor((node) => (node.id === g3.picked ? g3.colors.alarm : g3.colors[KIND_COLORS[node.kind]] || colors.text)).linkColor((link) => g3.colors[LINK_COLORS[link.kind]] || colors.text)
.linkWidth((link) => (link.kind === "contains" || link.kind === "spawn" ? 0.6 : Math.min(0.8 + Math.log2(1 + (link.count || 1)), 4))) .linkWidth((link) => (link.kind === "contains" || link.kind === "spawn" ? 0.6 : Math.min(0.8 + Math.log2(1 + (link.count || 1)), 4)))
.onNodeClick((node) => pick3d(g3.tab, node)).onEngineStop(() => g3.graph && g3.graph.zoomToFit(calm.matches ? 0 : 400, 40)); .onNodeClick((node) => pick3d(g3.tab, node)).onEngineStop(() => g3.graph && g3.graph.zoomToFit(calm.matches ? 0 : 400, 40));
g3.graph.warmupTicks(calm.matches ? 160 : 80); g3.graph.warmupTicks(calm.matches ? 160 : 80);
@@ -219,7 +238,7 @@
g3.graph.width(box.clientWidth).height(box.clientHeight).graphData(data); g3.graph.width(box.clientWidth).height(box.clientHeight).graphData(data);
drawPlanes(layout, data.nodes); drawPlanes(layout, data.nodes);
const words = `${data.nodes.length} nodes · ${data.links.length} edges · ${LAYOUTS.find(([id]) => id === layout)[1]}${calm.matches ? " · without motion (prefers-reduced-motion)" : ""}`; const words = `${data.nodes.length} nodes · ${data.links.length} edges · ${LAYOUTS.find(([id]) => id === layout)[1]}${calm.matches ? " · without motion (prefers-reduced-motion)" : ""}`;
host.replaceChildren(box, h("div", {class: "space-note"}, words), list3d(tab, data)); host.replaceChildren(box, h("div", {class: "space-note"}, words));
window.requestAnimationFrame(() => g3.graph && g3.host === host && g3.graph.zoomToFit(0, 40)); window.requestAnimationFrame(() => g3.graph && g3.host === host && g3.graph.zoomToFit(0, 40));
} }
+1 -1
View File
@@ -55,7 +55,7 @@
const active = claim.beat !== null && claim.beat >= state.now - ACTIVE_BEAT; const active = claim.beat !== null && claim.beat >= state.now - ACTIVE_BEAT;
return h("div", {class: "org-row", "data-office": claim.office}, return h("div", {class: "org-row", "data-office": claim.office},
h("span", {class: "org-office", title: `${title} ${claim.office}`}, icon(OFFICE_ICONS[claim.kind]), h("strong", {}, label)), h("span", {class: "org-office", title: `${title} ${claim.office}`}, icon(OFFICE_ICONS[claim.kind]), h("strong", {}, label)),
who ? h("button", {type: "button", class: "linkish org-holder", "data-focus": `agent:${claim.office}`, title: `Open ${who}`, on: {click: () => openAgent(who)}}, face(who, false), who) : h("span", {class: "muted"}, "–"), who ? h("button", {type: "button", class: "linkish org-holder", "data-focus": `agent:${claim.office}`, title: `Open ${who}`, on: {click: () => openAgent(who, near(`#main [data-focus="agent:${CSS.escape(claim.office)}"]`))}}, face(who, false), who) : h("span", {class: "muted"}, "–"),
active ? chip("bi-play-circle", "active", "ok") : chip("bi-pause-circle", "no heartbeat", "neutral"), active ? chip("bi-play-circle", "active", "ok") : chip("bi-pause-circle", "no heartbeat", "neutral"),
claim.work && claim.work !== "-" ? h("span", {class: "muted org-work", title: claim.work}, short(claim.work)) : null, claim.work && claim.work !== "-" ? h("span", {class: "muted org-work", title: claim.work}, short(claim.work)) : null,
h("span", {class: "org-actions"}, h("span", {class: "org-actions"},
+2 -2
View File
@@ -38,8 +38,8 @@
closeSheet(); closeSheet();
} else if (space.look) { } else if (space.look) {
skyLook(false); skyLook(false);
} else if (ui.agent !== null) { } else if (popupDialog().open) {
closeAgent(); closePopup();
} else { } else {
closeMenus(); closeMenus();
} }
+1 -1
View File
@@ -16,7 +16,7 @@
announce(); announce();
render(false); render(false);
drawTime(); drawTime();
drawDrawer(); drawAgent();
drawStream(); drawStream();
if (motion.current.size === 0 || ui.asOf !== motion.asOf) { if (motion.current.size === 0 || ui.asOf !== motion.asOf) {
const first = motion.current.size === 0; const first = motion.current.size === 0;
+79
View File
@@ -0,0 +1,79 @@
const pop = {anchor: null, marked: null, closed: null};
const HITS = 8;
const popupDialog = () => document.getElementById("popup");
const popupBox = () => popupDialog().querySelector(".popup-box");
const near = (selector) => () => document.querySelector(selector);
// Trip-wire: artifact ids hold NUL, which CSS.escape turns into U+FFFD, so an attribute selector never matches them.
const pickOf = (id) => () => [...document.querySelectorAll("#main [data-pick]")].find((node) => node.dataset.pick === id) || null;
const popupHead = (lead, title, ...sub) => h("div", {class: "popup-head"}, lead, h("div", {}, h("h2", {class: "panel-title"}, title), sub), h("span", {class: "spacer"}), iconBtn("bi-x-lg", "Close", "btn small ghost", closePopup));
function openPopup(label, body, anchor, closed) {
closePopup();
pop.anchor = anchor || null;
pop.closed = closed || null;
popupDialog().setAttribute("aria-label", label);
popupBox().replaceChildren(...body);
popupDialog().show();
placePopup();
}
function closePopup() {
if (!popupDialog().open) {
return;
}
popupDialog().close();
markPick(null);
const closed = pop.closed;
pop.anchor = null;
pop.closed = null;
if (closed) {
closed();
}
}
function markPick(target) {
if (pop.marked && pop.marked !== target) {
pop.marked.classList.remove("picked");
}
pop.marked = target;
if (target) {
target.classList.add("picked");
}
}
function placePopup() {
const dialog = popupDialog();
if (!dialog.open) {
return;
}
const found = pop.anchor ? pop.anchor() : null;
const target = found instanceof Element && found.isConnected ? found : null;
markPick(target);
const shape = target ? target.querySelector("circle") || target : null;
const box = popupBox();
box.style.maxHeight = "";
const at = Popup.place(shape ? shape.getBoundingClientRect() : found instanceof Element ? null : found, {width: box.offsetWidth, height: box.offsetHeight}, {width: dialog.clientWidth, height: dialog.clientHeight});
box.style.left = `${at.left}px`;
box.style.top = `${at.top}px`;
box.style.maxHeight = at.height === null ? "" : `${at.height}px`;
const line = dialog.querySelector(".leader line");
line.setAttribute("display", at.line ? "inline" : "none");
for (const [key, value] of Object.entries(at.line || {})) {
line.setAttribute(key, value);
}
}
function nodeSearch(nodes, pick) {
const list = h("div", {class: "chips hits", role: "group", "aria-label": "Search hits"});
const field = h("input", {type: "search", class: "field", placeholder: "Find a node …", "aria-label": "Find a node", on: {
input: () => list.replaceChildren(...Popup.hits(nodes(), field.value, HITS).map((node) => h("button", {type: "button", class: "chip", title: node.label, "data-hit": node.id, on: {click: () => pick(node)}}, node.label))),
keydown: (event) => {
const [first] = event.key === "Enter" ? Popup.hits(nodes(), field.value, 1) : [];
if (first) {
event.preventDefault();
pick(first);
}
},
}});
return h("div", {class: "node-search"}, field, list);
}
+1 -4
View File
@@ -52,9 +52,6 @@
} }
function render(force) { function render(force) {
if (ui.node !== null && !state.graph.nodes.some((node) => node.id === ui.node)) {
ui.node = null;
}
for (const [id, {button}] of navButtons) { for (const [id, {button}] of navButtons) {
button.setAttribute("aria-current", ui.screen === id ? "page" : "false"); button.setAttribute("aria-current", ui.screen === id ? "page" : "false");
} }
@@ -78,7 +75,6 @@
} }
} }
stage(); stage();
drawSide();
drawSpawn(); drawSpawn();
drawHierarchy(force); drawHierarchy(force);
drawToolbar(); drawToolbar();
@@ -87,6 +83,7 @@
drawTasks(); drawTasks();
drawStatus(); drawStatus();
tick(); tick();
placePopup();
} }
function drawStatus() { function drawStatus() {
+1 -6
View File
@@ -8,12 +8,7 @@
return; return;
} }
graphDom.stage.replaceChildren({orbit, chord, swimlane}[ui.views.agents]()); graphDom.stage.replaceChildren({orbit, chord, swimlane}[ui.views.agents]());
} placePopup();
function drawSide() {
if (graphDom && ui.screen === "agents" && ui.views.agents !== "tree" && ui.views.agents !== "hierarchy") {
graphDom.aside.replaceChildren(...side());
}
} }
function seenView(kind) { function seenView(kind) {
+1 -1
View File
@@ -90,7 +90,7 @@
function spawnItem(entry) { function spawnItem(entry) {
const running = entry.running === null ? null : entry.running ? chip("bi-play-circle", "running", "ok") : chip("bi-check-circle", "finished", "neutral"); const running = entry.running === null ? null : entry.running ? chip("bi-play-circle", "running", "ok") : chip("bi-check-circle", "finished", "neutral");
const open = entry.kind === "twig" ? () => focusSub(entry.session, entry.row.id) : entry.kind === "operator" ? null : () => select(entry.id); const open = entry.kind === "twig" ? () => focusSub(entry.session, entry.row.id) : entry.kind === "operator" ? null : () => select(entry.id);
const name = open ? h("button", {type: "button", class: "linkish", title: entry.kind === "twig" ? "Show transcript in the stream" : `Open ${entry.label}`, on: {click: open}}, entry.label) : h("strong", {}, entry.label); const name = open ? h("button", {type: "button", class: "linkish", title: entry.kind === "twig" ? "Show transcript in the stream" : `Open ${entry.label}`, "data-pick": entry.kind === "twig" ? null : entry.id, on: {click: open}}, entry.label) : h("strong", {}, entry.label);
const meta = entry.kind === "twig" ? h("span", {class: "muted"}, `Depth ${entry.row.depth} · `, when(entry.row.started)) : h("span", {class: "muted"}, NODE_KINDS[entry.kind]); const meta = entry.kind === "twig" ? h("span", {class: "muted"}, `Depth ${entry.row.depth} · `, when(entry.row.started)) : h("span", {class: "muted"}, NODE_KINDS[entry.kind]);
return h("li", {"data-kind": entry.kind, "data-running": entry.running === null ? null : String(entry.running)}, return h("li", {"data-kind": entry.kind, "data-running": entry.running === null ? null : String(entry.running)},
h("div", {class: "spawn-row"}, h("span", {class: "state", role: "img", ...named(NODE_KINDS[entry.kind] || "Subagent")}, icon(SPAWN_KINDS[entry.kind])), name, running, meta), h("div", {class: "spawn-row"}, h("span", {class: "state", role: "img", ...named(NODE_KINDS[entry.kind] || "Subagent")}, icon(SPAWN_KINDS[entry.kind])), name, running, meta),
+2 -2
View File
@@ -27,7 +27,7 @@
let state = JSON.parse(document.getElementById("state").textContent); let state = JSON.parse(document.getElementById("state").textContent);
const KINDS = Object.entries(state.kinds); const KINDS = Object.entries(state.kinds);
const ui = {screen: "stream", views: {agents: "orbit", artifacts: "tree", tasks: "list"}, preset: DAY, from: null, to: null, asOf: null, playing: false, busy: false, offline: false, agent: null, order: null, draft: "", node: null, cursor: 0, selected: new Set(), snoozed: false, review: null, file: 0}; const ui = {screen: "stream", views: {agents: "orbit", artifacts: "tree", tasks: "list"}, preset: DAY, from: null, to: null, asOf: null, playing: false, busy: false, offline: false, agent: null, order: null, draft: "", cursor: 0, selected: new Set(), snoozed: false, review: null, file: 0};
const filter = {view: "fuer-dich", saved: "", kinds: new Set(state.views["fuer-dich"].kinds), waiting: true, q: "", agent: "", session: "", sub: "", parts: new Set(PARTS.map(([id]) => id))}; const filter = {view: "fuer-dich", saved: "", kinds: new Set(state.views["fuer-dich"].kinds), waiting: true, q: "", agent: "", session: "", sub: "", parts: new Set(PARTS.map(([id]) => id))};
const stream = {key: null, events: [], entries: [], last: 0, sessions: {}, busy: false, again: false, dom: null, error: ""}; const stream = {key: null, events: [], entries: [], last: 0, sessions: {}, busy: false, again: false, dom: null, error: ""};
const tree = {data: null, model: null, busy: false, q: "", open: new Set(), dom: null}; const tree = {data: null, model: null, busy: false, q: "", open: new Set(), dom: null};
@@ -48,7 +48,7 @@
let userViews = validViews(stored(STORE.views, stored("kvb-hub.views", []))); let userViews = validViews(stored(STORE.views, stored("kvb-hub.views", [])));
let settings = state.settings; let settings = state.settings;
let settingsSig = JSON.stringify(settings); let settingsSig = JSON.stringify(settings);
let drawerDom = null; let agentDom = null;
let timeDom = null; let timeDom = null;
let graphDom = null; let graphDom = null;
let reviewDom = null; let reviewDom = null;
+1 -1
View File
@@ -58,7 +58,7 @@
filter.waiting = false; filter.waiting = false;
filter.session = session; filter.session = session;
filter.sub = sub; filter.sub = sub;
closeAgent(); closePopup();
if (ui.screen === "stream" && stream.dom) { if (ui.screen === "stream" && stream.dom) {
refilter(); refilter();
} else { } else {
+79
View File
@@ -0,0 +1,79 @@
const Popup = (() => {
"use strict";
const GAP = 16;
const REACH = 40;
const MIN = 160;
const clamp = (value, low, high) => Math.max(low, Math.min(value, high));
const fixed = (value) => Math.round(value * 10) / 10;
function leader(anchor, box) {
const cx = anchor.left + anchor.width / 2;
const cy = anchor.top + anchor.height / 2;
const x2 = clamp(cx, box.left, box.left + box.width);
const y2 = clamp(cy, box.top, box.top + box.height);
const dx = x2 - cx;
const dy = y2 - cy;
const share = Math.min(dx ? anchor.width / 2 / Math.abs(dx) : Infinity, dy ? anchor.height / 2 / Math.abs(dy) : Infinity);
return share >= 1 ? null : {x1: fixed(cx + dx * share), y1: fixed(cy + dy * share), x2: fixed(x2), y2: fixed(y2)};
}
function vertical(anchor, size, view, left) {
const below = view.height - GAP - (anchor.top + anchor.height + REACH);
const above = anchor.top - REACH - GAP;
const down = below >= size.height || (above < size.height && below >= above);
const room = down ? below : above;
if (room < Math.min(size.height, MIN)) {
return {left, top: clamp(anchor.top + anchor.height + REACH, GAP, view.height - size.height - GAP), height: null};
}
const height = Math.min(size.height, room);
return {left, top: down ? anchor.top + anchor.height + REACH : anchor.top - REACH - height, height: height < size.height ? height : null};
}
/**
* @param anchor - the picked node's viewport box {left, top, width, height}, or null for a popup without a node.
* @param size - the popup's natural {width, height}.
* @param view - the viewport's {width, height}.
* @returns {left, top, height, line}: the popup's corner right or left of the node, else below or above it with
* height capping it to the room there (null: no cap), else below and clamped into the viewport; line {x1, y1, x2,
* y2} runs from the node's edge to the nearest point of the popup, null without a node or when the popup covers it.
*/
function place(anchor, size, view) {
if (!anchor) {
return {left: fixed(Math.max(GAP, (view.width - size.width) / 2)), top: fixed(Math.max(GAP, (view.height - size.height) / 2)), height: null, line: null};
}
const top = clamp(anchor.top + anchor.height / 2 - size.height / 2, GAP, view.height - size.height - GAP);
const left = clamp(anchor.left + anchor.width / 2 - size.width / 2, GAP, view.width - size.width - GAP);
const right = anchor.left + anchor.width + REACH;
const before = anchor.left - REACH - size.width;
const spot = right + size.width <= view.width - GAP ? {left: right, top, height: null} : before >= GAP ? {left: before, top, height: null} : vertical(anchor, size, view, left);
const box = {left: spot.left, top: spot.top, width: size.width, height: spot.height === null ? size.height : spot.height};
return {left: fixed(spot.left), top: fixed(spot.top), height: spot.height === null ? null : fixed(spot.height), line: leader(anchor, box)};
}
/**
* @param nodes - {id, label} records.
* @param query - the typed search text; blank finds nothing.
* @param limit - the most hits returned.
* @returns the first node of every distinct label containing the query, case-insensitive, labels starting with it
* first, otherwise in node order.
*/
function hits(nodes, query, limit) {
const words = String(query).trim().toLowerCase();
if (!words) {
return [];
}
const found = new Map();
for (const node of nodes) {
const at = String(node.label).toLowerCase().indexOf(words);
if (at >= 0 && !found.has(node.label)) {
found.set(node.label, {node, first: at === 0});
}
}
return [...found.values()].sort((a, b) => Number(b.first) - Number(a.first)).slice(0, limit).map((entry) => entry.node);
}
return {GAP, REACH, MIN, place, hits};
})();
if (typeof module === "object" && module.exports) {
module.exports = Popup;
}
+166 -18
View File
@@ -102,6 +102,8 @@ PROBE = """(async () => {@HELPERS@
flows: all("#main .flow").map((flow) => flow.dataset.flow), flows: all("#main .flow").map((flow) => flow.dataset.flow),
flowEdges: all("#main svg.chart path.flow").map((path) => path.dataset.side), flowEdges: all("#main svg.chart path.flow").map((path) => path.dataset.side),
openWork: all("#main .open-work").length, openWork: all("#main .open-work").length,
panel: all("#drawer, .drawer, #main aside, #main .panel, #main .graph-body").length,
nodeChips: all("#main .space-list, #main .chip[data-node]").length,
wide: document.documentElement.scrollWidth > window.innerWidth + 1, wide: document.documentElement.scrollWidth > window.innerWidth + 1,
tall: document.documentElement.scrollHeight > window.innerHeight, tall: document.documentElement.scrollHeight > window.innerHeight,
}; };
@@ -198,34 +200,123 @@ ACTIONS = """(async () => {@HELPERS@
await sleep(900); await sleep(900);
document.querySelector('#main button.linkish[title^="Open "]').click(); document.querySelector('#main button.linkish[title^="Open "]').click();
await sleep(500); await sleep(500);
found.drawer = all("#drawer input[type=radio]").map((input) => [input.value, input.checked]); found.popup = all("#popup input[type=radio]").map((input) => [input.value, input.checked]);
found.drawerUnlabelled = all("#drawer svg.bi").map((svg) => svg.parentElement).filter((owner) => !text(owner) && !(owner.getAttribute("title") && owner.getAttribute("aria-label"))).length; found.popupUnlabelled = all("#popup svg.bi").map((svg) => svg.parentElement).filter((owner) => !text(owner) && !(owner.getAttribute("title") && owner.getAttribute("aria-label"))).length;
found.centred = [document.getElementById("popup").open, document.querySelector("#popup .leader line").getAttribute("display")];
return found; return found;
})()""".replace("@HELPERS@", HELPERS) })()""".replace("@HELPERS@", HELPERS)
POPUPS = """
const dialog = document.getElementById("popup");
const box = dialog.querySelector(".popup-box");
const line = dialog.querySelector(".leader line");
const click = (target) => target.dispatchEvent(new MouseEvent("click", {bubbles: true, cancelable: true}));
const tint = document.createElement("i");
tint.style.color = "var(--alarm)";
document.body.append(tint);
const red = getComputedStyle(tint).color;
tint.remove();
const tall = () => document.documentElement.scrollHeight > innerHeight;
const look = (node) => ({picked: node.classList.contains("picked"), size: Math.round(node.querySelector("circle").getBoundingClientRect().width), red: getComputedStyle(node.querySelector("circle")).fill === red});
const opened = () => {
const rect = box.getBoundingClientRect();
const ends = line.getAttribute("display") === "none" ? null : ["x1", "y1", "x2", "y2"].map((key) => Number(line.getAttribute(key)));
return {
open: dialog.open, fade: [dialog.classList.contains("fade"), getComputedStyle(dialog).animationName], title: text(box.querySelector(".panel-title")), opacity: Number(getComputedStyle(box).opacity), line: ends,
onBox: Boolean(ends) && (Math.abs(ends[2] - rect.left) < 1 || Math.abs(ends[2] - rect.right) < 1 || Math.abs(ends[3] - rect.top) < 1 || Math.abs(ends[3] - rect.bottom) < 1),
inside: rect.left >= 0 && rect.top >= 0 && rect.right <= innerWidth && rect.bottom <= innerHeight,
tall: tall(), wide: document.documentElement.scrollWidth > innerWidth + 1, panel: all("#drawer, .drawer, #main aside, #main .panel, #main .graph-body").length,
};
};
const away = async (pick) => {
const outside = document.elementFromPoint(2, innerHeight - 2);
click(outside);
await sleep(250);
return {...look(pick()), open: dialog.open, outside: outside === dialog};
};
"""
POPUP_ORBIT = """(async () => {@HELPERS@@POPUPS@
const pick = () => document.querySelector('#main svg .node[data-pick="s2"]');
if (!(await until(pick, 8000))) {
return {error: text(document.getElementById("main"))};
}
const before = look(pick());
const flat = !tall();
click(pick());
await sleep(400);
const shown = {...opened(), ...look(pick()), sections: all("h3", box).map((head) => head.firstChild.textContent), composer: Boolean(box.querySelector("form textarea")), offices: text(box.querySelector(".popup-body p"))};
const gone = await away(pick);
click(pick());
await sleep(300);
const again = dialog.open;
(document.activeElement || document.body).dispatchEvent(new KeyboardEvent("keydown", {key: "Escape", bubbles: true}));
await sleep(250);
const escaped = {...look(pick()), open: dialog.open, again};
click(pick());
await sleep(500);
return {before, flat, shown, gone, escaped, last: dialog.open};
})()""".replace("@HELPERS@", HELPERS).replace("@POPUPS@", POPUPS)
POPUP_ARTIFACT = """(async () => {@HELPERS@@POPUPS@
const first = await until(() => document.querySelector('#main svg .node[data-pick^="branch:"]'), 8000);
if (!first) {
return {error: text(document.getElementById("main"))};
}
const id = first.dataset.pick;
const pick = () => all("#main svg .node[data-pick]").find((node) => node.dataset.pick === id);
const before = look(pick());
const flat = !tall();
click(pick());
await sleep(400);
const shown = {...opened(), ...look(pick()), label: first.getAttribute("aria-label").split(",")[0]};
const gone = await away(pick);
click(pick());
await sleep(400);
return {before, flat, shown, gone, last: dialog.open};
})()""".replace("@HELPERS@", HELPERS).replace("@POPUPS@", POPUPS)
PICK_3D = """(async () => {@HELPERS@ PICK_3D = """(async () => {@HELPERS@
const list = await until(() => document.querySelector("#main .space-list"), 8000); const field = await until(() => document.querySelector('#main input[aria-label="Find a node"]'), 8000);
if (!list) { const ready = await until(() => document.querySelector("#main .graph3d canvas") && !text(document.querySelector("#main .space-note")).startsWith("Loading"), 8000);
if (!field || !ready) {
return {error: text(document.querySelector("#main .graph"))}; return {error: text(document.querySelector("#main .graph"))};
} }
list.open = true; const type = async (value) => {
const chip = all("#main .space-list [data-node]").find((node) => node.dataset.node.startsWith("@PREFIX@")); field.value = value;
field.dispatchEvent(new Event("input", {bubbles: true}));
await sleep(150);
return all("#main .hits .chip").map((chip) => [chip.dataset.hit, text(chip)]);
};
const tree = await (await fetch("/api/tree", {cache: "no-store"})).json();
const found = {empty: await type(""), twins: tree.repos.flatMap((repo) => repo.branches).filter((branch) => branch.name === "main").length, twin: await type("main"), hits: await type("@QUERY@")};
found.flat = document.documentElement.scrollHeight <= innerHeight;
const chip = all("#main .hits .chip").find((node) => node.dataset.hit.startsWith("@PREFIX@"));
if (!chip) { if (!chip) {
return {error: all("#main .space-list [data-node]").map((node) => node.dataset.node)}; return {error: found};
} }
chip.click(); chip.click();
await sleep(600); await sleep(600);
return {panel: text(document.querySelector("#main aside .panel-title")), label: chip.textContent}; const dialog = document.getElementById("popup");
const line = dialog.querySelector(".leader line");
const host = () => document.querySelector("#main .graph3d");
found.opened = {
open: dialog.open, title: text(dialog.querySelector(".popup-box .panel-title")), label: text(chip), hit: chip.dataset.hit, picked: host().dataset.picked || null,
line: line.getAttribute("display") !== "none", lists: all("#main .space-list, #main .chip[data-node]").length, flat: document.documentElement.scrollHeight <= innerHeight,
};
dialog.dispatchEvent(new MouseEvent("click", {bubbles: true}));
await sleep(300);
found.closed = {open: dialog.open, picked: host().dataset.picked || null};
chip.click();
await sleep(600);
return found;
})()""".replace("@HELPERS@", HELPERS) })()""".replace("@HELPERS@", HELPERS)
CALL = """(async () => {@HELPERS@ CALL = """(async () => {@HELPERS@
document.querySelector('#main [aria-label="Categories"] button[aria-label="Radio"]').click(); document.querySelector('#main [aria-label="Categories"] button[aria-label="Radio"]').click();
const someone = await until(() => document.querySelector('#main button.linkish[title^="Open "]'), 8000); const someone = await until(() => document.querySelector('#main button.linkish[title^="Open "]'), 8000);
someone.click(); someone.click();
await sleep(500); await sleep(500);
document.querySelector('#drawer input[type=radio][value="call"]').click(); document.querySelector('#popup input[type=radio][value="call"]').click();
const field = document.querySelector("#drawer textarea"); const field = document.querySelector("#popup textarea");
field.value = "E2E call ping"; field.value = "E2E call ping";
field.dispatchEvent(new Event("input", {bubbles: true})); field.dispatchEvent(new Event("input", {bubbles: true}));
document.querySelector('#drawer button[type="submit"]').click(); document.querySelector('#popup button[type="submit"]').click();
const entry = await until(() => all("#main .entry").find((row) => row.textContent.includes("Read") && row.textContent.includes("E2E call ping")), 20000); const entry = await until(() => all("#main .entry").find((row) => row.textContent.includes("Read") && row.textContent.includes("E2E call ping")), 20000);
return {to: text(someone), receipt: entry ? text(entry) : null, sent: all("#main .entry").filter((row) => row.textContent.includes("E2E call ping")).length}; return {to: text(someone), receipt: entry ? text(entry) : null, sent: all("#main .entry").filter((row) => row.textContent.includes("E2E call ping")).length};
})()""".replace("@HELPERS@", HELPERS) })()""".replace("@HELPERS@", HELPERS)
@@ -787,6 +878,64 @@ class TestOrchestratorE2E(HubCase):
self.assertEqual([entry for entry in click["log"] if entry.get("csp") == "media-src"], []) self.assertEqual([entry for entry in click["log"] if entry.get("csp") == "media-src"], [])
return data return data
def popped(self, result: dict, title: str) -> None:
before, shown, gone = result["before"], result["shown"], result["gone"]
self.assertEqual((before["picked"], before["red"]), (False, False))
self.assertEqual((shown["open"], shown["picked"], shown["red"], shown["fade"], shown["title"]), (True, True, True, [True, "fade"], title), shown)
self.assertGreater(shown["size"], before["size"] * 1.4)
self.assertLess(shown["opacity"], 1)
self.assertIsNotNone(shown["line"], shown)
self.assertTrue(shown["onBox"] and shown["inside"], shown)
self.assertEqual((not shown["tall"], shown["wide"], shown["panel"]), (result["flat"], False, 0))
self.assertEqual(gone, {"picked": False, "size": before["size"], "red": False, "open": False, "outside": True})
self.assertTrue(result["last"])
def check_popups(self, browser: str, driver: str, binary: str) -> None:
steps = []
for width, height in WIDTHS:
steps.append({"width": width, "height": height, "url": f"{self.base}#agents/orbit", "wait": 1200, "probe": POPUP_ORBIT, "shot": str(OUT / f"{browser}-{width}-agents-orbit-popup.png")})
steps.append({"width": width, "height": height, "url": f"{self.base}#artifacts/orbit", "wait": 1200, "probe": POPUP_ARTIFACT, "shot": str(OUT / f"{browser}-{width}-artifacts-orbit-popup.png")})
for tab, query, prefix in (("artifacts", "feature", "branch:"), ("agents", "sta", "s2")):
steps.append({"width": 1366, "height": 768, "url": f"{self.base}#{tab}/3d", "wait": 900, "probe": PICK_3D.replace("@QUERY@", query).replace("@PREFIX@", prefix), "shot": str(OUT / f"{browser}-1366-{tab}-3d-picked.png")})
data = self.drive(steps, None, driver, binary)
(OUT / f"{browser}-popups.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
self.assertEqual([error for error in data["errors"] if "(style-src-elem)" not in error and "/favicon.ico" not in error], [])
results = data["results"]
for index, (width, _) in enumerate(WIDTHS):
agent, artifact = results[2 * index : 2 * index + 2]
with self.subTest(browser=browser, width=width):
self.assertNotIn("error", agent)
self.assertNotIn("error", artifact)
self.assertTrue(agent["flat"], "#agents/orbit scrolls")
self.popped(agent, "Stage")
self.assertEqual(agent["shown"]["sections"], ["Tasks", "Claims", "Subagents", "History"])
self.assertTrue(agent["shown"]["composer"])
self.assertRegex(agent["shown"]["offices"], r"^Offices: .*identity/oidc")
escaped = agent["escaped"]
self.assertEqual((escaped["again"], escaped["open"], escaped["picked"], escaped["red"], escaped["size"]), (True, False, False, False, agent["before"]["size"]))
self.popped(artifact, artifact["shown"]["label"])
for found, tab in zip(results[-2:], ("artifacts", "agents"), strict=True):
with self.subTest(browser=browser, tab=tab):
self.assertNotIn("error", found)
self.assertEqual(found["empty"], [])
labels = [label for _, label in found["hits"]]
self.assertEqual(len(labels), len(set(labels)), labels)
self.assertLessEqual(len(labels), 8)
opened = found["opened"]
self.assertEqual((opened["open"], opened["title"], opened["picked"], opened["line"], opened["lists"], opened["flat"]), (True, opened["label"], opened["hit"], True, 0, found["flat"]), opened)
self.assertEqual(found["closed"], {"open": False, "picked": None})
artifacts, agents = results[-2:]
self.assertEqual((artifacts["twins"], [label for _, label in artifacts["twin"]]), (2, ["main"]))
self.assertTrue(artifacts["opened"]["hit"].startswith("branch:"), artifacts["opened"])
self.assertEqual((agents["hits"], agents["opened"]["title"]), ([["s2", "Stage"]], "Stage"))
def test_chromium_opens_agent_and_node_popups_and_finds_nodes_at_three_widths(self):
self.check_popups("chromium", "cdp.mjs", os.environ.get("CHROMIUM", "chromium"))
@unittest.skipUnless(shutil.which(os.environ.get("FIREFOX", "firefox")), "no Firefox")
def test_firefox_opens_agent_and_node_popups_and_finds_nodes_at_three_widths(self):
self.check_popups("firefox", "bidi.mjs", os.environ.get("FIREFOX", "firefox"))
def test_chromium_form_controls_never_overlap(self): def test_chromium_form_controls_never_overlap(self):
self.check_overlaps("chromium", "cdp.mjs", os.environ.get("CHROMIUM", "chromium")) self.check_overlaps("chromium", "cdp.mjs", os.environ.get("CHROMIUM", "chromium"))
@@ -812,8 +961,6 @@ class TestOrchestratorE2E(HubCase):
steps = [{"width": width, "height": height, "url": f"{self.base}#{screen}", "wait": 900, "probe": PROBE, "shot": self.shot(f"{width}-{screen.replace('/', '-')}")} for width, height in WIDTHS for screen in SCREENS] steps = [{"width": width, "height": height, "url": f"{self.base}#{screen}", "wait": 900, "probe": PROBE, "shot": self.shot(f"{width}-{screen.replace('/', '-')}")} for width, height in WIDTHS for screen in SCREENS]
steps += [{"width": 1366, "height": 768, "url": f"{self.base}#{old}", "wait": 600, "probe": PROBE, "shot": None} for old in ("graph", "graph/chord", "tree")] steps += [{"width": 1366, "height": 768, "url": f"{self.base}#{old}", "wait": 600, "probe": PROBE, "shot": None} for old in ("graph", "graph/chord", "tree")]
steps.append({"width": 1366, "height": 768, "url": f"{self.base}#stream", "wait": 900, "probe": ACTIONS, "shot": None}) steps.append({"width": 1366, "height": 768, "url": f"{self.base}#stream", "wait": 900, "probe": ACTIONS, "shot": None})
steps.append({"width": 1366, "height": 768, "url": f"{self.base}#artifacts/3d", "wait": 900, "probe": PICK_3D.replace("@PREFIX@", "branch:"), "shot": self.shot("1366-artifacts-3d-picked")})
steps.append({"width": 1366, "height": 768, "url": f"{self.base}#agents/3d", "wait": 900, "probe": PICK_3D.replace("@PREFIX@", "s2"), "shot": self.shot("1366-agents-3d-picked")})
steps.append({"width": 1366, "height": 768, "url": f"{self.base}#artifacts/3d", "wait": 900, "probe": PROBE, "shot": self.shot("1366-artifacts-3d-reduced-motion"), "media": [{"name": "prefers-reduced-motion", "value": "reduce"}]}) steps.append({"width": 1366, "height": 768, "url": f"{self.base}#artifacts/3d", "wait": 900, "probe": PROBE, "shot": self.shot("1366-artifacts-3d-reduced-motion"), "media": [{"name": "prefers-reduced-motion", "value": "reduce"}]})
steps.append({"width": 1366, "height": 768, "url": f"{self.base}#tasks/list", "wait": 900, "probe": INBOX_CHIP, "shot": self.shot("1366-tasks-grant")}) steps.append({"width": 1366, "height": 768, "url": f"{self.base}#tasks/list", "wait": 900, "probe": INBOX_CHIP, "shot": self.shot("1366-tasks-grant")})
data = self.drive(steps, None) data = self.drive(steps, None)
@@ -833,6 +980,7 @@ class TestOrchestratorE2E(HubCase):
self.assertGreater(probe["icons"], 8) self.assertGreater(probe["icons"], 8)
self.assertEqual(labelled(probe), {"unlabelled": [], "broken": []}) self.assertEqual(labelled(probe), {"unlabelled": [], "broken": []})
self.assertFalse(probe["wide"], "the page scrolls sideways") self.assertFalse(probe["wide"], "the page scrolls sideways")
self.assertEqual((probe["panel"], probe["nodeChips"]), (0, 0))
self.assertEqual([probe["tall"] for probe in probes if probe["hash"] == "#agents/orbit"], [False] * len(WIDTHS)) self.assertEqual([probe["tall"] for probe in probes if probe["hash"] == "#agents/orbit"], [False] * len(WIDTHS))
by_hash = {probe["hash"]: probe for probe in probes} by_hash = {probe["hash"]: probe for probe in probes}
stream = by_hash["#stream"] stream = by_hash["#stream"]
@@ -884,16 +1032,15 @@ class TestOrchestratorE2E(HubCase):
self.assertNotIn("error", actions) self.assertNotIn("error", actions)
self.assertEqual(actions["keys"], ["#agents/chord", "#artifacts/tree", "#tasks/rasci", "#stream"]) self.assertEqual(actions["keys"], ["#agents/chord", "#artifacts/tree", "#tasks/rasci", "#stream"])
self.assertEqual(actions["ambience"], ["sky-look", "music-toggle", "leitstand", "notify"]) self.assertEqual(actions["ambience"], ["sky-look", "music-toggle", "leitstand", "notify"])
self.assertEqual((actions["settingsUnlabelled"], actions["drawerUnlabelled"]), (0, 0)) self.assertEqual((actions["settingsUnlabelled"], actions["popupUnlabelled"]), (0, 0))
self.assertEqual([name for name, _, _ in actions["speech"]], ["Post", "Call", "Newsletter", "Broadcast", "MAYDAY", "PAN-PAN", "Sécurité"]) self.assertEqual([name for name, _, _ in actions["speech"]], ["Post", "Call", "Newsletter", "Broadcast", "MAYDAY", "PAN-PAN", "Sécurité"])
self.assertEqual([entry[1:] for entry in actions["speech"] if entry[0] == "MAYDAY"], [[True, True]]) self.assertEqual([entry[1:] for entry in actions["speech"] if entry[0] == "MAYDAY"], [[True, True]])
for tool in ("Radio to all …", "Cancel mayday", "Priority …", "Evening", "Firewall …", "Settings …", "Look at the sky", "Toggle control room", "Enable notifications"): for tool in ("Radio to all …", "Cancel mayday", "Priority …", "Evening", "Firewall …", "Settings …", "Look at the sky", "Toggle control room", "Enable notifications"):
with self.subTest(tool=tool): with self.subTest(tool=tool):
self.assertTrue(any(tool in entry for entry in actions["palette"]), actions["palette"]) self.assertTrue(any(tool in entry for entry in actions["palette"]), actions["palette"])
self.assertEqual(actions["drawer"], [["post", True], ["call", False]]) self.assertEqual(actions["popup"], [["post", True], ["call", False]])
artifact_pick, agent_pick, calm, chips = extra[4:] self.assertEqual(actions["centred"], [True, "none"])
self.assertEqual(artifact_pick["panel"], artifact_pick["label"]) calm, chips = extra[4:]
self.assertEqual(agent_pick["panel"], agent_pick["label"])
self.assertRegex(calm["note"], r"Force · without motion \(prefers-reduced-motion\)$") self.assertRegex(calm["note"], r"Force · without motion \(prefers-reduced-motion\)$")
self.assertEqual({key: value for key, value in chips["inbox"].items() if key != "counted"}, {"hash": "#stream/inbox", "items": chips["inbox"]["counted"], "pressed": "true", "current": ["Inbox"]}) self.assertEqual({key: value for key, value in chips["inbox"].items() if key != "counted"}, {"hash": "#stream/inbox", "items": chips["inbox"]["counted"], "pressed": "true", "current": ["Inbox"]})
self.assertGreaterEqual(chips["inbox"]["items"], 1) self.assertGreaterEqual(chips["inbox"]["items"], 1)
@@ -1205,6 +1352,7 @@ class TestOrchestratorE2E(HubCase):
self.assertEqual((probe["tabs"], probe["subs"]), (TABS, SUBS)) self.assertEqual((probe["tabs"], probe["subs"]), (TABS, SUBS))
self.assertEqual(labelled(probe), {"unlabelled": [], "broken": []}) self.assertEqual(labelled(probe), {"unlabelled": [], "broken": []})
self.assertFalse(probe["wide"], "the page scrolls sideways") self.assertFalse(probe["wide"], "the page scrolls sideways")
self.assertEqual((probe["panel"], probe["nodeChips"]), (0, 0))
self.assertTrue(shot.is_file() and shot.stat().st_size > 1000, shot) self.assertTrue(shot.is_file() and shot.stat().st_size > 1000, shot)
if screen.endswith("/3d"): if screen.endswith("/3d"):
self.assertTrue(probe["canvas"], probe["note"]) self.assertTrue(probe["canvas"], probe["note"])
+1
View File
@@ -76,6 +76,7 @@ def offline_redirects() -> Redirects:
VOICE = HUB / "templates" / "voice.js" VOICE = HUB / "templates" / "voice.js"
SPACE = HUB / "templates" / "space.js" SPACE = HUB / "templates" / "space.js"
NAV = HUB / "templates" / "nav.js" NAV = HUB / "templates" / "nav.js"
POPUP = HUB / "templates" / "popup.js"
class FakeScripts: class FakeScripts:
+11 -4
View File
@@ -6,11 +6,12 @@ import re
import subprocess import subprocess
import time import time
import unittest import unittest
from collections import Counter
from pathlib import Path from pathlib import Path
from unittest import mock from unittest import mock
from hub_case import HubCase from hub_case import HubCase
from hub_support import AMBIENCE, NAV, SPACE, VOICE from hub_support import AMBIENCE, NAV, POPUP, SPACE, VOICE
from kvb_orchestrator import exchange from kvb_orchestrator import exchange
@@ -62,9 +63,9 @@ class TestHubPage(HubCase):
self.assertEqual((state["reviews"][0]["title"], state["reviews"][0]["notes"]), ("<script>alert(1)</script>", "</pre><script>alert(2)</script>")) self.assertEqual((state["reviews"][0]["title"], state["reviews"][0]["notes"]), ("<script>alert(1)</script>", "</pre><script>alert(2)</script>"))
self.assertEqual((state["register"][0]["title"], state["radio"][0]["from"]), ("<script>alert(3)</script>", "<img src=x>")) self.assertEqual((state["register"][0]["title"], state["radio"][0]["from"]), ("<script>alert(3)</script>", "<img src=x>"))
def test_the_index_scripts_parse_and_embed_the_ambience_voice_nav_and_space_modules(self): def test_the_index_scripts_parse_and_embed_the_ambience_voice_nav_popup_and_space_modules(self):
html = self.index() html = self.index()
for module in (AMBIENCE, VOICE, NAV, SPACE): for module in (AMBIENCE, VOICE, NAV, POPUP, SPACE):
with self.subTest(module=module.name): with self.subTest(module=module.name):
self.assertIn(module.read_text(encoding="utf-8").strip(), html) self.assertIn(module.read_text(encoding="utf-8").strip(), html)
scripts = re.findall(r'<script nonce="[^"]+">(.*?)</script>', html, re.DOTALL) scripts = re.findall(r'<script nonce="[^"]+">(.*?)</script>', html, re.DOTALL)
@@ -73,11 +74,17 @@ class TestHubPage(HubCase):
page.write_text("\n;\n".join(scripts), encoding="utf-8") page.write_text("\n;\n".join(scripts), encoding="utf-8")
done = subprocess.run(["node", "--check", str(page)], capture_output=True, text=True, timeout=60, check=False) done = subprocess.run(["node", "--check", str(page)], capture_output=True, text=True, timeout=60, check=False)
self.assertEqual(done.returncode, 0, done.stderr) self.assertEqual(done.returncode, 0, done.stderr)
for module in (AMBIENCE, VOICE, NAV, SPACE): for module in (AMBIENCE, VOICE, NAV, POPUP, SPACE):
with self.subTest(module=module.name): with self.subTest(module=module.name):
done = subprocess.run(["node", "--check", str(module)], capture_output=True, text=True, timeout=60, check=False) done = subprocess.run(["node", "--check", str(module)], capture_output=True, text=True, timeout=60, check=False)
self.assertEqual(done.returncode, 0, done.stderr) self.assertEqual(done.returncode, 0, done.stderr)
def test_no_two_page_modules_declare_the_same_function(self):
page = re.findall(r'<script nonce="[^"]+">(.*?)</script>', self.index(), re.DOTALL)[-1]
names = Counter(re.findall(r"^ (?:async )?function (\w+)\(", page, re.MULTILINE))
self.assertGreater(len(names), 100)
self.assertEqual([name for name, count in names.items() if count > 1], [])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+116
View File
@@ -0,0 +1,116 @@
from __future__ import annotations
import json
import subprocess
import unittest
from hub_support import POPUP
POPUP_PROBE = """
const P = require(process.argv[1]);
let seed = 11;
const next = () => {
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
return seed / 4294967296;
};
const node = (left, top) => ({left, top, width: 20, height: 20});
const wide = {width: 1366, height: 768};
const phone = {width: 390, height: 844};
const random = Array.from({length: 600}, () => {
const view = {width: 300 + Math.round(next() * 1700), height: 300 + Math.round(next() * 900)};
const size = {width: Math.min(440, view.width - 2 * P.GAP), height: Math.round(80 + next() * (view.height - 2 * P.GAP - 80))};
const anchor = {left: Math.round(next() * view.width) - 10, top: Math.round(next() * view.height) - 10, width: 20, height: 20};
return {view, size, anchor, at: P.place(anchor, size, view)};
});
const nodes = [
{id: "s1", label: "Skills session"}, {id: "s2", label: "Stage"}, {id: "agent:Stage@/a", label: "Stage"}, {id: "agent:Stage@/b", label: "Stage"},
{id: "b1", label: "feature/stage-fix"}, {id: "b2", label: "main"}, {id: "b3", label: "main"}, {id: "x", label: "Backstage"},
];
const ids = (list) => list.map((entry) => entry.id);
console.log(JSON.stringify({
constants: [P.GAP, P.REACH, P.MIN],
right: P.place(node(100, 300), {width: 440, height: 300}, wide),
left: P.place(node(1200, 300), {width: 440, height: 300}, wide),
below: P.place(node(185, 100), {width: 358, height: 300}, phone),
above: P.place(node(185, 700), {width: 358, height: 300}, phone),
capped: P.place(node(185, 400), {width: 358, height: 700}, phone),
covered: P.place(node(185, 180), {width: 358, height: 368}, {width: 390, height: 400}),
centre: P.place(null, {width: 440, height: 300}, wide),
random,
hits: {
stage: ids(P.hits(nodes, "sta", 8)),
main: ids(P.hits(nodes, " MAIN ", 8)),
blank: P.hits(nodes, " ", 8),
limit: ids(P.hits(nodes, "a", 2)),
none: P.hits(nodes, "zzz", 8),
same: P.hits(nodes, "stage", 8)[0] === nodes[1],
},
}));
"""
SLACK = 0.06
class TestPopupModule(unittest.TestCase):
@classmethod
def setUpClass(cls):
done = subprocess.run(["node", "-e", POPUP_PROBE, str(POPUP)], capture_output=True, text=True, timeout=60, check=True)
cls.data = json.loads(done.stdout)
cls.gap, cls.reach, cls.min = cls.data["constants"]
def test_the_popup_sits_right_of_the_node_and_the_leader_spans_the_gap_between_them(self):
self.assertEqual(self.data["right"], {"left": 160, "top": 160, "height": None, "line": {"x1": 120, "y1": 310, "x2": 160, "y2": 310}})
self.assertEqual(self.data["right"]["line"]["x2"] - self.data["right"]["line"]["x1"], self.reach)
def test_a_node_near_the_right_edge_gets_the_popup_on_its_left(self):
self.assertEqual(self.data["left"], {"left": 720, "top": 160, "height": None, "line": {"x1": 1200, "y1": 310, "x2": 1160, "y2": 310}})
def test_on_a_phone_the_popup_goes_below_or_above_the_node(self):
self.assertEqual(self.data["below"], {"left": 16, "top": 160, "height": None, "line": {"x1": 195, "y1": 120, "x2": 195, "y2": 160}})
self.assertEqual(self.data["above"], {"left": 16, "top": 360, "height": None, "line": {"x1": 195, "y1": 700, "x2": 195, "y2": 660}})
def test_a_popup_too_tall_for_either_side_is_capped_to_the_larger_room_and_keeps_its_leader(self):
self.assertEqual(self.data["capped"], {"left": 16, "top": 460, "height": 368, "line": {"x1": 195, "y1": 420, "x2": 195, "y2": 460}})
def test_without_room_the_popup_covers_the_node_and_draws_no_leader(self):
self.assertEqual(self.data["covered"], {"left": 16, "top": 16, "height": None, "line": None})
def test_a_popup_without_a_node_is_centred_without_a_leader(self):
self.assertEqual(self.data["centre"], {"left": 463, "top": 234, "height": None, "line": None})
def test_every_placement_stays_in_the_viewport_and_every_leader_runs_from_the_node_edge_to_the_popup_edge(self):
leaders = 0
for case in self.data["random"]:
view, size, anchor, at = case["view"], case["size"], case["anchor"], case["at"]
height = size["height"] if at["height"] is None else at["height"]
box = (at["left"], at["top"], at["left"] + size["width"], at["top"] + height)
with self.subTest(case=case):
self.assertGreaterEqual(min(box[0], box[1]), self.gap - SLACK)
self.assertLessEqual(box[2], view["width"] - self.gap + SLACK)
self.assertLessEqual(box[3], view["height"] - self.gap + SLACK)
if at["height"] is not None:
self.assertGreaterEqual(at["height"], self.min - SLACK)
self.assertLess(at["height"], size["height"])
rect = (anchor["left"], anchor["top"], anchor["left"] + anchor["width"], anchor["top"] + anchor["height"])
line = at["line"]
if line is None:
self.assertTrue(box[0] <= rect[2] + SLACK and rect[0] <= box[2] + SLACK and box[1] <= rect[3] + SLACK and rect[1] <= box[3] + SLACK, "no leader although the popup misses the node")
continue
leaders += 1
self.assertTrue(any(abs(line["x2"] - edge) <= SLACK for edge in (box[0], box[2])) or any(abs(line["y2"] - edge) <= SLACK for edge in (box[1], box[3])))
self.assertTrue(any(abs(line["x1"] - edge) <= SLACK for edge in (rect[0], rect[2])) or any(abs(line["y1"] - edge) <= SLACK for edge in (rect[1], rect[3])))
self.assertTrue(rect[0] - SLACK <= line["x1"] <= rect[2] + SLACK and rect[1] - SLACK <= line["y1"] <= rect[3] + SLACK)
self.assertGreater(leaders, len(self.data["random"]) * 0.9)
def test_search_hits_are_one_per_label_prefix_first_case_insensitive_and_limited(self):
hits = self.data["hits"]
self.assertEqual(hits["stage"], ["s2", "b1", "x"])
self.assertEqual(hits["main"], ["b2"])
self.assertEqual(hits["limit"], ["s2", "b1"])
self.assertTrue(hits["same"])
def test_a_blank_or_unknown_query_finds_nothing(self):
self.assertEqual((self.data["hits"]["blank"], self.data["hits"]["none"]), ([], []))
if __name__ == "__main__":
unittest.main()