fix(#1356): WCAG 2.2 AA map a11y — cluster bubbles, role pills, multi-byte labels (#1357)

Red commit: d48c1add88 (CI:
https://github.com/Kpa-clawbot/CoreScope/actions/runs/26411462973)

Green commit CI:
https://github.com/Kpa-clawbot/CoreScope/actions/runs/26411699037

## What

Brings the map's three visual surfaces — cluster bubbles, role pills
inside cluster bubbles, and multi-byte hash labels on repeater markers —
up to WCAG 2.2 AA. Replaces the prior color-only signaling with
structural carriers (size, border-style, glyph, letter prefix) so color
is no longer the only channel.

## How

Locked design = Tufte's structural framing ([issue
comment](https://github.com/Kpa-clawbot/CoreScope/issues/1356#issuecomment-4535244400))
WITH the WCAG audit's "Minimal patch to reach AA" applied as overrides
([issue
comment](https://github.com/Kpa-clawbot/CoreScope/issues/1356#issuecomment-4535849354)).
Where the audit and the original proposal disagreed (border color, pill
text color, V3 accent palette, font sizes), the audit's values won.

## V1 cluster bubbles

- Neutral fill `rgba(33,41,54,0.92)` via new `--mc-cluster-fill` (was
per-bucket `--info / --warning / --accent`).
- Border-style ramp as the redundant non-color carrier of the count
bucket: `mc-sm` `1.5px solid`, `mc-md` `2.5px solid`, `mc-lg` `2px
double`.
- Border color `#666` + dark halo `box-shadow: 0 0 0 1px
rgba(0,0,0,0.5), 0 1px 2px rgba(0,0,0,0.35)` so the border edge is
visible against both Carto Positron (`#f8f9fa`) and Carto Dark Matter
(`#262626`).
- `<div role="img" aria-label="<n> nodes — <breakdown>">` with the count
+ pills wrapped `aria-hidden="true"` so the AT announcement is the
summary, not the literal glyphs.

## V2 role pills

- `ROLE_LETTERS` map (`R` / `C` / `M` / `S` / `O`) is the primary
carrier — visible inside every pill, so protanopes/deuteranopes can read
the role without depending on hue.
- Wong (2011) palette as the secondary carrier, declared as
`--mc-role-repeater/companion/room/sensor/observer` — does NOT touch the
reserved `--info / --warning / --accent` system vars.
- `color: #1a1a1a` on **all five** pills (CSS rule + inline
defense-in-depth). Passes SC 1.4.3 small-text (≥4.5:1) against every
Wong hue.
- Font now `0.625rem/1.1 ui-monospace` (was `9px`, audit bumped to
`10px`, this PR converts to `rem` so user font-size preferences scale
the pill).
- Per-pill `aria-label="<n> <role>s"`, `overflow: visible` so a user
`letter-spacing` override doesn't clip (SC 1.4.12).

## V3 multi-byte hash labels

- `MB_GLYPHS` prefix (`✓` / `?` / `✗`) is the primary non-color status
carrier; the hash text is the data.
- Neutral dark fill `--mc-mb-fill` + colored 3px left border via
per-status `--mc-mb-confirmed/suspected/unknown` (high-luminance set
`#56F0A0` / `#FFD966` / `#FF8888` — audit override of original Tol
"vibrant" set, which failed border-stripe SC 1.4.11).
- Font now `0.75rem/1.2 ui-monospace` (was `11px`, audit bumped to
`12px`, this PR converts to `rem` for SC 1.4.4 robustness).
- `<div role="img" aria-label="multi-byte <status>, hash <ID>"><span
aria-hidden="true">` so AT reads the meaningful label (not the literal
`✓ 3E`). Observer-overlay `★` carries `aria-hidden="true"` for the same
reason. Null `mbStatus` falls through to `"repeater hash <ID>"` cleanly
— no `"multi-byte undefined"`.
- Forced-colors graceful degradation via `@media (forced-colors:
active)` block mapping all three surfaces to `Canvas` / `CanvasText`
with `forced-color-adjust: auto` (NOT `none`).

## TDD red→green

| Commit | Files | CI |
|---|---|---|
| `d48c1add` (red) | `test-issue-1356-map-a11y.js`,
`.github/workflows/deploy.yml` (test + wiring only) | [**failure** — 27
assertion ✗, exit
1](https://github.com/Kpa-clawbot/CoreScope/actions/runs/26411462973) |
| `b94755e6` (green) | `public/map.js`, `public/style.css`,
`test-issue-1356-map-a11y.js` (impl) |
[**success**](https://github.com/Kpa-clawbot/CoreScope/actions/runs/26411699037)
|
| `ac63e6ab` | refactor: drop `MB_COLORS` alias, hoist `MB_MARKER_TINT`
(round-1 #3 + #4) | (round-2) |
| `8aad60cb` | style: font sizes to `rem` for SC 1.4.4 (round-1 #2) |
(round-2) |
| `50a1aab1` | test: round-1 coverage adds + de-tautologise V2.c / V3.h
(round-1 #5) | (round-2) |

Red commit failed on **assertions** (not compile error) — the harness
loaded `public/map.js` + `public/style.css` end-to-end and exhausted all
27 string-presence checks. Green commit lands the audit-overridden
design and clears 32/32. Round-2 commits extend coverage to 40/40
without altering the original red→green gate.

## WCAG SC addressed

- **SC 1.4.1 Use of Color (A)**: cluster size + border-style ramp; pill
capital-letter prefix; MB label glyph prefix. Every visual is now
carried by at least one non-color channel.
- **SC 1.4.3 Contrast Minimum (AA)**: cluster `#fff` count on composited
fill = 10.12:1 vs Positron / 14.64:1 vs Dark Matter. MB label text =
11.48:1 / 14.65:1. Pill `#1a1a1a` on Wong hues: R 5.43, C 9.10, M 6.14,
S 13.16, O 6.86 — all ≥4.5:1.
- **SC 1.4.11 Non-text Contrast (AA)**: cluster border `#666` = 4.83:1
vs Positron, 3.30:1 vs Dark Matter; MB stripes vs `--mc-mb-fill`:
`#56F0A0` 5.13, `#FFD966` 8.66, `#FF8888` 4.62. Stripe-vs-basemap edge
is mitigated by the 1px dark halo box-shadow on `.mc-mb-label`.
- **SC 1.3.1 Info & Relationships (A)**: every divIcon now has
`role="img"` + a descriptive `aria-label`; visible glyph spans are
`aria-hidden="true"` so AT reads the meaning, not the typography.
- **SC 1.4.5 Images of Text (AA)**: implemented surfaces use live text
(`<span>` + `<div>` with CSS font), not rasterised glyphs — user
font-size / zoom scale them. Where SVG markers are used (non-label
path), the textual information is also exposed via `marker.alt` + popup,
satisfying the "essential" exception.

## Manual verification

1. **Both Carto themes on staging.** Open https://analyzer.00id.net and
switch the basemap (Positron and Dark Matter) — cluster bubbles, pills,
and MB labels must remain legible on both. Border edge of cluster bubble
visible on Positron (was the original bug).
2. **Screen-reader (NVDA / VoiceOver) test.**
- Focus a cluster bubble → expect `"<n> nodes — <role breakdown>"` and
NO literal letter/number announce per pill.
- Focus a MB label on a repeater marker → expect `"multi-byte confirmed,
hash 3E"` (or whatever status/hash applies) and NO `"check mark thin
space 3 E"`.
- Observer-also-repeater label → still announces the meaningful label
only; ★ is silent.
3. **Coblis simulation** (or equivalent). Run cluster + pills + MB
labels through deuteranopia / protanopia / tritanopia simulation.
Cluster bucket must be distinguishable by size + border-style (without
hue). Pill role must be distinguishable by the letter (without hue). MB
status must be distinguishable by glyph (without hue).
4. **Windows High Contrast / forced-colors.** Toggle on; all three
surfaces should fall back to `Canvas` / `CanvasText` (no invisible
elements, no `forced-color-adjust: none` regression).

## Out of scope

Filed for separate follow-up issues (audit explicitly tagged these as
either pre-existing or modern-interpretation non-blockers):

1. **SC 2.1.1 Keyboard (A)** — cluster click-to-zoom is mouse-only today
(Leaflet markercluster limitation). Needs `role="button"` + `tabindex=0`
+ `keydown` handler. Pre-existing, not introduced by this PR.
2. **SC 2.4.7 Focus Visible (AA)** — moot until #1 is addressed (no
focusable target). When the cluster becomes focusable, a
`:focus-visible` outline must be added.
3. **`prefers-reduced-motion` gate** — `.mc-cluster:hover { transform:
scale(1.06) }` and the 120ms transition are untouched from pre-PR.
Should be gated on `@media (prefers-reduced-motion: reduce)` in a
follow-up hygiene pass.
4. **px → rem for non-font sizes** — this PR converts font sizes (the SC
1.4.4 sensitive surface). Border widths and small paddings are kept in
px because physical-pixel snapping matters more for borders than user
font-zoom.

Fixes #1356

---------

Co-authored-by: Kpa-clawbot <bot@kpa-clawbot.local>
This commit is contained in:
Kpa-clawbotandKpa-clawbot authored and GitHub committed 2026-05-25 11:38:50 -07:00
1 parent bbd185a826
commit 933ef4e6ef
4 files changed
+371 -32

No files matched your search

+1
View File
@@ -106,6 +106,7 @@ jobs:
node test-issue-1279-p2-code-filter.js
node test-area-filter.js
node test-issue-1293-marker-shapes.js
node test-issue-1356-map-a11y.js
- name: Verify proto syntax
run: |
+79 -19
View File
@@ -25,8 +25,33 @@
// Roles loaded from shared roles.js (ROLE_STYLE, ROLE_LABELS, ROLE_COLORS globals)
// Multi-byte support overlay colors
var MB_COLORS = { confirmed: '#27ae60', suspected: '#f39c12', unknown: '#e74c3c' };
// ── #1356 a11y constants — letter prefix + glyph + neutral fill carriers ──
// ROLE_LETTERS gives each role a single capital-letter primary carrier
// (legible at 10px monospace, survives full grayscale).
var ROLE_LETTERS = {
repeater: 'R',
companion: 'C',
room: 'M',
sensor: 'S',
observer: 'O',
};
// MB_GLYPHS prefix the hash text with a non-color status carrier.
var MB_GLYPHS = {
confirmed: '\u2713', // ✓
suspected: '?',
unknown: '\u2717', // ✗
};
// Per-status CSS class (drives the colored 3px left-border in style.css).
var MB_STATUS_CLASS = {
confirmed: 'status-confirmed',
suspected: 'status-suspected',
unknown: 'status-unknown',
};
// #1356 V3 marker-dot tint set — high-luminance accents that mirror the
// CSS `--mc-mb-confirmed/suspected/unknown` left-border stripe palette so the
// marker-dot and label-stripe surfaces stay visually consistent. Module
// scope (not loop-local) to avoid per-iteration object allocation.
var MB_MARKER_TINT = { confirmed: '#56F0A0', suspected: '#FFD966', unknown: '#FF8888' };
function makeMarkerIcon(role, isStale, isAlsoObserver, colorOverride) {
const s = ROLE_STYLE[role] || ROLE_STYLE.companion;
@@ -97,16 +122,26 @@
});
}
function makeRepeaterLabelIcon(node, isStale, isAlsoObserver, colorOverride) {
var s = ROLE_STYLE['repeater'] || ROLE_STYLE.companion;
function makeRepeaterLabelIcon(node, isStale, isAlsoObserver, mbStatus) {
var hs = node.hash_size || 1;
// Show the short mesh hash ID (first N bytes of pubkey, uppercased)
var shortHash = node.public_key ? node.public_key.slice(0, hs * 2).toUpperCase() : '??';
var bgColor = colorOverride || s.color;
// If this repeater is also an observer, show a star indicator inside the label
var obsIndicator = isAlsoObserver ? ' <span style="color:' + (ROLE_COLORS.observer || '#f1c40f') + ';font-size:13px;line-height:1;" title="Also an observer">★</span>' : '';
var html = '<div style="background:' + bgColor + ';color:#fff;font-weight:bold;font-size:11px;padding:2px 5px;border-radius:3px;border:2px solid #fff;box-shadow:0 1px 3px rgba(0,0,0,0.4);text-align:center;line-height:1.2;white-space:nowrap;">' +
shortHash + obsIndicator + '</div>';
// #1356 V3: glyph is the primary non-color status carrier, hash is the data,
// status color is a thin left-border (CSS class drives the hue).
var status = mbStatus || null;
var glyph = status ? (MB_GLYPHS[status] || MB_GLYPHS.unknown) : '';
var statusClass = status ? (' ' + (MB_STATUS_CLASS[status] || MB_STATUS_CLASS.unknown)) : '';
var ariaStatus = status ? ('multi-byte ' + status + ', hash ' + shortHash)
: ('repeater hash ' + shortHash);
// Observer indicator stays a star — it is an orthogonal signal, not a status color.
var obsIndicator = isAlsoObserver
? ' <span aria-hidden="true" style="color:' + (ROLE_COLORS.observer || '#f1c40f') + ';font-size:13px;line-height:1;" title="Also an observer">★</span>'
: '';
// Glyph + thin-space (U+2009) + hash. Visible content is aria-hidden so AT
// reads the aria-label only (avoids "check mark 3 E" literal announcements).
var visible = (glyph ? glyph + '\u2009' : '') + shortHash;
var html = '<div class="mc-mb-label' + statusClass + '" role="img" aria-label="' + ariaStatus + '">' +
'<span aria-hidden="true">' + visible + '</span>' + obsIndicator + '</div>';
return L.divIcon({
html: html,
className: 'meshcore-marker meshcore-label-marker' + (isStale ? ' marker-stale' : ''),
@@ -947,12 +982,18 @@
const pk = (node.public_key || '').toLowerCase();
const isAlsoObserver = _observerByPubkey.has(pk);
const useLabel = node.role === 'repeater' && filters.hashLabels;
// Multi-byte overlay: color repeaters by multi_byte_status
// #1356 V3: multi-byte status is no longer encoded by label fill color.
// Pass the raw status string to the label icon (it picks glyph + CSS class);
// marker-dot tinting (for non-label rendering) keeps a colorblind-safe hex.
var mbStatus = null;
var mbColor = null;
if (filters.multiByteOverlay && node.role === 'repeater') {
mbColor = MB_COLORS[node.multi_byte_status] || MB_COLORS.unknown;
mbStatus = node.multi_byte_status || 'unknown';
// Marker-dot tint (module-scope MB_MARKER_TINT) — high-luminance accent
// set kept in sync with --mc-mb-* CSS stripes so label + marker agree.
mbColor = MB_MARKER_TINT[mbStatus] || MB_MARKER_TINT.unknown;
}
const icon = useLabel ? makeRepeaterLabelIcon(node, isStale, isAlsoObserver, mbColor) : makeMarkerIcon(node.role || 'companion', isStale, isAlsoObserver, mbColor);
const icon = useLabel ? makeRepeaterLabelIcon(node, isStale, isAlsoObserver, mbStatus) : makeMarkerIcon(node.role || 'companion', isStale, isAlsoObserver, mbColor);
const latLng = L.latLng(node.lat, node.lon);
allMarkers.push({ latLng, node, icon, isLabel: useLabel, popupFn: function() { return buildPopup(node); }, alt: (node.name || 'Unknown') + ' (' + (node.role || 'node') + (isAlsoObserver ? ' + observer' : '') + ')' });
}
@@ -1454,24 +1495,43 @@
var total = (typeof cluster.getChildCount === 'function') ? cluster.getChildCount() : markers.length;
var bucket = total >= 100 ? 'lg' : total >= 30 ? 'md' : 'sm';
var roleOrder = ['repeater', 'companion', 'room', 'sensor', 'observer'];
// #1356 V2: pill background uses the --mc-role-* Wong palette (CSS var),
// pill text is the role letter (primary, monochrome-safe carrier).
// The audit's minimal patch keeps dark text on every Wong hue, so no
// per-role text-color branching is needed.
var ROLE_BG_VAR = {
repeater: 'var(--mc-role-repeater)',
companion: 'var(--mc-role-companion)',
room: 'var(--mc-role-room)',
sensor: 'var(--mc-role-sensor)',
observer: 'var(--mc-role-observer)',
};
var pillsHtml = '';
var tooltipParts = [];
var pillsShown = 0;
var palette = (typeof ROLE_COLORS !== 'undefined') ? ROLE_COLORS : {};
for (var j = 0; j < roleOrder.length; j++) {
var role = roleOrder[j];
var n = counts[role] || 0;
if (n <= 0) continue;
tooltipParts.push(n + ' ' + role + (n === 1 ? '' : 's'));
if (pillsShown < 4) {
var bg = palette[role] || '#6b7280';
pillsHtml += '<span class="mc-pill" style="background:' + bg + '">' + n + '</span>';
var bg = ROLE_BG_VAR[role] || 'var(--mc-role-companion)';
var letter = ROLE_LETTERS[role] || '?';
pillsHtml += '<span class="mc-pill role-' + role + '" ' +
'role="img" aria-label="' + n + ' ' + role + (n === 1 ? '' : 's') + '" ' +
'style="background:' + bg + ';color:#1a1a1a" ' +
'title="' + n + ' ' + role + (n === 1 ? '' : 's') + '">' +
letter + '</span>';
pillsShown += 1;
}
}
var html = '<div class="mc-cluster mc-' + bucket + '">' +
'<b class="mc-count">' + total + '</b>' +
'<div class="mc-pills">' + pillsHtml + '</div>' +
// #1356 V1: cluster gets role="img" + an aria-label summarising the
// count and per-role breakdown so screen readers announce the data.
var ariaLabel = total + ' nodes — ' + tooltipParts.join(', ');
var html = '<div class="mc-cluster mc-' + bucket + '" ' +
'role="img" aria-label="' + ariaLabel + '">' +
'<b class="mc-count" aria-hidden="true">' + total + '</b>' +
'<div class="mc-pills" aria-hidden="true">' + pillsHtml + '</div>' +
'</div>';
var icon = L.divIcon({
html: html,
@@ -1481,7 +1541,7 @@
// Stash a tooltip string for callers that want to bindTooltip (markercluster
// does not natively pipe this through, but it's available via cluster icon
// for E2E inspection).
icon._tooltip = total + ' nodes — ' + tooltipParts.join(', ');
icon._tooltip = ariaLabel;
return icon;
}
+91 -13
View File
@@ -3322,32 +3322,110 @@ th.sort-active { color: var(--accent, #60a5fa); }
.tools-card h3 { margin: 0 0 4px 0; font-size: 16px; }
.tools-card p { margin: 0; font-size: 13px; color: var(--text-muted); }
/* ── Map marker clustering (issue #1036) ── */
/* ── Map marker clustering (issue #1036, a11y refit issue #1356) ──
*
* #1356 WCAG 2.2 AA refit — Tufte structural framing + audit minimal patch.
* Design source: github.com/Kpa-clawbot/CoreScope/issues/1356 (Tufte + audit comments).
*
* Carriers (NON-color) of meaning, per WCAG 1.4.1:
* - V1 cluster bubbles: size (40/48/56px) + numeral + border-style ramp
* (1.5px solid / 2.5px solid / 2px double). Fill is a single neutral.
* - V2 role pills: capital-letter prefix (R/C/M/S/O). Wong (2011) palette
* hue is secondary. Dark text (#1a1a1a) on ALL five pills (audit override
* so only ONE text-color rule is needed and every pill passes 4.5:1).
* - V3 multi-byte hash labels: unicode glyph prefix (✓/?/✗) + neutral fill
* + 3px colored left-border using the audit's high-luminance accent set
* (NOT Tol "vibrant" — those failed 3:1 vs the neutral fill).
*
* Constants are --mc-* namespaced. The reserved --info / --warning / --accent
* system vars are NOT touched (per issue scope + AGENTS.md).
*/
:root {
/* V1 — cluster bubble */
--mc-cluster-fill: rgba(33, 41, 54, 0.88);
--mc-cluster-text: #ffffff;
--mc-cluster-border: #666666; /* audit: white border = 1.05:1 vs Carto-light; #666 = 4.83:1 */
/* V2 — role pills (Wong 2011 colorblind-safe palette) */
--mc-role-repeater: #D55E00; /* vermillion */
--mc-role-companion: #56B4E9; /* sky blue */
--mc-role-room: #009E73; /* bluish-green */
--mc-role-sensor: #F0E442; /* yellow */
--mc-role-observer: #CC79A7; /* reddish-purple */
/* V3 — multi-byte hash labels (neutral fill + high-luminance accent stripes) */
--mc-mb-fill: rgba(33, 41, 54, 0.92);
--mc-mb-text: #ffffff;
--mc-mb-confirmed: #56F0A0; /* audit override of Tol vibrant for fill contrast */
--mc-mb-suspected: #FFD966;
--mc-mb-unknown: #FF8888;
}
.mc-cluster-wrap { background: transparent !important; border: 0 !important; }
.mc-cluster {
width: 48px; height: 48px; border-radius: 50%;
display: flex; flex-direction: column; align-items: center; justify-content: center;
font-family: var(--font, system-ui, sans-serif);
color: #fff; text-shadow: 0 1px 2px rgba(0,0,0,0.5);
border: 2px solid rgba(255,255,255,0.85);
box-shadow: 0 2px 6px rgba(0,0,0,0.35);
background: var(--mc-cluster-fill);
color: var(--mc-cluster-text); text-shadow: 0 1px 2px rgba(0,0,0,0.5);
border: 2px solid var(--mc-cluster-border);
/* Dark halo + soft shadow — audit fix so the border edge is visible vs Carto-light */
box-shadow: 0 0 0 1px rgba(0,0,0,0.5), 0 1px 2px rgba(0,0,0,0.35);
cursor: pointer;
transition: transform 120ms ease;
}
.mc-cluster:hover { transform: scale(1.06); }
.mc-cluster.mc-sm { background: var(--info, #2563eb); width: 40px; height: 40px; }
.mc-cluster.mc-md { background: var(--warning, #d97706); width: 48px; height: 48px; }
.mc-cluster.mc-lg { background: var(--accent, #dc2626); width: 56px; height: 56px; }
.mc-cluster .mc-count { font-size: 14px; font-weight: 700; line-height: 1; }
.mc-cluster.mc-lg .mc-count { font-size: 16px; }
/* Border-style ramp is the redundant non-color carrier of the count bucket. */
.mc-cluster.mc-sm { width: 40px; height: 40px; border-width: 1.5px; border-style: solid; }
.mc-cluster.mc-md { width: 48px; height: 48px; border-width: 2.5px; border-style: solid; }
.mc-cluster.mc-lg { width: 56px; height: 56px; border-width: 2px; border-style: double; }
.mc-cluster .mc-count { font-size: 0.875rem; font-weight: 700; line-height: 1; font-variant-numeric: tabular-nums; }
.mc-cluster.mc-lg .mc-count { font-size: 1rem; }
.mc-cluster .mc-pills {
display: flex; gap: 2px; margin-top: 3px;
}
.mc-cluster .mc-pill {
display: inline-block; min-width: 12px; padding: 0 3px;
border-radius: 6px; font-size: 9px; font-weight: 600; line-height: 12px;
color: #fff; text-align: center; text-shadow: none;
border: 1px solid rgba(255,255,255,0.4);
display: inline-block; min-width: 12px; padding: 1px 3px;
border-radius: 3px;
/* Audit: bump 9px → 10px, monospace, dark text on every Wong hue.
#1a1a1a on all 5 Wong hues passes SC 1.4.3 small-text (≥4.5:1).
Sized in rem (0.625rem = 10px @ default 16px root) so user
font-size preferences scale the pill (SC 1.4.4 Resize Text 200%). */
font: 700 0.625rem/1.1 ui-monospace, "SF Mono", Consolas, monospace;
letter-spacing: 0;
color: #1a1a1a; text-align: center; text-shadow: none;
border: 1px solid rgba(0,0,0,0.25);
overflow: visible; /* SC 1.4.12 — user letter-spacing override must not clip */
}
/* V3 — multi-byte hash labels: neutral fill + colored 3px left border */
.mc-mb-label {
background: var(--mc-mb-fill);
color: var(--mc-mb-text);
/* Sized in rem (0.75rem = 12px @ default root) so user font-size
preferences scale the label per SC 1.4.4 Resize Text 200%. */
font: 600 0.75rem/1.2 ui-monospace, "SF Mono", Consolas, monospace;
letter-spacing: 0.02em;
padding: 2px 5px 2px 4px;
border-left: 3px solid transparent;
border-radius: 2px;
box-shadow: 0 0 0 1px rgba(0,0,0,0.5), 0 1px 2px rgba(0,0,0,0.35);
white-space: nowrap;
text-align: center;
line-height: 1.2;
}
.mc-mb-label.status-confirmed { border-left-color: var(--mc-mb-confirmed); }
.mc-mb-label.status-suspected { border-left-color: var(--mc-mb-suspected); }
.mc-mb-label.status-unknown { border-left-color: var(--mc-mb-unknown); }
/* Forced-colors / Windows High Contrast — degrade gracefully (audit item 7). */
@media (forced-colors: active) {
.mc-cluster, .mc-pill, .mc-mb-label {
forced-color-adjust: auto;
background: Canvas;
color: CanvasText;
border-color: CanvasText;
}
}
/* === #1034 PR1: Channel Add modal + sectioned sidebar === */
+200
View File
@@ -0,0 +1,200 @@
/**
* #1356 — WCAG 2.2 AA accessibility for map cluster bubbles, role pills,
* and multi-byte hash labels.
*
* Locked design = Tufte's structural framing (drop color as primary signal,
* use shape / glyph / border-style as carriers) WITH the audit's "Minimal
* patch to Tufte's proposal to reach AA" applied.
*
* Design sources:
* - https://github.com/Kpa-clawbot/CoreScope/issues/1356#issuecomment-4535244400
* - https://github.com/Kpa-clawbot/CoreScope/issues/1356#issuecomment-4535849354
*
* Pure-string assertions (mirrors test-issue-1293-marker-shapes.js pattern)
* so this runs in the JS-unit-tests CI step without a browser.
*/
'use strict';
const fs = require('fs');
const path = require('path');
let passed = 0, failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(' ✓ ' + msg); }
else { failed++; console.error(' ✗ ' + msg); }
}
const mapSrc = fs.readFileSync(path.join(__dirname, 'public', 'map.js'), 'utf8');
const cssSrc = fs.readFileSync(path.join(__dirname, 'public', 'style.css'), 'utf8');
console.log('\n=== #1356 V1: cluster bubble — neutral fill, border-style ramp, ARIA ===');
// V1.a — CSS must define a neutral cluster fill constant (not the bucket color).
assert(/--mc-cluster-fill\s*:/.test(cssSrc),
'style.css declares --mc-cluster-fill CSS variable');
// V1.b — Per-bucket background MUST NOT be the old --info/--warning/--accent system colors.
// (Those system vars are reserved per AGENTS.md / issue scope.)
const clusterBlock = cssSrc.match(/\.mc-cluster\.mc-sm[\s\S]{0,400}\.mc-cluster\.mc-lg[^}]*\}/);
assert(clusterBlock && !/var\(--info|var\(--warning|var\(--accent/.test(clusterBlock[0]),
'cluster sm/md/lg no longer use --info / --warning / --accent for fill');
// V1.c — Border-style ramp (solid → heavier → double) is the redundant carrier.
assert(/\.mc-cluster\.mc-lg[^}]*double/.test(cssSrc),
'cluster lg uses "double" border-style as a non-color carrier');
// V1.d — Audit override: border color must be #666 (NOT white) plus a dark halo via box-shadow.
assert(/--mc-cluster-border\s*:\s*#666/i.test(cssSrc),
'--mc-cluster-border is #666 (audit fix for SC 1.4.11 vs Carto-light)');
assert(/\.mc-cluster[^{]*\{[\s\S]*?box-shadow[^;]*rgba\(0\s*,\s*0\s*,\s*0/i.test(cssSrc),
'.mc-cluster has a dark halo box-shadow (audit fix for border visibility)');
// V1.e — ARIA on the cluster div (rendered in makeClusterIcon).
assert(/role=["']img["']/.test(mapSrc) && /aria-label[^=]*=[^>]*nodes/.test(mapSrc),
'makeClusterIcon emits role="img" + aria-label summarising count + role breakdown');
assert(/' nodes — '/.test(mapSrc) || /\d+ nodes — /.test(mapSrc) ||
/total\s*\+\s*' nodes — '/.test(mapSrc),
'cluster aria-label matches /\\d+ nodes — / pattern (summary + breakdown)');
console.log('\n=== #1356 V2: role pills — letter primary, Wong palette, dark text ===');
// V2.a — A ROLE_LETTERS map is defined for the 5 roles.
assert(/ROLE_LETTERS\s*=\s*\{[\s\S]*?repeater[\s\S]*?['"]R['"][\s\S]*?companion[\s\S]*?['"]C['"][\s\S]*?room[\s\S]*?['"]M['"][\s\S]*?sensor[\s\S]*?['"]S['"][\s\S]*?observer[\s\S]*?['"]O['"]/.test(mapSrc),
'map.js defines ROLE_LETTERS with R/C/M/S/O for the five roles');
// V2.b — makeClusterIcon emits the letter (not just a count) inside the pill.
const pillEmitRe = /<span class="mc-pill[^>]*>[^<]*' \+\s*ROLE_LETTERS\[/;
assert(pillEmitRe.test(mapSrc) || /ROLE_LETTERS\[role\][\s\S]{0,200}mc-pill/.test(mapSrc) ||
/mc-pill[\s\S]{0,200}ROLE_LETTERS\[role\]/.test(mapSrc),
'pill HTML embeds ROLE_LETTERS[role] as the primary content');
// V2.c — Dark text on ALL five pills (audit override of Tufte's per-pill switch).
// Require the CSS rule `.mc-pill { color: #1a1a1a }` (authoritative).
// The inline-style fallback alone is NOT enough: a regression that drops the
// CSS rule but keeps a stray inline style would still green, masking the
// theming-illusion bug (round-1 adversarial #5 short-circuit).
assert(/\.mc-pill\b[^{]*\{[^}]*color\s*:\s*#1a1a1a/i.test(cssSrc),
'.mc-pill CSS rule sets color #1a1a1a (authoritative, not just inline-style fallback)');
assert(/class="mc-pill[^"]*"[^>]*style="[^"]*color:\s*#1a1a1a/i.test(mapSrc),
'.mc-pill render-site also emits inline color #1a1a1a (defense-in-depth for divIcon)');
// V2.d — font-size ≥ 10px (audit bumped from 9px).
const pillFontMatch = cssSrc.match(/\.mc-pill\b[^{]*\{[^}]*font[^;]*;/);
assert(pillFontMatch && /1[0-9]px|0\.625rem|0\.6875rem|0\.75rem/.test(pillFontMatch[0]),
'.mc-pill font-size is ≥ 10px (audit fix for SC 1.4.3 / 1.4.4)');
// V2.e — Wong palette declared as --mc-role-* constants.
['repeater','companion','room','sensor','observer'].forEach(function(r){
assert(new RegExp('--mc-role-' + r + '\\s*:').test(cssSrc),
'--mc-role-' + r + ' CSS variable declared');
});
// V2.f — per-pill aria-label "<N> <role>s".
assert(/aria-label="'\s*\+\s*n\s*\+\s*' '\s*\+\s*role/.test(mapSrc) ||
/aria-label=("|')[\s\S]{0,80}\+\s*n\s*\+[\s\S]{0,80}\+\s*role/.test(mapSrc),
'pill HTML emits aria-label with count + role');
// V2.g — DO NOT touch --info / --warning / --accent (out of scope hard rule).
const mcRoleBlock = cssSrc.match(/--mc-role-[\s\S]{0,1500}/);
assert(mcRoleBlock && !/--info\s*:|--warning\s*:|--accent\s*:/.test(mcRoleBlock[0]),
'role pill constants are --mc-* namespaced (do not redefine --info/--warning/--accent)');
console.log('\n=== #1356 V3: multi-byte hash labels — glyph + neutral fill + colored border-left ===');
// V3.a — MB_GLYPHS map for ✓ / ? / ✗.
assert(/MB_GLYPHS\s*=\s*\{[\s\S]*?confirmed[\s\S]*?['"\\]u2713|MB_GLYPHS\s*=\s*\{[\s\S]*?confirmed[\s\S]*?['"]\u2713['"]/.test(mapSrc) ||
/MB_GLYPHS\s*=\s*\{[\s\S]*?confirmed[\s\S]*?['"]✓['"]/.test(mapSrc),
'map.js defines MB_GLYPHS with ✓ for confirmed');
assert(/MB_GLYPHS[\s\S]*?suspected[\s\S]*?['"]\?['"]/.test(mapSrc),
'MB_GLYPHS.suspected === "?"');
assert(/MB_GLYPHS[\s\S]*?unknown[\s\S]*?['"\\]u2717|MB_GLYPHS[\s\S]*?unknown[\s\S]*?['"]✗['"]/.test(mapSrc),
'MB_GLYPHS.unknown === ✗ (u2717)');
// V3.b — Neutral fill constant for multi-byte label.
assert(/--mc-mb-fill\s*:/.test(cssSrc),
'--mc-mb-fill CSS variable declared (neutral fill, not status color)');
// V3.c — High-luminance accent set (audit override of Tol "vibrant").
// Confirmed #56F0A0 / suspected #FFD966 / unknown #FF8888.
assert(/--mc-mb-confirmed\s*:\s*#56F0A0/i.test(cssSrc),
'--mc-mb-confirmed is #56F0A0 (audit high-luminance set, not #117733)');
assert(/--mc-mb-suspected\s*:\s*#FFD966/i.test(cssSrc),
'--mc-mb-suspected is #FFD966');
assert(/--mc-mb-unknown\s*:\s*#FF8888/i.test(cssSrc),
'--mc-mb-unknown is #FF8888');
// V3.d — 3px colored left border in style.
assert(/border-left\s*:\s*3px solid/.test(cssSrc),
'.mc-mb-label has 3px solid border-left (colored accent stripe)');
// V3.e — makeRepeaterLabelIcon prepends MB_GLYPHS[status].
assert(/MB_GLYPHS\[[^\]]+\][\s\S]{0,200}shortHash|shortHash[\s\S]{0,200}MB_GLYPHS\[/.test(mapSrc),
'makeRepeaterLabelIcon prepends MB_GLYPHS glyph to the hash text');
// V3.f — aria-label "multi-byte <status>, hash <ID>".
assert(/aria-label="'\s*\+\s*ariaStatus\s*\+\s*'"/.test(mapSrc) ||
/'multi-byte '\s*\+\s*status\s*\+\s*', hash '\s*\+\s*shortHash/.test(mapSrc) ||
/aria-label="multi-byte \$\{[^}]+\}, hash \$\{shortHash\}"/.test(mapSrc),
'makeRepeaterLabelIcon emits aria-label "multi-byte <status>, hash <ID>"');
// V3.g — Glyph span must be aria-hidden so AT does not read "check mark 3 E".
assert(/<span aria-hidden="true">[\s\S]{0,100}shortHash|<span aria-hidden="true">'\s*\+\s*(?:glyph|visible)/.test(mapSrc) ||
/aria-hidden="true">'\s*\+\s*visible/.test(mapSrc),
'visible glyph+hash span is aria-hidden="true" (AT reads aria-label only)');
// V3.h — repeater label MUST use the neutral fill via var(--mc-mb-fill); MUST
// NOT paint background per-status (that would re-enable the pre-#1356
// color-only signal). Affirmative check on the neutral-fill rule AND
// negative check on the per-status bgColor pattern (round-1 adversarial #5:
// the prior `!removal || affirmative` form short-circuited to a tautology).
assert(/\.mc-mb-label\b[^{]*\{[^}]*background\s*:\s*var\(--mc-mb-fill\)/.test(cssSrc),
'.mc-mb-label background uses var(--mc-mb-fill) — neutral fill, not status color');
assert(!/bgColor\s*=\s*colorOverride\s*\|\|\s*s\.color/.test(mapSrc),
'old per-status bgColor pattern is gone (no per-status background painting)');
console.log('\n=== #1356 Round-1 coverage adds: dual-marker star, null mbStatus, forced-colors ===');
// COV-1 — Observer-also-repeater dual marker: the ★ star glyph inside
// makeRepeaterLabelIcon's obsIndicator branch MUST carry aria-hidden="true",
// otherwise the AT announcement is polluted with "black star" / "star" on
// top of the meaningful aria-label. Round-1 (Kent + adversarial) flagged.
// Match the exact obsIndicator construction shape: `isAlsoObserver ? ' <span aria-hidden="true" ... ★`.
assert(/isAlsoObserver[\s\S]{0,40}\?\s*['"][^'"]*<span\s+aria-hidden="true"[^>]*>[^<]*★/.test(mapSrc),
'observer-also-repeater star span carries aria-hidden="true" (no AT pollution)');
// COV-2 — makeRepeaterLabelIcon with no multi_byte_status field must NOT emit
// an aria-label containing "multi-byte undefined" (the obvious bug if the
// null-fallback branch is dropped). Verify the source has the explicit
// `mbStatus || null` + truthy-check structure that prevents this.
assert(/var\s+status\s*=\s*mbStatus\s*\|\|\s*null\s*;/.test(mapSrc),
'makeRepeaterLabelIcon normalises missing mbStatus to null (not "undefined")');
assert(/ariaStatus\s*=\s*status\s*\?\s*\(\s*['"]multi-byte\s/.test(mapSrc),
'ariaStatus uses ternary on truthy `status` — null falls through to "repeater hash <ID>" branch');
// Negative regression: no template/concat that would ever produce "multi-byte undefined".
assert(!/['"]multi-byte\s*['"]\s*\+\s*mbStatus(?![^,]*\?)/.test(mapSrc),
'no unconditional concat of "multi-byte " + mbStatus (would emit "multi-byte undefined" on null)');
// COV-3 — @media (forced-colors: active) block MUST exist in style.css AND
// MUST NOT contain `forced-color-adjust: none` anywhere within its body
// (audit explicitly warned against `none`; degrades High Contrast Mode).
const fcMatch = cssSrc.match(/@media\s*\(\s*forced-colors\s*:\s*active\s*\)\s*\{[\s\S]*?\n\}/);
assert(fcMatch, '@media (forced-colors: active) block present in style.css');
assert(fcMatch && !/forced-color-adjust\s*:\s*none/i.test(fcMatch[0]),
'@media (forced-colors: active) block does NOT use forced-color-adjust: none (audit regression guard)');
console.log('\n=== #1356 Hard rules: --info / --warning / --accent untouched ===');
// Sanity: ensure new --mc-* constants don't redefine the reserved system vars.
// (--info and --warning are only used via var(..., fallback) — they may not be declared
// at all; --accent IS declared.)
const newConstantsBlock = (cssSrc.match(/\/\*[^*]*#1356[\s\S]*?\*\/[\s\S]*?(?=\/\*|$)/) || ['', ''])[0];
assert(!/--info\s*:|--warning\s*:|--accent\s*:/.test(newConstantsBlock),
'#1356 CSS block does not redefine --info / --warning / --accent');
assert(/--accent\s*:/.test(cssSrc), '--accent CSS variable still defined');
console.log('\n=== Summary ===');
console.log(` Passed: ${passed}`);
console.log(` Failed: ${failed}`);
if (failed > 0) { console.error('\n#1356 FAIL'); process.exit(1); }
console.log('\n#1356 PASS');