feat: LED hot-path perf, tutorials expansion, modal markup polish

Performance (LED hot path, allocation-free per-frame):
- Adalight: dedicated single-worker tx executor (avoids asyncio.to_thread
  overhead), pre-allocated wire buffer + uint8 scratch, header struct
  precomputed. New tests cover header format, buffer reuse, non-contiguous
  input, and brightness scaling.
- DDP: pre-built struct.Struct for the 10-byte header, allocation-free
  send buffer + memoryview emit path. New tests cover RGB/RGBW packets,
  sequence/PUSH semantics, and multi-packet fragmentation.
- Calibration: precomputed Phase 3 skip-LED resampling (floor/ceil indices,
  fractional weights, take/blend scratch buffers) — per-frame work is now
  np.take + in-place blend, no allocations.
- WLED target processor: matching hot-path tightening.

Tutorials:
- Sub-tab switching, breadcrumb header, and prepare/switchSubTab hooks
  so a tour can open/close the dashboard customize panel and resolve
  targets behind sub-tabs.
- New steps for integrations tab, dashboard customize panel (presets,
  global, sections, perf cells), targets, scenes, sync-clocks.
- en/ru/zh locales updated with the new tour strings.

Dashboard layout:
- Structural deep-equal so the "modified" indicator reflects truth after
  a user edits then reverts, instead of a stale flag.

UI polish:
- Mod-card / modal markup pass across ~33 modal templates and the
  tutorial overlay partial.
- appearance.css, modal.css, tutorials.css refresh.

Tooling:
- Add .mcp.json with code-review-graph MCP server config so the graph
  tools are available to the team out of the box.
