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 = '
' +
- '
' + 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 = '
' +
+ '
' + total + '' +
+ '
' + pillsHtml + '
' +
'
';
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(/[\s\S]{0,100}shortHash|'\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 ? ' ]*>[^<]*★/.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 " 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');