feat(ui): cards redesign + settings, modal, toolbar polish

Dashboard cards (mod-card system)
- New mod-card / mod-menu modules backing dashboard cards
- Reworked card colors, sections, dashboard layout, perf charts
- Channel-stripe styling, hairline borders, signal-flow animation
  on running cards, refined metric grid

Multiselect bulk toolbar
- Replaced tri-state checkbox with explicit Select-all / Deselect-all
  icon buttons; both disable when not applicable
- Dim + slight blur on non-selected siblings during selection mode so
  the active picks pop; selected card gains a subtle lift + primary-color
  glow halo
- Bulk tick uses ICON_CHECK from the icon registry (was U+2713) and
  scale-pops in via a cubic-bezier overshoot keyframe
- Toolbar restyled with luxury gradient bg, top accent stripe, glass
  blur, neon hover glows on each button group

Settings modal
- Tab bar converted to icon-only (cog / hard-drive / bell / palette /
  refresh / help) so labels never overflow at any locale; title and
  aria-label preserve translated names. Tabs distribute evenly via
  flex: 1 1 0 + space-around — no overflow possible
- IconSelect auto-populates <option> elements when the underlying select
  is empty, fixing the blank notification triggers (root cause: setting
  .value on an empty select is a no-op)
- Tab activation calls scrollIntoView on the active button as a safety
  net for narrow viewports

Modal exit animation
- Added symmetric fadeOut + slideDown keyframes; .modal.closing applies
  them with animation-fill-mode: forwards
- Modal.forceClose() defers display:none until animationend (with timer
  fallback). State cleanup (focus, body lock, stack) runs immediately so
  callers querying state get correct values
- isOpen returns false during the close animation; open() cancels any
  in-flight close so re-open works during the animation
- prefers-reduced-motion disables all modal animations

Locale picker
- Dropped redundant English/Русский/中文 long-form labels — picker now
  shows only EN / RU / ZH
- IconSelect trigger/cell hides empty icon/label slots via :empty so the
  layout collapses cleanly for minimal items

Filter input (cards section)
- Embedded magnifier icon via data URI (no HTML change); monospace
  uppercase placeholder, lux-bg-0 background, neon focus ring with inset
  shadow + outer glow
- Reset button only shows when the input has content (CSS-only via
  :placeholder-shown sibling selector — JS-resilient)

Snack toast
- Glass background (gradient + backdrop-blur) with top channel-color
  accent stripe matching the modal/toolbar language
- Per-type --toast-ch drives border/glow/timer color (success → primary,
  error → danger, info → info)
- Undo button gets a tinted hover with channel-color halo

Top header toolbar
- Removed hairline border from .header-btn for a flatter look; hover
  keeps the subtle background tint and primary-color glow

Device URL hyperlink
- Styled .mod-meta__link to pick up the card's --ch accent (instead of
  inheriting browser-blue underline). Dotted underline at rest solidifies
  on hover; soft text-shadow glow; web icon dims at rest, brightens on
  hover

