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 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: class PixelMapper:
"""Maps screen border pixels to LED colors based on calibration.""" """Maps screen border pixels to LED colors based on calibration."""
@@ -280,19 +364,10 @@ class PixelMapper:
indices = (indices + offset) % total_leds indices = (indices + offset) % total_leds
self._segment_indices.append(indices) self._segment_indices.append(indices)
# Pre-compute Phase 3 skip arrays (static geometry) # Pre-compute Phase 3 skip — linear interpolation by precomputed
skip_start = calibration.skip_leds_start # floor/ceil indices and fractional weights. Per-frame work is
skip_end = calibration.skip_leds_end # entirely write-in-place into pre-allocated scratch buffers.
self._skip_start = skip_start _build_skip_buffers(self, calibration, total_leds)
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
# Per-edge average computation cache (lazy-initialized on first frame) # Per-edge average computation cache (lazy-initialized on first frame)
self._edge_cache: Dict[str, tuple] = {} self._edge_cache: Dict[str, tuple] = {}
@@ -357,8 +432,9 @@ class PixelMapper:
) -> np.ndarray: ) -> np.ndarray:
"""Vectorized average-color mapping for one edge. Returns (led_count, 3) uint8. """Vectorized average-color mapping for one edge. Returns (led_count, 3) uint8.
Uses pre-allocated cumsum/mean buffers (lazy-initialized per edge) to Uses pre-allocated cumsum/mean buffers AND pre-allocated output
avoid per-frame allocations that cause GC-induced timing spikes. 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"): if edge_name in ("top", "bottom"):
axis = 0 axis = 0
@@ -369,7 +445,7 @@ class PixelMapper:
# Lazy-init / resize per-edge scratch buffers # Lazy-init / resize per-edge scratch buffers
cache = self._edge_cache.get(edge_name) 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 step = edge_len / led_count
boundaries = (np.arange(led_count + 1, dtype=np.float64) * step).astype(np.int64) boundaries = (np.arange(led_count + 1, dtype=np.float64) * step).astype(np.int64)
boundaries[1:] = np.maximum(boundaries[1:], boundaries[:-1] + 1) boundaries[1:] = np.maximum(boundaries[1:], boundaries[:-1] + 1)
@@ -379,20 +455,53 @@ class PixelMapper:
lengths = (ends - starts).reshape(-1, 1).astype(np.float64) lengths = (ends - starts).reshape(-1, 1).astype(np.float64)
cumsum_buf = np.empty((edge_len + 1, 3), dtype=np.float64) cumsum_buf = np.empty((edge_len + 1, 3), dtype=np.float64)
edge_1d_buf = np.empty((edge_len, 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 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) # Mean into pre-allocated buffer (no intermediate float64 array)
np.mean(edge_pixels, axis=axis, out=edge_1d_buf) 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 cumsum_buf[0] = 0
np.cumsum(edge_1d_buf, axis=0, out=cumsum_buf[1:]) np.cumsum(edge_1d_buf, axis=0, out=cumsum_buf[1:])
segment_sums = cumsum_buf[ends] - cumsum_buf[starts] # segment_sums = cumsum_buf[ends] - cumsum_buf[starts] — but each
return np.clip(segment_sums / lengths, 0, 255).astype(np.uint8) # 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: def map_border_to_leds(self, border_pixels: BorderPixels) -> np.ndarray:
"""Map screen border pixels to LED colors. """Map screen border pixels to LED colors.
@@ -423,18 +532,9 @@ class PixelMapper:
led_array[self._segment_indices[i]] = colors led_array[self._segment_indices[i]] = colors
# Phase 3: Physical skip — resample full perimeter to active LEDs # Phase 3: physical skip — resample full perimeter into active LEDs
if self._skip_src is not None: # using precomputed weights, all in-place.
np.copyto(self._skip_float, led_array, casting="unsafe") _apply_skip_resample(self, led_array)
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
return led_array return led_array
@@ -514,19 +614,8 @@ class AdvancedPixelMapper:
self._line_indices.append(indices) self._line_indices.append(indices)
led_start += line.led_count led_start += line.led_count
# Skip arrays (same logic as PixelMapper) # Skip arrays — share the same buffer layout as PixelMapper
skip_start = calibration.skip_leds_start _build_skip_buffers(self, calibration, total_leds)
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
# Per-line edge cache (keyed by line index to avoid collision) # Per-line edge cache (keyed by line index to avoid collision)
self._edge_cache: Dict[int, tuple] = {} self._edge_cache: Dict[int, tuple] = {}
@@ -586,7 +675,7 @@ class AdvancedPixelMapper:
edge_len = edge_pixels.shape[0] edge_len = edge_pixels.shape[0]
cache = self._edge_cache.get(cache_key) 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 step = edge_len / led_count
boundaries = (np.arange(led_count + 1, dtype=np.float64) * step).astype(np.int64) boundaries = (np.arange(led_count + 1, dtype=np.float64) * step).astype(np.int64)
boundaries[1:] = np.maximum(boundaries[1:], boundaries[:-1] + 1) boundaries[1:] = np.maximum(boundaries[1:], boundaries[:-1] + 1)
@@ -596,15 +685,45 @@ class AdvancedPixelMapper:
lengths = (ends - starts).reshape(-1, 1).astype(np.float64) lengths = (ends - starts).reshape(-1, 1).astype(np.float64)
cumsum_buf = np.empty((edge_len + 1, 3), dtype=np.float64) cumsum_buf = np.empty((edge_len + 1, 3), dtype=np.float64)
edge_1d_buf = np.empty((edge_len, 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 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) np.mean(edge_pixels, axis=axis, out=edge_1d_buf)
cumsum_buf[0] = 0 cumsum_buf[0] = 0
np.cumsum(edge_1d_buf, axis=0, out=cumsum_buf[1:]) np.cumsum(edge_1d_buf, axis=0, out=cumsum_buf[1:])
segment_sums = cumsum_buf[ends] - cumsum_buf[starts] np.take(cumsum_buf, ends, axis=0, out=sums_buf)
return np.clip(segment_sums / lengths, 0, 255).astype(np.uint8) 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( def _map_edge_fallback(
self, self,
@@ -672,18 +791,8 @@ class AdvancedPixelMapper:
led_array[self._line_indices[i]] = colors led_array[self._line_indices[i]] = colors
# Phase 3: Physical skip (same as PixelMapper) # Phase 3: physical skip same precomputed-weight resample as PixelMapper
if self._skip_src is not None: _apply_skip_resample(self, led_array)
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
return led_array return led_array
@@ -1,6 +1,7 @@
"""Adalight serial LED client — sends pixel data over serial using the Adalight protocol.""" """Adalight serial LED client — sends pixel data over serial using the Adalight protocol."""
import asyncio import asyncio
import concurrent.futures
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional, Tuple from typing import Optional, Tuple
@@ -56,15 +57,38 @@ class AdalightClient(LEDClient):
# Pre-compute Adalight header if led_count is known # Pre-compute Adalight header if led_count is known
self._header = _build_adalight_header(led_count) if led_count > 0 else b"" 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 # Pre-allocated wire buffer (header + RGB payload). Resized on the
self._pixel_buf = None # 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: async def connect(self) -> bool:
"""Open serial port and wait for Arduino reset.""" """Open serial port and wait for Arduino reset."""
try: try:
self._serial = open_transport(self._port, baud_rate=self._baud_rate, timeout=1) 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). # Wait for Arduino to finish bootloader reset (non-blocking).
# USB-to-TTL adapters without DTR don't reset, but the delay # USB-to-TTL adapters without DTR don't reset, but the delay
# is harmless on those — keeps the path uniform. # is harmless on those — keeps the path uniform.
@@ -77,11 +101,22 @@ class AdalightClient(LEDClient):
return True return True
except Exception as e: except Exception as e:
logger.error(f"Failed to open serial port {self._port}: {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}") raise RuntimeError(f"Failed to open serial port {self._port}: {e}")
async def close(self) -> None: async def close(self) -> None:
"""Send black frame and close the serial port.""" """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: try:
black = np.zeros((self._led_count, 3), dtype=np.uint8) black = np.zeros((self._led_count, 3), dtype=np.uint8)
frame = self._build_frame(black, brightness=255) frame = self._build_frame(black, brightness=255)
@@ -89,8 +124,8 @@ class AdalightClient(LEDClient):
f"Adalight sending black frame: {self._port} " f"Adalight sending black frame: {self._port} "
f"({self._led_count} LEDs, {len(frame)} bytes)" f"({self._led_count} LEDs, {len(frame)} bytes)"
) )
await asyncio.to_thread(self._serial.write, frame) await loop.run_in_executor(executor, self._serial.write, frame)
await asyncio.to_thread(self._serial.flush) await loop.run_in_executor(executor, self._serial.flush)
logger.info(f"Adalight black frame sent and flushed: {self._port}") logger.info(f"Adalight black frame sent and flushed: {self._port}")
except Exception as e: except Exception as e:
logger.warning(f"Failed to send black frame on close: {e}") logger.warning(f"Failed to send black frame on close: {e}")
@@ -108,6 +143,9 @@ class AdalightClient(LEDClient):
except Exception as e: except Exception as e:
logger.warning(f"Error closing serial port: {e}") logger.warning(f"Error closing serial port: {e}")
self._serial = None 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}") logger.info(f"Adalight disconnected: {self._port}")
@property @property
@@ -125,12 +163,15 @@ class AdalightClient(LEDClient):
pixels: numpy array (N, 3) uint8 or list of (R, G, B) tuples pixels: numpy array (N, 3) uint8 or list of (R, G, B) tuples
brightness: Global brightness (0-255) 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 return False
try: try:
frame = self._build_frame(pixels, brightness) 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 return True
except Exception as e: except Exception as e:
logger.error(f"Adalight send_pixels error: {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 # Serial write is blocking — use async send_pixels path instead
return False return False
def _build_frame(self, pixels, brightness: int) -> bytes: def _ensure_frame_buf(self, n_leds: int) -> None:
"""Build a complete Adalight frame: header + brightness-scaled RGB data.""" """Lazily allocate / resize the wire-format frame buffer.
if isinstance(pixels, np.ndarray):
arr = pixels.astype(np.uint16)
else:
arr = np.array(pixels, dtype=np.uint16)
# Note: brightness already applied by processor loop (_cached_brightness) Header bytes are written once at the front; subsequent calls only
np.clip(arr, 0, 255, out=arr) memcpy the pixel payload into the trailing slot.
rgb_bytes = arr.astype(np.uint8).tobytes() """
return self._header + rgb_bytes 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 @classmethod
async def check_health( 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_FLAGS_PUSH = 0x01 # PUSH flag (set on last packet of a frame)
DDP_TYPE_RGB = 0x01 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): def __init__(self, host: str, port: int = DDP_PORT, rgbw: bool = False):
"""Initialize DDP client. """Initialize DDP client.
@@ -57,6 +60,10 @@ class DDPClient:
# Pre-allocated RGBW buffer (resized on demand) # Pre-allocated RGBW buffer (resized on demand)
self._rgbw_buf: Optional[np.ndarray] = None self._rgbw_buf: Optional[np.ndarray] = None
self._rgbw_buf_n: int = 0 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): async def connect(self):
"""Establish UDP connection.""" """Establish UDP connection."""
@@ -93,52 +100,52 @@ class DDPClient:
f"color order={order_name.get(bus.color_order, '?')} ({bus.color_order})" f"color order={order_name.get(bus.color_order, '?')} ({bus.color_order})"
) )
def _build_ddp_packet( def _ensure_send_buf(self, capacity: int) -> None:
self, """Lazily allocate / grow the per-instance send buffer.
rgb_data: bytes,
offset: int = 0,
sequence: int = 1,
push: bool = False,
) -> bytes:
"""Build a DDP packet.
DDP packet format (10-byte header + data): ``capacity`` is the largest packet we may emit (header + payload).
- Byte 0: Flags (VER1 | PUSH on last packet) Once sized, the buffer is reused for every subsequent send so the
- Byte 1: Sequence number hot path stays allocation-free.
- 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
""" """
flags = self.DDP_FLAGS_VER1 buf = self._send_buf
if push: if buf is None or len(buf) < capacity:
flags |= self.DDP_FLAGS_PUSH self._send_buf = bytearray(capacity)
data_type = self.DDP_TYPE_RGB self._send_view = memoryview(self._send_buf)
source_id = 0x01
data_len = len(rgb_data)
# Build header (10 bytes) def _emit_packet(
header = struct.pack( self,
"!BBB B I H", # Network byte order (big-endian) payload: memoryview,
flags, # Flags offset: int,
sequence, # Sequence sequence: int,
data_type, # Data type push: bool,
source_id, # Source/Destination ) -> None:
offset, # Data offset (4 bytes) """Pack header + payload into the pre-allocated send buffer and emit.
data_len, # Data length (2 bytes)
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
) )
# Copy payload bytes into buffer (single memcpy)
return header + rgb_data 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: def _reorder_pixels_numpy(self, pixel_array: np.ndarray) -> np.ndarray:
"""Apply per-bus color order reordering using numpy fancy indexing. """Apply per-bus color order reordering using numpy fancy indexing.
@@ -168,13 +175,39 @@ class DDPClient:
return result 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( async def send_pixels(
self, pixels: List[Tuple[int, int, int]], max_packet_size: int = 1400 self, pixels: List[Tuple[int, int, int]], max_packet_size: int = 1400
) -> bool: ) -> bool:
"""Send pixel data via DDP. """Send pixel data via DDP.
Args: 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) max_packet_size: Maximum UDP packet size (default 1400 bytes for safety)
Returns: Returns:
@@ -187,65 +220,17 @@ class DDPClient:
raise RuntimeError("DDP client not connected") raise RuntimeError("DDP client not connected")
try: 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 # 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): if isinstance(pixels, np.ndarray):
pixel_array = pixels pixel_array = pixels
else: else:
pixel_array = np.array(pixels, dtype=np.uint8) 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 return True
except Exception as e: 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}") raise RuntimeError(f"DDP send failed: {e}")
def send_pixels_numpy(self, pixel_array: np.ndarray, max_packet_size: int = 1400) -> bool: 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 self._rgbw_buf[:, :3] = pixel_array
pixel_array = self._rgbw_buf pixel_array = self._rgbw_buf
pixel_bytes = pixel_array.tobytes()
bpp = 4 if self.rgbw else 3 bpp = 4 if self.rgbw else 3
total_bytes = len(pixel_bytes) # Get a 1-D bytes view of the pixel buffer with no allocation when
max_payload = max_packet_size - 10 # 10-byte header # the array is already C-contiguous (the common case).
bytes_per_packet = (max_payload // bpp) * bpp if not pixel_array.flags["C_CONTIGUOUS"]:
num_packets = (total_bytes + bytes_per_packet - 1) // bytes_per_packet pixel_array = np.ascontiguousarray(pixel_array)
# ``cast('B')`` on a memoryview of a numpy array returns a 1-D byte
for i in range(num_packets): # view; total length == nbytes.
start = i * bytes_per_packet payload_view = memoryview(pixel_array).cast("B")
end = min(start + bytes_per_packet, total_bytes) self._send_buffer(payload_view, bpp, max_packet_size)
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)
return True return True
async def __aenter__(self): async def __aenter__(self):
@@ -82,10 +82,17 @@ class WledTargetProcessor(TargetProcessor):
self._resolved_display_index: Optional[int] = None self._resolved_display_index: Optional[int] = None
self._device_config = None # populated on start(), typed DeviceConfig 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_key: tuple = (0, 0)
self._fit_cache_src: Optional[np.ndarray] = None self._fit_floor_idx: Optional[np.ndarray] = None
self._fit_cache_dst: 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 self._fit_result_buf: Optional[np.ndarray] = None
# LED preview WebSocket clients # LED preview WebSocket clients
@@ -384,6 +391,69 @@ class WledTargetProcessor(TargetProcessor):
logger.debug("Device probe failed for %s: %s", device_url, e) logger.debug("Device probe failed for %s: %s", device_url, e)
return False 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]: def get_display_index(self) -> Optional[int]:
"""Display index being captured, from the active stream.""" """Display index being captured, from the active stream."""
if self._resolved_display_index is not None: if self._resolved_display_index is not None:
@@ -646,24 +716,57 @@ class WledTargetProcessor(TargetProcessor):
# ----- Private: processing loop ----- # ----- Private: processing loop -----
def _fit_to_device(self, colors: np.ndarray, device_led_count: int) -> np.ndarray: 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) n = len(colors)
if n == device_led_count or device_led_count <= 0: if n == device_led_count or device_led_count <= 0:
return colors return colors
key = (n, device_led_count) key = (n, device_led_count)
if self._fit_cache_key != key: if self._fit_cache_key != key:
self._fit_cache_src = np.linspace(0, 1, n) if device_led_count > 1 and n > 1:
self._fit_cache_dst = np.linspace(0, 1, device_led_count) t = np.arange(device_led_count, dtype=np.float64) * (
self._fit_cache_key = key (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) self._fit_result_buf = np.empty((device_led_count, 3), dtype=np.uint8)
buf = self._fit_result_buf self._fit_cache_key = key
for ch in range(min(colors.shape[1], 3)):
np.copyto( # Source slice: ColorStripStreams produce (N, 3); guard against (N, 4) RGBA.
buf[:, ch], rgb = colors[:, :3] if colors.ndim == 2 and colors.shape[1] > 3 else colors
np.interp(self._fit_cache_dst, self._fit_cache_src, colors[:, ch]),
casting="unsafe", left_u8 = self._fit_left_u8
) right_u8 = self._fit_right_u8
return buf 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: async def _send_to_device(self, send_colors: np.ndarray) -> float:
"""Send colors to LED device and return send time in ms.""" """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_slow_iters: collections.deque = collections.deque(maxlen=50)
_diag_iter_times: collections.deque = collections.deque(maxlen=300) _diag_iter_times: collections.deque = collections.deque(maxlen=300)
# --- Liveness probe + adaptive FPS --- # --- 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 "" _device_url = self._device_config.device_url if self._device_config else ""
_probe_enabled = _device_url.startswith("http") _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_task: Optional[asyncio.Task] = None
_probe_client: Optional[httpx.AsyncClient] = None
if _probe_enabled: 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._effective_fps = self._target_fps
self._device_reachable = None self._device_reachable = None
@@ -816,63 +921,8 @@ class WledTargetProcessor(TargetProcessor):
loop_start = now = time.perf_counter() loop_start = now = time.perf_counter()
target_fps = self._target_fps if self._target_fps > 0 else 30 target_fps = self._target_fps if self._target_fps > 0 else 30
# --- Liveness probe --- # Use effective FPS for frame timing. ``self._effective_fps``
# Collect result as soon as it's done (every iteration) # is mutated by the liveness probe task — read once.
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
effective_fps = self._effective_fps if self._adaptive_fps else target_fps effective_fps = self._effective_fps if self._adaptive_fps else target_fps
self._metrics.fps_effective = effective_fps self._metrics.fps_effective = effective_fps
frame_time = 1.0 / effective_fps frame_time = 1.0 / effective_fps
@@ -992,8 +1042,8 @@ class WledTargetProcessor(TargetProcessor):
await self._broadcast_led_preview(send_colors, cur_brightness) await self._broadcast_led_preview(send_colors, cur_brightness)
_last_preview_broadcast = now _last_preview_broadcast = now
self._metrics.frames_skipped += 1 self._metrics.frames_skipped += 1
self._metrics.fps_current = _fps_current_from_timestamps()
await asyncio.sleep(SKIP_REPOLL) await asyncio.sleep(SKIP_REPOLL)
self._metrics.fps_current = _fps_current_from_timestamps()
continue continue
# Force-send preview when a new client just connected # 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) await self._broadcast_led_preview(send_colors, cur_brightness)
_last_preview_broadcast = now _last_preview_broadcast = now
self._metrics.frames_skipped += 1 self._metrics.frames_skipped += 1
self._metrics.fps_current = _fps_current_from_timestamps()
is_animated = stream.is_animated is_animated = stream.is_animated
repoll = SKIP_REPOLL if is_animated else frame_time repoll = SKIP_REPOLL if is_animated else frame_time
await asyncio.sleep(repoll) await asyncio.sleep(repoll)
self._metrics.fps_current = _fps_current_from_timestamps()
continue continue
prev_frame_ref = frame prev_frame_ref = frame
@@ -1161,9 +1211,9 @@ class WledTargetProcessor(TargetProcessor):
) )
raise raise
finally: finally:
# Clean up probe client # Stop the liveness probe task. ``_run_liveness_probe_loop``
if _probe_client is not None: # owns its own httpx.AsyncClient via ``async with`` so cancelling
await _probe_client.aclose() # the task closes the client cleanly.
if _probe_task is not None and not _probe_task.done(): if _probe_task is not None and not _probe_task.done():
_probe_task.cancel() _probe_task.cancel()
try: try:
+16 -16
View File
@@ -22,8 +22,8 @@ h1 {
on desktop and gracefully reflows on narrow viewports. */ on desktop and gracefully reflows on narrow viewports. */
.ap-grid { .ap-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 8px; gap: 6px;
margin-top: 6px; margin-top: 6px;
} }
@@ -35,8 +35,8 @@ h1 {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
gap: 5px; gap: 4px;
padding: 5px 5px 4px; padding: 4px 4px 3px;
border: 1px solid var(--lux-line, var(--border-color)); border: 1px solid var(--lux-line, var(--border-color));
border-radius: var(--lux-r-md, 8px); border-radius: var(--lux-r-md, 8px);
background: var(--lux-bg-1, var(--card-bg)); background: var(--lux-bg-1, var(--card-bg));
@@ -54,7 +54,7 @@ h1 {
.ap-card.active { .ap-card.active {
border: 2px solid var(--ap-ch); 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), 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); 0 0 16px -4px color-mix(in srgb, var(--ap-ch) 50%, transparent);
} }
@@ -62,19 +62,19 @@ h1 {
.ap-card.active::after { .ap-card.active::after {
content: '\2713'; content: '\2713';
position: absolute; position: absolute;
top: 4px; top: 3px;
right: 6px; right: 4px;
font-size: 0.65rem; font-size: 0.55rem;
font-weight: 700; font-weight: 700;
color: var(--ap-ch); color: var(--ap-ch);
} }
.ap-card-label { .ap-card-label {
font-size: 0.7rem; font-size: 0.62rem;
font-weight: 600; font-weight: 600;
color: var(--lux-ink-dim, var(--text-secondary)); color: var(--lux-ink-dim, var(--text-secondary));
text-align: center; text-align: center;
line-height: 1.2; line-height: 1.15;
letter-spacing: 0.02em; letter-spacing: 0.02em;
} }
@@ -89,24 +89,24 @@ h1 {
aspect-ratio: 1 / 1; aspect-ratio: 1 / 1;
border-radius: var(--lux-r-sm, 4px); border-radius: var(--lux-r-sm, 4px);
border: 1px solid; border: 1px solid;
padding: 7px 6px 5px; padding: 5px 5px 4px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 3px;
overflow: hidden; overflow: hidden;
} }
.ap-card-accent { .ap-card-accent {
width: 24px; width: 18px;
height: 4px; height: 3px;
border-radius: 2px; border-radius: 2px;
margin-bottom: 2px; margin-bottom: 1px;
} }
.ap-card-lines { .ap-card-lines {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 3px; gap: 2px;
} }
.ap-card-lines span { .ap-card-lines span {
+39 -4
View File
@@ -3826,10 +3826,45 @@ body.composite-layer-dragging .composite-layer-drag-handle {
.ds-section { .ds-section {
animation: dsSectionIn 0.35s cubic-bezier(0.16, 1, 0.3, 1) both; 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="identity"] { animation-delay: 0.02s; }
.ds-section[data-ds-key="connection"] { animation-delay: 0.06s; } .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="hardware"] { animation-delay: 0.10s; }
.ds-section[data-ds-key="behavior"] { animation-delay: 0.14s; } .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 { @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) */ /* 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 { import {
startCalibrationTutorial, startDeviceTutorial, startGettingStartedTutorial, startCalibrationTutorial, startDeviceTutorial, startGettingStartedTutorial,
startDashboardTutorial, startTargetsTutorial, startSourcesTutorial, startAutomationsTutorial, startDashboardTutorial, startTargetsTutorial, startSourcesTutorial, startAutomationsTutorial,
startIntegrationsTutorial,
closeTutorial, tutorialNext, tutorialPrev, closeTutorial, tutorialNext, tutorialPrev,
} from './features/tutorials.ts'; } from './features/tutorials.ts';
@@ -289,6 +290,7 @@ Object.assign(window, {
startTargetsTutorial, startTargetsTutorial,
startSourcesTutorial, startSourcesTutorial,
startAutomationsTutorial, startAutomationsTutorial,
startIntegrationsTutorial,
closeTutorial, closeTutorial,
tutorialNext, tutorialNext,
tutorialPrev, tutorialPrev,
+23 -2
View File
@@ -142,14 +142,35 @@ export function set_targetEditorDevices(v: Device[]) { _targetEditorDevices = v;
export const ledPreviewWebSockets: Record<string, WebSocket> = {}; export const ledPreviewWebSockets: Record<string, WebSocket> = {};
// Tutorial state // 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 { export interface TutorialState {
steps: { selector: string; textKey: string; position: string; global?: boolean }[]; steps: TutorialStepShape[];
overlay: HTMLElement; overlay: HTMLElement;
mode: string; mode: string;
step: number; step: number;
resolveTarget: (step: { selector: string; textKey: string; position: string; global?: boolean }) => Element | null; resolveTarget: (step: TutorialStepShape) => Element | null;
container: Element | null; container: Element | null;
onClose: (() => void) | 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 let activeTutorial: TutorialState | null = null;
export function setActiveTutorial(v: TutorialState | null) { activeTutorial = v; } export function setActiveTutorial(v: TutorialState | null) { activeTutorial = v; }
@@ -475,12 +475,15 @@ function _renderLineList(): void {
function _showLineProps(): void { function _showLineProps(): void {
const propsEl = document.getElementById('advcal-line-props')!; const propsEl = document.getElementById('advcal-line-props')!;
const sectionEl = document.getElementById('advcal-line-props-section');
const idx = _state.selectedLine; const idx = _state.selectedLine;
if (idx < 0 || idx >= _state.lines.length) { if (idx < 0 || idx >= _state.lines.length) {
propsEl.style.display = 'none'; propsEl.style.display = 'none';
if (sectionEl) sectionEl.style.display = 'none';
return; return;
} }
propsEl.style.display = ''; propsEl.style.display = '';
if (sectionEl) sectionEl.style.display = '';
const line = _state.lines[idx]; const line = _state.lines[idx];
(document.getElementById('advcal-line-source') as HTMLSelectElement).value = line.picture_source_id; (document.getElementById('advcal-line-source') as HTMLSelectElement).value = line.picture_source_id;
if (_lineSourceEntitySelect) _lineSourceEntitySelect.refresh(); if (_lineSourceEntitySelect) _lineSourceEntitySelect.refresh();
@@ -55,6 +55,8 @@ class CalibrationModal extends Modal {
(document.getElementById('calibration-css-id') as HTMLInputElement).value = ''; (document.getElementById('calibration-css-id') as HTMLInputElement).value = '';
const testGroup = document.getElementById('calibration-css-test-group'); const testGroup = document.getElementById('calibration-css-test-group');
if (testGroup) testGroup.style.display = 'none'; if (testGroup) testGroup.style.display = 'none';
const testSection = document.getElementById('calibration-test-setup-section');
if (testSection) testSection.style.display = 'none';
} else { } else {
const deviceId = (this.$('calibration-device-id') as HTMLInputElement).value; const deviceId = (this.$('calibration-device-id') as HTMLInputElement).value;
if (deviceId) clearTestMode(deviceId); 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('calibration-device-id') as HTMLInputElement).value = device.id;
(document.getElementById('cal-device-led-count-inline') as HTMLElement).textContent = device.led_count; (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'; (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('calibration-overlay-btn') as HTMLElement).style.display = 'none';
(document.getElementById('cal-start-position') as HTMLSelectElement).value = calibration.start_position; (document.getElementById('cal-start-position') as HTMLSelectElement).value = calibration.start_position;
@@ -262,6 +266,8 @@ export async function showCSSCalibration(cssId: any) {
_calTestDeviceList = devices; _calTestDeviceList = devices;
const testGroup = document.getElementById('calibration-css-test-group') as HTMLElement; const testGroup = document.getElementById('calibration-css-test-group') as HTMLElement;
testGroup.style.display = devices.length ? '' : 'none'; 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 // Pre-select device: 1) LED count match, 2) last remembered, 3) first
if (devices.length) { 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 _current: DashboardLayoutV1 = _clone(DEFAULT_LAYOUT, 'studio');
let _serverSyncedOnce = false; let _serverSyncedOnce = false;
const _listeners = new Set<() => void>(); const _listeners = new Set<() => void>();
@@ -300,7 +408,7 @@ export async function syncDashboardLayoutFromServer(): Promise<void> {
/** Persist a layout. Updates in-memory state immediately, debounces /** Persist a layout. Updates in-memory state immediately, debounces
* the network write, and notifies listeners synchronously. */ * the network write, and notifies listeners synchronously. */
export function saveDashboardLayout(next: DashboardLayoutV1): void { 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 */ } try { localStorage.setItem(LS_KEY, JSON.stringify(_current)); } catch { /* quota */ }
_notify(); _notify();
if (_saveTimer) clearTimeout(_saveTimer); if (_saveTimer) clearTimeout(_saveTimer);
@@ -551,7 +659,7 @@ function _mergeWithDefaults(input: unknown): DashboardLayoutV1 {
base.global = { ...base.global, ...obj.global }; base.global = { ...base.global, ...obj.global };
} }
if (typeof obj.presetActive === 'string') base.presetActive = obj.presetActive; base.presetActive = _computeActivePreset(base);
return base; return base;
} }
@@ -586,5 +694,6 @@ function _migrateFromLegacyKeys(): DashboardLayoutV1 {
} catch { /* ignore */ } } catch { /* ignore */ }
} }
layout.presetActive = _computeActivePreset(layout);
return layout; return layout;
} }
@@ -175,7 +175,8 @@ function renderIntegrationsList() {
initHASourceDelegation(container); initHASourceDelegation(container);
initMQTTSourceDelegation(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.update(treeGroups, activeTab);
_integrationsTree.observeSections('integrations-list', { _integrationsTree.observeSections('integrations-list', {
'weather-sources': 'weather', 'weather-sources': 'weather',
@@ -10,6 +10,8 @@ interface TutorialStep {
textKey: string; textKey: string;
position: string; position: string;
global?: boolean; global?: boolean;
/** Optional sub-tab to switch to before highlighting this step. */
subTab?: string;
} }
interface TutorialConfig { interface TutorialConfig {
@@ -19,6 +21,13 @@ interface TutorialConfig {
container: Element | null; container: Element | null;
resolveTarget: (step: TutorialStep) => Element | null; resolveTarget: (step: TutorialStep) => Element | null;
onClose?: (() => void) | 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[] = [ const calibrationTutorialSteps: TutorialStep[] = [
@@ -43,6 +52,7 @@ const gettingStartedSteps: TutorialStep[] = [
{ selector: '#tab-btn-automations', textKey: 'tour.automations', position: 'bottom' }, { selector: '#tab-btn-automations', textKey: 'tour.automations', position: 'bottom' },
{ selector: '#tab-btn-targets', textKey: 'tour.targets', position: 'bottom' }, { selector: '#tab-btn-targets', textKey: 'tour.targets', position: 'bottom' },
{ selector: '#tab-btn-streams', textKey: 'tour.sources', 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: '#tab-btn-graph', textKey: 'tour.graph', position: 'bottom' },
{ selector: 'a.header-link[href="/docs"]', textKey: 'tour.api', position: 'bottom' }, { selector: 'a.header-link[href="/docs"]', textKey: 'tour.api', position: 'bottom' },
{ selector: '[onclick*="openCommandPalette"]', textKey: 'tour.search', position: 'bottom' }, { selector: '[onclick*="openCommandPalette"]', textKey: 'tour.search', position: 'bottom' },
@@ -56,36 +66,57 @@ const gettingStartedSteps: TutorialStep[] = [
const dashboardTutorialSteps: TutorialStep[] = [ const dashboardTutorialSteps: TutorialStep[] = [
{ selector: '[data-dashboard-section="perf"]', textKey: 'tour.dash.perf', position: 'bottom' }, { 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="running"]', textKey: 'tour.dash.running', position: 'bottom' },
{ selector: '[data-dashboard-section="stopped"]', textKey: 'tour.dash.stopped', 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[] = [ const targetsTutorialSteps: TutorialStep[] = [
{ selector: '[data-tree-group="led_group"]', textKey: 'tour.tgt.led_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' }, { 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' }, { selector: '[data-card-section="led-targets"]', textKey: 'tour.tgt.targets', position: 'bottom', subTab: 'led-targets' },
{ selector: '[data-tree-group="kc_group"]', textKey: 'tour.tgt.kc_tab', position: 'right' } { selector: '[data-card-section="ha-light-targets"]', textKey: 'tour.tgt.ha_light_targets', position: 'bottom', subTab: 'ha-light-targets' }
]; ];
const sourcesTourSteps: TutorialStep[] = [ const sourcesTourSteps: TutorialStep[] = [
{ selector: '#streams-tree-nav [data-tree-leaf="raw"]', textKey: 'tour.src.raw', position: 'right' }, { selector: '#streams-tree-nav .tree-dd-trigger', textKey: 'tour.src.nav', position: 'right' },
{ selector: '[data-card-section="raw-templates"]', textKey: 'tour.src.templates', position: 'bottom' }, { selector: '[data-card-section="raw-streams"]', textKey: 'tour.src.raw', position: 'bottom', subTab: 'raw' },
{ selector: '#streams-tree-nav [data-tree-leaf="static_image"]', textKey: 'tour.src.static', position: 'right' }, { selector: '[data-card-section="raw-templates"]', textKey: 'tour.src.templates', position: 'bottom', subTab: 'raw_templates' },
{ selector: '#streams-tree-nav [data-tree-leaf="processed"]', textKey: 'tour.src.processed', position: 'right' }, { selector: '[data-card-section="static-streams"]', textKey: 'tour.src.static', position: 'bottom', subTab: 'static_image' },
{ selector: '#streams-tree-nav [data-tree-leaf="color_strip"]', textKey: 'tour.src.color_strip', position: 'right' }, { selector: '[data-card-section="proc-streams"]', textKey: 'tour.src.processed', position: 'bottom', subTab: 'processed' },
{ selector: '#streams-tree-nav [data-tree-leaf="audio"]', textKey: 'tour.src.audio', position: 'right' }, { selector: '[data-card-section="color-strips"]', textKey: 'tour.src.color_strip', position: 'bottom', subTab: 'color_strip' },
{ selector: '#streams-tree-nav [data-tree-leaf="value"]', textKey: 'tour.src.value', position: 'right' }, { selector: '[data-card-section="audio-capture"]', textKey: 'tour.src.audio', position: 'bottom', subTab: 'audio_capture' },
{ selector: '#streams-tree-nav [data-tree-leaf="sync"]', textKey: 'tour.src.sync', position: 'right' } { 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[] = [ const automationsTutorialSteps: TutorialStep[] = [
{ selector: '[data-card-section="automations"]', textKey: 'tour.auto.list', 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' }, { selector: '[data-cs-add="automations"]', textKey: 'tour.auto.add', position: 'bottom', subTab: 'automations' },
{ selector: '.card[data-automation-id]', textKey: 'tour.auto.card', position: 'bottom' }, { 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' }, { 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' }, { 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' }, { 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 => { const _fixedResolve = (step: TutorialStep): Element | null => {
@@ -97,13 +128,12 @@ const _fixedResolve = (step: TutorialStep): Element | null => {
}; };
const deviceTutorialSteps: TutorialStep[] = [ const deviceTutorialSteps: TutorialStep[] = [
{ selector: '.card-subtitle', textKey: 'device.tip.metadata', position: 'bottom' }, { selector: '.mod-head', textKey: 'device.tip.identity', position: 'bottom' },
{ selector: '.brightness-control', textKey: 'device.tip.brightness', position: 'bottom' }, { selector: '.mod-meta', textKey: 'device.tip.metadata', position: 'bottom' },
{ selector: '.card-actions .btn:nth-child(1)', textKey: 'device.tip.start', position: 'top' }, { selector: '.mod-fader', textKey: 'device.tip.brightness', position: 'bottom' },
{ selector: '.card-actions .btn:nth-child(2)', textKey: 'device.tip.settings', position: 'top' }, { selector: '.mod-foot .mod-btn[onclick*="pingDevice"]', textKey: 'device.tip.ping', position: 'top' },
{ selector: '.card-actions .btn:nth-child(3)', textKey: 'device.tip.capture_settings', position: 'top' }, { selector: '.mod-foot .mod-btn[onclick*="showSettings"]', textKey: 'device.tip.settings', position: 'top' },
{ selector: '.card-actions .btn:nth-child(4)', textKey: 'device.tip.calibrate', position: 'top' }, { selector: '.mod-menu-wrap', textKey: 'device.tip.menu', position: 'left' }
{ selector: '.card-actions .btn:nth-child(5)', textKey: 'device.tip.webui', position: 'top' }
]; ];
export function startTutorial(config: TutorialConfig): void { export function startTutorial(config: TutorialConfig): void {
@@ -120,7 +150,10 @@ export function startTutorial(config: TutorialConfig): void {
step: 0, step: 0,
resolveTarget: config.resolveTarget, resolveTarget: config.resolveTarget,
container: config.container, 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) // 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 { 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({ startTutorial({
steps: dashboardTutorialSteps, steps: dashboardTutorialSteps,
overlayId: 'getting-started-overlay', overlayId: 'getting-started-overlay',
mode: 'fixed', mode: 'fixed',
container: null, 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 { export function startTargetsTutorial(): void {
startTutorial({ startTutorial({
steps: targetsTutorialSteps, steps: targetsTutorialSteps,
overlayId: 'getting-started-overlay', overlayId: 'getting-started-overlay',
mode: 'fixed', mode: 'fixed',
container: null, 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', overlayId: 'getting-started-overlay',
mode: 'fixed', mode: 'fixed',
container: null, 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', overlayId: 'getting-started-overlay',
mode: 'fixed', mode: 'fixed',
container: null, 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 { function _positionSpotlight(target: Element, overlay: HTMLElement, step: TutorialStep, index: number, isFixed: boolean): void {
const targetRect = target.getBoundingClientRect(); const targetRect = target.getBoundingClientRect();
const pad = 6; const pad = 6;
@@ -272,6 +424,8 @@ function _positionSpotlight(target: Element, overlay: HTMLElement, step: Tutoria
h = targetRect.height + pad * 2; h = targetRect.height + pad * 2;
} }
const isV2 = overlay.classList.contains('tutorial-v2');
const backdrop = overlay.querySelector('.tutorial-backdrop') as HTMLElement; const backdrop = overlay.querySelector('.tutorial-backdrop') as HTMLElement;
if (backdrop) { if (backdrop) {
backdrop.style.clipPath = `polygon( backdrop.style.clipPath = `polygon(
@@ -294,18 +448,47 @@ function _positionSpotlight(target: Element, overlay: HTMLElement, step: Tutoria
const textEl = overlay.querySelector('.tutorial-tooltip-text'); const textEl = overlay.querySelector('.tutorial-tooltip-text');
const counterEl = overlay.querySelector('.tutorial-step-counter'); const counterEl = overlay.querySelector('.tutorial-step-counter');
if (textEl) textEl.textContent = t(step.textKey); 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 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 (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) { if (tooltip) {
positionTutorialTooltip(tooltip, x, y, w, h, step.position, isFixed); actualPosition = positionTutorialTooltip(tooltip, x, y, w, h, step.position, isFixed);
tooltip.style.visibility = ''; tooltip.style.visibility = '';
} }
if (ring) ring.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). */ /** 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 = ''; (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); const target = activeTutorial.resolveTarget(step);
if (!target) { if (!target) {
// Auto-skip hidden/missing targets in the current direction // 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 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'); tooltip.setAttribute('style', 'left:-9999px;top:-9999px');
const tooltipH = tooltip.offsetHeight || 150; 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 } right: { x: sx + sw + gap, y: sy + sh / 2 - tooltipH / 2 }
}; };
let chosen = preferred;
let pos = positions[preferred] || positions.bottom; let pos = positions[preferred] || positions.bottom;
const cW = isFixed ? window.innerWidth : activeTutorial!.container!.clientWidth; 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) { 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 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) { if (alt && alt.y >= 0 && alt.y + tooltipH <= cH && alt.x >= 0 && alt.x + tooltipW <= cW) {
pos = alt; 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)); 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`); tooltip.setAttribute('style', `left:${Math.round(pos.x)}px;top:${Math.round(pos.y)}px`);
return chosen;
} }
function handleTutorialKey(e: KeyboardEvent): void { function handleTutorialKey(e: KeyboardEvent): void {
+1
View File
@@ -68,6 +68,7 @@ interface Window {
startTargetsTutorial: (...args: any[]) => any; startTargetsTutorial: (...args: any[]) => any;
startSourcesTutorial: (...args: any[]) => any; startSourcesTutorial: (...args: any[]) => any;
startAutomationsTutorial: (...args: any[]) => any; startAutomationsTutorial: (...args: any[]) => any;
startIntegrationsTutorial: (...args: any[]) => any;
closeTutorial: (...args: any[]) => any; closeTutorial: (...args: any[]) => any;
tutorialNext: (...args: any[]) => any; tutorialNext: (...args: any[]) => any;
tutorialPrev: (...args: any[]) => any; tutorialPrev: (...args: any[]) => any;
+63 -11
View File
@@ -351,14 +351,13 @@
"device.last_seen.hours": "%dh ago", "device.last_seen.hours": "%dh ago",
"device.last_seen.days": "%dd ago", "device.last_seen.days": "%dd ago",
"device.tutorial.start": "Start tutorial", "device.tutorial.start": "Start tutorial",
"device.tip.metadata": "Device info (LED count, type, color channels) is auto-detected from the device", "device.tip.identity": "Device identity — name, type badge, and health indicator showing online/offline status.",
"device.tip.brightness": "Slide to adjust device brightness", "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.brightness": "Bright",
"device.tip.start": "Start or stop screen capture processing", "device.tip.ping": "Ping the device to refresh online status and latency.",
"device.tip.settings": "Configure general device settings (name, URL, health check)", "device.tip.settings": "Open device settings — configure name, URL, capabilities, and health checks.",
"device.tip.capture_settings": "Configure capture settings (display, capture template)", "device.tip.menu": "More actions — duplicate, hide, or delete this device.",
"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.add": "Click here to add a new LED device", "device.tip.add": "Click here to add a new LED device",
"settings.title": "Settings", "settings.title": "Settings",
"settings.tab.general": "General", "settings.tab.general": "General",
@@ -377,6 +376,39 @@
"settings.section.connection": "Connection", "settings.section.connection": "Connection",
"settings.section.hardware": "Hardware", "settings.section.hardware": "Hardware",
"settings.section.behavior": "Behavior", "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.title": "Capture Settings",
"settings.capture.saved": "Capture settings updated", "settings.capture.saved": "Capture settings updated",
"settings.capture.failed": "Failed to save capture settings", "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.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.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.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.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.automations": "Automations — automate scene switching with time, audio, or value rules.",
"tour.settings": "Settings — backup and restore configuration, manage auto-backups.", "tour.settings": "Settings — backup and restore configuration, manage auto-backups.",
@@ -423,21 +456,32 @@
"tour.language": "Language — choose your preferred interface language.", "tour.language": "Language — choose your preferred interface language.",
"tour.restart": "Restart tutorial", "tour.restart": "Restart tutorial",
"tour.dash.perf": "Performance — real-time FPS charts, latency metrics, and poll interval control.", "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.running": "Running targets — live streaming metrics and quick stop control.",
"tour.dash.stopped": "Stopped targets — ready to start with one click.", "tour.dash.stopped": "Stopped targets — ready to start with one click.",
"tour.dash.automations": "Automations — active automation status and quick enable/disable toggle.", "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.devices": "Devices — your LED controllers discovered on the network.",
"tour.tgt.css": "Color Strips — define how screen regions map to LED segments.", "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.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.raw": "Raw — live screen capture sources from your displays.",
"tour.src.templates": "Capture Templates — reusable capture configurations (resolution, FPS, crop).", "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.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.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.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.audio": "Audio — capture microphone/system audio and process it through templates for reactive effects.",
"tour.src.value": "Value numeric data sources used as rules in automations.", "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.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.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.", "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_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_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.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.tutorial.start": "Start tutorial",
"calibration.overlay_toggle": "Overlay", "calibration.overlay_toggle": "Overlay",
"calibration.start_position": "Starting Position:", "calibration.start_position": "Starting Position:",
@@ -880,6 +929,9 @@
"automations.add": "Add Automation", "automations.add": "Add Automation",
"automations.edit": "Edit Automation", "automations.edit": "Edit Automation",
"automations.delete.confirm": "Delete automation \"{name}\"?", "automations.delete.confirm": "Delete automation \"{name}\"?",
"automations.section.triggers": "Triggers",
"automations.section.action": "Action",
"automations.section.deactivation": "Deactivation",
"automations.name": "Name:", "automations.name": "Name:",
"automations.name.hint": "A descriptive name for this automation", "automations.name.hint": "A descriptive name for this automation",
"automations.name.placeholder": "My Automation", "automations.name.placeholder": "My Automation",
+63 -11
View File
@@ -355,14 +355,13 @@
"device.last_seen.hours": "%d ч назад", "device.last_seen.hours": "%d ч назад",
"device.last_seen.days": "%d д назад", "device.last_seen.days": "%d д назад",
"device.tutorial.start": "Начать обучение", "device.tutorial.start": "Начать обучение",
"device.tip.metadata": нформация об устройстве (кол-во LED, тип, цветовые каналы) определяется автоматически", "device.tip.identity": дентификация устройства — имя, тип и индикатор онлайн/офлайн.",
"device.tip.brightness": "Перетащите для регулировки яркости", "device.tip.metadata": "Адрес устройства и версия прошивки. Кликните по URL, чтобы открыть веб-интерфейс устройства.",
"device.tip.brightness": "Перетащите для регулировки яркости. Изменения применяются мгновенно.",
"device.brightness": "Яркость", "device.brightness": "Яркость",
"device.tip.start": "Запуск или остановка захвата экрана", "device.tip.ping": "Пинг устройства — обновляет статус и измеряет задержку.",
"device.tip.settings": "Основные настройки устройства (имя, URL, интервал проверки)", "device.tip.settings": "Открыть настройки устройства имя, URL, возможности и проверки соединения.",
"device.tip.capture_settings": "Настройки захвата (дисплей, шаблон захвата)", "device.tip.menu": "Дополнительные действия — дублировать, скрыть или удалить устройство.",
"device.tip.calibrate": "Калибровка позиций LED, направления и зоны покрытия",
"device.tip.webui": "Открыть встроенный веб-интерфейс устройства для расширенной настройки",
"device.tip.add": "Нажмите, чтобы добавить новое LED устройство", "device.tip.add": "Нажмите, чтобы добавить новое LED устройство",
"settings.title": "Настройки", "settings.title": "Настройки",
"settings.tab.general": "Основные", "settings.tab.general": "Основные",
@@ -381,6 +380,39 @@
"settings.section.connection": "Подключение", "settings.section.connection": "Подключение",
"settings.section.hardware": "Оборудование", "settings.section.hardware": "Оборудование",
"settings.section.behavior": "Поведение", "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.title": "Настройки Захвата",
"settings.capture.saved": "Настройки захвата обновлены", "settings.capture.saved": "Настройки захвата обновлены",
"settings.capture.failed": "Не удалось сохранить настройки захвата", "settings.capture.failed": "Не удалось сохранить настройки захвата",
@@ -417,6 +449,7 @@
"tour.dashboard": "Дашборд — обзор запущенных целей, автоматизаций и состояния устройств.", "tour.dashboard": "Дашборд — обзор запущенных целей, автоматизаций и состояния устройств.",
"tour.targets": "Цели — добавляйте WLED-устройства, настраивайте LED-цели с захватом и калибровкой.", "tour.targets": "Цели — добавляйте WLED-устройства, настраивайте LED-цели с захватом и калибровкой.",
"tour.sources": "Источники — управление шаблонами захвата, источниками изображений, звука и цветовых полос.", "tour.sources": "Источники — управление шаблонами захвата, источниками изображений, звука и цветовых полос.",
"tour.integrations": "Интеграции — подключайте внешние сервисы: погоду, Home Assistant, MQTT и игровые интеграции.",
"tour.graph": "Граф — визуальный обзор всех сущностей и их связей. Перетаскивайте порты для соединения, правый клик по связям для отключения.", "tour.graph": "Граф — визуальный обзор всех сущностей и их связей. Перетаскивайте порты для соединения, правый клик по связям для отключения.",
"tour.automations": "Автоматизации — автоматизируйте переключение сцен по расписанию, звуку или значениям.", "tour.automations": "Автоматизации — автоматизируйте переключение сцен по расписанию, звуку или значениям.",
"tour.settings": "Настройки — резервное копирование и восстановление конфигурации.", "tour.settings": "Настройки — резервное копирование и восстановление конфигурации.",
@@ -427,21 +460,32 @@
"tour.language": "Язык — выберите предпочитаемый язык интерфейса.", "tour.language": "Язык — выберите предпочитаемый язык интерфейса.",
"tour.restart": "Запустить тур заново", "tour.restart": "Запустить тур заново",
"tour.dash.perf": "Производительность — графики FPS в реальном времени, метрики задержки и интервал опроса.", "tour.dash.perf": "Производительность — графики FPS в реальном времени, метрики задержки и интервал опроса.",
"tour.dash.targets": "Каналы — все ваши цели в одном месте, разделённые на запущенные и остановленные.",
"tour.dash.running": "Запущенные цели — метрики стриминга и быстрая остановка.", "tour.dash.running": "Запущенные цели — метрики стриминга и быстрая остановка.",
"tour.dash.stopped": "Остановленные цели — готовы к запуску одним нажатием.", "tour.dash.stopped": "Остановленные цели — готовы к запуску одним нажатием.",
"tour.dash.automations": "Автоматизации — статус активных автоматизаций и быстрое включение/выключение.", "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.devices": "Устройства — ваши LED-контроллеры, найденные в сети.",
"tour.tgt.css": "Цветовые полосы — определите, как области экрана соответствуют сегментам LED.", "tour.tgt.css": "Цветовые полосы — определите, как области экрана соответствуют сегментам LED.",
"tour.tgt.targets": "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.raw": "Raw — источники захвата экрана с ваших дисплеев.",
"tour.src.templates": "Шаблоны захвата — переиспользуемые конфигурации (разрешение, FPS, обрезка).", "tour.src.templates": "Шаблоны захвата — переиспользуемые конфигурации (разрешение, FPS, обрезка).",
"tour.src.static": "Статичные изображения — тестируйте настройку с файлами изображений.", "tour.src.static": "Статичные изображения — тестируйте настройку с файлами изображений.",
"tour.src.processed": "Обработка — применяйте эффекты: размытие, яркость, цветокоррекция.", "tour.src.processed": "Обработка — применяйте эффекты: размытие, яркость, цветокоррекция.",
"tour.src.color_strip": "Цветовые полосы — определяют, как области экрана сопоставляются с LED-сегментами.", "tour.src.color_strip": "Цветовые полосы — определяют, как области экрана сопоставляются с LED-сегментами.",
"tour.src.audio": "Аудио — анализ микрофона или системного звука для реактивных LED-эффектов.", "tour.src.audio": "Аудио — захват микрофона или системного звука и обработка через шаблоны для реактивных эффектов.",
"tour.src.value": "Значениячисловые источники данных для условий автоматизаций.", "tour.src.value": "Источники значенийдинамические числа или цвета, управляемые временем суток, аудио, системными метриками, сущностями Home Assistant, градиентами или расписаниями. Используются для анимации эффектов, модуляции цветовых полос и триггеров автоматизаций.",
"tour.src.sync": "Синхро-часы — общие таймеры для синхронизации анимаций между несколькими источниками.", "tour.src.sync": "Синхро-часы — общие таймеры для синхронизации анимаций между несколькими источниками.",
"tour.auto.list": "Автоматизации — автоматизируйте активацию сцен по времени, звуку или значениям.", "tour.auto.list": "Автоматизации — автоматизируйте активацию сцен по времени, звуку или значениям.",
"tour.auto.add": "Нажмите + для создания новой автоматизации с условиями и сценой для активации.", "tour.auto.add": "Нажмите + для создания новой автоматизации с условиями и сценой для активации.",
@@ -449,6 +493,11 @@
"tour.auto.scenes_list": "Сцены — сохранённые состояния системы, которые автоматизации могут активировать или вы можете применить вручную.", "tour.auto.scenes_list": "Сцены — сохранённые состояния системы, которые автоматизации могут активировать или вы можете применить вручную.",
"tour.auto.scenes_add": "Нажмите + для захвата текущего состояния системы как нового пресета сцены.", "tour.auto.scenes_add": "Нажмите + для захвата текущего состояния системы как нового пресета сцены.",
"tour.auto.scenes_card": "Каждая карточка сцены показывает количество целей/устройств. Нажмите для редактирования, перезахвата или активации.", "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.tutorial.start": "Начать обучение",
"calibration.overlay_toggle": "Оверлей", "calibration.overlay_toggle": "Оверлей",
"calibration.start_position": "Начальная Позиция:", "calibration.start_position": "Начальная Позиция:",
@@ -861,6 +910,9 @@
"automations.add": "Добавить автоматизацию", "automations.add": "Добавить автоматизацию",
"automations.edit": "Редактировать автоматизацию", "automations.edit": "Редактировать автоматизацию",
"automations.delete.confirm": "Удалить автоматизацию \"{name}\"?", "automations.delete.confirm": "Удалить автоматизацию \"{name}\"?",
"automations.section.triggers": "Триггеры",
"automations.section.action": "Действие",
"automations.section.deactivation": "Деактивация",
"automations.name": "Название:", "automations.name": "Название:",
"automations.name.hint": "Описательное имя для автоматизации", "automations.name.hint": "Описательное имя для автоматизации",
"automations.name.placeholder": "Моя автоматизация", "automations.name.placeholder": "Моя автоматизация",
+63 -11
View File
@@ -355,14 +355,13 @@
"device.last_seen.hours": "%d小时前", "device.last_seen.hours": "%d小时前",
"device.last_seen.days": "%d天前", "device.last_seen.days": "%d天前",
"device.tutorial.start": "开始教程", "device.tutorial.start": "开始教程",
"device.tip.metadata": "设备信息(LED 数量、类型、颜色通道)从设备自动检测", "device.tip.identity": "设备标识 — 名称、类型徽章和在线/离线状态指示。",
"device.tip.brightness": "滑动调节设备亮度", "device.tip.metadata": "设备地址和固件版本。点击 URL 可打开设备的内置 Web 界面。",
"device.tip.brightness": "拖动调节设备亮度。更改将立即发送到设备。",
"device.brightness": "亮度", "device.brightness": "亮度",
"device.tip.start": "启动或停止屏幕采集处理", "device.tip.ping": "Ping 设备 — 刷新在线状态并测量延迟。",
"device.tip.settings": "配置设备常规设置(名称、地址、健康检查", "device.tip.settings": "打开设备设置 — 配置名称、地址、能力和健康检查",
"device.tip.capture_settings": "配置采集设置(显示器、采集模板)", "device.tip.menu": "更多操作 — 复制、隐藏或删除此设备。",
"device.tip.calibrate": "校准 LED 位置、方向和覆盖范围",
"device.tip.webui": "打开设备内置的 Web 界面进行高级配置",
"device.tip.add": "点击此处添加新的 LED 设备", "device.tip.add": "点击此处添加新的 LED 设备",
"settings.title": "设置", "settings.title": "设置",
"settings.tab.general": "常规", "settings.tab.general": "常规",
@@ -381,6 +380,39 @@
"settings.section.connection": "连接", "settings.section.connection": "连接",
"settings.section.hardware": "硬件", "settings.section.hardware": "硬件",
"settings.section.behavior": "行为", "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.title": "采集设置",
"settings.capture.saved": "采集设置已更新", "settings.capture.saved": "采集设置已更新",
"settings.capture.failed": "保存采集设置失败", "settings.capture.failed": "保存采集设置失败",
@@ -417,6 +449,7 @@
"tour.dashboard": "仪表盘 — 实时查看运行中的目标、自动化和设备状态。", "tour.dashboard": "仪表盘 — 实时查看运行中的目标、自动化和设备状态。",
"tour.targets": "目标 — 添加 WLED 设备,配置 LED 目标的捕获设置和校准。", "tour.targets": "目标 — 添加 WLED 设备,配置 LED 目标的捕获设置和校准。",
"tour.sources": "来源 — 管理捕获模板、图片来源、音频来源和色带。", "tour.sources": "来源 — 管理捕获模板、图片来源、音频来源和色带。",
"tour.integrations": "集成 — 连接外部服务:天气、Home Assistant、MQTT 和游戏集成。",
"tour.graph": "图表 — 所有实体及其连接的可视化概览。拖动端口进行连接,右键单击边线断开连接。", "tour.graph": "图表 — 所有实体及其连接的可视化概览。拖动端口进行连接,右键单击边线断开连接。",
"tour.automations": "自动化 — 通过时间、音频或数值条件自动切换场景。", "tour.automations": "自动化 — 通过时间、音频或数值条件自动切换场景。",
"tour.settings": "设置 — 备份和恢复配置,管理自动备份。", "tour.settings": "设置 — 备份和恢复配置,管理自动备份。",
@@ -427,21 +460,32 @@
"tour.language": "语言 — 选择您偏好的界面语言。", "tour.language": "语言 — 选择您偏好的界面语言。",
"tour.restart": "重新开始导览", "tour.restart": "重新开始导览",
"tour.dash.perf": "性能 — 实时 FPS 图表、延迟指标和轮询间隔控制。", "tour.dash.perf": "性能 — 实时 FPS 图表、延迟指标和轮询间隔控制。",
"tour.dash.targets": "通道 — 您的所有目标集中显示,按运行中和已停止分组。",
"tour.dash.running": "运行中的目标 — 实时流媒体指标和快速停止控制。", "tour.dash.running": "运行中的目标 — 实时流媒体指标和快速停止控制。",
"tour.dash.stopped": "已停止的目标 — 一键启动。", "tour.dash.stopped": "已停止的目标 — 一键启动。",
"tour.dash.automations": "自动化 — 活动自动化状态和快速启用/禁用切换。", "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.devices": "设备 — 在网络中发现的 LED 控制器。",
"tour.tgt.css": "色带 — 定义屏幕区域如何映射到 LED 段。", "tour.tgt.css": "色带 — 定义屏幕区域如何映射到 LED 段。",
"tour.tgt.targets": "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.raw": "原始 — 来自显示器的实时屏幕捕获源。",
"tour.src.templates": "捕获模板 — 可复用的捕获配置(分辨率、FPS、裁剪)。", "tour.src.templates": "捕获模板 — 可复用的捕获配置(分辨率、FPS、裁剪)。",
"tour.src.static": "静态图片 — 使用图片文件测试您的设置。", "tour.src.static": "静态图片 — 使用图片文件测试您的设置。",
"tour.src.processed": "处理 — 应用后处理效果,如模糊、亮度或色彩校正。", "tour.src.processed": "处理 — 应用后处理效果,如模糊、亮度或色彩校正。",
"tour.src.color_strip": "色带 — 定义屏幕区域如何映射到 LED 段。", "tour.src.color_strip": "色带 — 定义屏幕区域如何映射到 LED 段。",
"tour.src.audio": "音频 — 分析麦克风系统音频以实现响应式 LED 效果。", "tour.src.audio": "音频 — 捕获麦克风/系统音频,并通过模板进行处理以生成反应式效果。",
"tour.src.value": "数值 — 用于自动化条件的数字数据源。", "tour.src.value": "数值由时间、音频、系统指标、Home Assistant 实体、渐变或日程驱动的动态数字或颜色。用于动画效果、调制色带和触发自动化。",
"tour.src.sync": "同步时钟 — 在多个源之间同步动画的共享定时器。", "tour.src.sync": "同步时钟 — 在多个源之间同步动画的共享定时器。",
"tour.auto.list": "自动化 — 基于时间、音频或数值条件自动激活场景。", "tour.auto.list": "自动化 — 基于时间、音频或数值条件自动激活场景。",
"tour.auto.add": "点击 + 创建包含条件和要激活场景的新自动化。", "tour.auto.add": "点击 + 创建包含条件和要激活场景的新自动化。",
@@ -449,6 +493,11 @@
"tour.auto.scenes_list": "场景 — 保存的系统状态,自动化可以激活或您可以手动应用。", "tour.auto.scenes_list": "场景 — 保存的系统状态,自动化可以激活或您可以手动应用。",
"tour.auto.scenes_add": "点击 + 将当前系统状态捕获为新的场景预设。", "tour.auto.scenes_add": "点击 + 将当前系统状态捕获为新的场景预设。",
"tour.auto.scenes_card": "每个场景卡片显示目标/设备数量。点击编辑、重新捕获或激活。", "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.tutorial.start": "开始教程",
"calibration.overlay_toggle": "叠加层", "calibration.overlay_toggle": "叠加层",
"calibration.start_position": "起始位置:", "calibration.start_position": "起始位置:",
@@ -861,6 +910,9 @@
"automations.add": "添加自动化", "automations.add": "添加自动化",
"automations.edit": "编辑自动化", "automations.edit": "编辑自动化",
"automations.delete.confirm": "删除自动化 \"{name}\"", "automations.delete.confirm": "删除自动化 \"{name}\"",
"automations.section.triggers": "触发器",
"automations.section.action": "动作",
"automations.section.deactivation": "停用",
"automations.name": "名称:", "automations.name": "名称:",
"automations.name.hint": "此自动化的描述性名称", "automations.name.hint": "此自动化的描述性名称",
"automations.name.placeholder": "我的自动化", "automations.name.placeholder": "我的自动化",
@@ -20,6 +20,15 @@
<hr class="modal-divider"> <hr class="modal-divider">
</div> </div>
<form id="add-device-form"> <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="form-group" id="device-type-group">
<div class="label-row"> <div class="label-row">
<label for="device-type" data-i18n="device.type">Device Type:</label> <label for="device-type" data-i18n="device.type">Device Type:</label>
@@ -45,10 +54,21 @@
<option value="group">Group</option> <option value="group">Group</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group ds-name-group">
<label for="device-name" data-i18n="device.name">Device Name:</label> <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> <input type="text" id="device-name" data-i18n-placeholder="device.name.placeholder" placeholder="Living Room TV" required>
</div> </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="form-group" id="device-url-group">
<div class="label-row"> <div class="label-row">
<label for="device-url" id="device-url-label" data-i18n="device.url">URL:</label> <label for="device-url" id="device-url-label" data-i18n="device.url">URL:</label>
@@ -303,6 +323,17 @@
<option value="indicator">Indicator</option> <option value="indicator">Indicator</option>
</select> </select>
</div> </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="form-group" id="device-cspt-group">
<div class="label-row"> <div class="label-row">
<label for="device-css-processing-template" data-i18n="device.css_processing_template">Strip Processing Template:</label> <label for="device-css-processing-template" data-i18n="device.css_processing_template">Strip Processing Template:</label>
@@ -313,6 +344,9 @@
<option value=""></option> <option value=""></option>
</select> </select>
</div> </div>
</div>
</section>
<div id="add-device-error" class="error-message" style="display: none;"></div> <div id="add-device-error" class="error-message" style="display: none;"></div>
</form> </form>
</div> </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 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-content" style="max-width: 900px;">
<div class="modal-header"> <div class="modal-header">
@@ -8,121 +14,148 @@
<div class="modal-body"> <div class="modal-body">
<input type="hidden" id="advcal-css-id"> <input type="hidden" id="advcal-css-id">
<!-- Two-column layout: canvas + line list --> <!-- ── 01 · LAYOUT ─────────────────────────────────── -->
<div class="advcal-layout"> <section class="ds-section" data-ds-key="layout" data-ch="signal">
<!-- Left: Canvas showing monitor rectangles with lines --> <div class="ds-section-header">
<div class="advcal-canvas-panel"> <span class="ds-section-dot" aria-hidden="true"></span>
<canvas id="advcal-canvas" width="560" height="340"></canvas> <span class="ds-section-title" data-i18n="settings.section.layout">Layout</span>
<div class="advcal-canvas-hint"> <span class="ds-section-index" aria-hidden="true">01</span>
<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> </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 --> <!-- Right: Line list -->
<div class="advcal-lines-panel"> <div class="advcal-lines-panel">
<div id="advcal-line-list" class="advcal-line-list"> <div id="advcal-line-list" class="advcal-line-list">
<!-- Line items rendered dynamically --> <!-- Line items rendered dynamically -->
</div>
<div class="advcal-leds-counter"><span id="advcal-total-leds">0</span> LEDs</div>
</div>
</div> </div>
<div class="advcal-leds-counter"><span id="advcal-total-leds">0</span> LEDs</div>
</div> </div>
</div> </section>
<!-- Selected line properties --> <!-- ── 02 · LINE PROPERTIES (shown when a line is selected) ─ -->
<div id="advcal-line-props" class="advcal-line-props" style="display:none"> <section class="ds-section" data-ds-key="line-properties" data-ch="cyan" id="advcal-line-props-section" style="display:none">
<h3 style="margin: 0 0 8px; font-size: 0.9em;" data-i18n="calibration.advanced.line_properties">Line Properties</h3> <div class="ds-section-header">
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px;"> <span class="ds-section-dot" aria-hidden="true"></span>
<div class="form-group"> <span class="ds-section-title" data-i18n="settings.section.line_properties">Line Properties</span>
<div class="label-row"> <span class="ds-section-index" aria-hidden="true">02</span>
<label for="advcal-line-source" data-i18n="calibration.advanced.picture_source">Source:</label> </div>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <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> </div>
<small class="input-hint" style="display:none" data-i18n="calibration.advanced.picture_source.hint">The picture source (monitor) this line samples from</small> <div style="display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 12px; margin-top: 8px;">
<select id="advcal-line-source" onchange="updateCalibrationLine()"></select> <div class="form-group">
</div> <div class="label-row">
<div class="form-group"> <label for="advcal-line-span-start" data-i18n="calibration.advanced.span_start">Span Start:</label>
<div class="label-row"> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<label for="advcal-line-edge" data-i18n="calibration.advanced.edge">Edge:</label> </div>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <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>
<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> </div>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 12px; margin-top: 8px;"> </section>
<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>
<!-- Global strip settings (offset, skip) --> <!-- ── 03 · OFFSETS ────────────────────────────────── -->
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; margin-top: 12px;"> <section class="ds-section" data-ds-key="offsets" data-ch="amber">
<div class="form-group"> <div class="ds-section-header">
<div class="label-row"> <span class="ds-section-dot" aria-hidden="true"></span>
<label for="advcal-offset" data-i18n="calibration.offset">LED Offset:</label> <span class="ds-section-title" data-i18n="settings.section.offsets">Offsets</span>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <span class="ds-section-index" aria-hidden="true">03</span>
</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>
<div class="form-group"> <div class="ds-section-body">
<div class="label-row"> <div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px;">
<label for="advcal-skip-start" data-i18n="calibration.skip_start">Skip LEDs (Start):</label> <div class="form-group">
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <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> </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>
<div class="form-group"> </section>
<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>
<div id="advcal-error" class="error-message" style="display: none;"></div> <div id="advcal-error" class="error-message" style="display: none;"></div>
</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 id="api-key-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="api-key-modal-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@@ -10,25 +10,37 @@
<p class="modal-description" data-i18n="auth.message"> <p class="modal-description" data-i18n="auth.message">
Please enter your API key to authenticate and access the LED Grab. Please enter your API key to authenticate and access the LED Grab.
</p> </p>
<div class="form-group">
<div class="label-row"> <!-- ── 01 · AUTH ───────────────────────────────────── -->
<label for="api-key-input" data-i18n="auth.label">API Key:</label> <section class="ds-section" data-ds-key="auth" data-ch="cyan">
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <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> </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="ds-section-body">
<div class="password-input-wrapper"> <div class="form-group">
<input <div class="label-row">
type="password" <label for="api-key-input" data-i18n="auth.label">API Key:</label>
id="api-key-input" <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
data-i18n-placeholder="auth.placeholder" </div>
placeholder="Enter your API key..." <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>
autocomplete="off" <div class="password-input-wrapper">
> <input
<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"> type="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> id="api-key-input"
</button> 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>
</div> </section>
<div id="api-key-error" class="error-message" style="display: none;"></div> <div id="api-key-error" class="error-message" style="display: none;"></div>
</div> </div>
<div class="modal-footer"> <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 id="asset-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="asset-editor-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@@ -8,24 +10,34 @@
<input type="hidden" id="asset-editor-id"> <input type="hidden" id="asset-editor-id">
<div id="asset-editor-error" class="modal-error" style="display:none"></div> <div id="asset-editor-error" class="modal-error" style="display:none"></div>
<div class="form-group"> <!-- ── 01 · IDENTITY ───────────────────────────────── -->
<div class="label-row"> <section class="ds-section" data-ds-key="identity" data-ch="signal">
<label for="asset-editor-name" data-i18n="asset.name">Name:</label> <div class="ds-section-header">
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <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>
<small class="input-hint" style="display:none" data-i18n="asset.name.hint">Display name for this asset.</small> <div class="ds-section-body">
<input type="text" id="asset-editor-name" required maxlength="100"> <div class="form-group ds-name-group">
<div id="asset-editor-tags-container"></div> <div class="label-row">
</div> <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="form-group">
<div class="label-row"> <div class="label-row">
<label for="asset-editor-description" data-i18n="asset.description">Description:</label> <label for="asset-editor-description" data-i18n="asset.description">Description:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <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> </div>
<small class="input-hint" style="display:none" data-i18n="asset.description.hint">Optional description for this asset.</small> </section>
<input type="text" id="asset-editor-description" maxlength="500">
</div>
</div> </div>
<div class="modal-footer"> <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> <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 id="asset-upload-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="asset-upload-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@@ -7,52 +9,72 @@
<div class="modal-body"> <div class="modal-body">
<div id="asset-upload-error" class="modal-error" style="display:none"></div> <div id="asset-upload-error" class="modal-error" style="display:none"></div>
<div class="form-group"> <!-- ── 01 · IDENTITY ───────────────────────────────── -->
<div class="label-row"> <section class="ds-section" data-ds-key="identity" data-ch="signal">
<label for="asset-upload-name" data-i18n="asset.name">Name:</label> <div class="ds-section-header">
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <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>
<small class="input-hint" style="display:none" data-i18n="asset.name.hint">Display name for this asset.</small> <div class="ds-section-body">
<input type="text" id="asset-upload-name" maxlength="100"> <div class="form-group ds-name-group">
<div id="asset-upload-tags-container"></div> <div class="label-row">
</div> <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="form-group">
<div class="label-row"> <div class="label-row">
<label for="asset-upload-description" data-i18n="asset.description">Description:</label> <label for="asset-upload-description" data-i18n="asset.description">Description:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <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> </div>
<small class="input-hint" style="display:none" data-i18n="asset.description.hint">Optional description for this asset.</small> </section>
<input type="text" id="asset-upload-description" maxlength="500">
</div>
<div class="form-group"> <!-- ── 02 · FILE ───────────────────────────────────── -->
<div class="label-row"> <section class="ds-section" data-ds-key="file" data-ch="cyan">
<label data-i18n="asset.file">File:</label> <div class="ds-section-header">
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <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> </div>
<small class="input-hint" style="display:none" data-i18n="asset.file.hint">Select a file to upload (sound, image, video, or other).</small> <div class="ds-section-body">
<input type="file" id="asset-upload-file" required hidden> <div class="form-group">
<div id="asset-upload-dropzone" class="file-dropzone" tabindex="0" role="button" <div class="label-row">
data-i18n-aria-label="asset.drop_or_browse"> <label data-i18n="asset.file">File:</label>
<div class="file-dropzone-icon"> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<svg class="icon" viewBox="0 0 24 24" style="width:32px;height:32px"> </div>
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/> <small class="input-hint" style="display:none" data-i18n="asset.file.hint">Select a file to upload (sound, image, video, or other).</small>
<path d="M14 2v4a2 2 0 0 0 2 2h4"/> <input type="file" id="asset-upload-file" required hidden>
<path d="M12 12v6"/><path d="m15 15-3-3-3 3"/> <div id="asset-upload-dropzone" class="file-dropzone" tabindex="0" role="button"
</svg> data-i18n-aria-label="asset.drop_or_browse">
</div> <div class="file-dropzone-icon">
<div class="file-dropzone-text"> <svg class="icon" viewBox="0 0 24 24" style="width:32px;height:32px">
<span class="file-dropzone-label" data-i18n="asset.drop_or_browse">Drop file here or click to browse</span> <path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/>
</div> <path d="M14 2v4a2 2 0 0 0 2 2h4"/>
<div id="asset-upload-file-info" class="file-dropzone-info" style="display:none"> <path d="M12 12v6"/><path d="m15 15-3-3-3 3"/>
<span id="asset-upload-file-name" class="file-dropzone-filename"></span> </svg>
<span id="asset-upload-file-size" class="file-dropzone-filesize"></span> </div>
<button type="button" class="file-dropzone-remove" id="asset-upload-file-remove" <div class="file-dropzone-text">
title="Remove" data-i18n-title="common.remove">&#x2715;</button> <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> </div>
</div> </section>
</div> </div>
<div class="modal-footer"> <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> <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 id="apt-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="apt-modal-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@@ -9,34 +9,48 @@
<input type="hidden" id="apt-id"> <input type="hidden" id="apt-id">
<div id="apt-error" class="modal-error" style="display:none"></div> <div id="apt-error" class="modal-error" style="display:none"></div>
<div class="form-group"> <!-- ── 01 · IDENTITY ───────────────────────────────── -->
<div class="label-row"> <section class="ds-section" data-ds-key="identity" data-ch="signal">
<label for="apt-name" data-i18n="audio_processing.name">Template Name:</label> <div class="ds-section-header">
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <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>
<small class="input-hint" style="display:none" data-i18n="audio_processing.name.hint">A descriptive name for this audio processing template</small> <div class="ds-section-body">
<input type="text" id="apt-name" data-i18n-placeholder="audio_processing.name_placeholder" placeholder="My Audio Processing Template" required> <div class="form-group ds-name-group">
<div id="apt-tags-container"></div> <label for="apt-name" data-i18n="audio_processing.name">Template Name:</label>
</div> <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 class="form-group">
<div id="apt-filter-list" class="pp-filter-list"></div> <div class="label-row">
<label for="apt-description" data-i18n="audio_processing.description_label">Description (optional):</label>
<!-- Add filter control --> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<div class="pp-add-filter-row"> </div>
<select id="apt-add-filter-select" class="pp-add-filter-select"> <small class="input-hint" style="display:none" data-i18n="audio_processing.description.hint">Describe what this template does</small>
<option value="" data-i18n="filters.select_type">Select filter type...</option> <input type="text" id="apt-description" data-i18n-placeholder="audio_processing.description_placeholder" placeholder="Describe this template...">
</select> </div>
</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> </div>
<small class="input-hint" style="display:none" data-i18n="audio_processing.description.hint">Describe what this template does</small> </section>
<input type="text" id="apt-description" data-i18n-placeholder="audio_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="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>
<div class="modal-footer"> <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> <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 id="audio-source-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="audio-source-modal-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@@ -11,82 +12,95 @@
<div id="audio-source-error" class="modal-error" style="display: none;"></div> <div id="audio-source-error" class="modal-error" style="display: none;"></div>
<!-- Name --> <!-- ── 01 · IDENTITY ───────────────────────────────── -->
<div class="form-group"> <section class="ds-section" data-ds-key="identity" data-ch="signal">
<div class="label-row"> <div class="ds-section-header">
<label for="audio-source-name" data-i18n="audio_source.name">Name:</label> <span class="ds-section-dot" aria-hidden="true"></span>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <span class="ds-section-title" data-i18n="settings.section.identity">Identity</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="audio_source.name.hint">A descriptive name for this audio source</small> <div class="ds-section-body">
<input type="text" id="audio-source-name" data-i18n-placeholder="audio_source.name.placeholder" placeholder="System Audio" required> <div class="form-group ds-name-group">
<div id="audio-source-tags-container"></div> <label for="audio-source-name" data-i18n="audio_source.name">Name:</label>
</div> <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"> <input type="hidden" id="audio-source-type" value="capture">
<!-- Capture fields --> <!-- ── 02 · SOURCE ─────────────────────────────────── -->
<div id="audio-source-capture-section"> <section class="ds-section" data-ds-key="source" data-ch="cyan">
<div class="form-group"> <div class="ds-section-header">
<div class="label-row"> <span class="ds-section-dot" aria-hidden="true"></span>
<label for="audio-source-audio-template" data-i18n="audio_source.audio_template">Audio Template:</label> <span class="ds-section-title" data-i18n="settings.section.source">Source</span>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <span class="ds-section-index" aria-hidden="true">02</span>
</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>
<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="form-group">
<div class="label-row"> <div class="label-row">
<label for="audio-source-device" data-i18n="audio_source.device">Audio Device:</label> <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> <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> </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 --> <!-- Processed fields -->
<div id="audio-source-processed-section" style="display:none"> <div id="audio-source-processed-section" style="display:none">
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
<label for="audio-source-parent" data-i18n="audio_source.parent">Input Audio Source:</label> <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> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="audio_source.parent.hint">Audio source to apply processing filters to</small> <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"> <select id="audio-source-parent">
<!-- populated dynamically with audio sources --> <!-- populated dynamically with audio sources -->
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
<label for="audio-source-processing-template" data-i18n="audio_source.processing_template">Processing Template:</label> <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> <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> </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>
</div> </section>
<!-- 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>
</form> </form>
</div> </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 id="audio-template-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="audio-template-modal-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@@ -8,32 +8,52 @@
<div class="modal-body"> <div class="modal-body">
<input type="hidden" id="audio-template-id"> <input type="hidden" id="audio-template-id">
<form id="audio-template-form"> <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"> <!-- ── 01 · IDENTITY ───────────────────────────────── -->
<label for="audio-template-description" data-i18n="audio_template.description.label">Description (optional):</label> <section class="ds-section" data-ds-key="identity" data-ch="signal">
<input type="text" id="audio-template-description" data-i18n-placeholder="audio_template.description.placeholder" placeholder="Describe this template..." maxlength="500"> <div class="ds-section-header">
</div> <span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.identity">Identity</span>
<div class="form-group"> <span class="ds-section-index" aria-hidden="true">01</span>
<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> </div>
<small class="input-hint" style="display:none" data-i18n="audio_template.engine.hint">Select the audio capture backend to use</small> <div class="ds-section-body">
<select id="audio-template-engine" onchange="onAudioEngineChange()" required> <div class="form-group ds-name-group">
</select> <label for="audio-template-name" data-i18n="audio_template.name">Template Name:</label>
<small id="audio-engine-availability-hint" class="form-hint" style="display: none;"></small> <input type="text" id="audio-template-name" data-i18n-placeholder="audio_template.name.placeholder" placeholder="My Audio Template" required>
</div> <div id="audio-template-tags-container"></div>
</div>
<div id="audio-engine-config-section" style="display: none;"> <div class="form-group">
<h3 data-i18n="audio_template.config">Configuration</h3> <label for="audio-template-description" data-i18n="audio_template.description.label">Description (optional):</label>
<div id="audio-engine-config-fields"></div> <input type="text" id="audio-template-description" data-i18n-placeholder="audio_template.description.placeholder" placeholder="Describe this template..." maxlength="500">
</div> </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> <div id="audio-template-error" class="error-message" style="display: none;"></div>
</form> </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 id="automation-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="automation-editor-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@@ -9,82 +17,120 @@
<form id="automation-editor-form"> <form id="automation-editor-form">
<input type="hidden" id="automation-editor-id"> <input type="hidden" id="automation-editor-id">
<div class="form-group"> <!-- ── 01 · IDENTITY ───────────────────────────────── -->
<div class="label-row"> <section class="ds-section" data-ds-key="identity" data-ch="signal">
<label for="automation-editor-name" data-i18n="automations.name">Name:</label> <div class="ds-section-header">
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <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>
<small class="input-hint" style="display:none" data-i18n="automations.name.hint">A descriptive name for this automation</small> <div class="ds-section-body">
<input type="text" id="automation-editor-name" data-i18n-placeholder="automations.name.placeholder" placeholder="My Automation" required> <div class="form-group ds-name-group">
<div id="automation-tags-container"></div> <label for="automation-editor-name" data-i18n="automations.name">Name:</label>
</div> <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="ds-toggle-row">
<div class="label-row"> <div class="ds-toggle-text">
<label data-i18n="automations.enabled">Enabled:</label> <div class="label-row">
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <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> </div>
<small class="input-hint" style="display:none" data-i18n="automations.enabled.hint">Disabled automations won't activate even when rules are met</small> </section>
<label class="settings-toggle">
<input type="checkbox" id="automation-editor-enabled" checked>
<span class="settings-toggle-slider"></span>
</label>
</div>
<div class="form-group"> <!-- ── 02 · TRIGGERS ───────────────────────────────── -->
<div class="label-row"> <section class="ds-section" data-ds-key="triggers" data-ch="cyan">
<label for="automation-editor-logic" data-i18n="automations.rule_logic">Rule Logic:</label> <div class="ds-section-header">
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <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> </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> <div class="ds-section-body">
<select id="automation-editor-logic"> <div class="form-group">
<option value="or" data-i18n="automations.rule_logic.or">Any rule (OR)</option> <div class="label-row">
<option value="and" data-i18n="automations.rule_logic.and">All rules (AND)</option> <label for="automation-editor-logic" data-i18n="automations.rule_logic">Rule Logic:</label>
</select> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div> </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="form-group">
<div class="label-row"> <div class="label-row">
<label data-i18n="automations.rules">Rules:</label> <label data-i18n="automations.rules">Rules:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <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> </div>
<small class="input-hint" style="display:none" data-i18n="automations.rules.hint">Rules that determine when this automation activates</small> </section>
<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 class="form-group"> <!-- ── 03 · ACTION ─────────────────────────────────── -->
<div class="label-row"> <section class="ds-section" data-ds-key="action" data-ch="amber">
<label for="automation-scene-id" data-i18n="automations.scene">Scene:</label> <div class="ds-section-header">
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <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> </div>
<small class="input-hint" style="display:none" data-i18n="automations.scene.hint">Scene preset to activate when rules are met</small> <div class="ds-section-body">
<select id="automation-scene-id"></select> <div class="form-group">
</div> <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"> <!-- ── 04 · DEACTIVATION ───────────────────────────── -->
<div class="label-row"> <section class="ds-section" data-ds-key="deactivation" data-ch="violet">
<label for="automation-deactivation-mode" data-i18n="automations.deactivation_mode">Deactivation:</label> <div class="ds-section-header">
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <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> </div>
<small class="input-hint" style="display:none" data-i18n="automations.deactivation_mode.hint">What happens when rules stop matching</small> <div class="ds-section-body">
<select id="automation-deactivation-mode"> <div class="form-group">
<option value="none" data-i18n="automations.deactivation_mode.none">None — keep current state</option> <div class="label-row">
<option value="revert" data-i18n="automations.deactivation_mode.revert">Revert to previous state</option> <label for="automation-deactivation-mode" data-i18n="automations.deactivation_mode">Deactivation:</label>
<option value="fallback_scene" data-i18n="automations.deactivation_mode.fallback_scene">Activate fallback scene</option> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</select> </div>
</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="form-group" id="automation-fallback-scene-group" style="display:none">
<div class="label-row"> <div class="label-row">
<label for="automation-fallback-scene-id" data-i18n="automations.deactivation_scene">Fallback Scene:</label> <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> <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> </div>
<small class="input-hint" style="display:none" data-i18n="automations.deactivation_scene.hint">Scene to activate when this automation deactivates</small> </section>
<select id="automation-fallback-scene-id"></select>
</div>
<div id="automation-editor-error" class="error-message" style="display: none;"></div> <div id="automation-editor-error" class="error-message" style="display: none;"></div>
</form> </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 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-content" style="max-width: 700px;">
<div class="modal-header"> <div class="modal-header">
@@ -10,150 +15,191 @@
<input type="hidden" id="calibration-device-id"> <input type="hidden" id="calibration-device-id">
<input type="hidden" id="calibration-css-id"> <input type="hidden" id="calibration-css-id">
<input type="hidden" id="calibration-css-source-type"> <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 --> <!-- ── 01 · TEST SETUP (CSS calibration mode only) ─── -->
<div style="margin-bottom: 12px; padding: 0 24px;"> <section class="ds-section" data-ds-key="test-setup" data-ch="cyan" id="calibration-test-setup-section" style="display:none">
<div class="calibration-preview"> <div class="ds-section-header">
<!-- Screen with direction toggle, total LEDs, and offset --> <span class="ds-section-dot" aria-hidden="true"></span>
<div class="preview-screen"> <span class="ds-section-title" data-i18n="settings.section.test_setup">Test Setup</span>
<button type="button" class="direction-toggle" onclick="toggleDirection()" title="Toggle direction"> <span class="ds-section-index" aria-hidden="true">01</span>
<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> </div>
</button> <div class="ds-section-body">
<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 id="calibration-css-test-group" class="form-group" style="display:none">
<div class="preview-screen-border-width"> <div class="label-row">
<label for="cal-border-width" data-i18n="calibration.border_width">Border (px):</label> <label for="calibration-test-device" data-i18n="color_strip.test_device">Test on Device:</label>
<input type="number" id="cal-border-width" min="1" max="100" value="10"> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div> </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"> <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>
<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> <select id="calibration-test-device"></select>
</button>
</div> </div>
<div id="cal-css-led-count-group" class="form-group" style="display:none">
<!-- Edge bars with span controls and LED count inputs --> <div class="label-row">
<div class="preview-edge edge-top"> <label for="cal-css-led-count" data-i18n="color_strip.led_count">LED Count:</label>
<div class="edge-span-bar" data-edge="top"> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
<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> </div>
<input type="number" id="cal-top-leds" class="edge-led-input" min="0" value="0" <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>
oninput="updateCalibrationPreview()"> <input type="number" id="cal-css-led-count" min="0" max="1500" step="1" value="0" oninput="updateCalibrationPreview()">
</div> </div>
<div class="preview-edge edge-right"> </div>
<div class="edge-span-bar" data-edge="right"> </section>
<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> <!-- ── 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> </div>
<input type="number" id="cal-right-leds" class="edge-led-input" min="0" value="0"
oninput="updateCalibrationPreview()">
</div> </div>
<div class="preview-edge edge-bottom">
<div class="edge-span-bar" data-edge="bottom"> <!-- Hidden selects (used by saveCalibration) -->
<div class="edge-span-handle edge-span-handle-start" data-edge="bottom" data-handle="start"></div> <div style="display: none;">
<div class="edge-span-handle edge-span-handle-end" data-edge="bottom" data-handle="end"></div> <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> </div>
<input type="number" id="cal-bottom-leds" class="edge-led-input" min="0" value="0" <div class="form-group">
oninput="updateCalibrationPreview()"> <div class="label-row">
</div> <label for="cal-skip-start" data-i18n="calibration.skip_start">Skip LEDs (Start):</label>
<div class="preview-edge edge-left"> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<div class="edge-span-bar" data-edge="left"> </div>
<div class="edge-span-handle edge-span-handle-start" data-edge="left" data-handle="start"></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>
<div class="edge-span-handle edge-span-handle-end" data-edge="left" data-handle="end"></div> <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>
<input type="number" id="cal-left-leds" class="edge-led-input" min="0" value="0"
oninput="updateCalibrationPreview()">
</div> </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>
</div> </section>
<!-- Hidden selects (used by saveCalibration) --> <!-- Tutorial Overlay (v2 "Signal Bench") -->
<div style="display: none;"> <div id="calibration-tutorial-overlay" class="tutorial-overlay tutorial-v2">
<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">
<div class="tutorial-backdrop"></div> <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">
<div class="tutorial-tooltip-header"> <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> <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>
<div class="tutorial-pips" aria-hidden="true"></div>
<p class="tutorial-tooltip-text"></p> <p class="tutorial-tooltip-text"></p>
<div class="tutorial-tooltip-nav"> <div class="tutorial-tooltip-nav">
<button class="tutorial-prev-btn" onclick="tutorialPrev()">&#8592;</button> <button class="tutorial-prev-btn" onclick="tutorialPrev()" data-i18n-aria-label="aria.previous">&#9664; PREV</button>
<button class="tutorial-next-btn" onclick="tutorialNext()">&#8594;</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> </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 id="template-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="template-modal-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@@ -8,32 +8,52 @@
<div class="modal-body"> <div class="modal-body">
<input type="hidden" id="template-id"> <input type="hidden" id="template-id">
<form id="template-form"> <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"> <!-- ── 01 · IDENTITY ───────────────────────────────── -->
<label for="template-description" data-i18n="templates.description.label">Description (optional):</label> <section class="ds-section" data-ds-key="identity" data-ch="signal">
<input type="text" id="template-description" data-i18n-placeholder="templates.description.placeholder" placeholder="Describe this template..." maxlength="500"> <div class="ds-section-header">
</div> <span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.identity">Identity</span>
<div class="form-group"> <span class="ds-section-index" aria-hidden="true">01</span>
<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> </div>
<small class="input-hint" style="display:none" data-i18n="templates.engine.hint">Select the screen capture technology to use</small> <div class="ds-section-body">
<select id="template-engine" onchange="onEngineChange()" required> <div class="form-group ds-name-group">
</select> <label for="template-name" data-i18n="templates.name">Template Name:</label>
<small id="engine-availability-hint" class="form-hint" style="display: none;"></small> <input type="text" id="template-name" data-i18n-placeholder="templates.name.placeholder" placeholder="My Custom Template" required>
</div> <div id="capture-template-tags-container"></div>
</div>
<div id="engine-config-section" style="display: none;"> <div class="form-group">
<h3 data-i18n="templates.config">Configuration</h3> <label for="template-description" data-i18n="templates.description.label">Description (optional):</label>
<div id="engine-config-fields"></div> <input type="text" id="template-description" data-i18n-placeholder="templates.description.placeholder" placeholder="Describe this template..." maxlength="500">
</div> </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> <div id="template-error" class="error-message" style="display: none;"></div>
</form> </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 id="cspt-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="cspt-modal-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@@ -8,26 +8,45 @@
<div class="modal-body"> <div class="modal-body">
<input type="hidden" id="cspt-id"> <input type="hidden" id="cspt-id">
<form id="cspt-form"> <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 --> <!-- ── 01 · IDENTITY ───────────────────────────────── -->
<div id="cspt-filter-list" class="pp-filter-list"></div> <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="form-group">
<div class="pp-add-filter-row"> <label for="cspt-description" data-i18n="css_processing.description_label">Description (optional):</label>
<select id="cspt-add-filter-select" class="pp-add-filter-select"> <input type="text" id="cspt-description" data-i18n-placeholder="css_processing.description_placeholder" placeholder="Describe this template...">
<option value="" data-i18n="filters.select_type">Select filter type...</option> </div>
</select> </div>
</div> </section>
<div class="form-group"> <!-- ── 02 · FILTERS ────────────────────────────────── -->
<label for="cspt-description" data-i18n="css_processing.description_label">Description (optional):</label> <section class="ds-section" data-ds-key="filters" data-ch="cyan">
<input type="text" id="cspt-description" data-i18n-placeholder="css_processing.description_placeholder" placeholder="Describe this template..."> <div class="ds-section-header">
</div> <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> <div id="cspt-error" class="error-message" style="display: none;"></div>
</form> </form>
@@ -9,11 +9,30 @@
<form id="css-editor-form"> <form id="css-editor-form">
<input type="hidden" id="css-editor-id"> <input type="hidden" id="css-editor-id">
<div class="form-group"> <!-- ── 01 · IDENTITY ───────────────────────────────── -->
<label for="css-editor-name" data-i18n="color_strip.name">Name:</label> <section class="ds-section" data-ds-key="identity" data-ch="signal">
<input type="text" id="css-editor-name" data-i18n-placeholder="color_strip.name.placeholder" placeholder="Wall Strip" required> <div class="ds-section-header">
<div id="css-tags-container"></div> <span class="ds-section-dot" aria-hidden="true"></span>
</div> <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 id="css-editor-type-group" class="form-group">
<div class="label-row"> <div class="label-row">
@@ -778,6 +797,18 @@
</div> </div>
</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 --> <!-- Shared LED count field -->
<div id="css-editor-led-count-group" class="form-group"> <div id="css-editor-led-count-group" class="form-group">
<div class="label-row"> <div class="label-row">
@@ -813,6 +844,9 @@
</select> </select>
</div> </div>
</div>
</section>
<div id="css-editor-error" class="error-message" style="display: none;"></div> <div id="css-editor-error" class="error-message" style="display: none;"></div>
</form> </form>
</div> </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 id="game-integration-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="gi-title">
<div class="modal-content modal-lg"> <div class="modal-content modal-lg">
<div class="modal-header"> <div class="modal-header">
@@ -8,91 +17,124 @@
<input type="hidden" id="gi-id"> <input type="hidden" id="gi-id">
<div id="gi-error" class="modal-error" style="display:none"></div> <div id="gi-error" class="modal-error" style="display:none"></div>
<!-- Name + Tags --> <!-- ── 01 · IDENTITY ───────────────────────────────── -->
<div class="form-group"> <section class="ds-section" data-ds-key="identity" data-ch="signal">
<div class="label-row"> <div class="ds-section-header">
<label for="gi-name" data-i18n="game_integration.name">Name:</label> <span class="ds-section-dot" aria-hidden="true"></span>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <span class="ds-section-title" data-i18n="settings.section.identity">Identity</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="game_integration.name.hint">A descriptive name for this game integration</small> <div class="ds-section-body">
<input type="text" id="gi-name" required> <div class="form-group ds-name-group">
<div id="gi-tags-container"></div> <label for="gi-name" data-i18n="game_integration.name">Name:</label>
</div> <input type="text" id="gi-name" required>
<div id="gi-tags-container"></div>
</div>
<!-- Description --> <div class="form-group">
<div class="form-group"> <div class="label-row">
<div class="label-row"> <label for="gi-description" data-i18n="game_integration.description">Description:</label>
<label for="gi-description" data-i18n="game_integration.description">Description:</label> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<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> </div>
<small class="input-hint" style="display:none" data-i18n="game_integration.description.hint">Optional description of what this integration does</small> </section>
<input type="text" id="gi-description">
</div>
<!-- Enabled --> <!-- ── 02 · ADAPTER ────────────────────────────────── -->
<div class="form-group"> <section class="ds-section" data-ds-key="adapter" data-ch="cyan">
<label class="checkbox-label"> <div class="ds-section-header">
<input type="checkbox" id="gi-enabled" checked> <span class="ds-section-dot" aria-hidden="true"></span>
<span data-i18n="game_integration.enabled">Enabled</span> <span class="ds-section-title" data-i18n="settings.section.adapter">Adapter</span>
</label> <span class="ds-section-index" aria-hidden="true">02</span>
</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>
</div> </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> <div class="ds-section-body">
<select id="gi-adapter-type"></select> <div class="form-group">
</div> <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="form-group"> <div class="label-row">
<div class="label-row"> <label data-i18n="game_integration.adapter_config">Adapter Configuration</label>
<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>
<div id="gi-adapter-config-fields"></div> </section>
</div>
<!-- Setup instructions + Auto Setup buttons --> <!-- ── 03 · MAPPINGS ───────────────────────────────── -->
<div id="gi-setup-instructions-btn-wrapper" style="display:none"> <section class="ds-section" data-ds-key="mappings" data-ch="amber">
<button type="button" class="btn btn-secondary btn-sm" onclick="openSetupInstructions()" data-i18n="game_integration.setup_instructions">Setup Instructions</button> <div class="ds-section-header">
<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> <span class="ds-section-dot" aria-hidden="true"></span>
</div> <span class="ds-section-title" data-i18n="settings.section.mappings">Mappings</span>
<span class="ds-section-index" aria-hidden="true">03</span>
<!-- 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>
</div> </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"> <div class="gi-mapping-toolbar">
<select id="gi-mapping-preset" onchange="onMappingPresetChange()"> <select id="gi-mapping-preset" onchange="onMappingPresetChange()">
<option value="" data-i18n="game_integration.preset.select">Load preset...</option> <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="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> <option value="moba_health" data-i18n="game_integration.preset.moba_health">MOBA Health</option>
</select> </select>
<button class="btn btn-secondary btn-sm" onclick="addGameMapping()" data-i18n="game_integration.mapping.add">+ Add Mapping</button> <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>
<div id="gi-mappings-list" class="gi-mappings-list"></div> </section>
</div>
<!-- Live Event Monitor --> <!-- ── 04 · DIAGNOSTICS ────────────────────────────── -->
<div class="form-group"> <section class="ds-section" data-ds-key="diagnostics" data-ch="violet">
<div class="label-row"> <div class="ds-section-header">
<label data-i18n="game_integration.events.title">Live Events</label> <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>
<div id="gi-event-feed" class="gi-event-feed"></div> <div class="ds-section-body">
</div> <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">
<div class="form-group"> <button class="btn btn-secondary" onclick="testGameConnection()" data-i18n="game_integration.test.button">Test Connection</button>
<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 id="gi-test-panel" style="display:none" class="gi-test-panel"></div> </div>
</div> </div>
</section>
</div> </div>
<div class="modal-footer"> <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 id="gradient-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="gradient-editor-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@@ -9,49 +9,65 @@
<input type="hidden" id="gradient-editor-id"> <input type="hidden" id="gradient-editor-id">
<div id="gradient-editor-error" class="modal-error" style="display:none"></div> <div id="gradient-editor-error" class="modal-error" style="display:none"></div>
<!-- Name + Tags (same form-group, matching other entities) --> <!-- ── 01 · IDENTITY ───────────────────────────────── -->
<div class="form-group"> <section class="ds-section" data-ds-key="identity" data-ch="signal">
<div class="label-row"> <div class="ds-section-header">
<label for="gradient-editor-name" data-i18n="gradient.name">Name:</label> <span class="ds-section-dot" aria-hidden="true"></span>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <span class="ds-section-title" data-i18n="settings.section.identity">Identity</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="gradient.name.hint">A descriptive name for this gradient.</small> <div class="ds-section-body">
<input type="text" id="gradient-editor-name" required> <div class="form-group ds-name-group">
<div id="gradient-editor-tags-container"></div> <div class="label-row">
</div> <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="form-group"> <div class="label-row">
<div class="label-row"> <label for="gradient-editor-description" data-i18n="gradient.description">Description:</label>
<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>
<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> </div>
<small class="input-hint" style="display:none" data-i18n="gradient.description.hint">Optional description for this gradient.</small> </section>
<input type="text" id="gradient-editor-description" maxlength="500">
</div>
<!-- Gradient preview + markers (unique IDs prefixed ge-) --> <!-- ── 02 · GRADIENT ───────────────────────────────── -->
<div class="form-group"> <section class="ds-section" data-ds-key="gradient" data-ch="magenta">
<div class="label-row"> <div class="ds-section-header">
<label data-i18n="color_strip.gradient.preview">Gradient:</label> <span class="ds-section-dot" aria-hidden="true"></span>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <span class="ds-section-title" data-i18n="settings.section.gradient">Gradient</span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div> </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="ds-section-body">
<div class="gradient-editor"> <div class="form-group">
<canvas id="ge-gradient-canvas" height="44"></canvas> <div class="label-row">
<div id="ge-gradient-markers-track" class="gradient-markers-track"></div> <label data-i18n="color_strip.gradient.preview">Gradient:</label>
</div> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div> </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="form-group"> <div class="label-row">
<div class="label-row"> <label data-i18n="color_strip.gradient.stops">Color Stops:</label>
<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>
<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> </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> </section>
<div id="ge-gradient-stops-list"></div>
</div>
</div> </div>
<div class="modal-footer"> <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> <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 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-content">
<div class="modal-header"> <div class="modal-header">
@@ -11,109 +17,137 @@
<div id="ha-light-editor-error" class="error-message" style="display: none;"></div> <div id="ha-light-editor-error" class="error-message" style="display: none;"></div>
<!-- Name --> <!-- ── 01 · IDENTITY ───────────────────────────────── -->
<div class="form-group"> <section class="ds-section" data-ds-key="identity" data-ch="signal">
<div class="label-row"> <div class="ds-section-header">
<label for="ha-light-editor-name" data-i18n="ha_light.name">Name:</label> <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>
<input type="text" id="ha-light-editor-name" data-i18n-placeholder="ha_light.name.placeholder" placeholder="Living Room Lights" required> <div class="ds-section-body">
<div id="ha-light-tags-container"></div> <div class="form-group ds-name-group">
</div> <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="form-group"> <div class="label-row">
<div class="label-row"> <label for="ha-light-editor-description" data-i18n="ha_light.description">Description (optional):</label>
<label for="ha-light-editor-ha-source" data-i18n="ha_light.ha_source">HA Connection:</label> </div>
<input type="text" id="ha-light-editor-description" placeholder="">
</div>
</div> </div>
<select id="ha-light-editor-ha-source"></select> </section>
</div>
<!-- CSS Source --> <!-- ── 02 · ROUTING ────────────────────────────────── -->
<div class="form-group"> <section class="ds-section" data-ds-key="routing" data-ch="cyan">
<div class="label-row"> <div class="ds-section-header">
<label for="ha-light-editor-css-source" data-i18n="ha_light.css_source">Color Strip Source:</label> <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> </div>
<select id="ha-light-editor-css-source"></select> <div class="ds-section-body">
</div> <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="form-group"> <div class="label-row">
<div class="label-row"> <label for="ha-light-editor-css-source" data-i18n="ha_light.css_source">Color Strip Source:</label>
<label> </div>
<span data-i18n="ha_light.update_rate">Update Rate:</span> <select id="ha-light-editor-css-source"></select>
</label> </div>
<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>
<!-- Transition --> <div class="form-group">
<div class="form-group"> <div class="label-row">
<div class="label-row"> <label data-i18n="ha_light.mappings">Light Mappings:</label>
<label> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
<span data-i18n="ha_light.transition">Transition:</span> </div>
</label> <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>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <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> </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> </section>
<div id="ha-light-editor-transition-container"></div>
</div>
<!-- Brightness --> <!-- ── 03 · OUTPUT ─────────────────────────────────── -->
<div class="form-group"> <section class="ds-section" data-ds-key="output" data-ch="amber">
<div class="label-row"> <div class="ds-section-header">
<label data-i18n="targets.brightness">Brightness:</label> <span class="ds-section-dot" aria-hidden="true"></span>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <span class="ds-section-title" data-i18n="settings.section.output">Output</span>
<span class="ds-section-index" aria-hidden="true">03</span>
</div> </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 class="ds-section-body">
<div id="ha-light-editor-brightness-container"></div> <div class="form-group">
</div> <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="form-group"> <div class="label-row">
<div class="label-row"> <label>
<label> <span data-i18n="ha_light.transition">Transition:</span>
<span data-i18n="ha_light.color_tolerance">Color Tolerance:</span> </label>
</label> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</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>
<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-transition-container"></div>
<div id="ha-light-editor-color-tolerance-container"></div> </div>
</div>
<!-- Min Brightness Threshold --> <div class="form-group">
<div class="form-group"> <div class="label-row">
<div class="label-row"> <label data-i18n="targets.brightness">Brightness:</label>
<label> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
<span data-i18n="ha_light.min_brightness_threshold">Min Brightness Threshold:</span> </div>
</label> <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>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <div id="ha-light-editor-brightness-container"></div>
</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> </section>
<div id="ha-light-editor-min-brightness-threshold-container"></div>
</div>
<!-- Light Mappings --> <!-- ── 04 · FILTERING ──────────────────────────────── -->
<div class="form-group"> <section class="ds-section" data-ds-key="filtering" data-ch="violet">
<div class="label-row"> <div class="ds-section-header">
<label data-i18n="ha_light.mappings">Light Mappings:</label> <span class="ds-section-dot" aria-hidden="true"></span>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <span class="ds-section-title" data-i18n="settings.section.filtering">Filtering</span>
<span class="ds-section-index" aria-hidden="true">04</span>
</div> </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 class="ds-section-body">
<div id="ha-light-mappings-list"></div> <div class="form-group">
<button type="button" class="btn btn-sm btn-secondary" onclick="addHALightMapping()" style="margin-top: 4px;"> <div class="label-row">
+ <span data-i18n="ha_light.mappings.add">Add Mapping</span> <label>
</button> <span data-i18n="ha_light.color_tolerance">Color Tolerance:</span>
</div> </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="form-group"> <div class="label-row">
<div class="label-row"> <label>
<label for="ha-light-editor-description" data-i18n="ha_light.description">Description (optional):</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> </div>
<input type="text" id="ha-light-editor-description" placeholder=""> </section>
</div>
</form> </form>
</div> </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 id="ha-source-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="ha-source-modal-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@@ -11,63 +16,89 @@
<div id="ha-source-error" class="error-message" style="display: none;"></div> <div id="ha-source-error" class="error-message" style="display: none;"></div>
<!-- Name --> <!-- ── 01 · IDENTITY ───────────────────────────────── -->
<div class="form-group"> <section class="ds-section" data-ds-key="identity" data-ch="signal">
<div class="label-row"> <div class="ds-section-header">
<label for="ha-source-name" data-i18n="ha_source.name">Name:</label> <span class="ds-section-dot" aria-hidden="true"></span>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <span class="ds-section-title" data-i18n="settings.section.identity">Identity</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="ha_source.name.hint">A descriptive name for this Home Assistant connection</small> <div class="ds-section-body">
<input type="text" id="ha-source-name" data-i18n-placeholder="ha_source.name.placeholder" placeholder="My Home Assistant" required> <div class="form-group ds-name-group">
<div id="ha-source-tags-container"></div> <label for="ha-source-name" data-i18n="ha_source.name">Name:</label>
</div> <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="form-group"> <div class="label-row">
<div class="label-row"> <label for="ha-source-description" data-i18n="ha_source.description">Description (optional):</label>
<label for="ha-source-host" data-i18n="ha_source.host">Host:</label> </div>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <input type="text" id="ha-source-description" placeholder="">
</div>
</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> </section>
<input type="text" id="ha-source-host" placeholder="192.168.1.100:8123" required>
</div>
<!-- Token --> <!-- ── 02 · CONNECTION ─────────────────────────────── -->
<div class="form-group"> <section class="ds-section" data-ds-key="connection" data-ch="cyan">
<div class="label-row"> <div class="ds-section-header">
<label for="ha-source-token" data-i18n="ha_source.token">Access Token:</label> <span class="ds-section-dot" aria-hidden="true"></span>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <span class="ds-section-title" data-i18n="settings.section.connection">Connection</span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div> </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> <div class="ds-section-body">
<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> <div class="form-group">
<input type="password" id="ha-source-token" placeholder="eyJ0eXAiOi..." autocomplete="new-password"> <div class="label-row">
</div> <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">
<div class="form-group"> <div class="label-row">
<label class="checkbox-label"> <label for="ha-source-token" data-i18n="ha_source.token">Access Token:</label>
<input type="checkbox" id="ha-source-ssl"> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
<span data-i18n="ha_source.use_ssl">Use SSL (wss://)</span> </div>
</label> <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>
</div> <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="ds-toggle-row">
<div class="form-group"> <div class="ds-toggle-text">
<div class="label-row"> <div class="label-row">
<label for="ha-source-filters" data-i18n="ha_source.entity_filters">Entity Filters (optional):</label> <label for="ha-source-ssl" data-i18n="ha_source.use_ssl">Use SSL (wss://)</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.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> </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> </section>
<input type="text" id="ha-source-filters" placeholder="sensor.*, binary_sensor.*">
</div>
<!-- Description --> <!-- ── 03 · FILTERS ────────────────────────────────── -->
<div class="form-group"> <section class="ds-section" data-ds-key="filters" data-ch="amber">
<div class="label-row"> <div class="ds-section-header">
<label for="ha-source-description" data-i18n="ha_source.description">Description (optional):</label> <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> </div>
<input type="text" id="ha-source-description" placeholder=""> <div class="ds-section-body">
</div> <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> </form>
</div> </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 id="mqtt-source-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="mqtt-source-modal-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@@ -11,79 +16,99 @@
<div id="mqtt-source-error" class="error-message" style="display: none;"></div> <div id="mqtt-source-error" class="error-message" style="display: none;"></div>
<!-- Name --> <!-- ── 01 · IDENTITY ───────────────────────────────── -->
<div class="form-group"> <section class="ds-section" data-ds-key="identity" data-ch="signal">
<div class="label-row"> <div class="ds-section-header">
<label for="mqtt-source-name" data-i18n="mqtt_source.name">Name:</label> <span class="ds-section-dot" aria-hidden="true"></span>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <span class="ds-section-title" data-i18n="settings.section.identity">Identity</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="mqtt_source.name.hint">A descriptive name for this MQTT broker connection</small> <div class="ds-section-body">
<input type="text" id="mqtt-source-name" data-i18n-placeholder="mqtt_source.name.placeholder" placeholder="My MQTT Broker" required> <div class="form-group ds-name-group">
<div id="mqtt-source-tags-container"></div> <label for="mqtt-source-name" data-i18n="mqtt_source.name">Name:</label>
</div> <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="form-group"> <div class="label-row">
<div class="label-row"> <label for="mqtt-source-description" data-i18n="mqtt_source.description">Description (optional):</label>
<label for="mqtt-source-host" data-i18n="mqtt_source.broker_host">Broker Host:</label> </div>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <input type="text" id="mqtt-source-description" placeholder="">
</div>
</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> </section>
<input type="text" id="mqtt-source-host" placeholder="192.168.1.100" required>
</div>
<!-- Broker Port --> <!-- ── 02 · BROKER ─────────────────────────────────── -->
<div class="form-group"> <section class="ds-section" data-ds-key="broker" data-ch="cyan">
<div class="label-row"> <div class="ds-section-header">
<label for="mqtt-source-port" data-i18n="mqtt_source.broker_port">Port:</label> <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> </div>
<input type="number" id="mqtt-source-port" value="1883" min="1" max="65535"> <div class="ds-section-body">
</div> <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="ds-pair-row">
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
<label for="mqtt-source-username" data-i18n="mqtt_source.username">Username (optional):</label> <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> </div>
<input type="text" id="mqtt-source-username" placeholder="" autocomplete="username"> </section>
</div>
<!-- Password --> <!-- ── 03 · PROTOCOL ───────────────────────────────── -->
<div class="form-group"> <section class="ds-section" data-ds-key="protocol" data-ch="amber">
<div class="label-row"> <div class="ds-section-header">
<label for="mqtt-source-password" data-i18n="mqtt_source.password">Password (optional):</label> <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> </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> <div class="ds-section-body">
<input type="password" id="mqtt-source-password" placeholder="" autocomplete="new-password"> <div class="form-group">
</div> <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="form-group"> <div class="label-row">
<div class="label-row"> <label for="mqtt-source-base-topic" data-i18n="mqtt_source.base_topic">Base Topic:</label>
<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>
<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> </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> </section>
<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>
</form> </form>
</div> </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 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-content" style="max-width:520px;width:100%">
<div class="modal-header"> <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> <button class="modal-close-btn" onclick="closeNotificationHistory()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
<div class="modal-body"> <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> <!-- ── 01 · HISTORY ────────────────────────────────── -->
<div id="notification-history-status" style="display:none;color:var(--text-muted);font-size:0.85rem;margin-bottom:0.5rem"></div> <section class="ds-section" data-ds-key="history" data-ch="violet">
<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 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>
<div class="modal-footer"> <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> <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 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-content modal-content-wide">
<div class="modal-header"> <div class="modal-header">
@@ -9,53 +11,71 @@
<form id="pattern-template-form"> <form id="pattern-template-form">
<input type="hidden" id="pattern-template-id"> <input type="hidden" id="pattern-template-id">
<div id="pattern-name-group" class="form-group"> <!-- ── 01 · IDENTITY ───────────────────────────────── -->
<div class="label-row"> <section class="ds-section" data-ds-key="identity" data-ch="signal">
<label for="pattern-template-name" data-i18n="pattern.name">Template Name:</label> <div class="ds-section-header">
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <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>
<small class="input-hint" style="display:none" data-i18n="pattern.name.hint">A descriptive name for this rectangle layout</small> <div class="ds-section-body">
<input type="text" id="pattern-template-name" data-i18n-placeholder="pattern.name.placeholder" placeholder="My Pattern Template" required> <div id="pattern-name-group" class="form-group ds-name-group">
<div id="pattern-tags-container"></div> <div class="label-row">
</div> <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 id="pattern-desc-group" class="form-group">
<div class="label-row"> <div class="label-row">
<label for="pattern-template-description" data-i18n="pattern.description_label">Description (optional):</label> <label for="pattern-template-description" data-i18n="pattern.description_label">Description (optional):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <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> </div>
<small class="input-hint" style="display:none" data-i18n="pattern.description.hint">Optional notes about where or how this pattern is used</small> </section>
<input type="text" id="pattern-template-description" data-i18n-placeholder="pattern.description_placeholder" placeholder="Describe this pattern...">
</div>
<!-- Visual Editor --> <!-- ── 02 · LAYOUT ─────────────────────────────────── -->
<div class="form-group"> <section class="ds-section" data-ds-key="layout" data-ch="cyan">
<div class="label-row"> <div class="ds-section-header">
<label data-i18n="pattern.visual_editor">Visual Editor</label> <span class="ds-section-dot" aria-hidden="true"></span>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <span class="ds-section-title" data-i18n="settings.section.layout">Layout</span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div> </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="ds-section-body">
<div class="pattern-bg-row"> <div class="form-group">
<select id="pattern-bg-source"></select> <div class="label-row">
<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> <label data-i18n="pattern.visual_editor">Visual Editor</label>
</div> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<div class="pattern-canvas-container"> </div>
<canvas id="pattern-canvas"></canvas> <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> <div class="pattern-bg-row">
</div> <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 class="form-group"> <div id="pattern-rect-labels" class="pattern-rect-labels">
<div id="pattern-rect-labels" class="pattern-rect-labels"> <span data-i18n="pattern.rect.name">Name</span>
<span data-i18n="pattern.rect.name">Name</span> <span data-i18n="pattern.rect.x">X</span>
<span data-i18n="pattern.rect.x">X</span> <span data-i18n="pattern.rect.y">Y</span>
<span data-i18n="pattern.rect.y">Y</span> <span data-i18n="pattern.rect.width">W</span>
<span data-i18n="pattern.rect.width">W</span> <span data-i18n="pattern.rect.height">H</span>
<span data-i18n="pattern.rect.height">H</span> <span></span>
<span></span> </div>
<div id="pattern-rect-list" class="pattern-rect-list"></div>
</div>
</div> </div>
<div id="pattern-rect-list" class="pattern-rect-list"></div> </section>
</div>
<div id="pattern-template-error" class="error-message" style="display: none;"></div> <div id="pattern-template-error" class="error-message" style="display: none;"></div>
</form> </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 id="pp-template-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="pp-template-modal-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@@ -8,26 +10,45 @@
<div class="modal-body"> <div class="modal-body">
<input type="hidden" id="pp-template-id"> <input type="hidden" id="pp-template-id">
<form id="pp-template-form"> <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 --> <!-- ── 01 · IDENTITY ───────────────────────────────── -->
<div id="pp-filter-list" class="pp-filter-list"></div> <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="form-group">
<div class="pp-add-filter-row"> <label for="pp-template-description" data-i18n="postprocessing.description_label">Description (optional):</label>
<select id="pp-add-filter-select" class="pp-add-filter-select"> <input type="text" id="pp-template-description" data-i18n-placeholder="postprocessing.description_placeholder" placeholder="Describe this template...">
<option value="" data-i18n="filters.select_type">Select filter type...</option> </div>
</select> </div>
</div> </section>
<div class="form-group"> <!-- ── 02 · FILTERS ────────────────────────────────── -->
<label for="pp-template-description" data-i18n="postprocessing.description_label">Description (optional):</label> <section class="ds-section" data-ds-key="filters" data-ch="cyan">
<input type="text" id="pp-template-description" data-i18n-placeholder="postprocessing.description_placeholder" placeholder="Describe this template..."> <div class="ds-section-header">
</div> <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> <div id="pp-template-error" class="error-message" style="display: none;"></div>
</form> </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 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-content">
<div class="modal-header"> <div class="modal-header">
@@ -9,34 +13,50 @@
<form id="scene-preset-editor-form"> <form id="scene-preset-editor-form">
<input type="hidden" id="scene-preset-editor-id"> <input type="hidden" id="scene-preset-editor-id">
<div class="form-group"> <!-- ── 01 · IDENTITY ───────────────────────────────── -->
<div class="label-row"> <section class="ds-section" data-ds-key="identity" data-ch="signal">
<label for="scene-preset-editor-name" data-i18n="scenes.name">Name:</label> <div class="ds-section-header">
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <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>
<small class="input-hint" style="display:none" data-i18n="scenes.name.hint">A descriptive name for this scene preset</small> <div class="ds-section-body">
<input type="text" id="scene-preset-editor-name" data-i18n-placeholder="scenes.name.placeholder" placeholder="My Scene" required> <div class="form-group ds-name-group">
<div id="scene-tags-container"></div> <label for="scene-preset-editor-name" data-i18n="scenes.name">Name:</label>
</div> <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="form-group">
<div class="label-row"> <div class="label-row">
<label for="scene-preset-editor-description" data-i18n="scenes.description">Description:</label> <label for="scene-preset-editor-description" data-i18n="scenes.description">Description:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <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> </div>
<small class="input-hint" style="display:none" data-i18n="scenes.description.hint">Optional description of what this scene does</small> </section>
<input type="text" id="scene-preset-editor-description">
</div>
<div class="form-group" id="scene-target-selector-group" style="display:none"> <!-- ── 02 · TARGETS ────────────────────────────────── -->
<div class="label-row"> <section class="ds-section" data-ds-key="targets" data-ch="cyan">
<label data-i18n="scenes.targets">Targets:</label> <div class="ds-section-header">
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <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> </div>
<small class="input-hint" style="display:none" data-i18n="scenes.targets.hint">Select which targets to include in this scene snapshot</small> <div class="ds-section-body">
<div id="scene-target-list" class="scene-target-list"></div> <div class="form-group" id="scene-target-selector-group" style="display:none">
<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 class="label-row">
</div> <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> <div id="scene-preset-editor-error" class="error-message" style="display: none;"></div>
</form> </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 id="setup-required-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="setup-required-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <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. 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> </p>
<div class="form-group"> <!-- ── 01 · CONFIGURE ──────────────────────────────── -->
<label data-i18n="setup.step1_label">1. On the server machine, edit <code>config/default_config.yaml</code>:</label> <section class="ds-section" data-ds-key="configure" data-ch="signal">
<div class="code-snippet-wrapper"> <div class="ds-section-header">
<pre id="setup-yaml-snippet" class="code-snippet"><code>auth: <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: api_keys:
dev: "REPLACE_WITH_A_LONG_RANDOM_SECRET"</code></pre> 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"> <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> <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>
</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> </div>
<small class="input-hint" data-i18n="setup.hint_openssl"> </section>
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 class="form-group"> <!-- ── 02 · RESTART ────────────────────────────────── -->
<label data-i18n="setup.step2_label">2. Restart the server, then reload this page and log in with that key.</label> <section class="ds-section" data-ds-key="restart" data-ch="cyan">
</div> <div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<div class="form-group"> <span class="ds-section-title" data-i18n="settings.section.restart">Restart</span>
<label data-i18n="setup.step3_label">Alternative: open LedGrab from the server machine itself (loopback), no key required:</label> <span class="ds-section-index" aria-hidden="true">02</span>
<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> <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>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-icon btn-secondary" onclick="retrySetupCheck()" data-i18n-title="setup.retry" title="Retry"> <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 id="stream-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="stream-modal-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@@ -13,122 +16,147 @@
</div> </div>
<input type="hidden" id="stream-id"> <input type="hidden" id="stream-id">
<form id="stream-form"> <form id="stream-form">
<div class="form-group">
<label for="stream-name" data-i18n="streams.name">Source Name:</label> <!-- ── 01 · IDENTITY ───────────────────────────────── -->
<input type="text" id="stream-name" data-i18n-placeholder="streams.name.placeholder" placeholder="My Source" required> <section class="ds-section" data-ds-key="identity" data-ch="signal">
<div id="stream-tags-container"></div> <div class="ds-section-header">
</div> <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"> <input type="hidden" id="stream-type" value="raw">
<!-- Raw source fields --> <!-- ── 02 · SOURCE ─────────────────────────────────── -->
<div id="stream-raw-fields"> <section class="ds-section" data-ds-key="source" data-ch="cyan">
<div class="form-group"> <div class="ds-section-header">
<div class="label-row"> <span class="ds-section-dot" aria-hidden="true"></span>
<label data-i18n="streams.display">Display:</label> <span class="ds-section-title" data-i18n="settings.section.source">Source</span>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <span class="ds-section-index" aria-hidden="true">02</span>
</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>
<div class="form-group"> <div class="ds-section-body">
<div class="label-row"> <!-- Raw source fields -->
<label for="stream-capture-template" data-i18n="streams.capture_template">Capture Template:</label> <div id="stream-raw-fields">
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <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> </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 --> <!-- Processed source fields -->
<div id="stream-processed-fields" style="display: none;"> <div id="stream-processed-fields" style="display: none;">
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
<label for="stream-source" data-i18n="streams.source">Source:</label> <label for="stream-source" data-i18n="streams.source">Source:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <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> </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 --> <!-- Static image fields -->
<div id="stream-static-image-fields" style="display: none;"> <div id="stream-static-image-fields" style="display: none;">
<div class="form-group"> <div class="form-group">
<label for="stream-image-asset" data-i18n="streams.image_asset">Image Asset:</label> <label for="stream-image-asset" data-i18n="streams.image_asset">Image Asset:</label>
<select id="stream-image-asset"></select> <select id="stream-image-asset"></select>
</div> </div>
</div> </div>
<div id="stream-video-fields" style="display: none;"> <div id="stream-video-fields" style="display: none;">
<div class="form-group"> <div class="form-group">
<label for="stream-video-asset" data-i18n="streams.video_asset">Video Asset:</label> <label for="stream-video-asset" data-i18n="streams.video_asset">Video Asset:</label>
<select id="stream-video-asset"></select> <select id="stream-video-asset"></select>
</div> </div>
<div class="form-group settings-toggle-group"> <div class="ds-toggle-row">
<label data-i18n="picture_source.video.loop">Loop:</label> <div class="ds-toggle-text">
<label class="settings-toggle"> <div class="label-row">
<input type="checkbox" id="stream-video-loop" checked> <label for="stream-video-loop" data-i18n="picture_source.video.loop">Loop:</label>
<span class="settings-toggle-slider"></span> </div>
</label> </div>
</div> <label class="settings-toggle">
<div class="form-row"> <input type="checkbox" id="stream-video-loop" checked>
<div class="form-group" style="flex:1"> <span class="settings-toggle-slider"></span>
<label for="stream-video-speed" data-i18n="picture_source.video.speed">Playback Speed: <span id="stream-video-speed-value">1.0</span>×</label> </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> <div class="ds-pair-row">
<div class="form-group" style="flex:1"> <div class="form-group">
<label for="stream-video-fps" data-i18n="streams.target_fps">Target FPS:</label> <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="number" id="stream-video-fps" min="1" max="60" step="1" value="30"> <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> </div>
<div class="form-row"> </section>
<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>
<div id="stream-error" class="error-message" style="display: none;"></div> <div id="stream-error" class="error-message" style="display: none;"></div>
</form> </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 id="sync-clock-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="sync-clock-modal-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@@ -11,37 +11,50 @@
<div id="sync-clock-error" class="error-message" style="display: none;"></div> <div id="sync-clock-error" class="error-message" style="display: none;"></div>
<!-- Name --> <!-- ── 01 · IDENTITY ───────────────────────────────── -->
<div class="form-group"> <section class="ds-section" data-ds-key="identity" data-ch="signal">
<div class="label-row"> <div class="ds-section-header">
<label for="sync-clock-name" data-i18n="sync_clock.name">Name:</label> <span class="ds-section-dot" aria-hidden="true"></span>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <span class="ds-section-title" data-i18n="settings.section.identity">Identity</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="sync_clock.name.hint">A descriptive name for this synchronization clock</small> <div class="ds-section-body">
<input type="text" id="sync-clock-name" data-i18n-placeholder="sync_clock.name.placeholder" placeholder="Main Clock" required> <div class="form-group ds-name-group">
<div id="sync-clock-tags-container"></div> <label for="sync-clock-name" data-i18n="sync_clock.name">Name:</label>
</div> <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="form-group"> <div class="label-row">
<div class="label-row"> <label for="sync-clock-description" data-i18n="sync_clock.description">Description (optional):</label>
<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>
<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> </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> </section>
<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>
<!-- Description --> <!-- ── 02 · TIMING ─────────────────────────────────── -->
<div class="form-group"> <section class="ds-section" data-ds-key="timing" data-ch="amber">
<div class="label-row"> <div class="ds-section-header">
<label for="sync-clock-description" data-i18n="sync_clock.description">Description (optional):</label> <span class="ds-section-dot" aria-hidden="true"></span>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <span class="ds-section-title" data-i18n="settings.section.timing">Timing</span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="sync_clock.description.hint">Optional notes about this clock's purpose</small> <div class="ds-section-body">
<input type="text" id="sync-clock-description" data-i18n-placeholder="sync_clock.description.placeholder" placeholder=""> <div class="form-group">
</div> <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> </form>
</div> </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 id="target-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="target-editor-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@@ -9,103 +15,133 @@
<form id="target-editor-form"> <form id="target-editor-form">
<input type="hidden" id="target-editor-id"> <input type="hidden" id="target-editor-id">
<div class="form-group"> <!-- ── 01 · IDENTITY ───────────────────────────────── -->
<label for="target-editor-name" data-i18n="targets.name">Target Name:</label> <section class="ds-section" data-ds-key="identity" data-ch="signal">
<input type="text" id="target-editor-name" data-i18n-placeholder="targets.name.placeholder" placeholder="My Target" required> <div class="ds-section-header">
<div id="target-tags-container"></div> <span class="ds-section-dot" aria-hidden="true"></span>
</div> <span class="ds-section-title" data-i18n="settings.section.identity">Identity</span>
<span class="ds-section-index" aria-hidden="true">01</span>
<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> </div>
<small class="input-hint" style="display:none" data-i18n="targets.device.hint">Select the LED device to send data to</small> <div class="ds-section-body">
<select id="target-editor-device"></select> <div class="form-group ds-name-group">
<small id="target-editor-device-info" class="device-led-info" style="display:none"></small> <label for="target-editor-name" data-i18n="targets.name">Target Name:</label>
</div> <input type="text" id="target-editor-name" data-i18n-placeholder="targets.name.placeholder" placeholder="My Target" required>
<div id="target-tags-container"></div>
<div class="form-group"> </div>
<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> </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> </section>
<select id="target-editor-css-source"></select>
</div>
<div class="form-group"> <!-- ── 02 · ROUTING ────────────────────────────────── -->
<div class="label-row"> <section class="ds-section" data-ds-key="routing" data-ch="cyan">
<label data-i18n="targets.brightness">Brightness:</label> <div class="ds-section-header">
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <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> </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 class="ds-section-body">
<div id="target-editor-brightness-container"></div> <div class="form-group">
</div> <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="form-group">
<div class="label-row"> <div class="label-row">
<label> <label for="target-editor-css-source" data-i18n="targets.color_strip_source">Color Strip Source:</label>
<span data-i18n="targets.fps">Target FPS:</span> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</label> </div>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <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> </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> </section>
<div id="target-editor-fps-container"></div>
<small id="target-editor-fps-rec" class="input-hint" style="display:none"></small>
</div>
<details class="form-collapse" id="target-editor-advanced-settings"> <!-- ── 03 · OUTPUT ─────────────────────────────────── -->
<summary data-i18n="targets.section.advanced">Advanced</summary> <section class="ds-section" data-ds-key="output" data-ch="amber">
<div class="form-collapse-body"> <div class="ds-section-header">
<div class="form-group" id="target-editor-brightness-threshold-group"> <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"> <div class="label-row">
<label> <label>
<span data-i18n="targets.min_brightness_threshold">Min Brightness Threshold:</span> <span data-i18n="targets.fps">Target FPS:</span>
</label> </label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div> </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> <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-brightness-threshold-container"></div> <div id="target-editor-fps-container"></div>
<small id="target-editor-fps-rec" class="input-hint" style="display:none"></small>
</div> </div>
<div class="form-group" id="target-editor-adaptive-fps-group"> <details class="form-collapse" id="target-editor-advanced-settings">
<div class="label-row"> <summary data-i18n="targets.section.advanced">Advanced</summary>
<label for="target-editor-adaptive-fps" data-i18n="targets.adaptive_fps">Adaptive FPS:</label> <div class="form-collapse-body">
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <div class="form-group" id="target-editor-brightness-threshold-group">
</div> <div class="label-row">
<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>
<label class="settings-toggle"> <span data-i18n="targets.min_brightness_threshold">Min Brightness Threshold:</span>
<input type="checkbox" id="target-editor-adaptive-fps"> </label>
<span class="settings-toggle-slider"></span> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</label> </div>
</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="form-group" id="target-editor-adaptive-fps-group">
<div class="label-row"> <div class="label-row">
<label for="target-editor-protocol" data-i18n="targets.protocol">Protocol:</label> <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> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div> </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> <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>
<select id="target-editor-protocol"> <label class="settings-toggle">
<option value="ddp">DDP (UDP)</option> <input type="checkbox" id="target-editor-adaptive-fps">
<option value="http">HTTP</option> <span class="settings-toggle-slider"></span>
</select> </label>
</div> </div>
<div class="form-group" id="target-editor-keepalive-group"> <div class="form-group" id="target-editor-protocol-group">
<div class="label-row"> <div class="label-row">
<label for="target-editor-keepalive-interval"> <label for="target-editor-protocol" data-i18n="targets.protocol">Protocol:</label>
<span data-i18n="targets.keepalive_interval">Keep Alive Interval:</span> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
<span id="target-editor-keepalive-interval-value">1.0</span><span>s</span> </div>
</label> <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>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <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> </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> </details>
<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> </div>
</details> </section>
<div id="target-editor-error" class="error-message" style="display: none;"></div> <div id="target-editor-error" class="error-message" style="display: none;"></div>
</form> </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 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-content">
<div class="modal-header"> <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> <button class="modal-close-btn" onclick="closeTestAudioSourceModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<canvas id="audio-test-canvas" class="audio-test-canvas"></canvas> <!-- ── 01 · PREVIEW ────────────────────────────────── -->
<div class="audio-test-stats"> <section class="ds-section" data-ds-key="preview" data-ch="signal">
<span class="audio-test-stat"> <div class="ds-section-header">
<span class="audio-test-stat-label" data-i18n="audio_source.test.rms">RMS</span> <span class="ds-section-dot" aria-hidden="true"></span>
<span class="audio-test-stat-value" id="audio-test-rms">---</span> <span class="ds-section-title" data-i18n="settings.section.preview">Preview</span>
</span> <span class="ds-section-index" aria-hidden="true">01</span>
<span class="audio-test-stat"> </div>
<span class="audio-test-stat-label" data-i18n="audio_source.test.peak">Peak</span> <div class="ds-section-body">
<span class="audio-test-stat-value" id="audio-test-peak">---</span> <canvas id="audio-test-canvas" class="audio-test-canvas"></canvas>
</span> <div class="audio-test-stats">
<span class="audio-test-stat"> <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.rms">RMS</span>
<span class="audio-test-stat-label" data-i18n="audio_source.test.beat">Beat</span> <span class="audio-test-stat-value" id="audio-test-rms">---</span>
</span> </span>
</div> <span class="audio-test-stat">
<div id="audio-test-status" class="audio-test-status" data-i18n="audio_source.test.connecting">Connecting...</div> <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> </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 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-content">
<div class="modal-header"> <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> <button class="modal-close-btn" onclick="closeTestAudioTemplateModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="form-group"> <!-- ── 01 · TEST ───────────────────────────────────── -->
<div class="label-row"> <section class="ds-section" data-ds-key="test" data-ch="cyan">
<label for="test-audio-template-device" data-i18n="audio_template.test.device">Audio Device:</label> <div class="ds-section-header">
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <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>
<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> <div class="ds-section-body">
<select id="test-audio-template-device"></select> <div class="form-group">
</div> <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;"> <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> <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>
</button> <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> <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;"> <div id="audio-template-test-stats" class="audio-test-stats" style="display:none;">
<span class="audio-test-stat"> <span class="audio-test-stat">
<span class="audio-test-stat-label" data-i18n="audio_source.test.rms">RMS</span> <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 class="audio-test-stat-value" id="audio-template-test-rms">---</span>
</span> </span>
<span class="audio-test-stat"> <span class="audio-test-stat">
<span class="audio-test-stat-label" data-i18n="audio_source.test.peak">Peak</span> <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 class="audio-test-stat-value" id="audio-template-test-peak">---</span>
</span> </span>
<span class="audio-test-stat"> <span class="audio-test-stat">
<span id="audio-template-test-beat-dot" class="audio-test-beat-dot"></span> <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 class="audio-test-stat-label" data-i18n="audio_source.test.beat">Beat</span>
</span> </span>
</div> </div>
<div id="audio-template-test-status" class="audio-test-status" style="display:none;"></div> <div id="audio-template-test-status" class="audio-test-status" style="display:none;"></div>
</div>
</section>
</div> </div>
</div> </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 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-content">
<div class="modal-header"> <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> <button class="modal-close-btn" onclick="closeTestCssSourceModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<!-- Strip view (for generic sources) --> <!-- ── 01 · PREVIEW ────────────────────────────────── -->
<div id="css-test-strip-view" class="css-test-strip-wrap"> <section class="ds-section" data-ds-key="preview" data-ch="signal">
<canvas id="css-test-strip-canvas" class="css-test-strip-canvas"></canvas> <div class="ds-section-header">
<canvas id="css-test-strip-axis" class="css-test-strip-axis"></canvas> <span class="ds-section-dot" aria-hidden="true"></span>
<button id="css-test-fire-btn" class="css-test-fire-btn" style="display:none" <span class="ds-section-title" data-i18n="settings.section.preview">Preview</span>
onclick="fireCssTestNotification()" <span class="ds-section-index" aria-hidden="true">01</span>
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>
</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) --> <!-- Rectangle view (for picture sources) -->
<div id="css-test-rect-view" style="display:none"> <div id="css-test-rect-view" style="display:none">
<div class="css-test-rect-outer"> <div class="css-test-rect-outer">
<canvas id="css-test-rect-ticks" class="css-test-rect-ticks"></canvas> <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" id="css-test-rect">
<div class="css-test-rect-corner"></div> <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-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-rect-corner"></div>
<div class="css-test-edge-wrap"><canvas id="css-test-edge-left" class="css-test-edge"></canvas></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"> <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-name" class="css-test-rect-label"></span>
<span id="css-test-rect-leds" class="css-test-rect-label css-test-rect-leds"></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>
<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> <!-- Composite layers view -->
<div class="css-test-edge-wrap"><canvas id="css-test-edge-bottom" class="css-test-edge"></canvas></div> <div id="css-test-layers-view" style="display:none">
<div class="css-test-rect-corner"></div> <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> </div>
</div> </section>
<!-- Composite layers view --> <!-- ── 02 · CONTROLS ───────────────────────────────── -->
<div id="css-test-layers-view" style="display:none"> <section class="ds-section" data-ds-key="controls" data-ch="cyan">
<div id="css-test-layers" class="css-test-layers"></div> <div class="ds-section-header">
<canvas id="css-test-layers-axis" class="css-test-strip-axis"></canvas> <span class="ds-section-dot" aria-hidden="true"></span>
</div> <span class="ds-section-title" data-i18n="settings.section.controls">Controls</span>
<span class="ds-section-index" aria-hidden="true">02</span>
<!-- 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>
<div id="css-test-kc-meta" class="css-test-kc-meta"></div> <div class="ds-section-body">
</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>
<!-- CSPT test: input source selector (hidden by default) --> <!-- LED count & FPS controls -->
<div id="css-test-cspt-input-group" style="display:none" class="css-test-led-control"> <div id="css-test-led-fps-group" class="css-test-led-control">
<label for="css-test-cspt-input-select" data-i18n="color_strip.processed.input">Source:</label> <span id="css-test-led-group">
<select id="css-test-cspt-input-select" class="css-test-cspt-select" onchange="applyCssTestSettings()"></select> <label for="css-test-led-input" data-i18n="color_strip.test.led_count">LEDs:</label>
</div> <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 --> <!-- FPS chart (for api_input sources) — matches target card sparkline -->
<div id="css-test-led-fps-group" class="css-test-led-control"> <div id="css-test-fps-chart-group" class="target-fps-row" style="display:none">
<span id="css-test-led-group"> <div class="target-fps-sparkline">
<label for="css-test-led-input" data-i18n="color_strip.test.led_count">LEDs:</label> <canvas id="css-test-fps-chart"></canvas>
<input type="number" id="css-test-led-input" min="1" max="2000" step="1" value="100" class="css-test-led-input"> </div>
<span class="css-test-separator"></span> <div class="target-fps-label">
</span> <span id="css-test-fps-value" class="metric-value">0</span>
<label for="css-test-fps-input" data-i18n="color_strip.test.fps">FPS:</label> <span class="target-fps-avg" id="css-test-fps-avg"></span>
<input type="number" id="css-test-fps-input" min="1" max="60" step="1" value="20" class="css-test-led-input"> </div>
<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>
</div>
<!-- FPS chart (for api_input sources) — matches target card sparkline --> <div id="css-test-status" class="css-test-status" data-i18n="color_strip.test.connecting">Connecting...</div>
<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>
<div class="target-fps-label"> </section>
<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>
</div> </div>
</div> </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 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-content">
<div class="modal-header"> <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> <button class="modal-close-btn" onclick="closeTestPPTemplateModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="form-group"> <!-- ── 01 · TEST ───────────────────────────────────── -->
<label data-i18n="postprocessing.test.source_stream">Source:</label> <section class="ds-section" data-ds-key="test" data-ch="cyan">
<select id="test-pp-source-stream"></select> <div class="ds-section-header">
</div> <span class="ds-section-dot" aria-hidden="true"></span>
<div class="form-group"> <span class="ds-section-title" data-i18n="settings.section.test">Test</span>
<label for="test-pp-duration"> <span class="ds-section-index" aria-hidden="true">01</span>
<span data-i18n="streams.test.duration">Capture Duration (s):</span> </div>
<span id="test-pp-duration-value">5</span> <div class="ds-section-body">
</label> <div class="form-group">
<input type="range" id="test-pp-duration" min="1" max="10" step="1" value="5" oninput="updatePPTestDuration(this.value)" /> <label data-i18n="postprocessing.test.source_stream">Source:</label>
</div> <select id="test-pp-source-stream"></select>
</div>
<button type="button" class="btn btn-primary" onclick="runPPTemplateTest()" style="margin-top: 16px;"> <div class="form-group">
<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> <label for="test-pp-duration">
</button> <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> </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 id="test-stream-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="test-stream-modal-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <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> <button class="modal-close-btn" onclick="closeTestStreamModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="form-group"> <!-- ── 01 · TEST ───────────────────────────────────── -->
<label for="test-stream-duration"> <section class="ds-section" data-ds-key="test" data-ch="cyan">
<span data-i18n="streams.test.duration">Capture Duration (s):</span> <div class="ds-section-header">
<span id="test-stream-duration-value">5</span> <span class="ds-section-dot" aria-hidden="true"></span>
</label> <span class="ds-section-title" data-i18n="settings.section.test">Test</span>
<input type="range" id="test-stream-duration" min="1" max="10" step="1" value="5" oninput="updateStreamTestDuration(this.value)" /> <span class="ds-section-index" aria-hidden="true">01</span>
</div> </div>
<div class="ds-section-body">
<button type="button" class="btn btn-primary" onclick="runStreamTest()" style="margin-top: 16px;"> <div class="form-group">
<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> <label for="test-stream-duration">
</button> <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> </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 id="test-template-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="test-template-modal-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <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> <button class="modal-close-btn" onclick="closeTestTemplateModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="form-group"> <!-- ── 01 · TEST ───────────────────────────────────── -->
<label data-i18n="templates.test.display">Display:</label> <section class="ds-section" data-ds-key="test" data-ch="cyan">
<input type="hidden" id="test-template-display" value=""> <div class="ds-section-header">
<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 class="ds-section-dot" aria-hidden="true"></span>
<span id="test-display-picker-label" data-i18n="displays.picker.select">Select display...</span> <span class="ds-section-title" data-i18n="settings.section.test">Test</span>
</button> <span class="ds-section-index" aria-hidden="true">01</span>
</div> </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"> <div class="form-group">
<label for="test-template-duration"> <label for="test-template-duration">
<span data-i18n="templates.test.duration">Capture Duration (s):</span> <span data-i18n="templates.test.duration">Capture Duration (s):</span>
<span id="test-template-duration-value">5</span> <span id="test-template-duration-value">5</span>
</label> </label>
<input type="range" id="test-template-duration" min="1" max="10" step="1" value="5" oninput="updateCaptureDuration(this.value)" /> <input type="range" id="test-template-duration" min="1" max="10" step="1" value="5" oninput="updateCaptureDuration(this.value)" />
</div> </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>
<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> </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 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-content">
<div class="modal-header"> <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> <button class="modal-close-btn" onclick="closeTestValueSourceModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<canvas id="vs-test-canvas" class="vs-test-canvas"></canvas> <!-- ── 01 · PREVIEW ────────────────────────────────── -->
<div id="vs-test-color-swatch" class="vs-test-color-swatch" style="display:none"> <section class="ds-section" data-ds-key="preview" data-ch="signal">
<canvas id="vs-test-color-canvas" class="vs-test-color-canvas"></canvas> <div class="ds-section-header">
</div> <span class="ds-section-dot" aria-hidden="true"></span>
<div class="vs-test-stats"> <span class="ds-section-title" data-i18n="settings.section.preview">Preview</span>
<span class="vs-test-stat vs-test-stat-current"> <span class="ds-section-index" aria-hidden="true">01</span>
<span class="vs-test-stat-label" data-i18n="value_source.test.current">Current</span> </div>
<span class="vs-test-stat-value vs-test-value-large" id="vs-test-current">---</span> <div class="ds-section-body">
</span> <canvas id="vs-test-canvas" class="vs-test-canvas"></canvas>
<span class="vs-test-stat"> <div id="vs-test-color-swatch" class="vs-test-color-swatch" style="display:none">
<span class="vs-test-stat-label" data-i18n="value_source.test.min">Min</span> <canvas id="vs-test-color-canvas" class="vs-test-color-canvas"></canvas>
<span class="vs-test-stat-value" id="vs-test-min">---</span> </div>
</span> <div class="vs-test-stats">
<span class="vs-test-stat"> <span class="vs-test-stat vs-test-stat-current">
<span class="vs-test-stat-label" data-i18n="value_source.test.max">Max</span> <span class="vs-test-stat-label" data-i18n="value_source.test.current">Current</span>
<span class="vs-test-stat-value" id="vs-test-max">---</span> <span class="vs-test-stat-value vs-test-value-large" id="vs-test-current">---</span>
</span> </span>
</div> <span class="vs-test-stat">
<div id="vs-test-status" class="vs-test-status" data-i18n="value_source.test.connecting">Connecting...</div> <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> </div>
</div> </div>
@@ -11,16 +11,34 @@
<div id="value-source-error" class="error-message" style="display: none;"></div> <div id="value-source-error" class="error-message" style="display: none;"></div>
<!-- Name --> <!-- ── 01 · IDENTITY ───────────────────────────────── -->
<div class="form-group"> <section class="ds-section" data-ds-key="identity" data-ch="signal">
<div class="label-row"> <div class="ds-section-header">
<label for="value-source-name" data-i18n="value_source.name">Name:</label> <span class="ds-section-dot" aria-hidden="true"></span>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <span class="ds-section-title" data-i18n="settings.section.identity">Identity</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="value_source.name.hint">A descriptive name for this value source</small> <div class="ds-section-body">
<input type="text" id="value-source-name" data-i18n-placeholder="value_source.name.placeholder" placeholder="Brightness Pulse" required> <div class="form-group ds-name-group">
<div id="value-source-tags-container"></div> <div class="label-row">
</div> <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) --> <!-- Type (hidden in edit mode since type is immutable) -->
<div id="value-source-type-group" class="form-group"> <div id="value-source-type-group" class="form-group">
@@ -621,15 +639,27 @@
</div> </div>
</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> </div>
<small class="input-hint" style="display:none" data-i18n="value_source.description.hint">Optional notes about this value source</small> </section>
<input type="text" id="value-source-description" data-i18n-placeholder="value_source.description.placeholder" placeholder="Describe this value source...">
</div> <!-- ── 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> </form>
</div> </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 id="weather-source-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="weather-source-modal-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@@ -11,71 +14,92 @@
<div id="weather-source-error" class="error-message" style="display: none;"></div> <div id="weather-source-error" class="error-message" style="display: none;"></div>
<!-- Name --> <!-- ── 01 · IDENTITY ───────────────────────────────── -->
<div class="form-group"> <section class="ds-section" data-ds-key="identity" data-ch="signal">
<div class="label-row"> <div class="ds-section-header">
<label for="weather-source-name" data-i18n="weather_source.name">Name:</label> <span class="ds-section-dot" aria-hidden="true"></span>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <span class="ds-section-title" data-i18n="settings.section.identity">Identity</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="weather_source.name.hint">A descriptive name for this weather source</small> <div class="ds-section-body">
<input type="text" id="weather-source-name" data-i18n-placeholder="weather_source.name.placeholder" placeholder="My Weather" required> <div class="form-group ds-name-group">
<div id="weather-source-tags-container"></div> <label for="weather-source-name" data-i18n="weather_source.name">Name:</label>
</div> <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>
<!-- 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> </div>
<div class="weather-location-field">
<label for="weather-source-longitude" data-i18n="weather_source.longitude">Lon:</label> <div class="form-group">
<input type="number" id="weather-source-longitude" min="-180" max="180" step="0.01" value="0.0"> <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> </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> </div>
<small id="weather-source-location-name" class="input-hint" style="display:none"></small> </section>
</div>
<!-- Update Interval --> <!-- ── 02 · PROVIDER ───────────────────────────────── -->
<div class="form-group"> <section class="ds-section" data-ds-key="provider" data-ch="cyan">
<div class="label-row"> <div class="ds-section-header">
<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> <span class="ds-section-dot" aria-hidden="true"></span>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <span class="ds-section-title" data-i18n="settings.section.provider">Provider</span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div> </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> <div class="ds-section-body">
<input type="range" id="weather-source-interval" min="60" max="3600" step="60" value="600" <div class="form-group">
oninput="document.getElementById('weather-source-interval-display').textContent = Math.round(this.value / 60)"> <div class="label-row">
</div> <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="form-group"> <div class="label-row">
<div class="label-row"> <label data-i18n="weather_source.location">Location:</label>
<label for="weather-source-description" data-i18n="weather_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="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> </div>
<input type="text" id="weather-source-description" data-i18n-placeholder="weather_source.description.placeholder" placeholder=""> </section>
</div>
<!-- ── 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> </form>
</div> </div>
@@ -1,33 +1,61 @@
<!-- Getting Started Tutorial Overlay (viewport-level) --> <!-- Getting Started Tutorial Overlay (viewport-level) — v2 "Signal Bench" variant -->
<div id="getting-started-overlay" class="tutorial-overlay tutorial-overlay-fixed" role="dialog" aria-modal="true"> <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-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">
<div class="tutorial-tooltip-header"> <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> <span class="tutorial-step-counter"></span>
<button class="tutorial-close-btn" onclick="closeTutorial()" data-i18n-aria-label="aria.close">&times;</button> <button class="tutorial-close-btn" onclick="closeTutorial()" data-i18n-aria-label="aria.close">&times;</button>
</div> </div>
<div class="tutorial-pips" aria-hidden="true"></div>
<p class="tutorial-tooltip-text"></p> <p class="tutorial-tooltip-text"></p>
<div class="tutorial-tooltip-nav"> <div class="tutorial-tooltip-nav">
<button class="tutorial-prev-btn" onclick="tutorialPrev()" data-i18n-aria-label="aria.previous">&#8592;</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">&#8594;</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> </div>
</div> </div>
<!-- Device Tutorial Overlay (viewport-level) --> <!-- Device Tutorial Overlay (viewport-level) — v2 "Signal Bench" variant -->
<div id="device-tutorial-overlay" class="tutorial-overlay tutorial-overlay-fixed" role="dialog" aria-modal="true"> <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-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">
<div class="tutorial-tooltip-header"> <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> <span class="tutorial-step-counter"></span>
<button class="tutorial-close-btn" onclick="closeTutorial()" data-i18n-aria-label="aria.close">&times;</button> <button class="tutorial-close-btn" onclick="closeTutorial()" data-i18n-aria-label="aria.close">&times;</button>
</div> </div>
<div class="tutorial-pips" aria-hidden="true"></div>
<p class="tutorial-tooltip-text"></p> <p class="tutorial-tooltip-text"></p>
<div class="tutorial-tooltip-nav"> <div class="tutorial-tooltip-nav">
<button class="tutorial-prev-btn" onclick="tutorialPrev()" data-i18n-aria-label="aria.previous">&#8592;</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">&#8594;</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> </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)