Merge feature/orchestrator/s53-agent-presence into master
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> # Conflicts: # docs/requirements/001-agent-orchestrator.md # orchestrator/templates/page-board.js # tests/e2e/test_orchestrator_e2e.py
This commit is contained in:
commit
afebf24703
24 files changed
+798
-13
No files matched your search
@@ -15,6 +15,7 @@
|
||||
const tasks = node ? node.tasks : [];
|
||||
const claims = node ? node.claims : [];
|
||||
agentDom.info.replaceChildren(
|
||||
...[presenceMark(ui.agent, true)].filter(Boolean),
|
||||
h("p", {class: "muted"}, claims.length ? `Offices: ${claims.map((claim) => claim.office).join(", ")}` : "No office"),
|
||||
h("h3", {}, "Tasks", counter(tasks.length)),
|
||||
tasks.length ? h("ul", {class: "mini"}, tasks.map((task) => h("li", {},
|
||||
|
||||
@@ -91,10 +91,32 @@
|
||||
const mode = h("span", {});
|
||||
const night = h("details", {class: "menu"}, h("summary", named("Day/Night"), icon("bi-moon-stars"), caret()), h("div", {class: "menu-box"}, mode,
|
||||
h("div", {class: "actions"}, ACTIONS.slice(1).map(([id]) => actionButton(id, "btn small")))));
|
||||
const bar = h("div", {class: "toolbar", role: "toolbar", "aria-label": "Controls"}, iconBtn("bi-sort-down", "Priority", "btn small", openAgentOrder, "Priority: agent order in the hierarchy"), night);
|
||||
const bar = h("div", {class: "toolbar", role: "toolbar", "aria-label": "Controls"}, iconBtn("bi-sort-down", "Priority", "btn small", openAgentOrder, "Priority: agent order in the hierarchy"), night, statusMenu());
|
||||
return {bar, mode};
|
||||
}
|
||||
|
||||
function lookBox(id, label, on, run) {
|
||||
const state = Object.hasOwn(PRESENCE, id) ? {class: "check presence", "data-state": id, "data-tone": PRESENCE[id][2]} : {class: "check"};
|
||||
return h("label", state, h("input", {type: "checkbox", checked: on, "data-look": id, on: {change: (event) => run(event.target.checked)}}), state["data-state"] ? icon(PRESENCE[id][1], "glyph") : null, label);
|
||||
}
|
||||
|
||||
function setLook(change) {
|
||||
change();
|
||||
keep(STORE.status, {hidden: [...look.hidden], faces: look.faces});
|
||||
render(false);
|
||||
drawAgent();
|
||||
}
|
||||
|
||||
function statusMenu() {
|
||||
return h("details", {class: "menu"}, h("summary", named("Status", "Status: which agent states show their symbol, and faces in 3D"), icon("bi-activity"), caret()),
|
||||
h("div", {class: "menu-box"}, h("h3", {}, "Agent states"),
|
||||
Object.entries(PRESENCE).map(([id, [label]]) => lookBox(id, label, !look.hidden.has(id), (on) => setLook(() => (on ? look.hidden.delete(id) : look.hidden.add(id))))),
|
||||
h("h3", {}, "3D"),
|
||||
lookBox("faces", "Faces in 3D", look.faces, (on) => setLook(() => {
|
||||
look.faces = on;
|
||||
}))));
|
||||
}
|
||||
|
||||
function drawToolbar() {
|
||||
drawSpot();
|
||||
const toolbar = graphDom && graphDom.toolbar;
|
||||
|
||||
@@ -37,7 +37,15 @@
|
||||
const MOVE = 2000;
|
||||
const RELAYOUT = 60000;
|
||||
const SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
||||
const STORE = {views: "kvb-orchestrator.views"};
|
||||
const STORE = {views: "kvb-orchestrator.views", status: "kvb-orchestrator.status"};
|
||||
const PRESENCE = {
|
||||
working: ["Working", "bi-gear-fill", "info"],
|
||||
alive: ["Alive", "bi-heart-pulse-fill", "ok"],
|
||||
sleeping: ["Sleeping", "bi-moon-stars-fill", "violet"],
|
||||
suspending: ["Suspending", "bi-hourglass-split", "warn"],
|
||||
unreachable: ["Unreachable", "bi-wifi-off", "bad"],
|
||||
dead: ["Dead", "bi-x-octagon-fill", "bad"],
|
||||
};
|
||||
const SOUND_KINDS = [["mayday", "MAYDAY / PAN-PAN"], ["review", "Reviews, asks, decisions"], ["direct", "Post and calls to you"], ["radio", "All radio"]];
|
||||
const SPEECH_CLASSES = [["post", "Post"], ["call", "Call"], ["newsletter", "Newsletter"], ["broadcast", "Broadcast"], ["mayday", "MAYDAY"], ["pan-pan", "PAN-PAN"], ["security", "Sécurité"]];
|
||||
const SPEECH_QUEUE = 20;
|
||||
|
||||
@@ -75,6 +75,20 @@
|
||||
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 presenceOf = (key) => {
|
||||
const value = (state.presence || {})[key];
|
||||
return Object.hasOwn(PRESENCE, value) && !look.hidden.has(value) ? value : null;
|
||||
};
|
||||
const presenceMark = (key, labelled) => {
|
||||
const value = presenceOf(key);
|
||||
return value ? h("span", {class: "presence", "data-state": value, "data-tone": PRESENCE[value][2], role: "img", ...named(PRESENCE[value][0])}, icon(PRESENCE[value][1], "glyph"), labelled ? PRESENCE[value][0] : null) : null;
|
||||
};
|
||||
const presenceGlyph = (key, x, y, size) => {
|
||||
const value = presenceOf(key);
|
||||
return value ? s("g", {class: "presence", "data-state": value, "data-tone": PRESENCE[value][2], transform: `translate(${fixed(x)} ${fixed(y)})`},
|
||||
s("title", {}, PRESENCE[value][0]), s("rect", {class: "presence-dot", x: fixed(-size * 0.65), y: fixed(-size * 0.65), width: fixed(size * 1.3), height: fixed(size * 1.3), rx: fixed(size * 0.65)}),
|
||||
s("g", {class: "glyph"}, s("use", {href: `#${PRESENCE[value][1]}`, x: fixed(-size / 2), y: fixed(-size / 2), width: fixed(size), height: fixed(size)}))) : null;
|
||||
};
|
||||
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;
|
||||
|
||||
@@ -114,6 +114,7 @@
|
||||
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("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));
|
||||
svg.append(...[presenceGlyph(node.id, left - 14, top + index * lane + lane / 2, 12)].filter(Boolean));
|
||||
});
|
||||
for (let index = 0; index <= 6; index += 1) {
|
||||
const ts = begin + ((end - begin) * index) / 6;
|
||||
|
||||
@@ -138,6 +138,7 @@
|
||||
s("title", {}, `${node.label} · ${NODE_KINDS[node.kind]}${node.parent ? ` of ${labelOf(node.parent)}` : ""}${tasks}`),
|
||||
s("circle", {r}),
|
||||
node.kind === "subagent" ? null : s("g", {class: "face-art", transform: `translate(${-r} ${-r}) scale(${(2 * r) / Faces.SIZE})`}, faceParts(node.id)),
|
||||
node.kind === "subagent" ? null : presenceGlyph(node.id, r * 0.8, -r * 0.8, Math.max(10, r * 0.7)),
|
||||
caption(node, at, r, gap));
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
const SPAWNED = new Set(["twig", "subagent"]);
|
||||
const DEPTH_GAP = 90;
|
||||
const TIME_DEPTH = 640;
|
||||
const g3 = {lib: null, three: null, graph: null, host: null, tab: "", sig: "", planes: [], colors: null, failed: "", picked: null, fit: false};
|
||||
const TOP = {operator: 6, chair: 4, session: 2.5};
|
||||
const g3 = {lib: null, three: null, THREE: null, graph: null, host: null, tab: "", sig: "", planes: [], colors: null, failed: "", picked: null, fit: false, badges: new Map(), faces: new Set(), textures: new Map(), pulse: 0};
|
||||
|
||||
const layoutsOf = (tab) => (tab === "tasks" ? LAYOUTS.filter(([id]) => id !== "spawn") : LAYOUTS);
|
||||
const layoutOf = (tab) => (layoutsOf(tab).some(([id]) => id === space.views.graph.axis) ? space.views.graph.axis : "none");
|
||||
@@ -84,6 +85,7 @@
|
||||
const nodes = state.graph.nodes.map((node) => ({
|
||||
id: node.id, kind: node.kind, label: node.label, depth: depthOf[node.kind], ts: born(node) || (node.timeline[0] || {}).since || state.since,
|
||||
parent: node.kind === "operator" ? null : node.kind === "chair" ? HUMAN : node.kind === "session" ? chairKey() : node.parent || chairKey(),
|
||||
face: look.faces && Object.hasOwn(TOP, node.kind), badge: node.kind === "chair" || node.kind === "session",
|
||||
}));
|
||||
for (const node of state.graph.nodes.filter((entry) => entry.kind === "session" || entry.kind === "chair")) {
|
||||
const walk = (twig, parent, depth) => {
|
||||
@@ -196,7 +198,135 @@
|
||||
}
|
||||
|
||||
function signature3d(tab, data) {
|
||||
return JSON.stringify([tab, layoutOf(tab), data.nodes.map((node) => [node.id, node.kind, node.depth, node.ts, node.tone]), data.links.map((link) => [link.source, link.target, link.kind])]);
|
||||
return JSON.stringify([tab, layoutOf(tab), data.nodes.map((node) => [node.id, node.kind, node.depth, node.ts, node.tone, node.face]), data.links.map((link) => [link.source, link.target, link.kind])]);
|
||||
}
|
||||
|
||||
function canvasTexture(size, paint) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
paint(canvas.getContext("2d"));
|
||||
const texture = new g3.THREE.CanvasTexture(canvas);
|
||||
texture.colorSpace = g3.THREE.SRGBColorSpace;
|
||||
return texture;
|
||||
}
|
||||
|
||||
function cached(key, make) {
|
||||
if (!g3.textures.has(key)) {
|
||||
g3.textures.set(key, make());
|
||||
}
|
||||
return g3.textures.get(key);
|
||||
}
|
||||
|
||||
function paintFace(context, key) {
|
||||
context.scale(128 / Faces.SIZE, 128 / Faces.SIZE);
|
||||
for (const [tag, part] of Faces.of(key).parts) {
|
||||
const path = tag === "path" ? new Path2D(part.d) : new Path2D();
|
||||
if (tag === "ellipse") {
|
||||
path.ellipse(part.cx, part.cy, part.rx, part.ry, 0, 0, 2 * Math.PI);
|
||||
} else if (tag === "rect") {
|
||||
path.roundRect(part.x, part.y, part.width, part.height, part.rx || 0);
|
||||
}
|
||||
context.globalAlpha = part.opacity ?? 1;
|
||||
if (part.fill && part.fill !== "none") {
|
||||
context.fillStyle = part.fill;
|
||||
context.fill(path);
|
||||
}
|
||||
if (part.stroke) {
|
||||
context.globalAlpha *= part["stroke-opacity"] ?? 1;
|
||||
context.strokeStyle = part.stroke;
|
||||
context.lineWidth = part["stroke-width"] || 1;
|
||||
context.lineCap = part["stroke-linecap"] || "butt";
|
||||
context.lineJoin = part["stroke-linejoin"] || "miter";
|
||||
context.stroke(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function badgeTexture(value) {
|
||||
const [, name, tone] = PRESENCE[value];
|
||||
const color = g3.colors[tone] || g3.colors.text;
|
||||
return cached(`${value}:${color}`, () => canvasTexture(64, (context) => {
|
||||
context.fillStyle = "rgba(255, 255, 255, 0.92)";
|
||||
context.arc(32, 32, 31, 0, 2 * Math.PI);
|
||||
context.fill();
|
||||
context.fillStyle = color;
|
||||
context.translate(12, 12);
|
||||
context.scale(2.5, 2.5);
|
||||
for (const path of document.querySelectorAll(`#${name} path`)) {
|
||||
context.fill(new Path2D(path.getAttribute("d")), path.getAttribute("fill-rule") || "nonzero");
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
function count3d() {
|
||||
const box = g3.host && g3.host.querySelector(".graph3d");
|
||||
if (box) {
|
||||
box.dataset.faces = String(g3.faces.size);
|
||||
box.dataset.badges = String([...g3.badges.values()].filter((badge) => badge.visible).length);
|
||||
}
|
||||
if (!g3.pulse && g3.badges.size && !calm.matches) {
|
||||
g3.pulse = window.requestAnimationFrame(pulse3d);
|
||||
}
|
||||
}
|
||||
|
||||
function paintBadge(key, badge) {
|
||||
const value = presenceOf(key);
|
||||
badge.visible = value !== null;
|
||||
badge.userData.state = value;
|
||||
const map = value ? badgeTexture(value) : null;
|
||||
if (map && badge.material.map !== map) {
|
||||
badge.material.map = map;
|
||||
badge.material.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
function badges3d() {
|
||||
for (const [key, badge] of g3.badges) {
|
||||
paintBadge(key, badge);
|
||||
}
|
||||
count3d();
|
||||
}
|
||||
|
||||
function pulse3d(now) {
|
||||
g3.pulse = 0;
|
||||
if (!g3.graph || !g3.badges.size || calm.matches) {
|
||||
return;
|
||||
}
|
||||
for (const badge of g3.badges.values()) {
|
||||
const {state: value, size} = badge.userData;
|
||||
const beat = value === "alive" ? 1 + 0.2 * Math.max(0, Math.sin(now / 150)) : 1;
|
||||
badge.scale.set(size * beat, size * beat, 1);
|
||||
badge.material.rotation = value === "working" ? now / 500 : value === "suspending" ? Math.PI * (Math.floor(now / 1200) % 2) : 0;
|
||||
badge.material.opacity = ["sleeping", "dead", "unreachable"].includes(value) ? 0.6 + 0.4 * Math.cos(now / (value === "unreachable" ? 200 : 900)) ** 2 : 1;
|
||||
}
|
||||
g3.pulse = window.requestAnimationFrame(pulse3d);
|
||||
}
|
||||
|
||||
function object3d(node) {
|
||||
const THREE = g3.THREE;
|
||||
if (!THREE || !(node.face || node.badge)) {
|
||||
return null;
|
||||
}
|
||||
const radius = Math.cbrt(TOP[node.kind]) * 5;
|
||||
const group = new THREE.Group();
|
||||
if (node.face) {
|
||||
const face = new THREE.Sprite(new THREE.SpriteMaterial({map: cached(`face:${node.id}`, () => canvasTexture(128, (context) => paintFace(context, node.id)))}));
|
||||
face.scale.set(radius * 2.4, radius * 2.4, 1);
|
||||
group.add(face);
|
||||
g3.faces.add(node.id);
|
||||
}
|
||||
if (node.badge) {
|
||||
const badge = new THREE.Sprite(new THREE.SpriteMaterial());
|
||||
badge.userData.size = radius * 1.1;
|
||||
badge.scale.set(badge.userData.size, badge.userData.size, 1);
|
||||
badge.position.set(0, radius * 1.7, 0);
|
||||
paintBadge(node.id, badge);
|
||||
g3.badges.set(node.id, badge);
|
||||
group.add(badge);
|
||||
}
|
||||
count3d();
|
||||
return group;
|
||||
}
|
||||
|
||||
async function stage3d(tab, host) {
|
||||
@@ -209,6 +339,7 @@
|
||||
const data = model3d(tab);
|
||||
const sig = signature3d(tab, data);
|
||||
if (g3.host === host && g3.sig === sig) {
|
||||
badges3d();
|
||||
return;
|
||||
}
|
||||
if (g3.host !== host) {
|
||||
@@ -232,6 +363,12 @@
|
||||
host.replaceChildren(emptyState("3D not loaded", g3.failed || "3d-force-graph is missing."));
|
||||
return;
|
||||
}
|
||||
if (tab === "agents" && !g3.THREE) {
|
||||
g3.THREE = await threeModule();
|
||||
if (g3.host !== host) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!g3.graph) {
|
||||
try {
|
||||
g3.graph = Graph({controlType: "orbit"})(box);
|
||||
@@ -246,6 +383,7 @@
|
||||
.nodeColor((node) => (node.id === g3.picked ? g3.colors.alarm : g3.colors[node.tone || 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)))
|
||||
.linkDirectionalArrowLength((link) => (DEPENDENCIES.has(link.kind) ? 5 : 0)).linkDirectionalArrowRelPos(1)
|
||||
.nodeThreeObject(object3d).nodeThreeObjectExtend((node) => !node.face)
|
||||
.onNodeClick((node) => pick3d(g3.tab, node)).onEngineStop(builtFit);
|
||||
g3.fit = true;
|
||||
g3.graph.warmupTicks(calm.matches ? 160 : 80);
|
||||
@@ -257,6 +395,8 @@
|
||||
}
|
||||
const layout = layoutOf(tab);
|
||||
place(data.nodes, layout);
|
||||
g3.badges = new Map();
|
||||
g3.faces = new Set();
|
||||
g3.graph.width(box.clientWidth).height(box.clientHeight).graphData(data);
|
||||
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)" : ""}`;
|
||||
@@ -271,10 +411,14 @@
|
||||
g3.graph._destructor();
|
||||
}
|
||||
}
|
||||
window.cancelAnimationFrame(g3.pulse);
|
||||
g3.pulse = 0;
|
||||
g3.graph = null;
|
||||
g3.host = null;
|
||||
g3.sig = "";
|
||||
g3.planes = [];
|
||||
g3.badges = new Map();
|
||||
g3.faces = new Set();
|
||||
}
|
||||
|
||||
function refresh3d() {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
function hierarchySig() {
|
||||
const held = state.claims.map((claim) => [claim.office, claim.session, claim.agent, claim.work, claim.beat !== null && claim.beat >= state.now - ACTIVE_BEAT]);
|
||||
return JSON.stringify([held, state.agent_order, state.people]);
|
||||
return JSON.stringify([held, state.agent_order, state.people, state.presence, [...look.hidden]]);
|
||||
}
|
||||
|
||||
function drawHierarchy(force) {
|
||||
@@ -56,6 +56,7 @@
|
||||
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)),
|
||||
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"}, "–"),
|
||||
claim.session === "-" ? null : presenceMark(claim.session, false),
|
||||
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,
|
||||
h("span", {class: "org-actions"},
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
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]);
|
||||
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, entry.kind === "session" || entry.kind === "chair" ? presenceMark(entry.id, false) : null, running, meta),
|
||||
entry.children.length ? h("ul", {class: "spawn-tree"}, entry.children.map(spawnItem)) : null);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,11 @@
|
||||
.map((view) => ({name: view.name.slice(0, 60), kinds: view.kinds, waiting: view.waiting === true, q: typeof view.q === "string" ? view.q : "", agent: typeof view.agent === "string" ? view.agent : ""}));
|
||||
}
|
||||
|
||||
function validLook(value) {
|
||||
const hidden = value && Array.isArray(value.hidden) ? value.hidden.filter((name) => Object.hasOwn(PRESENCE, name)) : [];
|
||||
return {hidden: new Set(hidden), faces: !(value && value.faces === false)};
|
||||
}
|
||||
|
||||
let state = JSON.parse(document.getElementById("state").textContent);
|
||||
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: "", spot: false, cursor: 0, selected: new Set(), snoozed: false, review: null, file: 0};
|
||||
@@ -46,6 +51,7 @@
|
||||
views: {graph: {...state.settings.space.graph}, sky: {...state.settings.space.sky}}, glides: {graph: null, sky: null}, edits: 0, saved: 0, timer: 0, look: false, compass: null,
|
||||
};
|
||||
let userViews = validViews(stored(STORE.views, stored("kvb-hub.views", [])));
|
||||
const look = validLook(stored(STORE.status, null));
|
||||
let settings = state.settings;
|
||||
let settingsSig = JSON.stringify(settings);
|
||||
let agentDom = null;
|
||||
|
||||
@@ -18,6 +18,30 @@ svg .node.speaking circle { stroke: var(--ok); stroke-width: 4; animation: speak
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.radio-caption .speaker-face::after, svg .node.speaking circle, .speaking .face-art .mouth { animation: none; }
|
||||
}
|
||||
.presence { display: inline-flex; align-items: center; gap: 4px; flex: none; color: var(--muted); font-size: var(--fs-sm); font-weight: 600; }
|
||||
.presence[data-tone="ok"] { color: var(--ok); }
|
||||
.presence[data-tone="bad"] { color: var(--bad); }
|
||||
.presence[data-tone="warn"] { color: var(--warn); }
|
||||
.presence[data-tone="info"] { color: var(--info); }
|
||||
.presence[data-tone="violet"] { color: var(--violet); }
|
||||
svg .presence { fill: currentColor; }
|
||||
svg .presence-dot { fill: var(--panel); stroke: currentColor; stroke-width: 1; }
|
||||
.presence .glyph { transform-box: fill-box; transform-origin: center; }
|
||||
.presence[data-state="working"] .glyph { animation: presence-spin 3s linear infinite; }
|
||||
.presence[data-state="alive"] .glyph { animation: presence-beat 1.6s ease-in-out infinite; }
|
||||
.presence[data-state="sleeping"] .glyph { animation: presence-drift 4s ease-in-out infinite; }
|
||||
.presence[data-state="suspending"] .glyph { animation: presence-flip 2.4s ease-in-out infinite; }
|
||||
.presence[data-state="unreachable"] .glyph { animation: presence-blink 1.2s ease-in-out infinite; }
|
||||
.presence[data-state="dead"] .glyph { animation: presence-fade 5s ease-in-out infinite; }
|
||||
@keyframes presence-spin { to { transform: rotate(360deg); } }
|
||||
@keyframes presence-beat { 15%, 45% { transform: scale(1.25); } 30% { transform: scale(1); } }
|
||||
@keyframes presence-drift { 50% { transform: translateY(-15%); opacity: .6; } }
|
||||
@keyframes presence-flip { 50%, 100% { transform: rotate(180deg); } }
|
||||
@keyframes presence-blink { 50% { opacity: .25; } }
|
||||
@keyframes presence-fade { 50% { opacity: .35; } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.presence .glyph { 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); }
|
||||
.station-name, .station-when { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
Reference in new issue
Block a user