fix: UI polish — notification history buttons, CSS test error handling, schedule time picker, empty state labels
All checks were successful
Lint & Test / test (push) Successful in 31s

- Notification history: replace text buttons with icon buttons, use modal-footer for proper positioning
- CSS test: reject 0-LED picture sources with clear error message, show WS close reason in UI
- Calibration: distribute LEDs by aspect ratio (16:9 default) instead of evenly across edges
- Value source schedule: replace native time input with custom HH:MM picker matching automation style
- Remove "No ... yet" empty state labels from all CardSection instances
This commit is contained in:
2026-03-22 20:58:13 +03:00
parent 52c8614a3c
commit f376622482
7 changed files with 201 additions and 39 deletions

View File

@@ -829,6 +829,15 @@ async def test_color_strip_ws(
if hasattr(stream, "configure"): if hasattr(stream, "configure"):
stream.configure(max(1, led_count)) stream.configure(max(1, led_count))
# Reject picture sources with 0 calibration LEDs (no edges configured)
if stream.led_count <= 0:
csm.release(source_id, consumer_id)
await websocket.close(
code=4005,
reason="No LEDs configured. Open Calibration and set LED counts for each edge.",
)
return
# Clamp FPS to sane range # Clamp FPS to sane range
fps = max(1, min(60, fps)) fps = max(1, min(60, fps))
_frame_interval = 1.0 / fps _frame_interval = 1.0 / fps

View File

@@ -668,14 +668,20 @@ def create_pixel_mapper(
return PixelMapper(calibration, interpolation_mode) return PixelMapper(calibration, interpolation_mode)
def create_default_calibration(led_count: int) -> CalibrationConfig: def create_default_calibration(
led_count: int,
aspect_width: int = 16,
aspect_height: int = 9,
) -> CalibrationConfig:
"""Create a default calibration for a rectangular screen. """Create a default calibration for a rectangular screen.
Assumes LEDs are evenly distributed around the screen edges in clockwise order Distributes LEDs proportionally to the screen aspect ratio so that
starting from bottom-left. horizontal and vertical edges have equal LED density.
Args: Args:
led_count: Total number of LEDs led_count: Total number of LEDs
aspect_width: Screen width component of the aspect ratio (default 16)
aspect_height: Screen height component of the aspect ratio (default 9)
Returns: Returns:
Default calibration configuration Default calibration configuration
@@ -683,15 +689,48 @@ def create_default_calibration(led_count: int) -> CalibrationConfig:
if led_count < 4: if led_count < 4:
raise ValueError("Need at least 4 LEDs for default calibration") raise ValueError("Need at least 4 LEDs for default calibration")
# Distribute LEDs evenly across 4 edges # Distribute LEDs proportionally to aspect ratio (same density per edge)
leds_per_edge = led_count // 4 perimeter = 2 * (aspect_width + aspect_height)
remainder = led_count % 4 h_frac = aspect_width / perimeter # fraction for each horizontal edge
v_frac = aspect_height / perimeter # fraction for each vertical edge
# Distribute remainder to longer edges (bottom and top) # Float counts, then round so total == led_count
bottom_count = leds_per_edge + (1 if remainder > 0 else 0) raw_h = led_count * h_frac
right_count = leds_per_edge raw_v = led_count * v_frac
top_count = leds_per_edge + (1 if remainder > 1 else 0) bottom_count = round(raw_h)
left_count = leds_per_edge + (1 if remainder > 2 else 0) top_count = round(raw_h)
right_count = round(raw_v)
left_count = round(raw_v)
# Fix rounding error
diff = led_count - (bottom_count + top_count + right_count + left_count)
# Distribute remainder to horizontal edges first (longer edges)
if diff > 0:
bottom_count += 1
diff -= 1
if diff > 0:
top_count += 1
diff -= 1
if diff > 0:
right_count += 1
diff -= 1
if diff > 0:
left_count += 1
diff -= 1
# If we over-counted, remove from shorter edges first
if diff < 0:
left_count += diff # diff is negative
diff = 0
if left_count < 0:
diff = left_count
left_count = 0
right_count += diff
# Ensure each edge has at least 1 LED
bottom_count = max(1, bottom_count)
top_count = max(1, top_count)
right_count = max(1, right_count)
left_count = max(1, left_count)
config = CalibrationConfig( config = CalibrationConfig(
layout="clockwise", layout="clockwise",
@@ -703,7 +742,8 @@ def create_default_calibration(led_count: int) -> CalibrationConfig:
) )
logger.info( logger.info(
f"Created default calibration for {led_count} LEDs: " f"Created default calibration for {led_count} LEDs "
f"(aspect {aspect_width}:{aspect_height}): "
f"bottom={bottom_count}, right={right_count}, " f"bottom={bottom_count}, right={right_count}, "
f"top={top_count}, left={left_count}" f"top={top_count}, left={left_count}"
) )

View File

@@ -1024,3 +1024,68 @@ textarea:focus-visible {
width: 18px; width: 18px;
height: 18px; height: 18px;
} }
/* ── Schedule time picker (value sources) ── */
.schedule-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.schedule-time-wrap {
display: flex;
align-items: center;
gap: 2px;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
padding: 2px 6px;
transition: border-color var(--duration-fast) ease;
}
.schedule-time-wrap:focus-within {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.15);
}
.schedule-time-wrap input[type="number"] {
width: 2.4ch;
text-align: center;
font-size: 1.1rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
font-family: inherit;
background: var(--bg-secondary);
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--text-color);
padding: 4px 2px;
-moz-appearance: textfield;
transition: border-color var(--duration-fast) ease,
background var(--duration-fast) ease;
}
.schedule-time-wrap input[type="number"]::-webkit-inner-spin-button,
.schedule-time-wrap input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.schedule-time-wrap input[type="number"]:focus {
outline: none;
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 8%, var(--bg-secondary));
}
.schedule-time-colon {
font-size: 1.1rem;
font-weight: 700;
color: var(--text-muted);
line-height: 1;
padding: 0 1px;
user-select: none;
}
.schedule-value {
flex: 1;
min-width: 60px;
}
.schedule-value-display {
min-width: 2.5ch;
text-align: right;
font-variant-numeric: tabular-nums;
}

