Turning the single-purpose now-playing app into a generic, Rainmeter-style widget
platform — so the existing Rainmeter skins under C:\Users\gng\Documents\Rainmeter\Skins\gyng
can move here, with better layouting for the Corsair Xeneon and easier config
than hand-editing .ini files.
Status: shipped to
main— full sensor/meter platform, transparent multi-monitor click-through overlay, visual editor (drag/resize/snap/align/inspector/palette), persistence
- live-reload, tauri 2.11 + global hotkey, renamed to
widgetsack(frontend later ported Svelte→React), all verified on hardware. Remaining stretch items are deferred/skipped (see Phase 4).Phases 5 & 6 implemented (gates green; pending hardware verification). The layout designer (containers row/col/grid, align/gap/pad/justify/grow, floating, outline, drag-and-drop reorder/dock, taskbar-aware work area) and the widget designer (group/ungroup → reusable
WidgetDef, in-place def editor, params/sensor-remap, library insert/rename/delete-guard) both run on one recursivecore/model + one pure solver, rendered on the per-monitor overlay and editable in a studio app window (model A′). 152 client tests + 6 Rust tests;cargo build/test/clippy+npm check/lint/test/buildall green. Only the native passive-mode right-click menu (5d) is deferred — see Phase 5d. See "Layout tree + groups (v2)" and Phases 5–6.
- Replace Rainmeter with a homegrown, web-tech-themed widget system.
- Better layouting on the Xeneon; easier editing/config than
.ini. - Keep current wins: transparent borderless window, user-CSS theming, OBS capture, monitor management, now-playing via GSMTC.
- Stay framework-portable: the UI has since been ported from Svelte to React, so the rendering framework must stay a thin, swappable layer over a framework-agnostic core.
Source: C:\Users\gng\Documents\Rainmeter\Skins\gyng
| Skin | Contents | Maps to |
|---|---|---|
| DateTime | time %H%M, day-of-week (pictographic font via Substitute), date %d, month %B upper |
clock / text meter, no backend |
| System | CPU avg (smoothed), 32 per-core LINE graphs in an 8×4 grid, RAM %, Swap %, GPU load (UsageMonitor), dedicated VRAM (~10 GB card) | cpu.total, cpu.core.N, mem.used, swap.used, gpu.util, gpu.vram → gauge/bar/sparkline |
| Network | NetIn/NetOut mirrored histograms (one flipped) + autoscaled text | net.down / net.up → sparkline + text |
| Music | cover, progress bar, position/duration, title, artist | already covered by current NowPlaying widget |
Palette: white text 255,255,255; pale-green labels 218,237,226; teal accent 119,196,211.
Fonts: a condensed grotesque (Bahnschrift by default); a pictographic day-of-week font in DateTime (see Risks → fonts).
- Window model: A′ — overlay canvas + studio window (revised 2026-06-02 from
overlay-only). Two surfaces, clean split: a transparent, normally click-through overlay
per monitor is the display + in-place layout canvas (live widgets, selection, drag/
resize/align, native context menu); a normal, focusable, taskbar-present studio window
(
/studioroute, own capability label) is the design surface — widget designer, library, params, per-monitor outline, settings. Both consume the samewidgets.json/widgets.lib.json(single source of truth) and the framework-agnostic core; a lightweight in-memorylayout_draftevent channel keeps the two surfaces live during a drag, with the file as the commit point. One webview per monitor stays efficient even with 32 core graphs. Mirrors Rainmeter (skins on the desktop + a Manage window). See "Window topology" below. - Editing: Both.
widgets.json+ live reload is the foundation; an in-app visual editor (edit-mode toggle: drag/resize/snap, palette, inspector) is layered on top, reading/writing the same JSON. - GPU: NVIDIA (NVML) + generic perf-counter fallback. NVML for clean load/VRAM/temp; Windows perf counters (PDH) as the vendor-agnostic fallback — the same source Rainmeter's UsageMonitor uses. Only NVIDIA is confirmed present.
- Framework-portable core (now React). All durable logic lives in a
framework-agnostic TypeScript core with zero framework imports. Only the component
layer (meters, canvas, editor) is React. The Svelte→React port reused the core verbatim
and reimplemented only the components. See "Framework portability" below — this
constrained every phase, not just the UI. Outcome: components reimplemented,
core/shared unchanged (no web-component investment up front). - Z-order & input: always-on-top overlay, click-through by default, per-region interactivity required. Some widgets (now-playing controls, the editor) must catch clicks while the rest passes through. This is in scope, not deferred, and must be perf-conscious (see Risks → click-through). WorkerW "on-desktop" z-order stays out of scope.
- Multi-monitor from day one, no cross-monitor widgets (yet). One overlay window per monitor on all monitors; each widget is bound to a single monitor; no spanning/dragging widgets across monitors initially.
- Fonts: system fonts by default, any installed font allowed. Default to Bahnschrift (ships
with Windows); any widget can set
fontFamilyto an installed font, which the system-fonts loader @font-faces by name so it resolves even for per-user installs (and the day-pictograph font). Avoids bundling/licensing. - Sensor cadence: per-sensor configurable interval, 1 Hz default.
- Rename
np→widgetsack. Product identity change; staged as its own commit (Phase R) given the build-path blast radius — see Environment notes.
Rust (widgetsack/) Framework-AGNOSTIC core (client/src/lib/core/, no framework)
┌──────────────────────────┐ ┌──────────────────────────────────────────┐
│ sensors: trait + sched │ emit │ telemetry client: 1 listener → per-sensor │
│ - system (sysinfo) │"tele- │ latest + ring-buffer history │
│ - gpu (nvml/PDH) │ metry"│ subscribe(id,cb)->unsub + getSnapshot │
│ - media (gsmtc, push) │──────▶│ layout: schema + validate + migrate │
│ commands: │ │ sensors: id catalog + metadata │
│ list_sensors │◀─────▶│ format: clock / byte-rate autoscale / % │
│ load_layout/save_layout │invoke │ geometry: snap / align / rect │
└──────────────────────────┘ └───────────────┬────────────────────────────┘
│ │ (consumed via tiny adapter)
widgets.json (app data dir) ┌───────────┴───────────────┐
notify file-watch → live reload │ React layer (swappable) │ ← ported from
│ stores ← core.subscribe │ Svelte; swaps
│ registry: type → cmpt │ only this box
│ Canvas (per monitor) │
│ └ WidgetHost × instances │
│ └ meters, editor │
└────────────────────────────┘
Studio window (normal, taskbar) Overlay per monitor (transparent, click-through)
route /studio route /?monitor=<i>
┌────────────────────────┐ ┌── Monitor 1 ──┐ ┌── Monitor 2 ──┐
│ widget designer │ layout_ │ live widgets │ │ live widgets │
│ library · params │ draft ┌───▶│ + in-place │ │ + in-place │
│ outline · settings │──events───┘ │ editing │ │ editing │
└───────────┬────────────┘ └───────┬───────┘ └───────┬───────┘
│ save_layout / save_library (commit) load + listen │
└──────────────┬──────────────────────────────┴────────────┘
▼
widgets.json + widgets.lib.json (single source of truth, notify-watched)
- Overlay (built): display + WYSIWYG layout editing. Stays transparent/click-through; selection, drag/resize, alignment guides, and the native context menu live here.
- Studio (new): a normal
WebviewWindow(decorations, taskbar, focusable) on a/studioroute with its own capability label. Hosts the heavy panels and the widget designer. - Sync: the file stays the source of truth (commit +
notifylive-reload, as today). On top, transientlayout_draftTauri events mirror in-progress edits between studio ↔ overlays so dragging on the canvas updates the outline (and vice-versa) without a per-frame file round-trip. - Entry: tray "Open designer", the global hotkey, and the overlay's right-click → "Open designer / Edit layout" all focus (or spawn) the studio window.
These three contracts are the platform. They must not depend on any UI framework; a React port keeps them unchanged.
One telemetry event per tick carrying a batch (cheaper than one event per sensor;
32 cores = 1 event/tick, not 32). Media is push-based and emits its own samples on
change. Backend only runs sensors referenced by the active layout (subscription), so
NVML/PDH don't spin when no GPU widget exists.
type SensorValue =
| { kind: 'scalar'; value: number }
| { kind: 'text'; value: string }
| { kind: 'series'; value: number[] }
| { kind: 'json'; value: unknown }; // media session payload, etc.
type SensorSample = { sensor: string; tsMs: number; value: SensorValue };
type TelemetryBatch = SensorSample[]; // payload of the `telemetry` eventenum SensorValue { Scalar(f64), Text(String), Series(Vec<f64>), Json(Value) }
struct SensorSample { sensor: String, ts_ms: u64, value: SensorValue }type WidgetInstance = {
id: string;
type: string; // 'clock'|'gauge'|'bar'|'sparkline'|'text'|'nowplaying'
sensor?: string; // 'cpu.total','cpu.core.3','net.down',... (omit if self-sourcing)
rect: { x: number; y: number; w: number; h: number }; // per-monitor logical px
layer?: 'top' | 'desktop'; // z-layer (3c); routes widget to that layer's overlay window. default 'top'
config: Record<string, unknown>; // min,max,format,color,historyLen,unit,...
css?: string; // per-widget style override (current ThemeInjector)
};
type Layout = {
version: number; // for migrations
monitors: Record<string, { widgets: WidgetInstance[] }>; // key = monitor id
};Meters are either sensor-bound (gauge → cpu.total) or self-sourcing (clock →
local Date); sensor is therefore optional. Each per-monitor overlay window is labelled
with its monitor id and renders only layout.monitors[<its id>].
type SensorMeta = {
id: string; // 'cpu.total','net.down',...
kind: SensorValue['kind']; // scalar|text|series|json
unit?: string; // '%','B/s','MiB',...
min?: number; max?: number; // suggested gauge/bar bounds
available: boolean; // false e.g. GPU sensor with no NVIDIA driver
};list_sensors() -> SensorMeta[], load_layout() -> Layout, save_layout(Layout).
The backend owns the layout file, so on load/save it derives the set of referenced
sensor ids and runs only those (no NVML/PDH spin-up when unused). Live reload is driven by
a notify file-watch on widgets.json emitting a layout_changed event the frontend
listens for.
Home Assistant (Phase 8c). ha_connect() / ha_disconnect() start/stop the server-side
WS proxy task; list_ha_entities() -> HaEntity[] feeds the inspector dropdown (REST);
ha_call_service(domain, service, data) -> Value actuates (REST); save_ha_config(url, token)
writes plugins/ha.json; ha_config_status() -> { configured, url } — never returns the token.
HA entity state arrives over the existing telemetry event as ha.<entity_id> (plus
ha.<entity_id>.state when numeric, and ha.status for the connection state).
Two designers, one recursive model:
- Layout designer (Phase 5) arranges units on a monitor with containers (panes/splits), alignment, gap/padding, and a floating layer that escapes the grid.
- Widget designer (Phase 6) composes primitives into a group ("a logical widget") with its own internal layout; a group is one unit the layout designer places.
A unit is a primitive meter (today's gauge/bar/sparkline/text/clock/button)
or a group. Same node grammar at both levels — a group is a leaf to the layout solver
and a container-root to the widget designer — so a single pure solver recurses through both.
type Length = number | 'auto' | { fr: number }; // px | intrinsic | flex share
type Container = { // the layout designer's panes/splits
id: string; kind: 'row' | 'col' | 'grid'; // hsplit=row, vsplit=col, multi-pane=grid
cols?: number; // grid: 2 | 3 | 4
gap?: number; pad?: number | { t: number; r: number; b: number; l: number };
align?: 'start' | 'center' | 'end' | 'stretch'; // cross axis
justify?: 'start' | 'center' | 'end' | 'between' | 'around'; // main axis
bounds?: Rect; // default = parent content box (root → work area)
children: LayoutNode[];
};
type Group = { // built in the widget designer; one unit to layout
id: string; kind: 'group'; name?: string; // "System", "Network"
def?: string; // id of a reusable WidgetDef; inline subtree if absent
size: { w: number; h: number }; // the group's own box (its intrinsic size)
child: LayoutNode; // internal tree, local coords
config?: Record<string, unknown>; css?: string;
};
type Leaf = { id: string; unit: WidgetInstance | Group; basis?: Length };
type LayoutNode = Container | Leaf;
type MonitorLayout = { root: Container; floating: Leaf[] }; // flow tree + escape-the-grid layer
type Layout = { version: 2; monitors: Record<string, MonitorLayout> };
// reusable library (Phase 6c/d) — instantiate one def many times, rebinding params
type WidgetDef = { id: string; name: string; size: { w: number; h: number }; child: LayoutNode; params?: ParamSpec[] };Pure solver (the heart). solveLayout(node, contentRect) -> Map<widgetId, Rect> recurses:
containers distribute the main axis by basis/fr, place the cross axis by align, apply
gap/pad/justify; a group solves its child inside its own size box and contributes
that box as the unit's intrinsic size; a primitive contributes its rect.{w,h}. No text
measurement — intrinsic size is what you set in the inspector. Lives in core/ (zero framework deps),
unit-tested like align.ts. Canvas renders solved primitive rects; WidgetHost is unchanged.
Migration v1 → v2 (no data moves). widgets[] → floating: widgets.map(leaf), root =
empty container. Existing/demo layouts render identically (all floating). parseLayout accepts
both versions; rect means absolute monitor px for floating units and local/solved coords
in-flow or inside a group.
The pivot that made the React port cheap: Svelte's store contract and React's
useSyncExternalStore both consume subscribe(cb) => unsubscribe. So the core exposes
a minimal notify-based observable per sensor; each framework adds a ~5-line adapter — which
is exactly how the port landed.
// core/telemetry.ts — no framework imports
interface SensorObservable {
subscribe(cb: () => void): () => void; // notify-only
getSnapshot(): { value: SensorValue; history: number[] };
}// before — svelte/store adapter (Svelte store = { subscribe(run) })
const sensorStore = (id) => ({
subscribe: (run) => { const o = core.sensor(id); run(o.getSnapshot());
return o.subscribe(() => run(o.getSnapshot())); }
});// after (current): useSensor.ts
const useSensor = (id) => { const o = core.sensor(id);
return useSyncExternalStore(o.subscribe, o.getSnapshot); };Repo structure to enforce the boundary
client/src/lib/core/— telemetry, layout, sensors, format, geometry, commands. Zero React / framework imports. Unit-tested with vitest (no DOM).client/src/lib/widgets/— React: registry,WidgetHost, meters,Canvas, editor.- The React port imports
core/unchanged (it happened in place; theclient-react//packages/split never became necessary).
Rule of thumb: if it would be rewritten for React, it must not live in core/; if it
would be copy-pasted, it must. Meters are the gray area — kept in the component (React)
layer; web-components remain a later option only for runtime plugin loading (see Open decisions).
- Per-region click-through (headline risk, in scope).
set_ignore_cursor_eventsis all-or-nothing per window; Tauri has no native per-region hit-testing (#2090, #9250). Modes:- Normal + no interactive widgets → window fully click-through; no watcher runs.
- Edit mode → window fully interactive (ignore-cursor off); no per-frame work.
- Normal + ≥1 interactive widget → cursor watcher hit-tests against the interactive rects (synced frontend→Rust only on layout/edit-state change, not per frame).
- Perf rules (the "perf considerations"):
- Watcher only exists when the active layout has an interactive widget.
GetCursorPosis ~free; the cost isset_ignore_cursor_events(webview IPC) — so toggle only on state transitions, never per frame, with edge hysteresis to avoid flapping.- Short-circuit with a precomputed union bbox of interactive rects before per-rect tests.
- Pause the watcher when the overlay is hidden/occluded.
- Start with a gated
GetCursorPospoll (~60 Hz only while needed); if CPU shows up, switch to an event-drivenWH_MOUSE_LLlow-level hook (≈0 CPU when idle).
- Z-order / "stay on desktop." Always-on-top is a one-call toggle. True Rainmeter-style "pinned to desktop, below windows" needs WorkerW/Progman parenting (wallpaper-engine technique) — complex and Windows-specific. Scope: ship always-on-top + normal now; WorkerW is a Phase 4 stretch, flagged as optional.
- GPU. NVML (
nvml-wrapper) loadsnvml.dllat runtime → no build dep; must degrade gracefully if absent (no NVIDIA driver) and fall back to PDH counters (windowscrate) or disable GPU sensors. VRAM math mirrors the skin (dedicated / total). - DPI / multi-monitor. Overlay must cover each monitor exactly; Tauri mixes
physical/logical px and per-monitor scale factors. Reuse existing
monitor.tslogic; store rects in per-monitor logical px. - Fonts. Default to Bahnschrift (ships with Windows; no bundling/licensing). Widgets resolve system-installed fonts via the system-fonts loader (fontdb → @font-face), so a configured font and the custom day-pictograph font work when present, incl. per-user installs; fall back to the token stack when absent.
- Phase 0 refactor risk. Moving all window/monitor/settings logic out of
the legacy
NowPlayingcomponent and onto the canvas is the riskiest change. Mitigation: keep the current page working behind a flag/route until the canvas reaches parity; don't delete the old path until then. - Sparkline rendering. 32 sparklines @ ~1 Hz: SVG is fine and simplest. Switch to
<canvas>only if update rate climbs. Decide per-meter, not globally. - Media event weight. Thumbnails are sent as byte arrays in events today; folding
media into
telemetrymust not fatten every batch. Keep media push-only-on-change and consider aget_thumbnail(sessionId)command instead of inlining bytes (revisit).
-
sysinfodep +widgetsack/src/sensors.rs: loop emitstelemetrybatch withcpu.total(+ serde-contract test). - Wire sensor loop into
main.rssetup. -
core/telemetry.ts: framework-agnostic hub (per-sensor observable, ring buffer) + tests. - Store adapter (
createStore) +Gaugemeter (puregaugeFraction+ tests) +Canvas/WidgetHost/registry; CPU gauge mounted on the page. - All gates green:
npm run check/lint/test:unit/build,cargo test/clippy. - Visual confirm:
cargo tauri dev→ live CPU gauge (run by user). Checkpoint.
Identifier io.github.gyng kept so the app data dir / saved settings aren't orphaned.
- crate
name/default-run/description →widgetsack(binarywidgetsack.exe). - root
Cargo.tomlworkspace member; dirnp/→widgetsack/(git mv; before-command /frontendDistpaths are relative to the conf dir so they still resolve toclient/). -
tauri.conf.json:productName→widgetsack, windowtitle→WidgetSack. -
.github/workflows/build.yml: pin a current v2 tauri CLI (old rc.0 can't parse 2.11). - README — deferred (has uncommitted local edits). localStorage key
_mediaStoreleft as-is.
- Establish
core/(zero framework) vswidgets/(React) split; move types intocore/. - Widget registry +
WidgetHost(dynamic component lookup), instance-as-data. - Make NowPlaying one registered widget; extract window/monitor/settings off it (behind flag).
- Canvas becomes the main view; nothing lost.
- sysinfo:
mem.used,swap.used(%),net.up/net.down(B/s) — Phase 1a, with purepercent/rate_per_sectests; CPU + RAM gauges wired on the canvas. - sysinfo:
cpu.core.Nper-core scalars (Phase 2a) — one ring-buffered sensor per core. - GPU: NVML
gpu.util/gpu.vram/gpu.tempwith graceful degrade (NVML init fails → skipped, no crash) — Phase 1b. PDH fallback for non-NVIDIA still pending. - Totals + Rainmeter parity (zero-dep): absolute-byte sensors alongside the percents —
mem.{total,used.bytes,available,free},swap.{total,used.bytes,free},gpu.vram.{total,used,free}; plusnet.total+ cumulativenet.{down,up}.total, dynamic per-drivedisk.<letter>.{total,free,used,used.pct}(≈ FreeDiskSpace),host.uptime(+ adurationformatter) /host.procs,cpu.{freq,brand,cores.logical,cores.physical}, andgpu.{mem.util,clock.core,clock.mem,power,power.limit,fan,name}. The percent ids are kept for back-compat; the GPU demand-gate became agpu.*prefix, and the now-expensive refreshes (processes, cpu frequency, disks) are gated behind their own active-id checks. - Battery (Windows, when present):
battery.{percent,state,time}viaGetSystemPowerStatus(no new crate;Win32_System_Powerfeature). Desktops with no battery emit nothing. - More Win32 sensors (windows crate, no new crate; gated): one
GetPerformanceInfo→mem.commit.{used,limit,peak}/mem.cached/mem.kernel.{paged,nonpaged}(bytes) +host.handles/host.threads; oneCallNtPowerInformation(ProcessorInformation)→ livecpu.freq.current/cpu.freq.max+ per-corecpu.core.N.freq(MHz — sysinfo'scpu.freqis only the BASE clock on Windows, so this is the boost clock it can't give);host.idle(seconds since last input, GetLastInputInfo). New features:Win32_System_ProcessStatus,Win32_System_SystemInformation,Win32_UI_Input_KeyboardAndMouse. - More Win32 (round 2): live disk I/O
disk.<letter>.{read,write}(bytes/s) +disk.<letter>.busy.pct(active time) viaDeviceIoControl(IOCTL_DISK_PERFORMANCE)on a zero-access volume handle, with a per-volume prev-sample cache (featuresWin32_Security+Win32_Storage_FileSystem+Win32_System_IO+Win32_System_Ioctl); richer batterybattery.rate(W, signed) +battery.capacity.{full,remaining}(Wh) viaCallNtPowerInformation(SystemBatteryState); network linknet.linkspeed.{rx,tx}(bytes/s — the missing %-utilisation denominator) +net.adapter/net.state(text) from oneGetIfTable2walk picking the primary connected adapter (featuresWin32_NetworkManagement_IpHelper+_Ndis). All gated (is_disk_io_id/is_netlink_id; battery presence-gated). - Still deferred:
audio.peakfor a visualizer (IAudioMeterInformation; wants its own ~30–60 Hz timer + COM thread — and the parallel audio-spectrum widget already owns the WASAPI/COM-audio edge, so coordinate there). Thermal stays out — only the ACPI passive zone is reachable from Win32 (usually a static trip point); real die temps need a kernel driver (LHM-class). Also available-but-skipped:net.tcp.{established,retrans}, disk IOPS, net error counters. - CPU/board temps + fan RPM — deferred:
sysinfo'sComponentsis empty on Windows and NVML only exposes the GPU die temp (alreadygpu.temp). Real CPU/VRM temps + fan RPM need a LibreHardwareMonitor bridge (often admin + a running service) — out of proportion to value for now. Likewise GPUpcie/encoder/decoderutil are available via NVML but left out as niche. - Media (GSMTC) re-expressed as a push sensor emitting
Jsonunder the same contract. - Sensor catalog for the editor: live ids from the telemetry hub (
sensorIds) + a curated list → inspector<datalist>(testedsensorCatalog). Replaces a Rustlist_sensors. - Deferred (low value): per-sensor configurable interval; running sensors only when referenced by the layout — all current sensors are cheap at 1 Hz.
-
Gauge(Phase S) +Sparkline(Phase 2a, pure geometry + tests); per-core CPU row + net sparkline on the canvas. -
Text(byte-rate/percent formatters) +Clock(moment-like tokens,[literal]escaping) — Phase 2b, pure formatters + tests; clock/date + net up/down readouts on the canvas. -
Barmeter (horizontal/vertical fill; shares purefractionscale with Gauge) — Phase 2c. Core meter set complete. - Carry palette + fonts (system
fontFamilyoverride). Rebuild DateTime / System / Network as instances; reach parity.
-
widgets.jsonin app config dir;load_layout/save_layoutcommands + pureparseLayoutvalidation (Phase 3a, tested). Canvas loads it on mount, falling back to the demo default. -
notifyfile-watch on widgets.json →layout_changed; Canvas live-reloads external edits (ignored while actively editing) — Phase 3b. - 3c-1 (single overlay): transparent, always-on-top, skip-taskbar window filled to
its monitor;
setIgnoreCursorEvents(true)normally,falsein edit mode (whole-window click-through). Edit toggled by a tray menu ("Edit layout" / "Quit").layerfield added (defaulttop); NowPlaying legacy positioning disabled (overlay owns the window; settings stay reachable in edit mode). New capability fileoverlay.json. Verified on hardware (overlay fills monitor, clicks pass through, tray toggles edit). - 3c-1 follow-up — global hotkey (Ctrl+Alt+E) ✅ done (29b9ac8). Bumped the whole
tauri stack to 2.11 (a fresh lock resolve; the earlier conflict was pinned
tauri 2.8.5vs a 2.10tauri-runtime-wrythe plugin pulled). Registered in Rust; broadcaststoggle_editlike the tray/Ctrl+E. Aligned@tauri-apps/apito ^2. Verified on hardware. - 3c-2 (multi-monitor): the primary window spawns a click-through overlay per other
monitor (
?monitor=<i>); each window renders/saves only its monitor's widgets (read-modify-write, no clobber). NowPlaying renders on the primary only. Capabilityoverlay.jsoncoversoverlay-*(create-window + window perms + core:default). Edit toggles broadcast (tray + Ctrl+E) so all monitors stay in sync. Verified on hardware (multi-monitor overlays, per-monitor add/persist, Ctrl+E all). - Visual editor v1: Ctrl+E edit mode, drag-to-move with snap-to-grid (tested geometry), save-on-drop to widgets.json — Phase 3d.
- Editor: corner/edge resize handles with tested
resizeRect— Phase 3d-2. - Editor: widget palette (add) + inspector (sensor / x/y/w/h / config JSON) + select + remove — Phase 3d-3.
- Editor: alignment guides — snap a dragged widget's edges/centres to peers, with
teal guide lines (pure tested
snapRectToPeers) — Phase 4. - Per-monitor layouts; Xeneon arrangement. Migrate existing localStorage settings.
- 🚫 DEFERRED — 3c-3 (desktop-pinned layer): a second overlay window per monitor pinned
to the wallpaper via WorkerW/Progman; widgets with
layer: 'desktop'route to it. Fragile / Windows-version-specific. Deferred indefinitely — always-on-top covers the need; thelayerfield is already in the model so it can be picked up later additively. - Per-widget click-through (Phase 4, verified on hardware): widgets flagged
interactivecatch clicks in passive mode. A Rust cursor watcher (clickthrough.rs, ~60 Hz, idle when none, toggles on transition) hit-testsapp.cursor_position()against per-window screen rects synced from the frontend, flipping that window's ignore-cursor-events. Addablebutton(counter) widget is the fixture; three-way test passed (interactive catches, meters/empty pass through). - Editor: alignment guides — snap a dragged widget's edges/centres to peers, with
teal guide lines (pure tested
snapRectToPeers) — Phase 4. - 🚫 SKIPPED — PDH GPU fallback for non-NVIDIA (and temps/fans via HWiNFO/LHM shared memory). The target machine is NVIDIA (NVML covers it) and there's no non-NVIDIA hardware to verify against — not worth building blind. Revisit only if needed.
A real designer, not just free-drag: containers (multi-pane 2/3/4, h/v split) with align + gap/padding, a floating layer that escapes the grid, taskbar awareness, and a right-click context-menu entry. Additive — v2 migrates v1 to all-floating, so nothing already placed moves. Operates on units (primitives or groups); groups are atomic here (authored in Phase 6).
Design decisions (proposed; confirm in Open decisions):
- hsplit =
row, vsplit =col, multi-pane =grid(cols2/3/4). "Align L/C/R" maps tojustifyon a row andalignon a column; the inspector labels them in human terms. - Floating stays the default (today's behavior); the flow
rootis opt-in. - Root bounds default to the monitor work area (taskbar-aware); floating units may sit over the taskbar.
- Context menu = native (muda) shown by the Rust right-click detector, so it works in passive (click-through) mode without a webview round-trip.
-
core/layoutTree.ts(types +Container.basis+ constructors) andcore/solve.ts(solveLayout/solveMonitor/resolveGroup: row/col/grid, gap/pad/align/justify, fr/auto/px, group namespacing). Unit-tested hard (50 cases) — the heart of the feature. Designed + adversarially test-spec'd via a 5-agent workflow; critique fixes folded in (grid-cell clamp, children-derived grid intrinsic, fail-closed param setPath, rect re-validation on migrate, array-monitors rejection, exact-float render = no snap seam). -
core/migration.ts:parseLayoutAnyaccepts v1 + v2;migrateV1wrapswidgets[]into floating leaves (dropping malformed rects). Tested (10 cases). -
core/layoutEdit.ts: pure immutable tree ops (find/parent/insert/remove/move/update + flowLeaves/allContainers) — the shared editing core for 5c/5e/6a. Tested (16 cases). - Canvas migrated to the v2
MonitorLayout+ v2 file format; renders/edits the floating layer identically (root round-trips). Flow rendering of theroottree → 5c.
- Rust
current_work_area(window) -> ScreenRectviaMonitorFromPoint+GetMonitorInfoW.rcWork(windows0.61 crate, matched to Tauri's; no HWND so no version mismatch). Returns physical px. - Frontend
monitorWorkArea()rebases to the monitor origin + descales → local logical px; the Canvas solves the flow tree into the work area (taskbar excluded), falling back to the full window. (Work-area snap-guide line is a small follow-up.)
- A normal, decorated, taskbar-present
studioWebviewWindow(980×680) spawned from the tray "Open designer" item (Rust emitsopen_studio; the primary overlay spawns/focuses it via JS — reuses the workingWebviewWindowpath). Detected by window label (not a route/query — sidesteps dev/prod URL + prerender pitfalls). Capabilityoverlay.jsonnow covers"studio". - Studio runs the same
Canvasin studio mode: opaque window, always in edit mode, no overlay fill/click-through; lays the layout into the whole window. It edits the primary monitor and syncs to the overlays viawidgets.json+ the existinglayout_changedwatch (the file is the source of truth — a transientlayout_draftlive channel is a follow-up if the file-watch lag ever shows). - [~] Panels currently live in both the overlay edit mode and the studio (the overlay editor still works). Fully relocating them off the overlay is optional polish.
- Container panel (Inspector): kind, cols, gap, pad, align, justify, grow(fr) — live-editing the selected container. Widget panel gains dock/float; add-widget palette retained.
- Palette "+Row / +Col / +Grid" (Outline header) — adds into the selected container or root.
- Tree-outline (
Outline.tsx+ pureoutlineRows): select / reorder (↑↓) / reparent (⟸ out, ⟹ in) / dock (⤒) / float (⤓) / remove (✕). All edits funnel through oneopunion →handleOp→core/layoutEdit. Overlay solves + renders the flow tree (collectRenderables, group-aware); the reusablelibraryround-trips inwidgets.json.
- In-editor context menu: right-click any widget →
Make widget·Float/Dock →flow· (group)Edit def…/Ungroup·Remove; right-click empty canvas →+ Row/+ Column/+ Grid. Pure React (WidgetHostemitscontextmenu, Canvas renders a positioned menu + backdrop; Esc / click-away closes). Gate-verified. - 🚫 Passive-mode (right-click while NOT editing) deferred — to fire over a click-through
overlay without hijacking/duplicating the Windows desktop menu it needs a global
WH_MOUSE_LLhook that swallows right-clicks over widget rects (invasive: global input, threading/deadlock risk) and can't be runtime-verified without an interactive display. The editor already opens three robust ways (tray "Edit layout", tray "Open designer", Ctrl+Alt+E), so this is an optional follow-up — best done in a session where it can be tested on hardware.
- Overlay: in-flow widgets ghost-drag to reorder/reparent with a live teal insertion
bar; pure
dropTarget(root, solved, point, draggingId)(leaf-hit-test →{parentId,index}) feeds straight intomoveNode. Tested. - Overlay: dock a floating widget by releasing it over the flow tree; float a flow widget by dragging it onto empty canvas (re-anchored at the cursor). Floating free-move + snap/guides retained.
- Studio outline drag-reorder — deferred to 5s (the outline already reorders via buttons; HTML5 DnD doesn't cross OS windows, so studio→overlay placement stays click-to-add-then-drag).
Group a bunch of labels/meters into one composite — "a widget" — that's reusable from the
start: grouping creates a named WidgetDef in a first-class library, and the layout holds
instances (Group with def) that reference it. A separate view in the studio window
(its own tab, not the overlay), but it reuses the same containers / solver / inspector scoped to
the def's box. The
headline payoff: System's 32 per-core graphs become one def × 32 instances, each binding a
different cpu.core.N via params (6c) — not 32 hand-placed sparklines.
The solver gains a pure resolveGroup(group, library) (look up def, apply param overrides,
solve its child inside size); kept in core/ and tested. The library is its own file
(widgets.lib.json), watched + reloaded like widgets.json.
-
Library(WidgetDef[]) embedded inwidgets.jsonunder alibrarykey (loaded/saved with the layout, round-tripped read-modify-write across monitors) — avoids new Rust commands; can split intowidgets.lib.jsonlater if sharing defs warrants it. - Make widget: select a flow container/widget (or a floating widget) → wraps it into a new
WidgetDef(size=intrinsicSize,child= cloned subtree) + aGroupinstance referencing it. Ungroup inlines the def's child back (ungroupNode, pure + tested; floating single-primitive groups handled in the Canvas). Group inspector panel added. -
resolveGroup+collectRenderables(group-aware, id-namespaced) render instances on the canvas — one def × N instances never collide. Tested.
- "Edit def…" (group inspector) swaps the overlay into a scoped def editor:
monitoris replaced by the def's child tree, the real monitor stashed; a teal "Editing widget: [Done]" banner shows. All the existing tooling (outline / inspector / add / containers / drag) operates on the def; edits fold back into the library on every save, so every instance re-renders live. Defsize(w/h) editable from any instance's inspector. Save is guarded so the scoped tree never overwrites the real layout. (Full studio-tab scoping → 5s.)
- A def declares overridable bindings (
ParamSpec:key+ dottedtarget, e.g.unit.sensor) via the group inspector's "Add param"; each instance sets values in the inspector andresolveGroup/applyParamsrebinds them on a cloned child (fail-closed setPath). The "one core-graph def × N instances, each bindingcpu.core.N" workflow works end-to-end.
- Inspector "Library" palette: insert a def as a new group; rename a def (propagates to instances); delete a def guarded (refused while any instance references it). Ungroup is the current "detach"; duplicate-def is a small follow-up.
- Solver scope creep. Constrain v1: single-line row/col (no wrap), uniform grid cells,
basis ∈ {auto, px, fr}. Each rule lands as a unit test before any UI wiring. Recursion (group in container in group) must be covered by tests, not eyeballed. - Dual drag semantics (free-move floating vs reorder-in-flow vs dock/undock) is the top UX
risk and now a core interaction (5e). Mitigate with pure, tested
hitInsertionIndex/dropIntohelpers and clear affordances (insertion line + drop-zone highlight); 5c's inspector/outline path stays the precision fallback. - Native menu in passive mode. Right-click must be caught while click-through is on; extend
the cursor watcher to mouse buttons (or a
WH_MOUSE_LLhook if polling misses fast clicks). Fallback: menu only in edit mode if detection proves flaky. - Work area is dynamic (auto-hide taskbar, DPI, monitor hotplug). Re-query on
display-changed; treat insets as hints recomputed on focus /layout_changed. - Two coordinate spaces. Floating = absolute monitor px; in-flow / in-group = local/solved. Group/ungroup and float/unfloat must rebase rects correctly — pure, tested helpers only.
- Migration & React. v2 types, the solver, and all tree mutations are pure
core/→ the React port reuses them verbatim; only the editors (tree / DnD / group canvas) get reimplemented.
- Context-menu tech: native
mudamenu. A Rust right-click detector pops an OS-native menu; works in passive (click-through) mode with no webview round-trip. (In-app HTML rejected — not worth the cursor-events plumbing / first-click-miss risk.) - Build order: layout designer first, then the widget designer. Phase 5 (5a–5e) → Phase 6 (6a–6d); grouping rides on tooling Phase 5 already builds. 5e (drag-and-drop) is core, not a stretch — mouse-dragging is the primary way to build a layout (decided 2026-06-02).
- Groups are reusable from the start. Grouping creates a
WidgetDefin a first-class library plus an instance referencing it; params / sensor-remap (6c) are in scope, not deferred — "one core-graph def × 32 instances" is a first-class goal. (One-off-inline-first rejected.)
Schematic, not final visuals — they pin down the model and the two designers' UX.
How the model nests — one recursive tree; a Group is a leaf to the layout solver and a
container-root to the widget designer:
MonitorLayout
├─ root .............. Container(col)
│ ├─ Container(row) gap 8 · align center
│ │ ├─ Leaf ─ gauge cpu.total
│ │ ├─ Leaf ─ gauge mem.used
│ │ └─ Leaf ─ Group "GPU panel" ← a widget (built in Phase 6)
│ │ └─ child: Container(col) ─ [gauge, bar, text]
│ └─ Container(grid cols=8)
│ └─ Leaf ×32 ─ Group "core-graph" ← ONE def, each instance binds core=N
└─ floating ......... [ Leaf ─ Clock, Leaf ─ NowPlaying ] ← absolute, escapes the grid
Layout designer (Phase 5) — monitor overlay in edit mode: containers arrange units, a floating layer escapes the grid, inspector + outline drive the tree, work-area edge is snappable:
Layout designer · Monitor 1 (overlay) [ EDIT · Ctrl+E ]
............................................................................
┌ row · gap 8 · align center ─────────────────────┐ ┌ floating ┐
│ ┌──────┐ ┌──────┐ ┌──────┐ │ │ 12:04 │
│ │ CPU │ │ RAM │ │ GPU │ │ └──────────┘
│ └──────┘ └──────┘ └──────┘ │
└──────────────────────────────────────────────────┘
┌ grid cols=8 · per-core ──────────────────────────┐
│ ▂▃▅ ▃▅▂ ▅▂▁ ▁▂▃ ▂▃▅ ▃▅▂ ▅▂▁ ▁▂▃ (32 cores) │
└──────────────────────────────────────────────────┘
┌ Inspector ──────────────┐ ┌ Outline ──────────┐
│ Add ▸ gauge bar text │ │ ▾ root (col) │
│ ▸ Row Col Grid… │ │ ▾ row │
│ ─────────────────────── │ │ · CPU │
│ row gap [8] pad [0] │ │ · RAM │
│ align [ center ▾ ] │ │ · GPU │
│ justify [ start ▾ ] │ │ ▾ grid 8 │
└─────────────────────────┘ └────────────────────┘
═══════════════════════ taskbar · work-area edge ═══════════════════════════
Container kinds — row = hsplit, col = vsplit, grid = multi-pane (cols 2/3/4):
row (hsplit) col (vsplit) grid cols=3
┌───┬───┬───┐ ┌─────────┐ ┌───┬───┬───┐
│ A │ B │ C │ │ A │ │ A │ B │ C │
└───┴───┴───┘ ├─────────┤ ├───┼───┼───┤
← gap → │ B │ │ D │ E │ F │
├─────────┤ └───┴───┴───┘
│ C │
└─────────┘
Align vs justify (a row; the inspector labels these "vertical" / "horizontal"):
justify · main-axis (horizontal →) align · cross-axis (vertical ↓)
start center end start center stretch
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌───────┐ ┌───────┐ ┌───────┐
│■■■ │ │ ■■■ │ │ ■■■│ │■ ■ ■ │ │ │ │█ █ █ │
└─────────┘ └─────────┘ └─────────┘ │ │ │■ ■ ■ │ │█ █ █ │
│ │ │ │ │█ █ █ │
└───────┘ └───────┘ └───────┘
Drag-and-drop (Phase 5e, core) — drop a unit into a pane (insertion line shows the slot); drag a unit out of a pane to float it again:
Dragging "GPU" into a row — the insertion line shows where it lands:
┌ row · gap 8 ──────────────────────────┐
│ ┌─CPU─┐ ┃ ┌─RAM─┐ │ ┃ = insertion line
│ └─────┘ ┃ └─────┘ │ drop → row reflows to
└──────────────┃────────────────────────┘ CPU · GPU · RAM
┗━ lands between CPU and RAM
┌─GPU─┐
└drag─┘ ← the unit being dragged
Drag a unit OUT of a pane → it leaves the flow and floats (free move again).
Native right-click menu (Phase 5d) — popped by the Rust cursor watcher, so it works while the overlay is click-through (passive):
right-click a unit / the canvas
┌────────────────────────────┐
│ Edit layout │
│ ───────────────────────── │
│ Float / Unfloat │
│ Wrap in ▸ Row │
│ Align ▸ Col │
│ Send to layer ▸ Grid │
│ ───────────────────────── │
│ Group selection │ ← Phase 6: make a widget
│ Remove │
│ Settings… │
└────────────────────────────┘
Studio window (Phase 5s) — a normal app window (chrome, taskbar, alt-tab): the design home. Selecting on the overlay or in the outline drives the same selection here:
WidgetSack · Studio [ – □ × ]
┌ Tabs ──────────────────────────────────────────────────────────────┐
│ [ Layout ] Widgets Sensors Settings │
├──────────────────┬──────────────────────────────────────────────────┤
│ Outline │ Inspector · row (selected) │
│ ▾ Monitor 1 │ kind [ row ▾ ] cols [ – ] │
│ ▾ root (col) │ gap [ 8 ] pad [ 0 ] │
│ ▾ row ◀── │ align [ center ▾ ] │
│ · CPU │ justify [ start ▾ ] │
│ · RAM │ ─────────────────────────────────────────────────│
│ · GPU │ Add ▸ gauge bar spark text · + Row Col Grid │
│ ▸ grid 8 │ Insert widget ▸ core-graph net-panel clock │
│ ▸ Monitor 2 │ │
└──────────────────┴──────────────────────────────────────────────────┘
Widget designer (Phase 6) — a studio tab: a scoped canvas over one def's local box; same arrange/inspector tooling, edits propagate to every instance:
Widget designer · editing def "core-graph" [ ‹ back to monitor ]
size [ 40 × 26 ] params: core = cpu.core.{i}
............................................................................
┌ col · gap 2 ───────────────────────┐
│ CPU 78% ████████░░ │ labels + meters laid out in
│ ┌ sparkline ────────────────────┐ │ the def's OWN local coords
│ │ ▂▃▅▇▅▃▂▁▂▃▅ │ │
│ └─────────────────────────────────┘ │
└──────────────────────────────────────┘
Add ▸ gauge bar sparkline text [ Save as widget ]
Reuse (Phase 6c) — one def, many instances, each rebinding a param:
Library (widgets.lib.json) Monitor · grid cols=8
┌──────────────────────┐ ┌──┬──┬──┬──┬──┬──┬──┬──┐
│ def "core-graph" │ instance │0 │1 │2 │3 │4 │5 │6 │7 │
│ params: core │ ────────────▶ ├──┼──┼──┼──┼──┼──┼──┼──┤
│ size 40×26 │ × 32 │8 │9 │10│11│..│ │ │ │
└──────────────────────┘ └──┴──┴──┴──┴──┴──┴──┴──┘
one definition → many instances, each binds core = N
Make styling a separate, pluggable layer — themes/skins live outside the components (the
NowPlaying ThemeInjector model, generalized to the whole platform), so the look is swappable
and shareable without touching widget code. The css? fields on WidgetInstance/Group
already round-trip in widgets.json; this phase gives them (and a theme layer) real teeth.
✅ Implemented (gates green). 7a meters token-driven (
var(--np-*, fallback), visual parity) +np-*/data-part/data-w/data-def/data-grouphooks +core/tokens.ts. 7bcore/style.tsscopeCss/assembleStyles(tested) +<StyleLayer>injecting theme→def→ instance css. 7c Rustlist_themes/load_theme/save_theme+ watch + seededamber/monoexamples; studio theme picker + live-reload; selection persisted in the layout. 7d inspector CSS editors (instance / group / def) + a global token panel (persisted undertokens). 160 client tests + 6 Rust tests;npm+cargogates all green.
1. Tokens CSS custom properties the meters read (--np-accent, --np-fg, --np-font, …)
▲ set by
2. Theme a pluggable CSS bundle that sets tokens (+ optionally targets stable hooks).
│ A SEPARATE file: themes/<name>.css — selectable globally / per monitor.
3. Def CSS WidgetDef.css — restyles every instance of a composite widget (scoped to it).
4. Instance CSS WidgetInstance.css — a one-off per-widget override (scoped to it).
cascade: theme/tokens (global) → def → instance (most specific wins)
Meters keep colours/fonts in their own component CSS, so a theme's external CSS can't easily
override them. Fix by reading tokens with fallbacks — fill: var(--np-fg, #fff),
stroke: var(--np-accent, rgb(119,196,211)), font-family: var(--np-font-display, 'Bahnschrift', 'Arial Narrow', sans-serif), --np-track, --np-label, … Custom properties
inherit through the CSS cascade, so just setting tokens on a parent restyles every meter —
no unscoping needed, default look preserved (the fallbacks ARE today's palette). Starter
vocabulary in core/tokens.ts (framework-agnostic data): --np-accent / -fg / -muted / -label / -track / -bg, --np-font / -display, --np-size-value / -label, --np-radius / -gap.
- Each meter exposes a stable global hook for structural restyles beyond tokens: a root
class
np-gauge/np-bar/np-text+data-part="value|label|track|fill"(plain, non-hashed class names so external CSS can match). TheWidgetHostwrapper carriesdata-w="<id>",data-type="gauge",data-sensor="cpu.total"so any layer can match by id / type / sensor. - A
<StyleLayer>(generalizedThemeInjector) injects, in cascade order: the global theme CSS verbatim, then def and instance CSS each auto-scoped to their widget via native CSS nesting —[data-w="<id>"] { <user css> }(WebView2 supports nesting), so a one-off can't leak. Purecore/style.tsscopeCss(css, selector)does the wrap (tested).@-rules (keyframes/font-face) belong in the global theme, not scoped css — documented.
- A theme is a file:
themes/<name>.cssin the app config dir, with an optional manifest comment (/* @theme name; author */). Rustlist_themes()/load_theme(name)+ anotifywatch →theme_changed, mirroring the layout commands — drop in / live-edit, Rainmeter-style. - Selection: a
themefield in the layout (global default) + optional per-monitor override; the studio gets a theme picker. The bundled default theme reproduces today's look.
- Inspector CSS editor per instance + per def (the
cssfields already persist); a small tokens panel to set the common ones (accent / fg / font / track) without writing raw CSS.
core/tokens.ts (token vocab + default theme as data) and core/style.ts (scopeCss) are pure
and framework-agnostic; only <StyleLayer> (injection) is React. Consistent with §5's boundary.
- Scoping = native CSS-nesting wrapper
[data-w="<id>"] { <user css> }for def/instance css (global theme is verbatim). Leans on WebView2's CSS nesting;@keyframes/@font-facelive in the theme, not scoped blocks. (Shadow DOM rejected — too heavy for the overlay.) - Themes = separate
themes/*.cssfiles in the app config dir (list_themes/load_theme+notifywatch); the layout stores only the selection. Shareable, live-editable, Rainmeter-style. - Build the full stack 7a → 7d in order (token-drive meters → stable hooks + scoped css injection → theme files + picker → inspector css/token editors). Starter token set as listed.
A widget type is wired in three places, by convention, at build time: widgets/registry.ts
(type → component), core/layout.ts createWidget (per-type default sensor/rect/config), and
WIDGET_TYPES (palette). WidgetHost looks up the component, subscribes the bound sensor, and
passes value/history/...config. Sensors arrive from the Rust telemetry event → the hub —
but the hub is source-agnostic (hub.ingestBatch from anywhere), so new data sources slot in
cleanly. No formal descriptor, no runtime loading (a deliberate "fixed registry" call). Phase 8
formalizes this into a real plugin contract.
1. WidgetDescriptor (the standard widget API) — collapse the three places into one object:
type WidgetDescriptor = {
type: string; // 'gauge' | 'ha.light' | …
component: MeterComponent; // presentational: props value?/history?/...config
binds?: 'scalar' | 'series' | 'text' | 'json' | 'none'; // sensor kind it reads
label?: string; // palette name
defaultSize?: { w: number; h: number };
defaultConfig?: Record<string, unknown>;
configFields?: ConfigField[]; // schema-driven inspector (label/key/type/options)
interactive?: boolean; // catches clicks (per-widget click-through)
};registerWidget(descriptor) replaces the registry; createWidget reads default*; the palette
- inspector read
label/configFields. The 6 built-in meters become descriptors. "Add a widget" becomes one object, not three edits.
2. SensorSource (data plugins) — formalize the hub feed:
type SensorSource = {
id: string; // 'system' | 'home-assistant'
start(hub: TelemetryHub): Promise<() => void>; // connect, ingest samples, return unsub
catalog?(): SensorMeta[]; // entity ids/units for the inspector dropdown
};The Rust-telemetry feed becomes the built-in system source; a plugin adds more. The hub is the
shared sink — meters don't change.
3. Plugin — bundles the above + settings (and may ship themes from Phase 7):
type Plugin = {
id: string; name: string;
widgets?: WidgetDescriptor[];
sources?: SensorSource[];
settings?: SettingsSchema; // e.g. HA url + token; persisted in the config dir
};
registerPlugin(plugin); // adds widgets, starts sources, exposes settingsCore widgets/sources become a built-in core plugin registered the same way.
- v1 (build-time): a plugin is a module under
client/src/lib/plugins/<id>/exporting aPlugin; a manifest registers them. Adding one = code + rebuild. React components compile in; zero new security surface. - Future (runtime drop-in): load a plugin bundle from the config dir at runtime. Compiled React components don't load post-compile, so this needs web-component widgets (runtime- loadable, aligned with the framework-portability note) or a dynamic-import path + sandboxing/CSP. Bigger; deferred.
- Source (Rust proxy — locked): a Rust HA client (e.g.
tokio-tungstenite) holds the WebSocket tows://<host>/api/websocket+ the long-lived token server-side, subscribes tostate_changed, and forwardsha.<entity_id>samples through the existingtelemetryevent (scalarfor numeric,jsonfor full state) — one pipe, token never in the webview. - Control:
ha.light/ha.climateetc. call back via a Tauri commandha_call_service(domain, service, data)→ Rust → HA. Control widgets areinteractive→ reuse the per-widget click-through (clickthrough.rs). - Widgets:
ha.sensor(text/gauge over an entity),ha.light(toggle + brightness),ha.climate(setpoint), … - Settings + catalog: HA URL + token persisted server-side (a Rust-read
plugins/ha.jsonin the config dir, not inwidgets.json); alist_ha_entitiescommand feeds the sensor dropdown. Token stays out of the layout file and the webview.
- 8a —
WidgetMeta+registerWidget; migrate the 6 built-ins; schema-driven inspector config (raw JSON stays as the escape hatch). Pure-ish, fully testable. ✅ done - 8b —
SensorSource+registerPlugin; wrap the system feed as the built-in source. ✅ done - 8c — the Home Assistant plugin (source + widgets + settings) — the first real plugin. ✅ done
- 8d — (future) runtime plugin loading via web components.
The standard widget API split honours the core boundary (AGENTS.md §5): the pure half is
core/widget.ts (WidgetMeta/ConfigField/SensorKind, registerMeta/getMeta/listMetas,
createWidget, BUILTIN_METAS); the component-bearing half is widgets/registry.ts
(registerWidget(meta, component) mirrors the meta into the pure registry + stores the React
component). WidgetDescriptor is just WidgetMeta + component — no second registry. The 6
built-ins reproduce the old createWidget switch exactly (parity pinned by widget.test.ts).
- 8a —
core/widget.ts+widget.test.ts;Inspectorrenders one typed input perconfigFieldsentry above the raw-JSON<textarea>(kept as the escape hatch). The JSON box now re-syncs whenever the config object changes by reference, so a typed-field edit can't be silently reverted by a later JSON commit. - 8b —
core/plugin.ts(SensorSource+registerSource/startAllSources/sourceCatalogIds) +plugin.test.ts;telemetry/source.tsregisters the built-insystemsource;widgets/plugin.ts(Plugin+registerPlugin). Canvas mounts viastartAllSourcesand mergessourceCatalogIds()into the inspector's sensor dropdown. - 8c —
widgetsack/src/ha.rs: a reconnecting HA WebSocket proxy. Token + socket stay server-side (plugins/ha.json);state_changedis forwarded over the existingtelemetryevent asha.<entity_id>(Jsonalways;ha.<entity_id>.stateScalarwhen numeric) — the unchangedsystemlistener ingests it. Connection state rides the same event asha.status(Text). Commands:ha_connect/ha_disconnect(single-owner task, double-spawn-guarded),list_ha_entities(REST/api/states, dropdown catalog),ha_call_service(REST control,/+.path-injection guarded),save_ha_config/ha_config_status(status never returns the token). Reconnect backoff (1s→30s + jitter) resets only after a ≥30s-stable session. Pure seams (ws_url_from,state_to_samples,entity_from_state) unit-tested. Frontend plugin:widgets/plugins/home-assistant.tsregistershaSource+ha.sensor/ha.light/ha.climate(allbinds: 'json');ha.lightisinteractiveand dispatches acontrolevent thatWidgetHostbubbles to Canvas, which invokesha_call_service(the meter stays Tauri-free, §6).WidgetHostnow passes the value-shape itsbindsdictates (scalar/series byte-identical; json/text get the raw payload). - TLS (HA):
wss:///https://are supported vianative-tls= Windows SChannel — no OpenSSL, noaws-lc-rs/ring, so no NASM/cmake build tooling. Valid certs (e.g. Nabu Casa) work transparently throughconnect_async/reqwest. Self-signed LAN certs require an explicit, default-offinsecure: trueinplugins/ha.json, which wiresdanger_accept_invalid_certsinto BOTH the WS connector (connect_async_tls_with_config) and the REST client.#[serde(default)]keeps existing config files strict.ha.climateis read-only (setpoint control deferred). Runtime HA behaviour is unverified here (no live HA instance); the pure seams + frontend component tests are green, andcargo build/test/clippypass on Windows.
- Packaging = build-time plugin modules first (
lib/plugins/<id>/, rebuild to add). Runtime drop-in via web components is a later, separate step (8d). - HA = Rust proxy source (token server-side, forwarded over
telemetry; control via a Tauriha_call_servicecommand). The frontendSensorSourceAPI still exists for pure-JS plugins; HA specifically lives in Rust for security. (Frontend WebSocket rejected.) - Config = schema-driven (
configFields→ typed inspector inputs) with the raw-JSON box as the escape hatch for anything unlisted.
- Z-order/input (3c, refined): whole-window click-through (fully passive normally,
interactive in edit mode) on an always-on-top overlay. Per-widget
layer: top|desktopfield added now; always-on-top layer first, WorkerW desktop layer later (Phase 4). Per-widget click-through deferred. Edit toggled by global hotkey + tray. Overlays per monitor, configurable via the layout map. - Monitors: multi-monitor overlays from day one; no cross-monitor widgets to start.
- Fonts: Bahnschrift default;
fontFamilyoverride resolves system fonts (any installed font, incl. the day-pictograph font) for customization. - React: components reimplemented (port done);
core/shared unchanged (no web components now). - Sensor interval: configurable per sensor, 1 Hz default.
- Name: rename
np→widgetsack(Phase R).
- React 19 + TypeScript, Vite 5, Vitest 2 (migrated from Svelte 3.54 / SvelteKit 1.5 + adapter-static), Tauri 2.11, Rust edition 2024.
- Capabilities are scoped to window label
main; multi-window + fs access will need additions underwidgetsack/capabilities/. - Dynamic arbitrary-widget-code loading is not required — a fixed registry of built-in meter types configured by data covers 100% of the listed skins.