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:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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)"
|
None, description="Potential FPS (processing speed without throttle)"
|
||||||
)
|
)
|
||||||
fps_target: Optional[int] = Field(None, description="Target FPS")
|
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_skipped: Optional[int] = Field(None, description="Frames skipped (no screen change)")
|
||||||
frames_keepalive: Optional[int] = Field(
|
frames_keepalive: Optional[int] = Field(
|
||||||
None, description="Keepalive frames sent during standby"
|
None, description="Keepalive frames sent during standby"
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ class HALightTargetProcessor(TargetProcessor):
|
|||||||
"update_rate": self._update_rate,
|
"update_rate": self._update_rate,
|
||||||
"fps_actual": self._update_rate if self._is_running else None,
|
"fps_actual": self._update_rate if self._is_running else None,
|
||||||
"fps_target": self._update_rate,
|
"fps_target": self._update_rate,
|
||||||
|
"fps_capture": self._update_rate if self._is_running else None,
|
||||||
"uptime_seconds": uptime,
|
"uptime_seconds": uptime,
|
||||||
"entity_colors": entity_colors,
|
"entity_colors": entity_colors,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -399,8 +399,10 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
fps_target = self._target_fps
|
fps_target = self._target_fps
|
||||||
|
|
||||||
css_timing: dict = {}
|
css_timing: dict = {}
|
||||||
|
css_capture_fps: Optional[int] = None
|
||||||
if self._is_running and self._css_stream is not None:
|
if self._is_running and self._css_stream is not None:
|
||||||
css_timing = self._css_stream.get_last_timing()
|
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
|
send_ms = round(metrics.timing_send_ms, 1) if self._is_running else None
|
||||||
# Picture source timing
|
# Picture source timing
|
||||||
@@ -444,6 +446,7 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
"fps_actual": metrics.fps_actual if self._is_running else None,
|
"fps_actual": metrics.fps_actual if self._is_running else None,
|
||||||
"fps_potential": metrics.fps_potential if self._is_running else None,
|
"fps_potential": metrics.fps_potential if self._is_running else None,
|
||||||
"fps_target": fps_target,
|
"fps_target": fps_target,
|
||||||
|
"fps_capture": css_capture_fps,
|
||||||
"frames_skipped": metrics.frames_skipped if self._is_running else None,
|
"frames_skipped": metrics.frames_skipped if self._is_running else None,
|
||||||
"frames_keepalive": metrics.frames_keepalive if self._is_running else None,
|
"frames_keepalive": metrics.frames_keepalive if self._is_running else None,
|
||||||
"fps_current": metrics.fps_current if self._is_running else None,
|
"fps_current": metrics.fps_current if self._is_running else None,
|
||||||
|
|||||||
@@ -147,13 +147,15 @@ section {
|
|||||||
overflow: hidden;
|
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
|
* [data-has-color="1"] → user picked a personal color via the picker
|
||||||
* .card-running → "patched and live" indicator
|
* .card-running → "patched and live" indicator
|
||||||
* Idle cards without a personal color stay clean (no stripe), matching
|
* .mod-card → ambient channel signal (matches dashboard)
|
||||||
* the pre-redesign behavior where the left border meant "I marked this".
|
* Legacy cards without a personal color stay clean to avoid breaking
|
||||||
* The dashboard module rows keep their always-on stripe (at 0.6 opacity)
|
* the visual rhythm of feature tabs that haven't migrated yet. Once
|
||||||
* because the dashboard was approved as-is. */
|
* every create*Card() builder uses wrapCard({ mod }), the .mod-card
|
||||||
|
* scope can be dropped and the stripe becomes ambient on every card. */
|
||||||
.card::before {
|
.card::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -164,13 +166,20 @@ section {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
display: none;
|
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[data-has-color="1"]::before,
|
||||||
.card.card-running::before {
|
.card.card-running::before,
|
||||||
|
.card.mod-card::before {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card.mod-card:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* Corner bracket — silkscreened panel feel in the top-right */
|
/* Corner bracket — silkscreened panel feel in the top-right */
|
||||||
.card::after {
|
.card::after {
|
||||||
content: '';
|
content: '';
|
||||||
@@ -1112,6 +1121,14 @@ body.cs-drag-active .card-drag-handle {
|
|||||||
pointer-events: none;
|
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 */
|
/* Static color picker — inline in card-subtitle */
|
||||||
.section-header {
|
.section-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1533,81 +1550,258 @@ ul.section-tip li {
|
|||||||
display: block;
|
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,
|
||||||
.cs-selecting .card-selected.template-card {
|
.cs-selecting .card-selected.template-card {
|
||||||
border-color: var(--primary-color);
|
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 .card,
|
||||||
.cs-selecting .template-card {
|
.cs-selecting .template-card {
|
||||||
cursor: pointer;
|
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 */
|
/* Suppress hover lift during selection */
|
||||||
.cs-selecting .card:hover,
|
.cs-selecting .card:hover,
|
||||||
.cs-selecting .template-card:hover {
|
.cs-selecting .template-card:hover {
|
||||||
transform: none;
|
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-toolbar {
|
#bulk-toolbar {
|
||||||
|
--bulk-ch: var(--primary-color);
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 20px;
|
bottom: 24px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%) translateY(calc(100% + 30px));
|
transform: translateX(-50%) translateY(calc(100% + 40px));
|
||||||
background: var(--card-bg);
|
background: linear-gradient(180deg,
|
||||||
border: 1px solid var(--border-color);
|
var(--lux-bg-1, var(--card-bg)) 0%,
|
||||||
border-radius: var(--radius-md, 8px);
|
var(--lux-bg-2, var(--card-bg)) 100%);
|
||||||
padding: 8px 16px;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
z-index: var(--z-bulk-toolbar);
|
z-index: var(--z-bulk-toolbar);
|
||||||
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.3);
|
box-shadow:
|
||||||
transition: transform 0.25s ease;
|
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;
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#bulk-toolbar.visible {
|
#bulk-toolbar.visible {
|
||||||
transform: translateX(-50%) translateY(0);
|
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;
|
display: flex;
|
||||||
align-items: center;
|
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;
|
width: 16px;
|
||||||
height: 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 {
|
.bulk-count {
|
||||||
font-size: 0.85rem;
|
font-family: var(--font-mono, monospace);
|
||||||
color: var(--text-secondary);
|
font-size: 0.78rem;
|
||||||
min-width: 80px;
|
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 {
|
.bulk-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
padding-left: 8px;
|
||||||
|
margin-left: 4px;
|
||||||
|
border-left: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
||||||
}
|
}
|
||||||
|
|
||||||
.bulk-action-btn {
|
.bulk-action-btn {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
transition: box-shadow 0.18s ease, transform 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bulk-action-btn .icon {
|
.bulk-action-btn .icon {
|
||||||
@@ -1615,18 +1809,486 @@ ul.section-tip li {
|
|||||||
height: 16px;
|
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 {
|
.bulk-close {
|
||||||
background: none;
|
width: 30px;
|
||||||
border: none;
|
height: 30px;
|
||||||
color: var(--text-muted);
|
background: transparent;
|
||||||
font-size: 1rem;
|
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
||||||
|
color: var(--lux-ink-mute, var(--text-muted));
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 4px;
|
padding: 0;
|
||||||
border-radius: 4px;
|
border-radius: var(--lux-r-sm, 4px);
|
||||||
transition: color 0.2s;
|
display: inline-flex;
|
||||||
line-height: 1;
|
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 {
|
.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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -434,21 +434,52 @@ input:-webkit-autofill:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.toast {
|
.toast {
|
||||||
|
--toast-ch: var(--primary-color);
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 40px;
|
bottom: 40px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%) translateY(100px);
|
transform: translateX(-50%) translateY(100px);
|
||||||
padding: 16px 24px;
|
padding: 14px 22px;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--lux-r-lg, var(--radius-md, 10px));
|
||||||
color: white;
|
color: var(--lux-ink, #fff);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 15px;
|
font-size: 14.5px;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
opacity: 0;
|
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);
|
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;
|
min-width: 300px;
|
||||||
|
max-width: min(560px, calc(100vw - 32px));
|
||||||
text-align: center;
|
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 {
|
.toast.show {
|
||||||
@@ -464,17 +495,9 @@ input:-webkit-autofill:focus {
|
|||||||
100% { transform: translateX(-50%) translateY(0); }
|
100% { transform: translateX(-50%) translateY(0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast.success {
|
.toast.success { --toast-ch: var(--primary-color); }
|
||||||
background: var(--primary-color);
|
.toast.error { --toast-ch: var(--danger-color); }
|
||||||
}
|
.toast.info { --toast-ch: var(--info-color); }
|
||||||
|
|
||||||
.toast.error {
|
|
||||||
background: var(--danger-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast.info {
|
|
||||||
background: var(--info-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Toast with undo action */
|
/* Toast with undo action */
|
||||||
.toast-with-action {
|
.toast-with-action {
|
||||||
@@ -488,31 +511,38 @@ input:-webkit-autofill:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.toast-undo-btn {
|
.toast-undo-btn {
|
||||||
background: rgba(255, 255, 255, 0.25);
|
background: color-mix(in srgb, var(--toast-ch) 18%, transparent);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
border: 1px solid color-mix(in srgb, var(--toast-ch) 50%, transparent);
|
||||||
color: white;
|
color: var(--lux-ink, #fff);
|
||||||
padding: 4px 12px;
|
padding: 5px 14px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--lux-r-sm, var(--radius-sm));
|
||||||
font-weight: var(--weight-semibold, 600);
|
font-weight: var(--weight-semibold, 600);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
cursor: pointer;
|
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;
|
white-space: nowrap;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast-undo-btn:hover {
|
.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 {
|
.toast-timer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 3px;
|
height: 2px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
border-radius: 0 0 var(--lux-r-lg, var(--radius-md)) var(--lux-r-lg, var(--radius-md));
|
||||||
background: rgba(255, 255, 255, 0.3);
|
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;
|
transform-origin: left;
|
||||||
animation: toastTimer var(--toast-duration, 5s) linear forwards;
|
animation: toastTimer var(--toast-duration, 5s) linear forwards;
|
||||||
}
|
}
|
||||||
@@ -749,6 +779,15 @@ textarea:focus-visible {
|
|||||||
margin-left: auto;
|
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 {
|
.icon-select-popup {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: var(--z-lightbox);
|
z-index: var(--z-lightbox);
|
||||||
|
|||||||
@@ -286,6 +286,42 @@
|
|||||||
text-overflow: ellipsis;
|
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 {
|
.mod-leds {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -404,6 +440,34 @@
|
|||||||
margin-left: 3px;
|
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 {
|
.mod-metric .v .dashboard-fps-target {
|
||||||
font-family: var(--font-mono, monospace);
|
font-family: var(--font-mono, monospace);
|
||||||
font-size: 0.6rem;
|
font-size: 0.6rem;
|
||||||
@@ -1139,6 +1203,12 @@
|
|||||||
padding: 0 18px 14px;
|
padding: 0 18px 14px;
|
||||||
cursor: crosshair;
|
cursor: crosshair;
|
||||||
filter: drop-shadow(0 0 5px color-mix(in srgb, var(--perf-accent) 45%, transparent));
|
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 {
|
.perf-chart-spark .perf-chart-svg {
|
||||||
@@ -1149,6 +1219,18 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: block;
|
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 {
|
.perf-chart-unavailable {
|
||||||
@@ -1262,6 +1344,69 @@
|
|||||||
— the list owns the rest of the cell height. */
|
— the list owns the rest of the cell height. */
|
||||||
.perf-patches-cell .perf-chart-spark { display: none; }
|
.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 ── */
|
/* ── Devices cell — online/total count + dot strip per device ── */
|
||||||
.perf-devices-cell {
|
.perf-devices-cell {
|
||||||
--perf-accent: var(--ch-signal, var(--primary-color));
|
--perf-accent: var(--ch-signal, var(--primary-color));
|
||||||
@@ -1323,7 +1468,7 @@
|
|||||||
color: var(--perf-accent);
|
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-family: var(--font-mono, monospace);
|
||||||
font-size: 0.3em;
|
font-size: 0.3em;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -1333,6 +1478,12 @@
|
|||||||
margin-left: 6px;
|
margin-left: 6px;
|
||||||
align-self: center;
|
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
|
/* Target-FPS ceiling suffix — "/ 120" next to the big live number, sized
|
||||||
down + muted so the live value remains the primary reading. Matches
|
down + muted so the live value remains the primary reading. Matches
|
||||||
|
|||||||
@@ -770,12 +770,12 @@ h2 {
|
|||||||
height: 30px;
|
height: 30px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
border: none;
|
||||||
border-radius: var(--lux-r-sm, 3px);
|
border-radius: var(--lux-r-sm, 3px);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: var(--lux-ink-dim, var(--text-secondary));
|
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;
|
display: inline-grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
@@ -785,7 +785,6 @@ h2 {
|
|||||||
.header-btn:hover {
|
.header-btn:hover {
|
||||||
color: var(--lux-ink, var(--text-color));
|
color: var(--lux-ink, var(--text-color));
|
||||||
background: var(--lux-bg-2, var(--bg-secondary));
|
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);
|
box-shadow: 0 0 12px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 18%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -432,28 +432,43 @@
|
|||||||
|
|
||||||
.settings-tab-bar {
|
.settings-tab-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: space-around;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
border-bottom: 2px solid var(--border-color);
|
border-bottom: 2px solid var(--border-color);
|
||||||
padding: 0 0.75rem;
|
padding: 0 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-tab-btn {
|
.settings-tab-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 8px 12px;
|
padding: 10px 0;
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-bottom: 2px solid transparent;
|
border-bottom: 2px solid transparent;
|
||||||
margin-bottom: -2px;
|
margin-bottom: -2px;
|
||||||
white-space: nowrap;
|
display: inline-flex;
|
||||||
transition: color 0.2s ease, border-color 0.25s ease;
|
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 {
|
.settings-tab-btn:hover {
|
||||||
color: var(--text-color);
|
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 {
|
.settings-tab-btn.active {
|
||||||
@@ -461,6 +476,15 @@
|
|||||||
border-bottom-color: var(--primary-color);
|
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 {
|
.settings-panel {
|
||||||
display: none;
|
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 {
|
.modal-header {
|
||||||
padding: 22px 24px 14px 24px;
|
padding: 22px 24px 14px 24px;
|
||||||
border-bottom: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
border-bottom: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
||||||
|
|||||||
@@ -834,55 +834,89 @@ body.pp-filter-dragging .pp-filter-drag-handle {
|
|||||||
|
|
||||||
.cs-filter-wrap {
|
.cs-filter-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 180px;
|
width: 220px;
|
||||||
max-width: 40%;
|
max-width: 45%;
|
||||||
flex-shrink: 0;
|
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 {
|
.cs-filter-wrap .cs-filter {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 4px 26px 4px 10px;
|
padding: 7px 32px 7px 32px;
|
||||||
font-size: 0.78rem;
|
font-family: var(--font-mono, monospace);
|
||||||
border: 1px solid var(--border-color);
|
font-size: 0.76rem;
|
||||||
border-radius: 14px;
|
letter-spacing: 0.04em;
|
||||||
background: var(--bg-secondary);
|
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
||||||
color: var(--text-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;
|
outline: none;
|
||||||
box-shadow: none;
|
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.25);
|
||||||
box-sizing: border-box;
|
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 {
|
.cs-filter-wrap .cs-filter:focus {
|
||||||
border-color: var(--primary-color);
|
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 {
|
.cs-filter::placeholder {
|
||||||
color: var(--text-secondary);
|
color: var(--lux-ink-faint, var(--text-secondary));
|
||||||
font-size: 0.75rem;
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cs-filter-reset {
|
.cs-filter-reset {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 2px;
|
right: 4px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
background: none;
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-secondary);
|
color: var(--lux-ink-mute, var(--text-secondary));
|
||||||
font-size: 1rem;
|
font-size: 0.95rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0 5px;
|
padding: 0;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
border-radius: 50%;
|
border-radius: var(--lux-r-sm, 3px);
|
||||||
transition: color 0.15s, background 0.15s;
|
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;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cs-filter-reset:hover {
|
.cs-filter-reset:hover {
|
||||||
color: var(--text-color);
|
color: var(--lux-ink, var(--text-color));
|
||||||
background: var(--border-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 */
|
/* Empty state for CardSection */
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import { initCardGlare } from './core/card-glare.ts';
|
|||||||
import { initBgAnim, updateBgAnimAccent, updateBgAnimTheme } from './core/bg-anim.ts';
|
import { initBgAnim, updateBgAnimAccent, updateBgAnimTheme } from './core/bg-anim.ts';
|
||||||
import { initBgShaders } from './core/bg-shaders.ts';
|
import { initBgShaders } from './core/bg-shaders.ts';
|
||||||
import { initTabIndicator, updateTabIndicator } from './core/tab-indicator.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
|
// Layer 2: ui
|
||||||
import {
|
import {
|
||||||
@@ -240,6 +242,11 @@ Object.assign(window, {
|
|||||||
// core / state (for inline script)
|
// core / state (for inline script)
|
||||||
setApiKey,
|
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>)
|
// visual effects (called from inline <script>)
|
||||||
_updateBgAnimAccent: updateBgAnimAccent,
|
_updateBgAnimAccent: updateBgAnimAccent,
|
||||||
_updateBgAnimTheme: updateBgAnimTheme,
|
_updateBgAnimTheme: updateBgAnimTheme,
|
||||||
@@ -737,6 +744,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
|
|
||||||
// Initialize visual effects
|
// Initialize visual effects
|
||||||
initCardGlare();
|
initCardGlare();
|
||||||
|
initModMenu();
|
||||||
initBgAnim();
|
initBgAnim();
|
||||||
initBgShaders();
|
initBgShaders();
|
||||||
initAppearance();
|
initAppearance();
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
import { t } from './i18n.ts';
|
import { t } from './i18n.ts';
|
||||||
import { showConfirm } from './ui.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';
|
import type { CardSection } from './card-sections.ts';
|
||||||
|
|
||||||
let _activeSection: CardSection | null = null; // CardSection currently in bulk mode
|
let _activeSection: CardSection | null = null; // CardSection currently in bulk mode
|
||||||
@@ -53,28 +54,40 @@ function _render() {
|
|||||||
if (!section) { el.classList.remove('visible'); return; }
|
if (!section) { el.classList.remove('visible'); return; }
|
||||||
|
|
||||||
const count = section._selected.size;
|
const count = section._selected.size;
|
||||||
|
const total = section._visibleCardCount();
|
||||||
const actions = section.bulkActions || [];
|
const actions = section.bulkActions || [];
|
||||||
|
const allSelected = count > 0 && count === total;
|
||||||
|
const noneSelected = count === 0;
|
||||||
|
|
||||||
const actionBtns = actions.map(a => {
|
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 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 label = t(a.labelKey);
|
||||||
const inner = a.icon || label;
|
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('');
|
}).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 = `
|
el.innerHTML = `
|
||||||
<label class="bulk-select-all-wrap" title="${t('bulk.select_all')}">
|
<div class="bulk-pick">
|
||||||
<input type="checkbox" class="bulk-select-all-cb"${count > 0 && count === section._visibleCardCount() ? ' checked' : ''}>
|
<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>
|
||||||
</label>
|
<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>
|
||||||
<span class="bulk-count">${t('bulk.selected_count', { count })}</span>
|
</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>
|
<div class="bulk-actions">${actionBtns}</div>
|
||||||
<button class="bulk-close" title="${t('bulk.cancel')}">✕</button>
|
<button class="bulk-close" title="${t('bulk.cancel')}" aria-label="${t('bulk.cancel')}">${ICON_X}</button>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Select All checkbox
|
// Pick buttons
|
||||||
el.querySelector('.bulk-select-all-cb')!.addEventListener('change', (e) => {
|
el.querySelector('[data-bulk-pick="all"]')!.addEventListener('click', () => {
|
||||||
if ((e.target as HTMLInputElement).checked) section.selectAll();
|
section.selectAll();
|
||||||
else section.deselectAll();
|
});
|
||||||
|
el.querySelector('[data-bulk-pick="none"]')!.addEventListener('click', () => {
|
||||||
|
section.deselectAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Action buttons
|
// Action buttons
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
|
|
||||||
import { createColorPicker, registerColorPicker } from './color-picker.ts';
|
import { createColorPicker, registerColorPicker } from './color-picker.ts';
|
||||||
import { ICON_TRASH } from './icons.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 STORAGE_KEY = 'cardColors';
|
||||||
const DEFAULT_SWATCH = '#808080';
|
const DEFAULT_SWATCH = '#808080';
|
||||||
@@ -63,6 +65,25 @@ export function setCardColor(id: string, hex: string): void {
|
|||||||
const card = el as HTMLElement;
|
const card = el as HTMLElement;
|
||||||
if (hex) card.style.setProperty('--ch', hex);
|
if (hex) card.style.setProperty('--ch', hex);
|
||||||
else card.style.removeProperty('--ch');
|
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 });
|
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.
|
* Build a standard card shell with color support.
|
||||||
*
|
*
|
||||||
* Provides consistent structure across all card types:
|
* Two rendering paths:
|
||||||
* - .card-top-actions: remove button + optional extra top buttons
|
|
||||||
* - Bottom actions: action buttons + color picker (always last)
|
|
||||||
* - Automatic border-left color from localStorage
|
|
||||||
*
|
*
|
||||||
* @param {object} opts
|
* 1. Legacy (content/actions) — emits `.card-top-actions` with the
|
||||||
* @param {'card'|'template-card'} [opts.type='card'] Card CSS class
|
* remove button and a bottom `.card-actions` row with action
|
||||||
* @param {string} opts.dataAttr Data attribute name, e.g. 'data-device-id'
|
* buttons + colour picker. Used by all cards that haven't been
|
||||||
* @param {string} opts.id Entity ID value
|
* migrated to the modular system yet.
|
||||||
* @param {string} [opts.classes] Extra CSS classes on root element
|
*
|
||||||
* @param {string} [opts.topButtons] HTML for extra top-right buttons (power, autostart)
|
* 2. Modular (`mod`) — emits the dashboard's `.mod-head / .mod-body /
|
||||||
* @param {string} opts.removeOnclick onclick handler string for remove button
|
* .mod-foot` markup with a kebab overflow menu housing duplicate /
|
||||||
* @param {string} opts.removeTitle title attribute for remove button
|
* hide / delete. Card-color picker is integrated into the
|
||||||
* @param {string} opts.content Inner HTML (header, props, metrics, etc.)
|
* `.mod-badge` as a leading dot. Use this for any new card and
|
||||||
* @param {string} opts.actions Action button HTML (without wrapper div)
|
* when migrating existing card builders.
|
||||||
|
*
|
||||||
|
* Old callers keep working unchanged. New callers pass `mod`.
|
||||||
*/
|
*/
|
||||||
export function wrapCard({
|
export function wrapCard(opts: WrapCardLegacyOpts | WrapCardModOpts): string {
|
||||||
type = 'card',
|
if ('mod' in opts && opts.mod) {
|
||||||
dataAttr,
|
return _renderModCard(opts as WrapCardModOpts);
|
||||||
id,
|
}
|
||||||
classes = '',
|
return _renderLegacyCard(opts as WrapCardLegacyOpts);
|
||||||
topButtons = '',
|
}
|
||||||
removeOnclick,
|
|
||||||
removeTitle,
|
export interface WrapCardLegacyOpts {
|
||||||
content,
|
|
||||||
actions,
|
|
||||||
}: {
|
|
||||||
type?: 'card' | 'template-card';
|
type?: 'card' | 'template-card';
|
||||||
dataAttr: string;
|
dataAttr: string;
|
||||||
id: string;
|
id: string;
|
||||||
@@ -133,7 +215,28 @@ export function wrapCard({
|
|||||||
removeTitle: string;
|
removeTitle: string;
|
||||||
content: string;
|
content: string;
|
||||||
actions: 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 actionsClass = type === 'template-card' ? 'template-card-actions' : 'card-actions';
|
||||||
const colorStyle = cardColorStyle(id);
|
const colorStyle = cardColorStyle(id);
|
||||||
return `
|
return `
|
||||||
@@ -149,3 +252,39 @@ export function wrapCard({
|
|||||||
</div>
|
</div>
|
||||||
</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 { t } from './i18n.ts';
|
||||||
import { showBulkToolbar, hideBulkToolbar, updateBulkToolbar } from './bulk-toolbar.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 {
|
export interface BulkAction {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -316,30 +316,46 @@ export class CardSection {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Card click delegation for selection
|
// Card click delegation for selection.
|
||||||
// Ctrl+Click on a card auto-enters bulk mode if not already selecting
|
//
|
||||||
|
// 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) => {
|
content.addEventListener('click', (e: MouseEvent) => {
|
||||||
if (!this.keyAttr) return;
|
if (!this.keyAttr) return;
|
||||||
const card = (e.target as HTMLElement).closest(`[${this.keyAttr}]`);
|
const card = (e.target as HTMLElement).closest(`[${this.keyAttr}]`);
|
||||||
if (!card) return;
|
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)) {
|
|
||||||
this.enterSelectionMode();
|
|
||||||
}
|
|
||||||
if (!this._selecting) return;
|
|
||||||
|
|
||||||
|
if (this._selecting) {
|
||||||
|
// pointer-events:none on descendants means events
|
||||||
|
// already bypass inner controls; just toggle.
|
||||||
const key = card.getAttribute(this.keyAttr);
|
const key = card.getAttribute(this.keyAttr);
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
|
|
||||||
if (e.shiftKey && this._lastClickedKey) {
|
if (e.shiftKey && this._lastClickedKey) {
|
||||||
this._selectRange(content, this._lastClickedKey, key);
|
this._selectRange(content, this._lastClickedKey, key);
|
||||||
} else {
|
} else {
|
||||||
this._toggleSelect(key);
|
this._toggleSelect(key);
|
||||||
}
|
}
|
||||||
this._lastClickedKey = 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();
|
||||||
|
const key = card.getAttribute(this.keyAttr);
|
||||||
|
if (!key) return;
|
||||||
|
this._toggleSelect(key);
|
||||||
|
this._lastClickedKey = key;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Escape to exit selection mode
|
// Escape to exit selection mode
|
||||||
@@ -358,6 +374,7 @@ export class CardSection {
|
|||||||
if (this.keyAttr) {
|
if (this.keyAttr) {
|
||||||
this._injectHideButtons(content);
|
this._injectHideButtons(content);
|
||||||
this._injectDragHandles(content);
|
this._injectDragHandles(content);
|
||||||
|
this._injectBulkTicks(content);
|
||||||
this._initDrag(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)) {
|
if (this.keyAttr && (added.size > 0 || replaced.size > 0)) {
|
||||||
this._injectHideButtons(content);
|
this._injectHideButtons(content);
|
||||||
this._injectDragHandles(content);
|
this._injectDragHandles(content);
|
||||||
|
this._injectBulkTicks(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-cache searchable text for new/replaced cards
|
// 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;
|
if (!this.keyAttr) return;
|
||||||
content.querySelectorAll(`[${this.keyAttr}]`).forEach(card => {
|
content.querySelectorAll(`[${this.keyAttr}]`).forEach(card => {
|
||||||
if (card.querySelector('.card-bulk-check')) return;
|
// Strip any legacy injected checkboxes from older deploys
|
||||||
const cb = document.createElement('input');
|
const stale = card.querySelector('.card-bulk-check');
|
||||||
cb.type = 'checkbox';
|
if (stale) stale.remove();
|
||||||
cb.className = 'card-bulk-check';
|
if (card.querySelector('.mod-bulk-tick')) return;
|
||||||
cb.checked = this._selected.has(card.getAttribute(this.keyAttr)!);
|
const tick = document.createElement('span');
|
||||||
cb.addEventListener('click', (e: MouseEvent) => {
|
tick.className = 'mod-bulk-tick';
|
||||||
e.stopPropagation();
|
tick.setAttribute('aria-hidden', 'true');
|
||||||
const key = card.getAttribute(this.keyAttr)!;
|
tick.innerHTML = ICON_CHECK;
|
||||||
if (e.shiftKey && this._lastClickedKey) {
|
card.appendChild(tick);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }[] {
|
function _getLocaleItems(): { value: string; icon: string; label: string }[] {
|
||||||
return [
|
return [
|
||||||
{ value: 'en', icon: '<span style="font-weight:700">EN</span>', label: 'English' },
|
{ value: 'en', icon: '', label: 'EN' },
|
||||||
{ value: 'ru', icon: '<span style="font-weight:700">RU</span>', label: 'Русский' },
|
{ value: 'ru', icon: '', label: 'RU' },
|
||||||
{ value: 'zh', icon: '<span style="font-weight:700">ZH</span>', label: '中文' },
|
{ 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 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 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 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 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 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"/>';
|
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._searchable = searchable;
|
||||||
this._searchPlaceholder = searchPlaceholder;
|
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
|
// Hide the native select
|
||||||
this._select.style.display = 'none';
|
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_CLOCK = _svg(P.clock);
|
||||||
export const ICON_WARNING = _svg(P.triangleAlert);
|
export const ICON_WARNING = _svg(P.triangleAlert);
|
||||||
export const ICON_OK = _svg(P.circleCheck);
|
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_LINK_SOURCE = _svg(P.tv);
|
||||||
export const ICON_LED = _svg(P.lightbulb);
|
export const ICON_LED = _svg(P.lightbulb);
|
||||||
export const ICON_FPS = _svg(P.zap);
|
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_CW = _svg(P.rotateCw);
|
||||||
export const ICON_ROTATE_CCW = _svg(P.rotateCcw);
|
export const ICON_ROTATE_CCW = _svg(P.rotateCcw);
|
||||||
export const ICON_DOWNLOAD = _svg(P.download);
|
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_UNDO = _svg(P.undo2);
|
||||||
export const ICON_SCENE = _svg(P.sparkles);
|
export const ICON_SCENE = _svg(P.sparkles);
|
||||||
export const ICON_CAPTURE = _svg(P.camera);
|
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_MOUSE = _svg(P.mouse);
|
||||||
export const ICON_HEADPHONES = _svg(P.headphones);
|
export const ICON_HEADPHONES = _svg(P.headphones);
|
||||||
export const ICON_TRASH = _svg(P.trash2);
|
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_LIST_CHECKS = _svg(P.listChecks);
|
||||||
export const ICON_CIRCLE_OFF = _svg(P.circleOff);
|
export const ICON_CIRCLE_OFF = _svg(P.circleOff);
|
||||||
export const ICON_EXTERNAL_LINK = _svg(P.externalLink);
|
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;
|
||||||
|
/** 0–3 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();
|
||||||
|
}
|
||||||
@@ -29,11 +29,16 @@ export class Modal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get isOpen() {
|
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() {
|
open() {
|
||||||
this._previousFocus = document.activeElement;
|
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';
|
this.el!.style.display = 'flex';
|
||||||
if (this._lock) lockBody();
|
if (this._lock) lockBody();
|
||||||
if (this._backdrop) setupBackdropClose(this.el!, () => this.close());
|
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() {
|
forceClose() {
|
||||||
releaseFocus(this.el!);
|
if (!this.el) return;
|
||||||
this.el!.style.display = 'none';
|
// 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();
|
if (this._lock) unlockBody();
|
||||||
this._initialValues = {};
|
this._initialValues = {};
|
||||||
this.hideError();
|
this.hideError();
|
||||||
@@ -63,6 +83,40 @@ export class Modal {
|
|||||||
(this._previousFocus as HTMLElement).focus({ preventScroll: true });
|
(this._previousFocus as HTMLElement).focus({ preventScroll: true });
|
||||||
this._previousFocus = null;
|
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() {
|
async close() {
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ const SECTION_LABEL_KEYS: Record<string, string> = {
|
|||||||
const PERF_CELL_LABEL_KEYS: Record<string, string> = {
|
const PERF_CELL_LABEL_KEYS: Record<string, string> = {
|
||||||
patches: 'dashboard.perf.active_patches',
|
patches: 'dashboard.perf.active_patches',
|
||||||
fps: 'dashboard.perf.total_fps',
|
fps: 'dashboard.perf.total_fps',
|
||||||
|
capture_fps: 'dashboard.perf.total_capture_fps',
|
||||||
|
errors: 'dashboard.perf.errors',
|
||||||
devices: 'dashboard.perf.devices',
|
devices: 'dashboard.perf.devices',
|
||||||
cpu: 'dashboard.perf.cpu',
|
cpu: 'dashboard.perf.cpu',
|
||||||
ram: 'dashboard.perf.ram',
|
ram: 'dashboard.perf.ram',
|
||||||
@@ -129,6 +131,13 @@ function _mountPanel(): void {
|
|||||||
`;
|
`;
|
||||||
document.body.appendChild(panel);
|
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) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Escape' && panel.classList.contains('is-open')) {
|
if (e.key === 'Escape' && panel.classList.contains('is-open')) {
|
||||||
closeDashboardCustomize();
|
closeDashboardCustomize();
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export type SectionKey =
|
|||||||
export type PerfCellKey =
|
export type PerfCellKey =
|
||||||
| 'patches'
|
| 'patches'
|
||||||
| 'fps'
|
| 'fps'
|
||||||
|
| 'capture_fps'
|
||||||
|
| 'errors'
|
||||||
| 'devices'
|
| 'devices'
|
||||||
| 'cpu'
|
| 'cpu'
|
||||||
| 'ram'
|
| 'ram'
|
||||||
@@ -142,6 +144,8 @@ export const DEFAULT_LAYOUT: DashboardLayoutV1 = {
|
|||||||
perfCells: [
|
perfCells: [
|
||||||
_defaultPerfCell('patches'),
|
_defaultPerfCell('patches'),
|
||||||
_defaultPerfCell('fps'),
|
_defaultPerfCell('fps'),
|
||||||
|
_defaultPerfCell('capture_fps'),
|
||||||
|
_defaultPerfCell('errors'),
|
||||||
_defaultPerfCell('devices'),
|
_defaultPerfCell('devices'),
|
||||||
_defaultPerfCell('cpu'),
|
_defaultPerfCell('cpu'),
|
||||||
_defaultPerfCell('ram'),
|
_defaultPerfCell('ram'),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval,
|
|||||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, fetchMetricsHistory } from '../core/api.ts';
|
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, fetchMetricsHistory } from '../core/api.ts';
|
||||||
import { t } from '../core/i18n.ts';
|
import { t } from '../core/i18n.ts';
|
||||||
import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing } from '../core/ui.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 { startAutoRefresh, updateTabBadge } from './tabs.ts';
|
||||||
import { isActiveTab } from '../core/tab-registry.ts';
|
import { isActiveTab } from '../core/tab-registry.ts';
|
||||||
import {
|
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
|
// Total FPS cell in the perf strip. `fpsTargetSum` is drawn as
|
||||||
// a dashed reference line ("max achievable throughput").
|
// a dashed reference line ("max achievable throughput").
|
||||||
const fpsValues: number[] = [];
|
const fpsValues: number[] = [];
|
||||||
|
const captureFpsValues: number[] = [];
|
||||||
let fpsSum = 0;
|
let fpsSum = 0;
|
||||||
let fpsTargetSum = 0;
|
let fpsTargetSum = 0;
|
||||||
|
let captureFpsSum = 0;
|
||||||
for (const r of running) {
|
for (const r of running) {
|
||||||
const fps = r.state?.fps_actual != null ? r.state.fps_actual
|
const fps = r.state?.fps_actual != null ? r.state.fps_actual
|
||||||
: r.state?.fps_current != null ? r.state.fps_current
|
: 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.settings || {}).fps
|
||||||
?? r.update_rate;
|
?? r.update_rate;
|
||||||
if (typeof tgt === 'number' && tgt > 0) fpsTargetSum += tgt;
|
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 fpsMin = fpsValues.length > 0 ? Math.min(...fpsValues) : null;
|
||||||
const fpsMax = fpsValues.length > 0 ? Math.max(...fpsValues) : null;
|
const fpsMax = fpsValues.length > 0 ? Math.max(...fpsValues) : null;
|
||||||
updateTotalFps(fpsSum, fpsMin, fpsMax, fpsTargetSum);
|
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)
|
// Check if we can do an in-place metrics update (same targets, not first load)
|
||||||
const newRunningIds = running.map(t => t.id).sort().join(',');
|
const newRunningIds = running.map(t => t.id).sort().join(',');
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode,
|
|||||||
import { t } from '../core/i18n.ts';
|
import { t } from '../core/i18n.ts';
|
||||||
import { showToast, showConfirm, desktopFocus } from '../core/ui.ts';
|
import { showToast, showConfirm, desktopFocus } from '../core/ui.ts';
|
||||||
import { Modal } from '../core/modal.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 { 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 { TagInput, renderTagChips } from '../core/tag-input.ts';
|
||||||
import { EntitySelect } from '../core/entity-palette.ts';
|
import { EntitySelect } from '../core/entity-palette.ts';
|
||||||
import { getBaseOrigin } from './settings.ts';
|
import { getBaseOrigin } from './settings.ts';
|
||||||
@@ -127,76 +128,180 @@ export function createDeviceCard(device: Device & { state?: any }) {
|
|||||||
const devVersion = state.device_version;
|
const devVersion = state.device_version;
|
||||||
const devLastChecked = state.device_last_checked;
|
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) {
|
if (devLastChecked === null || devLastChecked === undefined) {
|
||||||
healthClass = 'health-unknown';
|
healthClass = 'health-unknown';
|
||||||
healthTitle = t('device.health.checking');
|
healthTitle = t('device.health.checking');
|
||||||
healthLabel = '';
|
|
||||||
} else if (devOnline) {
|
} else if (devOnline) {
|
||||||
healthClass = 'health-online';
|
healthClass = 'health-online';
|
||||||
healthTitle = `${t('device.health.online')}`;
|
healthTitle = t('device.health.online');
|
||||||
if (devName) healthTitle += ` - ${devName}`;
|
if (devName) healthTitle += ` - ${devName}`;
|
||||||
if (devVersion) healthTitle += ` v${devVersion}`;
|
if (devVersion) healthTitle += ` v${devVersion}`;
|
||||||
if (devLatency !== null && devLatency !== undefined) healthTitle += ` (${Math.round(devLatency)}ms)`;
|
if (devLatency !== null && devLatency !== undefined) healthTitle += ` (${Math.round(devLatency)}ms)`;
|
||||||
healthLabel = '';
|
|
||||||
} else {
|
} else {
|
||||||
healthClass = 'health-offline';
|
healthClass = 'health-offline';
|
||||||
healthTitle = t('device.health.offline');
|
healthTitle = t('device.health.offline');
|
||||||
if (state.device_error) healthTitle += `: ${state.device_error}`;
|
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)
|
const openrgbZones = isOpenrgbDevice(device.device_type)
|
||||||
? _splitOpenrgbZone(device.url).zones : [];
|
? _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',
|
dataAttr: 'data-device-id',
|
||||||
id: 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>` : '',
|
classes: hasBrightness && !brightnessLoaded ? 'brightness-loading' : '',
|
||||||
removeOnclick: `removeDevice('${device.id}')`,
|
mod,
|
||||||
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>`,
|
|
||||||
});
|
});
|
||||||
|
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) {
|
export async function turnOffDevice(deviceId: any) {
|
||||||
@@ -614,7 +719,17 @@ export async function saveDeviceSettings() {
|
|||||||
// Brightness
|
// Brightness
|
||||||
export function updateBrightnessLabel(deviceId: any, value: any) {
|
export function updateBrightnessLabel(deviceId: any, value: any) {
|
||||||
const slider = document.querySelector(`[data-device-brightness="${CSS.escape(deviceId)}"]`) as HTMLElement | null;
|
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) {
|
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;
|
const slider = document.querySelector(`[data-device-brightness="${CSS.escape(deviceId)}"]`) as HTMLInputElement | null;
|
||||||
if (slider) {
|
if (slider) {
|
||||||
slider.value = data.brightness;
|
slider.value = data.brightness;
|
||||||
slider.title = Math.round(data.brightness / 255 * 100) + '%';
|
|
||||||
slider.disabled = false;
|
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;
|
const wrap = document.querySelector(`[data-brightness-wrap="${CSS.escape(deviceId)}"]`) as HTMLElement | null;
|
||||||
if (wrap) wrap.classList.remove('brightness-loading');
|
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) {
|
} catch (err) {
|
||||||
// Silently fail — device may be offline
|
// Silently fail — device may be offline
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -16,17 +16,17 @@ import { createColorPicker, registerColorPicker } from '../core/color-picker.ts'
|
|||||||
import { getOrderedPerfCells, isPerfCellVisible, getGlobalConfig, saveDashboardLayout, getDashboardLayout, setGlobalPerfMode, effectivePerfWindow } from './dashboard-layout.ts';
|
import { getOrderedPerfCells, isPerfCellVisible, getGlobalConfig, saveDashboardLayout, getDashboardLayout, setGlobalPerfMode, effectivePerfWindow } from './dashboard-layout.ts';
|
||||||
|
|
||||||
const MAX_SAMPLES = 120;
|
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
|
/** Every cell key the user can color-customize, including the
|
||||||
* patches / devices cells that don't have sparklines but still
|
* patches / devices cells that don't have sparklines but still
|
||||||
* carry a header accent stripe. */
|
* 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 PERF_MODE_KEY = 'perfMetricsMode';
|
||||||
const SPARK_W = 600; // SVG viewBox width (scales with preserveAspectRatio)
|
const SPARK_W = 600; // SVG viewBox width (scales with preserveAspectRatio)
|
||||||
const SPARK_H = 64;
|
const SPARK_H = 64;
|
||||||
|
|
||||||
/** Metrics that don't have a per-process variant (host-only). */
|
/** 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
|
/** 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
|
perf cards share the same language as the rest of the app. Overrides
|
||||||
@@ -34,6 +34,8 @@ const HOST_ONLY_KEYS = new Set(['temp', 'fps']);
|
|||||||
const METRIC_CSS_VARS: Record<string, string> = {
|
const METRIC_CSS_VARS: Record<string, string> = {
|
||||||
patches: '--ch-magenta',
|
patches: '--ch-magenta',
|
||||||
fps: '--ch-cyan',
|
fps: '--ch-cyan',
|
||||||
|
capture_fps: '--ch-signal',
|
||||||
|
errors: '--ch-coral',
|
||||||
devices: '--ch-signal',
|
devices: '--ch-signal',
|
||||||
cpu: '--ch-coral',
|
cpu: '--ch-coral',
|
||||||
ram: '--ch-violet',
|
ram: '--ch-violet',
|
||||||
@@ -45,6 +47,8 @@ const METRIC_CSS_VARS: Record<string, string> = {
|
|||||||
const METRIC_FALLBACK: Record<string, string> = {
|
const METRIC_FALLBACK: Record<string, string> = {
|
||||||
patches: '#EC4899',
|
patches: '#EC4899',
|
||||||
fps: '#00D8FF',
|
fps: '#00D8FF',
|
||||||
|
capture_fps: '#22D3EE',
|
||||||
|
errors: '#FF6B6B',
|
||||||
devices: '#10B981',
|
devices: '#10B981',
|
||||||
cpu: '#FF6B6B',
|
cpu: '#FF6B6B',
|
||||||
ram: '#A855F7',
|
ram: '#A855F7',
|
||||||
@@ -55,11 +59,24 @@ const METRIC_FALLBACK: Record<string, string> = {
|
|||||||
type PerfMode = 'system' | 'app' | 'both';
|
type PerfMode = 'system' | 'app' | 'both';
|
||||||
|
|
||||||
let _pollTimer: ReturnType<typeof setInterval> | null = null;
|
let _pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
let _history: 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: [] };
|
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
|
/** Peak FPS observed during the session — used as the y-axis ceiling for
|
||||||
* the FPS sparkline so slow targets look proportional to fast ones. */
|
* the FPS sparkline so slow targets look proportional to fast ones. */
|
||||||
let _fpsPeak = 60;
|
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
|
/** Sum of fps_target across running targets — rendered as a dashed
|
||||||
* reference line on the FPS spark ("max achievable throughput"). */
|
* reference line on the FPS spark ("max achievable throughput"). */
|
||||||
let _fpsTargetSum = 0;
|
let _fpsTargetSum = 0;
|
||||||
@@ -74,6 +91,8 @@ let _lastFetchData: any = null;
|
|||||||
* loader to fire its next pass. */
|
* loader to fire its next pass. */
|
||||||
let _lastPatchesArgs: { running: { id: string; name: string; fps?: number }[]; totalCount: number } | null = null;
|
let _lastPatchesArgs: { running: { id: string; name: string; fps?: number }[]; totalCount: number } | null = null;
|
||||||
let _lastTotalFpsArgs: { totalFps: number; minFps: number | null; maxFps: number | null; targetSum: number } | null = null;
|
let _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;
|
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
|
/** Mirrors `layout.global.perfMode`. Kept as a module-local for legacy
|
||||||
* callers that read it directly; sync'd from the layout on every read
|
* callers that read it directly; sync'd from the layout on every read
|
||||||
@@ -209,6 +228,34 @@ export function renderPerfSection(): string {
|
|||||||
</div>
|
</div>
|
||||||
</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 = `
|
const devicesCell = `
|
||||||
<div class="perf-chart-card perf-devices-cell" data-metric="devices" style="--perf-accent:${_getColor('devices')}">
|
<div class="perf-chart-card perf-devices-cell" data-metric="devices" style="--perf-accent:${_getColor('devices')}">
|
||||||
<div class="perf-chart-header">
|
<div class="perf-chart-header">
|
||||||
@@ -232,6 +279,8 @@ export function renderPerfSection(): string {
|
|||||||
const cellRenderers: Record<string, () => string> = {
|
const cellRenderers: Record<string, () => string> = {
|
||||||
patches: () => patchesCell,
|
patches: () => patchesCell,
|
||||||
fps: () => fpsCell,
|
fps: () => fpsCell,
|
||||||
|
capture_fps: () => captureFpsCell,
|
||||||
|
errors: () => errorsCell,
|
||||||
devices: () => devicesCell,
|
devices: () => devicesCell,
|
||||||
cpu: () => sparkCard('cpu', 'dashboard.perf.cpu', false),
|
cpu: () => sparkCard('cpu', 'dashboard.perf.cpu', false),
|
||||||
ram: () => sparkCard('ram', 'dashboard.perf.ram', false),
|
ram: () => sparkCard('ram', 'dashboard.perf.ram', false),
|
||||||
@@ -265,7 +314,17 @@ export function updateActivePatches(
|
|||||||
const listEl = document.getElementById('perf-patches-list');
|
const listEl = document.getElementById('perf-patches-list');
|
||||||
if (!listEl) return;
|
if (!listEl) return;
|
||||||
if (running.length === 0) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const rows = running.slice(0, 4).map((r, i) => {
|
const rows = running.slice(0, 4).map((r, i) => {
|
||||||
@@ -320,7 +379,96 @@ export function updateTotalFps(
|
|||||||
subEl.textContent = '';
|
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
|
/** Devices cell — online / total count with a dot strip showing each
|
||||||
@@ -367,8 +515,63 @@ export function updateDevices(
|
|||||||
dotsEl.innerHTML = dots + more;
|
dotsEl.innerHTML = dots + more;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Render the SVG sparkline into its container. */
|
/** Resolve the global animations preference once per render — read from
|
||||||
function _renderChartSvg(key: string): void {
|
* 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}`);
|
const host = document.getElementById(`perf-chart-${key}`);
|
||||||
if (!host) return;
|
if (!host) return;
|
||||||
// Effective window (in seconds) for this cell — global default
|
// 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
|
// 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
|
// 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 yMin = key === 'temp' ? 20 : 0;
|
||||||
const yMax = key === 'temp' ? 100
|
const yMax = key === 'temp' ? 100
|
||||||
: key === 'fps' ? Math.max(60, _fpsPeak * 1.1, _fpsTargetSum * 1.1)
|
: 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;
|
: 100;
|
||||||
|
|
||||||
const paths: string[] = [];
|
const paths: string[] = [];
|
||||||
@@ -424,6 +632,8 @@ function _renderChartSvg(key: string): void {
|
|||||||
</defs>
|
</defs>
|
||||||
${paths.join('')}
|
${paths.join('')}
|
||||||
</svg>`;
|
</svg>`;
|
||||||
|
|
||||||
|
if (animate) _scrollSpark(host, sliceN);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build <path> elements (area + stroke) for one series. */
|
/** 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();
|
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.
|
/** Render the main + app values for a single perf card.
|
||||||
@@ -767,6 +977,26 @@ export function rerenderPerfGrid(): void {
|
|||||||
_lastTotalFpsArgs.targetSum,
|
_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) {
|
if (_lastDevicesArgs) {
|
||||||
updateDevices(_lastDevicesArgs);
|
updateDevices(_lastDevicesArgs);
|
||||||
}
|
}
|
||||||
@@ -796,7 +1026,8 @@ function _ensureTooltip(): HTMLDivElement {
|
|||||||
/** Format a sampled value per metric for the tooltip line. */
|
/** Format a sampled value per metric for the tooltip line. */
|
||||||
function _formatSampleValue(key: string, v: number): string {
|
function _formatSampleValue(key: string, v: number): string {
|
||||||
if (key === 'temp') return `${v.toFixed(1)}°C`;
|
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)}%`;
|
return `${v.toFixed(1)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -806,6 +1037,8 @@ function _metricLabel(key: string): string {
|
|||||||
if (key === 'gpu') return 'GPU';
|
if (key === 'gpu') return 'GPU';
|
||||||
if (key === 'temp') return 'Temp';
|
if (key === 'temp') return 'Temp';
|
||||||
if (key === 'fps') return 'Total FPS';
|
if (key === 'fps') return 'Total FPS';
|
||||||
|
if (key === 'capture_fps') return 'Total Capture FPS';
|
||||||
|
if (key === 'errors') return 'Errors';
|
||||||
return key.toUpperCase();
|
return key.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,12 +74,19 @@ export async function saveExternalUrl(): Promise<void> {
|
|||||||
const SETTINGS_ACTIVE_TAB_KEY = 'settings_active_tab';
|
const SETTINGS_ACTIVE_TAB_KEY = 'settings_active_tab';
|
||||||
|
|
||||||
export function switchSettingsTab(tabId: string): void {
|
export function switchSettingsTab(tabId: string): void {
|
||||||
|
let activeBtn: HTMLElement | null = null;
|
||||||
document.querySelectorAll('.settings-tab-btn').forEach(btn => {
|
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 => {
|
document.querySelectorAll('.settings-panel').forEach(panel => {
|
||||||
panel.classList.toggle('active', panel.id === `settings-panel-${tabId}`);
|
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.
|
// Remember so the next openSettingsModal() re-opens this tab.
|
||||||
try { localStorage.setItem(SETTINGS_ACTIVE_TAB_KEY, tabId); } catch { /* storage blocked */ }
|
try { localStorage.setItem(SETTINGS_ACTIVE_TAB_KEY, tabId); } catch { /* storage blocked */ }
|
||||||
// Lazy-render the appearance tab content
|
// Lazy-render the appearance tab content
|
||||||
|
|||||||
+3
@@ -87,6 +87,9 @@ interface Window {
|
|||||||
copyWsUrl: (...args: any[]) => any;
|
copyWsUrl: (...args: any[]) => any;
|
||||||
cloneDevice: (...args: any[]) => any;
|
cloneDevice: (...args: any[]) => any;
|
||||||
|
|
||||||
|
// ─── Mod-card menu ───
|
||||||
|
toggleCardHidden: (sectionKey: string, id: string) => void;
|
||||||
|
|
||||||
// ─── Dashboard ───
|
// ─── Dashboard ───
|
||||||
loadDashboard: (...args: any[]) => any;
|
loadDashboard: (...args: any[]) => any;
|
||||||
stopUptimeTimer: (...args: any[]) => any;
|
stopUptimeTimer: (...args: any[]) => any;
|
||||||
|
|||||||
@@ -300,6 +300,7 @@
|
|||||||
"device.url": "URL:",
|
"device.url": "URL:",
|
||||||
"device.url.placeholder": "http://192.168.1.100",
|
"device.url.placeholder": "http://192.168.1.100",
|
||||||
"device.led_count": "LED Count:",
|
"device.led_count": "LED Count:",
|
||||||
|
"dashboard.device.chip": "Chip:",
|
||||||
"device.led_count.hint": "Number of LEDs configured in the device",
|
"device.led_count.hint": "Number of LEDs configured in the device",
|
||||||
"device.led_count.hint.auto": "Auto-detected from device",
|
"device.led_count.hint.auto": "Auto-detected from device",
|
||||||
"device.button.add": "Add Device",
|
"device.button.add": "Add Device",
|
||||||
@@ -352,6 +353,7 @@
|
|||||||
"device.tutorial.start": "Start tutorial",
|
"device.tutorial.start": "Start tutorial",
|
||||||
"device.tip.metadata": "Device info (LED count, type, color channels) is auto-detected from the device",
|
"device.tip.metadata": "Device info (LED count, type, color channels) is auto-detected from the device",
|
||||||
"device.tip.brightness": "Slide to adjust device brightness",
|
"device.tip.brightness": "Slide to adjust device brightness",
|
||||||
|
"device.brightness": "Bright",
|
||||||
"device.tip.start": "Start or stop screen capture processing",
|
"device.tip.start": "Start or stop screen capture processing",
|
||||||
"device.tip.settings": "Configure general device settings (name, URL, health check)",
|
"device.tip.settings": "Configure general device settings (name, URL, health check)",
|
||||||
"device.tip.capture_settings": "Configure capture settings (display, capture template)",
|
"device.tip.capture_settings": "Configure capture settings (display, capture template)",
|
||||||
@@ -481,6 +483,9 @@
|
|||||||
"common.remove": "Remove",
|
"common.remove": "Remove",
|
||||||
"common.edit": "Edit",
|
"common.edit": "Edit",
|
||||||
"common.clone": "Clone",
|
"common.clone": "Clone",
|
||||||
|
"common.hide": "Hide card",
|
||||||
|
"common.more_actions": "More actions",
|
||||||
|
"common.card_color": "Card color",
|
||||||
"common.none": "None",
|
"common.none": "None",
|
||||||
"common.none_no_cspt": "None (no processing template)",
|
"common.none_no_cspt": "None (no processing template)",
|
||||||
"common.none_no_input": "None (no input source)",
|
"common.none_no_input": "None (no input source)",
|
||||||
@@ -797,7 +802,11 @@
|
|||||||
"dashboard.integrations.entities": "entities",
|
"dashboard.integrations.entities": "entities",
|
||||||
"dashboard.integrations.no_sources": "No integration sources configured",
|
"dashboard.integrations.no_sources": "No integration sources configured",
|
||||||
"dashboard.perf.active_patches": "Active Patches",
|
"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_fps": "Total FPS",
|
||||||
|
"dashboard.perf.total_capture_fps": "Total Capture FPS",
|
||||||
|
"dashboard.perf.errors": "Errors",
|
||||||
"dashboard.perf.devices": "Devices",
|
"dashboard.perf.devices": "Devices",
|
||||||
"dashboard.perf.cpu": "CPU",
|
"dashboard.perf.cpu": "CPU",
|
||||||
"dashboard.perf.ram": "RAM",
|
"dashboard.perf.ram": "RAM",
|
||||||
|
|||||||
@@ -304,6 +304,7 @@
|
|||||||
"device.url": "URL:",
|
"device.url": "URL:",
|
||||||
"device.url.placeholder": "http://192.168.1.100",
|
"device.url.placeholder": "http://192.168.1.100",
|
||||||
"device.led_count": "Количество Светодиодов:",
|
"device.led_count": "Количество Светодиодов:",
|
||||||
|
"dashboard.device.chip": "Чип:",
|
||||||
"device.led_count.hint": "Количество светодиодов, настроенных в устройстве",
|
"device.led_count.hint": "Количество светодиодов, настроенных в устройстве",
|
||||||
"device.led_count.hint.auto": "Автоматически определяется из устройства",
|
"device.led_count.hint.auto": "Автоматически определяется из устройства",
|
||||||
"device.button.add": "Добавить Устройство",
|
"device.button.add": "Добавить Устройство",
|
||||||
@@ -356,6 +357,7 @@
|
|||||||
"device.tutorial.start": "Начать обучение",
|
"device.tutorial.start": "Начать обучение",
|
||||||
"device.tip.metadata": "Информация об устройстве (кол-во LED, тип, цветовые каналы) определяется автоматически",
|
"device.tip.metadata": "Информация об устройстве (кол-во LED, тип, цветовые каналы) определяется автоматически",
|
||||||
"device.tip.brightness": "Перетащите для регулировки яркости",
|
"device.tip.brightness": "Перетащите для регулировки яркости",
|
||||||
|
"device.brightness": "Яркость",
|
||||||
"device.tip.start": "Запуск или остановка захвата экрана",
|
"device.tip.start": "Запуск или остановка захвата экрана",
|
||||||
"device.tip.settings": "Основные настройки устройства (имя, URL, интервал проверки)",
|
"device.tip.settings": "Основные настройки устройства (имя, URL, интервал проверки)",
|
||||||
"device.tip.capture_settings": "Настройки захвата (дисплей, шаблон захвата)",
|
"device.tip.capture_settings": "Настройки захвата (дисплей, шаблон захвата)",
|
||||||
@@ -485,6 +487,9 @@
|
|||||||
"common.remove": "Убрать",
|
"common.remove": "Убрать",
|
||||||
"common.edit": "Редактировать",
|
"common.edit": "Редактировать",
|
||||||
"common.clone": "Клонировать",
|
"common.clone": "Клонировать",
|
||||||
|
"common.hide": "Скрыть карточку",
|
||||||
|
"common.more_actions": "Ещё действия",
|
||||||
|
"common.card_color": "Цвет карточки",
|
||||||
"common.none": "Нет",
|
"common.none": "Нет",
|
||||||
"common.none_no_cspt": "Нет (без шаблона обработки)",
|
"common.none_no_cspt": "Нет (без шаблона обработки)",
|
||||||
"common.none_no_input": "Нет (без источника)",
|
"common.none_no_input": "Нет (без источника)",
|
||||||
@@ -778,7 +783,11 @@
|
|||||||
"dashboard.targets": "Цели",
|
"dashboard.targets": "Цели",
|
||||||
"dashboard.section.performance": "Производительность системы",
|
"dashboard.section.performance": "Производительность системы",
|
||||||
"dashboard.perf.active_patches": "Активные каналы",
|
"dashboard.perf.active_patches": "Активные каналы",
|
||||||
|
"dashboard.perf.patches.empty.idle": "Готов к запуску",
|
||||||
|
"dashboard.perf.patches.empty.none": "Каналов пока нет",
|
||||||
"dashboard.perf.total_fps": "Общий FPS",
|
"dashboard.perf.total_fps": "Общий FPS",
|
||||||
|
"dashboard.perf.total_capture_fps": "Общий FPS захвата",
|
||||||
|
"dashboard.perf.errors": "Ошибки",
|
||||||
"dashboard.perf.devices": "Устройства",
|
"dashboard.perf.devices": "Устройства",
|
||||||
"dashboard.perf.cpu": "ЦП",
|
"dashboard.perf.cpu": "ЦП",
|
||||||
"dashboard.perf.ram": "ОЗУ",
|
"dashboard.perf.ram": "ОЗУ",
|
||||||
|
|||||||
@@ -304,6 +304,7 @@
|
|||||||
"device.url": "地址:",
|
"device.url": "地址:",
|
||||||
"device.url.placeholder": "http://192.168.1.100",
|
"device.url.placeholder": "http://192.168.1.100",
|
||||||
"device.led_count": "LED 数量:",
|
"device.led_count": "LED 数量:",
|
||||||
|
"dashboard.device.chip": "芯片:",
|
||||||
"device.led_count.hint": "设备中配置的 LED 数量",
|
"device.led_count.hint": "设备中配置的 LED 数量",
|
||||||
"device.led_count.hint.auto": "从设备自动检测",
|
"device.led_count.hint.auto": "从设备自动检测",
|
||||||
"device.button.add": "添加设备",
|
"device.button.add": "添加设备",
|
||||||
@@ -356,6 +357,7 @@
|
|||||||
"device.tutorial.start": "开始教程",
|
"device.tutorial.start": "开始教程",
|
||||||
"device.tip.metadata": "设备信息(LED 数量、类型、颜色通道)从设备自动检测",
|
"device.tip.metadata": "设备信息(LED 数量、类型、颜色通道)从设备自动检测",
|
||||||
"device.tip.brightness": "滑动调节设备亮度",
|
"device.tip.brightness": "滑动调节设备亮度",
|
||||||
|
"device.brightness": "亮度",
|
||||||
"device.tip.start": "启动或停止屏幕采集处理",
|
"device.tip.start": "启动或停止屏幕采集处理",
|
||||||
"device.tip.settings": "配置设备常规设置(名称、地址、健康检查)",
|
"device.tip.settings": "配置设备常规设置(名称、地址、健康检查)",
|
||||||
"device.tip.capture_settings": "配置采集设置(显示器、采集模板)",
|
"device.tip.capture_settings": "配置采集设置(显示器、采集模板)",
|
||||||
@@ -485,6 +487,9 @@
|
|||||||
"common.remove": "移除",
|
"common.remove": "移除",
|
||||||
"common.edit": "编辑",
|
"common.edit": "编辑",
|
||||||
"common.clone": "克隆",
|
"common.clone": "克隆",
|
||||||
|
"common.hide": "隐藏卡片",
|
||||||
|
"common.more_actions": "更多操作",
|
||||||
|
"common.card_color": "卡片颜色",
|
||||||
"common.none": "无",
|
"common.none": "无",
|
||||||
"common.none_no_cspt": "无(无处理模板)",
|
"common.none_no_cspt": "无(无处理模板)",
|
||||||
"common.none_no_input": "无(无输入源)",
|
"common.none_no_input": "无(无输入源)",
|
||||||
@@ -778,7 +783,11 @@
|
|||||||
"dashboard.targets": "目标",
|
"dashboard.targets": "目标",
|
||||||
"dashboard.section.performance": "系统性能",
|
"dashboard.section.performance": "系统性能",
|
||||||
"dashboard.perf.active_patches": "活动通道",
|
"dashboard.perf.active_patches": "活动通道",
|
||||||
|
"dashboard.perf.patches.empty.idle": "准备就绪",
|
||||||
|
"dashboard.perf.patches.empty.none": "暂无通道",
|
||||||
"dashboard.perf.total_fps": "总帧率",
|
"dashboard.perf.total_fps": "总帧率",
|
||||||
|
"dashboard.perf.total_capture_fps": "总采集帧率",
|
||||||
|
"dashboard.perf.errors": "错误",
|
||||||
"dashboard.perf.devices": "设备",
|
"dashboard.perf.devices": "设备",
|
||||||
"dashboard.perf.cpu": "CPU",
|
"dashboard.perf.cpu": "CPU",
|
||||||
"dashboard.perf.ram": "内存",
|
"dashboard.perf.ram": "内存",
|
||||||
|
|||||||
@@ -6,14 +6,28 @@
|
|||||||
<button class="modal-close-btn" onclick="closeSettingsModal()" title="Close" data-i18n-aria-label="aria.close">✕</button>
|
<button class="modal-close-btn" onclick="closeSettingsModal()" title="Close" data-i18n-aria-label="aria.close">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tab bar -->
|
<!-- Tab bar — icon-only so labels never overflow at any locale.
|
||||||
<div class="settings-tab-bar">
|
The translated label remains available as title (hover tooltip)
|
||||||
<button class="settings-tab-btn active" data-settings-tab="general" onclick="switchSettingsTab('general')" data-i18n="settings.tab.general">General</button>
|
and aria-label (screen readers). -->
|
||||||
<button class="settings-tab-btn" data-settings-tab="backup" onclick="switchSettingsTab('backup')" data-i18n="settings.tab.backup">Backup</button>
|
<div class="settings-tab-bar" role="tablist">
|
||||||
<button class="settings-tab-btn" data-settings-tab="notifications" onclick="switchSettingsTab('notifications')" data-i18n="settings.tab.notifications">Notifications</button>
|
<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">
|
||||||
<button class="settings-tab-btn" data-settings-tab="appearance" onclick="switchSettingsTab('appearance')" data-i18n="settings.tab.appearance">Appearance</button>
|
<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 class="settings-tab-btn" data-settings-tab="updates" onclick="switchSettingsTab('updates')" data-i18n="settings.tab.updates">Updates</button>
|
</button>
|
||||||
<button class="settings-tab-btn" data-settings-tab="about" onclick="switchSettingsTab('about')" data-i18n="settings.tab.about">About</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>
|
||||||
|
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
|
|||||||
Reference in New Issue
Block a user