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:
1 parent
d89fb616b8
commit
4d88296e47
13 files changed
+262
-34
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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 #}
|
||||
|
||||
@@ -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"};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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"});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in new issue
Block a user