diff --git a/docs/cards-redesign-demo-v2.html b/docs/cards-redesign-demo-v2.html new file mode 100644 index 0000000..59bbe58 --- /dev/null +++ b/docs/cards-redesign-demo-v2.html @@ -0,0 +1,1420 @@ + + + + + +LedGrab · Entity Card · v2 (dashboard convergence) + + + + + + +
+ +
+
+
PROPOSAL · v2 · Convergence
+

One card system,
the dashboard's vocabulary.

+

v2 drops the parallel "patchbay rail" I sketched in v1 and adopts the existing .mod-head / .mod-leds / .mod-metrics / .mod-patch / .mod-btn classes from dashboard.css verbatim. Same instrument-style numerics, same recessed LED clusters, same patch indicator. Entity cards inherit dashboard polish; nothing new gets invented.

+
+
+ + +
+
+ +
01 Output zone  CH · SIGNAL
+ +
+ + +
+ +
+ + + +
+
+
+ LED · CH-01 +
Living Room Strip
+
192.168.1.42 · WLED v0.14 · RGB
+
+ +
+
+
+
FPS
+
59.7
+
+
+
PIXELS
+
144
+
+
+
LAT
+
8ms
+
+
+
+ Bright +
+ 198 +
+
+
PATCHED · OUT-1
+ + +
+
+ + +
+ +
+ + +
+
+
+ LED · CH-02 +
Bedroom Halo
+
10.0.4.18 · Adalight 921k
+
+ +
+
+
+
FPS
+
— —
+
+
+
PIXELS
+
60
+
+
+
ERR
+
14
+
+
+
+ Connection refused · 2h 14m ago +
+
+
OFFLINE
+ + +
+
+ + +
+ +
+ + +
+
+
+ HA · LIGHT +
Hue Bedside Lamp
+
light.bedside_lamp · color_temp 2700K
+
+ +
+
+ Source · Cinematic + Brightness · Outdoor temp +
+
+
STANDBY
+ +
+
+ +
+ +
02 Input zone  CH · CYAN CH · MAGENTA
+ +
+ + +
+ +
+ + +
+
+
+ SCREEN · IN +
Cinematic Capture
+
Display 2 · MSS · BGRA
+
+ +
+
+
+
REGION
+
3840×1080
+
+
+
TARGET
+
60fps
+
+
+
+ Pre-process · Cinematic CSPT + Letterbox crop +
+
+
STANDBY
+ + +
+
+ + +
+ +
+ + +
+
+
+ FFT · IN +
Spotify Loopback
+
WASAPI · 48 kHz · stereo
+
+ +
+
+
+
PEAK
+
-6.2dB
+
+
+
BANDS
+
32
+
+
+
CPU
+
3.1%
+
+
+
+ bass + mids + highs +
+
+
STREAMING
+ + +
+
+ + +
+ +
+ + +
+
+
+ VALUE · HA +
Outdoor temp
+
sensor.outdoor_temp · linear · clamped
+
+ +
+
+
+
NOW
+
14.7°
+
+
+
RANGE
+
8 — 22
+
+
+
TICK
+
2s
+
+
+
+ Bound to · brightness +
+
+
POLLING
+ +
+
+ +
+ +
03 Logic zone  CH · VIOLET CH · AMBER
+ +
+ + +
+ +
+ + +
+
+
+ AUTO · 07 +
Movie Night
+
Plex playing · 21:00 — 23:30
+
+ +
+
+ 21:00 — 23:30 + + + Plex playing + + Cinema scene +
+
+
ARMED
+ + +
+
+ + +
+ +
+ + +
+
+
+ SCN · 04 +
Sunset Warmth
+
4 targets · captured 21 Apr · used by 2 automations
+
+ +
+
Warm tungsten cast on every fixture for a 19:00 unwind. Recapture re-snapshots all targets in their current colors.
+
+
PRESET
+ + +
+
+ + +
+ +
+ + +
+
+
+ CLK · MASTER +
Master Tempo
+
Square · 1/16 sub · drift ±0.3ms
+
+ +
+
+
+
BPM
+
110.0
+
+
+
PHASE
+
0.42
+
+
+
SUB
+
1/16
+
+
+
+
TICKING
+ + +
+
+ + +
+ +
+ + +
+
+
+ GAME · CSGO +
Counter-Strike 2
+
GSI · port 3456 · 12 mapped events
+
+ +
+
+ flash + defuse + round-end + +9 more +
+
+
LISTENING · 12s ago
+ + +
+
+ +
+ +
04 Templates & assets
+ +
+ + +
+ +
+ + +
+
+
+ TPL · CAPTURE +
Desktop · 60 fps · region
+
MSS · 3 keys configured · used by 5 sources
+
+ +
+
+ crop + downsample + gamma + color-correct +
+
+
TEMPLATE
+ + + +
+
+ + +
+ +
+ + +
+
+
+ STRIP · MAPPED +
Cinema map · 144 px
+
Letterbox · smooth 0.35
+
+ +
+ +
+
+ Display 2 + CSPT · 4 filters +
+
+
MAPPING
+ + +
+
+ + +
+ +
+ +
+
+
+ PALETTE · G-08 +
Aurora BUILTIN
+
5 stops · HSL space · loop
+
+ +
+
+ 5 STOPS · LOOP +
+
+ Used in 3 strips +
+
+
PRESET
+ +
+
+ + +
+ +
+ + +
+
+
+ ASSET · IMG +
cosmic-loop-001.png
+
PNG · 1920×1080 · 412 KB
+
+ +
+
+
+
READY
+ + +
+
+ +
+ +
+

How v2 maps to the dashboard

+ + +

v1 → v2 specifics

+ + +

Implementation footprint

+ +
+ +
+ + + + diff --git a/docs/cards-redesign-demo.html b/docs/cards-redesign-demo.html new file mode 100644 index 0000000..69080ee --- /dev/null +++ b/docs/cards-redesign-demo.html @@ -0,0 +1,1117 @@ + + + + + +LedGrab · Entity Card · Patchbay redesign + + + + + + +
+ +
+
+
PROPOSAL · v1 · Patchbay
+

Entity card,
treated as hardware.

+

Every entity is a module in a virtual rack. The channel system you already built — dormant on most cards today — becomes the primary identity signal: type, status, and "live" state read at a glance.

