mod-card.ts
- ModMetricOpts.extra: raw HTML appended after the .v cell — used
to embed a sparkline canvas inside the FPS metric block
- ModMetricOpts.valueDataAttrs: data-attrs on the .v element so
live-update patchers can target the value directly
LED target card
- FPS sparkline (mod-metric-spark-canvas) is embedded INSIDE the
FPS cell as a sibling of .v — was a separate target-fps-row
block before, which floated under the metrics grid
- Label hardcoded to "FPS" (the i18n value "Target FPS:" was
meant for the editor field, not the readout)
- Uptime cell gets ICON_CLOCK; Errors cell gets ICON_OK / ICON_WARNING
based on count — matches dashboard cell decorations
- Drops the leading FPS icon (display-font number is the focal
element; no icon needed)
- _patchTargetMetrics now emits the dashboard FPS shape:
current<span.dashboard-fps-target>/target</span>
<span.dashboard-fps-avg>avg N.N</span> — picks up dashboard.css
styling for free
HA Light target card
- Same icon treatment (Uptime → clock; HA → ok/warning by
ha_connected); FPS icon dropped
Grid sizing
- .devices-grid bumped from minmax(300px, 1fr) / gap 20px to
minmax(min(380px, 100%), 1fr) / gap 14px — matches the
dashboard's section grid so metric values like "1m 43s" stop
truncating at the typical desktop width
When the user enables "Start with Windows" in the installer, the app
launches on every PC login. Previously each login popped a fresh WebUI
tab, which is noisy for a tray-resident background service.
The autostart shortcut now passes --autostart to start-hidden.vbs, which
sets LEDGRAB_AUTOSTART=1 in the child env. __main__ checks this flag
alongside LEDGRAB_RESTART when deciding whether to open the browser.
Manual launches (desktop/start-menu shortcuts) and the installer's
post-install "Launch LedGrab" finish-page action are unchanged — they
don't pass the arg, so they still open the WebUI tab.
start defaults to 0, length defaults to led_count - start (the rest of
the strip from start). A single segment with only mode + color now
fills the entire strip — no more length: 9999 magic value clients have
to pass.
Buffer auto-grow only fires for segments with an explicit length past
the current end; implicit "to the end" segments adapt to the current
strip size.
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
Surface device connection state changes (configured target online/offline)
and discovery events (new WLED on LAN, new serial port, devices that
disappear) through a configurable per-event channel matrix:
none / snack / OS / both.
- Backend: long-running mDNS browser + 10 s serial poller in
core/devices/discovery_watcher.py, gated by user pref. Reuses the
existing device_health_changed event for online/offline transitions.
New GET/PUT /api/v1/preferences/notifications endpoint with Pydantic v2
schema (channel matrix + background-discovery flag + grace/debounce).
13 new tests, full suite still 899 passing.
- Frontend: features/notifications-watcher.ts with startup-grace +
flap-debounce + bulk-coalesce pipeline. Web Notifications API for the
OS channel (no platform-specific code, works in PWA shell).
New "Notifications" tab in Settings with 4 IconSelect rows + bg toggle
+ permission row + test button. en/ru/zh translations.
Defaults: device_offline=both (urgent), online/discovered=snack, lost=none,
background discovery on. Already-configured devices are filtered from
discovery events to avoid double-notifications.
Chrome deprecated apple-mobile-web-app-capable in favor of the
standard mobile-web-app-capable. Add the new tag while keeping the
Apple variant for iOS Safari compatibility.
- ``tests/test_preferences_api.py`` no longer captures the auth API
key at module-import time. The new ``client`` fixture resolves it
inside its body and bakes the Bearer header into ``TestClient.headers``,
so the e2e conftest swapping the global config singleton during
collection cannot leave the test holding a stale 401-bound header.
Same proven pattern as ``test_audio_processing_templates_api.py``.
- ``.gitignore`` now anchors ``/server/src/data/`` defensively. If the
server is launched from ``server/src/`` (uncommon but possible during
ad-hoc debugging), its relative ``data/`` resolves there. Templates
now live in SQLite (``capture_templates`` / ``pattern_templates`` /
``postprocessing_templates`` tables); any stale ``*.json`` that
lands in that directory is a runtime export and must not be
committed.
- Three such stale exports were untracked at the start of the
pre-merge audit and have been deleted from the working tree.
- ``TODO.md`` flips the shutdown-action checklist to done and notes
that real-hardware verification (WLED + serial after Ctrl+C) is
still pending.
Reported during pre-merge review of the Lumenworks redesign:
non-dashboard cards (Inputs, Integrations, Targets) all showed
aggressive cyan / green left stripes "regardless of custom color set
or not", and even the ``+`` Add card carried a stripe.
Root cause: ``.template-card`` defaulted to ``--ch: cyan`` and
``::before`` painted it unconditionally; ``.add-template-card``
inherits ``.template-card`` so it picked the stripe up too.
Fix: gate the ``::before`` channel stripe behind opt-in selectors.
It now paints only when the card carries ``data-has-color="1"``
(the user picked a personal colour via the picker, set by ``wrapCard``)
or has the ``card-running`` class (the "patched and live" indicator).
Dashboard module rows are unchanged — their ``::before`` already
runs at ``opacity: 0.6`` and was approved as the visual benchmark.
``add-template-card::before`` is hidden unconditionally with
``!important`` since the Add card is not an entity and should never
carry a channel hue.
Three adjacent UI fixes that surfaced while soaking the Lumenworks
redesign:
- ``card-colors.ts`` now writes the user's picked color through to
*every* card representing the same entity (e.g. the targets-tab card
AND its dashboard mirror), not just the one that owns the picker.
Sets the ``--ch`` custom property on each match instead of a literal
``border-left``, which avoided the double-stripe (custom border +
Lumenworks ``::before`` channel stripe) the old approach produced
and reaches mirrors the picker callback's ``.closest()`` lookup
couldn't.
- ``appearance.ts`` "default" preset now *clears* its colour overrides
instead of stamping the historic muted greys (#1a1a1a / #2d2d2d /
#f5f5f5 / #ffffff). With the redesign's pure-black / pure-white base
palette in ``base.css``, "default" should mean "use the base" — the
preset swatch in Appearance now matches what ships out of the box.
Existing users with "default" selected will see a one-time visual
shift to the new neutrals on next reload; this is intentional.
- ``dashboard.css`` mod-metric label row gets explicit sizing for the
small status glyphs (clock / check / warning) so they sit beside the
mono-caps label without competing with the big value. Errors cell
picks up the coral channel tint when the count is non-zero.
Lets users choose what happens to LED targets when the server shuts
down. Default ("stop_targets") runs the existing per-device stop
sequence, so devices with auto-restore replay their prior state.
"Nothing" cancels the capture tasks without sending restore frames,
so the LEDs keep displaying their last frame on shutdown.
Backend:
- New setting ``shutdown_action`` persisted in db.settings
(``stop_targets`` default | ``nothing``) with GET/PUT
``/api/v1/system/shutdown-action`` endpoints
- ``ProcessorManager.stop_all(restore_devices: bool = True)`` now
picks the path based on the flag — ``proc.stop()`` for the normal
branch, public ``proc.cancel_task()`` for the "nothing" branch.
- ``TargetProcessor.cancel_task()`` (new, on the abstract base) cancels
the loop task and *awaits* its termination so no half-written frame
is in flight when the process exits. Replaces an earlier draft that
reached into the private ``_task`` attribute via ``getattr``.
- Lifespan in ``main.py`` reads the setting at shutdown and forwards
the flag; falls back to ``stop_targets`` on any read error.
- ``/health`` exposes ``uptime_seconds`` (process-wide monotonic clock
captured at first import of ``api.routes.system``) so the WebUI can
show the *server's* uptime instead of the browser session's.
Browser launch:
- ``__main__._open_browser`` now polls ``/health`` for up to 30 s
instead of sleeping a flat 2 s, so the tab opens once the server
actually accepts requests.
Frontend:
- New "Shutdown action" picker in Settings → General, rendered via
IconSelect with ICON_SQUARE / ICON_CIRCLE (added to ``core/icons.ts``
+ ``circle`` path to ``icon-paths.ts``).
- Transport-bar uptime ticker reads ``window.__serverUptime`` (typed
in ``global.d.ts``); shows "—" until the first /health response
lands so refresh doesn't briefly flash 00:00:00. After 99 h the
format widens to "Dd HH:MM:SS".
- New i18n keys for the action picker (label, hint, opt.stop /
opt.nothing + descriptions, saved / save_error toasts) in en/ru/zh.
No data migration needed — the setting is additive and defaults to
the existing behavior.
At ≤1100px the header grid only declared 3 tracks for 4 children, so
the toolbar wrapped to a second row, doubling the header height. Add a
4th track, tighten the meta cluster, and hide non-essential toolbar
items (API link, tour-restart) so everything fits in one row. At
≤900px drop CPU/Mem cells (Uptime + Poll remain) so the toolbar still
fits beside the meta cluster.
Sidebar tab captions on the 56 px icon rail were ellipsis-truncated to
"DASHBO…" / "AUTOMA…" / "INTEGR…". Switch to a 2-line clamp with
tighter font/tracking so each label renders in full.
Three related fixes after the Phase-4 migration landed:
- `--card-bg` flipped from `#101216` / `#f5f6f8` to pure `#000000` /
`#ffffff` in base.css. Off-pure greys read as muddy when sitting on a
pure-black/white page background; pure values keep card surfaces flush
with the rest of the chrome and let the channel stripe + corner
bracket carry all the visual differentiation.
- Removed the `[data-bg-anim="on"] .card { background: rgba(...) }`
block that turned every entity card translucent whenever the WebGL
background was enabled. Card backgrounds are now stable across the
toggle — the shader bleeds through `body { background: transparent }`
only, not through cards. The same card now reads identically with the
shader on or off.
- WebGL shader base colour (`_bgColor` in bg-anim.ts and bg-shaders.ts)
was using the legacy mid-grey `#1a1a1a` / `#f5f5f5`. That added a
constant grey haze under the additive accent glow that didn't exist
on the surrounding pure-black/white page. Switched to `[0,0,0]` /
`[1,1,1]` so the shader composes against the same base as the page.
- Reverted two leftovers from the Phase-4 commit where I had migrated
`.template-card` and `.graph-node-body` away from `var(--card-bg)`
toward `var(--lux-bg-1, …)`. Those backgrounds now live on
`var(--card-bg)` again, matching every other migrated card.
Brings the remaining tabs in line with the Channels-tab visual language:
- .template-card now mirrors .card and .dashboard-target — channel stripe
on the left edge with glow, silkscreened corner bracket top-right,
hairline border on --lux-bg-1, hover lift + stripe widen-and-glow.
Covers streams, capture / pp / cspt / pattern / audio templates and
every Integrations card (HA / MQTT / weather / value / sync clocks /
game integrations).
- Channel mapping extended in cards.css. Direct attribute hooks for the
per-domain ids; section-scoped hooks via [data-card-section="…"] for
the cards that share a generic data-id (HA / MQTT / weather / value
→ cyan, game-integrations → amber, sync-clocks → violet,
HA-light-targets → signal). No JS changes — uses the section markup
CardSection.render already emits.
- Graph editor nodes pick up the studio-console palette: --lux-bg-1
fill with hairline stroke, hover bold-line, selected/running stroke
--ch-signal with drop-shadow glow. Title font moved off Big Shoulders
Display (which read as "stretched" at 12 px) onto --font-body
(Manrope); subtitle keeps the mono-uppercase caption treatment with a
conservative letter-spacing. Running gradient now rides the channel
palette (signal → cyan → signal) rather than the legacy primary /
success colours. Port labels and grid dots adopt --lux-line tokens.
- Graph node titles get real text-overflow:ellipsis behaviour. SVG
<text> can't do that natively, so renderNodes runs a post-mount fit
pass that binary-searches the longest character prefix that fits
inside the clip rect (with 2 px slack), suffixed with "…". Trailing
whitespace is stripped before the ellipsis so we never get "Foo …".
Full text is stashed on data-full-text so the fit can be re-run on
re-renders.
Also bundles two perf-charts fixes from the same session:
- Hover regression — listener was bound to .perf-charts-grid, which
rerenderPerfGrid() replaces. Moved to document.body with a guard, and
the cursor → sample math now uses the same sliceN as the spark
rendering so the tooltip stays accurate when the user changes the
window setting.
- Color picker on every perf cell. Patches / Total FPS / Devices now
expose the same color picker as the spark cells; defaults added to
METRIC_CSS_VARS. Each card gets an inline --perf-accent on render so
saved colours apply immediately, including across rerenderPerfGrid.
Open-registry section/perf-cell schema persisted server-side under
db.get_setting('dashboard_layout'); localStorage cache for instant
first-paint, server sync after auth. 5 built-in presets
(Studio/Operator/Showrunner/Diagnostics/TV); JSON export/import.
Slide-in Customize panel toggles section + perf-cell visibility,
reorders via hand-rolled HTML5 drag (with up/down buttons for
keyboard/TV-remote use), changes density per section, and exposes
global Width / Animations / Perf-mode / Window with per-cell Inherit
overrides.
Window setting now drives the actual sparkline slice (30s/1m/2m/5m at
configurable poll interval) instead of always rendering 120 fixed
samples. Perf-grid edits re-render in place — sparklines repaint from
persistent module-level history, value labels replay from cached
last-fetch payload, so there is no flicker frame and no zero-data
window between layout change and next poll. initPerfCharts now fires
an immediate fetch on init so reload no longer shows "—" until the
first interval tick.
Reset confirmation uses the project's themed showConfirm modal
instead of the browser dialog. Reserved registry keys (audio-meters,
alerts, led-preview, source-thumbs, pinned, flow) are forward-
compatible so v1.1 cards slot in without a schema bump.
Backend exposes GET/PUT/DELETE /api/v1/preferences/dashboard-layout
treating the body as opaque JSON with a numeric version gate; covered
by 6 round-trip / validation / unknown-field tests.
Item cards (Automations, Channels, Inputs, Integrations):
- `.card-title` — bumped to weight 700, -0.01em tracking, solid --lux-ink
for better presence against the flat card bg.
- `.card-subtitle` / `.card-meta` — mono font, 0.04em tracking, tighter
gap so rule chips pack in a readable row.
- `.stream-card-prop` rule chips — rectangular 2px radius + hairline
border + flat dark bg (was rounded 10px grey pill). Channel-signal
icon tint; hover fades in a channel-green wash with matching border.
- `.badge` generic — rectangular 2px radius, mono 0.62rem, 0.12em
tracking, hairline border slot for variants.
- `.badge-automation-active` — channel-signal tinted bg + border +
soft outer glow so the "ACTIVE" state reads at a glance.
- `.badge-automation-inactive` / `-disabled` — transparent with a
hairline outline so they sit quietly alongside the active variant.
- `.device-url-badge` — switched from rounded pill to rectangular
hairline mono chip; hover shifts to filled bg + bolder border +
brighter ink.
- `.card-actions` — 1px hairline top divider, 6px gap.
- `.btn-icon` — 7/10px padding, 1rem icon, hairline border, channel-
signal glow on hover (replaces the old scale(1.1) jiggle).
- `.btn-icon.btn-warning` — amber ink + hairline + amber hover glow
(drives the "disable" action in the automation card).
- `.btn-icon.btn-success` — signal-green ink + hairline + green hover
glow ("enable" action).
Cross-link navigation highlight:
- `cardHighlight` keyframes were using an undefined `--primary-rgb` var,
so the outer glow fell back to 59/130/246 (the Tailwind blue default).
Rewritten with `var(--ch-signal)` + color-mix so the highlight tracks
the accent picker and reads as signal-green. Added double-layer
box-shadow (ring + 32px/10px bloom) so the highlight is obvious on
the flat dark/light card surfaces. Added .dashboard-target to the
selector + `isolation: isolate` so the glow isn't clipped inside
overflow: hidden containers (perf strip cells, tree-nav panels).
Perf strip (follow-up polish):
- Total FPS cell shows `/<N>` ceiling suffix next to the live value —
sum of fps_target across running targets, styled like the Patches
"/12". A dashed horizontal reference line at that ceiling is rendered
on the sparkline so the live value reads as "percentage of max
achievable throughput." Y-axis ceiling grows to targetSum * 1.1 so
the dashed line never clips.
- Removed the empty `.perf-chart-app` pill in the FPS cell (no app
variant). Added `:empty { display: none }` as a safety so any other
unpopulated cell doesn't render a ghost pill.
- Hover tooltips on all sparks — single floating `.perf-chart-tooltip`
in <body> with fixed positioning; event-delegated from the perf
grid so re-renders don't need rebinding. Shows metric label + sys
value + app value (in both-mode) + "−Ns ago" age line derived from
the poll interval. Vertical marker line follows the cursor over the
spark; `cursor: crosshair` on the spark container signals interact-
ability. `pointer-events: none` shifted from the spark container
down to the inner SVG so hover events land on the container.
Grid:
- Perf strip capped at 4 cols even on widescreen; wraps to 2 rows ×
4 when the full 7 cells are present. Responsive breakpoints at
1100 / 760 / 480 px.
- Big value font uses `clamp(1.8rem, 2.8vw, 2.8rem)` so readouts
like "18.9/31.8 GB" fit a 1fr cell at desktop while still scaling
down on narrow viewports. `white-space: nowrap; flex-wrap: nowrap;
overflow: hidden; text-overflow: clip` prevents mid-text wrapping.
- `.perf-chart-spark` uses `margin-top: auto` so sparkline baselines
align across cells regardless of whether a subtitle is present
(CPU/GPU model name, FPS min/max).
Dashboard target meta:
- Integrations card stripe reverted to the default signal color so it
matches the overall accent picker; the health-dot inside the card
carries the connection state. Removed the per-integration channel
override in both cards.css and dashboard.css.
Section headers:
- `.dashboard-section-header` / `.subtab-section-header` underline
switched from dashed to solid; channel-green 40px accent rule on
the left remains.
- Section count badge (`.dashboard-section-count`) restyled to match
the rest of the badge family (mono tabular-nums, 2px radius, hairline
border, --lux-bg-3 fill).
Build: tsc --noEmit clean; CSS bundle stable at ~216 KB.
Dashboard perf strip:
- Unified rack-module shell with hairline-divided cells (mockup parity)
replacing 3 separate perf cards. Cells auto-wrap to 2 rows of 4 on
widescreen; responsive breakpoints at 1100 / 760 / 480 px.
- Active Patches cell (first) shows running/total channel count plus up
to 4 live FPS readouts with channel-colored stripes; bottom-right
radial glow anchors the "live channel bank" corner.
- Total FPS cell — aggregate throughput across running targets, mono
"fps" unit suffix, session-peak-scaled sparkline with a 60 FPS floor.
- Devices cell — online/total count + per-device dot strip (green when
online with signal-glow, coral when offline, tooltip with name +
latency), fed from /devices/batch/states (added to the dashboard
batch poll).
- Value font uses clamp(1.8rem, 2.8vw, 2.8rem) + white-space: nowrap so
long readouts (RAM "18.9/31.8 GB", GPU "50% · 37°C") scale down
instead of wrapping.
- Sparklines anchor to the cell bottom via margin-top: auto so baselines
align across cells regardless of subtitle presence.
- App-load tag ("APP 3.1%") moved to a pinned top-right position per
card, accent-colored pill; replaces the subdued inline badge.
- Perf mode toggle (System / App / Both) triggers an immediate poll so
positioning updates without waiting for the next tick.
- Chart.js removed from perf-charts — inline SVG sparklines with
drop-shadow filter for the "lit instrument" feel. Chart.js still used
for per-target FPS charts via chart-utils (now owns the registration).
- Fixed history seed bug: app_ram is MB in the server history payload,
not percent — convert to percent using sample's ram_total before
pushing into _appHistory.ram. Skip seeding app_gpu_mem since the
history schema has no gpu_memory_total.
- Temperature card reveals with an explanatory hint when the backend
reports cpu_temp_hint_key (e.g. Windows without LibreHardwareMonitor)
instead of silently hiding; .perf-chart-card-hint neutralizes the big
display font so the message reads as plain body copy.
Transport bar:
- LED brand mark — 28 px, double-layer signal glow (0 22px + 0 8px),
brandPulse animation. Brand-stack wraps the title + version so
"LED GRAB" sits above "V0.3.0" on a single line each.
- Transport status chip — bigger (9/18 padding), mono uppercase,
inner+outer signal glow when .is-armed.
- Transport meta cells — Uptime (JS-local session ticker), CPU (app
CPU share), Mem (app RAM, G/M format) as stacked KEY/VALUE mono
readouts with hairline separators.
- New interactive Poll cell cycles through 1/2/5/10s presets on click;
replaces the range slider that used to live in the Dashboard toolbar
(it controlled the whole app, not just the Dashboard).
- Header icon buttons — hairline-bordered 30 px squares with channel-
glow on hover, replacing the pill container.
- Perf poll moved to global bootstrap so transport CPU / Mem stay live
across all tabs (was paused when leaving the Dashboard).
- Connection pip (#server-status) hidden; the brand mark itself turns
coral when offline via :has() selector on .header-title.
Dashboard cards:
- renderDashboardTarget now emits full rack-module markup with CH badge,
name, meta, LED cluster, 3-cell metric grid (FPS / Uptime / Errors),
and patch-label + stop button. Running cards get the signal-flow
strip at the bottom. data-fps-text / data-uptime-text / data-errors-
text hooks preserved so _updateRunningMetrics updates in place.
- LED count surfaced in the target card meta line (e.g. "LED · WLED ·
144 LED · GRADIENT") when the linked device reports led_count > 0.
- Integrations (HA + MQTT) picked up .mod-head markup — compact module
layout with online/offline patch indicator. Integration card stripe
uses the default signal color (not cyan or amber).
- Scene presets, sync clocks, automations gain the same compact module
treatment. Automations/scenes dropped into a dashboard-autostart-grid
so they share the visual language.
- Perf mode toggle, stream sub-tabs, cs-count / tree-count /
tab-badge / dashboard-section-count badges all use the mono
rectangular style with tabular-nums.
Command palette:
- Flat background (no gradient), channel-accent rule across the top,
mono placeholder / group headers / footer, active result gets a
channel-green left stripe.
Modals:
- Popover + backdrop get a stronger radial dim + 6 px blur.
- Per-modal-ID channel lanes (target→green, source→cyan, audio→magenta,
automation/scene→violet, settings→amber, confirm→coral) via --modal-ch
override.
- Modal header picks up a vertical channel stripe + hairline divider;
footer gets hairline top + subtle wash.
Components:
- Inputs use hairline borders + tabular-nums mono for number fields;
focus state has channel-green ring + soft glow.
- Buttons switch to mono-uppercase with signal-glow on primary,
coral-glow on danger, hairline border on secondary.
- Card background flattened — removed gradient wash in favor of solid
--lux-bg-1 for both dark (#0e1014) and light (#f6f8fb).
- Page background: pure black for dark, pure white for light.
Color-picker:
- Always detaches to <body> with fixed positioning when its swatch sits
inside an overflow: hidden / auto / clip ancestor (perf strip, modal
bodies, tree-dd panels). Prevents the popover getting clipped.
Settings modal:
- Remembers the last-opened tab via localStorage key
settings_active_tab; falls back to 'general' if the tab id no longer
exists. Explicit overrides (donation → about, update badge →
updates) still work because callers invoke switchSettingsTab after
openSettingsModal.
Microcopy:
- Sidebar / transport localization for en/ru/zh:
sidebar.workspaces · transport.meta.{uptime,cpu,mem,poll,poll_hint}
· transport.status.{ready,armed} · dashboard.perf.{active_patches,
total_fps,devices}
Backend (coordinated with frontend):
- /system/performance now returns cpu_temp_hint_key when no live CPU
temperature is available, so the Temperature card can render an
actionable explainer instead of being hidden. Frontend respects the
key via t() lookup.
Section headers:
- Underline switched from dashed to solid; channel-green accent rule
(40 px) on the left remains.
Build / tests:
- ruff clean on touched Python files.
- tsc --noEmit clean.
- Python metrics-provider tests: 18 passed.
- CSS bundle ~214 KB.
Full-app UI/UX refresh committing to a tech-instrument / studio-console
aesthetic inspired by hardware synths, Eurorack panels, and DAW layouts.
Design tokens and fonts:
- Embed Manrope (body), JetBrains Mono (labels/metrics), Big Shoulders
Display (numeric readouts) as local .woff2 variable fonts with
latin + latin-ext + cyrillic + cyrillic-ext subsets via unicode-range.
- New Lumenworks token layer in base.css: --lux-bg-0..3, --lux-line(-bold),
--lux-ink(-dim/-mute/-faint), --ch-signal/-cyan/-magenta/-amber/-coral/
-violet channel palette, --lux-signal-glow, --lux-shadow-rack, all
theme-aware for dark + light. Existing tokens untouched for compat.
Shell (header + sidebar):
- Header rebuilt as a 3-column CSS-grid transport bar (brand | center |
toolbar) with a glowing LED brand mark rendered via pseudo-elements on
.header-title. Gradient channel-color rule under the bottom border.
- New sidebar.css introduces a vertical channel-strip nav. Active tab
gets a glowing left stripe + radial tint + LED pip. .sidebar-foot
contains a live CPU/FPS meter plate.
- Sidebar collapses to a 56 px icon rail at <=1100 px and hides via
display:contents at <=600 px so mobile.css's fixed bottom tab-bar
flows through unchanged.
Cards and dashboard:
- .card gets channel stripe (data-card-type + .ch-* utilities auto-map
from data-target-id / data-stream-id / data-automation-id etc.), corner
bracket, gradient background, subtle rack shadow.
- .card-running replaces the old @property --border-angle conic-gradient
rotating border with a lightweight signalFlow linear-gradient strip on
the bottom edge (cheaper paint, no GPU layer compositing per card).
- Skeleton loaders rewritten: left hairline + corner bracket + gradient
shimmer instead of the old text-color opacity pulse.
- .dashboard-target rows pick up the same channel-stripe + signalFlow
treatment. Section headers use mono micro-caps with a channel-green
underline accent consistent across the app.
- .perf-chart-card: channel stripe replaces old border-top; per-metric
accents moved to the channel palette (CPU=coral, RAM=violet, GPU=green,
temp=amber). Metric values use tabular-nums + a soft glow.
Live bindings (no new endpoints):
- _updateSidebarMeter: binds the sidebar Load + FPS bars to the existing
/system/performance poll.
- _updateTransportStatus: toggles the transport chip between "Ready" and
"Armed - N live" whenever the dashboard's running-target set is
recomputed.
Tree-nav + sub-tabs:
- tree-nav.css trigger pill gets a channel-stripe left edge that glows
when open; panel has a gradient channel-accent rule across the top;
group headers use silkscreened micro-caps; active leaf has a pulsing
LED pip + channel tint.
- .stream-tab-btn / .subtab-section-header adopt the same mono-caps +
channel-underline language for consistency.
- Graph editor toolbar gets gradient + hairline + rack shadow + backdrop
blur. Canvas and nodes untouched.
Modals (40+ modals share modal.css):
- Radial-dim + 6 px blur backdrop. Content gets a gradient background,
hairline border, deep rack shadow, top channel-accent rule driven by
--modal-ch, bottom-right corner bracket (hidden on mobile fullscreen).
- Per-modal-ID channel lanes: target editors = green, source/input
editors = cyan, audio = magenta, automation/scene/game = violet,
settings/auth = amber, confirm = coral.
- Modal headers: vertical channel stripe left of the title + hairline
divider. Modal footers: hairline top border + subtle gradient wash.
Forms:
- Inputs use hairline borders; number inputs switch to mono + tabular-nums
for column alignment. Focus state: channel-green ring + soft glow.
- Buttons use mono-uppercase type with signal-glow on primary and coral-
glow on danger.
Mobile (<=600 px):
- Fixed bottom .tab-bar gets the full Lumenworks treatment: gradient fill,
top channel-accent rule matching the transport bar, backdrop blur.
Active tab has an LED pip above the icon + channel tint + icon recolor.
- Fullscreen modals: corner bracket hidden, header stripe slimmed.
Microcopy (en / ru / zh):
- "Targets" -> "Channels" / "Каналы" / "通道"
- "Sources" -> "Inputs" / "Входы" / "输入"
- Internal tab keys (dashboard/automations/targets/streams/integrations/
graph) kept stable so no JS or localStorage migration is needed.
- Added: sidebar.workspaces, sidebar.load, sidebar.fps,
transport.status.ready, transport.status.armed.
Compatibility:
- All existing class hooks preserved (.tab-bar, .tab-btn, .card,
.card-running, .tree-dd-*, .cs-*, .perf-chart-card, .modal-content,
.dashboard-target, etc.). No JS or API changes required for the new
look to take effect.
- Tour selectors survive (header .header-title, #tab-btn-*, onclick
markers on theme/settings/search, #cp-wrap-accent, etc.).
- Mobile <=600 px bottom tab-bar keeps working via display:contents
fall-through in the new sidebar.
Build: tsc --noEmit clean; npm run build clean. CSS bundle grew from
~177 KB to ~201 KB for the full new visual system. Fonts loaded lazily
per unicode-range subset (~98 KB critical path for English).
Phased plan + deferred follow-ups (dashboard hero strip, legacy-token
cleanup) recorded at the top of TODO.md.
Reference mockup: server/docs/ui-redesign-mockup.html.
Removes the inlined FPS select and auto-refresh button from the shared
image lightbox and rehosts the Key Colors live preview inside the
dedicated test-css-source modal alongside the other CSS test views.
- Drop initLightbox() / lightbox-fps-select IconSelect — the lightbox no
longer owns streaming controls.
- Add #css-test-kc-view (canvas + meta) and .css-test-kc-* styles.
- Reroute _testKeyColorsSource() through the existing modal session
lifecycle so KC, CSPT, and standard CSS tests share teardown paths.
Two release-blocking bugs traced to the same root cause: the unanchored
`data/` rule in .gitignore matched server/src/ledgrab/data/, which is
where shipped package assets live (prebuilt sounds, game adapters).
The files were never `git add`-able without -f, so they never reached
the v0.4.2 tag and CI builds couldn't include them.
- .gitignore: anchor /data/ and /server/data/ so nested package data
dirs are not ignored.
- Track previously-excluded shipped assets:
- server/src/ledgrab/data/prebuilt_sounds/{alert,bell,chime,ping,pop}.wav
- server/src/ledgrab/data/game_adapters/{minecraft,rocket_league,valorant}.yaml
- Bump _FALLBACK_VERSION 0.3.0 -> 0.4.2 to match pyproject.toml.
The Windows installer strips ledgrab-*.dist-info, so
importlib.metadata falls back to this literal — which is why
v0.4.2 reports v0.3.0 in the WebUI.
- Patch _FALLBACK_VERSION at bundle time in build-common.sh and
build-dist.ps1 so future drift is auto-corrected by the build.
SP110E peripherals silently tear down the GATT link ~1s after connect
unless a two-write vendor handshake (01 00 → FFE2, 01 B7 E3 D5 → FFE1)
arrives immediately. Without it the first real write hangs 30s then
reconnect-loops forever. Adds optional BLEProtocol.init_writes executed
on connect, plumbs a per-write char_uuid through both transports, and
fixes the SP110E color/power frames from an incorrect 5 bytes to the
documented 4 bytes.
Windows/WinRT robustness:
- asyncio.wait_for hangs on bleak because WinRT IAsyncOperations refuse
to cancel. _bounded_await() uses asyncio.wait() instead so timeouts
actually return control even when the inner task is uncancellable.
- BleakClient connect by raw MAC string times out when WinRT guesses
address type wrong; switched to pre-scanning with BleakScanner and
passing the resolved BLEDevice, which carries the address type.
- Target-start fetch timeout bumped to 30s with retry disabled so the
UI doesn't abort during the BLE pre-scan + connect + handshake path.
UI:
- Settings modal exposes Protocol Family (IconSelect grid, shared with
add-device via parameterized ensureBleFamilyIconSelect) so users can
fix a wrong family pick without recreating the device. Govee AES key
row toggles on/off with family selection.
Also turns LAN auth back on in default_config.yaml, logs start_processing
requests on entry for easier diagnosis, and captures the full debug trail
in docs/BLE_LED_CONTROLLERS.md for future BLE work.
Refs the mbullington SP110E protocol gist for the handshake bytes.
End-to-end BLE streaming: provider + client + per-protocol wire encoders
with whole-strip averaging, desktop (bleak) and Android (Kotlin BleBridge
via Chaquopy) transports, discovery with protocol-family detection that
auto-fills the UI, throttled not-connected warning + 10 s reconnect
cooldown so a dropped link no longer stalls the pipeline at ~30 s/frame,
and an explicit asyncio.wait_for wrapper around bleak connect() since
the WinRT backend doesn't always honor the timeout kwarg.
Also rewrites server/restart.ps1 to be parameterized (-Port / -Module /
-PythonVersion / timeouts / -Quiet), pick the right interpreter via the
py launcher, pre-flight the target module, poll port readiness on both
shutdown and startup, redirect child stdout/stderr so Start-Process
doesn't hang on inherited Git-Bash handles, and return proper exit codes.
Rolls in concurrent work: Android BLE permissions + launcher icons + ru/zh
resources, Chaquopy-safe value_stream psutil fallback, setup-required
modal, asset-store test coverage, and misc system/config touch-ups.
On rooted TV boxes, spawn `su -c screenrecord ... -` and feed the
H.264 stdout through MediaCodec into an ImageReader, surfacing RGBA
frames via PythonBridge. RootScreenrecordEngine (priority 110) is
picked automatically when root is available; falls back to
MediaProjection when Root.requestGrant() returns false.
Drops the direct pyserial imports from espnow_client/espnow_provider
in favor of open_transport/list_serial_ports/port_exists. The gateway
protocol is write-only, so no read() extension was needed. ESP-NOW
gateways are now reachable via usb:VID:PID URLs on Android.
OutputTarget.fps is a BindableFloat but TargetSnapshot.fps is a plain
int — capture_current_snapshot was stuffing the BindableFloat directly
into the snapshot, causing json.dumps to fail on recapture (500) and
polluting the in-memory cache so subsequent list calls also 500'd with
pydantic validation errors.
Adds end-to-end support for driving USB-connected Adalight / AmbiLED
LED controllers from Android TV boxes. Android's security model blocks
direct USB access from Python, so writes route through a Kotlin
UsbSerialBridge singleton via Chaquopy.
Python side:
- New SerialTransport Protocol (serial_transport.py) with open / write /
flush / close. Desktop uses PySerialTransport (wraps pyserial),
Android uses AndroidSerialTransport (wraps the Kotlin bridge).
- list_serial_ports() factory returns desktop COM ports on desktop,
USB devices on Android — callers don't branch.
- URL scheme extended: existing COM3[:baud] and /dev/ttyUSB0[:baud]
unchanged; new usb:VID:PID[:serial][@baud] for Android (@ is the
baud separator since : is already used between VID and PID).
- AdalightClient and SerialDeviceProvider refactored to go through
the transport — no more direct pyserial imports in hot paths.
- 17 new unit tests cover URL parsing, PySerial transport, factory
selection, platform-branching discovery. Full suite 750 passing.
Kotlin side:
- UsbSerialBridge.kt singleton uses com.hoho.android.usbserial (mik3y)
which ships drivers for CH340, CP2102, FTDI, Prolific, and CDC-ACM
(Arduino). Exposes listDevices, open, write, close via @JvmStatic
for Chaquopy. First open() attempt without permission triggers the
system USB permission dialog; next call succeeds once user grants.
- usb-serial-for-android is distributed via JitPack — added that repo
in settings.gradle.kts and the dependency in app/build.gradle.kts.
- AndroidManifest declares uses-feature android.hardware.usb.host
(required=false so non-USB-host phones still install).
- LedGrabApp.onCreate calls UsbSerialBridge.init(this) so the bridge
resolves the UsbManager without needing an Activity ref.
Verified: ./gradlew compileDebugKotlin succeeds; off-Android import
of android_serial_transport works. Real-hardware smoke test on a
TV box with a CH340/CP2102/FTDI adapter still pending.
ESP-NOW (espnow_client / espnow_provider) still imports pyserial
directly because it needs bidirectional reads — separate refactor
to extend the transport with read() if that path ever needs Android
USB support.
Extends MetricsProvider with thermals() returning a ThermalSnapshot
(battery_percent, battery_temp_c, cpu_temp_c — all optional). Each
provider implements it independently:
- AndroidMetricsProvider reads /sys/class/power_supply/battery/{capacity,
temp} (battery temp is tenths of degC) and walks
/sys/class/thermal/thermal_zone*, filtering by zone type
(cpu/soc/tsens/core) so battery and skin sensors don't dominate the
reading. Rejects nonsense values like INT_MAX from buggy zones.
- PsutilMetricsProvider uses sensors_battery() and
sensors_temperatures() when present (Linux+laptops); no-ops on
Windows/macOS where psutil doesn't expose them.
- NullMetricsProvider returns the empty snapshot.
PerformanceResponse gains battery_percent / battery_temp_c / cpu_temp_c.
The metrics-history ring buffer also carries cpu_temp / battery_pct /
battery_temp per sample so the dashboard can graph them over time.
Frontend dashboard (perf-charts.ts) gets a new Temperature chart card,
hidden by default and revealed only after seed/poll confirms the
backend reports cpu_temp_c. Battery temperature shows inline as a
secondary badge. The GPU card now also hides entirely when the backend
reports gpu=null instead of showing an "unavailable" placeholder.
HOST_ONLY_KEYS prevents the System/App/Both toggle from flipping a
non-existent app dataset for temp.
Tests: 6 new for thermals (battery tenths-of-degC parsing, CPU zone
filtering, fallback when sensors absent, INT_MAX rejection); 18 metrics
tests total; full suite 733 passing.
Moves direct psutil.* calls behind a MetricsProvider Protocol so the
codebase no longer needs ad-hoc `if psutil is not None` guards at every
call site. Each provider lives in its own module under
utils/metrics/: PsutilMetricsProvider for desktop, NullMetricsProvider
as a zeroed fallback, AndroidMetricsProvider that reads /proc/stat,
/proc/meminfo, /proc/self/stat, and /proc/self/status directly (psutil
isn't available under Chaquopy). The Android provider tracks the
previous CPU sample so cpu_percent() returns delta-based percentages
matching psutil's interval=None semantics, and degrades to zeros when
any /proc file is unreadable instead of crashing the dashboard.
Factory get_metrics_provider() in utils/metrics/__init__.py picks
Android > psutil > Null. api/routes/system.py and
core/processing/metrics_history.py now go through the factory; psutil
import is confined to one place. 12 new unit tests cover paren-in-comm
parsing of /proc/self/stat, delta CPU%, missing-file resilience, and
factory selection order. Full suite: 727 passing.
Silences browser accessibility warnings on the HA token, MQTT username,
and MQTT password fields. Uses new-password for the secret inputs to
discourage Chrome's site-password autofill from leaking into broker /
HA-token configuration.
Adds a native Android TV application that runs the full LedGrab Python
server in-process via Chaquopy. Captures the TV box screen using the
MediaProjection API and exposes the existing web UI on the device's
local network — users configure via phone/tablet browser.
Android (new /android/ module):
- Kotlin shell: MainActivity, CaptureService (foreground service),
ScreenCapture (MediaProjection + ImageReader), PythonBridge (Chaquopy).
- Polished Leanback-themed UI with QR code for easy web UI access.
- AGP 8.9 + Chaquopy 17 + Gradle 8.11 (avoids the AGP 8.7 thread-lock bug).
- Pre-built pydantic-core wheels for arm64-v8a, x86_64, x86 cross-compiled
with maturin + Android NDK, linked against Chaquopy's libpython3.11.so.
Python server platform guards:
- New utils/platform.py with is_android()/is_windows()/is_linux() helpers.
- Guard every top-level import of desktop-only packages (mss, psutil,
sounddevice, pyserial, PyAudioWPatch, etc.) with try/except ImportError.
- Android-incompatible calls gated with None-checks so the server runs on
reduced capabilities on Android (no CPU/RAM metrics, no mss displays).
- utils/image_codec.py gains a Pillow fallback for resize + JPEG encode
when cv2 is unavailable; all internal cv2.resize callers migrated.
- New android_entry.py start_server/stop_server invoked from Kotlin.
- get_displays API falls back to best available engine when mss fails.
New capture engines:
- MediaProjectionEngine: receives RGBA frames pushed from Kotlin through
a thread-safe queue; caches last frame for static-screen previews.
- ScrcpyClientEngine: optional H.264 streaming via scrcpy-client library
(priority 10, overrides the ADB-screencap engine when installed).
Frontend:
- Tab loaders previously required an apiKey; now correctly treat
"auth disabled" as authenticated (Android has no auth by default).
- Re-trigger the active tab's loader after loadServerInfo resolves
authRequired, since initTabs runs earlier.
- Add i18n keys for the demo / mediaprojection / scrcpy_client engines.
Docs:
- TODO.md: follow-ups for multi-ABI wheel rebuilds, CI pipeline, USB
serial LED controllers, root-only capture, perf metrics abstraction.
- CLAUDE.md: Android dependency sync policy (pip --exclude doesn't exist).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pattern templates route existed but was never wired into the router,
dependencies, or database table allowlist — causing 404 on graph tab load.
Graph toolbar now collapses secondary actions into a "more" overflow menu
on viewports narrower than 700px. Primary controls (fit, zoom, add) stay
visible; search, filter, panels, undo/redo, relayout, fullscreen, and
help move into the dropdown.
Use stable placeholder values in card HTML for volatile metrics (fps,
uptime, HA status, entity swatches) so CardSection.reconcile() skips
unchanged cards. Actual values are patched in-place via
patchHALightTargetMetrics() — same pattern LED target cards already use.
After _populateWeatherSourceDropdown() and _populateProcessedSelectors()
create new EntitySelect instances, the subsequent .value = assignment on
the native <select> doesn't trigger _syncTrigger(), so the trigger
button still shows "—". Call .refresh() after setting the value.
Allow composite sources to reference other composite/mapped sources as
layers. Adds cycle detection (via transitive dependency graph walk),
depth limiting (MAX_COMPOSITE_DEPTH=4), and a runtime safety net in the
stream manager. Frontend layer dropdown now shows all source types
except the source being edited.
17 new tests covering cycles, depth limits, and valid nesting — all
715 tests passing.
Introduces a new "group" device type that aggregates multiple physical
(or nested group) devices into one virtual device. Supports two modes:
- Sequence: LEDs concatenated end-to-end (led_count = sum of children)
- Independent: full pixel array resampled to each child independently
Includes cycle detection (DFS) to prevent circular group references,
delete protection for devices referenced by groups, recursive LED count
resolution for nested groups, and reorder controls (move up/down) for
child devices in the UI.
Backend: Device model, API schemas, GroupLEDClient, GroupDeviceProvider,
route validation, processing pipeline integration.
Frontend: type picker, child device picker with reorder, mode selector,
i18n (en/ru/zh), layers icon, CSS for group child rows.
Tests: 20 unit tests for cycle detection, LED count resolution, and
GroupLEDClient (sequence slicing, independent resampling, cleanup).
Embedded Python ships with tcl8.6/ and tk8.6/ next to python.exe, but
Tcl's auto-detection searches <exe>/../lib/tcl8.6 — a path that doesn't
exist in our layout. Without these env vars, tkinter.Tk() raises
"Can't find a usable init.tcl", breaking the screen overlay (and any
other tk-based UI) on installed builds.
The packaged embedded Python distribution does not ship the tcl/tk
runtime, so tkinter.messagebox.askyesno crashed with 'Can't find a
usable init.tcl' when the user clicked Shutdown or Restart in the
tray menu. Use ctypes + user32.MessageBoxW instead — no tcl/tk,
no extra dependencies.
Three separate bugs in the VBS launcher wedged together:
1. The previous fix added a UTF-8 em-dash in a comment. wscript.exe
on Windows refused to execute the file with "Execution of the
Windows Script Host failed. (Not enough memory resources are
available to complete this operation.)" — a misleading error that
actually means "I could not parse this file as ANSI VBScript".
Fix: keep the file pure ASCII, convert to CRLF.
2. The launcher was invoking pythonw.exe. WshShell.Run spawning
pythonw.exe inside the wscript host exited immediately (no process,
no log). python.exe with WindowStyle=0 works reliably and matches
the pattern used by the Media Server sibling app's VBS launcher,
which has been running on this machine without issue.
3. The env vars (PYTHONPATH, WLED_CONFIG_PATH) must be set before the
child process spawns, otherwise config.py falls back to the CWD
default path that does not exist at install time.
The VBS launcher (used by Start Menu, desktop, and autostart shortcuts
created by the NSIS installer) ran pythonw.exe without setting any env
vars. LedGrab.bat sets PYTHONPATH and WLED_CONFIG_PATH; the VBS did not.
With CWD set to the install root, config.py fell through to its default
lookup (./config/default_config.yaml), which does not exist there — the
real file is at app/config/default_config.yaml. The server silently ran
with built-in defaults on every shortcut launch: no devices, wrong data
dir, nothing persisted where the user expected.
The fix uses WshShell.Environment("Process") to set env vars on the
current VBS process, which child processes spawned via .Run inherit.
Kept CurrentDirectory = appRoot to preserve prior behavior for anyone
depending on CWD-relative paths inside the app.
The only user of 'packaging' was version_check.py — two small functions
(normalize_version, is_newer) that just need to parse "1.2.3-alpha.1"
and compare PEP 440-style versions. That's well within stdlib reach.
- Inline a NamedTuple-based Version with kind/pre_num ordering
(dev < alpha < beta < rc < release), same regex-normalized format
- Define a local InvalidVersion exception
- Remove packaging>=23.0 from pyproject.toml dependencies
Why now: the Windows cross-build uses a hard-coded DEPS array in
build-dist-windows.sh, which was never updated when 'packaging' was
added on March 25. Result: importable from pip-installed dev envs,
missing from the portable installer — tray icon appeared but uvicorn
died with ModuleNotFoundError: No module named 'packaging'.
Removing the dep entirely is cleaner than adding one more hard-coded
entry to the Windows DEPS list. Tests (678 passing) and a manual test
matrix covering dev/alpha/beta/rc/release ordering all pass.