diff --git a/CLAUDE.md b/CLAUDE.md index 9180a87..c9cd4e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,7 +96,13 @@ ast-index update # Incremental update after file change **Whenever server-side Python code is modified** (any file under `/server/src/` **excluding** `/server/src/wled_controller/static/`), **automatically restart the server** so the changes take effect immediately. Do NOT wait for the user to ask for a restart. -**No restart needed for frontend-only changes.** Files under `/server/src/wled_controller/static/` (HTML, JS, CSS, JSON locale files) are served directly by FastAPI's static file handler — changes take effect on the next browser page refresh without restarting the server. +**No restart needed for frontend-only changes** — but you **MUST rebuild the bundle**. The browser loads the esbuild bundle (`static/dist/app.bundle.js`, `static/dist/app.bundle.css`), NOT the source files. After ANY change to frontend files (JS, CSS under `/server/src/wled_controller/static/`), run: + +```bash +cd server && npm run build +``` + +Without this step, changes will NOT take effect. No server restart is needed — just rebuild and refresh the browser. ### Restart procedure @@ -136,6 +142,10 @@ Use `TODO.md` in the project root as the primary task tracker. **Do NOT use the - **When a task is fully done**: mark it `- [x]` and leave it for the user to clean up - **When the user requests a new feature/fix**: add it to the appropriate section with a priority tag +## Documentation Lookup + +**Use context7 MCP tools for library/framework documentation lookups.** When you need to check API signatures, usage patterns, or current behavior of external libraries (e.g., FastAPI, OpenCV, Pydantic, yt-dlp), use `mcp__plugin_context7_context7__resolve-library-id` to find the library, then `mcp__plugin_context7_context7__query-docs` to fetch up-to-date docs. This avoids relying on potentially outdated training data. + ## General Guidelines - Always test changes before marking as complete diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 8656bbe..326d428 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -54,7 +54,7 @@ netstat -an | grep 8080 - **Permission errors**: Ensure file permissions allow Python to execute #### Files that DON'T require restart: -- Static files (`static/*.html`, `static/*.css`, `static/*.js`) - these are served directly +- Static files (`static/*.html`, `static/*.css`, `static/*.js`) - but you **MUST rebuild the bundle** after changes: `cd server && npm run build` - Locale files (`static/locales/*.json`) - loaded by frontend - Documentation files (`*.md`) - Configuration files in `config/` if server supports hot-reload (check implementation) diff --git a/server/src/wled_controller/static/js/core/graph-nodes.js b/server/src/wled_controller/static/js/core/graph-nodes.js index dedd375..d871439 100644 --- a/server/src/wled_controller/static/js/core/graph-nodes.js +++ b/server/src/wled_controller/static/js/core/graph-nodes.js @@ -5,6 +5,7 @@ import { ENTITY_COLORS, NODE_WIDTH, NODE_HEIGHT, computePorts } from './graph-layout.js'; import { EDGE_COLORS } from './graph-edges.js'; import { createColorPicker, registerColorPicker, closeAllColorPickers } from './color-picker.js'; +import { getCardColor, setCardColor } from './card-colors.js'; import * as P from './icon-paths.js'; const SVG_NS = 'http://www.w3.org/2000/svg'; @@ -90,26 +91,10 @@ export function renderNodes(group, nodeMap, callbacks = {}) { /** * Render a single node. */ -// Per-node color overrides (persisted in localStorage) -const _NC_KEY = 'graph_node_colors'; -let _nodeColorOverrides = null; - -function _loadNodeColors() { - if (_nodeColorOverrides) return _nodeColorOverrides; - try { _nodeColorOverrides = JSON.parse(localStorage.getItem(_NC_KEY)) || {}; } catch { _nodeColorOverrides = {}; } - return _nodeColorOverrides; -} - -function _saveNodeColor(nodeId, color) { - const map = _loadNodeColors(); - map[nodeId] = color; - localStorage.setItem(_NC_KEY, JSON.stringify(map)); -} /** Return custom color for a node, or null if none set. */ export function getNodeColor(nodeId) { - const map = _loadNodeColors(); - return map[nodeId] || null; + return getCardColor(nodeId) || null; } /** Return color for a node: custom if set, else entity-type default. Used by minimap/search. */ @@ -117,6 +102,61 @@ export function getNodeDisplayColor(nodeId, kind) { return getNodeColor(nodeId) || ENTITY_COLORS[kind] || '#666'; } +/** Open a color picker for a graph node, positioned near the click point. */ +function _openNodeColorPicker(node, e) { + const nodeEl = e.target.closest('.graph-node') || document.querySelector(`.graph-node[data-id="${node.id}"]`); + if (!nodeEl) return; + const svg = nodeEl.ownerSVGElement; + const container = svg?.closest('.graph-container'); + if (!svg || !container) return; + + // Remove any previous graph color picker overlay + container.querySelector('.graph-cp-overlay')?.remove(); + closeAllColorPickers(); + + // Compute position relative to container + const cr = container.getBoundingClientRect(); + const px = e.clientX - cr.left; + const py = e.clientY - cr.top; + + // Create an HTML overlay with the custom color picker + const pickerId = `graph-node-${node.id}`; + const cpOverlay = document.createElement('div'); + cpOverlay.className = 'graph-cp-overlay'; + cpOverlay.style.cssText = `position:absolute; left:${px}px; top:${py}px; z-index:100;`; + const curColor = getNodeColor(node.id); + cpOverlay.innerHTML = createColorPicker({ + id: pickerId, + currentColor: curColor || ENTITY_COLORS[node.kind] || '#666', + anchor: 'left', + showReset: true, + resetColor: '#808080', + }); + container.appendChild(cpOverlay); + + // Register callback to update the bar color + registerColorPicker(pickerId, (hex) => { + const bar = nodeEl.querySelector('.graph-node-color-bar'); + const barCover = bar?.nextElementSibling; + if (bar) { + if (hex) { + bar.setAttribute('fill', hex); + if (barCover) barCover.setAttribute('fill', hex); + bar.style.display = ''; + if (barCover) barCover.style.display = ''; + } else { + bar.style.display = 'none'; + if (barCover) barCover.style.display = 'none'; + } + } + setCardColor(node.id, hex); + cpOverlay.remove(); + }); + + // Open the popover immediately + window._cpToggle(pickerId); +} + function renderNode(node, callbacks) { const { id, kind, name, subtype, x, y, width, height, running } = node; let color = getNodeColor(id); @@ -167,45 +207,7 @@ function renderNode(node, callbacks) { barHit.style.cursor = 'pointer'; barHit.addEventListener('click', (e) => { e.stopPropagation(); - const svg = barHit.ownerSVGElement; - const container = svg?.closest('.graph-container'); - if (!svg || !container) return; - - // Remove any previous graph color picker overlay - container.querySelector('.graph-cp-overlay')?.remove(); - closeAllColorPickers(); - - // Compute position relative to container - const ctm = barHit.getScreenCTM(); - const cr = container.getBoundingClientRect(); - const px = (ctm ? ctm.e : e.clientX) - cr.left; - const py = (ctm ? ctm.f : e.clientY) - cr.top; - - // Create an HTML overlay with the custom color picker - const pickerId = `graph-node-${id}`; - const overlay = document.createElement('div'); - overlay.className = 'graph-cp-overlay'; - overlay.style.cssText = `position:absolute; left:${px}px; top:${py}px; z-index:100;`; - overlay.innerHTML = createColorPicker({ - id: pickerId, - currentColor: color || ENTITY_COLORS[kind] || '#666', - anchor: 'left', - }); - container.appendChild(overlay); - - // Register callback to update the bar color - registerColorPicker(pickerId, (hex) => { - color = hex; - bar.setAttribute('fill', hex); - barCover.setAttribute('fill', hex); - bar.style.display = ''; - barCover.style.display = ''; - _saveNodeColor(id, hex); - overlay.remove(); - }); - - // Open the popover immediately - window._cpToggle(pickerId); + _openNodeColorPicker(node, e); }); g.appendChild(barHit); @@ -371,6 +373,9 @@ function _createOverlay(node, nodeWidth, callbacks) { // Clone (smaller scale to fit the compact button) btns.push({ svgPath: P.copy, action: 'clone', cls: '', scale: 0.6 }); + // Color picker + btns.push({ svgPath: P.palette, action: 'color', cls: '', scale: 0.6 }); + // Always: edit and delete btns.push({ icon: '\u270E', action: 'edit', cls: '' }); // ✎ btns.push({ icon: '\u2716', action: 'delete', cls: 'danger' }); // ✖ @@ -400,6 +405,7 @@ function _createOverlay(node, nodeWidth, callbacks) { test: 'Test / Preview', notify: 'Test notification', clone: 'Clone', + color: 'Color', edit: 'Edit', delete: 'Delete', }; @@ -440,6 +446,7 @@ function _createOverlay(node, nodeWidth, callbacks) { if (btn.action === 'notify' && callbacks.onNotificationTest) callbacks.onNotificationTest(node); if (btn.action === 'clone' && callbacks.onCloneNode) callbacks.onCloneNode(node); if (btn.action === 'activate' && callbacks.onActivatePreset) callbacks.onActivatePreset(node); + if (btn.action === 'color') _openNodeColorPicker(node, e); }); overlay.appendChild(bg); }); diff --git a/server/src/wled_controller/static/js/features/graph-editor.js b/server/src/wled_controller/static/js/features/graph-editor.js index a269fb3..902ad54 100644 --- a/server/src/wled_controller/static/js/features/graph-editor.js +++ b/server/src/wled_controller/static/js/features/graph-editor.js @@ -261,6 +261,8 @@ export async function loadGraphEditor() { } finally { _loading = false; } + // Ensure keyboard focus whenever the graph is (re-)loaded + container.focus(); } export function toggleGraphLegend() { @@ -816,6 +818,11 @@ function _renderGraph(container) { container.addEventListener('keydown', _onKeydown); container.setAttribute('tabindex', '0'); + container.style.outline = 'none'; + // Focus the container so keyboard shortcuts work immediately + container.focus(); + // Re-focus when clicking inside the graph + svgEl.addEventListener('pointerdown', () => container.focus()); _initialized = true; } @@ -1457,6 +1464,9 @@ function _onNotificationTest(node) { /* ── Keyboard ── */ function _onKeydown(e) { + // Trap Tab inside the graph to prevent focus escaping to footer + if (e.key === 'Tab') { e.preventDefault(); return; } + // Skip when typing in search input (except Escape/F11) const inInput = e.target.matches('input, textarea, select');