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 <noreply@commandcode.ai>
This commit is contained in:
pasmud
2026-09-26 22:52:34 +00:00
co-authored by CommandCodeBot
parent e482e57941
commit 3052732f28
3 changed files with 85 additions and 10 deletions
@@ -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; }
</style>
</head>
<body>
@@ -60,12 +60,12 @@ function renderControlPlaneViewHtml() {
</header>
<div id="wrap">
<div id="stage">
<canvas id="c"></canvas>
<canvas id="c" role="img" aria-label="Control-plane projection. See the Lanes panel for a text alternative.">Control-plane projection; see the Lanes panel for per-task risk.</canvas>
<div id="meta"></div>
<div id="legend">
<div><span class="dot" style="background:#3fb950"></span>clear</div>
<div><span class="dot" style="background:#e3b341"></span>traffic advisory (transmit)</div>
<div><span class="dot" style="background:#ff7b72"></span>resolution advisory (steer)</div>
<div><span class="shape" style="color:#2ea043">●</span>clear</div>
<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>
<div id="side">
@@ -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)' +
@@ -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('●</span>clear'), 'legend should show the clear circle marker');
assert.ok(html.includes('■</span>traffic advisory'), 'legend should show the advisory square marker');
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');
// 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');
+1 -1
View File
@@ -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 }) } };
}