diff --git a/.gitignore b/.gitignore index 98d679f..8c25cf6 100644 --- a/.gitignore +++ b/.gitignore @@ -95,3 +95,5 @@ tmp/ # OS Thumbs.db .DS_Store +# Added by code-review-graph +.code-review-graph/ diff --git a/CLAUDE.md b/CLAUDE.md index 0f5a74c..9d982a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,3 +104,42 @@ Do NOT commit code that fails linting or tests. Fix the issues first. - Follow existing code style and patterns - Update documentation when changing behavior - Never make commits or pushes without explicit user approval + + +## MCP Tools: code-review-graph + +**IMPORTANT: This project has a knowledge graph. ALWAYS use the +code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore +the codebase.** The graph is faster, cheaper (fewer tokens), and gives +you structural context (callers, dependents, test coverage) that file +scanning cannot. + +### When to use graph tools FIRST + +- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep +- **Understanding impact**: `get_impact_radius` instead of manually tracing imports +- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files +- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for +- **Architecture questions**: `get_architecture_overview` + `list_communities` + +Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need. + +### Key Tools + +| Tool | Use when | +|------|----------| +| `detect_changes` | Reviewing code changes — gives risk-scored analysis | +| `get_review_context` | Need source snippets for review — token-efficient | +| `get_impact_radius` | Understanding blast radius of a change | +| `get_affected_flows` | Finding which execution paths are impacted | +| `query_graph` | Tracing callers, callees, imports, tests, dependencies | +| `semantic_search_nodes` | Finding functions/classes by name or keyword | +| `get_architecture_overview` | Understanding high-level codebase structure | +| `refactor_tool` | Planning renames, finding dead code | + +### Workflow + +1. The graph auto-updates on file changes (via hooks). +2. Use `detect_changes` for code review. +3. Use `get_affected_flows` to understand impact. +4. Use `query_graph` pattern="tests_for" to check coverage. diff --git a/docs/settings-modal-redesign.html b/docs/settings-modal-redesign.html new file mode 100644 index 0000000..4f2a81e --- /dev/null +++ b/docs/settings-modal-redesign.html @@ -0,0 +1,1751 @@ + + + + + +LedGrab · Global Settings · redesign mockup + + + + + + + +
+
+ SETTINGS · REDESIGN MOCKUP + Global Settings — Lumenworks rack panel +
+
+ + +
+
+ + + + + + + + + + + diff --git a/server/src/ledgrab/api/routes/color_strip_sources/ws_stream.py b/server/src/ledgrab/api/routes/color_strip_sources/ws_stream.py index e2b8926..a491c94 100644 --- a/server/src/ledgrab/api/routes/color_strip_sources/ws_stream.py +++ b/server/src/ledgrab/api/routes/color_strip_sources/ws_stream.py @@ -476,13 +476,16 @@ async def test_color_strip_ws( meta["layer_infos"] = layer_infos await websocket.send_text(_json.dumps(meta)) - # For api_input: send the current buffer immediately so the client - # gets a frame right away (fallback color if inactive) rather than - # leaving the canvas blank/stale until external data arrives. + # For api_input: only send an initial frame if a client has actually + # pushed data (push_generation > 0). Without prior data, the preview + # stays blank instead of showing the fallback buffer as a stray frame. if is_api_input: - initial_colors = stream.get_latest_colors() - if initial_colors is not None: - await websocket.send_bytes(initial_colors.tobytes()) + initial_gen = stream.push_generation + if initial_gen > 0: + _last_push_gen = initial_gen + initial_colors = stream.get_latest_colors() + if initial_colors is not None: + await websocket.send_bytes(initial_colors.tobytes()) # For picture sources, grab the live stream for frame preview _frame_live = None diff --git a/server/src/ledgrab/api/schemas/update.py b/server/src/ledgrab/api/schemas/update.py index ee71835..4b5f8e6 100644 --- a/server/src/ledgrab/api/schemas/update.py +++ b/server/src/ledgrab/api/schemas/update.py @@ -3,6 +3,14 @@ from pydantic import BaseModel, Field +class UpdateAssetInfo(BaseModel): + """A downloadable asset attached to a release (e.g. an installer).""" + + name: str + size: int + download_url: str + + class UpdateReleaseInfo(BaseModel): version: str tag: str @@ -10,6 +18,7 @@ class UpdateReleaseInfo(BaseModel): body: str prerelease: bool published_at: str + assets: list[UpdateAssetInfo] = Field(default_factory=list) class UpdateStatusResponse(BaseModel): diff --git a/server/src/ledgrab/core/processing/color_strip/base.py b/server/src/ledgrab/core/processing/color_strip/base.py index 86fa0df..c7b1b47 100644 --- a/server/src/ledgrab/core/processing/color_strip/base.py +++ b/server/src/ledgrab/core/processing/color_strip/base.py @@ -45,6 +45,19 @@ class ColorStripStream(ABC): def target_fps(self) -> int: """Target processing rate.""" + @property + def actual_fps(self) -> Optional[float]: + """Measured rate of *new* frames the stream is delivering, or ``None``. + + Only streams backed by an external capture (screen, audio device, API + push) implement this — the value answers "is the upstream actually + keeping up?". Synthetic streams (gradient/static/cycle/effect/...) + always tick at their `target_fps` by construction, so reporting an + actual rate would just duplicate `target_fps` without diagnostic + value; they keep the default ``None``. + """ + return None + @property @abstractmethod def led_count(self) -> int: diff --git a/server/src/ledgrab/core/processing/color_strip/picture.py b/server/src/ledgrab/core/processing/color_strip/picture.py index 161a4dd..72f4943 100644 --- a/server/src/ledgrab/core/processing/color_strip/picture.py +++ b/server/src/ledgrab/core/processing/color_strip/picture.py @@ -2,6 +2,7 @@ import threading import time +from collections import deque from typing import Optional import numpy as np @@ -72,6 +73,15 @@ class PictureColorStripStream(ColorStripStream): self._thread: Optional[threading.Thread] = None self._last_timing: dict = {} + # Rolling 1s window of timestamps for *new* frames received from + # the live stream. `len(...)` is the per-second frame rate the + # picture pipeline is actually consuming — diverges from + # `target_fps` when the underlying screen capture stalls (heavy + # GPU load, occluded window, DXGI desktop switch, etc.). Reads + # from another thread see a stale length at worst; deque ops are + # atomic under the GIL so no lock is needed. + self._new_frame_timestamps: deque[float] = deque(maxlen=180) + @property def live_stream(self): """Public accessor for the underlying LiveStream (used by preview WebSocket).""" @@ -81,6 +91,31 @@ class PictureColorStripStream(ColorStripStream): def target_fps(self) -> int: return self._fps + @property + def actual_fps(self) -> Optional[float]: + """Measured new-frame rate over the last 1 second. + + Returns the count of distinct frames the picture loop accepted in + the trailing 1s window. ``None`` until the loop has run (no + meaningful number to report yet). + """ + ts_dq = self._new_frame_timestamps + if not ts_dq: + return None + # Stale-tolerant read: producer may pop while we iterate, but we + # only look at the snapshot length and the leftmost timestamp. + now = time.perf_counter() + # If the stream has gone idle (no new frames for >1s) the deque + # still holds samples until the loop next ticks; report 0 so the + # spark drops to the floor instead of pinning at the last rate. + try: + oldest = ts_dq[0] + except IndexError: + return None + if now - oldest > 1.5: + return 0.0 + return float(len(ts_dq)) + @property def led_count(self) -> int: return self._led_count @@ -116,6 +151,7 @@ class PictureColorStripStream(ColorStripStream): self._thread = None self._latest_colors = None self._previous_colors = None + self._new_frame_timestamps.clear() logger.info("PictureColorStripStream stopped") def get_latest_colors(self) -> Optional[np.ndarray]: @@ -206,6 +242,14 @@ class PictureColorStripStream(ColorStripStream): cached_frame = frame t0 = time.perf_counter() + # Record the new frame in the rolling 1s window + # used by `actual_fps`. Pop entries older than + # 1s so `len()` reads as frames-per-second. + ts_dq = self._new_frame_timestamps + ts_dq.append(t0) + cutoff = t0 - 1.0 + while ts_dq and ts_dq[0] < cutoff: + ts_dq.popleft() calibration = self._calibration mapper = self._pixel_mapper diff --git a/server/src/ledgrab/core/processing/composite_stream.py b/server/src/ledgrab/core/processing/composite_stream.py index 9b56397..fb7ab11 100644 --- a/server/src/ledgrab/core/processing/composite_stream.py +++ b/server/src/ledgrab/core/processing/composite_stream.py @@ -97,6 +97,30 @@ class CompositeColorStripStream(ColorStripStream): def target_fps(self) -> int: return self._fps + @property + def actual_fps(self) -> Optional[float]: + """Aggregate measured capture rate across capture-backed sub-streams. + + Sums `actual_fps` from each sub-stream that reports one (i.e. + capture-backed layers like screen/audio captures). Returns + ``None`` when no sub-stream measures capture — keeps synthetic- + only composites out of the "Total Capture FPS" cell instead of + contributing a 0. + """ + with self._sub_lock: + subs = list(self._sub_streams.values()) + total = 0.0 + any_reporting = False + for _src_id, _consumer_id, stream in subs: + try: + v = getattr(stream, "actual_fps", None) + except Exception: + v = None + if isinstance(v, (int, float)): + total += float(v) + any_reporting = True + return total if any_reporting else None + def set_capture_fps(self, fps: int) -> None: self._fps = max(1, min(90, fps)) self._frame_time = 1.0 / self._fps diff --git a/server/src/ledgrab/core/processing/metrics_history.py b/server/src/ledgrab/core/processing/metrics_history.py index 0d950a1..d067836 100644 --- a/server/src/ledgrab/core/processing/metrics_history.py +++ b/server/src/ledgrab/core/processing/metrics_history.py @@ -75,6 +75,16 @@ class MetricsHistory: self._system: deque = deque(maxlen=MAX_SAMPLES) self._targets: Dict[str, deque] = {} self._task: Optional[asyncio.Task] = None + # Baselines for converting cumulative `errors_count` / + # `frames_skipped` into per-second rates inside the system ring + # buffer. None until the first sample arrives so we don't + # synthesize a fake initial spike from "0 → live count". + self._prev_total_errors: Optional[int] = None + self._prev_total_skipped: Optional[int] = None + # Same shape, but for the network throughput counter. Reset to + # None when the cumulative sum drops (target stopped, counter + # reset) so we never emit a negative rate. + self._prev_total_bytes_sent: Optional[int] = None async def start(self): """Start the background sampling loop.""" @@ -110,7 +120,6 @@ class MetricsHistory: """Collect one snapshot of system and target metrics.""" # System metrics (blocking psutil/nvml calls in thread pool) sys_snap = await asyncio.to_thread(_collect_system_snapshot) - self._system.append(sys_snap) # Per-target metrics from processor states try: @@ -121,22 +130,151 @@ class MetricsHistory: now = datetime.now(timezone.utc).isoformat() active_ids = set() + + # Aggregates across running targets — mirrors the dashboard's + # frontend computation so the FPS / Capture FPS / Errors cells + # can seed their sparklines from this ring buffer and survive + # a page reload, the same way CPU / RAM already do. + total_fps = 0.0 + total_capture_fps = 0.0 + total_capture_fps_actual = 0.0 + capture_actual_count = 0 + total_fps_target = 0.0 + total_errors_count = 0 + total_frames_skipped = 0 + running_count = 0 + # Network / send-timing aggregates across running targets. + # `send_timing_*` reads "is the LED transport keeping up?" — a + # leading indicator of network congestion that fires before + # frames actually start dropping. + total_bytes_sent = 0 + send_timing_max_ms = 0.0 + send_timing_sum_ms = 0.0 + send_timing_count = 0 + for target_id, state in all_states.items(): active_ids.add(target_id) if target_id not in self._targets: self._targets[target_id] = deque(maxlen=MAX_SAMPLES) if state.get("processing"): + running_count += 1 + fps_actual = state.get("fps_actual") + if isinstance(fps_actual, (int, float)) and fps_actual > 0: + total_fps += float(fps_actual) + fps_capture = state.get("fps_capture") + if isinstance(fps_capture, (int, float)) and fps_capture > 0: + total_capture_fps += float(fps_capture) + fps_capture_actual = state.get("fps_capture_actual") + # `None` means the stream type doesn't measure capture + # (synthetic streams). Counted separately so the cell + # can read "0 of 0" vs "0 of N stalled". + if isinstance(fps_capture_actual, (int, float)): + total_capture_fps_actual += float(fps_capture_actual) + capture_actual_count += 1 + fps_target = state.get("fps_target") + if isinstance(fps_target, (int, float)) and fps_target > 0: + total_fps_target += float(fps_target) + errors_count = state.get("errors_count") + if isinstance(errors_count, (int, float)) and errors_count > 0: + total_errors_count += int(errors_count) + frames_skipped = state.get("frames_skipped") + if isinstance(frames_skipped, (int, float)) and frames_skipped > 0: + total_frames_skipped += int(frames_skipped) + bytes_sent = state.get("bytes_sent") + if isinstance(bytes_sent, (int, float)) and bytes_sent > 0: + total_bytes_sent += int(bytes_sent) + send_timing = state.get("timing_send_ms") + if isinstance(send_timing, (int, float)) and send_timing >= 0: + send_timing_sum_ms += float(send_timing) + send_timing_count += 1 + if send_timing > send_timing_max_ms: + send_timing_max_ms = float(send_timing) + self._targets[target_id].append( { "t": now, - "fps": state.get("fps_actual"), + "fps": fps_actual, "fps_current": state.get("fps_current"), - "fps_target": state.get("fps_target"), + "fps_target": fps_target, "timing": state.get("timing_total_ms"), "errors": state.get("errors_count", 0), } ) + # Convert the cumulative error/skipped totals into per-second + # rates. Guard against the first sample (no previous baseline) + # and against counter resets when a target stops or restarts + # (delta < 0 → treat as 0). + errors_per_sec = 0.0 + skipped_per_sec = 0.0 + bytes_per_sec = 0.0 + if self._prev_total_errors is not None: + delta = max(0, total_errors_count - self._prev_total_errors) + errors_per_sec = delta / SAMPLE_INTERVAL + if self._prev_total_skipped is not None: + delta = max(0, total_frames_skipped - self._prev_total_skipped) + skipped_per_sec = delta / SAMPLE_INTERVAL + if self._prev_total_bytes_sent is not None: + delta_b = max(0, total_bytes_sent - self._prev_total_bytes_sent) + bytes_per_sec = delta_b / SAMPLE_INTERVAL + self._prev_total_errors = total_errors_count + self._prev_total_skipped = total_frames_skipped + self._prev_total_bytes_sent = total_bytes_sent + + # Device latency aggregates — pulled from the manager's + # device-health view rather than re-deriving from per-target + # state, so devices that are shared by multiple targets only + # count once. + device_latency_avg_ms: Optional[float] = None + device_latency_max_ms: Optional[float] = None + device_online_count = 0 + device_total_count = 0 + try: + health_dicts = self._manager.get_all_device_health_dicts() + except Exception as e: + logger.error("Failed to get device health: %s", e) + health_dicts = {} + latency_sum = 0.0 + latency_n = 0 + latency_max = 0.0 + for _did, h in health_dicts.items(): + device_total_count += 1 + if h.get("device_online"): + device_online_count += 1 + lat = h.get("device_latency_ms") + if isinstance(lat, (int, float)) and lat >= 0: + latency_sum += float(lat) + latency_n += 1 + if lat > latency_max: + latency_max = float(lat) + if latency_n > 0: + device_latency_avg_ms = round(latency_sum / latency_n, 1) + device_latency_max_ms = round(latency_max, 1) + + sys_snap["total_fps"] = round(total_fps, 1) + sys_snap["total_capture_fps"] = round(total_capture_fps, 1) + sys_snap["total_capture_fps_actual"] = round(total_capture_fps_actual, 1) + sys_snap["capture_actual_count"] = capture_actual_count + sys_snap["total_fps_target"] = round(total_fps_target, 1) + sys_snap["total_errors_count"] = total_errors_count + sys_snap["total_frames_skipped"] = total_frames_skipped + sys_snap["errors_per_sec"] = round(errors_per_sec, 3) + sys_snap["skipped_per_sec"] = round(skipped_per_sec, 3) + sys_snap["running_count"] = running_count + sys_snap["total_bytes_sent"] = total_bytes_sent + sys_snap["bytes_per_sec"] = round(bytes_per_sec, 1) + sys_snap["send_timing_avg_ms"] = ( + round(send_timing_sum_ms / send_timing_count, 2) if send_timing_count > 0 else 0.0 + ) + sys_snap["send_timing_max_ms"] = round(send_timing_max_ms, 2) + sys_snap["send_timing_count"] = send_timing_count + sys_snap["device_latency_avg_ms"] = device_latency_avg_ms + sys_snap["device_latency_max_ms"] = device_latency_max_ms + sys_snap["device_online_count"] = device_online_count + sys_snap["device_total_count"] = device_total_count + + self._system.append(sys_snap) + # Prune deques for targets no longer registered for tid in list(self._targets.keys()): if tid not in active_ids: diff --git a/server/src/ledgrab/core/processing/target_processor.py b/server/src/ledgrab/core/processing/target_processor.py index 5fdb2cb..789ad09 100644 --- a/server/src/ledgrab/core/processing/target_processor.py +++ b/server/src/ledgrab/core/processing/target_processor.py @@ -69,6 +69,13 @@ class ProcessingMetrics: # Streaming liveness (HTTP probe during DDP) device_streaming_reachable: Optional[bool] = None fps_effective: int = 0 + # Cumulative LED-payload bytes sent to the device. Aggregated across + # all running targets in MetricsHistory to derive a per-second + # network throughput sparkline. Counts the color-array payload only; + # protocol overhead (DDP/UDP/IP headers) is sub-5 % for any + # non-trivial LED count and is intentionally ignored to keep the + # counter cheap (`np.ndarray.nbytes`, no per-frame allocation). + bytes_sent: int = 0 @dataclass diff --git a/server/src/ledgrab/core/processing/wled_target_processor.py b/server/src/ledgrab/core/processing/wled_target_processor.py index 2711334..e2cd4b9 100644 --- a/server/src/ledgrab/core/processing/wled_target_processor.py +++ b/server/src/ledgrab/core/processing/wled_target_processor.py @@ -400,9 +400,13 @@ class WledTargetProcessor(TargetProcessor): css_timing: dict = {} css_capture_fps: Optional[int] = None + css_capture_fps_actual: Optional[float] = None if self._is_running and self._css_stream is not None: css_timing = self._css_stream.get_last_timing() css_capture_fps = getattr(self._css_stream, "target_fps", None) + # `actual_fps` is None for synthetic streams (gradient/static/...) + # — only picture/audio/api-input style streams measure it. + css_capture_fps_actual = getattr(self._css_stream, "actual_fps", None) send_ms = round(metrics.timing_send_ms, 1) if self._is_running else None # Picture source timing @@ -447,6 +451,8 @@ class WledTargetProcessor(TargetProcessor): "fps_potential": metrics.fps_potential if self._is_running else None, "fps_target": fps_target, "fps_capture": css_capture_fps, + "fps_capture_actual": css_capture_fps_actual, + "bytes_sent": metrics.bytes_sent if self._is_running else None, "frames_skipped": metrics.frames_skipped if self._is_running else None, "frames_keepalive": metrics.frames_keepalive if self._is_running else None, "fps_current": metrics.fps_current if self._is_running else None, @@ -666,6 +672,8 @@ class WledTargetProcessor(TargetProcessor): self._led_client.send_pixels_fast(send_colors) else: await self._led_client.send_pixels(send_colors) + # Approximate network throughput counter (LED-payload bytes only). + self._metrics.bytes_sent += int(send_colors.nbytes) return (time.perf_counter() - t_start) * 1000 @staticmethod diff --git a/server/src/ledgrab/core/update/update_service.py b/server/src/ledgrab/core/update/update_service.py index 0f1bc6d..ad1d723 100644 --- a/server/src/ledgrab/core/update/update_service.py +++ b/server/src/ledgrab/core/update/update_service.py @@ -588,6 +588,14 @@ class UpdateService: "body": rel.body, "prerelease": rel.prerelease, "published_at": rel.published_at, + "assets": [ + { + "name": a.name, + "size": a.size, + "download_url": a.download_url, + } + for a in rel.assets + ], } if rel else None diff --git a/server/src/ledgrab/static/css/appearance.css b/server/src/ledgrab/static/css/appearance.css index ddb16e2..fc1dbc8 100644 --- a/server/src/ledgrab/static/css/appearance.css +++ b/server/src/ledgrab/static/css/appearance.css @@ -18,39 +18,45 @@ h1 { margin-bottom: 0.75rem; } +/* Responsive preset grid — matches the mockup's tight 4-up rhythm + on desktop and gracefully reflows on narrow viewports. */ .ap-grid { display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 10px; + grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); + gap: 8px; + margin-top: 6px; } /* ─── Preset card (shared) ─── */ .ap-card { + --ap-ch: var(--ch-magenta, #ff4ade); position: relative; display: flex; flex-direction: column; - align-items: center; - gap: 6px; - padding: 6px; - border: 2px solid var(--border-color); - border-radius: var(--radius-md); - background: var(--card-bg); + align-items: stretch; + gap: 5px; + padding: 5px 5px 4px; + border: 1px solid var(--lux-line, var(--border-color)); + border-radius: var(--lux-r-md, 8px); + background: var(--lux-bg-1, var(--card-bg)); cursor: pointer; transition: border-color var(--duration-normal) var(--ease-out), box-shadow var(--duration-normal) var(--ease-out), transform var(--duration-fast) var(--ease-out); } +.ap-card.ap-card-bg { --ap-ch: var(--ch-cyan, #00d8ff); } .ap-card:hover { - border-color: var(--text-muted); + border-color: color-mix(in srgb, var(--ap-ch) 50%, var(--lux-line, var(--border-color))); transform: translateY(-1px); } .ap-card.active { - border-color: var(--primary-color); - box-shadow: 0 0 0 1px var(--primary-color), - 0 0 12px -2px color-mix(in srgb, var(--primary-color) 40%, transparent); + border: 2px solid var(--ap-ch); + padding: 4px 4px 3px; + 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); } .ap-card.active::after { @@ -60,29 +66,30 @@ h1 { right: 6px; font-size: 0.65rem; font-weight: 700; - color: var(--primary-color); + color: var(--ap-ch); } .ap-card-label { - font-size: 0.72rem; + font-size: 0.7rem; font-weight: 600; - color: var(--text-secondary); + color: var(--lux-ink-dim, var(--text-secondary)); text-align: center; line-height: 1.2; + letter-spacing: 0.02em; } .ap-card.active .ap-card-label { - color: var(--primary-color); + color: var(--ap-ch); } /* ─── Style preset preview ─── */ .ap-card-preview { width: 100%; - aspect-ratio: 4 / 3; - border-radius: var(--radius-sm); + aspect-ratio: 1 / 1; + border-radius: var(--lux-r-sm, 4px); border: 1px solid; - padding: 8px 7px 6px; + padding: 7px 6px 5px; display: flex; flex-direction: column; gap: 4px; @@ -113,12 +120,12 @@ h1 { .ap-bg-preview { width: 100%; - aspect-ratio: 4 / 3; - border-radius: var(--radius-sm); + aspect-ratio: 1 / 1; + border-radius: var(--lux-r-sm, 4px); overflow: hidden; position: relative; background: var(--bg-color); - border: 1px solid var(--border-color); + border: 1px solid var(--lux-line, var(--border-color)); } .ap-bg-preview-inner { diff --git a/server/src/ledgrab/static/css/automations.css b/server/src/ledgrab/static/css/automations.css index f284250..76908b8 100644 --- a/server/src/ledgrab/static/css/automations.css +++ b/server/src/ledgrab/static/css/automations.css @@ -1,48 +1,23 @@ /* ===== AUTOMATIONS ===== */ -.badge-automation-active { - background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 16%, transparent); - border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 45%, transparent); - color: var(--ch-signal, var(--primary-color)); - box-shadow: 0 0 8px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 20%, transparent); -} - -.badge-automation-inactive { - background: transparent; - border-color: var(--lux-line, var(--border-color)); - color: var(--lux-ink-dim, var(--text-color)); -} - -.badge-automation-disabled { - background: transparent; - border-color: var(--lux-line, var(--border-color)); - color: var(--lux-ink-mute, var(--text-muted)); - opacity: 0.8; -} - .automation-status-disabled { opacity: 0.6; } -.automation-logic-label { - font-size: 0.7rem; +/* Chain-arrow separator — slips between chips on the AUTO card to + render the rule flow visually (rule + rule → scene ↩ revert). + Used inside .mod-chips, channel-tinted via the parent's --ch. */ +.mod-card .chain-arrow { + display: inline-flex; + align-items: center; + color: var(--ch); + opacity: 0.65; + font-family: var(--font-mono, monospace); + font-size: 0.72rem; font-weight: 600; - color: var(--text-muted); - padding: 0 4px; -} - -/* Automation rule pills — constrain to card width */ -[data-automation-id] .card-meta { - display: flex; - flex-wrap: wrap; - gap: 4px; - min-width: 0; -} -[data-automation-id] .stream-card-prop { - max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + letter-spacing: 0.04em; + user-select: none; + flex-shrink: 0; } /* Automation rule editor rows */ diff --git a/server/src/ledgrab/static/css/cards.css b/server/src/ledgrab/static/css/cards.css index daaeae4..830b2ab 100644 --- a/server/src/ledgrab/static/css/cards.css +++ b/server/src/ledgrab/static/css/cards.css @@ -2294,6 +2294,31 @@ ul.section-tip li { height: 100%; } +/* Corner tag overlay on a preview surface (e.g. "BUILT-IN" on built-in + gradient strips). Sits in the top-right with a backdrop-blurred dark + pill so it stays legible on any gradient — light, dark, or mid-tone. */ +.mod-preview__tag { + position: absolute; + top: 5px; + right: 5px; + padding: 2px 7px; + font-family: var(--font-mono, monospace); + font-size: 0.55rem; + font-weight: 600; + letter-spacing: 0.18em; + text-transform: uppercase; + line-height: 1.4; + color: rgba(255, 255, 255, 0.95); + background: rgba(0, 0, 0, 0.55); + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: var(--lux-r-sm, 3px); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + pointer-events: none; + user-select: none; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); +} + /* ── Description text ──────────────────────────────────────────── */ .mod-desc { diff --git a/server/src/ledgrab/static/css/components.css b/server/src/ledgrab/static/css/components.css index ddf954b..4befe6e 100644 --- a/server/src/ledgrab/static/css/components.css +++ b/server/src/ledgrab/static/css/components.css @@ -94,6 +94,19 @@ background: var(--lux-bg-3, var(--border-color)); } +/* Transparent hairline variant — used for low-emphasis actions like + "Revert" inside the per-section save bar. */ +.btn-ghost { + background: transparent; + color: var(--lux-ink-dim, var(--text-color)); + border-color: var(--lux-line, var(--border-color)); +} +.btn-ghost:hover { + background: var(--hover-bg, rgba(255, 255, 255, 0.05)); + color: var(--lux-ink, var(--text-color)); + border-color: var(--lux-line-bold, var(--border-color)); +} + .btn-icon { min-width: auto; padding: 7px 10px; diff --git a/server/src/ledgrab/static/css/graph-editor.css b/server/src/ledgrab/static/css/graph-editor.css index 36a8e74..86d5c99 100644 --- a/server/src/ledgrab/static/css/graph-editor.css +++ b/server/src/ledgrab/static/css/graph-editor.css @@ -430,7 +430,7 @@ html:has(#tab-graph.active) { } .graph-node-body { - fill: var(--card-bg); + fill: var(--lux-bg-1, var(--card-bg)); stroke: var(--lux-line, var(--border-color)); stroke-width: 1; rx: 6; @@ -723,7 +723,7 @@ html:has(#tab-graph.active) { } .graph-node-overlay-bg { - fill: var(--card-bg); + fill: var(--lux-bg-1, var(--card-bg)); stroke: var(--border-color); stroke-width: 1; rx: 6; diff --git a/server/src/ledgrab/static/css/layout.css b/server/src/ledgrab/static/css/layout.css index b65be3f..ac6b6a5 100644 --- a/server/src/ledgrab/static/css/layout.css +++ b/server/src/ledgrab/static/css/layout.css @@ -382,26 +382,27 @@ h2 { font-family: var(--font-mono, 'Orbitron', sans-serif); font-size: 0.55rem; font-weight: 600; - color: var(--lux-ink-mute, var(--text-secondary)); + color: var(--ch-signal, var(--primary-color)); background: transparent; - border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + border: var(--lux-hairline, 1px) solid color-mix(in srgb, var(--ch-signal, var(--primary-color)) 45%, transparent); padding: 2px 6px; border-radius: 2px; letter-spacing: 0.12em; text-transform: uppercase; - transition: background 0.3s, color 0.3s, box-shadow 0.3s; + transition: background 0.3s, color 0.3s, border-color 0.3s, box-shadow 0.3s; } #server-version.has-update { - background: var(--warning-color); + background: var(--ch-signal, var(--primary-color)); + border-color: var(--ch-signal, var(--primary-color)); color: #fff; cursor: pointer; animation: updatePulse 2s ease-in-out infinite; } @keyframes updatePulse { - 0%, 100% { box-shadow: 0 0 0 0 rgba(255, 152, 0, 0.4); } - 50% { box-shadow: 0 0 0 4px rgba(255, 152, 0, 0); } + 0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--ch-signal, var(--primary-color)) 40%, transparent); } + 50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 0%, transparent); } } /* ── Update banner ── */ diff --git a/server/src/ledgrab/static/css/modal.css b/server/src/ledgrab/static/css/modal.css index 50d959e..6b8d011 100644 --- a/server/src/ledgrab/static/css/modal.css +++ b/server/src/ledgrab/static/css/modal.css @@ -494,265 +494,1318 @@ animation: tabFadeIn 0.25s ease-out; } -/* ── About panel ──────────────────────────────────────────── */ -.about-section { - text-align: center; - padding: 8px 0 4px; -} +/* About panel — see Lumenworks .about-hero block lower in this file. */ -.about-logo { - margin-bottom: 8px; -} - -.about-logo .icon { - width: 36px; - height: 36px; - color: var(--primary-color); -} - -.about-title { - margin: 0 0 2px; - font-size: 1.1rem; - color: var(--text-color); -} - -.about-version { - display: inline-block; - margin-bottom: 8px; - padding: 2px 10px; - border-radius: 10px; - background: var(--bg-tertiary); - color: var(--text-secondary); - font-size: 0.8rem; - font-family: var(--font-mono, monospace); -} - -.about-text { - margin: 0 0 12px; - color: var(--text-secondary); - font-size: 0.85rem; - line-height: 1.4; -} - -.about-license { - margin: 10px 0 0; - color: var(--text-secondary); - font-size: 0.8rem; - opacity: 0.7; -} - -.about-links { - display: flex; - flex-direction: column; - gap: 8px; - max-width: 280px; - margin: 0 auto; -} - -.about-link { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 14px; - border-radius: var(--radius); - background: var(--bg-secondary); - border: 1px solid var(--border-color); - color: var(--text-color); - text-decoration: none; - font-size: 0.9rem; - transition: border-color 0.15s, background 0.15s; -} - -.about-link:hover { - border-color: var(--primary-color); - background: var(--bg-tertiary); -} - -.about-link .icon { - width: 18px; - height: 18px; - flex-shrink: 0; -} - -.about-link .icon:last-child { - width: 14px; - height: 14px; - margin-left: auto; - color: var(--text-secondary); -} - -.about-link span { - flex: 1; - text-align: left; -} - -.about-link-donate .icon:first-child { - color: #e25555; -} - -/* ── Log viewer overlay (full-screen) ──────────────────────── */ +/* ── Log viewer overlay (full-screen) — instrument rack aesthetic ── + The overlay borrows the .module shell from dashboard.css for its + header, then frames the live tail in a recessed "console" surface + with scanlines + signal-flow at the top edge. */ .log-overlay { + --ch: var(--ch-cyan, #00d8ff); position: fixed; inset: 0; z-index: var(--z-log-overlay); display: flex; flex-direction: column; - background: var(--bg-color, #111); - padding: 12px 16px; - animation: fadeIn 0.2s ease-out; + gap: 14px; + padding: 28px 28px 24px; + /* Atmospheric backdrop: deep base + signal-tinted glow + faint + grid texture so the surface reads as a piece of equipment, not + an empty pane. */ + background: + radial-gradient(900px 600px at 18% -8%, + color-mix(in srgb, var(--ch) 14%, transparent) 0%, + transparent 65%), + radial-gradient(700px 500px at 92% 108%, + color-mix(in srgb, var(--ch) 10%, transparent) 0%, + transparent 60%), + repeating-linear-gradient(0deg, + transparent 0, + transparent 23px, + color-mix(in srgb, var(--lux-ink, #fff) 3%, transparent) 23px, + color-mix(in srgb, var(--lux-ink, #fff) 3%, transparent) 24px), + var(--lux-bg-0, #000); + animation: logOverlayIn 0.24s cubic-bezier(0.16, 1, 0.3, 1); } +@keyframes logOverlayIn { + from { opacity: 0; backdrop-filter: blur(0); } + to { opacity: 1; backdrop-filter: blur(0); } +} + +[data-theme="light"] .log-overlay { + background: + radial-gradient(900px 600px at 18% -8%, + color-mix(in srgb, var(--ch) 18%, transparent) 0%, + transparent 65%), + radial-gradient(700px 500px at 92% 108%, + color-mix(in srgb, var(--ch) 14%, transparent) 0%, + transparent 60%), + repeating-linear-gradient(0deg, + transparent 0, + transparent 23px, + color-mix(in srgb, var(--lux-ink, #000) 3%, transparent) 23px, + color-mix(in srgb, var(--lux-ink, #000) 3%, transparent) 24px), + var(--bg-page, var(--bg-color, #f0f1f4)); +} + +/* Top-right close — square mod-icon-btn vocabulary. Floats over the + header module so the corner bracket on the module sits underneath. */ .log-overlay-close { position: absolute; - top: 8px; - right: 12px; - background: none; - border: none; - color: var(--text-secondary); - font-size: 1.3rem; + top: 14px; + right: 14px; + background: var(--lux-bg-1, var(--card-bg)); + border: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color)); + color: var(--lux-ink-dim, var(--text-secondary)); cursor: pointer; - width: 32px; - height: 32px; - display: flex; + width: 30px; + height: 30px; + padding: 0; + display: inline-flex; align-items: center; justify-content: center; - border-radius: 6px; - z-index: 1; - transition: color 0.15s, background 0.15s; + border-radius: var(--lux-r-sm, 3px); + z-index: 5; + transition: color 0.15s, background 0.15s, border-color 0.15s, + box-shadow 0.15s; +} +.log-overlay-close svg { + width: 14px; + height: 14px; +} +.log-overlay-close:hover, +.log-overlay-close:focus-visible { + color: var(--ch-coral, var(--danger-color)); + border-color: color-mix(in srgb, var(--ch-coral, var(--danger-color)) 50%, transparent); + background: color-mix(in srgb, var(--ch-coral, var(--danger-color)) 10%, var(--lux-bg-1, var(--card-bg))); + box-shadow: 0 0 14px color-mix(in srgb, var(--ch-coral, var(--danger-color)) 25%, transparent); + outline: none; } -.log-overlay-close:hover { - color: var(--text-color); - background: var(--border-color); -} - -.log-overlay-toolbar { - display: flex; - align-items: center; - gap: 8px; - padding-bottom: 10px; - padding-right: 36px; /* space for corner close btn */ +/* ── Header module ── leaves a slot of breathing room on the right + so the close button has its own corner. */ +.log-overlay .log-module { flex-shrink: 0; + padding-right: 56px; } -.log-overlay-toolbar h3 { - margin: 0; - font-size: 1rem; - white-space: nowrap; - margin-right: 4px; +/* The filter ). Style the resulting + `.icon-select-trigger` so it reads as a chip-sized instrument + control inside the mod-foot, matching the mod-btn vocabulary. */ +.log-overlay .icon-select-trigger { + width: auto; + flex: 0 0 auto; + gap: 6px; + padding: 6px 10px; + font-family: var(--font-mono, monospace); + font-size: 0.66rem; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + border: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color)); + border-radius: var(--lux-r-sm, 3px); + background: var(--lux-bg-2, var(--card-bg)); + color: var(--lux-ink-dim, var(--text-secondary)); +} +.log-overlay .icon-select-trigger:hover, +.log-overlay .icon-select-trigger:focus-visible { + color: var(--lux-ink, var(--text-color)); + border-color: color-mix(in srgb, var(--ch) 45%, var(--lux-line-bold, var(--border-color))); + background: var(--lux-bg-3, var(--border-color)); + outline: none; +} +.log-overlay .icon-select-trigger-icon { font-size: 0.78rem; } +.log-overlay .icon-select-trigger-arrow { + color: var(--lux-ink-mute, var(--text-secondary)); + margin-left: 2px; } +/* The mod-foot has filter + buttons. Push the filter alongside the + action buttons (right side); patch indicator stays on the far left + via its own margin-right:auto already in dashboard.css. */ +.log-overlay .log-mod-foot { + flex-wrap: wrap; +} + +/* Coral tint on warn/err metric cells once their counters move + beyond zero — same .has-errors hook pattern as the dashboard. */ +.log-overlay .mod-metric.has-warn .v, +.log-overlay .mod-metric.has-warn .k, +.log-overlay .mod-metric.has-warn .k svg { + color: var(--ch-amber, #ffb800); +} +.log-overlay .mod-metric.has-warn .k svg { opacity: 1; } + +.log-overlay .mod-metric.has-errors .v, +.log-overlay .mod-metric.has-errors .k, +.log-overlay .mod-metric.has-errors .k svg { + color: var(--ch-coral, var(--danger-color)); +} +.log-overlay .mod-metric.has-errors .k svg { opacity: 1; } + +/* ── Console surface — recessed bezel hosting the live
. ── */
+.log-console {
+    --ch: var(--ch-cyan, #00d8ff);
+    flex: 1;
+    min-height: 0;
+    position: relative;
+    display: flex;
+    background: var(--lux-bg-0, #000);
+    border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
+    border-radius: var(--lux-r-md, 6px);
+    overflow: hidden;
+    box-shadow:
+        inset 0 1px 0 rgba(0, 0, 0, 0.6),
+        inset 0 0 0 1px color-mix(in srgb, var(--ch) 8%, transparent),
+        0 12px 32px rgba(0, 0, 0, 0.5);
+}
+
+/* Signal-flow strip at the top edge — only animates while connected. */
+.log-console::before {
+    content: '';
+    position: absolute;
+    left: 0; right: 0; top: 0;
+    height: 2px;
+    background: linear-gradient(90deg,
+        transparent 0%,
+        color-mix(in srgb, var(--ch) 90%, transparent) 50%,
+        transparent 100%);
+    background-size: 30% 100%;
+    background-repeat: no-repeat;
+    background-position: -30% 0;
+    opacity: 0;
+    transition: opacity 0.2s;
+    pointer-events: none;
+}
+.log-overlay.is-streaming .log-console::before {
+    opacity: 0.85;
+    animation: signalFlow 2.4s linear infinite;
+}
+
+/* Faint scanline texture on the console — subtle CRT vibe. */
+.log-console::after {
+    content: '';
+    position: absolute;
+    inset: 0;
+    background: repeating-linear-gradient(
+        180deg,
+        transparent 0,
+        transparent 2px,
+        color-mix(in srgb, var(--ch) 6%, transparent) 2px,
+        color-mix(in srgb, var(--ch) 6%, transparent) 3px
+    );
+    pointer-events: none;
+    mix-blend-mode: screen;
+    opacity: 0.35;
+}
+[data-theme="light"] .log-console::after {
+    mix-blend-mode: multiply;
+    opacity: 0.18;
+}
+
+/* Left rail — silkscreened "TAIL" label with caret cursor. */
+.log-console__rail {
+    flex-shrink: 0;
+    width: 40px;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    padding: 8px 0;
+    border-right: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
+    background: linear-gradient(180deg,
+        color-mix(in srgb, var(--ch) 10%, transparent),
+        transparent 40%,
+        transparent);
+    font-family: var(--font-mono, monospace);
+    color: var(--lux-ink-mute, var(--text-secondary));
+    z-index: 1;
+}
+.log-console__caret {
+    color: var(--ch);
+    font-size: 0.85rem;
+    line-height: 1;
+    margin-bottom: 8px;
+    filter: drop-shadow(0 0 4px color-mix(in srgb, var(--ch) 70%, transparent));
+}
+.log-console__rail-label {
+    writing-mode: vertical-rl;
+    transform: rotate(180deg);
+    font-size: 0.55rem;
+    font-weight: 600;
+    letter-spacing: 0.32em;
+    text-transform: uppercase;
+    margin-top: 4px;
+}
+
+/* Empty state — sits over the 
, hidden once the first line lands. */
+.log-console__empty {
+    position: absolute;
+    inset: 0;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    gap: 10px;
+    color: var(--lux-ink-mute, var(--text-secondary));
+    font-family: var(--font-mono, monospace);
+    pointer-events: none;
+    z-index: 2;
+    transition: opacity 0.2s;
+}
+.log-overlay.has-data .log-console__empty { opacity: 0; }
+.log-console__empty-mark {
+    width: 36px;
+    height: 36px;
+    border-radius: 50%;
+    border: var(--lux-hairline, 1px) solid color-mix(in srgb, var(--ch) 35%, var(--lux-line, var(--border-color)));
+    position: relative;
+}
+.log-console__empty-mark::before,
+.log-console__empty-mark::after {
+    content: '';
+    position: absolute;
+    inset: -8px;
+    border-radius: 50%;
+    border: var(--lux-hairline, 1px) solid color-mix(in srgb, var(--ch) 22%, transparent);
+    animation: emptyPing 2.4s cubic-bezier(0, 0, 0.2, 1) infinite;
+}
+.log-console__empty-mark::after {
+    animation-delay: 1.2s;
+    inset: -16px;
+}
+@keyframes emptyPing {
+    0%   { transform: scale(0.6); opacity: 0.9; }
+    100% { transform: scale(1.4); opacity: 0; }
+}
+.log-console__empty-title {
+    font-size: 0.7rem;
+    font-weight: 600;
+    letter-spacing: 0.24em;
+    text-transform: uppercase;
+    color: var(--lux-ink-dim, var(--text-secondary));
+}
+.log-console__empty-sub {
+    font-size: 0.7rem;
+    letter-spacing: 0.04em;
+    color: var(--lux-ink-mute, var(--text-secondary));
+    max-width: 32ch;
+    text-align: center;
+    line-height: 1.5;
+}
+
+/* The 
 sits inside the console, taking remaining horizontal space. */
 .log-overlay .log-viewer-output {
     flex: 1;
+    margin: 0;
+    border: none;
+    border-radius: 0;
+    padding: 14px 18px 14px 14px;
+    background: transparent;
     max-height: none;
-    border-radius: 8px;
     min-height: 0;
+    overflow: auto;
+    z-index: 1;
+    /* Crisp font + extra leading for readability */
+    font-family: var(--font-mono, 'JetBrains Mono', 'Cascadia Code', monospace);
+    font-size: 0.78rem;
+    line-height: 1.55;
+    color: var(--lux-ink, #d4d4d4);
+    /* Custom scrollbar (WebKit) tinted to match channel */
+    scrollbar-color: color-mix(in srgb, var(--ch) 30%, var(--lux-bg-2, #15181d))
+                     transparent;
+    scrollbar-width: thin;
+}
+.log-overlay .log-viewer-output::-webkit-scrollbar { width: 10px; height: 10px; }
+.log-overlay .log-viewer-output::-webkit-scrollbar-thumb {
+    background: color-mix(in srgb, var(--ch) 28%, var(--lux-bg-2, #15181d));
+    border-radius: 99px;
+    border: 2px solid var(--lux-bg-0, #000);
+}
+.log-overlay .log-viewer-output::-webkit-scrollbar-track { background: transparent; }
+
+/* Per-line styling: each  becomes its own row with a thin
+   left edge tinted by severity. The mono colors are kept close to
+   the original VS-Code-ish palette so existing muscle memory works. */
+.log-overlay .log-viewer-output > span {
+    display: block;
+    position: relative;
+    padding: 2px 6px 2px 12px;
+    border-radius: 2px;
+    transition: background 0.12s;
+}
+.log-overlay .log-viewer-output > span::before {
+    content: '';
+    position: absolute;
+    left: 0; top: 4px; bottom: 4px;
+    width: 2px;
+    border-radius: 2px;
+    background: color-mix(in srgb, var(--lux-ink-faint, #3a414c) 70%, transparent);
+}
+.log-overlay .log-viewer-output > span:hover {
+    background: color-mix(in srgb, var(--ch) 6%, transparent);
+}
+.log-overlay .log-viewer-output > span.log-line-error::before {
+    background: var(--ch-coral, #ff5e5e);
+    box-shadow: 0 0 6px color-mix(in srgb, var(--ch-coral, #ff5e5e) 60%, transparent);
+}
+.log-overlay .log-viewer-output > span.log-line-warning::before {
+    background: var(--ch-amber, #ffb800);
+    box-shadow: 0 0 5px color-mix(in srgb, var(--ch-amber, #ffb800) 50%, transparent);
+}
+.log-overlay .log-viewer-output > span.log-line-debug::before {
+    background: color-mix(in srgb, var(--lux-ink-faint, #3a414c) 80%, transparent);
+}
+
+/* Compact layout on narrow viewports (mobile/portrait) */
+@media (max-width: 640px) {
+    .log-overlay { padding: 14px 12px 12px; gap: 10px; }
+    .log-overlay .log-module { padding-right: 48px; }
+    .log-console__rail { width: 28px; }
+    .log-overlay .log-viewer-output {
+        padding: 10px 12px;
+        font-size: 0.72rem;
+    }
 }
 
 /* ── Release notes content ─────────────────────────────────── */
 
-.release-notes-content {
-    flex: 1;
-    overflow-y: auto;
-    padding: 1rem 1.5rem;
-    font-size: 0.9rem;
-    line-height: 1.6;
-    color: var(--text-color);
-    background: var(--bg-secondary);
-    border-radius: 8px;
-}
+/*
+ * Scoped to `.release-notes-shell` so the log overlay is unaffected.
+ * Uses --lux-* / --ch-* / --font-display tokens shared with cards-redesign-v2
+ * (see docs/cards-redesign-demo-v2.html). Channel: signal (configurable via
+ * data-ch on the shell).
+ */
 
-.release-notes-content h2,
-.release-notes-content h3,
-.release-notes-content h4 {
-    margin: 1.2em 0 0.4em;
-    color: var(--text-color);
-}
-
-.release-notes-content h2 { font-size: 1.2rem; }
-.release-notes-content h3 { font-size: 1.05rem; }
-.release-notes-content h4 { font-size: 0.95rem; }
-
-.release-notes-content pre {
-    background: #0d0d0d;
-    color: #d4d4d4;
-    padding: 0.75rem 1rem;
-    border-radius: 6px;
-    overflow-x: auto;
-    font-size: 0.82rem;
-}
-
-.release-notes-content code {
-    background: var(--bg-tertiary, #2a2a2a);
-    padding: 0.15em 0.4em;
-    border-radius: 3px;
-    font-size: 0.88em;
-}
-
-.release-notes-content pre code {
-    background: none;
+.release-notes-shell {
+    --rn-ch: var(--ch-signal);
+    background: var(--lux-bg-0, var(--bg-color, #000));
     padding: 0;
+    overflow: hidden;
+    color: var(--lux-ink, var(--text-color));
+    font-family: var(--font-body, -apple-system, BlinkMacSystemFont, sans-serif);
+}
+.release-notes-shell[data-ch="cyan"]    { --rn-ch: var(--ch-cyan); }
+.release-notes-shell[data-ch="magenta"] { --rn-ch: var(--ch-magenta); }
+.release-notes-shell[data-ch="amber"]   { --rn-ch: var(--ch-amber); }
+.release-notes-shell[data-ch="coral"]   { --rn-ch: var(--ch-coral); }
+.release-notes-shell[data-ch="violet"]  { --rn-ch: var(--ch-violet); }
+
+/* The default .log-overlay-close is hidden — we render our own .rn-act--close. */
+.release-notes-shell > .log-overlay-close { display: none; }
+
+/* Always-on left channel stripe (mirrors .module::before from v2) */
+.rn-shell__stripe {
+    position: absolute;
+    left: 0; top: 0; bottom: 0;
+    width: 4px;
+    background: var(--rn-ch);
+    box-shadow: 0 0 18px color-mix(in srgb, var(--rn-ch) 55%, transparent),
+                0 0 4px color-mix(in srgb, var(--rn-ch) 90%, transparent);
+    opacity: .85;
+    z-index: 2;
 }
 
-.release-notes-content a {
-    color: var(--primary-color);
+/* Silkscreen corner brackets — instrument detail */
+.rn-shell__bracket {
+    position: absolute;
+    width: 18px; height: 18px;
+    pointer-events: none;
+    z-index: 2;
+    opacity: .55;
+}
+.rn-shell__bracket--tl {
+    top: 14px; left: 18px;
+    border-top: 1px solid var(--lux-line-bold, var(--border-color));
+    border-left: 1px solid var(--lux-line-bold, var(--border-color));
+}
+.rn-shell__bracket--br {
+    bottom: 14px; right: 14px;
+    border-bottom: 1px solid var(--lux-line-bold, var(--border-color));
+    border-right: 1px solid var(--lux-line-bold, var(--border-color));
 }
 
-.release-notes-content hr {
+/* ── Header rack ────────────────────────────────────────────────── */
+/* Header padding-left matches body padding-left so the title and the
+ * markdown content share a single left edge / column. The header acts
+ * as a fixed masthead — body scrolls underneath it — so its height
+ * directly steals from the visible reading area. Keep it compact. */
+.rn-head {
+    position: relative;
+    /* Paint above the scrollable body so any body content that scrolls
+     * up to the head's y-range is masked by the head's solid bg. */
+    z-index: 2;
+    /* Opt out of layout.css's global `header { height: 60px; display:
+     * grid; ... }` rule that's intended for the page transport bar.
+     * Without these resets the masthead panel collapses to 60px and
+     * its grid template fights our flex layout. */
+    height: auto;
+    grid-template-columns: none;
+    display: flex;
+    /* flex-start (not center) so the lede column — eyebrow, title,
+     * chips — stacks from the top of the panel. The actions get
+     * pinned to the title's vertical centre via align-self below. */
+    align-items: flex-start;
+    gap: 18px;
+    /* Generous vertical padding — the head must wrap the title AND
+     * the badge strip. Bigger top/bottom offset so the title doesn't
+     * crash into the panel border. */
+    padding: 20px 24px 18px 36px;
+    border-bottom: 1px solid var(--lux-line, var(--border-color));
+    flex-shrink: 0;
+    background:
+        linear-gradient(180deg,
+            color-mix(in srgb, var(--rn-ch) 4%, transparent) 0%,
+            transparent 60%),
+        var(--lux-bg-1, var(--bg-secondary));
+}
+/* Suppress the global `header::before` rainbow gradient line from
+ * layout.css — it was painting a horizontal accent line through the
+ * masthead, near the title baseline. */
+.rn-head::before {
+    content: none;
+}
+/* Vertically center the action cluster against the lede column so the
+ * close button sits at roughly title-height — neither pinned to the
+ * top corner nor dragged down by the chip strip. */
+.rn-head__actions {
+    align-self: center;
+}
+/* No gradient bar at the bottom of the header — the bar lives directly
+ * under the title (.rn-title::after) so it reads as a title underline,
+ * not a section divider. The header still has its hairline border-bottom. */
+
+.rn-head__lede {
+    flex: 1;
+    min-width: 0;
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+}
+
+/* Eyebrow row — mono uppercase + pulsing dot */
+.rn-eyebrow {
+    display: inline-flex;
+    align-items: center;
+    gap: 10px;
+    font-family: var(--font-mono, 'JetBrains Mono', monospace);
+    font-size: .68rem;
+    font-weight: 600;
+    letter-spacing: .28em;
+    text-transform: uppercase;
+    color: var(--lux-ink-mute, var(--text-secondary));
+    line-height: 1;
+}
+.rn-eyebrow__dot {
+    width: 7px; height: 7px;
+    border-radius: 50%;
+    background: var(--rn-ch);
+    box-shadow: 0 0 0 3px color-mix(in srgb, var(--rn-ch) 22%, transparent);
+    animation: rnPulse 2s ease-in-out infinite;
+}
+.rn-eyebrow__sep {
+    width: 1px; height: 10px;
+    background: var(--lux-line-bold, var(--border-color));
+}
+.rn-eyebrow__channel {
+    color: var(--rn-ch);
+    letter-spacing: .22em;
+}
+@keyframes rnPulse {
+    0%, 100% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--rn-ch) 22%, transparent); }
+    50%      { box-shadow: 0 0 0 6px color-mix(in srgb, var(--rn-ch)  0%, transparent); }
+}
+
+/* Display-font title with channel-tinted accent */
+.rn-title {
+    position: relative;
+    font-family: var(--font-display, 'Big Shoulders Display', 'Orbitron', sans-serif);
+    /* Full display-size masthead title — sits on the same row as the
+     * meta chips (`.rn-head` uses align-items: center) so the head
+     * stays compact despite the larger type. */
+    font-size: clamp(1.7rem, 3.3vw, 2.6rem);
+    font-weight: 900;
+    letter-spacing: -.02em;
+    line-height: 1.15;
+    color: var(--lux-ink, var(--text-color));
+    margin: 0;
+    display: flex;
+    flex-wrap: wrap;
+    align-items: baseline;
+    gap: 14px;
+}
+.rn-title__accent {
+    font-style: normal;
+    font-family: var(--font-display, 'Big Shoulders Display', sans-serif);
+    color: var(--rn-ch);
+    font-variant-numeric: tabular-nums;
+    letter-spacing: -.01em;
+}
+
+/* Metadata chip strip */
+.rn-meta {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 8px;
+    margin-top: 4px;
+}
+.rn-chip {
+    display: inline-flex;
+    align-items: center;
+    gap: 6px;
+    padding: 4px 10px;
+    font-family: var(--font-mono, 'JetBrains Mono', monospace);
+    font-size: .62rem;
+    font-weight: 600;
+    letter-spacing: .14em;
+    text-transform: uppercase;
+    color: var(--lux-ink-dim, var(--text-secondary));
+    background: var(--lux-bg-0, var(--bg-color, #000));
+    border: 1px solid var(--lux-line, var(--border-color));
+    border-radius: 3px;
+    line-height: 1.4;
+    white-space: nowrap;
+}
+.rn-chip__dot {
+    width: 7px; height: 7px;
+    border-radius: 50%;
+    background: var(--rn-ch);
+    box-shadow: 0 0 6px color-mix(in srgb, var(--rn-ch) 70%, transparent);
+}
+.rn-chip__k {
+    color: var(--lux-ink-mute, var(--text-secondary));
+    letter-spacing: .18em;
+}
+.rn-chip__v {
+    color: var(--lux-ink, var(--text-color));
+    font-variant-numeric: tabular-nums;
+}
+.rn-chip--pre {
+    color: var(--ch-amber);
+    border-color: color-mix(in srgb, var(--ch-amber) 50%, var(--lux-line-bold));
+    /* Solid amber-tinted fill — mixes with the panel bg, never with
+     * transparent, so the chip reads as a real opaque badge. */
+    background: color-mix(in srgb, var(--ch-amber) 22%, var(--lux-bg-1, #0e1014));
+}
+.rn-chip--pre .rn-chip__k { color: var(--ch-amber); }
+
+/* Header actions — square mono icon buttons */
+.rn-head__actions {
+    display: flex;
+    align-items: center;
+    gap: 6px;
+    flex-shrink: 0;
+}
+.rn-act {
+    appearance: none;
+    background: var(--lux-bg-2, var(--bg-tertiary, #1c2027));
+    border: 1px solid var(--lux-line-bold, var(--border-color));
+    border-radius: 3px;
+    width: 36px; height: 36px;
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    color: var(--lux-ink-dim, var(--text-secondary));
+    cursor: pointer;
+    transition: color .15s ease, border-color .15s ease,
+                background .15s ease, box-shadow .15s ease;
+    text-decoration: none;
+}
+.rn-act svg { width: 16px; height: 16px; }
+.rn-act:hover {
+    color: var(--lux-ink, var(--text-color));
+    border-color: color-mix(in srgb, var(--rn-ch) 45%, var(--lux-line-bold));
+    background: var(--lux-bg-3, var(--bg-tertiary, #1c2027));
+    box-shadow: 0 0 0 1px color-mix(in srgb, var(--rn-ch) 18%, transparent),
+                0 0 14px color-mix(in srgb, var(--rn-ch) 22%, transparent);
+}
+.rn-act:focus-visible {
+    outline: none;
+    box-shadow: 0 0 0 2px color-mix(in srgb, var(--rn-ch) 55%, transparent);
+}
+.rn-act--close:hover {
+    color: var(--ch-coral);
+    border-color: color-mix(in srgb, var(--ch-coral) 45%, var(--lux-line-bold));
+    box-shadow: 0 0 0 1px color-mix(in srgb, var(--ch-coral) 18%, transparent);
+}
+
+/* ── Body / scroll area ─────────────────────────────────────────── */
+.rn-body {
+    flex: 1;
+    min-height: 0;
+    overflow-y: auto;
+    overflow-x: hidden;
+    background: var(--lux-bg-0, var(--bg-color, #000));
+    padding: 28px 36px 48px;
+    scrollbar-width: thin;
+    scrollbar-color: var(--lux-line-bold, var(--border-color)) transparent;
+}
+.rn-body::-webkit-scrollbar { width: 10px; }
+.rn-body::-webkit-scrollbar-thumb {
+    background: var(--lux-line-bold, var(--border-color));
+    border-radius: 99px;
+    border: 2px solid transparent;
+    background-clip: padding-box;
+}
+.rn-body::-webkit-scrollbar-thumb:hover {
+    background: color-mix(in srgb, var(--rn-ch) 50%, var(--lux-line-bold));
+    background-clip: padding-box;
+}
+
+/* ── Asset rack ─────────────────────────────────────────────────
+ * Populated from release.assets — sits above the markdown body and
+ * lists every downloadable file as a rack-style chip with icon,
+ * filename, size, and extension badge. Centered in the same column
+ * as the prose so it reads as a header above the article. */
+.rn-assets {
+    max-width: 78ch;
+    margin: 0 auto 28px;
+    padding: 14px 16px 16px;
+    background: var(--lux-bg-1, var(--bg-secondary));
+    border: 1px solid var(--lux-line, var(--border-color));
+    border-radius: var(--lux-r-md, 6px);
+    box-shadow: inset 3px 0 0 var(--rn-ch);
+    position: relative;
+}
+.rn-assets::after {
+    /* Silkscreen corner bracket — instrument detail shared with v2 cards. */
+    content: '';
+    position: absolute;
+    top: 8px; right: 8px;
+    width: 12px; height: 12px;
+    border-top: 1px solid var(--lux-line-bold, var(--border-color));
+    border-right: 1px solid var(--lux-line-bold, var(--border-color));
+    opacity: .6;
+    pointer-events: none;
+}
+.rn-assets__head {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    margin: 0 0 12px;
+    padding-bottom: 10px;
+    border-bottom: 1px solid var(--lux-line, var(--border-color));
+}
+.rn-assets__dot {
+    width: 7px; height: 7px;
+    border-radius: 50%;
+    background: var(--rn-ch);
+    box-shadow: 0 0 6px color-mix(in srgb, var(--rn-ch) 60%, transparent);
+    flex-shrink: 0;
+}
+.rn-assets__title {
+    font-family: var(--font-mono, 'JetBrains Mono', monospace);
+    font-size: .72rem;
+    font-weight: 700;
+    text-transform: uppercase;
+    letter-spacing: .26em;
+    color: var(--lux-ink, var(--text-color));
+}
+.rn-assets__count {
+    margin-left: auto;
+    font-family: var(--font-mono, 'JetBrains Mono', monospace);
+    font-size: .62rem;
+    font-weight: 700;
+    letter-spacing: .14em;
+    color: var(--rn-ch);
+    padding: 2px 8px;
+    border: 1px solid color-mix(in srgb, var(--rn-ch) 30%, var(--lux-line));
+    border-radius: 99px;
+    background: color-mix(in srgb, var(--rn-ch) 14%, var(--lux-bg-1, #0e1014));
+    font-variant-numeric: tabular-nums;
+}
+.rn-assets__list {
+    display: flex;
+    flex-direction: column;
+    gap: 6px;
+}
+
+/* Each asset row — full-width rack chip, hover lifts and glows. */
+.rn-asset {
+    display: grid;
+    grid-template-columns: auto 1fr auto auto;
+    align-items: center;
+    gap: 12px;
+    padding: 9px 12px 9px 14px;
+    background: var(--lux-bg-0, var(--bg-color, #000));
+    border: 1px solid var(--lux-line, var(--border-color));
+    border-radius: var(--lux-r-sm, 3px);
+    color: var(--lux-ink, var(--text-color));
+    text-decoration: none;
+    transition: border-color .18s ease, background .18s ease,
+                transform .18s ease, box-shadow .18s ease;
+    position: relative;
+    overflow: hidden;
+}
+.rn-asset::before {
+    /* Per-row left stripe in the channel hue (same idiom as .module). */
+    content: '';
+    position: absolute;
+    left: 0; top: 0; bottom: 0;
+    width: 2px;
+    background: var(--rn-ch);
+    opacity: .55;
+    transition: opacity .18s ease, width .18s ease,
+                box-shadow .18s ease;
+}
+.rn-asset:hover {
+    border-color: color-mix(in srgb, var(--rn-ch) 55%, var(--lux-line-bold));
+    background: color-mix(in srgb, var(--rn-ch) 6%, var(--lux-bg-0));
+    transform: translateY(-1px);
+    box-shadow: 0 6px 18px color-mix(in srgb, var(--rn-ch) 22%, transparent);
+}
+.rn-asset:hover::before {
+    opacity: 1;
+    width: 3px;
+    box-shadow: 0 0 12px color-mix(in srgb, var(--rn-ch) 75%, transparent);
+}
+.rn-asset:focus-visible {
+    outline: none;
+    border-color: var(--rn-ch);
+    box-shadow: 0 0 0 2px color-mix(in srgb, var(--rn-ch) 55%, transparent);
+}
+
+.rn-asset__icon {
+    display: inline-flex;
+    width: 16px; height: 16px;
+    color: var(--rn-ch);
+    transition: transform .25s var(--ease, cubic-bezier(.16, 1, .3, 1));
+    flex-shrink: 0;
+}
+.rn-asset__icon svg { width: 100%; height: 100%; }
+.rn-asset:hover .rn-asset__icon { transform: translateY(2px); }
+
+/* Filename + description live in a stacked column inside the 1fr grid
+ * cell so the size + extension chips stay anchored to the right edge. */
+.rn-asset__col {
+    display: flex;
+    flex-direction: column;
+    gap: 2px;
+    min-width: 0;
+    overflow: hidden;
+}
+.rn-asset__name {
+    font-family: var(--font-mono, 'JetBrains Mono', monospace);
+    font-size: .82rem;
+    font-weight: 600;
+    color: var(--lux-ink, var(--text-color));
+    letter-spacing: .02em;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    min-width: 0;
+}
+.rn-asset__desc {
+    /* Sans-serif for the human-readable subtitle so it reads as a
+     * caption, distinct from the mono filename it labels. */
+    font-family: var(--font-body, 'Manrope', sans-serif);
+    font-size: .72rem;
+    font-weight: 400;
+    color: var(--lux-ink-mute, var(--text-secondary));
+    letter-spacing: .01em;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    min-width: 0;
+}
+.rn-asset:hover .rn-asset__desc {
+    color: var(--lux-ink-dim, var(--text-secondary));
+}
+.rn-asset__size {
+    font-family: var(--font-mono, 'JetBrains Mono', monospace);
+    font-size: .68rem;
+    font-weight: 600;
+    color: var(--lux-ink-mute, var(--text-secondary));
+    letter-spacing: .08em;
+    font-variant-numeric: tabular-nums;
+    flex-shrink: 0;
+    white-space: nowrap;
+}
+.rn-asset__ext {
+    padding: 2px 8px;
+    background: var(--lux-bg-2, var(--bg-tertiary, #1c2027));
+    border: 1px solid color-mix(in srgb, var(--rn-ch) 28%, var(--lux-line));
+    border-radius: 2px;
+    font-family: var(--font-mono, 'JetBrains Mono', monospace);
+    font-size: .62rem;
+    font-weight: 700;
+    color: var(--rn-ch);
+    letter-spacing: .14em;
+    text-transform: uppercase;
+    flex-shrink: 0;
+    white-space: nowrap;
+}
+
+/* Narrow viewports — drop the size column so the filename gets all the room. */
+@media (max-width: 540px) {
+    .rn-asset { grid-template-columns: auto 1fr auto; }
+    .rn-asset__size { display: none; }
+}
+
+/* ── Markdown content (scoped to .release-notes-shell) ────────── */
+.release-notes-shell .release-notes-content {
+    /* Article-style centered prose column. The header stays a full-width
+     * masthead (title at left, actions at far right) while the body is
+     * centered like a long-form article for comfortable reading. */
+    max-width: 78ch;
+    margin: 0 auto;
+    font-family: var(--font-body, 'Manrope', sans-serif);
+    font-size: .96rem;
+    line-height: 1.65;
+    color: var(--lux-ink, var(--text-color));
+    background: transparent;
+    padding: 0;
+    border-radius: 0;
+    counter-reset: rn-section;
+}
+
+/* Stagger reveal — each direct child fades in with a small delay */
+.release-notes-shell .release-notes-content > * {
+    animation: rnRise .55s var(--ease, cubic-bezier(.16, 1, .3, 1)) both;
+}
+.release-notes-shell .release-notes-content > *:nth-child(1)  { animation-delay: .04s; }
+.release-notes-shell .release-notes-content > *:nth-child(2)  { animation-delay: .09s; }
+.release-notes-shell .release-notes-content > *:nth-child(3)  { animation-delay: .14s; }
+.release-notes-shell .release-notes-content > *:nth-child(4)  { animation-delay: .19s; }
+.release-notes-shell .release-notes-content > *:nth-child(5)  { animation-delay: .24s; }
+.release-notes-shell .release-notes-content > *:nth-child(6)  { animation-delay: .29s; }
+.release-notes-shell .release-notes-content > *:nth-child(7)  { animation-delay: .34s; }
+.release-notes-shell .release-notes-content > *:nth-child(8)  { animation-delay: .38s; }
+.release-notes-shell .release-notes-content > *:nth-child(n+9){ animation-delay: .42s; }
+@keyframes rnRise {
+    from { opacity: 0; transform: translateY(6px); }
+    to   { opacity: 1; transform: none; }
+}
+
+/* H1 — display font, channel-tinted decorative bar */
+.release-notes-shell .release-notes-content h1 {
+    font-family: var(--font-display, 'Big Shoulders Display', sans-serif);
+    font-size: 2.1rem;
+    font-weight: 900;
+    letter-spacing: -.02em;
+    line-height: 1.05;
+    color: var(--lux-ink, var(--text-color));
+    margin: 1.6em 0 .55em;
+    padding-bottom: .35em;
+    border-bottom: 1px solid var(--lux-line, var(--border-color));
+    position: relative;
+}
+.release-notes-shell .release-notes-content h1::after {
+    content: '';
+    position: absolute;
+    left: 0; bottom: -1px;
+    width: 64px; height: 2px;
+    background: linear-gradient(90deg, var(--rn-ch), transparent);
+}
+.release-notes-shell .release-notes-content h1:first-child { margin-top: 0; }
+
+/* H2 — display font with leading mono index counter */
+.release-notes-shell .release-notes-content h2 {
+    font-family: var(--font-display, 'Big Shoulders Display', sans-serif);
+    font-size: 1.55rem;
+    font-weight: 800;
+    letter-spacing: -.015em;
+    color: var(--lux-ink, var(--text-color));
+    margin: 1.7em 0 .5em;
+    display: flex;
+    align-items: center;
+    gap: 14px;
+    counter-increment: rn-section;
+}
+.release-notes-shell .release-notes-content h2::before {
+    content: counter(rn-section, decimal-leading-zero);
+    font-family: var(--font-mono, 'JetBrains Mono', monospace);
+    font-size: .72rem;
+    font-weight: 700;
+    letter-spacing: .12em;
+    color: var(--rn-ch);
+    padding-right: 12px;
+    border-right: 1px solid var(--lux-line, var(--border-color));
+    line-height: 1;
+}
+
+/* H3/H4 — mono uppercase, channel-bracketed */
+.release-notes-shell .release-notes-content h3,
+.release-notes-shell .release-notes-content h4 {
+    font-family: var(--font-mono, 'JetBrains Mono', monospace);
+    font-weight: 700;
+    text-transform: uppercase;
+    letter-spacing: .22em;
+    color: var(--lux-ink, var(--text-color));
+    margin: 1.5em 0 .55em;
+    display: inline-flex;
+    align-items: center;
+    gap: 10px;
+}
+.release-notes-shell .release-notes-content h3 { font-size: .82rem; }
+.release-notes-shell .release-notes-content h4 { font-size: .72rem; color: var(--lux-ink-dim, var(--text-secondary)); }
+.release-notes-shell .release-notes-content h3::before,
+.release-notes-shell .release-notes-content h4::before {
+    content: '';
+    width: 14px; height: 1px;
+    background: var(--rn-ch);
+    flex-shrink: 0;
+    box-shadow: 0 0 6px color-mix(in srgb, var(--rn-ch) 60%, transparent);
+}
+
+/* Paragraphs */
+.release-notes-shell .release-notes-content p {
+    margin: 0 0 1em;
+    color: var(--lux-ink-dim, var(--text-secondary));
+}
+.release-notes-shell .release-notes-content p strong,
+.release-notes-shell .release-notes-content li strong { color: var(--lux-ink, var(--text-color)); }
+
+/* Lists — channel-tinted bullets/numerals */
+.release-notes-shell .release-notes-content ul,
+.release-notes-shell .release-notes-content ol {
+    margin: 0 0 1.1em;
+    padding-left: 1.6em;
+    color: var(--lux-ink-dim, var(--text-secondary));
+}
+.release-notes-shell .release-notes-content li { margin: .25em 0; }
+.release-notes-shell .release-notes-content ul { list-style: none; padding-left: 1.4em; }
+.release-notes-shell .release-notes-content ul > li { position: relative; }
+.release-notes-shell .release-notes-content ul > li::before {
+    content: '';
+    position: absolute;
+    left: -1.1em; top: .65em;
+    width: 6px; height: 6px;
+    background: var(--rn-ch);
+    transform: rotate(45deg);
+    box-shadow: 0 0 6px color-mix(in srgb, var(--rn-ch) 55%, transparent);
+}
+.release-notes-shell .release-notes-content ul > li > ul > li::before {
+    background: var(--lux-ink-mute, var(--text-secondary));
+    box-shadow: none;
+}
+.release-notes-shell .release-notes-content ol > li::marker {
+    color: var(--rn-ch);
+    font-family: var(--font-mono, 'JetBrains Mono', monospace);
+    font-weight: 700;
+}
+
+/* Task-list checkboxes (GitHub-flavoured) */
+.release-notes-shell .release-notes-content li input[type="checkbox"] {
+    accent-color: var(--rn-ch);
+    margin-right: .4em;
+    transform: translateY(1px);
+}
+.release-notes-shell .release-notes-content ul.contains-task-list { list-style: none; padding-left: .4em; }
+.release-notes-shell .release-notes-content ul.contains-task-list > li::before { display: none; }
+
+/* Inline code — hairline-bordered mono pill */
+.release-notes-shell .release-notes-content code {
+    font-family: var(--font-mono, 'JetBrains Mono', monospace);
+    font-size: .85em;
+    background: color-mix(in srgb, var(--rn-ch) 10%, var(--lux-bg-2, var(--bg-tertiary)));
+    color: var(--lux-ink, var(--text-color));
+    padding: .12em .42em;
+    border: 1px solid color-mix(in srgb, var(--rn-ch) 22%, var(--lux-line));
+    border-radius: 3px;
+}
+
+/* Code fences — instrument readout block with corner bracket */
+.release-notes-shell .release-notes-content pre {
+    position: relative;
+    background: var(--lux-bg-1, #0e1014);
+    color: var(--lux-ink, #e6ebf2);
+    border: 1px solid var(--lux-line, var(--border-color));
+    border-radius: var(--lux-r-md, 6px);
+    padding: 14px 18px 14px 22px;
+    margin: 1.1em 0 1.4em;
+    overflow-x: auto;
+    font-family: var(--font-mono, 'JetBrains Mono', monospace);
+    font-size: .82rem;
+    line-height: 1.55;
+    box-shadow: inset 3px 0 0 var(--rn-ch);
+}
+.release-notes-shell .release-notes-content pre::after {
+    content: '';
+    position: absolute;
+    top: 8px; right: 8px;
+    width: 10px; height: 10px;
+    border-top: 1px solid var(--lux-line-bold, var(--border-color));
+    border-right: 1px solid var(--lux-line-bold, var(--border-color));
+    opacity: .65;
+}
+.release-notes-shell .release-notes-content pre code {
+    background: transparent;
+    padding: 0;
     border: none;
-    border-top: 1px solid var(--border-color);
-    margin: 1rem 0;
+    color: inherit;
+    font-size: inherit;
 }
 
-.release-notes-content table {
+/* Blockquotes — channel-tinted callout */
+.release-notes-shell .release-notes-content blockquote {
+    margin: 1.2em 0;
+    padding: 12px 18px;
+    border-left: 3px solid var(--rn-ch);
+    background: color-mix(in srgb, var(--rn-ch) 6%, var(--lux-bg-1, var(--bg-secondary)));
+    color: var(--lux-ink-dim, var(--text-secondary));
+    border-radius: 0 var(--lux-r-sm, 3px) var(--lux-r-sm, 3px) 0;
+    font-style: italic;
+}
+.release-notes-shell .release-notes-content blockquote > p:last-child { margin-bottom: 0; }
+
+/* Links — dashed-underline reveal on hover */
+.release-notes-shell .release-notes-content a {
+    color: var(--rn-ch);
+    text-decoration: none;
+    border-bottom: 1px dashed color-mix(in srgb, var(--rn-ch) 45%, transparent);
+    transition: border-color .15s ease, color .15s ease;
+}
+.release-notes-shell .release-notes-content a:hover {
+    border-bottom-style: solid;
+    border-bottom-color: var(--rn-ch);
+    color: color-mix(in srgb, var(--rn-ch) 80%, var(--lux-ink));
+}
+
+/* Horizontal rule — patch-bay dot leader */
+.release-notes-shell .release-notes-content hr {
+    border: none;
+    height: 8px;
+    margin: 2em 0;
+    background-image: radial-gradient(circle, var(--lux-line-bold, var(--border-color)) 1px, transparent 1px);
+    background-size: 12px 8px;
+    background-repeat: repeat-x;
+    background-position: center;
+    opacity: .6;
+}
+
+/* Tables — rack-style, mono uppercase headers */
+.release-notes-shell .release-notes-content table {
     width: 100%;
     border-collapse: collapse;
-    margin: 0.5rem 0;
+    margin: 1.2em 0;
+    background: var(--lux-bg-1, var(--bg-secondary));
+    border: 1px solid var(--lux-line, var(--border-color));
+    border-radius: var(--lux-r-sm, 3px);
+    overflow: hidden;
 }
-
-.release-notes-content th,
-.release-notes-content td {
-    border: 1px solid var(--border-color);
-    padding: 0.4rem 0.6rem;
+.release-notes-shell .release-notes-content thead { background: var(--lux-bg-2, var(--bg-tertiary)); }
+.release-notes-shell .release-notes-content th {
+    font-family: var(--font-mono, 'JetBrains Mono', monospace);
+    font-size: .66rem;
+    font-weight: 700;
+    text-transform: uppercase;
+    letter-spacing: .18em;
+    color: var(--lux-ink-mute, var(--text-secondary));
     text-align: left;
-    font-size: 0.85rem;
+    padding: 10px 14px;
+    border-bottom: 1px solid var(--lux-line, var(--border-color));
+}
+.release-notes-shell .release-notes-content td {
+    padding: 9px 14px;
+    border-bottom: 1px solid var(--lux-line, var(--border-color));
+    font-size: .88rem;
+    color: var(--lux-ink-dim, var(--text-secondary));
+}
+.release-notes-shell .release-notes-content tr:last-child td { border-bottom: none; }
+.release-notes-shell .release-notes-content tr:hover td {
+    background: color-mix(in srgb, var(--rn-ch) 5%, transparent);
+    color: var(--lux-ink, var(--text-color));
 }
 
-.release-notes-content th {
-    background: var(--bg-tertiary, #2a2a2a);
+/* Images / asset embeds */
+.release-notes-shell .release-notes-content img {
+    max-width: 100%;
+    border-radius: var(--lux-r-sm, 3px);
+    border: 1px solid var(--lux-line, var(--border-color));
+    box-shadow: 0 6px 20px rgba(0, 0, 0, .3);
+}
+
+/* ── Inline asset-code links ───────────────────────────────────
+ * `` elements containing a filename that matches an entry in
+ * release.assets get wrapped in  by
+ * _linkInlineAssetCode(). The wrapper is invisible so the existing
+ *  visual is preserved, but the cursor + hover treatment make
+ * the chip feel actionable. Strips the default dashed-underline link
+ * styling so it doesn't fight the  background. */
+.release-notes-shell .release-notes-content a.rn-code-link {
+    text-decoration: none;
+    border-bottom: none;
+    color: inherit;
+    cursor: pointer;
+    transition: filter .15s ease;
+}
+.release-notes-shell .release-notes-content a.rn-code-link > code {
+    transition: border-color .15s ease, background .15s ease,
+                color .15s ease, box-shadow .15s ease;
+}
+.release-notes-shell .release-notes-content a.rn-code-link:hover > code {
+    color: var(--rn-ch);
+    border-color: color-mix(in srgb, var(--rn-ch) 65%, var(--lux-line-bold));
+    background: color-mix(in srgb, var(--rn-ch) 18%, var(--lux-bg-2, var(--bg-tertiary)));
+    box-shadow: 0 0 0 1px color-mix(in srgb, var(--rn-ch) 30%, transparent),
+                0 4px 14px color-mix(in srgb, var(--rn-ch) 22%, transparent);
+}
+/* Tiny ⤓ glyph after the code chip — the download affordance. */
+.release-notes-shell .release-notes-content a.rn-code-link::after {
+    content: '⤓';
+    margin-left: 4px;
+    color: var(--rn-ch);
+    opacity: .7;
+    font-family: var(--font-mono, 'JetBrains Mono', monospace);
+    font-size: .9em;
+    transition: opacity .15s ease, transform .2s var(--ease, cubic-bezier(.16, 1, .3, 1));
+    display: inline-block;
+}
+.release-notes-shell .release-notes-content a.rn-code-link:hover::after {
+    opacity: 1;
+    transform: translateY(2px);
+}
+.release-notes-shell .release-notes-content a.rn-code-link:focus-visible {
+    outline: none;
+}
+.release-notes-shell .release-notes-content a.rn-code-link:focus-visible > code {
+    border-color: var(--rn-ch);
+    box-shadow: 0 0 0 2px color-mix(in srgb, var(--rn-ch) 55%, transparent);
+}
+
+/* ── Download links ─────────────────────────────────────────────
+ * Anchors classified as `.rn-dl` by _decorateReleaseNotesLinks() get
+ * a rack-style chip treatment with a leading download icon and a
+ * trailing mono extension badge. Inline-flex so they sit happily
+ * inside paragraphs OR as standalone list items. */
+.release-notes-shell .release-notes-content a.rn-dl {
+    display: inline-flex;
+    align-items: center;
+    gap: 8px;
+    vertical-align: baseline;
+    padding: 5px 8px 5px 10px;
+    margin: 1px 2px;
+    background: color-mix(in srgb, var(--rn-ch) 8%, var(--lux-bg-1, var(--bg-secondary)));
+    border: 1px solid color-mix(in srgb, var(--rn-ch) 30%, var(--lux-line));
+    border-radius: var(--lux-r-sm, 3px);
+    color: var(--lux-ink, var(--text-color));
+    font-family: var(--font-mono, 'JetBrains Mono', monospace);
+    font-size: .8em;
+    font-weight: 600;
+    letter-spacing: .03em;
+    line-height: 1.2;
+    text-decoration: none;
+    box-shadow: inset 3px 0 0 var(--rn-ch);
+    transition: border-color .18s ease, background .18s ease,
+                transform .18s ease, box-shadow .18s ease;
+    /* Override the default dashed-underline link treatment */
+    border-bottom: 1px solid color-mix(in srgb, var(--rn-ch) 30%, var(--lux-line));
+}
+.release-notes-shell .release-notes-content a.rn-dl:hover {
+    border-color: color-mix(in srgb, var(--rn-ch) 75%, var(--lux-line-bold));
+    background: color-mix(in srgb, var(--rn-ch) 14%, var(--lux-bg-1, var(--bg-secondary)));
+    transform: translateY(-1px);
+    box-shadow: inset 3px 0 0 var(--rn-ch),
+                0 4px 14px color-mix(in srgb, var(--rn-ch) 28%, transparent);
+}
+.release-notes-shell .release-notes-content a.rn-dl:focus-visible {
+    outline: none;
+    border-color: var(--rn-ch);
+    box-shadow: inset 3px 0 0 var(--rn-ch),
+                0 0 0 2px color-mix(in srgb, var(--rn-ch) 55%, transparent);
+}
+.rn-dl__icon {
+    display: inline-flex;
+    width: 13px; height: 13px;
+    color: var(--rn-ch);
+    flex-shrink: 0;
+    transition: transform .25s var(--ease, cubic-bezier(.16, 1, .3, 1));
+}
+.rn-dl__icon svg { width: 100%; height: 100%; }
+.release-notes-shell .release-notes-content a.rn-dl:hover .rn-dl__icon {
+    transform: translateY(2px);
+}
+.rn-dl__ext {
+    margin-left: 2px;
+    padding: 1px 6px;
+    background: var(--lux-bg-0, var(--bg-color, #000));
+    border: 1px solid color-mix(in srgb, var(--rn-ch) 28%, var(--lux-line));
+    border-radius: 2px;
+    font-size: .82em;
+    font-weight: 700;
+    color: var(--rn-ch);
+    letter-spacing: .14em;
+    text-transform: uppercase;
+    font-variant-numeric: tabular-nums;
+    line-height: 1.1;
+    flex-shrink: 0;
+}
+
+/* Standalone download link as a single list item — promote to a
+ * larger "primary action" pill when it's the only child of 
  • . */ +.release-notes-shell .release-notes-content li > a.rn-dl:only-child { + padding: 8px 12px 8px 14px; + font-size: .86em; +} +.release-notes-shell .release-notes-content li > a.rn-dl:only-child .rn-dl__icon { + width: 15px; height: 15px; +} + +/* External-link decoration — small ↗ glyph after links pointing + * outside the current origin (and not classified as downloads). */ +.release-notes-shell .release-notes-content a.rn-ext::after { + content: '↗'; + margin-left: 3px; + color: var(--rn-ch); + font-size: .9em; + opacity: .85; + transition: transform .15s ease; + display: inline-block; +} +.release-notes-shell .release-notes-content a.rn-ext:hover::after { + transform: translate(1px, -1px); + opacity: 1; +} + +/* Reduced-motion: kill staggered animation, keep aesthetic */ +@media (prefers-reduced-motion: reduce) { + .release-notes-shell .release-notes-content > * { animation: none; } + .rn-eyebrow__dot { animation: none; } +} + +/* Narrow viewports — collapse header to stack */ +@media (max-width: 640px) { + .rn-head { flex-direction: column; align-items: stretch; padding: 18px 18px 14px 24px; } + .rn-head__actions { align-self: flex-end; } + .rn-body { padding: 22px 18px 36px; } + .release-notes-shell .release-notes-content h1 { font-size: 1.7rem; } + .release-notes-shell .release-notes-content h2 { font-size: 1.3rem; } } /* ── Log viewer base ───────────────────────────────────────── */ .log-viewer-output { - background: #0d0d0d; - color: #d4d4d4; + background: var(--lux-bg-0, #0d0d0d); + color: var(--lux-ink, #d4d4d4); font-family: var(--font-mono, 'Consolas', 'Courier New', monospace); font-size: 0.75rem; - line-height: 1.45; + line-height: 1.5; padding: 0.6rem 0.75rem; - border-radius: 6px; - border: 1px solid var(--border-color); + border-radius: var(--lux-r-sm, 3px); + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); max-height: 400px; overflow-y: auto; overflow-x: auto; white-space: pre; margin: 0; - /* Scroll performance */ - contain: strict; } +/* Severity tint applied to the line text. Inside the overlay these + pair with the channel-tinted left edge added by .log-overlay rules + above; outside the overlay they stand alone. */ .log-viewer-output .log-line-error { - color: #f48771; + color: color-mix(in srgb, var(--ch-coral, #ff5e5e) 80%, var(--lux-ink, #d4d4d4)); } .log-viewer-output .log-line-warning { - color: #ce9178; + color: color-mix(in srgb, var(--ch-amber, #ffb800) 78%, var(--lux-ink, #d4d4d4)); } .log-viewer-output .log-line-debug { - color: #6a9955; + color: color-mix(in srgb, var(--lux-ink-faint, #6a9955) 70%, var(--lux-ink-mute, #888)); } /* LED count control */ @@ -966,7 +2019,7 @@ } .modal-header { - padding: 22px 24px 14px 24px; + padding: 18px 24px 16px 28px; border-bottom: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); display: flex; align-items: center; @@ -979,7 +2032,7 @@ .modal-header::before { content: ''; position: absolute; - left: 12px; + left: 14px; top: 50%; transform: translateY(-50%); width: 3px; @@ -987,7 +2040,7 @@ background: var(--modal-ch, var(--ch-signal, var(--primary-color))); border-radius: 2px; box-shadow: 0 0 10px color-mix(in srgb, var(--modal-ch, var(--ch-signal, var(--primary-color))) 50%, transparent); - opacity: 0.8; + opacity: 0.85; } .modal-header h2 { @@ -997,11 +2050,19 @@ font-weight: 700; letter-spacing: -0.01em; color: var(--lux-ink, var(--text-color)); + display: flex; + align-items: center; + gap: 8px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; min-width: 0; - padding-left: 4px; +} +.modal-header h2 .icon { + color: var(--modal-ch, var(--ch-signal, var(--primary-color))); + width: 18px; + height: 18px; + flex-shrink: 0; } .modal-header-actions { @@ -1133,6 +2194,92 @@ opacity: 1; color: var(--primary-text-color); border-color: var(--primary-color); + background: color-mix(in srgb, var(--primary-color) 18%, transparent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary-color) 22%, transparent); +} + +/* Floating hint tooltip — anchored to the active `.hint-toggle` button. + Replaces the legacy inline small.input-hint reveal which pushed the + form below it down on every help click. The popover is `position: + fixed` and lives at the body level so it can escape modal overflow + and never reflow the form. */ +.hint-popover { + position: fixed; + top: 0; + left: 0; + z-index: 10005; + max-width: min(320px, calc(100vw - 24px)); + padding: 10px 13px; + background: linear-gradient(180deg, + var(--lux-bg-3, var(--bg-secondary)) 0%, + var(--lux-bg-2, var(--card-bg)) 100%); + border: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color)); + border-radius: var(--lux-r-md, 6px); + box-shadow: + 0 14px 36px rgba(0, 0, 0, 0.55), + 0 0 0 1px rgba(255, 255, 255, 0.02), + inset 0 1px 0 rgba(255, 255, 255, 0.04); + color: var(--lux-ink, var(--text-color)); + font-size: 0.82rem; + line-height: 1.5; + pointer-events: none; + opacity: 0; + transition: opacity 0.14s ease; + white-space: normal; + word-break: break-word; +} + +.hint-popover.open { + opacity: 1; + pointer-events: auto; +} + +/* Channel-color accent stripe along the edge nearest the anchor. */ +.hint-popover::before { + content: ''; + position: absolute; + left: 10px; + right: 10px; + height: 1px; + background: linear-gradient(90deg, + transparent 0%, + color-mix(in srgb, var(--ch-signal, var(--primary-color)) 70%, transparent) 50%, + transparent 100%); + pointer-events: none; +} +.hint-popover[data-placement="bottom"]::before { top: 0; } +.hint-popover[data-placement="top"]::before { bottom: 0; } + +/* Diamond arrow pointing back at the `?` button. Inherits the panel's + border + background so the seam reads as a continuous edge. */ +.hint-popover::after { + content: ''; + position: absolute; + left: var(--hint-arrow-x, 50%); + width: 9px; + height: 9px; + background: var(--lux-bg-3, var(--bg-secondary)); + border: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color)); + transform: translateX(-50%) rotate(45deg); + pointer-events: none; +} + +.hint-popover[data-placement="bottom"]::after { + top: -5px; + border-right: none; + border-bottom: none; +} + +.hint-popover[data-placement="top"]::after { + bottom: -5px; + border-left: none; + border-top: none; +} + +@media (prefers-reduced-motion: reduce) { + .hint-popover { + transition: none; + } } .input-hint { @@ -2492,4 +3639,1025 @@ body.composite-layer-dragging .composite-layer-drag-handle { line-height: 1; } +/* ── Device Settings: rack-panel sections ────────────────────────── + The General Settings modal groups its many conditional fields into + four logical sections (Identity / Connection / Hardware / Behavior), + each with its own channel-color silkscreen accent. Sections that end + up with no visible form-groups get the `[data-ds-empty="true"]` + attribute from devices.ts and collapse out of the layout entirely. */ + +#device-settings-modal .modal-content { + max-width: 620px; +} + +.ds-section { + --ds-ch: var(--ch-signal, var(--primary-color)); + position: relative; + margin: 0 0 14px; + padding: 0 14px 4px; + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + border-radius: var(--lux-r-md, 6px); + background: + linear-gradient(180deg, + color-mix(in srgb, var(--ds-ch) 4%, var(--lux-bg-2, var(--bg-secondary))) 0%, + color-mix(in srgb, var(--lux-bg-1, var(--card-bg)) 60%, transparent) 100%); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.025), + 0 1px 0 rgba(0, 0, 0, 0.18); +} + +.ds-section[data-ch="signal"] { --ds-ch: var(--ch-signal, var(--primary-color)); } +.ds-section[data-ch="cyan"] { --ds-ch: var(--ch-cyan, var(--info-color)); } +.ds-section[data-ch="amber"] { --ds-ch: var(--ch-amber, var(--warning-color)); } +.ds-section[data-ch="violet"] { --ds-ch: var(--ch-violet, #8b7eff); } +.ds-section[data-ch="coral"] { --ds-ch: var(--ch-coral, var(--danger-color)); } +.ds-section[data-ch="magenta"]{ --ds-ch: var(--ch-magenta, #ff4ade); } + +/* A faint left edge stripe in the channel color — reads like a panel + accent on a piece of rack equipment. */ +.ds-section::before { + content: ''; + position: absolute; + left: 0; top: 4px; bottom: 4px; + width: 2px; + border-radius: 2px; + background: linear-gradient(180deg, + color-mix(in srgb, var(--ds-ch) 70%, transparent) 0%, + color-mix(in srgb, var(--ds-ch) 0%, transparent) 100%); + opacity: 0.65; + pointer-events: none; +} + +.ds-section-header { + display: flex; + align-items: center; + gap: 7px; + margin: 0 -4px 6px; + padding: 8px 4px 4px; + line-height: 1; +} + +.ds-section-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--ds-ch); + box-shadow: + 0 0 7px color-mix(in srgb, var(--ds-ch) 65%, transparent), + 0 0 0 1px color-mix(in srgb, var(--ds-ch) 30%, transparent); + flex-shrink: 0; +} + +.ds-section-title { + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.16em; + color: var(--lux-ink, var(--text-color)); + opacity: 0.85; +} + +.ds-section-index { + margin-left: auto; + font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 0.62rem; + font-weight: 500; + color: var(--lux-ink-mute, var(--text-muted)); + letter-spacing: 0.12em; + opacity: 0.55; + font-variant-numeric: tabular-nums; +} + +.ds-section-body > .form-group:last-child { + margin-bottom: 8px; +} + +/* When devices.ts marks a section as empty, fully remove it from layout + so the modal doesn't show a barren header band. */ +.ds-section[data-ds-empty="true"] { + display: none; +} + +/* Identity section gets the signal-green accent and a slightly more + prominent input — it's the one field everyone sets. */ +#device-settings-modal .ds-section[data-ds-key="identity"] { + background: linear-gradient(180deg, + color-mix(in srgb, var(--ds-ch) 8%, var(--lux-bg-2, var(--bg-secondary))) 0%, + color-mix(in srgb, var(--ds-ch) 1%, transparent) 100%); + border-color: color-mix(in srgb, var(--ds-ch) 22%, var(--lux-line, var(--border-color))); +} + +#device-settings-modal .ds-name-group input#settings-device-name { + font-size: 1rem; + font-weight: 600; + letter-spacing: -0.005em; + padding: 10px 12px; +} + +/* Two-column row for naturally-paired numeric fields (DMX Universe + + Start Channel). When one of the pair is hidden, the other expands to + fill — `display:none` form-groups simply collapse out of the grid. */ +.ds-pair-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + align-items: start; +} + +.ds-pair-row > .form-group { + margin-bottom: 15px; + min-width: 0; +} + +@media (max-width: 480px) { + .ds-pair-row { + grid-template-columns: 1fr; + gap: 0; + } +} + +/* Toggle row: text on the left, action (button or switch) anchored right, + wrapped in a recessed sub-panel that reads like a discrete hardware + switch on the panel face. Used by Lifecycle / Backup / Notifications / + Updates and the device-settings Auto-Restore row. The selector must + match a bare `.ds-toggle-row` so the global Settings modal — which + does NOT pair it with `.form-group` — still gets the grid layout. */ +.ds-toggle-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 14px; + align-items: center; + padding: 12px 14px; + border: var(--lux-hairline, 1px) solid color-mix(in srgb, var(--lux-line, var(--border-color)) 80%, transparent); + border-radius: var(--lux-r-md, 6px); + background: color-mix(in srgb, var(--lux-bg-1, var(--card-bg)) 55%, transparent); + margin-bottom: 10px; +} +.ds-toggle-row:last-child { + margin-bottom: 0; +} + +.ds-toggle-row .ds-toggle-text { + min-width: 0; +} + +.ds-toggle-row .label-row { + margin-bottom: 0; +} + +.ds-toggle-row .label-row label { + margin-bottom: 0; + font-weight: 600; + color: var(--lux-ink, var(--text-color)); +} + +.ds-toggle-row .input-hint { + margin: 4px 0 0; + font-size: 0.78rem; + line-height: 1.35; +} + +.ds-toggle-row .settings-toggle { + margin-top: 0; + flex-shrink: 0; +} + +@media (prefers-reduced-motion: no-preference) { + .ds-section { + animation: dsSectionIn 0.35s cubic-bezier(0.16, 1, 0.3, 1) both; + } + .ds-section[data-ds-key="identity"] { animation-delay: 0.02s; } + .ds-section[data-ds-key="connection"] { animation-delay: 0.06s; } + .ds-section[data-ds-key="hardware"] { animation-delay: 0.10s; } + .ds-section[data-ds-key="behavior"] { animation-delay: 0.14s; } +} + +@keyframes dsSectionIn { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* ── Settings modal: rail + rack-panel layout ────────────────────── + The redesigned settings modal replaces the old icon-only top tab strip + with a left rail (icon + label) and wraps each tab body in a stack of + .ds-section panels. The rail and the existing tab-button machinery in + settings.ts share the same data-settings-tab attribute, so + switchSettingsTab() needs no behavioral changes. */ + +/* Top-anchor the settings modal so switching tabs (which changes content + height) doesn't re-center the dialog and shift the header up/down. + Why: vertical re-centering on tab change is jarring; the header staying + put is preferred even if the dialog is no longer optically centered. */ +#settings-modal { + align-items: flex-start; + padding-top: clamp(40px, 8vh, 80px); +} + +#settings-modal .modal-content { + max-width: 760px; + overflow: hidden; + max-height: calc(100vh - clamp(40px, 8vh, 80px) - 40px); +} + +/* ── Buttons inside the settings modal follow the rack-panel mockup + (mixed-case body font, auto width, tighter padding) instead of the + project's default uppercase monospace house style. Scoped here so + other modals continue to use the house style. */ +#settings-modal .btn { + flex: 0 0 auto; + min-width: auto; + padding: 8px 14px; + font-family: var(--font-body, inherit); + font-size: 0.78rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: none; + text-decoration: none; +} +/* Plain `.btn` (no .btn-primary / -danger / -secondary / -ghost / -icon + modifier) needs an explicit silhouette so an `` renders + as a real button instead of a default link. Used by the About panel's + GitHub / Donate action row, matching the mockup's bare-`.btn` look. */ +#settings-modal .btn:not([class*="btn-"]) { + background: var(--lux-bg-1, var(--card-bg)); + border-color: var(--lux-line-bold, var(--border-color)); + color: var(--lux-ink, var(--text-color)); +} +#settings-modal .btn:not([class*="btn-"]):hover { + border-color: var(--ch-amber, var(--warning-color)); + color: var(--lux-ink, var(--text-color)); + transform: translateY(-1px); +} +#settings-modal .btn:not([class*="btn-"]) .icon { + color: var(--lux-ink-dim, var(--text-secondary)); +} +#settings-modal .btn:not([class*="btn-"]):hover .icon { + color: var(--ch-amber, var(--warning-color)); +} +#settings-modal .btn-sm { + padding: 5px 9px; + font-size: 0.72rem; +} +/* Buttons inside a toggle-row anchor to the right of a title+sub block. + Giving them a shared min-width keeps paired actions (Download / Restore, + Open viewer / Restart, …) optically aligned across stacked rows even + when their labels differ in length. */ +#settings-modal .ds-toggle-row .btn { + min-width: 120px; +} + +/* ── About panel: stretch the .ds-section to fill the available height + and center the hero (mark · name · version · tagline · links · license) + inside it. The settings-body becomes a column flex container so the + active About panel can flex-grow; other panels keep their natural + content-height behaviour because they don't carry `flex: 1`. */ +#settings-modal .settings-body { + display: flex; + flex-direction: column; +} +#settings-panel-about.active { + flex: 1; + display: flex; + flex-direction: column; +} +#settings-panel-about > #about-panel-content { + flex: 1; + display: flex; + flex-direction: column; +} +#settings-panel-about .ds-section { + flex: 1; + display: flex; + flex-direction: column; + margin-bottom: 0; +} +#settings-panel-about .ds-section-body { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 8px 0; +} +#settings-panel-about .about-hero { + width: 100%; +} +/* `.inline-row` buttons that should fill the row keep their inline + `style="flex:1"`. Anywhere else, an inline-row primary button stretches + while the secondary stays auto — matches the mockup's + "Save schedule" + "Backup now" composition. */ +#settings-modal .inline-row > .btn[style*="flex:1"] { + flex: 1 1 auto; +} + +/* The classic modal-body padding is dropped here — the rail divides the + content area horizontally and the rail/body each handle their own + padding. */ +#settings-modal .modal-body { + padding: 0; +} + +.settings-layout { + display: flex; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; +} + +.settings-rail { + flex: 0 0 184px; + background: linear-gradient(180deg, + color-mix(in srgb, var(--lux-bg-2, var(--bg-secondary)) 60%, transparent), + transparent); + border-right: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + padding: 16px 0 16px; + display: flex; + flex-direction: column; + gap: 2px; + overflow-y: auto; + scrollbar-gutter: stable; +} + +.settings-rail-group { + padding: 8px 16px 4px; + font-size: 0.62rem; + font-weight: 600; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--lux-ink-faint, var(--text-muted)); + display: flex; + align-items: center; + gap: 6px; +} +.settings-rail-group::before { + content: ''; + width: 6px; + height: 1px; + background: var(--lux-line-bold, var(--border-color)); +} +.settings-rail-group:not(:first-child) { + margin-top: 6px; +} + +.settings-rail-btn { + --rail-ch: var(--ch-amber, var(--warning-color)); + background: none; + border: none; + cursor: pointer; + width: 100%; + padding: 9px 16px 9px 18px; + display: flex; + align-items: center; + gap: 10px; + color: var(--lux-ink-dim, var(--text-secondary)); + font: inherit; + font-size: 0.85rem; + font-weight: 500; + text-align: left; + border-left: 2px solid transparent; + transition: color var(--duration-normal, 0.25s) var(--ease-out, ease), + background var(--duration-normal, 0.25s) var(--ease-out, ease), + border-color var(--duration-normal, 0.25s) var(--ease-out, ease); + position: relative; +} +.settings-rail-btn[data-rail-ch="signal"] { --rail-ch: var(--ch-signal, var(--primary-color)); } +.settings-rail-btn[data-rail-ch="cyan"] { --rail-ch: var(--ch-cyan, var(--info-color)); } +.settings-rail-btn[data-rail-ch="amber"] { --rail-ch: var(--ch-amber, var(--warning-color)); } +.settings-rail-btn[data-rail-ch="violet"] { --rail-ch: var(--ch-violet, #8b7eff); } +.settings-rail-btn[data-rail-ch="magenta"] { --rail-ch: var(--ch-magenta, #ff4ade); } +.settings-rail-btn[data-rail-ch="coral"] { --rail-ch: var(--ch-coral, var(--danger-color)); } + +.settings-rail-btn .icon { + width: 16px; + height: 16px; + flex-shrink: 0; +} +.settings-rail-label { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.settings-rail-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: transparent; + transition: all var(--duration-normal, 0.25s) var(--ease-out, ease); + flex-shrink: 0; +} +.settings-rail-btn:hover { + color: var(--lux-ink, var(--text-color)); + background: color-mix(in srgb, var(--rail-ch) 6%, transparent); +} +.settings-rail-btn.active { + color: var(--lux-ink, var(--text-color)); + background: color-mix(in srgb, var(--rail-ch) 10%, transparent); + border-left-color: var(--rail-ch); + font-weight: 600; +} +.settings-rail-btn.active .icon { + color: var(--rail-ch); + filter: drop-shadow(0 0 6px color-mix(in srgb, var(--rail-ch) 50%, transparent)); +} +.settings-rail-btn.active .settings-rail-dot { + background: var(--rail-ch); + box-shadow: 0 0 6px color-mix(in srgb, var(--rail-ch) 70%, transparent); +} +.settings-rail-btn:focus-visible { + outline: 2px solid var(--rail-ch); + outline-offset: -2px; +} + +.settings-rail-badge { + margin-left: auto; + font-size: 0.6rem; + font-weight: 700; + padding: 1px 6px; + border-radius: 999px; + background: color-mix(in srgb, var(--ch-coral, var(--danger-color)) 80%, transparent); + color: #fff; + letter-spacing: 0.04em; + flex-shrink: 0; +} + +.settings-rail-footer { + margin-top: auto; + padding: 12px 16px 0; + border-top: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + font-family: var(--font-mono, monospace); + font-size: 0.62rem; + letter-spacing: 0.08em; + color: var(--lux-ink-faint, var(--text-muted)); +} +.settings-rail-footer:empty { + display: none; +} + +/* Body */ +.settings-body { + flex: 1 1 auto; + overflow-y: auto; + scrollbar-gutter: stable; + padding: 18px 22px 22px; + min-width: 0; +} + +/* The old top-strip tab bar is no longer rendered (the rail replaces it), + but keep the rules intact in case of cached templates. The rail is the + live UI surface for tab switching. */ +#settings-modal .settings-tab-bar { display: none; } + +/* Section meta pill (e.g. "RUNNING" / "DESTRUCTIVE" / "GRANTED" badges + that sit between the title and the index in a .ds-section header). */ +.ds-section-meta { + margin-left: 8px; + font-family: var(--font-mono, monospace); + font-size: 0.62rem; + font-weight: 600; + color: var(--lux-ink-mute, var(--text-muted)); + letter-spacing: 0.1em; + padding: 2px 6px; + border: var(--lux-hairline, 1px) solid color-mix(in srgb, var(--ds-ch, var(--primary-color)) 30%, transparent); + border-radius: 3px; + background: color-mix(in srgb, var(--ds-ch, var(--primary-color)) 8%, transparent); +} +.ds-section-meta[hidden] { display: none; } + +/* Toggle-row title/sub used by the new rack-panel rows + (Lifecycle/Backup/Notifications). The container .ds-toggle-row is + already styled — these refine its content slots. */ +.ds-toggle-row .ds-toggle-text { + min-width: 0; +} +.ds-toggle-row .ds-toggle-title { + font-weight: 600; + color: var(--lux-ink, var(--text-color)); + font-size: 0.85rem; +} +.ds-toggle-row .ds-toggle-sub { + margin-top: 3px; + font-size: 0.74rem; + line-height: 1.4; + color: var(--lux-ink-mute, var(--text-muted)); +} + +/* Danger variant — coral border + slightly recessed surface */ +.ds-toggle-row.ds-toggle-row--danger { + border-color: color-mix(in srgb, var(--ch-coral, var(--danger-color)) 28%, var(--lux-line, var(--border-color))); +} + +/* Inline action row (Save + Backup-now etc.) */ +.inline-row { + display: flex; + gap: 8px; + align-items: center; +} +.inline-row > input, +.inline-row > select { + flex: 1; + min-width: 0; +} +.inline-row--actions { + margin-top: 6px; +} + +/* ── API key list (read-only rows in General → API section) ── */ +.api-key-list { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 4px; +} +.api-key-row { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 10px; + align-items: center; + padding: 7px 10px; + background: var(--lux-bg-1, var(--card-bg)); + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + border-radius: var(--lux-r-sm, 4px); + font-size: 0.78rem; +} +.api-key-row .api-key-name { + font-family: var(--font-mono, monospace); + font-weight: 600; + color: var(--lux-ink, var(--text-color)); +} +.api-key-row .api-key-mask { + font-family: var(--font-mono, monospace); + color: var(--lux-ink-mute, var(--text-muted)); + letter-spacing: 0.06em; + font-size: 0.74rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.api-key-row .api-key-tag { + font-size: 0.62rem; + font-weight: 700; + padding: 2px 7px; + border-radius: 3px; + text-transform: uppercase; + letter-spacing: 0.08em; + background: color-mix(in srgb, var(--ch-amber, var(--warning-color)) 18%, transparent); + border: var(--lux-hairline, 1px) solid color-mix(in srgb, var(--ch-amber, var(--warning-color)) 35%, transparent); + color: var(--ch-amber, var(--warning-color)); +} +.api-key-empty-note { + font-size: 0.76rem; + color: var(--lux-ink-mute, var(--text-muted)); + padding: 12px; + text-align: center; + background: color-mix(in srgb, var(--ch-amber, var(--warning-color)) 4%, var(--lux-bg-1, var(--card-bg))); + border: var(--lux-hairline, 1px) dashed var(--lux-line, var(--border-color)); + border-radius: var(--lux-r-sm, 4px); +} + +/* ── Notifications matrix (rows = events, cols = channels) ── */ +.notif-matrix { + display: grid; + grid-template-columns: minmax(0, 1fr) repeat(4, 60px); + gap: 0; + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + border-radius: var(--lux-r-md, 6px); + overflow: hidden; + background: var(--lux-bg-1, var(--card-bg)); +} +.notif-matrix-cell { + border-bottom: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + padding: 10px 10px; + display: flex; + align-items: center; + justify-content: center; +} +.notif-matrix-cell:nth-last-child(-n+5) { + border-bottom: none; +} +.notif-matrix-head { + padding: 8px 10px; + font-size: 0.62rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--lux-ink-mute, var(--text-muted)); + background: color-mix(in srgb, var(--ch-violet, #8b7eff) 6%, var(--lux-bg-2, var(--bg-secondary))); +} +.notif-matrix-head:first-child { + justify-content: flex-start; +} +.notif-matrix-row-label { + justify-content: flex-start; + gap: 8px; + font-size: 0.82rem; + font-weight: 500; + color: var(--lux-ink, var(--text-color)); + text-align: left; +} +.notif-matrix-row-label .icon { + width: 14px; + height: 14px; + color: var(--ch-violet, #8b7eff); + flex-shrink: 0; +} +.notif-matrix-opt { + border-left: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + cursor: pointer; + transition: background var(--duration-fast, 0.15s) var(--ease-out, ease); +} +.notif-matrix-opt .notif-matrix-dot { + width: 14px; + height: 14px; + border-radius: 50%; + border: 1.5px solid var(--lux-line-bold, var(--border-color)); + background: transparent; + transition: all var(--duration-fast, 0.15s) var(--ease-out, ease); +} +.notif-matrix-opt:hover .notif-matrix-dot { + border-color: var(--ch-violet, #8b7eff); + transform: scale(1.15); +} +.notif-matrix-opt.selected { + background: color-mix(in srgb, var(--ch-violet, #8b7eff) 14%, transparent); +} +.notif-matrix-opt.selected .notif-matrix-dot { + background: var(--ch-violet, #8b7eff); + border-color: var(--ch-violet, #8b7eff); + box-shadow: 0 0 8px color-mix(in srgb, var(--ch-violet, #8b7eff) 60%, transparent); +} +.notif-matrix-opt:focus-visible { + outline: 2px solid var(--ch-violet, #8b7eff); + outline-offset: -2px; +} + +/* ── Status card (Updates tab) ── */ +.status-card { + --status-ch: var(--ch-signal, var(--primary-color)); + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 14px; + align-items: center; + padding: 12px 14px; + background: linear-gradient(180deg, + color-mix(in srgb, var(--status-ch) 8%, var(--lux-bg-2, var(--bg-secondary))) 0%, + var(--lux-bg-1, var(--card-bg)) 100%); + border: var(--lux-hairline, 1px) solid color-mix(in srgb, var(--status-ch) 30%, var(--lux-line, var(--border-color))); + border-radius: var(--lux-r-md, 6px); + margin-bottom: 10px; +} +.status-card[data-state="available"] { --status-ch: var(--ch-amber, var(--warning-color)); } +.status-card[data-state="error"] { --status-ch: var(--ch-coral, var(--danger-color)); } +.status-card[data-state="updated"], +.status-card[data-state="checking"] { --status-ch: var(--ch-signal, var(--primary-color)); } + +.status-card-icon { + width: 36px; + height: 36px; + border-radius: 50%; + background: color-mix(in srgb, var(--status-ch) 16%, transparent); + display: flex; + align-items: center; + justify-content: center; + color: var(--status-ch); + flex-shrink: 0; +} +.status-card-icon .icon { + width: 20px; + height: 20px; +} +.status-card-text { + min-width: 0; +} +.status-card-main { + font-family: var(--font-display, var(--font-body)); + font-size: 1rem; + font-weight: 700; + color: var(--lux-ink, var(--text-color)); + letter-spacing: 0.005em; + line-height: 1.2; +} +.status-card-sub { + font-size: 0.72rem; + color: var(--lux-ink-mute, var(--text-muted)); + font-family: var(--font-mono, monospace); + letter-spacing: 0.04em; + margin-top: 4px; + line-height: 1.5; + display: flex; + flex-direction: column; + gap: 2px; +} +.status-card-sub strong { + color: var(--lux-ink, var(--text-color)); + font-weight: 600; +} +/* Each "Current version / Install type / Last check" pair on its own line. */ +.status-card-line { + display: flex; + flex-wrap: wrap; + gap: 4px; + align-items: baseline; + min-width: 0; +} +.status-card-line:empty { + display: none; +} +/* Legacy inline separator from the old single-line layout. */ +.status-card-sep { + display: none; +} +.status-card-actions { + display: flex; + flex-direction: column; + gap: 6px; + flex-shrink: 0; +} + +/* Update progress bar */ +.update-progress { + margin-bottom: 10px; + height: 4px; + background: var(--lux-line, var(--border-color)); + border-radius: 2px; + overflow: hidden; +} +.update-progress > #update-progress-bar { + width: 0; + height: 100%; + background: linear-gradient(90deg, var(--ch-signal, var(--primary-color)), var(--ch-amber, var(--warning-color))); + transition: width 0.3s ease; +} + +/* ── Backup list (saved backup files) ── */ +.backup-list { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 200px; + overflow-y: auto; + margin-top: 6px; +} +.backup-list:empty::after { + content: attr(data-empty); + display: block; + text-align: center; + padding: 14px; + font-size: 0.78rem; + color: var(--lux-ink-mute, var(--text-muted)); + background: var(--lux-bg-1, var(--card-bg)); + border: var(--lux-hairline, 1px) dashed var(--lux-line, var(--border-color)); + border-radius: var(--lux-r-sm, 4px); +} +.backup-row { + display: grid; + grid-template-columns: minmax(0, 1fr) repeat(3, auto); + gap: 6px; + align-items: center; + padding: 8px 10px; + background: var(--lux-bg-1, var(--card-bg)); + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + border-radius: var(--lux-r-sm, 4px); + transition: border-color var(--duration-fast, 0.15s) var(--ease-out, ease); +} +.backup-row:hover { + border-color: var(--ch-amber, var(--warning-color)); +} +.backup-row .backup-name { + font-family: var(--font-mono, monospace); + font-size: 0.74rem; + color: var(--lux-ink, var(--text-color)); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.backup-row .backup-meta { + font-size: 0.7rem; + color: var(--lux-ink-mute, var(--text-muted)); + margin-top: 2px; + font-variant-numeric: tabular-nums; +} +.backup-row .icon-btn { + width: 26px; + height: 26px; + background: transparent; + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + border-radius: 4px; + color: var(--lux-ink-dim, var(--text-secondary)); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all var(--duration-fast, 0.15s) var(--ease-out, ease); +} +.backup-row .icon-btn:hover { + color: var(--lux-ink, var(--text-color)); + border-color: var(--ch-amber, var(--warning-color)); +} +.backup-row .icon-btn .icon { + width: 13px; + height: 13px; +} +.backup-row .icon-btn.danger:hover { + color: var(--ch-coral, var(--danger-color)); + border-color: var(--ch-coral, var(--danger-color)); +} + +/* ── Settings switch (custom toggle, replaces plain checkbox) ── */ +.settings-switch { + --switch-ch: var(--ch-signal, var(--primary-color)); + position: relative; + display: inline-block; + width: 38px; + height: 22px; + flex-shrink: 0; + cursor: pointer; +} +.settings-switch input { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + opacity: 0; + cursor: pointer; + z-index: 1; +} +.settings-switch-track { + position: absolute; + inset: 0; + background: var(--lux-bg-3, var(--bg-secondary)); + border-radius: 999px; + border: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color)); + transition: all var(--duration-normal, 0.25s) var(--ease-out, ease); +} +.settings-switch-track::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--lux-ink-dim, var(--text-secondary)); + transition: all var(--duration-normal, 0.25s) var(--ease-out, ease); +} +.settings-switch input:checked ~ .settings-switch-track { + background: color-mix(in srgb, var(--switch-ch) 80%, transparent); + border-color: var(--switch-ch); +} +.settings-switch input:checked ~ .settings-switch-track::after { + left: 18px; + background: #fff; +} +.settings-switch input:focus-visible ~ .settings-switch-track { + outline: 2px solid var(--switch-ch); + outline-offset: 2px; +} + +/* About hero (used by renderAboutPanel) */ +.about-hero { + text-align: center; + padding: 22px 16px 18px; +} +.about-hero .about-mark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 54px; + height: 54px; + border-radius: 14px; + background: linear-gradient(135deg, var(--ch-amber, var(--warning-color)), var(--ch-coral, var(--danger-color))); + color: #000; + font-family: var(--font-display, sans-serif); + font-size: 1.7rem; + font-weight: 900; + box-shadow: 0 8px 24px color-mix(in srgb, var(--ch-amber, var(--warning-color)) 40%, transparent); + margin-bottom: 10px; +} +.about-hero .about-name { + font-family: var(--font-display, var(--font-body)); + font-size: 1.5rem; + font-weight: 800; + color: var(--lux-ink, var(--text-color)); + margin-bottom: 2px; + letter-spacing: 0.005em; +} +.about-hero .about-version { + display: inline-block; + margin-top: 4px; + padding: 3px 11px; + border-radius: 999px; + font-family: var(--font-mono, monospace); + font-size: 0.72rem; + font-weight: 600; + background: color-mix(in srgb, var(--ch-amber, var(--warning-color)) 12%, transparent); + color: var(--ch-amber, var(--warning-color)); + border: var(--lux-hairline, 1px) solid color-mix(in srgb, var(--ch-amber, var(--warning-color)) 30%, transparent); +} +.about-hero .about-tag { + margin-top: 10px; + font-size: 0.78rem; + color: var(--lux-ink-dim, var(--text-secondary)); +} +.about-hero .about-links { + display: flex; + gap: 6px; + justify-content: center; + flex-wrap: wrap; + margin-top: 14px; +} +.about-hero .about-license { + margin-top: 14px; + font-size: 0.7rem; + color: var(--lux-ink-faint, var(--text-muted)); + opacity: 0.7; + letter-spacing: 0.04em; +} + +/* ── Per-section save bar ────────────────────────────────── + Appears under a dirty field; warns about unsaved changes and offers + inline Save / Revert. Toggled via the [hidden] attribute by settings.ts. */ +.save-bar { + margin: 6px 0 4px; + padding: 9px 12px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + background: color-mix(in srgb, var(--ch-amber, var(--warning-color)) 10%, var(--lux-bg-1, var(--card-bg))); + border: var(--lux-hairline, 1px) solid color-mix(in srgb, var(--ch-amber, var(--warning-color)) 36%, transparent); + border-left: 3px solid var(--ch-amber, var(--warning-color)); + border-radius: var(--lux-r-sm, 4px); + animation: saveBarSlide var(--duration-normal, 0.25s) var(--ease-out, ease); +} +.save-bar[hidden] { display: none; } +@keyframes saveBarSlide { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} +.save-bar-msg { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.78rem; + font-weight: 500; + color: var(--lux-ink, var(--text-color)); + min-width: 0; +} +.save-bar-msg::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--ch-amber, var(--warning-color)); + box-shadow: 0 0 8px var(--ch-amber, var(--warning-color)); + animation: saveBarPulse 1.6s ease-in-out infinite; + flex-shrink: 0; +} +.save-bar-msg strong { + color: var(--lux-ink, var(--text-color)); + font-weight: 600; +} +@keyframes saveBarPulse { + 0%, 100% { opacity: 0.6; } + 50% { opacity: 1; } +} +.save-bar-actions { + display: flex; + gap: 6px; + flex-shrink: 0; +} + +/* ── Responsive collapse: rail to icon-only at narrow widths ── */ +@media (max-width: 600px) { + #settings-modal .modal-content { + max-width: 100%; + } + .settings-rail { + flex-basis: 56px; + } + .settings-rail-btn { + padding: 11px 0; + justify-content: center; + gap: 0; + } + .settings-rail-btn .settings-rail-label, + .settings-rail-btn .settings-rail-dot { + display: none; + } + .settings-rail-group { display: none; } + .settings-rail-footer { display: none; } + .ds-pair-row { grid-template-columns: 1fr; gap: 0; } + .notif-matrix { grid-template-columns: minmax(0, 1fr) repeat(4, 50px); } + .status-card { grid-template-columns: auto 1fr; } + .status-card-actions { grid-column: 1 / -1; flex-direction: row; } + .status-card-actions .btn { flex: 1; } +} diff --git a/server/src/ledgrab/static/css/streams.css b/server/src/ledgrab/static/css/streams.css index 0589477..0fe7f66 100644 --- a/server/src/ledgrab/static/css/streams.css +++ b/server/src/ledgrab/static/css/streams.css @@ -10,7 +10,7 @@ .template-card { --ch: var(--ch-cyan, var(--info-color)); /* default channel — overridden per data-attr below */ - background: var(--card-bg); + background: var(--lux-bg-1, var(--card-bg)); border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); border-radius: var(--lux-r-md, var(--radius-md)); padding: 18px 20px 16px; diff --git a/server/src/ledgrab/static/js/app.ts b/server/src/ledgrab/static/js/app.ts index 6a8946e..55af99a 100644 --- a/server/src/ledgrab/static/js/app.ts +++ b/server/src/ledgrab/static/js/app.ts @@ -106,7 +106,7 @@ import { } from './features/integrations.ts'; import { openScenePresetCapture, editScenePreset, saveScenePreset, closeScenePresetEditor, - activateScenePreset, cloneScenePreset, deleteScenePreset, + activateScenePreset, cloneScenePreset, deleteScenePreset, recaptureScenePreset, addSceneTarget, } from './features/scene-presets.ts'; @@ -224,7 +224,7 @@ import { loadLogLevel, setLogLevel, loadShutdownAction, setShutdownAction, requestNotifPermissionFromSettings, testNotifFromSettings, - saveExternalUrl, getBaseOrigin, loadExternalUrl, + saveExternalUrl, revertExternalUrl, getBaseOrigin, loadExternalUrl, } from './features/settings.ts'; import { loadUpdateStatus, initUpdateListener, checkForUpdates, @@ -416,7 +416,7 @@ Object.assign(window, { deleteAutomation, copyWebhookUrl, - // scene presets (modal buttons stay on window; card actions migrated to event delegation) + // scene presets — modal buttons + mod-card inline handlers openScenePresetCapture, editScenePreset, saveScenePreset, @@ -424,6 +424,7 @@ Object.assign(window, { activateScenePreset, cloneScenePreset, deleteScenePreset, + recaptureScenePreset, addSceneTarget, // integrations @@ -630,6 +631,7 @@ Object.assign(window, { requestNotifPermissionFromSettings, testNotifFromSettings, saveExternalUrl, + revertExternalUrl, getBaseOrigin, // update diff --git a/server/src/ledgrab/static/js/core/ui.ts b/server/src/ledgrab/static/js/core/ui.ts index 32dd740..39b7366 100644 --- a/server/src/ledgrab/static/js/core/ui.ts +++ b/server/src/ledgrab/static/js/core/ui.ts @@ -16,14 +16,136 @@ export function desktopFocus(el: HTMLElement | null) { if (el && !isTouchDevice()) el.focus(); } -export function toggleHint(btn: HTMLElement) { - const hint = btn.closest('.label-row')!.nextElementSibling as HTMLElement | null; - if (hint && hint.classList.contains('input-hint')) { - const visible = hint.style.display !== 'none'; - hint.style.display = visible ? 'none' : 'block'; - btn.classList.toggle('active', !visible); - btn.setAttribute('aria-expanded', String(!visible)); +/* ── Hint popover ───────────────────────────────────────────────── + The legacy implementation toggled the inline `` + between display:none and display:block. That worked but pushed every + field below it down — every help click reflowed half the modal. The + popover variant anchors a floating tooltip to the `?` button so the + form layout stays stable. The inline `` is kept in the DOM + purely as a translation source: data-i18n still binds to it, and we + read its textContent at click time. */ + +let _hintPopoverEl: HTMLElement | null = null; +let _hintAnchorBtn: HTMLElement | null = null; +let _hintDismissBound = false; + +function _ensureHintPopover(): HTMLElement { + if (_hintPopoverEl && document.body.contains(_hintPopoverEl)) return _hintPopoverEl; + const el = document.createElement('div'); + el.className = 'hint-popover'; + el.setAttribute('role', 'tooltip'); + el.setAttribute('aria-hidden', 'true'); + document.body.appendChild(el); + _hintPopoverEl = el; + return el; +} + +function _closeHintPopover() { + if (_hintPopoverEl) { + _hintPopoverEl.classList.remove('open'); + _hintPopoverEl.setAttribute('aria-hidden', 'true'); } + if (_hintAnchorBtn) { + _hintAnchorBtn.classList.remove('active'); + _hintAnchorBtn.setAttribute('aria-expanded', 'false'); + _hintAnchorBtn = null; + } +} + +function _positionHintPopover(pop: HTMLElement, anchor: HTMLElement) { + // Park at viewport origin and measure natural size while still hidden. + pop.style.left = '0px'; + pop.style.top = '0px'; + const popRect = pop.getBoundingClientRect(); + const anchorRect = anchor.getBoundingClientRect(); + const gap = 8; + const spaceBelow = window.innerHeight - anchorRect.bottom; + const placeAbove = spaceBelow < popRect.height + gap && anchorRect.top > popRect.height + gap; + const top = placeAbove + ? anchorRect.top - popRect.height - gap + : anchorRect.bottom + gap; + let left = anchorRect.left + anchorRect.width / 2 - popRect.width / 2; + left = Math.max(8, Math.min(left, window.innerWidth - popRect.width - 8)); + pop.style.top = `${top}px`; + pop.style.left = `${left}px`; + pop.dataset.placement = placeAbove ? 'top' : 'bottom'; + const rawArrowX = anchorRect.left + anchorRect.width / 2 - left; + const arrowX = Math.max(14, Math.min(rawArrowX, popRect.width - 14)); + pop.style.setProperty('--hint-arrow-x', `${arrowX}px`); +} + +function _bindHintDismiss() { + if (_hintDismissBound) return; + _hintDismissBound = true; + document.addEventListener('mousedown', (e) => { + if (!_hintAnchorBtn) return; + const target = e.target as HTMLElement | null; + if (!target) return; + if (_hintAnchorBtn === target || _hintAnchorBtn.contains(target)) return; + if (_hintPopoverEl && _hintPopoverEl.contains(target)) return; + _closeHintPopover(); + }, true); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && _hintAnchorBtn) { + e.stopPropagation(); + _closeHintPopover(); + } + }); + // Reposition or close on layout shifts. + document.addEventListener('scroll', () => _closeHintPopover(), true); + window.addEventListener('resize', () => _closeHintPopover()); + document.addEventListener('languageChanged', () => _closeHintPopover()); + // Catch the case where a modal closes programmatically (Save button, + // success path) — the modal grows the .closing class which kicks off + // the fadeOut animation. Dismiss any anchored popover at the same time + // so we don't leave an orphaned tooltip floating over the page. + document.addEventListener('animationstart', (e) => { + if (!_hintAnchorBtn) return; + const target = e.target as HTMLElement | null; + if (target?.classList?.contains('modal') && target.classList.contains('closing')) { + _closeHintPopover(); + } + }, true); +} + +export function toggleHint(btn: HTMLElement) { + // Look for the .input-hint either as the immediate next sibling of the + // .label-row (form-group pattern) or anywhere else inside the + // surrounding .ds-toggle-text / .form-group wrapper. This keeps the + // helper working when the hint sits between the label-row and a + // status sub-line (e.g. OS Permission row in the settings modal). + const labelRow = btn.closest('.label-row') as HTMLElement | null; + let hint = labelRow?.nextElementSibling as HTMLElement | null; + if (!hint || !hint.classList.contains('input-hint')) { + const wrap = btn.closest('.ds-toggle-text, .form-group, .ds-toggle-row') as HTMLElement | null; + hint = wrap?.querySelector(':scope > .input-hint') as HTMLElement | null; + } + if (!hint || !hint.classList.contains('input-hint')) return; + + // Force the legacy inline to stay collapsed — the popover + // is now the sole visible surface for hints. + hint.style.display = 'none'; + + if (_hintAnchorBtn === btn) { + _closeHintPopover(); + return; + } + + const text = (hint.textContent || '').trim(); + if (!text) return; + + // Reuse a single popover so we don't pile up tooltip nodes. + _bindHintDismiss(); + if (_hintAnchorBtn) _closeHintPopover(); + const pop = _ensureHintPopover(); + pop.textContent = text; + pop.setAttribute('aria-hidden', 'false'); + _positionHintPopover(pop, btn); + pop.classList.add('open'); + + btn.classList.add('active'); + btn.setAttribute('aria-expanded', 'true'); + _hintAnchorBtn = btn; } const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; @@ -433,12 +555,15 @@ export function formatCompact(n: number | null | undefined) { return (v < 10 ? v.toFixed(1) : Math.round(v)) + 'B'; } -export function formatUptime(seconds: number | null | undefined) { +export function formatUptime(seconds: number | null | undefined): string { if (!seconds || seconds <= 0) return '-'; - const h = Math.floor(seconds / 3600); - const m = Math.floor((seconds % 3600) / 60); - const s = Math.floor(seconds % 60); - if (h > 0) return t('time.hours_minutes', { h, m }); - if (m > 0) return t('time.minutes_seconds', { m, s }); - return t('time.seconds', { s }); + const total = Math.floor(seconds); + const d = Math.floor(total / 86400); + const h = Math.floor((total % 86400) / 3600); + const m = Math.floor((total % 3600) / 60); + const s = total % 60; + const pad = (n: number) => String(n).padStart(2, '0'); + if (d > 0) return `${d}d ${h}h`; + if (h > 0) return `${h}:${pad(m)}:${pad(s)}`; + return `${m}:${pad(s)}`; } diff --git a/server/src/ledgrab/static/js/features/appearance.ts b/server/src/ledgrab/static/js/features/appearance.ts index 67beb02..2887aa5 100644 --- a/server/src/ledgrab/static/js/features/appearance.ts +++ b/server/src/ledgrab/static/js/features/appearance.ts @@ -436,15 +436,19 @@ export function initAppearance(): void { } } -/** Render the Appearance tab content. Called when the tab is switched to. */ +/** Render the Appearance tab content. Called when the tab is switched to. + * Each preset grid lives in its own channel-coded .ds-section so the panel + * matches the rest of the redesigned settings modal. The .ds-section-meta + * pill shows the active preset name in uppercase. */ export function renderAppearanceTab(): void { const panel = document.getElementById('settings-panel-appearance'); if (!panel) return; - // Don't re-render if already populated - if (panel.querySelector('.appearance-presets')) { + // Don't re-render if already populated — just refresh selections + meta pills + if (panel.querySelector('.ds-section')) { _updatePresetSelection('style', _activeStyleId); _updatePresetSelection('bg', _activeBgEffectId); + _updateAppearanceMetaPills(); return; } @@ -476,18 +480,46 @@ export function renderAppearanceTab(): void { }).join(''); panel.innerHTML = ` -
    -
    - - ${t('appearance.style.hint')} +
    +
    + + ${t('appearance.style.label')} + + +
    +
    + ${t('appearance.style.hint')}
    ${styleHtml}
    -
    - - ${t('appearance.bg.hint')} +
    +
    +
    + + ${t('appearance.bg.label')} + + +
    +
    + ${t('appearance.bg.hint')}
    ${bgHtml}
    -
    `; + `; + + _updateAppearanceMetaPills(); +} + +/** Refresh the .ds-section-meta pills to match the active preset names. */ +function _updateAppearanceMetaPills(): void { + const styleMeta = document.getElementById('appearance-style-meta'); + if (styleMeta) { + const preset = STYLE_PRESETS.find(p => p.id === _activeStyleId); + styleMeta.textContent = preset ? t(preset.nameKey).toUpperCase() : ''; + } + const bgMeta = document.getElementById('appearance-bg-meta'); + if (bgMeta) { + const effect = BG_EFFECT_PRESETS.find(e => e.id === _activeBgEffectId); + bgMeta.textContent = effect ? t(effect.nameKey).toUpperCase() : ''; + } } /** Return the currently active style preset ID. */ @@ -549,12 +581,13 @@ function _ensureFont(url: string, id: string): void { document.head.appendChild(link); } -/** Update the visual selection ring on preset cards. */ +/** Update the visual selection ring on preset cards and the meta pill. */ function _updatePresetSelection(type: 'style' | 'bg', activeId: string): void { const attr = type === 'style' ? 'style' : 'bg'; document.querySelectorAll(`[data-preset-type="${attr}"]`).forEach(el => { el.classList.toggle('active', (el as HTMLElement).dataset.presetId === activeId); }); + _updateAppearanceMetaPills(); } // ─── Listen for theme changes to reapply preset colors ────── diff --git a/server/src/ledgrab/static/js/features/assets.ts b/server/src/ledgrab/static/js/features/assets.ts index f1e3742..5a6f762 100644 --- a/server/src/ledgrab/static/js/features/assets.ts +++ b/server/src/ledgrab/static/js/features/assets.ts @@ -10,6 +10,7 @@ import { showToast, showConfirm } from '../core/ui.ts'; import { ICON_CLONE, ICON_EDIT, ICON_DOWNLOAD, ICON_ASSET, ICON_TRASH, getAssetTypeIcon } from '../core/icons.ts'; import * as P from '../core/icon-paths.ts'; import { wrapCard } from '../core/card-colors.ts'; +import type { ModCardOpts, ModChipOpts } from '../core/mod-card.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts'; import { loadPictureSources } from './streams.ts'; import type { Asset } from '../types.ts'; @@ -136,39 +137,49 @@ function getAssetTypeLabel(assetType: string): string { // ── Card builder ── export function createAssetCard(asset: Asset): string { - const icon = getAssetTypeIcon(asset.asset_type); const sizeStr = formatFileSize(asset.size_bytes); - const prebuiltBadge = asset.prebuilt - ? `${_icon(P.star)} ${t('asset.prebuilt')}` - : ''; + const typeLabel = getAssetTypeLabel(asset.asset_type); - let playBtn = ''; - if (asset.asset_type === 'sound') { - playBtn = ``; + const badgeText = `ASSET · ${asset.asset_type.slice(0, 3).toUpperCase()}`; + const chips: ModChipOpts[] = [ + { icon: getAssetTypeIcon(asset.asset_type), text: typeLabel }, + { icon: _icon(P.fileText), text: sizeStr }, + ]; + if (asset.prebuilt) { + chips.push({ icon: _icon(P.star), text: t('asset.prebuilt'), title: t('asset.prebuilt') }); } - return wrapCard({ - dataAttr: 'data-id', - id: asset.id, - removeOnclick: `deleteAsset('${asset.id}')`, - removeTitle: t('common.delete'), - content: ` -
    -
    - ${icon} ${escapeHtml(asset.name)} -
    -
    -
    - ${getAssetTypeIcon(asset.asset_type)} ${escapeHtml(getAssetTypeLabel(asset.asset_type))} - ${_icon(P.fileText)} ${sizeStr} - ${prebuiltBadge} -
    - ${renderTagChips(asset.tags)}`, - actions: ` - ${playBtn} - - `, - }); + const iconActions: any[] = []; + if (asset.asset_type === 'sound') { + iconActions.push({ icon: ICON_PLAY_SOUND, onclick: '', title: t('asset.play'), dataAttrs: { 'data-action': 'play' } }); + } + iconActions.push({ icon: ICON_DOWNLOAD, onclick: '', title: t('asset.download'), dataAttrs: { 'data-action': 'download' } }); + iconActions.push({ icon: ICON_EDIT, onclick: '', title: t('common.edit'), dataAttrs: { 'data-action': 'edit' } }); + + const mod: ModCardOpts = { + head: { + badge: { text: badgeText }, + name: asset.name, + metaHtml: escapeHtml(`${typeLabel} · ${sizeStr}`), + leds: ['on'], + menu: { + hideOnclick: `toggleCardHidden('assets','${asset.id}')`, + deleteOnclick: `deleteAsset('${asset.id}')`, + }, + }, + body: { + chips, + }, + foot: { + patchState: 'idle', + patchLabel: 'READY', + iconActions, + }, + }; + + const cardHtml = wrapCard({ dataAttr: 'data-id', id: asset.id, mod }); + const tagsHtml = renderTagChips(asset.tags); + return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}
    `) : cardHtml; } // ── Sound playback ── diff --git a/server/src/ledgrab/static/js/features/audio-processing-templates.ts b/server/src/ledgrab/static/js/features/audio-processing-templates.ts index 48cc1e3..f12d80c 100644 --- a/server/src/ledgrab/static/js/features/audio-processing-templates.ts +++ b/server/src/ledgrab/static/js/features/audio-processing-templates.ts @@ -23,6 +23,7 @@ import { ICON_AUDIO_TEMPLATE, ICON_CLONE, ICON_EDIT } from '../core/icons.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts'; import { FilterListManager } from '../core/filter-list.ts'; import { wrapCard } from '../core/card-colors.ts'; +import type { ModCardOpts } from '../core/mod-card.ts'; import { loadPictureSources } from './streams.ts'; // ── Module state ───────────────────────────────────────────── @@ -266,34 +267,44 @@ export function renderAPTModalFilterList() { aptFilterManager.render(); } // ── Card rendering (used by streams.ts) ─────────────────────── export function createAudioProcessingTemplateCard(tmpl: any): string { - let filterChainHtml = ''; - if (tmpl.filters && tmpl.filters.length > 0) { - const filterNames = tmpl.filters.map((fi: any) => { + const filters = tmpl.filters || []; + const chainExtra = filters.length > 0 ? `
    ${ + filters.map((fi: any, idx: number) => { let label = _getAudioFilterName(fi.filter_id); if (fi.filter_id === 'audio_filter_template' && fi.options?.template_id) { const ref = _cachedAudioProcessingTemplates.find((p: any) => p.id === fi.options.template_id); if (ref) label += `: ${ref.name}`; } - return `${escapeHtml(label)}`; - }); - filterChainHtml = `
    ${filterNames.join('\u2192')}
    `; - } + const arrow = idx < filters.length - 1 ? '\u2192' : ''; + return `${escapeHtml(label)}${arrow}`; + }).join('') + }
    ` : ''; - return wrapCard({ - type: 'template-card', - dataAttr: 'data-apt-id', - id: tmpl.id, - removeOnclick: `deleteAudioProcessingTemplate('${tmpl.id}')`, - removeTitle: t('common.delete'), - content: ` -
    -
    ${ICON_AUDIO_TEMPLATE} ${escapeHtml(tmpl.name)}
    -
    - ${tmpl.description ? `
    ${escapeHtml(tmpl.description)}
    ` : ''} - ${filterChainHtml} - ${renderTagChips(tmpl.tags)}`, - actions: ` - - `, - }); + const mod: ModCardOpts = { + head: { + badge: { text: 'TPL \u00b7 AUDIO PROC' }, + name: tmpl.name, + metaHtml: escapeHtml(`${filters.length} ${t('audio_processing.title') || 'filters'}`), + leds: ['off'], + menu: { + duplicateOnclick: `cloneAudioProcessingTemplate('${tmpl.id}')`, + hideOnclick: `toggleCardHidden('audio-processing-templates','${tmpl.id}')`, + deleteOnclick: `deleteAudioProcessingTemplate('${tmpl.id}')`, + }, + }, + body: { + desc: tmpl.description || undefined, + extraHtml: chainExtra || undefined, + }, + foot: { + patchState: 'idle', + patchLabel: 'PIPELINE', + iconActions: [ + { icon: ICON_EDIT, onclick: `editAudioProcessingTemplate('${tmpl.id}')`, title: t('common.edit') }, + ], + }, + }; + const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-apt-id', id: tmpl.id, mod }); + const tagsHtml = renderTagChips(tmpl.tags); + return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}`) : cardHtml; } diff --git a/server/src/ledgrab/static/js/features/automations.ts b/server/src/ledgrab/static/js/features/automations.ts index 56a8937..459d384 100644 --- a/server/src/ledgrab/static/js/features/automations.ts +++ b/server/src/ledgrab/static/js/features/automations.ts @@ -11,9 +11,10 @@ import { Modal } from '../core/modal.ts'; import { CardSection } from '../core/card-sections.ts'; import { updateTabBadge, updateSubTabHash } from './tabs.ts'; import { isActiveTab, getActiveSubTab, setActiveSubTab } from '../core/tab-registry.ts'; -import { ICON_SETTINGS, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE, ICON_CLONE, ICON_TRASH, ICON_CIRCLE_OFF, ICON_UNDO, ICON_WEB, ICON_SEARCH } from '../core/icons.ts'; +import { ICON_START, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE, ICON_TRASH, ICON_CIRCLE_OFF, ICON_UNDO, ICON_WEB, ICON_SEARCH, ICON_EDIT, ICON_PAUSE } from '../core/icons.ts'; import * as P from '../core/icon-paths.ts'; import { wrapCard } from '../core/card-colors.ts'; +import type { ModCardOpts, ModChipOpts } from '../core/mod-card.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts'; import { getBaseOrigin } from './settings.ts'; import { IconSelect } from '../core/icon-select.ts'; @@ -257,96 +258,180 @@ function renderAutomations(automations: any, sceneMap: any) { } } -type RulePillRenderer = (c: any) => string; +type RuleChipBuilder = (c: any) => ModChipOpts; -const RULE_PILL_RENDERERS: Record = { - startup: (c) => `${ICON_START} ${t('automations.rule.startup')}`, +/* Build one chip per automation rule. The chip shows the rule type's + icon + a tight, scannable label. Mirrors the AUTO card in the + cards-redesign demo: rules read as a left-to-right chain leading into + the scene activation. */ +const RULE_CHIP_RENDERERS: Record = { + startup: () => ({ icon: ICON_START, text: t('automations.rule.startup') }), application: (c) => { - const apps = (c.apps || []).join(', '); + const apps = (c.apps || []).join(', ') || '—'; const matchLabel = t('automations.rule.application.match_type.' + (c.match_type || 'running')); - return `${t('automations.rule.application')}: ${apps} (${matchLabel})`; + return { icon: _icon(P.smartphone), text: `${apps} (${matchLabel})`, title: t('automations.rule.application') }; }, - time_of_day: (c) => `${ICON_CLOCK} ${t('automations.rule.time_of_day')}: ${c.start_time || '00:00'} – ${c.end_time || '23:59'}`, + time_of_day: (c) => ({ + icon: ICON_CLOCK, + text: `${c.start_time || '00:00'} – ${c.end_time || '23:59'}`, + title: t('automations.rule.time_of_day'), + }), system_idle: (c) => { const mode = c.when_idle !== false ? t('automations.rule.system_idle.when_idle') : t('automations.rule.system_idle.when_active'); - return `${ICON_TIMER} ${c.idle_minutes || 5}m (${mode})`; + return { icon: ICON_TIMER, text: `${c.idle_minutes || 5}m (${mode})`, title: t('automations.rule.system_idle') }; }, display_state: (c) => { const stateLabel = t('automations.rule.display_state.' + (c.state || 'on')); - return `${ICON_MONITOR} ${t('automations.rule.display_state')}: ${stateLabel}`; + return { icon: ICON_MONITOR, text: stateLabel, title: t('automations.rule.display_state') }; }, - mqtt: (c) => `${ICON_RADIO} ${t('automations.rule.mqtt')}: ${escapeHtml(c.topic || '')} = ${escapeHtml(c.payload || '*')}`, - webhook: (c) => `${ICON_WEB} ${t('automations.rule.webhook')}`, - home_assistant: (c) => `${_icon(P.home)} ${t('automations.rule.home_assistant')}: ${escapeHtml(c.entity_id || '')} = ${escapeHtml(c.state || '*')}`, + mqtt: (c) => ({ + icon: ICON_RADIO, + text: `${c.topic || ''} = ${c.payload || '*'}`, + title: t('automations.rule.mqtt'), + }), + webhook: () => ({ icon: ICON_WEB, text: t('automations.rule.webhook') }), + home_assistant: (c) => ({ + icon: _icon(P.home), + text: `${c.entity_id || ''} = ${c.state || '*'}`, + title: t('automations.rule.home_assistant'), + }), }; +/** Render a chain-arrow separator span. `+` between AND-rules, + * the localised OR label between OR-rules, and `→` for the + * rule-chain → scene-activation transition. */ +function _chainArrow(glyph: string): string { + return ``; +} + +/** Render a single chip as the same markup `renderModChips` produces, + * so the body chain row reads identically to chip arrays elsewhere + * (devices/value sources). Inline build so we can intersperse + * chain-arrow separators between chips. */ +function _chipHtml(c: ModChipOpts): string { + const variant = c.variant === 'tag' ? ' chip--tag' + : c.variant === 'err' ? ' chip--err' + : ''; + const link = c.onclick ? ' chip--link' : ''; + const titleAttr = c.title ? ` title="${escapeHtml(c.title)}"` : ''; + const onclickAttr = c.onclick ? ` onclick="${c.onclick}"` : ''; + return `${c.icon || ''} ${escapeHtml(c.text)}`; +} + function createAutomationCard(automation: Automation, sceneMap = new Map()) { - const statusClass = !automation.enabled ? 'disabled' : automation.is_active ? 'active' : 'inactive'; - const statusText = !automation.enabled ? t('automations.status.disabled') : automation.is_active ? t('automations.status.active') : t('automations.status.inactive'); + // ── Rule chips: one per rule, joined by chain-arrow separators + // (`+` for AND, `OR` for OR — mirrors the demo's flow language). ── + const ruleChips: ModChipOpts[] = automation.rules.length + ? automation.rules.map(c => { + const builder = RULE_CHIP_RENDERERS[c.rule_type]; + return builder ? builder(c) : { text: c.rule_type }; + }) + : [{ text: t('automations.rules.empty') }]; - let rulePills = ''; - if (automation.rules.length === 0) { - rulePills = `${t('automations.rules.empty')}`; - } else { - const parts = automation.rules.map(c => { - const renderer = RULE_PILL_RENDERERS[c.rule_type]; - return renderer ? renderer(c) : `${c.rule_type}`; - }); - const logicLabel = automation.rule_logic === 'and' ? t('automations.logic.and') : t('automations.logic.or'); - rulePills = parts.join(`${logicLabel}`); - } + const logicGlyph = automation.rule_logic === 'and' ? '+' : 'OR'; + const ruleChain = ruleChips.map(_chipHtml).join(_chainArrow(logicGlyph)); - // Scene info + // ── Scene chip: the action — clickable, navigates to the scene card. ── const scene = automation.scene_preset_id ? sceneMap.get(automation.scene_preset_id) : null; - const sceneName = scene ? escapeHtml(scene.name) : t('automations.scene.none_selected'); - const sceneColor = scene ? scene.color || '#4fc3f7' : '#888'; + const sceneChipHtml = _chipHtml(scene ? { + icon: ICON_SCENE, + text: scene.name, + title: t('automations.scene'), + onclick: `event.stopPropagation(); navigateToCard('automations','scenes','scenes','data-scene-id','${automation.scene_preset_id}')`, + variant: 'tag', + } : { + icon: ICON_SCENE, + text: t('automations.scene.none_selected'), + }); - // Deactivation mode label - let deactivationMeta = ''; + // ── Optional deactivation chip — `↩` revert or fallback scene. + // Rendered after the scene chip with a chain arrow so the card + // reads as: rules → scene ↩ (deactivation behaviour). ── + let deactivationHtml = ''; if (automation.deactivation_mode === 'revert') { - deactivationMeta = `${ICON_UNDO} ${t('automations.deactivation_mode.revert')}`; + deactivationHtml = _chainArrow('↩') + _chipHtml({ + icon: ICON_UNDO, + text: t('automations.deactivation_mode.revert'), + }); } else if (automation.deactivation_mode === 'fallback_scene') { const fallback = automation.deactivation_scene_preset_id ? sceneMap.get(automation.deactivation_scene_preset_id) : null; - if (fallback) { - const fbColor = fallback.color || '#4fc3f7'; - deactivationMeta = `${ICON_UNDO} ${escapeHtml(fallback.name)}`; - } else { - deactivationMeta = `${ICON_UNDO} ${t('automations.deactivation_mode.fallback_scene')}`; - } + deactivationHtml = _chainArrow('↩') + _chipHtml(fallback ? { + icon: ICON_UNDO, + text: fallback.name, + title: t('automations.deactivation_mode.fallback_scene'), + onclick: `event.stopPropagation(); navigateToCard('automations','scenes','scenes','data-scene-id','${automation.deactivation_scene_preset_id}')`, + } : { + icon: ICON_UNDO, + text: t('automations.deactivation_mode.fallback_scene'), + }); } - let lastActivityMeta = ''; + const chipsHtml = `
    ${ruleChain}${_chainArrow('→')}${sceneChipHtml}${deactivationHtml}
    `; + + // ── State surfaces: LED + patch indicator ── + // Active = blink (live signal); Enabled-but-idle = off (waiting); + // Disabled = fault (red, indicates unavailable rather than error). + const ledState = !automation.enabled ? 'fault' + : automation.is_active ? 'blink' + : 'off'; + const patchState = !automation.enabled ? 'offline' + : automation.is_active ? 'live' + : 'idle'; + const patchLabel = !automation.enabled ? t('automations.status.disabled').toUpperCase() + : automation.is_active ? t('automations.status.active').toUpperCase() + : t('automations.status.inactive').toUpperCase(); + + // ── Meta: last-fired timestamp only — the rule/scene chain is + // already laid out below, so meta stays a quiet single-line + // history hint. ── + let metaHtml: string | undefined; if (automation.last_activated_at) { const ts = new Date(automation.last_activated_at); - lastActivityMeta = `${ICON_CLOCK} ${ts.toLocaleString()}`; + metaHtml = `${t('automations.last_activated')} · ${escapeHtml(ts.toLocaleString())}`; } - return wrapCard({ + // ── Badge: "AUTO · XX" — short id slice mirrors the demo's + // "AUTO · 07" pattern (last 2 hex chars, uppercase). ── + const shortId = (automation.id || '').replace(/^auto_/i, '').slice(-2).toUpperCase() || 'NA'; + + const mod: ModCardOpts = { + running: automation.is_active, + head: { + badge: { text: `AUTO · ${shortId}` }, + name: automation.name, + metaHtml, + leds: [ledState], + menu: { + duplicateOnclick: `cloneAutomation('${automation.id}')`, + hideOnclick: `toggleCardHidden('automations','${automation.id}')`, + deleteOnclick: `deleteAutomation('${automation.id}', '${escapeHtml(automation.name)}')`, + }, + }, + body: { + extraHtml: chipsHtml, + }, + foot: { + patchState, + patchLabel, + secondaryActions: [ + automation.enabled + ? { label: t('automations.action.disable'), icon: ICON_PAUSE, onclick: `toggleAutomationEnabled('${automation.id}', false)`, variant: 'stop' } + : { label: t('search.action.enable'), icon: ICON_START, onclick: `toggleAutomationEnabled('${automation.id}', true)`, variant: 'go' }, + ], + iconActions: [ + { icon: ICON_EDIT, onclick: `openAutomationEditor('${automation.id}')`, title: t('automations.edit') }, + ], + }, + }; + + const cardHtml = wrapCard({ dataAttr: 'data-automation-id', id: automation.id, classes: !automation.enabled ? 'automation-status-disabled' : '', - removeOnclick: `deleteAutomation('${automation.id}', '${escapeHtml(automation.name)}')`, - removeTitle: t('common.delete'), - content: ` -
    -
    - ${escapeHtml(automation.name)} - ${statusText} -
    -
    -
    - ${rulePills} - ${ICON_SCENE} ${sceneName} - ${deactivationMeta} -
    - ${renderTagChips(automation.tags)}`, - actions: ` - - - `, + mod, }); + const tagsHtml = renderTagChips(automation.tags); + return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}`) : cardHtml; } export async function openAutomationEditor(automationId?: any, cloneData?: any) { diff --git a/server/src/ledgrab/static/js/features/color-strips/cards.ts b/server/src/ledgrab/static/js/features/color-strips/cards.ts index 04bf4fe..6abe9b2 100644 --- a/server/src/ledgrab/static/js/features/color-strips/cards.ts +++ b/server/src/ledgrab/static/js/features/color-strips/cards.ts @@ -18,6 +18,7 @@ import { ICON_AUTOMATION, ICON_FAST_FORWARD, ICON_THERMOMETER, ICON_PATTERN_TEMPLATE, } from '../../core/icons.ts'; import { wrapCard } from '../../core/card-colors.ts'; +import type { ModCardOpts } from '../../core/mod-card.ts'; import type { ColorStripSource } from '../../types.ts'; import { bindableValue, bindableColor } from '../../types.ts'; import { renderTagChips } from '../../core/tag-input.ts'; @@ -273,6 +274,25 @@ function _renderPictureCardProps(source: ColorStripSource, pictureSourceMap: Rec /* ── Main card builder ────────────────────────────────────────── */ +const STRIP_BADGE: Record = { + static: 'STRIP · COLOR', + gradient: 'STRIP · GRD', + color_cycle: 'STRIP · CYCLE', + effect: 'STRIP · FX', + composite: 'STRIP · COMP', + mapped: 'STRIP · MAP', + audio: 'STRIP · AUDIO', + api_input: 'STRIP · API', + notification: 'STRIP · NOTIF', + daylight: 'STRIP · DAY', + candlelight: 'STRIP · CANDLE', + weather: 'STRIP · WEATHER', + key_colors: 'STRIP · KEY', + math_wave: 'STRIP · WAVE', + processed: 'STRIP · OUT', + picture_advanced: 'STRIP · CALIB', +}; + export function createColorStripCard(source: ColorStripSource, pictureSourceMap: Record, audioSourceMap: Record) { // Clock crosslink badge const clockObj = source.clock_id ? _cachedSyncClocks.find(c => c.id === source.clock_id) : null; @@ -291,45 +311,54 @@ export function createColorStripCard(source: ColorStripSource, pictureSourceMap: ? renderer(source, { clockBadge, animBadge, audioSourceMap, pictureSourceMap }) : _renderPictureCardProps(source, pictureSourceMap); - const icon = getColorStripIcon(source.source_type); + const ledCount = (source as any).led_count || 0; + const badgeText = STRIP_BADGE[source.source_type] || 'STRIP · MAPPED'; + const metaText = ledCount ? `${ledCount} px` : (source.source_type || '').replace(/_/g, ' '); + const isNotification = source.source_type === 'notification'; const isPictureKind = !NON_PICTURE_TYPES.has(source.source_type); - const calibrationBtn = isPictureKind - ? `` - : ''; - const overlayBtn = isPictureKind - ? `` - : ''; - const testNotifyBtn = isNotification - ? `` - : ''; - const notifHistoryBtn = isNotification - ? `` - : ''; const isKeyColors = source.source_type === 'key_colors'; - const regionsBtn = isKeyColors - ? `` - : ''; - const testPreviewBtn = ``; - return wrapCard({ - dataAttr: 'data-css-id', - id: source.id, - removeOnclick: `deleteColorStrip('${source.id}')`, - removeTitle: t('common.delete'), - content: ` -
    -
    - ${icon} ${escapeHtml(source.name)} -
    -
    -
    - ${propsHtml} -
    - ${renderTagChips(source.tags)}`, - actions: ` - - - ${calibrationBtn}${overlayBtn}${regionsBtn}${testNotifyBtn}${notifHistoryBtn}${testPreviewBtn}`, - }); + const iconActions: any[] = []; + iconActions.push({ icon: ICON_TEST, onclick: `event.stopPropagation(); testColorStrip('${source.id}')`, title: t('color_strip.test.title') }); + if (isPictureKind) { + const calibrationOnclick = source.source_type === 'picture_advanced' + ? `showAdvancedCalibration('${source.id}')` + : `showCSSCalibration('${source.id}')`; + iconActions.push({ icon: ICON_CALIBRATION, onclick: calibrationOnclick, title: t('calibration.title') }); + iconActions.push({ icon: ICON_OVERLAY, onclick: `event.stopPropagation(); toggleCSSOverlay('${source.id}')`, title: t('overlay.toggle') }); + } + if (isKeyColors) { + iconActions.push({ icon: ICON_PATTERN_TEMPLATE, onclick: `event.stopPropagation(); configureKCRegions('${source.id}')`, title: t('color_strip.key_colors.configure_regions') }); + } + if (isNotification) { + iconActions.push({ icon: ICON_BELL, onclick: `event.stopPropagation(); testNotification('${source.id}')`, title: t('color_strip.notification.test') }); + iconActions.push({ icon: ICON_AUTOMATION, onclick: `event.stopPropagation(); showNotificationHistory()`, title: t('color_strip.notification.history.title') }); + } + iconActions.push({ icon: ICON_EDIT, onclick: `showCSSEditor('${source.id}')`, title: t('common.edit') }); + + const mod: ModCardOpts = { + head: { + badge: { text: badgeText }, + name: source.name, + metaHtml: escapeHtml(metaText), + leds: ['off'], + menu: { + duplicateOnclick: `cloneColorStrip('${source.id}')`, + hideOnclick: `toggleCardHidden('color-strips','${source.id}')`, + deleteOnclick: `deleteColorStrip('${source.id}')`, + }, + }, + body: { + extraHtml: propsHtml ? `
    ${propsHtml}
    ` : undefined, + }, + foot: { + patchState: 'idle', + patchLabel: 'STRIP', + iconActions, + }, + }; + const cardHtml = wrapCard({ dataAttr: 'data-css-id', id: source.id, mod }); + const tagsHtml = renderTagChips(source.tags); + return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}`) : cardHtml; } diff --git a/server/src/ledgrab/static/js/features/dashboard-customize.ts b/server/src/ledgrab/static/js/features/dashboard-customize.ts index 08187e2..ff2e38b 100644 --- a/server/src/ledgrab/static/js/features/dashboard-customize.ts +++ b/server/src/ledgrab/static/js/features/dashboard-customize.ts @@ -76,12 +76,16 @@ const PERF_CELL_LABEL_KEYS: Record = { patches: 'dashboard.perf.active_patches', fps: 'dashboard.perf.total_fps', capture_fps: 'dashboard.perf.total_capture_fps', + capture_fps_actual: 'dashboard.perf.total_capture_fps_actual', errors: 'dashboard.perf.errors', devices: 'dashboard.perf.devices', cpu: 'dashboard.perf.cpu', ram: 'dashboard.perf.ram', gpu: 'dashboard.perf.gpu', temp: 'dashboard.perf.temp', + network: 'dashboard.perf.network', + device_latency: 'dashboard.perf.device_latency', + send_timing: 'dashboard.perf.send_timing', }; let _unsubscribe: (() => void) | null = null; diff --git a/server/src/ledgrab/static/js/features/dashboard-layout.ts b/server/src/ledgrab/static/js/features/dashboard-layout.ts index 362614f..e5c4812 100644 --- a/server/src/ledgrab/static/js/features/dashboard-layout.ts +++ b/server/src/ledgrab/static/js/features/dashboard-layout.ts @@ -36,14 +36,17 @@ export type PerfCellKey = | 'patches' | 'fps' | 'capture_fps' + | 'capture_fps_actual' | 'errors' | 'devices' | 'cpu' | 'ram' | 'gpu' | 'temp' - // Reserved. | 'network' + | 'device_latency' + | 'send_timing' + // Reserved. | 'disk' | 'audio-peak'; @@ -145,12 +148,16 @@ export const DEFAULT_LAYOUT: DashboardLayoutV1 = { _defaultPerfCell('patches'), _defaultPerfCell('fps'), _defaultPerfCell('capture_fps'), + _defaultPerfCell('capture_fps_actual', false), _defaultPerfCell('errors'), _defaultPerfCell('devices'), _defaultPerfCell('cpu'), _defaultPerfCell('ram'), _defaultPerfCell('gpu'), _defaultPerfCell('temp', false), + _defaultPerfCell('network', false), + _defaultPerfCell('device_latency', false), + _defaultPerfCell('send_timing', false), ], global: { width: 'full', diff --git a/server/src/ledgrab/static/js/features/dashboard.ts b/server/src/ledgrab/static/js/features/dashboard.ts index dc59cad..8a6ed41 100644 --- a/server/src/ledgrab/static/js/features/dashboard.ts +++ b/server/src/ledgrab/static/js/features/dashboard.ts @@ -6,7 +6,7 @@ import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval, import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, fetchMetricsHistory } from '../core/api.ts'; import { t } from '../core/i18n.ts'; import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing } from '../core/ui.ts'; -import { renderPerfSection, renderPerfModeToggle, setPerfMode, initPerfCharts, startPerfPolling, stopPerfPolling, updateActivePatches, updateTotalFps, updateTotalCaptureFps, updateTotalErrors, updateDevices, rerenderPerfGrid } from './perf-charts.ts'; +import { renderPerfSection, renderPerfModeToggle, setPerfMode, initPerfCharts, startPerfPolling, stopPerfPolling, updateActivePatches, updateTotalFps, updateTotalCaptureFps, updateTotalCaptureFpsActual, updateTotalErrors, updateDevices, updateNetworkThroughput, updateDeviceLatency, updateSendTiming, rerenderPerfGrid } from './perf-charts.ts'; import { startAutoRefresh, updateTabBadge } from './tabs.ts'; import { isActiveTab } from '../core/tab-registry.ts'; import { @@ -39,6 +39,11 @@ let _fpsCurrentHistory: Record = {}; let _fpsCharts: Record = {}; let _lastRunningIds: string[] = []; let _lastSyncClockIds: string = ''; +/** Previous cumulative `bytes_sent` summed across running targets. + * Used to convert the WLED transport byte counter into a per-poll + * delta that drives the Network throughput sparkline. `null` until + * the first poll so we don't emit a phantom spike on page load. */ +let _prevTotalBytesSent: number | null = null; let _uptimeBase: Record = {}; let _uptimeTimer: ReturnType | null = null; let _uptimeElements: Record = {}; @@ -99,10 +104,6 @@ function _startUptimeTimer(): void { if (!el) continue; const seconds = _getInterpolatedUptime(id); if (seconds != null) { - // Pure text — the .mod-metric "UPTIME" label already - // carries the icon meaning, and dropping it gives the - // value enough room for "4m 32s" / "1h 17m" without - // clipping inside the fixed-width metric cell. el.textContent = formatUptime(seconds); } } @@ -601,6 +602,32 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise= 0) { + latencySum += l; + latencyN += 1; + if (l > latencyMax) latencyMax = l; + } + } + const latencyAvg = latencyN > 0 ? latencySum / latencyN : null; + updateDeviceLatency( + latencyAvg, + latencyN > 0 ? latencyMax : null, + onlineCount, + deviceStateList.length, + ); } catch { /* ignore parse errors */ } } @@ -656,6 +683,13 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise 0) { + captureFpsActualTargetSum += captureFps; + } + } } const fpsMin = fpsValues.length > 0 ? Math.min(...fpsValues) : null; const fpsMax = fpsValues.length > 0 ? Math.max(...fpsValues) : null; @@ -680,6 +725,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise 0 ? Math.min(...captureFpsValues) : null; const captureFpsMax = captureFpsValues.length > 0 ? Math.max(...captureFpsValues) : null; updateTotalCaptureFps(captureFpsSum, captureFpsMin, captureFpsMax); + updateTotalCaptureFpsActual(captureFpsActualSum, captureFpsActualTargetSum, captureActualReportingCount); // Errors / dropped frames — fed cumulative totals; the perf // cell turns them into per-second rates by tracking deltas @@ -688,14 +734,44 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise 0) totalErrors += e; const s = r.state?.frames_skipped; if (typeof s === 'number' && s > 0) totalSkipped += s; + const b = r.state?.bytes_sent; + if (typeof b === 'number' && b > 0) totalBytesSent += b; + const t = r.state?.timing_send_ms; + if (typeof t === 'number' && Number.isFinite(t) && t >= 0) { + sendTimingSum += t; + sendTimingCount += 1; + if (t > sendTimingMax) sendTimingMax = t; + } } updateTotalErrors(totalErrors, totalSkipped, dashboardPollInterval); + // Network throughput — convert cumulative byte counter into + // a per-second rate via deltas, same shape as the errors + // cell. Counter resets (target stop/restart) leave the + // total unchanged or smaller; rate is clamped non-negative + // inside the perf-charts module. + const pollSec = Math.max(0.05, dashboardPollInterval / 1000); + const bytesDelta = _prevTotalBytesSent != null + ? Math.max(0, totalBytesSent - _prevTotalBytesSent) + : 0; + const bytesPerSec = _prevTotalBytesSent != null ? bytesDelta / pollSec : 0; + _prevTotalBytesSent = totalBytesSent; + updateNetworkThroughput(bytesPerSec, totalBytesSent); + + // Send-timing — already an instantaneous "ms last frame" + // value, so we just average / max across running targets. + const sendTimingAvg = sendTimingCount > 0 ? sendTimingSum / sendTimingCount : 0; + updateSendTiming(sendTimingAvg, sendTimingMax, sendTimingCount); + // Check if we can do an in-place metrics update (same targets, not first load) const newRunningIds = running.map(t => t.id).sort().join(','); const prevRunningIds = [..._lastRunningIds].sort().join(','); diff --git a/server/src/ledgrab/static/js/features/devices.ts b/server/src/ledgrab/static/js/features/devices.ts index d08026c..11e3fc1 100644 --- a/server/src/ledgrab/static/js/features/devices.ts +++ b/server/src/ledgrab/static/js/features/devices.ts @@ -23,6 +23,30 @@ import type { Device } from '../types.ts'; let _deviceTagsInput: any = null; let _settingsCsptEntitySelect: any = null; +/* The General Settings modal groups its many conditional fields into + four `.ds-section` panels (Identity / Connection / Hardware / Behavior). + showSettings() toggles individual `.form-group` visibility by device + type and capability — this helper then collapses any section whose + form-groups have all ended up `display: none`, so the user never + sees a section header with nothing underneath it. */ +function _updateSettingsSectionVisibility() { + const root = document.getElementById('device-settings-modal'); + if (!root) return; + const sections = root.querySelectorAll('.ds-section'); + sections.forEach((sec) => { + if (sec.dataset.dsKey === 'identity') { + sec.dataset.dsEmpty = 'false'; + return; + } + const groups = sec.querySelectorAll('.form-group'); + let anyVisible = false; + groups.forEach((g) => { + if (g.style.display !== 'none') anyVisible = true; + }); + sec.dataset.dsEmpty = anyVisible ? 'false' : 'true'; + }); +} + function _ensureSettingsCsptSelect() { const sel = document.getElementById('settings-css-processing-template') as HTMLSelectElement | null; if (!sel) return; @@ -624,6 +648,7 @@ export async function showSettings(deviceId: any) { const csptSel = document.getElementById('settings-css-processing-template') as HTMLSelectElement | null; if (csptSel) csptSel.value = device.default_css_processing_template_id || ''; + _updateSettingsSectionVisibility(); settingsModal.snapshot(); settingsModal.open(); diff --git a/server/src/ledgrab/static/js/features/donation.ts b/server/src/ledgrab/static/js/features/donation.ts index 61b8625..f140f17 100644 --- a/server/src/ledgrab/static/js/features/donation.ts +++ b/server/src/ledgrab/static/js/features/donation.ts @@ -4,7 +4,7 @@ */ import { t } from '../core/i18n.ts'; -import { ICON_HEART, ICON_EXTERNAL_LINK, ICON_X, ICON_GITHUB, ICON_HELP } from '../core/icons.ts'; +import { ICON_HEART, ICON_X, ICON_GITHUB, ICON_HELP } from '../core/icons.ts'; // ─── Config ───────────────────────────────────────────────── @@ -61,40 +61,48 @@ export function snoozeDonation(): void { _hideBanner(); } -/** Render the About panel content in settings modal. */ +/** Render the About panel content in settings modal. + * Uses the Lumenworks rack-panel + about-hero pattern from the + * settings-modal-redesign mockup: a channel-coded .ds-section + * wrapping a centered hero with mark, name, version pill, tagline, + * and external-link buttons. */ export function renderAboutPanel(): void { const container = document.getElementById('about-panel-content'); if (!container) return; - const version = document.getElementById('version-number')?.textContent || ''; - - let links = ''; + const version = document.getElementById('version-number')?.textContent?.trim() || ''; + const linkButtons: string[] = []; if (_repoUrl) { - links += `
    - ${ICON_GITHUB} - ${t('donation.view_source')} - ${ICON_EXTERNAL_LINK} - `; + linkButtons.push( + ` + ${ICON_GITHUB} + ${t('donation.view_source')} + `, + ); } - if (_donateUrl) { - links += ` - ${ICON_HEART} - ${t('donation.about_donate')} - ${ICON_EXTERNAL_LINK} - `; + linkButtons.push( + ` + ${ICON_HEART} + ${t('donation.about_donate')} + `, + ); } container.innerHTML = ` -
    - -

    ${t('donation.about_title')}

    - ${version ? `${version}` : ''} -

    ${t('donation.about_opensource')}

    - ${links ? `` : ''} -

    ${t('donation.about_license')}

    -
    +
    +
    +
    + +
    ${t('donation.about_title')}
    + ${version ? `
    ${version}
    ` : ''} +
    ${t('donation.about_opensource')}
    + ${linkButtons.length ? `` : ''} +
    ${t('donation.about_license')}
    +
    +
    +
    `; } diff --git a/server/src/ledgrab/static/js/features/game-integration.ts b/server/src/ledgrab/static/js/features/game-integration.ts index 5c679d8..4954b2d 100644 --- a/server/src/ledgrab/static/js/features/game-integration.ts +++ b/server/src/ledgrab/static/js/features/game-integration.ts @@ -12,6 +12,7 @@ import { Modal } from '../core/modal.ts'; import { showToast, showConfirm } from '../core/ui.ts'; import { CardSection } from '../core/card-sections.ts'; import { wrapCard } from '../core/card-colors.ts'; +import type { ModCardOpts, ModChipOpts, LedState } from '../core/mod-card.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts'; import { IconSelect, type IconSelectItem } from '../core/icon-select.ts'; import { @@ -540,34 +541,54 @@ export function testGameConnection() { // ── Card renderer ── export function createGameIntegrationCard(gi: GameIntegration): string { - const adapterIcon = getGameAdapterIcon(gi.adapter_type); const adapterName = _cachedGameAdapters.find(a => a.adapter_type === gi.adapter_type)?.display_name || gi.adapter_type; - const enabledClass = gi.enabled ? 'gi-status-active' : 'gi-status-inactive'; const enabledLabel = gi.enabled ? t('game_integration.status.active') : t('game_integration.status.inactive'); const mappingCount = gi.event_mappings?.length || 0; + const isEnabled = !!gi.enabled; - return wrapCard({ - type: 'template-card', - dataAttr: 'data-gi-id', - id: gi.id, - removeOnclick: `deleteGameIntegration('${gi.id}')`, - removeTitle: t('common.delete'), - content: ` -
    -
    ${adapterIcon} ${escapeHtml(gi.name)}
    -
    - ${gi.description ? `
    ${escapeHtml(gi.description)}
    ` : ''} -
    - ${ICON_GAMEPAD} ${escapeHtml(adapterName)} - ${ICON_CIRCLE_DOT} ${enabledLabel} - ${mappingCount > 0 ? `${_icon(P.listChecks)} ${mappingCount}` : ''} -
    - ${renderTagChips(gi.tags)}`, - actions: ` - - - `, - }); + // Badge: GAME · {ADAPTER} — adapter type compressed to 4-6 chars uppercase + const adapterBadge = String(gi.adapter_type).toUpperCase().slice(0, 8); + const badgeText = `GAME · ${adapterBadge}`; + + const leds: LedState[] = isEnabled ? ['on'] : ['off']; + + const chips: ModChipOpts[] = [ + { icon: ICON_GAMEPAD, text: adapterName, title: t('game_integration.adapter') }, + { icon: ICON_CIRCLE_DOT, text: enabledLabel, title: t('game_integration.status'), variant: isEnabled ? 'tag' : 'default' }, + ]; + if (mappingCount > 0) { + chips.push({ icon: _icon(P.listChecks), text: `${mappingCount} ${t('game_integration.mappings') || 'mappings'}`, title: t('game_integration.mappings') }); + } + + const mod: ModCardOpts = { + head: { + badge: { text: badgeText }, + name: gi.name, + metaHtml: escapeHtml(`${adapterName} · ${mappingCount} ${t('game_integration.mappings') || 'events'}`), + leds, + menu: { + duplicateOnclick: `cloneGameIntegration('${gi.id}')`, + hideOnclick: `toggleCardHidden('game-integrations','${gi.id}')`, + deleteOnclick: `deleteGameIntegration('${gi.id}')`, + }, + }, + body: { + desc: gi.description || undefined, + chips, + }, + foot: { + patchState: isEnabled ? 'live' : 'idle', + patchLabel: isEnabled ? 'ARMED' : 'DISARMED', + iconActions: [ + { icon: ICON_TEST, onclick: `event.stopPropagation(); showGameEventMonitor('${gi.id}')`, title: t('game_integration.events.monitor') }, + { icon: ICON_EDIT, onclick: `showGameIntegrationEditor('${gi.id}')`, title: t('common.edit') }, + ], + }, + running: isEnabled, + }; + const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-gi-id', id: gi.id, mod }); + const tagsHtml = renderTagChips(gi.tags); + return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}`) : cardHtml; } // ── CRUD ── diff --git a/server/src/ledgrab/static/js/features/home-assistant-sources.ts b/server/src/ledgrab/static/js/features/home-assistant-sources.ts index 700a80e..56e2b41 100644 --- a/server/src/ledgrab/static/js/features/home-assistant-sources.ts +++ b/server/src/ledgrab/static/js/features/home-assistant-sources.ts @@ -10,6 +10,7 @@ import { showToast, showConfirm } from '../core/ui.ts'; import { ICON_CLONE, ICON_EDIT, ICON_REFRESH } from '../core/icons.ts'; import * as P from '../core/icon-paths.ts'; import { wrapCard } from '../core/card-colors.ts'; +import type { ModCardOpts, ModChipOpts, LedState } from '../core/mod-card.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts'; import type { HomeAssistantSource } from '../types.ts'; @@ -217,44 +218,51 @@ export async function testHASource(): Promise { // ── Card rendering ── export function createHASourceCard(source: HomeAssistantSource) { - let healthClass: string, healthTitle: string; - if (source.connected) { - healthClass = 'health-online'; - healthTitle = `${t('ha_source.connected')} — ${source.entity_count} entities`; - } else { - healthClass = 'health-offline'; - healthTitle = t('ha_source.disconnected'); - } - const statusDot = ``; + const isConnected = !!source.connected; + const leds: LedState[] = isConnected ? ['on', 'on'] : ['fault']; + const healthTitle = isConnected + ? `${t('ha_source.connected')} — ${source.entity_count} entities` + : t('ha_source.disconnected'); - return wrapCard({ - type: 'template-card', - dataAttr: 'data-id', - id: source.id, - removeOnclick: `deleteHASource('${source.id}')`, - removeTitle: t('common.delete'), - content: ` -
    -
    ${ICON_HA} ${statusDot} ${escapeHtml(source.name)}
    -
    -
    - - ${P.wifi} ${escapeHtml(source.host)} - - ${source.connected ? ` - ${P.listChecks} ${source.entity_count} entities - ` : ''} - ${source.use_ssl ? ` - ${P.lock} SSL - ` : ''} -
    - ${renderTagChips(source.tags)} - ${source.description ? `
    ${escapeHtml(source.description)}
    ` : ''}`, - actions: ` - - - `, - }); + const chips: ModChipOpts[] = [ + { icon: `${P.wifi}`, text: source.host, title: source.host }, + ]; + if (isConnected) { + chips.push({ icon: `${P.listChecks}`, text: `${source.entity_count} entities` }); + } + if (source.use_ssl) { + chips.push({ icon: `${P.lock}`, text: 'SSL' }); + } + + const mod: ModCardOpts = { + head: { + badge: { text: 'HA · BRIDGE' }, + name: source.name, + metaHtml: escapeHtml(`${source.host}${isConnected ? ` · ${source.entity_count} entities` : ''}`), + leds, + menu: { + duplicateOnclick: `cloneHASource('${source.id}')`, + hideOnclick: `toggleCardHidden('ha-sources','${source.id}')`, + deleteOnclick: `deleteHASource('${source.id}')`, + }, + }, + body: { + desc: source.description || undefined, + chips, + }, + foot: { + patchState: isConnected ? 'live' : 'offline', + patchLabel: isConnected ? 'CONNECTED' : 'OFFLINE', + iconActions: [ + { icon: ICON_REFRESH, onclick: '', title: healthTitle, dataAttrs: { 'data-action': 'test' } }, + { icon: ICON_EDIT, onclick: '', title: t('common.edit'), dataAttrs: { 'data-action': 'edit' } }, + ], + }, + running: isConnected, + }; + const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-id', id: source.id, mod }); + const tagsHtml = renderTagChips(source.tags); + return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}`) : cardHtml; } // ── Event delegation ── diff --git a/server/src/ledgrab/static/js/features/mqtt-sources.ts b/server/src/ledgrab/static/js/features/mqtt-sources.ts index 28eae6f..fbdcab5 100644 --- a/server/src/ledgrab/static/js/features/mqtt-sources.ts +++ b/server/src/ledgrab/static/js/features/mqtt-sources.ts @@ -10,6 +10,7 @@ import { showToast, showConfirm } from '../core/ui.ts'; import { ICON_CLONE, ICON_EDIT, ICON_TEST } from '../core/icons.ts'; import * as P from '../core/icon-paths.ts'; import { wrapCard } from '../core/card-colors.ts'; +import type { ModCardOpts, ModChipOpts, LedState } from '../core/mod-card.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts'; import type { MQTTSource } from '../types.ts'; @@ -234,41 +235,44 @@ async function _testMQTTSourceFromCard(sourceId: string): Promise { // ── Card rendering ── export function createMQTTSourceCard(source: MQTTSource) { - let healthClass: string, healthTitle: string; - if (source.connected) { - healthClass = 'health-online'; - healthTitle = t('mqtt_source.connected'); - } else { - healthClass = 'health-offline'; - healthTitle = t('mqtt_source.disconnected'); - } - const statusDot = ``; + const isConnected = !!source.connected; + const leds: LedState[] = isConnected ? ['on', 'blink'] : ['fault']; + const broker = `${source.broker_host}:${source.broker_port}`; - return wrapCard({ - type: 'template-card', - dataAttr: 'data-id', - id: source.id, - removeOnclick: `deleteMQTTSource('${source.id}')`, - removeTitle: t('common.delete'), - content: ` -
    -
    ${ICON_MQTT} ${statusDot} ${escapeHtml(source.name)}
    -
    -
    - - ${P.wifi} ${escapeHtml(source.broker_host)}:${source.broker_port} - - - ${P.hash} ${escapeHtml(source.base_topic)} - -
    - ${renderTagChips(source.tags)} - ${source.description ? `
    ${escapeHtml(source.description)}
    ` : ''}`, - actions: ` - - - `, - }); + const chips: ModChipOpts[] = [ + { icon: `${P.wifi}`, text: broker, title: broker }, + { icon: `${P.hash}`, text: source.base_topic, title: source.base_topic }, + ]; + + const mod: ModCardOpts = { + head: { + badge: { text: 'MQTT · BROKER' }, + name: source.name, + metaHtml: escapeHtml(`${broker} · ${source.base_topic}`), + leds, + menu: { + duplicateOnclick: `cloneMQTTSource('${source.id}')`, + hideOnclick: `toggleCardHidden('mqtt-sources','${source.id}')`, + deleteOnclick: `deleteMQTTSource('${source.id}')`, + }, + }, + body: { + desc: source.description || undefined, + chips, + }, + foot: { + patchState: isConnected ? 'live' : 'offline', + patchLabel: isConnected ? 'SUBSCRIBED' : 'OFFLINE', + iconActions: [ + { icon: ICON_TEST, onclick: '', title: t('mqtt_source.test'), dataAttrs: { 'data-action': 'test' } }, + { icon: ICON_EDIT, onclick: '', title: t('common.edit'), dataAttrs: { 'data-action': 'edit' } }, + ], + }, + running: isConnected, + }; + const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-id', id: source.id, mod }); + const tagsHtml = renderTagChips(source.tags); + return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}`) : cardHtml; } // ── Event delegation ── diff --git a/server/src/ledgrab/static/js/features/perf-charts.ts b/server/src/ledgrab/static/js/features/perf-charts.ts index 572672c..2aa81b0 100644 --- a/server/src/ledgrab/static/js/features/perf-charts.ts +++ b/server/src/ledgrab/static/js/features/perf-charts.ts @@ -16,17 +16,17 @@ import { createColorPicker, registerColorPicker } from '../core/color-picker.ts' import { getOrderedPerfCells, isPerfCellVisible, getGlobalConfig, saveDashboardLayout, getDashboardLayout, setGlobalPerfMode, effectivePerfWindow } from './dashboard-layout.ts'; const MAX_SAMPLES = 120; -const CHART_KEYS = ['cpu', 'ram', 'gpu', 'temp', 'fps', 'capture_fps', 'errors'] as const; +const CHART_KEYS = ['cpu', 'ram', 'gpu', 'temp', 'fps', 'capture_fps', 'capture_fps_actual', 'errors', 'network', 'device_latency', 'send_timing'] as const; /** Every cell key the user can color-customize, including the * patches / devices cells that don't have sparklines but still * carry a header accent stripe. */ -const ALL_COLORABLE_KEYS = ['patches', 'fps', 'capture_fps', 'errors', 'devices', 'cpu', 'ram', 'gpu', 'temp'] as const; +const ALL_COLORABLE_KEYS = ['patches', 'fps', 'capture_fps', 'capture_fps_actual', 'errors', 'devices', 'cpu', 'ram', 'gpu', 'temp', 'network', 'device_latency', 'send_timing'] as const; const PERF_MODE_KEY = 'perfMetricsMode'; const SPARK_W = 600; // SVG viewBox width (scales with preserveAspectRatio) const SPARK_H = 64; /** Metrics that don't have a per-process variant (host-only). */ -const HOST_ONLY_KEYS = new Set(['temp', 'fps', 'capture_fps', 'errors']); +const HOST_ONLY_KEYS = new Set(['temp', 'fps', 'capture_fps', 'capture_fps_actual', 'errors', 'network', 'device_latency', 'send_timing']); /** Default accent per metric — maps to channel palette via CSS vars so the perf cards share the same language as the rest of the app. Overrides @@ -35,12 +35,16 @@ const METRIC_CSS_VARS: Record = { patches: '--ch-magenta', fps: '--ch-cyan', capture_fps: '--ch-signal', + capture_fps_actual: '--ch-cyan', errors: '--ch-coral', devices: '--ch-signal', cpu: '--ch-coral', ram: '--ch-violet', gpu: '--ch-signal', temp: '--ch-amber', + network: '--ch-violet', + device_latency: '--ch-amber', + send_timing: '--ch-magenta', }; /** Fallback hex used only if CSS-var resolution fails (e.g. detached node). */ @@ -48,19 +52,23 @@ const METRIC_FALLBACK: Record = { patches: '#EC4899', fps: '#00D8FF', capture_fps: '#22D3EE', + capture_fps_actual: '#00D8FF', errors: '#FF6B6B', devices: '#10B981', cpu: '#FF6B6B', ram: '#A855F7', gpu: '#10B981', temp: '#FCD34D', + network: '#A855F7', + device_latency: '#FCD34D', + send_timing: '#EC4899', }; type PerfMode = 'system' | 'app' | 'both'; let _pollTimer: ReturnType | null = null; -let _history: Record = { cpu: [], ram: [], gpu: [], temp: [], fps: [], capture_fps: [], errors: [] }; -let _appHistory: Record = { cpu: [], ram: [], gpu: [], temp: [], fps: [], capture_fps: [], errors: [] }; +let _history: Record = { cpu: [], ram: [], gpu: [], temp: [], fps: [], capture_fps: [], capture_fps_actual: [], errors: [], network: [], device_latency: [], send_timing: [] }; +let _appHistory: Record = { cpu: [], ram: [], gpu: [], temp: [], fps: [], capture_fps: [], capture_fps_actual: [], errors: [], network: [], device_latency: [], send_timing: [] }; /** Peak errors-per-second observed during the session — y-axis ceiling * for the errors sparkline so a single spike doesn't flatten the rest * of the line. */ @@ -77,6 +85,19 @@ let _prevSkippedTotal: number | null = null; let _fpsPeak = 60; /** Same role as `_fpsPeak`, but for the capture-side sparkline. */ let _captureFpsPeak = 60; +/** Same role as `_captureFpsPeak`, but for the *measured* capture + * rate ("Total Capture FPS"). Tracked independently so a slow + * external capture doesn't get its scale clamped to whatever the + * source-side spark already touched. */ +let _captureFpsActualPeak = 60; +/** Y-axis ceiling tracker for the network-throughput sparkline. + * Bytes/sec — scales freely. Initial floor of 1 KB/s keeps the + * spark from collapsing onto a single pixel during idle periods. */ +let _networkPeak = 1024; +/** Y-axis ceilings for the latency / send-timing sparks (ms). + * Floors chosen so common WiFi-good values don't pin the line. */ +let _deviceLatencyPeak = 50; +let _sendTimingPeak = 20; /** Sum of fps_target across running targets — rendered as a dashed * reference line on the FPS spark ("max achievable throughput"). */ let _fpsTargetSum = 0; @@ -92,7 +113,11 @@ let _lastFetchData: any = null; let _lastPatchesArgs: { running: { id: string; name: string; fps?: number }[]; totalCount: number } | null = null; let _lastTotalFpsArgs: { totalFps: number; minFps: number | null; maxFps: number | null; targetSum: number } | null = null; let _lastTotalCaptureFpsArgs: { totalFps: number; minFps: number | null; maxFps: number | null } | null = null; +let _lastTotalCaptureFpsActualArgs: { totalFps: number; targetSum: number; reportingCount: number } | null = null; let _lastErrorsArgs: { totalErrors: number; totalSkipped: number; pollMs: number } | null = null; +let _lastNetworkArgs: { bytesPerSec: number; totalBytes: number } | null = null; +let _lastDeviceLatencyArgs: { avgMs: number | null; maxMs: number | null; onlineCount: number; totalCount: number } | null = null; +let _lastSendTimingArgs: { avgMs: number; maxMs: number; reportingCount: number } | null = null; let _lastDevicesArgs: { device_id: string; device_online: boolean; device_name?: string; device_latency_ms?: number | null }[] | null = null; /** Mirrors `layout.global.perfMode`. Kept as a module-local for legacy * callers that read it directly; sync'd from the layout on every read @@ -166,28 +191,24 @@ export function setPerfMode(mode: PerfMode): void { _fetchPerformance(); } -/** Returns the static HTML for the perf section. */ -export function renderPerfSection(): string { - _syncMode(); - for (const key of ALL_COLORABLE_KEYS) { - registerColorPicker(`perf-${key}`, hex => _onChartColorChange(key, hex)); - } - - /** Color-picker widget rendered next to each cell's label. Even - * cells without sparklines (patches/devices) get one — it drives - * the card's `--perf-accent` CSS var for the header stripe. */ - const colorWidget = (key: string) => createColorPicker({ +/** Color-picker widget rendered next to each cell's label. Even + * cells without sparklines (patches/devices) get one — it drives + * the card's `--perf-accent` CSS var for the header stripe. */ +function _colorWidget(key: string): string { + return createColorPicker({ id: `perf-${key}`, currentColor: _getColor(key), onPick: undefined, anchor: 'left', showReset: true, }); +} - const sparkCard = (key: string, labelKey: string, hiddenByEnv: boolean) => ` +function _sparkCardHtml(key: string, labelKey: string, hiddenByEnv: boolean): string { + return `
    - ${t(labelKey)} ${colorWidget(key)} + ${t(labelKey)} ${_colorWidget(key)}
    @@ -198,11 +219,19 @@ export function renderPerfSection(): string {
    `; +} - const patchesCell = ` +/** Build HTML for a single perf cell. Returns null for unknown keys. + * Cells with env-gated visibility (gpu, temp) start hidden and reveal + * themselves when the server reports a real reading; user can also + * force them hidden via Customize. */ +function _renderCellHtml(key: string): string | null { + switch (key) { + case 'patches': + return `
    - ${t('dashboard.perf.active_patches') || 'Active Patches'} ${colorWidget('patches')} + ${t('dashboard.perf.active_patches') || 'Active Patches'} ${_colorWidget('patches')}
    @@ -213,11 +242,11 @@ export function renderPerfSection(): string {
    `; - - const fpsCell = ` + case 'fps': + return `
    - ${t('dashboard.perf.total_fps') || 'Total FPS'} ${colorWidget('fps')} + ${t('dashboard.perf.total_fps') || 'Total FPS'} ${_colorWidget('fps')}
    @@ -227,11 +256,11 @@ export function renderPerfSection(): string {
    `; - - const captureFpsCell = ` + case 'capture_fps': + return `
    - ${t('dashboard.perf.total_capture_fps') || 'Total Capture FPS'} ${colorWidget('capture_fps')} + ${t('dashboard.perf.total_capture_fps') || 'Total Source FPS'} ${_colorWidget('capture_fps')}
    @@ -241,11 +270,25 @@ export function renderPerfSection(): string {
    `; - - const errorsCell = ` + case 'capture_fps_actual': + return ` +
    +
    + ${t('dashboard.perf.total_capture_fps_actual') || 'Total Capture FPS'} ${_colorWidget('capture_fps_actual')} +
    +
    +
    + + +
    +
    +
    +
    `; + case 'errors': + return `
    - ${t('dashboard.perf.errors') || 'Errors'} ${colorWidget('errors')} + ${t('dashboard.perf.errors') || 'Errors'} ${_colorWidget('errors')}
    @@ -255,11 +298,53 @@ export function renderPerfSection(): string {
    `; - - const devicesCell = ` + case 'network': + return ` +
    +
    + ${t('dashboard.perf.network') || 'Network'} ${_colorWidget('network')} +
    +
    +
    + + +
    +
    +
    +
    `; + case 'device_latency': + return ` +
    +
    + ${t('dashboard.perf.device_latency') || 'Device Latency'} ${_colorWidget('device_latency')} +
    +
    +
    + + +
    +
    +
    +
    `; + case 'send_timing': + return ` +
    +
    + ${t('dashboard.perf.send_timing') || 'Send Timing'} ${_colorWidget('send_timing')} +
    +
    +
    + + +
    +
    +
    +
    `; + case 'devices': + return `
    - ${t('dashboard.perf.devices') || 'Devices'} ${colorWidget('devices')} + ${t('dashboard.perf.devices') || 'Devices'} ${_colorWidget('devices')}
    @@ -271,28 +356,42 @@ export function renderPerfSection(): string {
    `; + case 'cpu': return _sparkCardHtml('cpu', 'dashboard.perf.cpu', false); + case 'ram': return _sparkCardHtml('ram', 'dashboard.perf.ram', false); + case 'gpu': return _sparkCardHtml('gpu', 'dashboard.perf.gpu', false); + case 'temp': return _sparkCardHtml('temp', 'dashboard.perf.temp', true); + default: return null; + } +} - // Cell registry — what each layout key actually renders. Cells with - // env-gated visibility (gpu, temp) start hidden and reveal themselves - // when the server reports a real reading; user can also force them - // hidden via Customize. - const cellRenderers: Record string> = { - patches: () => patchesCell, - fps: () => fpsCell, - capture_fps: () => captureFpsCell, - errors: () => errorsCell, - devices: () => devicesCell, - cpu: () => sparkCard('cpu', 'dashboard.perf.cpu', false), - ram: () => sparkCard('ram', 'dashboard.perf.ram', false), - gpu: () => sparkCard('gpu', 'dashboard.perf.gpu', false), - temp: () => sparkCard('temp', 'dashboard.perf.temp', true), - }; +/** Re-register color-picker callbacks for every colorable cell. Idempotent + * — overwrites the previous handler keyed by id. Called whenever the + * perf section is (re)initialised so newly-created cells get wired up. */ +function _registerPerfColorPickers(): void { + for (const key of ALL_COLORABLE_KEYS) { + registerColorPicker(`perf-${key}`, hex => _onChartColorChange(key, hex)); + } +} + +/** Build a fresh `.perf-chart-card` element from a key. */ +function _buildCellElement(key: string): HTMLElement | null { + const html = _renderCellHtml(key); + if (!html) return null; + const tmp = document.createElement('div'); + tmp.innerHTML = html.trim(); + return tmp.firstElementChild as HTMLElement | null; +} + +/** Returns the static HTML for the perf section. */ +export function renderPerfSection(): string { + _syncMode(); + _registerPerfColorPickers(); let cellsHtml = ''; for (const cell of getOrderedPerfCells()) { if (!cell.visible) continue; - const render = cellRenderers[cell.key]; - if (render) cellsHtml += render(); + const html = _renderCellHtml(cell.key); + if (html) cellsHtml += html; } return `
    ${cellsHtml}
    `; @@ -382,12 +481,12 @@ export function updateTotalFps( _renderChartSvg('fps', /*animate=*/true); } -/** Total Capture FPS cell — pushed a new sample each dashboard refresh - * cycle. `totalFps` is the sum of `fps_capture` (configured capture-side - * rate) across running targets; `minFps` / `maxFps` are the live - * extremes shown as a subdued subtitle. Mirrors `updateTotalFps` but - * for the capture side, so multi-stream setups can see how much capture - * work is being scheduled. */ +/** Total Source FPS cell — sum of every running target's upstream + * color-strip-source `target_fps` (picture/audio/gradient/effect/...). + * This is the *requested* tick rate of the pipeline feeding LEDs, not + * the measured throughput of any external capture — a static-color + * stream still ticks at its idle rate and contributes here. The + * internal key stays `capture_fps` for layout-storage compatibility. */ export function updateTotalCaptureFps( totalFps: number, minFps: number | null, @@ -415,6 +514,184 @@ export function updateTotalCaptureFps( _renderChartSvg('capture_fps', /*animate=*/true); } +/** Total Capture FPS cell — sum of *measured* new-frame rates from + * capture-backed streams (screen capture today; audio/api-input + * follow-up). Diverges from Total Source FPS when the upstream + * capture stalls — Source FPS reads "what was requested," Capture FPS + * reads "what actually arrived." `reportingCount` is how many running + * targets have a measured rate (i.e. are capture-backed); used as the + * subtitle so a synthetic-only setup reads "no captures" instead of + * silently sitting at 0. */ +export function updateTotalCaptureFpsActual( + totalFps: number, + targetSum: number, + reportingCount: number, +): void { + _lastTotalCaptureFpsActualArgs = { totalFps, targetSum, reportingCount }; + const fps = Math.max(0, totalFps); + _history.capture_fps_actual.push(fps); + if (_history.capture_fps_actual.length > MAX_SAMPLES) _history.capture_fps_actual.shift(); + if (fps > _captureFpsActualPeak) _captureFpsActualPeak = fps; + + const valEl = document.getElementById('perf-capture_fps_actual-value'); + if (valEl) { + if (reportingCount === 0) { + valEl.innerHTML = 'no captures'; + } else { + const fpsText = fps.toFixed(fps < 10 ? 1 : 0); + const ceilingSuffix = targetSum > 0 + ? `/ ${Math.round(targetSum)}` + : ''; + valEl.innerHTML = `${fpsText}${ceilingSuffix}fps`; + } + } + const subEl = document.getElementById('perf-capture_fps_actual-sub'); + if (subEl) { + if (reportingCount === 0) { + subEl.textContent = ''; + } else if (targetSum > 0) { + // Drop ratio reads "how far behind requested" — useful at-a-glance + // diagnostic for capture saturation. + const ratio = Math.max(0, Math.min(1, fps / targetSum)); + subEl.textContent = `${Math.round(ratio * 100)}% of requested · ${reportingCount} capture${reportingCount > 1 ? 's' : ''}`; + } else { + subEl.textContent = `${reportingCount} capture${reportingCount > 1 ? 's' : ''}`; + } + } + _renderChartSvg('capture_fps_actual', /*animate=*/true); +} + +/** Network throughput cell — bytes/sec the LED transport is moving + * across all running targets, plus a cumulative "X total" subtitle. + * Pairs with the Errors cell to triage where a slowdown lives: + * pipeline-side errors with low throughput → CPU/GPU bottleneck, + * pipeline running clean with throughput pinned → WiFi/wired + * saturated. The bytes counter approximates LED-payload only + * (protocol overhead is sub-5 % for any real LED count). */ +export function updateNetworkThroughput( + bytesPerSec: number, + totalBytes: number, +): void { + _lastNetworkArgs = { bytesPerSec, totalBytes }; + const bps = Math.max(0, bytesPerSec); + _history.network.push(bps); + if (_history.network.length > MAX_SAMPLES) _history.network.shift(); + if (bps > _networkPeak) _networkPeak = bps; + _paintNetworkValue(bps, totalBytes); + _renderChartSvg('network', /*animate=*/true); +} + +/** Device-latency cell — average ping latency across *online* + * devices, with the worst-offender max as a subtitle. A leading + * indicator of WiFi degradation that fires before frames start + * dropping; pairs with the Devices cell to pinpoint which device + * is misbehaving. */ +export function updateDeviceLatency( + avgMs: number | null, + maxMs: number | null, + onlineCount: number, + totalCount: number, +): void { + _lastDeviceLatencyArgs = { avgMs, maxMs, onlineCount, totalCount }; + const sample = avgMs != null && Number.isFinite(avgMs) ? Math.max(0, avgMs) : 0; + _history.device_latency.push(sample); + if (_history.device_latency.length > MAX_SAMPLES) _history.device_latency.shift(); + if (sample > _deviceLatencyPeak) _deviceLatencyPeak = sample; + _paintDeviceLatencyValue(avgMs, maxMs, onlineCount, totalCount); + _renderChartSvg('device_latency', /*animate=*/true); +} + +/** Send-timing cell — average and max time spent inside the LED + * client's send call across running targets. Climbs as a + * pre-failure signal when the network gets congested, several + * seconds before the Errors cell starts showing skipped frames. */ +export function updateSendTiming( + avgMs: number, + maxMs: number, + reportingCount: number, +): void { + _lastSendTimingArgs = { avgMs, maxMs, reportingCount }; + const sample = Math.max(0, avgMs); + _history.send_timing.push(sample); + if (_history.send_timing.length > MAX_SAMPLES) _history.send_timing.shift(); + const peakSample = Math.max(sample, Math.max(0, maxMs)); + if (peakSample > _sendTimingPeak) _sendTimingPeak = peakSample; + _paintSendTimingValue(avgMs, maxMs, reportingCount); + _renderChartSvg('send_timing', /*animate=*/true); +} + +function _formatBytesPerSec(bps: number): { value: string; unit: string } { + if (bps >= 1024 * 1024) return { value: (bps / 1024 / 1024).toFixed(1), unit: 'MB/s' }; + if (bps >= 1024) return { value: (bps / 1024).toFixed(1), unit: 'KB/s' }; + return { value: bps.toFixed(0), unit: 'B/s' }; +} + +function _formatBytes(b: number): string { + if (b >= 1024 * 1024 * 1024) return `${(b / 1024 / 1024 / 1024).toFixed(2)} GB`; + if (b >= 1024 * 1024) return `${(b / 1024 / 1024).toFixed(1)} MB`; + if (b >= 1024) return `${(b / 1024).toFixed(1)} KB`; + return `${b} B`; +} + +function _paintNetworkValue(bytesPerSec: number, totalBytes: number): void { + const valEl = document.getElementById('perf-network-value'); + if (valEl) { + const { value, unit } = _formatBytesPerSec(bytesPerSec); + valEl.innerHTML = `${value}${unit}`; + } + const subEl = document.getElementById('perf-network-sub'); + if (subEl) { + subEl.textContent = totalBytes > 0 ? `${_formatBytes(totalBytes)} total` : ''; + } +} + +function _paintDeviceLatencyValue( + avgMs: number | null, + maxMs: number | null, + onlineCount: number, + totalCount: number, +): void { + const valEl = document.getElementById('perf-device_latency-value'); + if (valEl) { + if (totalCount === 0) { + valEl.innerHTML = 'no devices'; + } else if (avgMs == null) { + valEl.innerHTML = 'offline'; + } else { + const txt = avgMs < 10 ? avgMs.toFixed(1) : avgMs.toFixed(0); + valEl.innerHTML = `${txt}ms`; + } + } + const subEl = document.getElementById('perf-device_latency-sub'); + if (subEl) { + const parts: string[] = []; + if (totalCount > 0) parts.push(`${onlineCount}/${totalCount} online`); + if (maxMs != null) parts.push(`max ${maxMs < 10 ? maxMs.toFixed(1) : maxMs.toFixed(0)}ms`); + subEl.textContent = parts.join(' · '); + } +} + +function _paintSendTimingValue(avgMs: number, maxMs: number, reportingCount: number): void { + const valEl = document.getElementById('perf-send_timing-value'); + if (valEl) { + if (reportingCount === 0) { + valEl.innerHTML = 'idle'; + } else { + const txt = avgMs < 10 ? avgMs.toFixed(1) : avgMs.toFixed(0); + valEl.innerHTML = `${txt}ms`; + } + } + const subEl = document.getElementById('perf-send_timing-sub'); + if (subEl) { + if (reportingCount === 0) { + subEl.textContent = ''; + } else { + const maxTxt = maxMs < 10 ? maxMs.toFixed(1) : maxMs.toFixed(0); + subEl.textContent = `max ${maxTxt}ms · ${reportingCount} target${reportingCount > 1 ? 's' : ''}`; + } + } +} + /** Errors cell — converts the cumulative `errors_count` and * `frames_skipped` totals (summed across running targets) into rates by * taking per-poll deltas. The card stays at "0" / muted accent when @@ -601,7 +878,11 @@ function _renderChartSvg(key: string, animate: boolean = false): void { const yMax = key === 'temp' ? 100 : key === 'fps' ? Math.max(60, _fpsPeak * 1.1, _fpsTargetSum * 1.1) : key === 'capture_fps' ? Math.max(60, _captureFpsPeak * 1.1) + : key === 'capture_fps_actual' ? Math.max(60, _captureFpsActualPeak * 1.1, _fpsTargetSum * 1.1) : key === 'errors' ? Math.max(5, _errorsPeak * 1.2) + : key === 'network' ? Math.max(1024, _networkPeak * 1.1) + : key === 'device_latency' ? Math.max(50, _deviceLatencyPeak * 1.2) + : key === 'send_timing' ? Math.max(20, _sendTimingPeak * 1.2) : 100; const paths: string[] = []; @@ -884,6 +1165,12 @@ async function _seedFromServer(): Promise { // the full /system/performance payload that does include totals. _appHistory.gpu = []; + // FPS / Capture FPS / Errors aggregates — populated by the + // server-side ring buffer so these sparks survive page reloads + // (mirrors how CPU / RAM already work). Older payloads without + // these fields produce empty arrays; live polling fills them in. + _seedAggregateHistories(samples); + if (_history.gpu.length > 0) { _hasGpu = true; } else if (samples.length > 0) { @@ -903,6 +1190,209 @@ async function _seedFromServer(): Promise { } } +/** Reconstruct the FPS / Capture FPS / Errors history + value labels + * from the server's ring buffer. Each system snapshot since v1.x + * carries the running-target aggregate so we can rehydrate without + * waiting for the dashboard's batch fetch (which is what populated + * these previously and lost everything on every reload). */ +function _seedAggregateHistories(samples: any[]): void { + if (samples.length === 0) return; + + const fpsSeries = samples + .map((s: any) => s.total_fps) + .filter((v: any) => typeof v === 'number' && Number.isFinite(v)); + if (fpsSeries.length > 0) { + _history.fps = fpsSeries.slice(-MAX_SAMPLES); + _fpsPeak = Math.max(60, ..._history.fps); + } + + const captureSeries = samples + .map((s: any) => s.total_capture_fps) + .filter((v: any) => typeof v === 'number' && Number.isFinite(v)); + if (captureSeries.length > 0) { + _history.capture_fps = captureSeries.slice(-MAX_SAMPLES); + _captureFpsPeak = Math.max(60, ..._history.capture_fps); + } + + const captureActualSeries = samples + .map((s: any) => s.total_capture_fps_actual) + .filter((v: any) => typeof v === 'number' && Number.isFinite(v)); + if (captureActualSeries.length > 0) { + _history.capture_fps_actual = captureActualSeries.slice(-MAX_SAMPLES); + _captureFpsActualPeak = Math.max(60, ..._history.capture_fps_actual); + } + + // Errors history is a per-second rate. The server already does + // the delta math against its own running totals, so we just + // pull `errors_per_sec` straight through. Set the cumulative + // baseline so the dashboard's next live update doesn't synthesize + // a phantom spike from a stale "0 → live count" comparison. + const errorsSeries = samples + .map((s: any) => s.errors_per_sec) + .filter((v: any) => typeof v === 'number' && Number.isFinite(v)); + if (errorsSeries.length > 0) { + _history.errors = errorsSeries.slice(-MAX_SAMPLES); + _errorsPeak = Math.max(1, ..._history.errors); + } + const lastSample = samples[samples.length - 1]; + if (typeof lastSample?.total_errors_count === 'number') { + _prevErrorsTotal = lastSample.total_errors_count; + } + if (typeof lastSample?.total_frames_skipped === 'number') { + _prevSkippedTotal = lastSample.total_frames_skipped; + } + + // Latest target-sum is shown as a dashed reference line on the + // FPS spark — pin it so the chart doesn't redraw without the + // ceiling line for the brief window before dashboard.ts polls. + if (typeof lastSample?.total_fps_target === 'number' && lastSample.total_fps_target > 0) { + _fpsTargetSum = lastSample.total_fps_target; + } + + // Paint value labels from the latest sample so the cards don't + // sit on "—" / "0" until the next dashboard poll. Mirrors what + // `_applyPerfDataToDom` does for CPU/RAM/GPU on the same load. + if (typeof lastSample?.total_fps === 'number') { + _paintFpsValue(lastSample.total_fps); + } + if (typeof lastSample?.total_capture_fps === 'number') { + _paintCaptureFpsValue(lastSample.total_capture_fps); + } + if (typeof lastSample?.total_capture_fps_actual === 'number') { + _paintCaptureFpsActualValue( + lastSample.total_capture_fps_actual, + typeof lastSample.total_fps_target === 'number' ? lastSample.total_fps_target : 0, + typeof lastSample.capture_actual_count === 'number' ? lastSample.capture_actual_count : 0, + ); + } + + // Network throughput history — direct passthrough from the + // server's per-second rate (computed from cumulative byte counter + // deltas, same shape as `errors_per_sec`). + const networkSeries = samples + .map((s: any) => s.bytes_per_sec) + .filter((v: any) => typeof v === 'number' && Number.isFinite(v)); + if (networkSeries.length > 0) { + _history.network = networkSeries.slice(-MAX_SAMPLES); + _networkPeak = Math.max(1024, ..._history.network); + } + if (typeof lastSample?.bytes_per_sec === 'number') { + _paintNetworkValue( + lastSample.bytes_per_sec, + typeof lastSample.total_bytes_sent === 'number' ? lastSample.total_bytes_sent : 0, + ); + } + + // Device latency history — sparkline plots the avg-across-online + // series. `null` samples (no devices online) become 0 in the + // history so the spark drops to floor instead of going jagged. + const latencySeries = samples + .map((s: any) => (typeof s.device_latency_avg_ms === 'number' && Number.isFinite(s.device_latency_avg_ms)) ? s.device_latency_avg_ms : 0) + if (latencySeries.length > 0) { + _history.device_latency = latencySeries.slice(-MAX_SAMPLES); + _deviceLatencyPeak = Math.max(50, ..._history.device_latency); + } + if (lastSample) { + _paintDeviceLatencyValue( + typeof lastSample.device_latency_avg_ms === 'number' ? lastSample.device_latency_avg_ms : null, + typeof lastSample.device_latency_max_ms === 'number' ? lastSample.device_latency_max_ms : null, + typeof lastSample.device_online_count === 'number' ? lastSample.device_online_count : 0, + typeof lastSample.device_total_count === 'number' ? lastSample.device_total_count : 0, + ); + } + + // Send-timing history — plots the avg series; max travels in + // the subtitle/tooltip but isn't a separate spark line to avoid + // adding visual noise. + const sendSeries = samples + .map((s: any) => (typeof s.send_timing_avg_ms === 'number' && Number.isFinite(s.send_timing_avg_ms)) ? s.send_timing_avg_ms : 0) + if (sendSeries.length > 0) { + _history.send_timing = sendSeries.slice(-MAX_SAMPLES); + const maxes = samples + .map((s: any) => (typeof s.send_timing_max_ms === 'number' && Number.isFinite(s.send_timing_max_ms)) ? s.send_timing_max_ms : 0); + _sendTimingPeak = Math.max(20, ..._history.send_timing, ...maxes); + } + if (lastSample) { + _paintSendTimingValue( + typeof lastSample.send_timing_avg_ms === 'number' ? lastSample.send_timing_avg_ms : 0, + typeof lastSample.send_timing_max_ms === 'number' ? lastSample.send_timing_max_ms : 0, + typeof lastSample.send_timing_count === 'number' ? lastSample.send_timing_count : 0, + ); + } + if (typeof lastSample?.errors_per_sec === 'number') { + _paintErrorsValue( + lastSample.errors_per_sec, + lastSample.total_errors_count ?? 0, + lastSample.skipped_per_sec ?? 0, + ); + } +} + +function _paintFpsValue(fps: number): void { + const valEl = document.getElementById('perf-fps-value'); + if (!valEl) return; + const fpsText = fps.toFixed(fps < 10 ? 1 : 0); + const ceilingSuffix = _fpsTargetSum > 0 + ? `/ ${Math.round(_fpsTargetSum)}` + : ''; + valEl.innerHTML = `${fpsText}${ceilingSuffix}fps`; +} + +function _paintCaptureFpsValue(fps: number): void { + const valEl = document.getElementById('perf-capture_fps-value'); + if (!valEl) return; + const fpsText = fps.toFixed(fps < 10 ? 1 : 0); + valEl.innerHTML = `${fpsText}fps`; +} + +function _paintCaptureFpsActualValue(fps: number, targetSum: number, reportingCount: number): void { + const valEl = document.getElementById('perf-capture_fps_actual-value'); + if (valEl) { + if (reportingCount === 0) { + valEl.innerHTML = 'no captures'; + } else { + const fpsText = fps.toFixed(fps < 10 ? 1 : 0); + const ceilingSuffix = targetSum > 0 + ? `/ ${Math.round(targetSum)}` + : ''; + valEl.innerHTML = `${fpsText}${ceilingSuffix}fps`; + } + } + const subEl = document.getElementById('perf-capture_fps_actual-sub'); + if (subEl) { + if (reportingCount === 0) { + subEl.textContent = ''; + } else if (targetSum > 0) { + const ratio = Math.max(0, Math.min(1, fps / targetSum)); + subEl.textContent = `${Math.round(ratio * 100)}% of requested · ${reportingCount} capture${reportingCount > 1 ? 's' : ''}`; + } else { + subEl.textContent = `${reportingCount} capture${reportingCount > 1 ? 's' : ''}`; + } + } +} + +function _paintErrorsValue(errorsRate: number, totalErrors: number, skippedRate: number): void { + const card = document.querySelector('.perf-errors-cell') as HTMLElement | null; + if (card) card.classList.toggle('has-errors', totalErrors > 0 || errorsRate > 0); + + const valEl = document.getElementById('perf-errors-value'); + if (valEl) { + const rateText = errorsRate >= 10 + ? errorsRate.toFixed(0) + : errorsRate >= 1 + ? errorsRate.toFixed(1) + : '0'; + valEl.innerHTML = `${rateText}/s`; + } + const subEl = document.getElementById('perf-errors-sub'); + if (subEl) { + const parts: string[] = []; + if (totalErrors > 0) parts.push(`${totalErrors} total`); + if (skippedRate >= 0.1) parts.push(`${skippedRate.toFixed(skippedRate < 10 ? 1 : 0)} skipped/s`); + subEl.textContent = parts.join(' · '); + } +} + /** Initialize perf section — paint from server-side history and wire up * spark hover tooltips. Also fires one immediate `_fetchPerformance` so * the value labels (CPU %, RAM GB, GPU °C, etc.) populate on page load @@ -924,52 +1414,110 @@ export async function initPerfCharts(): Promise { /** Re-render the perf grid in place after a layout change. * - * Replaces just the `.perf-charts-grid` element (cell count / order / - * mode / window / yScale all read from the layout via `renderPerfSection`), - * then replays the cached state into the new DOM: - * - sparkline SVGs from the persistent `_history` arrays - * - cpu/ram/gpu/temp value labels from `_lastFetchData` - * - patches/total-fps/devices cells from cached external setter args + * Reconciles the existing `.perf-charts-grid` against the desired cell + * set + order from the layout. Cells that already exist are kept in + * place (or moved to a new index) — their DOM, listeners, color picker + * state, hidden-by-env state, and label values all survive intact. Only + * cells that were not visible before are freshly created; cells that + * disappeared from the layout are removed. * - * This avoids the full-dashboard innerHTML wipe that previously caused a - * frame of layout flicker plus a window where every cell showed "0" / - * "—" until the next dashboard fetch landed. */ + * Replay of cached state targets only newly-created cells, so unchanged + * cells don't get a phantom-scroll animation from a fresh + * `update*FPS/Errors/Devices` call. Without this, every layout tweak + * (mode toggle, window change, color reset) would visibly reset every + * sparkline. */ export function rerenderPerfGrid(): void { const wrapper = document.querySelector('.dashboard-perf-persistent'); if (!wrapper) return; - const oldGrid = wrapper.querySelector('.perf-charts-grid'); - if (!oldGrid) return; + const grid = wrapper.querySelector('.perf-charts-grid'); + if (!grid) return; - // `renderPerfSection()` returns the entire `.perf-charts-grid` div. - const tmp = document.createElement('div'); - tmp.innerHTML = renderPerfSection(); - const newGrid = tmp.firstElementChild; - if (!newGrid) return; - oldGrid.replaceWith(newGrid); + _syncMode(); + _registerPerfColorPickers(); - // Sparks: paint from existing module-level history (no flash). + // Index existing cells by metric key so we can keep / move them + // instead of rebuilding their DOM. + const existing = new Map(); + grid.querySelectorAll(':scope > .perf-chart-card[data-metric]').forEach(el => { + const k = el.dataset.metric; + if (k) existing.set(k, el); + }); + + // Compute desired keys (in order). Anything visible in the layout + // makes the cut; env-detection hides cards via the `hidden` attr, + // which is independent of layout visibility. + const desiredKeys: string[] = []; + for (const cell of getOrderedPerfCells()) { + if (cell.visible) desiredKeys.push(cell.key); + } + + // First pass: walk desired order, taking existing elements where + // possible and creating new ones otherwise. Track which keys are + // newly created — those are the only ones that need a value replay. + const newKeys = new Set(); + const ordered: HTMLElement[] = []; + for (const key of desiredKeys) { + let el = existing.get(key); + if (el) { + existing.delete(key); + } else { + el = _buildCellElement(key) ?? undefined; + if (!el) continue; + newKeys.add(key); + } + // Update mode + accent on every kept cell. Cheap and idempotent; + // covers the common "mode toggle" path where cell set is unchanged. + el.dataset.perfMode = _mode; + el.style.setProperty('--perf-accent', _getColor(key)); + ordered.push(el); + } + + // Remove cells that should no longer be visible. + existing.forEach(el => el.remove()); + + // Reorder via appendChild — moving an already-attached node preserves + // its state (listeners, animation transforms, hidden attr). Skip the + // append when the node is already at the correct position to avoid + // gratuitous DOM mutations. + let cursor: ChildNode | null = grid.firstChild; + for (const el of ordered) { + if (cursor === el) { + cursor = el.nextSibling; + } else { + grid.insertBefore(el, cursor); + // `el` is now positioned before `cursor`; cursor stays the same. + } + } + + // Re-render every spark (window / yScale may have changed). Pass + // `animate=false` so we don't trigger phantom scrolls — only real + // poll samples animate. for (const key of CHART_KEYS) _renderChartSvg(key); - // Re-apply env-detection visibility (the new HTML always renders - // gpu/temp cells without the hidden attr; cached `_hasGpu/_hasTemp` - // tell us what to actually do). - if (_hasGpu === false) { + // Re-apply env-detection visibility for newly-created gpu/temp cells + // (the HTML template renders them unhidden by default). + if (newKeys.has('gpu') && _hasGpu === false) { const card = document.getElementById('perf-gpu-card'); if (card) card.setAttribute('hidden', ''); } - if (_hasTemp === true) { + if (newKeys.has('temp') && _hasTemp === true) { const card = document.getElementById('perf-temp-card'); if (card) card.removeAttribute('hidden'); } - // Replay cached values so labels show real numbers, not "—". - if (_lastFetchData) { + // Replay cached state only into newly-created cells. Existing cells + // already have correct labels in their DOM. + if (newKeys.size === 0) return; + + const needsFetchReplay = newKeys.has('cpu') || newKeys.has('ram') + || newKeys.has('gpu') || newKeys.has('temp'); + if (needsFetchReplay && _lastFetchData) { _applyPerfDataToDom(_lastFetchData, /*pushHistory=*/false); } - if (_lastPatchesArgs) { + if (newKeys.has('patches') && _lastPatchesArgs) { updateActivePatches(_lastPatchesArgs.running, _lastPatchesArgs.totalCount); } - if (_lastTotalFpsArgs) { + if (newKeys.has('fps') && _lastTotalFpsArgs) { updateTotalFps( _lastTotalFpsArgs.totalFps, _lastTotalFpsArgs.minFps, @@ -977,14 +1525,42 @@ export function rerenderPerfGrid(): void { _lastTotalFpsArgs.targetSum, ); } - if (_lastTotalCaptureFpsArgs) { + if (newKeys.has('capture_fps') && _lastTotalCaptureFpsArgs) { updateTotalCaptureFps( _lastTotalCaptureFpsArgs.totalFps, _lastTotalCaptureFpsArgs.minFps, _lastTotalCaptureFpsArgs.maxFps, ); } - if (_lastErrorsArgs) { + if (newKeys.has('capture_fps_actual') && _lastTotalCaptureFpsActualArgs) { + updateTotalCaptureFpsActual( + _lastTotalCaptureFpsActualArgs.totalFps, + _lastTotalCaptureFpsActualArgs.targetSum, + _lastTotalCaptureFpsActualArgs.reportingCount, + ); + } + if (newKeys.has('network') && _lastNetworkArgs) { + updateNetworkThroughput( + _lastNetworkArgs.bytesPerSec, + _lastNetworkArgs.totalBytes, + ); + } + if (newKeys.has('device_latency') && _lastDeviceLatencyArgs) { + updateDeviceLatency( + _lastDeviceLatencyArgs.avgMs, + _lastDeviceLatencyArgs.maxMs, + _lastDeviceLatencyArgs.onlineCount, + _lastDeviceLatencyArgs.totalCount, + ); + } + if (newKeys.has('send_timing') && _lastSendTimingArgs) { + updateSendTiming( + _lastSendTimingArgs.avgMs, + _lastSendTimingArgs.maxMs, + _lastSendTimingArgs.reportingCount, + ); + } + if (newKeys.has('errors') && _lastErrorsArgs) { // Replay must not synthesize a fake spike from delta against an // older baseline (e.g. layout-change re-render after a long // session). Pin the baseline to the cached totals so the call @@ -997,7 +1573,7 @@ export function rerenderPerfGrid(): void { _lastErrorsArgs.pollMs, ); } - if (_lastDevicesArgs) { + if (newKeys.has('devices') && _lastDevicesArgs) { updateDevices(_lastDevicesArgs); } } @@ -1026,7 +1602,13 @@ function _ensureTooltip(): HTMLDivElement { /** Format a sampled value per metric for the tooltip line. */ function _formatSampleValue(key: string, v: number): string { if (key === 'temp') return `${v.toFixed(1)}°C`; - if (key === 'fps' || key === 'capture_fps') return `${v.toFixed(v < 10 ? 1 : 0)} FPS`; + if (key === 'fps' || key === 'capture_fps' || key === 'capture_fps_actual') return `${v.toFixed(v < 10 ? 1 : 0)} FPS`; + if (key === 'network') { + if (v >= 1024 * 1024) return `${(v / 1024 / 1024).toFixed(1)} MB/s`; + if (v >= 1024) return `${(v / 1024).toFixed(1)} KB/s`; + return `${v.toFixed(0)} B/s`; + } + if (key === 'device_latency' || key === 'send_timing') return `${v.toFixed(v < 10 ? 1 : 0)} ms`; if (key === 'errors') return `${v.toFixed(v < 1 ? 2 : v < 10 ? 1 : 0)}/s`; return `${v.toFixed(1)}%`; } @@ -1037,7 +1619,11 @@ function _metricLabel(key: string): string { if (key === 'gpu') return 'GPU'; if (key === 'temp') return 'Temp'; if (key === 'fps') return 'Total FPS'; - if (key === 'capture_fps') return 'Total Capture FPS'; + if (key === 'capture_fps') return 'Total Source FPS'; + if (key === 'capture_fps_actual') return 'Total Capture FPS'; + if (key === 'network') return 'Network'; + if (key === 'device_latency') return 'Device Latency'; + if (key === 'send_timing') return 'Send Timing'; if (key === 'errors') return 'Errors'; return key.toUpperCase(); } diff --git a/server/src/ledgrab/static/js/features/scene-presets.ts b/server/src/ledgrab/static/js/features/scene-presets.ts index ceaa76e..9ecd30f 100644 --- a/server/src/ledgrab/static/js/features/scene-presets.ts +++ b/server/src/ledgrab/static/js/features/scene-presets.ts @@ -9,11 +9,12 @@ import { showToast, showConfirm } from '../core/ui.ts'; import { Modal } from '../core/modal.ts'; import { CardSection } from '../core/card-sections.ts'; import { - ICON_SCENE, ICON_CAPTURE, ICON_START, ICON_EDIT, ICON_REFRESH, ICON_TARGET, ICON_CLONE, ICON_TRASH, ICON_LINK, + ICON_CAPTURE, ICON_START, ICON_EDIT, ICON_REFRESH, ICON_TARGET, ICON_TRASH, ICON_LINK, } from '../core/icons.ts'; import { scenePresetsCache, outputTargetsCache, automationsCacheObj } from '../core/state.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts'; -import { cardColorStyle, cardColorButton } from '../core/card-colors.ts'; +import { wrapCard, cardColorStyle } from '../core/card-colors.ts'; +import type { ModCardOpts, ModChipOpts, LedState } from '../core/mod-card.ts'; import { EntityPalette } from '../core/entity-palette.ts'; import { navigateToCard } from '../core/navigation.ts'; import { isActiveTab } from '../core/tab-registry.ts'; @@ -81,35 +82,81 @@ export function createSceneCard(preset: ScenePreset) { const automations = automationsCacheObj.data || []; const usedByCount = automations.filter(a => a.scene_preset_id === preset.id).length; - const meta = [ - targetCount > 0 ? `${ICON_TARGET} ${targetCount} ${t('scenes.targets_count')}` : null, - usedByCount > 0 ? `${ICON_LINK} ${t('scene_preset.used_by').replace('%d', usedByCount)}` : null, - ].filter(Boolean); - const updated = preset.updated_at ? new Date(preset.updated_at).toLocaleString() : ''; - const colorStyle = cardColorStyle(preset.id); - return `
    -
    - -
    -
    -
    ${escapeHtml(preset.name)}
    -
    - ${preset.description ? `
    ${escapeHtml(preset.description)}
    ` : ''} -
    - ${meta.map(m => `${m}`).join('')} - ${updated ? `${updated}` : ''} -
    - ${renderTagChips(preset.tags)} -
    - - - - - ${cardColorButton(preset.id, 'data-scene-id')} -
    -
    `; + // ── Badge: SCN · XX (last 2 hex chars of id, mirrors AUTO · 07 in + // automations.ts and SCN · 04 in cards-redesign-demo-v2). ── + const shortId = (preset.id || '').replace(/^scn_/i, '').slice(-2).toUpperCase() || 'NA'; + + // ── Meta line: target count + last-updated timestamp. The "used by + // N automations" hint moves down into a chip so it reads as a + // crosslink, not a count. ── + const metaParts: string[] = []; + if (targetCount > 0) metaParts.push(`${targetCount} ${t('scenes.targets_count')}`); + if (updated) metaParts.push(updated); + const metaHtml = metaParts.length ? metaParts.map(escapeHtml).join(' · ') : undefined; + + // ── Chips: usage crosslink + target count quick-jump. ── + const chips: ModChipOpts[] = []; + if (usedByCount > 0) { + chips.push({ + icon: ICON_LINK, + text: t('scene_preset.used_by').replace('%d', String(usedByCount)), + variant: 'tag', + }); + } + + // ── 2 dim LEDs in the bezel — scenes are stored snapshots, never + // "running"; the two-LED cluster mirrors the demo. ── + const leds: LedState[] = ['off', 'off']; + + const mod: ModCardOpts = { + head: { + badge: { text: `SCN · ${shortId}` }, + name: preset.name, + metaHtml, + leds, + menu: { + duplicateOnclick: `cloneScenePreset('${preset.id}')`, + hideOnclick: `toggleCardHidden('scenes','${preset.id}')`, + deleteOnclick: `deleteScenePreset('${preset.id}')`, + }, + }, + body: { + desc: preset.description || undefined, + chips: chips.length ? chips : undefined, + }, + foot: { + patchState: 'idle', + patchLabel: t('scenes.status.preset'), + primaryAction: { + label: t('scenes.action.activate'), + icon: ICON_START, + onclick: `activateScenePreset('${preset.id}')`, + title: t('scenes.activate'), + variant: 'go', + }, + secondaryActions: [{ + label: t('scenes.action.recapture'), + icon: ICON_REFRESH, + onclick: `recaptureScenePreset('${preset.id}')`, + title: t('scenes.recapture'), + }], + iconActions: [{ + icon: ICON_EDIT, + onclick: `editScenePreset('${preset.id}')`, + title: t('scenes.edit'), + }], + }, + }; + + const cardHtml = wrapCard({ + dataAttr: 'data-scene-id', + id: preset.id, + mod, + }); + const tagsHtml = renderTagChips(preset.tags); + return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}
    `) : cardHtml; } // ===== Dashboard section (compact cards) ===== @@ -510,7 +557,7 @@ export function initScenePresetDelegation(container: HTMLElement): void { if (action === 'navigate-scene') { // Only navigate if click wasn't on a child button if ((e.target as HTMLElement).closest('button')) return; - navigateToCard('automations', null, 'scenes', 'data-scene-id', id!); + navigateToCard('automations', 'scenes', 'scenes', 'data-scene-id', id!); return; } diff --git a/server/src/ledgrab/static/js/features/settings.ts b/server/src/ledgrab/static/js/features/settings.ts index b2b8201..b345e8d 100644 --- a/server/src/ledgrab/static/js/features/settings.ts +++ b/server/src/ledgrab/static/js/features/settings.ts @@ -7,7 +7,7 @@ import { API_BASE, fetchWithAuth } from '../core/api.ts'; import { Modal } from '../core/modal.ts'; import { showToast, showConfirm } from '../core/ui.ts'; import { t } from '../core/i18n.ts'; -import { ICON_UNDO, ICON_DOWNLOAD, ICON_SQUARE, ICON_CIRCLE, ICON_BELL, ICON_MONITOR, ICON_X, ICON_LIGHTBULB } from '../core/icons.ts'; +import { ICON_UNDO, ICON_DOWNLOAD, ICON_SQUARE, ICON_CIRCLE } from '../core/icons.ts'; import { IconSelect } from '../core/icon-select.ts'; import { openAuthedWs } from '../core/ws-auth.ts'; import { @@ -19,6 +19,7 @@ import { // ─── External URL (used by other modules for user-visible URLs) ── let _externalUrl = ''; +let _externalUrlInputBound = false; /** Get the configured external base URL (empty string = not set). */ export function getExternalUrl(): string { @@ -33,6 +34,24 @@ export function getBaseOrigin(): string { return _externalUrl || window.location.origin; } +/** Show or hide the save-bar that sits below the External URL field. */ +function _updateExternalUrlSaveBar(): void { + const input = document.getElementById('settings-external-url') as HTMLInputElement | null; + const bar = document.getElementById('settings-external-url-save-bar'); + if (!input || !bar) return; + const dirty = input.value.trim().replace(/\/+$/, '') !== _externalUrl; + bar.hidden = !dirty; +} + +/** Wire input listener once so editing the External URL toggles the save-bar. */ +function _bindExternalUrlInput(): void { + if (_externalUrlInputBound) return; + const input = document.getElementById('settings-external-url') as HTMLInputElement | null; + if (!input) return; + input.addEventListener('input', _updateExternalUrlSaveBar); + _externalUrlInputBound = true; +} + export async function loadExternalUrl(): Promise { try { const resp = await fetchWithAuth('/system/external-url'); @@ -41,6 +60,8 @@ export async function loadExternalUrl(): Promise { _externalUrl = data.external_url || ''; const input = document.getElementById('settings-external-url') as HTMLInputElement | null; if (input) input.value = _externalUrl; + _bindExternalUrlInput(); + _updateExternalUrlSaveBar(); } catch (err) { console.error('Failed to load external URL:', err); } @@ -62,6 +83,7 @@ export async function saveExternalUrl(): Promise { const data = await resp.json(); _externalUrl = data.external_url || ''; input.value = _externalUrl; + _updateExternalUrlSaveBar(); showToast(t('settings.external_url.saved'), 'success'); } catch (err) { console.error('Failed to save external URL:', err); @@ -69,13 +91,23 @@ export async function saveExternalUrl(): Promise { } } +/** Discard pending edits and restore the persisted External URL value. */ +export function revertExternalUrl(): void { + const input = document.getElementById('settings-external-url') as HTMLInputElement | null; + if (!input) return; + input.value = _externalUrl; + _updateExternalUrlSaveBar(); +} + // ─── Settings-modal tab switching ─────────────────────────── const SETTINGS_ACTIVE_TAB_KEY = 'settings_active_tab'; export function switchSettingsTab(tabId: string): void { let activeBtn: HTMLElement | null = null; - document.querySelectorAll('.settings-tab-btn').forEach(btn => { + // Both selectors are queried so older cached templates with the legacy + // top tab strip continue to work alongside the new left rail. + document.querySelectorAll('.settings-tab-btn, .settings-rail-btn').forEach(btn => { const isActive = (btn as HTMLElement).dataset.settingsTab === tabId; btn.classList.toggle('active', isActive); if (isActive) activeBtn = btn as HTMLElement; @@ -83,10 +115,18 @@ export function switchSettingsTab(tabId: string): void { document.querySelectorAll('.settings-panel').forEach(panel => { panel.classList.toggle('active', panel.id === `settings-panel-${tabId}`); }); - // Keep the active tab visible inside the (possibly scrolling) tab bar. + // Keep the active tab visible inside the (possibly scrolling) tab bar / rail. if (activeBtn) { (activeBtn as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }); } + // Swap the modal channel stripe color to match the tab's rail accent. + const modalContent = document.querySelector('#settings-modal .modal-content') as HTMLElement | null; + const railCh = (activeBtn as HTMLElement | null)?.dataset.railCh; + if (modalContent && railCh) { + modalContent.style.setProperty('--modal-ch', `var(--ch-${railCh}, var(--ch-amber))`); + } else if (modalContent) { + modalContent.style.removeProperty('--modal-ch'); + } // Remember so the next openSettingsModal() re-opens this tab. try { localStorage.setItem(SETTINGS_ACTIVE_TAB_KEY, tabId); } catch { /* storage blocked */ } // Lazy-render the appearance tab content @@ -113,6 +153,13 @@ export function switchSettingsTab(tabId: string): void { /** @type {WebSocket|null} */ let _logWs: WebSocket | null = null; +/** Connection state — drives LED cluster, patch indicator, and the + * signal-flow strip on the console surface. */ +type LogConnectionState = 'idle' | 'connecting' | 'live' | 'error'; + +/** Live tally of streamed lines, by severity. Reset on Clear. */ +const _logStats = { total: 0, warn: 0, err: 0 }; + /** Level ordering for filter comparisons */ const _LOG_LEVELS = { DEBUG: 10, INFO: 20, WARNING: 30, ERROR: 40, CRITICAL: 50 }; @@ -142,17 +189,101 @@ function _linePassesFilter(line: string): boolean { return (_LOG_LEVELS[lineLvl] ?? 10) >= (_LOG_LEVELS[filter] ?? 0); } +/** Mirror the live tally into the .mod-metric cells and toggle the + * channel-tinted "has-warn"/"has-errors" classes for non-zero counts. */ +function _renderLogStats(): void { + const total = document.getElementById('log-stat-total'); + const warn = document.getElementById('log-stat-warn'); + const err = document.getElementById('log-stat-err'); + if (total) total.textContent = String(_logStats.total); + if (warn) warn.textContent = String(_logStats.warn); + if (err) err.textContent = String(_logStats.err); + + document.getElementById('log-stat-warn-cell')?.classList.toggle('has-warn', _logStats.warn > 0); + document.getElementById('log-stat-err-cell')?.classList.toggle('has-errors', _logStats.err > 0); +} + +function _resetLogStats(): void { + _logStats.total = 0; + _logStats.warn = 0; + _logStats.err = 0; + _renderLogStats(); +} + +/** Map the live connection state onto the header LED cluster, the + * patch indicator dot/label, and the signal-flow class. */ +function _setLogConnectionState(state: LogConnectionState): void { + const overlay = document.getElementById('log-overlay'); + overlay?.classList.toggle('is-streaming', state === 'live'); + + const leds = Array.from(document.querySelectorAll('#log-viewer-leds .led')); + leds.forEach((led, idx) => { + led.classList.remove('on', 'blink', 'fault'); + if (state === 'live') { + led.classList.add('on'); + if (idx > 0) led.classList.add('blink'); + } else if (state === 'connecting') { + if (idx === 0) led.classList.add('on', 'blink'); + } else if (state === 'error') { + if (idx === 0) led.classList.add('fault'); + } + }); + + const patchDot = document.querySelector('#log-patch-indicator .patch-dot'); + patchDot?.classList.toggle('is-live', state === 'live'); + + const patchLabel = document.getElementById('log-patch-label'); + if (patchLabel) { + const key = + state === 'live' ? 'settings.logs.patch.live' + : state === 'connecting' ? 'settings.logs.patch.connecting' + : state === 'error' ? 'settings.logs.patch.error' + : 'settings.logs.patch.idle'; + const fallback = + state === 'live' ? 'STREAMING' + : state === 'connecting' ? 'CONNECTING' + : state === 'error' ? 'OFFLINE' + : 'STANDBY'; + const translated = t(key); + // t() returns the key itself when a translation is missing — + // detect that and fall through to the English label. + patchLabel.textContent = translated === key ? fallback : translated; + patchLabel.dataset.i18n = key; + } + + const btn = document.getElementById('log-viewer-connect-btn'); + if (btn) { + const isConnected = state === 'live' || state === 'connecting'; + const labelKey = isConnected ? 'settings.logs.disconnect' : 'settings.logs.connect'; + const labelEl = btn.querySelector('span') ?? btn; + labelEl.textContent = t(labelKey); + if (labelEl instanceof HTMLElement) labelEl.dataset.i18n = labelKey; + btn.classList.toggle('mod-btn-go', !isConnected); + btn.classList.toggle('mod-btn-stop', isConnected); + } +} + function _appendLine(line: string): void { // Skip keepalive empty pings if (!line) return; + + const level = _detectLevel(line); + + // Tally is independent of the active filter — counts always reflect + // the unfiltered stream so the user can see the full picture. + _logStats.total += 1; + if (level === 'WARNING') _logStats.warn += 1; + if (level === 'ERROR' || level === 'CRITICAL') _logStats.err += 1; + _renderLogStats(); + + document.getElementById('log-overlay')?.classList.add('has-data'); + if (!_linePassesFilter(line)) return; const output = document.getElementById('log-viewer-output'); if (!output) return; - const level = _detectLevel(line); const cls = _levelClass(level); - const span = document.createElement('span'); if (cls) span.className = cls; span.textContent = line + '\n'; @@ -163,22 +294,22 @@ function _appendLine(line: string): void { } export function connectLogViewer(): void { - const btn = document.getElementById('log-viewer-connect-btn'); - if (_logWs && (_logWs.readyState === WebSocket.OPEN || _logWs.readyState === WebSocket.CONNECTING)) { // Disconnect _logWs.close(); _logWs = null; - if (btn) { btn.textContent = t('settings.logs.connect'); btn.dataset.i18n = 'settings.logs.connect'; } + _setLogConnectionState('idle'); return; } const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const url = `${proto}//${location.host}/api/v1/system/logs/ws`; + _setLogConnectionState('connecting'); + openAuthedWs(url).then((ws) => { _logWs = ws; - if (btn) { btn.textContent = t('settings.logs.disconnect'); btn.dataset.i18n = 'settings.logs.disconnect'; } + _setLogConnectionState('live'); ws.onmessage = (evt) => { _appendLine(evt.data); @@ -186,14 +317,16 @@ export function connectLogViewer(): void { ws.onerror = () => { showToast(t('settings.logs.error'), 'error'); + _setLogConnectionState('error'); }; ws.onclose = () => { _logWs = null; - if (btn) { btn.textContent = t('settings.logs.connect'); btn.dataset.i18n = 'settings.logs.connect'; } + _setLogConnectionState('idle'); }; }).catch(() => { showToast(t('settings.logs.error'), 'error'); + _setLogConnectionState('error'); }); } @@ -202,13 +335,14 @@ export function disconnectLogViewer(): void { _logWs.close(); _logWs = null; } - const btn = document.getElementById('log-viewer-connect-btn'); - if (btn) { btn.textContent = t('settings.logs.connect'); btn.dataset.i18n = 'settings.logs.connect'; } + _setLogConnectionState('idle'); } export function clearLogViewer(): void { const output = document.getElementById('log-viewer-output'); if (output) output.innerHTML = ''; + _resetLogStats(); + document.getElementById('log-overlay')?.classList.remove('has-data'); } /** Re-render the log output according to the current filter selection. */ @@ -278,10 +412,8 @@ let _logLevelIconSelect: IconSelect | null = null; let _autoBackupIntervalIconSelect: IconSelect | null = null; let _shutdownActionIconSelect: IconSelect | null = null; -// Notification matrix: one IconSelect per event type. Constructed lazily -// when the Notifications tab is first opened so the icon palette and i18n -// strings have a chance to load. -const _notifIconSelects: Partial> = {}; +// Notifications: the visual matrix is now the source of truth — see +// initNotificationsPanel() / _setNotifMatrixSelection() below. type ShutdownAction = 'stop_targets' | 'nothing'; const _SHUTDOWN_ACTIONS: readonly ShutdownAction[] = ['stop_targets', 'nothing'] as const; @@ -359,7 +491,8 @@ export function openSettingsModal(): void { } } - // Initialize auto-backup interval icon select + // Initialize auto-backup interval icon select. The onChange callback + // auto-persists the section — there is no longer a manual Save button. if (!_autoBackupIntervalIconSelect) { const sel = document.getElementById('auto-backup-interval') as HTMLSelectElement | null; if (sel) { @@ -367,6 +500,7 @@ export function openSettingsModal(): void { target: sel, items: _getHourIntervalItems(), columns: 3, + onChange: () => saveAutoBackupSettings(), }); } } @@ -387,9 +521,26 @@ export function openSettingsModal(): void { loadApiKeysList(); loadExternalUrl(); loadAutoBackupSettings(); + _bindAutoBackupListeners(); loadBackupList(); loadLogLevel(); loadShutdownAction(); + _seedRailFooter(); + // Refresh the update status so the rail badge ("update available" pill + // on the Updates tab) is current when the modal opens — it would + // otherwise reflect whatever state the app loaded with. + if (typeof (window as any).loadUpdateStatus === 'function') { + (window as any).loadUpdateStatus(); + } +} + +/** Populate the rail footer with version info, mirroring the page header + * badge so the modal feels grounded. Idempotent — safe to call repeatedly. */ +function _seedRailFooter(): void { + const footer = document.getElementById('settings-rail-build'); + if (!footer) return; + const version = document.getElementById('version-number')?.textContent?.trim() || ''; + footer.textContent = version ? version : ''; } export function closeSettingsModal(): void { @@ -500,15 +651,36 @@ export async function loadAutoBackupSettings(): Promise { } else { statusEl.textContent = t('settings.auto_backup.last_backup') + ': ' + t('settings.auto_backup.never'); } + + const pill = document.getElementById('auto-backup-status-pill'); + if (pill) { + if (data.enabled) { + pill.textContent = t('settings.auto_backup.pill.running'); + pill.hidden = false; + } else { + pill.hidden = true; + } + } } catch (err) { console.error('Failed to load auto-backup settings:', err); } } +/** Persist auto-backup settings. The Save button has been removed — + * this is invoked silently from change-listeners on the three fields + * (enabled checkbox, interval IconSelect, max-backups input). + * Errors still surface as toasts; success is silent because the + * Auto-Backup section's own pill/status text reflects the new state. */ export async function saveAutoBackupSettings(): Promise { - const enabled = (document.getElementById('auto-backup-enabled') as HTMLInputElement).checked; - const interval_hours = parseFloat((document.getElementById('auto-backup-interval') as HTMLInputElement).value); - const max_backups = parseInt((document.getElementById('auto-backup-max') as HTMLInputElement).value, 10); + const enabledEl = document.getElementById('auto-backup-enabled') as HTMLInputElement | null; + const intervalEl = document.getElementById('auto-backup-interval') as HTMLInputElement | null; + const maxEl = document.getElementById('auto-backup-max') as HTMLInputElement | null; + if (!enabledEl || !intervalEl || !maxEl) return; + + const enabled = enabledEl.checked; + const interval_hours = parseFloat(intervalEl.value); + const max_backups = parseInt(maxEl.value, 10); + if (Number.isNaN(interval_hours) || Number.isNaN(max_backups)) return; try { const resp = await fetchWithAuth('/system/auto-backup/settings', { @@ -519,7 +691,6 @@ export async function saveAutoBackupSettings(): Promise { const err = await resp.json().catch(() => ({})); throw new Error(err.detail || `HTTP ${resp.status}`); } - showToast(t('settings.auto_backup.saved'), 'success'); loadAutoBackupSettings(); loadBackupList(); } catch (err) { @@ -528,6 +699,22 @@ export async function saveAutoBackupSettings(): Promise { } } +/** Wire change listeners on the three auto-backup fields so any edit + * auto-saves. Called once per modal open; idempotent. */ +let _autoBackupListenersBound = false; +function _bindAutoBackupListeners(): void { + if (_autoBackupListenersBound) return; + const enabledEl = document.getElementById('auto-backup-enabled') as HTMLInputElement | null; + const maxEl = document.getElementById('auto-backup-max') as HTMLInputElement | null; + if (!enabledEl || !maxEl) return; + // Toggle: persist on every flip. + enabledEl.addEventListener('change', () => { saveAutoBackupSettings(); }); + // Number input: fires on blur / Enter — avoids saving on every keystroke + // while the user is still typing a multi-digit value. + maxEl.addEventListener('change', () => { saveAutoBackupSettings(); }); + _autoBackupListenersBound = true; +} + export async function triggerBackupNow(): Promise { try { const resp = await fetchWithAuth('/system/auto-backup/trigger', { method: 'POST' }); @@ -548,40 +735,50 @@ export async function triggerBackupNow(): Promise { export async function loadBackupList(): Promise { const container = document.getElementById('saved-backups-list')!; + const meta = document.getElementById('saved-backups-meta'); + container.setAttribute('data-empty', t('settings.saved_backups.empty')); try { const resp = await fetchWithAuth('/system/backups'); if (!resp.ok) return; const data = await resp.json(); if (data.count === 0) { - container.innerHTML = `
    ${t('settings.saved_backups.empty')}
    `; + container.innerHTML = ''; + if (meta) meta.hidden = true; return; } + let totalBytes = 0; container.innerHTML = data.backups.map(b => { const sizeBytes = b.size_bytes || 0; + totalBytes += sizeBytes; const sizeStr = sizeBytes >= 1024 * 1024 ? (sizeBytes / (1024 * 1024)).toFixed(1) + ' MB' : (sizeBytes / 1024).toFixed(1) + ' KB'; const date = new Date(b.created_at).toLocaleString(); const isAuto = b.filename.startsWith('ledgrab-autobackup-'); - const typeBadge = isAuto - ? `${t('settings.saved_backups.type.auto')}` - : `${t('settings.saved_backups.type.manual')}`; - return `
    - ${typeBadge} -
    - ${date} - ${sizeStr} + const typeKey = isAuto ? 'settings.saved_backups.type.auto' : 'settings.saved_backups.type.manual'; + return `
    +
    +
    ${date}
    +
    ${sizeStr} · ${t(typeKey)}
    - - - + + +
    `; }).join(''); + if (meta) { + meta.hidden = false; + const totalStr = totalBytes >= 1024 * 1024 + ? (totalBytes / (1024 * 1024)).toFixed(1) + ' MB' + : (totalBytes / 1024).toFixed(1) + ' KB'; + meta.textContent = `${data.count} · ${totalStr}`; + } } catch (err) { console.error('Failed to load backup list:', err); container.innerHTML = ''; + if (meta) meta.hidden = true; } } @@ -668,26 +865,35 @@ export async function deleteSavedBackup(filename: string): Promise { export async function loadApiKeysList(): Promise { const container = document.getElementById('settings-api-keys-list'); if (!container) return; + const meta = document.getElementById('settings-api-keys-meta'); try { const resp = await fetchWithAuth('/system/api-keys'); if (!resp.ok) { - container.innerHTML = `
    ${t('settings.api_keys.load_error')}
    `; + container.innerHTML = `
    ${t('settings.api_keys.load_error')}
    `; + if (meta) meta.hidden = true; return; } const data = await resp.json(); if (data.count === 0) { - container.innerHTML = `
    ${t('settings.api_keys.empty')}
    `; + container.innerHTML = `
    ${t('settings.api_keys.empty')}
    `; + if (meta) meta.hidden = true; return; } container.innerHTML = data.keys.map(k => - `
    - ${k.label} - ${k.masked} + `
    + ${k.label} + ${k.masked} + ${t('settings.api_keys.read_only')}
    ` ).join(''); + if (meta) { + meta.hidden = false; + meta.textContent = `${data.count} ${data.count === 1 ? t('settings.api_keys.meta.one') : t('settings.api_keys.meta.many')}`; + } } catch (err) { console.error('Failed to load API keys:', err); if (container) container.innerHTML = ''; + if (meta) meta.hidden = true; } } @@ -776,33 +982,88 @@ const _NOTIF_EVENT_KEYS = [ ] as const; type NotifEventKey = typeof _NOTIF_EVENT_KEYS[number]; -function _getNotifChannelItems(): { value: string; icon: string; label: string; desc: string }[] { - return [ - { value: 'none', icon: ICON_X, label: t('settings.notifications.channel.none.label'), desc: t('settings.notifications.channel.none.desc') }, - { value: 'snack', icon: ICON_BELL, label: t('settings.notifications.channel.snack.label'), desc: t('settings.notifications.channel.snack.desc') }, - { value: 'os', icon: ICON_MONITOR, label: t('settings.notifications.channel.os.label'), desc: t('settings.notifications.channel.os.desc') }, - { value: 'both', icon: ICON_LIGHTBULB, label: t('settings.notifications.channel.both.label'), desc: t('settings.notifications.channel.both.desc') }, - ]; -} - function _isNotifChannel(v: string): v is NotificationChannel { return v === 'none' || v === 'snack' || v === 'os' || v === 'both'; } let _notifPrefsLoaded = false; +let _notifMatrixWired = false; + +const _NOTIF_CHANNELS: NotificationChannel[] = ['snack', 'os', 'both', 'none']; + +/** Reflect a (event, channel) selection in the visual matrix and the + * hidden underlying (must already contain a matching
    `) : cardHtml; }; const renderCaptureTemplateCard = (template: any) => { - const engineIcon = getEngineIcon(template.engine_type); - const configEntries = Object.entries(template.engine_config); - return wrapCard({ - type: 'template-card', - dataAttr: 'data-template-id', - id: template.id, - removeOnclick: `deleteTemplate('${template.id}')`, - removeTitle: t('common.delete'), - content: ` -
    -
    ${ICON_TEMPLATE} ${escapeHtml(template.name)}
    -
    - ${template.description ? `
    ${escapeHtml(template.description)}
    ` : ''} -
    - ${getEngineIcon(template.engine_type)} ${template.engine_type.toUpperCase()} - ${configEntries.length > 0 ? `${ICON_WRENCH} ${configEntries.length}` : ''} -
    - ${renderTagChips(template.tags)} - ${configEntries.length > 0 ? ` -
    - -
    -
    - - ${configEntries.map(([key, val]) => ` - - - - - `).join('')} -
    ${escapeHtml(key)}${escapeHtml(String(val))}
    -
    -
    + const configEntries = Object.entries(template.engine_config || {}); + const chips: ModChipOpts[] = [ + { icon: getEngineIcon(template.engine_type), text: String(template.engine_type).toUpperCase(), title: t('templates.engine') }, + ]; + if (configEntries.length > 0) { + chips.push({ icon: ICON_WRENCH, text: `${configEntries.length} ${escapeHtml(t('templates.config.show') || 'config')}`, title: t('templates.config.show') }); + } + const configBlock = configEntries.length > 0 ? ` +
    + +
    +
    + + ${configEntries.map(([key, val]) => ` + + + + + `).join('')} +
    ${escapeHtml(key)}${escapeHtml(String(val))}
    - ` : ''}`, - actions: ` - - - `, - }); +
    +
    ` : ''; + + const mod: ModCardOpts = { + head: { + badge: { text: 'TPL · CAPTURE' }, + name: template.name, + metaHtml: escapeHtml(`${String(template.engine_type).toUpperCase()} · ${configEntries.length} keys`), + leds: ['off'], + menu: { + duplicateOnclick: `cloneCaptureTemplate('${template.id}')`, + hideOnclick: `toggleCardHidden('raw-templates','${template.id}')`, + deleteOnclick: `deleteTemplate('${template.id}')`, + }, + }, + body: { + desc: template.description || undefined, + chips, + extraHtml: configBlock || undefined, + }, + foot: { + patchState: 'idle', + patchLabel: 'TEMPLATE', + iconActions: [ + { icon: ICON_TEST, onclick: `showTestTemplateModal('${template.id}')`, title: t('templates.test.title') }, + { icon: ICON_EDIT, onclick: `editTemplate('${template.id}')`, title: t('common.edit') }, + ], + }, + }; + + const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-template-id', id: template.id, mod }); + const tagsHtml = renderTagChips(template.tags); + return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}
    `) : cardHtml; }; const renderPPTemplateCard = (tmpl: any) => { - let filterChainHtml = ''; - if (tmpl.filters && tmpl.filters.length > 0) { - const filterNames = tmpl.filters.map(fi => { + const filters = tmpl.filters || []; + const chainExtra = filters.length > 0 ? `
    ${ + filters.map((fi: any, idx: number) => { let label = _getFilterName(fi.filter_id); if (fi.filter_id === 'filter_template' && fi.options?.template_id) { const ref = _cachedPPTemplates.find(p => p.id === fi.options.template_id); if (ref) label += `: ${ref.name}`; } - return `${escapeHtml(label)}`; - }); - filterChainHtml = `
    ${filterNames.join('')}
    `; - } - return wrapCard({ - type: 'template-card', - dataAttr: 'data-pp-template-id', - id: tmpl.id, - removeOnclick: `deletePPTemplate('${tmpl.id}')`, - removeTitle: t('common.delete'), - content: ` -
    -
    ${ICON_TEMPLATE} ${escapeHtml(tmpl.name)}
    -
    - ${tmpl.description ? `
    ${escapeHtml(tmpl.description)}
    ` : ''} - ${filterChainHtml} - ${renderTagChips(tmpl.tags)}`, - actions: ` - - - `, - }); + const arrow = idx < filters.length - 1 ? '' : ''; + return `${escapeHtml(label)}${arrow}`; + }).join('') + }
    ` : ''; + + const mod: ModCardOpts = { + head: { + badge: { text: 'TPL · FILTER' }, + name: tmpl.name, + metaHtml: escapeHtml(`${filters.length} ${t('postprocessing.title') || 'filters'}`), + leds: ['off'], + menu: { + duplicateOnclick: `clonePPTemplate('${tmpl.id}')`, + hideOnclick: `toggleCardHidden('proc-templates','${tmpl.id}')`, + deleteOnclick: `deletePPTemplate('${tmpl.id}')`, + }, + }, + body: { + desc: tmpl.description || undefined, + extraHtml: chainExtra || undefined, + }, + foot: { + patchState: 'idle', + patchLabel: 'PIPELINE', + iconActions: [ + { icon: ICON_TEST, onclick: `showTestPPTemplateModal('${tmpl.id}')`, title: t('postprocessing.test.title') }, + { icon: ICON_EDIT, onclick: `editPPTemplate('${tmpl.id}')`, title: t('common.edit') }, + ], + }, + }; + const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-pp-template-id', id: tmpl.id, mod }); + const tagsHtml = renderTagChips(tmpl.tags); + return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}
    `) : cardHtml; }; const renderCSPTCard = (tmpl: any) => { - let filterChainHtml = ''; - if (tmpl.filters && tmpl.filters.length > 0) { - const filterNames = tmpl.filters.map(fi => { + const filters = tmpl.filters || []; + const chainExtra = filters.length > 0 ? `
    ${ + filters.map((fi: any, idx: number) => { let label = _getStripFilterName(fi.filter_id); if (fi.filter_id === 'css_filter_template' && fi.options?.template_id) { const ref = _cachedCSPTemplates.find(p => p.id === fi.options.template_id); if (ref) label += `: ${ref.name}`; } - return `${escapeHtml(label)}`; - }); - filterChainHtml = `
    ${filterNames.join('\u2192')}
    `; - } - return wrapCard({ - type: 'template-card', - dataAttr: 'data-cspt-id', - id: tmpl.id, - removeOnclick: `deleteCSPT('${tmpl.id}')`, - removeTitle: t('common.delete'), - content: ` -
    -
    ${ICON_CSPT} ${escapeHtml(tmpl.name)}
    -
    - ${tmpl.description ? `
    ${escapeHtml(tmpl.description)}
    ` : ''} - ${filterChainHtml} - ${renderTagChips(tmpl.tags)}`, - actions: ` - - - `, - }); + const arrow = idx < filters.length - 1 ? '\u2192' : ''; + return `${escapeHtml(label)}${arrow}`; + }).join('') + }
    ` : ''; + + const mod: ModCardOpts = { + head: { + badge: { text: 'TPL \u00b7 STRIP' }, + name: tmpl.name, + metaHtml: escapeHtml(`${filters.length} ${t('css_processing.title') || 'strip filters'}`), + leds: ['off'], + menu: { + duplicateOnclick: `cloneCSPT('${tmpl.id}')`, + hideOnclick: `toggleCardHidden('css-proc-templates','${tmpl.id}')`, + deleteOnclick: `deleteCSPT('${tmpl.id}')`, + }, + }, + body: { + desc: tmpl.description || undefined, + extraHtml: chainExtra || undefined, + }, + foot: { + patchState: 'idle', + patchLabel: 'PIPELINE', + iconActions: [ + { icon: ICON_TEST, onclick: `event.stopPropagation(); testCSPT('${tmpl.id}')`, title: t('color_strip.test.title') }, + { icon: ICON_EDIT, onclick: `editCSPT('${tmpl.id}')`, title: t('common.edit') }, + ], + }, + }; + const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-cspt-id', id: tmpl.id, mod }); + const tagsHtml = renderTagChips(tmpl.tags); + return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}
    `) : cardHtml; }; const rawStreams = streams.filter(s => s.stream_type === 'raw'); @@ -646,120 +740,178 @@ function renderPictureSourcesList(streams: any) { }; const renderAudioSourceCard = (src: any) => { - const icon = getAudioSourceIcon(src.source_type); + const chips: ModChipOpts[] = []; + let badgeText: string; + let metaText: string; - let propsHtml = ''; if (src.source_type === 'processed') { + badgeText = 'AUDIO · FX'; const parent = _cachedAudioSources.find(s => s.id === src.audio_source_id); - const parentName = parent ? parent.name : src.audio_source_id; + const parentName = parent ? parent.name : (src.audio_source_id || '—'); const parentSection = parent ? _getSectionForSource(parent.source_type) : 'audio-capture'; const parentTab = parent ? _getTabForSource(parent.source_type) : 'audio_capture'; - const parentBadge = parent - ? `${getAudioSourceIcon(parent.source_type)} ${escapeHtml(parentName)}` - : `${ICON_AUDIO_LOOPBACK} ${escapeHtml(parentName)}`; - propsHtml = `${parentBadge}`; + chips.push({ + icon: parent ? getAudioSourceIcon(parent.source_type) : ICON_AUDIO_LOOPBACK, + text: parentName, + title: t('audio_source.parent'), + onclick: parent + ? `event.stopPropagation(); navigateToCard('streams','${parentTab}','${parentSection}','data-id','${src.audio_source_id}')` + : undefined, + }); if (src.audio_processing_template_id) { - const aptTmpl = _cachedAudioProcessingTemplates.find(t => t.id === src.audio_processing_template_id); - const aptName = aptTmpl ? escapeHtml(aptTmpl.name) : escapeHtml(src.audio_processing_template_id); - propsHtml += aptTmpl - ? `${ICON_AUDIO_TEMPLATE} ${aptName}` - : `${ICON_AUDIO_TEMPLATE} ${aptName}`; + const aptTmpl = _cachedAudioProcessingTemplates.find(tt => tt.id === src.audio_processing_template_id); + const aptName = aptTmpl ? aptTmpl.name : src.audio_processing_template_id; + chips.push({ + icon: ICON_AUDIO_TEMPLATE, + text: aptName, + title: t('audio_processing.title'), + onclick: aptTmpl + ? `event.stopPropagation(); navigateToCard('streams','audio_processing','audio-processing-templates','data-apt-id','${src.audio_processing_template_id}')` + : undefined, + }); } + metaText = parent ? `via ${parentName}` : 'orphan source'; } else { - // Capture source - const devIdx = src.device_index ?? -1; const loopback = src.is_loopback !== false; - const devLabel = loopback ? `${ICON_AUDIO_LOOPBACK} Loopback` : `${ICON_AUDIO_INPUT} Input`; - const tpl = src.audio_template_id ? _cachedAudioTemplates.find(t => t.id === src.audio_template_id) : null; - const tplBadge = tpl ? `${ICON_AUDIO_TEMPLATE} ${escapeHtml(tpl.name)}` : ''; - propsHtml = `${devLabel} #${devIdx}${tplBadge}`; + const devIdx = src.device_index ?? -1; + badgeText = loopback ? 'LOOP · IN' : 'MIC · IN'; + const devLabel = loopback ? 'Loopback' : 'Input'; + metaText = `${devLabel} #${devIdx}`; + const tpl = src.audio_template_id ? _cachedAudioTemplates.find(tt => tt.id === src.audio_template_id) : null; + if (tpl) { + chips.push({ + icon: ICON_AUDIO_TEMPLATE, + text: tpl.name, + title: t('audio_source.audio_template'), + onclick: `event.stopPropagation(); navigateToCard('streams','audio_templates','audio-templates','data-audio-template-id','${src.audio_template_id}')`, + }); + } } - return wrapCard({ - type: 'template-card', - dataAttr: 'data-id', - id: src.id, - removeOnclick: `deleteAudioSource('${src.id}')`, - removeTitle: t('common.delete'), - content: ` -
    -
    ${icon} ${escapeHtml(src.name)}
    -
    - ${src.description ? `
    ${escapeHtml(src.description)}
    ` : ''} -
    ${propsHtml}
    - ${renderTagChips(src.tags)}`, - actions: ` - - - `, - }); + const sectionKey = src.source_type === 'processed' ? 'audio-processed' : 'audio-capture'; + const mod: ModCardOpts = { + head: { + badge: { text: badgeText }, + name: src.name, + metaHtml: escapeHtml(metaText), + leds: ['off'], + menu: { + duplicateOnclick: `cloneAudioSource('${src.id}')`, + hideOnclick: `toggleCardHidden('${sectionKey}','${src.id}')`, + deleteOnclick: `deleteAudioSource('${src.id}')`, + }, + }, + body: { + desc: src.description || undefined, + chips: chips.length ? chips : undefined, + }, + foot: { + patchState: 'idle', + patchLabel: 'SOURCE', + iconActions: [ + { icon: ICON_TEST, onclick: '', title: t('audio_source.test'), dataAttrs: { 'data-action': 'test-audio' } }, + { icon: ICON_EDIT, onclick: '', title: t('common.edit'), dataAttrs: { 'data-action': 'edit-audio' } }, + ], + }, + }; + const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-id', id: src.id, mod }); + const tagsHtml = renderTagChips(src.tags); + return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}
    `) : cardHtml; }; const renderAudioTemplateCard = (template: any) => { const configEntries = Object.entries(template.engine_config || {}); - return wrapCard({ - type: 'template-card', - dataAttr: 'data-audio-template-id', - id: template.id, - removeOnclick: `deleteAudioTemplate('${template.id}')`, - removeTitle: t('common.delete'), - content: ` -
    -
    ${ICON_AUDIO_TEMPLATE} ${escapeHtml(template.name)}
    -
    - ${template.description ? `
    ${escapeHtml(template.description)}
    ` : ''} -
    - ${ICON_AUDIO_TEMPLATE} ${template.engine_type.toUpperCase()} - ${configEntries.length > 0 ? `${ICON_WRENCH} ${configEntries.length}` : ''} -
    - ${renderTagChips(template.tags)} - ${configEntries.length > 0 ? ` -
    - -
    -
    - - ${configEntries.map(([key, val]) => ` - - - - - `).join('')} -
    ${escapeHtml(key)}${escapeHtml(String(val))}
    -
    -
    + const chips: ModChipOpts[] = [ + { icon: getAudioEngineIcon(template.engine_type), text: String(template.engine_type).toUpperCase(), title: t('audio_template.engine') }, + ]; + if (configEntries.length > 0) { + chips.push({ icon: ICON_WRENCH, text: `${configEntries.length} ${escapeHtml(t('audio_template.config.show') || 'config')}`, title: t('audio_template.config.show') }); + } + const configBlock = configEntries.length > 0 ? ` +
    + +
    +
    + + ${configEntries.map(([key, val]) => ` + + + + + `).join('')} +
    ${escapeHtml(key)}${escapeHtml(String(val))}
    - ` : ''}`, - actions: ` - - - `, - }); +
    +
    ` : ''; + + const mod: ModCardOpts = { + head: { + badge: { text: 'TPL · AUDIO' }, + name: template.name, + metaHtml: escapeHtml(`${String(template.engine_type).toUpperCase()} · ${configEntries.length} keys`), + leds: ['off'], + menu: { + duplicateOnclick: `cloneAudioTemplate('${template.id}')`, + hideOnclick: `toggleCardHidden('audio-templates','${template.id}')`, + deleteOnclick: `deleteAudioTemplate('${template.id}')`, + }, + }, + body: { + desc: template.description || undefined, + chips, + extraHtml: configBlock || undefined, + }, + foot: { + patchState: 'idle', + patchLabel: 'TEMPLATE', + iconActions: [ + { icon: ICON_TEST, onclick: `showTestAudioTemplateModal('${template.id}')`, title: t('audio_template.test') }, + { icon: ICON_EDIT, onclick: `editAudioTemplate('${template.id}')`, title: t('common.edit') }, + ], + }, + }; + const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-audio-template-id', id: template.id, mod }); + const tagsHtml = renderTagChips(template.tags); + return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}
    `) : cardHtml; }; // Gradient card renderer const renderGradientCard = (g: GradientEntity) => { const cssStops = g.stops.map(s => `rgb(${s.color.join(',')}) ${(s.position * 100).toFixed(0)}%`).join(', '); - const stripPreview = `
    `; - const lockBadge = g.is_builtin ? `${t('gradient.builtin')}` : ''; - const cloneBtn = ``; - const editBtn = g.is_builtin ? '' : ``; - return wrapCard({ - type: 'template-card', - dataAttr: 'data-id', - id: g.id, - removeOnclick: g.is_builtin ? '' : `deleteGradient('${g.id}')`, - removeTitle: t('common.delete'), - content: ` -
    -
    ${ICON_PALETTE} ${escapeHtml(g.name)}${lockBadge}
    -
    - ${stripPreview} -
    - ${g.stops.length} ${t('gradient.stops_label')} -
    `, - actions: `${cloneBtn}${editBtn}`, - }); + // The `.mod-preview` wrapper inside renderModBody doesn't accept + // inline style, so emit a sibling block via `extraHtml` so the + // gradient fills the full preview surface. + const previewBlock = `
    ${ + g.is_builtin ? `${escapeHtml(t('gradient.builtin') || 'BUILTIN').toUpperCase()}` : '' + }
    `; + const iconActions: any[] = [ + { icon: ICON_CLONE, onclick: `cloneGradient('${g.id}')`, title: t('common.clone') }, + ]; + if (!g.is_builtin) { + iconActions.push({ icon: ICON_EDIT, onclick: `editGradient('${g.id}')`, title: t('common.edit') }); + } + const mod: ModCardOpts = { + head: { + badge: { text: 'PALETTE · GRD' }, + name: g.name, + metaHtml: escapeHtml(`${g.stops.length} ${t('gradient.stops_label') || 'stops'}`), + leds: ['off'], + menu: { + duplicateOnclick: `cloneGradient('${g.id}')`, + hideOnclick: `toggleCardHidden('gradients','${g.id}')`, + deleteOnclick: g.is_builtin ? undefined : `deleteGradient('${g.id}')`, + }, + }, + body: { + extraHtml: previewBlock, + }, + foot: { + patchState: 'idle', + patchLabel: 'PRESET', + iconActions, + }, + }; + return wrapCard({ type: 'template-card', dataAttr: 'data-id', id: g.id, mod }); }; // Build item arrays for all sections diff --git a/server/src/ledgrab/static/js/features/sync-clocks.ts b/server/src/ledgrab/static/js/features/sync-clocks.ts index 7c8973a..1074642 100644 --- a/server/src/ledgrab/static/js/features/sync-clocks.ts +++ b/server/src/ledgrab/static/js/features/sync-clocks.ts @@ -9,6 +9,7 @@ import { Modal } from '../core/modal.ts'; import { showToast, showConfirm } from '../core/ui.ts'; import { ICON_CLOCK, ICON_CLONE, ICON_EDIT, ICON_START, ICON_PAUSE } from '../core/icons.ts'; import { wrapCard } from '../core/card-colors.ts'; +import type { ModCardOpts, ModChipOpts, LedState } from '../core/mod-card.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts'; import { loadPictureSources } from './streams.ts'; import type { SyncClock } from '../types.ts'; @@ -223,35 +224,51 @@ function _formatElapsed(seconds: number): string { } export function createSyncClockCard(clock: SyncClock) { - const statusIcon = clock.is_running ? ICON_START : ICON_PAUSE; - const statusLabel = clock.is_running ? t('sync_clock.status.running') : t('sync_clock.status.paused'); - const toggleAction = clock.is_running ? 'pause' : 'resume'; - const toggleTitle = clock.is_running ? t('sync_clock.action.pause') : t('sync_clock.action.resume'); + const isRunning = !!clock.is_running; + const statusLabel = isRunning ? t('sync_clock.status.running') : t('sync_clock.status.paused'); + const toggleAction = isRunning ? 'pause' : 'resume'; + const toggleTitle = isRunning ? t('sync_clock.action.pause') : t('sync_clock.action.resume'); const elapsedLabel = clock.elapsed_time != null ? _formatElapsed(clock.elapsed_time) : null; - return wrapCard({ - type: 'template-card', - dataAttr: 'data-id', - id: clock.id, - removeOnclick: `deleteSyncClock('${clock.id}')`, - removeTitle: t('common.delete'), - content: ` -
    -
    ${ICON_CLOCK} ${escapeHtml(clock.name)}
    -
    -
    - ${statusIcon} ${statusLabel} - ${ICON_CLOCK} ${clock.speed}x - ${elapsedLabel ? `⏱ ${elapsedLabel}` : ''} -
    - ${renderTagChips(clock.tags)} - ${clock.description ? `
    ${escapeHtml(clock.description)}
    ` : ''}`, - actions: ` - - - - `, - }); + const chips: ModChipOpts[] = [ + { icon: ICON_CLOCK, text: `${clock.speed}x` }, + ]; + if (elapsedLabel) { + chips.push({ text: `⏱ ${elapsedLabel}`, title: t('sync_clock.elapsed') }); + } + + const leds: LedState[] = isRunning ? ['on', 'blink'] : ['off']; + + const mod: ModCardOpts = { + head: { + badge: { text: 'CLK · MASTER' }, + name: clock.name, + metaHtml: escapeHtml(`${statusLabel} · ${clock.speed}x`), + leds, + menu: { + duplicateOnclick: `cloneSyncClock('${clock.id}')`, + hideOnclick: `toggleCardHidden('sync-clocks','${clock.id}')`, + deleteOnclick: `deleteSyncClock('${clock.id}')`, + }, + }, + body: { + desc: clock.description || undefined, + chips, + }, + foot: { + patchState: isRunning ? 'live' : 'idle', + patchLabel: isRunning ? 'TICKING' : 'PAUSED', + iconActions: [ + { icon: isRunning ? ICON_PAUSE : ICON_START, onclick: '', title: toggleTitle, dataAttrs: { 'data-action': toggleAction } }, + { icon: ICON_CLOCK, onclick: '', title: t('sync_clock.action.reset'), dataAttrs: { 'data-action': 'reset' } }, + { icon: ICON_EDIT, onclick: '', title: t('common.edit'), dataAttrs: { 'data-action': 'edit' } }, + ], + }, + running: isRunning, + }; + const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-id', id: clock.id, mod }); + const tagsHtml = renderTagChips(clock.tags); + return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}
    `) : cardHtml; } // ── Event delegation for sync-clock card actions ── diff --git a/server/src/ledgrab/static/js/features/update.ts b/server/src/ledgrab/static/js/features/update.ts index a4fe994..ffe6a4e 100644 --- a/server/src/ledgrab/static/js/features/update.ts +++ b/server/src/ledgrab/static/js/features/update.ts @@ -10,6 +10,12 @@ import { ICON_EXTERNAL_LINK, ICON_X, ICON_DOWNLOAD } from '../core/icons.ts'; // ─── State ────────────────────────────────────────────────── +interface UpdateAsset { + name: string; + size: number; + download_url: string; +} + interface UpdateRelease { version: string; tag: string; @@ -17,6 +23,7 @@ interface UpdateRelease { body: string; prerelease: boolean; published_at: string; + assets?: UpdateAsset[]; } interface UpdateStatus { @@ -179,7 +186,15 @@ function _applyStatus(status: UpdateStatus): void { && status.release != null && status.release.version !== dismissed; + // The header badge + floating banner respect the user's dismissal so they + // don't keep nagging on every page reload. The rail badge inside the + // settings modal is the *destination* the user is told to visit — it + // should keep flagging the update even after dismissal so they can find + // the Updates tab again. Hide it only when there's genuinely no update. + const hasAnyUpdate = status.has_update && status.release != null; + _setVersionBadgeUpdate(hasVisibleUpdate); + _setRailUpdateBadge(hasAnyUpdate, status.release?.version ?? ''); if (hasVisibleUpdate) { _showBanner(status); @@ -190,6 +205,24 @@ function _applyStatus(status: UpdateStatus): void { _renderUpdatePanel(status); } +/** Toggle the small badge on the Updates rail-button so the sidebar + * reflects "update available" the same way the in-app version badge does. + * Runs on every status fetch / WS event so the indicator stays in sync. */ +function _setRailUpdateBadge(visible: boolean, version: string): void { + const badge = document.getElementById('settings-rail-update-badge'); + if (!badge) return; + badge.hidden = !visible; + if (visible) { + // Numeric "1" reads cleaner than the version string in the small pill. + badge.textContent = '1'; + if (version) { + badge.setAttribute('title', t('update.available').replace('{version}', version)); + } + } else { + badge.removeAttribute('title'); + } +} + // ─── WS event handlers ───────────────────────────────────── export function initUpdateListener(): void { @@ -282,6 +315,8 @@ function _getChannelItems(): { value: string; icon: string; label: string; desc: } export function initUpdateSettingsPanel(): void { + // The IconSelects auto-persist on change — there is no longer a manual + // Save button in the Auto-Check section. if (!_channelIconSelect) { const sel = document.getElementById('update-channel') as HTMLSelectElement | null; if (sel) { @@ -289,6 +324,7 @@ export function initUpdateSettingsPanel(): void { target: sel, items: _getChannelItems(), columns: 2, + onChange: () => saveUpdateSettings(), }); } } @@ -299,9 +335,16 @@ export function initUpdateSettingsPanel(): void { target: sel, items: _getIntervalItems(), columns: 3, + onChange: () => saveUpdateSettings(), }); } } + // Toggle auto-saves on every flip. + const enabledEl = document.getElementById('update-enabled') as HTMLInputElement | null; + if (enabledEl && !enabledEl.dataset.autosaveBound) { + enabledEl.addEventListener('change', () => { saveUpdateSettings(); }); + enabledEl.dataset.autosaveBound = '1'; + } } export async function loadUpdateSettings(): Promise { @@ -331,12 +374,17 @@ export async function loadUpdateSettings(): Promise { await loadUpdateStatus(); } +/** Persist auto-check settings. The Save button has been removed — + * this is invoked silently from change-listeners on the three fields + * (enabled toggle, interval IconSelect, channel IconSelect). + * Errors still surface as toasts; success is silent. */ export async function saveUpdateSettings(): Promise { const enabled = (document.getElementById('update-enabled') as HTMLInputElement)?.checked ?? true; const intervalStr = (document.getElementById('update-interval') as HTMLSelectElement)?.value ?? '24'; const check_interval_hours = parseFloat(intervalStr); const channelVal = (document.getElementById('update-channel') as HTMLSelectElement)?.value ?? 'false'; const include_prerelease = channelVal === 'true'; + if (Number.isNaN(check_interval_hours)) return; try { const resp = await fetchWithAuth('/system/update/settings', { @@ -347,7 +395,6 @@ export async function saveUpdateSettings(): Promise { const err = await resp.json().catch(() => ({})); throw new Error(err.detail || `HTTP ${resp.status}`); } - showToast(t('update.settings_saved'), 'success'); } catch (err) { showToast(t('update.settings_save_error') + ': ' + (err as Error).message, 'error'); } @@ -358,16 +405,36 @@ function _renderUpdatePanel(status: UpdateStatus): void { if (currentEl) currentEl.textContent = `v${status.current_version}`; const statusEl = document.getElementById('update-status-text'); + const card = document.getElementById('update-status-card'); + const meta = document.getElementById('update-status-meta'); + + let state: 'available' | 'error' | 'updated' | 'checking' = 'updated'; + let metaText = ''; if (statusEl) { if (status.has_update && status.release) { statusEl.textContent = t('update.available').replace('{version}', status.release.version); - statusEl.style.color = 'var(--warning-color)'; + statusEl.style.color = ''; + state = 'available'; + metaText = t('update.pill.available'); } else if (status.last_error) { statusEl.textContent = t('update.check_error') + ': ' + status.last_error; - statusEl.style.color = 'var(--danger-color)'; + statusEl.style.color = ''; + state = 'error'; + metaText = t('update.pill.error'); } else { statusEl.textContent = t('update.up_to_date'); - statusEl.style.color = 'var(--primary-color)'; + statusEl.style.color = ''; + state = 'updated'; + metaText = t('update.pill.updated'); + } + } + if (card) card.setAttribute('data-state', state); + if (meta) { + if (metaText) { + meta.textContent = metaText; + meta.hidden = false; + } else { + meta.hidden = true; } } @@ -424,15 +491,472 @@ function _renderUpdatePanel(status: UpdateStatus): void { // ─── Release Notes Overlay ───────────────────────────────── +function _setRnText(id: string, text: string | null): void { + const el = document.getElementById(id); + if (!el) return; + if (text) { + el.textContent = text; + el.hidden = false; + } else { + el.textContent = ''; + el.hidden = true; + } +} + +function _setRnChipShown(id: string, shown: boolean): void { + const el = document.getElementById(id); + if (el) el.hidden = !shown; +} + +function _formatPublished(iso: string | null | undefined): string | null { + if (!iso) return null; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return null; + // Compact ISO-like form: YYYY-MM-DD (matches the mono "instrument" tone). + const yyyy = d.getFullYear(); + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + return `${yyyy}-${mm}-${dd}`; +} + +/** Populate the redesigned overlay header from the cached release status. */ +function _renderReleaseNotesHeader(): void { + const release = _lastStatus?.release ?? null; + + const name = release?.name?.trim() || ''; + const version = release?.version?.trim() || ''; + const nameEl = document.getElementById('release-notes-name'); + if (nameEl) { + nameEl.textContent = name || t('update.release_notes'); + } + + // Only show the version accent when the name doesn't already contain + // the version — avoids rendering "LedGrab v0.5.0 v0.5.0". + const nameLower = name.toLowerCase(); + const verLower = version.toLowerCase(); + const nameHasVersion = !!verLower && ( + nameLower.includes(verLower) || nameLower.includes('v' + verLower) + ); + if (version && !nameHasVersion) { + _setRnText('release-notes-version', `v${version}`); + } else { + _setRnText('release-notes-version', null); + } + + const tag = release?.tag?.trim() || null; + _setRnText('release-notes-tag', tag); + _setRnChipShown('release-notes-tag-chip', !!tag); + + const dateText = _formatPublished(release?.published_at); + _setRnText('release-notes-date', dateText); + _setRnChipShown('release-notes-date-chip', !!dateText); + + _setRnChipShown('release-notes-pre-chip', !!release?.prerelease); + + const metaWrap = document.getElementById('release-notes-meta'); + if (metaWrap) { + const anyChipShown = !!tag || !!dateText || !!release?.prerelease; + metaWrap.hidden = !anyChipShown; + } + + // External link to the release on the upstream tracker (if available). + const ext = document.getElementById('release-notes-external') as HTMLAnchorElement | null; + if (ext) { + const url = _lastStatus?.releases_url; + if (url) { + ext.href = url; + ext.hidden = false; + } else { + ext.removeAttribute('href'); + ext.hidden = true; + } + } +} + +/** File extensions we recognise as direct downloads in markdown links. */ +const _RN_ASSET_EXTS = new Set([ + 'exe', 'msi', 'dmg', 'pkg', 'zip', 'tgz', 'tbz', '7z', 'rar', + 'apk', 'aab', 'ipa', 'deb', 'rpm', 'appimage', 'iso', 'img', + 'sig', 'asc', 'jar', 'whl', 'gz', 'bz2', 'xz', +]); + +interface AssetClass { type: 'asset'; ext: string } +interface ExternalClass { type: 'external' } +type LinkClass = AssetClass | ExternalClass | null; + +/** Classify a markdown link as a download asset, an external link, or + * neither (in-page anchor / same-origin page link). */ +function _classifyReleaseLink(href: string): LinkClass { + if (!href || href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('javascript:')) { + return null; + } + let url: URL; + try { + url = new URL(href, location.origin); + } catch { + return null; + } + const path = url.pathname; + const lower = path.toLowerCase(); + + // GitHub / Gitea release-asset and attachment paths are always downloads, + // even if the extension isn't one we recognise (e.g. unsigned binaries). + if (lower.includes('/releases/download/') || lower.includes('/attachments/')) { + const tarMatch = lower.match(/\.tar\.(gz|bz2|xz)$/); + if (tarMatch) return { type: 'asset', ext: 'tar.' + tarMatch[1] }; + const m = lower.match(/\.([a-z0-9]{1,8})$/); + return { type: 'asset', ext: m ? m[1] : 'bin' }; + } + + // Compound tar archives — must be checked before single-extension match. + const tarMatch = lower.match(/\.tar\.(gz|bz2|xz)$/); + if (tarMatch) return { type: 'asset', ext: 'tar.' + tarMatch[1] }; + + // Single-extension downloadable file. + const m = lower.match(/\.([a-z0-9]+)$/); + if (m && _RN_ASSET_EXTS.has(m[1])) { + return { type: 'asset', ext: m[1] }; + } + + // Anything off-origin we treat as external (gets a small ↗ glyph). + if (url.origin && url.origin !== location.origin) { + return { type: 'external' }; + } + + return null; +} + +/** Walk `` tags inside the rendered markdown and decorate each one + * according to its classification: download assets become rack-style + * chips with a download icon and an extension badge; external links + * get a small ↗ marker. Idempotent — safe to call multiple times. */ +function _decorateReleaseNotesLinks(root: HTMLElement): void { + const anchors = root.querySelectorAll('a[href]'); + anchors.forEach((a) => { + if (a.classList.contains('rn-dl') || a.classList.contains('rn-ext')) { + return; // already decorated + } + const cls = _classifyReleaseLink(a.getAttribute('href') || ''); + if (!cls) return; + + if (cls.type === 'asset') { + a.classList.add('rn-dl'); + // Hint to the browser to download rather than navigate. + if (!a.hasAttribute('download')) a.setAttribute('download', ''); + a.setAttribute('target', '_blank'); + a.setAttribute('rel', 'noopener noreferrer'); + + const icon = document.createElement('span'); + icon.className = 'rn-dl__icon'; + icon.setAttribute('aria-hidden', 'true'); + icon.innerHTML = ICON_DOWNLOAD; + a.prepend(icon); + + const badge = document.createElement('span'); + badge.className = 'rn-dl__ext'; + badge.textContent = cls.ext; + a.appendChild(badge); + } else { + a.classList.add('rn-ext'); + a.setAttribute('target', '_blank'); + a.setAttribute('rel', 'noopener noreferrer'); + } + }); +} + +/** Pretty-print a byte count as a compact human-readable string + * (`24.3 MB`, `512 KB`, `7 B`). Tabular-friendly — single decimal. */ +function _formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return ''; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let i = 0; + let n = bytes; + while (n >= 1024 && i < units.length - 1) { + n /= 1024; + i += 1; + } + return (i === 0 ? n.toFixed(0) : n.toFixed(n >= 100 ? 0 : 1)) + ' ' + units[i]; +} + +/** Extract the file extension (incl. compound `tar.gz/bz2/xz`) from a name. */ +function _assetExt(name: string): string { + const lower = name.toLowerCase(); + const tar = lower.match(/\.tar\.(gz|bz2|xz)$/); + if (tar) return 'tar.' + tar[1]; + const m = lower.match(/\.([a-z0-9]{1,8})$/); + return m ? m[1] : 'bin'; +} + +/** Build a `` with a class and text content. */ +function _span(cls: string, text?: string): HTMLSpanElement { + const el = document.createElement('span'); + el.className = cls; + if (text !== undefined) el.textContent = text; + return el; +} + +/** Validate a download URL — reject anything that isn't http(s) so a + * malicious upstream release can't smuggle a `javascript:` URL. */ +function _safeDownloadHref(raw: string): string | null { + try { + const u = new URL(raw, location.origin); + if (u.protocol === 'http:' || u.protocol === 'https:') { + return u.toString(); + } + } catch { + return null; + } + return null; +} + +/** Compute the trailing file extension (incl. compound `.tar.gz/.bz2/.xz`) + * on a filename string, or '' if there isn't one. Lower-cased. */ +function _extOf(name: string): string { + const lower = name.toLowerCase(); + const tar = lower.match(/\.tar\.(gz|bz2|xz)$/); + if (tar) return '.tar.' + tar[1]; + const m = lower.match(/\.[a-z0-9]+$/); + return m ? m[0] : ''; +} + +/** Tokenise a filename for fuzzy matching: split on `-_.\s` and keep + * tokens of length ≥ 2 (drops the version segment to noise tokens). */ +function _fileTokens(name: string): Set { + return new Set( + name.toLowerCase() + .split(/[-_.\s]+/) + .filter((t) => t.length >= 2), + ); +} + +/** Find the asset whose name best matches a filename string from the + * release-notes markdown. Returns the asset on exact match, or on a + * same-extension fuzzy match where the user-supplied tokens cover ≥ 60% + * of the asset's tokens (handles "LedGrab-v0.5.0-setup.exe" → the + * full "LedGrab-v0.5.0-win-x64-setup.exe" asset). */ +function _findMatchingAsset(text: string, assets: UpdateAsset[]): UpdateAsset | null { + const lower = text.trim().toLowerCase(); + if (!lower || !lower.includes('.')) return null; + + // 1. Exact name match (basename only — strip any path component). + const basename = lower.replace(/^.*[\\/]/, ''); + for (const a of assets) { + if (a.name.toLowerCase() === basename) return a; + } + + // 2. Fuzzy: same extension + significant token overlap. + const ext = _extOf(basename); + if (!ext) return null; + + const userTokens = _fileTokens(basename); + if (userTokens.size === 0) return null; + + let best: UpdateAsset | null = null; + let bestScore = 0; + for (const a of assets) { + const aLower = a.name.toLowerCase(); + if (!aLower.endsWith(ext)) continue; + const aTokens = _fileTokens(a.name); + if (aTokens.size === 0) continue; + let overlap = 0; + userTokens.forEach((t) => { if (aTokens.has(t)) overlap += 1; }); + // Score by how many of the user's tokens the asset covers. + const score = overlap / userTokens.size; + if (score > bestScore) { + bestScore = score; + best = a; + } + } + return bestScore >= 0.6 ? best : null; +} + +/** Wrap any inline `` element whose text matches a release asset + * in an `` so the file becomes clickable. Skips code blocks (`
    `)
    + *  and codes already inside an ``. Idempotent. */
    +function _linkInlineAssetCode(root: HTMLElement): void {
    +    const assets = (_lastStatus?.release?.assets ?? []).filter(
    +        (a) => _safeDownloadHref(a.download_url) !== null,
    +    );
    +    if (assets.length === 0) return;
    +
    +    const codes = root.querySelectorAll('code');
    +    codes.forEach((code) => {
    +        if (code.closest('pre')) return;        // skip code-fence blocks
    +        if (code.closest('a')) return;          // already linked
    +        const text = code.textContent || '';
    +        const asset = _findMatchingAsset(text, assets);
    +        if (!asset) return;
    +        const href = _safeDownloadHref(asset.download_url);
    +        if (!href) return;
    +
    +        const a = document.createElement('a');
    +        a.href = href;
    +        a.setAttribute('download', '');
    +        a.target = '_blank';
    +        a.rel = 'noopener noreferrer';
    +        a.classList.add('rn-code-link');
    +        // Build a richer tooltip: filename · description · size, when available.
    +        const sizeText = _formatBytes(asset.size);
    +        const descText = _describeAsset(asset.name);
    +        const tooltipParts = [asset.name];
    +        if (descText) tooltipParts.push(descText);
    +        if (sizeText) tooltipParts.push(sizeText);
    +        a.title = tooltipParts.join(' · ');
    +
    +        const parent = code.parentNode;
    +        if (!parent) return;
    +        parent.insertBefore(a, code);
    +        a.appendChild(code);
    +    });
    +}
    +
    +/** Recognise checksum / signature side-files. These are 100-byte text
    + *  artifacts paired with a binary, useful only for power-user
    + *  verification — they make the rack noisy without adding download
    + *  value, so we hide them. */
    +function _isChecksumAsset(name: string): boolean {
    +    const lower = name.toLowerCase();
    +    return /\.(sha256|sha512|sha1|sha384|md5|sig|asc|sigstore|sbom|json\.sig)$/.test(lower)
    +        || /\.(sha256|sha512|sha1|md5)\.txt$/.test(lower);
    +}
    +
    +/** Derive a short human description for an asset from its filename
    + *  pattern (installer / portable / archive / mobile build). Falls back
    + *  to '' when nothing recognisable matches — the description line is
    + *  then hidden. Strings come from the locale bundle. */
    +function _describeAsset(name: string): string {
    +    const lower = name.toLowerCase();
    +
    +    // Windows family
    +    if (/setup\.exe$/.test(lower)) return t('update.assets.desc.windows_installer');
    +    if (/\.msi$/.test(lower))      return t('update.assets.desc.windows_msi');
    +    if (/\.exe$/.test(lower) && /\bwin/.test(lower)) {
    +        return t('update.assets.desc.windows_exe');
    +    }
    +    if (/\.zip$/.test(lower) && /\bwin/.test(lower)) {
    +        return t('update.assets.desc.windows_portable');
    +    }
    +
    +    // Linux family
    +    if (/\.appimage$/.test(lower)) return t('update.assets.desc.linux_appimage');
    +    if (/\.deb$/.test(lower))      return t('update.assets.desc.linux_deb');
    +    if (/\.rpm$/.test(lower))      return t('update.assets.desc.linux_rpm');
    +    if (/\.flatpak$/.test(lower) || /\.snap$/.test(lower)) {
    +        return t('update.assets.desc.linux_sandbox');
    +    }
    +    if (/(\.tar\.(gz|bz2|xz)|\.tgz)$/.test(lower) && /linux/.test(lower)) {
    +        return t('update.assets.desc.linux_tarball');
    +    }
    +
    +    // macOS family
    +    if (/\.dmg$/.test(lower)) return t('update.assets.desc.macos_dmg');
    +    if (/\.pkg$/.test(lower)) return t('update.assets.desc.macos_installer');
    +
    +    // Android / iOS
    +    if (/\.apk$/.test(lower)) return t('update.assets.desc.android');
    +    if (/\.aab$/.test(lower)) return t('update.assets.desc.android_bundle');
    +    if (/\.ipa$/.test(lower)) return t('update.assets.desc.ios');
    +
    +    // Generic archive fallbacks
    +    if (/\.zip$/.test(lower))                       return t('update.assets.desc.zip_archive');
    +    if (/(\.tar\.(gz|bz2|xz)|\.tgz)$/.test(lower))  return t('update.assets.desc.tarball');
    +    if (/\.(7z|rar)$/.test(lower))                  return t('update.assets.desc.archive');
    +
    +    return '';
    +}
    +
    +/** Render the explicit "Downloads" rack at the top of the overlay body
    + *  using the `assets` list from the release JSON. Each asset becomes a
    + *  rack-style chip with icon + filename + description + size + ext badge.
    + *  Checksum / signature side-files are filtered out. The rack hides when
    + *  there are no installable assets. DOM nodes are built imperatively so
    + *  attacker-controlled `name`/`url` strings can never inject HTML. */
    +function _renderReleaseAssets(): void {
    +    const wrap = document.getElementById('release-notes-assets');
    +    if (!wrap) return;
    +
    +    const assets = (_lastStatus?.release?.assets ?? []).filter(
    +        (a) => _safeDownloadHref(a.download_url) !== null
    +            && !_isChecksumAsset(a.name),
    +    );
    +    if (assets.length === 0) {
    +        wrap.hidden = true;
    +        wrap.replaceChildren();
    +        return;
    +    }
    +    wrap.hidden = false;
    +
    +    // Header row: dot + title + count pill
    +    const head = document.createElement('div');
    +    head.className = 'rn-assets__head';
    +    const dot = _span('rn-assets__dot');
    +    dot.setAttribute('aria-hidden', 'true');
    +    head.append(
    +        dot,
    +        _span('rn-assets__title', t('update.assets.title')),
    +        _span('rn-assets__count', String(assets.length)),
    +    );
    +
    +    // Asset list
    +    const list = document.createElement('div');
    +    list.className = 'rn-assets__list';
    +
    +    for (const a of assets) {
    +        const href = _safeDownloadHref(a.download_url);
    +        if (!href) continue;
    +
    +        const row = document.createElement('a');
    +        row.className = 'rn-asset';
    +        row.href = href;
    +        row.setAttribute('download', '');
    +        row.target = '_blank';
    +        row.rel = 'noopener noreferrer';
    +        row.title = a.name;
    +
    +        // Icon — ICON_DOWNLOAD is a known-safe SVG string from our own bundle.
    +        const icon = _span('rn-asset__icon');
    +        icon.setAttribute('aria-hidden', 'true');
    +        icon.innerHTML = ICON_DOWNLOAD;
    +
    +        // Filename + description stacked in a single column so they
    +        // share the 1fr grid slot and the description doesn't push the
    +        // size / extension chips around.
    +        const col = document.createElement('div');
    +        col.className = 'rn-asset__col';
    +        col.append(_span('rn-asset__name', a.name));
    +        const desc = _describeAsset(a.name);
    +        if (desc) col.append(_span('rn-asset__desc', desc));
    +
    +        const ext = _span('rn-asset__ext', _assetExt(a.name));
    +
    +        row.append(icon, col);
    +        const sizeText = _formatBytes(a.size);
    +        if (sizeText) row.append(_span('rn-asset__size', sizeText));
    +        row.append(ext);
    +
    +        list.append(row);
    +    }
    +
    +    wrap.replaceChildren(head, list);
    +}
    +
     export function openReleaseNotes(): void {
         const overlay = document.getElementById('release-notes-overlay');
         const content = document.getElementById('release-notes-content');
    -    if (overlay && content) {
    -        import('marked').then(({ marked }) => {
    -            content.innerHTML = marked.parse(_releaseNotesBody) as string;
    -            overlay.style.display = 'flex';
    -        });
    -    }
    +    if (!overlay || !content) return;
    +
    +    _renderReleaseNotesHeader();
    +
    +    import('marked').then(({ marked }) => {
    +        content.innerHTML = marked.parse(_releaseNotesBody) as string;
    +        _linkInlineAssetCode(content);
    +        _decorateReleaseNotesLinks(content);
    +        overlay.style.display = 'flex';
    +        // Restart the staggered reveal animation each time we open.
    +        // Forcing a reflow re-triggers `animation` on the freshly-parsed children.
    +        // eslint-disable-next-line @typescript-eslint/no-unused-expressions
    +        void content.offsetWidth;
    +    });
     }
     
     export function closeReleaseNotes(): void {
    diff --git a/server/src/ledgrab/static/js/features/value-sources.ts b/server/src/ledgrab/static/js/features/value-sources.ts
    index fb74dbd..28f0e5d 100644
    --- a/server/src/ledgrab/static/js/features/value-sources.ts
    +++ b/server/src/ledgrab/static/js/features/value-sources.ts
    @@ -27,6 +27,7 @@ import {
         ICON_HOME, ICON_RAINBOW, ICON_LINK, ICON_DROPLETS, ICON_GAMEPAD,
     } from '../core/icons.ts';
     import { wrapCard } from '../core/card-colors.ts';
    +import type { ModCardOpts, ModChipOpts } from '../core/mod-card.ts';
     import { TagInput, renderTagChips } from '../core/tag-input.ts';
     import { openAuthedWs } from '../core/ws-auth.ts';
     import { IconSelect, showTypePicker } from '../core/icon-select.ts';
    @@ -1228,46 +1229,63 @@ function _renderVsColorSwatch() {
     
     // ── Card rendering (used by streams.js) ───────────────────────
     
    -export function createValueSourceCard(src: ValueSource) {
    -    const icon = getValueSourceIcon(src.source_type);
    +const VALUE_BADGE: Record = {
    +    static: 'VALUE · K',
    +    animated: 'VALUE · LFO',
    +    audio: 'VALUE · AUDIO',
    +    adaptive_time: 'VALUE · TIME',
    +    daylight: 'VALUE · SUN',
    +    adaptive_scene: 'VALUE · SCENE',
    +    static_color: 'VALUE · RGB',
    +    animated_color: 'VALUE · RGB·LFO',
    +    adaptive_time_color: 'VALUE · RGB·TIME',
    +    ha_entity: 'VALUE · HA',
    +    gradient_map: 'VALUE · MAP',
    +    css_extract: 'VALUE · STRIP',
    +    system_metrics: 'VALUE · SYS',
    +};
    +
    +function _valueSourceChipsAndExtras(src: ValueSource): { chips: ModChipOpts[]; metaText: string; extra: string } {
    +    const chips: ModChipOpts[] = [];
    +    let metaText: string = src.source_type;
    +    let extra = '';
     
    -    let propsHtml = '';
         if (src.source_type === 'static') {
    -        propsHtml = `${ICON_LED_PREVIEW} ${t('value_source.type.static')}: ${src.value ?? 1.0}`;
    +        chips.push({ icon: ICON_LED_PREVIEW, text: `${src.value ?? 1.0}`, title: t('value_source.type.static') });
    +        metaText = `${t('value_source.type.static')} · ${src.value ?? 1.0}`;
         } else if (src.source_type === 'animated') {
             const waveLabel = src.waveform || 'sine';
    -        propsHtml = `
    -            ${ICON_ACTIVITY} ${escapeHtml(waveLabel)}
    -            ${ICON_TIMER} ${src.speed ?? 10} cpm
    -            ${ICON_MOVE_VERTICAL} ${src.min_value ?? 0}–${src.max_value ?? 1}
    -        `;
    +        chips.push({ icon: ICON_ACTIVITY, text: waveLabel });
    +        chips.push({ icon: ICON_TIMER, text: `${src.speed ?? 10} cpm` });
    +        chips.push({ icon: ICON_MOVE_VERTICAL, text: `${src.min_value ?? 0}–${src.max_value ?? 1}` });
    +        metaText = `${waveLabel} · ${src.speed ?? 10} cpm`;
         } else if (src.source_type === 'audio') {
             const audioSrc = _cachedAudioSources.find(a => a.id === src.audio_source_id);
             const audioName = audioSrc ? audioSrc.name : (src.audio_source_id || '-');
             const audioSection = audioSrc ? (audioSrc.source_type === 'processed' ? 'audio-processed' : 'audio-capture') : 'audio-capture';
             const audioTab = audioSrc ? (audioSrc.source_type === 'processed' ? 'audio_processed' : 'audio_capture') : 'audio_capture';
             const modeLabel = src.mode || 'rms';
    -        const audioBadge = audioSrc
    -            ? `${ICON_MUSIC} ${escapeHtml(audioName)}`
    -            : `${ICON_MUSIC} ${escapeHtml(audioName)}`;
    -        propsHtml = `
    -            ${audioBadge}
    -            ${ICON_TRENDING_UP} ${modeLabel.toUpperCase()}
    -            ${ICON_MOVE_VERTICAL} ${src.min_value ?? 0}–${src.max_value ?? 1}
    -        `;
    +        chips.push({
    +            icon: ICON_MUSIC, text: audioName, title: t('value_source.audio_source'),
    +            onclick: audioSrc ? `event.stopPropagation(); navigateToCard('streams','${audioTab}','${audioSection}','data-id','${src.audio_source_id}')` : undefined,
    +        });
    +        chips.push({ icon: ICON_TRENDING_UP, text: modeLabel.toUpperCase() });
    +        chips.push({ icon: ICON_MOVE_VERTICAL, text: `${src.min_value ?? 0}–${src.max_value ?? 1}` });
    +        metaText = `${audioName} · ${modeLabel.toUpperCase()}`;
         } else if (src.source_type === 'adaptive_time') {
             const pts = (src.schedule || []).length;
    -        propsHtml = `
    -            ${ICON_MAP_PIN} ${pts} ${t('value_source.schedule.points')}
    -            ${ICON_MOVE_VERTICAL} ${src.min_value ?? 0}–${src.max_value ?? 1}
    -        `;
    +        chips.push({ icon: ICON_MAP_PIN, text: `${pts} ${t('value_source.schedule.points')}` });
    +        chips.push({ icon: ICON_MOVE_VERTICAL, text: `${src.min_value ?? 0}–${src.max_value ?? 1}` });
    +        metaText = `${pts} schedule pts`;
         } else if (src.source_type === 'daylight') {
             if (src.use_real_time) {
    -            propsHtml = `${ICON_CLOCK} ${t('value_source.daylight.real_time')}`;
    +            chips.push({ icon: ICON_CLOCK, text: t('value_source.daylight.real_time') || 'Real-time' });
    +            metaText = t('value_source.daylight.real_time') || 'Real-time';
             } else {
    -            propsHtml = `${ICON_TIMER} ${t('value_source.daylight.speed_label')} ${src.speed ?? 1.0}x`;
    +            chips.push({ icon: ICON_TIMER, text: `${t('value_source.daylight.speed_label') || 'speed'} ${src.speed ?? 1.0}x` });
    +            metaText = `${src.speed ?? 1.0}x`;
             }
    -        propsHtml += `${ICON_MOVE_VERTICAL} ${src.min_value ?? 0}\u2013${src.max_value ?? 1}`;
    +        chips.push({ icon: ICON_MOVE_VERTICAL, text: `${src.min_value ?? 0}\u2013${src.max_value ?? 1}` });
         } else if (src.source_type === 'adaptive_scene') {
             const ps = _cachedStreams.find(s => s.id === src.picture_source_id);
             const psName = ps ? ps.name : (src.picture_source_id || '-');
    @@ -1276,39 +1294,41 @@ export function createValueSourceCard(src: ValueSource) {
                 if (ps.stream_type === 'static_image') { psSubTab = 'static_image'; psSection = 'static-streams'; }
                 else if (ps.stream_type === 'processed') { psSubTab = 'processed'; psSection = 'proc-streams'; }
             }
    -        const psBadge = ps
    -            ? `${ICON_MONITOR} ${escapeHtml(psName)}`
    -            : `${ICON_MONITOR} ${escapeHtml(psName)}`;
    -        propsHtml = `
    -            ${psBadge}
    -            ${ICON_REFRESH} ${src.scene_behavior || 'complement'}
    -        `;
    +        chips.push({
    +            icon: ICON_MONITOR, text: psName, title: t('value_source.picture_source'),
    +            onclick: ps ? `event.stopPropagation(); navigateToCard('streams','${psSubTab}','${psSection}','data-stream-id','${src.picture_source_id}')` : undefined,
    +        });
    +        chips.push({ icon: ICON_REFRESH, text: src.scene_behavior || 'complement' });
    +        metaText = `${psName} · ${src.scene_behavior || 'complement'}`;
         } else if (src.source_type === 'static_color') {
             const rgb = (src as any).color || [255, 255, 255];
             const hex = rgbArrayToHex(rgb);
    -        propsHtml = ` ${hex}`;
    +        chips.push({
    +            icon: ``,
    +            text: hex,
    +        });
    +        metaText = hex;
         } else if (src.source_type === 'animated_color') {
             const colors = (src as any).colors || [];
    -        propsHtml = `
    -            ${ICON_ACTIVITY} ${colors.length} ${t('value_source.animated_color.color_count')}
    -            ${ICON_TIMER} ${(src as any).speed ?? 10} cpm
    -        `;
    +        chips.push({ icon: ICON_ACTIVITY, text: `${colors.length} ${t('value_source.animated_color.color_count') || 'colors'}` });
    +        chips.push({ icon: ICON_TIMER, text: `${(src as any).speed ?? 10} cpm` });
    +        metaText = `${colors.length} colors`;
         } else if (src.source_type === 'adaptive_time_color') {
             const pts = ((src as any).schedule || []).length;
    -        propsHtml = `${ICON_MAP_PIN} ${pts} ${t('value_source.schedule.points')}`;
    +        chips.push({ icon: ICON_MAP_PIN, text: `${pts} ${t('value_source.schedule.points')}` });
    +        metaText = `${pts} schedule pts`;
         } else if (src.source_type === 'ha_entity') {
             const haSrc = _cachedHASources.find(h => h.id === (src as any).ha_source_id);
             const haName = haSrc ? haSrc.name : ((src as any).ha_source_id || '-');
             const entityId = (src as any).entity_id || '';
             const attr = (src as any).attribute;
    -        const haBadge = haSrc
    -            ? `${ICON_HOME} ${escapeHtml(haName)}`
    -            : `${ICON_HOME} ${escapeHtml(haName)}`;
    -        propsHtml = `
    -            ${haBadge}
    -            ${ICON_LINK} ${escapeHtml(entityId)}${attr ? '.' + escapeHtml(attr) : ''}
    -            ${ICON_MOVE_VERTICAL} ${(src as any).min_ha_value ?? 0}\u2013${(src as any).max_ha_value ?? 100}
    -        `;
    +        chips.push({
    +            icon: ICON_HOME, text: haName, title: t('value_source.ha_source'),
    +            onclick: haSrc ? `event.stopPropagation(); navigateToCard('integrations','home_assistant','ha-sources','data-id','${(src as any).ha_source_id}')` : undefined,
    +        });
    +        chips.push({ icon: ICON_LINK, text: `${entityId}${attr ? '.' + attr : ''}` });
    +        chips.push({ icon: ICON_MOVE_VERTICAL, text: `${(src as any).min_ha_value ?? 0}\u2013${(src as any).max_ha_value ?? 100}` });
    +        metaText = `${haName} \u00b7 ${entityId}`;
         } else if (src.source_type === 'gradient_map') {
             const inputVs = _cachedValueSources.find(v => v.id === (src as any).value_source_id);
             const inputName = inputVs ? inputVs.name : ((src as any).value_source_id || '-');
    @@ -1319,53 +1339,70 @@ export function createValueSourceCard(src: ValueSource) {
             const gradientCss = stops.length >= 2
                 ? `linear-gradient(to right, ${stops.map((s: any) => `rgb(${s.color.join(',')}) ${(s.position * 100).toFixed(0)}%`).join(', ')})`
                 : '#333';
    -        const inputBadge = inputVs
    -            ? `${ICON_LINK} ${escapeHtml(inputName)}`
    -            : `${ICON_LINK} ${escapeHtml(inputName)}`;
    -        const gradBadge = grad
    -            ? `${ICON_RAINBOW} ${escapeHtml(gradName)}`
    -            : `${ICON_RAINBOW} ${escapeHtml(gradName)}`;
    -        propsHtml = `
    -            ${inputBadge}
    -            ${gradBadge}
    -            
    - `; + chips.push({ + icon: ICON_LINK, text: inputName, title: t('value_source.gradient_map.input'), + onclick: inputVs ? `event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${(src as any).value_source_id}')` : undefined, + }); + chips.push({ + icon: ICON_RAINBOW, text: gradName, title: t('value_source.gradient_map.gradient'), + onclick: grad ? `event.stopPropagation(); navigateToCard('streams','gradients','gradients','data-id','${(src as any).gradient_id}')` : undefined, + }); + extra = `
    `; + metaText = `${inputName} \u2192 ${gradName}`; } else if (src.source_type === 'css_extract') { const cssSrc = _cachedColorStripSources.find(c => c.id === (src as any).color_strip_source_id); const cssName = cssSrc ? cssSrc.name : ((src as any).color_strip_source_id || '-'); const ledStart = (src as any).led_start ?? 0; const ledEnd = (src as any).led_end ?? -1; const rangeLabel = ledEnd < 0 ? `${ledStart}\u2013all` : `${ledStart}\u2013${ledEnd}`; - const cssBadge = cssSrc - ? `${ICON_DROPLETS} ${escapeHtml(cssName)}` - : `${ICON_DROPLETS} ${escapeHtml(cssName)}`; - propsHtml = ` - ${cssBadge} - ${ICON_MOVE_VERTICAL} LED ${rangeLabel} - `; + chips.push({ + icon: ICON_DROPLETS, text: cssName, title: t('value_source.css_extract.source'), + onclick: cssSrc ? `event.stopPropagation(); navigateToCard('streams','color_strip','color-strips','data-css-id','${(src as any).color_strip_source_id}')` : undefined, + }); + chips.push({ icon: ICON_MOVE_VERTICAL, text: `LED ${rangeLabel}` }); + metaText = `${cssName} \u00b7 ${rangeLabel}`; } else if (src.source_type === 'system_metrics') { const metricLabel = t(`value_source.metric.${(src as any).metric}`) || (src as any).metric; - propsHtml = `${ICON_ACTIVITY} ${escapeHtml(metricLabel)}`; + chips.push({ icon: ICON_ACTIVITY, text: metricLabel }); + metaText = metricLabel; } - return wrapCard({ - type: 'template-card', - dataAttr: 'data-id', - id: src.id, - removeOnclick: `deleteValueSource('${src.id}')`, - removeTitle: t('common.delete'), - content: ` -
    -
    ${icon} ${escapeHtml(src.name)}
    -
    -
    ${propsHtml}
    - ${renderTagChips(src.tags)} - ${src.description ? `
    ${escapeHtml(src.description)}
    ` : ''}`, - actions: ` - - - `, - }); + return { chips, metaText, extra }; +} + +export function createValueSourceCard(src: ValueSource) { + const { chips, metaText, extra } = _valueSourceChipsAndExtras(src); + const badgeText = VALUE_BADGE[src.source_type] || 'VALUE \u00b7 IN'; + + const mod: ModCardOpts = { + head: { + badge: { text: badgeText }, + name: src.name, + metaHtml: escapeHtml(metaText), + leds: ['off'], + menu: { + duplicateOnclick: `cloneValueSource('${src.id}')`, + hideOnclick: `toggleCardHidden('value-sources','${src.id}')`, + deleteOnclick: `deleteValueSource('${src.id}')`, + }, + }, + body: { + desc: src.description || undefined, + chips: chips.length ? chips : undefined, + extraHtml: extra || undefined, + }, + foot: { + patchState: 'idle', + patchLabel: 'VALUE', + iconActions: [ + { icon: ICON_TEST, onclick: `testValueSource('${src.id}')`, title: t('value_source.test') }, + { icon: ICON_EDIT, onclick: `editValueSource('${src.id}')`, title: t('common.edit') }, + ], + }, + }; + const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-id', id: src.id, mod }); + const tagsHtml = renderTagChips(src.tags); + return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}
    `) : cardHtml; } // ── Helpers ─────────────────────────────────────────────────── diff --git a/server/src/ledgrab/static/js/features/weather-sources.ts b/server/src/ledgrab/static/js/features/weather-sources.ts index da296c0..b75b753 100644 --- a/server/src/ledgrab/static/js/features/weather-sources.ts +++ b/server/src/ledgrab/static/js/features/weather-sources.ts @@ -11,6 +11,7 @@ import { ICON_CLONE, ICON_EDIT, ICON_TEST } from '../core/icons.ts'; import * as P from '../core/icon-paths.ts'; import { IconSelect } from '../core/icon-select.ts'; import { wrapCard } from '../core/card-colors.ts'; +import type { ModCardOpts, ModChipOpts } from '../core/mod-card.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts'; import type { WeatherSource } from '../types.ts'; @@ -267,33 +268,42 @@ export function weatherSourceGeolocate(): void { export function createWeatherSourceCard(source: WeatherSource) { const intervalMin = Math.round(source.update_interval / 60); const providerLabel = source.provider === 'open_meteo' ? 'Open-Meteo' : source.provider; + const coords = `${source.latitude.toFixed(1)}, ${source.longitude.toFixed(1)}`; - return wrapCard({ - type: 'template-card', - dataAttr: 'data-id', - id: source.id, - removeOnclick: `deleteWeatherSource('${source.id}')`, - removeTitle: t('common.delete'), - content: ` -
    -
    ${ICON_WEATHER} ${escapeHtml(source.name)}
    -
    -
    - ${ICON_WEATHER} ${providerLabel} - - ${P.mapPin} ${source.latitude.toFixed(1)}, ${source.longitude.toFixed(1)} - - - ${P.clock} ${intervalMin}min - -
    - ${renderTagChips(source.tags)} - ${source.description ? `
    ${escapeHtml(source.description)}
    ` : ''}`, - actions: ` - - - `, - }); + const chips: ModChipOpts[] = [ + { icon: ICON_WEATHER, text: providerLabel }, + { icon: _icon(P.mapPin), text: coords, title: `${source.latitude.toFixed(2)}, ${source.longitude.toFixed(2)}` }, + { icon: _icon(P.clock), text: `${intervalMin} min` }, + ]; + + const mod: ModCardOpts = { + head: { + badge: { text: 'WEATHER · IN' }, + name: source.name, + metaHtml: escapeHtml(`${providerLabel} · ${coords}`), + leds: ['on'], + menu: { + duplicateOnclick: `cloneWeatherSource('${source.id}')`, + hideOnclick: `toggleCardHidden('weather-sources','${source.id}')`, + deleteOnclick: `deleteWeatherSource('${source.id}')`, + }, + }, + body: { + desc: source.description || undefined, + chips, + }, + foot: { + patchState: 'live', + patchLabel: 'POLLING', + iconActions: [ + { icon: ICON_TEST, onclick: '', title: t('weather_source.test'), dataAttrs: { 'data-action': 'test' } }, + { icon: ICON_EDIT, onclick: '', title: t('common.edit'), dataAttrs: { 'data-action': 'edit' } }, + ], + }, + }; + const cardHtml = wrapCard({ type: 'template-card', dataAttr: 'data-id', id: source.id, mod }); + const tagsHtml = renderTagChips(source.tags); + return tagsHtml ? cardHtml.replace(/<\/div>\s*$/, `${tagsHtml}
    `) : cardHtml; } // ── Event delegation ── diff --git a/server/src/ledgrab/static/js/global.d.ts b/server/src/ledgrab/static/js/global.d.ts index 1f1c4f8..c556127 100644 --- a/server/src/ledgrab/static/js/global.d.ts +++ b/server/src/ledgrab/static/js/global.d.ts @@ -411,6 +411,7 @@ startTargetOverlay: (...args: any[]) => any; loadShutdownAction: (...args: any[]) => any; setShutdownAction: (...args: any[]) => any; saveExternalUrl: (...args: any[]) => any; + revertExternalUrl: (...args: any[]) => any; getBaseOrigin: (...args: any[]) => any; // ─── Appearance ─── diff --git a/server/src/ledgrab/static/locales/en.json b/server/src/ledgrab/static/locales/en.json index 813944f..bfbb886 100644 --- a/server/src/ledgrab/static/locales/en.json +++ b/server/src/ledgrab/static/locales/en.json @@ -373,6 +373,10 @@ "settings.external_url.saved": "External URL saved", "settings.external_url.save_error": "Failed to save external URL", "settings.general.title": "General Settings", + "settings.section.identity": "Identity", + "settings.section.connection": "Connection", + "settings.section.hardware": "Hardware", + "settings.section.behavior": "Behavior", "settings.capture.title": "Capture Settings", "settings.capture.saved": "Capture settings updated", "settings.capture.failed": "Failed to save capture settings", @@ -804,7 +808,11 @@ "dashboard.perf.patches.empty.idle": "Ready to launch", "dashboard.perf.patches.empty.none": "No patches yet", "dashboard.perf.total_fps": "Total FPS", - "dashboard.perf.total_capture_fps": "Total Capture FPS", + "dashboard.perf.total_capture_fps": "Total Source FPS", + "dashboard.perf.total_capture_fps_actual": "Total Capture FPS", + "dashboard.perf.network": "Network", + "dashboard.perf.device_latency": "Device Latency", + "dashboard.perf.send_timing": "Send Timing", "dashboard.perf.errors": "Errors", "dashboard.perf.devices": "Devices", "dashboard.perf.cpu": "CPU", @@ -983,6 +991,9 @@ "scenes.capture": "Capture", "scenes.activate": "Activate scene", "scenes.recapture": "Recapture current state", + "scenes.action.activate": "Activate", + "scenes.action.recapture": "Recapture", + "scenes.status.preset": "Preset", "scenes.delete": "Delete scene", "scenes.targets_count": "targets", "scenes.captured": "Scene captured", @@ -1001,9 +1012,6 @@ "scenes.error.delete_failed": "Failed to delete scene", "scenes.cloned": "Scene cloned", "scenes.error.clone_failed": "Failed to clone scene", - "time.hours_minutes": "{h}h {m}m", - "time.minutes_seconds": "{m}m {s}s", - "time.seconds": "{s}s", "dashboard.type.led": "LED", "dashboard.type.kc": "Key Colors", "aria.close": "Close", @@ -1771,12 +1779,12 @@ "search.action.disable": "Disable", "settings.backup.label": "Backup Configuration", "settings.backup.hint": "Download all configuration (devices, targets, streams, templates, automations) as a single JSON file.", - "settings.backup.button": "Download Backup", + "settings.backup.button": "Download", "settings.backup.success": "Backup downloaded successfully", "settings.backup.error": "Backup download failed", "settings.restore.label": "Restore Configuration", "settings.restore.hint": "Upload a previously downloaded backup file to replace all configuration. The server will restart automatically.", - "settings.restore.button": "Restore from Backup", + "settings.restore.button": "Restore", "settings.restore.confirm": "This will replace ALL configuration and restart the server. Are you sure?", "settings.restore.success": "Configuration restored", "settings.restore.error": "Restore failed", @@ -1855,6 +1863,15 @@ "settings.logs.filter.info_desc": "Info, warning, and errors", "settings.logs.filter.warning_desc": "Warnings and errors only", "settings.logs.filter.error_desc": "Errors only", + "settings.logs.stat.lines": "LINES", + "settings.logs.stat.warn": "WARN", + "settings.logs.stat.err": "ERR", + "settings.logs.patch.idle": "STANDBY", + "settings.logs.patch.connecting": "CONNECTING", + "settings.logs.patch.live": "STREAMING", + "settings.logs.patch.error": "OFFLINE", + "settings.logs.empty.title": "Awaiting log frames", + "settings.logs.empty.sub": "Connect the WebSocket stream to begin tailing.", "device.error.power_off_failed": "Failed to turn off device", "device.error.remove_failed": "Failed to remove device", "device.error.settings_load_failed": "Failed to load device settings", @@ -2256,6 +2273,7 @@ "settings.notifications.permission.state.granted": "Granted — OS toasts will appear", "settings.notifications.permission.state.denied": "Denied — change in browser settings", "settings.notifications.permission.state.default": "Not yet requested", + "settings.notifications.permission.hint": "The browser controls OS notification permission per site. Once denied, LedGrab can no longer ask again — you need to clear it in the browser. Click the site icon (lock) in the address bar → Site settings → Notifications → Allow, then reload.", "settings.notifications.test_button": "Send a test notification", "settings.notifications.saved": "Notification preferences saved", "settings.notifications.save_error": "Failed to save notification preferences", @@ -2267,6 +2285,33 @@ "settings.notifications.channel.os.desc": "System notification (works while the browser is in the background)", "settings.notifications.channel.both.label": "Both", "settings.notifications.channel.both.desc": "In-app toast and system notification", + "settings.notifications.permission.pill.granted": "GRANTED", + "settings.notifications.permission.pill.denied": "BLOCKED", + "settings.rail.group.workspace": "Workspace", + "settings.rail.group.system": "System", + "settings.save_bar.unsaved": "Unsaved changes in", + "settings.save_bar.revert": "Revert", + "settings.save_bar.save": "Save", + "settings.section.api_keys": "Identity & API", + "settings.section.server": "Server", + "settings.section.lifecycle": "Lifecycle", + "settings.section.destructive": "DESTRUCTIVE", + "settings.section.manual": "Manual", + "settings.section.notif_channels": "Channels", + "settings.section.notif_discovery": "Discovery", + "settings.section.notif_permission": "OS Permission", + "settings.api_keys.read_only": "Read-only", + "settings.api_keys.meta.one": "key", + "settings.api_keys.meta.many": "keys", + "settings.logs.sub": "Live tail of server log output, filterable by level. Opens in a full-screen overlay.", + "settings.restart.sub": "Bounce the LedGrab process. Active capture and connected devices will pause for ~3 seconds.", + "settings.restart.button": "Restart", + "settings.notif_matrix.col.event": "Event", + "settings.notif_matrix.event_count": "4 EVENTS", + "settings.auto_backup.pill.running": "RUNNING", + "update.pill.available": "UPDATE AVAILABLE", + "update.pill.error": "ERROR", + "update.pill.updated": "UP TO DATE", "notifications.unknown_device": "Unknown device", "notifications.device_online.title": "Device online", "notifications.device_online.body": "{device} is back online", @@ -2294,6 +2339,26 @@ "update.never": "never", "update.release_notes": "Release Notes", "update.view_release_notes": "View Release Notes", + "update.release_notes_hint": "What's new in the available version — read the changelog before applying.", + "update.release_notes_open": "Open", + "update.assets.title": "Downloads", + "update.assets.desc.windows_installer": "Windows installer — Start Menu shortcut, optional autostart, uninstaller", + "update.assets.desc.windows_portable": "Windows portable — unzip anywhere, run LedGrab.bat", + "update.assets.desc.windows_msi": "Windows MSI installer", + "update.assets.desc.windows_exe": "Windows executable", + "update.assets.desc.linux_tarball": "Linux archive — extract, run ./run.sh", + "update.assets.desc.linux_appimage": "Linux portable — single executable", + "update.assets.desc.linux_deb": "Debian / Ubuntu package", + "update.assets.desc.linux_rpm": "Fedora / RHEL package", + "update.assets.desc.linux_sandbox": "Sandboxed Linux package", + "update.assets.desc.macos_dmg": "macOS disk image — drag to Applications", + "update.assets.desc.macos_installer": "macOS installer package", + "update.assets.desc.android": "Android — sideload on Android 7.0+", + "update.assets.desc.android_bundle": "Android App Bundle (Play Store)", + "update.assets.desc.ios": "iOS application", + "update.assets.desc.zip_archive": "ZIP archive", + "update.assets.desc.tarball": "Tar archive", + "update.assets.desc.archive": "Compressed archive", "update.auto_check_label": "Auto-Check Settings", "update.auto_check_hint": "Periodically check for new releases in the background.", "update.enable": "Enable auto-check", diff --git a/server/src/ledgrab/static/locales/ru.json b/server/src/ledgrab/static/locales/ru.json index 7856abc..fe9141a 100644 --- a/server/src/ledgrab/static/locales/ru.json +++ b/server/src/ledgrab/static/locales/ru.json @@ -377,6 +377,10 @@ "settings.external_url.saved": "Внешний URL сохранён", "settings.external_url.save_error": "Не удалось сохранить внешний URL", "settings.general.title": "Основные Настройки", + "settings.section.identity": "Идентификация", + "settings.section.connection": "Подключение", + "settings.section.hardware": "Оборудование", + "settings.section.behavior": "Поведение", "settings.capture.title": "Настройки Захвата", "settings.capture.saved": "Настройки захвата обновлены", "settings.capture.failed": "Не удалось сохранить настройки захвата", @@ -785,7 +789,11 @@ "dashboard.perf.patches.empty.idle": "Готов к запуску", "dashboard.perf.patches.empty.none": "Каналов пока нет", "dashboard.perf.total_fps": "Общий FPS", - "dashboard.perf.total_capture_fps": "Общий FPS захвата", + "dashboard.perf.total_capture_fps": "Общий FPS источников", + "dashboard.perf.total_capture_fps_actual": "Общий FPS захвата", + "dashboard.perf.network": "Сеть", + "dashboard.perf.device_latency": "Задержка устройств", + "dashboard.perf.send_timing": "Время отправки", "dashboard.perf.errors": "Ошибки", "dashboard.perf.devices": "Устройства", "dashboard.perf.cpu": "ЦП", @@ -964,6 +972,9 @@ "scenes.capture": "Захват", "scenes.activate": "Активировать сцену", "scenes.recapture": "Перезахватить текущее состояние", + "scenes.action.activate": "Активировать", + "scenes.action.recapture": "Перезахват", + "scenes.status.preset": "Пресет", "scenes.delete": "Удалить сцену", "scenes.targets_count": "целей", "scenes.captured": "Сцена захвачена", @@ -982,9 +993,6 @@ "scenes.error.delete_failed": "Не удалось удалить сцену", "scenes.cloned": "Сцена клонирована", "scenes.error.clone_failed": "Не удалось клонировать сцену", - "time.hours_minutes": "{h}ч {m}м", - "time.minutes_seconds": "{m}м {s}с", - "time.seconds": "{s}с", "dashboard.type.led": "LED", "dashboard.type.kc": "Цвета клавиш", "aria.close": "Закрыть", @@ -1587,12 +1595,12 @@ "search.action.disable": "Отключить", "settings.backup.label": "Резервное копирование", "settings.backup.hint": "Скачать всю конфигурацию (устройства, цели, потоки, шаблоны, автоматизации) в виде одного JSON-файла.", - "settings.backup.button": "Скачать резервную копию", + "settings.backup.button": "Скачать", "settings.backup.success": "Резервная копия скачана", "settings.backup.error": "Ошибка скачивания резервной копии", "settings.restore.label": "Восстановление конфигурации", "settings.restore.hint": "Загрузите ранее сохранённый файл резервной копии для замены всей конфигурации. Сервер перезапустится автоматически.", - "settings.restore.button": "Восстановить из копии", + "settings.restore.button": "Восстановить", "settings.restore.confirm": "Это заменит ВСЮ конфигурацию и перезапустит сервер. Вы уверены?", "settings.restore.success": "Конфигурация восстановлена", "settings.restore.error": "Ошибка восстановления", @@ -1671,6 +1679,15 @@ "settings.logs.filter.info_desc": "Info, предупреждения и ошибки", "settings.logs.filter.warning_desc": "Только предупреждения и ошибки", "settings.logs.filter.error_desc": "Только ошибки", + "settings.logs.stat.lines": "СТРОК", + "settings.logs.stat.warn": "ВНИМ", + "settings.logs.stat.err": "ОШИБ", + "settings.logs.patch.idle": "ОЖИДАНИЕ", + "settings.logs.patch.connecting": "ПОДКЛЮЧЕНИЕ", + "settings.logs.patch.live": "ПОТОК", + "settings.logs.patch.error": "ОФЛАЙН", + "settings.logs.empty.title": "Ожидание журналов", + "settings.logs.empty.sub": "Подключите WebSocket-поток, чтобы начать трансляцию.", "device.error.power_off_failed": "Не удалось выключить устройство", "device.error.remove_failed": "Не удалось удалить устройство", "device.error.settings_load_failed": "Не удалось загрузить настройки устройства", @@ -1972,6 +1989,7 @@ "settings.notifications.permission.state.granted": "Разрешено — будут показываться уведомления ОС", "settings.notifications.permission.state.denied": "Запрещено — измените в настройках браузера", "settings.notifications.permission.state.default": "Разрешение ещё не запрошено", + "settings.notifications.permission.hint": "Браузер управляет разрешением на системные уведомления для каждого сайта отдельно. После отказа LedGrab уже не может запросить разрешение снова — его нужно сбросить в браузере. Нажмите на значок сайта (замок) в адресной строке → Настройки сайта → Уведомления → Разрешить, затем перезагрузите страницу.", "settings.notifications.test_button": "Отправить тестовое уведомление", "settings.notifications.saved": "Настройки уведомлений сохранены", "settings.notifications.save_error": "Не удалось сохранить настройки уведомлений", @@ -1983,6 +2001,33 @@ "settings.notifications.channel.os.desc": "Системное уведомление (работает в фоне браузера)", "settings.notifications.channel.both.label": "Оба", "settings.notifications.channel.both.desc": "Снэк и системное уведомление", + "settings.notifications.permission.pill.granted": "РАЗРЕШЕНО", + "settings.notifications.permission.pill.denied": "ЗАБЛОКИРОВАНО", + "settings.rail.group.workspace": "Рабочая зона", + "settings.rail.group.system": "Система", + "settings.save_bar.unsaved": "Несохранённые изменения в поле", + "settings.save_bar.revert": "Отменить", + "settings.save_bar.save": "Сохранить", + "settings.section.api_keys": "Идентификация и API", + "settings.section.server": "Сервер", + "settings.section.lifecycle": "Жизненный цикл", + "settings.section.destructive": "ОПАСНО", + "settings.section.manual": "Вручную", + "settings.section.notif_channels": "Каналы", + "settings.section.notif_discovery": "Обнаружение", + "settings.section.notif_permission": "Разрешение ОС", + "settings.api_keys.read_only": "Только чтение", + "settings.api_keys.meta.one": "ключ", + "settings.api_keys.meta.many": "ключей", + "settings.logs.sub": "Живой поток лога сервера с фильтром по уровню. Открывается в полноэкранном слое.", + "settings.restart.sub": "Перезапустить процесс LedGrab. Захват и подключенные устройства приостановятся примерно на 3 секунды.", + "settings.restart.button": "Перезапустить", + "settings.notif_matrix.col.event": "Событие", + "settings.notif_matrix.event_count": "4 СОБЫТИЯ", + "settings.auto_backup.pill.running": "АКТИВНО", + "update.pill.available": "ДОСТУПНО ОБНОВЛЕНИЕ", + "update.pill.error": "ОШИБКА", + "update.pill.updated": "АКТУАЛЬНО", "notifications.unknown_device": "Неизвестное устройство", "notifications.device_online.title": "Устройство в сети", "notifications.device_online.body": "{device} снова в сети", @@ -2010,6 +2055,26 @@ "update.never": "никогда", "update.release_notes": "Примечания к релизу", "update.view_release_notes": "Открыть примечания к релизу", + "update.release_notes_hint": "Что нового в доступной версии — прочитайте список изменений перед применением.", + "update.release_notes_open": "Открыть", + "update.assets.title": "Загрузки", + "update.assets.desc.windows_installer": "Установщик Windows — ярлык в меню «Пуск», опциональный автозапуск, деинсталлятор", + "update.assets.desc.windows_portable": "Windows portable — распакуйте в любую папку и запустите LedGrab.bat", + "update.assets.desc.windows_msi": "MSI-установщик Windows", + "update.assets.desc.windows_exe": "Исполняемый файл Windows", + "update.assets.desc.linux_tarball": "Архив Linux — распакуйте и запустите ./run.sh", + "update.assets.desc.linux_appimage": "Linux portable — один исполняемый файл", + "update.assets.desc.linux_deb": "Пакет Debian / Ubuntu", + "update.assets.desc.linux_rpm": "Пакет Fedora / RHEL", + "update.assets.desc.linux_sandbox": "Sandbox-пакет Linux", + "update.assets.desc.macos_dmg": "Образ диска macOS — перетащите в «Программы»", + "update.assets.desc.macos_installer": "Установщик macOS", + "update.assets.desc.android": "Android — sideload на Android 7.0+", + "update.assets.desc.android_bundle": "Android App Bundle (Play Store)", + "update.assets.desc.ios": "Приложение iOS", + "update.assets.desc.zip_archive": "Архив ZIP", + "update.assets.desc.tarball": "Tar-архив", + "update.assets.desc.archive": "Сжатый архив", "update.auto_check_label": "Автоматическая проверка", "update.auto_check_hint": "Периодически проверять наличие новых версий в фоновом режиме.", "update.enable": "Включить автопроверку", diff --git a/server/src/ledgrab/static/locales/zh.json b/server/src/ledgrab/static/locales/zh.json index 6ce1540..2abd9bc 100644 --- a/server/src/ledgrab/static/locales/zh.json +++ b/server/src/ledgrab/static/locales/zh.json @@ -377,6 +377,10 @@ "settings.external_url.saved": "外部 URL 已保存", "settings.external_url.save_error": "保存外部 URL 失败", "settings.general.title": "常规设置", + "settings.section.identity": "标识", + "settings.section.connection": "连接", + "settings.section.hardware": "硬件", + "settings.section.behavior": "行为", "settings.capture.title": "采集设置", "settings.capture.saved": "采集设置已更新", "settings.capture.failed": "保存采集设置失败", @@ -785,7 +789,11 @@ "dashboard.perf.patches.empty.idle": "准备就绪", "dashboard.perf.patches.empty.none": "暂无通道", "dashboard.perf.total_fps": "总帧率", - "dashboard.perf.total_capture_fps": "总采集帧率", + "dashboard.perf.total_capture_fps": "总输入帧率", + "dashboard.perf.total_capture_fps_actual": "总采集帧率", + "dashboard.perf.network": "网络", + "dashboard.perf.device_latency": "设备延迟", + "dashboard.perf.send_timing": "发送耗时", "dashboard.perf.errors": "错误", "dashboard.perf.devices": "设备", "dashboard.perf.cpu": "CPU", @@ -964,6 +972,9 @@ "scenes.capture": "捕获", "scenes.activate": "激活场景", "scenes.recapture": "重新捕获当前状态", + "scenes.action.activate": "激活", + "scenes.action.recapture": "重新捕获", + "scenes.status.preset": "预设", "scenes.delete": "删除场景", "scenes.targets_count": "目标", "scenes.captured": "场景已捕获", @@ -982,9 +993,6 @@ "scenes.error.delete_failed": "删除场景失败", "scenes.cloned": "场景已克隆", "scenes.error.clone_failed": "克隆场景失败", - "time.hours_minutes": "{h}时 {m}分", - "time.minutes_seconds": "{m}分 {s}秒", - "time.seconds": "{s}秒", "dashboard.type.led": "LED", "dashboard.type.kc": "关键颜色", "aria.close": "关闭", @@ -1587,12 +1595,12 @@ "search.action.disable": "禁用", "settings.backup.label": "备份配置", "settings.backup.hint": "将所有配置(设备、目标、流、模板、自动化)下载为单个 JSON 文件。", - "settings.backup.button": "下载备份", + "settings.backup.button": "下载", "settings.backup.success": "备份下载成功", "settings.backup.error": "备份下载失败", "settings.restore.label": "恢复配置", "settings.restore.hint": "上传之前下载的备份文件以替换所有配置。服务器将自动重启。", - "settings.restore.button": "从备份恢复", + "settings.restore.button": "恢复", "settings.restore.confirm": "这将替换所有配置并重启服务器。确定继续吗?", "settings.restore.success": "配置已恢复", "settings.restore.error": "恢复失败", @@ -1671,6 +1679,15 @@ "settings.logs.filter.info_desc": "Info、警告和错误", "settings.logs.filter.warning_desc": "仅警告和错误", "settings.logs.filter.error_desc": "仅错误", + "settings.logs.stat.lines": "行数", + "settings.logs.stat.warn": "警告", + "settings.logs.stat.err": "错误", + "settings.logs.patch.idle": "待机", + "settings.logs.patch.connecting": "连接中", + "settings.logs.patch.live": "传输中", + "settings.logs.patch.error": "离线", + "settings.logs.empty.title": "等待日志", + "settings.logs.empty.sub": "连接 WebSocket 流以开始实时跟踪。", "device.error.power_off_failed": "关闭设备失败", "device.error.remove_failed": "移除设备失败", "device.error.settings_load_failed": "加载设备设置失败", @@ -1970,6 +1987,7 @@ "settings.notifications.permission.state.granted": "已授权 — 系统通知将会显示", "settings.notifications.permission.state.denied": "已拒绝 — 请在浏览器设置中修改", "settings.notifications.permission.state.default": "尚未请求授权", + "settings.notifications.permission.hint": "浏览器为每个站点单独管理系统通知权限。一旦被拒绝,LedGrab 将无法再次请求 — 必须在浏览器中重置。点击地址栏中的站点图标(锁形)→ 站点设置 → 通知 → 允许,然后刷新页面。", "settings.notifications.test_button": "发送测试通知", "settings.notifications.saved": "通知偏好已保存", "settings.notifications.save_error": "保存通知偏好失败", @@ -1981,6 +1999,33 @@ "settings.notifications.channel.os.desc": "系统通知(浏览器在后台时也能收到)", "settings.notifications.channel.both.label": "两者", "settings.notifications.channel.both.desc": "弹出提示与系统通知同时显示", + "settings.notifications.permission.pill.granted": "已授权", + "settings.notifications.permission.pill.denied": "已阻止", + "settings.rail.group.workspace": "工作区", + "settings.rail.group.system": "系统", + "settings.save_bar.unsaved": "字段未保存的更改:", + "settings.save_bar.revert": "还原", + "settings.save_bar.save": "保存", + "settings.section.api_keys": "身份与 API", + "settings.section.server": "服务器", + "settings.section.lifecycle": "生命周期", + "settings.section.destructive": "危险操作", + "settings.section.manual": "手动", + "settings.section.notif_channels": "通道", + "settings.section.notif_discovery": "发现", + "settings.section.notif_permission": "系统权限", + "settings.api_keys.read_only": "只读", + "settings.api_keys.meta.one": "个密钥", + "settings.api_keys.meta.many": "个密钥", + "settings.logs.sub": "服务器日志的实时流,可按级别过滤。在全屏覆盖层中打开。", + "settings.restart.sub": "重启 LedGrab 进程。捕获和已连接设备将暂停约 3 秒。", + "settings.restart.button": "重启", + "settings.notif_matrix.col.event": "事件", + "settings.notif_matrix.event_count": "4 个事件", + "settings.auto_backup.pill.running": "运行中", + "update.pill.available": "可用更新", + "update.pill.error": "错误", + "update.pill.updated": "已是最新", "notifications.unknown_device": "未知设备", "notifications.device_online.title": "设备已上线", "notifications.device_online.body": "{device} 已重新上线", @@ -2008,6 +2053,26 @@ "update.never": "从未", "update.release_notes": "发布说明", "update.view_release_notes": "查看发布说明", + "update.release_notes_hint": "当前可用版本的更新内容 — 应用前请先查看变更日志。", + "update.release_notes_open": "打开", + "update.assets.title": "下载", + "update.assets.desc.windows_installer": "Windows 安装程序 — 开始菜单快捷方式、可选自启动、卸载器", + "update.assets.desc.windows_portable": "Windows 便携版 — 解压后运行 LedGrab.bat", + "update.assets.desc.windows_msi": "Windows MSI 安装程序", + "update.assets.desc.windows_exe": "Windows 可执行文件", + "update.assets.desc.linux_tarball": "Linux 归档 — 解压后运行 ./run.sh", + "update.assets.desc.linux_appimage": "Linux 便携版 — 单文件可执行", + "update.assets.desc.linux_deb": "Debian / Ubuntu 软件包", + "update.assets.desc.linux_rpm": "Fedora / RHEL 软件包", + "update.assets.desc.linux_sandbox": "Linux 沙盒软件包", + "update.assets.desc.macos_dmg": "macOS 磁盘镜像 — 拖入「应用程序」", + "update.assets.desc.macos_installer": "macOS 安装包", + "update.assets.desc.android": "Android — 在 Android 7.0+ 上侧载", + "update.assets.desc.android_bundle": "Android App Bundle(Play Store)", + "update.assets.desc.ios": "iOS 应用", + "update.assets.desc.zip_archive": "ZIP 归档", + "update.assets.desc.tarball": "Tar 归档", + "update.assets.desc.archive": "压缩归档", "update.auto_check_label": "自动检查设置", "update.auto_check_hint": "在后台定期检查新版本。", "update.enable": "启用自动检查", diff --git a/server/src/ledgrab/templates/modals/device-settings.html b/server/src/ledgrab/templates/modals/device-settings.html index 9d65972..d9937f3 100644 --- a/server/src/ledgrab/templates/modals/device-settings.html +++ b/server/src/ledgrab/templates/modals/device-settings.html @@ -1,4 +1,7 @@ - +