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 = ``;
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 '