diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d7050edf..580e8381 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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: | diff --git a/public/map.js b/public/map.js index 751a2cfc..3f39b777 100644 --- a/public/map.js +++ b/public/map.js @@ -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 ? ' ' : ''; - var html = '
' + - shortHash + obsIndicator + '
'; + // #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 + ? ' ' + : ''; + // 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 = ''; 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 += '' + n + ''; + var bg = ROLE_BG_VAR[role] || 'var(--mc-role-companion)'; + var letter = ROLE_LETTERS[role] || '?'; + pillsHtml += '' + + letter + ''; pillsShown += 1; } } - var html = '
' + - '' + total + '' + - '
' + pillsHtml + '
' + + // #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 = '
' + + '' + + '' + '
'; 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; } diff --git a/public/style.css b/public/style.css index f609500d..0c1ba82a 100644 --- a/public/style.css +++ b/public/style.css @@ -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 === */ diff --git a/test-issue-1356-map-a11y.js b/test-issue-1356-map-a11y.js new file mode 100644 index 00000000..3ce77233 --- /dev/null +++ b/test-issue-1356-map-a11y.js @@ -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 = /]*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 " 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 , hash ". +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 , hash "'); + +// V3.g — Glyph span must be aria-hidden so AT does not read "check mark 3 E". +assert(/