feat(orchestrator): S3 generated SVG faces whose mouths move while speaking

Requirement 001 phase 6, S3:

- faces.js derives a face from the agent's stable key (its session id,
  or name:<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 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-08 22:06:31 +00:00
1 parent d89fb616b8
commit 4d88296e47
13 files changed
+262 -34

No files matched your search

+95
View File
@@ -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:<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;
}
+5 -5
View File
@@ -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" %}
</script>
<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 #}
-1
View File
@@ -4,7 +4,6 @@
const DAY = 86400;
const HUMAN = "Operator";
const PRESETS = [[3600, "1 h"], [21600, "6 h"], [DAY, "24 h"], [7 * DAY, "7 d"], [0, "all"], [-1, "custom"]];
const EMOJI = ["🦊", "🐙", "🦉", "🐝", "🐢", "🦄", "🐳", "🦋", "🐧", "🦁", "🐼", "🐨", "🦜", "🐬", "🦀", "🐸", "🦔", "🦩", "🐞", "🌵", "🍀", "🌻", "🍄", "🌙", "⭐", "🔥", "🌊", "⚡", "🎈", "🎲", "🧭", "🚀", "🪐", "🔭", "🧩", "🎯", "🪁", "🧪", "🔮", "🗿", "🌋", "🍉", "🍋", "🥨", "🐋", "🦚", "🐌", "🌈"];
const PARTS = [["user", "User"], ["assistant", "Assistant"], ["tool_call", "Tool call"], ["tool_result", "Tool result"], ["system", "System"]];
const PART_TONES = {user: "info", assistant: "ok", tool_call: "violet", tool_result: "teal", system: "neutral"};
const KIND_TONES = {radio: "teal", review: "info", ask: "warn", decision: "violet", operator: "bad", register: "neutral", rename: "info", cron: "teal", rule: "ok", approval: "ok", run: "info", transcript: "neutral"};
+7 -8
View File
@@ -68,14 +68,13 @@
updated.textContent = `${ui.offline ? "No connection · " : ""}${live() ? "Live" : "As of"} ${absolute.format(stampOf(live() ? state.now : state.until))}`;
}
function avatar(name) {
let hash = 2166136261;
for (const char of String(name)) {
hash = Math.imul(hash ^ char.codePointAt(0), 16777619) >>> 0;
}
return EMOJI[hash % EMOJI.length];
}
const face = (name, big) => h("span", {class: big ? "avatar big" : "avatar", "aria-hidden": "true"}, avatar(name));
const faceParts = (key) => Faces.of(key).parts.map(([tag, attrs]) => s(tag, attrs));
const talking = (key) => Boolean(speech.current && speech.current.who === key);
const face = (who, big) => {
const key = agentKey(who);
return h("span", {class: `avatar${big ? " big" : ""}${talking(key) ? " speaking" : ""}`, "data-face": key, "aria-hidden": "true"},
s("svg", {class: "face-art", viewBox: `0 0 ${Faces.SIZE} ${Faces.SIZE}`, focusable: "false"}, faceParts(key)));
};
const person = (name) => (name && name !== "-" && name !== "all" && name !== HUMAN ? h("button", {type: "button", class: "linkish", title: `Open ${name}`, on: {click: () => openAgent(name)}}, name) : dash(name));
const people = () => state.people || {};
const labelOf = (key) => (people()[key] || {name: String(key).replace(/^name:/, "")}).name;
+1 -1
View File
@@ -137,7 +137,7 @@
},
s("title", {}, `${node.label} · ${NODE_KINDS[node.kind]}${node.parent ? ` of ${labelOf(node.parent)}` : ""}${tasks}`),
s("circle", {r}),
node.kind === "subagent" ? null : s("text", {class: "initial", "text-anchor": "middle", dy: "0.35em"}, node.label.slice(0, 1).toUpperCase()),
node.kind === "subagent" ? null : s("g", {class: "face-art", transform: `translate(${-r} ${-r}) scale(${(2 * r) / Faces.SIZE})`}, faceParts(node.id)),
caption(node, at, r, gap));
}
+2 -2
View File
@@ -4,7 +4,7 @@
...["agents", "artifacts", "tasks"].flatMap((screen) => Nav.viewsOf(screen).map(([id, name]) => ({label: `${Nav.TABS.find(([tab]) => tab === screen)[1]}: ${name}`, hint: Nav.hashOf(screen, id), run: () => go(screen, id)}))),
...Object.entries(state.views).map(([id, view]) => ({label: `Stream: ${view.label}`, hint: "Category", run: () => useView(id)})),
...userViews.map((view) => ({label: `Stream: ${view.name}`, hint: "saved view", run: () => useSaved(view)})),
...agentNames().map((name) => ({label: `${avatar(name)} ${name}`, hint: "Agent", run: () => openAgent(name)})),
...agentNames().map((name) => ({label: name, hint: "Agent", face: name, run: () => openAgent(name)})),
{label: "Radio to all …", hint: "Newsletter, Broadcast, Pan-Pan, Security, Mayday", run: openToAll},
...ACTIONS.map(([id, label]) => ({label, hint: id === "clear-mayday" ? "Mayday" : "Day/Night", run: () => confirmAct(id)})),
{label: "Priority …", hint: "agent order in the hierarchy", run: openAgentOrder},
@@ -40,7 +40,7 @@
drawPalette();
}
},
}}, h("span", {}, item.label), h("span", {class: "muted"}, item.hint))));
}}, h("span", {class: item.face ? "who" : null}, item.face ? face(item.face, false) : null, item.label), h("span", {class: "muted"}, item.hint))));
const current = palette.list.children[palette.index];
if (current) {
current.scrollIntoView({block: "nearest"});
+5 -2
View File
@@ -57,18 +57,21 @@
function speakerChips() {
return Object.entries(people()).sort(([, a], [, b]) => a.now.localeCompare(b.now)).map(([key, entry]) => h("li", {class: "speaker", "data-key": key, title: entry.now},
h("span", {class: "speaker-face"}, face(entry.now, false)), h("span", {class: "speaker-name"}, entry.now)));
h("span", {class: "speaker-face"}, face(key, false)), h("span", {class: "speaker-name"}, entry.now)));
}
function drawSpeaker() {
const now = speech.current && speech.current.who ? speech.current : null;
for (const art of document.querySelectorAll(".avatar[data-face]")) {
art.classList.toggle("speaking", Boolean(now && art.dataset.face === now.who));
}
if (radioDom) {
for (const chip of radioDom.speakers.querySelectorAll(".speaker")) {
chip.classList.toggle("speaking", Boolean(now && chip.dataset.key === now.who));
}
radioDom.caption.hidden = !now;
radioDom.caption.replaceChildren(...(now ? [
h("span", {class: "speaker-face"}, face(nameOf(now.who), false)),
h("span", {class: "speaker-face"}, face(now.who, false)),
h("strong", {}, `${nameOf(now.who)} speaking`),
h("span", {class: "caption-line", title: now.line}, now.line),
iconBtn("bi-stop-fill", "Stop speaking", "btn small ghost", cancelSpeech),
+4 -1
View File
@@ -19,8 +19,11 @@
.caption-line { flex: 1; min-width: 0; overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; }
svg .node.speaking circle { stroke: var(--ok); stroke-width: 4; animation: speaking-node 1.3s ease-in-out infinite; }
@keyframes speaking-node { 50% { stroke-width: 10; stroke-opacity: .45; } }
.face-art .mouth { transform-box: fill-box; transform-origin: center; }
.speaking .face-art .mouth { animation: talk .3s ease-in-out infinite alternate; }
@keyframes talk { to { transform: scale(1.15, 2.2); } }
@media (prefers-reduced-motion: reduce) {
.speaker.speaking .speaker-face::after, .radio-caption .speaker-face::after, svg .node.speaking circle { animation: none; }
.speaker.speaking .speaker-face::after, .radio-caption .speaker-face::after, svg .node.speaking circle, .speaking .face-art .mouth { animation: none; }
}
.stations { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; }
.station { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) auto auto; align-items: center; gap: var(--sp-2); min-width: 0; padding: 3px 3px 3px 10px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--sunken); }