Misc
- ICON_CHECK and ICON_HARD_DRIVE added to the icon registry
- Existing card-redesign demos checked in under docs/
- Removed obsolete docs/plans/device-typed-configs.md
This commit is contained in:
2026-04-26 03:10:16 +03:00
parent ccf4406349
commit a56569b02f
34 changed files with 4992 additions and 541 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-274
View File
@@ -1,274 +0,0 @@
# Refactor Plan: Per-Provider Typed Device Configs
**Status:** Planned, not started.
**Target branch:** `refactor/device-typed-configs`
**Intended executor:** Sonnet agent (one phase per invocation; human review between phases).
## Goal
Replace the flat [`DeviceInfo`](../../server/src/ledgrab/core/processing/target_processor.py) dataclass (and the `**kwargs`-based `LEDDeviceProvider.create_client(url, **kwargs)` contract) with a **discriminated union of per-provider config dataclasses**. Each provider owns its config type and reads typed fields instead of guessing kwargs.
## Motivation
Current pain points:
- [server/src/ledgrab/core/processing/wled_target_processor.py](../../server/src/ledgrab/core/processing/wled_target_processor.py) unpacks ~21 fields by hand into `create_led_client(**kwargs)`.
- Every provider's `create_client` starts with `kwargs.get("x", default)` — no type safety, no IDE hints, no way to know at a glance which fields a provider actually uses.
- Adding a new per-device-type field requires threading it through `Device``DeviceInfo``_DEVICE_FIELD_DEFAULTS` → call-site unpacking → kwargs bag → provider.
- Fields leak across device types (a WLED device carries `ble_govee_key=""` at runtime for no reason).
## Scope guardrails
- **Storage schema (SQLite) unchanged.** Columns stay, dead-for-this-type fields stay, no destructive migration.
- **Frontend HTML/TS unchanged in phases 1-4.** It already branches on `device_type` with show/hide logic. Frontend changes are deferred to Phase 5.
- **API schemas are last.** Phase 5 converts `DeviceCreate`/`DeviceUpdate`/`DeviceResponse` to a Pydantic v2 discriminated union. This is the only breaking external change and can be deferred indefinitely if needed.
---
## Phase 1 — Config hierarchy (foundation, non-breaking)
### Create
**File:** `server/src/ledgrab/core/devices/device_config.py`
Pattern:
```python
from dataclasses import dataclass
from typing import List, Literal, Optional, Union
@dataclass(frozen=True)
class BaseDeviceConfig:
device_id: str
device_url: str
led_count: int
software_brightness: int = 255
test_mode_active: bool = False
auto_shutdown: bool = False
rgbw: bool = False
@dataclass(frozen=True)
class WLEDConfig(BaseDeviceConfig):
device_type: Literal["wled"] = "wled"
use_ddp: bool = False
# ... one @dataclass(frozen=True) per provider
```
### Config field inventory
Base: `device_id`, `device_url`, `led_count`, `software_brightness`, `test_mode_active`, `auto_shutdown`, `rgbw`.
| Config | Extra fields beyond Base |
| -------------- | ------------------------ |
| WLEDConfig | `use_ddp: bool = False` |
| AdalightConfig | `baud_rate: Optional[int] = None` |
| AmbiLEDConfig | `baud_rate: Optional[int] = None` |
| DMXConfig | `dmx_protocol`, `dmx_start_universe`, `dmx_start_channel` |
| ESPNowConfig | `baud_rate`, `espnow_peer_mac`, `espnow_channel` |
| HueConfig | `hue_username`, `hue_client_key`, `hue_entertainment_group_id` |
| SPIConfig | `spi_speed_hz`, `spi_led_type` |
| ChromaConfig | `chroma_device_type` |
| GameSenseConfig| `gamesense_device_type` |
| BLEConfig | `ble_family`, `ble_govee_key` |
| GroupConfig | `group_mode`, `group_device_ids` (**no `device_store` here** — see Phase 2) |
| OpenRGBConfig | `zone_mode` |
| MockConfig | `send_latency_ms: int = 0` |
| DemoConfig | `send_latency_ms: int = 0` |
| MQTTConfig | (none) |
| WSConfig | (none) |
| USBHIDConfig | (none — `hid_usage_page` is parsed from the URL, not config) |
```python
DeviceConfig = Union[
WLEDConfig, AdalightConfig, AmbiLEDConfig, DMXConfig, ESPNowConfig,
HueConfig, SPIConfig, ChromaConfig, GameSenseConfig, BLEConfig,
GroupConfig, MQTTConfig, WSConfig, USBHIDConfig, OpenRGBConfig,
MockConfig, DemoConfig,
]
```
### Add
**`Device.to_config() -> DeviceConfig`** in [server/src/ledgrab/storage/device_store.py](../../server/src/ledgrab/storage/device_store.py) (around lines 14-97 where `Device` lives).
- Dispatches on `self.device_type`.
- Constructs the right subclass, pulling only relevant columns.
- Ignores columns that don't apply to the type.
- This is the **only** place that knows the flat→typed mapping.
### Do NOT touch in Phase 1
- Provider signatures (still `create_client(self, url, **kwargs)`).
- `create_led_client` factory.
- Any call site.
- `DeviceInfo` itself.
### Acceptance
- New unit test `server/tests/core/devices/test_device_config.py`:
- For each provider, build a `Device` with that `device_type`, call `to_config()`, assert right subclass and right fields.
- Edge case: extra/irrelevant Device fields must not leak into the wrong config type.
- `cd server && ruff check src/ tests/ --fix` — green.
- `cd server && py -3.13 -m pytest tests/ --no-cov -q` — green (existing tests untouched, new test passes).
- `cd server && npx tsc --noEmit` — green (no TS impact this phase, just a sanity check).
---
## Phase 2 + Phase 3 — Provider API migration + call-site migration (single PR)
**These must land in one commit** because the provider signature change would otherwise break the 3 call sites immediately.
### Change the abstract base
[server/src/ledgrab/core/devices/led_client.py](../../server/src/ledgrab/core/devices/led_client.py):
```python
class LEDDeviceProvider(ABC):
@abstractmethod
def create_client(self, config: DeviceConfig, *, deps: ProviderDeps) -> LEDClient: ...
```
`ProviderDeps` is a tiny new dataclass:
```python
@dataclass(frozen=True)
class ProviderDeps:
device_store: "DeviceStore"
# Add future cross-cutting runtime deps here (http_client, etc.)
```
`create_led_client`:
```python
def create_led_client(config: DeviceConfig, *, deps: ProviderDeps) -> LEDClient:
return get_provider(config.device_type).create_client(config, deps=deps)
```
### Update every provider (17 files)
- Narrow signature per provider: e.g. `WLEDDeviceProvider.create_client(self, config: WLEDConfig, *, deps: ProviderDeps)`.
- Drop all `kwargs.get("x")` lookups — read typed fields directly.
- Providers that don't need `deps` just ignore it.
- **GroupDeviceProvider** is the only current consumer of `deps`: reads `deps.device_store`.
### Call sites (3)
1. [server/src/ledgrab/core/processing/wled_target_processor.py](../../server/src/ledgrab/core/processing/wled_target_processor.py) lines ~120-148 — the 21-field unpacking. Replace with:
```python
config = device.to_config()
self._led_client = create_led_client(config, deps=self._provider_deps)
```
`self._provider_deps` is plumbed in from `ProcessorManager` when the target processor is constructed.
2. [server/src/ledgrab/core/processing/device_test_mode.py](../../server/src/ledgrab/core/processing/device_test_mode.py) lines 72-78 — minimal test-mode client. Build a synthetic config via a helper `_minimal_config_for_test_mode(device)` (keeps just `device_id`, `device_url`, `led_count`, `baud_rate`) and pass it.
3. [server/src/ledgrab/core/devices/group_client.py](../../server/src/ledgrab/core/devices/group_client.py) lines 47-70 — child client construction inside the group. Same pattern: `child_config = child_device.to_config()`; pass `deps` through.
### Delete
- `DeviceInfo` dataclass in [server/src/ledgrab/core/processing/target_processor.py](../../server/src/ledgrab/core/processing/target_processor.py) lines 71-109.
- `ProcessorManager._get_device_info()` and `_DEVICE_FIELD_DEFAULTS` in [server/src/ledgrab/core/processing/processor_manager.py](../../server/src/ledgrab/core/processing/processor_manager.py) lines 230-275 — `Device.to_config()` subsumes this. Verify no other callers via `ast-index usages "_get_device_info"`.
### Acceptance
- `ast-index search "device_info\."` — no hits in non-test code.
- `ast-index search "DeviceInfo"` — no hits outside archival comments.
- `cd server && py -3.13 -m pytest tests/ --no-cov -q` — all tests pass.
- Manual smoke: start server, create a WLED device, start processing, verify LEDs update (or mock output shows frames).
- `cd server && ruff check src/ tests/ --fix` — green.
---
## Phase 4 — Test migration
Update these files:
- `server/tests/storage/test_device_store.py` — add `to_config()` cases per device type.
- `server/tests/api/routes/test_devices_routes.py` — should be mostly untouched (API schemas still flat until Phase 5).
- `server/tests/e2e/test_device_flow.py` — update internal assertions only if they touch `DeviceInfo` directly.
- `server/tests/test_group_device.py` — construct child clients with `GroupConfig`.
- Any fixture helper that builds a fake `DeviceInfo` — migrate to the right `*Config` subclass.
### Acceptance
- `cd server && py -3.13 -m pytest tests/ --no-cov -q` — all green.
- Coverage of `device_config.py` and `Device.to_config()` ≥ 90%.
---
## Phase 5 — API discriminated union (OPTIONAL, separate PR)
**Do not start until Phases 1-4 are merged and stable.** Flag this to the human before beginning. This is the only phase with an externally breaking change.
### Backend
[server/src/ledgrab/api/schemas/devices.py](../../server/src/ledgrab/api/schemas/devices.py) — replace flat `DeviceCreate`/`DeviceUpdate` with Pydantic v2 tagged unions:
```python
class WLEDDeviceCreate(BaseModel):
device_type: Literal["wled"]
name: str
url: str
led_count: int
use_ddp: bool = False
# ... base fields only
DeviceCreate = Annotated[
Union[WLEDDeviceCreate, AdalightDeviceCreate, ...],
Field(discriminator="device_type"),
]
```
Add `model_config = ConfigDict(extra="ignore")` on each union member for **one release cycle** so existing clients (frontend, HAOS integration, curl scripts) that send extra fields don't 422 immediately. Add a deprecation note and tighten to `extra="forbid"` in a follow-up.
### Frontend
- [server/src/ledgrab/static/js/features/devices.ts](../../server/src/ledgrab/static/js/features/devices.ts) and related — when building the POST/PATCH body, scope the payload to the selected `device_type` using the show/hide knowledge already in `device-discovery.ts`.
- **No plain `<select>` elements** — any new pickers use IconSelect or EntitySelect (see root CLAUDE.md UI rules).
### Tests
- Update `test_devices_routes.py` to assert discriminated union rejection of mismatched shapes.
- Add round-trip tests: create device of each type via API → fetch → compare fields.
### Acceptance
- `cd server && py -3.13 -m pytest tests/ --no-cov -q` — green.
- `cd server && npx tsc --noEmit && npm run build` — green.
- Manual smoke for at least 3 device types (WLED, DMX, Hue) — create, edit, delete via UI.
- HAOS integration still works against the server (spot-check; not automated).
---
## Conventions the implementing agent must follow
- **Project task tracker is `TODO.md`** — check the "Refactor: Per-Provider Device Configs" section, tick boxes as phases land. Do **not** use the `TodoWrite` tool.
- **Auto-restart after Python changes.** See [contexts/server-operations.md](../../contexts/server-operations.md).
- **No commits without explicit user approval.** Present each phase's diff for review first.
- **Pre-commit gate every phase:**
- `cd server && ruff check src/ tests/ --fix`
- `cd server && py -3.13 -m pytest tests/ --no-cov -q`
- Phase 5 additionally: `cd server && npx tsc --noEmit && npm run build`
- **No plain `<select>`** — Phase 5 uses IconSelect / EntitySelect.
- **Android parity:** if you add any new runtime dep to `server/pyproject.toml`, update `android/app/build.gradle.kts` per the root [CLAUDE.md](../../CLAUDE.md) "Android Dependency Sync" section. This refactor should not need any new deps.
- **Data migration policy:** storage schema is unchanged, so no JSON-file migration is needed. But if you rename any serialized field during `to_dict`/`from_dict`, add migration logic per the root [CLAUDE.md](../../CLAUDE.md) "Data Migration Policy" section.
- **Use `ast-index`** for code search (`ast-index search`, `ast-index usages`, `ast-index callers`, `ast-index class`). Fall back to Grep only for regex/string-literal/comment searches.
- **Never run `cd` in Bash.** Use absolute paths or the project-relative `cd server && <cmd>` idiom (one-shot, same invocation).
## Known risks
1. **Frozen dataclass + inheritance + defaults** — Python's `@dataclass(frozen=True)` with inheritance requires every subclass field to have a default if any parent field does. Base has defaulted fields. Verify in Phase 1. If it breaks, use `kw_only=True` (Python 3.10+).
2. **`use_ddp` origin** — currently inferred from `self._protocol == "ddp"` at the call site, not from Device storage. Options: add a column (schema change, more work), **or** keep inference logic inside `Device.to_config()` (recommended — no schema change). Prefer the latter.
3. **Test-mode minimal client** ([device_test_mode.py](../../server/src/ledgrab/core/processing/device_test_mode.py) lines 72-78) may not have all `BaseDeviceConfig` fields available. Build a synthetic config via a named helper; do not leak the hack into `Device.to_config()`.
4. **Group `device_store` import cycle** — `GroupConfig` must **not** hold `device_store` (would pull storage into the config module). `ProviderDeps` is the deliberate cut.
5. **BLE optional import** — `BLEDeviceProvider` is conditionally registered (see [led_client.py](../../server/src/ledgrab/core/devices/led_client.py) lines 321-330). Ensure `BLEConfig` still imports cleanly even when `bleak` is absent — put `BLEConfig` in `device_config.py` (not in `ble_provider.py`) so it's always importable.
## Deliverables per phase
1. Branch: `refactor/device-typed-configs`.
2. One commit per phase, conventional-commit messages:
- `refactor(devices): phase 1 — add DeviceConfig hierarchy`
- `refactor(devices): phases 2+3 — typed provider signatures + call-site migration`
- `refactor(devices): phase 4 — test migration to typed configs`
- `refactor(devices): phase 5 — API discriminated union` (separate PR)
3. Phase-by-phase diffs presented for user review **before** each commit.
4. Final PR body linking all phases, with manual test plan per device type touched.
@@ -280,6 +280,9 @@ class TargetProcessingState(BaseModel):
None, description="Potential FPS (processing speed without throttle)"
)
fps_target: Optional[int] = Field(None, description="Target FPS")
fps_capture: Optional[int] = Field(
None, description="Configured capture-side FPS for the underlying color strip stream"
)
frames_skipped: Optional[int] = Field(None, description="Frames skipped (no screen change)")
frames_keepalive: Optional[int] = Field(
None, description="Keepalive frames sent during standby"
@@ -224,6 +224,7 @@ class HALightTargetProcessor(TargetProcessor):
"update_rate": self._update_rate,
"fps_actual": self._update_rate if self._is_running else None,
"fps_target": self._update_rate,
"fps_capture": self._update_rate if self._is_running else None,
"uptime_seconds": uptime,
"entity_colors": entity_colors,
}
@@ -399,8 +399,10 @@ class WledTargetProcessor(TargetProcessor):
fps_target = self._target_fps
css_timing: dict = {}
css_capture_fps: Optional[int] = 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)
send_ms = round(metrics.timing_send_ms, 1) if self._is_running else None
# Picture source timing
@@ -444,6 +446,7 @@ class WledTargetProcessor(TargetProcessor):
"fps_actual": metrics.fps_actual if self._is_running else None,
"fps_potential": metrics.fps_potential if self._is_running else None,
"fps_target": fps_target,
"fps_capture": css_capture_fps,
"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,
+699 -37
View File
@@ -147,13 +147,15 @@ section {
overflow: hidden;
}
/* Channel stripe on left edge — opt-in only:
/* Channel stripe on left edge — always on at .6 opacity for cards
* that adopt the modular markup (.mod-card), opt-in for legacy cards.
* [data-has-color="1"] → user picked a personal color via the picker
* .card-running → "patched and live" indicator
* Idle cards without a personal color stay clean (no stripe), matching
* the pre-redesign behavior where the left border meant "I marked this".
* The dashboard module rows keep their always-on stripe (at 0.6 opacity)
* because the dashboard was approved as-is. */
* .mod-card → ambient channel signal (matches dashboard)
* Legacy cards without a personal color stay clean to avoid breaking
* the visual rhythm of feature tabs that haven't migrated yet. Once
* every create*Card() builder uses wrapCard({ mod }), the .mod-card
* scope can be dropped and the stripe becomes ambient on every card. */
.card::before {
content: '';
position: absolute;
@@ -164,13 +166,20 @@ section {
pointer-events: none;
z-index: 1;
display: none;
opacity: 0.6;
transition: opacity 0.2s ease, box-shadow 0.2s ease, width 0.2s ease;
}
.card[data-has-color="1"]::before,
.card.card-running::before {
.card.card-running::before,
.card.mod-card::before {
display: block;
}
.card.mod-card:hover::before {
opacity: 1;
}
/* Corner bracket — silkscreened panel feel in the top-right */
.card::after {
content: '';
@@ -1112,6 +1121,14 @@ body.cs-drag-active .card-drag-handle {
pointer-events: none;
}
/* Mod-card brightness fader — loading + disabled states */
.card.mod-card.brightness-loading .mod-fader,
.template-card.mod-card.brightness-loading .mod-fader,
.mod-fader:has(.mod-fader__slider:disabled) {
opacity: 0.4;
pointer-events: none;
}
/* Static color picker — inline in card-subtitle */
.section-header {
display: flex;
@@ -1533,81 +1550,258 @@ ul.section-tip li {
display: block;
}
/* Selected card highlight */
/* Selected card highlight — accent ring, soft outer glow, and a subtle
lift so the chosen card pops above the dimmed siblings. */
.cs-selecting .card-selected,
.cs-selecting .card-selected.template-card {
border-color: var(--primary-color);
box-shadow: 0 0 0 1px var(--primary-color), 0 4px 12px color-mix(in srgb, var(--primary-color) 15%, transparent);
box-shadow:
0 0 0 1px var(--primary-color),
0 0 24px color-mix(in srgb, var(--primary-color) 22%, transparent),
0 8px 28px rgba(0, 0, 0, 0.35);
transform: translateY(-1px);
}
/* Make cards visually clickable in selection mode */
/* Non-selected siblings during selection: desaturate, dim and softly blur
so the eye locks onto the active picks. Hover restores full clarity to
keep the card affordance obvious. */
.cs-selecting .card:not(.card-selected),
.cs-selecting .template-card:not(.card-selected) {
opacity: 0.55;
filter: saturate(0.55) blur(0.4px);
transition:
opacity 0.2s ease,
filter 0.25s ease,
transform 0.2s ease,
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.cs-selecting .card:not(.card-selected):hover,
.cs-selecting .template-card:not(.card-selected):hover {
opacity: 0.92;
filter: none;
}
@media (prefers-reduced-motion: reduce) {
.cs-selecting .card:not(.card-selected),
.cs-selecting .template-card:not(.card-selected) {
filter: saturate(0.55);
}
}
/* Make cards visually clickable in selection mode — card root is the
click target, descendants are inert. */
.cs-selecting .card,
.cs-selecting .template-card {
cursor: pointer;
}
/* All card descendants pass clicks through to the card root in
selection mode. This disables buttons, sliders, kebab, color dot,
and any other interactive child without needing per-element rules. */
.cs-selecting .card *,
.cs-selecting .template-card * {
pointer-events: none;
}
/* Suppress hover lift during selection */
.cs-selecting .card:hover,
.cs-selecting .template-card:hover {
transform: none;
}
/* Corner checkmark on selected cards — replaces the per-card checkbox
that used to be injected at the top of the card body. Sits in the
top-right corner where the kebab is otherwise visible (kebab is
pointer-events:none in this mode, so the indicator can overlay it). */
.cs-selecting .card-selected::before,
.cs-selecting .template-card.card-selected::before {
/* override the channel stripe pulse by restoring full opacity */
opacity: 1;
}
.cs-selecting .card-selected,
.cs-selecting .template-card.card-selected {
position: relative;
}
.cs-selecting .card-selected > .mod-bulk-tick,
.cs-selecting .template-card.card-selected > .mod-bulk-tick {
display: flex;
}
/* The tick itself — injected once per card via JS so it picks up the
.card-selected toggle naturally. Hidden until the card is selected. */
.mod-bulk-tick {
display: none;
position: absolute;
top: 8px;
right: 8px;
width: 22px;
height: 22px;
align-items: center;
justify-content: center;
background: var(--primary-color);
color: var(--primary-contrast, #fff);
border-radius: 50%;
box-shadow:
0 0 0 2px var(--card-bg, var(--lux-bg-1)),
0 0 14px color-mix(in srgb, var(--primary-color) 50%, transparent);
z-index: 3;
pointer-events: none;
}
.mod-bulk-tick svg {
display: block;
width: 13px;
height: 13px;
stroke-width: 2.6;
}
.cs-selecting .card-selected > .mod-bulk-tick,
.cs-selecting .template-card.card-selected > .mod-bulk-tick {
animation: bulkTickPop 0.22s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes bulkTickPop {
0% { transform: scale(0.4); opacity: 0; }
60% { transform: scale(1.12); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.cs-selecting .card-selected > .mod-bulk-tick,
.cs-selecting .template-card.card-selected > .mod-bulk-tick {
animation: none;
}
}
/* ── Bulk toolbar ──────────────────────────────────────────── */
#bulk-toolbar {
--bulk-ch: var(--primary-color);
position: fixed;
bottom: 20px;
bottom: 24px;
left: 50%;
transform: translateX(-50%) translateY(calc(100% + 30px));
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-md, 8px);
padding: 8px 16px;
transform: translateX(-50%) translateY(calc(100% + 40px));
background: linear-gradient(180deg,
var(--lux-bg-1, var(--card-bg)) 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-lg, var(--radius-md, 10px));
padding: 8px 10px 8px 12px;
display: flex;
align-items: center;
gap: 12px;
gap: 10px;
z-index: var(--z-bulk-toolbar);
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.3);
transition: transform 0.25s ease;
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.02),
0 12px 36px rgba(0, 0, 0, 0.5),
0 4px 16px var(--shadow-color, rgba(0, 0, 0, 0.4));
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
transition: transform 0.28s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
white-space: nowrap;
opacity: 0;
pointer-events: none;
}
#bulk-toolbar.visible {
transform: translateX(-50%) translateY(0);
opacity: 1;
pointer-events: auto;
}
.bulk-select-all-wrap {
/* Top accent stripe — same channel-glow language as modals */
#bulk-toolbar::before {
content: '';
position: absolute;
left: 0; right: 0; top: 0;
height: 1.5px;
background: linear-gradient(90deg,
transparent 0%,
var(--bulk-ch) 20%,
var(--bulk-ch) 80%,
transparent 100%);
box-shadow: 0 0 12px color-mix(in srgb, var(--bulk-ch) 55%, transparent);
border-radius: inherit;
pointer-events: none;
}
/* Pick group (Select all / Deselect all) */
.bulk-pick {
display: flex;
align-items: center;
cursor: pointer;
gap: 2px;
padding: 2px;
background: var(--lux-bg-0, var(--bg-color));
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
border-radius: var(--lux-r-sm, 4px);
}
.bulk-select-all-cb {
.bulk-pick-btn {
width: 28px;
height: 28px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: var(--lux-ink-mute, var(--text-secondary));
border-radius: var(--lux-r-sm, 3px);
cursor: pointer;
transition: color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease;
}
.bulk-pick-btn .icon {
width: 16px;
height: 16px;
margin: 0;
accent-color: var(--primary-color);
cursor: pointer;
}
.bulk-pick-btn:hover:not(:disabled) {
color: var(--bulk-ch);
background: color-mix(in srgb, var(--bulk-ch) 14%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--bulk-ch) 35%, transparent),
0 0 12px color-mix(in srgb, var(--bulk-ch) 30%, transparent);
}
.bulk-pick-btn:disabled,
.bulk-pick-btn[aria-disabled="true"] {
opacity: 0.35;
cursor: not-allowed;
}
.bulk-count {
font-size: 0.85rem;
color: var(--text-secondary);
min-width: 80px;
font-family: var(--font-mono, monospace);
font-size: 0.78rem;
letter-spacing: 0.08em;
color: var(--lux-ink, var(--text-color));
min-width: 90px;
padding: 0 4px;
font-variant-numeric: tabular-nums;
}
.bulk-count-total {
color: var(--lux-ink-mute, var(--text-secondary));
font-size: 0.95em;
margin-left: 2px;
}
.bulk-actions {
display: flex;
gap: 4px;
padding-left: 8px;
margin-left: 4px;
border-left: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
}
.bulk-action-btn {
width: 32px;
height: 32px;
padding: 0;
display: flex;
display: inline-flex;
align-items: center;
justify-content: center;
transition: box-shadow 0.18s ease, transform 0.15s ease;
}
.bulk-action-btn .icon {
@@ -1615,18 +1809,486 @@ ul.section-tip li {
height: 16px;
}
.bulk-action-btn:hover:not(:disabled) {
box-shadow: 0 0 16px color-mix(in srgb, var(--primary-color) 30%, transparent);
transform: translateY(-1px);
}
.bulk-action-btn.btn-danger:hover:not(:disabled) {
box-shadow: 0 0 16px color-mix(in srgb, var(--danger-color, #ef4444) 40%, transparent);
}
.bulk-action-btn:disabled,
.bulk-action-btn[aria-disabled="true"] {
opacity: 0.4;
cursor: not-allowed;
}
.bulk-close {
background: none;
border: none;
color: var(--text-muted);
font-size: 1rem;
width: 30px;
height: 30px;
background: transparent;
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
color: var(--lux-ink-mute, var(--text-muted));
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: color 0.2s;
line-height: 1;
padding: 0;
border-radius: var(--lux-r-sm, 4px);
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: 2px;
transition: color 0.18s ease, border-color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease;
}
.bulk-close .icon {
width: 14px;
height: 14px;
}
.bulk-close:hover {
color: var(--text-color);
color: var(--lux-ink, var(--text-color));
border-color: color-mix(in srgb, var(--bulk-ch) 50%, var(--lux-line, var(--border-color)));
background: color-mix(in srgb, var(--bulk-ch) 8%, transparent);
}
@media (prefers-reduced-motion: reduce) {
#bulk-toolbar {
transition: opacity 0.15s ease;
}
.bulk-action-btn:hover:not(:disabled) {
transform: none;
}
}
/* ════════════════════════════════════════════════════════════════════
Mod-cards — entity cards that adopt the dashboard's `.mod-*` markup
════════════════════════════════════════════════════════════════════
The `.mod-*` child selectors (mod-head, mod-leds, mod-metrics,
mod-foot, mod-patch, mod-btn, etc.) live in dashboard.css and apply
regardless of host class. The rules below adapt the .card /
.template-card hosts to layout the modular children correctly (gap,
padding, suppress legacy decorations) and add the kebab menu styles
that are unique to entity cards.
════════════════════════════════════════════════════════════════════ */
.card.mod-card,
.template-card.mod-card {
/* Vertical flex stack matches .dashboard-target:has(.mod-head) */
display: flex;
flex-direction: column;
gap: 14px;
padding: 16px 18px 14px 22px;
align-items: stretch;
}
/* Foot pinned to the card bottom — entity cards have variable body
content (chips/metrics/fader may all be absent), so without this the
foot floats in the middle when the grid forces a tall card. */
.card.mod-card .mod-foot,
.template-card.mod-card .mod-foot {
margin-top: auto;
}
/* Suppress legacy decorations when modular children are present */
.card.mod-card .card-header,
.card.mod-card .card-actions,
.card.mod-card .card-top-actions,
.template-card.mod-card .template-card-header,
.template-card.mod-card .template-card-actions,
.template-card.mod-card .card-top-actions { display: none; }
/* Idle silkscreen corner bracket — drop on mod-cards with a kebab.
The kebab visually replaces the bracket as the top-right anchor. */
.card.mod-card:has(.mod-menu-wrap):not(.is-running)::after,
.template-card.mod-card:has(.mod-menu-wrap):not(.is-running)::after {
display: none;
}
/* Running state on mod-cards — same intensified stripe + bottom signal-
flow used by .dashboard-target.is-running. Two class names keep
markup interchangeable: callers can use `is-running` (dashboard
convention) or `card-running` (legacy entity-card convention). */
.card.mod-card.is-running,
.card.mod-card.card-running,
.template-card.mod-card.is-running,
.template-card.mod-card.card-running {
border-color: color-mix(in srgb, var(--ch) 32%, var(--lux-line, var(--border-color)));
box-shadow: 0 0 0 1px color-mix(in srgb, var(--ch) 18%, transparent),
0 6px 20px rgba(0, 0, 0, 0.25);
}
.card.mod-card.is-running::before,
.card.mod-card.card-running::before,
.template-card.mod-card.is-running::before,
.template-card.mod-card.card-running::before {
opacity: 1;
width: 4px;
box-shadow: 0 0 14px color-mix(in srgb, var(--ch) 70%, transparent),
0 0 4px color-mix(in srgb, var(--ch) 90%, transparent);
}
/* Bottom signal-flow strip (running indicator) */
.card.mod-card.is-running::after,
.card.mod-card.card-running::after,
.template-card.mod-card.is-running::after,
.template-card.mod-card.card-running::after {
content: '';
position: absolute;
top: auto; right: auto;
left: 4px; bottom: 0;
width: calc(100% - 4px); height: 2px;
border: none;
opacity: 0.7;
background: linear-gradient(90deg,
transparent 0%,
color-mix(in srgb, var(--ch) 85%, transparent) 50%,
transparent 100%);
background-size: 30% 100%;
background-repeat: no-repeat;
animation: signalFlow 2.4s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.card.mod-card.is-running::after,
.card.mod-card.card-running::after,
.template-card.mod-card.is-running::after,
.template-card.mod-card.card-running::after {
animation: none;
background-position: 50% 0;
background-size: 60% 100%;
}
}
/* ── Color picker dot inside .mod-badge ─────────────────────────── */
.mod-badge { padding-left: 4px; }
.mod-badge__color-wrap {
display: inline-flex;
align-items: center;
margin-right: 6px;
line-height: 0;
position: relative;
}
.mod-badge__color {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
background: var(--ch);
border: var(--lux-hairline, 1px) solid color-mix(in srgb, var(--ch) 60%, var(--lux-line-bold, var(--border-color)));
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
box-shadow: 0 0 0 0 color-mix(in srgb, var(--ch) 40%, transparent);
}
.mod-badge__color:hover,
.mod-badge__color:focus-visible {
transform: scale(1.25);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ch) 25%, transparent);
outline: none;
}
/* User picked a personal hue — fill with --user-color but keep the
channel-tinted ring */
.mod-badge__color[data-custom] {
background: var(--user-color);
}
/* ── Overflow menu (kebab) ──────────────────────────────────────── */
.mod-menu-wrap {
position: relative;
flex-shrink: 0;
align-self: flex-start;
/* Match the .mod-leds bezel height (~22px) so the kebab and the LED
cluster sit on the same visual baseline at the top of the head row.
Without this, the 26px kebab dropped 4px below the 22px bezel and
read as misaligned. */
display: inline-flex;
align-items: center;
height: 22px;
}
.mod-menu-btn {
appearance: none;
background: transparent;
border: none;
width: 22px; height: 22px;
border-radius: 3px;
color: var(--lux-ink-mute, var(--text-secondary));
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: color 0.15s, background 0.15s, opacity 0.15s;
opacity: 0.45;
padding: 0;
}
.card.mod-card:hover .mod-menu-btn,
.template-card.mod-card:hover .mod-menu-btn,
.dashboard-target:hover .mod-menu-btn,
.mod-menu-wrap.is-open .mod-menu-btn {
opacity: 1;
}
.mod-menu-btn:hover,
.mod-menu-wrap.is-open .mod-menu-btn {
color: var(--lux-ink, var(--text-color));
background: var(--lux-bg-3, var(--border-color));
}
.mod-menu-btn:focus-visible {
outline: none;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ch) 50%, transparent);
}
.mod-menu-btn .icon {
width: 14px;
height: 14px;
/* The kebab path uses fill (filled circles) — disable stroke */
stroke: none;
fill: currentColor;
}
/* The dropdown — `position: fixed` with JS-computed coordinates.
* mod-menu.ts portals the element to <body> on open so that a
* transformed ancestor (e.g. card hover translate) doesn't trap the
* fixed positioning, and the card's `overflow: hidden` doesn't clip
* the dropdown. Display toggles on `.mod-menu.is-open` (set whether
* the menu is in the wrap or portaled to body). */
.mod-menu {
position: fixed;
z-index: var(--z-dropdown, 200);
min-width: 172px;
background: var(--lux-bg-1, var(--card-bg));
border: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color));
border-radius: var(--lux-r-sm, 4px);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45),
0 0 0 1px rgba(255, 255, 255, 0.02);
padding: 4px;
display: none;
flex-direction: column;
transform-origin: top right;
animation: modMenuIn 0.12s cubic-bezier(0.16, 1, 0.3, 1);
}
.mod-menu.is-open { display: flex; }
@keyframes modMenuIn {
from { opacity: 0; transform: scale(0.96) translateY(-4px); }
to { opacity: 1; transform: none; }
}
.mod-menu__item {
appearance: none;
background: transparent;
border: none;
text-align: left;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: 2px;
font-family: var(--font-mono, monospace);
font-size: 0.66rem;
font-weight: 500;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--lux-ink-dim, var(--text-secondary));
cursor: pointer;
transition: background 0.12s, color 0.12s;
white-space: nowrap;
width: 100%;
}
.mod-menu__item:hover,
.mod-menu__item:focus-visible {
background: var(--lux-bg-3, var(--border-color));
color: var(--lux-ink, var(--text-color));
outline: none;
}
.mod-menu__item .icon {
width: 14px;
height: 14px;
flex-shrink: 0;
color: var(--lux-ink-mute, var(--text-secondary));
}
.mod-menu__item:hover .icon,
.mod-menu__item:focus-visible .icon {
color: var(--lux-ink, var(--text-color));
}
.mod-menu__sep {
height: 1px;
margin: 4px 6px;
background: var(--lux-line, var(--border-color));
}
.mod-menu__item--danger {
color: var(--ch-coral, var(--danger-color));
}
.mod-menu__item--danger .icon {
color: var(--ch-coral, var(--danger-color));
opacity: 0.85;
}
.mod-menu__item--danger:hover,
.mod-menu__item--danger:focus-visible {
background: color-mix(in srgb, var(--ch-coral, var(--danger-color)) 12%, transparent);
color: var(--ch-coral, var(--danger-color));
}
.mod-menu__item--danger:hover .icon,
.mod-menu__item--danger:focus-visible .icon {
color: var(--ch-coral, var(--danger-color));
opacity: 1;
}
/* ── Property chips (replaces parallel .card-meta / .stream-card-prop
systems on mod-cards). Reused by the legacy .stream-card-prop
class so cards can mix-and-match during migration. ─────────── */
.mod-chips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.mod-card .chip {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono, monospace);
font-size: 0.66rem;
font-weight: 500;
letter-spacing: 0.04em;
color: var(--lux-ink-dim, var(--text-secondary));
background: transparent;
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
padding: 3px 9px;
border-radius: 999px;
cursor: default;
transition: color 0.15s, border-color 0.15s, background 0.15s;
white-space: nowrap;
}
.mod-card .chip .icon {
width: 11px;
height: 11px;
flex-shrink: 0;
color: var(--ch);
}
.mod-card .chip--link { cursor: pointer; }
.mod-card .chip--link:hover {
color: var(--lux-ink, var(--text-color));
border-color: color-mix(in srgb, var(--ch) 40%, transparent);
background: color-mix(in srgb, var(--ch) 12%, transparent);
}
.mod-card .chip--tag {
color: var(--ch);
border-color: color-mix(in srgb, var(--ch) 22%, transparent);
background: color-mix(in srgb, var(--ch) 10%, transparent);
}
.mod-card .chip--err {
color: var(--ch-coral, var(--danger-color));
border-color: color-mix(in srgb, var(--ch-coral, var(--danger-color)) 30%, transparent);
background: color-mix(in srgb, var(--ch-coral, var(--danger-color)) 10%, transparent);
}
/* ── Brightness fader (mod-card variant) ────────────────────────── */
.mod-fader {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 10px;
background: var(--lux-bg-0, var(--bg-color));
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
border-radius: var(--lux-r-sm, 4px);
}
.mod-fader__k {
font-family: var(--font-mono, monospace);
font-size: 0.55rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--lux-ink-mute, var(--text-secondary));
min-width: 42px;
}
.mod-fader__track {
flex: 1;
position: relative;
height: 5px;
border-radius: 99px;
background: color-mix(in srgb, var(--ch) 12%, var(--lux-bg-3, var(--border-color)));
overflow: hidden;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.4);
pointer-events: none;
}
[data-theme="light"] .mod-fader__track {
box-shadow: inset 0 1px 2px rgba(15, 20, 25, 0.08);
}
.mod-fader__fill {
position: absolute;
left: 0; top: 0; bottom: 0;
background: linear-gradient(90deg,
color-mix(in srgb, var(--ch) 50%, transparent),
var(--ch));
box-shadow: 0 0 8px color-mix(in srgb, var(--ch) 60%, transparent);
}
/* The slider input lays flat over the track, transparent, so the user
drags the visual track without seeing the native control. */
.mod-fader { position: relative; }
.mod-fader__slider {
position: absolute;
left: 52px;
right: 50px; /* between label and value cells */
top: 50%;
transform: translateY(-50%);
height: 18px;
width: auto;
margin: 0;
opacity: 0;
cursor: pointer;
z-index: 2;
}
.mod-fader__v {
font-family: var(--font-mono, monospace);
font-size: 0.78rem;
font-weight: 700;
color: var(--lux-ink, var(--text-color));
min-width: 34px;
text-align: right;
font-variant-numeric: tabular-nums;
}
/* ── Preview surface (gradient strip / asset thumb / LED preview) ── */
.mod-preview {
border-radius: var(--lux-r-sm, 4px);
box-shadow: inset 0 0 0 1px var(--lux-line, var(--border-color));
overflow: hidden;
position: relative;
}
.mod-preview canvas {
display: block;
width: 100%;
height: 100%;
}
/* ── Description text ──────────────────────────────────────────── */
.mod-desc {
font-size: 0.82rem;
color: var(--lux-ink-dim, var(--text-secondary));
line-height: 1.45;
}
/* ── Mod-card specific tweaks for chips inside cards ─────────────
The flex order in .mod-foot is: .mod-patch (margin-right:auto) on
the left, primary action(s) on the right. Multiple .chip rows above
the foot inherit normal flex-wrap behavior from .mod-chips. */
/* Mobile — reduce padding on mod-cards */
@media (max-width: 768px) {
.card.mod-card,
.template-card.mod-card {
padding: 14px 14px 12px 18px;
gap: 12px;
}
.mod-menu {
min-width: 200px;
}
}
+69 -30
View File
@@ -434,21 +434,52 @@ input:-webkit-autofill:focus {
}
.toast {
--toast-ch: var(--primary-color);
position: fixed;
bottom: 40px;
left: 50%;
transform: translateX(-50%) translateY(100px);
padding: 16px 24px;
border-radius: var(--radius-md);
color: white;
padding: 14px 22px;
border-radius: var(--lux-r-lg, var(--radius-md, 10px));
color: var(--lux-ink, #fff);
font-weight: 600;
font-size: 15px;
font-size: 14.5px;
letter-spacing: 0.01em;
opacity: 0;
transition: opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1), transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
transition: opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1),
transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
z-index: var(--z-toast);
box-shadow: 0 4px 20px var(--shadow-color);
background: linear-gradient(180deg,
var(--lux-bg-1, var(--card-bg)) 0%,
var(--lux-bg-2, var(--card-bg)) 100%);
border: var(--lux-hairline, 1px) solid color-mix(in srgb,
var(--toast-ch) 35%, var(--lux-line-bold, var(--border-color)));
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.02),
0 14px 40px rgba(0, 0, 0, 0.5),
0 0 26px color-mix(in srgb, var(--toast-ch) 22%, transparent);
min-width: 300px;
max-width: min(560px, calc(100vw - 32px));
text-align: center;
overflow: hidden;
}
/* Top accent stripe — matches modals/bulk-toolbar so the channel language
is consistent across every floating surface. */
.toast::before {
content: '';
position: absolute;
left: 0; right: 0; top: 0;
height: 1.5px;
background: linear-gradient(90deg,
transparent 0%,
var(--toast-ch) 20%,
var(--toast-ch) 80%,
transparent 100%);
box-shadow: 0 0 12px color-mix(in srgb, var(--toast-ch) 55%, transparent);
pointer-events: none;
}
.toast.show {
@@ -458,23 +489,15 @@ input:-webkit-autofill:focus {
}
@keyframes toastBounceIn {
0% { transform: translateX(-50%) translateY(60px); opacity: 0; }
50% { transform: translateX(-50%) translateY(-4px); opacity: 1; }
70% { transform: translateX(-50%) translateY(2px); }
0% { transform: translateX(-50%) translateY(60px); opacity: 0; }
50% { transform: translateX(-50%) translateY(-4px); opacity: 1; }
70% { transform: translateX(-50%) translateY(2px); }
100% { transform: translateX(-50%) translateY(0); }
}
.toast.success {
background: var(--primary-color);
}
.toast.error {
background: var(--danger-color);
}
.toast.info {
background: var(--info-color);
}
.toast.success { --toast-ch: var(--primary-color); }
.toast.error { --toast-ch: var(--danger-color); }
.toast.info { --toast-ch: var(--info-color); }
/* Toast with undo action */
.toast-with-action {
@@ -488,31 +511,38 @@ input:-webkit-autofill:focus {
}
.toast-undo-btn {
background: rgba(255, 255, 255, 0.25);
border: 1px solid rgba(255, 255, 255, 0.4);
color: white;
padding: 4px 12px;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--toast-ch) 18%, transparent);
border: 1px solid color-mix(in srgb, var(--toast-ch) 50%, transparent);
color: var(--lux-ink, #fff);
padding: 5px 14px;
border-radius: var(--lux-r-sm, var(--radius-sm));
font-weight: var(--weight-semibold, 600);
font-size: 0.85rem;
letter-spacing: 0.04em;
cursor: pointer;
transition: background var(--duration-fast, 0.15s);
transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
white-space: nowrap;
flex-shrink: 0;
}
.toast-undo-btn:hover {
background: rgba(255, 255, 255, 0.4);
background: color-mix(in srgb, var(--toast-ch) 32%, transparent);
border-color: var(--toast-ch);
box-shadow: 0 0 14px color-mix(in srgb, var(--toast-ch) 35%, transparent);
}
.toast-timer {
width: 100%;
height: 3px;
height: 2px;
position: absolute;
bottom: 0;
left: 0;
border-radius: 0 0 var(--radius-md) var(--radius-md);
background: rgba(255, 255, 255, 0.3);
border-radius: 0 0 var(--lux-r-lg, var(--radius-md)) var(--lux-r-lg, var(--radius-md));
background: linear-gradient(90deg,
color-mix(in srgb, var(--toast-ch) 70%, transparent) 0%,
var(--toast-ch) 50%,
color-mix(in srgb, var(--toast-ch) 70%, transparent) 100%);
box-shadow: 0 0 8px color-mix(in srgb, var(--toast-ch) 60%, transparent);
transform-origin: left;
animation: toastTimer var(--toast-duration, 5s) linear forwards;
}
@@ -749,6 +779,15 @@ textarea:focus-visible {
margin-left: auto;
}
/* Hide empty icon/label slots so flex gaps don't reserve unused space.
Used by minimal items like the locale picker (2-letter code only). */
.icon-select-trigger-icon:empty,
.icon-select-trigger-label:empty,
.icon-select-cell-icon:empty,
.icon-select-cell-label:empty {
display: none;
}
.icon-select-popup {
position: fixed;
z-index: var(--z-lightbox);
+152 -1
View File
@@ -286,6 +286,42 @@
text-overflow: ellipsis;
}
/* Device URL hyperlink inside the meta line.
Picks up the card's channel accent (--ch) so it ties into the card's
color identity instead of using the generic browser-blue link style. */
.mod-meta__link {
display: inline-flex;
align-items: baseline;
gap: 4px;
color: var(--ch, var(--primary-text-color));
text-decoration: none;
border-bottom: 1px dotted color-mix(in srgb, var(--ch, var(--primary-color)) 55%, transparent);
padding-bottom: 1px;
transition: color 0.15s ease, border-color 0.15s ease, text-shadow 0.15s ease;
}
.mod-meta__link:hover,
.mod-meta__link:focus-visible {
color: var(--ch, var(--primary-color));
border-bottom-style: solid;
border-bottom-color: currentColor;
text-shadow: 0 0 8px color-mix(in srgb, var(--ch, var(--primary-color)) 35%, transparent);
outline: none;
}
.mod-meta__link .icon {
width: 0.95em;
height: 0.95em;
transform: translateY(0.12em);
opacity: 0.7;
transition: opacity 0.15s ease;
}
.mod-meta__link:hover .icon,
.mod-meta__link:focus-visible .icon {
opacity: 1;
}
.mod-leds {
display: flex;
align-items: center;
@@ -404,6 +440,34 @@
margin-left: 3px;
}
/* Text-stack variant — used for short identifier values that don't render
well in the heavy display font (e.g. SK6812 + RGBW). The primary line
sits in the same slot as `.v`, and an optional `.v-sub` element below
carries a secondary line in the small mono caps style. */
.mod-metric--text-stack .v {
font-family: var(--font-mono, monospace);
font-size: 1rem;
font-weight: 700;
letter-spacing: 0.04em;
line-height: 1.15;
white-space: normal;
overflow: visible;
text-overflow: clip;
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-start;
}
.mod-metric--text-stack .v-sub {
font-family: var(--font-mono, monospace);
font-size: 0.55rem;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--lux-ink-mute, var(--text-secondary));
opacity: 0.85;
}
.mod-metric .v .dashboard-fps-target {
font-family: var(--font-mono, monospace);
font-size: 0.6rem;
@@ -1139,6 +1203,12 @@
padding: 0 18px 14px;
cursor: crosshair;
filter: drop-shadow(0 0 5px color-mix(in srgb, var(--perf-accent) 45%, transparent));
/* Clip the scroll-out animation: when a new sample arrives the SVG
is snap-translated +1 step right then eased back to 0, so the
previous sample's frame remains visually anchored while old
samples slide off the left. Without overflow:hidden the temporary
overshoot would peek into the padding. */
overflow: hidden;
}
.perf-chart-spark .perf-chart-svg {
@@ -1149,6 +1219,18 @@
width: 100%;
height: 100%;
display: block;
/* Promote to its own compositor layer so the scroll animation runs
on the GPU — the path strings underneath don't repaint, only the
layer transform updates per frame. */
will-change: transform;
}
/* Honor the global animations preference. "reduced" keeps the scroll
but cuts duration so motion is brief; "off" pins the SVG so each
tick is a hard cut. */
[data-layout-anim="off"] .perf-chart-spark .perf-chart-svg {
transition: none !important;
transform: none !important;
}
.perf-chart-unavailable {
@@ -1262,6 +1344,69 @@
— the list owns the rest of the cell height. */
.perf-patches-cell .perf-chart-spark { display: none; }
/* Errors cell — value is muted at 0 (healthy state, no alarm) and
shifts to the cell's coral accent the moment the rate or cumulative
count goes non-zero. The accent stripe + value tint give a passive
"is anything wrong?" indicator without flashing or animation. */
.perf-errors-cell .perf-chart-value {
color: var(--lux-ink-mute, var(--text-secondary));
opacity: 0.65;
}
.perf-errors-cell.has-errors .perf-chart-value {
color: var(--perf-accent, var(--ch-coral, var(--danger-color)));
opacity: 1;
}
.perf-errors-cell.has-errors .perf-chart-subtitle {
color: var(--perf-accent, var(--ch-coral, var(--danger-color)));
opacity: 0.85;
}
/* Empty-state hint shown when no patches are running. A pulsing accent
dot keeps the card visually alive even when idle, and the small caps
text reads as a status line ("Ready to launch") rather than a stale
empty list. */
.perf-patches-empty {
display: flex;
align-items: center;
gap: 8px;
padding-top: 4px;
font-family: var(--font-mono, monospace);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--lux-ink-mute, var(--text-secondary));
opacity: 0.85;
}
.perf-patches-empty-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--perf-accent, var(--ch-magenta, var(--primary-color)));
box-shadow: 0 0 6px currentColor;
color: var(--perf-accent, var(--ch-magenta, var(--primary-color)));
flex-shrink: 0;
animation: perfPatchesIdlePulse 2.4s ease-in-out infinite;
}
.perf-patches-empty-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@keyframes perfPatchesIdlePulse {
0%, 100% { opacity: 0.45; transform: scale(1); }
50% { opacity: 1; transform: scale(1.15); }
}
/* Honor the global "reduced/off" animations preference set on the
dashboard root — the pulse vanishes when the user wants stillness. */
[data-layout-anim="off"] .perf-patches-empty-dot,
[data-layout-anim="reduced"] .perf-patches-empty-dot {
animation: none;
opacity: 0.7;
}
/* ── Devices cell — online/total count + dot strip per device ── */
.perf-devices-cell {
--perf-accent: var(--ch-signal, var(--primary-color));
@@ -1323,7 +1468,7 @@
color: var(--perf-accent);
}
.perf-chart-card[data-metric="fps"] .perf-fps-unit {
.perf-chart-card .perf-fps-unit {
font-family: var(--font-mono, monospace);
font-size: 0.3em;
font-weight: 500;
@@ -1333,6 +1478,12 @@
margin-left: 6px;
align-self: center;
}
/* Errors cell — when the rate is non-zero, the unit takes the same coral
tint as the value so they read as a single composite reading. */
.perf-errors-cell.has-errors .perf-fps-unit {
color: var(--perf-accent, var(--ch-coral, var(--danger-color)));
opacity: 0.85;
}
/* Target-FPS ceiling suffix — "/ 120" next to the big live number, sized
down + muted so the live value remains the primary reading. Matches
+2 -3
View File
@@ -770,12 +770,12 @@ h2 {
height: 30px;
padding: 0;
background: transparent;
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
border: none;
border-radius: var(--lux-r-sm, 3px);
cursor: pointer;
font-size: 0.9rem;
color: var(--lux-ink-dim, var(--text-secondary));
transition: color 0.2s, background 0.2s, border-color 0.2s, box-shadow 0.2s;
transition: color 0.2s, background 0.2s, box-shadow 0.2s;
display: inline-grid;
place-items: center;
line-height: 1;
@@ -785,7 +785,6 @@ h2 {
.header-btn:hover {
color: var(--lux-ink, var(--text-color));
background: var(--lux-bg-2, var(--bg-secondary));
border-color: var(--lux-line-bold, var(--border-color));
box-shadow: 0 0 12px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 18%, transparent);
}
+66 -5
View File
@@ -432,28 +432,43 @@
.settings-tab-bar {
display: flex;
justify-content: center;
justify-content: space-around;
gap: 0;
border-bottom: 2px solid var(--border-color);
padding: 0 0.75rem;
padding: 0 0.5rem;
}
.settings-tab-btn {
background: none;
border: none;
padding: 8px 12px;
padding: 10px 0;
flex: 1 1 0;
min-width: 0;
font-size: 0.85rem;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
white-space: nowrap;
transition: color 0.2s ease, border-color 0.25s ease;
display: inline-flex;
align-items: center;
justify-content: center;
transition: color 0.2s ease, border-color 0.25s ease, background 0.2s ease;
}
.settings-tab-btn .icon {
width: 18px;
height: 18px;
transition: transform 0.2s ease, filter 0.2s ease;
}
.settings-tab-btn:hover {
color: var(--text-color);
background: color-mix(in srgb, var(--primary-color) 6%, transparent);
}
.settings-tab-btn:hover .icon {
transform: translateY(-1px);
}
.settings-tab-btn.active {
@@ -461,6 +476,15 @@
border-bottom-color: var(--primary-color);
}
.settings-tab-btn.active .icon {
filter: drop-shadow(0 0 6px color-mix(in srgb, var(--primary-color) 50%, transparent));
}
.settings-tab-btn:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: -2px;
}
.settings-panel {
display: none;
}
@@ -904,6 +928,43 @@
}
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes slideDown {
from {
transform: translateY(0) scale(1);
opacity: 1;
}
to {
transform: translateY(12px) scale(0.97);
opacity: 0;
}
}
/* Exit animation — applied by Modal.forceClose() before display:none.
Symmetric to fadeIn/slideUp, slightly faster on the way out for a
responsive feel. */
.modal.closing {
animation: fadeOut 0.18s ease-in forwards;
pointer-events: none;
}
.modal.closing .modal-content {
animation: slideDown 0.2s cubic-bezier(0.7, 0, 0.84, 0) forwards;
}
@media (prefers-reduced-motion: reduce) {
.modal,
.modal.closing,
.modal-content,
.modal.closing .modal-content {
animation: none !important;
}
}
.modal-header {
padding: 22px 24px 14px 24px;
border-bottom: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
+56 -22
View File
@@ -834,55 +834,89 @@ body.pp-filter-dragging .pp-filter-drag-handle {
.cs-filter-wrap {
position: relative;
width: 180px;
max-width: 40%;
width: 220px;
max-width: 45%;
flex-shrink: 0;
}
/* Search input with embedded magnifier icon (data URI keeps the HTML
untouched), neon focus glow, monospace placeholder for the technical
look used elsewhere in the dashboard. */
.cs-filter-wrap .cs-filter {
width: 100%;
padding: 4px 26px 4px 10px;
font-size: 0.78rem;
border: 1px solid var(--border-color);
border-radius: 14px;
background: var(--bg-secondary);
color: var(--text-color);
padding: 7px 32px 7px 32px;
font-family: var(--font-mono, monospace);
font-size: 0.76rem;
letter-spacing: 0.04em;
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
border-radius: var(--lux-r-md, 6px);
background-color: var(--lux-bg-0, var(--bg-secondary));
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238a8a8a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><circle cx='11' cy='11' r='8'/><path d='m21 21-4.35-4.35'/></svg>");
background-repeat: no-repeat;
background-position: 10px center;
background-size: 14px 14px;
color: var(--lux-ink, var(--text-color));
outline: none;
box-shadow: none;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.25);
box-sizing: border-box;
transition: border-color 0.2s, background 0.2s, width 0.2s;
transition:
border-color 0.15s ease,
background-color 0.15s ease,
box-shadow 0.2s ease;
}
.cs-filter-wrap .cs-filter:hover {
border-color: var(--lux-line-bold, var(--border-color));
}
.cs-filter-wrap .cs-filter:focus {
border-color: var(--primary-color);
background: var(--bg-color);
background-color: var(--lux-bg-1, var(--bg-color));
box-shadow:
inset 0 1px 2px rgba(0, 0, 0, 0.3),
0 0 0 3px color-mix(in srgb, var(--primary-color) 18%, transparent),
0 0 18px color-mix(in srgb, var(--primary-color) 22%, transparent);
}
.cs-filter::placeholder {
color: var(--text-secondary);
font-size: 0.75rem;
color: var(--lux-ink-faint, var(--text-secondary));
font-size: 0.72rem;
letter-spacing: 0.06em;
text-transform: uppercase;
opacity: 0.85;
}
.cs-filter-reset {
position: absolute;
right: 2px;
right: 4px;
top: 50%;
transform: translateY(-50%);
background: none;
width: 22px;
height: 22px;
background: transparent;
border: none;
color: var(--text-secondary);
font-size: 1rem;
color: var(--lux-ink-mute, var(--text-secondary));
font-size: 0.95rem;
cursor: pointer;
padding: 0 5px;
padding: 0;
line-height: 1;
border-radius: 50%;
transition: color 0.15s, background 0.15s;
border-radius: var(--lux-r-sm, 3px);
display: inline-flex;
align-items: center;
justify-content: center;
transition: color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
}
/* Hide the clear button when the input is empty CSS-only so the visual
state stays correct regardless of any JS-set inline display value. */
.cs-filter-wrap .cs-filter:placeholder-shown + .cs-filter-reset {
display: none;
}
.cs-filter-reset:hover {
color: var(--text-color);
background: var(--border-color);
color: var(--lux-ink, var(--text-color));
background: color-mix(in srgb, var(--primary-color) 14%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--primary-color) 30%, transparent);
}
/* Empty state for CardSection */
+8
View File
@@ -16,6 +16,8 @@ import { initCardGlare } from './core/card-glare.ts';
import { initBgAnim, updateBgAnimAccent, updateBgAnimTheme } from './core/bg-anim.ts';
import { initBgShaders } from './core/bg-shaders.ts';
import { initTabIndicator, updateTabIndicator } from './core/tab-indicator.ts';
import { initModMenu } from './core/mod-menu.ts';
import { toggleCardHidden } from './core/card-sections.ts';
// Layer 2: ui
import {
@@ -240,6 +242,11 @@ Object.assign(window, {
// core / state (for inline script)
setApiKey,
// mod-card menu — referenced by inline onclick on .mod-menu__item
// for the "Hide card" entry. The handler uses the registered
// CardSection's section key (e.g. 'led-devices') + the card id.
toggleCardHidden,
// visual effects (called from inline <script>)
_updateBgAnimAccent: updateBgAnimAccent,
_updateBgAnimTheme: updateBgAnimTheme,
@@ -737,6 +744,7 @@ document.addEventListener('DOMContentLoaded', async () => {
// Initialize visual effects
initCardGlare();
initModMenu();
initBgAnim();
initBgShaders();
initAppearance();
@@ -11,6 +11,7 @@
import { t } from './i18n.ts';
import { showConfirm } from './ui.ts';
import { ICON_LIST_CHECKS, ICON_CIRCLE_OFF, ICON_X } from './icons.ts';
import type { CardSection } from './card-sections.ts';
let _activeSection: CardSection | null = null; // CardSection currently in bulk mode
@@ -53,28 +54,40 @@ function _render() {
if (!section) { el.classList.remove('visible'); return; }
const count = section._selected.size;
const total = section._visibleCardCount();
const actions = section.bulkActions || [];
const allSelected = count > 0 && count === total;
const noneSelected = count === 0;
const actionBtns = actions.map(a => {
const cls = a.style === 'danger' ? 'btn btn-icon btn-danger bulk-action-btn' : 'btn btn-icon btn-secondary bulk-action-btn';
const label = t(a.labelKey);
const inner = a.icon || label;
return `<button class="${cls}" data-bulk-action="${a.key}" title="${label}">${inner}</button>`;
const disabled = noneSelected ? ' disabled aria-disabled="true"' : '';
return `<button class="${cls}"${disabled} data-bulk-action="${a.key}" title="${label}">${inner}</button>`;
}).join('');
// Two distinct icon buttons replace the previous tri-state checkbox so
// the affordance is unambiguous: pick everything, or clear the picks.
const selectAllDisabled = allSelected ? ' disabled aria-disabled="true"' : '';
const deselectAllDisabled = noneSelected ? ' disabled aria-disabled="true"' : '';
el.innerHTML = `
<label class="bulk-select-all-wrap" title="${t('bulk.select_all')}">
<input type="checkbox" class="bulk-select-all-cb"${count > 0 && count === section._visibleCardCount() ? ' checked' : ''}>
</label>
<span class="bulk-count">${t('bulk.selected_count', { count })}</span>
<div class="bulk-pick">
<button class="bulk-pick-btn" data-bulk-pick="all"${selectAllDisabled} title="${t('bulk.select_all')}" aria-label="${t('bulk.select_all')}">${ICON_LIST_CHECKS}</button>
<button class="bulk-pick-btn" data-bulk-pick="none"${deselectAllDisabled} title="${t('bulk.deselect_all')}" aria-label="${t('bulk.deselect_all')}">${ICON_CIRCLE_OFF}</button>
</div>
<span class="bulk-count" aria-live="polite">${t('bulk.selected_count', { count })}<small class="bulk-count-total"> / ${total}</small></span>
<div class="bulk-actions">${actionBtns}</div>
<button class="bulk-close" title="${t('bulk.cancel')}">&#x2715;</button>
<button class="bulk-close" title="${t('bulk.cancel')}" aria-label="${t('bulk.cancel')}">${ICON_X}</button>
`;
// Select All checkbox
el.querySelector('.bulk-select-all-cb')!.addEventListener('change', (e) => {
if ((e.target as HTMLInputElement).checked) section.selectAll();
else section.deselectAll();
// Pick buttons
el.querySelector('[data-bulk-pick="all"]')!.addEventListener('click', () => {
section.selectAll();
});
el.querySelector('[data-bulk-pick="none"]')!.addEventListener('click', () => {
section.deselectAll();
});
// Action buttons
+165 -26
View File
@@ -21,6 +21,8 @@
import { createColorPicker, registerColorPicker } from './color-picker.ts';
import { ICON_TRASH } from './icons.ts';
import { renderModCardInner } from './mod-card.ts';
import type { ModCardOpts } from './mod-card.ts';
const STORAGE_KEY = 'cardColors';
const DEFAULT_SWATCH = '#808080';
@@ -63,6 +65,25 @@ export function setCardColor(id: string, hex: string): void {
const card = el as HTMLElement;
if (hex) card.style.setProperty('--ch', hex);
else card.style.removeProperty('--ch');
// Mod-card variant: also sync the leading colour-dot inside
// .mod-badge. The picker's `_cpPick` already inlined a
// background:hex on the swatch — clear it so the dot reverts
// to its channel-color default when the user resets.
const dot = card.querySelector(`#cp-swatch-cc-${escaped}`) as HTMLElement | null;
if (dot && dot.classList.contains('mod-badge__color')) {
if (hex) {
dot.dataset.custom = '1';
dot.style.setProperty('--user-color', hex);
// Clear any inline `background:hex` set by _cpPick so the
// CSS [data-custom] rule wins (background = var(--user-color)).
dot.style.removeProperty('background');
} else {
delete dot.dataset.custom;
dot.style.removeProperty('--user-color');
dot.style.removeProperty('background');
}
}
});
}
@@ -94,36 +115,97 @@ export function cardColorButton(entityId: string, cardAttr: string): string {
return createColorPicker({ id: pickerId, currentColor: color, onPick: undefined, anchor: 'left', showReset: true, resetColor: DEFAULT_SWATCH });
}
/**
* Mod-card variant of `cardColorButton` emits the leading
* `.mod-badge__color` dot inside a `.mod-badge`. The dot IS the
* picker swatch (same id as the legacy swatch so `_cpToggle()` finds
* it). The popover is appended as a sibling of the badge so it can
* detach to <body> with fixed positioning when needed (the existing
* picker logic in color-picker.ts handles that automatically).
*
* Returns inline HTML to be injected as the first child of
* `.mod-badge`. No surrounding span the dot is the badge's leading
* element directly.
*/
export function cardColorDot(entityId: string, cardAttr: string): string {
const color = getCardColor(entityId);
const pickerId = `cc-${entityId}`;
registerColorPicker(pickerId, (hex) => {
setCardColor(entityId, hex);
});
// The dot doubles as the picker swatch. Inline `--user-color` is
// applied only when the user has picked a personal hue; absent it,
// the dot inherits the channel colour from the parent badge via
// the `.mod-badge__color` rule in cards.css.
const customAttr = color ? ` data-custom="1" style="--user-color:${color}"` : '';
const dataAttr = ` data-card-attr="${cardAttr}"`;
return `<span class="color-picker-wrapper mod-badge__color-wrap" id="cp-wrap-${entityId}">` +
`<span class="mod-badge__color color-picker-swatch" id="cp-swatch-${pickerId}" tabindex="0" role="button" aria-label="${getCardColorAriaLabel()}" onclick="event.stopPropagation(); window._cpToggle('${pickerId}')"${customAttr}${dataAttr}></span>` +
_colorPopoverHtml(pickerId, color || DEFAULT_SWATCH) +
`</span>`;
}
/** Build just the popover portion (the swatch is rendered separately
* by `cardColorDot`). Mirrors `createColorPicker`'s popover output. */
function _colorPopoverHtml(pickerId: string, currentColor: string): string {
// Reuse createColorPicker's full output but strip the wrapping
// .color-picker-wrapper and the legacy round swatch — we only need
// the popover. Easier: render the full widget into a temporary
// string and extract the popover. But that adds complexity. Since
// the popover markup is stable, inline it here.
const PRESETS = ['#4CAF50', '#7C4DFF', '#FF6D00', '#E91E63', '#00BCD4', '#FF5252', '#26A69A', '#2196F3', '#FFC107'];
const dots = PRESETS.map(c => {
const active = c.toLowerCase() === currentColor.toLowerCase() ? ' active' : '';
return `<button class="color-picker-dot${active}" style="background:${c}" aria-label="${c}" onclick="event.stopPropagation(); window._cpPick('${pickerId}','${c}')"></button>`;
}).join('');
return `<div class="color-picker-popover anchor-left" id="cp-pop-${pickerId}" style="display:none" onclick="event.stopPropagation()">` +
`<div class="color-picker-grid">${dots}</div>` +
`<div class="color-picker-custom" onclick="this.querySelector('input').click()">` +
`<input type="color" id="cp-native-${pickerId}" value="${currentColor}" ` +
`oninput="event.stopPropagation(); window._cpPick('${pickerId}',this.value)" ` +
`onchange="event.stopPropagation(); window._cpPick('${pickerId}',this.value)">` +
`<span>Custom</span>` +
`</div>` +
`<div class="color-picker-reset" onclick="event.stopPropagation(); window._cpReset('${pickerId}','${DEFAULT_SWATCH}')"><span>Reset</span></div>` +
`</div>`;
}
function getCardColorAriaLabel(): string {
// i18n is not always loaded when this module evaluates (renders
// happen during early page boot). Fall back to English.
return (window as any).__t?.('common.card_color') || 'Card color';
}
/**
* Build a standard card shell with color support.
*
* Provides consistent structure across all card types:
* - .card-top-actions: remove button + optional extra top buttons
* - Bottom actions: action buttons + color picker (always last)
* - Automatic border-left color from localStorage
* Two rendering paths:
*
* @param {object} opts
* @param {'card'|'template-card'} [opts.type='card'] Card CSS class
* @param {string} opts.dataAttr Data attribute name, e.g. 'data-device-id'
* @param {string} opts.id Entity ID value
* @param {string} [opts.classes] Extra CSS classes on root element
* @param {string} [opts.topButtons] HTML for extra top-right buttons (power, autostart)
* @param {string} opts.removeOnclick onclick handler string for remove button
* @param {string} opts.removeTitle title attribute for remove button
* @param {string} opts.content Inner HTML (header, props, metrics, etc.)
* @param {string} opts.actions Action button HTML (without wrapper div)
* 1. Legacy (content/actions) emits `.card-top-actions` with the
* remove button and a bottom `.card-actions` row with action
* buttons + colour picker. Used by all cards that haven't been
* migrated to the modular system yet.
*
* 2. Modular (`mod`) emits the dashboard's `.mod-head / .mod-body /
* .mod-foot` markup with a kebab overflow menu housing duplicate /
* hide / delete. Card-color picker is integrated into the
* `.mod-badge` as a leading dot. Use this for any new card and
* when migrating existing card builders.
*
* Old callers keep working unchanged. New callers pass `mod`.
*/
export function wrapCard({
type = 'card',
dataAttr,
id,
classes = '',
topButtons = '',
removeOnclick,
removeTitle,
content,
actions,
}: {
export function wrapCard(opts: WrapCardLegacyOpts | WrapCardModOpts): string {
if ('mod' in opts && opts.mod) {
return _renderModCard(opts as WrapCardModOpts);
}
return _renderLegacyCard(opts as WrapCardLegacyOpts);
}
export interface WrapCardLegacyOpts {
type?: 'card' | 'template-card';
dataAttr: string;
id: string;
@@ -133,7 +215,28 @@ export function wrapCard({
removeTitle: string;
content: string;
actions: string;
}): string {
mod?: undefined;
}
export interface WrapCardModOpts {
type?: 'card' | 'template-card';
dataAttr: string;
id: string;
classes?: string;
mod: ModCardOpts;
}
function _renderLegacyCard({
type = 'card',
dataAttr,
id,
classes = '',
topButtons = '',
removeOnclick,
removeTitle,
content,
actions,
}: WrapCardLegacyOpts): string {
const actionsClass = type === 'template-card' ? 'template-card-actions' : 'card-actions';
const colorStyle = cardColorStyle(id);
return `
@@ -149,3 +252,39 @@ export function wrapCard({
</div>
</div>`;
}
function _renderModCard({
type = 'card',
dataAttr,
id,
classes = '',
mod,
}: WrapCardModOpts): string {
// mod-card.ts and card-colors.ts have a cyclic import (mod-card uses
// cardColorDot, card-colors uses renderModCardInner). The cycle is
// safe because both modules only call each other inside function
// bodies — never during module initialization. ESM live bindings
// resolve correctly by the time these functions actually run.
const inner = renderModCardInner(_withColorBindings(mod, id, dataAttr));
const colorStyle = cardColorStyle(id);
const runningCls = mod.running ? ' card-running is-running' : '';
const colorAttr = colorStyle ? ` style="${colorStyle}" data-has-color="1"` : '';
return `
<div class="${type} mod-card${classes ? ' ' + classes : ''}${runningCls}" ${dataAttr}="${id}"${colorAttr}>
${inner}
</div>`;
}
/** Inject entityId/cardAttr into the head opts so the badge dot can
* register its picker without callers needing to pass the same id
* twice. */
function _withColorBindings(mod: ModCardOpts, id: string, dataAttr: string): ModCardOpts {
return {
...mod,
head: {
...mod.head,
entityId: mod.head.entityId ?? id,
cardAttr: mod.head.cardAttr ?? dataAttr,
},
};
}
@@ -23,7 +23,7 @@
import { t } from './i18n.ts';
import { showBulkToolbar, hideBulkToolbar, updateBulkToolbar } from './bulk-toolbar.ts';
import { ICON_LIST_CHECKS, ICON_EYE, ICON_EYE_OFF } from './icons.ts';
import { ICON_LIST_CHECKS, ICON_EYE, ICON_EYE_OFF, ICON_CHECK } from './icons.ts';
export interface BulkAction {
key: string;
@@ -316,30 +316,46 @@ export class CardSection {
});
}
// Card click delegation for selection
// Ctrl+Click on a card auto-enters bulk mode if not already selecting
// Card click delegation for selection.
//
// When in selection mode, the entire card becomes the click
// target — clicking anywhere on it toggles selection. Inner
// controls (buttons, sliders, kebab, color dot) are visually
// disabled via `.cs-selecting *` pointer-events:none in CSS,
// so events pass through to the card root.
//
// Outside selection mode, Ctrl/Cmd+Click on the card body
// (not on a button/input) auto-enters bulk mode and selects
// that card.
content.addEventListener('click', (e: MouseEvent) => {
if (!this.keyAttr) return;
const card = (e.target as HTMLElement).closest(`[${this.keyAttr}]`);
if (!card) return;
// Don't hijack clicks on buttons, links, inputs inside cards
if ((e.target as HTMLElement).closest('button, a, input, select, textarea, .card-actions, .template-card-actions, .color-picker-wrapper')) return;
// Auto-enter selection mode on Ctrl/Cmd+Click
if (!this._selecting && (e.ctrlKey || e.metaKey)) {
if (this._selecting) {
// pointer-events:none on descendants means events
// already bypass inner controls; just toggle.
const key = card.getAttribute(this.keyAttr);
if (!key) return;
if (e.shiftKey && this._lastClickedKey) {
this._selectRange(content, this._lastClickedKey, key);
} else {
this._toggleSelect(key);
}
this._lastClickedKey = key;
return;
}
// Not selecting — only auto-enter on Ctrl/Cmd+Click
// outside any interactive control.
if ((e.target as HTMLElement).closest('button, a, input, select, textarea, .card-actions, .template-card-actions, .color-picker-wrapper, .mod-menu-wrap, .mod-fader')) return;
if (e.ctrlKey || e.metaKey) {
this.enterSelectionMode();
}
if (!this._selecting) return;
const key = card.getAttribute(this.keyAttr);
if (!key) return;
if (e.shiftKey && this._lastClickedKey) {
this._selectRange(content, this._lastClickedKey, key);
} else {
const key = card.getAttribute(this.keyAttr);
if (!key) return;
this._toggleSelect(key);
this._lastClickedKey = key;
}
this._lastClickedKey = key;
});
// Escape to exit selection mode
@@ -358,6 +374,7 @@ export class CardSection {
if (this.keyAttr) {
this._injectHideButtons(content);
this._injectDragHandles(content);
this._injectBulkTicks(content);
this._initDrag(content);
}
@@ -463,10 +480,11 @@ export class CardSection {
}
}
// Re-inject hide buttons and drag handles on new/replaced cards
// Re-inject hide buttons, drag handles, bulk ticks on new/replaced cards
if (this.keyAttr && (added.size > 0 || replaced.size > 0)) {
this._injectHideButtons(content);
this._injectDragHandles(content);
this._injectBulkTicks(content);
}
// Re-cache searchable text for new/replaced cards
@@ -638,28 +656,31 @@ export class CardSection {
});
}
_injectCheckboxes(content: HTMLElement) {
_injectCheckboxes(_content: HTMLElement) {
// Selection mode now uses the card root as the click target —
// no per-card checkbox is injected. The visual indicator is the
// `.card-selected` border + box-shadow defined in CSS, plus
// pointer-events:none on descendants so the entire card behaves
// as one big toggle. Kept as a no-op so existing callsites in
// bind() / reconcile() don't need changes.
}
/** Inject a single .mod-bulk-tick element per card. Hidden by CSS
* unless the card is `.card-selected` AND the section is
* `.cs-selecting` so it costs nothing visually outside bulk mode
* and renders a corner checkmark when both flags are on. */
_injectBulkTicks(content: HTMLElement) {
if (!this.keyAttr) return;
content.querySelectorAll(`[${this.keyAttr}]`).forEach(card => {
if (card.querySelector('.card-bulk-check')) return;
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'card-bulk-check';
cb.checked = this._selected.has(card.getAttribute(this.keyAttr)!);
cb.addEventListener('click', (e: MouseEvent) => {
e.stopPropagation();
const key = card.getAttribute(this.keyAttr)!;
if (e.shiftKey && this._lastClickedKey) {
this._selectRange(content, this._lastClickedKey, key);
} else {
this._toggleSelect(key);
}
this._lastClickedKey = key;
});
// Insert as first child of .card-top-actions, or prepend to card
const topActions = card.querySelector('.card-top-actions');
if (topActions) topActions.prepend(cb);
else card.prepend(cb);
// Strip any legacy injected checkboxes from older deploys
const stale = card.querySelector('.card-bulk-check');
if (stale) stale.remove();
if (card.querySelector('.mod-bulk-tick')) return;
const tick = document.createElement('span');
tick.className = 'mod-bulk-tick';
tick.setAttribute('aria-hidden', 'true');
tick.innerHTML = ICON_CHECK;
card.appendChild(tick);
});
}
+6 -4
View File
@@ -98,12 +98,14 @@ export function changeLocale() {
}
}
/** Build locale items for the IconSelect. Uses 2-letter code badge as icon. */
/** Build locale items for the IconSelect. The 2-letter code is the only label
* long-form names (English / Русский / ) were redundant when the code is
* already an unambiguous identifier. Empty `icon` is hidden by CSS via :empty. */
function _getLocaleItems(): { value: string; icon: string; label: string }[] {
return [
{ value: 'en', icon: '<span style="font-weight:700">EN</span>', label: 'English' },
{ value: 'ru', icon: '<span style="font-weight:700">RU</span>', label: 'Русский' },
{ value: 'zh', icon: '<span style="font-weight:700">ZH</span>', label: '中文' },
{ value: 'en', icon: '', label: 'EN' },
{ value: 'ru', icon: '', label: 'RU' },
{ value: 'zh', icon: '', label: 'ZH' },
];
}
@@ -81,6 +81,7 @@ export const keyboard = '<path d="M10 8h.01"/><path d="M12 12h.01"/><path d=
export const mouse = '<rect x="5" y="2" width="14" height="20" rx="7"/><path d="M12 6v4"/>';
export const headphones = '<path d="M3 14h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a9 9 0 0 1 18 0v7a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3"/>';
export const trash2 = '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>';
export const moreHorizontal = '<circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none"/><circle cx="19" cy="12" r="1.6" fill="currentColor" stroke="none"/><circle cx="5" cy="12" r="1.6" fill="currentColor" stroke="none"/>';
export const listChecks = '<path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/>';
export const circleOff = '<path d="m2 2 20 20"/><path d="M8.35 2.69A10 10 0 0 1 21.3 15.65"/><path d="M19.08 19.08A10 10 0 1 1 4.92 4.92"/>';
export const externalLink = '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>';
@@ -87,6 +87,19 @@ export class IconSelect {
this._searchable = searchable;
this._searchPlaceholder = searchPlaceholder;
// Ensure the native select has an <option> for every item so .value can
// be set programmatically. HTML-authored selects sometimes ship empty,
// and assigning .value to a select with no matching option is a no-op,
// which leaves the trigger label blank.
if (this._select.options.length === 0) {
for (const item of this._items) {
const opt = document.createElement('option');
opt.value = item.value;
opt.textContent = item.label;
this._select.appendChild(opt);
}
}
// Hide the native select
this._select.style.display = 'none';
@@ -269,6 +269,7 @@ export const ICON_AUDIO_INPUT = _svg(P.mic);
export const ICON_CLOCK = _svg(P.clock);
export const ICON_WARNING = _svg(P.triangleAlert);
export const ICON_OK = _svg(P.circleCheck);
export const ICON_CHECK = _svg(P.check);
export const ICON_LINK_SOURCE = _svg(P.tv);
export const ICON_LED = _svg(P.lightbulb);
export const ICON_FPS = _svg(P.zap);
@@ -318,6 +319,7 @@ export const ICON_FAST_FORWARD = _svg(P.fastForward);
export const ICON_ROTATE_CW = _svg(P.rotateCw);
export const ICON_ROTATE_CCW = _svg(P.rotateCcw);
export const ICON_DOWNLOAD = _svg(P.download);
export const ICON_HARD_DRIVE = _svg(P.hardDrive);
export const ICON_UNDO = _svg(P.undo2);
export const ICON_SCENE = _svg(P.sparkles);
export const ICON_CAPTURE = _svg(P.camera);
@@ -330,6 +332,7 @@ export const ICON_KEYBOARD = _svg(P.keyboard);
export const ICON_MOUSE = _svg(P.mouse);
export const ICON_HEADPHONES = _svg(P.headphones);
export const ICON_TRASH = _svg(P.trash2);
export const ICON_KEBAB = _svg(P.moreHorizontal);
export const ICON_LIST_CHECKS = _svg(P.listChecks);
export const ICON_CIRCLE_OFF = _svg(P.circleOff);
export const ICON_EXTERNAL_LINK = _svg(P.externalLink);
@@ -0,0 +1,366 @@
/**
* Mod-card renderers structured helpers that emit the dashboard's
* `.mod-*` markup vocabulary on entity cards.
*
* Background: dashboard.css already ships the `.mod-head / .mod-id /
* .mod-badge / .mod-leds / .mod-metrics / .mod-foot / .mod-patch /
* .mod-btn` system. This module makes that same vocabulary callable
* from any feature's `create*Card()` builder, so device, target,
* automation, source, etc. cards can converge on the same visual
* language used by the Dashboard tab.
*
* Used by the new `wrapCard({ mod })` path in `card-colors.ts`.
*/
import { t } from './i18n.ts';
import { escapeHtml } from './api.ts';
import { ICON_TRASH, ICON_CLONE, ICON_EYE_OFF, ICON_EYE, ICON_KEBAB } from './icons.ts';
import { cardColorDot } from './card-colors.ts';
export type LedState = 'on' | 'off' | 'blink' | 'fault';
export interface ModBadgeOpts {
/** Mono-caps type label, e.g. "LED · CH-01" or "FFT · IN" */
text: string;
/** Emit a leading colour-picker dot inside the badge (default true).
* Set false for cards that don't carry a personal accent. */
colorDot?: boolean;
}
export interface ModMetricOpts {
/** Caps label */
k: string;
/** Display-font value (HTML allowed for <small> / <span> etc.) */
v: string;
/** Add 'signal' modifier so the value tints to --ch */
accent?: boolean;
/** Mark the cell as having errors (coral tint) */
error?: boolean;
/** Tooltip on the cell */
title?: string;
/** Inline icon next to the label */
icon?: string;
/** Optional id on the .v element for live updates */
valueId?: string;
/** Visual variant for the cell 'text-stack' renders the value in
* mono font with optional `<span class="v-sub">` block for a
* secondary line (e.g. LED chip + RGB/RGBW). The default display
* font is too coarse for string identifiers. */
variant?: 'text-stack';
}
export interface ModChipOpts {
/** Visible text */
text: string;
/** Inline icon HTML before the text */
icon?: string;
/** Click handler — triggers the link variant if set */
onclick?: string;
/** "tag" (filled), "err" (coral), or "default" */
variant?: 'default' | 'tag' | 'err';
/** Tooltip */
title?: string;
}
export interface ModFaderOpts {
/** Mono-caps label, e.g. "Bright" */
label: string;
/** Current value (0..max). Displayed as raw integer. */
value: number;
/** Maximum value (e.g. 255). Defaults to 100. */
max?: number;
/** Optional id on the slider for binding */
sliderId?: string;
/** oninput handler — receives `this.value` */
oninput?: string;
/** onchange handler — receives `this.value` */
onchange?: string;
/** Optional data attributes on the slider */
dataAttrs?: Record<string, string>;
/** Disable the fader */
disabled?: boolean;
}
export interface ModFootOpts {
/** Patch indicator state — controls the dot style */
patchState?: 'live' | 'standby' | 'offline' | 'idle';
/** Patch label — short caps text, e.g. "PATCHED · OUT-1" */
patchLabel?: string;
/** Primary action button (left of any secondary actions) */
primaryAction?: ModBtnOpts;
/** Secondary text-button(s) */
secondaryActions?: ModBtnOpts[];
/** Tertiary icon-only button(s) — settings, edit, etc. */
iconActions?: ModBtnOpts[];
}
export interface ModBtnOpts {
/** Visible label (omitted for icon-only variant) */
label?: string;
/** Icon HTML */
icon?: string;
/** onclick handler */
onclick: string;
/** Tooltip */
title?: string;
/** Variant */
variant?: 'go' | 'stop' | 'default';
/** Make this an icon-only button (.mod-btn-icon) */
iconOnly?: boolean;
/** i18n keys for runtime translation */
i18nTitle?: string;
}
export interface ModMenuItemOpts {
/** Visible text */
label: string;
/** Icon HTML */
icon?: string;
/** Inline onclick. The menu auto-closes on click. */
onclick: string;
/** Mark as destructive (coral colour, separator above) */
danger?: boolean;
}
export interface ModMenuOpts {
/** Append a Duplicate item (default true if duplicateOnclick provided) */
duplicateOnclick?: string;
/** Append a Hide-card item (default true). The handler should toggle
* the entity's hidden state via the existing CardSection helpers. */
hideOnclick?: string;
/** Append a destructive Delete item (with separator above). When
* omitted, no Delete option appears (e.g. built-in entities). */
deleteOnclick?: string;
/** Custom menu items inserted before the standard set */
extraItems?: ModMenuItemOpts[];
}
export interface ModHeadOpts {
badge: ModBadgeOpts;
/** Card title — escaped before rendering */
name: string;
/** Optional secondary line under the title plain text, escaped.
* Use `metaHtml` instead when you need a clickable link or other
* inline markup. Mutually exclusive with `metaHtml`. */
meta?: string;
/** Raw HTML version of `meta`. Caller is responsible for escaping
* any user-controlled substrings before passing in. */
metaHtml?: string;
/** 03 LEDs in the recessed bezel. Hidden when empty. */
leds?: LedState[];
/** Health dot to inject inline beside the name (raw HTML) */
healthDot?: string;
/** Overflow menu options. Pass null to suppress the menu entirely
* (e.g. for read-only or system cards). */
menu?: ModMenuOpts | null;
/** Entity id required when colorDot is true so the picker can
* register against it. */
entityId?: string;
/** Data-attr name (e.g. 'data-device-id') used by the picker to
* propagate the chosen colour to every card representing this
* entity. */
cardAttr?: string;
}
export interface ModBodyOpts {
metrics?: ModMetricOpts[];
chips?: ModChipOpts[];
fader?: ModFaderOpts;
/** Raw HTML for a preview surface (gradient strip, asset thumb, LED preview) */
preview?: string;
/** Free-form description text shown below the head */
desc?: string;
}
export interface ModCardOpts {
head: ModHeadOpts;
body?: ModBodyOpts;
foot?: ModFootOpts;
/** Mark the card as `is-running` */
running?: boolean;
}
// ─────────────────────────────────────────────────────────────────
// Renderers
// ─────────────────────────────────────────────────────────────────
function _ledsHtml(leds?: LedState[]): string {
if (!leds || leds.length === 0) return '';
const dots = leds.map(s => {
if (s === 'off') return '<span class="led"></span>';
if (s === 'fault') return '<span class="led fault"></span>';
if (s === 'blink') return '<span class="led on blink"></span>';
return '<span class="led on"></span>';
}).join('');
return `<div class="mod-leds" aria-hidden="true">${dots}</div>`;
}
function _menuHtml(menu: ModMenuOpts | null | undefined): string {
if (menu === null) return '';
const m = menu || {};
const items: string[] = [];
if (m.extraItems) {
for (const it of m.extraItems) {
const cls = it.danger ? 'mod-menu__item mod-menu__item--danger' : 'mod-menu__item';
items.push(`<button type="button" class="${cls}" role="menuitem" onclick="${it.onclick}">${it.icon || ''} <span>${escapeHtml(it.label)}</span></button>`);
}
}
if (m.duplicateOnclick) {
items.push(`<button type="button" class="mod-menu__item" role="menuitem" onclick="${m.duplicateOnclick}">${ICON_CLONE} <span>${t('common.clone')}</span></button>`);
}
if (m.hideOnclick) {
items.push(`<button type="button" class="mod-menu__item" role="menuitem" onclick="${m.hideOnclick}">${ICON_EYE_OFF} <span>${t('common.hide') || 'Hide'}</span></button>`);
}
if (m.deleteOnclick) {
if (items.length > 0) items.push('<div class="mod-menu__sep"></div>');
items.push(`<button type="button" class="mod-menu__item mod-menu__item--danger" role="menuitem" onclick="${m.deleteOnclick}">${ICON_TRASH} <span>${t('common.delete')}</span></button>`);
}
if (items.length === 0) return '';
return `<div class="mod-menu-wrap">
<button type="button" class="mod-menu-btn" aria-haspopup="true" aria-expanded="false" aria-label="${t('common.more_actions') || 'More actions'}" title="${t('common.more_actions') || 'More actions'}">${ICON_KEBAB}</button>
<div class="mod-menu" role="menu">${items.join('')}</div>
</div>`;
}
function _badgeHtml(badge: ModBadgeOpts, entityId?: string, cardAttr?: string): string {
const showDot = badge.colorDot !== false && entityId && cardAttr;
const dot = showDot ? cardColorDot(entityId!, cardAttr!) : '';
return `<span class="mod-badge">${dot}${escapeHtml(badge.text)}</span>`;
}
export function renderModHead(head: ModHeadOpts): string {
const badgeHtml = _badgeHtml(head.badge, head.entityId, head.cardAttr);
const nameHtml = `<div class="mod-name"><span>${escapeHtml(head.name)}</span>${head.healthDot || ''}</div>`;
const metaHtml = head.metaHtml ? `<div class="mod-meta">${head.metaHtml}</div>`
: head.meta ? `<div class="mod-meta">${escapeHtml(head.meta)}</div>`
: '';
const ledsHtml = _ledsHtml(head.leds);
const menuHtml = _menuHtml(head.menu);
// Order: id (flex:1) → kebab → LED bezel. LED status is the
// running/idle indicator and lives at the far-right corner where
// it doubles as the visual anchor of the head row. Kebab sits to
// its left as the second-most-discreet element.
return `<div class="mod-head">
<div class="mod-id">
${badgeHtml}
${nameHtml}
${metaHtml}
</div>
${menuHtml}
${ledsHtml}
</div>`;
}
export function renderModMetrics(metrics: ModMetricOpts[]): string {
if (!metrics.length) return '';
const cellsClass = metrics.length === 1 ? 'mod-metrics mod-metrics--1'
: metrics.length === 2 ? 'mod-metrics mod-metrics--2'
: 'mod-metrics';
const cells = metrics.map(m => {
const cellCls = [
'mod-metric',
m.error ? 'has-errors' : '',
m.variant ? `mod-metric--${m.variant}` : '',
].filter(Boolean).join(' ');
const titleAttr = m.title ? ` title="${escapeHtml(m.title)}"` : '';
const vCls = ['v', m.accent ? 'signal' : '', m.error ? 'has-errors' : ''].filter(Boolean).join(' ');
const vIdAttr = m.valueId ? ` id="${m.valueId}"` : '';
const labelHtml = `<span class="k">${m.icon || ''} <span>${escapeHtml(m.k)}</span></span>`;
return `<div class="${cellCls}"${titleAttr}>${labelHtml}<span class="${vCls}"${vIdAttr}>${m.v}</span></div>`;
}).join('');
return `<div class="${cellsClass}">${cells}</div>`;
}
export function renderModChips(chips: ModChipOpts[]): string {
if (!chips.length) return '';
const items = chips.map(c => {
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 `<span class="chip${variant}${link}"${titleAttr}${onclickAttr}>${c.icon || ''} ${escapeHtml(c.text)}</span>`;
}).join('');
return `<div class="mod-chips">${items}</div>`;
}
export function renderModFader(f: ModFaderOpts): string {
const max = f.max || 100;
const pct = Math.round(Math.max(0, Math.min(1, f.value / max)) * 100);
const sliderId = f.sliderId ? ` id="${f.sliderId}"` : '';
const dataAttrs = f.dataAttrs
? Object.entries(f.dataAttrs).map(([k, v]) => `${k}="${escapeHtml(v)}"`).join(' ')
: '';
const oninputAttr = f.oninput ? ` oninput="${f.oninput}"` : '';
const onchangeAttr = f.onchange ? ` onchange="${f.onchange}"` : '';
const disabledAttr = f.disabled ? ' disabled' : '';
return `<div class="mod-fader">
<span class="mod-fader__k">${escapeHtml(f.label)}</span>
<div class="mod-fader__track"><div class="mod-fader__fill" style="width:${pct}%"></div></div>
<input type="range" class="mod-fader__slider" min="0" max="${max}" value="${f.value}"${sliderId} ${dataAttrs}${oninputAttr}${onchangeAttr}${disabledAttr}>
<span class="mod-fader__v">${f.value}</span>
</div>`;
}
function _btnHtml(b: ModBtnOpts): string {
const variant = b.variant === 'go' ? ' mod-btn-go'
: b.variant === 'stop' ? ' mod-btn-stop'
: '';
const icon = b.iconOnly ? ' mod-btn-icon' : '';
const titleAttr = b.title ? ` title="${escapeHtml(b.title)}"` : '';
const i18nAttr = b.i18nTitle ? ` data-i18n-title="${b.i18nTitle}"` : '';
const labelHtml = b.label ? ` <span>${escapeHtml(b.label)}</span>` : '';
return `<button type="button" class="mod-btn${variant}${icon}" onclick="${b.onclick}"${titleAttr}${i18nAttr}>${b.icon || ''}${labelHtml}</button>`;
}
export function renderModFoot(foot: ModFootOpts): string {
const liveCls = foot.patchState === 'live' ? ' is-live' : '';
const dimCls = foot.patchState === 'offline' ? ' is-offline' : '';
const patchHtml = foot.patchLabel
? `<div class="mod-patch"><span class="patch-dot${liveCls}${dimCls}"></span><span>${escapeHtml(foot.patchLabel)}</span></div>`
: '';
const buttons: string[] = [];
if (foot.primaryAction) buttons.push(_btnHtml(foot.primaryAction));
if (foot.secondaryActions) {
for (const b of foot.secondaryActions) buttons.push(_btnHtml(b));
}
if (foot.iconActions) {
for (const b of foot.iconActions) buttons.push(_btnHtml({ ...b, iconOnly: true }));
}
return `<div class="mod-foot">${patchHtml}${buttons.join('')}</div>`;
}
export function renderModBody(body: ModBodyOpts | undefined): string {
if (!body) return '';
const parts: string[] = [];
if (body.desc) parts.push(`<div class="mod-desc">${escapeHtml(body.desc)}</div>`);
if (body.metrics) parts.push(renderModMetrics(body.metrics));
if (body.preview) parts.push(`<div class="mod-preview">${body.preview}</div>`);
if (body.chips) parts.push(renderModChips(body.chips));
if (body.fader) parts.push(renderModFader(body.fader));
return parts.join('');
}
/**
* Compose a full mod-card body (head + optional body + foot).
* Used by `wrapCard({ mod })` callers normally don't invoke this
* directly.
*/
export function renderModCardInner(opts: ModCardOpts): string {
return [
renderModHead(opts.head),
renderModBody(opts.body),
opts.foot ? renderModFoot(opts.foot) : '',
].filter(Boolean).join('');
}
@@ -0,0 +1,172 @@
/**
* Mod-card overflow menu single document-level handler that opens
* the kebab dropdown on entity cards that adopt the `.mod-*` markup.
*
* The menu portal-detaches to <body> when opened so that:
* - the card's `overflow: hidden` doesn't clip the dropdown,
* - and a hovered card's `transform: translateY(-2px)` doesn't turn
* `position: fixed` into "positioned relative to the card" (a
* well-known browser behaviour where `position: fixed` is rooted
* in the nearest transformed ancestor instead of the viewport).
*
* On close, the menu is re-inserted at its original DOM position so
* subsequent reconciliations keep markup stable.
*
* Markup (emitted by `renderModMenu` in `mod-card.ts`):
*
* <div class="mod-menu-wrap">
* <button class="mod-menu-btn" type="button" aria-haspopup="true" aria-expanded="false"></button>
* <div class="mod-menu" role="menu">
* <button class="mod-menu__item" data-action="duplicate"></button>
* <button class="mod-menu__item" data-action="hide"></button>
* <div class="mod-menu__sep"></div>
* <button class="mod-menu__item mod-menu__item--danger" data-action="delete"></button>
* </div>
* </div>
*
* Action handlers are attached via inline onclick on the menu items
* themselves (matching the rest of the codebase's pattern).
*
* Initialised once from app.ts via `initModMenu()`.
*/
let _initialised = false;
let _controller: AbortController | null = null;
const MENU_VERTICAL_GAP = 4;
const MENU_VIEWPORT_MARGIN = 12;
interface OpenMenuInfo {
wrap: HTMLElement;
origParent: Element;
origNext: Node | null;
}
const _openMenus = new Map<HTMLElement, OpenMenuInfo>();
function _restoreMenu(menu: HTMLElement, info: OpenMenuInfo): void {
menu.classList.remove('is-open');
menu.style.top = '';
menu.style.right = '';
menu.style.left = '';
menu.style.transformOrigin = '';
info.wrap.classList.remove('is-open');
const btn = info.wrap.querySelector('.mod-menu-btn');
if (btn) btn.setAttribute('aria-expanded', 'false');
if (info.origParent.isConnected) {
info.origParent.insertBefore(menu, info.origNext);
} else {
// Card was reconciled away while the menu was open — drop the
// detached menu rather than reattach to a dead parent.
menu.remove();
}
}
function _closeAll(): void {
_openMenus.forEach((info, menu) => _restoreMenu(menu, info));
_openMenus.clear();
}
function _openMenu(wrap: HTMLElement): void {
const btn = wrap.querySelector('.mod-menu-btn') as HTMLElement | null;
const menu = wrap.querySelector('.mod-menu') as HTMLElement | null;
if (!btn || !menu) return;
// Portal the menu to <body> so transformed/overflow-clipping
// ancestors don't capture or hide it. Track origin so we can
// restore on close.
const origParent = menu.parentElement!;
const origNext = menu.nextSibling;
document.body.appendChild(menu);
_openMenus.set(menu, { wrap, origParent, origNext });
wrap.classList.add('is-open');
menu.classList.add('is-open');
btn.setAttribute('aria-expanded', 'true');
// Position after a paint so offsetHeight is measurable.
requestAnimationFrame(() => _positionMenu(menu, btn));
}
/** Anchor the fixed-position menu to the kebab button. Opens downward
* by default; flips upward when there isn't enough room below the
* trigger (e.g. cards near the viewport bottom). */
function _positionMenu(menu: HTMLElement, btn: HTMLElement): void {
const rect = btn.getBoundingClientRect();
const menuH = menu.offsetHeight || 140;
const spaceBelow = window.innerHeight - rect.bottom;
if (spaceBelow < menuH + MENU_VIEWPORT_MARGIN) {
// Open upward
menu.style.top = `${rect.top - menuH - MENU_VERTICAL_GAP}px`;
menu.style.transformOrigin = 'bottom right';
} else {
menu.style.top = `${rect.bottom + MENU_VERTICAL_GAP}px`;
menu.style.transformOrigin = 'top right';
}
menu.style.right = `${window.innerWidth - rect.right}px`;
menu.style.left = 'auto';
}
function _onClick(e: MouseEvent): void {
const target = e.target as HTMLElement;
const btn = target.closest('.mod-menu-btn') as HTMLElement | null;
if (btn) {
e.stopPropagation();
const wrap = btn.closest('.mod-menu-wrap') as HTMLElement | null;
if (!wrap) return;
const wasOpen = wrap.classList.contains('is-open');
_closeAll();
if (!wasOpen) _openMenu(wrap);
return;
}
// A click on a menu item: let the inline onclick fire, then close.
const item = target.closest('.mod-menu__item') as HTMLElement | null;
if (item) {
// Use a short timeout so the inline onclick (e.g. a confirm prompt)
// executes before the menu disappears under the user's pointer.
setTimeout(_closeAll, 0);
return;
}
// Click outside any menu — close all.
if (!target.closest('.mod-menu')) _closeAll();
}
function _onKeydown(e: KeyboardEvent): void {
if (e.key === 'Escape') _closeAll();
}
/**
* Initialise the mod-menu system once at app boot.
* Idempotent calling more than once is a no-op.
*/
export function initModMenu(): void {
if (_initialised) return;
_initialised = true;
_controller = new AbortController();
const { signal } = _controller;
document.addEventListener('click', _onClick, { signal });
document.addEventListener('keydown', _onKeydown, { signal });
// Close on scroll/resize so the fixed-position menu doesn't drift
// away from its anchor.
window.addEventListener('scroll', _closeAll, { passive: true, capture: true, signal });
window.addEventListener('resize', _closeAll, { passive: true, signal });
}
/** Tear down listeners (used by tests / hot reload). */
export function disposeModMenu(): void {
if (_controller) {
_controller.abort();
_controller = null;
}
_initialised = false;
_closeAll();
}
/** Manually close any open menu exposed for callers that need to
* dismiss a menu after a programmatic action (e.g. a confirm dialog
* closing). */
export function closeAllModMenus(): void {
_closeAll();
}
+57 -3
View File
@@ -29,11 +29,16 @@ export class Modal {
}
get isOpen() {
return this.el?.style.display === 'flex';
// While the exit animation runs, display is still 'flex' but the modal
// is non-interactive — treat it as closed.
return this.el?.style.display === 'flex' && !this.el?.classList.contains('closing');
}
open() {
this._previousFocus = document.activeElement;
// Cancel any in-flight close animation so the modal becomes interactive again.
this.el!.classList.remove('closing');
this._cancelExitAnim();
this.el!.style.display = 'flex';
if (this._lock) lockBody();
if (this._backdrop) setupBackdropClose(this.el!, () => this.close());
@@ -51,9 +56,24 @@ export class Modal {
}
}
/** Pending exit-animation cleanup, so a re-open during the animation can cancel it. */
_exitCleanup: (() => void) | null = null;
_cancelExitAnim() {
if (this._exitCleanup) {
this._exitCleanup();
this._exitCleanup = null;
}
}
forceClose() {
releaseFocus(this.el!);
this.el!.style.display = 'none';
if (!this.el) return;
// Already closing → don't restart the animation.
if (this.el.classList.contains('closing')) return;
// Modal-state cleanup happens immediately so subsequent code that
// queries Modal._stack / focus / body-lock sees a "closed" state.
releaseFocus(this.el);
if (this._lock) unlockBody();
this._initialValues = {};
this.hideError();
@@ -63,6 +83,40 @@ export class Modal {
(this._previousFocus as HTMLElement).focus({ preventScroll: true });
this._previousFocus = null;
}
// Run the exit animation, then hide. The animation is owned by the
// .modal-content child (the visually larger movement); we listen for
// its `animationend` and fall back to a timeout in case the user has
// prefers-reduced-motion or the element is detached mid-flight.
const el = this.el;
const content = el.querySelector('.modal-content') as HTMLElement | null;
const EXIT_MS = 220;
const finalize = () => {
this._exitCleanup = null;
// Guard against re-open: if open() was called during animation,
// the .closing class is gone and display is already 'flex'.
if (!el.classList.contains('closing')) return;
el.classList.remove('closing');
el.style.display = 'none';
};
let timer: number | null = null;
const onEnd = () => {
if (timer !== null) { clearTimeout(timer); timer = null; }
content?.removeEventListener('animationend', onEnd);
finalize();
};
this._exitCleanup = () => {
if (timer !== null) { clearTimeout(timer); timer = null; }
content?.removeEventListener('animationend', onEnd);
};
el.classList.add('closing');
if (content) {
content.addEventListener('animationend', onEnd, { once: false });
}
timer = window.setTimeout(onEnd, EXIT_MS + 80);
}
async close() {
@@ -75,6 +75,8 @@ const SECTION_LABEL_KEYS: Record<string, string> = {
const PERF_CELL_LABEL_KEYS: Record<string, string> = {
patches: 'dashboard.perf.active_patches',
fps: 'dashboard.perf.total_fps',
capture_fps: 'dashboard.perf.total_capture_fps',
errors: 'dashboard.perf.errors',
devices: 'dashboard.perf.devices',
cpu: 'dashboard.perf.cpu',
ram: 'dashboard.perf.ram',
@@ -129,6 +131,13 @@ function _mountPanel(): void {
`;
document.body.appendChild(panel);
// Force a layout flush so the initial off-screen transform commits before
// the caller adds `.is-open`. Without this, on the first open after a page
// reload the browser collapses both styles into one paint and the slide-in
// transition is skipped.
void panel.offsetWidth;
void backdrop.offsetWidth;
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && panel.classList.contains('is-open')) {
closeDashboardCustomize();
@@ -35,6 +35,8 @@ export type SectionKey =
export type PerfCellKey =
| 'patches'
| 'fps'
| 'capture_fps'
| 'errors'
| 'devices'
| 'cpu'
| 'ram'
@@ -142,6 +144,8 @@ export const DEFAULT_LAYOUT: DashboardLayoutV1 = {
perfCells: [
_defaultPerfCell('patches'),
_defaultPerfCell('fps'),
_defaultPerfCell('capture_fps'),
_defaultPerfCell('errors'),
_defaultPerfCell('devices'),
_defaultPerfCell('cpu'),
_defaultPerfCell('ram'),
@@ -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, updateDevices, rerenderPerfGrid } from './perf-charts.ts';
import { renderPerfSection, renderPerfModeToggle, setPerfMode, initPerfCharts, startPerfPolling, stopPerfPolling, updateActivePatches, updateTotalFps, updateTotalCaptureFps, updateTotalErrors, updateDevices, rerenderPerfGrid } from './perf-charts.ts';
import { startAutoRefresh, updateTabBadge } from './tabs.ts';
import { isActiveTab } from '../core/tab-registry.ts';
import {
@@ -652,8 +652,10 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
// Total FPS cell in the perf strip. `fpsTargetSum` is drawn as
// a dashed reference line ("max achievable throughput").
const fpsValues: number[] = [];
const captureFpsValues: number[] = [];
let fpsSum = 0;
let fpsTargetSum = 0;
let captureFpsSum = 0;
for (const r of running) {
const fps = r.state?.fps_actual != null ? r.state.fps_actual
: r.state?.fps_current != null ? r.state.fps_current
@@ -666,10 +668,33 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
?? (r.settings || {}).fps
?? r.update_rate;
if (typeof tgt === 'number' && tgt > 0) fpsTargetSum += tgt;
const captureFps = r.state?.fps_capture;
if (typeof captureFps === 'number' && captureFps > 0) {
captureFpsValues.push(captureFps);
captureFpsSum += captureFps;
}
}
const fpsMin = fpsValues.length > 0 ? Math.min(...fpsValues) : null;
const fpsMax = fpsValues.length > 0 ? Math.max(...fpsValues) : null;
updateTotalFps(fpsSum, fpsMin, fpsMax, fpsTargetSum);
const captureFpsMin = captureFpsValues.length > 0 ? Math.min(...captureFpsValues) : null;
const captureFpsMax = captureFpsValues.length > 0 ? Math.max(...captureFpsValues) : null;
updateTotalCaptureFps(captureFpsSum, captureFpsMin, captureFpsMax);
// Errors / dropped frames — fed cumulative totals; the perf
// cell turns them into per-second rates by tracking deltas
// across polls. Computed across running targets only so a
// long-stopped target's historical errors don't keep the
// counter elevated forever.
let totalErrors = 0;
let totalSkipped = 0;
for (const r of running) {
const e = r.metrics?.errors_count;
if (typeof e === 'number' && e > 0) totalErrors += e;
const s = r.state?.frames_skipped;
if (typeof s === 'number' && s > 0) totalSkipped += s;
}
updateTotalErrors(totalErrors, totalSkipped, dashboardPollInterval);
// Check if we can do an in-place metrics update (same targets, not first load)
const newRunningIds = running.map(t => t.id).sort().join(',');
+173 -52
View File
@@ -12,8 +12,9 @@ import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode,
import { t } from '../core/i18n.ts';
import { showToast, showConfirm, desktopFocus } from '../core/ui.ts';
import { Modal } from '../core/modal.ts';
import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_PLUG, ICON_REFRESH, ICON_TEMPLATE, ICON_CLONE } from '../core/icons.ts';
import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_REFRESH, ICON_TEMPLATE } from '../core/icons.ts';
import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, LedState, ModMetricOpts, ModChipOpts, ModBtnOpts } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { getBaseOrigin } from './settings.ts';
@@ -127,76 +128,180 @@ export function createDeviceCard(device: Device & { state?: any }) {
const devVersion = state.device_version;
const devLastChecked = state.device_last_checked;
let healthClass, healthTitle, healthLabel;
// Health dot — kept inline beside the name so the existing
// event-driven status updates (.health-dot[data-device-id=…])
// continue to work without needing new selectors.
let healthClass: string, healthTitle: string;
if (devLastChecked === null || devLastChecked === undefined) {
healthClass = 'health-unknown';
healthTitle = t('device.health.checking');
healthLabel = '';
} else if (devOnline) {
healthClass = 'health-online';
healthTitle = `${t('device.health.online')}`;
healthTitle = t('device.health.online');
if (devName) healthTitle += ` - ${devName}`;
if (devVersion) healthTitle += ` v${devVersion}`;
if (devLatency !== null && devLatency !== undefined) healthTitle += ` (${Math.round(devLatency)}ms)`;
healthLabel = '';
} else {
healthClass = 'health-offline';
healthTitle = t('device.health.offline');
if (state.device_error) healthTitle += `: ${state.device_error}`;
healthLabel = '';
}
const healthDot = `<span class="health-dot ${healthClass}" title="${escapeHtml(healthTitle)}" role="status" aria-label="${escapeHtml(healthTitle)}"></span>`;
// ── LED bezel — 1-3 dots reflecting connection state ──────────
let leds: LedState[];
if (devLastChecked === null || devLastChecked === undefined) {
leds = ['off'];
} else if (devOnline) {
leds = ['on', 'blink', 'blink'];
} else {
leds = ['fault'];
}
const ledCount = state.device_led_count || device.led_count;
// ── Type label for the badge ──
const badgeText = `${(device.device_type || 'wled').toUpperCase()} · OUT`;
// Parse zone names from OpenRGB URL for badge display
// ── Metadata sub-line — URL becomes a clickable anchor when http(s),
// plain text otherwise. Built as HTML so the link is real, not a
// chip. Each part is HTML-escaped individually. ──
const metaPartsHtml: string[] = [];
if (device.url && device.url.startsWith('http')) {
const safeUrl = escapeHtml(device.url);
const display = escapeHtml(device.url.replace(/^https?:\/\//, ''));
metaPartsHtml.push(
`<a class="mod-meta__link" href="${safeUrl}" target="_blank" rel="noopener" title="${t('device.button.webui') || 'Open device web UI'}" onclick="event.stopPropagation();">${display} ${ICON_WEB}</a>`
);
} else if (device.url && !device.url.startsWith('mock://') && !device.url.startsWith('ws://') && !device.url.startsWith('openrgb://')) {
metaPartsHtml.push(escapeHtml(device.url));
}
if (devVersion) metaPartsHtml.push(escapeHtml(`v${devVersion}`));
// ── Metric strip — three blocks: LED count, latency, and chip type
// (which carries both the LED chip identifier and RGB/RGBW colour
// channel layout, stacked in two rows). ──
const ledCount = state.device_led_count || device.led_count;
const metrics: ModMetricOpts[] = [];
if (ledCount) {
metrics.push({ k: t('device.led_count') || 'PIXELS', v: String(ledCount), title: t('device.led_count') });
}
if (devOnline && devLatency !== null && devLatency !== undefined) {
metrics.push({ k: 'LAT', v: `${Math.round(devLatency)}<small>ms</small>`, accent: true });
}
const ledTypeName = state.device_led_type
? state.device_led_type.replace(/ RGBW$/, '')
: null;
const colorChannels = state.device_rgbw === true ? 'RGBW'
: state.device_rgbw === false ? 'RGB'
: null;
if (ledTypeName || colorChannels) {
const primary = ledTypeName ?? colorChannels!;
const secondary = ledTypeName && colorChannels ? colorChannels : null;
const subHtml = secondary
? `<span class="v-sub">${escapeHtml(secondary)}</span>`
: '';
metrics.push({
k: t('dashboard.device.chip') || 'CHIP',
v: `${escapeHtml(primary)}${subHtml}`,
variant: 'text-stack',
title: [state.device_led_type, colorChannels].filter(Boolean).join(' · ') || undefined,
});
}
// ── Chips: OpenRGB zones (LED type/RGBW now live in the metric strip) ──
const openrgbZones = isOpenrgbDevice(device.device_type)
? _splitOpenrgbZone(device.url).zones : [];
const chips: ModChipOpts[] = [];
if (openrgbZones.length) {
for (const z of openrgbZones) {
chips.push({ icon: ICON_LED, text: String(z), title: t('device.openrgb.zone') });
}
}
return wrapCard({
// ── Patch indicator state ──
const isOnline = devOnline && devLastChecked != null;
const patchState: 'live' | 'standby' | 'offline' | 'idle' =
devLastChecked == null ? 'idle' :
isOnline ? 'live' :
'offline';
const patchLabel = devLastChecked == null ? (t('device.health.checking') || 'CHECKING').toUpperCase()
: isOnline ? (t('device.health.online') || 'ONLINE').toUpperCase()
: (t('device.health.offline') || 'OFFLINE').toUpperCase();
// ── Brightness fader ──
const hasBrightness = (device.capabilities || []).includes('brightness_control');
const brightnessVal = _deviceBrightnessCache[device.id] ?? 0;
const brightnessLoaded = _deviceBrightnessCache[device.id] != null;
// ── Foot actions ──
const hasPower = (device.capabilities || []).includes('power_control');
const iconActions: ModBtnOpts[] = [];
if (hasPower && isOnline) {
// Power-off as a stop-style icon button (was in topButtons before)
iconActions.push({
icon: ICON_STOP_PLAIN,
onclick: `turnOffDevice('${device.id}')`,
title: t('device.button.power_off'),
variant: 'stop',
});
}
iconActions.push({
icon: ICON_REFRESH,
onclick: `event.stopPropagation(); pingDevice('${device.id}')`,
title: t('device.button.ping'),
});
iconActions.push({
icon: ICON_SETTINGS,
onclick: `showSettings('${device.id}')`,
title: t('device.button.settings'),
});
const mod: ModCardOpts = {
head: {
badge: { text: badgeText },
name: device.name || device.id,
metaHtml: metaPartsHtml.length ? metaPartsHtml.join(' · ') : undefined,
healthDot,
leds,
menu: {
duplicateOnclick: `cloneDevice('${device.id}')`,
hideOnclick: `toggleCardHidden('led-devices','${device.id}')`,
deleteOnclick: `removeDevice('${device.id}')`,
},
},
body: {
metrics: metrics.length ? metrics : undefined,
chips: chips.length ? chips : undefined,
fader: hasBrightness ? {
label: t('device.brightness') || 'Bright',
value: brightnessVal,
max: 255,
sliderId: undefined,
oninput: `updateBrightnessLabel('${device.id}', this.value)`,
onchange: `saveCardBrightness('${device.id}', this.value)`,
dataAttrs: { 'data-device-brightness': device.id },
disabled: !brightnessLoaded,
} : undefined,
},
foot: {
patchState,
patchLabel,
iconActions,
},
running: isOnline && hasBrightness && brightnessVal > 0,
};
// Tag chips render via existing renderTagChips() — append after wrapCard
// returns, since the modular API doesn't yet have a "tags" slot.
const cardHtml = wrapCard({
dataAttr: 'data-device-id',
id: device.id,
topButtons: (device.capabilities || []).includes('power_control') ? `<button class="card-top-btn card-power-btn" onclick="turnOffDevice('${device.id}')" title="${t('device.button.power_off')}">${ICON_STOP_PLAIN}</button>` : '',
removeOnclick: `removeDevice('${device.id}')`,
removeTitle: t('device.button.remove'),
content: `
<div class="card-header">
<div class="card-title" title="${escapeHtml(device.name || device.id)}">
<span class="health-dot ${healthClass}" title="${healthTitle}" role="status" aria-label="${healthTitle}"></span>
<span class="card-title-text">${device.name || device.id}</span>
${healthLabel}
</div>
</div>
<div class="card-subtitle">
<span class="card-meta device-type-badge">${(device.device_type || 'wled').toUpperCase()}</span>
${device.url && device.url.startsWith('http') ? `<a class="device-url-badge" href="${device.url}" target="_blank" rel="noopener" title="${t('device.button.webui')}"><span class="device-url-text">${escapeHtml(device.url.replace(/^https?:\/\//, ''))}</span><span class="device-url-icon">${ICON_WEB}</span></a>` : (device.url && !device.url.startsWith('mock://') && !device.url.startsWith('ws://') && !device.url.startsWith('openrgb://') && !device.url.startsWith('http') ? `<span class="device-url-badge"><span class="device-url-text">${escapeHtml(device.url)}</span></span>` : '')}
${openrgbZones.length
? openrgbZones.map((z: any) => `<span class="card-meta zone-badge" data-zone-name="${escapeHtml(z)}">${ICON_LED} ${escapeHtml(z)}</span>`).join('')
: (ledCount ? `<span class="card-meta" title="${t('device.led_count')}">${ICON_LED} ${ledCount}</span>` : '')}
${state.device_led_type ? `<span class="card-meta">${ICON_PLUG} ${state.device_led_type.replace(/ RGBW$/, '')}</span>` : ''}
<span class="card-meta" title="${state.device_rgbw ? 'RGBW' : 'RGB'}"><span class="channel-indicator"><span class="ch" style="background:#e53935"></span><span class="ch" style="background:#43a047"></span><span class="ch" style="background:#1e88e5"></span>${state.device_rgbw ? '<span class="ch" style="background:#eee"></span>' : ''}</span></span>
</div>
<div class="stream-card-props"><span class="stream-card-prop" style="opacity:0.65;" data-last-seen="${device.id}"></span></div>
${(device.capabilities || []).includes('brightness_control') ? `
<div class="brightness-control${_deviceBrightnessCache[device.id] == null ? ' brightness-loading' : ''}" data-brightness-wrap="${device.id}">
<input type="range" class="brightness-slider" min="0" max="255"
value="${_deviceBrightnessCache[device.id] ?? 0}" data-device-brightness="${device.id}"
oninput="updateBrightnessLabel('${device.id}', this.value)"
onchange="saveCardBrightness('${device.id}', this.value)"
title="${_deviceBrightnessCache[device.id] != null ? Math.round(_deviceBrightnessCache[device.id] / 255 * 100) + '%' : '...'}"
${_deviceBrightnessCache[device.id] == null ? 'disabled' : ''}>
</div>` : ''}
${renderTagChips(device.tags)}`,
actions: `
<button class="btn btn-icon btn-secondary card-ping-btn" onclick="event.stopPropagation(); pingDevice('${device.id}')" title="${t('device.button.ping')}">
${ICON_REFRESH}
</button>
<button class="btn btn-icon btn-secondary" onclick="cloneDevice('${device.id}')" title="${t('common.clone')}">
${ICON_CLONE}
</button>
<button class="btn btn-icon btn-secondary" onclick="showSettings('${device.id}')" title="${t('device.button.settings')}">
${ICON_SETTINGS}
</button>`,
classes: hasBrightness && !brightnessLoaded ? 'brightness-loading' : '',
mod,
});
const tags = renderTagChips(device.tags);
if (!tags) return cardHtml;
// Insert tags before the closing </div> of the wrapper card.
return cardHtml.replace(/<\/div>\s*$/, `${tags}</div>`);
}
export async function turnOffDevice(deviceId: any) {
@@ -614,7 +719,17 @@ export async function saveDeviceSettings() {
// Brightness
export function updateBrightnessLabel(deviceId: any, value: any) {
const slider = document.querySelector(`[data-device-brightness="${CSS.escape(deviceId)}"]`) as HTMLElement | null;
if (slider) slider.title = Math.round(parseInt(value) / 255 * 100) + '%';
if (!slider) return;
const v = parseInt(value);
slider.title = Math.round(v / 255 * 100) + '%';
// mod-card fader visuals — fill width + numeric readout
const fader = slider.closest('.mod-fader');
if (fader) {
const fill = fader.querySelector('.mod-fader__fill') as HTMLElement | null;
if (fill) fill.style.width = `${(v / 255) * 100}%`;
const valEl = fader.querySelector('.mod-fader__v') as HTMLElement | null;
if (valEl) valEl.textContent = String(v);
}
}
export async function saveCardBrightness(deviceId: any, value: any) {
@@ -649,11 +764,17 @@ export async function fetchDeviceBrightness(deviceId: any) {
const slider = document.querySelector(`[data-device-brightness="${CSS.escape(deviceId)}"]`) as HTMLInputElement | null;
if (slider) {
slider.value = data.brightness;
slider.title = Math.round(data.brightness / 255 * 100) + '%';
slider.disabled = false;
// Sync the mod-fader's fill + numeric readout via the
// shared visual-update helper.
updateBrightnessLabel(deviceId, data.brightness);
}
// Legacy wrapper (pre-migration cards)
const wrap = document.querySelector(`[data-brightness-wrap="${CSS.escape(deviceId)}"]`) as HTMLElement | null;
if (wrap) wrap.classList.remove('brightness-loading');
// mod-card variant — loading flag lives on the card itself
const card = slider?.closest('.card.mod-card') as HTMLElement | null;
if (card) card.classList.remove('brightness-loading');
} catch (err) {
// Silently fail — device may be offline
} finally {
@@ -16,50 +16,67 @@ 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'] as const;
const CHART_KEYS = ['cpu', 'ram', 'gpu', 'temp', 'fps', 'capture_fps', 'errors'] 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', 'devices', 'cpu', 'ram', 'gpu', 'temp'] as const;
const ALL_COLORABLE_KEYS = ['patches', 'fps', 'capture_fps', 'errors', 'devices', 'cpu', 'ram', 'gpu', 'temp'] 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']);
const HOST_ONLY_KEYS = new Set(['temp', 'fps', 'capture_fps', 'errors']);
/** 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
per-user in localStorage still honoured by `_getColor`. */
const METRIC_CSS_VARS: Record<string, string> = {
patches: '--ch-magenta',
fps: '--ch-cyan',
devices: '--ch-signal',
cpu: '--ch-coral',
ram: '--ch-violet',
gpu: '--ch-signal',
temp: '--ch-amber',
patches: '--ch-magenta',
fps: '--ch-cyan',
capture_fps: '--ch-signal',
errors: '--ch-coral',
devices: '--ch-signal',
cpu: '--ch-coral',
ram: '--ch-violet',
gpu: '--ch-signal',
temp: '--ch-amber',
};
/** Fallback hex used only if CSS-var resolution fails (e.g. detached node). */
const METRIC_FALLBACK: Record<string, string> = {
patches: '#EC4899',
fps: '#00D8FF',
devices: '#10B981',
cpu: '#FF6B6B',
ram: '#A855F7',
gpu: '#10B981',
temp: '#FCD34D',
patches: '#EC4899',
fps: '#00D8FF',
capture_fps: '#22D3EE',
errors: '#FF6B6B',
devices: '#10B981',
cpu: '#FF6B6B',
ram: '#A855F7',
gpu: '#10B981',
temp: '#FCD34D',
};
type PerfMode = 'system' | 'app' | 'both';
let _pollTimer: ReturnType<typeof setInterval> | null = null;
let _history: Record<string, number[]> = { cpu: [], ram: [], gpu: [], temp: [], fps: [] };
let _appHistory: Record<string, number[]> = { cpu: [], ram: [], gpu: [], temp: [], fps: [] };
let _history: Record<string, number[]> = { cpu: [], ram: [], gpu: [], temp: [], fps: [], capture_fps: [], errors: [] };
let _appHistory: Record<string, number[]> = { cpu: [], ram: [], gpu: [], temp: [], fps: [], capture_fps: [], errors: [] };
/** 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. */
let _errorsPeak = 1;
/** Cumulative error count across all running targets at the end of the
* previous poll. Used to compute per-poll deltas rate. `null` until
* the first sample arrives so we don't synthesize a fake initial spike
* from "0 → live count". */
let _prevErrorsTotal: number | null = null;
/** Same as `_prevErrorsTotal` for `frames_skipped`. */
let _prevSkippedTotal: number | null = null;
/** Peak FPS observed during the session used as the y-axis ceiling for
* the FPS sparkline so slow targets look proportional to fast ones. */
let _fpsPeak = 60;
/** Same role as `_fpsPeak`, but for the capture-side sparkline. */
let _captureFpsPeak = 60;
/** Sum of fps_target across running targets rendered as a dashed
* reference line on the FPS spark ("max achievable throughput"). */
let _fpsTargetSum = 0;
@@ -74,6 +91,8 @@ let _lastFetchData: any = null;
* loader to fire its next pass. */
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 _lastErrorsArgs: { totalErrors: number; totalSkipped: number; pollMs: 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
@@ -209,6 +228,34 @@ export function renderPerfSection(): string {
</div>
</div>`;
const captureFpsCell = `
<div class="perf-chart-card" data-metric="capture_fps" data-perf-mode="${_mode}" style="--perf-accent:${_getColor('capture_fps')}">
<div class="perf-chart-header">
<span class="perf-chart-label">${t('dashboard.perf.total_capture_fps') || 'Total Capture FPS'} ${colorWidget('capture_fps')}</span>
</div>
<div class="perf-chart-body">
<div class="perf-chart-value-block">
<span class="perf-chart-value" id="perf-capture_fps-value"></span>
<span class="perf-chart-subtitle" id="perf-capture_fps-sub"></span>
</div>
<div class="perf-chart-spark" id="perf-chart-capture_fps"></div>
</div>
</div>`;
const errorsCell = `
<div class="perf-chart-card perf-errors-cell" data-metric="errors" data-perf-mode="${_mode}" style="--perf-accent:${_getColor('errors')}">
<div class="perf-chart-header">
<span class="perf-chart-label">${t('dashboard.perf.errors') || 'Errors'} ${colorWidget('errors')}</span>
</div>
<div class="perf-chart-body">
<div class="perf-chart-value-block">
<span class="perf-chart-value" id="perf-errors-value">0</span>
<span class="perf-chart-subtitle" id="perf-errors-sub"></span>
</div>
<div class="perf-chart-spark" id="perf-chart-errors"></div>
</div>
</div>`;
const devicesCell = `
<div class="perf-chart-card perf-devices-cell" data-metric="devices" style="--perf-accent:${_getColor('devices')}">
<div class="perf-chart-header">
@@ -232,6 +279,8 @@ export function renderPerfSection(): string {
const cellRenderers: Record<string, () => 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),
@@ -265,7 +314,17 @@ export function updateActivePatches(
const listEl = document.getElementById('perf-patches-list');
if (!listEl) return;
if (running.length === 0) {
listEl.innerHTML = '';
// Empty-state hint — "Ready to launch" when targets exist but
// none are running, or "No patches yet" when the user hasn't
// configured any. Keeps the cell from looking abandoned.
const hintKey = totalCount === 0
? 'dashboard.perf.patches.empty.none'
: 'dashboard.perf.patches.empty.idle';
const hintText = t(hintKey) || (totalCount === 0 ? 'No patches yet' : 'Ready to launch');
listEl.innerHTML = `<div class="perf-patches-empty">
<span class="perf-patches-empty-dot" aria-hidden="true"></span>
<span class="perf-patches-empty-text">${escapeText(hintText)}</span>
</div>`;
return;
}
const rows = running.slice(0, 4).map((r, i) => {
@@ -320,7 +379,96 @@ export function updateTotalFps(
subEl.textContent = '';
}
}
_renderChartSvg('fps');
_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. */
export function updateTotalCaptureFps(
totalFps: number,
minFps: number | null,
maxFps: number | null,
): void {
_lastTotalCaptureFpsArgs = { totalFps, minFps, maxFps };
const fps = Math.max(0, totalFps);
_history.capture_fps.push(fps);
if (_history.capture_fps.length > MAX_SAMPLES) _history.capture_fps.shift();
if (fps > _captureFpsPeak) _captureFpsPeak = fps;
const valEl = document.getElementById('perf-capture_fps-value');
if (valEl) {
const fpsText = fps.toFixed(fps < 10 ? 1 : 0);
valEl.innerHTML = `${fpsText}<span class="perf-fps-unit">fps</span>`;
}
const subEl = document.getElementById('perf-capture_fps-sub');
if (subEl) {
if (minFps != null && maxFps != null && minFps !== maxFps) {
subEl.textContent = `min ${minFps.toFixed(1)} · max ${maxFps.toFixed(1)}`;
} else {
subEl.textContent = '';
}
}
_renderChartSvg('capture_fps', /*animate=*/true);
}
/** 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
* things are healthy and tints coral the moment the rate goes
* non-zero, so it functions as a passive "is anything wrong?" indicator
* rather than demanding constant attention. */
export function updateTotalErrors(
totalErrors: number,
totalSkipped: number,
pollMs: number,
): void {
_lastErrorsArgs = { totalErrors, totalSkipped, pollMs };
const safePollMs = pollMs > 0 ? pollMs : 1000;
// Per-second deltas — guard against the first sample (no previous
// baseline) and against counter resets when targets restart (delta
// < 0 means "previous total no longer applies"; treat as 0).
let errorsRate = 0;
let skippedRate = 0;
if (_prevErrorsTotal != null) {
const delta = Math.max(0, totalErrors - _prevErrorsTotal);
errorsRate = delta / (safePollMs / 1000);
}
if (_prevSkippedTotal != null) {
const delta = Math.max(0, totalSkipped - _prevSkippedTotal);
skippedRate = delta / (safePollMs / 1000);
}
_prevErrorsTotal = totalErrors;
_prevSkippedTotal = totalSkipped;
_history.errors.push(errorsRate);
if (_history.errors.length > MAX_SAMPLES) _history.errors.shift();
if (errorsRate > _errorsPeak) _errorsPeak = errorsRate;
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}<span class="perf-fps-unit">/s</span>`;
}
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(' · ');
}
_renderChartSvg('errors', /*animate=*/true);
}
/** Devices cell — online / total count with a dot strip showing each
@@ -367,8 +515,63 @@ export function updateDevices(
dotsEl.innerHTML = dots + more;
}
/** Render the SVG sparkline into its container. */
function _renderChartSvg(key: string): void {
/** Resolve the global animations preference once per render read from
* the `data-layout-anim` attribute the dashboard puts on its root. */
function _animLevel(): 'full' | 'reduced' | 'off' {
const c = document.getElementById('dashboard-content');
const v = c?.dataset.layoutAnim;
return v === 'off' || v === 'reduced' ? v : 'full';
}
/** Smoothly scroll the freshly-rendered spark left by one sample-step.
*
* The chart already drew the new sample at the right edge, so without
* animation it appears to jump on every poll. To get a continuous
* left-scroll feel we:
* 1. Snap the SVG +1 step to the right (no transition) the
* previous frame's right-edge sample is now visually at the same
* pixel position it occupied last tick, so the user sees a
* 1-frame "frozen" matching state.
* 2. requestAnimationFrame ease back to translateX(0) over the
* poll interval. Old samples slide off the left edge (clipped by
* `.perf-chart-spark { overflow:hidden }`); the new sample slides
* into view from the right.
*
* Cost: a single GPU-composited `transform` per cell. The path strings
* themselves don't repaint mid-animation. */
function _scrollSpark(host: HTMLElement, sliceN: number): void {
if (sliceN < 2) return;
const level = _animLevel();
if (level === 'off') return;
const svg = host.querySelector('svg.perf-chart-svg') as SVGSVGElement | null;
if (!svg) return;
const stepPct = 100 / (sliceN - 1);
// "reduced" cuts the duration to ~1/4 so motion is brief but the
// chart still doesn't jump-cut. "full" matches the poll cadence so
// each scroll lands exactly when the next sample arrives.
const baseMs = dashboardPollInterval || 1000;
const animMs = level === 'reduced' ? Math.min(220, baseMs / 4) : baseMs;
svg.style.transition = 'none';
svg.style.transform = `translateX(${stepPct.toFixed(3)}%)`;
// Force the snap to commit before we install the easing transition,
// otherwise the browser collapses both styles into one paint and
// skips the scroll entirely.
void svg.getBoundingClientRect();
requestAnimationFrame(() => {
svg.style.transition = `transform ${animMs}ms linear`;
svg.style.transform = 'translateX(0)';
});
}
/** Render the SVG sparkline into its container.
*
* `animate` triggers the smooth left-scroll animation only set on
* paths that just pushed a fresh sample. Non-sample paths (mode
* toggle, layout replay, color change, initial seed) pass false so
* the chart redraws instantly without a phantom scroll. */
function _renderChartSvg(key: string, animate: boolean = false): void {
const host = document.getElementById(`perf-chart-${key}`);
if (!host) return;
// Effective window (in seconds) for this cell — global default
@@ -390,10 +593,15 @@ function _renderChartSvg(key: string): void {
// Scale y per metric — temp varies 20..90°C; fps uses whichever is
// larger of the session peak or the target-sum ceiling with some
// headroom; others are 0..100 %.
// headroom; capture_fps tracks the same shape but without a
// target-sum reference; errors uses a small-number scale so a single
// spike from 0 doesn't pin the line at the top forever; others are
// 0..100 %.
const yMin = key === 'temp' ? 20 : 0;
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 === 'errors' ? Math.max(5, _errorsPeak * 1.2)
: 100;
const paths: string[] = [];
@@ -424,6 +632,8 @@ function _renderChartSvg(key: string): void {
</defs>
${paths.join('')}
</svg>`;
if (animate) _scrollSpark(host, sliceN);
}
/** Build <path> elements (area + stroke) for one series. */
@@ -471,7 +681,7 @@ function _pushSample(key: string, sysValue: number, appValue: number | null): vo
if (_appHistory[key].length > MAX_SAMPLES) _appHistory[key].shift();
}
_renderChartSvg(key);
_renderChartSvg(key, /*animate=*/true);
}
/** Render the main + app values for a single perf card.
@@ -767,6 +977,26 @@ export function rerenderPerfGrid(): void {
_lastTotalFpsArgs.targetSum,
);
}
if (_lastTotalCaptureFpsArgs) {
updateTotalCaptureFps(
_lastTotalCaptureFpsArgs.totalFps,
_lastTotalCaptureFpsArgs.minFps,
_lastTotalCaptureFpsArgs.maxFps,
);
}
if (_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
// computes a zero rate; the next real poll picks up from here.
_prevErrorsTotal = _lastErrorsArgs.totalErrors;
_prevSkippedTotal = _lastErrorsArgs.totalSkipped;
updateTotalErrors(
_lastErrorsArgs.totalErrors,
_lastErrorsArgs.totalSkipped,
_lastErrorsArgs.pollMs,
);
}
if (_lastDevicesArgs) {
updateDevices(_lastDevicesArgs);
}
@@ -796,7 +1026,8 @@ 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') return `${v.toFixed(v < 10 ? 1 : 0)} FPS`;
if (key === 'fps' || key === 'capture_fps') return `${v.toFixed(v < 10 ? 1 : 0)} FPS`;
if (key === 'errors') return `${v.toFixed(v < 1 ? 2 : v < 10 ? 1 : 0)}/s`;
return `${v.toFixed(1)}%`;
}
@@ -806,6 +1037,8 @@ 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 === 'errors') return 'Errors';
return key.toUpperCase();
}
@@ -74,12 +74,19 @@ export async function saveExternalUrl(): Promise<void> {
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 => {
btn.classList.toggle('active', (btn as HTMLElement).dataset.settingsTab === tabId);
const isActive = (btn as HTMLElement).dataset.settingsTab === tabId;
btn.classList.toggle('active', isActive);
if (isActive) activeBtn = btn as HTMLElement;
});
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.
if (activeBtn) {
(activeBtn as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
}
// 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
+3
View File
@@ -87,6 +87,9 @@ interface Window {
copyWsUrl: (...args: any[]) => any;
cloneDevice: (...args: any[]) => any;
// ─── Mod-card menu ───
toggleCardHidden: (sectionKey: string, id: string) => void;
// ─── Dashboard ───
loadDashboard: (...args: any[]) => any;
stopUptimeTimer: (...args: any[]) => any;
@@ -300,6 +300,7 @@
"device.url": "URL:",
"device.url.placeholder": "http://192.168.1.100",
"device.led_count": "LED Count:",
"dashboard.device.chip": "Chip:",
"device.led_count.hint": "Number of LEDs configured in the device",
"device.led_count.hint.auto": "Auto-detected from device",
"device.button.add": "Add Device",
@@ -352,6 +353,7 @@
"device.tutorial.start": "Start tutorial",
"device.tip.metadata": "Device info (LED count, type, color channels) is auto-detected from the device",
"device.tip.brightness": "Slide to adjust device brightness",
"device.brightness": "Bright",
"device.tip.start": "Start or stop screen capture processing",
"device.tip.settings": "Configure general device settings (name, URL, health check)",
"device.tip.capture_settings": "Configure capture settings (display, capture template)",
@@ -481,6 +483,9 @@
"common.remove": "Remove",
"common.edit": "Edit",
"common.clone": "Clone",
"common.hide": "Hide card",
"common.more_actions": "More actions",
"common.card_color": "Card color",
"common.none": "None",
"common.none_no_cspt": "None (no processing template)",
"common.none_no_input": "None (no input source)",
@@ -797,7 +802,11 @@
"dashboard.integrations.entities": "entities",
"dashboard.integrations.no_sources": "No integration sources configured",
"dashboard.perf.active_patches": "Active Patches",
"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.errors": "Errors",
"dashboard.perf.devices": "Devices",
"dashboard.perf.cpu": "CPU",
"dashboard.perf.ram": "RAM",
@@ -304,6 +304,7 @@
"device.url": "URL:",
"device.url.placeholder": "http://192.168.1.100",
"device.led_count": "Количество Светодиодов:",
"dashboard.device.chip": "Чип:",
"device.led_count.hint": "Количество светодиодов, настроенных в устройстве",
"device.led_count.hint.auto": "Автоматически определяется из устройства",
"device.button.add": "Добавить Устройство",
@@ -356,6 +357,7 @@
"device.tutorial.start": "Начать обучение",
"device.tip.metadata": "Информация об устройстве (кол-во LED, тип, цветовые каналы) определяется автоматически",
"device.tip.brightness": "Перетащите для регулировки яркости",
"device.brightness": "Яркость",
"device.tip.start": "Запуск или остановка захвата экрана",
"device.tip.settings": "Основные настройки устройства (имя, URL, интервал проверки)",
"device.tip.capture_settings": "Настройки захвата (дисплей, шаблон захвата)",
@@ -485,6 +487,9 @@
"common.remove": "Убрать",
"common.edit": "Редактировать",
"common.clone": "Клонировать",
"common.hide": "Скрыть карточку",
"common.more_actions": "Ещё действия",
"common.card_color": "Цвет карточки",
"common.none": "Нет",
"common.none_no_cspt": "Нет (без шаблона обработки)",
"common.none_no_input": "Нет (без источника)",
@@ -778,7 +783,11 @@
"dashboard.targets": "Цели",
"dashboard.section.performance": "Производительность системы",
"dashboard.perf.active_patches": "Активные каналы",
"dashboard.perf.patches.empty.idle": "Готов к запуску",
"dashboard.perf.patches.empty.none": "Каналов пока нет",
"dashboard.perf.total_fps": "Общий FPS",
"dashboard.perf.total_capture_fps": "Общий FPS захвата",
"dashboard.perf.errors": "Ошибки",
"dashboard.perf.devices": "Устройства",
"dashboard.perf.cpu": "ЦП",
"dashboard.perf.ram": "ОЗУ",
@@ -304,6 +304,7 @@
"device.url": "地址:",
"device.url.placeholder": "http://192.168.1.100",
"device.led_count": "LED 数量:",
"dashboard.device.chip": "芯片:",
"device.led_count.hint": "设备中配置的 LED 数量",
"device.led_count.hint.auto": "从设备自动检测",
"device.button.add": "添加设备",
@@ -356,6 +357,7 @@
"device.tutorial.start": "开始教程",
"device.tip.metadata": "设备信息(LED 数量、类型、颜色通道)从设备自动检测",
"device.tip.brightness": "滑动调节设备亮度",
"device.brightness": "亮度",
"device.tip.start": "启动或停止屏幕采集处理",
"device.tip.settings": "配置设备常规设置(名称、地址、健康检查)",
"device.tip.capture_settings": "配置采集设置(显示器、采集模板)",
@@ -485,6 +487,9 @@
"common.remove": "移除",
"common.edit": "编辑",
"common.clone": "克隆",
"common.hide": "隐藏卡片",
"common.more_actions": "更多操作",
"common.card_color": "卡片颜色",
"common.none": "无",
"common.none_no_cspt": "无(无处理模板)",
"common.none_no_input": "无(无输入源)",
@@ -778,7 +783,11 @@
"dashboard.targets": "目标",
"dashboard.section.performance": "系统性能",
"dashboard.perf.active_patches": "活动通道",
"dashboard.perf.patches.empty.idle": "准备就绪",
"dashboard.perf.patches.empty.none": "暂无通道",
"dashboard.perf.total_fps": "总帧率",
"dashboard.perf.total_capture_fps": "总采集帧率",
"dashboard.perf.errors": "错误",
"dashboard.perf.devices": "设备",
"dashboard.perf.cpu": "CPU",
"dashboard.perf.ram": "内存",
@@ -6,14 +6,28 @@
<button class="modal-close-btn" onclick="closeSettingsModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div>
<!-- Tab bar -->
<div class="settings-tab-bar">
<button class="settings-tab-btn active" data-settings-tab="general" onclick="switchSettingsTab('general')" data-i18n="settings.tab.general">General</button>
<button class="settings-tab-btn" data-settings-tab="backup" onclick="switchSettingsTab('backup')" data-i18n="settings.tab.backup">Backup</button>
<button class="settings-tab-btn" data-settings-tab="notifications" onclick="switchSettingsTab('notifications')" data-i18n="settings.tab.notifications">Notifications</button>
<button class="settings-tab-btn" data-settings-tab="appearance" onclick="switchSettingsTab('appearance')" data-i18n="settings.tab.appearance">Appearance</button>
<button class="settings-tab-btn" data-settings-tab="updates" onclick="switchSettingsTab('updates')" data-i18n="settings.tab.updates">Updates</button>
<button class="settings-tab-btn" data-settings-tab="about" onclick="switchSettingsTab('about')" data-i18n="settings.tab.about">About</button>
<!-- Tab bar — icon-only so labels never overflow at any locale.
The translated label remains available as title (hover tooltip)
and aria-label (screen readers). -->
<div class="settings-tab-bar" role="tablist">
<button class="settings-tab-btn active" data-settings-tab="general" onclick="switchSettingsTab('general')" role="tab" data-i18n-title="settings.tab.general" data-i18n-aria-label="settings.tab.general" title="General" aria-label="General">
<svg class="icon" viewBox="0 0 24 24"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/></svg>
</button>
<button class="settings-tab-btn" data-settings-tab="backup" onclick="switchSettingsTab('backup')" role="tab" data-i18n-title="settings.tab.backup" data-i18n-aria-label="settings.tab.backup" title="Backup" aria-label="Backup">
<svg class="icon" viewBox="0 0 24 24"><line x1="22" x2="2" y1="12" y2="12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><line x1="6" x2="6.01" y1="16" y2="16"/><line x1="10" x2="10.01" y1="16" y2="16"/></svg>
</button>
<button class="settings-tab-btn" data-settings-tab="notifications" onclick="switchSettingsTab('notifications')" role="tab" data-i18n-title="settings.tab.notifications" data-i18n-aria-label="settings.tab.notifications" title="Notifications" aria-label="Notifications">
<svg class="icon" viewBox="0 0 24 24"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/><path d="M4 2C2.8 3.7 2 5.7 2 8"/><path d="M22 8c0-2.3-.8-4.3-2-6"/></svg>
</button>
<button class="settings-tab-btn" data-settings-tab="appearance" onclick="switchSettingsTab('appearance')" role="tab" data-i18n-title="settings.tab.appearance" data-i18n-aria-label="settings.tab.appearance" title="Appearance" aria-label="Appearance">
<svg class="icon" viewBox="0 0 24 24"><path d="M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z"/><circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/></svg>
</button>
<button class="settings-tab-btn" data-settings-tab="updates" onclick="switchSettingsTab('updates')" role="tab" data-i18n-title="settings.tab.updates" data-i18n-aria-label="settings.tab.updates" title="Updates" aria-label="Updates">
<svg class="icon" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>
</button>
<button class="settings-tab-btn" data-settings-tab="about" onclick="switchSettingsTab('about')" role="tab" data-i18n-title="settings.tab.about" data-i18n-aria-label="settings.tab.about" title="About" aria-label="About">
<svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>
</button>
</div>
<div class="modal-body">