View File

@@ -146,9 +146,7 @@ export class CardSection {
? `<div class="template-card add-template-card cs-add-card" data-cs-add="${this.sectionKey}" onclick="${this.addCardOnclick}"><div class="add-template-icon">+</div></div>` ? `<div class="template-card add-template-card cs-add-card" data-cs-add="${this.sectionKey}" onclick="${this.addCardOnclick}"><div class="add-template-icon">+</div></div>`
: ''; : '';
const emptyState = (count === 0 && this.emptyKey) const emptyState = '';
? `<div class="cs-empty-state" data-cs-empty="${this.sectionKey}"><span class="cs-empty-text text-muted">${t(this.emptyKey)}</span></div>`
: '';
return ` return `
<div class="subtab-section${collapsedClass}" data-card-section="${this.sectionKey}"> <div class="subtab-section${collapsedClass}" data-card-section="${this.sectionKey}">
@@ -314,24 +312,9 @@ export class CardSection {
const countEl = document.querySelector(`[data-cs-toggle="${this.sectionKey}"] .cs-count`); const countEl = document.querySelector(`[data-cs-toggle="${this.sectionKey}"] .cs-count`);
if (countEl && !this._filterValue) countEl.textContent = String(items.length); if (countEl && !this._filterValue) countEl.textContent = String(items.length);
// Show/hide empty state // Remove any stale empty-state element from DOM
if (this.emptyKey) { const staleEmpty = content.querySelector(`[data-cs-empty="${this.sectionKey}"]`);
let emptyEl = content.querySelector(`[data-cs-empty="${this.sectionKey}"]`) as HTMLElement | null; if (staleEmpty) staleEmpty.remove();
if (items.length === 0) {
if (!emptyEl) {
emptyEl = document.createElement('div');
emptyEl.className = 'cs-empty-state';
emptyEl.setAttribute('data-cs-empty', this.sectionKey);
emptyEl.innerHTML = `<span class="cs-empty-text text-muted">${t(this.emptyKey)}</span>`;
const addCard = content.querySelector('.cs-add-card');
if (addCard) content.insertBefore(emptyEl, addCard);
else content.appendChild(emptyEl);
}
emptyEl.style.display = '';
} else if (emptyEl) {
emptyEl.style.display = 'none';
}
}
const newMap = new Map(items.map(i => [i.key, i.html])); const newMap = new Map(items.map(i => [i.key, i.html]));
const addCard = content.querySelector('.cs-add-card'); const addCard = content.querySelector('.cs-add-card');

View File

@@ -383,10 +383,18 @@ function _cssTestConnect(sourceId: string, ledCount: number, fps?: number) {
_cssTestWs.onerror = () => { _cssTestWs.onerror = () => {
if (gen !== _cssTestGeneration) return; if (gen !== _cssTestGeneration) return;
(document.getElementById('css-test-status') as HTMLElement).textContent = t('color_strip.test.error'); (document.getElementById('css-test-status') as HTMLElement).textContent = t('color_strip.test.error');
(document.getElementById('css-test-status') as HTMLElement).style.display = '';
}; };
_cssTestWs.onclose = () => { _cssTestWs.onclose = (ev) => {
if (gen === _cssTestGeneration) _cssTestWs = null; if (gen !== _cssTestGeneration) return;
_cssTestWs = null;
// Show server-provided close reason (e.g. "No LEDs configured")
if (ev.reason) {
const statusEl = document.getElementById('css-test-status') as HTMLElement;
statusEl.textContent = ev.reason;
statusEl.style.display = '';
}
}; };
// Start render loop (only once) // Start render loop (only once)

View File

@@ -874,16 +874,73 @@ function _populatePictureSourceDropdown(selectedId: any) {
export function addSchedulePoint(time: string = '', value: number = 1.0) { export function addSchedulePoint(time: string = '', value: number = 1.0) {
const list = document.getElementById('value-source-schedule-list'); const list = document.getElementById('value-source-schedule-list');
if (!list) return; if (!list) return;
const timeStr = time || '12:00';
const [h, m] = timeStr.split(':').map(Number);
const pad = (n: number) => String(n).padStart(2, '0');
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'schedule-row'; row.className = 'schedule-row';
row.innerHTML = ` row.innerHTML = `
<input type="time" class="schedule-time" value="${time || '12:00'}"> <div class="schedule-time-wrap">
<input type="number" class="schedule-h" min="0" max="23" value="${pad(h)}" data-role="hour">
<span class="schedule-time-colon">:</span>
<input type="number" class="schedule-m" min="0" max="59" value="${pad(m)}" data-role="minute">
</div>
<input type="hidden" class="schedule-time" value="${timeStr}">
<input type="range" class="schedule-value" min="0" max="1" step="0.01" value="${value}" <input type="range" class="schedule-value" min="0" max="1" step="0.01" value="${value}"
oninput="this.nextElementSibling.textContent = this.value"> oninput="this.nextElementSibling.textContent = this.value">
<span class="schedule-value-display">${value}</span> <span class="schedule-value-display">${value}</span>
<button type="button" class="btn btn-icon btn-danger btn-sm" onclick="this.parentElement.remove()">&#x2715;</button> <button type="button" class="btn btn-icon btn-danger btn-sm" onclick="this.parentElement.remove()">&#x2715;</button>
`; `;
list.appendChild(row); list.appendChild(row);
_wireScheduleTimePicker(row);
}
function _wireScheduleTimePicker(row: HTMLElement) {
const hInput = row.querySelector('.schedule-h') as HTMLInputElement;
const mInput = row.querySelector('.schedule-m') as HTMLInputElement;
const hidden = row.querySelector('.schedule-time') as HTMLInputElement;
if (!hInput || !mInput || !hidden) return;
const pad = (n: number) => String(n).padStart(2, '0');
function clamp(input: HTMLInputElement, min: number, max: number) {
let v = parseInt(input.value, 10);
if (isNaN(v)) v = min;
if (v < min) v = min;
if (v > max) v = max;
input.value = pad(v);
return v;
}
function sync() {
const hv = clamp(hInput, 0, 23);
const mv = clamp(mInput, 0, 59);
hidden.value = `${pad(hv)}:${pad(mv)}`;
}
[hInput, mInput].forEach(inp => {
inp.addEventListener('focus', () => inp.select());
inp.addEventListener('input', sync);
inp.addEventListener('blur', sync);
inp.addEventListener('keydown', (e) => {
const isHour = inp.dataset.role === 'hour';
const max = isHour ? 23 : 59;
if (e.key === 'ArrowUp') {
e.preventDefault();
let v = parseInt(inp.value, 10) || 0;
inp.value = pad(v >= max ? 0 : v + 1);
sync();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
let v = parseInt(inp.value, 10) || 0;
inp.value = pad(v <= 0 ? max : v - 1);
sync();
}
});
});
sync();
} }
function _getScheduleFromUI() { function _getScheduleFromUI() {

View File

@@ -10,9 +10,9 @@
<div id="notification-history-status" style="display:none;color:var(--text-muted);font-size:0.85rem;margin-bottom:0.5rem"></div> <div id="notification-history-status" style="display:none;color:var(--text-muted);font-size:0.85rem;margin-bottom:0.5rem"></div>
<div id="notification-history-list" style="max-height:340px;overflow-y:auto;border:1px solid var(--border-color);border-radius:4px;padding:0.25rem 0"></div> <div id="notification-history-list" style="max-height:340px;overflow-y:auto;border:1px solid var(--border-color);border-radius:4px;padding:0.25rem 0"></div>
</div> </div>
<div class="modal-actions"> <div class="modal-footer">
<button class="btn btn-secondary" onclick="refreshNotificationHistory()" data-i18n="color_strip.notification.history.refresh">Refresh</button> <button class="btn btn-icon btn-secondary" onclick="refreshNotificationHistory()" data-i18n-title="color_strip.notification.history.refresh" title="Refresh"><svg class="icon" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg></button>
<button class="btn btn-secondary" onclick="closeNotificationHistory()" data-i18n="settings.button.cancel">Close</button> <button class="btn btn-icon btn-secondary" onclick="closeNotificationHistory()" data-i18n-title="settings.button.cancel" title="Close"><svg class="icon" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div> </div>
</div> </div>
</div> </div>