Improve CSS test preview: HD resolution, screen-only border, and refactor frontend docs
- 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>
This commit is contained in:
113
CLAUDE.md
113
CLAUDE.md
@@ -123,118 +123,9 @@ This is a monorepo containing:
|
||||
For detailed server-specific instructions (restart policy, testing, etc.), see:
|
||||
- `server/CLAUDE.md`
|
||||
|
||||
## UI Conventions for Dialogs
|
||||
## Frontend (HTML, CSS, JS, i18n)
|
||||
|
||||
### Hints
|
||||
|
||||
Every form field in a modal should have a hint. Use the `.label-row` wrapper with a `?` toggle button:
|
||||
|
||||
```html
|
||||
<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 `IconSelect` from `js/core/icon-select.js`. This replaces the `<select>` with a visual grid of icon+label+description cells. See `_ensureCSSTypeIconSelect()`, `_ensureEffectTypeIconSelect()`, `_ensureInterpolationIconSelect()` in `color-strips.js` for examples.
|
||||
|
||||
- **Entity references** (picture sources, audio sources, devices, templates, clocks) → use `EntitySelect` from `js/core/entity-palette.js`. This replaces the `<select>` with a searchable command-palette-style picker. See `_cssPictureSourceEntitySelect` in `color-strips.js` or `_lineSourceEntitySelect` in `advanced-calibration.js` for 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`:
|
||||
|
||||
1. **Subclass Modal** with `snapshotValues()` returning an object of all tracked field values:
|
||||
|
||||
```javascript
|
||||
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();
|
||||
```
|
||||
|
||||
2. **Call `modal.snapshot()`** after the form is fully populated (after `modal.open()`).
|
||||
3. **Close/cancel button** calls `await modal.close()` — triggers dirty check + confirmation.
|
||||
4. **Save function** calls `modal.forceClose()` after successful save — skips dirty check.
|
||||
5. 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-props` with 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-link` CSS class and an `onclick` handler calling `navigateToCard(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:
|
||||
|
||||
```html
|
||||
<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:
|
||||
|
||||
```html
|
||||
<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';` then `showToast(t('my.key'), 'error')`
|
||||
- In inline `<script>` blocks (where `t()` may not be available yet): use `window.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`)
|
||||
For all frontend conventions (CSS variables, UI patterns, modals, localization, tutorials), see [`contexts/frontend.md`](contexts/frontend.md).
|
||||
|
||||
## Task Tracking via TODO.md
|
||||
|
||||
|
||||
146
contexts/frontend.md
Normal file
146
contexts/frontend.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# 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:
|
||||
|
||||
```html
|
||||
<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 `IconSelect` from `js/core/icon-select.js`. This replaces the `<select>` with a visual grid of icon+label+description cells. See `_ensureCSSTypeIconSelect()`, `_ensureEffectTypeIconSelect()`, `_ensureInterpolationIconSelect()` in `color-strips.js` for examples.
|
||||
|
||||
- **Entity references** (picture sources, audio sources, devices, templates, clocks) → use `EntitySelect` from `js/core/entity-palette.js`. This replaces the `<select>` with a searchable command-palette-style picker. See `_cssPictureSourceEntitySelect` in `color-strips.js` or `_lineSourceEntitySelect` in `advanced-calibration.js` for 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`:
|
||||
|
||||
1. **Subclass Modal** with `snapshotValues()` returning an object of all tracked field values:
|
||||
|
||||
```javascript
|
||||
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();
|
||||
```
|
||||
|
||||
2. **Call `modal.snapshot()`** after the form is fully populated (after `modal.open()`).
|
||||
3. **Close/cancel button** calls `await modal.close()` — triggers dirty check + confirmation.
|
||||
4. **Save function** calls `modal.forceClose()` after successful save — skips dirty check.
|
||||
5. 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-props` with 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-link` CSS class and an `onclick` handler calling `navigateToCard(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:
|
||||
|
||||
```html
|
||||
<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:
|
||||
|
||||
```html
|
||||
<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';` then `showToast(t('my.key'), 'error')`
|
||||
- In inline `<script>` blocks (where `t()` may not be available yet): use `window.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`)
|
||||
@@ -775,7 +775,7 @@ async def test_color_strip_ws(
|
||||
if is_picture and hasattr(stream, '_live_stream'):
|
||||
_frame_live = stream._live_stream
|
||||
_last_aux_time = 0.0
|
||||
_AUX_INTERVAL = 0.5 # send JPEG preview / brightness updates every 0.5s
|
||||
_AUX_INTERVAL = 0.08 # send JPEG preview / brightness updates ~12 FPS
|
||||
|
||||
# Stream binary RGB frames at ~20 Hz
|
||||
while True:
|
||||
@@ -832,7 +832,7 @@ async def test_color_strip_ws(
|
||||
img = img[:, :, :3]
|
||||
# Downscale for bandwidth
|
||||
h, w = img.shape[:2]
|
||||
scale = min(480 / w, 360 / h, 1.0)
|
||||
scale = min(960 / w, 540 / h, 1.0)
|
||||
if scale < 1.0:
|
||||
new_w = max(1, int(w * scale))
|
||||
new_h = max(1, int(h * scale))
|
||||
|
||||
@@ -175,8 +175,10 @@
|
||||
grid-template-columns: 14px 1fr 14px;
|
||||
grid-template-rows: 14px 1fr 14px;
|
||||
width: 100%;
|
||||
height: 320px;
|
||||
aspect-ratio: 16 / 9;
|
||||
max-height: 70vh;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.css-test-rect-corner {
|
||||
@@ -184,12 +186,11 @@
|
||||
}
|
||||
|
||||
.css-test-rect-screen {
|
||||
position: relative;
|
||||
background-image: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
border-radius: 2px;
|
||||
outline: 1px solid rgba(255, 255, 255, 0.15);
|
||||
outline-offset: -1px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -198,6 +199,16 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.css-test-rect-screen::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 2px solid var(--primary-color);
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.css-test-rect-label {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 0.8rem;
|
||||
|
||||
@@ -1973,6 +1973,10 @@ function _cssTestConnect(sourceId, ledCount) {
|
||||
document.getElementById('css-test-layers-view').style.display = _cssTestIsComposite ? '' : 'none';
|
||||
document.getElementById('css-test-status').style.display = 'none';
|
||||
|
||||
// Widen modal for picture sources to show the screen rectangle larger
|
||||
const modalContent = document.querySelector('#test-css-source-modal .modal-content');
|
||||
if (modalContent) modalContent.style.maxWidth = isPicture ? '900px' : '';
|
||||
|
||||
// Hide LED count control for picture sources (LED count is fixed by calibration)
|
||||
document.getElementById('css-test-led-control').style.display = isPicture ? 'none' : '';
|
||||
|
||||
@@ -2016,6 +2020,14 @@ function _cssTestConnect(sourceId, ledCount) {
|
||||
screen.style.backgroundSize = 'cover';
|
||||
screen.style.backgroundPosition = 'center';
|
||||
if (oldUrl) URL.revokeObjectURL(oldUrl);
|
||||
// Set aspect ratio from first decoded frame
|
||||
const rect = document.getElementById('css-test-rect');
|
||||
if (rect && !rect._aspectSet && img.naturalWidth && img.naturalHeight) {
|
||||
rect.style.aspectRatio = `${img.naturalWidth} / ${img.naturalHeight}`;
|
||||
rect.style.height = 'auto';
|
||||
rect._aspectSet = true;
|
||||
requestAnimationFrame(() => _cssTestRenderTicks(_cssTestMeta?.edges));
|
||||
}
|
||||
};
|
||||
img.onerror = () => URL.revokeObjectURL(url);
|
||||
img.src = url;
|
||||
@@ -2356,6 +2368,12 @@ export function closeTestCssSourceModal() {
|
||||
// Revoke blob URL for frame preview
|
||||
const screen = document.getElementById('css-test-rect-screen');
|
||||
if (screen && screen._blobUrl) { URL.revokeObjectURL(screen._blobUrl); screen._blobUrl = null; screen.style.backgroundImage = ''; }
|
||||
// Reset aspect ratio for next open
|
||||
const rect = document.getElementById('css-test-rect');
|
||||
if (rect) { rect.style.aspectRatio = ''; rect.style.height = ''; rect._aspectSet = false; }
|
||||
// Reset modal width
|
||||
const modalContent = document.querySelector('#test-css-source-modal .modal-content');
|
||||
if (modalContent) modalContent.style.maxWidth = '';
|
||||
const modal = document.getElementById('test-css-source-modal');
|
||||
if (modal) { modal.style.display = 'none'; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user