feat(1488): customize marker stroke (color, width, opacity) (#1494)

## Summary

Reporter (@EldoonNemar in #1488) found the new white marker stroke
overwhelming with hundreds of nodes on screen. This PR exposes the
stroke through CSS vars + a customizer panel so operators can dial
color/width/opacity (or remove it) without code edits.

**Scope:** ship stroke customization only. The reporter also asked for
the old glow-style highlight ring as an alternative — that's a separate
visual feature that needs design discussion, so it's deferred to a
follow-up issue.

## Changes

- **`public/style.css`** `:root` declares `--mc-marker-stroke-color` /
`--mc-marker-stroke-width` / `--mc-marker-stroke-opacity` with sensible
defaults (white, 1, 1) that match current behavior.
- **`public/roles.js`** `makeRoleMarkerSVG` — replaced the 6 baked
`stroke="#fff" stroke-width="1"` literals with a single shared
`strokeAttr` referencing the CSS vars. One source of truth for all role
shapes.
- **`public/map.js`** `makeMarkerIcon` — same migration. The observer
star overlay keeps its narrow 0.8 width but routes color + opacity
through the same vars.
- **`public/live.js`** `addNodeMarker` fallback SVG — same migration.
- **`public/customize-v2.js`** — new `markerStroke` object section
(color/width/opacity) with validation, `applyCSS` writes, three controls
on the Colors tab → "Marker Stroke" panel (color picker + width slider
0–4 + opacity slider 0–100%). Optimistic CSS-var writes on the `input`
event so markers repaint live as the operator drags.
- **`cmd/server/{config,types,routes}.go`** — `ThemeFile` / `Config` /
`ThemeResponse` pick up `MarkerStroke` so `theme.json` and `config.json`
can ship server-side defaults. Defaults mirror the `:root` CSS values so
no breaking change for current operators.
- **`config.example.json`** — documented `markerStroke` section with
usage hint.

## TDD

- **Red commit** `92183f95` — `test-issue-1488-marker-stroke-vars.js` (5
sections, 18 assertions); failed 14/18 before implementation.
- **Green commit** `ce39637e` — implementation; same test now passes
18/18.
- Existing `#1438` (marker CSS-var migration) and `#1293` (marker
shapes) regression tests still pass.
- Go tests (`cmd/server/...`) all green.

## CDP validation

Synthetic page with 600 markers, three blocks proving CSS-var control
works end-to-end:

| Block | Stroke setting | Computed `getComputedStyle().stroke` / width
/ opacity |
| --- | --- | --- |
| Default | `var(--mc-marker-stroke-color)` (no override) |
`rgba(255,255,255,0.85)` / `1px` / `1` |
| Tuned | inline `--mc-marker-stroke-*` (operator override) |
`rgb(255,255,255)` / `0.5px` / `0.3` |
| Cyan | inline `--mc-marker-stroke-*` (branding/CB) | `rgb(0,229,255)`
/ `2px` / `1` |

Same SVG source, three different rendered strokes — that's the whole
point. Runtime `documentElement.style.setProperty(...)` (which is
exactly what the customizer slider's `input` handler does) repaints
mounted markers without reload. CDP screenshot attached to the
implementation note.

## Hot-deploy

Frontend + Go binary changes. Safe to hot-deploy frontend files
(`public/*.js`, `public/style.css`) via the standard staging path; Go
binary update needs a container restart.

## Defer

Glow highlight ring (the second half of #1488) — separate follow-up
issue. This PR delivers the immediately-useful, smaller deliverable.

Partial fix for #1488 (stroke customization shipped; glow ring deferred
to a follow-up issue).

---------

Co-authored-by: meshcore-bot <bot@meshcore.local>
This commit is contained in:
Kpa-clawbot
2026-05-29 07:31:36 -07:00
committed by GitHub
parent c841dbccdd
commit ca2c3d6c79
10 changed files with 270 additions and 22 deletions
+8
View File
@@ -48,6 +48,12 @@ type Config struct {
TypeColors map[string]interface{} `json:"typeColors"`
Home map[string]interface{} `json:"home"`
// #1488 — marker stroke (outline) settings. Operators dial color, width
// and opacity to soften the default white outline when hundreds of
// nodes feel overwhelming. Frontend reads these as CSS vars; see
// public/customize-v2.js applyCSS markerStroke block.
MarkerStroke map[string]interface{} `json:"markerStroke,omitempty"`
MapDefaults struct {
Center []float64 `json:"center"`
Zoom int `json:"zoom"`
@@ -329,6 +335,8 @@ type ThemeFile struct {
NodeColors map[string]interface{} `json:"nodeColors"`
TypeColors map[string]interface{} `json:"typeColors"`
Home map[string]interface{} `json:"home"`
// #1488 — marker stroke overlay from theme.json.
MarkerStroke map[string]interface{} `json:"markerStroke,omitempty"`
}
func LoadConfig(baseDirs ...string) (*Config, error) {
+16 -6
View File
@@ -523,13 +523,23 @@ func (s *Server) handleConfigTheme(w http.ResponseWriter, r *http.Request) {
}
home := mergeMap(defaultHome, s.cfg.Home, theme.Home)
// #1488 — marker stroke overlay. Defaults mirror the :root values in
// public/style.css so a fresh visitor with no config + no override
// still gets the same painted outline as the static CSS fallback.
markerStroke := mergeMap(map[string]interface{}{
"color": "rgba(255,255,255,0.85)",
"width": 1,
"opacity": 1,
}, s.cfg.MarkerStroke, theme.MarkerStroke)
writeJSON(w, ThemeResponse{
Branding: branding,
Theme: themeColors,
ThemeDark: themeDark,
NodeColors: nodeColors,
TypeColors: typeColors,
Home: home,
Branding: branding,
Theme: themeColors,
ThemeDark: themeDark,
NodeColors: nodeColors,
TypeColors: typeColors,
Home: home,
MarkerStroke: markerStroke,
})
}
+3
View File
@@ -979,6 +979,9 @@ type ThemeResponse struct {
NodeColors map[string]interface{} `json:"nodeColors"`
TypeColors map[string]interface{} `json:"typeColors"`
Home interface{} `json:"home"`
// #1488 — marker stroke overlay so the frontend can apply server-side
// defaults before the operator's localStorage override loads.
MarkerStroke map[string]interface{} `json:"markerStroke,omitempty"`
}
type MapConfigResponse struct {
+6
View File
@@ -47,6 +47,12 @@
"observer": "#8b5cf6",
"_comment": "Marker/badge colors per node role. Used on map, nodes list, and live feed."
},
"markerStroke": {
"color": "rgba(255,255,255,0.85)",
"width": 1,
"opacity": 1,
"_comment": "#1488 — outline around each map marker (live + map pages). 'color' accepts any CSS color (hex, rgb, rgba); use rgba() or drop 'opacity' below ~0.5 to soften the outline when hundreds of nodes feel overwhelming. 'width' is the SVG stroke width (0 hides the outline entirely). Operators can override per-browser via the in-app Theme Customizer (Colors tab → Marker Stroke)."
},
"mapDarkTileProvider": "carto-dark",
"_comment_mapDarkTileProvider": "Default dark-mode basemap provider. Allowed: 'carto-dark' (Carto dark_all — default), 'esri-darkgray-labels' (Esri Dark Gray Canvas + reference labels), 'voyager-inverted' (Carto Voyager with CSS invert filter), 'positron-inverted' (Carto Positron with CSS invert filter). Light mode is unaffected. Users can override per-browser via the in-app customizer (persisted to localStorage). #1420.",
"home": {
+108 -2
View File
@@ -33,8 +33,8 @@
'meshcore-live-heatmap-opacity'
];
var VALID_SECTIONS = ['branding', 'theme', 'themeDark', 'nodeColors', 'typeColors', 'home', 'timestamps', 'heatmapOpacity', 'liveHeatmapOpacity', 'distanceUnit', 'favorites', 'myNodes'];
var OBJECT_SECTIONS = ['branding', 'theme', 'themeDark', 'nodeColors', 'typeColors', 'home', 'timestamps'];
var VALID_SECTIONS = ['branding', 'theme', 'themeDark', 'nodeColors', 'typeColors', 'home', 'timestamps', 'heatmapOpacity', 'liveHeatmapOpacity', 'distanceUnit', 'favorites', 'myNodes', 'markerStroke'];
var OBJECT_SECTIONS = ['branding', 'theme', 'themeDark', 'nodeColors', 'typeColors', 'home', 'timestamps', 'markerStroke'];
var SCALAR_SECTIONS = ['heatmapOpacity', 'liveHeatmapOpacity'];
var DISTANCE_UNIT_VALUES = ['km', 'mi', 'auto'];
@@ -409,6 +409,32 @@
} else {
console.warn('[customizer-v2] Invalid opacity value rejected:', key, delta[key]);
}
} else if (key === 'markerStroke' && typeof delta[key] === 'object' && delta[key] !== null) {
// #1488 — markerStroke { color, width, opacity }
var msIn = delta[key];
var msOut = {};
if (typeof msIn.color === 'string' && isValidColor(msIn.color)) {
msOut.color = msIn.color;
} else if (msIn.color != null) {
console.warn('[customizer-v2] Invalid markerStroke.color rejected:', msIn.color);
}
var msW = typeof msIn.width === 'string' ? parseFloat(msIn.width) : msIn.width;
if (msIn.width != null) {
if (isFinite(msW) && msW >= 0 && msW <= 10) {
msOut.width = msW;
} else {
console.warn('[customizer-v2] Invalid markerStroke.width rejected:', msIn.width);
}
}
var msO = typeof msIn.opacity === 'string' ? parseFloat(msIn.opacity) : msIn.opacity;
if (msIn.opacity != null) {
if (isValidOpacity(msO)) {
msOut.opacity = msO;
} else {
console.warn('[customizer-v2] Invalid markerStroke.opacity rejected:', msIn.opacity);
}
}
if (Object.keys(msOut).length) clean[key] = msOut;
} else if (key === 'timestamps' && typeof delta[key] === 'object' && delta[key] !== null) {
var ts = {};
var tsrc = delta[key];
@@ -637,6 +663,22 @@
localStorage.setItem('meshcore-live-heatmap-opacity', effectiveConfig.liveHeatmapOpacity);
}
// #1488 — marker stroke: drive CSS vars from effective config. SVG
// markers across map.js / live.js / roles.js all read these vars, so
// a single write here repaints every mounted marker without a reload.
var ms = effectiveConfig.markerStroke;
if (ms && typeof ms === 'object') {
if (typeof ms.color === 'string' && ms.color) {
root.setProperty('--mc-marker-stroke-color', ms.color);
}
if (ms.width != null && isFinite(ms.width)) {
root.setProperty('--mc-marker-stroke-width', String(ms.width));
}
if (ms.opacity != null && isFinite(ms.opacity)) {
root.setProperty('--mc-marker-stroke-opacity', String(ms.opacity));
}
}
// Distance unit → sync to localStorage for all pages
if (typeof effectiveConfig.distanceUnit === 'string' && DISTANCE_UNIT_VALUES.indexOf(effectiveConfig.distanceUnit) >= 0) {
localStorage.setItem('meshcore-distance-unit', effectiveConfig.distanceUnit);
@@ -1262,6 +1304,15 @@
var liveHeatOpacity = typeof eff.liveHeatmapOpacity === 'number' ? eff.liveHeatmapOpacity : 0.3;
var liveHeatPct = Math.round(liveHeatOpacity * 100);
// #1488 — marker stroke controls. Defaults match the :root values in
// style.css; the UI shows the effective merged value (server config →
// local override) so the operator sees what's actually painted.
var ms = eff.markerStroke || {};
var msColor = typeof ms.color === 'string' && ms.color ? ms.color : '#ffffff';
var msWidth = ms.width != null && isFinite(ms.width) ? Number(ms.width) : 1;
var msOpacity = ms.opacity != null && isFinite(ms.opacity) ? Number(ms.opacity) : 1;
var msOpacityPct = Math.round(msOpacity * 100);
return '<div class="cust-panel' + (_activeTab === 'nodes' ? ' active' : '') + '" data-panel="nodes">' +
'<p class="cust-section-title">Node Role Colors</p>' +
'<p class="cust-hint" style="margin-bottom:8px">These are the canonical role colors used across the app. They inherit from your server config (or built-in defaults), and can be optionally remapped by a colorblind-safe preset below.</p>' +
@@ -1279,6 +1330,22 @@
'<div class="cust-hint">Heatmap overlay on the Live page (0100%)</div></div>' +
'<input type="range" data-cv2-slider="liveHeatmapOpacity" min="0" max="100" value="' + liveHeatPct + '" style="width:120px;cursor:pointer">' +
'<span class="cust-hex" id="cv2LiveHeatPct">' + liveHeatPct + '%</span></div>' +
'<hr style="border:none;border-top:1px solid var(--border);margin:16px 0">' +
'<p class="cust-section-title">Marker Stroke <span style="font-weight:normal;color:var(--text-muted);font-size:11px">(#1488)</span></p>' +
'<p class="cust-hint" style="margin-bottom:8px">Outline around each map marker. Dial these down when hundreds of nodes make the default white border overwhelming. Changes are live on every mounted marker.</p>' +
'<div class="cust-color-row"><div><label>Color' + _overrideDot('markerStroke', 'color') + '</label>' +
'<div class="cust-hint">Marker outline color. Defaults to white for contrast on dark + light tiles.</div></div>' +
'<input type="color" data-cv2-field="markerStroke.color" value="' + esc(msColor) + '">' +
'<span class="cust-node-dot" style="background:' + esc(msColor) + '"></span>' +
'<span class="cust-hex">' + esc(msColor) + '</span></div>' +
'<div class="cust-color-row"><div><label>Width' + _overrideDot('markerStroke', 'width') + '</label>' +
'<div class="cust-hint">Stroke thickness in SVG units (04). Set to 0 to remove the outline entirely.</div></div>' +
'<input type="range" data-cv2-marker-stroke="width" min="0" max="4" step="0.1" value="' + msWidth + '" style="width:120px;cursor:pointer">' +
'<span class="cust-hex" id="cv2MarkerStrokeW">' + msWidth + '</span></div>' +
'<div class="cust-color-row"><div><label>Opacity' + _overrideDot('markerStroke', 'opacity') + '</label>' +
'<div class="cust-hint">Stroke alpha (0100%). Drop this to ~30% for a softer outline.</div></div>' +
'<input type="range" data-cv2-marker-stroke="opacity" min="0" max="100" value="' + msOpacityPct + '" style="width:120px;cursor:pointer">' +
'<span class="cust-hex" id="cv2MarkerStrokeO">' + msOpacityPct + '%</span></div>' +
'</div>';
}
@@ -1995,6 +2062,11 @@
// Mirror to logo brand vars so the wordmark recolors live too.
if (key === 'accent') document.documentElement.style.setProperty('--logo-accent', inp.value);
if (key === 'accentHover') document.documentElement.style.setProperty('--logo-accent-hi', inp.value);
// #1488 — marker stroke color also gets a live CSS-var write
// so the operator sees outlines repaint on drag.
if (section === 'markerStroke' && key === 'color') {
document.documentElement.style.setProperty('--mc-marker-stroke-color', inp.value);
}
// Update hex display
var hex = inp.parentElement.querySelector('.cust-hex');
if (hex) hex.textContent = inp.value;
@@ -2074,6 +2146,40 @@
});
});
// #1488 — Marker stroke width/opacity sliders. Color picker uses the
// generic data-cv2-field handler (markerStroke.color) above.
container.querySelectorAll('[data-cv2-marker-stroke]').forEach(function (inp) {
var which = inp.dataset.cv2MarkerStroke; // 'width' | 'opacity'
inp.addEventListener('input', function () {
var raw = parseFloat(inp.value);
if (!isFinite(raw)) return;
if (which === 'opacity') {
var lbl = document.getElementById('cv2MarkerStrokeO');
if (lbl) lbl.textContent = Math.round(raw) + '%';
// Optimistic CSS write so the markers repaint on drag.
document.documentElement.style.setProperty('--mc-marker-stroke-opacity', String(raw / 100));
} else {
var lblW = document.getElementById('cv2MarkerStrokeW');
if (lblW) lblW.textContent = String(raw);
document.documentElement.style.setProperty('--mc-marker-stroke-width', String(raw));
}
});
inp.addEventListener('change', function () {
var raw = parseFloat(inp.value);
if (!isFinite(raw)) return;
var eff = _getEffective();
var current = JSON.parse(JSON.stringify(eff.markerStroke || {}));
if (which === 'opacity') current.opacity = raw / 100;
else current.width = raw;
// Persist the whole object so partial picks survive (color +
// width + opacity coexist in a single section).
var delta = JSON.parse(JSON.stringify(readOverrides()));
delta.markerStroke = Object.assign({}, delta.markerStroke || {}, current);
writeOverrides(delta);
_runPipeline();
});
});
// Home page list editing
container.querySelectorAll('[data-cv2-home]').forEach(function (inp) {
inp.addEventListener('input', function () {
+1 -1
View File
@@ -2422,7 +2422,7 @@
? window.makeRoleMarkerSVG(n.role, null, sizePx)
: '<svg width="' + sizePx + '" height="' + sizePx + '" viewBox="0 0 ' + sizePx + ' ' + sizePx +
'"><circle cx="' + (sizePx/2) + '" cy="' + (sizePx/2) + '" r="' + (sizePx/2 - 2) +
'" fill="' + fillExpr + '" stroke="#fff" stroke-width="1"/></svg>');
'" fill="' + fillExpr + '" stroke="var(--mc-marker-stroke-color)" stroke-width="var(--mc-marker-stroke-width)" stroke-opacity="var(--mc-marker-stroke-opacity)"/></svg>');
const icon = L.divIcon({
html: svgHtml,
+10 -7
View File
@@ -62,18 +62,21 @@
// operator picks a per-role override. colorOverride (MultiByte
// status tint) wins when provided.
const fillColor = colorOverride || ('var(--mc-role-' + (role || 'companion') + ')');
// #1488 — marker stroke through CSS vars so operators can dial
// colour / width / opacity via the customizer (Colors → Marker Stroke).
const strokeAttr = ' stroke="var(--mc-marker-stroke-color)" stroke-width="var(--mc-marker-stroke-width)" stroke-opacity="var(--mc-marker-stroke-opacity)"';
const size = s.radius * 2 + 4;
const c = size / 2;
let path;
switch (s.shape) {
case 'diamond':
path = `<polygon points="${c},2 ${size-2},${c} ${c},${size-2} 2,${c}" fill="${fillColor}" stroke="#fff" stroke-width="1"/>`;
path = `<polygon points="${c},2 ${size-2},${c} ${c},${size-2} 2,${c}" fill="${fillColor}"${strokeAttr}/>`;
break;
case 'square':
path = `<rect x="3" y="3" width="${size-6}" height="${size-6}" fill="${fillColor}" stroke="#fff" stroke-width="1"/>`;
path = `<rect x="3" y="3" width="${size-6}" height="${size-6}" fill="${fillColor}"${strokeAttr}/>`;
break;
case 'triangle':
path = `<polygon points="${c},2 ${size-2},${size-2} 2,${size-2}" fill="${fillColor}" stroke="#fff" stroke-width="1"/>`;
path = `<polygon points="${c},2 ${size-2},${size-2} 2,${size-2}" fill="${fillColor}"${strokeAttr}/>`;
break;
case 'hexagon': {
// #1293 — pointy-top hexagon for room servers
@@ -84,7 +87,7 @@
hpts += (c + hr * Math.cos(ha)).toFixed(2) + ',' +
(c + hr * Math.sin(ha)).toFixed(2) + ' ';
}
path = `<polygon points="${hpts.trim()}" fill="${fillColor}" stroke="#fff" stroke-width="1"/>`;
path = `<polygon points="${hpts.trim()}" fill="${fillColor}"${strokeAttr}/>`;
break;
}
case 'star': {
@@ -97,11 +100,11 @@
pts += `${cx + outer * Math.cos(aOuter)},${cy + outer * Math.sin(aOuter)} `;
pts += `${cx + inner * Math.cos(aInner)},${cy + inner * Math.sin(aInner)} `;
}
path = `<polygon points="${pts.trim()}" fill="${fillColor}" stroke="#fff" stroke-width="1"/>`;
path = `<polygon points="${pts.trim()}" fill="${fillColor}"${strokeAttr}/>`;
break;
}
default: // circle
path = `<circle cx="${c}" cy="${c}" r="${c-2}" fill="${fillColor}" stroke="#fff" stroke-width="1"/>`;
path = `<circle cx="${c}" cy="${c}" r="${c-2}" fill="${fillColor}"${strokeAttr}/>`;
}
// If this node is also an observer, add a small star overlay
let obsOverlay = '';
@@ -116,7 +119,7 @@
starPts += `${scx + so * Math.cos(aO)},${scy + so * Math.sin(aO)} `;
starPts += `${scx + si * Math.cos(aI)},${scy + si * Math.sin(aI)} `;
}
obsOverlay = `<g transform="translate(${sx},${sy})"><polygon points="${starPts.trim()}" fill="var(--mc-role-observer)" stroke="#fff" stroke-width="0.8"/></g>`;
obsOverlay = `<g transform="translate(${sx},${sy})"><polygon points="${starPts.trim()}" fill="var(--mc-role-observer)" stroke="var(--mc-marker-stroke-color)" stroke-width="0.8" stroke-opacity="var(--mc-marker-stroke-opacity)"/></g>`;
}
const svg = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg">${path}${obsOverlay}</svg>`;
return L.divIcon({
+9 -6
View File
@@ -278,20 +278,23 @@
// that need a fixed tint (matrix mode, stale dim) keep passing
// their explicit colour.
var fill = color || ('var(--mc-role-' + (role || 'companion') + ')');
// #1488 — stroke routed through CSS vars so operators can dial
// colour/width without code edits (customizer Colors → Marker Stroke).
var strokeAttr = ' stroke="var(--mc-marker-stroke-color)" stroke-width="var(--mc-marker-stroke-width)" stroke-opacity="var(--mc-marker-stroke-opacity)"';
var path;
switch (shape) {
case 'square':
path = '<rect x="3" y="3" width="' + (size - 6) + '" height="' + (size - 6) +
'" fill="' + fill + '" stroke="#fff" stroke-width="1"/>';
'" fill="' + fill + '"' + strokeAttr + '/>';
break;
case 'triangle':
path = '<polygon points="' + c + ',2 ' + (size - 2) + ',' + (size - 2) +
' 2,' + (size - 2) + '" fill="' + fill + '" stroke="#fff" stroke-width="1"/>';
' 2,' + (size - 2) + '" fill="' + fill + '"' + strokeAttr + '/>';
break;
case 'diamond':
path = '<polygon points="' + c + ',2 ' + (size - 2) + ',' + c + ' ' +
c + ',' + (size - 2) + ' 2,' + c +
'" fill="' + fill + '" stroke="#fff" stroke-width="1"/>';
'" fill="' + fill + '"' + strokeAttr + '/>';
break;
case 'hexagon': {
// Pointy-top hexagon centred at (c,c), inscribed radius ≈ c-1.5
@@ -303,7 +306,7 @@
(c + r * Math.sin(a)).toFixed(2) + ' ';
}
path = '<polygon points="' + pts.trim() + '" fill="' + fill +
'" stroke="#fff" stroke-width="1"/>';
'"' + strokeAttr + '/>';
break;
}
case 'star': {
@@ -316,12 +319,12 @@
spts += (cx + inner * Math.cos(aI)) + ',' + (cy + inner * Math.sin(aI)) + ' ';
}
path = '<polygon points="' + spts.trim() + '" fill="' + fill +
'" stroke="#fff" stroke-width="1"/>';
'"' + strokeAttr + '/>';
break;
}
default: // circle
path = '<circle cx="' + c + '" cy="' + c + '" r="' + (c - 2) +
'" fill="' + fill + '" stroke="#fff" stroke-width="1"/>';
'" fill="' + fill + '"' + strokeAttr + '/>';
}
return '<svg width="' + size + '" height="' + size +
'" viewBox="0 0 ' + size + ' ' + size +
+10
View File
@@ -3733,6 +3733,16 @@ th.sort-active { color: var(--accent, #60a5fa); }
--mc-role-sensor: #F0E442; /* yellow */
--mc-role-observer: #CC79A7; /* reddish-purple */
/* #1488 marker stroke (outline around the role shape on map + live).
* Reporter (EldoonNemar) found the new white stroke overwhelming with
* hundreds of nodes. Defaults preserve visibility on both dark + light
* tiles; operators dial these via the customizer (Colors Marker
* Stroke) or via config.json (markerStroke.*). All SVG marker builders
* read these vars so changes propagate live without a reload. */
--mc-marker-stroke-color: rgba(255,255,255,0.85);
--mc-marker-stroke-width: 1;
--mc-marker-stroke-opacity: 1;
/* #1407 per-role text colors paired with each --mc-role-X bg so
* .mc-pill (and any badge using the var) hits WCAG 1.4.3 AA against
* the active preset. Wong defaults all clear 4.5:1 with dark text;
+99
View File
@@ -0,0 +1,99 @@
/**
* #1488 Give operators control over marker stroke (color, width, opacity).
*
* Background: #1438 migrated marker SVG fills to var(--mc-role-X) but the
* stroke="#fff" / stroke-width="1" literals were left baked. With hundreds
* of nodes the new white outline is overwhelming. Expose stroke through CSS
* vars so customizer (and config.json) can dial it down, recolor it, or
* narrow it without code edits.
*
* RED gate asserts the source pattern:
* - public/roles.js makeRoleMarkerSVG strokes use var(--mc-marker-stroke-*)
* - public/map.js makeMarkerIcon + observer overlay use the same vars
* - public/live.js addNodeMarker fallback uses the same vars
* - public/style.css declares defaults under :root
* - public/customize-v2.js has marker-stroke controls + routes them to CSS
*/
'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 rolesSrc = fs.readFileSync(path.join(__dirname, 'public', 'roles.js'), 'utf8');
const mapSrc = fs.readFileSync(path.join(__dirname, 'public', 'map.js'), 'utf8');
const liveSrc = fs.readFileSync(path.join(__dirname, 'public', 'live.js'), 'utf8');
const styleSrc = fs.readFileSync(path.join(__dirname, 'public', 'style.css'), 'utf8');
const customizeSrc = fs.readFileSync(path.join(__dirname, 'public', 'customize-v2.js'), 'utf8');
console.log('\n=== #1488 A: style.css declares marker-stroke CSS vars ===');
{
assert(/--mc-marker-stroke-color\s*:/.test(styleSrc),
'style.css declares --mc-marker-stroke-color');
assert(/--mc-marker-stroke-width\s*:/.test(styleSrc),
'style.css declares --mc-marker-stroke-width');
assert(/--mc-marker-stroke-opacity\s*:/.test(styleSrc),
'style.css declares --mc-marker-stroke-opacity');
}
console.log('\n=== #1488 B: roles.js makeRoleMarkerSVG uses CSS-var stroke ===');
{
const helperMatch = rolesSrc.match(/window\.makeRoleMarkerSVG[\s\S]*?\n\s*\};/);
const block = helperMatch ? helperMatch[0] : '';
assert(block.length > 0, 'makeRoleMarkerSVG block located');
const bakedFff = (block.match(/stroke="#fff"/g) || []).length;
const bakedWhite = (block.match(/stroke="white"/g) || []).length;
assert(bakedFff === 0,
'makeRoleMarkerSVG has no baked stroke="#fff" (got: ' + bakedFff + ')');
assert(bakedWhite === 0,
'makeRoleMarkerSVG has no baked stroke="white" (got: ' + bakedWhite + ')');
assert(/var\(--mc-marker-stroke-color\)/.test(block),
'makeRoleMarkerSVG emits stroke="var(--mc-marker-stroke-color)"');
assert(/var\(--mc-marker-stroke-width\)/.test(block),
'makeRoleMarkerSVG emits stroke-width="var(--mc-marker-stroke-width)"');
}
console.log('\n=== #1488 C: map.js makeMarkerIcon uses CSS-var stroke ===');
{
const fnIdx = mapSrc.indexOf('function makeMarkerIcon');
assert(fnIdx >= 0, 'makeMarkerIcon function located');
const block = mapSrc.slice(fnIdx, fnIdx + 3500);
const bakedFff = (block.match(/stroke="#fff"/g) || []).length;
assert(bakedFff === 0,
'makeMarkerIcon has no baked stroke="#fff" in default path (got: ' + bakedFff + ')');
assert(/var\(--mc-marker-stroke-color\)/.test(block),
'makeMarkerIcon references var(--mc-marker-stroke-color)');
}
console.log('\n=== #1488 D: live.js addNodeMarker fallback uses CSS-var stroke ===');
{
const addIdx = liveSrc.indexOf('function addNodeMarker');
assert(addIdx >= 0, 'addNodeMarker function located');
const block = liveSrc.slice(addIdx, addIdx + 3500);
const bakedFff = (block.match(/stroke="#fff"/g) || []).length;
assert(bakedFff === 0,
'addNodeMarker fallback has no baked stroke="#fff" (got: ' + bakedFff + ')');
assert(/var\(--mc-marker-stroke-color\)/.test(block),
'addNodeMarker fallback references var(--mc-marker-stroke-color)');
}
console.log('\n=== #1488 E: customize-v2.js exposes marker stroke controls ===');
{
assert(/markerStroke|marker-stroke/i.test(customizeSrc),
'customize-v2.js mentions markerStroke section');
assert(/--mc-marker-stroke-color/.test(customizeSrc),
'customize-v2.js writes --mc-marker-stroke-color');
assert(/--mc-marker-stroke-width/.test(customizeSrc),
'customize-v2.js writes --mc-marker-stroke-width');
assert(/--mc-marker-stroke-opacity/.test(customizeSrc),
'customize-v2.js writes --mc-marker-stroke-opacity');
}
console.log('\n--- Summary ---');
console.log(passed + ' passed, ' + failed + ' failed');
process.exit(failed > 0 ? 1 : 0);