diff --git a/docs/requirements/001-agent-orchestrator.md b/docs/requirements/001-agent-orchestrator.md index 0b6213a..a813792 100644 --- a/docs/requirements/001-agent-orchestrator.md +++ b/docs/requirements/001-agent-orchestrator.md @@ -594,11 +594,11 @@ flowchart TD ### 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. -- [ ] 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 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. ### Phase 6: faces (S3) diff --git a/orchestrator/templates/index.html.j2 b/orchestrator/templates/index.html.j2 index b98aad5..2d40eb7 100644 --- a/orchestrator/templates/index.html.j2 +++ b/orchestrator/templates/index.html.j2 @@ -28,7 +28,7 @@ body { min-height: 100vh; margin: 0; background: transparent; color: var(--text) button, input, select { font: inherit; color: inherit; } 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); } -.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); } @media (prefers-reduced-motion: reduce) { *, *::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 .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; } -@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-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; } @@ -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 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; } -.graph-body { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 14px; align-items: start; } .stage-card { padding: 8px; } .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 .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 .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; } -} +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 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; } .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; } .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 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 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; } @@ -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-subagent circle { fill: var(--violet-soft); stroke: var(--violet); } 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 .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); } @@ -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-info circle { fill: var(--info-soft); stroke: var(--info); } 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 .lane-mark { stroke-width: 1.2; } 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); } .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; } -.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); } -.drawer[hidden] { display: none; } -.drawer-head { display: flex; align-items: center; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--line); } -.drawer-body { overflow: auto; padding: 4px 18px 12px; } -.drawer-form { display: grid; gap: 8px; padding: 12px 18px 18px; border-top: 1px solid var(--line); } +.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); } +.fade { animation: fade .2s var(--ease); } +.leader { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; } +.leader line { stroke: var(--alarm); stroke-width: 2; stroke-linecap: round; } +.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; } .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; } @@ -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: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-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 .sky { z-index: 5; pointer-events: auto; cursor: grab; touch-action: none; } body.sky-look .sky:active { cursor: grabbing; } @@ -441,7 +438,7 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
- +
@@ -454,6 +451,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi {% include "voice.js" %} {% include "nav.js" %} + +{% include "popup.js" %} ", "")) self.assertEqual((state["register"][0]["title"], state["radio"][0]["from"]), ("", "")) - 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() - for module in (AMBIENCE, VOICE, NAV, SPACE): + for module in (AMBIENCE, VOICE, NAV, POPUP, SPACE): with self.subTest(module=module.name): self.assertIn(module.read_text(encoding="utf-8").strip(), html) scripts = re.findall(r'', html, re.DOTALL) @@ -73,11 +74,17 @@ class TestHubPage(HubCase): 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) 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): done = subprocess.run(["node", "--check", str(module)], capture_output=True, text=True, timeout=60, check=False) self.assertEqual(done.returncode, 0, done.stderr) + def test_no_two_page_modules_declare_the_same_function(self): + page = re.findall(r'', 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__": unittest.main() diff --git a/tests/test_hub_popup.py b/tests/test_hub_popup.py new file mode 100644 index 0000000..4e97340 --- /dev/null +++ b/tests/test_hub_popup.py @@ -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()