Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
98 changes: 79 additions & 19 deletions public/map.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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' : ''),
Expand Down Expand Up @@ -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' : '') + ')' });
}
Expand Down Expand Up @@ -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,
Expand All @@ -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;
}

Expand Down
104 changes: 91 additions & 13 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 === */
Expand Down
Loading