- Bulk selection mode: Ctrl+Click or toggle button to enter, Escape to exit - Shift+Click for range select, bottom toolbar with SVG icon action buttons - All CardSections wired with bulk actions: Delete everywhere, Start/Stop for targets, Enable/Disable for automations - Remove expand/collapse all buttons (no collapsible sections remain) - Fix graph node color picker overlay persisting after outside click - Add Icons section to frontend.md conventions - Add trash2, listChecks, circleOff icons to icon system - Backend: processing loop performance improvements (monotonic timestamps, deque-based FPS tracking) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
14 KiB
Frontend Rules & Conventions
Read this file when working on frontend tasks (HTML, CSS, JS, locales, templates).
CSS Custom Properties (Variables)
Defined in server/src/wled_controller/static/css/base.css.
IMPORTANT: There is NO --accent variable. Always use --primary-color for accent/brand color.
Global (:root)
| Variable | Value | Usage |
|---|---|---|
--primary-color |
#4CAF50 |
Accent/brand color — borders, highlights, active states |
--primary-hover |
#5cb860 |
Hover state for primary elements |
--primary-contrast |
#ffffff |
Text on primary background |
--danger-color |
#f44336 |
Destructive actions, errors |
--warning-color |
#ff9800 |
Warnings |
--info-color |
#2196F3 |
Informational highlights |
Theme-specific ([data-theme="dark"] / [data-theme="light"])
| Variable | Dark | Light | Usage |
|---|---|---|---|
--bg-color |
#1a1a1a |
#f5f5f5 |
Page background |
--bg-secondary |
#242424 |
#eee |
Secondary background |
--card-bg |
#2d2d2d |
#ffffff |
Card/panel background |
--text-color |
#e0e0e0 |
#333333 |
Primary text |
--text-secondary |
#999 |
#666 |
Secondary text |
--text-muted |
#777 |
#999 |
Muted/disabled text |
--border-color |
#404040 |
#e0e0e0 |
Borders, dividers |
--primary-text-color |
#66bb6a |
#3d8b40 |
Primary-colored text |
--success-color |
#28a745 |
#2e7d32 |
Success indicators |
--shadow-color |
rgba(0,0,0,0.3) |
rgba(0,0,0,0.12) |
Box shadows |
UI Conventions for Dialogs
Hints
Every form field in a modal should have a hint. Use the .label-row wrapper with a ? toggle button:
<div class="form-group">
<div class="label-row">
<label for="my-field" data-i18n="my.label">Label:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="my.label.hint">Hint text</small>
<input type="text" id="my-field">
</div>
Add hint text to both en.json and ru.json locale files using a .hint suffix on the label key.
Select dropdowns
Do not add placeholder options like -- Select something --. Populate the <select> with real options only and let the first one be selected by default.
Empty/None option format
When a selector has an optional entity (e.g., sync clock, processing template, brightness source), the empty option must use the format None (<description>) where the description explains what happens when nothing is selected. Use i18n keys, never hardcoded — or bare None.
Examples:
None (no processing template)—t('common.none_no_cspt')None (no input source)—t('common.none_no_input')None (use own speed)—t('common.none_own_speed')None (full brightness)—t('color_strip.composite.brightness.none')None (device brightness)—t('targets.brightness_vs.none')
For EntitySelect with allowNone: true, pass the same i18n string as noneLabel.
Enhanced selectors (IconSelect & EntitySelect)
Plain <select> dropdowns should be enhanced with visual selectors depending on the data type:
-
Predefined options (source types, effect types, palettes, waveforms, viz modes) → use
IconSelectfromjs/core/icon-select.js. This replaces the<select>with a visual grid of icon+label+description cells. See_ensureCSSTypeIconSelect(),_ensureEffectTypeIconSelect(),_ensureInterpolationIconSelect()incolor-strips.jsfor examples. -
Entity references (picture sources, audio sources, devices, templates, clocks) → use
EntitySelectfromjs/core/entity-palette.js. This replaces the<select>with a searchable command-palette-style picker. See_cssPictureSourceEntitySelectincolor-strips.jsor_lineSourceEntitySelectinadvanced-calibration.jsfor examples.
Both widgets hide the native <select> but keep it in the DOM with its value in sync. After programmatically changing the <select> value, call .refresh() (EntitySelect) or .setValue(val) (IconSelect) to update the trigger display. Call .destroy() when the modal closes.
IMPORTANT: For IconSelect item icons, use SVG icons from js/core/icon-paths.js (via _icon(P.iconName)) or styled <span> elements (e.g., <span style="font-weight:bold">A</span>). Never use emoji — they render inconsistently across platforms and themes.
Modal dirty check (discard unsaved changes)
Every editor modal must have a dirty check so closing with unsaved changes shows a "Discard unsaved changes?" confirmation. Use the Modal base class pattern from js/core/modal.js:
-
Subclass Modal with
snapshotValues()returning an object of all tracked field values:class MyEditorModal extends Modal { constructor() { super('my-modal-id'); } snapshotValues() { return { name: document.getElementById('my-name').value, // ... all form fields }; } onForceClose() { // Optional: cleanup (reset flags, clear state, etc.) } } const myModal = new MyEditorModal(); -
Call
modal.snapshot()after the form is fully populated (aftermodal.open()). -
Close/cancel button calls
await modal.close()— triggers dirty check + confirmation. -
Save function calls
modal.forceClose()after successful save — skips dirty check. -
For complex/dynamic state (filter lists, schedule rows, conditions), serialize to JSON string in
snapshotValues().
The base class handles: isDirty() comparison, confirmation dialog, backdrop click, ESC key, focus trapping, and body scroll lock.
Card appearance
When creating or modifying entity cards (devices, targets, CSS sources, streams, audio/value sources, templates), always reference existing cards of the same or similar type for visual consistency. Cards should have:
- Clone (📋) and Edit (✏️) icon buttons in
.template-card-actions - Delete (✕) button as
.card-remove-btn - Property badges in
.stream-card-propswith emoji icons - Crosslinks: When a card references another entity (audio source, picture source, capture template, PP template, etc.), make the property badge a clickable link using the
stream-card-linkCSS class and anonclickhandler callingnavigateToCard(tab, subTab, sectionKey, cardAttr, cardValue). Only add the link when the referenced entity is found (to avoid broken navigation). Example:<span class="stream-card-prop stream-card-link" onclick="event.stopPropagation(); navigateToCard('streams','audio','audio-multi','data-id','${id}')">🎵 Name</span>
Modal footer buttons
Use icon-only buttons (✓ / ✕) matching the device settings modal pattern, not text buttons:
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeMyModal()" title="Cancel" data-i18n-title="settings.button.cancel" data-i18n-aria-label="aria.cancel">✕</button>
<button class="btn btn-icon btn-primary" onclick="saveMyEntity()" title="Save" data-i18n-title="settings.button.save" data-i18n-aria-label="aria.save">✓</button>
</div>
Slider value display
For range sliders, display the current value inside the label (not in a separate wrapper). This keeps the value visible next to the property name:
<label for="my-slider"><span data-i18n="my.label">Speed:</span> <span id="my-slider-display">1.0</span></label>
...
<input type="range" id="my-slider" min="0" max="10" step="0.1" value="1.0"
oninput="document.getElementById('my-slider-display').textContent = this.value">
Do not use a range-with-value wrapper div.
Tutorials
The app has an interactive tutorial system (static/js/features/tutorials.js) with a generic engine, spotlight overlay, tooltip positioning, and keyboard navigation. Tutorials exist for:
- Getting started (header-level walkthrough of all tabs and controls)
- Per-tab tutorials (Dashboard, Targets, Sources, Profiles) triggered by
?buttons - Device card tutorial and Calibration tutorial (context-specific)
When adding new tabs, sections, or major UI elements, update the corresponding tutorial step array in tutorials.js and add tour.* i18n keys to all 3 locale files (en.json, ru.json, zh.json).
Icons
Always use SVG icons from the icon system, never text/emoji/Unicode symbols for buttons and UI controls.
- Icon SVG paths are defined in
static/js/core/icon-paths.js(Lucide icons, 24×24 viewBox) - Icon constants are exported from
static/js/core/icons.js(e.g.ICON_START,ICON_TRASH,ICON_EDIT) - Use
_svg(path)wrapper fromicons.jsto create new icon constants from paths
When you need a new icon:
- Find the Lucide icon at https://lucide.dev
- Copy the inner SVG elements (paths, circles, rects) into
icon-paths.jsas a new export - Add a corresponding
ICON_*constant inicons.jsusing_svg(P.myIcon) - Import and use the constant in your feature module
Common icons: ICON_START (play), ICON_STOP (power), ICON_EDIT (pencil), ICON_CLONE (copy), ICON_TRASH (trash), ICON_SETTINGS (gear), ICON_TEST (flask), ICON_OK (circle-check), ICON_WARNING (triangle-alert), ICON_HELP (circle-help), ICON_LIST_CHECKS (list-checks), ICON_CIRCLE_OFF (circle-off).
For icon-only buttons, use btn btn-icon CSS classes. The .icon class inside buttons auto-sizes to 16×16.
Localization (i18n)
Every user-facing string must be localized. Never use hardcoded English strings in showToast(), error.textContent, modal messages, or any other UI-visible text. Always use t('key') from ../core/i18n.js and add the corresponding key to all three locale files (en.json, ru.json, zh.json).
- In JS modules:
import { t } from '../core/i18n.js';thenshowToast(t('my.key'), 'error') - In inline
<script>blocks (wheret()may not be available yet): usewindow.t ? t('key') : 'fallback' - In HTML templates: use
data-i18n="key"for text content,data-i18n-title="key"for title attributes,data-i18n-aria-label="key"for aria-labels - Keys follow dotted namespace convention:
feature.context.description(e.g.device.error.brightness,calibration.saved)
Dynamic content and language changes
When a feature module generates HTML with baked-in t() calls (e.g., toolbar button titles, legend text), that content won't update when the user switches language. To handle this, listen for the languageChanged event and re-render:
document.addEventListener('languageChanged', () => {
if (_initialized) _reRender();
});
Static HTML using data-i18n attributes is handled automatically by the i18n system. Only dynamically generated HTML needs this pattern.
Bundling & Development Workflow
The frontend uses esbuild to bundle all JS modules and CSS files into single files for production.
Files
- Entry points:
static/js/app.js(JS),static/css/all.css(CSS imports all individual sheets) - Output:
static/dist/app.bundle.jsandstatic/dist/app.bundle.css(minified + source maps) - Config:
server/esbuild.mjs - HTML:
templates/index.htmlreferences the bundles, not individual source files
Commands (from server/ directory)
| Command | Description |
|---|---|
npm run build |
One-shot bundle + minify (~30ms) |
npm run watch |
Watch mode — auto-rebuilds on any JS/CSS file save |
Development workflow
- Run
npm run watchin a terminal (stays running) - Edit source files in
static/js/orstatic/css/as usual - esbuild rebuilds the bundle automatically (~30ms)
- Refresh the browser to see changes
Dependencies
All JS/CSS dependencies are bundled — no CDN or external requests at runtime:
- Chart.js — imported in
perf-charts.js, exposed aswindow.Chartfortargets.jsanddashboard.js - ELK.js — imported in
graph-layout.jsfor graph auto-layout - Fonts — DM Sans (400-700) and Orbitron (700) woff2 files in
static/fonts/, declared incss/fonts.css
When adding a new JS dependency: npm install <pkg> in server/, then import it in the relevant source file. esbuild bundles it automatically.
Notes
- The
dist/directory is gitignored — bundles are build artifacts, runnpm run buildafter clone - Source maps are generated so browser DevTools show original source files
- The server sets
Cache-Control: no-cacheon static JS/CSS/JSON to prevent stale browser caches during development - GZip compression middleware reduces transfer sizes by ~75%
- Do not edit files in
static/dist/— they are overwritten by the build
Chrome Browser Tools
See contexts/chrome-tools.md for Chrome MCP tool usage, browser tricks (hard reload, zoom, console), and verification workflow.
Duration & Numeric Formatting
Uptime / duration values
Use formatUptime(seconds) from core/ui.js. Outputs {s}s, {m}m {s}s, or {h}h {m}m via i18n keys time.seconds, time.minutes_seconds, time.hours_minutes.
Large numbers
Use formatCompact(n) from core/ui.js. Outputs 1.2K, 3.5M etc. Set element.title to the exact value for hover detail.
Preventing layout shift
Numeric/duration values that update frequently (FPS, uptime, frame counts) must use fixed-width styling to prevent layout reflow:
font-family: var(--font-mono, monospace)— equal-width charactersfont-variant-numeric: tabular-nums— equal-width digits in proportional fonts- Fixed
widthormin-widthon the value container text-align: rightto anchor the growing edge
Reference: .dashboard-metric-value in dashboard.css uses font-family: var(--font-mono), font-weight: 600, min-width: 48px.
FPS sparkline charts
Use createFpsSparkline(canvasId, actualHistory, currentHistory, fpsTarget) from core/chart-utils.js. Wrap the canvas in .target-fps-sparkline (36px height, position: relative, overflow: hidden). Show the value in .target-fps-label with .metric-value and .target-fps-avg.
Visual Graph Editor
See contexts/graph-editor.md for full graph editor architecture and conventions.
IMPORTANT: When adding or modifying entity types, subtypes, or connection fields, the graph editor files must be updated in sync. The graph maintains its own maps of entity colors, labels, icons, connection rules, and cache references. See the "Keeping the graph in sync with entity types" section in graph-editor.md for the complete checklist.