This commit is contained in:
2026-05-01 03:02:13 +03:00
parent 9d4a534ec6
commit 797b806972
57 changed files with 4020 additions and 1788 deletions
+12
View File
@@ -0,0 +1,12 @@
{
"mcpServers": {
"code-review-graph": {
"command": "uvx",
"args": [
"code-review-graph",
"serve"
],
"type": "stdio"
}
}
}
+172 -63
View File
@@ -233,6 +233,90 @@ class CalibrationConfig:
return None
def _build_skip_buffers(mapper, calibration: CalibrationConfig, total_leds: int) -> None:
"""Pre-compute Phase 3 skip-LED resampling indices and scratch buffers.
Phase 3 takes the full ``total_leds`` strip and resamples it into
``active_count = total_leds - skip_start - skip_end`` LEDs using linear
interpolation. We precompute floor/ceil source indices and fractional
weights once so per-frame work becomes a couple of ``np.take`` +
in-place arithmetic ops with no allocations.
Attaches all skip-related state to ``mapper`` directly to keep the
storage layout consistent between PixelMapper and AdvancedPixelMapper.
"""
skip_start = calibration.skip_leds_start
skip_end = calibration.skip_leds_end
mapper._skip_start = skip_start
mapper._skip_end = skip_end
active_count = max(0, total_leds - skip_start - skip_end)
mapper._active_count = active_count
if not (0 < active_count < total_leds):
# No skip needed (full strip used) or no active LEDs.
mapper._skip_floor_idx = None
mapper._skip_ceil_idx = None
mapper._skip_frac = None
mapper._skip_left_u8 = None
mapper._skip_right_u8 = None
mapper._skip_blend_f32 = None
mapper._skip_resampled = None
return
# Floor/ceil source indices and fractional weights for each
# destination LED. ``t = src_x[k] = k * (total_leds - 1) / (active_count - 1)``
# — equivalent to ``np.linspace(0, total_leds - 1, active_count)``.
if active_count > 1:
t = np.arange(active_count, dtype=np.float64) * ((total_leds - 1) / (active_count - 1))
else:
t = np.zeros(active_count, dtype=np.float64)
floor_idx = np.floor(t).astype(np.int64)
np.clip(floor_idx, 0, total_leds - 1, out=floor_idx)
ceil_idx = np.minimum(floor_idx + 1, total_leds - 1)
frac = (t - floor_idx).astype(np.float32)[:, None] # (active_count, 1)
mapper._skip_floor_idx = floor_idx
mapper._skip_ceil_idx = ceil_idx
mapper._skip_frac = frac
# uint8 take destinations + float32 blend scratch — all reused per frame
mapper._skip_left_u8 = np.empty((active_count, 3), dtype=np.uint8)
mapper._skip_right_u8 = np.empty((active_count, 3), dtype=np.uint8)
mapper._skip_blend_f32 = np.empty((active_count, 3), dtype=np.float32)
mapper._skip_resampled = np.empty((active_count, 3), dtype=np.uint8)
def _apply_skip_resample(mapper, led_array: np.ndarray) -> None:
"""Phase 3 in-place resample of ``led_array`` (no allocations).
Applies linear interpolation precomputed in ``_build_skip_buffers`` and
writes the result back into ``led_array`` with the configured skip
leading/trailing zeros.
"""
floor_idx = mapper._skip_floor_idx
if floor_idx is None:
if mapper._active_count <= 0:
led_array[:] = 0
return
left_u8 = mapper._skip_left_u8
right_u8 = mapper._skip_right_u8
blend = mapper._skip_blend_f32
resampled = mapper._skip_resampled
np.take(led_array, floor_idx, axis=0, out=left_u8)
np.take(led_array, mapper._skip_ceil_idx, axis=0, out=right_u8)
np.copyto(blend, right_u8, casting="unsafe") # uint8 → float32
blend -= left_u8 # right - left
blend *= mapper._skip_frac # frac * (right - left)
blend += left_u8 # left + frac*(right - left)
np.clip(blend, 0, 255, out=blend)
np.copyto(resampled, blend, casting="unsafe") # float32 → uint8
led_array[:] = 0
end_idx = mapper._total_leds - mapper._skip_end
led_array[mapper._skip_start : end_idx] = resampled
class PixelMapper:
"""Maps screen border pixels to LED colors based on calibration."""
@@ -280,19 +364,10 @@ class PixelMapper:
indices = (indices + offset) % total_leds
self._segment_indices.append(indices)
# Pre-compute Phase 3 skip arrays (static geometry)
skip_start = calibration.skip_leds_start
skip_end = calibration.skip_leds_end
self._skip_start = skip_start
self._skip_end = skip_end
self._active_count = max(0, total_leds - skip_start - skip_end)
if 0 < self._active_count < total_leds:
self._skip_src = np.linspace(0, total_leds - 1, self._active_count)
self._skip_x = np.arange(total_leds, dtype=np.float64)
self._skip_float = np.empty((total_leds, 3), dtype=np.float64)
self._skip_resampled = np.empty((self._active_count, 3), dtype=np.uint8)
else:
self._skip_src = self._skip_x = self._skip_float = self._skip_resampled = None
# Pre-compute Phase 3 skip — linear interpolation by precomputed
# floor/ceil indices and fractional weights. Per-frame work is
# entirely write-in-place into pre-allocated scratch buffers.
_build_skip_buffers(self, calibration, total_leds)
# Per-edge average computation cache (lazy-initialized on first frame)
self._edge_cache: Dict[str, tuple] = {}
@@ -357,8 +432,9 @@ class PixelMapper:
) -> np.ndarray:
"""Vectorized average-color mapping for one edge. Returns (led_count, 3) uint8.
Uses pre-allocated cumsum/mean buffers (lazy-initialized per edge) to
avoid per-frame allocations that cause GC-induced timing spikes.
Uses pre-allocated cumsum/mean buffers AND pre-allocated output
buffers (lazy-initialized per edge). All per-frame numpy ops write
in-place — zero allocations on the hot path.
"""
if edge_name in ("top", "bottom"):
axis = 0
@@ -369,7 +445,7 @@ class PixelMapper:
# Lazy-init / resize per-edge scratch buffers
cache = self._edge_cache.get(edge_name)
if cache is None or cache[0] != edge_len:
if cache is None or cache[0] != edge_len or cache[1] != led_count:
step = edge_len / led_count
boundaries = (np.arange(led_count + 1, dtype=np.float64) * step).astype(np.int64)
boundaries[1:] = np.maximum(boundaries[1:], boundaries[:-1] + 1)
@@ -379,20 +455,53 @@ class PixelMapper:
lengths = (ends - starts).reshape(-1, 1).astype(np.float64)
cumsum_buf = np.empty((edge_len + 1, 3), dtype=np.float64)
edge_1d_buf = np.empty((edge_len, 3), dtype=np.float64)
cache = (edge_len, starts, ends, lengths, cumsum_buf, edge_1d_buf)
sums_buf = np.empty((led_count, 3), dtype=np.float64)
starts_buf = np.empty((led_count, 3), dtype=np.float64)
out_uint8 = np.empty((led_count, 3), dtype=np.uint8)
cache = (
edge_len,
led_count,
starts,
ends,
lengths,
cumsum_buf,
edge_1d_buf,
sums_buf,
starts_buf,
out_uint8,
)
self._edge_cache[edge_name] = cache
_, starts, ends, lengths, cumsum_buf, edge_1d_buf = cache
(
_,
_,
starts,
ends,
lengths,
cumsum_buf,
edge_1d_buf,
sums_buf,
starts_buf,
out_uint8,
) = cache
# Mean into pre-allocated buffer (no intermediate float64 array)
np.mean(edge_pixels, axis=axis, out=edge_1d_buf)
# Cumsum into pre-allocated buffer
# Cumsum into pre-allocated buffer (cumsum_buf[0] left at 0 from init)
cumsum_buf[0] = 0
np.cumsum(edge_1d_buf, axis=0, out=cumsum_buf[1:])
segment_sums = cumsum_buf[ends] - cumsum_buf[starts]
return np.clip(segment_sums / lengths, 0, 255).astype(np.uint8)
# segment_sums = cumsum_buf[ends] - cumsum_buf[starts] — but each
# fancy-index expression allocates. np.take with ``out=`` writes
# directly into our pre-allocated scratch.
np.take(cumsum_buf, ends, axis=0, out=sums_buf)
np.take(cumsum_buf, starts, axis=0, out=starts_buf)
np.subtract(sums_buf, starts_buf, out=sums_buf)
np.divide(sums_buf, lengths, out=sums_buf)
np.clip(sums_buf, 0, 255, out=sums_buf)
np.copyto(out_uint8, sums_buf, casting="unsafe")
return out_uint8
def map_border_to_leds(self, border_pixels: BorderPixels) -> np.ndarray:
"""Map screen border pixels to LED colors.
@@ -423,18 +532,9 @@ class PixelMapper:
led_array[self._segment_indices[i]] = colors
# Phase 3: Physical skip — resample full perimeter to active LEDs
if self._skip_src is not None:
np.copyto(self._skip_float, led_array, casting="unsafe")
for ch in range(3):
self._skip_resampled[:, ch] = np.round(
np.interp(self._skip_src, self._skip_x, self._skip_float[:, ch])
).astype(np.uint8)
led_array[:] = 0
end_idx = self._total_leds - self._skip_end
led_array[self._skip_start : end_idx] = self._skip_resampled
elif self._active_count <= 0:
led_array[:] = 0
# Phase 3: physical skip — resample full perimeter into active LEDs
# using precomputed weights, all in-place.
_apply_skip_resample(self, led_array)
return led_array
@@ -514,19 +614,8 @@ class AdvancedPixelMapper:
self._line_indices.append(indices)
led_start += line.led_count
# Skip arrays (same logic as PixelMapper)
skip_start = calibration.skip_leds_start
skip_end = calibration.skip_leds_end
self._skip_start = skip_start
self._skip_end = skip_end
self._active_count = max(0, total_leds - skip_start - skip_end)
if 0 < self._active_count < total_leds:
self._skip_src = np.linspace(0, total_leds - 1, self._active_count)
self._skip_x = np.arange(total_leds, dtype=np.float64)
self._skip_float = np.empty((total_leds, 3), dtype=np.float64)
self._skip_resampled = np.empty((self._active_count, 3), dtype=np.uint8)
else:
self._skip_src = self._skip_x = self._skip_float = self._skip_resampled = None
# Skip arrays — share the same buffer layout as PixelMapper
_build_skip_buffers(self, calibration, total_leds)
# Per-line edge cache (keyed by line index to avoid collision)
self._edge_cache: Dict[int, tuple] = {}
@@ -586,7 +675,7 @@ class AdvancedPixelMapper:
edge_len = edge_pixels.shape[0]
cache = self._edge_cache.get(cache_key)
if cache is None or cache[0] != edge_len:
if cache is None or cache[0] != edge_len or cache[1] != led_count:
step = edge_len / led_count
boundaries = (np.arange(led_count + 1, dtype=np.float64) * step).astype(np.int64)
boundaries[1:] = np.maximum(boundaries[1:], boundaries[:-1] + 1)
@@ -596,15 +685,45 @@ class AdvancedPixelMapper:
lengths = (ends - starts).reshape(-1, 1).astype(np.float64)
cumsum_buf = np.empty((edge_len + 1, 3), dtype=np.float64)
edge_1d_buf = np.empty((edge_len, 3), dtype=np.float64)
cache = (edge_len, starts, ends, lengths, cumsum_buf, edge_1d_buf)
sums_buf = np.empty((led_count, 3), dtype=np.float64)
starts_buf = np.empty((led_count, 3), dtype=np.float64)
out_uint8 = np.empty((led_count, 3), dtype=np.uint8)
cache = (
edge_len,
led_count,
starts,
ends,
lengths,
cumsum_buf,
edge_1d_buf,
sums_buf,
starts_buf,
out_uint8,
)
self._edge_cache[cache_key] = cache
_, starts, ends, lengths, cumsum_buf, edge_1d_buf = cache
(
_,
_,
starts,
ends,
lengths,
cumsum_buf,
edge_1d_buf,
sums_buf,
starts_buf,
out_uint8,
) = cache
np.mean(edge_pixels, axis=axis, out=edge_1d_buf)
cumsum_buf[0] = 0
np.cumsum(edge_1d_buf, axis=0, out=cumsum_buf[1:])
segment_sums = cumsum_buf[ends] - cumsum_buf[starts]
return np.clip(segment_sums / lengths, 0, 255).astype(np.uint8)
np.take(cumsum_buf, ends, axis=0, out=sums_buf)
np.take(cumsum_buf, starts, axis=0, out=starts_buf)
np.subtract(sums_buf, starts_buf, out=sums_buf)
np.divide(sums_buf, lengths, out=sums_buf)
np.clip(sums_buf, 0, 255, out=sums_buf)
np.copyto(out_uint8, sums_buf, casting="unsafe")
return out_uint8
def _map_edge_fallback(
self,
@@ -672,18 +791,8 @@ class AdvancedPixelMapper:
led_array[self._line_indices[i]] = colors
# Phase 3: Physical skip (same as PixelMapper)
if self._skip_src is not None:
np.copyto(self._skip_float, led_array, casting="unsafe")
for ch in range(3):
self._skip_resampled[:, ch] = np.round(
np.interp(self._skip_src, self._skip_x, self._skip_float[:, ch])
).astype(np.uint8)
led_array[:] = 0
end_idx = self._total_leds - self._skip_end
led_array[self._skip_start : end_idx] = self._skip_resampled
elif self._active_count <= 0:
led_array[:] = 0
# Phase 3: physical skip same precomputed-weight resample as PixelMapper
_apply_skip_resample(self, led_array)
return led_array
@@ -1,6 +1,7 @@
"""Adalight serial LED client — sends pixel data over serial using the Adalight protocol."""
import asyncio
import concurrent.futures
from datetime import datetime, timezone
from typing import Optional, Tuple
@@ -56,15 +57,38 @@ class AdalightClient(LEDClient):
# Pre-compute Adalight header if led_count is known
self._header = _build_adalight_header(led_count) if led_count > 0 else b""
self._header_len = len(self._header)
# Pre-allocate numpy buffer for brightness scaling
self._pixel_buf = None
# Pre-allocated wire buffer (header + RGB payload). Resized on the
# first frame and reused thereafter so the hot path performs no
# allocations — only a single memcpy of the pixel bytes.
self._frame_buf: Optional[bytearray] = None
self._frame_buf_n: int = 0
# Scratch uint8 array used to coerce non-uint8 / non-contiguous input
# without allocating a fresh array per frame.
self._u8_scratch: Optional[np.ndarray] = None
self._u8_scratch_n: int = 0
# Dedicated single-worker executor for serial writes. Using
# ``loop.run_in_executor`` against this avoids the per-call
# ``contextvars.copy_context()`` and ``functools.partial`` overhead
# that ``asyncio.to_thread`` incurs (~510 µs per call), and
# guarantees FIFO ordering of writes from this client even when
# other tasks are using the default executor.
self._tx_executor: Optional[concurrent.futures.ThreadPoolExecutor] = None
async def connect(self) -> bool:
"""Open serial port and wait for Arduino reset."""
try:
self._serial = open_transport(self._port, baud_rate=self._baud_rate, timeout=1)
await asyncio.to_thread(self._serial.open)
# Single-worker executor — created here so the thread is bound
# to this client's lifecycle (started on connect, shut down on
# close). ``thread_name_prefix`` makes it identifiable in
# diagnostics.
self._tx_executor = concurrent.futures.ThreadPoolExecutor(
max_workers=1,
thread_name_prefix=f"adalight-tx-{self._port}",
)
await asyncio.get_running_loop().run_in_executor(self._tx_executor, self._serial.open)
# Wait for Arduino to finish bootloader reset (non-blocking).
# USB-to-TTL adapters without DTR don't reset, but the delay
# is harmless on those — keeps the path uniform.
@@ -77,11 +101,22 @@ class AdalightClient(LEDClient):
return True
except Exception as e:
logger.error(f"Failed to open serial port {self._port}: {e}")
if self._tx_executor is not None:
self._tx_executor.shutdown(wait=False)
self._tx_executor = None
raise RuntimeError(f"Failed to open serial port {self._port}: {e}")
async def close(self) -> None:
"""Send black frame and close the serial port."""
if self._connected and self._serial and self._serial.is_open and self._led_count > 0:
loop = asyncio.get_running_loop()
executor = self._tx_executor
if (
self._connected
and self._serial
and self._serial.is_open
and self._led_count > 0
and executor is not None
):
try:
black = np.zeros((self._led_count, 3), dtype=np.uint8)
frame = self._build_frame(black, brightness=255)
@@ -89,8 +124,8 @@ class AdalightClient(LEDClient):
f"Adalight sending black frame: {self._port} "
f"({self._led_count} LEDs, {len(frame)} bytes)"
)
await asyncio.to_thread(self._serial.write, frame)
await asyncio.to_thread(self._serial.flush)
await loop.run_in_executor(executor, self._serial.write, frame)
await loop.run_in_executor(executor, self._serial.flush)
logger.info(f"Adalight black frame sent and flushed: {self._port}")
except Exception as e:
logger.warning(f"Failed to send black frame on close: {e}")
@@ -108,6 +143,9 @@ class AdalightClient(LEDClient):
except Exception as e:
logger.warning(f"Error closing serial port: {e}")
self._serial = None
if self._tx_executor is not None:
self._tx_executor.shutdown(wait=False)
self._tx_executor = None
logger.info(f"Adalight disconnected: {self._port}")
@property
@@ -125,12 +163,15 @@ class AdalightClient(LEDClient):
pixels: numpy array (N, 3) uint8 or list of (R, G, B) tuples
brightness: Global brightness (0-255)
"""
if not self.is_connected:
executor = self._tx_executor
if not self.is_connected or executor is None:
return False
try:
frame = self._build_frame(pixels, brightness)
await asyncio.to_thread(self._serial.write, frame)
# ``run_in_executor`` skips the per-call ``contextvars.copy_context``
# / ``functools.partial`` overhead that ``asyncio.to_thread`` does.
await asyncio.get_running_loop().run_in_executor(executor, self._serial.write, frame)
return True
except Exception as e:
logger.error(f"Adalight send_pixels error: {e}")
@@ -141,17 +182,63 @@ class AdalightClient(LEDClient):
# Serial write is blocking — use async send_pixels path instead
return False
def _build_frame(self, pixels, brightness: int) -> bytes:
"""Build a complete Adalight frame: header + brightness-scaled RGB data."""
if isinstance(pixels, np.ndarray):
arr = pixels.astype(np.uint16)
else:
arr = np.array(pixels, dtype=np.uint16)
def _ensure_frame_buf(self, n_leds: int) -> None:
"""Lazily allocate / resize the wire-format frame buffer.
# Note: brightness already applied by processor loop (_cached_brightness)
np.clip(arr, 0, 255, out=arr)
rgb_bytes = arr.astype(np.uint8).tobytes()
return self._header + rgb_bytes
Header bytes are written once at the front; subsequent calls only
memcpy the pixel payload into the trailing slot.
"""
needed = self._header_len + n_leds * 3
if self._frame_buf is None or len(self._frame_buf) != needed:
buf = bytearray(needed)
buf[: self._header_len] = self._header
self._frame_buf = buf
self._frame_buf_n = n_leds
def _ensure_u8_scratch(self, n_leds: int) -> np.ndarray:
"""Pre-allocated (N, 3) uint8 scratch for non-conforming inputs."""
if self._u8_scratch is None or self._u8_scratch_n != n_leds:
self._u8_scratch = np.empty((n_leds, 3), dtype=np.uint8)
self._u8_scratch_n = n_leds
return self._u8_scratch
def _build_frame(self, pixels, brightness: int) -> bytes:
"""Build a complete Adalight frame in the pre-allocated wire buffer.
The processor loop hands us a contiguous (N, 3) uint8 array with
brightness already applied (see ``_cached_brightness``), so the hot
path is one memcpy from the pixel buffer into the trailing slot of
``_frame_buf``. All other input shapes (lists of tuples, wrong
dtype, non-contiguous views) coerce into a pre-allocated uint8
scratch before the memcpy — still allocation-free in steady state.
"""
if isinstance(pixels, np.ndarray):
n_leds = pixels.shape[0]
if pixels.dtype == np.uint8 and pixels.flags["C_CONTIGUOUS"]:
# Hot path: input matches wire format exactly.
arr = pixels
else:
# Slow path: dtype mismatch or non-contiguous view. Coerce
# into a pre-allocated uint8 scratch. Wider integer dtypes
# are clamped to [0, 255] to match historical behaviour.
arr = self._ensure_u8_scratch(n_leds)
if pixels.dtype != np.uint8:
# Clamp wider integer dtypes to [0, 255] before the
# uint8 narrowing copy. This is the rare slow path —
# one extra allocation here is fine.
np.copyto(arr, np.clip(pixels, 0, 255), casting="unsafe")
else:
np.copyto(arr, pixels)
else:
# List/tuple input — rare path, only hit by tests/legacy callers.
arr = np.array(pixels, dtype=np.uint8)
n_leds = arr.shape[0]
self._ensure_frame_buf(n_leds)
# memcpy pixel bytes into the trailing slot of the pre-built buffer
view = memoryview(self._frame_buf)
view[self._header_len :] = memoryview(arr).cast("B")
return self._frame_buf
@classmethod
async def check_health(
+86 -114
View File
@@ -39,6 +39,9 @@ class DDPClient:
DDP_FLAGS_PUSH = 0x01 # PUSH flag (set on last packet of a frame)
DDP_TYPE_RGB = 0x01
# Pre-built struct.Struct for the 10-byte DDP header (avoids per-call format parsing)
_HEADER_STRUCT = struct.Struct("!BBB B I H")
def __init__(self, host: str, port: int = DDP_PORT, rgbw: bool = False):
"""Initialize DDP client.
@@ -57,6 +60,10 @@ class DDPClient:
# Pre-allocated RGBW buffer (resized on demand)
self._rgbw_buf: Optional[np.ndarray] = None
self._rgbw_buf_n: int = 0
# Pre-allocated send buffer (header + payload). Sized lazily on first
# send so we never allocate fresh bytes per frame on the hot path.
self._send_buf: Optional[bytearray] = None
self._send_view: Optional[memoryview] = None
async def connect(self):
"""Establish UDP connection."""
@@ -93,52 +100,52 @@ class DDPClient:
f"color order={order_name.get(bus.color_order, '?')} ({bus.color_order})"
)
def _build_ddp_packet(
self,
rgb_data: bytes,
offset: int = 0,
sequence: int = 1,
push: bool = False,
) -> bytes:
"""Build a DDP packet.
def _ensure_send_buf(self, capacity: int) -> None:
"""Lazily allocate / grow the per-instance send buffer.
DDP packet format (10-byte header + data):
- Byte 0: Flags (VER1 | PUSH on last packet)
- Byte 1: Sequence number
- Byte 2: Data type (0x01 = RGB)
- Byte 3: Source/Destination ID
- Bytes 4-7: Data offset (4 bytes, big-endian)
- Bytes 8-9: Data length (2 bytes, big-endian)
- Bytes 10+: Pixel data
Args:
rgb_data: RGB pixel data as bytes
offset: Byte offset (pixel_index * 3)
sequence: Sequence number (0-255)
push: True for the last packet of a frame
Returns:
Complete DDP packet as bytes
``capacity`` is the largest packet we may emit (header + payload).
Once sized, the buffer is reused for every subsequent send so the
hot path stays allocation-free.
"""
flags = self.DDP_FLAGS_VER1
if push:
flags |= self.DDP_FLAGS_PUSH
data_type = self.DDP_TYPE_RGB
source_id = 0x01
data_len = len(rgb_data)
buf = self._send_buf
if buf is None or len(buf) < capacity:
self._send_buf = bytearray(capacity)
self._send_view = memoryview(self._send_buf)
# Build header (10 bytes)
header = struct.pack(
"!BBB B I H", # Network byte order (big-endian)
flags, # Flags
sequence, # Sequence
data_type, # Data type
source_id, # Source/Destination
offset, # Data offset (4 bytes)
data_len, # Data length (2 bytes)
def _emit_packet(
self,
payload: memoryview,
offset: int,
sequence: int,
push: bool,
) -> None:
"""Pack header + payload into the pre-allocated send buffer and emit.
DDP packet layout (10-byte header):
[0] Flags (VER1 | PUSH on last)
[1] Sequence
[2] Data type (0x01 = RGB)
[3] Source/Destination ID
[4-7] Data offset (big-endian)
[8-9] Data length (big-endian)
[10+] Pixel data
"""
flags = self.DDP_FLAGS_VER1 | (self.DDP_FLAGS_PUSH if push else 0)
data_len = len(payload)
self._ensure_send_buf(10 + data_len)
buf = self._send_buf
view = self._send_view
# Fill header into pre-allocated buffer (no allocation)
self._HEADER_STRUCT.pack_into(
buf, 0, flags, sequence, self.DDP_TYPE_RGB, 0x01, offset, data_len
)
return header + rgb_data
# Copy payload bytes into buffer (single memcpy)
view[10 : 10 + data_len] = payload
# asyncio's selector_datagram_transport.sendto fast-path calls
# socket.sendto(data) which accepts a buffer-like; it only copies to
# bytes when the OS send buffer is full and the datagram must be
# queued. So passing a memoryview is safe and avoids `bytes(...)`.
self._transport.sendto(view[: 10 + data_len])
def _reorder_pixels_numpy(self, pixel_array: np.ndarray) -> np.ndarray:
"""Apply per-bus color order reordering using numpy fancy indexing.
@@ -168,13 +175,39 @@ class DDPClient:
return result
def _send_buffer(self, payload_view: memoryview, bpp: int, max_packet_size: int) -> int:
"""Chunk and emit a contiguous payload via DDP. Returns packet count.
``payload_view`` is a 1-D bytes-like view; the caller guarantees its
length is a multiple of ``bpp``. Each emitted packet is sized to a
whole number of pixels so RGB channels never split across packets.
"""
total_bytes = len(payload_view)
max_payload = max_packet_size - 10 # 10-byte header
bytes_per_packet = (max_payload // bpp) * bpp
if bytes_per_packet <= 0:
bytes_per_packet = bpp # degenerate guard
num_packets = (total_bytes + bytes_per_packet - 1) // bytes_per_packet
for i in range(num_packets):
start = i * bytes_per_packet
end = total_bytes if (i == num_packets - 1) else (start + bytes_per_packet)
self._sequence = (self._sequence + 1) % 256
self._emit_packet(
payload_view[start:end],
offset=start,
sequence=self._sequence,
push=(i == num_packets - 1),
)
return num_packets
async def send_pixels(
self, pixels: List[Tuple[int, int, int]], max_packet_size: int = 1400
) -> bool:
"""Send pixel data via DDP.
Args:
pixels: List of (R, G, B) tuples
pixels: List of (R, G, B) tuples or an (N, 3) uint8 numpy array
max_packet_size: Maximum UDP packet size (default 1400 bytes for safety)
Returns:
@@ -187,65 +220,17 @@ class DDPClient:
raise RuntimeError("DDP client not connected")
try:
# Send plain RGB — WLED handles per-bus color order conversion
# internally when outputting to hardware.
# Accept numpy arrays directly to avoid per-pixel Python loop
bpp = 4 if self.rgbw else 3 # bytes per pixel
if isinstance(pixels, np.ndarray):
pixel_array = pixels
else:
pixel_array = np.array(pixels, dtype=np.uint8)
if self.rgbw:
n = pixel_array.shape[0]
if n != self._rgbw_buf_n:
self._rgbw_buf = np.zeros((n, 4), dtype=np.uint8)
self._rgbw_buf_n = n
self._rgbw_buf[:, :3] = pixel_array
pixel_array = self._rgbw_buf
pixel_bytes = pixel_array.tobytes()
total_bytes = len(pixel_bytes)
# Align payload to full pixels (multiple of bpp) to avoid splitting
# a pixel's channels across packets
max_payload = max_packet_size - 10 # 10-byte header
bytes_per_packet = (max_payload // bpp) * bpp
# Split into multiple packets if needed
num_packets = (total_bytes + bytes_per_packet - 1) // bytes_per_packet
logger.debug(
f"DDP: Sending {len(pixels)} pixels ({total_bytes} bytes) "
f"in {num_packets} packet(s) to {self.host}:{self.port}"
)
for i in range(num_packets):
start = i * bytes_per_packet
end = min(start + bytes_per_packet, total_bytes)
chunk = pixel_bytes[start:end]
is_last = i == num_packets - 1
# Increment sequence number
self._sequence = (self._sequence + 1) % 256
# Set PUSH flag on the last packet to signal frame completion
packet = self._build_ddp_packet(
chunk,
offset=start,
sequence=self._sequence,
push=is_last,
)
self._transport.sendto(packet)
logger.debug(
f"Sent DDP packet {i+1}/{num_packets}: "
f"{len(chunk)} bytes at offset {start}"
f"{' [PUSH]' if is_last else ''}"
)
self.send_pixels_numpy(pixel_array, max_packet_size=max_packet_size)
return True
except Exception as e:
logger.error(f"Failed to send DDP pixels: {e}")
logger.error("Failed to send DDP pixels: %s", e)
raise RuntimeError(f"DDP send failed: {e}")
def send_pixels_numpy(self, pixel_array: np.ndarray, max_packet_size: int = 1400) -> bool:
@@ -270,28 +255,15 @@ class DDPClient:
self._rgbw_buf[:, :3] = pixel_array
pixel_array = self._rgbw_buf
pixel_bytes = pixel_array.tobytes()
bpp = 4 if self.rgbw else 3
total_bytes = len(pixel_bytes)
max_payload = max_packet_size - 10 # 10-byte header
bytes_per_packet = (max_payload // bpp) * bpp
num_packets = (total_bytes + bytes_per_packet - 1) // bytes_per_packet
for i in range(num_packets):
start = i * bytes_per_packet
end = min(start + bytes_per_packet, total_bytes)
chunk = pixel_bytes[start:end]
is_last = i == num_packets - 1
self._sequence = (self._sequence + 1) % 256
packet = self._build_ddp_packet(
chunk,
offset=start,
sequence=self._sequence,
push=is_last,
)
self._transport.sendto(packet)
# Get a 1-D bytes view of the pixel buffer with no allocation when
# the array is already C-contiguous (the common case).
if not pixel_array.flags["C_CONTIGUOUS"]:
pixel_array = np.ascontiguousarray(pixel_array)
# ``cast('B')`` on a memoryview of a numpy array returns a 1-D byte
# view; total length == nbytes.
payload_view = memoryview(pixel_array).cast("B")
self._send_buffer(payload_view, bpp, max_packet_size)
return True
async def __aenter__(self):
@@ -82,10 +82,17 @@ class WledTargetProcessor(TargetProcessor):
self._resolved_display_index: Optional[int] = None
self._device_config = None # populated on start(), typed DeviceConfig
# Fit-to-device linspace cache (per-instance to avoid cross-target thrash)
# Fit-to-device cache (per-instance to avoid cross-target thrash).
# Holds precomputed floor/ceil source indices, fractional weights,
# and reusable scratch buffers so the per-frame interpolation runs
# entirely with in-place numpy ops — no allocations.
self._fit_cache_key: tuple = (0, 0)
self._fit_cache_src: Optional[np.ndarray] = None
self._fit_cache_dst: Optional[np.ndarray] = None
self._fit_floor_idx: Optional[np.ndarray] = None
self._fit_ceil_idx: Optional[np.ndarray] = None
self._fit_frac: Optional[np.ndarray] = None
self._fit_left_u8: Optional[np.ndarray] = None
self._fit_right_u8: Optional[np.ndarray] = None
self._fit_blend_f32: Optional[np.ndarray] = None
self._fit_result_buf: Optional[np.ndarray] = None
# LED preview WebSocket clients
@@ -384,6 +391,69 @@ class WledTargetProcessor(TargetProcessor):
logger.debug("Device probe failed for %s: %s", device_url, e)
return False
async def _run_liveness_probe_loop(self, device_url: str, probe_interval: float = 10.0) -> None:
"""Background loop that probes the device and updates adaptive state.
Runs independently from the per-frame processing loop so the hot
path doesn't pay for `_probe_task.done()` / scheduling checks every
iteration. Updates ``self._device_reachable``,
``self._metrics.device_streaming_reachable`` and (when adaptive FPS
is enabled) ``self._effective_fps`` directly.
"""
async with httpx.AsyncClient(timeout=httpx.Timeout(2.0)) as client:
while self._is_running:
try:
reachable = await self._probe_device(device_url, client)
except asyncio.CancelledError:
raise
except Exception:
reachable = False
prev_reachable = self._device_reachable
self._device_reachable = reachable
self._metrics.device_streaming_reachable = reachable
if self._adaptive_fps:
target_fps = self._target_fps if self._target_fps > 0 else 30
if not reachable:
old_eff = self._effective_fps
new_eff = max(1, self._effective_fps // 2)
if old_eff != new_eff:
self._effective_fps = new_eff
logger.warning(
"[ADAPTIVE] %s device unreachable, FPS %d%d",
self._target_id,
old_eff,
new_eff,
)
elif self._effective_fps < target_fps:
step = max(1, target_fps // 8)
old_eff = self._effective_fps
new_eff = min(target_fps, self._effective_fps + step)
if old_eff != new_eff:
self._effective_fps = new_eff
logger.info(
"[ADAPTIVE] %s device reachable, FPS %d%d",
self._target_id,
old_eff,
new_eff,
)
if prev_reachable != reachable:
logger.info(
"[PROBE] %s device %s",
self._target_id,
"reachable" if reachable else "UNREACHABLE",
)
# Cooperative sleep that promptly notices stop().
# Sleep in 0.5s chunks so cancellation latency stays < 0.5s.
slept = 0.0
while slept < probe_interval and self._is_running:
chunk = min(0.5, probe_interval - slept)
await asyncio.sleep(chunk)
slept += chunk
def get_display_index(self) -> Optional[int]:
"""Display index being captured, from the active stream."""
if self._resolved_display_index is not None:
@@ -646,24 +716,57 @@ class WledTargetProcessor(TargetProcessor):
# ----- Private: processing loop -----
def _fit_to_device(self, colors: np.ndarray, device_led_count: int) -> np.ndarray:
"""Resample colors to match the target LED count."""
"""Resample colors to match the target LED count.
Linear interpolation using floor/ceil source indices and fractional
weights — all precomputed when ``(n, device_led_count)`` changes.
Per-frame work is two ``np.take`` calls and a few in-place ops on
pre-allocated scratch buffers. No per-frame allocations.
"""
n = len(colors)
if n == device_led_count or device_led_count <= 0:
return colors
key = (n, device_led_count)
if self._fit_cache_key != key:
self._fit_cache_src = np.linspace(0, 1, n)
self._fit_cache_dst = np.linspace(0, 1, device_led_count)
self._fit_cache_key = key
if device_led_count > 1 and n > 1:
t = np.arange(device_led_count, dtype=np.float64) * (
(n - 1) / (device_led_count - 1)
)
else:
t = np.zeros(device_led_count, dtype=np.float64)
floor_idx = np.floor(t).astype(np.int64)
np.clip(floor_idx, 0, n - 1, out=floor_idx)
ceil_idx = np.minimum(floor_idx + 1, n - 1)
frac = (t - floor_idx).astype(np.float32)[:, None] # (M, 1) for channel broadcast
self._fit_floor_idx = floor_idx
self._fit_ceil_idx = ceil_idx
self._fit_frac = frac
self._fit_left_u8 = np.empty((device_led_count, 3), dtype=np.uint8)
self._fit_right_u8 = np.empty((device_led_count, 3), dtype=np.uint8)
self._fit_blend_f32 = np.empty((device_led_count, 3), dtype=np.float32)
self._fit_result_buf = np.empty((device_led_count, 3), dtype=np.uint8)
buf = self._fit_result_buf
for ch in range(min(colors.shape[1], 3)):
np.copyto(
buf[:, ch],
np.interp(self._fit_cache_dst, self._fit_cache_src, colors[:, ch]),
casting="unsafe",
)
return buf
self._fit_cache_key = key
# Source slice: ColorStripStreams produce (N, 3); guard against (N, 4) RGBA.
rgb = colors[:, :3] if colors.ndim == 2 and colors.shape[1] > 3 else colors
left_u8 = self._fit_left_u8
right_u8 = self._fit_right_u8
blend = self._fit_blend_f32
out = self._fit_result_buf
# uint8 → uint8 take with `out=` — no allocation
np.take(rgb, self._fit_floor_idx, axis=0, out=left_u8)
np.take(rgb, self._fit_ceil_idx, axis=0, out=right_u8)
# Promote right to float32 in pre-allocated scratch
np.copyto(blend, right_u8, casting="unsafe") # blend = right (float32)
blend -= left_u8 # blend = right - left
blend *= self._fit_frac # blend = frac * (right - left)
blend += left_u8 # blend = left + frac * (right - left)
np.clip(blend, 0, 255, out=blend)
np.copyto(out, blend, casting="unsafe") # float32 → uint8
return out
async def _send_to_device(self, send_colors: np.ndarray) -> float:
"""Send colors to LED device and return send time in ms."""
@@ -785,14 +888,16 @@ class WledTargetProcessor(TargetProcessor):
_diag_slow_iters: collections.deque = collections.deque(maxlen=50)
_diag_iter_times: collections.deque = collections.deque(maxlen=300)
# --- Liveness probe + adaptive FPS ---
# The probe runs as an independent task so the hot loop doesn't
# pay for per-iteration probe-state checks.
_device_url = self._device_config.device_url if self._device_config else ""
_probe_enabled = _device_url.startswith("http")
_probe_interval = 10.0 # seconds between probes
_last_probe_time = 0.0 # force first probe soon (after 10s)
_probe_task: Optional[asyncio.Task] = None
_probe_client: Optional[httpx.AsyncClient] = None
if _probe_enabled:
_probe_client = httpx.AsyncClient(timeout=httpx.Timeout(2.0))
_probe_task = asyncio.create_task(
self._run_liveness_probe_loop(_device_url),
name=f"liveness-probe-{self._target_id}",
)
self._effective_fps = self._target_fps
self._device_reachable = None
@@ -816,63 +921,8 @@ class WledTargetProcessor(TargetProcessor):
loop_start = now = time.perf_counter()
target_fps = self._target_fps if self._target_fps > 0 else 30
# --- Liveness probe ---
# Collect result as soon as it's done (every iteration)
if _probe_task is not None and _probe_task.done():
try:
reachable = _probe_task.result()
except Exception:
reachable = False
prev_reachable = self._device_reachable
self._device_reachable = reachable
self._metrics.device_streaming_reachable = reachable
_probe_task = None
if self._adaptive_fps:
if not reachable:
# Backoff: halve effective FPS
old_eff = self._effective_fps
self._effective_fps = max(1, self._effective_fps // 2)
if old_eff != self._effective_fps:
logger.warning(
f"[ADAPTIVE] {self._target_id} device unreachable, "
f"FPS {old_eff}{self._effective_fps}"
)
next_frame_time = time.perf_counter()
else:
# Recovery: gradually increase
if self._effective_fps < target_fps:
step = max(1, target_fps // 8)
old_eff = self._effective_fps
self._effective_fps = min(
target_fps, self._effective_fps + step
)
if old_eff != self._effective_fps:
logger.info(
f"[ADAPTIVE] {self._target_id} device reachable, "
f"FPS {old_eff}{self._effective_fps}"
)
next_frame_time = time.perf_counter()
if prev_reachable != reachable:
logger.info(
f"[PROBE] {self._target_id} device "
f"{'reachable' if reachable else 'UNREACHABLE'}"
)
# Fire new probe every _probe_interval seconds
if (
_probe_enabled
and _probe_task is None
and (now - _last_probe_time) >= _probe_interval
):
if _probe_client is not None:
_last_probe_time = now
_probe_task = asyncio.create_task(
self._probe_device(_device_url, _probe_client)
)
# Use effective FPS for frame timing
# Use effective FPS for frame timing. ``self._effective_fps``
# is mutated by the liveness probe task — read once.
effective_fps = self._effective_fps if self._adaptive_fps else target_fps
self._metrics.fps_effective = effective_fps
frame_time = 1.0 / effective_fps
@@ -992,8 +1042,8 @@ class WledTargetProcessor(TargetProcessor):
await self._broadcast_led_preview(send_colors, cur_brightness)
_last_preview_broadcast = now
self._metrics.frames_skipped += 1
self._metrics.fps_current = _fps_current_from_timestamps()
await asyncio.sleep(SKIP_REPOLL)
self._metrics.fps_current = _fps_current_from_timestamps()
continue
# Force-send preview when a new client just connected
@@ -1035,10 +1085,10 @@ class WledTargetProcessor(TargetProcessor):
await self._broadcast_led_preview(send_colors, cur_brightness)
_last_preview_broadcast = now
self._metrics.frames_skipped += 1
self._metrics.fps_current = _fps_current_from_timestamps()
is_animated = stream.is_animated
repoll = SKIP_REPOLL if is_animated else frame_time
await asyncio.sleep(repoll)
self._metrics.fps_current = _fps_current_from_timestamps()
continue
prev_frame_ref = frame
@@ -1161,9 +1211,9 @@ class WledTargetProcessor(TargetProcessor):
)
raise
finally:
# Clean up probe client
if _probe_client is not None:
await _probe_client.aclose()
# Stop the liveness probe task. ``_run_liveness_probe_loop``
# owns its own httpx.AsyncClient via ``async with`` so cancelling
# the task closes the client cleanly.
if _probe_task is not None and not _probe_task.done():
_probe_task.cancel()
try:
+16 -16
View File
@@ -22,8 +22,8 @@ h1 {
on desktop and gracefully reflows on narrow viewports. */
.ap-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 8px;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 6px;
margin-top: 6px;
}
@@ -35,8 +35,8 @@ h1 {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 5px;
padding: 5px 5px 4px;
gap: 4px;
padding: 4px 4px 3px;
border: 1px solid var(--lux-line, var(--border-color));
border-radius: var(--lux-r-md, 8px);
background: var(--lux-bg-1, var(--card-bg));
@@ -54,7 +54,7 @@ h1 {
.ap-card.active {
border: 2px solid var(--ap-ch);
padding: 4px 4px 3px;
padding: 3px 3px 2px;
box-shadow: 0 0 0 1px color-mix(in srgb, var(--ap-ch) 40%, transparent),
0 0 16px -4px color-mix(in srgb, var(--ap-ch) 50%, transparent);
}
@@ -62,19 +62,19 @@ h1 {
.ap-card.active::after {
content: '\2713';
position: absolute;
top: 4px;
right: 6px;
font-size: 0.65rem;
top: 3px;
right: 4px;
font-size: 0.55rem;
font-weight: 700;
color: var(--ap-ch);
}
.ap-card-label {
font-size: 0.7rem;
font-size: 0.62rem;
font-weight: 600;
color: var(--lux-ink-dim, var(--text-secondary));
text-align: center;
line-height: 1.2;
line-height: 1.15;
letter-spacing: 0.02em;
}
@@ -89,24 +89,24 @@ h1 {
aspect-ratio: 1 / 1;
border-radius: var(--lux-r-sm, 4px);
border: 1px solid;
padding: 7px 6px 5px;
padding: 5px 5px 4px;
display: flex;
flex-direction: column;
gap: 4px;
gap: 3px;
overflow: hidden;
}
.ap-card-accent {
width: 24px;
height: 4px;
width: 18px;
height: 3px;
border-radius: 2px;
margin-bottom: 2px;
margin-bottom: 1px;
}
.ap-card-lines {
display: flex;
flex-direction: column;
gap: 3px;
gap: 2px;
}
.ap-card-lines span {
+39 -4
View File
@@ -3826,10 +3826,45 @@ body.composite-layer-dragging .composite-layer-drag-handle {
.ds-section {
animation: dsSectionIn 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
}
.ds-section[data-ds-key="identity"] { animation-delay: 0.02s; }
.ds-section[data-ds-key="connection"] { animation-delay: 0.06s; }
.ds-section[data-ds-key="hardware"] { animation-delay: 0.10s; }
.ds-section[data-ds-key="behavior"] { animation-delay: 0.14s; }
.ds-section[data-ds-key="identity"] { animation-delay: 0.02s; }
.ds-section[data-ds-key="connection"] { animation-delay: 0.06s; }
.ds-section[data-ds-key="hardware"] { animation-delay: 0.10s; }
.ds-section[data-ds-key="behavior"] { animation-delay: 0.14s; }
.ds-section[data-ds-key="targets"] { animation-delay: 0.06s; }
.ds-section[data-ds-key="triggers"] { animation-delay: 0.06s; }
.ds-section[data-ds-key="action"] { animation-delay: 0.10s; }
.ds-section[data-ds-key="deactivation"] { animation-delay: 0.14s; }
.ds-section[data-ds-key="provider"] { animation-delay: 0.06s; }
.ds-section[data-ds-key="refresh"] { animation-delay: 0.10s; }
.ds-section[data-ds-key="filters"] { animation-delay: 0.10s; }
.ds-section[data-ds-key="routing"] { animation-delay: 0.06s; }
.ds-section[data-ds-key="output"] { animation-delay: 0.10s; }
.ds-section[data-ds-key="filtering"] { animation-delay: 0.14s; }
.ds-section[data-ds-key="broker"] { animation-delay: 0.06s; }
.ds-section[data-ds-key="protocol"] { animation-delay: 0.10s; }
.ds-section[data-ds-key="adapter"] { animation-delay: 0.06s; }
.ds-section[data-ds-key="mappings"] { animation-delay: 0.10s; }
.ds-section[data-ds-key="diagnostics"] { animation-delay: 0.14s; }
.ds-section[data-ds-key="source"] { animation-delay: 0.06s; }
.ds-section[data-ds-key="engine"] { animation-delay: 0.06s; }
.ds-section[data-ds-key="timing"] { animation-delay: 0.06s; }
.ds-section[data-ds-key="gradient"] { animation-delay: 0.06s; }
.ds-section[data-ds-key="layout"] { animation-delay: 0.06s; }
.ds-section[data-ds-key="strip"] { animation-delay: 0.10s; }
.ds-section[data-ds-key="type"] { animation-delay: 0.06s; }
.ds-section[data-ds-key="notes"] { animation-delay: 0.10s; }
.ds-section[data-ds-key="file"] { animation-delay: 0.06s; }
.ds-section[data-ds-key="auth"] { animation-delay: 0.02s; }
.ds-section[data-ds-key="configure"] { animation-delay: 0.02s; }
.ds-section[data-ds-key="restart"] { animation-delay: 0.06s; }
.ds-section[data-ds-key="loopback"] { animation-delay: 0.10s; }
.ds-section[data-ds-key="test-setup"] { animation-delay: 0.02s; }
.ds-section[data-ds-key="offsets"] { animation-delay: 0.10s; }
.ds-section[data-ds-key="line-properties"] { animation-delay: 0.06s; }
.ds-section[data-ds-key="test"] { animation-delay: 0.02s; }
.ds-section[data-ds-key="preview"] { animation-delay: 0.02s; }
.ds-section[data-ds-key="controls"] { animation-delay: 0.06s; }
.ds-section[data-ds-key="history"] { animation-delay: 0.02s; }
}
@keyframes dsSectionIn {
+343
View File
@@ -195,3 +195,346 @@
}
/* target z-index for fixed overlay is set inline via JS (target is outside overlay DOM) */
/* ──────────────────────────────────────────────────────────────────────
v2 "Signal Bench" — opt-in via .tutorial-v2 on the overlay root.
Keeps the legacy ring+tooltip CSS untouched so unmigrated tours keep
working. Aligns with the lux/instrument language used elsewhere in
the UI (hairlines, JetBrains Mono labels, --ch-signal accent).
────────────────────────────────────────────────────────────────────── */
/* Backdrop: dimmer page + a hint of grain so the cutout reads as an
instrument viewfinder instead of a flat hole-punch. */
.tutorial-overlay.tutorial-v2 .tutorial-backdrop {
background:
repeating-linear-gradient(
0deg,
rgba(255, 255, 255, 0.018) 0px,
rgba(255, 255, 255, 0.018) 1px,
transparent 1px,
transparent 3px
),
radial-gradient(
1400px 900px at 50% 35%,
rgba(0, 0, 0, 0.78) 0%,
rgba(0, 0, 0, 0.92) 100%
);
transition: clip-path 0.32s cubic-bezier(0.22, 1, 0.36, 1);
}
[data-theme="light"] .tutorial-overlay.tutorial-v2 .tutorial-backdrop {
background:
repeating-linear-gradient(
0deg,
rgba(0, 0, 0, 0.02) 0px,
rgba(0, 0, 0, 0.02) 1px,
transparent 1px,
transparent 3px
),
radial-gradient(
1400px 900px at 50% 35%,
rgba(20, 24, 30, 0.55) 0%,
rgba(20, 24, 30, 0.72) 100%
);
}
/* Ring becomes a hairline + signal-glow frame; the prominent visual is
the 4 corner brackets layered on top. */
.tutorial-overlay.tutorial-v2 .tutorial-ring {
border: 1px dashed color-mix(in srgb, var(--ch-signal) 38%, transparent);
border-radius: 2px;
animation: none;
box-shadow:
0 0 0 1px color-mix(in srgb, var(--ch-signal) 14%, transparent),
0 0 22px color-mix(in srgb, var(--ch-signal) 28%, transparent),
inset 0 0 0 1px color-mix(in srgb, var(--ch-signal) 6%, transparent);
transition:
left 0.28s cubic-bezier(0.22, 1, 0.36, 1),
top 0.28s cubic-bezier(0.22, 1, 0.36, 1),
width 0.28s cubic-bezier(0.22, 1, 0.36, 1),
height 0.28s cubic-bezier(0.22, 1, 0.36, 1);
}
.tutorial-overlay.tutorial-v2 .tutorial-reticle-corner {
position: absolute;
width: 16px;
height: 16px;
border: 2px solid var(--ch-signal);
pointer-events: none;
filter: drop-shadow(0 0 6px color-mix(in srgb, var(--ch-signal) 55%, transparent));
}
.tutorial-overlay.tutorial-v2 .tutorial-reticle-corner.tl {
top: -2px; left: -2px;
border-right: none; border-bottom: none;
}
.tutorial-overlay.tutorial-v2 .tutorial-reticle-corner.tr {
top: -2px; right: -2px;
border-left: none; border-bottom: none;
}
.tutorial-overlay.tutorial-v2 .tutorial-reticle-corner.bl {
bottom: -2px; left: -2px;
border-right: none; border-top: none;
}
.tutorial-overlay.tutorial-v2 .tutorial-reticle-corner.br {
bottom: -2px; right: -2px;
border-left: none; border-top: none;
}
/* Animate corner draw-in on each step change (ring already eases its
bounds; this adds a snappy "lock" on top). */
.tutorial-overlay.tutorial-v2.step-changed .tutorial-reticle-corner {
animation: tutorial-corner-lock 0.26s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes tutorial-corner-lock {
0% { opacity: 0; transform: scale(2.4); }
60% { opacity: 1; transform: scale(0.85); }
100% { opacity: 1; transform: scale(1); }
}
/* Patch cable — connects reticle to tooltip. Animated dash-offset reads
as transmission flowing toward the tooltip. Absolute by default for
modal-mode overlays; viewport-fixed for tour overlays via the
.tutorial-overlay-fixed class. */
.tutorial-overlay.tutorial-v2 .tutorial-cable {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 101;
overflow: visible;
}
.tutorial-overlay-fixed.tutorial-v2 .tutorial-cable {
position: fixed;
}
.tutorial-overlay.tutorial-v2 .tutorial-cable line {
stroke: var(--ch-signal);
stroke-width: 1.25;
stroke-dasharray: 4 5;
stroke-linecap: round;
fill: none;
opacity: 0.7;
filter: drop-shadow(0 0 4px color-mix(in srgb, var(--ch-signal) 50%, transparent));
animation: tutorial-cable-flow 1.4s linear infinite;
transition: all 0.28s cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes tutorial-cable-flow {
from { stroke-dashoffset: 0; }
to { stroke-dashoffset: -18; }
}
/* Tooltip — instrument readout: square corners, hairline border,
inner ring, lux shadow. */
.tutorial-overlay.tutorial-v2 .tutorial-tooltip {
width: 296px;
background: var(--card-bg);
border: 1px solid var(--lux-line-bold);
border-radius: 2px;
box-shadow:
0 0 0 1px var(--lux-bg-2),
var(--lux-shadow-rack, 0 8px 24px rgba(0, 0, 0, 0.5)),
0 0 32px color-mix(in srgb, var(--ch-signal) 12%, transparent);
animation: tutorial-tooltip-v2-in 0.28s cubic-bezier(0.22, 1, 0.36, 1);
overflow: hidden;
position: relative;
}
.tutorial-overlay.tutorial-v2 .tutorial-tooltip::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: linear-gradient(
90deg,
transparent 0%,
var(--ch-signal) 20%,
var(--ch-signal) 80%,
transparent 100%
);
opacity: 0.85;
}
@keyframes tutorial-tooltip-v2-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.tutorial-overlay.tutorial-v2 .tutorial-tooltip-header {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px 8px;
border-bottom: 1px solid var(--lux-line);
}
.tutorial-overlay.tutorial-v2 .tutorial-tooltip-eyebrow {
flex: 1;
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.14em;
color: var(--ch-signal);
text-transform: uppercase;
}
.tutorial-overlay.tutorial-v2 .tutorial-step-counter {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.06em;
color: var(--lux-ink-dim);
font-variant-numeric: tabular-nums;
}
.tutorial-tooltip-breadcrumb {
display: none;
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--lux-ink-mute, var(--text-muted));
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tutorial-tooltip-breadcrumb.is-visible {
display: inline-block;
}
.tutorial-tooltip-breadcrumb.is-visible + .tutorial-step-counter::before {
content: '· ';
color: var(--lux-ink-mute, var(--text-muted));
margin-right: 4px;
}
.tutorial-overlay.tutorial-v2 .tutorial-close-btn {
color: var(--lux-ink-mute);
border-radius: 2px;
}
.tutorial-overlay.tutorial-v2 .tutorial-close-btn:hover {
color: var(--lux-ink);
background: var(--lux-bg-2);
}
/* Segmented progress pips — one slot per step. */
.tutorial-overlay.tutorial-v2 .tutorial-pips {
display: flex;
gap: 3px;
padding: 10px 12px 4px;
}
.tutorial-overlay.tutorial-v2 .tutorial-pip {
flex: 1;
height: 3px;
background: var(--lux-line);
border-radius: 1px;
transition: background 0.2s ease, box-shadow 0.2s ease;
}
.tutorial-overlay.tutorial-v2 .tutorial-pip.done {
background: color-mix(in srgb, var(--ch-signal) 60%, var(--lux-line));
}
.tutorial-overlay.tutorial-v2 .tutorial-pip.active {
background: var(--ch-signal);
box-shadow: 0 0 8px color-mix(in srgb, var(--ch-signal) 60%, transparent);
}
.tutorial-overlay.tutorial-v2 .tutorial-tooltip-text {
margin: 0;
padding: 8px 14px 14px;
line-height: 1.55;
color: var(--lux-ink);
font-size: 13.5px;
font-weight: 400;
letter-spacing: 0.005em;
}
.tutorial-overlay.tutorial-v2 .tutorial-tooltip-nav {
display: flex;
gap: 8px;
padding: 10px 12px;
border-top: 1px solid var(--lux-line);
}
.tutorial-overlay.tutorial-v2 .tutorial-prev-btn,
.tutorial-overlay.tutorial-v2 .tutorial-next-btn {
flex: 1;
padding: 7px 10px;
border: 1px solid var(--lux-line-bold);
border-radius: 2px;
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
.tutorial-overlay.tutorial-v2 .tutorial-prev-btn {
background: transparent;
color: var(--lux-ink-dim);
}
.tutorial-overlay.tutorial-v2 .tutorial-prev-btn:hover:not(:disabled) {
color: var(--lux-ink);
border-color: var(--lux-ink-mute);
background: var(--lux-bg-2);
}
.tutorial-overlay.tutorial-v2 .tutorial-next-btn {
background: var(--ch-signal);
color: var(--primary-contrast, #000);
border-color: var(--ch-signal);
}
.tutorial-overlay.tutorial-v2 .tutorial-next-btn:hover:not(:disabled) {
box-shadow: 0 0 16px color-mix(in srgb, var(--ch-signal) 50%, transparent);
filter: brightness(1.06);
}
.tutorial-overlay.tutorial-v2 .tutorial-prev-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
/* Keyboard hint chip — surfaces existing arrow/Esc bindings. */
.tutorial-overlay.tutorial-v2 .tutorial-keyhint {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px 10px;
border-top: 1px solid var(--lux-line);
background: var(--lux-bg-1);
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 9.5px;
font-weight: 500;
letter-spacing: 0.1em;
color: var(--lux-ink-mute);
text-transform: uppercase;
}
.tutorial-overlay.tutorial-v2 .tutorial-keyhint kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 4px;
background: var(--lux-bg-3);
border: 1px solid var(--lux-line-bold);
border-radius: 2px;
font: inherit;
font-size: 10px;
color: var(--lux-ink-dim);
}
.tutorial-overlay.tutorial-v2 .tutorial-keyhint span {
margin-right: 6px;
}
.tutorial-overlay.tutorial-v2 .tutorial-keyhint span:last-child {
margin-right: 0;
}
/* Mobile — collapse cable, dock tooltip to bottom of viewport. */
@media (max-width: 640px) {
.tutorial-overlay.tutorial-v2 .tutorial-cable { display: none; }
.tutorial-overlay.tutorial-v2 .tutorial-tooltip {
width: calc(100vw - 24px);
max-width: 360px;
}
.tutorial-overlay.tutorial-v2 .tutorial-keyhint { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.tutorial-overlay.tutorial-v2 .tutorial-cable line { animation: none; }
.tutorial-overlay.tutorial-v2 .tutorial-tooltip { animation: none; }
.tutorial-overlay.tutorial-v2.step-changed .tutorial-reticle-corner { animation: none; }
.tutorial-overlay.tutorial-v2 .tutorial-ring { transition: none; }
}
+2
View File
@@ -34,6 +34,7 @@ import {
import {
startCalibrationTutorial, startDeviceTutorial, startGettingStartedTutorial,
startDashboardTutorial, startTargetsTutorial, startSourcesTutorial, startAutomationsTutorial,
startIntegrationsTutorial,
closeTutorial, tutorialNext, tutorialPrev,
} from './features/tutorials.ts';
@@ -289,6 +290,7 @@ Object.assign(window, {
startTargetsTutorial,
startSourcesTutorial,
startAutomationsTutorial,
startIntegrationsTutorial,
closeTutorial,
tutorialNext,
tutorialPrev,
+23 -2
View File
@@ -142,14 +142,35 @@ export function set_targetEditorDevices(v: Device[]) { _targetEditorDevices = v;
export const ledPreviewWebSockets: Record<string, WebSocket> = {};
// Tutorial state
export interface TutorialStepShape {
selector: string;
textKey: string;
position: string;
global?: boolean;
/** Optional sub-tab to switch to before highlighting. Lets the
* framework reveal panels that live behind tab switches. */
subTab?: string;
}
export interface TutorialState {
steps: { selector: string; textKey: string; position: string; global?: boolean }[];
steps: TutorialStepShape[];
overlay: HTMLElement;
mode: string;
step: number;
resolveTarget: (step: { selector: string; textKey: string; position: string; global?: boolean }) => Element | null;
resolveTarget: (step: TutorialStepShape) => Element | null;
container: Element | null;
onClose: (() => void) | null;
/** Called with `step.subTab` before each step. Lets the tour
* reveal panels by switching the relevant sub-tab. */
switchSubTab: ((key: string) => void) | null;
/** CSS selector pointing at an element whose `textContent` is
* the current sub-tab label (e.g. `.tree-dd-trigger-title`).
* Rendered into the tooltip header as a breadcrumb so the user
* knows which panel is being highlighted. */
breadcrumbSelector: string | null;
/** Called with the current step before resolving the target.
* Lets a tour open/close auxiliary UI (e.g. side panels, popups)
* so steps inside that UI can be highlighted. */
prepare: ((step: TutorialStepShape) => void) | null;
}
export let activeTutorial: TutorialState | null = null;
export function setActiveTutorial(v: TutorialState | null) { activeTutorial = v; }
@@ -475,12 +475,15 @@ function _renderLineList(): void {
function _showLineProps(): void {
const propsEl = document.getElementById('advcal-line-props')!;
const sectionEl = document.getElementById('advcal-line-props-section');
const idx = _state.selectedLine;
if (idx < 0 || idx >= _state.lines.length) {
propsEl.style.display = 'none';
if (sectionEl) sectionEl.style.display = 'none';
return;
}
propsEl.style.display = '';
if (sectionEl) sectionEl.style.display = '';
const line = _state.lines[idx];
(document.getElementById('advcal-line-source') as HTMLSelectElement).value = line.picture_source_id;
if (_lineSourceEntitySelect) _lineSourceEntitySelect.refresh();
@@ -55,6 +55,8 @@ class CalibrationModal extends Modal {
(document.getElementById('calibration-css-id') as HTMLInputElement).value = '';
const testGroup = document.getElementById('calibration-css-test-group');
if (testGroup) testGroup.style.display = 'none';
const testSection = document.getElementById('calibration-test-setup-section');
if (testSection) testSection.style.display = 'none';
} else {
const deviceId = (this.$('calibration-device-id') as HTMLInputElement).value;
if (deviceId) clearTestMode(deviceId);
@@ -162,6 +164,8 @@ export async function showCalibration(deviceId: any) {
(document.getElementById('calibration-device-id') as HTMLInputElement).value = device.id;
(document.getElementById('cal-device-led-count-inline') as HTMLElement).textContent = device.led_count;
(document.getElementById('cal-css-led-count-group') as HTMLElement).style.display = 'none';
const testSectionDevMode = document.getElementById('calibration-test-setup-section');
if (testSectionDevMode) testSectionDevMode.style.display = 'none';
(document.getElementById('calibration-overlay-btn') as HTMLElement).style.display = 'none';
(document.getElementById('cal-start-position') as HTMLSelectElement).value = calibration.start_position;
@@ -262,6 +266,8 @@ export async function showCSSCalibration(cssId: any) {
_calTestDeviceList = devices;
const testGroup = document.getElementById('calibration-css-test-group') as HTMLElement;
testGroup.style.display = devices.length ? '' : 'none';
const testSection = document.getElementById('calibration-test-setup-section') as HTMLElement | null;
if (testSection) testSection.style.display = '';
// Pre-select device: 1) LED count match, 2) last remembered, 3) first
if (devices.length) {
@@ -229,6 +229,114 @@ function _clone(layout: DashboardLayoutV1, presetActive?: string): DashboardLayo
};
}
/** Structural deep-equal that ignores `undefined` properties and key order.
* Needed because a saved layout coming back from JSON.parse may omit
* optional fields (e.g. `colorOverride`) that the factory output also
* omits — but a naive `JSON.stringify` comparison can still differ if
* insertion order ever drifts. */
function _deepEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') {
return false;
}
if (Array.isArray(a) || Array.isArray(b)) {
if (!Array.isArray(a) || !Array.isArray(b)) return false;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (!_deepEqual(a[i], b[i])) return false;
}
return true;
}
const ao = a as Record<string, unknown>;
const bo = b as Record<string, unknown>;
const aKeys = Object.keys(ao).filter(k => ao[k] !== undefined);
const bKeys = Object.keys(bo).filter(k => bo[k] !== undefined);
if (aKeys.length !== bKeys.length) return false;
for (const k of aKeys) {
if (!_deepEqual(ao[k], bo[k])) return false;
}
return true;
}
/** Detect which preset (if any) the layout currently matches. Recomputed
* on every save/load so the "modified" hint is the truth, not a flag that
* drifts when a user edits then reverts a setting.
*
* Compares only the data-bearing fields — `presetActive` itself is ignored,
* since it's the value we're computing. */
function _computeActivePreset(layout: DashboardLayoutV1): string | undefined {
for (const [name, factory] of Object.entries(PRESETS)) {
const p = factory();
if (
layout.version === p.version &&
_deepEqual(layout.global, p.global) &&
_deepEqual(layout.sections, p.sections) &&
_deepEqual(layout.perfCells, p.perfCells)
) {
return name;
}
}
return undefined;
}
/** Diagnostic: list per-preset mismatches for the current layout. Exposed
* on `window.__dashboardLayoutDiff` so a user reporting "MODIFIED with no
* changes" can run it from DevTools and see exactly which field drifted. */
function _diffAgainstPresets(layout: DashboardLayoutV1): Record<string, string[]> {
const out: Record<string, string[]> = {};
for (const [name, factory] of Object.entries(PRESETS)) {
const p = factory();
const diffs: string[] = [];
if (layout.version !== p.version) diffs.push(`version: ${layout.version} vs ${p.version}`);
const asRec = (o: object): Record<string, unknown> => o as unknown as Record<string, unknown>;
if (!_deepEqual(layout.global, p.global)) {
for (const k of Object.keys({ ...layout.global, ...p.global })) {
const lv = asRec(layout.global)[k];
const pv = asRec(p.global)[k];
if (!_deepEqual(lv, pv)) diffs.push(`global.${k}: ${JSON.stringify(lv)} vs ${JSON.stringify(pv)}`);
}
}
if (!_deepEqual(layout.sections, p.sections)) {
const lKeys = layout.sections.map(s => s.key).join(',');
const pKeys = p.sections.map(s => s.key).join(',');
if (lKeys !== pKeys) diffs.push(`sections.order: [${lKeys}] vs [${pKeys}]`);
for (const ls of layout.sections) {
const ps = p.sections.find(x => x.key === ls.key);
if (!ps) { diffs.push(`sections.${ls.key}: extra`); continue; }
if (!_deepEqual(ls, ps)) {
for (const k of Object.keys({ ...ls, ...ps })) {
const lv = asRec(ls)[k];
const pv = asRec(ps)[k];
if (!_deepEqual(lv, pv)) diffs.push(`sections.${ls.key}.${k}: ${JSON.stringify(lv)} vs ${JSON.stringify(pv)}`);
}
}
}
}
if (!_deepEqual(layout.perfCells, p.perfCells)) {
const lKeys = layout.perfCells.map(c => c.key).join(',');
const pKeys = p.perfCells.map(c => c.key).join(',');
if (lKeys !== pKeys) diffs.push(`perfCells.order: [${lKeys}] vs [${pKeys}]`);
for (const lc of layout.perfCells) {
const pc = p.perfCells.find(x => x.key === lc.key);
if (!pc) { diffs.push(`perfCells.${lc.key}: extra`); continue; }
if (!_deepEqual(lc, pc)) {
for (const k of Object.keys({ ...lc, ...pc })) {
const lv = asRec(lc)[k];
const pv = asRec(pc)[k];
if (!_deepEqual(lv, pv)) diffs.push(`perfCells.${lc.key}.${k}: ${JSON.stringify(lv)} vs ${JSON.stringify(pv)}`);
}
}
}
}
out[name] = diffs;
}
return out;
}
if (typeof window !== 'undefined') {
(window as unknown as Record<string, unknown>).__dashboardLayoutDiff = () => _diffAgainstPresets(_current);
}
let _current: DashboardLayoutV1 = _clone(DEFAULT_LAYOUT, 'studio');
let _serverSyncedOnce = false;
const _listeners = new Set<() => void>();
@@ -300,7 +408,7 @@ export async function syncDashboardLayoutFromServer(): Promise<void> {
/** Persist a layout. Updates in-memory state immediately, debounces
* the network write, and notifies listeners synchronously. */
export function saveDashboardLayout(next: DashboardLayoutV1): void {
_current = _clone(next, next.presetActive);
_current = _clone(next, _computeActivePreset(next));
try { localStorage.setItem(LS_KEY, JSON.stringify(_current)); } catch { /* quota */ }
_notify();
if (_saveTimer) clearTimeout(_saveTimer);
@@ -551,7 +659,7 @@ function _mergeWithDefaults(input: unknown): DashboardLayoutV1 {
base.global = { ...base.global, ...obj.global };
}
if (typeof obj.presetActive === 'string') base.presetActive = obj.presetActive;
base.presetActive = _computeActivePreset(base);
return base;
}
@@ -586,5 +694,6 @@ function _migrateFromLegacyKeys(): DashboardLayoutV1 {
} catch { /* ignore */ }
}
layout.presetActive = _computeActivePreset(layout);
return layout;
}
@@ -175,7 +175,8 @@ function renderIntegrationsList() {
initHASourceDelegation(container);
initMQTTSourceDelegation(container);
// Render tree sidebar
// Render tree sidebar with tutorial trigger button
_integrationsTree.setExtraHtml(`<button class="tutorial-trigger-btn" onclick="startIntegrationsTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
_integrationsTree.update(treeGroups, activeTab);
_integrationsTree.observeSections('integrations-list', {
'weather-sources': 'weather',
@@ -10,6 +10,8 @@ interface TutorialStep {
textKey: string;
position: string;
global?: boolean;
/** Optional sub-tab to switch to before highlighting this step. */
subTab?: string;
}
interface TutorialConfig {
@@ -19,6 +21,13 @@ interface TutorialConfig {
container: Element | null;
resolveTarget: (step: TutorialStep) => Element | null;
onClose?: (() => void) | null;
/** Called with `step.subTab` before each step. */
switchSubTab?: (key: string) => void;
/** CSS selector for the element whose textContent is the current
* sub-tab label — shown as a breadcrumb in the tooltip header. */
breadcrumbSelector?: string;
/** Called with the current step before resolving the target. */
prepare?: (step: TutorialStep) => void;
}
const calibrationTutorialSteps: TutorialStep[] = [
@@ -43,6 +52,7 @@ const gettingStartedSteps: TutorialStep[] = [
{ selector: '#tab-btn-automations', textKey: 'tour.automations', position: 'bottom' },
{ selector: '#tab-btn-targets', textKey: 'tour.targets', position: 'bottom' },
{ selector: '#tab-btn-streams', textKey: 'tour.sources', position: 'bottom' },
{ selector: '#tab-btn-integrations', textKey: 'tour.integrations', position: 'bottom' },
{ selector: '#tab-btn-graph', textKey: 'tour.graph', position: 'bottom' },
{ selector: 'a.header-link[href="/docs"]', textKey: 'tour.api', position: 'bottom' },
{ selector: '[onclick*="openCommandPalette"]', textKey: 'tour.search', position: 'bottom' },
@@ -56,36 +66,57 @@ const gettingStartedSteps: TutorialStep[] = [
const dashboardTutorialSteps: TutorialStep[] = [
{ selector: '[data-dashboard-section="perf"]', textKey: 'tour.dash.perf', position: 'bottom' },
{ selector: '[data-dashboard-section="targets"]', textKey: 'tour.dash.targets', position: 'bottom' },
{ selector: '[data-dashboard-section="running"]', textKey: 'tour.dash.running', position: 'bottom' },
{ selector: '[data-dashboard-section="stopped"]', textKey: 'tour.dash.stopped', position: 'bottom' },
{ selector: '[data-dashboard-section="automations"]', textKey: 'tour.dash.automations', position: 'bottom' }
{ selector: '[data-dashboard-section="automations"]', textKey: 'tour.dash.automations', position: 'bottom' },
{ selector: '[data-dashboard-section="scenes"]', textKey: 'tour.dash.scenes', position: 'bottom' },
{ selector: '[data-dashboard-section="sync-clocks"]', textKey: 'tour.dash.sync_clocks', position: 'bottom' },
{ selector: '[data-dashboard-section="integrations"]', textKey: 'tour.dash.integrations', position: 'bottom' },
// Customize panel walkthrough — panel is opened/closed via the
// tour's `prepare` hook based on whether the step targets it.
{ selector: '[onclick*="openDashboardCustomize"]', textKey: 'tour.dash.customize_btn', position: 'bottom' },
{ selector: '#dashboard-customize-panel .dash-cust-header', textKey: 'tour.dash.customize_panel', position: 'left' },
{ selector: '#dashboard-customize-panel .dash-cust-section:nth-of-type(1)', textKey: 'tour.dash.customize_presets', position: 'left' },
{ selector: '#dashboard-customize-panel .dash-cust-section:nth-of-type(2)', textKey: 'tour.dash.customize_global', position: 'left' },
{ selector: '#dashboard-customize-panel .dash-cust-section:nth-of-type(3)', textKey: 'tour.dash.customize_sections', position: 'left' },
{ selector: '#dashboard-customize-panel .dash-cust-section:nth-of-type(4)', textKey: 'tour.dash.customize_perf_cells', position: 'left' }
];
const targetsTutorialSteps: TutorialStep[] = [
{ selector: '[data-tree-group="led_group"]', textKey: 'tour.tgt.led_tab', position: 'right' },
{ selector: '[data-card-section="led-devices"]', textKey: 'tour.tgt.devices', position: 'bottom' },
{ selector: '[data-card-section="led-targets"]', textKey: 'tour.tgt.targets', position: 'bottom' },
{ selector: '[data-tree-group="kc_group"]', textKey: 'tour.tgt.kc_tab', position: 'right' }
{ selector: '#targets-tree-nav .tree-dd-trigger', textKey: 'tour.tgt.nav', position: 'right' },
{ selector: '[data-card-section="led-devices"]', textKey: 'tour.tgt.devices', position: 'bottom', subTab: 'led-devices' },
{ selector: '[data-card-section="led-targets"]', textKey: 'tour.tgt.targets', position: 'bottom', subTab: 'led-targets' },
{ selector: '[data-card-section="ha-light-targets"]', textKey: 'tour.tgt.ha_light_targets', position: 'bottom', subTab: 'ha-light-targets' }
];
const sourcesTourSteps: TutorialStep[] = [
{ selector: '#streams-tree-nav [data-tree-leaf="raw"]', textKey: 'tour.src.raw', position: 'right' },
{ selector: '[data-card-section="raw-templates"]', textKey: 'tour.src.templates', position: 'bottom' },
{ selector: '#streams-tree-nav [data-tree-leaf="static_image"]', textKey: 'tour.src.static', position: 'right' },
{ selector: '#streams-tree-nav [data-tree-leaf="processed"]', textKey: 'tour.src.processed', position: 'right' },
{ selector: '#streams-tree-nav [data-tree-leaf="color_strip"]', textKey: 'tour.src.color_strip', position: 'right' },
{ selector: '#streams-tree-nav [data-tree-leaf="audio"]', textKey: 'tour.src.audio', position: 'right' },
{ selector: '#streams-tree-nav [data-tree-leaf="value"]', textKey: 'tour.src.value', position: 'right' },
{ selector: '#streams-tree-nav [data-tree-leaf="sync"]', textKey: 'tour.src.sync', position: 'right' }
{ selector: '#streams-tree-nav .tree-dd-trigger', textKey: 'tour.src.nav', position: 'right' },
{ selector: '[data-card-section="raw-streams"]', textKey: 'tour.src.raw', position: 'bottom', subTab: 'raw' },
{ selector: '[data-card-section="raw-templates"]', textKey: 'tour.src.templates', position: 'bottom', subTab: 'raw_templates' },
{ selector: '[data-card-section="static-streams"]', textKey: 'tour.src.static', position: 'bottom', subTab: 'static_image' },
{ selector: '[data-card-section="proc-streams"]', textKey: 'tour.src.processed', position: 'bottom', subTab: 'processed' },
{ selector: '[data-card-section="color-strips"]', textKey: 'tour.src.color_strip', position: 'bottom', subTab: 'color_strip' },
{ selector: '[data-card-section="audio-capture"]', textKey: 'tour.src.audio', position: 'bottom', subTab: 'audio_capture' },
{ selector: '[data-card-section="value-sources"]', textKey: 'tour.src.value', position: 'bottom', subTab: 'value' },
{ selector: '[data-card-section="sync-clocks"]', textKey: 'tour.src.sync', position: 'bottom', subTab: 'sync' }
];
const automationsTutorialSteps: TutorialStep[] = [
{ selector: '[data-card-section="automations"]', textKey: 'tour.auto.list', position: 'bottom' },
{ selector: '[data-cs-add="automations"]', textKey: 'tour.auto.add', position: 'bottom' },
{ selector: '.card[data-automation-id]', textKey: 'tour.auto.card', position: 'bottom' },
{ selector: '[data-card-section="scenes"]', textKey: 'tour.auto.scenes_list', position: 'bottom' },
{ selector: '[data-cs-add="scenes"]', textKey: 'tour.auto.scenes_add', position: 'bottom' },
{ selector: '.card[data-scene-id]', textKey: 'tour.auto.scenes_card', position: 'bottom' },
{ selector: '[data-card-section="automations"]', textKey: 'tour.auto.list', position: 'bottom', subTab: 'automations' },
{ selector: '[data-cs-add="automations"]', textKey: 'tour.auto.add', position: 'bottom', subTab: 'automations' },
{ selector: '.card[data-automation-id]', textKey: 'tour.auto.card', position: 'bottom', subTab: 'automations' },
{ selector: '[data-card-section="scenes"]', textKey: 'tour.auto.scenes_list', position: 'bottom', subTab: 'scenes' },
{ selector: '[data-cs-add="scenes"]', textKey: 'tour.auto.scenes_add', position: 'bottom', subTab: 'scenes' },
{ selector: '.card[data-scene-id]', textKey: 'tour.auto.scenes_card', position: 'bottom', subTab: 'scenes' },
];
const integrationsTutorialSteps: TutorialStep[] = [
{ selector: '#integrations-tree-nav .tree-dd-trigger', textKey: 'tour.int.nav', position: 'right' },
{ selector: '[data-card-section="weather-sources"]', textKey: 'tour.int.weather', position: 'bottom', subTab: 'weather' },
{ selector: '[data-card-section="ha-sources"]', textKey: 'tour.int.home_assistant', position: 'bottom', subTab: 'home_assistant' },
{ selector: '[data-card-section="mqtt-sources"]', textKey: 'tour.int.mqtt', position: 'bottom', subTab: 'mqtt' },
{ selector: '[data-card-section="game-integrations"]', textKey: 'tour.int.game', position: 'bottom', subTab: 'game' }
];
const _fixedResolve = (step: TutorialStep): Element | null => {
@@ -97,13 +128,12 @@ const _fixedResolve = (step: TutorialStep): Element | null => {
};
const deviceTutorialSteps: TutorialStep[] = [
{ selector: '.card-subtitle', textKey: 'device.tip.metadata', position: 'bottom' },
{ selector: '.brightness-control', textKey: 'device.tip.brightness', position: 'bottom' },
{ selector: '.card-actions .btn:nth-child(1)', textKey: 'device.tip.start', position: 'top' },
{ selector: '.card-actions .btn:nth-child(2)', textKey: 'device.tip.settings', position: 'top' },
{ selector: '.card-actions .btn:nth-child(3)', textKey: 'device.tip.capture_settings', position: 'top' },
{ selector: '.card-actions .btn:nth-child(4)', textKey: 'device.tip.calibrate', position: 'top' },
{ selector: '.card-actions .btn:nth-child(5)', textKey: 'device.tip.webui', position: 'top' }
{ selector: '.mod-head', textKey: 'device.tip.identity', position: 'bottom' },
{ selector: '.mod-meta', textKey: 'device.tip.metadata', position: 'bottom' },
{ selector: '.mod-fader', textKey: 'device.tip.brightness', position: 'bottom' },
{ selector: '.mod-foot .mod-btn[onclick*="pingDevice"]', textKey: 'device.tip.ping', position: 'top' },
{ selector: '.mod-foot .mod-btn[onclick*="showSettings"]', textKey: 'device.tip.settings', position: 'top' },
{ selector: '.mod-menu-wrap', textKey: 'device.tip.menu', position: 'left' }
];
export function startTutorial(config: TutorialConfig): void {
@@ -120,7 +150,10 @@ export function startTutorial(config: TutorialConfig): void {
step: 0,
resolveTarget: config.resolveTarget,
container: config.container,
onClose: config.onClose || null
onClose: config.onClose || null,
switchSubTab: config.switchSubTab || null,
breadcrumbSelector: config.breadcrumbSelector || null,
prepare: config.prepare || null
});
// Hide tooltip and ring until first step positions them (prevents flash at 0,0)
@@ -186,22 +219,47 @@ export function startGettingStartedTutorial(): void {
}
export function startDashboardTutorial(): void {
const isCustomizeStep = (step: TutorialStep): boolean =>
step.selector.includes('#dashboard-customize-panel');
const callWindow = (fnName: string): void => {
const fn = (window as any)[fnName];
if (typeof fn === 'function') fn();
};
startTutorial({
steps: dashboardTutorialSteps,
overlayId: 'getting-started-overlay',
mode: 'fixed',
container: null,
resolveTarget: _fixedResolve
resolveTarget: _fixedResolve,
prepare: (step: TutorialStep): void => {
// Open the customize panel for steps inside it; close it
// when the tour moves back out so dashboard sections aren't
// covered.
if (isCustomizeStep(step)) callWindow('openDashboardCustomize');
else callWindow('closeDashboardCustomize');
},
onClose: (): void => callWindow('closeDashboardCustomize')
});
}
function _windowSwitch(fnName: string): (key: string) => void {
return (key: string): void => {
const fn = (window as any)[fnName];
if (typeof fn === 'function') fn(key);
};
}
export function startTargetsTutorial(): void {
startTutorial({
steps: targetsTutorialSteps,
overlayId: 'getting-started-overlay',
mode: 'fixed',
container: null,
resolveTarget: _fixedResolve
resolveTarget: _fixedResolve,
switchSubTab: _windowSwitch('switchTargetSubTab'),
breadcrumbSelector: '#targets-tree-nav .tree-dd-trigger-title'
});
}
@@ -211,7 +269,9 @@ export function startSourcesTutorial(): void {
overlayId: 'getting-started-overlay',
mode: 'fixed',
container: null,
resolveTarget: _fixedResolve
resolveTarget: _fixedResolve,
switchSubTab: _windowSwitch('switchStreamTab'),
breadcrumbSelector: '#streams-tree-nav .tree-dd-trigger-title'
});
}
@@ -221,7 +281,21 @@ export function startAutomationsTutorial(): void {
overlayId: 'getting-started-overlay',
mode: 'fixed',
container: null,
resolveTarget: _fixedResolve
resolveTarget: _fixedResolve,
switchSubTab: _windowSwitch('switchAutomationTab'),
breadcrumbSelector: '#automations-tree-nav .tree-dd-trigger-title'
});
}
export function startIntegrationsTutorial(): void {
startTutorial({
steps: integrationsTutorialSteps,
overlayId: 'getting-started-overlay',
mode: 'fixed',
container: null,
resolveTarget: _fixedResolve,
switchSubTab: _windowSwitch('switchIntegrationTab'),
breadcrumbSelector: '#integrations-tree-nav .tree-dd-trigger-title'
});
}
@@ -254,6 +328,84 @@ export function tutorialPrev(): void {
}
}
function _renderPips(overlay: HTMLElement, total: number, current: number): void {
const container = overlay.querySelector('.tutorial-pips');
if (!container) return;
if (container.children.length !== total) {
container.innerHTML = '';
for (let i = 0; i < total; i++) {
const pip = document.createElement('span');
pip.className = 'tutorial-pip';
container.appendChild(pip);
}
}
Array.from(container.children).forEach((pip, i) => {
pip.classList.toggle('done', i < current);
pip.classList.toggle('active', i === current);
});
}
function _drawCable(
overlay: HTMLElement,
rx: number, ry: number, rw: number, rh: number,
tooltip: HTMLElement,
actualPosition: string
): void {
const cable = overlay.querySelector('.tutorial-cable');
if (!cable) return;
const line = cable.querySelector('line') as SVGLineElement | null;
if (!line) return;
// Tooltip rect is viewport-relative. Ring rect (rx, ry, rw, rh) is in
// the same coord space as the SVG: viewport for fixed overlays,
// container-relative for absolute (modal) overlays. Translate the
// tooltip rect so both endpoints share that space.
const tt = tooltip.getBoundingClientRect();
const isFixed = activeTutorial?.mode === 'fixed';
let ttLeft = tt.left;
let ttTop = tt.top;
if (!isFixed && activeTutorial?.container) {
const cRect = activeTutorial.container.getBoundingClientRect();
ttLeft -= cRect.left;
ttTop -= cRect.top;
}
const ttRight = ttLeft + tt.width;
const ttBottom = ttTop + tt.height;
let x1: number, y1: number, x2: number, y2: number;
switch (actualPosition) {
case 'top':
x1 = rx + rw / 2; y1 = ry;
x2 = ttLeft + tt.width / 2; y2 = ttBottom;
break;
case 'left':
x1 = rx; y1 = ry + rh / 2;
x2 = ttRight; y2 = ttTop + tt.height / 2;
break;
case 'right':
x1 = rx + rw; y1 = ry + rh / 2;
x2 = ttLeft; y2 = ttTop + tt.height / 2;
break;
case 'bottom':
default:
x1 = rx + rw / 2; y1 = ry + rh;
x2 = ttLeft + tt.width / 2; y2 = ttTop;
break;
}
line.setAttribute('x1', String(x1));
line.setAttribute('y1', String(y1));
line.setAttribute('x2', String(x2));
line.setAttribute('y2', String(y2));
}
function _flashCornerLock(overlay: HTMLElement): void {
overlay.classList.remove('step-changed');
// Force reflow so removing+re-adding the class re-triggers the animation.
void (overlay as HTMLElement).offsetWidth;
overlay.classList.add('step-changed');
}
function _positionSpotlight(target: Element, overlay: HTMLElement, step: TutorialStep, index: number, isFixed: boolean): void {
const targetRect = target.getBoundingClientRect();
const pad = 6;
@@ -272,6 +424,8 @@ function _positionSpotlight(target: Element, overlay: HTMLElement, step: Tutoria
h = targetRect.height + pad * 2;
}
const isV2 = overlay.classList.contains('tutorial-v2');
const backdrop = overlay.querySelector('.tutorial-backdrop') as HTMLElement;
if (backdrop) {
backdrop.style.clipPath = `polygon(
@@ -294,18 +448,47 @@ function _positionSpotlight(target: Element, overlay: HTMLElement, step: Tutoria
const textEl = overlay.querySelector('.tutorial-tooltip-text');
const counterEl = overlay.querySelector('.tutorial-step-counter');
if (textEl) textEl.textContent = t(step.textKey);
if (counterEl) counterEl.textContent = `${index + 1} / ${activeTutorial!.steps.length}`;
const total = activeTutorial!.steps.length;
if (counterEl) {
const pad2 = (n: number) => String(n).padStart(2, '0');
counterEl.textContent = isV2
? `${pad2(index + 1)} / ${pad2(total)}`
: `${index + 1} / ${total}`;
}
const breadcrumbEl = overlay.querySelector('.tutorial-tooltip-breadcrumb') as HTMLElement | null;
if (breadcrumbEl) {
const sel = activeTutorial!.breadcrumbSelector;
const labelSrc = sel ? document.querySelector(sel) : null;
const label = (labelSrc?.textContent || '').trim();
breadcrumbEl.textContent = label;
breadcrumbEl.classList.toggle('is-visible', label.length > 0);
}
const prevBtn = overlay.querySelector('.tutorial-prev-btn') as HTMLButtonElement;
const nextBtn = overlay.querySelector('.tutorial-next-btn');
const nextBtn = overlay.querySelector('.tutorial-next-btn') as HTMLButtonElement | null;
if (prevBtn) prevBtn.disabled = (index === 0);
if (nextBtn) nextBtn.textContent = (index === activeTutorial!.steps.length - 1) ? '\u2713' : '\u2192';
if (nextBtn) {
const isLast = index === total - 1;
if (isV2) {
nextBtn.innerHTML = isLast ? 'FINISH \u2713' : 'NEXT \u25b6';
} else {
nextBtn.textContent = isLast ? '\u2713' : '\u2192';
}
}
let actualPosition = step.position;
if (tooltip) {
positionTutorialTooltip(tooltip, x, y, w, h, step.position, isFixed);
actualPosition = positionTutorialTooltip(tooltip, x, y, w, h, step.position, isFixed);
tooltip.style.visibility = '';
}
if (ring) ring.style.visibility = '';
if (isV2) {
_renderPips(overlay, total, index);
if (tooltip) _drawCable(overlay, x, y, w, h, tooltip, actualPosition);
_flashCornerLock(overlay);
}
}
/** Wait for scroll to settle (position stops changing). */
@@ -337,6 +520,16 @@ function showTutorialStep(index: number, direction: number = 1): void {
(el as HTMLElement).style.zIndex = '';
});
// Reveal panels that live behind a sub-tab before resolving the target.
if (step.subTab && activeTutorial.switchSubTab) {
activeTutorial.switchSubTab(step.subTab);
}
// Tour-level hook: open/close auxiliary UI (side panels, popups).
if (activeTutorial.prepare) {
activeTutorial.prepare(step);
}
const target = activeTutorial.resolveTarget(step);
if (!target) {
// Auto-skip hidden/missing targets in the current direction
@@ -368,9 +561,10 @@ function showTutorialStep(index: number, direction: number = 1): void {
}
}
function positionTutorialTooltip(tooltip: HTMLElement, sx: number, sy: number, sw: number, sh: number, preferred: string, isFixed: boolean): void {
function positionTutorialTooltip(tooltip: HTMLElement, sx: number, sy: number, sw: number, sh: number, preferred: string, isFixed: boolean): string {
const gap = 12;
const tooltipW = 260;
// v2 tooltip is wider; measure the live width instead of hardcoding.
const tooltipW = tooltip.offsetWidth || 260;
tooltip.setAttribute('style', 'left:-9999px;top:-9999px');
const tooltipH = tooltip.offsetHeight || 150;
@@ -381,6 +575,7 @@ function positionTutorialTooltip(tooltip: HTMLElement, sx: number, sy: number, s
right: { x: sx + sw + gap, y: sy + sh / 2 - tooltipH / 2 }
};
let chosen = preferred;
let pos = positions[preferred] || positions.bottom;
const cW = isFixed ? window.innerWidth : activeTutorial!.container!.clientWidth;
@@ -388,9 +583,11 @@ function positionTutorialTooltip(tooltip: HTMLElement, sx: number, sy: number, s
if (pos.y + tooltipH > cH || pos.y < 0 || pos.x + tooltipW > cW || pos.x < 0) {
const opposite = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' };
const alt = positions[opposite[preferred]];
const altKey = opposite[preferred];
const alt = positions[altKey];
if (alt && alt.y >= 0 && alt.y + tooltipH <= cH && alt.x >= 0 && alt.x + tooltipW <= cW) {
pos = alt;
chosen = altKey;
}
}
@@ -398,6 +595,7 @@ function positionTutorialTooltip(tooltip: HTMLElement, sx: number, sy: number, s
pos.y = Math.max(8, Math.min(cH - tooltipH - 8, pos.y));
tooltip.setAttribute('style', `left:${Math.round(pos.x)}px;top:${Math.round(pos.y)}px`);
return chosen;
}
function handleTutorialKey(e: KeyboardEvent): void {
+1
View File
@@ -68,6 +68,7 @@ interface Window {
startTargetsTutorial: (...args: any[]) => any;
startSourcesTutorial: (...args: any[]) => any;
startAutomationsTutorial: (...args: any[]) => any;
startIntegrationsTutorial: (...args: any[]) => any;
closeTutorial: (...args: any[]) => any;
tutorialNext: (...args: any[]) => any;
tutorialPrev: (...args: any[]) => any;
+63 -11
View File
@@ -351,14 +351,13 @@
"device.last_seen.hours": "%dh ago",
"device.last_seen.days": "%dd ago",
"device.tutorial.start": "Start tutorial",
"device.tip.metadata": "Device info (LED count, type, color channels) is auto-detected from the device",
"device.tip.brightness": "Slide to adjust device brightness",
"device.tip.identity": "Device identity — name, type badge, and health indicator showing online/offline status.",
"device.tip.metadata": "Device address and firmware version. Click the URL to open the device's built-in web UI.",
"device.tip.brightness": "Drag to adjust device brightness. Changes are sent to the device immediately.",
"device.brightness": "Bright",
"device.tip.start": "Start or stop screen capture processing",
"device.tip.settings": "Configure general device settings (name, URL, health check)",
"device.tip.capture_settings": "Configure capture settings (display, capture template)",
"device.tip.calibrate": "Calibrate LED positions, direction, and coverage",
"device.tip.webui": "Open the device's built-in web interface for advanced configuration",
"device.tip.ping": "Ping the device to refresh online status and latency.",
"device.tip.settings": "Open device settings — configure name, URL, capabilities, and health checks.",
"device.tip.menu": "More actions — duplicate, hide, or delete this device.",
"device.tip.add": "Click here to add a new LED device",
"settings.title": "Settings",
"settings.tab.general": "General",
@@ -377,6 +376,39 @@
"settings.section.connection": "Connection",
"settings.section.hardware": "Hardware",
"settings.section.behavior": "Behavior",
"settings.section.provider": "Provider",
"settings.section.refresh": "Refresh",
"settings.section.filters": "Filters",
"settings.section.routing": "Routing",
"settings.section.output": "Output",
"settings.section.filtering": "Filtering",
"settings.section.broker": "Broker",
"settings.section.protocol": "Protocol",
"settings.section.adapter": "Adapter",
"settings.section.mappings": "Mappings",
"settings.section.diagnostics": "Diagnostics",
"settings.section.source": "Source",
"settings.section.engine": "Engine",
"settings.section.timing": "Timing",
"settings.section.gradient": "Gradient",
"settings.section.layout": "Layout",
"settings.section.strip": "Strip",
"settings.section.type": "Type",
"settings.section.notes": "Notes",
"settings.section.file": "File",
"settings.section.auth": "Auth",
"settings.section.configure": "Configure",
"settings.section.restart": "Restart",
"settings.section.loopback": "Loopback",
"settings.section.test_setup": "Test Setup",
"settings.section.offsets": "Offsets",
"settings.section.line_properties": "Line Properties",
"settings.section.test": "Test",
"settings.section.preview": "Preview",
"settings.section.controls": "Controls",
"settings.section.history": "History",
"ha_source.use_ssl.hint": "Enable for HTTPS / wss connections to Home Assistant",
"game_integration.enabled.hint": "Disabled integrations stop polling and emitting events",
"settings.capture.title": "Capture Settings",
"settings.capture.saved": "Capture settings updated",
"settings.capture.failed": "Failed to save capture settings",
@@ -413,6 +445,7 @@
"tour.dashboard": "Dashboard — live overview of running targets, automations, and device health at a glance.",
"tour.targets": "Targets — add WLED devices, configure LED targets with capture settings and calibration.",
"tour.sources": "Sources — manage capture templates, picture sources, audio sources, and color strips.",
"tour.integrations": "Integrations — connect external services: Weather, Home Assistant, MQTT, and Game integrations.",
"tour.graph": "Graph — visual overview of all entities and their connections. Drag ports to connect, right-click edges to disconnect.",
"tour.automations": "Automations — automate scene switching with time, audio, or value rules.",
"tour.settings": "Settings — backup and restore configuration, manage auto-backups.",
@@ -423,21 +456,32 @@
"tour.language": "Language — choose your preferred interface language.",
"tour.restart": "Restart tutorial",
"tour.dash.perf": "Performance — real-time FPS charts, latency metrics, and poll interval control.",
"tour.dash.targets": "Channels — all your targets in one place, grouped into running and stopped subsections.",
"tour.dash.running": "Running targets — live streaming metrics and quick stop control.",
"tour.dash.stopped": "Stopped targets — ready to start with one click.",
"tour.dash.automations": "Automations — active automation status and quick enable/disable toggle.",
"tour.tgt.led_tab": "LED tab — standard LED strip targets with device and color strip configuration.",
"tour.dash.scenes": "Scene Presets — saved system snapshots you can apply with a single click.",
"tour.dash.sync_clocks": "Sync Clocks — shared timers that synchronize animations across sources.",
"tour.dash.integrations": "Integrations — connection status for Weather, Home Assistant, and MQTT sources.",
"tour.dash.customize_btn": "Customize button — opens a side panel where you can reorder sections, toggle visibility, and tune the dashboard layout.",
"tour.dash.customize_panel": "Dashboard Customize — your control center for layout. Changes apply live; close with Esc or the × button.",
"tour.dash.customize_presets": "Presets — apply curated layouts (compact, focused, full) in one click. Switching to a preset overrides any manual tweaks.",
"tour.dash.customize_global": "Global settings — content width, animation level, performance mode (system/app/both), and the sample window for charts.",
"tour.dash.customize_sections": "Sections — drag to reorder; click the eye to hide; click the chevron to start collapsed by default. Each section can also be tagged dense/compact/comfortable.",
"tour.dash.customize_perf_cells": "Performance cells — choose which metric tiles to show in the Performance section, configure their span, sample window, and Y-axis scale.",
"tour.tgt.nav": "Use this dropdown to switch between target types: LED devices, LED targets, and Home Assistant lights.",
"tour.tgt.devices": "Devices — your LED controllers discovered on the network.",
"tour.tgt.css": "Color Strips — define how screen regions map to LED segments.",
"tour.tgt.targets": "LED Targets — combine a device, color strip, and capture source for streaming.",
"tour.tgt.kc_tab": "Key Colors — alternative target type using color-matching instead of pixel mapping.",
"tour.tgt.ha_light_targets": "HA Light Targets — pick a Home Assistant light entity and a color/value source to drive it.",
"tour.src.nav": "Use this dropdown to switch between source types — pictures, color strips, audio, value, sync, and assets.",
"tour.src.raw": "Raw — live screen capture sources from your displays.",
"tour.src.templates": "Capture Templates — reusable capture configurations (resolution, FPS, crop).",
"tour.src.static": "Static Image — test your setup with image files instead of live capture.",
"tour.src.processed": "Processed — apply post-processing effects like blur, brightness, or color correction.",
"tour.src.color_strip": "Color Strips — define how screen regions map to LED segments.",
"tour.src.audio": "Audio — analyze microphone or system audio for reactive LED effects.",
"tour.src.value": "Value numeric data sources used as rules in automations.",
"tour.src.audio": "Audio — capture microphone/system audio and process it through templates for reactive effects.",
"tour.src.value": "Value Sources — dynamic numbers or colors driven by time of day, audio, system metrics, Home Assistant entities, gradients, or schedules. Used to animate effects, modulate color strips, and trigger automations.",
"tour.src.sync": "Sync Clocks — shared timers that synchronize animations across multiple sources.",
"tour.auto.list": "Automations — automate scene activation based on time, audio, or value rules.",
"tour.auto.add": "Click + to create a new automation with rules and a scene to activate.",
@@ -445,6 +489,11 @@
"tour.auto.scenes_list": "Scenes — saved system states that automations can activate or you can apply manually.",
"tour.auto.scenes_add": "Click + to capture the current system state as a new scene preset.",
"tour.auto.scenes_card": "Each scene card shows target/device counts. Click to edit, recapture, or activate.",
"tour.int.nav": "Use this dropdown to switch between integration types: Weather, Home Assistant, MQTT, and Game integrations.",
"tour.int.weather": "Weather — pull live weather data (temperature, conditions, forecast) into value sources for environmental effects.",
"tour.int.home_assistant": "Home Assistant — connect to your HA instance. Lights, sensors, and entities become value sources and target endpoints.",
"tour.int.mqtt": "MQTT — subscribe to broker topics to feed external sensor data, automations, or remote triggers into LedGrab.",
"tour.int.game": "Game — react to in-game events (Minecraft, Hammer of God, etc.) by triggering scenes and color effects from game state.",
"calibration.tutorial.start": "Start tutorial",
"calibration.overlay_toggle": "Overlay",
"calibration.start_position": "Starting Position:",
@@ -880,6 +929,9 @@
"automations.add": "Add Automation",
"automations.edit": "Edit Automation",
"automations.delete.confirm": "Delete automation \"{name}\"?",
"automations.section.triggers": "Triggers",
"automations.section.action": "Action",
"automations.section.deactivation": "Deactivation",
"automations.name": "Name:",
"automations.name.hint": "A descriptive name for this automation",
"automations.name.placeholder": "My Automation",
+63 -11
View File
@@ -355,14 +355,13 @@
"device.last_seen.hours": "%d ч назад",
"device.last_seen.days": "%d д назад",
"device.tutorial.start": "Начать обучение",
"device.tip.metadata": нформация об устройстве (кол-во LED, тип, цветовые каналы) определяется автоматически",
"device.tip.brightness": "Перетащите для регулировки яркости",
"device.tip.identity": дентификация устройства — имя, тип и индикатор онлайн/офлайн.",
"device.tip.metadata": "Адрес устройства и версия прошивки. Кликните по URL, чтобы открыть веб-интерфейс устройства.",
"device.tip.brightness": "Перетащите для регулировки яркости. Изменения применяются мгновенно.",
"device.brightness": "Яркость",
"device.tip.start": "Запуск или остановка захвата экрана",
"device.tip.settings": "Основные настройки устройства (имя, URL, интервал проверки)",
"device.tip.capture_settings": "Настройки захвата (дисплей, шаблон захвата)",
"device.tip.calibrate": "Калибровка позиций LED, направления и зоны покрытия",
"device.tip.webui": "Открыть встроенный веб-интерфейс устройства для расширенной настройки",
"device.tip.ping": "Пинг устройства — обновляет статус и измеряет задержку.",
"device.tip.settings": "Открыть настройки устройства имя, URL, возможности и проверки соединения.",
"device.tip.menu": "Дополнительные действия — дублировать, скрыть или удалить устройство.",
"device.tip.add": "Нажмите, чтобы добавить новое LED устройство",
"settings.title": "Настройки",
"settings.tab.general": "Основные",
@@ -381,6 +380,39 @@
"settings.section.connection": "Подключение",
"settings.section.hardware": "Оборудование",
"settings.section.behavior": "Поведение",
"settings.section.provider": "Провайдер",
"settings.section.refresh": "Обновление",
"settings.section.filters": "Фильтры",
"settings.section.routing": "Маршрутизация",
"settings.section.output": "Вывод",
"settings.section.filtering": "Фильтрация",
"settings.section.broker": "Брокер",
"settings.section.protocol": "Протокол",
"settings.section.adapter": "Адаптер",
"settings.section.mappings": "Сопоставления",
"settings.section.diagnostics": "Диагностика",
"settings.section.source": "Источник",
"settings.section.engine": "Движок",
"settings.section.timing": "Тайминг",
"settings.section.gradient": "Градиент",
"settings.section.layout": "Раскладка",
"settings.section.strip": "Лента",
"settings.section.type": "Тип",
"settings.section.notes": "Заметки",
"settings.section.file": "Файл",
"settings.section.auth": "Авторизация",
"settings.section.configure": "Настройка",
"settings.section.restart": "Перезапуск",
"settings.section.loopback": "Локальный доступ",
"settings.section.test_setup": "Параметры теста",
"settings.section.offsets": "Смещения",
"settings.section.line_properties": "Параметры линии",
"settings.section.test": "Тест",
"settings.section.preview": "Превью",
"settings.section.controls": "Управление",
"settings.section.history": "История",
"ha_source.use_ssl.hint": "Включите для HTTPS / wss соединений с Home Assistant",
"game_integration.enabled.hint": "Отключённые интеграции перестают опрашивать и генерировать события",
"settings.capture.title": "Настройки Захвата",
"settings.capture.saved": "Настройки захвата обновлены",
"settings.capture.failed": "Не удалось сохранить настройки захвата",
@@ -417,6 +449,7 @@
"tour.dashboard": "Дашборд — обзор запущенных целей, автоматизаций и состояния устройств.",
"tour.targets": "Цели — добавляйте WLED-устройства, настраивайте LED-цели с захватом и калибровкой.",
"tour.sources": "Источники — управление шаблонами захвата, источниками изображений, звука и цветовых полос.",
"tour.integrations": "Интеграции — подключайте внешние сервисы: погоду, Home Assistant, MQTT и игровые интеграции.",
"tour.graph": "Граф — визуальный обзор всех сущностей и их связей. Перетаскивайте порты для соединения, правый клик по связям для отключения.",
"tour.automations": "Автоматизации — автоматизируйте переключение сцен по расписанию, звуку или значениям.",
"tour.settings": "Настройки — резервное копирование и восстановление конфигурации.",
@@ -427,21 +460,32 @@
"tour.language": "Язык — выберите предпочитаемый язык интерфейса.",
"tour.restart": "Запустить тур заново",
"tour.dash.perf": "Производительность — графики FPS в реальном времени, метрики задержки и интервал опроса.",
"tour.dash.targets": "Каналы — все ваши цели в одном месте, разделённые на запущенные и остановленные.",
"tour.dash.running": "Запущенные цели — метрики стриминга и быстрая остановка.",
"tour.dash.stopped": "Остановленные цели — готовы к запуску одним нажатием.",
"tour.dash.automations": "Автоматизации — статус активных автоматизаций и быстрое включение/выключение.",
"tour.tgt.led_tab": "LED — стандартные LED-цели с настройкой устройств и цветовых полос.",
"tour.dash.scenes": "Пресеты сцен — сохранённые состояния системы, применяются одним кликом.",
"tour.dash.sync_clocks": "Синхро-часы — общие таймеры для синхронизации анимаций между источниками.",
"tour.dash.integrations": "Интеграции — статус подключения для Weather, Home Assistant и MQTT.",
"tour.dash.customize_btn": "Кнопка «Настроить» — открывает боковую панель, где можно изменить порядок секций, скрыть их или подстроить раскладку дашборда.",
"tour.dash.customize_panel": "Настройка дашборда — центр управления раскладкой. Изменения применяются мгновенно; закрытие — Esc или кнопка ×.",
"tour.dash.customize_presets": "Пресеты — применяйте готовые раскладки (компактная, фокус, полная) одним кликом. Выбор пресета сбрасывает ручные настройки.",
"tour.dash.customize_global": "Глобальные настройки — ширина контента, уровень анимаций, режим производительности (system/app/both) и окно выборки графиков.",
"tour.dash.customize_sections": "Секции — перетаскивайте для смены порядка; «глаз» скрывает; «шеврон» делает секцию свёрнутой по умолчанию. Также для каждой секции можно выбрать плотность.",
"tour.dash.customize_perf_cells": "Ячейки производительности — выбирайте, какие метрики отображать в секции Performance, настраивайте ширину, окно выборки и шкалу Y.",
"tour.tgt.nav": "Используйте этот выпадающий список для переключения между типами целей: LED-устройства, LED-цели и лампы Home Assistant.",
"tour.tgt.devices": "Устройства — ваши LED-контроллеры, найденные в сети.",
"tour.tgt.css": "Цветовые полосы — определите, как области экрана соответствуют сегментам LED.",
"tour.tgt.targets": "LED-цели — объедините устройство, цветовую полосу и источник захвата для стриминга.",
"tour.tgt.kc_tab": "Key Colors — альтернативный тип цели с подбором цветов вместо пиксельного маппинга.",
"tour.tgt.ha_light_targets": "Цели HA-ламп — выберите лампу Home Assistant и источник цвета/значения для управления.",
"tour.src.nav": "Используйте этот выпадающий список для переключения между типами источников — изображения, цветовые полосы, аудио, значения, синхронизация, ассеты.",
"tour.src.raw": "Raw — источники захвата экрана с ваших дисплеев.",
"tour.src.templates": "Шаблоны захвата — переиспользуемые конфигурации (разрешение, FPS, обрезка).",
"tour.src.static": "Статичные изображения — тестируйте настройку с файлами изображений.",
"tour.src.processed": "Обработка — применяйте эффекты: размытие, яркость, цветокоррекция.",
"tour.src.color_strip": "Цветовые полосы — определяют, как области экрана сопоставляются с LED-сегментами.",
"tour.src.audio": "Аудио — анализ микрофона или системного звука для реактивных LED-эффектов.",
"tour.src.value": "Значениячисловые источники данных для условий автоматизаций.",
"tour.src.audio": "Аудио — захват микрофона или системного звука и обработка через шаблоны для реактивных эффектов.",
"tour.src.value": "Источники значенийдинамические числа или цвета, управляемые временем суток, аудио, системными метриками, сущностями Home Assistant, градиентами или расписаниями. Используются для анимации эффектов, модуляции цветовых полос и триггеров автоматизаций.",
"tour.src.sync": "Синхро-часы — общие таймеры для синхронизации анимаций между несколькими источниками.",
"tour.auto.list": "Автоматизации — автоматизируйте активацию сцен по времени, звуку или значениям.",
"tour.auto.add": "Нажмите + для создания новой автоматизации с условиями и сценой для активации.",
@@ -449,6 +493,11 @@
"tour.auto.scenes_list": "Сцены — сохранённые состояния системы, которые автоматизации могут активировать или вы можете применить вручную.",
"tour.auto.scenes_add": "Нажмите + для захвата текущего состояния системы как нового пресета сцены.",
"tour.auto.scenes_card": "Каждая карточка сцены показывает количество целей/устройств. Нажмите для редактирования, перезахвата или активации.",
"tour.int.nav": "Используйте этот выпадающий список для переключения между типами интеграций: погода, Home Assistant, MQTT и игровые интеграции.",
"tour.int.weather": "Погода — получайте данные о погоде (температура, условия, прогноз) в виде источников значений для эффектов от окружающей среды.",
"tour.int.home_assistant": "Home Assistant — подключение к экземпляру HA. Лампы, датчики и сущности становятся источниками значений и целями вывода.",
"tour.int.mqtt": "MQTT — подписывайтесь на топики брокера для передачи данных внешних датчиков, автоматизаций или удалённых триггеров в LedGrab.",
"tour.int.game": "Игры — реакция на игровые события (Minecraft, Hammer of God и др.) с активацией сцен и цветовых эффектов на основе состояния игры.",
"calibration.tutorial.start": "Начать обучение",
"calibration.overlay_toggle": "Оверлей",
"calibration.start_position": "Начальная Позиция:",
@@ -861,6 +910,9 @@
"automations.add": "Добавить автоматизацию",
"automations.edit": "Редактировать автоматизацию",
"automations.delete.confirm": "Удалить автоматизацию \"{name}\"?",
"automations.section.triggers": "Триггеры",
"automations.section.action": "Действие",
"automations.section.deactivation": "Деактивация",
"automations.name": "Название:",
"automations.name.hint": "Описательное имя для автоматизации",
"automations.name.placeholder": "Моя автоматизация",
+63 -11
View File
@@ -355,14 +355,13 @@
"device.last_seen.hours": "%d小时前",
"device.last_seen.days": "%d天前",
"device.tutorial.start": "开始教程",
"device.tip.metadata": "设备信息(LED 数量、类型、颜色通道)从设备自动检测",
"device.tip.brightness": "滑动调节设备亮度",
"device.tip.identity": "设备标识 — 名称、类型徽章和在线/离线状态指示。",
"device.tip.metadata": "设备地址和固件版本。点击 URL 可打开设备的内置 Web 界面。",
"device.tip.brightness": "拖动调节设备亮度。更改将立即发送到设备。",
"device.brightness": "亮度",
"device.tip.start": "启动或停止屏幕采集处理",
"device.tip.settings": "配置设备常规设置(名称、地址、健康检查",
"device.tip.capture_settings": "配置采集设置(显示器、采集模板)",
"device.tip.calibrate": "校准 LED 位置、方向和覆盖范围",
"device.tip.webui": "打开设备内置的 Web 界面进行高级配置",
"device.tip.ping": "Ping 设备 — 刷新在线状态并测量延迟。",
"device.tip.settings": "打开设备设置 — 配置名称、地址、能力和健康检查",
"device.tip.menu": "更多操作 — 复制、隐藏或删除此设备。",
"device.tip.add": "点击此处添加新的 LED 设备",
"settings.title": "设置",
"settings.tab.general": "常规",
@@ -381,6 +380,39 @@
"settings.section.connection": "连接",
"settings.section.hardware": "硬件",
"settings.section.behavior": "行为",
"settings.section.provider": "提供商",
"settings.section.refresh": "刷新",
"settings.section.filters": "过滤器",
"settings.section.routing": "路由",
"settings.section.output": "输出",
"settings.section.filtering": "过滤",
"settings.section.broker": "代理服务器",
"settings.section.protocol": "协议",
"settings.section.adapter": "适配器",
"settings.section.mappings": "映射",
"settings.section.diagnostics": "诊断",
"settings.section.source": "源",
"settings.section.engine": "引擎",
"settings.section.timing": "时序",
"settings.section.gradient": "渐变",
"settings.section.layout": "布局",
"settings.section.strip": "灯带",
"settings.section.type": "类型",
"settings.section.notes": "备注",
"settings.section.file": "文件",
"settings.section.auth": "认证",
"settings.section.configure": "配置",
"settings.section.restart": "重启",
"settings.section.loopback": "本地访问",
"settings.section.test_setup": "测试设置",
"settings.section.offsets": "偏移",
"settings.section.line_properties": "线属性",
"settings.section.test": "测试",
"settings.section.preview": "预览",
"settings.section.controls": "控制",
"settings.section.history": "历史",
"ha_source.use_ssl.hint": "为 Home Assistant 启用 HTTPS / wss 连接",
"game_integration.enabled.hint": "已禁用的集成将停止轮询和发出事件",
"settings.capture.title": "采集设置",
"settings.capture.saved": "采集设置已更新",
"settings.capture.failed": "保存采集设置失败",
@@ -417,6 +449,7 @@
"tour.dashboard": "仪表盘 — 实时查看运行中的目标、自动化和设备状态。",
"tour.targets": "目标 — 添加 WLED 设备,配置 LED 目标的捕获设置和校准。",
"tour.sources": "来源 — 管理捕获模板、图片来源、音频来源和色带。",
"tour.integrations": "集成 — 连接外部服务:天气、Home Assistant、MQTT 和游戏集成。",
"tour.graph": "图表 — 所有实体及其连接的可视化概览。拖动端口进行连接,右键单击边线断开连接。",
"tour.automations": "自动化 — 通过时间、音频或数值条件自动切换场景。",
"tour.settings": "设置 — 备份和恢复配置,管理自动备份。",
@@ -427,21 +460,32 @@
"tour.language": "语言 — 选择您偏好的界面语言。",
"tour.restart": "重新开始导览",
"tour.dash.perf": "性能 — 实时 FPS 图表、延迟指标和轮询间隔控制。",
"tour.dash.targets": "通道 — 您的所有目标集中显示,按运行中和已停止分组。",
"tour.dash.running": "运行中的目标 — 实时流媒体指标和快速停止控制。",
"tour.dash.stopped": "已停止的目标 — 一键启动。",
"tour.dash.automations": "自动化 — 活动自动化状态和快速启用/禁用切换。",
"tour.tgt.led_tab": "LED 标签 — 标准 LED 灯带目标,包含设备和色带配置。",
"tour.dash.scenes": "场景预设 — 已保存的系统快照,一键应用。",
"tour.dash.sync_clocks": "同步时钟 — 跨多个源同步动画的共享计时器。",
"tour.dash.integrations": "集成 — Weather、Home Assistant 和 MQTT 源的连接状态。",
"tour.dash.customize_btn": "自定义按钮 — 打开侧面板,可重新排序、隐藏或调整仪表盘布局。",
"tour.dash.customize_panel": "自定义仪表盘 — 布局控制中心。更改即时生效;按 Esc 或 × 关闭。",
"tour.dash.customize_presets": "预设 — 一键应用精选布局(紧凑、聚焦、完整)。切换预设会覆盖任何手动调整。",
"tour.dash.customize_global": "全局设置 — 内容宽度、动画级别、性能模式(system/app/both)和图表采样窗口。",
"tour.dash.customize_sections": "部分 — 拖拽以重新排序;「眼睛」可隐藏;「箭头」可设为默认折叠。每个部分还可选择密度(密集/紧凑/舒适)。",
"tour.dash.customize_perf_cells": "性能单元 — 选择性能部分中显示哪些指标,配置跨度、采样窗口和 Y 轴比例。",
"tour.tgt.nav": "使用此下拉菜单在目标类型之间切换:LED 设备、LED 目标和 Home Assistant 灯。",
"tour.tgt.devices": "设备 — 在网络中发现的 LED 控制器。",
"tour.tgt.css": "色带 — 定义屏幕区域如何映射到 LED 段。",
"tour.tgt.targets": "LED 目标 — 将设备、色带和捕获源组合进行流式传输。",
"tour.tgt.kc_tab": "Key Colors — 使用颜色匹配代替像素映射的替代目标类型。",
"tour.tgt.ha_light_targets": "HA 灯目标 — 选择 Home Assistant 灯实体和颜色/数值源进行驱动。",
"tour.src.nav": "使用此下拉菜单在源类型之间切换 — 图片、色带、音频、数值、同步和资源。",
"tour.src.raw": "原始 — 来自显示器的实时屏幕捕获源。",
"tour.src.templates": "捕获模板 — 可复用的捕获配置(分辨率、FPS、裁剪)。",
"tour.src.static": "静态图片 — 使用图片文件测试您的设置。",
"tour.src.processed": "处理 — 应用后处理效果,如模糊、亮度或色彩校正。",
"tour.src.color_strip": "色带 — 定义屏幕区域如何映射到 LED 段。",
"tour.src.audio": "音频 — 分析麦克风系统音频以实现响应式 LED 效果。",
"tour.src.value": "数值 — 用于自动化条件的数字数据源。",
"tour.src.audio": "音频 — 捕获麦克风/系统音频,并通过模板进行处理以生成反应式效果。",
"tour.src.value": "数值由时间、音频、系统指标、Home Assistant 实体、渐变或日程驱动的动态数字或颜色。用于动画效果、调制色带和触发自动化。",
"tour.src.sync": "同步时钟 — 在多个源之间同步动画的共享定时器。",
"tour.auto.list": "自动化 — 基于时间、音频或数值条件自动激活场景。",
"tour.auto.add": "点击 + 创建包含条件和要激活场景的新自动化。",
@@ -449,6 +493,11 @@
"tour.auto.scenes_list": "场景 — 保存的系统状态,自动化可以激活或您可以手动应用。",
"tour.auto.scenes_add": "点击 + 将当前系统状态捕获为新的场景预设。",
"tour.auto.scenes_card": "每个场景卡片显示目标/设备数量。点击编辑、重新捕获或激活。",
"tour.int.nav": "使用此下拉菜单在集成类型之间切换:天气、Home Assistant、MQTT 和游戏集成。",
"tour.int.weather": "天气 — 获取实时天气数据(温度、状况、预报)作为环境效果的数值源。",
"tour.int.home_assistant": "Home Assistant — 连接到您的 HA 实例。灯、传感器和实体可作为数值源和目标端点。",
"tour.int.mqtt": "MQTT — 订阅代理主题,将外部传感器数据、自动化或远程触发器传入 LedGrab。",
"tour.int.game": "游戏 — 根据游戏内事件(Minecraft、Hammer of God 等)触发场景和色彩效果。",
"calibration.tutorial.start": "开始教程",
"calibration.overlay_toggle": "叠加层",
"calibration.start_position": "起始位置:",
@@ -861,6 +910,9 @@
"automations.add": "添加自动化",
"automations.edit": "编辑自动化",
"automations.delete.confirm": "删除自动化 \"{name}\"",
"automations.section.triggers": "触发器",
"automations.section.action": "动作",
"automations.section.deactivation": "停用",
"automations.name": "名称:",
"automations.name.hint": "此自动化的描述性名称",
"automations.name.placeholder": "我的自动化",
@@ -20,6 +20,15 @@
<hr class="modal-divider">
</div>
<form id="add-device-form">
<!-- ── 01 · IDENTITY ───────────────────────────────── -->
<section class="ds-section" data-ds-key="identity" data-ch="signal">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.identity">Identity</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<div class="ds-section-body">
<div class="form-group" id="device-type-group">
<div class="label-row">
<label for="device-type" data-i18n="device.type">Device Type:</label>
@@ -45,10 +54,21 @@
<option value="group">Group</option>
</select>
</div>
<div class="form-group">
<div class="form-group ds-name-group">
<label for="device-name" data-i18n="device.name">Device Name:</label>
<input type="text" id="device-name" data-i18n-placeholder="device.name.placeholder" placeholder="Living Room TV" required>
</div>
</div>
</section>
<!-- ── 02 · CONNECTION ─────────────────────────────── -->
<section class="ds-section" data-ds-key="connection" data-ch="cyan">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.connection">Connection</span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div>
<div class="ds-section-body">
<div class="form-group" id="device-url-group">
<div class="label-row">
<label for="device-url" id="device-url-label" data-i18n="device.url">URL:</label>
@@ -303,6 +323,17 @@
<option value="indicator">Indicator</option>
</select>
</div>
</div>
</section>
<!-- ── 03 · OUTPUT ─────────────────────────────────── -->
<section class="ds-section" data-ds-key="output" data-ch="amber">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.output">Output</span>
<span class="ds-section-index" aria-hidden="true">03</span>
</div>
<div class="ds-section-body">
<div class="form-group" id="device-cspt-group">
<div class="label-row">
<label for="device-css-processing-template" data-i18n="device.css_processing_template">Strip Processing Template:</label>
@@ -313,6 +344,9 @@
<option value=""></option>
</select>
</div>
</div>
</section>
<div id="add-device-error" class="error-message" style="display: none;"></div>
</form>
</div>
@@ -1,4 +1,10 @@
<!-- Advanced Calibration Modal (multimonitor line-based) -->
<!-- Advanced Calibration Modal — sectioned rack-panel layout. Channels:
Layout (signal) — canvas + line list (the visual core)
Line Properties (cyan) — selected-line config (only when a line is picked)
Offsets (amber) — global LED offset + skip start/end
The "Line Properties" section reuses #advcal-line-props which is
toggled display:none/'' by the JS — the wrapping section toggles
in sync below. All inner element IDs preserved. -->
<div id="advanced-calibration-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="advcal-modal-title">
<div class="modal-content" style="max-width: 900px;">
<div class="modal-header">
@@ -8,121 +14,148 @@
<div class="modal-body">
<input type="hidden" id="advcal-css-id">
<!-- Two-column layout: canvas + line list -->
<div class="advcal-layout">
<!-- Left: Canvas showing monitor rectangles with lines -->
<div class="advcal-canvas-panel">
<canvas id="advcal-canvas" width="560" height="340"></canvas>
<div class="advcal-canvas-hint">
<small data-i18n="calibration.advanced.canvas_hint">Drag monitors to reposition. Click edges to select lines. Scroll to zoom, drag empty space to pan.</small>
<button class="btn-micro" onclick="resetCalibrationView()" title="Reset view" data-i18n-title="calibration.advanced.reset_view">&#x21BA;</button>
</div>
<!-- ── 01 · LAYOUT ─────────────────────────────────── -->
<section class="ds-section" data-ds-key="layout" data-ch="signal">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.layout">Layout</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<div class="ds-section-body">
<!-- Two-column layout: canvas + line list -->
<div class="advcal-layout">
<!-- Left: Canvas showing monitor rectangles with lines -->
<div class="advcal-canvas-panel">
<canvas id="advcal-canvas" width="560" height="340"></canvas>
<div class="advcal-canvas-hint">
<small data-i18n="calibration.advanced.canvas_hint">Drag monitors to reposition. Click edges to select lines. Scroll to zoom, drag empty space to pan.</small>
<button class="btn-micro" onclick="resetCalibrationView()" title="Reset view" data-i18n-title="calibration.advanced.reset_view">&#x21BA;</button>
</div>
</div>
<!-- Right: Line list -->
<div class="advcal-lines-panel">
<div id="advcal-line-list" class="advcal-line-list">
<!-- Line items rendered dynamically -->
<!-- Right: Line list -->
<div class="advcal-lines-panel">
<div id="advcal-line-list" class="advcal-line-list">
<!-- Line items rendered dynamically -->
</div>
<div class="advcal-leds-counter"><span id="advcal-total-leds">0</span> LEDs</div>
</div>
</div>
<div class="advcal-leds-counter"><span id="advcal-total-leds">0</span> LEDs</div>
</div>
</div>
</section>
<!-- Selected line properties -->
<div id="advcal-line-props" class="advcal-line-props" style="display:none">
<h3 style="margin: 0 0 8px; font-size: 0.9em;" data-i18n="calibration.advanced.line_properties">Line Properties</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px;">
<div class="form-group">
<div class="label-row">
<label for="advcal-line-source" data-i18n="calibration.advanced.picture_source">Source:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<!-- ── 02 · LINE PROPERTIES (shown when a line is selected) ─ -->
<section class="ds-section" data-ds-key="line-properties" data-ch="cyan" id="advcal-line-props-section" style="display:none">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.line_properties">Line Properties</span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div>
<div class="ds-section-body">
<div id="advcal-line-props" class="advcal-line-props" style="display:none">
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px;">
<div class="form-group">
<div class="label-row">
<label for="advcal-line-source" data-i18n="calibration.advanced.picture_source">Source:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="calibration.advanced.picture_source.hint">The picture source (monitor) this line samples from</small>
<select id="advcal-line-source" onchange="updateCalibrationLine()"></select>
</div>
<div class="form-group">
<div class="label-row">
<label for="advcal-line-edge" data-i18n="calibration.advanced.edge">Edge:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="calibration.advanced.edge.hint">Which screen edge to sample pixels from</small>
<select id="advcal-line-edge" onchange="updateCalibrationLine()">
<option value="top">Top</option>
<option value="right">Right</option>
<option value="bottom">Bottom</option>
<option value="left">Left</option>
</select>
</div>
<div class="form-group">
<div class="label-row">
<label for="advcal-line-leds" data-i18n="calibration.advanced.led_count">LEDs:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="calibration.advanced.led_count.hint">Number of LEDs mapped to this line</small>
<input type="number" id="advcal-line-leds" min="1" max="1500" value="10" onchange="updateCalibrationLine()">
</div>
</div>
<small class="input-hint" style="display:none" data-i18n="calibration.advanced.picture_source.hint">The picture source (monitor) this line samples from</small>
<select id="advcal-line-source" onchange="updateCalibrationLine()"></select>
</div>
<div class="form-group">
<div class="label-row">
<label for="advcal-line-edge" data-i18n="calibration.advanced.edge">Edge:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 12px; margin-top: 8px;">
<div class="form-group">
<div class="label-row">
<label for="advcal-line-span-start" data-i18n="calibration.advanced.span_start">Span Start:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="calibration.advanced.span_start.hint">Where sampling begins along the edge (0 = start, 1 = end). Use to cover only part of an edge.</small>
<input type="number" id="advcal-line-span-start" min="0" max="1" step="0.01" value="0" onchange="updateCalibrationLine()">
</div>
<div class="form-group">
<div class="label-row">
<label for="advcal-line-span-end" data-i18n="calibration.advanced.span_end">Span End:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="calibration.advanced.span_end.hint">Where sampling ends along the edge (0 = start, 1 = end). Together with Span Start, defines the active portion.</small>
<input type="number" id="advcal-line-span-end" min="0" max="1" step="0.01" value="1" onchange="updateCalibrationLine()">
</div>
<div class="form-group">
<div class="label-row">
<label for="advcal-line-border-width" data-i18n="calibration.advanced.border_width">Depth (px):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="calibration.advanced.border_width.hint">How many pixels deep from the edge to sample. Larger values capture more of the screen interior.</small>
<input type="number" id="advcal-line-border-width" min="1" max="100" value="10" onchange="updateCalibrationLine()">
</div>
<div class="form-group" style="display:flex; align-items:end; gap:8px;">
<label class="checkbox-label">
<input type="checkbox" id="advcal-line-reverse" onchange="updateCalibrationLine()">
<span data-i18n="calibration.advanced.reverse">Reverse</span>
</label>
</div>
</div>
<small class="input-hint" style="display:none" data-i18n="calibration.advanced.edge.hint">Which screen edge to sample pixels from</small>
<select id="advcal-line-edge" onchange="updateCalibrationLine()">
<option value="top">Top</option>
<option value="right">Right</option>
<option value="bottom">Bottom</option>
<option value="left">Left</option>
</select>
</div>
<div class="form-group">
<div class="label-row">
<label for="advcal-line-leds" data-i18n="calibration.advanced.led_count">LEDs:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="calibration.advanced.led_count.hint">Number of LEDs mapped to this line</small>
<input type="number" id="advcal-line-leds" min="1" max="1500" value="10" onchange="updateCalibrationLine()">
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 12px; margin-top: 8px;">
<div class="form-group">
<div class="label-row">
<label for="advcal-line-span-start" data-i18n="calibration.advanced.span_start">Span Start:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="calibration.advanced.span_start.hint">Where sampling begins along the edge (0 = start, 1 = end). Use to cover only part of an edge.</small>
<input type="number" id="advcal-line-span-start" min="0" max="1" step="0.01" value="0" onchange="updateCalibrationLine()">
</div>
<div class="form-group">
<div class="label-row">
<label for="advcal-line-span-end" data-i18n="calibration.advanced.span_end">Span End:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="calibration.advanced.span_end.hint">Where sampling ends along the edge (0 = start, 1 = end). Together with Span Start, defines the active portion.</small>
<input type="number" id="advcal-line-span-end" min="0" max="1" step="0.01" value="1" onchange="updateCalibrationLine()">
</div>
<div class="form-group">
<div class="label-row">
<label for="advcal-line-border-width" data-i18n="calibration.advanced.border_width">Depth (px):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="calibration.advanced.border_width.hint">How many pixels deep from the edge to sample. Larger values capture more of the screen interior.</small>
<input type="number" id="advcal-line-border-width" min="1" max="100" value="10" onchange="updateCalibrationLine()">
</div>
<div class="form-group" style="display:flex; align-items:end; gap:8px;">
<label class="checkbox-label">
<input type="checkbox" id="advcal-line-reverse" onchange="updateCalibrationLine()">
<span data-i18n="calibration.advanced.reverse">Reverse</span>
</label>
</div>
</div>
</div>
</section>
<!-- Global strip settings (offset, skip) -->
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; margin-top: 12px;">
<div class="form-group">
<div class="label-row">
<label for="advcal-offset" data-i18n="calibration.offset">LED Offset:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="calibration.offset.hint">Distance from physical LED 0 to the start corner (along strip direction)</small>
<input type="number" id="advcal-offset" min="0" value="0">
<!-- ── 03 · OFFSETS ────────────────────────────────── -->
<section class="ds-section" data-ds-key="offsets" data-ch="amber">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.offsets">Offsets</span>
<span class="ds-section-index" aria-hidden="true">03</span>
</div>
<div class="form-group">
<div class="label-row">
<label for="advcal-skip-start" data-i18n="calibration.skip_start">Skip LEDs (Start):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<div class="ds-section-body">
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px;">
<div class="form-group">
<div class="label-row">
<label for="advcal-offset" data-i18n="calibration.offset">LED Offset:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="calibration.offset.hint">Distance from physical LED 0 to the start corner (along strip direction)</small>
<input type="number" id="advcal-offset" min="0" value="0">
</div>
<div class="form-group">
<div class="label-row">
<label for="advcal-skip-start" data-i18n="calibration.skip_start">Skip LEDs (Start):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="calibration.skip_start.hint">Number of LEDs to turn off at the beginning of the strip (0 = none)</small>
<input type="number" id="advcal-skip-start" min="0" value="0">
</div>
<div class="form-group">
<div class="label-row">
<label for="advcal-skip-end" data-i18n="calibration.skip_end">Skip LEDs (End):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="calibration.skip_end.hint">Number of LEDs to turn off at the end of the strip (0 = none)</small>
<input type="number" id="advcal-skip-end" min="0" value="0">
</div>
</div>
<small class="input-hint" style="display:none" data-i18n="calibration.skip_start.hint">Number of LEDs to turn off at the beginning of the strip (0 = none)</small>
<input type="number" id="advcal-skip-start" min="0" value="0">
</div>
<div class="form-group">
<div class="label-row">
<label for="advcal-skip-end" data-i18n="calibration.skip_end">Skip LEDs (End):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="calibration.skip_end.hint">Number of LEDs to turn off at the end of the strip (0 = none)</small>
<input type="number" id="advcal-skip-end" min="0" value="0">
</div>
</div>
</section>
<div id="advcal-error" class="error-message" style="display: none;"></div>
</div>
@@ -1,4 +1,4 @@
<!-- Login Modal -->
<!-- Login Modal — sectioned rack-panel layout. -->
<div id="api-key-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="api-key-modal-title">
<div class="modal-content">
<div class="modal-header">
@@ -10,25 +10,37 @@
<p class="modal-description" data-i18n="auth.message">
Please enter your API key to authenticate and access the LED Grab.
</p>
<div class="form-group">
<div class="label-row">
<label for="api-key-input" data-i18n="auth.label">API Key:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<!-- ── 01 · AUTH ───────────────────────────────────── -->
<section class="ds-section" data-ds-key="auth" data-ch="cyan">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.auth">Auth</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<small class="input-hint" style="display:none" data-i18n="auth.hint">Your API key will be stored securely in your browser's local storage.</small>
<div class="password-input-wrapper">
<input
type="password"
id="api-key-input"
data-i18n-placeholder="auth.placeholder"
placeholder="Enter your API key..."
autocomplete="off"
>
<button type="button" class="password-toggle" onclick="togglePasswordVisibility()" title="Toggle password visibility" data-i18n-title="auth.toggle_password" aria-label="Toggle password visibility" data-i18n-aria-label="auth.toggle_password">
<svg class="icon" viewBox="0 0 24 24"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg>
</button>
<div class="ds-section-body">
<div class="form-group">
<div class="label-row">
<label for="api-key-input" data-i18n="auth.label">API Key:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="auth.hint">Your API key will be stored securely in your browser's local storage.</small>
<div class="password-input-wrapper">
<input
type="password"
id="api-key-input"
data-i18n-placeholder="auth.placeholder"
placeholder="Enter your API key..."
autocomplete="off"
>
<button type="button" class="password-toggle" onclick="togglePasswordVisibility()" title="Toggle password visibility" data-i18n-title="auth.toggle_password" aria-label="Toggle password visibility" data-i18n-aria-label="auth.toggle_password">
<svg class="icon" viewBox="0 0 24 24"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg>
</button>
</div>
</div>
</div>
</div>
</section>
<div id="api-key-error" class="error-message" style="display: none;"></div>
</div>
<div class="modal-footer">
@@ -1,3 +1,5 @@
<!-- Asset metadata editor modal — sectioned rack-panel.
Single Identity section; metadata-only editor by design. -->
<div id="asset-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="asset-editor-title">
<div class="modal-content">
<div class="modal-header">
@@ -8,24 +10,34 @@
<input type="hidden" id="asset-editor-id">
<div id="asset-editor-error" class="modal-error" style="display:none"></div>
<div class="form-group">
<div class="label-row">
<label for="asset-editor-name" data-i18n="asset.name">Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<!-- ── 01 · IDENTITY ───────────────────────────────── -->
<section class="ds-section" data-ds-key="identity" data-ch="signal">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.identity">Identity</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<small class="input-hint" style="display:none" data-i18n="asset.name.hint">Display name for this asset.</small>
<input type="text" id="asset-editor-name" required maxlength="100">
<div id="asset-editor-tags-container"></div>
</div>
<div class="ds-section-body">
<div class="form-group ds-name-group">
<div class="label-row">
<label for="asset-editor-name" data-i18n="asset.name">Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="asset.name.hint">Display name for this asset.</small>
<input type="text" id="asset-editor-name" required maxlength="100">
<div id="asset-editor-tags-container"></div>
</div>
<div class="form-group">
<div class="label-row">
<label for="asset-editor-description" data-i18n="asset.description">Description:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<div class="form-group">
<div class="label-row">
<label for="asset-editor-description" data-i18n="asset.description">Description:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="asset.description.hint">Optional description for this asset.</small>
<input type="text" id="asset-editor-description" maxlength="500">
</div>
</div>
<small class="input-hint" style="display:none" data-i18n="asset.description.hint">Optional description for this asset.</small>
<input type="text" id="asset-editor-description" maxlength="500">
</div>
</section>
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeAssetEditorModal()" title="Cancel" data-i18n-title="settings.button.cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
@@ -1,3 +1,5 @@
<!-- Asset upload modal — sectioned rack-panel.
Identity collects metadata; File holds the dropzone. -->
<div id="asset-upload-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="asset-upload-title">
<div class="modal-content">
<div class="modal-header">
@@ -7,52 +9,72 @@
<div class="modal-body">
<div id="asset-upload-error" class="modal-error" style="display:none"></div>
<div class="form-group">
<div class="label-row">
<label for="asset-upload-name" data-i18n="asset.name">Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<!-- ── 01 · IDENTITY ───────────────────────────────── -->
<section class="ds-section" data-ds-key="identity" data-ch="signal">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.identity">Identity</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<small class="input-hint" style="display:none" data-i18n="asset.name.hint">Display name for this asset.</small>
<input type="text" id="asset-upload-name" maxlength="100">
<div id="asset-upload-tags-container"></div>
</div>
<div class="ds-section-body">
<div class="form-group ds-name-group">
<div class="label-row">
<label for="asset-upload-name" data-i18n="asset.name">Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="asset.name.hint">Display name for this asset.</small>
<input type="text" id="asset-upload-name" maxlength="100">
<div id="asset-upload-tags-container"></div>
</div>
<div class="form-group">
<div class="label-row">
<label for="asset-upload-description" data-i18n="asset.description">Description:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<div class="form-group">
<div class="label-row">
<label for="asset-upload-description" data-i18n="asset.description">Description:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="asset.description.hint">Optional description for this asset.</small>
<input type="text" id="asset-upload-description" maxlength="500">
</div>
</div>
<small class="input-hint" style="display:none" data-i18n="asset.description.hint">Optional description for this asset.</small>
<input type="text" id="asset-upload-description" maxlength="500">
</div>
</section>
<div class="form-group">
<div class="label-row">
<label data-i18n="asset.file">File:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<!-- ── 02 · FILE ───────────────────────────────────── -->
<section class="ds-section" data-ds-key="file" data-ch="cyan">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.file">File</span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div>
<small class="input-hint" style="display:none" data-i18n="asset.file.hint">Select a file to upload (sound, image, video, or other).</small>
<input type="file" id="asset-upload-file" required hidden>
<div id="asset-upload-dropzone" class="file-dropzone" tabindex="0" role="button"
data-i18n-aria-label="asset.drop_or_browse">
<div class="file-dropzone-icon">
<svg class="icon" viewBox="0 0 24 24" style="width:32px;height:32px">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/>
<path d="M14 2v4a2 2 0 0 0 2 2h4"/>
<path d="M12 12v6"/><path d="m15 15-3-3-3 3"/>
</svg>
</div>
<div class="file-dropzone-text">
<span class="file-dropzone-label" data-i18n="asset.drop_or_browse">Drop file here or click to browse</span>
</div>
<div id="asset-upload-file-info" class="file-dropzone-info" style="display:none">
<span id="asset-upload-file-name" class="file-dropzone-filename"></span>
<span id="asset-upload-file-size" class="file-dropzone-filesize"></span>
<button type="button" class="file-dropzone-remove" id="asset-upload-file-remove"
title="Remove" data-i18n-title="common.remove">&#x2715;</button>
<div class="ds-section-body">
<div class="form-group">
<div class="label-row">
<label data-i18n="asset.file">File:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="asset.file.hint">Select a file to upload (sound, image, video, or other).</small>
<input type="file" id="asset-upload-file" required hidden>
<div id="asset-upload-dropzone" class="file-dropzone" tabindex="0" role="button"
data-i18n-aria-label="asset.drop_or_browse">
<div class="file-dropzone-icon">
<svg class="icon" viewBox="0 0 24 24" style="width:32px;height:32px">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/>
<path d="M14 2v4a2 2 0 0 0 2 2h4"/>
<path d="M12 12v6"/><path d="m15 15-3-3-3 3"/>
</svg>
</div>
<div class="file-dropzone-text">
<span class="file-dropzone-label" data-i18n="asset.drop_or_browse">Drop file here or click to browse</span>
</div>
<div id="asset-upload-file-info" class="file-dropzone-info" style="display:none">
<span id="asset-upload-file-name" class="file-dropzone-filename"></span>
<span id="asset-upload-file-size" class="file-dropzone-filesize"></span>
<button type="button" class="file-dropzone-remove" id="asset-upload-file-remove"
title="Remove" data-i18n-title="common.remove">&#x2715;</button>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeAssetUploadModal()" title="Cancel" data-i18n-title="settings.button.cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
@@ -1,4 +1,4 @@
<!-- Audio Processing Template Editor Modal -->
<!-- Audio Processing Template Editor Modal — sectioned rack-panel. -->
<div id="apt-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="apt-modal-title">
<div class="modal-content">
<div class="modal-header">
@@ -9,34 +9,48 @@
<input type="hidden" id="apt-id">
<div id="apt-error" class="modal-error" style="display:none"></div>
<div class="form-group">
<div class="label-row">
<label for="apt-name" data-i18n="audio_processing.name">Template Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<!-- ── 01 · IDENTITY ───────────────────────────────── -->
<section class="ds-section" data-ds-key="identity" data-ch="signal">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.identity">Identity</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<small class="input-hint" style="display:none" data-i18n="audio_processing.name.hint">A descriptive name for this audio processing template</small>
<input type="text" id="apt-name" data-i18n-placeholder="audio_processing.name_placeholder" placeholder="My Audio Processing Template" required>
<div id="apt-tags-container"></div>
</div>
<div class="ds-section-body">
<div class="form-group ds-name-group">
<label for="apt-name" data-i18n="audio_processing.name">Template Name:</label>
<input type="text" id="apt-name" data-i18n-placeholder="audio_processing.name_placeholder" placeholder="My Audio Processing Template" required>
<div id="apt-tags-container"></div>
</div>
<!-- Dynamic audio filter list -->
<div id="apt-filter-list" class="pp-filter-list"></div>
<!-- Add filter control -->
<div class="pp-add-filter-row">
<select id="apt-add-filter-select" class="pp-add-filter-select">
<option value="" data-i18n="filters.select_type">Select filter type...</option>
</select>
</div>
<div class="form-group">
<div class="label-row">
<label for="apt-description" data-i18n="audio_processing.description_label">Description (optional):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<div class="form-group">
<div class="label-row">
<label for="apt-description" data-i18n="audio_processing.description_label">Description (optional):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="audio_processing.description.hint">Describe what this template does</small>
<input type="text" id="apt-description" data-i18n-placeholder="audio_processing.description_placeholder" placeholder="Describe this template...">
</div>
</div>
<small class="input-hint" style="display:none" data-i18n="audio_processing.description.hint">Describe what this template does</small>
<input type="text" id="apt-description" data-i18n-placeholder="audio_processing.description_placeholder" placeholder="Describe this template...">
</div>
</section>
<!-- ── 02 · FILTERS ────────────────────────────────── -->
<section class="ds-section" data-ds-key="filters" data-ch="cyan">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.filters">Filters</span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div>
<div class="ds-section-body">
<div id="apt-filter-list" class="pp-filter-list"></div>
<div class="pp-add-filter-row">
<select id="apt-add-filter-select" class="pp-add-filter-select">
<option value="" data-i18n="filters.select_type">Select filter type...</option>
</select>
</div>
</div>
</section>
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeAudioProcessingTemplateModal()" title="Cancel" data-i18n-title="settings.button.cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
@@ -1,4 +1,5 @@
<!-- Audio Source Editor Modal -->
<!-- Audio Source Editor Modal — sectioned rack-panel. The capture vs.
processed inner subsections keep their toggle logic untouched. -->
<div id="audio-source-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="audio-source-modal-title">
<div class="modal-content">
<div class="modal-header">
@@ -11,82 +12,95 @@
<div id="audio-source-error" class="modal-error" style="display: none;"></div>
<!-- Name -->
<div class="form-group">
<div class="label-row">
<label for="audio-source-name" data-i18n="audio_source.name">Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
<!-- ── 01 · IDENTITY ───────────────────────────────── -->
<section class="ds-section" data-ds-key="identity" data-ch="signal">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.identity">Identity</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<small class="input-hint" style="display:none" data-i18n="audio_source.name.hint">A descriptive name for this audio source</small>
<input type="text" id="audio-source-name" data-i18n-placeholder="audio_source.name.placeholder" placeholder="System Audio" required>
<div id="audio-source-tags-container"></div>
</div>
<div class="ds-section-body">
<div class="form-group ds-name-group">
<label for="audio-source-name" data-i18n="audio_source.name">Name:</label>
<input type="text" id="audio-source-name" data-i18n-placeholder="audio_source.name.placeholder" placeholder="System Audio" required>
<div id="audio-source-tags-container"></div>
</div>
<div class="form-group">
<div class="label-row">
<label for="audio-source-description" data-i18n="audio_source.description">Description (optional):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="audio_source.description.hint">Optional notes about this audio source</small>
<input type="text" id="audio-source-description" data-i18n-placeholder="audio_source.description.placeholder" placeholder="Describe this audio source...">
</div>
</div>
</section>
<!-- Type (hidden — determined by which add button was clicked) -->
<input type="hidden" id="audio-source-type" value="capture">
<!-- Capture fields -->
<div id="audio-source-capture-section">
<div class="form-group">
<div class="label-row">
<label for="audio-source-audio-template" data-i18n="audio_source.audio_template">Audio Template:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="audio_source.audio_template.hint">Audio capture template that defines which engine and settings to use for this device</small>
<select id="audio-source-audio-template">
<!-- populated dynamically -->
</select>
<!-- ── 02 · SOURCE ─────────────────────────────────── -->
<section class="ds-section" data-ds-key="source" data-ch="cyan">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.source">Source</span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div>
<div class="ds-section-body">
<!-- Capture fields -->
<div id="audio-source-capture-section">
<div class="form-group">
<div class="label-row">
<label for="audio-source-audio-template" data-i18n="audio_source.audio_template">Audio Template:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="audio_source.audio_template.hint">Audio capture template that defines which engine and settings to use for this device</small>
<select id="audio-source-audio-template">
<!-- populated dynamically -->
</select>
</div>
<div class="form-group">
<div class="label-row">
<label for="audio-source-device" data-i18n="audio_source.device">Audio Device:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
<div class="form-group">
<div class="label-row">
<label for="audio-source-device" data-i18n="audio_source.device">Audio Device:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="audio_source.device.hint">Audio input source. Loopback devices capture system audio output; input devices capture microphone or line-in.</small>
<div class="select-with-action">
<select id="audio-source-device">
<!-- populated dynamically -->
</select>
<button type="button" class="btn btn-secondary btn-sm" id="audio-source-refresh-devices" onclick="refreshAudioDevices()" data-i18n-title="audio_source.refresh_devices" title="Refresh devices">&#x21BB;</button>
</div>
</div>
</div>
<small class="input-hint" style="display:none" data-i18n="audio_source.device.hint">Audio input source. Loopback devices capture system audio output; input devices capture microphone or line-in.</small>
<div class="select-with-action">
<select id="audio-source-device">
<!-- populated dynamically -->
</select>
<button type="button" class="btn btn-secondary btn-sm" id="audio-source-refresh-devices" onclick="refreshAudioDevices()" data-i18n-title="audio_source.refresh_devices" title="Refresh devices">&#x21BB;</button>
</div>
</div>
</div>
<!-- Processed fields -->
<div id="audio-source-processed-section" style="display:none">
<div class="form-group">
<div class="label-row">
<label for="audio-source-parent" data-i18n="audio_source.parent">Input Audio Source:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="audio_source.parent.hint">Audio source to apply processing filters to</small>
<select id="audio-source-parent">
<!-- populated dynamically with audio sources -->
</select>
</div>
<!-- Processed fields -->
<div id="audio-source-processed-section" style="display:none">
<div class="form-group">
<div class="label-row">
<label for="audio-source-parent" data-i18n="audio_source.parent">Input Audio Source:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="audio_source.parent.hint">Audio source to apply processing filters to</small>
<select id="audio-source-parent">
<!-- populated dynamically with audio sources -->
</select>
</div>
<div class="form-group">
<div class="label-row">
<label for="audio-source-processing-template" data-i18n="audio_source.processing_template">Processing Template:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
<div class="form-group">
<div class="label-row">
<label for="audio-source-processing-template" data-i18n="audio_source.processing_template">Processing Template:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="audio_source.processing_template.hint">Audio processing template with filters to apply to the input source</small>
<select id="audio-source-processing-template">
<!-- populated dynamically -->
</select>
</div>
</div>
<small class="input-hint" style="display:none" data-i18n="audio_source.processing_template.hint">Audio processing template with filters to apply to the input source</small>
<select id="audio-source-processing-template">
<!-- populated dynamically -->
</select>
</div>
</div>
<!-- Description -->
<div class="form-group">
<div class="label-row">
<label for="audio-source-description" data-i18n="audio_source.description">Description (optional):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="audio_source.description.hint">Optional notes about this audio source</small>
<input type="text" id="audio-source-description" data-i18n-placeholder="audio_source.description.placeholder" placeholder="Describe this audio source...">
</div>
</section>
</form>
</div>
@@ -1,4 +1,4 @@
<!-- Audio Template Modal -->
<!-- Audio Template Modal — sectioned rack-panel layout. -->
<div id="audio-template-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="audio-template-modal-title">
<div class="modal-content">
<div class="modal-header">
@@ -8,32 +8,52 @@
<div class="modal-body">
<input type="hidden" id="audio-template-id">
<form id="audio-template-form">
<div class="form-group">
<label for="audio-template-name" data-i18n="audio_template.name">Template Name:</label>
<input type="text" id="audio-template-name" data-i18n-placeholder="audio_template.name.placeholder" placeholder="My Audio Template" required>
<div id="audio-template-tags-container"></div>
</div>
<div class="form-group">
<label for="audio-template-description" data-i18n="audio_template.description.label">Description (optional):</label>
<input type="text" id="audio-template-description" data-i18n-placeholder="audio_template.description.placeholder" placeholder="Describe this template..." maxlength="500">
</div>
<div class="form-group">
<div class="label-row">
<label for="audio-template-engine" data-i18n="audio_template.engine">Audio Engine:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<!-- ── 01 · IDENTITY ───────────────────────────────── -->
<section class="ds-section" data-ds-key="identity" data-ch="signal">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.identity">Identity</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<small class="input-hint" style="display:none" data-i18n="audio_template.engine.hint">Select the audio capture backend to use</small>
<select id="audio-template-engine" onchange="onAudioEngineChange()" required>
</select>
<small id="audio-engine-availability-hint" class="form-hint" style="display: none;"></small>
</div>
<div class="ds-section-body">
<div class="form-group ds-name-group">
<label for="audio-template-name" data-i18n="audio_template.name">Template Name:</label>
<input type="text" id="audio-template-name" data-i18n-placeholder="audio_template.name.placeholder" placeholder="My Audio Template" required>
<div id="audio-template-tags-container"></div>
</div>
<div id="audio-engine-config-section" style="display: none;">
<h3 data-i18n="audio_template.config">Configuration</h3>
<div id="audio-engine-config-fields"></div>
</div>
<div class="form-group">
<label for="audio-template-description" data-i18n="audio_template.description.label">Description (optional):</label>
<input type="text" id="audio-template-description" data-i18n-placeholder="audio_template.description.placeholder" placeholder="Describe this template..." maxlength="500">
</div>
</div>
</section>
<!-- ── 02 · ENGINE ─────────────────────────────────── -->
<section class="ds-section" data-ds-key="engine" data-ch="cyan">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.engine">Engine</span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div>
<div class="ds-section-body">
<div class="form-group">
<div class="label-row">
<label for="audio-template-engine" data-i18n="audio_template.engine">Audio Engine:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="audio_template.engine.hint">Select the audio capture backend to use</small>
<select id="audio-template-engine" onchange="onAudioEngineChange()" required>
</select>
<small id="audio-engine-availability-hint" class="form-hint" style="display: none;"></small>
</div>
<div id="audio-engine-config-section" style="display: none;">
<div id="audio-engine-config-fields"></div>
</div>
</div>
</section>
<div id="audio-template-error" class="error-message" style="display: none;"></div>
</form>
@@ -1,4 +1,12 @@
<!-- Automation Editor Modal -->
<!-- Automation Editor Modal — sectioned rack-panel layout, matches the
settings-modal-redesign mockup vocabulary. The four .ds-section
wrappers map the automation flow as a left-to-right timeline:
Identity (signal) → who is it
Triggers (cyan) → when does it fire
Action (amber) → what does it do
Deactivation (violet) → what happens when it stops
All inner element IDs are preserved so automations.ts and the
dirty-check snapshotValues() keep working untouched. -->
<div id="automation-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="automation-editor-title">
<div class="modal-content">
<div class="modal-header">
@@ -9,82 +17,120 @@
<form id="automation-editor-form">
<input type="hidden" id="automation-editor-id">
<div class="form-group">
<div class="label-row">
<label for="automation-editor-name" data-i18n="automations.name">Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<!-- ── 01 · IDENTITY ───────────────────────────────── -->
<section class="ds-section" data-ds-key="identity" data-ch="signal">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.identity">Identity</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<small class="input-hint" style="display:none" data-i18n="automations.name.hint">A descriptive name for this automation</small>
<input type="text" id="automation-editor-name" data-i18n-placeholder="automations.name.placeholder" placeholder="My Automation" required>
<div id="automation-tags-container"></div>
</div>
<div class="ds-section-body">
<div class="form-group ds-name-group">
<label for="automation-editor-name" data-i18n="automations.name">Name:</label>
<input type="text" id="automation-editor-name" data-i18n-placeholder="automations.name.placeholder" placeholder="My Automation" required>
<div id="automation-tags-container"></div>
</div>
<div class="form-group settings-toggle-group">
<div class="label-row">
<label data-i18n="automations.enabled">Enabled:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<div class="ds-toggle-row">
<div class="ds-toggle-text">
<div class="label-row">
<label for="automation-editor-enabled" data-i18n="automations.enabled">Enabled:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="automations.enabled.hint">Disabled automations won't activate even when rules are met</small>
</div>
<label class="settings-toggle">
<input type="checkbox" id="automation-editor-enabled" checked>
<span class="settings-toggle-slider"></span>
</label>
</div>
</div>
<small class="input-hint" style="display:none" data-i18n="automations.enabled.hint">Disabled automations won't activate even when rules are met</small>
<label class="settings-toggle">
<input type="checkbox" id="automation-editor-enabled" checked>
<span class="settings-toggle-slider"></span>
</label>
</div>
</section>
<div class="form-group">
<div class="label-row">
<label for="automation-editor-logic" data-i18n="automations.rule_logic">Rule Logic:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<!-- ── 02 · TRIGGERS ───────────────────────────────── -->
<section class="ds-section" data-ds-key="triggers" data-ch="cyan">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="automations.section.triggers">Triggers</span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div>
<small class="input-hint" style="display:none" data-i18n="automations.rule_logic.hint">How multiple rules are combined: ANY (OR) or ALL (AND)</small>
<select id="automation-editor-logic">
<option value="or" data-i18n="automations.rule_logic.or">Any rule (OR)</option>
<option value="and" data-i18n="automations.rule_logic.and">All rules (AND)</option>
</select>
</div>
<div class="ds-section-body">
<div class="form-group">
<div class="label-row">
<label for="automation-editor-logic" data-i18n="automations.rule_logic">Rule Logic:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="automations.rule_logic.hint">How multiple rules are combined: ANY (OR) or ALL (AND)</small>
<select id="automation-editor-logic">
<option value="or" data-i18n="automations.rule_logic.or">Any rule (OR)</option>
<option value="and" data-i18n="automations.rule_logic.and">All rules (AND)</option>
</select>
</div>
<div class="form-group">
<div class="label-row">
<label data-i18n="automations.rules">Rules:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<div class="form-group">
<div class="label-row">
<label data-i18n="automations.rules">Rules:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="automations.rules.hint">Rules that determine when this automation activates</small>
<div id="automation-rules-list"></div>
<button type="button" class="btn btn-secondary btn-sm" onclick="addAutomationRule()" style="margin-top: 6px;">
+ <span data-i18n="automations.rules.add">Add Rule</span>
</button>
</div>
</div>
<small class="input-hint" style="display:none" data-i18n="automations.rules.hint">Rules that determine when this automation activates</small>
<div id="automation-rules-list"></div>
<button type="button" class="btn btn-secondary btn-sm" onclick="addAutomationRule()" style="margin-top: 6px;">
+ <span data-i18n="automations.rules.add">Add Rule</span>
</button>
</div>
</section>
<div class="form-group">
<div class="label-row">
<label for="automation-scene-id" data-i18n="automations.scene">Scene:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<!-- ── 03 · ACTION ─────────────────────────────────── -->
<section class="ds-section" data-ds-key="action" data-ch="amber">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="automations.section.action">Action</span>
<span class="ds-section-index" aria-hidden="true">03</span>
</div>
<small class="input-hint" style="display:none" data-i18n="automations.scene.hint">Scene preset to activate when rules are met</small>
<select id="automation-scene-id"></select>
</div>
<div class="ds-section-body">
<div class="form-group">
<div class="label-row">
<label for="automation-scene-id" data-i18n="automations.scene">Scene:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="automations.scene.hint">Scene preset to activate when rules are met</small>
<select id="automation-scene-id"></select>
</div>
</div>
</section>
<div class="form-group">
<div class="label-row">
<label for="automation-deactivation-mode" data-i18n="automations.deactivation_mode">Deactivation:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<!-- ── 04 · DEACTIVATION ───────────────────────────── -->
<section class="ds-section" data-ds-key="deactivation" data-ch="violet">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="automations.section.deactivation">Deactivation</span>
<span class="ds-section-index" aria-hidden="true">04</span>
</div>
<small class="input-hint" style="display:none" data-i18n="automations.deactivation_mode.hint">What happens when rules stop matching</small>
<select id="automation-deactivation-mode">
<option value="none" data-i18n="automations.deactivation_mode.none">None — keep current state</option>
<option value="revert" data-i18n="automations.deactivation_mode.revert">Revert to previous state</option>
<option value="fallback_scene" data-i18n="automations.deactivation_mode.fallback_scene">Activate fallback scene</option>
</select>
</div>
<div class="ds-section-body">
<div class="form-group">
<div class="label-row">
<label for="automation-deactivation-mode" data-i18n="automations.deactivation_mode">Deactivation:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="automations.deactivation_mode.hint">What happens when rules stop matching</small>
<select id="automation-deactivation-mode">
<option value="none" data-i18n="automations.deactivation_mode.none">None — keep current state</option>
<option value="revert" data-i18n="automations.deactivation_mode.revert">Revert to previous state</option>
<option value="fallback_scene" data-i18n="automations.deactivation_mode.fallback_scene">Activate fallback scene</option>
</select>
</div>
<div class="form-group" id="automation-fallback-scene-group" style="display:none">
<div class="label-row">
<label for="automation-fallback-scene-id" data-i18n="automations.deactivation_scene">Fallback Scene:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<div class="form-group" id="automation-fallback-scene-group" style="display:none">
<div class="label-row">
<label for="automation-fallback-scene-id" data-i18n="automations.deactivation_scene">Fallback Scene:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="automations.deactivation_scene.hint">Scene to activate when this automation deactivates</small>
<select id="automation-fallback-scene-id"></select>
</div>
</div>
<small class="input-hint" style="display:none" data-i18n="automations.deactivation_scene.hint">Scene to activate when this automation deactivates</small>
<select id="automation-fallback-scene-id"></select>
</div>
</section>
<div id="automation-editor-error" class="error-message" style="display: none;"></div>
</form>
@@ -1,4 +1,9 @@
<!-- Calibration Modal -->
<!-- Calibration Modal — sectioned rack-panel layout. Channels:
Test Setup (cyan) — test device + LED count (CSS calibration mode)
Layout (signal) — visual edge editor with screen + edges
Offsets (amber) — LED offset + skip start + skip end
The visual layout panel is the centerpiece, hence signal channel.
All inner element IDs and the canvas overlay are preserved. -->
<div id="calibration-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="calibration-modal-title">
<div class="modal-content" style="max-width: 700px;">
<div class="modal-header">
@@ -10,150 +15,191 @@
<input type="hidden" id="calibration-device-id">
<input type="hidden" id="calibration-css-id">
<input type="hidden" id="calibration-css-source-type">
<!-- Device picker shown in CSS calibration mode for edge testing -->
<div id="calibration-css-test-group" class="form-group" style="display:none; margin-bottom: 12px; padding: 0 4px;">
<div class="label-row">
<label for="calibration-test-device" data-i18n="color_strip.test_device">Test on Device:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.test_device.hint">Select a device to send test pixels to when clicking edge toggles</small>
<select id="calibration-test-device"></select>
</div>
<!-- LED count input (CSS calibration mode only) -->
<div id="cal-css-led-count-group" class="form-group" style="display:none; margin-bottom: 12px; padding: 0 4px;">
<div class="label-row">
<label for="cal-css-led-count" data-i18n="color_strip.led_count">LED Count:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.led_count.hint">Total number of LEDs on the physical strip. Set to 0 to use the sum from calibration. If your strip has LEDs behind the TV that are not mapped to screen edges, set the exact count here and they will be filled with black.</small>
<input type="number" id="cal-css-led-count" min="0" max="1500" step="1" value="0" oninput="updateCalibrationPreview()">
</div>
<!-- Interactive Preview with integrated LED inputs and test toggles -->
<div style="margin-bottom: 12px; padding: 0 24px;">
<div class="calibration-preview">
<!-- Screen with direction toggle, total LEDs, and offset -->
<div class="preview-screen">
<button type="button" class="direction-toggle" onclick="toggleDirection()" title="Toggle direction">
<span id="direction-icon"><svg class="icon" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg></span> <span id="direction-label">CW</span>
</button>
<div class="preview-screen-total" onclick="toggleEdgeInputs()" title="Toggle edge LED inputs"><span id="cal-total-leds-inline">0</span> / <span id="cal-device-led-count-inline">0</span></div>
<div class="preview-screen-border-width">
<label for="cal-border-width" data-i18n="calibration.border_width">Border (px):</label>
<input type="number" id="cal-border-width" min="1" max="100" value="10">
<!-- ── 01 · TEST SETUP (CSS calibration mode only) ─── -->
<section class="ds-section" data-ds-key="test-setup" data-ch="cyan" id="calibration-test-setup-section" style="display:none">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.test_setup">Test Setup</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<div class="ds-section-body">
<div id="calibration-css-test-group" class="form-group" style="display:none">
<div class="label-row">
<label for="calibration-test-device" data-i18n="color_strip.test_device">Test on Device:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<button id="calibration-overlay-btn" class="calibration-overlay-toggle" onclick="toggleCalibrationOverlay()" data-i18n-title="overlay.button.show" title="Show overlay visualization" style="display:none">
<svg class="icon" viewBox="0 0 24 24"><path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/></svg> <span data-i18n="calibration.overlay_toggle">Overlay</span>
</button>
<small class="input-hint" style="display:none" data-i18n="color_strip.test_device.hint">Select a device to send test pixels to when clicking edge toggles</small>
<select id="calibration-test-device"></select>
</div>
<!-- Edge bars with span controls and LED count inputs -->
<div class="preview-edge edge-top">
<div class="edge-span-bar" data-edge="top">
<div class="edge-span-handle edge-span-handle-start" data-edge="top" data-handle="start"></div>
<div class="edge-span-handle edge-span-handle-end" data-edge="top" data-handle="end"></div>
<div id="cal-css-led-count-group" class="form-group" style="display:none">
<div class="label-row">
<label for="cal-css-led-count" data-i18n="color_strip.led_count">LED Count:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<input type="number" id="cal-top-leds" class="edge-led-input" min="0" value="0"
oninput="updateCalibrationPreview()">
<small class="input-hint" style="display:none" data-i18n="color_strip.led_count.hint">Total number of LEDs on the physical strip. Set to 0 to use the sum from calibration. If your strip has LEDs behind the TV that are not mapped to screen edges, set the exact count here and they will be filled with black.</small>
<input type="number" id="cal-css-led-count" min="0" max="1500" step="1" value="0" oninput="updateCalibrationPreview()">
</div>
<div class="preview-edge edge-right">
<div class="edge-span-bar" data-edge="right">
<div class="edge-span-handle edge-span-handle-start" data-edge="right" data-handle="start"></div>
<div class="edge-span-handle edge-span-handle-end" data-edge="right" data-handle="end"></div>
</div>
</section>
<!-- ── 02 · LAYOUT ─────────────────────────────────── -->
<section class="ds-section" data-ds-key="layout" data-ch="signal">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.layout">Layout</span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div>
<div class="ds-section-body">
<!-- Interactive Preview with integrated LED inputs and test toggles -->
<div style="padding: 0 10px;">
<div class="calibration-preview">
<!-- Screen with direction toggle, total LEDs, and offset -->
<div class="preview-screen">
<button type="button" class="direction-toggle" onclick="toggleDirection()" title="Toggle direction">
<span id="direction-icon"><svg class="icon" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg></span> <span id="direction-label">CW</span>
</button>
<div class="preview-screen-total" onclick="toggleEdgeInputs()" title="Toggle edge LED inputs"><span id="cal-total-leds-inline">0</span> / <span id="cal-device-led-count-inline">0</span></div>
<div class="preview-screen-border-width">
<label for="cal-border-width" data-i18n="calibration.border_width">Border (px):</label>
<input type="number" id="cal-border-width" min="1" max="100" value="10">
</div>
<button id="calibration-overlay-btn" class="calibration-overlay-toggle" onclick="toggleCalibrationOverlay()" data-i18n-title="overlay.button.show" title="Show overlay visualization" style="display:none">
<svg class="icon" viewBox="0 0 24 24"><path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/></svg> <span data-i18n="calibration.overlay_toggle">Overlay</span>
</button>
</div>
<!-- Edge bars with span controls and LED count inputs -->
<div class="preview-edge edge-top">
<div class="edge-span-bar" data-edge="top">
<div class="edge-span-handle edge-span-handle-start" data-edge="top" data-handle="start"></div>
<div class="edge-span-handle edge-span-handle-end" data-edge="top" data-handle="end"></div>
</div>
<input type="number" id="cal-top-leds" class="edge-led-input" min="0" value="0"
oninput="updateCalibrationPreview()">
</div>
<div class="preview-edge edge-right">
<div class="edge-span-bar" data-edge="right">
<div class="edge-span-handle edge-span-handle-start" data-edge="right" data-handle="start"></div>
<div class="edge-span-handle edge-span-handle-end" data-edge="right" data-handle="end"></div>
</div>
<input type="number" id="cal-right-leds" class="edge-led-input" min="0" value="0"
oninput="updateCalibrationPreview()">
</div>
<div class="preview-edge edge-bottom">
<div class="edge-span-bar" data-edge="bottom">
<div class="edge-span-handle edge-span-handle-start" data-edge="bottom" data-handle="start"></div>
<div class="edge-span-handle edge-span-handle-end" data-edge="bottom" data-handle="end"></div>
</div>
<input type="number" id="cal-bottom-leds" class="edge-led-input" min="0" value="0"
oninput="updateCalibrationPreview()">
</div>
<div class="preview-edge edge-left">
<div class="edge-span-bar" data-edge="left">
<div class="edge-span-handle edge-span-handle-start" data-edge="left" data-handle="start"></div>
<div class="edge-span-handle edge-span-handle-end" data-edge="left" data-handle="end"></div>
</div>
<input type="number" id="cal-left-leds" class="edge-led-input" min="0" value="0"
oninput="updateCalibrationPreview()">
</div>
<!-- Edge test toggle zones (outside container border, in tick area) -->
<button type="button" class="edge-toggle toggle-top" onclick="toggleTestEdge('top')"></button>
<button type="button" class="edge-toggle toggle-right" onclick="toggleTestEdge('right')"></button>
<button type="button" class="edge-toggle toggle-bottom" onclick="toggleTestEdge('bottom')"></button>
<button type="button" class="edge-toggle toggle-left" onclick="toggleTestEdge('left')"></button>
<!-- Corner start position buttons -->
<button type="button" class="preview-corner corner-top-left" onclick="setStartPosition('top_left')"></button>
<button type="button" class="preview-corner corner-top-right" onclick="setStartPosition('top_right')"></button>
<button type="button" class="preview-corner corner-bottom-left" onclick="setStartPosition('bottom_left')"></button>
<button type="button" class="preview-corner corner-bottom-right" onclick="setStartPosition('bottom_right')"></button>
<!-- Canvas overlay for ticks, arrows, start label -->
<canvas id="calibration-preview-canvas"></canvas>
</div>
<input type="number" id="cal-right-leds" class="edge-led-input" min="0" value="0"
oninput="updateCalibrationPreview()">
</div>
<div class="preview-edge edge-bottom">
<div class="edge-span-bar" data-edge="bottom">
<div class="edge-span-handle edge-span-handle-start" data-edge="bottom" data-handle="start"></div>
<div class="edge-span-handle edge-span-handle-end" data-edge="bottom" data-handle="end"></div>
<!-- Hidden selects (used by saveCalibration) -->
<div style="display: none;">
<select id="cal-start-position">
<option value="bottom_left">Bottom Left</option>
<option value="bottom_right">Bottom Right</option>
<option value="top_left">Top Left</option>
<option value="top_right">Top Right</option>
</select>
<select id="cal-layout">
<option value="clockwise">Clockwise</option>
<option value="counterclockwise">Counterclockwise</option>
</select>
</div>
</div>
</section>
<!-- ── 03 · OFFSETS ────────────────────────────────── -->
<section class="ds-section" data-ds-key="offsets" data-ch="amber">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.offsets">Offsets</span>
<span class="ds-section-index" aria-hidden="true">03</span>
</div>
<div class="ds-section-body">
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px;">
<div class="form-group">
<div class="label-row">
<label for="cal-offset" data-i18n="calibration.offset">LED Offset:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="calibration.offset.hint">Distance from physical LED 0 to the start corner (along strip direction)</small>
<input type="number" id="cal-offset" min="0" value="0" oninput="updateOffsetSkipLock(); updateCalibrationPreview()">
</div>
<input type="number" id="cal-bottom-leds" class="edge-led-input" min="0" value="0"
oninput="updateCalibrationPreview()">
</div>
<div class="preview-edge edge-left">
<div class="edge-span-bar" data-edge="left">
<div class="edge-span-handle edge-span-handle-start" data-edge="left" data-handle="start"></div>
<div class="edge-span-handle edge-span-handle-end" data-edge="left" data-handle="end"></div>
<div class="form-group">
<div class="label-row">
<label for="cal-skip-start" data-i18n="calibration.skip_start">Skip LEDs (Start):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="calibration.skip_start.hint">Number of LEDs to turn off at the beginning of the strip (0 = none)</small>
<input type="number" id="cal-skip-start" min="0" value="0" oninput="updateOffsetSkipLock(); updateCalibrationPreview()">
</div>
<div class="form-group">
<div class="label-row">
<label for="cal-skip-end" data-i18n="calibration.skip_end">Skip LEDs (End):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="calibration.skip_end.hint">Number of LEDs to turn off at the end of the strip (0 = none)</small>
<input type="number" id="cal-skip-end" min="0" value="0" oninput="updateOffsetSkipLock(); updateCalibrationPreview()">
</div>
<input type="number" id="cal-left-leds" class="edge-led-input" min="0" value="0"
oninput="updateCalibrationPreview()">
</div>
<!-- Edge test toggle zones (outside container border, in tick area) -->
<button type="button" class="edge-toggle toggle-top" onclick="toggleTestEdge('top')"></button>
<button type="button" class="edge-toggle toggle-right" onclick="toggleTestEdge('right')"></button>
<button type="button" class="edge-toggle toggle-bottom" onclick="toggleTestEdge('bottom')"></button>
<button type="button" class="edge-toggle toggle-left" onclick="toggleTestEdge('left')"></button>
<!-- Corner start position buttons -->
<button type="button" class="preview-corner corner-top-left" onclick="setStartPosition('top_left')"></button>
<button type="button" class="preview-corner corner-top-right" onclick="setStartPosition('top_right')"></button>
<button type="button" class="preview-corner corner-bottom-left" onclick="setStartPosition('bottom_left')"></button>
<button type="button" class="preview-corner corner-bottom-right" onclick="setStartPosition('bottom_right')"></button>
<!-- Canvas overlay for ticks, arrows, start label -->
<canvas id="calibration-preview-canvas"></canvas>
</div>
</div>
</section>
<!-- Hidden selects (used by saveCalibration) -->
<div style="display: none;">
<select id="cal-start-position">
<option value="bottom_left">Bottom Left</option>
<option value="bottom_right">Bottom Right</option>
<option value="top_left">Top Left</option>
<option value="top_right">Top Right</option>
</select>
<select id="cal-layout">
<option value="clockwise">Clockwise</option>
<option value="counterclockwise">Counterclockwise</option>
</select>
</div>
<!-- Offset & Skip LEDs -->
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; padding: 0 24px;">
<div class="form-group">
<div class="label-row">
<label for="cal-offset" data-i18n="calibration.offset">LED Offset:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="calibration.offset.hint">Distance from physical LED 0 to the start corner (along strip direction)</small>
<input type="number" id="cal-offset" min="0" value="0" oninput="updateOffsetSkipLock(); updateCalibrationPreview()">
</div>
<div class="form-group">
<div class="label-row">
<label for="cal-skip-start" data-i18n="calibration.skip_start">Skip LEDs (Start):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="calibration.skip_start.hint">Number of LEDs to turn off at the beginning of the strip (0 = none)</small>
<input type="number" id="cal-skip-start" min="0" value="0" oninput="updateOffsetSkipLock(); updateCalibrationPreview()">
</div>
<div class="form-group">
<div class="label-row">
<label for="cal-skip-end" data-i18n="calibration.skip_end">Skip LEDs (End):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="calibration.skip_end.hint">Number of LEDs to turn off at the end of the strip (0 = none)</small>
<input type="number" id="cal-skip-end" min="0" value="0" oninput="updateOffsetSkipLock(); updateCalibrationPreview()">
</div>
</div>
<!-- Tutorial Overlay -->
<div id="calibration-tutorial-overlay" class="tutorial-overlay">
<!-- Tutorial Overlay (v2 "Signal Bench") -->
<div id="calibration-tutorial-overlay" class="tutorial-overlay tutorial-v2">
<div class="tutorial-backdrop"></div>
<div class="tutorial-ring"></div>
<svg class="tutorial-cable" aria-hidden="true">
<line x1="0" y1="0" x2="0" y2="0"></line>
</svg>
<div class="tutorial-ring">
<span class="tutorial-reticle-corner tl"></span>
<span class="tutorial-reticle-corner tr"></span>
<span class="tutorial-reticle-corner bl"></span>
<span class="tutorial-reticle-corner br"></span>
</div>
<div class="tutorial-tooltip">
<div class="tutorial-tooltip-header">
<span class="tutorial-tooltip-eyebrow">CAL · TOUR</span>
<span class="tutorial-tooltip-breadcrumb"></span>
<span class="tutorial-step-counter"></span>
<button class="tutorial-close-btn" onclick="closeTutorial()">&times;</button>
<button class="tutorial-close-btn" onclick="closeTutorial()" data-i18n-aria-label="aria.close">&times;</button>
</div>
<div class="tutorial-pips" aria-hidden="true"></div>
<p class="tutorial-tooltip-text"></p>
<div class="tutorial-tooltip-nav">
<button class="tutorial-prev-btn" onclick="tutorialPrev()">&#8592;</button>
<button class="tutorial-next-btn" onclick="tutorialNext()">&#8594;</button>
<button class="tutorial-prev-btn" onclick="tutorialPrev()" data-i18n-aria-label="aria.previous">&#9664; PREV</button>
<button class="tutorial-next-btn" onclick="tutorialNext()" data-i18n-aria-label="aria.next">NEXT &#9654;</button>
</div>
<div class="tutorial-keyhint" aria-hidden="true">
<kbd>&#8592;</kbd><kbd>&#8594;</kbd><span>NAVIGATE</span><kbd>ESC</kbd><span>CLOSE</span>
</div>
</div>
</div>
@@ -1,4 +1,4 @@
<!-- Template Modal -->
<!-- Capture Template Modal — sectioned rack-panel layout. -->
<div id="template-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="template-modal-title">
<div class="modal-content">
<div class="modal-header">
@@ -8,32 +8,52 @@
<div class="modal-body">
<input type="hidden" id="template-id">
<form id="template-form">
<div class="form-group">
<label for="template-name" data-i18n="templates.name">Template Name:</label>
<input type="text" id="template-name" data-i18n-placeholder="templates.name.placeholder" placeholder="My Custom Template" required>
<div id="capture-template-tags-container"></div>
</div>
<div class="form-group">
<label for="template-description" data-i18n="templates.description.label">Description (optional):</label>
<input type="text" id="template-description" data-i18n-placeholder="templates.description.placeholder" placeholder="Describe this template..." maxlength="500">
</div>
<div class="form-group">
<div class="label-row">
<label for="template-engine" data-i18n="templates.engine">Capture Engine:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<!-- ── 01 · IDENTITY ───────────────────────────────── -->
<section class="ds-section" data-ds-key="identity" data-ch="signal">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.identity">Identity</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<small class="input-hint" style="display:none" data-i18n="templates.engine.hint">Select the screen capture technology to use</small>
<select id="template-engine" onchange="onEngineChange()" required>
</select>
<small id="engine-availability-hint" class="form-hint" style="display: none;"></small>
</div>
<div class="ds-section-body">
<div class="form-group ds-name-group">
<label for="template-name" data-i18n="templates.name">Template Name:</label>
<input type="text" id="template-name" data-i18n-placeholder="templates.name.placeholder" placeholder="My Custom Template" required>
<div id="capture-template-tags-container"></div>
</div>
<div id="engine-config-section" style="display: none;">
<h3 data-i18n="templates.config">Configuration</h3>
<div id="engine-config-fields"></div>
</div>
<div class="form-group">
<label for="template-description" data-i18n="templates.description.label">Description (optional):</label>
<input type="text" id="template-description" data-i18n-placeholder="templates.description.placeholder" placeholder="Describe this template..." maxlength="500">
</div>
</div>
</section>
<!-- ── 02 · ENGINE ─────────────────────────────────── -->
<section class="ds-section" data-ds-key="engine" data-ch="cyan">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.engine">Engine</span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div>
<div class="ds-section-body">
<div class="form-group">
<div class="label-row">
<label for="template-engine" data-i18n="templates.engine">Capture Engine:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="templates.engine.hint">Select the screen capture technology to use</small>
<select id="template-engine" onchange="onEngineChange()" required>
</select>
<small id="engine-availability-hint" class="form-hint" style="display: none;"></small>
</div>
<div id="engine-config-section" style="display: none;">
<div id="engine-config-fields"></div>
</div>
</div>
</section>
<div id="template-error" class="error-message" style="display: none;"></div>
</form>
@@ -1,4 +1,4 @@
<!-- Color Strip Processing Template Modal -->
<!-- Color Strip Processing Template Modal — sectioned rack-panel. -->
<div id="cspt-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="cspt-modal-title">
<div class="modal-content">
<div class="modal-header">
@@ -8,26 +8,45 @@
<div class="modal-body">
<input type="hidden" id="cspt-id">
<form id="cspt-form">
<div class="form-group">
<label for="cspt-name" data-i18n="css_processing.name">Template Name:</label>
<input type="text" id="cspt-name" data-i18n-placeholder="css_processing.name_placeholder" placeholder="My Strip Processing Template" required>
<div id="cspt-tags-container"></div>
</div>
<!-- Dynamic filter list -->
<div id="cspt-filter-list" class="pp-filter-list"></div>
<!-- ── 01 · IDENTITY ───────────────────────────────── -->
<section class="ds-section" data-ds-key="identity" data-ch="signal">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.identity">Identity</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<div class="ds-section-body">
<div class="form-group ds-name-group">
<label for="cspt-name" data-i18n="css_processing.name">Template Name:</label>
<input type="text" id="cspt-name" data-i18n-placeholder="css_processing.name_placeholder" placeholder="My Strip Processing Template" required>
<div id="cspt-tags-container"></div>
</div>
<!-- Add filter control -->
<div class="pp-add-filter-row">
<select id="cspt-add-filter-select" class="pp-add-filter-select">
<option value="" data-i18n="filters.select_type">Select filter type...</option>
</select>
</div>
<div class="form-group">
<label for="cspt-description" data-i18n="css_processing.description_label">Description (optional):</label>
<input type="text" id="cspt-description" data-i18n-placeholder="css_processing.description_placeholder" placeholder="Describe this template...">
</div>
</div>
</section>
<div class="form-group">
<label for="cspt-description" data-i18n="css_processing.description_label">Description (optional):</label>
<input type="text" id="cspt-description" data-i18n-placeholder="css_processing.description_placeholder" placeholder="Describe this template...">
</div>
<!-- ── 02 · FILTERS ────────────────────────────────── -->
<section class="ds-section" data-ds-key="filters" data-ch="cyan">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.filters">Filters</span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div>
<div class="ds-section-body">
<div id="cspt-filter-list" class="pp-filter-list"></div>
<div class="pp-add-filter-row">
<select id="cspt-add-filter-select" class="pp-add-filter-select">
<option value="" data-i18n="filters.select_type">Select filter type...</option>
</select>
</div>
</div>
</section>
<div id="cspt-error" class="error-message" style="display: none;"></div>
</form>
@@ -9,11 +9,30 @@
<form id="css-editor-form">
<input type="hidden" id="css-editor-id">
<div class="form-group">
<label for="css-editor-name" data-i18n="color_strip.name">Name:</label>
<input type="text" id="css-editor-name" data-i18n-placeholder="color_strip.name.placeholder" placeholder="Wall Strip" required>
<div id="css-tags-container"></div>
</div>
<!-- ── 01 · IDENTITY ───────────────────────────────── -->
<section class="ds-section" data-ds-key="identity" data-ch="signal">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.identity">Identity</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<div class="ds-section-body">
<div class="form-group ds-name-group">
<label for="css-editor-name" data-i18n="color_strip.name">Name:</label>
<input type="text" id="css-editor-name" data-i18n-placeholder="color_strip.name.placeholder" placeholder="Wall Strip" required>
<div id="css-tags-container"></div>
</div>
</div>
</section>
<!-- ── 02 · SOURCE ─────────────────────────────────── -->
<section class="ds-section" data-ds-key="source" data-ch="cyan">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.source">Source</span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div>
<div class="ds-section-body">
<div id="css-editor-type-group" class="form-group">
<div class="label-row">
@@ -778,6 +797,18 @@
</div>
</div>
</div>
</section>
<!-- ── 03 · STRIP ──────────────────────────────────── -->
<section class="ds-section" data-ds-key="strip" data-ch="amber">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.strip">Strip</span>
<span class="ds-section-index" aria-hidden="true">03</span>
</div>
<div class="ds-section-body">
<!-- Shared LED count field -->
<div id="css-editor-led-count-group" class="form-group">
<div class="label-row">
@@ -813,6 +844,9 @@
</select>
</div>
</div>
</section>
<div id="css-editor-error" class="error-message" style="display: none;"></div>
</form>
</div>
@@ -1,3 +1,12 @@
<!-- Game Integration Editor Modal — sectioned rack-panel layout matching
the settings-modal-redesign vocabulary. The four channels read as a
pipeline:
Identity (signal) — name + description + tags + enabled switch
Adapter (cyan) — adapter type + auto-generated config + setup buttons
Mappings (amber) — event→effect mapping list + preset loader
Diagnostics (violet) — live event feed + connection test panel
The Enabled checkbox is upgraded to a .ds-toggle-row recessed switch.
All inner element IDs preserved for game-integration.ts compatibility. -->
<div id="game-integration-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="gi-title">
<div class="modal-content modal-lg">
<div class="modal-header">
@@ -8,91 +17,124 @@
<input type="hidden" id="gi-id">
<div id="gi-error" class="modal-error" style="display:none"></div>
<!-- Name + Tags -->
<div class="form-group">
<div class="label-row">
<label for="gi-name" data-i18n="game_integration.name">Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<!-- ── 01 · IDENTITY ───────────────────────────────── -->
<section class="ds-section" data-ds-key="identity" data-ch="signal">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.identity">Identity</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<small class="input-hint" style="display:none" data-i18n="game_integration.name.hint">A descriptive name for this game integration</small>
<input type="text" id="gi-name" required>
<div id="gi-tags-container"></div>
</div>
<div class="ds-section-body">
<div class="form-group ds-name-group">
<label for="gi-name" data-i18n="game_integration.name">Name:</label>
<input type="text" id="gi-name" required>
<div id="gi-tags-container"></div>
</div>
<!-- Description -->
<div class="form-group">
<div class="label-row">
<label for="gi-description" data-i18n="game_integration.description">Description:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<div class="form-group">
<div class="label-row">
<label for="gi-description" data-i18n="game_integration.description">Description:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="game_integration.description.hint">Optional description of what this integration does</small>
<input type="text" id="gi-description">
</div>
<div class="ds-toggle-row">
<div class="ds-toggle-text">
<div class="label-row">
<label for="gi-enabled" data-i18n="game_integration.enabled">Enabled</label>
</div>
<small class="input-hint" style="display:none" data-i18n="game_integration.enabled.hint">Disabled integrations stop polling and emitting events</small>
</div>
<label class="settings-toggle">
<input type="checkbox" id="gi-enabled" checked>
<span class="settings-toggle-slider"></span>
</label>
</div>
</div>
<small class="input-hint" style="display:none" data-i18n="game_integration.description.hint">Optional description of what this integration does</small>
<input type="text" id="gi-description">
</div>
</section>
<!-- Enabled -->
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="gi-enabled" checked>
<span data-i18n="game_integration.enabled">Enabled</span>
</label>
</div>
<!-- Game / Adapter picker -->
<div class="form-group">
<div class="label-row">
<label for="gi-adapter-type" data-i18n="game_integration.adapter_type">Game / Adapter:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<!-- ── 02 · ADAPTER ────────────────────────────────── -->
<section class="ds-section" data-ds-key="adapter" data-ch="cyan">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.adapter">Adapter</span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div>
<small class="input-hint" style="display:none" data-i18n="game_integration.adapter_type.hint">Select the game or adapter type for this integration</small>
<select id="gi-adapter-type"></select>
</div>
<div class="ds-section-body">
<div class="form-group">
<div class="label-row">
<label for="gi-adapter-type" data-i18n="game_integration.adapter_type">Game / Adapter:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="game_integration.adapter_type.hint">Select the game or adapter type for this integration</small>
<select id="gi-adapter-type"></select>
</div>
<!-- Adapter config (auto-generated) -->
<div class="form-group">
<div class="label-row">
<label data-i18n="game_integration.adapter_config">Adapter Configuration</label>
<div class="form-group">
<div class="label-row">
<label data-i18n="game_integration.adapter_config">Adapter Configuration</label>
</div>
<div id="gi-adapter-config-fields"></div>
</div>
<div id="gi-setup-instructions-btn-wrapper" style="display:none">
<button type="button" class="btn btn-secondary btn-sm" onclick="openSetupInstructions()" data-i18n="game_integration.setup_instructions">Setup Instructions</button>
<button type="button" id="gi-auto-setup-btn" class="btn btn-primary btn-sm" onclick="autoSetupGameIntegration()" style="display:none" data-i18n="game_integration.auto_setup">Auto Setup</button>
</div>
</div>
<div id="gi-adapter-config-fields"></div>
</div>
</section>
<!-- Setup instructions + Auto Setup buttons -->
<div id="gi-setup-instructions-btn-wrapper" style="display:none">
<button type="button" class="btn btn-secondary btn-sm" onclick="openSetupInstructions()" data-i18n="game_integration.setup_instructions">Setup Instructions</button>
<button type="button" id="gi-auto-setup-btn" class="btn btn-primary btn-sm" onclick="autoSetupGameIntegration()" style="display:none" data-i18n="game_integration.auto_setup">Auto Setup</button>
</div>
<!-- Event Mapping Editor -->
<div class="form-group">
<div class="label-row">
<label data-i18n="game_integration.event_mappings">Event Mappings</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<!-- ── 03 · MAPPINGS ───────────────────────────────── -->
<section class="ds-section" data-ds-key="mappings" data-ch="amber">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.mappings">Mappings</span>
<span class="ds-section-index" aria-hidden="true">03</span>
</div>
<small class="input-hint" style="display:none" data-i18n="game_integration.event_mappings.hint">Map game events to LED effects. Each event type can trigger a different visual effect.</small>
<div class="ds-section-body">
<div class="form-group">
<div class="label-row">
<label data-i18n="game_integration.event_mappings">Event Mappings</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="game_integration.event_mappings.hint">Map game events to LED effects. Each event type can trigger a different visual effect.</small>
<div class="gi-mapping-toolbar">
<select id="gi-mapping-preset" onchange="onMappingPresetChange()">
<option value="" data-i18n="game_integration.preset.select">Load preset...</option>
<option value="fps_combat" data-i18n="game_integration.preset.fps_combat">FPS Combat</option>
<option value="moba_health" data-i18n="game_integration.preset.moba_health">MOBA Health</option>
</select>
<button class="btn btn-secondary btn-sm" onclick="addGameMapping()" data-i18n="game_integration.mapping.add">+ Add Mapping</button>
<div class="gi-mapping-toolbar">
<select id="gi-mapping-preset" onchange="onMappingPresetChange()">
<option value="" data-i18n="game_integration.preset.select">Load preset...</option>
<option value="fps_combat" data-i18n="game_integration.preset.fps_combat">FPS Combat</option>
<option value="moba_health" data-i18n="game_integration.preset.moba_health">MOBA Health</option>
</select>
<button class="btn btn-secondary btn-sm" onclick="addGameMapping()" data-i18n="game_integration.mapping.add">+ Add Mapping</button>
</div>
<div id="gi-mappings-list" class="gi-mappings-list"></div>
</div>
</div>
<div id="gi-mappings-list" class="gi-mappings-list"></div>
</div>
</section>
<!-- Live Event Monitor -->
<div class="form-group">
<div class="label-row">
<label data-i18n="game_integration.events.title">Live Events</label>
<!-- ── 04 · DIAGNOSTICS ────────────────────────────── -->
<section class="ds-section" data-ds-key="diagnostics" data-ch="violet">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.diagnostics">Diagnostics</span>
<span class="ds-section-index" aria-hidden="true">04</span>
</div>
<div id="gi-event-feed" class="gi-event-feed"></div>
</div>
<div class="ds-section-body">
<div class="form-group">
<div class="label-row">
<label data-i18n="game_integration.events.title">Live Events</label>
</div>
<div id="gi-event-feed" class="gi-event-feed"></div>
</div>
<!-- Connection Test -->
<div class="form-group">
<button class="btn btn-secondary" onclick="testGameConnection()" data-i18n="game_integration.test.button">Test Connection</button>
<div id="gi-test-panel" style="display:none" class="gi-test-panel"></div>
</div>
<div class="form-group">
<button class="btn btn-secondary" onclick="testGameConnection()" data-i18n="game_integration.test.button">Test Connection</button>
<div id="gi-test-panel" style="display:none" class="gi-test-panel"></div>
</div>
</div>
</section>
</div>
<div class="modal-footer">
@@ -1,4 +1,4 @@
<!-- Gradient entity editor modal -->
<!-- Gradient entity editor modal — sectioned rack-panel. -->
<div id="gradient-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="gradient-editor-title">
<div class="modal-content">
<div class="modal-header">
@@ -9,49 +9,65 @@
<input type="hidden" id="gradient-editor-id">
<div id="gradient-editor-error" class="modal-error" style="display:none"></div>
<!-- Name + Tags (same form-group, matching other entities) -->
<div class="form-group">
<div class="label-row">
<label for="gradient-editor-name" data-i18n="gradient.name">Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
<!-- ── 01 · IDENTITY ───────────────────────────────── -->
<section class="ds-section" data-ds-key="identity" data-ch="signal">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.identity">Identity</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<small class="input-hint" style="display:none" data-i18n="gradient.name.hint">A descriptive name for this gradient.</small>
<input type="text" id="gradient-editor-name" required>
<div id="gradient-editor-tags-container"></div>
</div>
<div class="ds-section-body">
<div class="form-group ds-name-group">
<div class="label-row">
<label for="gradient-editor-name" data-i18n="gradient.name">Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="gradient.name.hint">A descriptive name for this gradient.</small>
<input type="text" id="gradient-editor-name" required>
<div id="gradient-editor-tags-container"></div>
</div>
<!-- Description -->
<div class="form-group">
<div class="label-row">
<label for="gradient-editor-description" data-i18n="gradient.description">Description:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
<div class="form-group">
<div class="label-row">
<label for="gradient-editor-description" data-i18n="gradient.description">Description:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="gradient.description.hint">Optional description for this gradient.</small>
<input type="text" id="gradient-editor-description" maxlength="500">
</div>
</div>
<small class="input-hint" style="display:none" data-i18n="gradient.description.hint">Optional description for this gradient.</small>
<input type="text" id="gradient-editor-description" maxlength="500">
</div>
</section>
<!-- Gradient preview + markers (unique IDs prefixed ge-) -->
<div class="form-group">
<div class="label-row">
<label data-i18n="color_strip.gradient.preview">Gradient:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
<!-- ── 02 · GRADIENT ───────────────────────────────── -->
<section class="ds-section" data-ds-key="gradient" data-ch="magenta">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.gradient">Gradient</span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.gradient.preview.hint">Visual preview. Click the marker track below to add a stop. Drag markers to reposition.</small>
<div class="gradient-editor">
<canvas id="ge-gradient-canvas" height="44"></canvas>
<div id="ge-gradient-markers-track" class="gradient-markers-track"></div>
</div>
</div>
<div class="ds-section-body">
<div class="form-group">
<div class="label-row">
<label data-i18n="color_strip.gradient.preview">Gradient:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.gradient.preview.hint">Visual preview. Click the marker track below to add a stop. Drag markers to reposition.</small>
<div class="gradient-editor">
<canvas id="ge-gradient-canvas" height="44"></canvas>
<div id="ge-gradient-markers-track" class="gradient-markers-track"></div>
</div>
</div>
<!-- Color stops list -->
<div class="form-group">
<div class="label-row">
<label data-i18n="color_strip.gradient.stops">Color Stops:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
<div class="form-group">
<div class="label-row">
<label data-i18n="color_strip.gradient.stops">Color Stops:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.gradient.stops.hint">Each stop defines a color at a relative position (0.0 = start, 1.0 = end). The ↔ button adds a right-side color to create a hard edge at that stop.</small>
<div id="ge-gradient-stops-list"></div>
</div>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.gradient.stops.hint">Each stop defines a color at a relative position (0.0 = start, 1.0 = end). The ↔ button adds a right-side color to create a hard edge at that stop.</small>
<div id="ge-gradient-stops-list"></div>
</div>
</section>
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeGradientEditor()" title="Cancel" data-i18n-title="settings.button.cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
@@ -1,4 +1,10 @@
<!-- HA Light Target Editor Modal -->
<!-- HA Light Target Editor Modal — sectioned rack-panel layout matching
the settings-modal-redesign vocabulary. Channels map the data flow:
Identity (signal) — name + description
Routing (cyan) — HA connection + CSS source + light mappings
Output (amber) — update rate + transition + brightness
Filtering (violet) — color tolerance + min brightness threshold
All inner element IDs preserved for ha-lights.ts compatibility. -->
<div id="ha-light-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="ha-light-editor-title">
<div class="modal-content">
<div class="modal-header">
@@ -11,109 +17,137 @@
<div id="ha-light-editor-error" class="error-message" style="display: none;"></div>
<!-- Name -->
<div class="form-group">
<div class="label-row">
<label for="ha-light-editor-name" data-i18n="ha_light.name">Name:</label>
<!-- ── 01 · IDENTITY ───────────────────────────────── -->
<section class="ds-section" data-ds-key="identity" data-ch="signal">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.identity">Identity</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<input type="text" id="ha-light-editor-name" data-i18n-placeholder="ha_light.name.placeholder" placeholder="Living Room Lights" required>
<div id="ha-light-tags-container"></div>
</div>
<div class="ds-section-body">
<div class="form-group ds-name-group">
<label for="ha-light-editor-name" data-i18n="ha_light.name">Name:</label>
<input type="text" id="ha-light-editor-name" data-i18n-placeholder="ha_light.name.placeholder" placeholder="Living Room Lights" required>
<div id="ha-light-tags-container"></div>
</div>
<!-- HA Source -->
<div class="form-group">
<div class="label-row">
<label for="ha-light-editor-ha-source" data-i18n="ha_light.ha_source">HA Connection:</label>
<div class="form-group">
<div class="label-row">
<label for="ha-light-editor-description" data-i18n="ha_light.description">Description (optional):</label>
</div>
<input type="text" id="ha-light-editor-description" placeholder="">
</div>
</div>
<select id="ha-light-editor-ha-source"></select>
</div>
</section>
<!-- CSS Source -->
<div class="form-group">
<div class="label-row">
<label for="ha-light-editor-css-source" data-i18n="ha_light.css_source">Color Strip Source:</label>
<!-- ── 02 · ROUTING ────────────────────────────────── -->
<section class="ds-section" data-ds-key="routing" data-ch="cyan">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.routing">Routing</span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div>
<select id="ha-light-editor-css-source"></select>
</div>
<div class="ds-section-body">
<div class="form-group">
<div class="label-row">
<label for="ha-light-editor-ha-source" data-i18n="ha_light.ha_source">HA Connection:</label>
</div>
<select id="ha-light-editor-ha-source"></select>
</div>
<!-- Update Rate -->
<div class="form-group">
<div class="label-row">
<label>
<span data-i18n="ha_light.update_rate">Update Rate:</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="ha_light.update_rate.hint">How often to send color updates to HA lights (0.5-5.0 Hz). Lower values are safer for HA performance.</small>
<div id="ha-light-editor-update-rate-container"></div>
</div>
<div class="form-group">
<div class="label-row">
<label for="ha-light-editor-css-source" data-i18n="ha_light.css_source">Color Strip Source:</label>
</div>
<select id="ha-light-editor-css-source"></select>
</div>
<!-- Transition -->
<div class="form-group">
<div class="label-row">
<label>
<span data-i18n="ha_light.transition">Transition:</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
<div class="form-group">
<div class="label-row">
<label data-i18n="ha_light.mappings">Light Mappings:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="ha_light.mappings.hint">Map LED ranges to HA light entities. Each mapping averages the LED segment to a single color.</small>
<div id="ha-light-mappings-list"></div>
<button type="button" class="btn btn-sm btn-secondary" onclick="addHALightMapping()" style="margin-top: 4px;">
+ <span data-i18n="ha_light.mappings.add">Add Mapping</span>
</button>
</div>
</div>
<small class="input-hint" style="display:none" data-i18n="ha_light.transition.hint">Smooth fade duration between colors (HA transition parameter). Higher values give smoother but slower transitions.</small>
<div id="ha-light-editor-transition-container"></div>
</div>
</section>
<!-- Brightness -->
<div class="form-group">
<div class="label-row">
<label data-i18n="targets.brightness">Brightness:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
<!-- ── 03 · OUTPUT ─────────────────────────────────── -->
<section class="ds-section" data-ds-key="output" data-ch="amber">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.output">Output</span>
<span class="ds-section-index" aria-hidden="true">03</span>
</div>
<small class="input-hint" style="display:none" data-i18n="targets.brightness.hint">Output brightness multiplier (01). Can be bound to a value source for dynamic control.</small>
<div id="ha-light-editor-brightness-container"></div>
</div>
<div class="ds-section-body">
<div class="form-group">
<div class="label-row">
<label>
<span data-i18n="ha_light.update_rate">Update Rate:</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="ha_light.update_rate.hint">How often to send color updates to HA lights (0.5-5.0 Hz). Lower values are safer for HA performance.</small>
<div id="ha-light-editor-update-rate-container"></div>
</div>
<!-- Color Tolerance -->
<div class="form-group">
<div class="label-row">
<label>
<span data-i18n="ha_light.color_tolerance">Color Tolerance:</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="ha_light.color_tolerance.hint">Skip sending color updates when the RGB delta is below this threshold. Reduces HA traffic for near-static scenes.</small>
<div id="ha-light-editor-color-tolerance-container"></div>
</div>
<div class="form-group">
<div class="label-row">
<label>
<span data-i18n="ha_light.transition">Transition:</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="ha_light.transition.hint">Smooth fade duration between colors (HA transition parameter). Higher values give smoother but slower transitions.</small>
<div id="ha-light-editor-transition-container"></div>
</div>
<!-- Min Brightness Threshold -->
<div class="form-group">
<div class="label-row">
<label>
<span data-i18n="ha_light.min_brightness_threshold">Min Brightness Threshold:</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
<div class="form-group">
<div class="label-row">
<label data-i18n="targets.brightness">Brightness:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="targets.brightness.hint">Output brightness multiplier (01). Can be bound to a value source for dynamic control.</small>
<div id="ha-light-editor-brightness-container"></div>
</div>
</div>
<small class="input-hint" style="display:none" data-i18n="ha_light.min_brightness_threshold.hint">Effective output brightness below this value turns lights off completely (0 = disabled)</small>
<div id="ha-light-editor-min-brightness-threshold-container"></div>
</div>
</section>
<!-- Light Mappings -->
<div class="form-group">
<div class="label-row">
<label data-i18n="ha_light.mappings">Light Mappings:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
<!-- ── 04 · FILTERING ──────────────────────────────── -->
<section class="ds-section" data-ds-key="filtering" data-ch="violet">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.filtering">Filtering</span>
<span class="ds-section-index" aria-hidden="true">04</span>
</div>
<small class="input-hint" style="display:none" data-i18n="ha_light.mappings.hint">Map LED ranges to HA light entities. Each mapping averages the LED segment to a single color.</small>
<div id="ha-light-mappings-list"></div>
<button type="button" class="btn btn-sm btn-secondary" onclick="addHALightMapping()" style="margin-top: 4px;">
+ <span data-i18n="ha_light.mappings.add">Add Mapping</span>
</button>
</div>
<div class="ds-section-body">
<div class="form-group">
<div class="label-row">
<label>
<span data-i18n="ha_light.color_tolerance">Color Tolerance:</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="ha_light.color_tolerance.hint">Skip sending color updates when the RGB delta is below this threshold. Reduces HA traffic for near-static scenes.</small>
<div id="ha-light-editor-color-tolerance-container"></div>
</div>
<!-- Description -->
<div class="form-group">
<div class="label-row">
<label for="ha-light-editor-description" data-i18n="ha_light.description">Description (optional):</label>
<div class="form-group">
<div class="label-row">
<label>
<span data-i18n="ha_light.min_brightness_threshold">Min Brightness Threshold:</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="ha_light.min_brightness_threshold.hint">Effective output brightness below this value turns lights off completely (0 = disabled)</small>
<div id="ha-light-editor-min-brightness-threshold-container"></div>
</div>
</div>
<input type="text" id="ha-light-editor-description" placeholder="">
</div>
</section>
</form>
</div>
@@ -1,4 +1,9 @@
<!-- Home Assistant Source Editor Modal -->
<!-- Home Assistant Source Editor Modal — sectioned rack-panel layout
matching the settings-modal-redesign vocabulary. Channels: signal
(identity), cyan (connection: host/token/SSL), amber (entity
filters). The SSL checkbox is upgraded to a .ds-toggle-row recessed
hardware switch so it visually anchors the connection panel. All
element IDs preserved for ha-source.ts compatibility. -->
<div id="ha-source-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="ha-source-modal-title">
<div class="modal-content">
<div class="modal-header">
@@ -11,63 +16,89 @@
<div id="ha-source-error" class="error-message" style="display: none;"></div>
<!-- Name -->
<div class="form-group">
<div class="label-row">
<label for="ha-source-name" data-i18n="ha_source.name">Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
<!-- ── 01 · IDENTITY ───────────────────────────────── -->
<section class="ds-section" data-ds-key="identity" data-ch="signal">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.identity">Identity</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<small class="input-hint" style="display:none" data-i18n="ha_source.name.hint">A descriptive name for this Home Assistant connection</small>
<input type="text" id="ha-source-name" data-i18n-placeholder="ha_source.name.placeholder" placeholder="My Home Assistant" required>
<div id="ha-source-tags-container"></div>
</div>
<div class="ds-section-body">
<div class="form-group ds-name-group">
<label for="ha-source-name" data-i18n="ha_source.name">Name:</label>
<input type="text" id="ha-source-name" data-i18n-placeholder="ha_source.name.placeholder" placeholder="My Home Assistant" required>
<div id="ha-source-tags-container"></div>
</div>
<!-- Host -->
<div class="form-group">
<div class="label-row">
<label for="ha-source-host" data-i18n="ha_source.host">Host:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
<div class="form-group">
<div class="label-row">
<label for="ha-source-description" data-i18n="ha_source.description">Description (optional):</label>
</div>
<input type="text" id="ha-source-description" placeholder="">
</div>
</div>
<small class="input-hint" style="display:none" data-i18n="ha_source.host.hint">Home Assistant host and port, e.g. 192.168.1.100:8123</small>
<input type="text" id="ha-source-host" placeholder="192.168.1.100:8123" required>
</div>
</section>
<!-- Token -->
<div class="form-group">
<div class="label-row">
<label for="ha-source-token" data-i18n="ha_source.token">Access Token:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
<!-- ── 02 · CONNECTION ─────────────────────────────── -->
<section class="ds-section" data-ds-key="connection" data-ch="cyan">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.connection">Connection</span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div>
<small class="input-hint" style="display:none" data-i18n="ha_source.token.hint">Long-Lived Access Token from HA (Profile > Security > Long-Lived Access Tokens)</small>
<small id="ha-source-token-hint" class="input-hint" style="display:none" data-i18n="ha_source.token.edit_hint">Leave blank to keep the current token</small>
<input type="password" id="ha-source-token" placeholder="eyJ0eXAiOi..." autocomplete="new-password">
</div>
<div class="ds-section-body">
<div class="form-group">
<div class="label-row">
<label for="ha-source-host" data-i18n="ha_source.host">Host:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="ha_source.host.hint">Home Assistant host and port, e.g. 192.168.1.100:8123</small>
<input type="text" id="ha-source-host" placeholder="192.168.1.100:8123" required>
</div>
<!-- SSL -->
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="ha-source-ssl">
<span data-i18n="ha_source.use_ssl">Use SSL (wss://)</span>
</label>
</div>
<div class="form-group">
<div class="label-row">
<label for="ha-source-token" data-i18n="ha_source.token">Access Token:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="ha_source.token.hint">Long-Lived Access Token from HA (Profile > Security > Long-Lived Access Tokens)</small>
<small id="ha-source-token-hint" class="input-hint" style="display:none" data-i18n="ha_source.token.edit_hint">Leave blank to keep the current token</small>
<input type="password" id="ha-source-token" placeholder="eyJ0eXAiOi..." autocomplete="new-password">
</div>
<!-- Entity Filters -->
<div class="form-group">
<div class="label-row">
<label for="ha-source-filters" data-i18n="ha_source.entity_filters">Entity Filters (optional):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
<div class="ds-toggle-row">
<div class="ds-toggle-text">
<div class="label-row">
<label for="ha-source-ssl" data-i18n="ha_source.use_ssl">Use SSL (wss://)</label>
</div>
<small class="input-hint" style="display:none" data-i18n="ha_source.use_ssl.hint">Enable for HTTPS / wss connections to Home Assistant</small>
</div>
<label class="settings-toggle">
<input type="checkbox" id="ha-source-ssl">
<span class="settings-toggle-slider"></span>
</label>
</div>
</div>
<small class="input-hint" style="display:none" data-i18n="ha_source.entity_filters.hint">Comma-separated glob patterns to filter entities, e.g. sensor.*, binary_sensor.front_door. Leave empty for all entities.</small>
<input type="text" id="ha-source-filters" placeholder="sensor.*, binary_sensor.*">
</div>
</section>
<!-- Description -->
<div class="form-group">
<div class="label-row">
<label for="ha-source-description" data-i18n="ha_source.description">Description (optional):</label>
<!-- ── 03 · FILTERS ────────────────────────────────── -->
<section class="ds-section" data-ds-key="filters" data-ch="amber">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.filters">Filters</span>
<span class="ds-section-index" aria-hidden="true">03</span>
</div>
<input type="text" id="ha-source-description" placeholder="">
</div>
<div class="ds-section-body">
<div class="form-group">
<div class="label-row">
<label for="ha-source-filters" data-i18n="ha_source.entity_filters">Entity Filters (optional):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="ha_source.entity_filters.hint">Comma-separated glob patterns to filter entities, e.g. sensor.*, binary_sensor.front_door. Leave empty for all entities.</small>
<input type="text" id="ha-source-filters" placeholder="sensor.*, binary_sensor.*">
</div>
</div>
</section>
</form>
</div>
@@ -1,4 +1,9 @@
<!-- MQTT Source Editor Modal -->
<!-- MQTT Source Editor Modal — sectioned rack-panel layout matching the
settings-modal-redesign vocabulary. Channels:
Identity (signal) — name + description
Broker (cyan) — host/port + credentials
Protocol (amber) — client id + base topic
All inner element IDs preserved for mqtt.ts compatibility. -->
<div id="mqtt-source-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="mqtt-source-modal-title">
<div class="modal-content">
<div class="modal-header">
@@ -11,79 +16,99 @@
<div id="mqtt-source-error" class="error-message" style="display: none;"></div>
<!-- Name -->
<div class="form-group">
<div class="label-row">
<label for="mqtt-source-name" data-i18n="mqtt_source.name">Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
<!-- ── 01 · IDENTITY ───────────────────────────────── -->
<section class="ds-section" data-ds-key="identity" data-ch="signal">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.identity">Identity</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<small class="input-hint" style="display:none" data-i18n="mqtt_source.name.hint">A descriptive name for this MQTT broker connection</small>
<input type="text" id="mqtt-source-name" data-i18n-placeholder="mqtt_source.name.placeholder" placeholder="My MQTT Broker" required>
<div id="mqtt-source-tags-container"></div>
</div>
<div class="ds-section-body">
<div class="form-group ds-name-group">
<label for="mqtt-source-name" data-i18n="mqtt_source.name">Name:</label>
<input type="text" id="mqtt-source-name" data-i18n-placeholder="mqtt_source.name.placeholder" placeholder="My MQTT Broker" required>
<div id="mqtt-source-tags-container"></div>
</div>
<!-- Broker Host -->
<div class="form-group">
<div class="label-row">
<label for="mqtt-source-host" data-i18n="mqtt_source.broker_host">Broker Host:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
<div class="form-group">
<div class="label-row">
<label for="mqtt-source-description" data-i18n="mqtt_source.description">Description (optional):</label>
</div>
<input type="text" id="mqtt-source-description" placeholder="">
</div>
</div>
<small class="input-hint" style="display:none" data-i18n="mqtt_source.broker_host.hint">MQTT broker hostname or IP address, e.g. 192.168.1.100</small>
<input type="text" id="mqtt-source-host" placeholder="192.168.1.100" required>
</div>
</section>
<!-- Broker Port -->
<div class="form-group">
<div class="label-row">
<label for="mqtt-source-port" data-i18n="mqtt_source.broker_port">Port:</label>
<!-- ── 02 · BROKER ─────────────────────────────────── -->
<section class="ds-section" data-ds-key="broker" data-ch="cyan">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.broker">Broker</span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div>
<input type="number" id="mqtt-source-port" value="1883" min="1" max="65535">
</div>
<div class="ds-section-body">
<div class="ds-pair-row">
<div class="form-group">
<div class="label-row">
<label for="mqtt-source-host" data-i18n="mqtt_source.broker_host">Broker Host:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="mqtt_source.broker_host.hint">MQTT broker hostname or IP address, e.g. 192.168.1.100</small>
<input type="text" id="mqtt-source-host" placeholder="192.168.1.100" required>
</div>
<div class="form-group">
<div class="label-row">
<label for="mqtt-source-port" data-i18n="mqtt_source.broker_port">Port:</label>
</div>
<input type="number" id="mqtt-source-port" value="1883" min="1" max="65535">
</div>
</div>
<!-- Username -->
<div class="form-group">
<div class="label-row">
<label for="mqtt-source-username" data-i18n="mqtt_source.username">Username (optional):</label>
<div class="ds-pair-row">
<div class="form-group">
<div class="label-row">
<label for="mqtt-source-username" data-i18n="mqtt_source.username">Username (optional):</label>
</div>
<input type="text" id="mqtt-source-username" placeholder="" autocomplete="username">
</div>
<div class="form-group">
<div class="label-row">
<label for="mqtt-source-password" data-i18n="mqtt_source.password">Password (optional):</label>
</div>
<small id="mqtt-source-password-hint" class="input-hint" style="display:none" data-i18n="mqtt_source.password.edit_hint">Leave blank to keep the current password</small>
<input type="password" id="mqtt-source-password" placeholder="" autocomplete="new-password">
</div>
</div>
</div>
<input type="text" id="mqtt-source-username" placeholder="" autocomplete="username">
</div>
</section>
<!-- Password -->
<div class="form-group">
<div class="label-row">
<label for="mqtt-source-password" data-i18n="mqtt_source.password">Password (optional):</label>
<!-- ── 03 · PROTOCOL ───────────────────────────────── -->
<section class="ds-section" data-ds-key="protocol" data-ch="amber">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.protocol">Protocol</span>
<span class="ds-section-index" aria-hidden="true">03</span>
</div>
<small id="mqtt-source-password-hint" class="input-hint" style="display:none" data-i18n="mqtt_source.password.edit_hint">Leave blank to keep the current password</small>
<input type="password" id="mqtt-source-password" placeholder="" autocomplete="new-password">
</div>
<div class="ds-section-body">
<div class="form-group">
<div class="label-row">
<label for="mqtt-source-client-id" data-i18n="mqtt_source.client_id">Client ID:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="mqtt_source.client_id.hint">Unique MQTT client identifier. Change if running multiple instances.</small>
<input type="text" id="mqtt-source-client-id" value="ledgrab" placeholder="ledgrab">
</div>
<!-- Client ID -->
<div class="form-group">
<div class="label-row">
<label for="mqtt-source-client-id" data-i18n="mqtt_source.client_id">Client ID:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
<div class="form-group">
<div class="label-row">
<label for="mqtt-source-base-topic" data-i18n="mqtt_source.base_topic">Base Topic:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="mqtt_source.base_topic.hint">Prefix for status and state topics, e.g. ledgrab/status</small>
<input type="text" id="mqtt-source-base-topic" value="ledgrab" placeholder="ledgrab">
</div>
</div>
<small class="input-hint" style="display:none" data-i18n="mqtt_source.client_id.hint">Unique MQTT client identifier. Change if running multiple instances.</small>
<input type="text" id="mqtt-source-client-id" value="ledgrab" placeholder="ledgrab">
</div>
<!-- Base Topic -->
<div class="form-group">
<div class="label-row">
<label for="mqtt-source-base-topic" data-i18n="mqtt_source.base_topic">Base Topic:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="mqtt_source.base_topic.hint">Prefix for status and state topics, e.g. ledgrab/status</small>
<input type="text" id="mqtt-source-base-topic" value="ledgrab" placeholder="ledgrab">
</div>
<!-- Description -->
<div class="form-group">
<div class="label-row">
<label for="mqtt-source-description" data-i18n="mqtt_source.description">Description (optional):</label>
</div>
<input type="text" id="mqtt-source-description" placeholder="">
</div>
</section>
</form>
</div>
@@ -1,4 +1,4 @@
<!-- OS Notification History Modal -->
<!-- OS Notification History Modal — sectioned rack-panel layout. -->
<div id="notification-history-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="notification-history-modal-title" style="display:none">
<div class="modal-content" style="max-width:520px;width:100%">
<div class="modal-header">
@@ -6,9 +6,19 @@
<button class="modal-close-btn" onclick="closeNotificationHistory()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div>
<div class="modal-body">
<p class="form-hint" style="margin-bottom:0.75rem" data-i18n="color_strip.notification.history.hint">Recent OS notifications captured by the listener (newest first). Up to 50 entries.</p>
<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>
<!-- ── 01 · HISTORY ────────────────────────────────── -->
<section class="ds-section" data-ds-key="history" data-ch="violet">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.history">History</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<div class="ds-section-body">
<p class="form-hint" style="margin-bottom:0.75rem" data-i18n="color_strip.notification.history.hint">Recent OS notifications captured by the listener (newest first). Up to 50 entries.</p>
<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>
</section>
</div>
<div class="modal-footer">
<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>
@@ -1,4 +1,6 @@
<!-- Pattern Template Editor Modal -->
<!-- Pattern Template Editor Modal — sectioned rack-panel.
The visual rectangle editor + numeric coordinate list together form
the Layout section. -->
<div id="pattern-template-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="pattern-template-modal-title">
<div class="modal-content modal-content-wide">
<div class="modal-header">
@@ -9,53 +11,71 @@
<form id="pattern-template-form">
<input type="hidden" id="pattern-template-id">
<div id="pattern-name-group" class="form-group">
<div class="label-row">
<label for="pattern-template-name" data-i18n="pattern.name">Template Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<!-- ── 01 · IDENTITY ───────────────────────────────── -->
<section class="ds-section" data-ds-key="identity" data-ch="signal">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.identity">Identity</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<small class="input-hint" style="display:none" data-i18n="pattern.name.hint">A descriptive name for this rectangle layout</small>
<input type="text" id="pattern-template-name" data-i18n-placeholder="pattern.name.placeholder" placeholder="My Pattern Template" required>
<div id="pattern-tags-container"></div>
</div>
<div class="ds-section-body">
<div id="pattern-name-group" class="form-group ds-name-group">
<div class="label-row">
<label for="pattern-template-name" data-i18n="pattern.name">Template Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="pattern.name.hint">A descriptive name for this rectangle layout</small>
<input type="text" id="pattern-template-name" data-i18n-placeholder="pattern.name.placeholder" placeholder="My Pattern Template" required>
<div id="pattern-tags-container"></div>
</div>
<div id="pattern-desc-group" class="form-group">
<div class="label-row">
<label for="pattern-template-description" data-i18n="pattern.description_label">Description (optional):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<div id="pattern-desc-group" class="form-group">
<div class="label-row">
<label for="pattern-template-description" data-i18n="pattern.description_label">Description (optional):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="pattern.description.hint">Optional notes about where or how this pattern is used</small>
<input type="text" id="pattern-template-description" data-i18n-placeholder="pattern.description_placeholder" placeholder="Describe this pattern...">
</div>
</div>
<small class="input-hint" style="display:none" data-i18n="pattern.description.hint">Optional notes about where or how this pattern is used</small>
<input type="text" id="pattern-template-description" data-i18n-placeholder="pattern.description_placeholder" placeholder="Describe this pattern...">
</div>
</section>
<!-- Visual Editor -->
<div class="form-group">
<div class="label-row">
<label data-i18n="pattern.visual_editor">Visual Editor</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<!-- ── 02 · LAYOUT ─────────────────────────────────── -->
<section class="ds-section" data-ds-key="layout" data-ch="cyan">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.layout">Layout</span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div>
<small class="input-hint" style="display:none" data-i18n="pattern.visual_editor.hint">Click + buttons to add rectangles. Drag edges to resize, drag inside to move.</small>
<div class="pattern-bg-row">
<select id="pattern-bg-source"></select>
<button type="button" class="btn btn-icon btn-secondary pattern-capture-btn" onclick="capturePatternBackground()" title="Capture Background" data-i18n-title="pattern.capture_bg"><svg class="icon" viewBox="0 0 24 24"><path d="M13.997 4a2 2 0 0 1 1.76 1.05l.486.9A2 2 0 0 0 18.003 7H20a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h1.997a2 2 0 0 0 1.759-1.048l.489-.904A2 2 0 0 1 10.004 4z"/><circle cx="12" cy="13" r="3"/></svg></button>
</div>
<div class="pattern-canvas-container">
<canvas id="pattern-canvas"></canvas>
</div>
</div>
<div class="ds-section-body">
<div class="form-group">
<div class="label-row">
<label data-i18n="pattern.visual_editor">Visual Editor</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="pattern.visual_editor.hint">Click + buttons to add rectangles. Drag edges to resize, drag inside to move.</small>
<div class="pattern-bg-row">
<select id="pattern-bg-source"></select>
<button type="button" class="btn btn-icon btn-secondary pattern-capture-btn" onclick="capturePatternBackground()" title="Capture Background" data-i18n-title="pattern.capture_bg"><svg class="icon" viewBox="0 0 24 24"><path d="M13.997 4a2 2 0 0 1 1.76 1.05l.486.9A2 2 0 0 0 18.003 7H20a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h1.997a2 2 0 0 0 1.759-1.048l.489-.904A2 2 0 0 1 10.004 4z"/><circle cx="12" cy="13" r="3"/></svg></button>
</div>
<div class="pattern-canvas-container">
<canvas id="pattern-canvas"></canvas>
</div>
</div>
<!-- Precise coordinate list -->
<div class="form-group">
<div id="pattern-rect-labels" class="pattern-rect-labels">
<span data-i18n="pattern.rect.name">Name</span>
<span data-i18n="pattern.rect.x">X</span>
<span data-i18n="pattern.rect.y">Y</span>
<span data-i18n="pattern.rect.width">W</span>
<span data-i18n="pattern.rect.height">H</span>
<span></span>
<div class="form-group">
<div id="pattern-rect-labels" class="pattern-rect-labels">
<span data-i18n="pattern.rect.name">Name</span>
<span data-i18n="pattern.rect.x">X</span>
<span data-i18n="pattern.rect.y">Y</span>
<span data-i18n="pattern.rect.width">W</span>
<span data-i18n="pattern.rect.height">H</span>
<span></span>
</div>
<div id="pattern-rect-list" class="pattern-rect-list"></div>
</div>
</div>
<div id="pattern-rect-list" class="pattern-rect-list"></div>
</div>
</section>
<div id="pattern-template-error" class="error-message" style="display: none;"></div>
</form>
@@ -1,4 +1,6 @@
<!-- Processing Template Modal -->
<!-- Processing Template Modal — sectioned rack-panel layout.
The dynamic filter list (#pp-filter-list) is the heart of the modal,
so it gets its own Filters section with the cyan source channel. -->
<div id="pp-template-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="pp-template-modal-title">
<div class="modal-content">
<div class="modal-header">
@@ -8,26 +10,45 @@
<div class="modal-body">
<input type="hidden" id="pp-template-id">
<form id="pp-template-form">
<div class="form-group">
<label for="pp-template-name" data-i18n="postprocessing.name">Template Name:</label>
<input type="text" id="pp-template-name" data-i18n-placeholder="postprocessing.name.placeholder" placeholder="My Processing Template" required>
<div id="pp-template-tags-container"></div>
</div>
<!-- Dynamic filter list -->
<div id="pp-filter-list" class="pp-filter-list"></div>
<!-- ── 01 · IDENTITY ───────────────────────────────── -->
<section class="ds-section" data-ds-key="identity" data-ch="signal">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.identity">Identity</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<div class="ds-section-body">
<div class="form-group ds-name-group">
<label for="pp-template-name" data-i18n="postprocessing.name">Template Name:</label>
<input type="text" id="pp-template-name" data-i18n-placeholder="postprocessing.name.placeholder" placeholder="My Processing Template" required>
<div id="pp-template-tags-container"></div>
</div>
<!-- Add filter control -->
<div class="pp-add-filter-row">
<select id="pp-add-filter-select" class="pp-add-filter-select">
<option value="" data-i18n="filters.select_type">Select filter type...</option>
</select>
</div>
<div class="form-group">
<label for="pp-template-description" data-i18n="postprocessing.description_label">Description (optional):</label>
<input type="text" id="pp-template-description" data-i18n-placeholder="postprocessing.description_placeholder" placeholder="Describe this template...">
</div>
</div>
</section>
<div class="form-group">
<label for="pp-template-description" data-i18n="postprocessing.description_label">Description (optional):</label>
<input type="text" id="pp-template-description" data-i18n-placeholder="postprocessing.description_placeholder" placeholder="Describe this template...">
</div>
<!-- ── 02 · FILTERS ────────────────────────────────── -->
<section class="ds-section" data-ds-key="filters" data-ch="cyan">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.filters">Filters</span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div>
<div class="ds-section-body">
<div id="pp-filter-list" class="pp-filter-list"></div>
<div class="pp-add-filter-row">
<select id="pp-add-filter-select" class="pp-add-filter-select">
<option value="" data-i18n="filters.select_type">Select filter type...</option>
</select>
</div>
</div>
</section>
<div id="pp-template-error" class="error-message" style="display: none;"></div>
</form>
@@ -1,4 +1,8 @@
<!-- Scene Preset Editor Modal -->
<!-- Scene Preset Editor Modal — sectioned rack-panel layout, matches the
settings-modal-redesign mockup vocabulary. .ds-section wrappers carry
the channel accent (signal = identity, cyan = targets); the inner
form-groups keep their original IDs so scene-presets.ts can populate
and snapshotValues() keeps working untouched. -->
<div id="scene-preset-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="scene-preset-editor-title">
<div class="modal-content">
<div class="modal-header">
@@ -9,34 +13,50 @@
<form id="scene-preset-editor-form">
<input type="hidden" id="scene-preset-editor-id">
<div class="form-group">
<div class="label-row">
<label for="scene-preset-editor-name" data-i18n="scenes.name">Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<!-- ── 01 · IDENTITY ───────────────────────────────── -->
<section class="ds-section" data-ds-key="identity" data-ch="signal">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.identity">Identity</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<small class="input-hint" style="display:none" data-i18n="scenes.name.hint">A descriptive name for this scene preset</small>
<input type="text" id="scene-preset-editor-name" data-i18n-placeholder="scenes.name.placeholder" placeholder="My Scene" required>
<div id="scene-tags-container"></div>
</div>
<div class="ds-section-body">
<div class="form-group ds-name-group">
<label for="scene-preset-editor-name" data-i18n="scenes.name">Name:</label>
<input type="text" id="scene-preset-editor-name" data-i18n-placeholder="scenes.name.placeholder" placeholder="My Scene" required>
<div id="scene-tags-container"></div>
</div>
<div class="form-group">
<div class="label-row">
<label for="scene-preset-editor-description" data-i18n="scenes.description">Description:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<div class="form-group">
<div class="label-row">
<label for="scene-preset-editor-description" data-i18n="scenes.description">Description:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="scenes.description.hint">Optional description of what this scene does</small>
<input type="text" id="scene-preset-editor-description">
</div>
</div>
<small class="input-hint" style="display:none" data-i18n="scenes.description.hint">Optional description of what this scene does</small>
<input type="text" id="scene-preset-editor-description">
</div>
</section>
<div class="form-group" id="scene-target-selector-group" style="display:none">
<div class="label-row">
<label data-i18n="scenes.targets">Targets:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<!-- ── 02 · TARGETS ────────────────────────────────── -->
<section class="ds-section" data-ds-key="targets" data-ch="cyan">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="scenes.targets">Targets</span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div>
<small class="input-hint" style="display:none" data-i18n="scenes.targets.hint">Select which targets to include in this scene snapshot</small>
<div id="scene-target-list" class="scene-target-list"></div>
<button type="button" id="scene-target-add-btn" class="btn btn-sm btn-secondary" onclick="addSceneTarget()" style="margin-top: 6px;">+ <span data-i18n="scenes.targets.add">Add Target</span></button>
</div>
<div class="ds-section-body">
<div class="form-group" id="scene-target-selector-group" style="display:none">
<div class="label-row">
<label data-i18n="scenes.targets">Targets:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="scenes.targets.hint">Select which targets to include in this scene snapshot</small>
<div id="scene-target-list" class="scene-target-list"></div>
<button type="button" id="scene-target-add-btn" class="btn btn-sm btn-secondary" onclick="addSceneTarget()" style="margin-top: 6px;">+ <span data-i18n="scenes.targets.add">Add Target</span></button>
</div>
</div>
</section>
<div id="scene-preset-editor-error" class="error-message" style="display: none;"></div>
</form>
@@ -1,4 +1,5 @@
<!-- Setup Required Modal (shown when LAN client hits a server with no API keys configured) -->
<!-- Setup Required Modal — sectioned rack-panel; three numbered steps
map to three rack panels (signal/cyan/amber). -->
<div id="setup-required-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="setup-required-title">
<div class="modal-content">
<div class="modal-header">
@@ -12,31 +13,62 @@
This LedGrab server has no API keys configured, so access from other devices on the network is disabled for security. Configure a key on the machine running the server to enable LAN access.
</p>
<div class="form-group">
<label data-i18n="setup.step1_label">1. On the server machine, edit <code>config/default_config.yaml</code>:</label>
<div class="code-snippet-wrapper">
<pre id="setup-yaml-snippet" class="code-snippet"><code>auth:
<!-- ── 01 · CONFIGURE ──────────────────────────────── -->
<section class="ds-section" data-ds-key="configure" data-ch="signal">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.configure">Configure</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<div class="ds-section-body">
<div class="form-group">
<label data-i18n="setup.step1_label">1. On the server machine, edit <code>config/default_config.yaml</code>:</label>
<div class="code-snippet-wrapper">
<pre id="setup-yaml-snippet" class="code-snippet"><code>auth:
api_keys:
dev: "REPLACE_WITH_A_LONG_RANDOM_SECRET"</code></pre>
<button type="button" class="btn btn-icon btn-secondary copy-btn" onclick="copySetupSnippet()" data-i18n-title="setup.copy" title="Copy">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
</button>
<button type="button" class="btn btn-icon btn-secondary copy-btn" onclick="copySetupSnippet()" data-i18n-title="setup.copy" title="Copy">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
</button>
</div>
<small class="input-hint" data-i18n="setup.hint_openssl">
Generate a strong key on Linux/macOS with <code>openssl rand -hex 32</code>, or on Windows PowerShell with <code>[guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N')</code>.
</small>
</div>
</div>
<small class="input-hint" data-i18n="setup.hint_openssl">
Generate a strong key on Linux/macOS with <code>openssl rand -hex 32</code>, or on Windows PowerShell with <code>[guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N')</code>.
</small>
</div>
</section>
<div class="form-group">
<label data-i18n="setup.step2_label">2. Restart the server, then reload this page and log in with that key.</label>
</div>
<div class="form-group">
<label data-i18n="setup.step3_label">Alternative: open LedGrab from the server machine itself (loopback), no key required:</label>
<div class="code-snippet-wrapper">
<a id="setup-loopback-link" class="btn btn-secondary" href="http://localhost:8080" target="_blank" rel="noopener">http://localhost:8080</a>
<!-- ── 02 · RESTART ────────────────────────────────── -->
<section class="ds-section" data-ds-key="restart" data-ch="cyan">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.restart">Restart</span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div>
</div>
<div class="ds-section-body">
<div class="form-group">
<label data-i18n="setup.step2_label">2. Restart the server, then reload this page and log in with that key.</label>
</div>
</div>
</section>
<!-- ── 03 · LOOPBACK ───────────────────────────────── -->
<section class="ds-section" data-ds-key="loopback" data-ch="amber">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.loopback">Loopback</span>
<span class="ds-section-index" aria-hidden="true">03</span>
</div>
<div class="ds-section-body">
<div class="form-group">
<label data-i18n="setup.step3_label">Alternative: open LedGrab from the server machine itself (loopback), no key required:</label>
<div class="code-snippet-wrapper">
<a id="setup-loopback-link" class="btn btn-secondary" href="http://localhost:8080" target="_blank" rel="noopener">http://localhost:8080</a>
</div>
</div>
</div>
</section>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-icon btn-secondary" onclick="retrySetupCheck()" data-i18n-title="setup.retry" title="Retry">
+134 -106
View File
@@ -1,4 +1,7 @@
<!-- Source Modal -->
<!-- Source Modal — sectioned rack-panel layout. The Source section's
inner type-specific fieldsets (`stream-*-fields`) keep their
display:none toggle logic untouched; the .ds-section wrapper just
gives them a channel-coded frame. All element IDs preserved. -->
<div id="stream-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="stream-modal-title">
<div class="modal-content">
<div class="modal-header">
@@ -13,122 +16,147 @@
</div>
<input type="hidden" id="stream-id">
<form id="stream-form">
<div class="form-group">
<label for="stream-name" data-i18n="streams.name">Source Name:</label>
<input type="text" id="stream-name" data-i18n-placeholder="streams.name.placeholder" placeholder="My Source" required>
<div id="stream-tags-container"></div>
</div>
<!-- ── 01 · IDENTITY ───────────────────────────────── -->
<section class="ds-section" data-ds-key="identity" data-ch="signal">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.identity">Identity</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<div class="ds-section-body">
<div class="form-group ds-name-group">
<label for="stream-name" data-i18n="streams.name">Source Name:</label>
<input type="text" id="stream-name" data-i18n-placeholder="streams.name.placeholder" placeholder="My Source" required>
<div id="stream-tags-container"></div>
</div>
<div class="form-group">
<label for="stream-description" data-i18n="streams.description_label">Description (optional):</label>
<input type="text" id="stream-description" data-i18n-placeholder="streams.description_placeholder" placeholder="Describe this source...">
</div>
</div>
</section>
<input type="hidden" id="stream-type" value="raw">
<!-- Raw source fields -->
<div id="stream-raw-fields">
<div class="form-group">
<div class="label-row">
<label data-i18n="streams.display">Display:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="streams.display.hint">Which screen to capture</small>
<input type="hidden" id="stream-display-index" value="">
<button type="button" class="btn btn-display-picker" id="stream-display-picker-btn" onclick="openDisplayPicker(onStreamDisplaySelected, document.getElementById('stream-display-index').value, document.getElementById('stream-capture-template').selectedOptions[0]?.dataset?.hasOwnDisplays === '1' ? document.getElementById('stream-capture-template').selectedOptions[0].dataset.engineType : null)">
<span id="stream-display-picker-label" data-i18n="displays.picker.select">Select display...</span>
</button>
<!-- ── 02 · SOURCE ─────────────────────────────────── -->
<section class="ds-section" data-ds-key="source" data-ch="cyan">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.source">Source</span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div>
<div class="form-group">
<div class="label-row">
<label for="stream-capture-template" data-i18n="streams.capture_template">Capture Template:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<div class="ds-section-body">
<!-- Raw source fields -->
<div id="stream-raw-fields">
<div class="form-group">
<div class="label-row">
<label data-i18n="streams.display">Display:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="streams.display.hint">Which screen to capture</small>
<input type="hidden" id="stream-display-index" value="">
<button type="button" class="btn btn-display-picker" id="stream-display-picker-btn" onclick="openDisplayPicker(onStreamDisplaySelected, document.getElementById('stream-display-index').value, document.getElementById('stream-capture-template').selectedOptions[0]?.dataset?.hasOwnDisplays === '1' ? document.getElementById('stream-capture-template').selectedOptions[0].dataset.engineType : null)">
<span id="stream-display-picker-label" data-i18n="displays.picker.select">Select display...</span>
</button>
</div>
<div class="form-group">
<div class="label-row">
<label for="stream-capture-template" data-i18n="streams.capture_template">Capture Template:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="streams.capture_template.hint">Engine template defining how the screen is captured</small>
<select id="stream-capture-template"></select>
</div>
<div class="form-group">
<div class="label-row">
<label for="stream-target-fps" data-i18n="streams.target_fps">Target FPS:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="streams.target_fps.hint">Target frames per second for capture (1-90)</small>
<div class="slider-row">
<input type="range" id="stream-target-fps" min="1" max="90" value="30" oninput="document.getElementById('stream-target-fps-value').textContent = this.value">
<span id="stream-target-fps-value" class="slider-value">30</span>
</div>
</div>
</div>
<small class="input-hint" style="display:none" data-i18n="streams.capture_template.hint">Engine template defining how the screen is captured</small>
<select id="stream-capture-template"></select>
</div>
<div class="form-group">
<div class="label-row">
<label for="stream-target-fps" data-i18n="streams.target_fps">Target FPS:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="streams.target_fps.hint">Target frames per second for capture (1-90)</small>
<div class="slider-row">
<input type="range" id="stream-target-fps" min="1" max="90" value="30" oninput="document.getElementById('stream-target-fps-value').textContent = this.value">
<span id="stream-target-fps-value" class="slider-value">30</span>
</div>
</div>
</div>
<!-- Processed source fields -->
<div id="stream-processed-fields" style="display: none;">
<div class="form-group">
<div class="label-row">
<label for="stream-source" data-i18n="streams.source">Source:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<!-- Processed source fields -->
<div id="stream-processed-fields" style="display: none;">
<div class="form-group">
<div class="label-row">
<label for="stream-source" data-i18n="streams.source">Source:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="streams.source.hint">The source to apply processing filters to</small>
<select id="stream-source"></select>
</div>
<div class="form-group">
<div class="label-row">
<label for="stream-pp-template" data-i18n="streams.pp_template">Processing Template:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="streams.pp_template.hint">Filter template to apply to the source</small>
<select id="stream-pp-template"></select>
</div>
</div>
<small class="input-hint" style="display:none" data-i18n="streams.source.hint">The source to apply processing filters to</small>
<select id="stream-source"></select>
</div>
<div class="form-group">
<div class="label-row">
<label for="stream-pp-template" data-i18n="streams.pp_template">Processing Template:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="streams.pp_template.hint">Filter template to apply to the source</small>
<select id="stream-pp-template"></select>
</div>
</div>
<!-- Static image fields -->
<div id="stream-static-image-fields" style="display: none;">
<div class="form-group">
<label for="stream-image-asset" data-i18n="streams.image_asset">Image Asset:</label>
<select id="stream-image-asset"></select>
</div>
</div>
<!-- Static image fields -->
<div id="stream-static-image-fields" style="display: none;">
<div class="form-group">
<label for="stream-image-asset" data-i18n="streams.image_asset">Image Asset:</label>
<select id="stream-image-asset"></select>
</div>
</div>
<div id="stream-video-fields" style="display: none;">
<div class="form-group">
<label for="stream-video-asset" data-i18n="streams.video_asset">Video Asset:</label>
<select id="stream-video-asset"></select>
</div>
<div class="form-group settings-toggle-group">
<label data-i18n="picture_source.video.loop">Loop:</label>
<label class="settings-toggle">
<input type="checkbox" id="stream-video-loop" checked>
<span class="settings-toggle-slider"></span>
</label>
</div>
<div class="form-row">
<div class="form-group" style="flex:1">
<label for="stream-video-speed" data-i18n="picture_source.video.speed">Playback Speed: <span id="stream-video-speed-value">1.0</span>×</label>
<input type="range" id="stream-video-speed" min="0.1" max="5" step="0.1" value="1.0" oninput="document.getElementById('stream-video-speed-value').textContent=this.value">
</div>
<div class="form-group" style="flex:1">
<label for="stream-video-fps" data-i18n="streams.target_fps">Target FPS:</label>
<input type="number" id="stream-video-fps" min="1" max="60" step="1" value="30">
<div id="stream-video-fields" style="display: none;">
<div class="form-group">
<label for="stream-video-asset" data-i18n="streams.video_asset">Video Asset:</label>
<select id="stream-video-asset"></select>
</div>
<div class="ds-toggle-row">
<div class="ds-toggle-text">
<div class="label-row">
<label for="stream-video-loop" data-i18n="picture_source.video.loop">Loop:</label>
</div>
</div>
<label class="settings-toggle">
<input type="checkbox" id="stream-video-loop" checked>
<span class="settings-toggle-slider"></span>
</label>
</div>
<div class="ds-pair-row">
<div class="form-group">
<label for="stream-video-speed" data-i18n="picture_source.video.speed">Playback Speed: <span id="stream-video-speed-value">1.0</span>×</label>
<input type="range" id="stream-video-speed" min="0.1" max="5" step="0.1" value="1.0" oninput="document.getElementById('stream-video-speed-value').textContent=this.value">
</div>
<div class="form-group">
<label for="stream-video-fps" data-i18n="streams.target_fps">Target FPS:</label>
<input type="number" id="stream-video-fps" min="1" max="60" step="1" value="30">
</div>
</div>
<div class="ds-pair-row">
<div class="form-group">
<label for="stream-video-start" data-i18n="picture_source.video.start_time">Start Time (s):</label>
<input type="number" id="stream-video-start" min="0" step="0.1">
</div>
<div class="form-group">
<label for="stream-video-end" data-i18n="picture_source.video.end_time">End Time (s):</label>
<input type="number" id="stream-video-end" min="0" step="0.1">
</div>
</div>
<div class="form-group">
<div class="label-row">
<label for="stream-video-resolution" data-i18n="picture_source.video.resolution_limit">Max Width (px):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="picture_source.video.resolution_limit.hint">Downscale video at decode time for performance</small>
<input type="number" id="stream-video-resolution" min="64" max="7680" step="1" placeholder="720">
</div>
</div>
</div>
<div class="form-row">
<div class="form-group" style="flex:1">
<label for="stream-video-start" data-i18n="picture_source.video.start_time">Start Time (s):</label>
<input type="number" id="stream-video-start" min="0" step="0.1">
</div>
<div class="form-group" style="flex:1">
<label for="stream-video-end" data-i18n="picture_source.video.end_time">End Time (s):</label>
<input type="number" id="stream-video-end" min="0" step="0.1">
</div>
</div>
<div class="form-group">
<div class="label-row">
<label for="stream-video-resolution" data-i18n="picture_source.video.resolution_limit">Max Width (px):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="picture_source.video.resolution_limit.hint">Downscale video at decode time for performance</small>
<input type="number" id="stream-video-resolution" min="64" max="7680" step="1" placeholder="720">
</div>
</div>
<div class="form-group">
<label for="stream-description" data-i18n="streams.description_label">Description (optional):</label>
<input type="text" id="stream-description" data-i18n-placeholder="streams.description_placeholder" placeholder="Describe this source...">
</div>
</section>
<div id="stream-error" class="error-message" style="display: none;"></div>
</form>
@@ -1,4 +1,4 @@
<!-- Sync Clock Editor Modal -->
<!-- Sync Clock Editor Modal — sectioned rack-panel. -->
<div id="sync-clock-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="sync-clock-modal-title">
<div class="modal-content">
<div class="modal-header">
@@ -11,37 +11,50 @@
<div id="sync-clock-error" class="error-message" style="display: none;"></div>
<!-- Name -->
<div class="form-group">
<div class="label-row">
<label for="sync-clock-name" data-i18n="sync_clock.name">Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
<!-- ── 01 · IDENTITY ───────────────────────────────── -->
<section class="ds-section" data-ds-key="identity" data-ch="signal">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.identity">Identity</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<small class="input-hint" style="display:none" data-i18n="sync_clock.name.hint">A descriptive name for this synchronization clock</small>
<input type="text" id="sync-clock-name" data-i18n-placeholder="sync_clock.name.placeholder" placeholder="Main Clock" required>
<div id="sync-clock-tags-container"></div>
</div>
<div class="ds-section-body">
<div class="form-group ds-name-group">
<label for="sync-clock-name" data-i18n="sync_clock.name">Name:</label>
<input type="text" id="sync-clock-name" data-i18n-placeholder="sync_clock.name.placeholder" placeholder="Main Clock" required>
<div id="sync-clock-tags-container"></div>
</div>
<!-- Speed -->
<div class="form-group">
<div class="label-row">
<label for="sync-clock-speed"><span data-i18n="sync_clock.speed">Speed:</span> <span id="sync-clock-speed-display">1.0</span>x</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
<div class="form-group">
<div class="label-row">
<label for="sync-clock-description" data-i18n="sync_clock.description">Description (optional):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="sync_clock.description.hint">Optional notes about this clock's purpose</small>
<input type="text" id="sync-clock-description" data-i18n-placeholder="sync_clock.description.placeholder" placeholder="">
</div>
</div>
<small class="input-hint" style="display:none" data-i18n="sync_clock.speed.hint">Animation speed multiplier for all linked sources (0.1x slow - 10x fast)</small>
<input type="range" id="sync-clock-speed" min="0.1" max="10" step="0.1" value="1.0"
oninput="document.getElementById('sync-clock-speed-display').textContent = this.value">
</div>
</section>
<!-- Description -->
<div class="form-group">
<div class="label-row">
<label for="sync-clock-description" data-i18n="sync_clock.description">Description (optional):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
<!-- ── 02 · TIMING ─────────────────────────────────── -->
<section class="ds-section" data-ds-key="timing" data-ch="amber">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.timing">Timing</span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div>
<small class="input-hint" style="display:none" data-i18n="sync_clock.description.hint">Optional notes about this clock's purpose</small>
<input type="text" id="sync-clock-description" data-i18n-placeholder="sync_clock.description.placeholder" placeholder="">
</div>
<div class="ds-section-body">
<div class="form-group">
<div class="label-row">
<label for="sync-clock-speed"><span data-i18n="sync_clock.speed">Speed:</span> <span id="sync-clock-speed-display">1.0</span>x</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="sync_clock.speed.hint">Animation speed multiplier for all linked sources (0.1x slow - 10x fast)</small>
<input type="range" id="sync-clock-speed" min="0.1" max="10" step="0.1" value="1.0"
oninput="document.getElementById('sync-clock-speed-display').textContent = this.value">
</div>
</div>
</section>
</form>
</div>
@@ -1,4 +1,10 @@
<!-- Target Editor Modal (name, device, segments, settings) -->
<!-- Target Editor Modal — sectioned rack-panel layout. Maps the data
flow as a left-to-right pipeline:
Identity (signal) — name + tags
Routing (cyan) — device + color strip source
Output (amber) — brightness + FPS + advanced (collapsible)
The `<details>` advanced collapse is preserved inside the Output
section. All inner element IDs preserved. -->
<div id="target-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="target-editor-title">
<div class="modal-content">
<div class="modal-header">
@@ -9,103 +15,133 @@
<form id="target-editor-form">
<input type="hidden" id="target-editor-id">
<div class="form-group">
<label for="target-editor-name" data-i18n="targets.name">Target Name:</label>
<input type="text" id="target-editor-name" data-i18n-placeholder="targets.name.placeholder" placeholder="My Target" required>
<div id="target-tags-container"></div>
</div>
<div class="form-group">
<div class="label-row">
<label for="target-editor-device" data-i18n="targets.device">Device:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<!-- ── 01 · IDENTITY ───────────────────────────────── -->
<section class="ds-section" data-ds-key="identity" data-ch="signal">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.identity">Identity</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<small class="input-hint" style="display:none" data-i18n="targets.device.hint">Select the LED device to send data to</small>
<select id="target-editor-device"></select>
<small id="target-editor-device-info" class="device-led-info" style="display:none"></small>
</div>
<div class="form-group">
<div class="label-row">
<label for="target-editor-css-source" data-i18n="targets.color_strip_source">Color Strip Source:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<div class="ds-section-body">
<div class="form-group ds-name-group">
<label for="target-editor-name" data-i18n="targets.name">Target Name:</label>
<input type="text" id="target-editor-name" data-i18n-placeholder="targets.name.placeholder" placeholder="My Target" required>
<div id="target-tags-container"></div>
</div>
</div>
<small class="input-hint" style="display:none" data-i18n="targets.color_strip_source.hint">Select the color strip source that provides LED colors for this target</small>
<select id="target-editor-css-source"></select>
</div>
</section>
<div class="form-group">
<div class="label-row">
<label data-i18n="targets.brightness">Brightness:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
<!-- ── 02 · ROUTING ────────────────────────────────── -->
<section class="ds-section" data-ds-key="routing" data-ch="cyan">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.routing">Routing</span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div>
<small class="input-hint" style="display:none" data-i18n="targets.brightness.hint">Output brightness multiplier (01). Can be bound to a value source for dynamic control.</small>
<div id="target-editor-brightness-container"></div>
</div>
<div class="ds-section-body">
<div class="form-group">
<div class="label-row">
<label for="target-editor-device" data-i18n="targets.device">Device:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="targets.device.hint">Select the LED device to send data to</small>
<select id="target-editor-device"></select>
<small id="target-editor-device-info" class="device-led-info" style="display:none"></small>
</div>
<div class="form-group" id="target-editor-fps-group">
<div class="label-row">
<label>
<span data-i18n="targets.fps">Target FPS:</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<div class="form-group">
<div class="label-row">
<label for="target-editor-css-source" data-i18n="targets.color_strip_source">Color Strip Source:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="targets.color_strip_source.hint">Select the color strip source that provides LED colors for this target</small>
<select id="target-editor-css-source"></select>
</div>
</div>
<small class="input-hint" style="display:none" data-i18n="targets.fps.hint">How many frames per second to send to the device (1-90). Higher values give smoother animations but use more bandwidth.</small>
<div id="target-editor-fps-container"></div>
<small id="target-editor-fps-rec" class="input-hint" style="display:none"></small>
</div>
</section>
<details class="form-collapse" id="target-editor-advanced-settings">
<summary data-i18n="targets.section.advanced">Advanced</summary>
<div class="form-collapse-body">
<div class="form-group" id="target-editor-brightness-threshold-group">
<!-- ── 03 · OUTPUT ─────────────────────────────────── -->
<section class="ds-section" data-ds-key="output" data-ch="amber">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.output">Output</span>
<span class="ds-section-index" aria-hidden="true">03</span>
</div>
<div class="ds-section-body">
<div class="form-group">
<div class="label-row">
<label data-i18n="targets.brightness">Brightness:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="targets.brightness.hint">Output brightness multiplier (01). Can be bound to a value source for dynamic control.</small>
<div id="target-editor-brightness-container"></div>
</div>
<div class="form-group" id="target-editor-fps-group">
<div class="label-row">
<label>
<span data-i18n="targets.min_brightness_threshold">Min Brightness Threshold:</span>
<span data-i18n="targets.fps">Target FPS:</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="targets.min_brightness_threshold.hint">Effective output brightness (pixel brightness × device/source brightness) below this value turns LEDs off completely (0 = disabled)</small>
<div id="target-editor-brightness-threshold-container"></div>
<small class="input-hint" style="display:none" data-i18n="targets.fps.hint">How many frames per second to send to the device (1-90). Higher values give smoother animations but use more bandwidth.</small>
<div id="target-editor-fps-container"></div>
<small id="target-editor-fps-rec" class="input-hint" style="display:none"></small>
</div>
<div class="form-group" id="target-editor-adaptive-fps-group">
<div class="label-row">
<label for="target-editor-adaptive-fps" data-i18n="targets.adaptive_fps">Adaptive FPS:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="targets.adaptive_fps.hint">Automatically reduce send rate when the device becomes unresponsive, and gradually recover when it stabilizes. Recommended for WiFi devices with weak signal.</small>
<label class="settings-toggle">
<input type="checkbox" id="target-editor-adaptive-fps">
<span class="settings-toggle-slider"></span>
</label>
</div>
<details class="form-collapse" id="target-editor-advanced-settings">
<summary data-i18n="targets.section.advanced">Advanced</summary>
<div class="form-collapse-body">
<div class="form-group" id="target-editor-brightness-threshold-group">
<div class="label-row">
<label>
<span data-i18n="targets.min_brightness_threshold">Min Brightness Threshold:</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="targets.min_brightness_threshold.hint">Effective output brightness (pixel brightness × device/source brightness) below this value turns LEDs off completely (0 = disabled)</small>
<div id="target-editor-brightness-threshold-container"></div>
</div>
<div class="form-group" id="target-editor-protocol-group">
<div class="label-row">
<label for="target-editor-protocol" data-i18n="targets.protocol">Protocol:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="targets.protocol.hint">DDP sends pixels via fast UDP (recommended). HTTP uses the JSON API — slower but reliable, limited to ~500 LEDs.</small>
<select id="target-editor-protocol">
<option value="ddp">DDP (UDP)</option>
<option value="http">HTTP</option>
</select>
</div>
<div class="form-group" id="target-editor-adaptive-fps-group">
<div class="label-row">
<label for="target-editor-adaptive-fps" data-i18n="targets.adaptive_fps">Adaptive FPS:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="targets.adaptive_fps.hint">Automatically reduce send rate when the device becomes unresponsive, and gradually recover when it stabilizes. Recommended for WiFi devices with weak signal.</small>
<label class="settings-toggle">
<input type="checkbox" id="target-editor-adaptive-fps">
<span class="settings-toggle-slider"></span>
</label>
</div>
<div class="form-group" id="target-editor-keepalive-group">
<div class="label-row">
<label for="target-editor-keepalive-interval">
<span data-i18n="targets.keepalive_interval">Keep Alive Interval:</span>
<span id="target-editor-keepalive-interval-value">1.0</span><span>s</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<div class="form-group" id="target-editor-protocol-group">
<div class="label-row">
<label for="target-editor-protocol" data-i18n="targets.protocol">Protocol:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="targets.protocol.hint">DDP sends pixels via fast UDP (recommended). HTTP uses the JSON API — slower but reliable, limited to ~500 LEDs.</small>
<select id="target-editor-protocol">
<option value="ddp">DDP (UDP)</option>
<option value="http">HTTP</option>
</select>
</div>
<div class="form-group" id="target-editor-keepalive-group">
<div class="label-row">
<label for="target-editor-keepalive-interval">
<span data-i18n="targets.keepalive_interval">Keep Alive Interval:</span>
<span id="target-editor-keepalive-interval-value">1.0</span><span>s</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="targets.keepalive_interval.hint">How often to resend the last frame when the screen is static, to keep the device in live mode (0.5-5.0s)</small>
<input type="range" id="target-editor-keepalive-interval" min="0.5" max="5.0" step="0.5" value="1.0" oninput="document.getElementById('target-editor-keepalive-interval-value').textContent = this.value">
</div>
</div>
<small class="input-hint" style="display:none" data-i18n="targets.keepalive_interval.hint">How often to resend the last frame when the screen is static, to keep the device in live mode (0.5-5.0s)</small>
<input type="range" id="target-editor-keepalive-interval" min="0.5" max="5.0" step="0.5" value="1.0" oninput="document.getElementById('target-editor-keepalive-interval-value').textContent = this.value">
</div>
</details>
</div>
</details>
</section>
<div id="target-editor-error" class="error-message" style="display: none;"></div>
</form>
@@ -1,4 +1,4 @@
<!-- Test Audio Source Modal -->
<!-- Test Audio Source Modal — sectioned rack-panel layout. -->
<div id="test-audio-source-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="test-audio-source-modal-title">
<div class="modal-content">
<div class="modal-header">
@@ -6,22 +6,32 @@
<button class="modal-close-btn" onclick="closeTestAudioSourceModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div>
<div class="modal-body">
<canvas id="audio-test-canvas" class="audio-test-canvas"></canvas>
<div class="audio-test-stats">
<span class="audio-test-stat">
<span class="audio-test-stat-label" data-i18n="audio_source.test.rms">RMS</span>
<span class="audio-test-stat-value" id="audio-test-rms">---</span>
</span>
<span class="audio-test-stat">
<span class="audio-test-stat-label" data-i18n="audio_source.test.peak">Peak</span>
<span class="audio-test-stat-value" id="audio-test-peak">---</span>
</span>
<span class="audio-test-stat">
<span id="audio-test-beat-dot" class="audio-test-beat-dot"></span>
<span class="audio-test-stat-label" data-i18n="audio_source.test.beat">Beat</span>
</span>
</div>
<div id="audio-test-status" class="audio-test-status" data-i18n="audio_source.test.connecting">Connecting...</div>
<!-- ── 01 · PREVIEW ────────────────────────────────── -->
<section class="ds-section" data-ds-key="preview" data-ch="signal">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.preview">Preview</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<div class="ds-section-body">
<canvas id="audio-test-canvas" class="audio-test-canvas"></canvas>
<div class="audio-test-stats">
<span class="audio-test-stat">
<span class="audio-test-stat-label" data-i18n="audio_source.test.rms">RMS</span>
<span class="audio-test-stat-value" id="audio-test-rms">---</span>
</span>
<span class="audio-test-stat">
<span class="audio-test-stat-label" data-i18n="audio_source.test.peak">Peak</span>
<span class="audio-test-stat-value" id="audio-test-peak">---</span>
</span>
<span class="audio-test-stat">
<span id="audio-test-beat-dot" class="audio-test-beat-dot"></span>
<span class="audio-test-stat-label" data-i18n="audio_source.test.beat">Beat</span>
</span>
</div>
<div id="audio-test-status" class="audio-test-status" data-i18n="audio_source.test.connecting">Connecting...</div>
</div>
</section>
</div>
</div>
</div>
@@ -1,4 +1,4 @@
<!-- Test Audio Template Modal -->
<!-- Test Audio Template Modal — sectioned rack-panel layout. -->
<div id="test-audio-template-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="test-audio-template-modal-title">
<div class="modal-content">
<div class="modal-header">
@@ -6,35 +6,46 @@
<button class="modal-close-btn" onclick="closeTestAudioTemplateModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div>
<div class="modal-body">
<div class="form-group">
<div class="label-row">
<label for="test-audio-template-device" data-i18n="audio_template.test.device">Audio Device:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<!-- ── 01 · TEST ───────────────────────────────────── -->
<section class="ds-section" data-ds-key="test" data-ch="cyan">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.test">Test</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<small class="input-hint" style="display:none" data-i18n="audio_template.test.device.hint">Select which audio device to capture from during the test</small>
<select id="test-audio-template-device"></select>
</div>
<div class="ds-section-body">
<div class="form-group">
<div class="label-row">
<label for="test-audio-template-device" data-i18n="audio_template.test.device">Audio Device:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="audio_template.test.device.hint">Select which audio device to capture from during the test</small>
<select id="test-audio-template-device"></select>
</div>
<button type="button" id="test-audio-template-start-btn" class="btn btn-primary" onclick="startAudioTemplateTest()" style="margin-top: 8px;">
<svg class="icon" viewBox="0 0 24 24"><path d="M14 2v6a2 2 0 0 0 .245.96l5.51 10.08A2 2 0 0 1 18 22H6a2 2 0 0 1-1.755-2.96l5.51-10.08A2 2 0 0 0 10 8V2"/><path d="M6.453 15h11.094"/><path d="M8.5 2h7"/></svg> <span data-i18n="audio_template.test.run">Run</span>
</button>
<button type="button" id="test-audio-template-start-btn" class="btn btn-primary" onclick="startAudioTemplateTest()" style="margin-top: 8px;">
<svg class="icon" viewBox="0 0 24 24"><path d="M14 2v6a2 2 0 0 0 .245.96l5.51 10.08A2 2 0 0 1 18 22H6a2 2 0 0 1-1.755-2.96l5.51-10.08A2 2 0 0 0 10 8V2"/><path d="M6.453 15h11.094"/><path d="M8.5 2h7"/></svg>
<span data-i18n="audio_template.test.run">Run</span>
</button>
<canvas id="audio-template-test-canvas" class="audio-test-canvas" style="display:none; margin-top: 12px;"></canvas>
<div id="audio-template-test-stats" class="audio-test-stats" style="display:none;">
<span class="audio-test-stat">
<span class="audio-test-stat-label" data-i18n="audio_source.test.rms">RMS</span>
<span class="audio-test-stat-value" id="audio-template-test-rms">---</span>
</span>
<span class="audio-test-stat">
<span class="audio-test-stat-label" data-i18n="audio_source.test.peak">Peak</span>
<span class="audio-test-stat-value" id="audio-template-test-peak">---</span>
</span>
<span class="audio-test-stat">
<span id="audio-template-test-beat-dot" class="audio-test-beat-dot"></span>
<span class="audio-test-stat-label" data-i18n="audio_source.test.beat">Beat</span>
</span>
</div>
<div id="audio-template-test-status" class="audio-test-status" style="display:none;"></div>
<canvas id="audio-template-test-canvas" class="audio-test-canvas" style="display:none; margin-top: 12px;"></canvas>
<div id="audio-template-test-stats" class="audio-test-stats" style="display:none;">
<span class="audio-test-stat">
<span class="audio-test-stat-label" data-i18n="audio_source.test.rms">RMS</span>
<span class="audio-test-stat-value" id="audio-template-test-rms">---</span>
</span>
<span class="audio-test-stat">
<span class="audio-test-stat-label" data-i18n="audio_source.test.peak">Peak</span>
<span class="audio-test-stat-value" id="audio-template-test-peak">---</span>
</span>
<span class="audio-test-stat">
<span id="audio-template-test-beat-dot" class="audio-test-beat-dot"></span>
<span class="audio-test-stat-label" data-i18n="audio_source.test.beat">Beat</span>
</span>
</div>
<div id="audio-template-test-status" class="audio-test-status" style="display:none;"></div>
</div>
</section>
</div>
</div>
</div>
@@ -1,4 +1,10 @@
<!-- Test Color Strip Source Modal -->
<!-- Test Color Strip Source Modal — sectioned rack-panel layout.
Channels:
Preview (signal) — all four view modes (strip / rect / layers / KC)
Controls (cyan) — input source select + LED+FPS row + FPS chart + status
The view-mode toggle remains a JS-driven display:none/'' on the
internal `#css-test-*-view` containers; the wrapping section just
gives them a channel-coded frame. -->
<div id="test-css-source-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="test-css-source-modal-title">
<div class="modal-content">
<div class="modal-header">
@@ -6,82 +12,102 @@
<button class="modal-close-btn" onclick="closeTestCssSourceModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div>
<div class="modal-body">
<!-- Strip view (for generic sources) -->
<div id="css-test-strip-view" class="css-test-strip-wrap">
<canvas id="css-test-strip-canvas" class="css-test-strip-canvas"></canvas>
<canvas id="css-test-strip-axis" class="css-test-strip-axis"></canvas>
<button id="css-test-fire-btn" class="css-test-fire-btn" style="display:none"
onclick="fireCssTestNotification()"
data-i18n-title="color_strip.notification.test"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/><path d="M4 2C2.8 3.7 2 5.7 2 8"/><path d="M22 8c0-2.3-.8-4.3-2-6"/></svg></button>
</div>
<!-- ── 01 · PREVIEW ────────────────────────────────── -->
<section class="ds-section" data-ds-key="preview" data-ch="signal">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.preview">Preview</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<div class="ds-section-body">
<!-- Strip view (for generic sources) -->
<div id="css-test-strip-view" class="css-test-strip-wrap">
<canvas id="css-test-strip-canvas" class="css-test-strip-canvas"></canvas>
<canvas id="css-test-strip-axis" class="css-test-strip-axis"></canvas>
<button id="css-test-fire-btn" class="css-test-fire-btn" style="display:none"
onclick="fireCssTestNotification()"
data-i18n-title="color_strip.notification.test"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/><path d="M4 2C2.8 3.7 2 5.7 2 8"/><path d="M22 8c0-2.3-.8-4.3-2-6"/></svg></button>
</div>
<!-- Rectangle view (for picture sources) -->
<div id="css-test-rect-view" style="display:none">
<div class="css-test-rect-outer">
<canvas id="css-test-rect-ticks" class="css-test-rect-ticks"></canvas>
<div class="css-test-rect" id="css-test-rect">
<div class="css-test-rect-corner"></div>
<div class="css-test-edge-wrap"><canvas id="css-test-edge-top" class="css-test-edge"></canvas></div>
<div class="css-test-rect-corner"></div>
<!-- Rectangle view (for picture sources) -->
<div id="css-test-rect-view" style="display:none">
<div class="css-test-rect-outer">
<canvas id="css-test-rect-ticks" class="css-test-rect-ticks"></canvas>
<div class="css-test-rect" id="css-test-rect">
<div class="css-test-rect-corner"></div>
<div class="css-test-edge-wrap"><canvas id="css-test-edge-top" class="css-test-edge"></canvas></div>
<div class="css-test-rect-corner"></div>
<div class="css-test-edge-wrap"><canvas id="css-test-edge-left" class="css-test-edge"></canvas></div>
<div id="css-test-rect-screen" class="css-test-rect-screen">
<span id="css-test-rect-name" class="css-test-rect-label"></span>
<span id="css-test-rect-leds" class="css-test-rect-label css-test-rect-leds"></span>
<div class="css-test-edge-wrap"><canvas id="css-test-edge-left" class="css-test-edge"></canvas></div>
<div id="css-test-rect-screen" class="css-test-rect-screen">
<span id="css-test-rect-name" class="css-test-rect-label"></span>
<span id="css-test-rect-leds" class="css-test-rect-label css-test-rect-leds"></span>
</div>
<div class="css-test-edge-wrap"><canvas id="css-test-edge-right" class="css-test-edge"></canvas></div>
<div class="css-test-rect-corner"></div>
<div class="css-test-edge-wrap"><canvas id="css-test-edge-bottom" class="css-test-edge"></canvas></div>
<div class="css-test-rect-corner"></div>
</div>
</div>
<div class="css-test-edge-wrap"><canvas id="css-test-edge-right" class="css-test-edge"></canvas></div>
</div>
<div class="css-test-rect-corner"></div>
<div class="css-test-edge-wrap"><canvas id="css-test-edge-bottom" class="css-test-edge"></canvas></div>
<div class="css-test-rect-corner"></div>
<!-- Composite layers view -->
<div id="css-test-layers-view" style="display:none">
<div id="css-test-layers" class="css-test-layers"></div>
<canvas id="css-test-layers-axis" class="css-test-strip-axis"></canvas>
</div>
<!-- Key Colors view (frame + region overlays) -->
<div id="css-test-kc-view" style="display:none">
<div class="css-test-kc-wrap">
<canvas id="css-test-kc-canvas" class="css-test-kc-canvas"></canvas>
</div>
<div id="css-test-kc-meta" class="css-test-kc-meta"></div>
</div>
</div>
</div>
</section>
<!-- Composite layers view -->
<div id="css-test-layers-view" style="display:none">
<div id="css-test-layers" class="css-test-layers"></div>
<canvas id="css-test-layers-axis" class="css-test-strip-axis"></canvas>
</div>
<!-- Key Colors view (frame + region overlays) -->
<div id="css-test-kc-view" style="display:none">
<div class="css-test-kc-wrap">
<canvas id="css-test-kc-canvas" class="css-test-kc-canvas"></canvas>
<!-- ── 02 · CONTROLS ───────────────────────────────── -->
<section class="ds-section" data-ds-key="controls" data-ch="cyan">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.controls">Controls</span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div>
<div id="css-test-kc-meta" class="css-test-kc-meta"></div>
</div>
<div class="ds-section-body">
<!-- CSPT test: input source selector (hidden by default) -->
<div id="css-test-cspt-input-group" style="display:none" class="css-test-led-control">
<label for="css-test-cspt-input-select" data-i18n="color_strip.processed.input">Source:</label>
<select id="css-test-cspt-input-select" class="css-test-cspt-select" onchange="applyCssTestSettings()"></select>
</div>
<!-- CSPT test: input source selector (hidden by default) -->
<div id="css-test-cspt-input-group" style="display:none" class="css-test-led-control">
<label for="css-test-cspt-input-select" data-i18n="color_strip.processed.input">Source:</label>
<select id="css-test-cspt-input-select" class="css-test-cspt-select" onchange="applyCssTestSettings()"></select>
</div>
<!-- LED count & FPS controls -->
<div id="css-test-led-fps-group" class="css-test-led-control">
<span id="css-test-led-group">
<label for="css-test-led-input" data-i18n="color_strip.test.led_count">LEDs:</label>
<input type="number" id="css-test-led-input" min="1" max="2000" step="1" value="100" class="css-test-led-input">
<span class="css-test-separator"></span>
</span>
<label for="css-test-fps-input" data-i18n="color_strip.test.fps">FPS:</label>
<input type="number" id="css-test-fps-input" min="1" max="60" step="1" value="20" class="css-test-led-input">
<button class="btn btn-icon btn-sm btn-secondary css-test-led-apply" onclick="applyCssTestSettings()" title="Apply" data-i18n-title="color_strip.test.apply">&#x2713;</button>
</div>
<!-- LED count & FPS controls -->
<div id="css-test-led-fps-group" class="css-test-led-control">
<span id="css-test-led-group">
<label for="css-test-led-input" data-i18n="color_strip.test.led_count">LEDs:</label>
<input type="number" id="css-test-led-input" min="1" max="2000" step="1" value="100" class="css-test-led-input">
<span class="css-test-separator"></span>
</span>
<label for="css-test-fps-input" data-i18n="color_strip.test.fps">FPS:</label>
<input type="number" id="css-test-fps-input" min="1" max="60" step="1" value="20" class="css-test-led-input">
<button class="btn btn-icon btn-sm btn-secondary css-test-led-apply" onclick="applyCssTestSettings()" title="Apply" data-i18n-title="color_strip.test.apply">&#x2713;</button>
</div>
<!-- FPS chart (for api_input sources) — matches target card sparkline -->
<div id="css-test-fps-chart-group" class="target-fps-row" style="display:none">
<div class="target-fps-sparkline">
<canvas id="css-test-fps-chart"></canvas>
</div>
<div class="target-fps-label">
<span id="css-test-fps-value" class="metric-value">0</span>
<span class="target-fps-avg" id="css-test-fps-avg"></span>
</div>
</div>
<!-- FPS chart (for api_input sources) — matches target card sparkline -->
<div id="css-test-fps-chart-group" class="target-fps-row" style="display:none">
<div class="target-fps-sparkline">
<canvas id="css-test-fps-chart"></canvas>
<div id="css-test-status" class="css-test-status" data-i18n="color_strip.test.connecting">Connecting...</div>
</div>
<div class="target-fps-label">
<span id="css-test-fps-value" class="metric-value">0</span>
<span class="target-fps-avg" id="css-test-fps-avg"></span>
</div>
</div>
<div id="css-test-status" class="css-test-status" data-i18n="color_strip.test.connecting">Connecting...</div>
</section>
</div>
</div>
</div>
@@ -1,4 +1,4 @@
<!-- Test PP Template Modal -->
<!-- Test Processing Template Modal — sectioned rack-panel layout. -->
<div id="test-pp-template-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="test-pp-template-modal-title">
<div class="modal-content">
<div class="modal-header">
@@ -6,22 +6,33 @@
<button class="modal-close-btn" onclick="closeTestPPTemplateModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label data-i18n="postprocessing.test.source_stream">Source:</label>
<select id="test-pp-source-stream"></select>
</div>
<div class="form-group">
<label for="test-pp-duration">
<span data-i18n="streams.test.duration">Capture Duration (s):</span>
<span id="test-pp-duration-value">5</span>
</label>
<input type="range" id="test-pp-duration" min="1" max="10" step="1" value="5" oninput="updatePPTestDuration(this.value)" />
</div>
<!-- ── 01 · TEST ───────────────────────────────────── -->
<section class="ds-section" data-ds-key="test" data-ch="cyan">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.test">Test</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<div class="ds-section-body">
<div class="form-group">
<label data-i18n="postprocessing.test.source_stream">Source:</label>
<select id="test-pp-source-stream"></select>
</div>
<button type="button" class="btn btn-primary" onclick="runPPTemplateTest()" style="margin-top: 16px;">
<svg class="icon" viewBox="0 0 24 24"><path d="M14 2v6a2 2 0 0 0 .245.96l5.51 10.08A2 2 0 0 1 18 22H6a2 2 0 0 1-1.755-2.96l5.51-10.08A2 2 0 0 0 10 8V2"/><path d="M6.453 15h11.094"/><path d="M8.5 2h7"/></svg> <span data-i18n="streams.test.run">Run</span>
</button>
<div class="form-group">
<label for="test-pp-duration">
<span data-i18n="streams.test.duration">Capture Duration (s):</span>
<span id="test-pp-duration-value">5</span>
</label>
<input type="range" id="test-pp-duration" min="1" max="10" step="1" value="5" oninput="updatePPTestDuration(this.value)" />
</div>
<button type="button" class="btn btn-primary" onclick="runPPTemplateTest()" style="margin-top: 8px;">
<svg class="icon" viewBox="0 0 24 24"><path d="M14 2v6a2 2 0 0 0 .245.96l5.51 10.08A2 2 0 0 1 18 22H6a2 2 0 0 1-1.755-2.96l5.51-10.08A2 2 0 0 0 10 8V2"/><path d="M6.453 15h11.094"/><path d="M8.5 2h7"/></svg>
<span data-i18n="streams.test.run">Run</span>
</button>
</div>
</section>
</div>
</div>
</div>
@@ -1,4 +1,4 @@
<!-- Test Source Modal -->
<!-- Test Source Modal — sectioned rack-panel layout. -->
<div id="test-stream-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="test-stream-modal-title">
<div class="modal-content">
<div class="modal-header">
@@ -6,18 +6,28 @@
<button class="modal-close-btn" onclick="closeTestStreamModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="test-stream-duration">
<span data-i18n="streams.test.duration">Capture Duration (s):</span>
<span id="test-stream-duration-value">5</span>
</label>
<input type="range" id="test-stream-duration" min="1" max="10" step="1" value="5" oninput="updateStreamTestDuration(this.value)" />
</div>
<button type="button" class="btn btn-primary" onclick="runStreamTest()" style="margin-top: 16px;">
<svg class="icon" viewBox="0 0 24 24"><path d="M14 2v6a2 2 0 0 0 .245.96l5.51 10.08A2 2 0 0 1 18 22H6a2 2 0 0 1-1.755-2.96l5.51-10.08A2 2 0 0 0 10 8V2"/><path d="M6.453 15h11.094"/><path d="M8.5 2h7"/></svg> <span data-i18n="streams.test.run">Run</span>
</button>
<!-- ── 01 · TEST ───────────────────────────────────── -->
<section class="ds-section" data-ds-key="test" data-ch="cyan">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.test">Test</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<div class="ds-section-body">
<div class="form-group">
<label for="test-stream-duration">
<span data-i18n="streams.test.duration">Capture Duration (s):</span>
<span id="test-stream-duration-value">5</span>
</label>
<input type="range" id="test-stream-duration" min="1" max="10" step="1" value="5" oninput="updateStreamTestDuration(this.value)" />
</div>
<button type="button" class="btn btn-primary" onclick="runStreamTest()" style="margin-top: 8px;">
<svg class="icon" viewBox="0 0 24 24"><path d="M14 2v6a2 2 0 0 0 .245.96l5.51 10.08A2 2 0 0 1 18 22H6a2 2 0 0 1-1.755-2.96l5.51-10.08A2 2 0 0 0 10 8V2"/><path d="M6.453 15h11.094"/><path d="M8.5 2h7"/></svg>
<span data-i18n="streams.test.run">Run</span>
</button>
</div>
</section>
</div>
</div>
</div>
@@ -1,4 +1,4 @@
<!-- Test Template Modal -->
<!-- Test Capture Template Modal — sectioned rack-panel layout. -->
<div id="test-template-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="test-template-modal-title">
<div class="modal-content">
<div class="modal-header">
@@ -6,26 +6,36 @@
<button class="modal-close-btn" onclick="closeTestTemplateModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label data-i18n="templates.test.display">Display:</label>
<input type="hidden" id="test-template-display" value="">
<button type="button" class="btn btn-display-picker" id="test-display-picker-btn" onclick="openDisplayPicker(onTestDisplaySelected, document.getElementById('test-template-display').value, window._engineHasOwnDisplays?.(window.currentTestingTemplate?.engine_type) ? window.currentTestingTemplate.engine_type : null)">
<span id="test-display-picker-label" data-i18n="displays.picker.select">Select display...</span>
</button>
</div>
<!-- ── 01 · TEST ───────────────────────────────────── -->
<section class="ds-section" data-ds-key="test" data-ch="cyan">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.test">Test</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<div class="ds-section-body">
<div class="form-group">
<label data-i18n="templates.test.display">Display:</label>
<input type="hidden" id="test-template-display" value="">
<button type="button" class="btn btn-display-picker" id="test-display-picker-btn" onclick="openDisplayPicker(onTestDisplaySelected, document.getElementById('test-template-display').value, window._engineHasOwnDisplays?.(window.currentTestingTemplate?.engine_type) ? window.currentTestingTemplate.engine_type : null)">
<span id="test-display-picker-label" data-i18n="displays.picker.select">Select display...</span>
</button>
</div>
<div class="form-group">
<label for="test-template-duration">
<span data-i18n="templates.test.duration">Capture Duration (s):</span>
<span id="test-template-duration-value">5</span>
</label>
<input type="range" id="test-template-duration" min="1" max="10" step="1" value="5" oninput="updateCaptureDuration(this.value)" />
</div>
<button type="button" class="btn btn-primary" onclick="runTemplateTest()" style="margin-top: 16px;">
<svg class="icon" viewBox="0 0 24 24"><path d="M14 2v6a2 2 0 0 0 .245.96l5.51 10.08A2 2 0 0 1 18 22H6a2 2 0 0 1-1.755-2.96l5.51-10.08A2 2 0 0 0 10 8V2"/><path d="M6.453 15h11.094"/><path d="M8.5 2h7"/></svg> <span data-i18n="templates.test.run">Run</span>
</button>
<div class="form-group">
<label for="test-template-duration">
<span data-i18n="templates.test.duration">Capture Duration (s):</span>
<span id="test-template-duration-value">5</span>
</label>
<input type="range" id="test-template-duration" min="1" max="10" step="1" value="5" oninput="updateCaptureDuration(this.value)" />
</div>
<button type="button" class="btn btn-primary" onclick="runTemplateTest()" style="margin-top: 8px;">
<svg class="icon" viewBox="0 0 24 24"><path d="M14 2v6a2 2 0 0 0 .245.96l5.51 10.08A2 2 0 0 1 18 22H6a2 2 0 0 1-1.755-2.96l5.51-10.08A2 2 0 0 0 10 8V2"/><path d="M6.453 15h11.094"/><path d="M8.5 2h7"/></svg>
<span data-i18n="templates.test.run">Run</span>
</button>
</div>
</section>
</div>
</div>
</div>
@@ -1,4 +1,4 @@
<!-- Test Value Source Modal -->
<!-- Test Value Source Modal — sectioned rack-panel layout. -->
<div id="test-value-source-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="test-value-source-modal-title">
<div class="modal-content">
<div class="modal-header">
@@ -6,25 +6,35 @@
<button class="modal-close-btn" onclick="closeTestValueSourceModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div>
<div class="modal-body">
<canvas id="vs-test-canvas" class="vs-test-canvas"></canvas>
<div id="vs-test-color-swatch" class="vs-test-color-swatch" style="display:none">
<canvas id="vs-test-color-canvas" class="vs-test-color-canvas"></canvas>
</div>
<div class="vs-test-stats">
<span class="vs-test-stat vs-test-stat-current">
<span class="vs-test-stat-label" data-i18n="value_source.test.current">Current</span>
<span class="vs-test-stat-value vs-test-value-large" id="vs-test-current">---</span>
</span>
<span class="vs-test-stat">
<span class="vs-test-stat-label" data-i18n="value_source.test.min">Min</span>
<span class="vs-test-stat-value" id="vs-test-min">---</span>
</span>
<span class="vs-test-stat">
<span class="vs-test-stat-label" data-i18n="value_source.test.max">Max</span>
<span class="vs-test-stat-value" id="vs-test-max">---</span>
</span>
</div>
<div id="vs-test-status" class="vs-test-status" data-i18n="value_source.test.connecting">Connecting...</div>
<!-- ── 01 · PREVIEW ────────────────────────────────── -->
<section class="ds-section" data-ds-key="preview" data-ch="signal">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.preview">Preview</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<div class="ds-section-body">
<canvas id="vs-test-canvas" class="vs-test-canvas"></canvas>
<div id="vs-test-color-swatch" class="vs-test-color-swatch" style="display:none">
<canvas id="vs-test-color-canvas" class="vs-test-color-canvas"></canvas>
</div>
<div class="vs-test-stats">
<span class="vs-test-stat vs-test-stat-current">
<span class="vs-test-stat-label" data-i18n="value_source.test.current">Current</span>
<span class="vs-test-stat-value vs-test-value-large" id="vs-test-current">---</span>
</span>
<span class="vs-test-stat">
<span class="vs-test-stat-label" data-i18n="value_source.test.min">Min</span>
<span class="vs-test-stat-value" id="vs-test-min">---</span>
</span>
<span class="vs-test-stat">
<span class="vs-test-stat-label" data-i18n="value_source.test.max">Max</span>
<span class="vs-test-stat-value" id="vs-test-max">---</span>
</span>
</div>
<div id="vs-test-status" class="vs-test-status" data-i18n="value_source.test.connecting">Connecting...</div>
</div>
</section>
</div>
</div>
</div>
@@ -11,16 +11,34 @@
<div id="value-source-error" class="error-message" style="display: none;"></div>
<!-- Name -->
<div class="form-group">
<div class="label-row">
<label for="value-source-name" data-i18n="value_source.name">Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
<!-- ── 01 · IDENTITY ───────────────────────────────── -->
<section class="ds-section" data-ds-key="identity" data-ch="signal">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.identity">Identity</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.name.hint">A descriptive name for this value source</small>
<input type="text" id="value-source-name" data-i18n-placeholder="value_source.name.placeholder" placeholder="Brightness Pulse" required>
<div id="value-source-tags-container"></div>
</div>
<div class="ds-section-body">
<div class="form-group ds-name-group">
<div class="label-row">
<label for="value-source-name" data-i18n="value_source.name">Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.name.hint">A descriptive name for this value source</small>
<input type="text" id="value-source-name" data-i18n-placeholder="value_source.name.placeholder" placeholder="Brightness Pulse" required>
<div id="value-source-tags-container"></div>
</div>
</div>
</section>
<!-- ── 02 · TYPE ───────────────────────────────────── -->
<section class="ds-section" data-ds-key="type" data-ch="cyan">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.type">Type</span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div>
<div class="ds-section-body">
<!-- Type (hidden in edit mode since type is immutable) -->
<div id="value-source-type-group" class="form-group">
@@ -621,15 +639,27 @@
</div>
</div>
<!-- Description -->
<div class="form-group">
<div class="label-row">
<label for="value-source-description" data-i18n="value_source.description">Description (optional):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.description.hint">Optional notes about this value source</small>
<input type="text" id="value-source-description" data-i18n-placeholder="value_source.description.placeholder" placeholder="Describe this value source...">
</div>
</section>
<!-- ── 03 · NOTES ──────────────────────────────────── -->
<section class="ds-section" data-ds-key="notes" data-ch="amber">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.notes">Notes</span>
<span class="ds-section-index" aria-hidden="true">03</span>
</div>
<div class="ds-section-body">
<div class="form-group">
<div class="label-row">
<label for="value-source-description" data-i18n="value_source.description">Description (optional):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.description.hint">Optional notes about this value source</small>
<input type="text" id="value-source-description" data-i18n-placeholder="value_source.description.placeholder" placeholder="Describe this value source...">
</div>
</div>
</section>
</form>
</div>
@@ -1,4 +1,7 @@
<!-- Weather Source Editor Modal -->
<!-- Weather Source Editor Modal — sectioned rack-panel layout matching the
settings-modal-redesign vocabulary. Channels: signal (identity),
cyan (provider + location), amber (refresh cadence). All inner
element IDs preserved for streams.ts/weather.ts compatibility. -->
<div id="weather-source-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="weather-source-modal-title">
<div class="modal-content">
<div class="modal-header">
@@ -11,71 +14,92 @@
<div id="weather-source-error" class="error-message" style="display: none;"></div>
<!-- Name -->
<div class="form-group">
<div class="label-row">
<label for="weather-source-name" data-i18n="weather_source.name">Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
<!-- ── 01 · IDENTITY ───────────────────────────────── -->
<section class="ds-section" data-ds-key="identity" data-ch="signal">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.identity">Identity</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<small class="input-hint" style="display:none" data-i18n="weather_source.name.hint">A descriptive name for this weather source</small>
<input type="text" id="weather-source-name" data-i18n-placeholder="weather_source.name.placeholder" placeholder="My Weather" required>
<div id="weather-source-tags-container"></div>
</div>
<!-- Provider -->
<div class="form-group">
<div class="label-row">
<label for="weather-source-provider" data-i18n="weather_source.provider">Provider:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="weather_source.provider.hint">Weather data provider. Open-Meteo is free and requires no API key.</small>
<select id="weather-source-provider">
<option value="open_meteo">Open-Meteo</option>
</select>
</div>
<!-- Location -->
<div class="form-group">
<div class="label-row">
<label data-i18n="weather_source.location">Location:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="weather_source.location.hint">Your geographic coordinates. Use the auto-detect button or enter manually.</small>
<div class="weather-location-row">
<div class="weather-location-field">
<label for="weather-source-latitude" data-i18n="weather_source.latitude">Lat:</label>
<input type="number" id="weather-source-latitude" min="-90" max="90" step="0.01" value="50.0">
<div class="ds-section-body">
<div class="form-group ds-name-group">
<label for="weather-source-name" data-i18n="weather_source.name">Name:</label>
<input type="text" id="weather-source-name" data-i18n-placeholder="weather_source.name.placeholder" placeholder="My Weather" required>
<div id="weather-source-tags-container"></div>
</div>
<div class="weather-location-field">
<label for="weather-source-longitude" data-i18n="weather_source.longitude">Lon:</label>
<input type="number" id="weather-source-longitude" min="-180" max="180" step="0.01" value="0.0">
<div class="form-group">
<div class="label-row">
<label for="weather-source-description" data-i18n="weather_source.description">Description (optional):</label>
</div>
<input type="text" id="weather-source-description" data-i18n-placeholder="weather_source.description.placeholder" placeholder="">
</div>
<button type="button" class="btn btn-icon btn-secondary" id="weather-source-geolocate-btn"
onclick="weatherSourceGeolocate()" title="Use my location" data-i18n-title="weather_source.use_my_location">
<svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>
</button>
</div>
<small id="weather-source-location-name" class="input-hint" style="display:none"></small>
</div>
</section>
<!-- Update Interval -->
<div class="form-group">
<div class="label-row">
<label for="weather-source-interval"><span data-i18n="weather_source.update_interval">Update Interval:</span> <span id="weather-source-interval-display">10</span> min</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
<!-- ── 02 · PROVIDER ───────────────────────────────── -->
<section class="ds-section" data-ds-key="provider" data-ch="cyan">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.provider">Provider</span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div>
<small class="input-hint" style="display:none" data-i18n="weather_source.update_interval.hint">How often to fetch weather data. Lower values give more responsive changes.</small>
<input type="range" id="weather-source-interval" min="60" max="3600" step="60" value="600"
oninput="document.getElementById('weather-source-interval-display').textContent = Math.round(this.value / 60)">
</div>
<div class="ds-section-body">
<div class="form-group">
<div class="label-row">
<label for="weather-source-provider" data-i18n="weather_source.provider">Provider:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="weather_source.provider.hint">Weather data provider. Open-Meteo is free and requires no API key.</small>
<select id="weather-source-provider">
<option value="open_meteo">Open-Meteo</option>
</select>
</div>
<!-- Description -->
<div class="form-group">
<div class="label-row">
<label for="weather-source-description" data-i18n="weather_source.description">Description (optional):</label>
<div class="form-group">
<div class="label-row">
<label data-i18n="weather_source.location">Location:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="weather_source.location.hint">Your geographic coordinates. Use the auto-detect button or enter manually.</small>
<div class="weather-location-row">
<div class="weather-location-field">
<label for="weather-source-latitude" data-i18n="weather_source.latitude">Lat:</label>
<input type="number" id="weather-source-latitude" min="-90" max="90" step="0.01" value="50.0">
</div>
<div class="weather-location-field">
<label for="weather-source-longitude" data-i18n="weather_source.longitude">Lon:</label>
<input type="number" id="weather-source-longitude" min="-180" max="180" step="0.01" value="0.0">
</div>
<button type="button" class="btn btn-icon btn-secondary" id="weather-source-geolocate-btn"
onclick="weatherSourceGeolocate()" title="Use my location" data-i18n-title="weather_source.use_my_location">
<svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>
</button>
</div>
<small id="weather-source-location-name" class="input-hint" style="display:none"></small>
</div>
</div>
<input type="text" id="weather-source-description" data-i18n-placeholder="weather_source.description.placeholder" placeholder="">
</div>
</section>
<!-- ── 03 · REFRESH ────────────────────────────────── -->
<section class="ds-section" data-ds-key="refresh" data-ch="amber">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.refresh">Refresh</span>
<span class="ds-section-index" aria-hidden="true">03</span>
</div>
<div class="ds-section-body">
<div class="form-group">
<div class="label-row">
<label for="weather-source-interval"><span data-i18n="weather_source.update_interval">Update Interval:</span> <span id="weather-source-interval-display">10</span> min</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="weather_source.update_interval.hint">How often to fetch weather data. Lower values give more responsive changes.</small>
<input type="range" id="weather-source-interval" min="60" max="3600" step="60" value="600"
oninput="document.getElementById('weather-source-interval-display').textContent = Math.round(this.value / 60)">
</div>
</div>
</section>
</form>
</div>
@@ -1,33 +1,61 @@
<!-- Getting Started Tutorial Overlay (viewport-level) -->
<div id="getting-started-overlay" class="tutorial-overlay tutorial-overlay-fixed" role="dialog" aria-modal="true">
<!-- Getting Started Tutorial Overlay (viewport-level) — v2 "Signal Bench" variant -->
<div id="getting-started-overlay" class="tutorial-overlay tutorial-overlay-fixed tutorial-v2" role="dialog" aria-modal="true">
<div class="tutorial-backdrop"></div>
<div class="tutorial-ring"></div>
<svg class="tutorial-cable" aria-hidden="true">
<line x1="0" y1="0" x2="0" y2="0"></line>
</svg>
<div class="tutorial-ring">
<span class="tutorial-reticle-corner tl"></span>
<span class="tutorial-reticle-corner tr"></span>
<span class="tutorial-reticle-corner bl"></span>
<span class="tutorial-reticle-corner br"></span>
</div>
<div class="tutorial-tooltip">
<div class="tutorial-tooltip-header">
<span class="tutorial-tooltip-eyebrow">SIGNAL · TOUR</span>
<span class="tutorial-tooltip-breadcrumb"></span>
<span class="tutorial-step-counter"></span>
<button class="tutorial-close-btn" onclick="closeTutorial()" data-i18n-aria-label="aria.close">&times;</button>
</div>
<div class="tutorial-pips" aria-hidden="true"></div>
<p class="tutorial-tooltip-text"></p>
<div class="tutorial-tooltip-nav">
<button class="tutorial-prev-btn" onclick="tutorialPrev()" data-i18n-aria-label="aria.previous">&#8592;</button>
<button class="tutorial-next-btn" onclick="tutorialNext()" data-i18n-aria-label="aria.next">&#8594;</button>
<button class="tutorial-prev-btn" onclick="tutorialPrev()" data-i18n-aria-label="aria.previous">&#9664; PREV</button>
<button class="tutorial-next-btn" onclick="tutorialNext()" data-i18n-aria-label="aria.next">NEXT &#9654;</button>
</div>
<div class="tutorial-keyhint" aria-hidden="true">
<kbd>&#8592;</kbd><kbd>&#8594;</kbd><span>NAVIGATE</span><kbd>ESC</kbd><span>CLOSE</span>
</div>
</div>
</div>
<!-- Device Tutorial Overlay (viewport-level) -->
<div id="device-tutorial-overlay" class="tutorial-overlay tutorial-overlay-fixed" role="dialog" aria-modal="true">
<!-- Device Tutorial Overlay (viewport-level) — v2 "Signal Bench" variant -->
<div id="device-tutorial-overlay" class="tutorial-overlay tutorial-overlay-fixed tutorial-v2" role="dialog" aria-modal="true">
<div class="tutorial-backdrop"></div>
<div class="tutorial-ring"></div>
<svg class="tutorial-cable" aria-hidden="true">
<line x1="0" y1="0" x2="0" y2="0"></line>
</svg>
<div class="tutorial-ring">
<span class="tutorial-reticle-corner tl"></span>
<span class="tutorial-reticle-corner tr"></span>
<span class="tutorial-reticle-corner bl"></span>
<span class="tutorial-reticle-corner br"></span>
</div>
<div class="tutorial-tooltip">
<div class="tutorial-tooltip-header">
<span class="tutorial-tooltip-eyebrow">DEVICE · TOUR</span>
<span class="tutorial-tooltip-breadcrumb"></span>
<span class="tutorial-step-counter"></span>
<button class="tutorial-close-btn" onclick="closeTutorial()" data-i18n-aria-label="aria.close">&times;</button>
</div>
<div class="tutorial-pips" aria-hidden="true"></div>
<p class="tutorial-tooltip-text"></p>
<div class="tutorial-tooltip-nav">
<button class="tutorial-prev-btn" onclick="tutorialPrev()" data-i18n-aria-label="aria.previous">&#8592;</button>
<button class="tutorial-next-btn" onclick="tutorialNext()" data-i18n-aria-label="aria.next">&#8594;</button>
<button class="tutorial-prev-btn" onclick="tutorialPrev()" data-i18n-aria-label="aria.previous">&#9664; PREV</button>
<button class="tutorial-next-btn" onclick="tutorialNext()" data-i18n-aria-label="aria.next">NEXT &#9654;</button>
</div>
<div class="tutorial-keyhint" aria-hidden="true">
<kbd>&#8592;</kbd><kbd>&#8594;</kbd><span>NAVIGATE</span><kbd>ESC</kbd><span>CLOSE</span>
</div>
</div>
</div>
+133
View File
@@ -0,0 +1,133 @@
"""Tests for AdalightClient frame construction (the LED hot path)."""
import numpy as np
import pytest
from ledgrab.core.devices.adalight_client import (
AdalightClient,
_build_adalight_header,
)
def _make_client(led_count: int = 10) -> AdalightClient:
"""Build a client without opening the serial port."""
return AdalightClient(url="COM99", led_count=led_count)
def test_adalight_header_format():
# Adalight protocol: 'A' 'd' 'a' <count_hi> <count_lo> <checksum>
# where count = led_count - 1, checksum = hi ^ lo ^ 0x55
header = _build_adalight_header(10)
count = 9
hi = (count >> 8) & 0xFF
lo = count & 0xFF
expected_checksum = hi ^ lo ^ 0x55
assert header == bytes([ord("A"), ord("d"), ord("a"), hi, lo, expected_checksum])
def test_build_frame_uint8_fast_path():
client = _make_client(led_count=3)
pixels = np.array(
[[10, 20, 30], [40, 50, 60], [70, 80, 90]],
dtype=np.uint8,
)
frame = client._build_frame(pixels, brightness=255)
# Header (6 bytes) + RGB (9 bytes)
assert len(frame) == 6 + 9
assert bytes(frame[:6]) == client._header
assert bytes(frame[6:]) == bytes([10, 20, 30, 40, 50, 60, 70, 80, 90])
def test_build_frame_reuses_buffer_across_calls():
client = _make_client(led_count=3)
pixels = np.array([[1, 2, 3]] * 3, dtype=np.uint8)
frame1 = client._build_frame(pixels, brightness=255)
buf_id_1 = id(client._frame_buf)
frame2 = client._build_frame(pixels, brightness=255)
buf_id_2 = id(client._frame_buf)
# Same bytearray reused — frame returned IS the internal buffer
assert buf_id_1 == buf_id_2
assert frame1 is frame2
# Header preserved at front, payload identical
assert bytes(frame2[:6]) == client._header
def test_build_frame_handles_non_contiguous_input():
"""Slicing a wider array yields a non-contiguous view; payload must
still serialise to the same bytes as a contiguous copy."""
client = _make_client(led_count=4)
wide = np.arange(48, dtype=np.uint8).reshape(4, 4, 3)
pixels = wide[:, 1, :] # (4, 3) view, possibly non-contiguous
frame = client._build_frame(pixels, brightness=255)
expected = np.ascontiguousarray(pixels).tobytes()
assert bytes(frame[6:]) == expected
def test_build_frame_clamps_wider_int_dtypes():
"""Wider integer inputs (e.g. uint16 from legacy code) clamp to [0, 255]
before narrowing, matching historical behaviour."""
client = _make_client(led_count=2)
pixels = np.array([[100, 200, 300], [400, 50, 25]], dtype=np.uint16)
frame = client._build_frame(pixels, brightness=255)
# Values >255 clamp to 255, values <=255 pass through.
assert bytes(frame[6:]) == bytes([100, 200, 255, 255, 50, 25])
def test_build_frame_accepts_list_of_tuples():
"""Legacy callers pass list[tuple]; should produce same output."""
client = _make_client(led_count=2)
pixels = [(10, 20, 30), (40, 50, 60)]
frame = client._build_frame(pixels, brightness=255)
assert bytes(frame[6:]) == bytes([10, 20, 30, 40, 50, 60])
def test_build_frame_resizes_buffer_on_led_count_change():
"""If led count changes between calls, the buffer is reallocated."""
client = _make_client(led_count=5)
small = np.zeros((3, 3), dtype=np.uint8)
big = np.zeros((100, 3), dtype=np.uint8)
client._build_frame(small, brightness=255)
small_len = len(client._frame_buf)
client._build_frame(big, brightness=255)
big_len = len(client._frame_buf)
# 6-byte header + 3-byte/LED RGB
assert small_len == 6 + 3 * 3
assert big_len == 6 + 100 * 3
def test_build_frame_no_uint16_round_trip():
"""The hot path must NOT promote uint8 → uint16 → uint8.
Asserts the scratch buffer (used only for non-uint8 input) is never
allocated when the input is already uint8.
"""
client = _make_client(led_count=3)
pixels = np.array([[1, 2, 3]] * 3, dtype=np.uint8)
assert client._u8_scratch is None
client._build_frame(pixels, brightness=255)
# Scratch never touched on the fast path.
assert client._u8_scratch is None
@pytest.mark.parametrize("led_count", [1, 50, 300, 1000])
def test_build_frame_total_length(led_count):
client = _make_client(led_count=led_count)
pixels = np.zeros((led_count, 3), dtype=np.uint8)
frame = client._build_frame(pixels, brightness=255)
assert len(frame) == 6 + led_count * 3
+170
View File
@@ -0,0 +1,170 @@
"""Tests for DDP client packet construction (the LED hot path)."""
import struct
from unittest.mock import MagicMock
import numpy as np
import pytest
from ledgrab.core.devices.ddp_client import DDPClient
def _make_client(rgbw: bool = False) -> DDPClient:
"""Build a DDPClient with a mocked transport that captures sendto bytes."""
client = DDPClient(host="127.0.0.1", rgbw=rgbw)
transport = MagicMock()
captured: list[bytes] = []
def _sendto(data, addr=None): # noqa: ARG001
# asyncio's sendto accepts bytes-like; copy to bytes for assertion stability
captured.append(bytes(data))
transport.sendto.side_effect = _sendto
client._transport = transport
client._captured = captured # type: ignore[attr-defined]
return client
def _parse_header(packet: bytes) -> dict:
"""Parse a 10-byte DDP header back into named fields."""
flags, seq, dtype, src, offset, dlen = struct.unpack("!BBB B I H", packet[:10])
return {
"flags": flags,
"seq": seq,
"type": dtype,
"src": src,
"offset": offset,
"data_len": dlen,
"payload": packet[10:],
}
def test_send_pixels_numpy_single_packet_rgb():
client = _make_client(rgbw=False)
pixels = np.array(
[[10, 20, 30], [40, 50, 60], [70, 80, 90]],
dtype=np.uint8,
)
client.send_pixels_numpy(pixels)
assert len(client._captured) == 1
parsed = _parse_header(client._captured[0])
# PUSH on the only packet, VER=1
assert parsed["flags"] == 0x40 | 0x01
assert parsed["type"] == 0x01
assert parsed["offset"] == 0
assert parsed["data_len"] == 9
assert parsed["payload"] == bytes([10, 20, 30, 40, 50, 60, 70, 80, 90])
def test_send_pixels_numpy_increments_sequence():
client = _make_client(rgbw=False)
pixels = np.array([[1, 2, 3]], dtype=np.uint8)
client.send_pixels_numpy(pixels)
client.send_pixels_numpy(pixels)
client.send_pixels_numpy(pixels)
seqs = [_parse_header(p)["seq"] for p in client._captured]
assert seqs == [1, 2, 3]
def test_send_pixels_numpy_chunks_large_payload():
"""A 1000-LED payload (3000 bytes) splits into multiple packets."""
client = _make_client(rgbw=False)
n = 1000
pixels = np.arange(n * 3, dtype=np.uint8).reshape(n, 3)
client.send_pixels_numpy(pixels, max_packet_size=1400)
# max_payload = 1390, bytes_per_packet = 1389 (multiple of 3) → 3 packets
assert len(client._captured) >= 2
headers = [_parse_header(p) for p in client._captured]
# Only the last packet has PUSH
assert (headers[-1]["flags"] & 0x01) == 0x01
for h in headers[:-1]:
assert (h["flags"] & 0x01) == 0
# Offsets are contiguous and lengths sum to total
total = sum(h["data_len"] for h in headers)
assert total == n * 3
expected_offset = 0
for h in headers:
assert h["offset"] == expected_offset
expected_offset += h["data_len"]
def test_send_pixels_numpy_rgbw_pads_alpha():
client = _make_client(rgbw=True)
pixels = np.array([[1, 2, 3], [4, 5, 6]], dtype=np.uint8)
client.send_pixels_numpy(pixels)
parsed = _parse_header(client._captured[0])
# bpp=4 → payload is 8 bytes, alpha=0
assert parsed["data_len"] == 8
expected = bytes([1, 2, 3, 0, 4, 5, 6, 0])
assert parsed["payload"] == expected
def test_send_pixels_numpy_reuses_send_buffer():
"""The internal send_buf must be allocated lazily and reused — no fresh
bytearray per call."""
client = _make_client(rgbw=False)
pixels = np.array([[1, 2, 3]], dtype=np.uint8)
assert client._send_buf is None
client.send_pixels_numpy(pixels)
first_buf_id = id(client._send_buf)
assert first_buf_id is not None
client.send_pixels_numpy(pixels)
assert id(client._send_buf) == first_buf_id
def test_send_pixels_numpy_growing_payload_grows_buffer():
client = _make_client(rgbw=False)
small = np.array([[1, 2, 3]], dtype=np.uint8)
big = np.zeros((600, 3), dtype=np.uint8)
client.send_pixels_numpy(small)
small_capacity = len(client._send_buf)
client.send_pixels_numpy(big, max_packet_size=2048)
big_capacity = len(client._send_buf)
assert big_capacity >= small_capacity
def test_send_pixels_numpy_handles_non_contiguous():
"""Slicing a wider array yields a non-contiguous view; payload must
still serialise to the same bytes as a contiguous copy."""
client = _make_client(rgbw=False)
wide = np.arange(60, dtype=np.uint8).reshape(5, 4, 3)
# take the middle pixel column → (5, 3) but possibly non-contiguous
pixels = wide[:, 1, :]
client.send_pixels_numpy(pixels)
payload = _parse_header(client._captured[0])["payload"]
assert payload == np.ascontiguousarray(pixels).tobytes()
@pytest.mark.asyncio
async def test_send_pixels_async_delegates_to_numpy():
client = _make_client(rgbw=False)
pixels = [(10, 20, 30), (40, 50, 60)]
result = await client.send_pixels(pixels)
assert result is True
payload = _parse_header(client._captured[0])["payload"]
assert payload == bytes([10, 20, 30, 40, 50, 60])
def test_send_pixels_numpy_raises_when_disconnected():
client = DDPClient(host="127.0.0.1")
pixels = np.array([[1, 2, 3]], dtype=np.uint8)
with pytest.raises(RuntimeError):
client.send_pixels_numpy(pixels)