fix(control-pane): announce polled advisories through a live region

The control-plane view polls every few seconds and rewrites the canvas
aria-label with the new counts, but neither the canvas nor #status is a
live region, so a screen-reader user working elsewhere never hears an
advisory appear.

Add a visually hidden polite live region that speaks only when the
advisory and steering counts actually move. Re-announcing an unchanged
summary every five seconds would talk over the operator without saying
anything new, so the last announced pair is remembered and a no-change
poll writes nothing. The canvas keeps its own label as the on-demand
description.

Also give the runtime test a recording canvas context and a populated
view, so the rendered risk words, the marker shapes, and the live region
are asserted at runtime rather than only against template source. The
risk-level assertion now reads the rendered risk cells instead of the
whole panel, where a level word could be satisfied by a task id.

The populated assertions were mutation checked: collapsing the marker
shapes, dropping the risk words, freezing the canvas label, announcing
on every poll, emptying the live region, and removing the steering cue
each fail at least one assertion.

Fixes the no-regex-spaces lint error on the riskColor match, which used
two literal spaces in a row.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
pasmud
2026-09-27 00:46:39 +00:00
co-authored by CommandCodeBot
parent 18e78da390
commit 6744a01748
3 changed files with 205 additions and 26 deletions
@@ -50,6 +50,8 @@ function renderControlPlaneViewHtml() {
#legend { position: absolute; left: 12px; bottom: 12px; font-size: 11px; color: #8b949e; background: rgba(11,14,20,.7); padding: 6px 8px; border-radius: 6px; }
#meta { position: absolute; right: 12px; top: 12px; font-size: 11px; color: #8b949e; background: rgba(11,14,20,.7); padding: 6px 8px; border-radius: 6px; text-align: right; }
.shape { display: inline-block; width: 12px; margin-right: 5px; text-align: center; font-weight: 700; }
/* Off-screen, not display:none, so assistive tech still reads the node. */
.sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; border: 0; clip: rect(0 0 0 0); clip-path: inset(50%); overflow: hidden; white-space: nowrap; }
</style>
</head>
<body>
@@ -67,6 +69,7 @@ function renderControlPlaneViewHtml() {
<div><span class="shape" style="color:#e3b341">■</span>traffic advisory (transmit)</div>
<div><span class="shape" style="color:#ff7b72">▲</span>resolution advisory (steer)</div>
</div>
<div id="announce" class="sr" role="status" aria-live="polite" aria-atomic="true"></div>
</div>
<div id="side">
<h2>Events</h2>
@@ -80,6 +83,9 @@ function renderControlPlaneViewHtml() {
var canvas = document.getElementById('c');
var ctx = canvas.getContext('2d');
var view = { tasks: [], lanes: [], pairs: [], events: [], projection: { agents: [] }, thresholds: { ta: 0.35, ra: 0.7 } };
// Last advisory/steering pair handed to the live region, so a poll that
// changes nothing stays silent.
var lastSpoken = null;
function resize() {
var r = canvas.parentElement.getBoundingClientRect();
@@ -244,14 +250,26 @@ function renderControlPlaneViewHtml() {
view = Object.assign({}, data);
renderEvents(); renderLanes(); draw();
var c = view.counts || {};
canvas.setAttribute('aria-label',
(c.tasks || 0) + ' tasks in ' + (c.lanes || 0) + ' lanes, ' +
var summary = (c.tasks || 0) + ' tasks in ' + (c.lanes || 0) + ' lanes, ' +
(c.advisories || 0) + ' advisories, ' + (c.resolutions || 0) + ' steering. ' +
'See the Lanes panel for per-task risk.');
'See the Lanes panel for per-task risk.';
canvas.setAttribute('aria-label', summary);
document.getElementById('status').textContent =
(c.tasks || 0) + ' tasks in ' + (c.lanes || 0) + ' lanes | ' + (c.agents || 0) + ' with edits | ' +
(c.advisories || 0) + ' advisories (' + (c.resolutions || 0) + ' steering)' +
(view.inventory && view.inventory.status !== 'ok' ? ' | inventory ' + view.inventory.status : '');
// Polling runs every few seconds, so only speak when the advisory and
// steering counts actually move. Repeating an unchanged summary would talk
// over the operator without telling them anything new.
var speaker = document.getElementById('announce');
var spoken = (c.advisories || 0) + '/' + (c.resolutions || 0);
if (spoken !== lastSpoken) {
lastSpoken = spoken;
speaker.textContent = (c.advisories || 0) + ' advisories, ' +
(c.resolutions || 0) + ' steering. ' +
((c.resolutions || 0) > 0 ? 'Steering is required.' : 'No steering is required.');
}
}
function poll() {
+9 -2
View File
@@ -15,6 +15,13 @@ assert.ok(html.includes('■</span>traffic advisory'), 'legend should show the a
assert.ok(html.includes('▲</span>resolution'), 'legend should show the resolution triangle marker');
assert.ok(!html.includes('class="dot"'), 'legend should not render color-only dots');
// Counts are polled every few seconds, so a screen-reader user needs a polite
// live region to hear an advisory move. The canvas keeps its own label as the
// on-demand description.
assert.ok(html.includes('role="status" aria-live="polite"'),
'polled counts should be announced through a polite live region');
assert.ok(html.includes('class="sr"'), 'the live region should be hidden visually but not removed from the tree');
// Clear and resolution must stay separable when hue is unavailable, so their
// relative luminances have to differ by more than the ~1.05:1 that used to
// collapse "clear" and "steer now" into the same grey.
@@ -47,7 +54,7 @@ const [clear, traffic, resolution] = legend.map(entry => entry.color);
// The legend and the canvas must agree, otherwise the operator reads a different
// colour from the one the marker is drawn in.
const riskColorBody = html.match(/function riskColor\(risk\) \{([\s\S]*?)\n \}/)[1];
const riskColorBody = html.match(/function riskColor\(risk\) \{([\s\S]*?)\n {2}\}/)[1];
const canvasColors = [...riskColorBody.matchAll(/return '(#[0-9a-f]{6})';/g)].map(match => match[1]);
assert.deepStrictEqual(canvasColors, [resolution, traffic, clear],
'the legend palette and the riskColor palette must match');
@@ -62,4 +69,4 @@ for (const level of legend) {
`the ${level.color} marker must meet 4.5:1 against the page background, got ${contrastRatio(level.color, BACKGROUND).toFixed(2)}:1`);
}
console.log('Results: Passed: 15, Failed: 0');
console.log('Results: Passed: 17, Failed: 0');
+175 -21
View File
@@ -13,17 +13,86 @@ const proximityHtml = renderProximityVizHtml();
assert.ok(proximityHtml.includes('grid-template-rows: minmax(0, 1fr)'));
assert.ok(proximityHtml.includes('#stage { position: relative; height: 100%; min-height: 0;'));
async function renderResponse(ok, data) {
const elements = new Map();
const context = new Proxy({}, { get: () => () => {} });
function element() {
return { textContent: '', style: {}, appendChild() {}, setAttribute() {}, getContext: () => context,
clientWidth: 640, clientHeight: 480,
parentElement: { getBoundingClientRect: () => ({ width: 640, height: 480 }) } };
// A recording 2D context, so a test can assert what the view actually drew
// rather than only what the template happens to contain.
function createContext() {
const log = [];
const context = { fillStyle: '', strokeStyle: '', lineWidth: 1, globalAlpha: 1, font: '', log };
const record = fn => (...args) => { log.push({ fn, args, fillStyle: context.fillStyle }); };
for (const fn of ['setTransform', 'clearRect', 'beginPath', 'moveTo', 'lineTo', 'stroke',
'arc', 'rect', 'closePath', 'fill', 'fillText', 'save', 'restore']) {
context[fn] = record(fn);
}
return context;
}
// Group the draw calls into paths and keep the filled ones: those are the risk
// markers, and the axis and pair-link paths only stroke.
function markerShapes(context) {
const paths = [];
let current = null;
for (const entry of context.log) {
if (entry.fn === 'beginPath') {
if (current) paths.push(current);
current = [];
continue;
}
if (!current) current = [];
current.push(entry);
}
if (current) paths.push(current);
return paths.filter(path => path.some(entry => entry.fn === 'fill')).map(path => {
const shape = path.some(e => e.fn === 'arc') ? 'circle'
: path.some(e => e.fn === 'rect') ? 'square' : 'triangle';
return { shape, color: path.find(e => e.fn === 'fill').fillStyle };
});
}
function textOf(node) {
return (node.textContent || '') + node.children.map(textOf).join('');
}
function findAll(node, className) {
const found = node.className === className ? [node] : [];
for (const child of node.children) found.push(...findAll(child, className));
return found;
}
function element(tag, context) {
const node = {
tag: tag || 'div',
className: '',
children: [],
style: {},
attributes: {},
clientWidth: 640,
clientHeight: 480,
parentElement: { getBoundingClientRect: () => ({ width: 640, height: 480 }) },
appendChild(child) { node.children.push(child); return child; },
setAttribute(name, value) { node.attributes[name] = value; },
getContext: () => context
};
let text = '';
let writes = 0;
Object.defineProperty(node, 'textContent', {
get() { return text; },
set(value) { text = String(value); node.children = []; writes += 1; },
configurable: true
});
Object.defineProperty(node, 'writes', { get() { return writes; }, configurable: true });
return node;
}
// Drives the view's inline script against a queue of poll responses, so one run
// can cover several polls and the state each one leaves behind.
async function render(responses) {
const context = createContext();
const elements = new Map();
const timers = [];
const queue = responses.slice();
const document = {
getElementById(id) { if (!elements.has(id)) elements.set(id, element()); return elements.get(id); },
createElement: element
getElementById(id) { if (!elements.has(id)) elements.set(id, element(null, context)); return elements.get(id); },
createElement: tag => element(tag, context)
};
const html = renderControlPlaneViewHtml();
const start = html.indexOf('<script>');
@@ -31,27 +100,112 @@ async function renderResponse(ok, data) {
assert.ok(start >= 0 && end > start, 'fixed renderer template must contain its inline script');
const code = html.slice(start + '<script>'.length, end);
vm.runInNewContext(code, {
document, window: { addEventListener() {}, devicePixelRatio: 1 }, setInterval() {},
fetch: async () => ({ ok, json: async () => data })
document,
window: { addEventListener() {}, devicePixelRatio: 1 },
setInterval(fn) { timers.push(fn); },
fetch: async () => {
const next = queue.length > 1 ? queue.shift() : queue[0];
return { ok: next.ok, json: async () => next.data };
}
});
await new Promise(resolve => setImmediate(resolve));
return elements;
return {
elements,
context,
writesTo(id) { return elements.get(id).writes; },
async pollAgain() {
for (const fn of timers) fn();
await new Promise(resolve => setImmediate(resolve));
}
};
}
function populatedView(overrides) {
return Object.assign({
schemaVersion: 'ecc.control-plane.view.v1',
tasks: [
{ id: 'task-clear', harness: 'claude', state: 'running', workingSet: { fileCount: 1 },
projection: { maxRisk: 0.1, point: [1, 0] } },
{ id: 'task-traffic', harness: 'codex', state: 'running', workingSet: { fileCount: 4 },
projection: { maxRisk: 0.5, point: [0, 1] } },
{ id: 'task-resolution', harness: 'gemini', state: 'blocked', workingSet: { fileCount: 9 },
projection: { maxRisk: 0.9, point: [-1, -1] } }
],
lanes: [{ label: 'main', kind: 'lane', taskIds: ['task-clear', 'task-traffic', 'task-resolution'] }],
pairs: [{ a: 'task-clear', b: 'task-traffic', risk: 0.5 }],
events: [{ level: 'resolution', kind: 'pair', message: 'overlap', risk: 0.9 }],
projection: {
agents: [
{ agentId: 'task-clear', point: [1, 0], maxRisk: 0.1 },
{ agentId: 'task-traffic', point: [0, 1], maxRisk: 0.5 },
{ agentId: 'task-resolution', point: [-1, -1], maxRisk: 0.9 }
],
normalization: 'raw'
},
thresholds: { ta: 0.35, ra: 0.7 },
counts: { tasks: 3, lanes: 1, agents: 2, advisories: 2, resolutions: 1 }
}, overrides);
}
let passed = 0;
(async () => {
const failed = await renderResponse(false, { ok: false, error: 'snapshot unavailable' });
assert.strictEqual(failed.get('status').textContent, 'offline', 'HTTP errors must not display a healthy empty view');
const failed = await render([{ ok: false, data: { ok: false, error: 'snapshot unavailable' } }]);
assert.strictEqual(failed.elements.get('status').textContent, 'offline', 'HTTP errors must not display a healthy empty view');
passed += 1;
const malformed = await renderResponse(true, { schemaVersion: 'wrong' });
assert.strictEqual(malformed.get('status').textContent, 'offline', 'invalid schemas must be rejected');
const malformed = await render([{ ok: true, data: { schemaVersion: 'wrong' } }]);
assert.strictEqual(malformed.elements.get('status').textContent, 'offline', 'invalid schemas must be rejected');
passed += 1;
const valid = await renderResponse(true, {
schemaVersion: 'ecc.control-plane.view.v1', tasks: [], lanes: [], pairs: [], events: [],
projection: { agents: [] }, thresholds: { ta: 0.35, ra: 0.7 }, counts: {}
});
assert.ok(valid.get('status').textContent.includes('0 tasks'));
const valid = await render([{ ok: true, data: populatedView({ tasks: [], lanes: [], pairs: [], events: [], projection: { agents: [] }, counts: {} }) }]);
assert.ok(valid.elements.get('status').textContent.includes('0 tasks'));
passed += 1;
// A populated view has to name each task's risk level in words, not leave the
// level encoded only in the marker colour. Assert on the rendered risk cells
// rather than the whole panel, so a level word cannot be satisfied by a task
// id that happens to contain it.
const populated = await render([{ ok: true, data: populatedView() }]);
const riskCells = findAll(populated.elements.get('lanes'), 'risk').map(node => textOf(node));
assert.deepStrictEqual(riskCells,
['10% - clear', '50% - traffic', '90% - resolution'],
'each task must state its risk level in words beside the percentage');
passed += 1;
// Each risk level draws its own shape, so a colour-blind operator still sees
// the three levels apart on the canvas.
assert.deepStrictEqual(markerShapes(populated.context).map(marker => marker.shape),
['circle', 'square', 'triangle'], 'the three risk levels must draw circle, square, and triangle');
passed += 1;
const canvasLabel = populated.elements.get('c').attributes['aria-label'];
assert.ok(canvasLabel.includes('3 tasks') && canvasLabel.includes('2 advisories') && canvasLabel.includes('1 steering'),
`the canvas label must carry the polled counts, got ${canvasLabel}`);
passed += 1;
const announced = populated.elements.get('announce').textContent;
assert.ok(announced.includes('2 advisories') && announced.includes('Steering is required.'),
`a populated view must announce the steering state, got ${announced}`);
passed += 1;
// Polling every few seconds must not repeat an unchanged announcement. Setting
// the same text again is a no-op for a real DOM, but the view should not even
// attempt the write, so count the assignments rather than comparing strings.
await populated.pollAgain();
assert.strictEqual(populated.writesTo('announce'), 1,
'an unchanged poll must not write the live region again');
passed += 1;
const changed = await render([
{ ok: true, data: populatedView() },
{ ok: true, data: populatedView({ counts: { tasks: 3, lanes: 1, agents: 2, advisories: 3, resolutions: 0 } }) }
]);
await changed.pollAgain();
assert.ok(changed.elements.get('announce').textContent.includes('3 advisories')
&& changed.elements.get('announce').textContent.includes('No steering is required.'),
`a changed poll must announce the new counts, got ${changed.elements.get('announce').textContent}`);
passed += 1;
console.log(`Results: Passed: ${passed}, Failed: 0`);
})().catch(error => {
console.error(error.message);