From 3052732f28e3c5e0f95f10e4b5405986e57ded82 Mon Sep 17 00:00:00 2001 From: pasmud Date: Sat, 26 Sep 2026 22:52:34 +0000 Subject: [PATCH 1/7] fix(control-pane): add non-color risk cues to the control-plane view The control-plane projection encoded risk with hue alone. The clear green #3fb950 and the resolution red #ff7b72 differ by only 1.01:1 in relative luminance, so a red-green color-blind operator saw the same grey for "clear" and "steer now". Risk now carries a second, non-color channel, matching the pattern merged for the sibling airspace view in #3193: - markers are drawn as a circle, square, or triangle per risk level - the legend shows the matching glyph instead of color-only dots - the Lanes panel prints the level name next to the percentage - the canvas gets role="img" and a live aria-label describing counts The clear green also moves to #2ea043 so it stays separable from the resolution red by luminance (1.34:1) while keeping 5.73:1 against the #0b0e14 background. Adds tests/lib/control-plane-view-ui-a11y.test.js covering the roles, the shapes, the legend, and the contrast ratios. The existing test harness needed a setAttribute stub for the new aria-label update. Co-Authored-By: CommandCodeBot --- .../lib/control-pane/control-plane-view-ui.js | 50 +++++++++++++++---- tests/lib/control-plane-view-ui-a11y.test.js | 43 ++++++++++++++++ tests/lib/control-plane-view-ui.test.js | 2 +- 3 files changed, 85 insertions(+), 10 deletions(-) create mode 100644 tests/lib/control-plane-view-ui-a11y.test.js diff --git a/scripts/lib/control-pane/control-plane-view-ui.js b/scripts/lib/control-pane/control-plane-view-ui.js index 2abf84d9b..ec6d33191 100644 --- a/scripts/lib/control-pane/control-plane-view-ui.js +++ b/scripts/lib/control-pane/control-plane-view-ui.js @@ -49,7 +49,7 @@ 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; } @@ -60,12 +60,12 @@ function renderControlPlaneViewHtml() {
- + Control-plane projection; see the Lanes panel for per-task risk.
-
clear
-
traffic advisory (transmit)
-
resolution advisory (steer)
+
●clear
+
■traffic advisory (transmit)
+
▲resolution advisory (steer)
@@ -91,10 +91,35 @@ function renderControlPlaneViewHtml() { } window.addEventListener('resize', resize); + // Keep clear and resolution apart by luminance as well as hue: at the + // previous #3fb950/#ff7b72 the two differed by 1.01:1, so a red-green + // colour-blind operator saw the same grey for "clear" and "steer now". + 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 +167,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 +224,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); }); @@ -216,6 +244,10 @@ 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, ' + + (c.advisories || 0) + ' advisories, ' + (c.resolutions || 0) + ' steering. ' + + 'See the Lanes panel for per-task risk.'); 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)' + 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..e2b4f4f84 --- /dev/null +++ b/tests/lib/control-plane-view-ui-a11y.test.js @@ -0,0 +1,43 @@ +'use strict'; + +const assert = require('assert'); +const { renderControlPlaneViewHtml } = require('../../scripts/lib/control-pane/control-plane-view-ui'); + +const html = renderControlPlaneViewHtml(); + +assert.ok(html.includes('role="img"'), 'canvas should expose an image role'); +assert.ok(html.includes('aria-label='), 'canvas should carry a text alternative'); +assert.ok(html.includes("function riskLevel(risk)"), 'risk levels should be named independently of color'); +assert.ok(html.includes("ctx.rect(x - radius"), 'traffic advisories should use a square marker'); +assert.ok(html.includes("ctx.lineTo(x + radius"), 'resolution advisories should use a triangular marker'); +assert.ok(html.includes('●clear'), 'legend should show the clear circle marker'); +assert.ok(html.includes('■traffic advisory'), 'legend should show the advisory square marker'); +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'); + +// 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. +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 clear = '#2ea043'; +const resolution = '#ff7b72'; +assert.ok(contrastRatio(clear, resolution) >= 1.3, + `clear and resolution must differ by luminance, got ${contrastRatio(clear, resolution).toFixed(2)}:1`); +assert.ok(contrastRatio(clear, '#0b0e14') >= 4.5, + 'the clear marker must still meet 4.5:1 against the page background'); +assert.ok(contrastRatio(resolution, '#0b0e14') >= 4.5, + 'the resolution marker must still meet 4.5:1 against the page background'); + +console.log('Results: Passed: 12, Failed: 0'); diff --git a/tests/lib/control-plane-view-ui.test.js b/tests/lib/control-plane-view-ui.test.js index 67bbca6c9..5fc32f712 100644 --- a/tests/lib/control-plane-view-ui.test.js +++ b/tests/lib/control-plane-view-ui.test.js @@ -17,7 +17,7 @@ async function renderResponse(ok, data) { const elements = new Map(); const context = new Proxy({}, { get: () => () => {} }); function element() { - return { textContent: '', style: {}, appendChild() {}, getContext: () => context, + return { textContent: '', style: {}, appendChild() {}, setAttribute() {}, getContext: () => context, clientWidth: 640, clientHeight: 480, parentElement: { getBoundingClientRect: () => ({ width: 640, height: 480 }) } }; } From 18e78da3900c8be194ce9f3b3354d24826ffdd27 Mon Sep 17 00:00:00 2001 From: pasmud Date: Sat, 26 Sep 2026 22:59:22 +0000 Subject: [PATCH 2/7] test(control-pane): derive the a11y palette from the rendered view CodeRabbit correctly noted that hard-coding #2ea043 and #ff7b72 in the test decoupled the contrast assertions from the colors the view actually renders, so a palette change could leave them passing. The test now reads the legend glyphs, the legend colors, the riskColor() returns, and the body background back out of the rendered HTML, and asserts that the legend and canvas palettes agree before checking any ratio. Verified both directions: a mismatched canvas color fails the palette check, and a full revert to #3fb950 fails the luminance check at 1.01:1. Extends the coverage to the traffic level and the body background, which were previously unchecked. Co-Authored-By: CommandCodeBot --- tests/lib/control-plane-view-ui-a11y.test.js | 36 ++++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/tests/lib/control-plane-view-ui-a11y.test.js b/tests/lib/control-plane-view-ui-a11y.test.js index e2b4f4f84..b484c4c0e 100644 --- a/tests/lib/control-plane-view-ui-a11y.test.js +++ b/tests/lib/control-plane-view-ui-a11y.test.js @@ -31,13 +31,35 @@ function contrastRatio(left, right) { return (lighter + 0.05) / (darker + 0.05); } -const clear = '#2ea043'; -const resolution = '#ff7b72'; +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] })); + +assert.strictEqual(legend.length, 3, 'the legend should declare three risk levels'); +assert.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 \}/)[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'); + +// 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. assert.ok(contrastRatio(clear, resolution) >= 1.3, `clear and resolution must differ by luminance, got ${contrastRatio(clear, resolution).toFixed(2)}:1`); -assert.ok(contrastRatio(clear, '#0b0e14') >= 4.5, - 'the clear marker must still meet 4.5:1 against the page background'); -assert.ok(contrastRatio(resolution, '#0b0e14') >= 4.5, - 'the resolution marker must still meet 4.5:1 against the page background'); +for (const level of legend) { + assert.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('Results: Passed: 12, Failed: 0'); +console.log('Results: Passed: 15, Failed: 0'); From 6744a017482edd6e3d62fcd1d2c07c75ecad6de6 Mon Sep 17 00:00:00 2001 From: pasmud Date: Sun, 27 Sep 2026 00:46:39 +0000 Subject: [PATCH 3/7] 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 --- .../lib/control-pane/control-plane-view-ui.js | 24 ++- tests/lib/control-plane-view-ui-a11y.test.js | 11 +- tests/lib/control-plane-view-ui.test.js | 196 ++++++++++++++++-- 3 files changed, 205 insertions(+), 26 deletions(-) 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('