diff --git a/scripts/lib/control-pane/control-plane-view-ui.js b/scripts/lib/control-pane/control-plane-view-ui.js
index ec6d33191..ce35ada45 100644
--- a/scripts/lib/control-pane/control-plane-view-ui.js
+++ b/scripts/lib/control-pane/control-plane-view-ui.js
@@ -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; }
@@ -67,6 +69,7 @@ function renderControlPlaneViewHtml() {
■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 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() {
diff --git a/tests/lib/control-plane-view-ui-a11y.test.js b/tests/lib/control-plane-view-ui-a11y.test.js
index b484c4c0e..df0c8a38b 100644
--- a/tests/lib/control-plane-view-ui-a11y.test.js
+++ b/tests/lib/control-plane-view-ui-a11y.test.js
@@ -15,6 +15,13 @@ assert.ok(html.includes('■traffic advisory'), 'legend should show the a
assert.ok(html.includes('▲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');
diff --git a/tests/lib/control-plane-view-ui.test.js b/tests/lib/control-plane-view-ui.test.js
index 5fc32f712..ce58a6f04 100644
--- a/tests/lib/control-plane-view-ui.test.js
+++ b/tests/lib/control-plane-view-ui.test.js
@@ -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('