From 3052732f28e3c5e0f95f10e4b5405986e57ded82 Mon Sep 17 00:00:00 2001 From: pasmud Date: Sat, 26 Sep 2026 22:52:34 +0000 Subject: [PATCH] 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 }) } }; }