From 4d88296e47d9c9a85bfca14cc035f5fd5d06e277 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Sun, 8 Nov 2026 22:06:31 +0000 Subject: [PATCH] feat(orchestrator): S3 generated SVG faces whose mouths move while speaking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requirement 001 phase 6, S3: - faces.js derives a face from the agent's stable key (its session id, or name: without a session), so a rename keeps the face: shape 4, colour 7, eyes 5, brows 4, mouth 5, hair or headwear 6, accessory 5, 84,000 combinations. It emits constant SVG tags and attributes only. - The emoji avatar is gone: popup, Orbit and Chord nodes, the radio view, stream and palette draw the SVG face (the 3D views keep their marker). - While the radio reads an agent's message, its faces carry the class speaking and the mouth runs the talk keyframes; a silent face does not move, and prefers-reduced-motion stops the mouth. Evidence: make test 673 OK, ruff and markdownlint clean (/tmp/s3-make-test-1.log); e2e 20 OK in Chromium and Firefox, none skipped (/tmp/s3-e2e-3.log); screenshots /tmp/orchestrator-phase4-shots/*-speaking.png; MEDUSA 13 files, 2 findings ("PLA" in "templates", the UI call openAgent) accepted after an independent skeptic that found no markup sink or selector injection in the face code. ๐ŸŽฏ Fix: 94 % ๐Ÿ’ฅ Break: 3 % ๐Ÿ’ธ Cost: 100 % โฑ๏ธ Time: โ€“ ๐Ÿ›ก๏ธ Security: 100 % ๐Ÿ† Score: 91 ๐Ÿšฆ Gate: โœ… 90/5, cost and time <= 100, security 100 % Co-Authored-By: Claude Opus 5.5 --- docs/requirements/001-agent-orchestrator.md | 4 +- orchestrator/templates/faces.js | 95 +++++++++++++++++++++ orchestrator/templates/index.html.j2 | 10 +-- orchestrator/templates/page-constants.js | 1 - orchestrator/templates/page-dom.js | 15 ++-- orchestrator/templates/page-graph-layout.js | 2 +- orchestrator/templates/page-palette.js | 4 +- orchestrator/templates/page-radioview.js | 7 +- orchestrator/templates/style-ui.css | 5 +- tests/e2e/test_orchestrator_e2e.py | 50 +++++++++-- tests/hub_support.py | 1 + tests/test_hub_faces.py | 94 ++++++++++++++++++++ tests/test_hub_page.py | 8 +- 13 files changed, 262 insertions(+), 34 deletions(-) create mode 100644 orchestrator/templates/faces.js create mode 100644 tests/test_hub_faces.py diff --git a/docs/requirements/001-agent-orchestrator.md b/docs/requirements/001-agent-orchestrator.md index a813792..7aac0f5 100644 --- a/docs/requirements/001-agent-orchestrator.md +++ b/docs/requirements/001-agent-orchestrator.md @@ -603,9 +603,9 @@ flowchart TD ### Phase 6: faces (S3) -- [ ] The face generator yields at least 1000 distinct attribute +- [x] The face generator yields at least 1000 distinct attribute combinations and the same face for the same agent id (unit test). -- [ ] A speaking agent's face animates its mouth; a silent one does not +- [x] A speaking agent's face animates its mouth; a silent one does not (DOM test on the class while the radio plays). ### Phase 6: night commands (S4) diff --git a/orchestrator/templates/faces.js b/orchestrator/templates/faces.js new file mode 100644 index 0000000..0914d2e --- /dev/null +++ b/orchestrator/templates/faces.js @@ -0,0 +1,95 @@ +const Faces = (() => { + "use strict"; + const SIZE = 40; + const INK = "#2b2622"; + const dot = (cx, cy, rx, ry, fill) => ["ellipse", {cx, cy, rx, ry, fill}]; + const line = (d, width) => ["path", {d, fill: "none", stroke: INK, "stroke-width": width, "stroke-linecap": "round", "stroke-linejoin": "round"}]; + const blob = (d, fill) => ["path", {d, fill}]; + const sclera = (cx) => ["ellipse", {cx, cy: 20, rx: 3.2, ry: 3, fill: "#fff", stroke: INK, "stroke-width": 0.8}]; + const cheek = (cx) => ["ellipse", {cx, cy: 25, rx: 2.4, ry: 1.5, fill: "#f08a8a", opacity: 0.6}]; + const SHAPES = [ + ["ellipse", {cx: 20, cy: 22, rx: 13, ry: 13}], + ["ellipse", {cx: 20, cy: 23, rx: 11, ry: 14}], + ["rect", {x: 7, y: 9, width: 26, height: 26, rx: 8}], + ["ellipse", {cx: 20, cy: 22, rx: 15, ry: 12}], + ]; + const COLOURS = ["#f6d5b8", "#e3ac85", "#b97a55", "#7d4f33", "#f4cf5d", "#9fd4c7", "#c2b2f2"]; + const EYES = [ + [dot(15, 20, 1.8, 1.8, INK), dot(25, 20, 1.8, 1.8, INK)], + [dot(15, 20, 1.4, 2.5, INK), dot(25, 20, 1.4, 2.5, INK)], + [line("M12.5 21q2.5-3 5 0M22.5 21q2.5-3 5 0", 1.6)], + [line("M12.5 20.5h5M22.5 20.5h5", 1.6)], + [sclera(15), sclera(25), dot(15.6, 20.4, 1.4, 1.4, INK), dot(25.6, 20.4, 1.4, 1.4, INK)], + ]; + const BROWS = [ + [], + [line("M12 15.5h6M22 15.5h6", 1.4)], + [line("M12 16q3-2.5 6 0M22 16q3-2.5 6 0", 1.4)], + [line("M12 14.5l6 1.5M28 14.5l-6 1.5", 1.4)], + ]; + const MOUTHS = [ + blob("M14 27q6 6 12 0z", INK), + dot(20, 28, 2.2, 2, INK), + blob("M13 26q7 7 14 0q-7 2-14 0z", INK), + ["rect", {x: 15, y: 27.2, width: 10, height: 1.8, rx: 0.9, fill: INK}], + blob("M15 28q5 3 10-2q-5 4.5-10 2z", INK), + ]; + const HAIR = [ + [], + [blob("M7 19q0-12 13-12t13 12q-5-6-13-6t-13 6z", "#3b2a20")], + [blob("M9 15l2-8 3 5 3-7 3 6 3-6 3 7 3-5 2 8q-11-4-22 0z", "#d9a441")], + [blob("M8 14q12-14 24 0z", "#3f6fb5"), ["rect", {x: 17, y: 12.5, width: 19, height: 2.6, rx: 1.3, fill: "#2f568f"}]], + [blob("M8 13q12-17 24 0z", "#c0504d"), ["rect", {x: 7, y: 11.5, width: 26, height: 3, rx: 1.5, fill: "#9c3d3a"}], dot(20, 4.6, 2.6, 2.6, "#f1f1f1")], + [dot(20, 6.5, 4.5, 4, "#6b3f2a"), blob("M8 18q1-11 12-11t12 11q-4-5-12-5t-12 5z", "#6b3f2a")], + ]; + const EXTRAS = [ + [], + [["path", {d: "M11 20a4 3.2 0 1 0 8 0a4 3.2 0 1 0-8 0M21 20a4 3.2 0 1 0 8 0a4 3.2 0 1 0-8 0M19 20h2", fill: "none", stroke: INK, "stroke-width": 1.1}]], + [cheek(12.5), cheek(27.5)], + [["rect", {x: 4.5, y: 18, width: 3.5, height: 8, rx: 1.75, fill: INK}], line("M6.5 25.5q1 5 7.5 4.5", 1.3)], + [12.5, 14.5, 12, 27.5, 25.5, 28].map((cx, index) => dot(cx, 24 + (index % 3) * 1.1, 0.75, 0.75, "#8a5a3c")), + ]; + const ATTRIBUTES = {shape: SHAPES, colour: COLOURS, eyes: EYES, brows: BROWS, mouth: MOUTHS, hair: HAIR, accessory: EXTRAS}; + const COUNTS = Object.fromEntries(Object.entries(ATTRIBUTES).map(([name, list]) => [name, list.length])); + const COMBINATIONS = Object.values(COUNTS).reduce((product, count) => product * count, 1); + + function hash(text) { + let value = 2166136261; + for (const char of String(text)) { + value = Math.imul(value ^ char.codePointAt(0), 16777619); + } + value = Math.imul(value ^ (value >>> 16), 0x85ebca6b); + value = Math.imul(value ^ (value >>> 13), 0xc2b2ae35); + return (value ^ (value >>> 16)) >>> 0; + } + + /** + * @param id - the agent's stable key: its session id, or name: for an agent without a session. + * @returns {pick, parts}: pick holds the variant index of every attribute in ATTRIBUTES order; parts lists the + * [tag, attributes] SVG children of a 0 0 SIZE SIZE view box from back to front, the mouth carrying class "mouth". + */ + function of(id) { + let rest = hash(id); + const pick = {}; + for (const [name, count] of Object.entries(COUNTS)) { + pick[name] = rest % count; + rest = Math.floor(rest / count); + } + const [tag, shape] = SHAPES[pick.shape]; + const [mouth, lips] = MOUTHS[pick.mouth]; + const parts = [ + [tag, {...shape, fill: COLOURS[pick.colour], stroke: INK, "stroke-width": 0.8, "stroke-opacity": 0.4}], + ...HAIR[pick.hair], + ...EYES[pick.eyes], + ...BROWS[pick.brows], + [mouth, {...lips, class: "mouth"}], + ...EXTRAS[pick.accessory], + ]; + return {pick, parts}; + } + + return {SIZE, COUNTS, COMBINATIONS, of}; +})(); +if (typeof module === "object" && module.exports) { + module.exports = Faces; +} diff --git a/orchestrator/templates/index.html.j2 b/orchestrator/templates/index.html.j2 index 2d40eb7..4a6db0d 100644 --- a/orchestrator/templates/index.html.j2 +++ b/orchestrator/templates/index.html.j2 @@ -242,9 +242,6 @@ svg .node.k-chair circle { fill: var(--accent-soft); stroke: var(--accent); stro svg .node.k-subagent circle { fill: var(--violet-soft); stroke: var(--violet); } svg .node.claimed circle { stroke: var(--ok); stroke-width: 2.2; } 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); } -svg .k-chair .initial { fill: var(--accent); font-size: var(--fs-lg); } svg .group-arc { fill: none; stroke: var(--line-strong); stroke-width: 7; stroke-linecap: round; } svg .group-arc.k-operator, svg .group-arc.k-chair { stroke: var(--accent); } svg .tick { stroke: var(--c); stroke-linecap: round; opacity: .7; } @@ -281,8 +278,9 @@ svg .lane-mark.tone-info { fill: var(--info); stroke: var(--info); } svg .lane-mark.tone-neutral { fill: var(--muted); stroke: var(--muted); } input[type="datetime-local"] { padding: 3px 6px; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--panel); font-size: var(--fs-sm); } .custom { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; } -.avatar { display: inline-grid; place-items: center; flex: none; width: 26px; height: 26px; border-radius: 50%; background: var(--sunken); border: 1px solid var(--line); font-size: var(--fs-lg); line-height: 1; } -.avatar.big { width: 52px; height: 52px; font-size: var(--fs-3xl); } +.avatar { display: inline-grid; place-items: center; flex: none; width: 26px; height: 26px; overflow: hidden; border-radius: 50%; background: var(--sunken); border: 1px solid var(--line); } +.avatar.big { width: 52px; height: 52px; } +.avatar svg { display: block; width: 100%; height: 100%; } .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; } .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); } @@ -453,6 +451,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi {% include "nav.js" %} {% include "popup.js" %} + +{% include "faces.js" %} ", "")) self.assertEqual((state["register"][0]["title"], state["radio"][0]["from"]), ("", "")) - def test_the_index_scripts_parse_and_embed_the_ambience_voice_nav_popup_and_space_modules(self): + def test_the_index_scripts_parse_and_embed_the_ambience_voice_nav_popup_faces_and_space_modules(self): html = self.index() - for module in (AMBIENCE, VOICE, NAV, POPUP, SPACE): + for module in (AMBIENCE, VOICE, NAV, POPUP, FACES, SPACE): with self.subTest(module=module.name): self.assertIn(module.read_text(encoding="utf-8").strip(), html) scripts = re.findall(r'', html, re.DOTALL) @@ -74,7 +74,7 @@ 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, POPUP, SPACE): + for module in (AMBIENCE, VOICE, NAV, POPUP, FACES, 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)