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.
Files changed (198) hide show
  1. package/README.md +130 -2
  2. package/build/eslint-plugin/README.md +164 -0
  3. package/build/eslint-plugin/config.mjs +308 -0
  4. package/build/eslint-plugin/index.mjs +7 -0
  5. package/build/eslint-plugin/plugin.mjs +46 -0
  6. package/build/eslint-plugin/rules/blank-line-after-open-brace.mjs +137 -0
  7. package/build/eslint-plugin/rules/blank-line-after-open-brace.test.mjs +112 -0
  8. package/build/eslint-plugin/rules/comment-style.mjs +190 -0
  9. package/build/eslint-plugin/rules/comment-style.test.mjs +190 -0
  10. package/build/eslint-plugin/rules/enforce-node-protocol.mjs +114 -0
  11. package/build/eslint-plugin/rules/enforce-node-protocol.test.mjs +116 -0
  12. package/build/eslint-plugin/rules/paren-comparisons-in-logical.mjs +94 -0
  13. package/build/eslint-plugin/rules/paren-comparisons-in-logical.test.mjs +107 -0
  14. package/build/eslint-plugin/rules/split-type-imports.mjs +354 -0
  15. package/build/eslint-plugin/rules/split-type-imports.test.mjs +268 -0
  16. package/build/eslint-plugin/test-setup.mjs +21 -0
  17. package/build/fs-ops.mjs +184 -0
  18. package/build/tsconfig.json +18 -3
  19. package/dist/backpressure.d.ts +128 -0
  20. package/dist/backpressure.js +273 -0
  21. package/dist/backpressure.js.map +1 -0
  22. package/dist/cli/index.d.ts +95 -0
  23. package/dist/cli/index.js +355 -0
  24. package/dist/cli/index.js.map +1 -0
  25. package/dist/clock-double.d.ts +93 -0
  26. package/dist/clock-double.js +141 -0
  27. package/dist/clock-double.js.map +1 -0
  28. package/dist/clock.d.ts +39 -0
  29. package/dist/clock.js +34 -0
  30. package/dist/clock.js.map +1 -0
  31. package/dist/eslint-plugin/config.d.mts +193 -0
  32. package/dist/eslint-plugin/index.d.mts +2 -0
  33. package/dist/eslint-plugin/plugin.d.mts +87 -0
  34. package/dist/eslint-plugin/rules/blank-line-after-open-brace.d.mts +19 -0
  35. package/dist/eslint-plugin/rules/comment-style.d.mts +15 -0
  36. package/dist/eslint-plugin/rules/enforce-node-protocol.d.mts +18 -0
  37. package/dist/eslint-plugin/rules/paren-comparisons-in-logical.d.mts +15 -0
  38. package/dist/eslint-plugin/rules/split-type-imports.d.mts +16 -0
  39. package/dist/featureOptions-docs.d.ts +96 -0
  40. package/dist/featureOptions-docs.js +193 -0
  41. package/dist/featureOptions-docs.js.map +1 -0
  42. package/dist/featureOptions.d.ts +674 -0
  43. package/dist/featureOptions.js +870 -0
  44. package/dist/featureOptions.js.map +1 -0
  45. package/dist/ffmpeg/codecs.d.ts +256 -72
  46. package/dist/ffmpeg/codecs.js +477 -257
  47. package/dist/ffmpeg/codecs.js.map +1 -1
  48. package/dist/ffmpeg/dgram-util.d.ts +46 -0
  49. package/dist/ffmpeg/dgram-util.js +38 -0
  50. package/dist/ffmpeg/dgram-util.js.map +1 -0
  51. package/dist/ffmpeg/exec.d.ts +83 -64
  52. package/dist/ffmpeg/exec.js +77 -86
  53. package/dist/ffmpeg/exec.js.map +1 -1
  54. package/dist/ffmpeg/fmp4.d.ts +70 -4
  55. package/dist/ffmpeg/fmp4.js +100 -11
  56. package/dist/ffmpeg/fmp4.js.map +1 -1
  57. package/dist/ffmpeg/hap-enums.d.ts +214 -0
  58. package/dist/ffmpeg/hap-enums.js +92 -0
  59. package/dist/ffmpeg/hap-enums.js.map +1 -0
  60. package/dist/ffmpeg/index.d.ts +16 -9
  61. package/dist/ffmpeg/index.js +6 -0
  62. package/dist/ffmpeg/index.js.map +1 -1
  63. package/dist/ffmpeg/mp4-assembler.d.ts +120 -0
  64. package/dist/ffmpeg/mp4-assembler.js +387 -0
  65. package/dist/ffmpeg/mp4-assembler.js.map +1 -0
  66. package/dist/ffmpeg/mp4-parser.d.ts +94 -0
  67. package/dist/ffmpeg/mp4-parser.js +130 -0
  68. package/dist/ffmpeg/mp4-parser.js.map +1 -0
  69. package/dist/ffmpeg/options.d.ts +62 -149
  70. package/dist/ffmpeg/options.js +608 -499
  71. package/dist/ffmpeg/options.js.map +1 -1
  72. package/dist/ffmpeg/process.d.ts +143 -91
  73. package/dist/ffmpeg/process.js +406 -265
  74. package/dist/ffmpeg/process.js.map +1 -1
  75. package/dist/ffmpeg/record.d.ts +302 -188
  76. package/dist/ffmpeg/record.js +408 -560
  77. package/dist/ffmpeg/record.js.map +1 -1
  78. package/dist/ffmpeg/recording-process-double.d.ts +144 -0
  79. package/dist/ffmpeg/recording-process-double.js +168 -0
  80. package/dist/ffmpeg/recording-process-double.js.map +1 -0
  81. package/dist/ffmpeg/rtp-parser.d.ts +70 -0
  82. package/dist/ffmpeg/rtp-parser.js +77 -0
  83. package/dist/ffmpeg/rtp-parser.js.map +1 -0
  84. package/dist/ffmpeg/rtp.d.ts +198 -141
  85. package/dist/ffmpeg/rtp.js +473 -244
  86. package/dist/ffmpeg/rtp.js.map +1 -1
  87. package/dist/ffmpeg/settings.d.ts +5 -1
  88. package/dist/ffmpeg/settings.js +20 -2
  89. package/dist/ffmpeg/settings.js.map +1 -1
  90. package/dist/ffmpeg/stream.d.ts +57 -107
  91. package/dist/ffmpeg/stream.js +121 -148
  92. package/dist/ffmpeg/stream.js.map +1 -1
  93. package/dist/formatters.d.ts +106 -0
  94. package/dist/formatters.js +174 -0
  95. package/dist/formatters.js.map +1 -0
  96. package/dist/homebridge-enums.d.ts +30 -0
  97. package/dist/homebridge-enums.js +17 -0
  98. package/dist/homebridge-enums.js.map +1 -0
  99. package/dist/index.d.ts +12 -5
  100. package/dist/index.js +8 -2
  101. package/dist/index.js.map +1 -1
  102. package/dist/logclient/auth.d.ts +114 -0
  103. package/dist/logclient/auth.js +199 -0
  104. package/dist/logclient/auth.js.map +1 -0
  105. package/dist/logclient/cli-run.d.ts +76 -0
  106. package/dist/logclient/cli-run.js +639 -0
  107. package/dist/logclient/cli-run.js.map +1 -0
  108. package/dist/logclient/cli.d.ts +3 -0
  109. package/dist/logclient/cli.js +97 -0
  110. package/dist/logclient/cli.js.map +1 -0
  111. package/dist/logclient/client.d.ts +145 -0
  112. package/dist/logclient/client.js +600 -0
  113. package/dist/logclient/client.js.map +1 -0
  114. package/dist/logclient/config.d.ts +173 -0
  115. package/dist/logclient/config.js +199 -0
  116. package/dist/logclient/config.js.map +1 -0
  117. package/dist/logclient/endpoints.d.ts +54 -0
  118. package/dist/logclient/endpoints.js +73 -0
  119. package/dist/logclient/endpoints.js.map +1 -0
  120. package/dist/logclient/filter.d.ts +45 -0
  121. package/dist/logclient/filter.js +51 -0
  122. package/dist/logclient/filter.js.map +1 -0
  123. package/dist/logclient/frame.d.ts +93 -0
  124. package/dist/logclient/frame.js +203 -0
  125. package/dist/logclient/frame.js.map +1 -0
  126. package/dist/logclient/index.d.ts +31 -0
  127. package/dist/logclient/index.js +12 -0
  128. package/dist/logclient/index.js.map +1 -0
  129. package/dist/logclient/parser.d.ts +211 -0
  130. package/dist/logclient/parser.js +393 -0
  131. package/dist/logclient/parser.js.map +1 -0
  132. package/dist/logclient/rest.d.ts +41 -0
  133. package/dist/logclient/rest.js +111 -0
  134. package/dist/logclient/rest.js.map +1 -0
  135. package/dist/logclient/settings.d.ts +15 -0
  136. package/dist/logclient/settings.js +64 -0
  137. package/dist/logclient/settings.js.map +1 -0
  138. package/dist/logclient/socket-double.d.ts +201 -0
  139. package/dist/logclient/socket-double.js +384 -0
  140. package/dist/logclient/socket-double.js.map +1 -0
  141. package/dist/logclient/socket.d.ts +257 -0
  142. package/dist/logclient/socket.js +620 -0
  143. package/dist/logclient/socket.js.map +1 -0
  144. package/dist/logclient/stitch.d.ts +83 -0
  145. package/dist/logclient/stitch.js +146 -0
  146. package/dist/logclient/stitch.js.map +1 -0
  147. package/dist/logclient/time-expression.d.ts +42 -0
  148. package/dist/logclient/time-expression.js +181 -0
  149. package/dist/logclient/time-expression.js.map +1 -0
  150. package/dist/logclient/time-window.d.ts +38 -0
  151. package/dist/logclient/time-window.js +53 -0
  152. package/dist/logclient/time-window.js.map +1 -0
  153. package/dist/logclient/types.d.ts +107 -0
  154. package/dist/logclient/types.js +6 -0
  155. package/dist/logclient/types.js.map +1 -0
  156. package/dist/mqttClient.d.ts +287 -0
  157. package/dist/mqttClient.js +433 -0
  158. package/dist/mqttClient.js.map +1 -0
  159. package/dist/service.d.ts +64 -15
  160. package/dist/service.js +93 -66
  161. package/dist/service.js.map +1 -1
  162. package/dist/ui/featureOptions.js +870 -0
  163. package/dist/ui/featureOptions.js.map +1 -0
  164. package/dist/ui/formatters.js +174 -0
  165. package/dist/ui/formatters.js.map +1 -0
  166. package/dist/ui/pluginConfigSession.mjs +141 -0
  167. package/dist/ui/webUi-featureOptions/categoryState.mjs +135 -0
  168. package/dist/ui/webUi-featureOptions/effects/keyboard.mjs +61 -0
  169. package/dist/ui/webUi-featureOptions/effects/persist.mjs +226 -0
  170. package/dist/ui/webUi-featureOptions/effects/theme.mjs +398 -0
  171. package/dist/ui/webUi-featureOptions/effects/tokens.mjs +152 -0
  172. package/dist/ui/webUi-featureOptions/rendering.mjs +431 -0
  173. package/dist/ui/webUi-featureOptions/selectors.mjs +360 -0
  174. package/dist/ui/webUi-featureOptions/state.mjs +319 -0
  175. package/dist/ui/webUi-featureOptions/store.mjs +181 -0
  176. package/dist/ui/webUi-featureOptions/utils.mjs +200 -0
  177. package/dist/ui/webUi-featureOptions/views/connectionError.mjs +152 -0
  178. package/dist/ui/webUi-featureOptions/views/deviceInfo.mjs +80 -0
  179. package/dist/ui/webUi-featureOptions/views/header.mjs +77 -0
  180. package/dist/ui/webUi-featureOptions/views/nav.mjs +327 -0
  181. package/dist/ui/webUi-featureOptions/views/options.mjs +521 -0
  182. package/dist/ui/webUi-featureOptions/views/search.mjs +395 -0
  183. package/dist/ui/webUi-featureOptions.mjs +677 -0
  184. package/dist/ui/webUi.mjs +192 -87
  185. package/dist/util.d.ts +597 -45
  186. package/dist/util.js +787 -68
  187. package/dist/util.js.map +1 -1
  188. package/package.json +33 -15
  189. package/build/eslint-rules.mjs +0 -511
  190. package/dist/featureoptions.d.ts +0 -264
  191. package/dist/featureoptions.js +0 -480
  192. package/dist/featureoptions.js.map +0 -1
  193. package/dist/mqttclient.d.ts +0 -178
  194. package/dist/mqttclient.js +0 -310
  195. package/dist/mqttclient.js.map +0 -1
  196. package/dist/ui/featureoptions.js +0 -480
  197. package/dist/ui/featureoptions.js.map +0 -1
  198. package/dist/ui/webUi-featureoptions.mjs +0 -3765