+
+
+ + +
+
+ +
01 Output targets · LED devices
+ +
+ + +
+ +
+
+
LED · OUT // CH-01
+
+
+
+
+ + Living Room Strip + 192.168.1.42 +
+
+
+
Pixels
+
144
+
+
+
FPS
+
59.7
+
+
+
Lat
+
8ms
+
+
+
+ WLED · v0.14 + RGB + Display 2 +
+
+ Bright +
+ 198 +
+
+ +
+ + +
+ +
+
+
LED · OUT // CH-02
+
+
+
+
+ + Bedroom Halo + 10.0.4.18 +
+
+
+
Pixels
+
60
+
+
+
FPS
+
— —
+
+
+
Last
+
2h 14m
+
+
+
+ Connection refused + Adalight · 921k +
+
+ +
+ + +
+ +
+
+
FFT · IN // 48 kHz
+
+
+
+
+ + Spotify Loopback +
+
+
+
Bands
+
32
+
+
+
Peak
+
-6.2 dB
+
+
+
CPU
+
3.1%
+
+
+
+ WASAPI · stereo + Bass · Mids · Highs +
+
+ +
+ +
+ +
02 Sources & integrations
+ +
+ + +
+ +
+
+
SCREEN · IN // DISP-2
+
+
+
+
+ + Cinematic Capture +
+
+
+
Region
+
3840×1080
+
+
+
Tgt
+
60fps
+
+
+
+ Mss · BGRA + Pre-process · CSPT + Letterbox crop +
+
+ +
+ + +
+ +
+
+
SCENE · LOGIC // AUTO-07
+
+
+
+
+ + Movie Night +
+
+ 21:00 — 23:30 + When · Plex playing + → Living + Bedroom + cinema +
+
+ +
+ + +
+ +
+
+
SCENE · PRESET // SCN-04
+
+
+
+
+ + Sunset Warmth +
+

Captured 3 days ago — warm tungsten cast on every fixture for a 19:00 unwind.

+
+ 4 targets + Used by 2 automations + Updated 21 Apr +
+
+ +
+ + +
+ +
+
+
CLK · GEN // 110 BPM
+
+
+
+
+ + Master Tempo +
+
+
+
BPM
+
110.00
+
+
+
Phase
+
0.42
+
+
+
Sub
+
1/16
+
+
+
+ Drift · ±0.3ms +
+
+ +
+
+ +
03 Templates & assets
+ +
+ + +
+ +
+
+
TPL · CAPTURE // MSS
+
+
+
+
+ + Desktop · 60 fps · region +
+

Reusable capture preset for screen sources. 3 engine options pre-configured.

+
+
+
Engine
+
MSS
+
+
+
Bind
+
3 keys
+
+
+
Used by
+
5 sources
+
+
+ +
+ crop + + downsample + + gamma + + color-correct +
+
+ +
+ + +
+ +
+
+
PALETTE // G-08
+
+
+
+
+ + Aurora + BUILTIN +
+ +
+ 5 STOPS · LOOP +
+
+ Used in 3 strips + HSL space +
+
+ +
+ + +
+ +
+
+
ASSET · IMG // PNG
+
+
+
+
+ + cosmic-loop-001.png +
+ +
+
+
+
Size
+
1920×1080
+
+
+
Bytes
+
412 KB
+
+
+
+ +
+ + +
+ +
+
+
STRIP · MAPPED // 144 PX
+
+
+
+
+ + Cinema map · 144 px +
+ +
+
+
+
Source
+
Cinematic
+
+
+
Map
+
Letterbox
+
+
+
Smooth
+
0.35
+
+
+
+ Display 2 + post-fx · 4 filters +
+
+ +
+ + +
+ +
+
+
GAME · CSGO // GSI
+
+
+
+
+ + Counter-Strike 2 +
+
+
+
Port
+
3456
+
+
+
Events
+
12 mapped
+
+
+
Last
+
12s
+
+
+
+ flash + defuse + round-end + +9 more +
+
+ +
+ + +
+ +
+
+
VALUE · HA // SENSOR
+
+
+
+
+ + Outdoor temp + sensor.outdoor_temp +
+
+
+
Now
+
14.7°
+
+
+
Range
+
8 — 22
+
+
+
Tick
+
2s
+
+
+
+ Bound to: brightness + Linear · clamped +
+
+ +
+ +
+ +
+

Coverage matrix — every card type

+ + +

What changes — and why

