diff --git a/scripts/lib/control-pane/control-plane-view-ui.js b/scripts/lib/control-pane/control-plane-view-ui.js
index 2abf84d9b..30ffd831b 100644
--- a/scripts/lib/control-pane/control-plane-view-ui.js
+++ b/scripts/lib/control-pane/control-plane-view-ui.js
@@ -49,7 +49,9 @@ function renderControlPlaneViewHtml() {
.empty { color: #6e7681; font-size: 12px; }
#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; }
- .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 5px; vertical-align: middle; }
+ .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; }
@@ -60,13 +62,14 @@ function renderControlPlaneViewHtml() {
-
+
-
clear
-
traffic advisory (transmit)
-
resolution advisory (steer)
+
●clear
+
■traffic advisory (transmit)
+
▲resolution advisory (steer)
+
Events
@@ -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 message handed to the live region, so a poll that changes nothing
+ // stays silent.
+ var lastSpoken = null;
function resize() {
var r = canvas.parentElement.getBoundingClientRect();
@@ -91,10 +97,35 @@ function renderControlPlaneViewHtml() {
}
window.addEventListener('resize', resize);
+ // The previous clear/resolution palette had similar relative luminance.
+ // Separate luminance values plus redundant shapes and text reduce reliance
+ // on hue; palette math alone does not establish a user's visual experience.
+ function riskLevel(risk) {
+ if (risk >= view.thresholds.ra) return 'resolution';
+ if (risk >= view.thresholds.ta) return 'traffic';
+ return 'clear';
+ }
+
function riskColor(risk) {
if (risk >= view.thresholds.ra) return '#ff7b72';
if (risk >= view.thresholds.ta) return '#e3b341';
- return '#3fb950';
+ return '#2ea043';
+ }
+
+ // Shape is the second, non-colour channel: circle / square / triangle.
+ function drawRiskMarker(x, y, radius, level) {
+ ctx.beginPath();
+ if (level === 'resolution') {
+ ctx.moveTo(x, y - radius);
+ ctx.lineTo(x + radius, y + radius);
+ ctx.lineTo(x - radius, y + radius);
+ ctx.closePath();
+ } else if (level === 'traffic') {
+ ctx.rect(x - radius, y - radius, radius * 2, radius * 2);
+ } else {
+ ctx.arc(x, y, radius, 0, Math.PI * 2);
+ }
+ ctx.fill();
}
// Fit the projected points into the canvas with a margin. The PCA scores
@@ -142,8 +173,9 @@ function renderControlPlaneViewHtml() {
var t = taskById[a.agentId] || {};
var files = (t.workingSet && t.workingSet.fileCount) || 1;
var radius = 6 + Math.sqrt(files) * 3;
- ctx.fillStyle = riskColor(a.maxRisk || 0);
- ctx.beginPath(); ctx.arc(p[0], p[1], radius, 0, Math.PI * 2); ctx.fill();
+ var risk = a.maxRisk || 0;
+ ctx.fillStyle = riskColor(risk);
+ drawRiskMarker(p[0], p[1], radius, riskLevel(risk));
ctx.fillStyle = '#c9d1d9';
ctx.font = '11px -apple-system, system-ui, sans-serif';
ctx.fillText(String(a.agentId).slice(0, 18), p[0] + radius + 4, p[1] + 3);
@@ -198,7 +230,9 @@ function renderControlPlaneViewHtml() {
var st = document.createElement('span'); st.textContent = t.harness + ' / ' + t.state + ' / ' + (t.workingSet.fileCount || 0) + ' files';
var risk = document.createElement('span'); risk.className = 'risk';
risk.style.color = riskColor(t.projection.maxRisk || 0);
- risk.textContent = t.projection.point ? Math.round((t.projection.maxRisk || 0) * 100) + '%' : 'no pair';
+ risk.textContent = (t.projection.point
+ ? Math.round((t.projection.maxRisk || 0) * 100) + '% - ' + riskLevel(t.projection.maxRisk || 0)
+ : 'no pair');
row.appendChild(idEl); row.appendChild(st); row.appendChild(risk);
el.appendChild(row);
});
@@ -206,6 +240,40 @@ function renderControlPlaneViewHtml() {
});
}
+ // Wording shared by the canvas label and the live region so an outage reads
+ // the same way however the operator reaches it.
+ var UNAVAILABLE = 'Control-plane data is unavailable. Advisories and steering are unknown.';
+ // Polls are not sequenced, so a slow request can settle out of order. Only
+ // the newest poll that has already settled may update the view: a success
+ // from a superseded poll would show older counts, and a failure from a
+ // superseded poll would erase newer counts. Anchoring to the last settled
+ // poll rather than the last started one also lets a failure land while a
+ // newer poll is still pending, instead of leaving stale guidance on screen.
+ var settledPoll = 0;
+ // Monotonic id handed to each poll as it starts.
+ var pollSeq = 0;
+ // A poll that never answers must not stay pending forever, or the last
+ // steering guidance stays on screen indefinitely.
+ var TIMEOUT_MS = 10000;
+
+ // 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.
+ function announce(message) {
+ if (message === lastSpoken) return;
+ lastSpoken = message;
+ document.getElementById('announce').textContent = message;
+ }
+
+ function unavailable() {
+ document.getElementById('status').textContent = 'offline';
+ // The last guidance is now stale, so replace it rather than leaving the
+ // live region claiming the airspace is clear. The canvas label goes with
+ // it, or it would still report the last successful counts.
+ canvas.setAttribute('aria-label', UNAVAILABLE);
+ announce(UNAVAILABLE);
+ }
+
function apply(data) {
if (!data || data.schemaVersion !== 'ecc.control-plane.view.v1' ||
!['tasks', 'lanes', 'pairs', 'events'].every(function (key) { return Array.isArray(data[key]); }) ||
@@ -216,19 +284,50 @@ function renderControlPlaneViewHtml() {
view = Object.assign({}, data);
renderEvents(); renderLanes(); draw();
var c = view.counts || {};
+ 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.';
+ 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 : '');
+
+ announce((c.advisories || 0) + ' advisories, ' +
+ (c.resolutions || 0) + ' steering. ' +
+ ((c.resolutions || 0) > 0 ? 'Steering is required.' : 'No steering is required.'));
}
function poll() {
- fetch('/api/control-plane').then(function (r) {
+ var token = ++pollSeq;
+ var timer = null;
+ var controller = new AbortController();
+ // Reject on a timer so a hung request cannot keep the previous guidance on
+ // screen forever. The abort is what wakes this poll up, so a timeout is
+ // reported as an outage rather than swallowed.
+ timer = setTimeout(function () { controller.abort(); }, TIMEOUT_MS);
+ // Claim before rendering or reporting failure: two JSON bodies may settle
+ // in the same turn, before a later cleanup continuation can run. Equality
+ // lets a render failure report unavailable for the token that just claimed.
+ function claim() {
+ if (token < settledPoll) return false;
+ settledPoll = token;
+ return true;
+ }
+ function settle() {
+ clearTimeout(timer);
+ }
+ fetch('/api/control-plane', { signal: controller.signal }).then(function (r) {
if (!r.ok) throw new Error('Control-plane request failed');
return r.json();
- }).then(apply).catch(function () {
- document.getElementById('status').textContent = 'offline';
- });
+ }).then(function (data) {
+ // A newer poll already owns the view, so do not resurrect older counts.
+ if (!claim()) return;
+ apply(data);
+ }).catch(function () {
+ if (!claim()) return;
+ unavailable();
+ }).then(settle, settle);
}
resize();
diff --git a/tests/lib/control-plane-view-ui-a11y.test.js b/tests/lib/control-plane-view-ui-a11y.test.js
new file mode 100644
index 000000000..becd2072d
--- /dev/null
+++ b/tests/lib/control-plane-view-ui-a11y.test.js
@@ -0,0 +1,79 @@
+'use strict';
+
+const assert = require('assert');
+const { renderControlPlaneViewHtml } = require('../../scripts/lib/control-pane/control-plane-view-ui');
+
+// Count successful assertion executions, including each palette-loop iteration.
+// These are source/palette assertions, not browser accessibility test cases.
+let assertions = 0;
+const countedAssert = {};
+for (const method of ['ok', 'strictEqual', 'deepStrictEqual']) {
+ countedAssert[method] = (...args) => { assert[method](...args); assertions += 1; };
+}
+
+const html = renderControlPlaneViewHtml();
+
+countedAssert.ok(html.includes('role="img"'), 'canvas should expose an image role');
+countedAssert.ok(html.includes('aria-label='), 'canvas should carry a text alternative');
+countedAssert.ok(html.includes("function riskLevel(risk)"), 'risk levels should be named independently of color');
+countedAssert.ok(html.includes("ctx.rect(x - radius"), 'traffic advisories should use a square marker');
+countedAssert.ok(html.includes("ctx.lineTo(x + radius"), 'resolution advisories should use a triangular marker');
+countedAssert.ok(html.includes('●clear'), 'legend should show the clear circle marker');
+countedAssert.ok(html.includes('■traffic advisory'), 'legend should show the advisory square marker');
+countedAssert.ok(html.includes('▲resolution'), 'legend should show the resolution triangle marker');
+countedAssert.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.
+countedAssert.ok(html.includes('role="status" aria-live="polite"'),
+ 'polled counts should be announced through a polite live region');
+countedAssert.ok(html.includes('class="sr"'), 'the live region should be hidden visually but not removed from the tree');
+
+// Check numerical palette separation as an additional channel. This does not
+// simulate color vision, establish glyph visibility, or test assistive tools.
+function relativeLuminance(hex) {
+ const channels = hex.replace('#', '').match(/../g).map(part => parseInt(part, 16) / 255)
+ .map(value => (value <= 0.03928 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4)));
+ return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
+}
+
+function contrastRatio(left, right) {
+ const a = relativeLuminance(left);
+ const b = relativeLuminance(right);
+ const [lighter, darker] = a > b ? [a, b] : [b, a];
+ return (lighter + 0.05) / (darker + 0.05);
+}
+
+const BACKGROUND = html.match(/body \{[^}]*background: (#[0-9a-f]{6})/)[1];
+
+// Read the palette back out of the rendered view instead of hard-coding it, so
+// a colour change cannot leave these contrast assertions quietly passing.
+const legend = [...html.matchAll(
+ /([\u25cf\u25a0\u25b2])<\/span>/g
+)].map(match => ({ color: match[1], glyph: match[2] }));
+
+countedAssert.strictEqual(legend.length, 3, 'the legend should declare three risk levels');
+countedAssert.deepStrictEqual(legend.map(entry => entry.glyph), ['\u25cf', '\u25a0', '\u25b2'],
+ 'clear, traffic, and resolution should be marked circle, square, and triangle');
+
+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 {2}\}/)[1];
+const canvasColors = [...riskColorBody.matchAll(/return '(#[0-9a-f]{6})';/g)].map(match => match[1]);
+countedAssert.deepStrictEqual(canvasColors, [resolution, traffic, clear],
+ 'the legend palette and the riskColor palette must match');
+
+// Check numerical palette separation as an additional channel. This does not
+// simulate color vision, establish glyph visibility, or test assistive tools.
+countedAssert.ok(contrastRatio(clear, resolution) >= 1.3,
+ `clear and resolution must differ by luminance, got ${contrastRatio(clear, resolution).toFixed(2)}:1`);
+for (const level of legend) {
+ countedAssert.ok(contrastRatio(level.color, BACKGROUND) >= 4.5,
+ `the ${level.color} marker must meet 4.5:1 against the page background, got ${contrastRatio(level.color, BACKGROUND).toFixed(2)}:1`);
+}
+
+console.log('Reporting unit: source/palette assertions; no browser or assistive-tool acceptance.');
+console.log(`Results: Passed: ${assertions}, Failed: 0`);
diff --git a/tests/lib/control-plane-view-ui.test.js b/tests/lib/control-plane-view-ui.test.js
index 67bbca6c9..ef4646166 100644
--- a/tests/lib/control-plane-view-ui.test.js
+++ b/tests/lib/control-plane-view-ui.test.js
@@ -13,17 +13,100 @@ 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() {}, 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;
+}
+
+// A poll settles through several chained promise callbacks, so draining needs a
+// few turns of the event loop rather than a single tick.
+function settle() {
+ return new Promise(resolve => {
+ let remaining = 5;
+ const step = () => (remaining-- > 0 ? setImmediate(step) : resolve());
+ step();
+ });
+}
+
+// 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. A response may
+// carry a `hold` promise to park until the test releases it, or `never: true` to
+// stay pending so the request timeout can be exercised.
+async function render(responses) {
+ const context = createContext();
+ const elements = new Map();
+ const timers = [];
+ const timeouts = [];
+ const queue = responses.slice();
+ let jsonReads = 0;
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('