- Bump capture preview resolution from 480×360 to 960×540 (HD) - Increase preview FPS from 2 to ~12 FPS (AUX_INTERVAL 0.5→0.08) - Add accent-color border on screen rect only (not LED edges) via ::after - Use dynamic aspect-ratio from decoded JPEG frames instead of fixed height - Widen modal to 900px for picture sources - Move frontend conventions from CLAUDE.md to contexts/frontend.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
8.1 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.
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.
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).
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)