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() {
- + 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)
+

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('