From ca2c3d6c792ca545613832c0e0b833ace5a76375 Mon Sep 17 00:00:00 2001 From: Kpa-clawbot Date: Fri, 29 May 2026 07:31:36 -0700 Subject: [PATCH] feat(1488): customize marker stroke (color, width, opacity) (#1494) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- cmd/server/config.go | 8 ++ cmd/server/routes.go | 22 ++++-- cmd/server/types.go | 3 + config.example.json | 6 ++ public/customize-v2.js | 110 +++++++++++++++++++++++++- public/live.js | 2 +- public/map.js | 17 ++-- public/roles.js | 15 ++-- public/style.css | 10 +++ test-issue-1488-marker-stroke-vars.js | 99 +++++++++++++++++++++++ 10 files changed, 270 insertions(+), 22 deletions(-) create mode 100644 test-issue-1488-marker-stroke-vars.js diff --git a/cmd/server/config.go b/cmd/server/config.go index 557f76b7..661ca4bc 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -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) { diff --git a/cmd/server/routes.go b/cmd/server/routes.go index cfe6fc05..bdc801ec 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -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, }) } diff --git a/cmd/server/types.go b/cmd/server/types.go index 3818f6f6..630e773b 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -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 { diff --git a/config.example.json b/config.example.json index 1224c0b3..1a8ed3c8 100644 --- a/config.example.json +++ b/config.example.json @@ -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": { diff --git a/public/customize-v2.js b/public/customize-v2.js index 94d17032..5f16734d 100644 --- a/public/customize-v2.js +++ b/public/customize-v2.js @@ -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 '
' + '

Node Role Colors

' + '

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.

' + @@ -1279,6 +1330,22 @@ '
Heatmap overlay on the Live page (0–100%)
' + '' + '' + liveHeatPct + '%' + + '
' + + '

Marker Stroke (#1488)

' + + '

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.

' + + '
' + + '
Marker outline color. Defaults to white for contrast on dark + light tiles.
' + + '' + + '' + + '' + esc(msColor) + '
' + + '
' + + '
Stroke thickness in SVG units (0–4). Set to 0 to remove the outline entirely.
' + + '' + + '' + msWidth + '
' + + '
' + + '
Stroke alpha (0–100%). Drop this to ~30% for a softer outline.
' + + '' + + '' + msOpacityPct + '%
' + ''; } @@ -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 () { diff --git a/public/live.js b/public/live.js index 4fc38d9e..4e2295e9 100644 --- a/public/live.js +++ b/public/live.js @@ -2422,7 +2422,7 @@ ? window.makeRoleMarkerSVG(n.role, null, sizePx) : ''); + '" fill="' + fillExpr + '" stroke="var(--mc-marker-stroke-color)" stroke-width="var(--mc-marker-stroke-width)" stroke-opacity="var(--mc-marker-stroke-opacity)"/>'); const icon = L.divIcon({ html: svgHtml, diff --git a/public/map.js b/public/map.js index fc500aea..b951bc33 100644 --- a/public/map.js +++ b/public/map.js @@ -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 = ``; + path = ``; break; case 'square': - path = ``; + path = ``; break; case 'triangle': - path = ``; + path = ``; 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 = ``; + path = ``; 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 = ``; + path = ``; break; } default: // circle - path = ``; + path = ``; } // 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 = ``; + obsOverlay = ``; } const svg = `${path}${obsOverlay}`; return L.divIcon({ diff --git a/public/roles.js b/public/roles.js index ed58f7b9..5d033537 100644 --- a/public/roles.js +++ b/public/roles.js @@ -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 = ''; + '" fill="' + fill + '"' + strokeAttr + '/>'; break; case 'triangle': path = ''; + ' 2,' + (size - 2) + '" fill="' + fill + '"' + strokeAttr + '/>'; break; case 'diamond': path = ''; + '" 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 = ''; + '"' + strokeAttr + '/>'; break; } case 'star': { @@ -316,12 +319,12 @@ spts += (cx + inner * Math.cos(aI)) + ',' + (cy + inner * Math.sin(aI)) + ' '; } path = ''; + '"' + strokeAttr + '/>'; break; } default: // circle path = ''; + '" fill="' + fill + '"' + strokeAttr + '/>'; } return ' 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);