+ +
+ +
+ + + + diff --git a/docs/plans/device-typed-configs.md b/docs/plans/device-typed-configs.md deleted file mode 100644 index adf531c..0000000 --- a/docs/plans/device-typed-configs.md +++ /dev/null @@ -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 ``** — 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 && ` 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. diff --git a/server/src/ledgrab/api/schemas/output_targets.py b/server/src/ledgrab/api/schemas/output_targets.py index 3feb7a7..fd88a75 100644 --- a/server/src/ledgrab/api/schemas/output_targets.py +++ b/server/src/ledgrab/api/schemas/output_targets.py @@ -280,6 +280,9 @@ class TargetProcessingState(BaseModel): None, description="Potential FPS (processing speed without throttle)" ) fps_target: Optional[int] = Field(None, description="Target FPS") + fps_capture: Optional[int] = Field( + None, description="Configured capture-side FPS for the underlying color strip stream" + ) frames_skipped: Optional[int] = Field(None, description="Frames skipped (no screen change)") frames_keepalive: Optional[int] = Field( None, description="Keepalive frames sent during standby" diff --git a/server/src/ledgrab/core/processing/ha_light_target_processor.py b/server/src/ledgrab/core/processing/ha_light_target_processor.py index 8b78a22..576ea47 100644 --- a/server/src/ledgrab/core/processing/ha_light_target_processor.py +++ b/server/src/ledgrab/core/processing/ha_light_target_processor.py @@ -224,6 +224,7 @@ class HALightTargetProcessor(TargetProcessor): "update_rate": self._update_rate, "fps_actual": self._update_rate if self._is_running else None, "fps_target": self._update_rate, + "fps_capture": self._update_rate if self._is_running else None, "uptime_seconds": uptime, "entity_colors": entity_colors, } diff --git a/server/src/ledgrab/core/processing/wled_target_processor.py b/server/src/ledgrab/core/processing/wled_target_processor.py index 6bae6ea..2711334 100644 --- a/server/src/ledgrab/core/processing/wled_target_processor.py +++ b/server/src/ledgrab/core/processing/wled_target_processor.py @@ -399,8 +399,10 @@ class WledTargetProcessor(TargetProcessor): fps_target = self._target_fps css_timing: dict = {} + css_capture_fps: Optional[int] = None if self._is_running and self._css_stream is not None: css_timing = self._css_stream.get_last_timing() + css_capture_fps = getattr(self._css_stream, "target_fps", None) send_ms = round(metrics.timing_send_ms, 1) if self._is_running else None # Picture source timing @@ -444,6 +446,7 @@ class WledTargetProcessor(TargetProcessor): "fps_actual": metrics.fps_actual if self._is_running else None, "fps_potential": metrics.fps_potential if self._is_running else None, "fps_target": fps_target, + "fps_capture": css_capture_fps, "frames_skipped": metrics.frames_skipped if self._is_running else None, "frames_keepalive": metrics.frames_keepalive if self._is_running else None, "fps_current": metrics.fps_current if self._is_running else None, diff --git a/server/src/ledgrab/static/css/cards.css b/server/src/ledgrab/static/css/cards.css index 6f296f3..843e644 100644 --- a/server/src/ledgrab/static/css/cards.css +++ b/server/src/ledgrab/static/css/cards.css @@ -147,13 +147,15 @@ section { overflow: hidden; } -/* Channel stripe on left edge — opt-in only: +/* Channel stripe on left edge — always on at .6 opacity for cards + * that adopt the modular markup (.mod-card), opt-in for legacy cards. * [data-has-color="1"] → user picked a personal color via the picker * .card-running → "patched and live" indicator - * Idle cards without a personal color stay clean (no stripe), matching - * the pre-redesign behavior where the left border meant "I marked this". - * The dashboard module rows keep their always-on stripe (at 0.6 opacity) - * because the dashboard was approved as-is. */ + * .mod-card → ambient channel signal (matches dashboard) + * Legacy cards without a personal color stay clean to avoid breaking + * the visual rhythm of feature tabs that haven't migrated yet. Once + * every create*Card() builder uses wrapCard({ mod }), the .mod-card + * scope can be dropped and the stripe becomes ambient on every card. */ .card::before { content: ''; position: absolute; @@ -164,13 +166,20 @@ section { pointer-events: none; z-index: 1; display: none; + opacity: 0.6; + transition: opacity 0.2s ease, box-shadow 0.2s ease, width 0.2s ease; } .card[data-has-color="1"]::before, -.card.card-running::before { +.card.card-running::before, +.card.mod-card::before { display: block; } +.card.mod-card:hover::before { + opacity: 1; +} + /* Corner bracket — silkscreened panel feel in the top-right */ .card::after { content: ''; @@ -1112,6 +1121,14 @@ body.cs-drag-active .card-drag-handle { pointer-events: none; } +/* Mod-card brightness fader — loading + disabled states */ +.card.mod-card.brightness-loading .mod-fader, +.template-card.mod-card.brightness-loading .mod-fader, +.mod-fader:has(.mod-fader__slider:disabled) { + opacity: 0.4; + pointer-events: none; +} + /* Static color picker — inline in card-subtitle */ .section-header { display: flex; @@ -1533,81 +1550,258 @@ ul.section-tip li { display: block; } -/* Selected card highlight */ +/* Selected card highlight — accent ring, soft outer glow, and a subtle + lift so the chosen card pops above the dimmed siblings. */ .cs-selecting .card-selected, .cs-selecting .card-selected.template-card { border-color: var(--primary-color); - box-shadow: 0 0 0 1px var(--primary-color), 0 4px 12px color-mix(in srgb, var(--primary-color) 15%, transparent); + box-shadow: + 0 0 0 1px var(--primary-color), + 0 0 24px color-mix(in srgb, var(--primary-color) 22%, transparent), + 0 8px 28px rgba(0, 0, 0, 0.35); + transform: translateY(-1px); } -/* Make cards visually clickable in selection mode */ +/* Non-selected siblings during selection: desaturate, dim and softly blur + so the eye locks onto the active picks. Hover restores full clarity to + keep the card affordance obvious. */ +.cs-selecting .card:not(.card-selected), +.cs-selecting .template-card:not(.card-selected) { + opacity: 0.55; + filter: saturate(0.55) blur(0.4px); + transition: + opacity 0.2s ease, + filter 0.25s ease, + transform 0.2s ease, + border-color 0.2s ease, + box-shadow 0.2s ease; +} + +.cs-selecting .card:not(.card-selected):hover, +.cs-selecting .template-card:not(.card-selected):hover { + opacity: 0.92; + filter: none; +} + +@media (prefers-reduced-motion: reduce) { + .cs-selecting .card:not(.card-selected), + .cs-selecting .template-card:not(.card-selected) { + filter: saturate(0.55); + } +} + +/* Make cards visually clickable in selection mode — card root is the + click target, descendants are inert. */ .cs-selecting .card, .cs-selecting .template-card { cursor: pointer; } +/* All card descendants pass clicks through to the card root in + selection mode. This disables buttons, sliders, kebab, color dot, + and any other interactive child without needing per-element rules. */ +.cs-selecting .card *, +.cs-selecting .template-card * { + pointer-events: none; +} + /* Suppress hover lift during selection */ .cs-selecting .card:hover, .cs-selecting .template-card:hover { transform: none; } +/* Corner checkmark on selected cards — replaces the per-card checkbox + that used to be injected at the top of the card body. Sits in the + top-right corner where the kebab is otherwise visible (kebab is + pointer-events:none in this mode, so the indicator can overlay it). */ +.cs-selecting .card-selected::before, +.cs-selecting .template-card.card-selected::before { + /* override the channel stripe pulse by restoring full opacity */ + opacity: 1; +} +.cs-selecting .card-selected, +.cs-selecting .template-card.card-selected { + position: relative; +} +.cs-selecting .card-selected > .mod-bulk-tick, +.cs-selecting .template-card.card-selected > .mod-bulk-tick { + display: flex; +} +/* The tick itself — injected once per card via JS so it picks up the + .card-selected toggle naturally. Hidden until the card is selected. */ +.mod-bulk-tick { + display: none; + position: absolute; + top: 8px; + right: 8px; + width: 22px; + height: 22px; + align-items: center; + justify-content: center; + background: var(--primary-color); + color: var(--primary-contrast, #fff); + border-radius: 50%; + box-shadow: + 0 0 0 2px var(--card-bg, var(--lux-bg-1)), + 0 0 14px color-mix(in srgb, var(--primary-color) 50%, transparent); + z-index: 3; + pointer-events: none; +} + +.mod-bulk-tick svg { + display: block; + width: 13px; + height: 13px; + stroke-width: 2.6; +} + +.cs-selecting .card-selected > .mod-bulk-tick, +.cs-selecting .template-card.card-selected > .mod-bulk-tick { + animation: bulkTickPop 0.22s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +@keyframes bulkTickPop { + 0% { transform: scale(0.4); opacity: 0; } + 60% { transform: scale(1.12); opacity: 1; } + 100% { transform: scale(1); opacity: 1; } +} + +@media (prefers-reduced-motion: reduce) { + .cs-selecting .card-selected > .mod-bulk-tick, + .cs-selecting .template-card.card-selected > .mod-bulk-tick { + animation: none; + } +} + /* ── Bulk toolbar ──────────────────────────────────────────── */ #bulk-toolbar { + --bulk-ch: var(--primary-color); position: fixed; - bottom: 20px; + bottom: 24px; left: 50%; - transform: translateX(-50%) translateY(calc(100% + 30px)); - background: var(--card-bg); - border: 1px solid var(--border-color); - border-radius: var(--radius-md, 8px); - padding: 8px 16px; + transform: translateX(-50%) translateY(calc(100% + 40px)); + background: linear-gradient(180deg, + var(--lux-bg-1, var(--card-bg)) 0%, + var(--lux-bg-2, var(--card-bg)) 100%); + border: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color)); + border-radius: var(--lux-r-lg, var(--radius-md, 10px)); + padding: 8px 10px 8px 12px; display: flex; align-items: center; - gap: 12px; + gap: 10px; z-index: var(--z-bulk-toolbar); - box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.3); - transition: transform 0.25s ease; + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.02), + 0 12px 36px rgba(0, 0, 0, 0.5), + 0 4px 16px var(--shadow-color, rgba(0, 0, 0, 0.4)); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + transition: transform 0.28s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease; white-space: nowrap; + opacity: 0; + pointer-events: none; } #bulk-toolbar.visible { transform: translateX(-50%) translateY(0); + opacity: 1; + pointer-events: auto; } -.bulk-select-all-wrap { +/* Top accent stripe — same channel-glow language as modals */ +#bulk-toolbar::before { + content: ''; + position: absolute; + left: 0; right: 0; top: 0; + height: 1.5px; + background: linear-gradient(90deg, + transparent 0%, + var(--bulk-ch) 20%, + var(--bulk-ch) 80%, + transparent 100%); + box-shadow: 0 0 12px color-mix(in srgb, var(--bulk-ch) 55%, transparent); + border-radius: inherit; + pointer-events: none; +} + +/* Pick group (Select all / Deselect all) */ +.bulk-pick { display: flex; align-items: center; - cursor: pointer; + gap: 2px; + padding: 2px; + background: var(--lux-bg-0, var(--bg-color)); + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + border-radius: var(--lux-r-sm, 4px); } -.bulk-select-all-cb { +.bulk-pick-btn { + width: 28px; + height: 28px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + color: var(--lux-ink-mute, var(--text-secondary)); + border-radius: var(--lux-r-sm, 3px); + cursor: pointer; + transition: color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease; +} + +.bulk-pick-btn .icon { width: 16px; height: 16px; - margin: 0; - accent-color: var(--primary-color); - cursor: pointer; +} + +.bulk-pick-btn:hover:not(:disabled) { + color: var(--bulk-ch); + background: color-mix(in srgb, var(--bulk-ch) 14%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--bulk-ch) 35%, transparent), + 0 0 12px color-mix(in srgb, var(--bulk-ch) 30%, transparent); +} + +.bulk-pick-btn:disabled, +.bulk-pick-btn[aria-disabled="true"] { + opacity: 0.35; + cursor: not-allowed; } .bulk-count { - font-size: 0.85rem; - color: var(--text-secondary); - min-width: 80px; + font-family: var(--font-mono, monospace); + font-size: 0.78rem; + letter-spacing: 0.08em; + color: var(--lux-ink, var(--text-color)); + min-width: 90px; + padding: 0 4px; + font-variant-numeric: tabular-nums; +} + +.bulk-count-total { + color: var(--lux-ink-mute, var(--text-secondary)); + font-size: 0.95em; + margin-left: 2px; } .bulk-actions { display: flex; gap: 4px; + padding-left: 8px; + margin-left: 4px; + border-left: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); } .bulk-action-btn { width: 32px; height: 32px; padding: 0; - display: flex; + display: inline-flex; align-items: center; justify-content: center; + transition: box-shadow 0.18s ease, transform 0.15s ease; } .bulk-action-btn .icon { @@ -1615,18 +1809,486 @@ ul.section-tip li { height: 16px; } +.bulk-action-btn:hover:not(:disabled) { + box-shadow: 0 0 16px color-mix(in srgb, var(--primary-color) 30%, transparent); + transform: translateY(-1px); +} + +.bulk-action-btn.btn-danger:hover:not(:disabled) { + box-shadow: 0 0 16px color-mix(in srgb, var(--danger-color, #ef4444) 40%, transparent); +} + +.bulk-action-btn:disabled, +.bulk-action-btn[aria-disabled="true"] { + opacity: 0.4; + cursor: not-allowed; +} + .bulk-close { - background: none; - border: none; - color: var(--text-muted); - font-size: 1rem; + width: 30px; + height: 30px; + background: transparent; + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + color: var(--lux-ink-mute, var(--text-muted)); cursor: pointer; - padding: 4px; - border-radius: 4px; - transition: color 0.2s; - line-height: 1; + padding: 0; + border-radius: var(--lux-r-sm, 4px); + display: inline-flex; + align-items: center; + justify-content: center; + margin-left: 2px; + transition: color 0.18s ease, border-color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease; +} + +.bulk-close .icon { + width: 14px; + height: 14px; } .bulk-close:hover { - color: var(--text-color); + color: var(--lux-ink, var(--text-color)); + border-color: color-mix(in srgb, var(--bulk-ch) 50%, var(--lux-line, var(--border-color))); + background: color-mix(in srgb, var(--bulk-ch) 8%, transparent); +} + +@media (prefers-reduced-motion: reduce) { + #bulk-toolbar { + transition: opacity 0.15s ease; + } + .bulk-action-btn:hover:not(:disabled) { + transform: none; + } +} + +/* ════════════════════════════════════════════════════════════════════ + Mod-cards — entity cards that adopt the dashboard's `.mod-*` markup + ════════════════════════════════════════════════════════════════════ + The `.mod-*` child selectors (mod-head, mod-leds, mod-metrics, + mod-foot, mod-patch, mod-btn, etc.) live in dashboard.css and apply + regardless of host class. The rules below adapt the .card / + .template-card hosts to layout the modular children correctly (gap, + padding, suppress legacy decorations) and add the kebab menu styles + that are unique to entity cards. + ════════════════════════════════════════════════════════════════════ */ + +.card.mod-card, +.template-card.mod-card { + /* Vertical flex stack matches .dashboard-target:has(.mod-head) */ + display: flex; + flex-direction: column; + gap: 14px; + padding: 16px 18px 14px 22px; + align-items: stretch; +} + +/* Foot pinned to the card bottom — entity cards have variable body + content (chips/metrics/fader may all be absent), so without this the + foot floats in the middle when the grid forces a tall card. */ +.card.mod-card .mod-foot, +.template-card.mod-card .mod-foot { + margin-top: auto; +} + +/* Suppress legacy decorations when modular children are present */ +.card.mod-card .card-header, +.card.mod-card .card-actions, +.card.mod-card .card-top-actions, +.template-card.mod-card .template-card-header, +.template-card.mod-card .template-card-actions, +.template-card.mod-card .card-top-actions { display: none; } + +/* Idle silkscreen corner bracket — drop on mod-cards with a kebab. + The kebab visually replaces the bracket as the top-right anchor. */ +.card.mod-card:has(.mod-menu-wrap):not(.is-running)::after, +.template-card.mod-card:has(.mod-menu-wrap):not(.is-running)::after { + display: none; +} + +/* Running state on mod-cards — same intensified stripe + bottom signal- + flow used by .dashboard-target.is-running. Two class names keep + markup interchangeable: callers can use `is-running` (dashboard + convention) or `card-running` (legacy entity-card convention). */ +.card.mod-card.is-running, +.card.mod-card.card-running, +.template-card.mod-card.is-running, +.template-card.mod-card.card-running { + border-color: color-mix(in srgb, var(--ch) 32%, var(--lux-line, var(--border-color))); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--ch) 18%, transparent), + 0 6px 20px rgba(0, 0, 0, 0.25); +} + +.card.mod-card.is-running::before, +.card.mod-card.card-running::before, +.template-card.mod-card.is-running::before, +.template-card.mod-card.card-running::before { + opacity: 1; + width: 4px; + box-shadow: 0 0 14px color-mix(in srgb, var(--ch) 70%, transparent), + 0 0 4px color-mix(in srgb, var(--ch) 90%, transparent); +} + +/* Bottom signal-flow strip (running indicator) */ +.card.mod-card.is-running::after, +.card.mod-card.card-running::after, +.template-card.mod-card.is-running::after, +.template-card.mod-card.card-running::after { + content: ''; + position: absolute; + top: auto; right: auto; + left: 4px; bottom: 0; + width: calc(100% - 4px); height: 2px; + border: none; + opacity: 0.7; + background: linear-gradient(90deg, + transparent 0%, + color-mix(in srgb, var(--ch) 85%, transparent) 50%, + transparent 100%); + background-size: 30% 100%; + background-repeat: no-repeat; + animation: signalFlow 2.4s linear infinite; +} + +@media (prefers-reduced-motion: reduce) { + .card.mod-card.is-running::after, + .card.mod-card.card-running::after, + .template-card.mod-card.is-running::after, + .template-card.mod-card.card-running::after { + animation: none; + background-position: 50% 0; + background-size: 60% 100%; + } +} + +/* ── Color picker dot inside .mod-badge ─────────────────────────── */ + +.mod-badge { padding-left: 4px; } + +.mod-badge__color-wrap { + display: inline-flex; + align-items: center; + margin-right: 6px; + line-height: 0; + position: relative; +} + +.mod-badge__color { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; + background: var(--ch); + border: var(--lux-hairline, 1px) solid color-mix(in srgb, var(--ch) 60%, var(--lux-line-bold, var(--border-color))); + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease; + box-shadow: 0 0 0 0 color-mix(in srgb, var(--ch) 40%, transparent); +} +.mod-badge__color:hover, +.mod-badge__color:focus-visible { + transform: scale(1.25); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--ch) 25%, transparent); + outline: none; +} +/* User picked a personal hue — fill with --user-color but keep the + channel-tinted ring */ +.mod-badge__color[data-custom] { + background: var(--user-color); +} + +/* ── Overflow menu (kebab) ──────────────────────────────────────── */ + +.mod-menu-wrap { + position: relative; + flex-shrink: 0; + align-self: flex-start; + /* Match the .mod-leds bezel height (~22px) so the kebab and the LED + cluster sit on the same visual baseline at the top of the head row. + Without this, the 26px kebab dropped 4px below the 22px bezel and + read as misaligned. */ + display: inline-flex; + align-items: center; + height: 22px; +} + +.mod-menu-btn { + appearance: none; + background: transparent; + border: none; + width: 22px; height: 22px; + border-radius: 3px; + color: var(--lux-ink-mute, var(--text-secondary)); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: color 0.15s, background 0.15s, opacity 0.15s; + opacity: 0.45; + padding: 0; +} +.card.mod-card:hover .mod-menu-btn, +.template-card.mod-card:hover .mod-menu-btn, +.dashboard-target:hover .mod-menu-btn, +.mod-menu-wrap.is-open .mod-menu-btn { + opacity: 1; +} +.mod-menu-btn:hover, +.mod-menu-wrap.is-open .mod-menu-btn { + color: var(--lux-ink, var(--text-color)); + background: var(--lux-bg-3, var(--border-color)); +} +.mod-menu-btn:focus-visible { + outline: none; + box-shadow: 0 0 0 2px color-mix(in srgb, var(--ch) 50%, transparent); +} +.mod-menu-btn .icon { + width: 14px; + height: 14px; + /* The kebab path uses fill (filled circles) — disable stroke */ + stroke: none; + fill: currentColor; +} + +/* The dropdown — `position: fixed` with JS-computed coordinates. + * mod-menu.ts portals the element to 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; + } } diff --git a/server/src/ledgrab/static/css/components.css b/server/src/ledgrab/static/css/components.css index c9dc248..ddf954b 100644 --- a/server/src/ledgrab/static/css/components.css +++ b/server/src/ledgrab/static/css/components.css @@ -434,21 +434,52 @@ input:-webkit-autofill:focus { } .toast { + --toast-ch: var(--primary-color); position: fixed; bottom: 40px; left: 50%; transform: translateX(-50%) translateY(100px); - padding: 16px 24px; - border-radius: var(--radius-md); - color: white; + padding: 14px 22px; + border-radius: var(--lux-r-lg, var(--radius-md, 10px)); + color: var(--lux-ink, #fff); font-weight: 600; - font-size: 15px; + font-size: 14.5px; + letter-spacing: 0.01em; opacity: 0; - transition: opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1), transform 0.4s cubic-bezier(0.16, 1, 0.3, 1); + transition: opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1), + transform 0.4s cubic-bezier(0.16, 1, 0.3, 1); z-index: var(--z-toast); - box-shadow: 0 4px 20px var(--shadow-color); + background: linear-gradient(180deg, + var(--lux-bg-1, var(--card-bg)) 0%, + var(--lux-bg-2, var(--card-bg)) 100%); + border: var(--lux-hairline, 1px) solid color-mix(in srgb, + var(--toast-ch) 35%, var(--lux-line-bold, var(--border-color))); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.02), + 0 14px 40px rgba(0, 0, 0, 0.5), + 0 0 26px color-mix(in srgb, var(--toast-ch) 22%, transparent); min-width: 300px; + max-width: min(560px, calc(100vw - 32px)); text-align: center; + overflow: hidden; +} + +/* Top accent stripe — matches modals/bulk-toolbar so the channel language + is consistent across every floating surface. */ +.toast::before { + content: ''; + position: absolute; + left: 0; right: 0; top: 0; + height: 1.5px; + background: linear-gradient(90deg, + transparent 0%, + var(--toast-ch) 20%, + var(--toast-ch) 80%, + transparent 100%); + box-shadow: 0 0 12px color-mix(in srgb, var(--toast-ch) 55%, transparent); + pointer-events: none; } .toast.show { @@ -458,23 +489,15 @@ input:-webkit-autofill:focus { } @keyframes toastBounceIn { - 0% { transform: translateX(-50%) translateY(60px); opacity: 0; } - 50% { transform: translateX(-50%) translateY(-4px); opacity: 1; } - 70% { transform: translateX(-50%) translateY(2px); } + 0% { transform: translateX(-50%) translateY(60px); opacity: 0; } + 50% { transform: translateX(-50%) translateY(-4px); opacity: 1; } + 70% { transform: translateX(-50%) translateY(2px); } 100% { transform: translateX(-50%) translateY(0); } } -.toast.success { - background: var(--primary-color); -} - -.toast.error { - background: var(--danger-color); -} - -.toast.info { - background: var(--info-color); -} +.toast.success { --toast-ch: var(--primary-color); } +.toast.error { --toast-ch: var(--danger-color); } +.toast.info { --toast-ch: var(--info-color); } /* Toast with undo action */ .toast-with-action { @@ -488,31 +511,38 @@ input:-webkit-autofill:focus { } .toast-undo-btn { - background: rgba(255, 255, 255, 0.25); - border: 1px solid rgba(255, 255, 255, 0.4); - color: white; - padding: 4px 12px; - border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--toast-ch) 18%, transparent); + border: 1px solid color-mix(in srgb, var(--toast-ch) 50%, transparent); + color: var(--lux-ink, #fff); + padding: 5px 14px; + border-radius: var(--lux-r-sm, var(--radius-sm)); font-weight: var(--weight-semibold, 600); font-size: 0.85rem; + letter-spacing: 0.04em; cursor: pointer; - transition: background var(--duration-fast, 0.15s); + transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; white-space: nowrap; flex-shrink: 0; } .toast-undo-btn:hover { - background: rgba(255, 255, 255, 0.4); + background: color-mix(in srgb, var(--toast-ch) 32%, transparent); + border-color: var(--toast-ch); + box-shadow: 0 0 14px color-mix(in srgb, var(--toast-ch) 35%, transparent); } .toast-timer { width: 100%; - height: 3px; + height: 2px; position: absolute; bottom: 0; left: 0; - border-radius: 0 0 var(--radius-md) var(--radius-md); - background: rgba(255, 255, 255, 0.3); + border-radius: 0 0 var(--lux-r-lg, var(--radius-md)) var(--lux-r-lg, var(--radius-md)); + background: linear-gradient(90deg, + color-mix(in srgb, var(--toast-ch) 70%, transparent) 0%, + var(--toast-ch) 50%, + color-mix(in srgb, var(--toast-ch) 70%, transparent) 100%); + box-shadow: 0 0 8px color-mix(in srgb, var(--toast-ch) 60%, transparent); transform-origin: left; animation: toastTimer var(--toast-duration, 5s) linear forwards; } @@ -749,6 +779,15 @@ textarea:focus-visible { margin-left: auto; } +/* Hide empty icon/label slots so flex gaps don't reserve unused space. + Used by minimal items like the locale picker (2-letter code only). */ +.icon-select-trigger-icon:empty, +.icon-select-trigger-label:empty, +.icon-select-cell-icon:empty, +.icon-select-cell-label:empty { + display: none; +} + .icon-select-popup { position: fixed; z-index: var(--z-lightbox); diff --git a/server/src/ledgrab/static/css/dashboard.css b/server/src/ledgrab/static/css/dashboard.css index 82c9d4a..455bd0d 100644 --- a/server/src/ledgrab/static/css/dashboard.css +++ b/server/src/ledgrab/static/css/dashboard.css @@ -286,6 +286,42 @@ text-overflow: ellipsis; } +/* Device URL hyperlink inside the meta line. + Picks up the card's channel accent (--ch) so it ties into the card's + color identity instead of using the generic browser-blue link style. */ +.mod-meta__link { + display: inline-flex; + align-items: baseline; + gap: 4px; + color: var(--ch, var(--primary-text-color)); + text-decoration: none; + border-bottom: 1px dotted color-mix(in srgb, var(--ch, var(--primary-color)) 55%, transparent); + padding-bottom: 1px; + transition: color 0.15s ease, border-color 0.15s ease, text-shadow 0.15s ease; +} + +.mod-meta__link:hover, +.mod-meta__link:focus-visible { + color: var(--ch, var(--primary-color)); + border-bottom-style: solid; + border-bottom-color: currentColor; + text-shadow: 0 0 8px color-mix(in srgb, var(--ch, var(--primary-color)) 35%, transparent); + outline: none; +} + +.mod-meta__link .icon { + width: 0.95em; + height: 0.95em; + transform: translateY(0.12em); + opacity: 0.7; + transition: opacity 0.15s ease; +} + +.mod-meta__link:hover .icon, +.mod-meta__link:focus-visible .icon { + opacity: 1; +} + .mod-leds { display: flex; align-items: center; @@ -404,6 +440,34 @@ margin-left: 3px; } +/* Text-stack variant — used for short identifier values that don't render + well in the heavy display font (e.g. SK6812 + RGBW). The primary line + sits in the same slot as `.v`, and an optional `.v-sub` element below + carries a secondary line in the small mono caps style. */ +.mod-metric--text-stack .v { + font-family: var(--font-mono, monospace); + font-size: 1rem; + font-weight: 700; + letter-spacing: 0.04em; + line-height: 1.15; + white-space: normal; + overflow: visible; + text-overflow: clip; + display: flex; + flex-direction: column; + gap: 4px; + align-items: flex-start; +} +.mod-metric--text-stack .v-sub { + font-family: var(--font-mono, monospace); + font-size: 0.55rem; + font-weight: 600; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--lux-ink-mute, var(--text-secondary)); + opacity: 0.85; +} + .mod-metric .v .dashboard-fps-target { font-family: var(--font-mono, monospace); font-size: 0.6rem; @@ -1139,6 +1203,12 @@ padding: 0 18px 14px; cursor: crosshair; filter: drop-shadow(0 0 5px color-mix(in srgb, var(--perf-accent) 45%, transparent)); + /* Clip the scroll-out animation: when a new sample arrives the SVG + is snap-translated +1 step right then eased back to 0, so the + previous sample's frame remains visually anchored while old + samples slide off the left. Without overflow:hidden the temporary + overshoot would peek into the padding. */ + overflow: hidden; } .perf-chart-spark .perf-chart-svg { @@ -1149,6 +1219,18 @@ width: 100%; height: 100%; display: block; + /* Promote to its own compositor layer so the scroll animation runs + on the GPU — the path strings underneath don't repaint, only the + layer transform updates per frame. */ + will-change: transform; +} + +/* Honor the global animations preference. "reduced" keeps the scroll + but cuts duration so motion is brief; "off" pins the SVG so each + tick is a hard cut. */ +[data-layout-anim="off"] .perf-chart-spark .perf-chart-svg { + transition: none !important; + transform: none !important; } .perf-chart-unavailable { @@ -1262,6 +1344,69 @@ — the list owns the rest of the cell height. */ .perf-patches-cell .perf-chart-spark { display: none; } +/* Errors cell — value is muted at 0 (healthy state, no alarm) and + shifts to the cell's coral accent the moment the rate or cumulative + count goes non-zero. The accent stripe + value tint give a passive + "is anything wrong?" indicator without flashing or animation. */ +.perf-errors-cell .perf-chart-value { + color: var(--lux-ink-mute, var(--text-secondary)); + opacity: 0.65; +} +.perf-errors-cell.has-errors .perf-chart-value { + color: var(--perf-accent, var(--ch-coral, var(--danger-color))); + opacity: 1; +} +.perf-errors-cell.has-errors .perf-chart-subtitle { + color: var(--perf-accent, var(--ch-coral, var(--danger-color))); + opacity: 0.85; +} + +/* Empty-state hint shown when no patches are running. A pulsing accent + dot keeps the card visually alive even when idle, and the small caps + text reads as a status line ("Ready to launch") rather than a stale + empty list. */ +.perf-patches-empty { + display: flex; + align-items: center; + gap: 8px; + padding-top: 4px; + font-family: var(--font-mono, monospace); + font-size: 0.62rem; + font-weight: 600; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--lux-ink-mute, var(--text-secondary)); + opacity: 0.85; +} +.perf-patches-empty-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--perf-accent, var(--ch-magenta, var(--primary-color))); + box-shadow: 0 0 6px currentColor; + color: var(--perf-accent, var(--ch-magenta, var(--primary-color))); + flex-shrink: 0; + animation: perfPatchesIdlePulse 2.4s ease-in-out infinite; +} +.perf-patches-empty-text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +@keyframes perfPatchesIdlePulse { + 0%, 100% { opacity: 0.45; transform: scale(1); } + 50% { opacity: 1; transform: scale(1.15); } +} + +/* Honor the global "reduced/off" animations preference set on the + dashboard root — the pulse vanishes when the user wants stillness. */ +[data-layout-anim="off"] .perf-patches-empty-dot, +[data-layout-anim="reduced"] .perf-patches-empty-dot { + animation: none; + opacity: 0.7; +} + /* ── Devices cell — online/total count + dot strip per device ── */ .perf-devices-cell { --perf-accent: var(--ch-signal, var(--primary-color)); @@ -1323,7 +1468,7 @@ color: var(--perf-accent); } -.perf-chart-card[data-metric="fps"] .perf-fps-unit { +.perf-chart-card .perf-fps-unit { font-family: var(--font-mono, monospace); font-size: 0.3em; font-weight: 500; @@ -1333,6 +1478,12 @@ margin-left: 6px; align-self: center; } +/* Errors cell — when the rate is non-zero, the unit takes the same coral + tint as the value so they read as a single composite reading. */ +.perf-errors-cell.has-errors .perf-fps-unit { + color: var(--perf-accent, var(--ch-coral, var(--danger-color))); + opacity: 0.85; +} /* Target-FPS ceiling suffix — "/ 120" next to the big live number, sized down + muted so the live value remains the primary reading. Matches diff --git a/server/src/ledgrab/static/css/layout.css b/server/src/ledgrab/static/css/layout.css index 03f7674..b65be3f 100644 --- a/server/src/ledgrab/static/css/layout.css +++ b/server/src/ledgrab/static/css/layout.css @@ -770,12 +770,12 @@ h2 { height: 30px; padding: 0; background: transparent; - border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + border: none; border-radius: var(--lux-r-sm, 3px); cursor: pointer; font-size: 0.9rem; color: var(--lux-ink-dim, var(--text-secondary)); - transition: color 0.2s, background 0.2s, border-color 0.2s, box-shadow 0.2s; + transition: color 0.2s, background 0.2s, box-shadow 0.2s; display: inline-grid; place-items: center; line-height: 1; @@ -785,7 +785,6 @@ h2 { .header-btn:hover { color: var(--lux-ink, var(--text-color)); background: var(--lux-bg-2, var(--bg-secondary)); - border-color: var(--lux-line-bold, var(--border-color)); box-shadow: 0 0 12px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 18%, transparent); } diff --git a/server/src/ledgrab/static/css/modal.css b/server/src/ledgrab/static/css/modal.css index 8985ad4..50d959e 100644 --- a/server/src/ledgrab/static/css/modal.css +++ b/server/src/ledgrab/static/css/modal.css @@ -432,28 +432,43 @@ .settings-tab-bar { display: flex; - justify-content: center; + justify-content: space-around; gap: 0; border-bottom: 2px solid var(--border-color); - padding: 0 0.75rem; + padding: 0 0.5rem; } .settings-tab-btn { background: none; border: none; - padding: 8px 12px; + padding: 10px 0; + flex: 1 1 0; + min-width: 0; font-size: 0.85rem; font-weight: 500; color: var(--text-secondary); cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -2px; - white-space: nowrap; - transition: color 0.2s ease, border-color 0.25s ease; + display: inline-flex; + align-items: center; + justify-content: center; + transition: color 0.2s ease, border-color 0.25s ease, background 0.2s ease; +} + +.settings-tab-btn .icon { + width: 18px; + height: 18px; + transition: transform 0.2s ease, filter 0.2s ease; } .settings-tab-btn:hover { color: var(--text-color); + background: color-mix(in srgb, var(--primary-color) 6%, transparent); +} + +.settings-tab-btn:hover .icon { + transform: translateY(-1px); } .settings-tab-btn.active { @@ -461,6 +476,15 @@ border-bottom-color: var(--primary-color); } +.settings-tab-btn.active .icon { + filter: drop-shadow(0 0 6px color-mix(in srgb, var(--primary-color) 50%, transparent)); +} + +.settings-tab-btn:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: -2px; +} + .settings-panel { display: none; } @@ -904,6 +928,43 @@ } } +@keyframes fadeOut { + from { opacity: 1; } + to { opacity: 0; } +} + +@keyframes slideDown { + from { + transform: translateY(0) scale(1); + opacity: 1; + } + to { + transform: translateY(12px) scale(0.97); + opacity: 0; + } +} + +/* Exit animation — applied by Modal.forceClose() before display:none. + Symmetric to fadeIn/slideUp, slightly faster on the way out for a + responsive feel. */ +.modal.closing { + animation: fadeOut 0.18s ease-in forwards; + pointer-events: none; +} + +.modal.closing .modal-content { + animation: slideDown 0.2s cubic-bezier(0.7, 0, 0.84, 0) forwards; +} + +@media (prefers-reduced-motion: reduce) { + .modal, + .modal.closing, + .modal-content, + .modal.closing .modal-content { + animation: none !important; + } +} + .modal-header { padding: 22px 24px 14px 24px; border-bottom: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); diff --git a/server/src/ledgrab/static/css/streams.css b/server/src/ledgrab/static/css/streams.css index b2b86ed..0589477 100644 --- a/server/src/ledgrab/static/css/streams.css +++ b/server/src/ledgrab/static/css/streams.css @@ -834,55 +834,89 @@ body.pp-filter-dragging .pp-filter-drag-handle { .cs-filter-wrap { position: relative; - width: 180px; - max-width: 40%; + width: 220px; + max-width: 45%; flex-shrink: 0; } +/* Search input with embedded magnifier icon (data URI keeps the HTML + untouched), neon focus glow, monospace placeholder for the technical + look used elsewhere in the dashboard. */ .cs-filter-wrap .cs-filter { width: 100%; - padding: 4px 26px 4px 10px; - font-size: 0.78rem; - border: 1px solid var(--border-color); - border-radius: 14px; - background: var(--bg-secondary); - color: var(--text-color); + padding: 7px 32px 7px 32px; + font-family: var(--font-mono, monospace); + font-size: 0.76rem; + letter-spacing: 0.04em; + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + border-radius: var(--lux-r-md, 6px); + background-color: var(--lux-bg-0, var(--bg-secondary)); + background-image: url("data:image/svg+xml;utf8,"); + background-repeat: no-repeat; + background-position: 10px center; + background-size: 14px 14px; + color: var(--lux-ink, var(--text-color)); outline: none; - box-shadow: none; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.25); box-sizing: border-box; - transition: border-color 0.2s, background 0.2s, width 0.2s; + transition: + border-color 0.15s ease, + background-color 0.15s ease, + box-shadow 0.2s ease; +} + +.cs-filter-wrap .cs-filter:hover { + border-color: var(--lux-line-bold, var(--border-color)); } .cs-filter-wrap .cs-filter:focus { border-color: var(--primary-color); - background: var(--bg-color); + background-color: var(--lux-bg-1, var(--bg-color)); + box-shadow: + inset 0 1px 2px rgba(0, 0, 0, 0.3), + 0 0 0 3px color-mix(in srgb, var(--primary-color) 18%, transparent), + 0 0 18px color-mix(in srgb, var(--primary-color) 22%, transparent); } .cs-filter::placeholder { - color: var(--text-secondary); - font-size: 0.75rem; + color: var(--lux-ink-faint, var(--text-secondary)); + font-size: 0.72rem; + letter-spacing: 0.06em; + text-transform: uppercase; + opacity: 0.85; } .cs-filter-reset { position: absolute; - right: 2px; + right: 4px; top: 50%; transform: translateY(-50%); - background: none; + width: 22px; + height: 22px; + background: transparent; border: none; - color: var(--text-secondary); - font-size: 1rem; + color: var(--lux-ink-mute, var(--text-secondary)); + font-size: 0.95rem; cursor: pointer; - padding: 0 5px; + padding: 0; line-height: 1; - border-radius: 50%; - transition: color 0.15s, background 0.15s; + border-radius: var(--lux-r-sm, 3px); + display: inline-flex; + align-items: center; + justify-content: center; + transition: color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease; +} + +/* Hide the clear button when the input is empty — CSS-only so the visual + state stays correct regardless of any JS-set inline display value. */ +.cs-filter-wrap .cs-filter:placeholder-shown + .cs-filter-reset { display: none; } .cs-filter-reset:hover { - color: var(--text-color); - background: var(--border-color); + color: var(--lux-ink, var(--text-color)); + background: color-mix(in srgb, var(--primary-color) 14%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--primary-color) 30%, transparent); } /* Empty state for CardSection */ diff --git a/server/src/ledgrab/static/js/app.ts b/server/src/ledgrab/static/js/app.ts index c5eb96f..6a8946e 100644 --- a/server/src/ledgrab/static/js/app.ts +++ b/server/src/ledgrab/static/js/app.ts @@ -16,6 +16,8 @@ import { initCardGlare } from './core/card-glare.ts'; import { initBgAnim, updateBgAnimAccent, updateBgAnimTheme } from './core/bg-anim.ts'; import { initBgShaders } from './core/bg-shaders.ts'; import { initTabIndicator, updateTabIndicator } from './core/tab-indicator.ts'; +import { initModMenu } from './core/mod-menu.ts'; +import { toggleCardHidden } from './core/card-sections.ts'; // Layer 2: ui import { @@ -240,6 +242,11 @@ Object.assign(window, { // core / state (for inline script) setApiKey, + // mod-card menu — referenced by inline onclick on .mod-menu__item + // for the "Hide card" entry. The handler uses the registered + // CardSection's section key (e.g. 'led-devices') + the card id. + toggleCardHidden, + // visual effects (called from inline