homebridge-plugin-utils 1.34.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +130 -2
- package/build/eslint-plugin/README.md +164 -0
- package/build/eslint-plugin/config.mjs +308 -0
- package/build/eslint-plugin/index.mjs +7 -0
- package/build/eslint-plugin/plugin.mjs +46 -0
- package/build/eslint-plugin/rules/blank-line-after-open-brace.mjs +137 -0
- package/build/eslint-plugin/rules/blank-line-after-open-brace.test.mjs +112 -0
- package/build/eslint-plugin/rules/comment-style.mjs +190 -0
- package/build/eslint-plugin/rules/comment-style.test.mjs +190 -0
- package/build/eslint-plugin/rules/enforce-node-protocol.mjs +114 -0
- package/build/eslint-plugin/rules/enforce-node-protocol.test.mjs +116 -0
- package/build/eslint-plugin/rules/paren-comparisons-in-logical.mjs +94 -0
- package/build/eslint-plugin/rules/paren-comparisons-in-logical.test.mjs +107 -0
- package/build/eslint-plugin/rules/split-type-imports.mjs +354 -0
- package/build/eslint-plugin/rules/split-type-imports.test.mjs +268 -0
- package/build/eslint-plugin/test-setup.mjs +21 -0
- package/build/fs-ops.mjs +184 -0
- package/build/tsconfig.json +18 -3
- package/dist/backpressure.d.ts +128 -0
- package/dist/backpressure.js +273 -0
- package/dist/backpressure.js.map +1 -0
- package/dist/cli/index.d.ts +95 -0
- package/dist/cli/index.js +355 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/clock-double.d.ts +93 -0
- package/dist/clock-double.js +141 -0
- package/dist/clock-double.js.map +1 -0
- package/dist/clock.d.ts +39 -0
- package/dist/clock.js +34 -0
- package/dist/clock.js.map +1 -0
- package/dist/eslint-plugin/config.d.mts +193 -0
- package/dist/eslint-plugin/index.d.mts +2 -0
- package/dist/eslint-plugin/plugin.d.mts +87 -0
- package/dist/eslint-plugin/rules/blank-line-after-open-brace.d.mts +19 -0
- package/dist/eslint-plugin/rules/comment-style.d.mts +15 -0
- package/dist/eslint-plugin/rules/enforce-node-protocol.d.mts +18 -0
- package/dist/eslint-plugin/rules/paren-comparisons-in-logical.d.mts +15 -0
- package/dist/eslint-plugin/rules/split-type-imports.d.mts +16 -0
- package/dist/featureOptions-docs.d.ts +96 -0
- package/dist/featureOptions-docs.js +193 -0
- package/dist/featureOptions-docs.js.map +1 -0
- package/dist/featureOptions.d.ts +674 -0
- package/dist/featureOptions.js +870 -0
- package/dist/featureOptions.js.map +1 -0
- package/dist/ffmpeg/codecs.d.ts +256 -72
- package/dist/ffmpeg/codecs.js +477 -257
- package/dist/ffmpeg/codecs.js.map +1 -1
- package/dist/ffmpeg/dgram-util.d.ts +46 -0
- package/dist/ffmpeg/dgram-util.js +38 -0
- package/dist/ffmpeg/dgram-util.js.map +1 -0
- package/dist/ffmpeg/exec.d.ts +83 -64
- package/dist/ffmpeg/exec.js +77 -86
- package/dist/ffmpeg/exec.js.map +1 -1
- package/dist/ffmpeg/fmp4.d.ts +70 -4
- package/dist/ffmpeg/fmp4.js +100 -11
- package/dist/ffmpeg/fmp4.js.map +1 -1
- package/dist/ffmpeg/hap-enums.d.ts +214 -0
- package/dist/ffmpeg/hap-enums.js +92 -0
- package/dist/ffmpeg/hap-enums.js.map +1 -0
- package/dist/ffmpeg/index.d.ts +16 -9
- package/dist/ffmpeg/index.js +6 -0
- package/dist/ffmpeg/index.js.map +1 -1
- package/dist/ffmpeg/mp4-assembler.d.ts +120 -0
- package/dist/ffmpeg/mp4-assembler.js +387 -0
- package/dist/ffmpeg/mp4-assembler.js.map +1 -0
- package/dist/ffmpeg/mp4-parser.d.ts +94 -0
- package/dist/ffmpeg/mp4-parser.js +130 -0
- package/dist/ffmpeg/mp4-parser.js.map +1 -0
- package/dist/ffmpeg/options.d.ts +62 -149
- package/dist/ffmpeg/options.js +608 -499
- package/dist/ffmpeg/options.js.map +1 -1
- package/dist/ffmpeg/process.d.ts +143 -91
- package/dist/ffmpeg/process.js +406 -265
- package/dist/ffmpeg/process.js.map +1 -1
- package/dist/ffmpeg/record.d.ts +302 -188
- package/dist/ffmpeg/record.js +408 -560
- package/dist/ffmpeg/record.js.map +1 -1
- package/dist/ffmpeg/recording-process-double.d.ts +144 -0
- package/dist/ffmpeg/recording-process-double.js +168 -0
- package/dist/ffmpeg/recording-process-double.js.map +1 -0
- package/dist/ffmpeg/rtp-parser.d.ts +70 -0
- package/dist/ffmpeg/rtp-parser.js +77 -0
- package/dist/ffmpeg/rtp-parser.js.map +1 -0
- package/dist/ffmpeg/rtp.d.ts +198 -141
- package/dist/ffmpeg/rtp.js +473 -244
- package/dist/ffmpeg/rtp.js.map +1 -1
- package/dist/ffmpeg/settings.d.ts +5 -1
- package/dist/ffmpeg/settings.js +20 -2
- package/dist/ffmpeg/settings.js.map +1 -1
- package/dist/ffmpeg/stream.d.ts +57 -107
- package/dist/ffmpeg/stream.js +121 -148
- package/dist/ffmpeg/stream.js.map +1 -1
- package/dist/formatters.d.ts +106 -0
- package/dist/formatters.js +174 -0
- package/dist/formatters.js.map +1 -0
- package/dist/homebridge-enums.d.ts +30 -0
- package/dist/homebridge-enums.js +17 -0
- package/dist/homebridge-enums.js.map +1 -0
- package/dist/index.d.ts +12 -5
- package/dist/index.js +8 -2
- package/dist/index.js.map +1 -1
- package/dist/logclient/auth.d.ts +114 -0
- package/dist/logclient/auth.js +199 -0
- package/dist/logclient/auth.js.map +1 -0
- package/dist/logclient/cli-run.d.ts +76 -0
- package/dist/logclient/cli-run.js +639 -0
- package/dist/logclient/cli-run.js.map +1 -0
- package/dist/logclient/cli.d.ts +3 -0
- package/dist/logclient/cli.js +97 -0
- package/dist/logclient/cli.js.map +1 -0
- package/dist/logclient/client.d.ts +145 -0
- package/dist/logclient/client.js +600 -0
- package/dist/logclient/client.js.map +1 -0
- package/dist/logclient/config.d.ts +173 -0
- package/dist/logclient/config.js +199 -0
- package/dist/logclient/config.js.map +1 -0
- package/dist/logclient/endpoints.d.ts +54 -0
- package/dist/logclient/endpoints.js +73 -0
- package/dist/logclient/endpoints.js.map +1 -0
- package/dist/logclient/filter.d.ts +45 -0
- package/dist/logclient/filter.js +51 -0
- package/dist/logclient/filter.js.map +1 -0
- package/dist/logclient/frame.d.ts +93 -0
- package/dist/logclient/frame.js +203 -0
- package/dist/logclient/frame.js.map +1 -0
- package/dist/logclient/index.d.ts +31 -0
- package/dist/logclient/index.js +12 -0
- package/dist/logclient/index.js.map +1 -0
- package/dist/logclient/parser.d.ts +211 -0
- package/dist/logclient/parser.js +393 -0
- package/dist/logclient/parser.js.map +1 -0
- package/dist/logclient/rest.d.ts +41 -0
- package/dist/logclient/rest.js +111 -0
- package/dist/logclient/rest.js.map +1 -0
- package/dist/logclient/settings.d.ts +15 -0
- package/dist/logclient/settings.js +64 -0
- package/dist/logclient/settings.js.map +1 -0
- package/dist/logclient/socket-double.d.ts +201 -0
- package/dist/logclient/socket-double.js +384 -0
- package/dist/logclient/socket-double.js.map +1 -0
- package/dist/logclient/socket.d.ts +257 -0
- package/dist/logclient/socket.js +620 -0
- package/dist/logclient/socket.js.map +1 -0
- package/dist/logclient/stitch.d.ts +83 -0
- package/dist/logclient/stitch.js +146 -0
- package/dist/logclient/stitch.js.map +1 -0
- package/dist/logclient/time-expression.d.ts +42 -0
- package/dist/logclient/time-expression.js +181 -0
- package/dist/logclient/time-expression.js.map +1 -0
- package/dist/logclient/time-window.d.ts +38 -0
- package/dist/logclient/time-window.js +53 -0
- package/dist/logclient/time-window.js.map +1 -0
- package/dist/logclient/types.d.ts +107 -0
- package/dist/logclient/types.js +6 -0
- package/dist/logclient/types.js.map +1 -0
- package/dist/mqttClient.d.ts +287 -0
- package/dist/mqttClient.js +433 -0
- package/dist/mqttClient.js.map +1 -0
- package/dist/service.d.ts +64 -15
- package/dist/service.js +93 -66
- package/dist/service.js.map +1 -1
- package/dist/ui/featureOptions.js +870 -0
- package/dist/ui/featureOptions.js.map +1 -0
- package/dist/ui/formatters.js +174 -0
- package/dist/ui/formatters.js.map +1 -0
- package/dist/ui/pluginConfigSession.mjs +141 -0
- package/dist/ui/webUi-featureOptions/categoryState.mjs +135 -0
- package/dist/ui/webUi-featureOptions/effects/keyboard.mjs +61 -0
- package/dist/ui/webUi-featureOptions/effects/persist.mjs +226 -0
- package/dist/ui/webUi-featureOptions/effects/theme.mjs +398 -0
- package/dist/ui/webUi-featureOptions/effects/tokens.mjs +152 -0
- package/dist/ui/webUi-featureOptions/rendering.mjs +431 -0
- package/dist/ui/webUi-featureOptions/selectors.mjs +360 -0
- package/dist/ui/webUi-featureOptions/state.mjs +319 -0
- package/dist/ui/webUi-featureOptions/store.mjs +181 -0
- package/dist/ui/webUi-featureOptions/utils.mjs +200 -0
- package/dist/ui/webUi-featureOptions/views/connectionError.mjs +152 -0
- package/dist/ui/webUi-featureOptions/views/deviceInfo.mjs +80 -0
- package/dist/ui/webUi-featureOptions/views/header.mjs +77 -0
- package/dist/ui/webUi-featureOptions/views/nav.mjs +327 -0
- package/dist/ui/webUi-featureOptions/views/options.mjs +521 -0
- package/dist/ui/webUi-featureOptions/views/search.mjs +395 -0
- package/dist/ui/webUi-featureOptions.mjs +677 -0
- package/dist/ui/webUi.mjs +192 -87
- package/dist/util.d.ts +597 -45
- package/dist/util.js +787 -68
- package/dist/util.js.map +1 -1
- package/package.json +33 -15
- package/build/eslint-rules.mjs +0 -511
- package/dist/featureoptions.d.ts +0 -264
- package/dist/featureoptions.js +0 -480
- package/dist/featureoptions.js.map +0 -1
- package/dist/mqttclient.d.ts +0 -178
- package/dist/mqttclient.js +0 -310
- package/dist/mqttclient.js.map +0 -1
- package/dist/ui/featureoptions.js +0 -480
- package/dist/ui/featureoptions.js.map +0 -1
- package/dist/ui/webUi-featureoptions.mjs +0 -3765
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/* Copyright(C) 2017-2026, HJD (https://github.com/hjdhjd). All rights reserved.
|
|
2
|
+
*
|
|
3
|
+
* webUi-featureOptions/effects/persist.mjs: The persistence effect for the feature options webUI.
|
|
4
|
+
*/
|
|
5
|
+
"use strict";
|
|
6
|
+
|
|
7
|
+
import { delay } from "../utils.mjs";
|
|
8
|
+
import { effect } from "../store.mjs";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Debounce window for coalescing rapid mutations before writing to disk. 300ms sits below the instant-feel ceiling - a single click feels immediate from the user's
|
|
12
|
+
* perspective (a 300ms persist completes invisibly while the next click is being decided) - yet above the burst-coalescing floor, so a rapid sequence of mutations
|
|
13
|
+
* (the user dragging a slider, batching reset/revert through multiple controllers) collapses into a single write rather than one write per mutation.
|
|
14
|
+
*/
|
|
15
|
+
const DEBOUNCE_MS = 300;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Register the persistence effect on a store.
|
|
19
|
+
*
|
|
20
|
+
* Coalesces, serializes, and rolls back option persistence:
|
|
21
|
+
*
|
|
22
|
+
* - **Coalescing**: every mutation that changes `configuredOptions` enters this effect through a single dirty flag (`pending`). The drain loop reads the flag at
|
|
23
|
+
* each iteration and re-enters when it is set, so rapid mutations collapse into one persist of the latest state. The debounce on top of the drain absorbs
|
|
24
|
+
* burst-mutation patterns (drag/keystroke) without queueing multiple writes.
|
|
25
|
+
* - **Serialization**: a single drain promise (`inFlight`) is held while a persist call is in flight. Concurrent mutations only set `pending = true`; the drain
|
|
26
|
+
* loop picks up the latest state at its next iteration. No two persist calls can be in flight against the host at the same time.
|
|
27
|
+
* - **Rollback**: a final-iteration failure (no superseding mutation) dispatches `persist:failed`, which the reducer translates into restoring
|
|
28
|
+
* `configuredOptions` to the last-known-good `persistedAnchor`. Intermediate-iteration failures (a mutation rescued the user's intent) are swallowed because
|
|
29
|
+
* the next iteration will retry with the newer state; the user's most recent action is what matters.
|
|
30
|
+
*
|
|
31
|
+
* The dispatch sequence per iteration:
|
|
32
|
+
*
|
|
33
|
+
* 1. `persist:started` with the snapshot being written. Status transitions to `persisting`; the status bar can show a "saving" affordance.
|
|
34
|
+
* 2. `session.commit({ options })` is awaited - the session merges the options onto its primary entry, preserves siblings, writes to the host, and advances its
|
|
35
|
+
* held reference only on success.
|
|
36
|
+
* 3. Success: `persist:succeeded` with the snapshot. Status returns to `ready`; the anchor advances to this snapshot.
|
|
37
|
+
* 4. Failure with no superseding mutation: `persist:failed` with the error. Status transitions to `persist-error`; the reducer rolls `configuredOptions` back to
|
|
38
|
+
* the anchor.
|
|
39
|
+
* 5. Failure with a superseding mutation: the error is swallowed; the loop continues with the newer state.
|
|
40
|
+
*
|
|
41
|
+
* The effect is registered with the page-level signal. Aborting the signal cleanly tears down the listener; an in-flight `updatePluginConfig` call cannot itself
|
|
42
|
+
* be aborted (the Homebridge bridge does not accept an `AbortSignal`), but the drain checks `signal.aborted` after every await and bails before dispatching any
|
|
43
|
+
* post-call action against a torn-down view.
|
|
44
|
+
*
|
|
45
|
+
* Returns a `{ flush }` handle. The page calls `flush()` at its navigate-away chokepoint (and best-effort on browser background/close) BEFORE aborting the signal,
|
|
46
|
+
* so a debounced-but-unwritten edit reaches disk instead of being dropped when the signal aborts. `flush()` drives the same single-writer drain to completion - it
|
|
47
|
+
* skips the debounce wait, never starts a parallel commit, and is a no-op when the store is already clean.
|
|
48
|
+
*
|
|
49
|
+
* @param {Object} args
|
|
50
|
+
* @param {{toast?: {error?: (message: string, id: string) => void}}} args.host - The Homebridge bridge, used only for the failure toast channel.
|
|
51
|
+
* @param {{commit: (patch: Object) => Promise<void>}} args.session - The config session; option saves persist through its single write seam.
|
|
52
|
+
* @param {AbortSignal} args.signal - The lifecycle signal. Aborting tears down the effect.
|
|
53
|
+
* @param {import("../store.mjs").FeatureOptionsStore} args.store - The store the effect subscribes against.
|
|
54
|
+
* @returns {{flush: () => Promise<void>}} A handle whose `flush()` drains any pending edit to disk now; called at the page's navigate-away / browser-exit edges.
|
|
55
|
+
*/
|
|
56
|
+
export const registerPersistEffect = ({ host, session, signal, store }) => {
|
|
57
|
+
|
|
58
|
+
// The drain-state variables below encode the irreducible coordination this pattern requires:
|
|
59
|
+
//
|
|
60
|
+
// - `inFlight`: the in-flight drain promise. When non-null, the drain loop is active; subsequent mutations only set `pending` and return.
|
|
61
|
+
// - `pending`: the dirty flag. Set by every subscribed mutation; cleared by the drain loop at the top of each iteration. Setting it during an in-flight persist
|
|
62
|
+
// is the signal "your current snapshot is already stale; iterate again with the newer state."
|
|
63
|
+
// - `debounceAbort`: the abort controller for the current debounce window. Every new mutation aborts the prior debounce so the timer always restarts from zero
|
|
64
|
+
// on the latest mutation - the burst-mutation case (drag/keystroke) collapses to one debounced fire after the burst ends.
|
|
65
|
+
// - `flushing`: set by flush() to drive the drain to completion NOW (skipping the debounce wait) when the page is navigating away. It keeps the loop running even
|
|
66
|
+
// once `pending` has been consumed, so an edit made within the debounce window is written before teardown rather than dropped. Reset in every drain's `finally`
|
|
67
|
+
// so it never outlives the drain it belongs to - the set-once-never-cleared leak this flag would otherwise introduce.
|
|
68
|
+
let debounceAbort = null;
|
|
69
|
+
let flushing = false;
|
|
70
|
+
let inFlight = null;
|
|
71
|
+
let pending = false;
|
|
72
|
+
|
|
73
|
+
const drain = async () => {
|
|
74
|
+
|
|
75
|
+
while((pending || flushing) && !signal.aborted) {
|
|
76
|
+
|
|
77
|
+
pending = false;
|
|
78
|
+
|
|
79
|
+
// The debounce is the burst-coalescing wait, but on the flush path (navigating away) we skip it: the edit must reach disk before teardown, so we go straight to
|
|
80
|
+
// the snapshot. In the normal path we restart the debounce on the latest mutation as before.
|
|
81
|
+
if(!flushing) {
|
|
82
|
+
|
|
83
|
+
debounceAbort?.abort();
|
|
84
|
+
debounceAbort = new AbortController();
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
|
|
88
|
+
// The debounce itself is the loop's intentional serialization point - each iteration must wait the debounce window before sampling state again. Same
|
|
89
|
+
// exception applies as the persist call below: this await is the point.
|
|
90
|
+
// eslint-disable-next-line no-await-in-loop
|
|
91
|
+
await delay(DEBOUNCE_MS, AbortSignal.any([ debounceAbort.signal, signal ]));
|
|
92
|
+
} catch {
|
|
93
|
+
|
|
94
|
+
// Debounce was aborted. If the page signal aborted, bail entirely. Otherwise the abort was a new mutation arriving during the debounce window - continue
|
|
95
|
+
// the loop and the next iteration starts a fresh debounce.
|
|
96
|
+
if(signal.aborted) {
|
|
97
|
+
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// In-loop dirty check. The effect-fn dirty check below does NOT gate the flush path (flush() bypasses the fn and starts the drain directly), so the drain must
|
|
106
|
+
// re-test here: once a just-committed edit advances `persistedAnchor` to match `configuredOptions`, there is nothing left to write, and we break rather than
|
|
107
|
+
// looping again on a now-clean store. This also skips a redundant write when an edit was reverted back to the anchor within the debounce window.
|
|
108
|
+
if(store.state.configuredOptions === store.state.persistedAnchor) {
|
|
109
|
+
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Capture the configuredOptions reference (not a spread copy). Reference preservation matters: when the persist succeeds with no superseding mutation,
|
|
114
|
+
// `persistedAnchor` becomes this same reference, so the next `pending` check at the effect's fn sees `configuredOptions === persistedAnchor` and skips.
|
|
115
|
+
const snapshot = store.state.configuredOptions;
|
|
116
|
+
|
|
117
|
+
store.dispatch({ snapshot, type: "persist:started" });
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
|
|
121
|
+
// Sequential awaits are the point: the drain serializes persists so concurrent mutations cannot race on disk. The eslint rule's concern (parallelizing
|
|
122
|
+
// independent work) does not apply - each iteration's persist is the only legitimate I/O the drain should be running at any moment. The session owns the
|
|
123
|
+
// payload shape (primary entry + siblings) and the write; we hand it only the options delta.
|
|
124
|
+
// eslint-disable-next-line no-await-in-loop
|
|
125
|
+
await session.commit({ options: snapshot });
|
|
126
|
+
|
|
127
|
+
if(signal.aborted) {
|
|
128
|
+
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
store.dispatch({ snapshot, type: "persist:succeeded" });
|
|
133
|
+
} catch(error) {
|
|
134
|
+
|
|
135
|
+
// A mutation arrived during the persist call - swallow the error and let the next iteration retry with the newer state. The user's most recent intent is
|
|
136
|
+
// what matters; an intermediate failure that the next iteration rescues need not surface to the user.
|
|
137
|
+
if(pending) {
|
|
138
|
+
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if(signal.aborted) {
|
|
143
|
+
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
store.dispatch({ error, type: "persist:failed" });
|
|
148
|
+
|
|
149
|
+
// Surface the failure via the host's toast channel so the user sees an actionable indication that their last edit did not reach disk. The single
|
|
150
|
+
// toast-emission seam keeps the user-facing notification policy in one place; effects elsewhere that dispatch persist:failed have the same path
|
|
151
|
+
// available through this effect's subscription.
|
|
152
|
+
host.toast?.error?.(error?.message ?? String(error), "config-persist");
|
|
153
|
+
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
effect({
|
|
160
|
+
|
|
161
|
+
events: [ "option:set", "option:cleared", "options:reset", "model:reverted" ],
|
|
162
|
+
fn: () => {
|
|
163
|
+
|
|
164
|
+
// Reference-equality dirty check: when configuredOptions matches the anchor, there is nothing new to persist. Handles both the registration-time immediate
|
|
165
|
+
// call (initial state shares one empty-array reference across the three options-array fields) and any genuine no-op mutation that produced the same reference
|
|
166
|
+
// (e.g., an option:cleared against an option that did not exist - applyClearOption returns the input reference unchanged, so the reducer's spread preserves
|
|
167
|
+
// configuredOptions).
|
|
168
|
+
if(store.state.configuredOptions === store.state.persistedAnchor) {
|
|
169
|
+
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
pending = true;
|
|
174
|
+
|
|
175
|
+
if(inFlight) {
|
|
176
|
+
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Single-writer drain start-site (mutation path). The macrotask-ordering invariant the serialization rests on: every subscribed store mutation
|
|
181
|
+
// (option:set / option:cleared / options:reset / model:reverted) and every flush() caller (hide() / the visibilitychange handler) originates from a DOM-event
|
|
182
|
+
// macrotask, so none can preempt the queued `inFlight.finally` microtask and strand a `pending` edit in the gap between the drain returning and its `finally`
|
|
183
|
+
// clearing `inFlight`. (Forward-safety: if a future caller ever dispatches one of those subscribed actions from a microtask continuation, this invariant
|
|
184
|
+
// must be re-checked.) Reset `flushing` here too so it never outlives the drain it belongs to.
|
|
185
|
+
inFlight = drain().finally(() => {
|
|
186
|
+
|
|
187
|
+
inFlight = null;
|
|
188
|
+
flushing = false;
|
|
189
|
+
});
|
|
190
|
+
},
|
|
191
|
+
signal,
|
|
192
|
+
store
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// Drain any pending-but-unwritten edit to disk NOW, used by the page's navigate-away chokepoint (hide()) and the best-effort browser-exit handler. flush() drives
|
|
196
|
+
// the drain to completion by setting `flushing` (which keeps the loop running past the debounce wait) and aborting the in-flight debounce so the current iteration
|
|
197
|
+
// proceeds straight to the write. It preserves single-writer serialization: it starts a NEW drain only when none is in flight; when a drain is already running it
|
|
198
|
+
// merely nudges it (set `flushing`, abort the debounce) and awaits the same promise - never a parallel commit. When the store is already clean (nothing dirty), it
|
|
199
|
+
// starts no drain and resets `flushing` immediately, so a flush() on a clean store cannot leak `flushing = true` into the next mutation's debounce decision.
|
|
200
|
+
const flush = async () => {
|
|
201
|
+
|
|
202
|
+
flushing = true;
|
|
203
|
+
|
|
204
|
+
debounceAbort?.abort();
|
|
205
|
+
|
|
206
|
+
if(!inFlight) {
|
|
207
|
+
|
|
208
|
+
if(store.state.configuredOptions === store.state.persistedAnchor) {
|
|
209
|
+
|
|
210
|
+
flushing = false;
|
|
211
|
+
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
inFlight = drain().finally(() => {
|
|
216
|
+
|
|
217
|
+
inFlight = null;
|
|
218
|
+
flushing = false;
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
await inFlight;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
return { flush };
|
|
226
|
+
};
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/* Copyright(C) 2017-2026, HJD (https://github.com/hjdhjd). All rights reserved.
|
|
2
|
+
*
|
|
3
|
+
* webUi-featureOptions/effects/theme.mjs: Theme detection, stylesheet adoption, dark-mode toggling, and Bootstrap accent probing for the feature options webUI.
|
|
4
|
+
*/
|
|
5
|
+
"use strict";
|
|
6
|
+
|
|
7
|
+
import { delay } from "../utils.mjs";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Register the theme effect. Adopts the layout/behavior stylesheet, applies color-scheme + dark-mode class from the Homebridge lighting-mode setting, listens for
|
|
11
|
+
* `prefers-color-scheme` changes, and probes Bootstrap's `.btn-primary` to enhance the accent tokens.
|
|
12
|
+
*
|
|
13
|
+
* Cleanup is automatic via the AbortSignal: aborting releases the stylesheet from the document, clears the `color-scheme`, `.fo-dark` class, and accent-token inline
|
|
14
|
+
* overrides the effect wrote on `:root` (so it leaves no trace on a shared document), removes the matchMedia listener (via `{signal}` on addEventListener), and
|
|
15
|
+
* short-circuits the in-progress Bootstrap probe at the next await checkpoint.
|
|
16
|
+
*
|
|
17
|
+
* The function returns once the synchronous portion completes (stylesheet adopted, color-scheme applied, matchMedia listener registered). The Bootstrap accent
|
|
18
|
+
* probe runs in the background - the caller's `show()` pipeline is not blocked on probe completion. Until the probe resolves, the tokens' declared `AccentColor` /
|
|
19
|
+
* `AccentColorText` defaults remain in effect, so the user sees a sensible accent immediately rather than waiting up to `probe.timeoutMs` for Bootstrap to load.
|
|
20
|
+
*
|
|
21
|
+
* @param {Object} args
|
|
22
|
+
* @param {{userCurrentLightingMode: () => Promise<string>}} args.host - The Homebridge bridge (or a test stub matching that surface). The lighting mode is normally
|
|
23
|
+
* "light" or "dark"; any unrecognized value is tolerated and treated as a no-op (no color scheme is applied).
|
|
24
|
+
* @param {AbortSignal} args.signal - Lifecycle signal. Aborting tears down every listener and the background probe.
|
|
25
|
+
* @param {Object} [args.probe] - Optional probe overrides.
|
|
26
|
+
* @param {number} [args.probe.timeoutMs=2000] - Maximum time, in milliseconds, to poll for Bootstrap's stylesheet. Override to `0` in tests to skip the probe.
|
|
27
|
+
* @param {number} [args.probe.intervalMs=20] - Poll interval, in milliseconds.
|
|
28
|
+
* @returns {Promise<void>} Resolves when the synchronous setup is complete (after color-scheme is applied and the matchMedia listener is registered). Does NOT wait
|
|
29
|
+
* for the Bootstrap probe to complete.
|
|
30
|
+
*/
|
|
31
|
+
export const registerThemeEffect = async ({ host, probe: { intervalMs = 20, timeoutMs = 2000 } = {}, signal }) => {
|
|
32
|
+
|
|
33
|
+
if(signal.aborted) {
|
|
34
|
+
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Build & adopt the stylesheet immediately. The user perceives the layout the moment this resolves, with the fallback accent color in effect via the CSS
|
|
39
|
+
// custom-property cascade. The stylesheet content is mode-independent (`:root.fo-dark` selectors resolve dynamically based on the class), so it does not need
|
|
40
|
+
// re-emission when the theme mode changes.
|
|
41
|
+
const stylesheet = new CSSStyleSheet();
|
|
42
|
+
|
|
43
|
+
stylesheet.replaceSync(buildThemeCss());
|
|
44
|
+
document.adoptedStyleSheets = [ ...document.adoptedStyleSheets, stylesheet ];
|
|
45
|
+
|
|
46
|
+
signal.addEventListener("abort", () => {
|
|
47
|
+
|
|
48
|
+
// Restore the document to its pre-effect state, symmetric with every mutation the effect made to it: drop the adopted stylesheet, then the `color-scheme` and
|
|
49
|
+
// `.fo-dark` class that applyColorScheme set on `:root` and the accent-token inline overrides the Bootstrap probe wrote there. The `color-scheme` removal is the
|
|
50
|
+
// load-bearing one - it is a native property, so a leftover `dark` value would tint default form-control and scrollbar rendering on whatever content occupies the
|
|
51
|
+
// document after teardown (in a multi-page host, a sibling tab). The class and token overrides are inert once the stylesheet that reads them is gone, but are
|
|
52
|
+
// cleared too so the effect leaves no trace on `:root`.
|
|
53
|
+
document.adoptedStyleSheets = document.adoptedStyleSheets.filter((sheet) => sheet !== stylesheet);
|
|
54
|
+
document.documentElement.classList.remove("fo-dark");
|
|
55
|
+
document.documentElement.style.removeProperty("color-scheme");
|
|
56
|
+
document.documentElement.style.removeProperty("--fo-accent-bg");
|
|
57
|
+
document.documentElement.style.removeProperty("--fo-accent-fg");
|
|
58
|
+
}, { once: true });
|
|
59
|
+
|
|
60
|
+
// Apply the color-scheme from the current Homebridge setting. The lightweight portion of "apply theme" - no Bootstrap probe required; just sets the color-scheme
|
|
61
|
+
// property on :root and toggles the fo-dark class.
|
|
62
|
+
applyColorScheme(await host.userCurrentLightingMode());
|
|
63
|
+
|
|
64
|
+
if(signal.aborted) {
|
|
65
|
+
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Listen for system / browser changes to the current dark-mode setting. Re-applying the color-scheme is cheap (no probe); the accent is then re-probed directly
|
|
70
|
+
// and immediately below - not through the deferred wait-for-Bootstrap path used at initial registration - since Bootstrap is assumed to already be loaded by
|
|
71
|
+
// the time a preference change can fire.
|
|
72
|
+
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", async () => {
|
|
73
|
+
|
|
74
|
+
applyColorScheme(await host.userCurrentLightingMode());
|
|
75
|
+
|
|
76
|
+
if(signal.aborted) {
|
|
77
|
+
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
probeAndApplyAccent();
|
|
82
|
+
}, { signal });
|
|
83
|
+
|
|
84
|
+
// Fire the Bootstrap probe in the background. Its job is to replace the fallback `AccentColor` keyword with Bootstrap's actual `.btn-primary` background color;
|
|
85
|
+
// until it resolves, the user sees the system accent. Void-discarded because the caller cannot meaningfully wait on it.
|
|
86
|
+
void runAccentProbe({ intervalMs, signal, timeoutMs });
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Set the color-scheme property on :root and toggle the fo-dark class. No accent probe - the accent custom properties are managed separately so the lightweight
|
|
90
|
+
// color-scheme update does not pay the probe cost.
|
|
91
|
+
const applyColorScheme = (mode) => {
|
|
92
|
+
|
|
93
|
+
if((mode !== "dark") && (mode !== "light")) {
|
|
94
|
+
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const current = document.documentElement.style.getPropertyValue("color-scheme");
|
|
99
|
+
|
|
100
|
+
if(current === mode) {
|
|
101
|
+
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
document.documentElement.style.setProperty("color-scheme", mode);
|
|
106
|
+
document.documentElement.classList.toggle("fo-dark", mode === "dark");
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// Background probe coordinator. Awaits Bootstrap readiness (or the timeout), then re-probes the accent color and writes the result into the :root custom properties.
|
|
110
|
+
// A teardown that fires mid-probe short-circuits the post-await mutation; the stylesheet stays adopted (the signal-keyed listener handles that) and the fallback
|
|
111
|
+
// accent remains in effect for the brief window before teardown completes.
|
|
112
|
+
const runAccentProbe = async ({ intervalMs, signal, timeoutMs }) => {
|
|
113
|
+
|
|
114
|
+
await waitForBootstrap({ intervalMs, signal, timeoutMs });
|
|
115
|
+
|
|
116
|
+
if(signal.aborted) {
|
|
117
|
+
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
probeAndApplyAccent();
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// Probe the current Bootstrap `.btn-primary` accent color and write it into the `--fo-accent-*` tokens. The cascade propagates the change to every element
|
|
125
|
+
// referencing the accent tokens or their `color-mix()` derivatives.
|
|
126
|
+
//
|
|
127
|
+
// Probed values are validated before being written. An empty string or fully-transparent value (which happens when Bootstrap's stylesheet has not applied to the
|
|
128
|
+
// probe button yet) is rejected, leaving the tokens module's declared `AccentColor` default in effect. The opportunistic-override contract: the probe enhances
|
|
129
|
+
// when it can, never degrades when it can not.
|
|
130
|
+
const probeAndApplyAccent = () => {
|
|
131
|
+
|
|
132
|
+
const colors = probeAccentColor();
|
|
133
|
+
|
|
134
|
+
if(isValidAccentValue(colors.background)) {
|
|
135
|
+
|
|
136
|
+
document.documentElement.style.setProperty("--fo-accent-bg", colors.background);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if(isValidAccentValue(colors.text)) {
|
|
140
|
+
|
|
141
|
+
document.documentElement.style.setProperty("--fo-accent-fg", colors.text);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// Probe the current primary background and foreground from Bootstrap's .btn-primary. Returns an object with the two resolved color values; caller decides whether
|
|
146
|
+
// to accept or reject each via {@link isValidAccentValue}.
|
|
147
|
+
const probeAccentColor = () => {
|
|
148
|
+
|
|
149
|
+
const probeBtn = document.createElement("button");
|
|
150
|
+
|
|
151
|
+
probeBtn.className = "btn btn-primary";
|
|
152
|
+
|
|
153
|
+
// getComputedStyle resolves color and background-color independent of layout, so hiding the probe button via display: none does not affect the readout below.
|
|
154
|
+
probeBtn.style.display = "none";
|
|
155
|
+
document.body.appendChild(probeBtn);
|
|
156
|
+
|
|
157
|
+
const background = getComputedStyle(probeBtn).backgroundColor;
|
|
158
|
+
const text = getComputedStyle(probeBtn).color;
|
|
159
|
+
|
|
160
|
+
document.body.removeChild(probeBtn);
|
|
161
|
+
|
|
162
|
+
return { background, text };
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// Whether a probed accent-color value is usable. Empty, fully transparent, or `unset`-equivalent values mean the probe ran before Bootstrap was ready; writing
|
|
166
|
+
// them would replace the sensible `AccentColor` default with a useless value.
|
|
167
|
+
const isValidAccentValue = (value) => {
|
|
168
|
+
|
|
169
|
+
if(!value) {
|
|
170
|
+
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const normalized = value.replace(/\s+/g, "").toLowerCase();
|
|
175
|
+
|
|
176
|
+
return (normalized !== "transparent") && (normalized !== "rgba(0,0,0,0)");
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// Wait for Bootstrap to finish loading in the DOM, or until the timeout expires. The composed signal (deadline + caller) is the single source of truth for the
|
|
180
|
+
// loop and each per-iteration delay; one mechanism handles both timeout exhaustion and external cancellation. Emits a console.warn when the timeout expires
|
|
181
|
+
// without detecting Bootstrap - a timeout-with-no-result is actionable info for plugin developers configuring their UI; without the warning the silent fallback
|
|
182
|
+
// would leave them wondering why their accent color does not match Bootstrap.
|
|
183
|
+
const waitForBootstrap = async ({ intervalMs, signal, timeoutMs }) => {
|
|
184
|
+
|
|
185
|
+
// Honor the documented `probe: { timeoutMs: 0 }` opt-out synchronously. A pre-aborted caller signal gets the same fast path.
|
|
186
|
+
if((timeoutMs <= 0) || signal.aborted) {
|
|
187
|
+
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// .d-none is a Bootstrap-defined utility class, not a native CSS keyword; its display: none effect on this briefly-attached probe element only appears once
|
|
192
|
+
// Bootstrap's stylesheet has actually applied, making it a reliable readiness signal.
|
|
193
|
+
const isBootstrapApplied = () => {
|
|
194
|
+
|
|
195
|
+
const testElem = document.createElement("div");
|
|
196
|
+
|
|
197
|
+
testElem.className = "d-none";
|
|
198
|
+
document.body.appendChild(testElem);
|
|
199
|
+
|
|
200
|
+
const display = getComputedStyle(testElem).display;
|
|
201
|
+
|
|
202
|
+
document.body.removeChild(testElem);
|
|
203
|
+
|
|
204
|
+
return display === "none";
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const deadline = AbortSignal.timeout(timeoutMs);
|
|
208
|
+
const composed = AbortSignal.any([ signal, deadline ]);
|
|
209
|
+
|
|
210
|
+
while(!composed.aborted) {
|
|
211
|
+
|
|
212
|
+
if(isBootstrapApplied()) {
|
|
213
|
+
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
|
|
219
|
+
// The poll interval is the loop's intentional throttle - one probe per interval, not in a tight loop. Same exception as the persist drain: the await is the
|
|
220
|
+
// point of the iteration.
|
|
221
|
+
// eslint-disable-next-line no-await-in-loop
|
|
222
|
+
await delay(intervalMs, composed);
|
|
223
|
+
} catch {
|
|
224
|
+
|
|
225
|
+
// Composed signal aborted (deadline or caller). Exit the loop.
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Only warn on the deadline path - a caller-driven abort (page teardown) is not actionable, just a lifecycle event. The accent color falls back to the system
|
|
231
|
+
// AccentColor keyword; the UI continues to function.
|
|
232
|
+
if(deadline.aborted && !signal.aborted) {
|
|
233
|
+
|
|
234
|
+
// eslint-disable-next-line no-console
|
|
235
|
+
console.warn("FeatureOptions: Bootstrap stylesheet did not load within " + timeoutMs + "ms - accent color falling back to the system AccentColor keyword.");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return false;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
// The theme stylesheet. Layout, sidebar, category-frame, search bar, dark-mode utility-class overrides. Color, spacing, radius, and motion values reference the
|
|
242
|
+
// `--fo-*` tokens declared by the tokens effect; structural layout rules (resets, flex containers, container queries) intentionally use raw values since they are
|
|
243
|
+
// not design-token concerns. Consumers see one cohesive design language regardless of which mode is active.
|
|
244
|
+
const buildThemeCss = () => [
|
|
245
|
+
|
|
246
|
+
// Base layout reset.
|
|
247
|
+
"html, body { margin: 0; padding: 0; }",
|
|
248
|
+
|
|
249
|
+
// Single source of truth for option-row visibility. Search, filter, and dependency logic all toggle this class.
|
|
250
|
+
".fo-hidden { display: none !important; }",
|
|
251
|
+
|
|
252
|
+
// Page background AND base text color - the two halves of the base contrast pair, owned together. HBPU forces the surface background, so it must also own the text
|
|
253
|
+
// color: otherwise the body inherits config-ui-x's cascade, which - because the custom-UI iframe body carries Bootstrap's `.modal-content` class - resolves the
|
|
254
|
+
// text from `--bs-modal-color` rather than the surface's body color, so an unrelated host value can land on the forced background and render inherited text
|
|
255
|
+
// (category headers, device names) unreadable. `!important` beats `.modal-content`'s class-level color; elements carrying their own `.text-*` class still set
|
|
256
|
+
// their own color, so only un-classed inherited text is affected.
|
|
257
|
+
"body { background-color: var(--fo-surface-bg) !important; color: var(--fo-text-on-elevated) !important; }",
|
|
258
|
+
|
|
259
|
+
// Page layout.
|
|
260
|
+
"#pageFeatureOptions { display: flex !important; flex-direction: column; width: 100%; }",
|
|
261
|
+
".feature-main-content { display: flex !important; flex-direction: row !important; width: 100%; }",
|
|
262
|
+
|
|
263
|
+
// Sidebar. Background matches the main surface rather than an elevated fill; an accent-derived border delineates it, consistent with the other container frames.
|
|
264
|
+
"#sidebar { display: block; width: 200px; min-width: 200px; max-width: 200px; position: relative; " +
|
|
265
|
+
"background-color: var(--fo-surface-bg) !important; border: 1px solid var(--fo-border-accent); border-radius: var(--fo-radius-md); }",
|
|
266
|
+
"#sidebar .sidebar-content { padding: 0rem; overflow: unset; }",
|
|
267
|
+
"#controllersContainer { padding: 0; margin-bottom: 0; }",
|
|
268
|
+
"#devicesContainer { padding: 0; margin-top: 0; padding-top: 0 !important; }",
|
|
269
|
+
|
|
270
|
+
// Feature content (right-hand pane).
|
|
271
|
+
".feature-content { display: flex !important; flex-direction: column !important; flex: 1 1 auto; min-width: 0; }",
|
|
272
|
+
|
|
273
|
+
// Category disclosure header.
|
|
274
|
+
".fo-category-header { align-items: center; cursor: pointer; display: flex; font-weight: bold; gap: var(--fo-space-sm); " +
|
|
275
|
+
"list-style: none; padding: var(--fo-space-sm) var(--fo-space-md); user-select: none; -webkit-user-select: none; }",
|
|
276
|
+
".fo-category-header::-webkit-details-marker { display: none; }",
|
|
277
|
+
".fo-category-header:hover { color: var(--fo-accent-bg); }",
|
|
278
|
+
|
|
279
|
+
// Arrow glyph.
|
|
280
|
+
".fo-category-arrow { display: inline-block; font-family: var(--fo-font-monospace); line-height: 1; " +
|
|
281
|
+
"transition: transform var(--fo-transition-base); }",
|
|
282
|
+
"details[open] > .fo-category-header .fo-category-arrow { transform: rotate(90deg); }",
|
|
283
|
+
|
|
284
|
+
// Rows container: bordered accent frame + soft outer ring + two subgrid column tracks - the checkbox gutter and the content column. A value option's field stacks
|
|
285
|
+
// beneath its label inside the content column rather than occupying a third track, so there is no shared input column whose width a wide field could distort.
|
|
286
|
+
".fo-category-rows { border: 1px solid var(--fo-accent-bg); border-radius: var(--fo-radius-md); " +
|
|
287
|
+
"box-shadow: 0 0 0 1px var(--fo-accent-hover); overflow: hidden; display: grid; grid-template-columns: auto 1fr; }",
|
|
288
|
+
|
|
289
|
+
// Off-screen rendering optimization, scoped to OPEN categories only. `content-visibility: auto` lets the browser skip layout and paint for off-screen rows;
|
|
290
|
+
// `contain-intrinsic-size` provides a placeholder height (heuristic 200px average) so scroll position stays stable as open categories enter and leave the viewport.
|
|
291
|
+
// It MUST stay scoped to `details[open]`: a collapsed category's rows container is empty (rows materialize lazily on first expand), and leaving content-visibility on
|
|
292
|
+
// it makes the browser hold the `contain-intrinsic-size` placeholder height instead of letting the closed disclosure collapse the container to zero. This is the
|
|
293
|
+
// defined behavior of content-visibility, not an engine quirk - it was observed identically in Chromium and WebKit, and was the source of the large empty gaps
|
|
294
|
+
// between collapsed categories. Scoping to open categories keeps the optimization where rows actually exist and lets collapsed categories collapse to zero.
|
|
295
|
+
"details[open] > .fo-category-rows { content-visibility: auto; contain-intrinsic-size: 0 200px; }",
|
|
296
|
+
|
|
297
|
+
// Per-row subgrid inheriting the parent's column tracks. The checkbox top-aligns (align-items: start) so that on a multi-line label - or a value option whose field
|
|
298
|
+
// stacks beneath the label - it sits beside the first line rather than floating against the vertical center of a tall cell.
|
|
299
|
+
".fo-option-row { align-items: start; display: grid; gap: var(--fo-space-sm); grid-column: 1 / -1; " +
|
|
300
|
+
"grid-template-columns: subgrid; padding: var(--fo-space-xs) var(--fo-space-md); " +
|
|
301
|
+
"transition: background-color var(--fo-transition-fast); }",
|
|
302
|
+
".fo-option-row:hover { background-color: var(--fo-row-hover-bg); }",
|
|
303
|
+
".fo-option-row.fo-hidden { display: none !important; }",
|
|
304
|
+
|
|
305
|
+
// The checkbox top-aligns with the row; this nudge re-centers it on the label's first line (half the line's leading), so a single-line row keeps the control optically
|
|
306
|
+
// centered on its text while a multi-line or stacked row aligns the control to the first line.
|
|
307
|
+
".fo-option-checkbox { margin-top: calc((1lh - 1em) / 2); }",
|
|
308
|
+
|
|
309
|
+
// Content cell: the label and, for a value option, its field stack vertically. align-items: flex-start keeps the fixed-width field left-aligned at its declared width
|
|
310
|
+
// rather than stretching, and min-width: 0 lets a long label wrap within the grid track instead of forcing the track wider.
|
|
311
|
+
".fo-option-content { align-items: flex-start; display: flex; flex-direction: column; gap: var(--fo-space-xs); min-width: 0; }",
|
|
312
|
+
|
|
313
|
+
// Main options area. Owns its outline with a theme-aware border rather than config-ui-x's box-shadow "border", which exists only in the host's dark theme (so it
|
|
314
|
+
// is absent in light) and bleeds outside the box (so it clips at the flush iframe edge). Every outer container frame shares --fo-border-accent (the probed theme
|
|
315
|
+
// accent, subtled), a step lighter than the full-accent per-category frame nested inside so the two read as one family at different weights; the border sits
|
|
316
|
+
// inside the box, so no edge gutter, and box-shadow: none drops the leftover host shadow.
|
|
317
|
+
"#optionsContainer { border: 1px solid var(--fo-border-accent); box-shadow: none; margin: 0; padding: 1rem; }",
|
|
318
|
+
|
|
319
|
+
// Info header. Owns its outline via --fo-border-accent (see #optionsContainer) so it reads in both themes; the inside-the-box border needs no edge gutter.
|
|
320
|
+
"#headerInfo { border: 1px solid var(--fo-border-accent); box-shadow: none; flex-shrink: 0; " +
|
|
321
|
+
"margin-bottom: var(--fo-space-sm) !important; padding: var(--fo-space-sm) !important; }",
|
|
322
|
+
|
|
323
|
+
// Device stats grid. The `#headerInfo` ancestor carries `container-type: inline-size` (below) so the grid's responsive hiding fires when its container narrows -
|
|
324
|
+
// not when the viewport does. The Homebridge plugin UI panel can resize independently of the viewport (custom UI tab, embedded contexts), so container-relative
|
|
325
|
+
// sizing is what users actually want.
|
|
326
|
+
"#headerInfo { container-type: inline-size; }",
|
|
327
|
+
".device-stats-grid { border: 1px solid var(--fo-border-accent); border-radius: var(--fo-radius-md); display: flex; " +
|
|
328
|
+
"justify-content: space-between; gap: var(--fo-space-md); margin-bottom: var(--fo-space-sm); padding: 0 var(--fo-space-md); flex-wrap: nowrap; overflow: hidden; }",
|
|
329
|
+
".device-stats-grid .stat-item:first-child { flex: 0 0 25%; }",
|
|
330
|
+
".device-stats-grid .stat-item:not(:first-child) { flex-grow: 1; min-width: 0; }",
|
|
331
|
+
".stat-item { display: flex; flex-direction: column; gap: var(--fo-space-xxs); }",
|
|
332
|
+
".stat-label { font-weight: 600; color: var(--fo-text-muted); font-size: var(--fo-font-size-xs); " +
|
|
333
|
+
"text-transform: uppercase; letter-spacing: 0.05em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }",
|
|
334
|
+
".stat-value { font-size: 0.875rem; color: var(--fo-text-on-elevated); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }",
|
|
335
|
+
|
|
336
|
+
// Responsive hiding for device stats grid. `@container` (not `@media`) so the breakpoints fire on the panel's actual width, not the viewport. Progressive
|
|
337
|
+
// degradation: as the container narrows, the last stat hides first, then the second-to-last, etc., so the most-important left-most stat (Firmware) stays visible
|
|
338
|
+
// longest.
|
|
339
|
+
"@container (max-width: 700px) { .device-stats-grid .stat-item:nth-last-of-type(1) { display: none !important; } }",
|
|
340
|
+
"@container (max-width: 500px) { .device-stats-grid .stat-item:nth-last-of-type(2) { display: none !important; } }",
|
|
341
|
+
"@container (max-width: 300px) { .device-stats-grid .stat-item:nth-last-of-type(3) { display: none !important; } }",
|
|
342
|
+
"@container (max-width: 400px) { #statusInfo { display: none !important; } }",
|
|
343
|
+
|
|
344
|
+
// Navigation styles.
|
|
345
|
+
".nav-link { border-radius: var(--fo-radius-sm); transition: all var(--fo-transition-base); position: relative; " +
|
|
346
|
+
"padding: var(--fo-space-xs) var(--fo-space-md) !important; line-height: 1.2; font-size: var(--fo-font-size-sm); }",
|
|
347
|
+
".nav-link:hover { background-color: var(--fo-accent-hover); color: var(--fo-accent-bg) !important; }",
|
|
348
|
+
".nav-link.active { background-color: var(--fo-accent-bg); color: var(--fo-accent-fg) !important; }",
|
|
349
|
+
// In-scope controller affordance: a 1px accent ring (inset box-shadow so it follows the radius and adds no layout shift) marking the controller whose devices are
|
|
350
|
+
// currently listed. `:not(.active)` suppresses it when that controller is the active selection - so it shows only in the Global-selected state, one tier below the
|
|
351
|
+
// filled active pill.
|
|
352
|
+
".nav-link.context:not(.active) { box-shadow: inset 0 0 0 1px var(--fo-border-accent); }",
|
|
353
|
+
".nav-header { border-bottom: 1px solid var(--fo-border-subtle); margin-bottom: var(--fo-space-xxs); " +
|
|
354
|
+
"padding: var(--fo-space-xs) var(--fo-space-md) !important; font-size: var(--fo-font-size-xs) !important; line-height: 1.2; }",
|
|
355
|
+
"#devicesContainer .nav-header, #controllersContainer .nav-header { font-weight: 600; margin-top: 0 !important; padding-top: var(--fo-space-sm) !important; }",
|
|
356
|
+
// The Global Options link shares `.nav-header` for its bold-uppercase look but is a selectable, highlighted pill, not a section separator - so it keeps symmetric
|
|
357
|
+
// vertical padding rather than inheriting the section-header top spacing above (which otherwise pushes its text below the pill's center).
|
|
358
|
+
"#controllersContainer .nav-link[data-navigation=\"global\"] { padding-top: var(--fo-space-xs) !important; }",
|
|
359
|
+
|
|
360
|
+
// Search bar.
|
|
361
|
+
".search-toolbar { border-radius: var(--fo-radius-md); padding: 0 0 var(--fo-space-sm) 0; }",
|
|
362
|
+
".search-input-wrapper { min-width: 0; }",
|
|
363
|
+
".filter-pills { display: flex; gap: var(--fo-space-sm); flex-wrap: wrap; }",
|
|
364
|
+
|
|
365
|
+
// Search input resting border. Matches the container frames via --fo-border-accent for a consistent outline; scoped :not(:focus) so it never touches the focus
|
|
366
|
+
// state - the accent glow (box-shadow) and focus border that appear when the field is selected are left entirely to the focus rules below.
|
|
367
|
+
"#search .form-control:not(:focus) { border-color: var(--fo-border-accent); }",
|
|
368
|
+
|
|
369
|
+
// Search input focus state (governs light mode). Sets the accent border and glow so the selected field matches the theme instead of Bootstrap's blue; the
|
|
370
|
+
// dark-mode override further below wins in dark and carries the same glow via the shared --fo-focus-ring token.
|
|
371
|
+
"#search .form-control:focus { border-color: var(--fo-border-accent); box-shadow: var(--fo-focus-ring); }",
|
|
372
|
+
|
|
373
|
+
// Status bar. Owns its outline via --fo-border-accent (see #optionsContainer) for a consistent border in both themes.
|
|
374
|
+
"#featureStatusBar { border: 1px solid var(--fo-border-accent); box-shadow: none; }",
|
|
375
|
+
|
|
376
|
+
// Grouped-option visual indicator.
|
|
377
|
+
".fo-option-row.grouped-option { background-color: var(--fo-accent-subtle); }",
|
|
378
|
+
".fo-option-row.grouped-option .fo-option-content { padding-left: 1.25rem; position: relative; }",
|
|
379
|
+
".fo-option-row.grouped-option .fo-option-content::before { content: \"\\21B3\"; position: absolute; left: var(--fo-space-xs); color: var(--fo-grouped-indicator); }",
|
|
380
|
+
|
|
381
|
+
// Dark-mode-only overrides for Bootstrap utility classes that need treatment beyond what tokens express.
|
|
382
|
+
":root.fo-dark .text-body { color: var(--fo-text-muted) !important; }",
|
|
383
|
+
":root.fo-dark .text-muted { color: var(--fo-text-muted) !important; }",
|
|
384
|
+
":root.fo-dark #search .form-control { background-color: var(--fo-form-control-bg); border-color: var(--fo-border-accent); color: var(--fo-text-on-elevated); }",
|
|
385
|
+
":root.fo-dark #search .form-control:focus { background-color: var(--fo-form-control-bg); border-color: var(--fo-form-control-focus-border); " +
|
|
386
|
+
"color: var(--fo-text-on-elevated); box-shadow: var(--fo-focus-ring); }",
|
|
387
|
+
":root.fo-dark #search .form-control::placeholder { color: var(--fo-form-control-placeholder); }",
|
|
388
|
+
":root.fo-dark #statusInfo .text-muted { color: var(--fo-statusinfo-muted) !important; }",
|
|
389
|
+
|
|
390
|
+
// Utility styles.
|
|
391
|
+
".btn-xs { font-size: var(--fo-font-size-xs) !important; padding: var(--fo-space-xxs) var(--fo-space-sm) !important; line-height: 1.5; touch-action: manipulation; }",
|
|
392
|
+
".cursor-pointer { cursor: pointer; }",
|
|
393
|
+
".user-select-none { user-select: none; -webkit-user-select: none; }",
|
|
394
|
+
|
|
395
|
+
// Accessibility.
|
|
396
|
+
"@media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }"
|
|
397
|
+
|
|
398
|
+
].join("\n");
|