@@ -0,0 +1,521 @@
1
+ /* Copyright(C) 2017-2026, HJD (https://github.com/hjdhjd). All rights reserved.
2
+ *
3
+ * webUi-featureOptions/views/options.mjs: The config table view - categories, lazy rows, tri-state clicks, scope-aware cache, category-state persistence.
4
+ */
5
+ "use strict";
6
+
7
+ import { applyCategoryStates, captureCategoryStates } from "../utils.mjs";
8
+ import { applyRowState, categoryShell, optionRow, triStateTransition } from "../rendering.mjs";
9
+ import { projection, scopeCacheKey, selectedControllerId, selectedDeviceId } from "../selectors.mjs";
10
+ import { FeatureOptionsCategoryState } from "../categoryState.mjs";
11
+ import { buildConfigIndex } from "../../featureOptions.js";
12
+ import { effect } from "../store.mjs";
13
+
14
+ /**
15
+ * Mount the config-table view.
16
+ *
17
+ * The view's responsibilities, in order of complexity:
18
+ *
19
+ * 1. **Initial build** on `model:loaded`: builds the empty config table (no categories yet - those come from the first scope-render).
20
+ * 2. **Scope-aware render** on `scope:changed`: detaches the prior view's DOM into a per-device cache, restores or builds the new view's DOM, applies persisted
21
+ * category-expansion state from localStorage.
22
+ * 3. **Lazy row materialization** on category-disclosure toggle: builds row elements only when the user expands a category for the first time.
23
+ * 4. **Per-row updates** on `option:set` / `option:cleared` / `options:reset` / `model:reverted` / `persist:failed`: walks the projection and re-derives each
24
+ * materialized row's full state (tri-state, value-input, label color, visibility, dependency badge) in place through the shared `applyRowState` writer. No DOM
25
+ * rebuild - just attribute and class swaps on existing rows, run through the same writer construction uses so the two paths cannot diverge.
26
+ * 5. **Visibility updates** on `filter:changed`: the same projection walk re-derives each row, which includes its visibility and the "requires parent" badge.
27
+ * 6. **Click delegation** for: row clicks (forward to checkbox), checkbox changes (tri-state transition + action dispatch), text-input changes (forward to
28
+ * checkbox).
29
+ * 7. **Category state persistence**: captures the current view's expand/collapse state on every toggle and on scope-change, restores it when entering a view.
30
+ *
31
+ * The per-device DOM cache lets navigating from device A to device B and back return to A's previously-rendered DOM without re-running the projection or
32
+ * rebuilding the category shells. The cache map's lifetime is the view's lifetime; aborting the signal releases it.
33
+ *
34
+ * @param {Object} args
35
+ * @param {HTMLElement} args.configTable - The `#configTable` element.
36
+ * @param {string | undefined} args.platform - The Homebridge plugin platform identifier (for localStorage key namespacing).
37
+ * @param {AbortSignal} args.signal - Lifecycle signal.
38
+ * @param {import("../store.mjs").FeatureOptionsStore} args.store - The store.
39
+ */
40
+ export const mountOptionsView = ({ configTable, platform, signal, store }) => {
41
+
42
+ // Per-view DOM cache, keyed by {@link scopeCacheKey}. Detached DOM lives here while another view is mounted; re-mounting restores from cache when possible.
43
+ const cache = new Map();
44
+ let mountedKey;
45
+
46
+ // Per-view category expansion state, persisted via localStorage. The orchestrator writes the user's expand/collapse choices through this object so the disk
47
+ // projection survives page reloads; on re-entry to a view we apply the persisted state so the user's collapse choices stay sticky across sessions.
48
+ const categoryState = new FeatureOptionsCategoryState(platform);
49
+
50
+ // Rebuild on model:loaded - clears any prior content and prepares for the first scope-render. The actual category shells come from the scope-render path.
51
+ effect({
52
+
53
+ events: ["model:loaded"],
54
+ fn: () => {
55
+
56
+ if(store.state.status.kind === "loading") {
57
+
58
+ return;
59
+ }
60
+
61
+ configTable.textContent = "";
62
+ cache.clear();
63
+ mountedKey = undefined;
64
+ },
65
+ signal,
66
+ store
67
+ });
68
+
69
+ // Scope-aware render. Detach the prior view's DOM into the cache (keyed by the prior deviceId); restore the new view's DOM from cache or build fresh.
70
+ effect({
71
+
72
+ events: [ "model:loaded", "scope:changed", "devices:loaded" ],
73
+ fn: () => {
74
+
75
+ if(store.state.status.kind === "loading") {
76
+
77
+ return;
78
+ }
79
+
80
+ // {@link scopeCacheKey} is the single identifier for "which view is this." Used as both the DOM-cache map key and the category-state localStorage context
81
+ // key so a navigation and a localStorage lookup observe the same notion of view.
82
+ const newKey = scopeCacheKey(store.state.scope);
83
+
84
+ // Capture the OUTGOING view's category state before detaching its DOM. The capture reads details[data-category] open-state from the live DOM.
85
+ if(mountedKey !== undefined) {
86
+
87
+ if(configTable.querySelector("details[data-category]")) {
88
+
89
+ categoryState.set(mountedKey, captureCategoryStates(configTable));
90
+ }
91
+
92
+ // Detach the currently-mounted DOM into the cache.
93
+ const detached = [...configTable.children];
94
+
95
+ if(detached.length > 0) {
96
+
97
+ cache.set(mountedKey, detached);
98
+ }
99
+
100
+ for(const child of detached) {
101
+
102
+ configTable.removeChild(child);
103
+ }
104
+ }
105
+
106
+ // Attach the cached DOM for the new view, if any. Otherwise build the category shells fresh from the projection.
107
+ const cached = cache.get(newKey);
108
+
109
+ if(cached) {
110
+
111
+ for(const child of cached) {
112
+
113
+ configTable.appendChild(child);
114
+ }
115
+
116
+ cache.delete(newKey);
117
+ } else {
118
+
119
+ buildCategoryShells({ configTable, state: store.state });
120
+ }
121
+
122
+ mountedKey = newKey;
123
+
124
+ // Restore the incoming view's persisted category state, transparently migrating any data still stored under the legacy key shape (see
125
+ // {@link legacyContextKey}) to the current {@link scopeCacheKey} shape on first read. After a view has been migrated once, its data lives entirely under
126
+ // the current shape and no further legacy lookup is needed.
127
+ const savedStates = restoreLegacyMigrated({ categoryState, newKey, scope: store.state.scope });
128
+
129
+ if(savedStates) {
130
+
131
+ applyCategoryStates(configTable, savedStates);
132
+ }
133
+
134
+ // Apply visibility and per-row state from the current projection.
135
+ applyProjectionToDom({ configTable, state: store.state });
136
+ },
137
+ signal,
138
+ store
139
+ });
140
+
141
+ // Per-option mutations: scope-aware cache invalidation. Only entries that inherit from the mutation's scope are dropped; unrelated cached views remain
142
+ // identity-stable across the mutation. The handler reads the action's `args.id` field as the mutation's scope marker - undefined for a global mutation, otherwise a
143
+ // controller or device serial. The immediate-run case (action === undefined) is the registration-time fire with no triggering action; the cache has nothing to
144
+ // invalidate then and the projection has nothing new to apply, so we exit early.
145
+ effect({
146
+
147
+ events: [ "option:cleared", "option:set" ],
148
+ fn: (action) => {
149
+
150
+ if(!action || (store.state.status.kind === "loading")) {
151
+
152
+ return;
153
+ }
154
+
155
+ invalidateCacheForMutation({ action, cache, controllers: store.state.controllers });
156
+ applyProjectionToDom({ configTable, state: store.state });
157
+ },
158
+ signal,
159
+ store
160
+ });
161
+
162
+ // Global-undo actions: wholesale state replacement. Every cached view's resolved values may have changed at any scope, so the only correct policy is to drop
163
+ // every entry. The currently-mounted view re-renders in place via the projection walk; the cache rebuilds lazily on subsequent navigations.
164
+ effect({
165
+
166
+ events: [ "model:reverted", "options:reset", "persist:failed" ],
167
+ fn: () => {
168
+
169
+ if(store.state.status.kind === "loading") {
170
+
171
+ return;
172
+ }
173
+
174
+ cache.clear();
175
+ applyProjectionToDom({ configTable, state: store.state });
176
+ },
177
+ signal,
178
+ store
179
+ });
180
+
181
+ // Filter updates - cheap visibility refresh, no per-row state change.
182
+ effect({
183
+
184
+ events: ["filter:changed"],
185
+ fn: () => {
186
+
187
+ if(store.state.status.kind === "loading") {
188
+
189
+ return;
190
+ }
191
+
192
+ applyProjectionToDom({ configTable, state: store.state });
193
+ },
194
+ signal,
195
+ store
196
+ });
197
+
198
+ // Category-disclosure toggle (capture-phase because `toggle` does not bubble). Materializes rows lazily on first expand; coalesces post-toggle persistence
199
+ // into a microtask so bulk toggles (expand-all / collapse-all) produce one localStorage write.
200
+ let pendingPostToggleSync = null;
201
+
202
+ configTable.addEventListener("toggle", (event) => {
203
+
204
+ const details = event.target;
205
+
206
+ if(!(details.matches?.("details.fo-category"))) {
207
+
208
+ return;
209
+ }
210
+
211
+ if(details.open) {
212
+
213
+ ensureRowsRendered({ details, state: store.state });
214
+ }
215
+
216
+ schedulePostToggleSync();
217
+ }, { capture: true, signal });
218
+
219
+ // Click delegation for rows. Forwards to the checkbox so a click in the row's whitespace toggles the option.
220
+ configTable.addEventListener("click", (event) => {
221
+
222
+ const row = event.target.closest(".fo-option-row");
223
+
224
+ if(!row || event.target.closest("input, label")) {
225
+
226
+ return;
227
+ }
228
+
229
+ row.querySelector("input[type='checkbox']")?.click();
230
+ }, { signal });
231
+
232
+ // Change delegation for checkboxes and text inputs. Checkbox change runs the tri-state transition and dispatches the resulting action; text input change
233
+ // re-fires as a checkbox change so the same path handles both.
234
+ configTable.addEventListener("change", (event) => handleChange({ event, store }), { signal });
235
+
236
+ // Coalesce post-toggle work into a single microtask. Multiple synchronous toggles (bulk expand-all, saved-state restore) all settle to one persistence write.
237
+ function schedulePostToggleSync() {
238
+
239
+ if(pendingPostToggleSync) {
240
+
241
+ return;
242
+ }
243
+
244
+ pendingPostToggleSync = Promise.resolve().then(() => {
245
+
246
+ pendingPostToggleSync = null;
247
+
248
+ if(signal.aborted || (mountedKey === undefined)) {
249
+
250
+ return;
251
+ }
252
+
253
+ if(configTable.querySelector("details[data-category]")) {
254
+
255
+ categoryState.set(mountedKey, captureCategoryStates(configTable));
256
+ }
257
+ });
258
+ }
259
+ };
260
+
261
+ // Scope-aware cache invalidation for a per-option mutation. The action's `args.id` field carries the mutation's scope marker (the persisted entry-string format
262
+ // encodes scope by serial), so we discriminate by matching it against the controllers list:
263
+ //
264
+ // - `id` undefined - global-scope mutation. Every cached view inherits from global. Drop every entry.
265
+ // - `id` matches a controller's serial - controller-scope mutation. Every cached device-view under this controller inherits from it. Drop entries whose key
266
+ // has the `device:<id>/` prefix; preserve the global entry and other controllers' devices.
267
+ // - `id` matches a device's serial (not a controller) - device-scope mutation. No cached view inherits from a leaf device; the mutated device itself is the
268
+ // currently mounted view (not in the cache). No cache action required.
269
+ //
270
+ // The prefix match exploits the {@link scopeCacheKey} contract: device-view keys carry their controller's serial in their first path segment, so an O(N) walk
271
+ // over the cache invalidates exactly the device-under-controller subtree without a separate controller-to-devices lookup.
272
+ const invalidateCacheForMutation = ({ action, cache, controllers }) => {
273
+
274
+ const id = action.args.id;
275
+
276
+ if(id === undefined) {
277
+
278
+ cache.clear();
279
+
280
+ return;
281
+ }
282
+
283
+ if(controllers.some((c) => c.serialNumber === id)) {
284
+
285
+ const prefix = "device:" + id + "/";
286
+
287
+ for(const key of cache.keys()) {
288
+
289
+ if(key.startsWith(prefix)) {
290
+
291
+ cache.delete(key);
292
+ }
293
+ }
294
+
295
+ return;
296
+ }
297
+
298
+ // Device-scope mutation: no cache action. The mounted device is not in the cache, and no other cached view inherits from a leaf device.
299
+ };
300
+
301
+ // The pre-reactive-store architecture wrote category-state entries under context keys of shape `"Global Options"` (for the global view) or the bare device serial
302
+ // (for any per-device view). The reactive-store refactor unified these under {@link scopeCacheKey}'s output ("global", "controller:X", "device:X/Y"). This helper
303
+ // maps a scope back to the legacy key shape it would have been written under so the restore path can do a one-time migration. Returns null when no legacy shape
304
+ // existed for the given scope kind - the prior architecture never persisted a controller-only view (the controller link click was transient, resolving immediately
305
+ // to a device-view), so controller-scope migrations have no source to read from.
306
+ const legacyContextKey = (scope) => {
307
+
308
+ switch(scope.kind) {
309
+
310
+ case "global":
311
+
312
+ return "Global Options";
313
+
314
+ case "device":
315
+
316
+ return scope.deviceId;
317
+
318
+ case "controller":
319
+
320
+ return null;
321
+
322
+ default:
323
+
324
+ // Exhaustive switch over the Scope DU - a future variant addition surfaces here as a runtime throw rather than a silent fallthrough that would skip migration.
325
+ throw new Error("legacyContextKey: unknown scope kind.");
326
+ }
327
+ };
328
+
329
+ // Read persisted category state for the view identified by {@link newKey}, transparently migrating data found under the legacy key shape. The lookup tries the
330
+ // new key first (fast path for already-migrated data); on miss, it falls back to {@link legacyContextKey} and, if a legacy entry exists, atomically rewrites it
331
+ // under the new key and deletes the legacy entry. After every visited view has been migrated once, the legacy keys are gone from disk and no further fallback
332
+ // lookup yields a result.
333
+ const restoreLegacyMigrated = ({ categoryState, newKey, scope }) => {
334
+
335
+ const direct = categoryState.get(newKey);
336
+
337
+ if(direct) {
338
+
339
+ return direct;
340
+ }
341
+
342
+ const legacyKey = legacyContextKey(scope);
343
+
344
+ if(legacyKey === null) {
345
+
346
+ return undefined;
347
+ }
348
+
349
+ const legacy = categoryState.get(legacyKey);
350
+
351
+ if(!legacy) {
352
+
353
+ return undefined;
354
+ }
355
+
356
+ // Migrate atomically: write under new key, drop legacy. The next visit to this view reads directly from the new key (the fast path above).
357
+ categoryState.set(newKey, legacy);
358
+ categoryState.delete(legacyKey);
359
+
360
+ return legacy;
361
+ };
362
+
363
+ // Build the empty category shells for every active category in the projection. Rows materialize lazily on first expand via {@link ensureRowsRendered}.
364
+ const buildCategoryShells = ({ configTable, state }) => {
365
+
366
+ const p = projection(state);
367
+ const fragment = document.createDocumentFragment();
368
+ const scopeKind = state.scope.kind;
369
+
370
+ for(const { category } of p.categories) {
371
+
372
+ fragment.appendChild(categoryShell({ category, scopeKind }));
373
+ }
374
+
375
+ configTable.appendChild(fragment);
376
+ };
377
+
378
+ // Materialize the rows for a single category. Idempotent via dataset.rowsRendered - re-opening an already-built category is a no-op for materialization.
379
+ const ensureRowsRendered = ({ details, state }) => {
380
+
381
+ if(details.dataset.rowsRendered === "true") {
382
+
383
+ return;
384
+ }
385
+
386
+ const categoryName = details.getAttribute("data-category");
387
+ const p = projection(state);
388
+ const categoryProjection = p.categories.find((c) => c.name === categoryName);
389
+
390
+ if(!categoryProjection) {
391
+
392
+ return;
393
+ }
394
+
395
+ const rowsContainer = details.querySelector(".fo-category-rows");
396
+
397
+ if(!rowsContainer) {
398
+
399
+ return;
400
+ }
401
+
402
+ const fragment = document.createDocumentFragment();
403
+ const deviceId = selectedDeviceId(state);
404
+ const scopeKind = state.scope.kind;
405
+
406
+ for(const entry of categoryProjection.entries) {
407
+
408
+ fragment.appendChild(optionRow({ deviceId, entry, scopeKind }));
409
+ }
410
+
411
+ rowsContainer.appendChild(fragment);
412
+ details.dataset.rowsRendered = "true";
413
+
414
+ // optionRow applies each row's full state through applyRowState at construction, so a freshly-materialized category arrives correct from its first render - no
415
+ // separate post-materialization apply pass is needed, and there is no window where a row exists without its derived state.
416
+ };
417
+
418
+ // Walk the projection and re-derive every materialized row's state in place. Categories with no materialized rows are skipped (rows materialize lazily on expand, so
419
+ // an unexpanded category has none to update). For each materialized category we set the category-level visibility, then re-derive each row through the shared
420
+ // applyRowState writer - the same writer construction uses - so a mutation re-checks, re-colors, re-values, and re-hides every affected row without a DOM rebuild.
421
+ const applyProjectionToDom = ({ configTable, state }) => {
422
+
423
+ const p = projection(state);
424
+ const scopeKind = state.scope.kind;
425
+
426
+ for(const categoryProjection of p.categories) {
427
+
428
+ const details = configTable.querySelector("details[data-category=\"" + categoryProjection.name + "\"]");
429
+
430
+ if(!details) {
431
+
432
+ continue;
433
+ }
434
+
435
+ // Category-level visibility: hide the entire category when the projection has no visible entries.
436
+ details.classList.toggle("fo-hidden", !categoryProjection.hasVisible);
437
+
438
+ if(details.dataset.rowsRendered !== "true") {
439
+
440
+ continue;
441
+ }
442
+
443
+ const rowsContainer = details.querySelector(".fo-category-rows");
444
+
445
+ if(!rowsContainer) {
446
+
447
+ continue;
448
+ }
449
+
450
+ for(const entry of categoryProjection.entries) {
451
+
452
+ const row = rowsContainer.querySelector("#row-" + cssEscape(entry.expandedName));
453
+
454
+ if(row) {
455
+
456
+ applyRowState({ entry, row, scopeKind });
457
+ }
458
+ }
459
+ }
460
+ };
461
+
462
+ // Escape a string for use inside a CSS ID selector. We use querySelector against the rows container to find rows by their id (`row-<expandedName>`); option
463
+ // names like `Audio.Volume` contain dots that would be interpreted as class selectors without escaping. CSS.escape is the platform-native answer; it is
464
+ // unavailable in some DOM environments (including the test harness), so a manual regex fallback covers those cases.
465
+ const cssEscape = (value) => ((typeof CSS !== "undefined") && CSS.escape) ? CSS.escape(value) : value.replace(/[^\w-]/g, "\\$&");
466
+
467
+ // Handle a change event on the config table. Checkboxes get the tri-state transition; text inputs re-fire as a checkbox change so the same path handles both.
468
+ const handleChange = ({ event, store }) => {
469
+
470
+ const target = event.target;
471
+
472
+ if(target.matches("input[type='text']")) {
473
+
474
+ target.closest(".fo-option-row")?.querySelector("input[type='checkbox']")?.dispatchEvent(new Event("change", { bubbles: true }));
475
+
476
+ return;
477
+ }
478
+
479
+ if(!target.matches("input[type='checkbox']")) {
480
+
481
+ return;
482
+ }
483
+
484
+ const row = target.closest(".fo-option-row");
485
+ const categoryName = target.closest("details[data-category]")?.getAttribute("data-category");
486
+
487
+ if(!row || !categoryName) {
488
+
489
+ return;
490
+ }
491
+
492
+ const expandedName = target.id;
493
+ const state = store.state;
494
+ const p = projection(state);
495
+ const categoryProjection = p.categories.find((c) => c.name === categoryName);
496
+ const entry = categoryProjection?.entries.find((e) => e.expandedName === expandedName);
497
+
498
+ if(!entry) {
499
+
500
+ return;
501
+ }
502
+
503
+ // Run the pure transition to compute the action to dispatch. The DOM is not updated here: dispatching drives the reactive re-projection, which re-derives this row
504
+ // (and any others the mutation affects) through applyRowState against the post-dispatch projection. One DOM-writing path - the same one construction uses - rather
505
+ // than an imperative apply here plus a re-derive on update that could drift apart.
506
+ const inputValue = row.querySelector("input[type='text']");
507
+ const configIndex = buildConfigIndex(state.catalog, state.configuredOptions);
508
+ const { action } = triStateTransition({
509
+
510
+ catalog: state.catalog,
511
+ checkbox: target,
512
+ configIndex,
513
+ controllerId: selectedControllerId(state),
514
+ deviceId: selectedDeviceId(state),
515
+ entry,
516
+ inputValue
517
+ });
518
+
519
+ store.dispatch(action);
520
+ };
521
+