frugal-iot-client 1.3.13 → 1.3.14

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 (49) hide show
  1. package/.claude/settings.json +12 -0
  2. package/CARDS_PLAN.md +841 -0
  3. package/CARDS_UX.md +1022 -0
  4. package/CLAUDE.md +128 -14
  5. package/FLASH_PLAN.md +250 -9
  6. package/admin.js +1377 -0
  7. package/base/boards.json +6 -4
  8. package/base/bootloader/esp32c3.bin +0 -0
  9. package/base/bootloader/esp32s2.bin +0 -0
  10. package/base/bootloader/esp32s3.bin +0 -0
  11. package/cards.js +867 -0
  12. package/core.js +2696 -0
  13. package/dashboard.js +12 -0
  14. package/dashboard_example.html +2 -2
  15. package/flash.js +609 -0
  16. package/frugaliot.css +342 -14
  17. package/graph.js +475 -0
  18. package/index-old.html +37 -0
  19. package/index.html +6 -6
  20. package/nodeview.js +433 -0
  21. package/package.json +11 -4
  22. package/test/cards.test.js +227 -0
  23. package/test/cardui.test.js +435 -0
  24. package/test/datatree.test.js +171 -0
  25. package/test/fixtures/config.json +1416 -0
  26. package/test/fixtures/regenerate.js +40 -0
  27. package/test/grid.test.js +258 -0
  28. package/test/lightdom.test.js +57 -0
  29. package/test/mock.html +113 -0
  30. package/test/mock.js +269 -0
  31. package/test/modules.test.js +48 -0
  32. package/test/oldui.test.js +40 -0
  33. package/test/page.test.js +292 -0
  34. package/test/permissions.test.js +134 -0
  35. package/test/serialize.js +49 -0
  36. package/test/setup.js +54 -0
  37. package/test/smoke.test.js +43 -0
  38. package/test/snapshots/card-control-wired-back.txt +197 -0
  39. package/test/snapshots/card-control-wired-front.txt +60 -0
  40. package/test/snapshots/card-mixed-sensors-summary.txt +14 -0
  41. package/test/snapshots/card-one-device-summary.txt +14 -0
  42. package/test/snapshots/control-wired.txt +264 -0
  43. package/test/snapshots/default-front.txt +225 -0
  44. package/test/snapshots/mixed-sensors.txt +208 -0
  45. package/test/snapshots/no-readings.txt +54 -0
  46. package/test/snapshots/one-device.txt +154 -0
  47. package/test/snapshots/unknown-module.txt +117 -0
  48. package/webcomponents.js +15 -5541
  49. package/widgets.js +760 -0
package/CARDS_PLAN.md ADDED
@@ -0,0 +1,841 @@
1
+ # Device Cards — Implementation Plan
2
+
3
+ Step 2 of 3. The design is `CARDS_UX.md`; decisions D-1…D-33, deferrals L-1…L-7 and resolved plan
4
+ questions O-1…O-10 are settled there and are inputs here, not open questions. This document says
5
+ **how and in what order**, and sets the code rules the implementation must hold to.
6
+
7
+ Two questions are genuinely new and marked **P-n**. Everything else is a recommendation to follow.
8
+
9
+ ---
10
+
11
+ ## 1. What "done" means
12
+
13
+ A new page renders every device in a project as a card with summary / front / back modes, arranged
14
+ by drag, remembered locally, gated by `WRITE`; and the project's own back carries the admin cards.
15
+ `index.html` and the existing node/group UI **still work unchanged** and are covered by regression
16
+ tests throughout.
17
+
18
+ Explicitly not done here: retiring `index.html` (a later step once the admin cards are proven), and
19
+ everything in `CARDS_UX.md` §14.
20
+
21
+ ## 2. Sequence
22
+
23
+ Phases are ordered by what they unblock. Sizes are relative, not hours.
24
+
25
+ | # | Phase | Size | Gates | Ships alone? |
26
+ |---|---|---|---|---|
27
+ | 0 | Test infrastructure and mock harness | M | everything | **DONE** |
28
+ | 1 | `html-element-extended`: light-DOM rendering | S | 5 | **DONE** |
29
+ | 2 | Schema: `devices.yaml`, `width`, `units`, capabilities | M | 4, 8 | **DONE** |
30
+ | 3 | Split `webcomponents.js` | M | — (but easier before 4+) | **DONE** |
31
+ | 4 | Data tree: move roll-up down, add derived getters | M | 5, 6 | **DONE** |
32
+ | 5 | The card element — summary / front / back | L | 6, 7 | **DONE** |
33
+ | 6 | The grid — layout, drag, `localStorage` | M | 7 | **DONE** |
34
+ | 7 | The new page, project front/back, admin cards | L | — | **DONE** |
35
+ | 8 | Permissions: `WRITE`, `OTAFLASH` | S | — | **DONE** |
36
+ | 9 | Polish: sheets, motion, i18n sweep, visual pass | M | — | yes |
37
+
38
+ Phases 1 and 2 are independent of everything and of each other — start them whenever. Phase 0 comes
39
+ first because its value is protecting phases 3–7, and because it is the only way to see the work
40
+ without hardware.
41
+
42
+ ## 3. Code design criteria
43
+
44
+ Rules for this work. The first group is house style from `CLAUDE.md`, restated because these are the
45
+ ones a large new surface tends to erode; the second is specific to cards.
46
+
47
+ ### 3.1 Existing house rules that this work must not erode
48
+
49
+ - 2-space indent. Callback-style async (`async` library), not promise chains.
50
+ - Comments say **why**, one line, never restating the code. No trailing prose summaries.
51
+ - **Derived values are getters on the data tree**, not logic in a view. If a card computes something
52
+ by walking `node → group → topic`, that walk belongs on `MqttTopic`/`MqttTopicNode` as a getter and
53
+ the card reads it. This rule does most of the work in phase 4 — treat a traversal inside a card as
54
+ a defect.
55
+ - Every new UI string goes in all four languages (EN, FR, HI, ID), even if the text is identical.
56
+ - Translation only reaches `textContent` **attributes** — `el('p', {textContent: "…"})`, never
57
+ `el('p', {}, ["…"])`. Pass `i8n: false` for names, ids and per-row data.
58
+ - No new `XXY` calls. Use `XXX` for states that should not happen, and leave the calls in.
59
+
60
+ ### 3.2 New rules for the card work
61
+
62
+ - **The card renders a resolved row list, never a group traversal.** `nodeMt.frontRows` returns an
63
+ ordered array of `{kind: 'reading'|'actuator'|'control', mt|groupMt, label}`; the card maps it to
64
+ DOM. All the §4.2 precedence — device `front:` list, else `graphable` readings then actuators then
65
+ controls, minus `insidefrugaliot`, minus control-owned topics — lives in that one getter, where it
66
+ can be unit-tested with no DOM.
67
+ - **Light DOM for card and grid, shadow DOM for widgets.** `mqtt-devicecard` and `mqtt-devicegrid`
68
+ extend `HTMLElementExtendedMinimum`; `mqtt-bar` and friends are untouched. Card CSS classes are
69
+ prefixed `fi-` since there is no shadow boundary to protect them.
70
+ - **No `<slot>` in a light-DOM element, and nothing it did not render may live inside it.**
71
+ `renderAndReplace()` clears its render root, which for these elements is the element itself. The
72
+ grid renders once via `render0()` and mutates in place as devices arrive; anything reaching for a
73
+ slot has picked the wrong base class.
74
+ - **Cross-boundary styling is CSS custom properties, never `:host-context`.** Define the palette,
75
+ spacing and type scale as `--fi-*` properties on `:root`; widgets read them. Custom properties
76
+ pierce shadow boundaries, which is the whole reason to use them here.
77
+ - **One permission gate.** A single `get canWrite()` (on the wrapper, from `server_config.user`),
78
+ read wherever it is needed. Never a scattered `hasPermission('WRITE')` in each widget — that is how
79
+ a control ends up editable on one card and not another.
80
+ - **Numbers go through one formatter.** `mt.formatted` applies `width`, appends the unit symbol, and
81
+ never truncates. No `toFixed` anywhere else.
82
+ - **`localStorage` access is wrapped and versioned.** One module, try/catch on every read and write
83
+ (private browsing throws), a `v:` field in the stored object, unknown/absent → sane default. A
84
+ storage failure must degrade to "no remembered layout", never to a broken grid.
85
+ - **Core does not import widgets.** `MqttTopic.createElement()` reaches widgets by tag name
86
+ (`el('mqtt-bar', …)`), not by class import. Preserve that: it is what keeps `core.js` loadable on
87
+ its own and `index-embedded.html` working.
88
+ - **No silent schema fallbacks.** A topic with no `display`, an unknown module, a `front:` entry
89
+ naming a twig that does not exist — `XXX` and render something visibly wrong-looking, rather than
90
+ quietly skipping. Silent skips are how a device ends up with a blank card and no clue why.
91
+
92
+ ## 4. Phases
93
+
94
+ ### Phase 0 — Test infrastructure and mock harness
95
+
96
+ The seam is already there and it is a good one. `MqttClient` dispatches messages in six lines at
97
+ `webcomponents.js:2013`:
98
+
99
+ ```js
100
+ for (let o of mqtt_subscriptions) {
101
+ if (topicMatches(o.topic, topic)) { o.cb(topic, msg); }
102
+ }
103
+ ```
104
+
105
+ **Extract that into an exported `mqtt_deliver(topic, msg)`** and the mock needs no fake broker, no
106
+ network and no `mqtt` module — it calls `mqtt_deliver` with canned messages and the entire tree, old
107
+ UI and new, behaves exactly as it does against a real broker.
108
+
109
+ Three tiers, in descending order of how often they get used:
110
+
111
+ **Tier 0 — `test/mock.html`, a browser harness.** Loads a canned scenario and renders it. This is the
112
+ thing to open to see progress, and to review a card against the mockups in `CARDS_UX.md` §3. Cheap,
113
+ and it is what makes the rest of the work reviewable without flashing a device.
114
+
115
+ **Tier 1 — data-tree tests under `node:test`.** Headless mode already exists
116
+ (`<mqtt-wrapper headless>` builds the full tree with no elements), so the getters from phase 4 —
117
+ `frontRows`, `summaryChips`, `formatted`, `usableName`, staleness — are testable without a browser.
118
+ This is where most assertions should live, because it is where most of the logic will live if §3.2 is
119
+ followed.
120
+
121
+ > **Correction, found while building this.** An earlier draft said tier 1 needs "no jsdom, no
122
+ > browser". Wrong: `webcomponents.js` has ~25 `customElements.define` calls plus `Chart.register`
123
+ > and `_adapters._date.override` at module scope, so merely importing it requires `customElements`
124
+ > and `document` to exist. jsdom supplies them, and once it does the *same* run can also render and
125
+ > snapshot the DOM — so tiers 1 and 2 largely merge, and Playwright is not needed yet.
126
+
127
+ **Tier 2 — rendered-DOM snapshots**, of the same scenarios. **These cover the old node/group UI as
128
+ well as the cards** — one scenario set, both layouts, so phases 3–7 have a regression check for the
129
+ existing UX rather than a hand-check at the end. Descend into shadow roots, record tags, meaningful
130
+ attributes and text, and normalise timestamps.
131
+
132
+ Assert on rendered output, not internals; internals are what phases 3 and 4 deliberately change.
133
+
134
+ **Deferred: bare specifiers.** Converting `/node_modules/…` imports was listed here, and is not
135
+ done. Each package resolves differently in node and in the browser (`mqtt` in particular has separate
136
+ node and browser builds), so it is its own change with its own verification, and mixing it into a
137
+ refactor whose contract is "byte-identical output" would have undermined that. The test resolve hook
138
+ stays until then.
139
+
140
+ **A second obstacle worth recording**: the client's imports are server-root-relative
141
+ (`/node_modules/html-element-extended/…`), which node cannot resolve at all — only a web server can.
142
+ `test/setup.js` installs a `module.registerHooks` resolve hook that maps them onto the real
143
+ `node_modules`. **Phase 3 should convert them to bare specifiers** and add the missing importmap
144
+ entries, at which point the hook can go; the absolute paths are also why the client cannot be served
145
+ from a subpath.
146
+
147
+ **Scenarios the canned set must include**, because these are the states that never show up when you
148
+ happen to be watching a live broker: device live / stale / offline / never-seen; device discovered
149
+ with no readings; reading out of range; control wired and unwired; actuator driven by a control;
150
+ `manual` set; no `WRITE`; one device; twelve devices; a module absent from `modules.yaml`.
151
+
152
+ **P-1 is now moot for this phase.** jsdom turned out to handle custom elements and shadow DOM well
153
+ enough to snapshot both UIs, so it is the only new devDependency. Playwright stays worth considering
154
+ later, for the things jsdom genuinely cannot do — pointer-event drag (phase 6), the bottom sheets and
155
+ transitions (phase 9), and real CSS layout. Decide it when phase 6 starts, not now.
156
+
157
+ **Delivered:**
158
+
159
+ | File | Role |
160
+ |---|---|
161
+ | `test/setup.js` | jsdom globals + the `/node_modules/…` resolve hook |
162
+ | `test/mock.js` | scenarios and `runScenario()`, replayed through `mqtt_deliver` |
163
+ | `test/serialize.js` | rendered-tree serializer that descends into shadow roots |
164
+ | `test/fixtures/regenerate.js` | rebuilds `fixtures/config.json` from the real server schema |
165
+ | `test/datatree.test.js` | 15 data-tree tests |
166
+ | `test/oldui.test.js` | 5 rendered snapshots of the existing UI |
167
+ | `test/snapshots/*.txt` | the committed snapshots |
168
+ | `test/mock.html` | browser harness — pick a scenario, see it render |
169
+
170
+ The harness is at **`/dashboard/test/mock.html`** — the client is served under `/dashboard`, not the
171
+ server root. It needs no login and never connects to a broker: the schema comes from the checked-in
172
+ fixture and every value from `mqtt_deliver`.
173
+
174
+ Source changes: `mqtt_deliver()` extracted from `MqttClient`'s `on('message')`; `configSet()` added
175
+ and used by both existing `server_config = json` sites; the export list extended with the data-tree
176
+ classes and the subscription functions a headless consumer needs.
177
+
178
+ One real bug fell out of running the harness. `CssUrl` was `'./frugaliot.css'` and the graph icons
179
+ `'images/icon_graph.svg'` — both resolved against the **document**, so from a page one directory down
180
+ every shadow root's stylesheet link 404'd and the UI rendered entirely unstyled. Since nearly all of
181
+ the UI lives inside shadow roots, the client only worked when served from the one directory holding
182
+ `frugaliot.css`. Both now resolve against the module's own URL:
183
+
184
+ ```js
185
+ const CssUrl = new URL('./frugaliot.css', import.meta.url).href;
186
+ const ImagesUrl = new URL('./images/', import.meta.url).href;
187
+ ```
188
+
189
+ Same family as the `/node_modules/…` imports, and the same lesson for phase 3: nothing in the client
190
+ should assume the document's directory.
191
+
192
+ Scenarios deliberately **not** included yet: `live/stale/offline/never-seen` and `manual`. They
193
+ depend on `nodeMt.status`, which arrives in phase 4; faking them now would test the fake. Add them
194
+ with that phase, driven by an injectable clock rather than real timers.
195
+
196
+ ### Phase 1 — `html-element-extended`: light-DOM rendering
197
+
198
+ In `~/git/github_mitra42/html-element-extended` — **a repo outside the frugal-iot tree; I will call
199
+ out the edit when I make it.**
200
+
201
+ Done as a `renderRoot` getter returning `this.shadowRoot || this`, with `renderAndReplace()` using
202
+ it in all three places. A named getter rather than an inline `||` so a subclass can see where its
203
+ output goes, and so the constraint below has somewhere to be documented.
204
+
205
+ Backwards compatible by construction: every existing consumer extends `HTMLElementExtended`, so
206
+ `shadowRoot` is truthy and behaviour is byte-identical. Nothing in `webcomponents.js` references
207
+ `shadowRoot`. Confirmed by the phase 0 snapshots passing unchanged against the linked library.
208
+
209
+ **Constraint this creates, and it matters for phases 5–6.** A light-DOM element renders into itself,
210
+ so `<slot>` means nothing to it and `renderAndReplace()` **destroys any children it did not render**.
211
+ The existing UI leans on slots heavily; cards cannot. Two consequences:
212
+
213
+ - `mqtt-devicecard` builds all its own children from the data tree — no slots, no author children.
214
+ - `mqtt-devicegrid` appends cards as devices are discovered, so a later full re-render would wipe
215
+ them. Use `render0()` — which the library already supports, rendering once — and mutate in place
216
+ afterwards, rather than re-rendering the grid.
217
+
218
+ **`npm link` needed a workaround.** The global npm prefix is `/usr/local/lib/node_modules`, owned by
219
+ root, so `npm link` fails without sudo. `npm run link:hee` does the local half directly:
220
+
221
+ ```
222
+ ln -sfn ../../html-element-extended node_modules/html-element-extended
223
+ ```
224
+
225
+ `package.json` is untouched (still `^0.1.6`), and the published copy is kept alongside as
226
+ `html-element-extended.published-0.1.6`. **`npm install` replaces the symlink**, so re-run
227
+ `npm run link:hee` after one; `npm run unlink:hee` goes back to the published package. The library
228
+ change is `0.1.7 (unreleased)` in its CHANGELOG — publish once the card work settles.
229
+
230
+ Verified by `test/lightdom.test.js`: a Minimum subclass renders into itself with no shadow root and
231
+ re-renders replace rather than append; an HTMLElementExtended subclass still renders into its shadow
232
+ root with nothing leaking into its light DOM.
233
+
234
+ ### Phase 2 — Schema
235
+
236
+ Master is `frugal-iot-server/config.d/schema/`; propagate to the three
237
+ `frugal-iot-logger/examples/*/config.d/schema/` copies. Never the reverse.
238
+
239
+ 1. **`devices.yaml`** (new). Keys match by prefix (§4.6): device id, then exact OTA key, then the
240
+ longest key the OTA key starts with. The OTA key is `PREFIX_SUFFIX` where the suffix is the board,
241
+ so one entry per *application* covers every board it is built for:
242
+ ```yaml
243
+ sht30: # matches sht30_c3_pico, sht30_d1_mini, sht30_s2_mini_4x, …
244
+ front: [sht/temperature, sht/humidity]
245
+ sonoff: # matches sonoff_r4
246
+ front: [controlhysteresis, relay/on]
247
+ summary: [controlhysteresis] # optional — defaults to the first two of front
248
+ esp8266-fb94bb: # one device overriding its build
249
+ front: [sht/temperature]
250
+ ```
251
+ Roughly one entry per example application, so the file stays small.
252
+ 2. **`topics.yaml`**: add `width:` to `float` and `exponential` topics only — **25 of 54**, not all
253
+ (D-35); ints derive their field width from `min`/`max`. Fill in missing `units:`. Mechanical but
254
+ needs per-sensor judgement, so its own reviewed pass.
255
+ 3. **`modules.yaml`**: `summary: true` on the modules that should contribute a summary.
256
+ 4. **Server**: *no change needed.* `readConfigFromDir` maps `config.d/<dir>/<file>.yaml` onto
257
+ `config.<dir>.<file>`, so dropping the file in makes it `config.schema.devices` and it reaches the
258
+ client in `/config.json` for free. Confirmed while building phase 0's fixture, which mirrors that
259
+ mapping.
260
+ 5. **`frugal-iot-server/scripts/check-schema.js`** — extended, not written. It already existed, in
261
+ the server beside the master schema; I looked only in the logger's `scripts/` and wrongly
262
+ concluded it was missing, then wrote a second one there. The duplicate is deleted and its checks
263
+ merged into the original, which already had several the new one lacked — a module topic
264
+ overriding a field its base topic does not define, a `duplicates` rule that sets neither
265
+ threshold, a topic that is not a set of settings, and per-type reasoning about what `log:` would
266
+ default to. Added to it: `width` missing / present on a type with no decimals / too small for its
267
+ own range; `units` missing on a numeric topic; `devices.yaml` entries naming a module or leaf that
268
+ does not exist (an **error**, since it silently loses a row); prefix shadowing between device
269
+ keys; `summary: true` being a no-op; and `--resolve <otakey>`. Its "warnings never block" contract
270
+ is kept — errors exit 1, and both call sites already guard with `|| true`.
271
+ 6. **`frugal-iot-server/scripts/copy-schema-to-examples.zsh`** — likewise already existed, and had
272
+ just been left behind: it copied a hard-coded `topics.yaml modules.yaml` and so never copied the
273
+ new `devices.yaml`. Changed to glob every `*.yaml` in the schema directory, so the next file added
274
+ is not silently missed too. My duplicate `propagate-schema.js` is deleted.
275
+
276
+ **Outcome.** `width` on all 25 float/exponential topics, derived so decimals stay steady as a value
277
+ moves; `summary: false` on six modules (D-37); `devices.yaml` with 11 application entries; the
278
+ checker and propagator; `check-schema.js` reports OK. `defaults.h` is unaffected — the generator
279
+ extracts only `color`/`min`/`max`.
280
+
281
+ **Judgement calls, resolved:**
282
+
283
+ - `pressure` had `min: 0, max: 99` — a placeholder that all three bmx280 modules overrode. The base
284
+ is now `300..1100` with `units: hPa`, and those three overrides are deleted as redundant. Note
285
+ `ms5803` never overrode it and so now inherits the atmospheric range, which suits it as a
286
+ barometer but not as a depth sensor.
287
+ - `soil` now uses `units: "%"`. SenML has only `%RH`, which is relative humidity and not this; the
288
+ registry is restrictive and unmaintained, so we do not follow it here.
289
+ - `gps` contributes to summaries after all — its `summary: false` is removed.
290
+ - `satellites` had `units: m/s`, copied from `speed`; corrected to `count`. **Its type is still
291
+ `float` for what is a count** — changing that touches logging and firmware, so it is flagged, not
292
+ changed.
293
+ - `altitude` gained `units: m`. `latitude`/`longitude` keep `lat`/`lon`, which are real SenML units
294
+ despite looking like field names.
295
+ - Still no units, and each needs a human: `loadcell` (depends on the cell), `hdop` (dimensionless),
296
+ `controlfloat` (a generic template).
297
+
298
+ Deliberately **not** here: `control: true` (deferred L-4 — with `climate` deleted the name-prefix
299
+ test is correct for every module that exists).
300
+
301
+ ### Phase 3 — Split `webcomponents.js`
302
+
303
+ 5,723 lines in one file. Mechanical, no behaviour change, verified by running the phase 0 snapshots
304
+ before and after and requiring identical output.
305
+
306
+ | File | Lines | Imports | Contents |
307
+ |---|---|---|---|
308
+ | `core.js` | 2214 | **none** | mqtt plumbing, `mqtt_deliver`, topic helpers, the `MqttTopic*` data tree, `Watchdog`, i18n + `languages` + `el`, `server_config`, `MqttClient`, `MqttWrapper`, `LanguagePicker` |
309
+ | `widgets.js` | 717 | core | `MqttElement`…`MqttChooseTopic` — the leaf display and control elements |
310
+ | `graph.js` | 475 | core, widgets | `MqttGraph`, `MqttGraphDataset`, the inlined Luxon adapter — pulls Chart.js |
311
+ | `nodeview.js` | 457 | core, widgets | `MqttProject`, `MqttNode`, `MqttGroup` + subclasses — **retires with `index.html`**, said so in its header |
312
+ | `flash.js` | 609 | core | `MqttFlash` and the partition helpers — pulls esptool-js |
313
+ | `admin.js` | 1331 | core | `MqttAdmin`, `MqttLogin`, `TabbedDisplay` |
314
+ | `webcomponents.js` | 19 | all | the entry every page already loads: `export * from './core.js'` plus side-effect imports |
315
+
316
+ `cards.js` and its entry point arrive with phases 5–7; there was nothing to put in them yet.
317
+
318
+ **No HTML changed.** Every page already loads `webcomponents.js` as its entry, and
319
+ `dashboard_example.html` imports four helpers from it, so keeping it as the shim left all five pages
320
+ untouched — a better outcome than the planned separate `index.js`.
321
+
322
+ **Verification: the five old-UI snapshots are byte-identical**, which is what "mechanical, no
323
+ behaviour change" was supposed to mean. `test/modules.test.js` now also pins the shape: core imports
324
+ nothing, widgets needs only core, graph and nodeview need only core and widgets, and importing
325
+ `core.js` alone registers no display widgets — so `index-embedded.html`'s core+widgets case (O-9)
326
+ cannot silently regress.
327
+
328
+ **Four behaviour-preserving edits were needed first**, because the module graph was not a DAG:
329
+
330
+ - `MqttTopic.graph` called `MqttGraph.graph` directly — a core→graph cycle. It now goes by tag name,
331
+ `customElements.get('mqtt-graph')`, the same way `createElement` reaches the widgets. This is the
332
+ rule in §3.2 and it is now what keeps core standalone.
333
+ - `MqttWrapper` borrowed `MqttReceiver.observedAttributes`, a core→widgets reference evaluated at
334
+ `customElements.define` time. Both now share a `RECEIVER_ATTRIBUTES` constant in core.
335
+ - `MqttChooseTopic` did `++unique_id` on a core variable — an imported binding cannot be assigned to.
336
+ It calls `nextUniqueId()`, and the label and select now share one captured id instead of reading
337
+ the counter twice.
338
+ - Two locals shadowed module-level names — `let el` inside `elementsForEach` and `let graph` inside
339
+ `createGraph` — which hid a real import behind a false negative. Renamed to `elx` and `graphEl`.
340
+
341
+ **Two things the generator nearly lost, worth recording** because both would have failed only at
342
+ runtime: `Chart.register(...registerables)` lived in the file preamble, outside every declaration
343
+ block; and `import { DateTime } from 'luxon'` sits *mid-file* at line 53, inside the copied
344
+ chartjs-adapter-luxon block, so it was swept into a core-owned block instead of following the adapter
345
+ into `graph.js`.
346
+
347
+ ### Phase 4 — Data tree
348
+
349
+ **4a. Move the summary roll-up down to `MqttTopicGroup`.** `summaryText()` currently lives only on
350
+ the `MqttGroup*` *element* subclasses (`webcomponents.js:5245-5317`), which exist only when not
351
+ headless. So a headless card page cannot call it — the card would have no summary at all. Move the
352
+ logic to `MqttTopicGroup`, reading `this.topics[leaf].state.value` directly rather than relying on
353
+ values being mirrored up as element attributes. The `MqttGroup*` element subclasses become thin
354
+ wrappers that call it, and disappear with `nodeview.js`.
355
+
356
+ This is the one architectural change of consequence in the plan, it is squarely within "derived
357
+ values are getters on the data tree", and it makes every summary unit-testable with no DOM.
358
+
359
+ **4a turned out easier than feared.** `groupMt.state[leafAttr]` was *already* being mirrored on
360
+ every message, in both the element path (`MqttReceiver.topicValueSet`) and the headless one
361
+ (`MqttTopic.message_received`). So `MqttTopicGroup` already held exactly the state the element
362
+ subclasses were reading, and the summary logic moved across essentially verbatim. What was needed
363
+ around it:
364
+
365
+ - `MqttTopicGroup` subclasses in `core.js` (`…Relay`, `…Soil`, `…Ota`, `…Battery`, `…DS18B20`,
366
+ `…Ht`, `…ControlHysteresis`), chosen by a `topicGroupClasses` registry keyed on module id — the
367
+ data-tree counterpart of looking up `mqtt-group${groupId}` as a custom element.
368
+ - `groupMt.nodeMt` and `groupMt.twig` set at creation, so `projectMt` resolves (the control summary
369
+ needs it to name what its inputs are wired to) and `topicPath` is right for a group.
370
+ - The group element now links to its group topic (`el.mt` / `mt.element`), which it never did.
371
+ - `MqttSummaryGroup.summaryText()` delegates to `this.mt.summaryText()`; the per-module element
372
+ subclasses keep only what is genuinely presentational — `MqttGroupLedbuiltin.renderSummary()`
373
+ draws a coloured dot rather than returning text, so it stays.
374
+
375
+ **4b, all done and all tested with no DOM:** `mt.decimals`, `mt.formatted`, `mt.outOfRange`,
376
+ `unitSymbol`/`unitSuffix`, `nodeMt.status`, `nodeMt.age`, `nodeMt.otaKey`, `nodeMt.deviceConfig`,
377
+ `nodeMt.orderedGroupIds`, `nodeMt.labelFor`, `nodeMt.resolveEntry`, `nodeMt.frontRows`,
378
+ `nodeMt.defaultFrontEntries`, `nodeMt.summaryChips`.
379
+
380
+ Three details worth knowing:
381
+
382
+ - **Status needs no timer.** `noteMessage()` learns the reporting interval as messages arrive, the
383
+ same smoothing `Watchdog` does, but derives `live | stale | offline | never` from arithmetic rather
384
+ than a `setTimeout`. That makes it testable, and `setClock()` in core lets a test decide what "now"
385
+ is. The old UI keeps its timer-driven `Watchdog` until `nodeview.js` retires.
386
+ - **Group order comes from `modules.yaml`, not from message arrival**, or a card would reorder itself
387
+ between loads depending on which sensor reported first.
388
+ - **A declared row is dropped when the device lacks that module.** `devices.yaml` is keyed by
389
+ application, so it lists what the application *can* have; a device whose control never reported
390
+ must not get an empty row. Pinned by a test.
391
+
392
+ ### Phase 5 — The card
393
+
394
+ **Summary mode is done.** `cards.js` holds `mqtt-devicecard`, light DOM, reading only data-tree
395
+ getters. Four things came out of reviewing it in the harness:
396
+
397
+ - A bare `47%` beside the device name says nothing about *what* is 47%. It is now the battery level
398
+ icon alone (`images/Battery0-6.png`, already in the repo and reached for by the old UI's dead
399
+ `topicChanged` code) — the summary is a glanceable view and the level is in the icon's shape, so
400
+ the number is clutter. The percentage is on the icon's title and will be text on the front. The age
401
+ shows whenever the device is not live (D-39).
402
+ - An unwired control is left off the summary entirely (D-40): a device often carries a control nobody
403
+ wired up, and it should not take one of the four places. It still appears on the front.
404
+ - The summary cap of two was too tight — an ENS160 device wants temperature, humidity and air
405
+ quality, and a control besides. Raised to four for the fallbacks; a declared `summary:` list is
406
+ uncapped (D-36).
407
+ - A control was missing from the summary entirely, because `sht30`'s `summary:` list stopped short
408
+ of it. Those redundant lists are deleted from `devices.yaml` so the default picks the control up,
409
+ and a control now contributes a **chip** — `Relay ✓` — rather than its whole rule (D-38).
410
+ - The hand-written module summaries read raw state and hardcoded `°C`/`%RH`, so a summary showed
411
+ `30.142857°C`. They now go through `mt.formatted`, which gives them width-based rounding and the
412
+ schema's own units, and removes the hardcoded units D-23 complained about. The old UI gets the
413
+ same improvement: `3940` became `3940 mV` and a lone `✓` became `Relay ✓`.
414
+
415
+ **Front mode is done.** Three row kinds off `nodeMt.frontRows`, built from the widgets the client
416
+ already has, plus the header buttons (⌄ collapse, ⚙ back) and the battery / last-seen footer.
417
+
418
+ - **Widgets are pre-bound (`el.mt`) but deliberately not registered as `mt.element`.** Only one
419
+ element can hold that at a time, so binding would have this card competing with a graph, a second
420
+ card, or the old UI for the same topic. The card pushes values into its own children from
421
+ `refresh()` instead — which is also why the "clear the back-reference when rebuilding" footgun in
422
+ CLAUDE.md does not apply here.
423
+ - **The `label` attribute never worked.** Every widget rendered `this.mt.name` regardless, so
424
+ `dashboard_example.html`'s `label:` has always been ignored. A `displayLabel` getter now prefers
425
+ the attribute, which is what lets a card show "Soil Temperature" where the topic is called
426
+ "Temperature".
427
+ - **The bar showed the raw value** — `30.142857` — which made `width` pointless. `mqtt-bar` and
428
+ `mqtt-text` now display `mt.formatted`, and the bar gained the min/max end labels §9 asked for.
429
+ Both improvements land on the old UI too: `3940` → `3940 mV`, `30.142857` → `30.1°C`, and every
430
+ bar now says what its range is.
431
+
432
+ **Back mode is done**, so phase 5 is complete. Flat sections in fixed order — Device, Controls,
433
+ readings by module, Advanced — with `<details>` used exactly once, for Advanced. The control section
434
+ is the compact `[>] [ 32.0 ] ± [ 3.0 ]` row lifted from `dashboard_example.html` (D-19), with the
435
+ input and output as the topics' own widgets so their wiring dropdowns come for free. Every module
436
+ gets a section, including ones the front leaves out — which is how a device's relay stays reachable
437
+ when its `devices.yaml` entry does not list it.
438
+
439
+ Editing is **not** gated yet: `WRITE` arrives in phase 8, and until then the back is as editable as
440
+ the existing UI is.
441
+
442
+ Reviewing the back turned up six more, and one of them was a genuine headless crash:
443
+
444
+ - **`renderWiredName` reached `wiredTopic.node`** — the `MqttNode` *element*, which a headless page
445
+ does not have. It threw inside `connectedCallback`, so the widget rendered nothing at all and the
446
+ wiring chooser simply appeared to be missing. A `mt.fullName` getter now goes through `nodeMt`.
447
+ There is a test for the failure *mode* as well as the bug: no widget on a card may render empty.
448
+ - **`labels="<,>"` rendered a two-option `<select>`.** D-19 says a comparison should flip in one tap,
449
+ and I had described the existing behaviour wrongly when writing it. It is a button showing the
450
+ current symbol, and clicking it flips.
451
+ - **A wireable field inside the compact row wrapped itself in a `<details>`**, so a stray disclosure
452
+ sat above the limit and revealed a second copy of the label and value when opened. There is now a
453
+ `wiring` attribute: `none` for a row that lays out and labels its own fields, `open` for one whose
454
+ purpose is to wire something.
455
+ - **The output's chooser was there but collapsed**, which is the same thing as absent when the row
456
+ exists to be wired. `wiring="open"` on input and output.
457
+ - **Every chooser claimed "Unused".** `renderDropdown` read the `wired` *attribute*, which is only
458
+ ever set on the element path, so on a headless card it saw null — each chooser was misreporting
459
+ its own state. It reads `mt.wired` now.
460
+ - **The wired source was named twice**, once by the chooser and once as text beside the value, and
461
+ the text then sat stale until the broker echoed a change back, so the two disagreed. The chooser
462
+ names it; the text only appears where `wiring="none"` means there is no chooser to do so.
463
+ - **My first attempt at the overflow was wrong in a way the design criteria already warned about.**
464
+ I wrote `.fi-when input { max-width }` from the page stylesheet, but those inputs are inside the
465
+ widgets' shadow roots, so it never applied. It is a custom property now — which §3.2 says is the
466
+ only way to style across that boundary.
467
+ - **The gear on the back did nothing** — the back *is* where the gear goes. It is gone, and `✕`
468
+ returns to the front rather than all the way to the summary.
469
+ - **Out-of-range readings were only red on the front**, and the hysteresis field overflowed the card.
470
+
471
+ Two more pre-existing defects surfaced while building it:
472
+
473
+ - **`label=""` was indistinguishable from no label.** `displayLabel` used `||`, so an explicitly
474
+ empty label fell back to the topic name and the back read "Name Node Name [input]". It now
475
+ treats any string, including empty, as the caller labelling it themselves.
476
+ - **`min`/`max` were stringified unconditionally**, so a text topic got `min="undefined"` in the
477
+ attribute and `min="NaN"` on the input it rendered.
478
+
479
+ Reviewing the front turned up four more, three of them in code that predates the cards:
480
+
481
+ - **A bar whose reading was below min showed a stub of fill, not an empty bar.** `MqttBar.width`
482
+ returned a negative percentage, which is not a width, so the fill shrank to fit its own text.
483
+ Clamped to 0..100, so under-range empties the bar and over-range fills it, with the true value
484
+ still printed. The `out-of-range` scenario now carries one of each.
485
+ - **The range-end labels rendered as "0100".** `mqtt-bar`'s host is `display: inline` by default, so
486
+ its shadow content shrink-wraps and `justify-content: space-between` had no width to work across.
487
+ The host is a block now.
488
+ - **`frugaliot.css` had a stray `}`**, at what was line 263, leaving every rule after it parsed at
489
+ brace depth −1. It has presumably been there a while.
490
+ - **The collapse control was a `⌄`**, which reads as "there is more below" — the opposite of what it
491
+ does. It is now a `✕`, and sits last so it lands in the corner.
492
+
493
+ **And one real bug that change exposed.** In `MqttReceiver.topicValueSet` the group roll-up
494
+ (`setAttribute`, which triggers `reSummarize`) ran *before* `valueSet` mirrored the value into the
495
+ data tree. Any summary built from the data tree was therefore one message behind — and empty for a
496
+ topic that reports only once. The tree is now updated first, before anything is asked to re-render,
497
+ which is the right order regardless.
498
+
499
+
500
+
501
+ `mqtt-devicecard`, light DOM, `mode="summary|front|back"`, bound to its `MqttTopicNode` by the
502
+ existing `el.mt` / `mt.element` convention (pre-bind before `appendChild`, per `CLAUDE.md`).
503
+
504
+ Build in mode order — summary, then front, then back — with each reviewed in `test/mock.html` against
505
+ the §3 mockups before starting the next. The back is the biggest single piece; its control row lifts
506
+ `wireUpDashboard` from `dashboard_example.html` rather than reimplementing it (D-19).
507
+
508
+ Reuse and do not rewrite: `mqtt-bar`, `mqtt-text`, `mqtt-toggle`, `mqtt-choosetopic`, `Watchdog`, and
509
+ the graph panel.
510
+
511
+ ### Phase 6 — The grid
512
+
513
+ `mqtt-devicegrid`, light DOM, CSS grid `repeat(auto-fill, minmax(300px, 1fr))`.
514
+
515
+ - Drag-to-reorder on Pointer Events, not HTML5 DnD (unreliable on Android). Long-press to lift on
516
+ touch, plus always-visible ▲▼ buttons (D-13), plus keyboard grab/move/drop.
517
+ - Layout store: `frugaliot:layout:v1:{org}/{project}` → `{v, order, mode, pinned}`, per §3.2's
518
+ wrapping rule.
519
+ - Apply the stored order **per card arrival**, not once at load (O-10) — cards appear as discovery
520
+ messages land. Append unknown devices; re-sort only on an explicit drag.
521
+
522
+ ### Phase 6 — done
523
+
524
+ `mqtt-devicegrid`, light DOM, rendering once via `render0()` and mutating in place — which phase 1
525
+ said it would have to, since `renderAndReplace` on a light-DOM element clears its own children, and
526
+ here those are the cards.
527
+
528
+ **Reordering is a model, not three implementations.** `moveBy`, `moveTo` and `moveBefore` on the
529
+ grid are what the ▲▼ buttons, the keyboard and the pointer drag all end up calling, through neutral
530
+ `frugaliot:cardmove` / `cardmovebefore` events — a card says "the user asked to move me" and the grid
531
+ decides what that means. That also makes the behaviour testable: jsdom has neither layout nor
532
+ pointers, so a drag cannot be exercised, but everything a drag *does* can be.
533
+
534
+ - **Pointer Events, not HTML5 drag-and-drop** (unreliable on Android). Mouse drags after 6px; touch
535
+ needs a 400ms hold and abandons the drag if the finger moves first, so the page still scrolls.
536
+ - **Keyboard**: Space to pick up, arrows to move, Space or Escape to put down.
537
+ - **The order is applied per arrival**, so a device discovered late lands where the layout says
538
+ rather than at the end (O-10). A remembered device that no longer exists is skipped, not left as a
539
+ hole; one the layout has never seen goes to the end, disturbing nothing.
540
+ - **Storage is wrapped and versioned.** A layout written by another version is ignored rather than
541
+ half-read, rubbish in the key does not throw, and a `localStorage` that throws outright — private
542
+ browsing does — loses the remembered layout and nothing else. There is a test that builds and
543
+ reorders a grid with storage replaced by something that throws on every call.
544
+ - jsdom only provides `localStorage` with `--localstorage-file`, so `test/setup.js` installs an
545
+ in-memory one.
546
+
547
+ The harness grew a "Forget layout" button, since a remembered order otherwise outlives the scenario
548
+ you are trying to look at.
549
+
550
+ **Dragging needed four fixes before it worked at all.** The fourth is the one worth remembering:
551
+
552
+ - **`el({onpointerdown: …})` never wired anything up.** `EL` assigns only `textContent`, `style`,
553
+ `innerHTML`, `action`, `onsubmit`, `onclick` and `onchange` as properties; **any other function it
554
+ is handed is put into `el.state` instead**, silently. The handler was correct and simply never
555
+ connected. It is bound with `addEventListener` on the host now, which also survives re-renders.
556
+ Worth knowing generally: `onclick` and `onchange` work, and nothing else does — a grep says those
557
+ are the only two the client uses, so nothing else is affected today.
558
+
559
+ The other three would not have shown up in a test that only exercised the model:
560
+
561
+ - **The hit test found the card being dragged.** It is under the pointer, so `elementFromPoint`
562
+ returned it rather than the card beneath. It now takes itself out of hit-testing for the one call.
563
+ - **Dragging downwards was a no-op.** The target's index was read *after* the dragged card had been
564
+ taken out of the list: remove A from `[A,B,C]` and B is at index 0, so inserting A at 0 puts it
565
+ straight back. The index is read first now — `moveBefore` became `moveOver`, since for a downward
566
+ drag "where B is" means after B, not before it.
567
+ - **The first successful move killed the drag.** Reordering re-appends the cards, so the dragged one
568
+ is disconnected and reconnected, and `disconnectedCallback` was calling `endDrag()`. The drag now
569
+ lives on `document` listeners so it survives being re-parented, `disconnectedCallback` leaves it
570
+ alone, and `setPointerCapture` is gone — capture is lost on re-parent anyway, and the document
571
+ listeners do the same job.
572
+
573
+ The pointer test now **dispatches real events rather than calling the handlers**, which is what
574
+ makes the unwired-handler bug catchable — the previous version called `card.onPointerDown(...)`
575
+ directly and so passed against code that was never connected to the DOM at all. `elementFromPoint`
576
+ is stubbed to behave as a browser does, returning the dragged card until it removes itself from
577
+ hit-testing. Verified by reverting each fix in turn and watching the test fail.
578
+
579
+ ### Phase 7 — The page, project front/back, admin cards
580
+
581
+ A new page beside `index.html` (O-3) — nothing in `index.html` or `nodeview.js` is modified.
582
+
583
+ - Sticky header: org and project selectors, connection status, language picker, app gear.
584
+ - Project front = the device grid. Project gear flips to the project back = admin cards.
585
+ - Admin cards, each shown only with its capability (D-28, and the §11 table): OTA `OTAUPDATE`,
586
+ Flash `OTAFLASH`, Permissions `ADMIN`, Projects `ADMIN`, Publish Message `ADMIN`, Nodes `ADMIN`,
587
+ API `ADMIN`.
588
+ - **Omit the project gear entirely when no card would appear** (D-29).
589
+ - API is wrapped, not redesigned (D-30). Flash moves out of the OTA tab's `OTAUPDATE` gating, which
590
+ is a relocation of `mqtt-flash`, not just a new condition.
591
+ - The eight §8.1 empty and error states are part of this phase, not afterthoughts.
592
+
593
+ **P-2 resolved: `dashboard.html`** — it names the job rather than the implementation, and is what it
594
+ becomes when `index.html` retires. Nothing in `index.html` or `nodeview.js` was touched.
595
+
596
+ **The page is thin, because the wrapper already does most of it.** `<mqtt-wrapper headless>` supplies
597
+ the organization and project selectors, the broker connection and its status, and the data tree;
598
+ `mqtt-dashboard` only decides what fills the space beneath — the grid, the project's back, or one of
599
+ the states where there is nothing to show yet. It renders once (`render0`), since rebuilding it would
600
+ drop the connection the wrapper holds.
601
+
602
+ **The admin cards reuse `MqttAdmin` rather than reimplementing it** (D-30). Its tab contents were
603
+ already separate methods, so the change was small: one description of the sections, a `section`
604
+ attribute that renders just one of them without the tab strip, and a guard so several admin elements
605
+ on a page do not each fetch `/config.json`. Flash gets its own card, and `otaRestContent` omits its
606
+ copy when rendering as a section so it does not appear twice.
607
+
608
+ Capability gating is **exactly what the tabs already had** — OTA on `OTAUPDATE`, Permissions, Nodes
609
+ and API on `ADMIN` — plus Flash on the new `OTAFLASH`. Widening who can see an organization's
610
+ inventory is not something a card redesign should do quietly. The gear is omitted entirely when that
611
+ would leave nothing (D-29).
612
+
613
+ **A new event, `frugaliot:projectchanged`**, fired by `MqttWrapper.addProject`. `topicschanged` only
614
+ fires once a node arrives, which is too late for a project that has none yet — and "project chosen,
615
+ waiting for devices" is one of the states §8.1 asks for.
616
+
617
+ **Two rounds of review on the page itself**, both about styling the new page inheriting the old
618
+ one's assumptions:
619
+
620
+ - **The header rendered in the browser's serif default at the phone-width sizes.** The page had no
621
+ typography of its own, and `frugaliot.css`'s `@media (max-width: 1001px)` bumps — `xxx-large` on a
622
+ dropdown, `xx-large` on a select, `x-large` on the language picker — all applied. Those rules live
623
+ inside shadow roots, so they now read `var(--fi-chrome-font, …)` and keep their old values
624
+ everywhere else; `.fi-header` and `.fi-admincard` set it to `1rem`. Same for `float: right`, which
625
+ was fighting the flex header.
626
+ - **The header wrapped onto two lines** because the wrapper was `flex: 1 1 auto` and took the whole
627
+ row. It is content-sized now, with the language picker pushed right by `margin-left: auto`.
628
+ - **An admin card has a summary and a back, and no front** (D-41), which is the shape the review
629
+ asked for: nothing in an OTA uploader is worth watching at a glance. Collapsed to its name until
630
+ opened, content built on first open and then kept, so moving between cards does not discard a
631
+ half-filled form — and an unopened card costs nothing.
632
+
633
+ **A round of review on the harness and the schema, after phase 7:**
634
+
635
+ - **The harness now shows a scenario's messages**, behind a disclosure under the controls. When a
636
+ card looks wrong the first question is always what it was told, and that was previously only
637
+ answerable by reading `mock.js`.
638
+ - **A module the schema does not know now reaches the back of the card.** It was being logged and
639
+ dropped, so a developer adding a module saw nothing at all. `MqttTopicNode` keeps unrecognised
640
+ twigs and the back lists them under "Not in the schema" — the readings appear before
641
+ `modules.yaml` catches up, which is exactly when they are most needed.
642
+ - **Two scenarios added**, plus one rewritten: a module whose group exists but whose reading never
643
+ arrives (a row with no value — distinct from a device that has said nothing at all), and every
644
+ module in the schema at once, alphabetically, for scanning every sensor and control in one pass.
645
+ The latter is built when it runs rather than declared, since it needs the schema to exist, so
646
+ `runScenario` now accepts a function for `messages`.
647
+ - **`devices.yaml` grew from 11 entries to 16.** The eight applications missing were not all
648
+ deliberate: `commonground`, `datalogger`, `gps`, `loramesher` and `power` now have entries. Three
649
+ remain absent on purpose and the file says why — `all` is every sensor at once with no order worth
650
+ declaring, `gsheets` carries only a logging control, and `lcd_sht` shows another node's readings
651
+ rather than its own. And `remotedisplay` was never missing: it is built with
652
+ `SYSTEM_OTA_PREFIX=sht30`, so two applications share one key and one card layout, which the file
653
+ now notes.
654
+
655
+ **A second review round on the harness turned up a real labelling bug**, which is what the
656
+ every-module scenario was for:
657
+
658
+ - **A module with two readings labelled both of them with the module name.** An AHT20 showed
659
+ "AHT20" twice, with nothing to say which was the temperature. D-8's rule — use the module's name
660
+ when two readings collide — only works when the module contributes *one* reading, where
661
+ "Soil Temperature" describes what is measured. With more than one it now reads
662
+ "AHT20 Temperature" / "AHT20 Humidity", and a single-reading module still uses its own name.
663
+ - **Timestamps now come from the injectable clock**, not `Date.now()`, in both the element and
664
+ headless paths. A replayed history piled every reading onto one instant, so a graph had nothing to
665
+ draw. A scenario message may now carry the moment it arrived, and `graph-history` uses that for
666
+ 144 readings across a day.
667
+ - **`twelve-devices` became `every-device`** — one device per `devices.yaml` entry, announcing that
668
+ entry's OTA key, so every configured layout can be seen at once instead of twelve copies of the
669
+ same one. The grid tests no longer assume a count.
670
+ - **`remotedisplay` had its own bug**, in the node repo rather than here: it was built with
671
+ `SYSTEM_OTA_PREFIX=sht30`, which is why it looked absent. Fixed there, and it now has an entry —
672
+ 17 in all. The note about two applications sharing a key is gone with it.
673
+ - The device-id override example was lost from `devices.yaml` in an earlier edit and is not being
674
+ put back: naming a particular device in a schema everyone receives is odd. The mechanism is
675
+ documented in the file header and covered by a test that supplies its own config.
676
+
677
+ **Not done here:** the disconnected banner, which needs `MqttClient` to say when its status changes;
678
+ and Publish Message, still superuser-gated inside the Admin section — moving it to `ADMIN` is D-32,
679
+ which belongs with the rest of the permissions work in phase 8.
680
+
681
+ ### Phase 8 — Permissions
682
+
683
+ - `WRITE` and `OTAFLASH` into the capability dropdown at `webcomponents.js:3060`, which today offers
684
+ `OTAUPDATE`/`ADMIN`/`READ`. No server or DB change — capabilities are free text in
685
+ `permissions(id, capability, org)`.
686
+ - One `canWrite` gate (§3.2); without it every control degrades to its display form per §10.1, and
687
+ ADVANCED's actions are omitted.
688
+ - Card order and open/closed state are local presentation and stay available without `WRITE`.
689
+ - Nothing here is a security boundary and no code comment or UI text may imply it is (D-27, L-7).
690
+
691
+ ### Phase 8 — done
692
+
693
+ **One gate, in one place.** `mt.canWrite` on the data tree asks `hasCapability(organization, 'WRITE')`,
694
+ and `renderMaybeWired` — the single point where every widget decides between an input and a display —
695
+ now asks it alongside `mt.rw`. That one change degrades every control on both UIs at once: an
696
+ editable field becomes its value, an actuator stops being tappable, and the wiring chooser goes,
697
+ because rewiring is a change like any other. Nothing was gated per widget, which is what §3.2 was
698
+ guarding against.
699
+
700
+ The back is still shown without `WRITE` (D-26): a reader needs last seen, battery and OTA key to
701
+ diagnose a quiet device, and none of that is on the front. Card order and open/closed state are local
702
+ presentation and stay available to everyone (§10.2), which has a test.
703
+
704
+ **The dropdown now offers `READ`, `WRITE`, `OTAUPDATE`, `OTAFLASH`, `ADMIN`** — no server or database
705
+ change, since capabilities are free text in `permissions(id, capability, org)`.
706
+
707
+ **Publish Message moved to `ADMIN`** (D-32), from `user.id !== 1`. Worth seeing plainly: that is a
708
+ widening, from superuser-only to any organization admin.
709
+
710
+ **Nothing here is a security boundary**, and the comment on `hasCapability` says so at length: every
711
+ user of an organization shares one set of broker credentials, which reach the browser in
712
+ `/config.json`, so anyone without `WRITE` still holds working publish credentials. Real enforcement
713
+ is L-7.
714
+
715
+ **Two of my own tests were passing for the wrong reason** and are fixed: `card.querySelector('mqtt-choosetopic')`
716
+ can never match, because the chooser is inside the widget's shadow root — so "the chooser goes"
717
+ asserted null against something that was always null. Shadow content is not in `textContent` either.
718
+
719
+ **Two more found by using the real page:**
720
+
721
+ - **An admin card loaded no data at all** — the Permissions card said "Nobody added for this
722
+ organization yet" and made no request. `setOrganization` fetches only for `activeTabTitle`, which
723
+ is set by the tab strip's `tabchange` event; with a single section there is no tab strip, so it
724
+ stayed at its default of "Dashboard" and the section's own data was never asked for. Setting
725
+ `activeTabTitle` from the `section` attribute fixes it, and `setDefaultOrganization` no longer
726
+ guesses over an organization it was handed.
727
+ - **A random `Cannot read properties of null (reading 'append')`** on load was not a race in the code:
728
+ `/node_modules` is served `immutable, max-age=86400`, so a browser holding the pre-`renderRoot`
729
+ copy of `html-element-extended` keeps it for a day, and whether a given load hits that cache is
730
+ what varied. A hard reload clears it. `cards.js` now checks for `renderRoot` at load and says so
731
+ plainly, since the null append names nothing useful. Worth knowing while the library is linked
732
+ rather than published: **`immutable` on a file that is being edited is a trap**.
733
+
734
+ I also put the section handler in the wrong class first — `MqttLogin` and `MqttAdmin` both have a
735
+ `changeAttribute` that handles `"lang"`, and the anchor matched the first.
736
+
737
+ ### Phase 9 — Polish
738
+
739
+ **Branding first** (D-42). The greens come from `frugaliot.naturalinnovation.org`'s own stylesheet —
740
+ `#2ecc71` leaf, `#27ae60` deeper — but used as a pale tint behind near-black text rather than a fill
741
+ under white the way the main site's hero panels do. These screens are read in direct sun and in dim
742
+ sheds; `#1a1a1a` on `#f3faf6` is about 16:1 and survives both. The logo is the icon the main site
743
+ uses in its own header, top left, linking back to it.
744
+
745
+ **The phone-width bumps, for the fourth time.** `@media (max-width: 1001px)` rules across
746
+ `frugaliot.css` inflate fonts and icons for the node/group UI, where a node is one wide block. Inside
747
+ a card they made every input and select far wider than the card, and on a phone the back's content
748
+ ran off the screen entirely. Every one of them now reads `var(--fi-chrome-font, …)` and the card page
749
+ sets it once. Two more fixes went with it: a field row stacks its label above its value below 560px
750
+ rather than keeping a fixed column that cannot shrink, and the grid uses
751
+ `minmax(min(100%, 300px), 1fr)` so a phone narrower than 300px does not get a column wider than the
752
+ screen.
753
+
754
+ That block of media queries is now parameterised rather than replaced. Replacing it wholesale is
755
+ still the right end state, and belongs with retiring `nodeview.js`.
756
+
757
+ **Audit before deploying — what the old UI does that the new page does not.**
758
+
759
+ Reachable on `dashboard.html`, checked element by element: the cards and grid, every leaf widget,
760
+ the graph, `mqtt-admin`'s four sections, `mqtt-flash`, the language picker and the connection. Two
761
+ gaps were found and closed:
762
+
763
+ - **A graph had nowhere to land.** `MqttGraph` looks for `#graph-container` and otherwise appends to
764
+ the end of the document, which on this page is below everything. The page now provides one, below
765
+ the grid, which is where §6 says it belongs.
766
+ - **A device known to the server but not currently reporting read as "never seen".** The old UI
767
+ listed those from `/config.json` with their last-seen time; the headless path added the node but
768
+ dropped the timestamp, so a device asleep since before the page loaded claimed never to have been
769
+ heard from. `lastMessageAt` is seeded from config now, so it reads as offline with a real age.
770
+
771
+ `dashboard.html` also got its own entry, `dashboard.js`, importing everything except `nodeview.js` —
772
+ the one module it genuinely replaces.
773
+
774
+ **Deployment shape settled.** The card page is now `index.html`, so `/dashboard/` serves it and
775
+ login lands there; the node/group UI moved to `index-old.html` and the project's home page has a
776
+ "Previous dashboard" card pointing at it. Nobody has to know a URL to get the new one, and nobody
777
+ loses the old one.
778
+
779
+ **A logout, at last.** There was no `/logout` route and no link in either UI. There is now a route on
780
+ the server and a button in the header — it matters more since a permissions change ends the session.
781
+
782
+ **Two things needed no work at all**, which is worth recording so nobody builds them:
783
+
784
+ - *The disconnected banner.* `MqttClient.setStatus` already re-renders, so the header switches
785
+ between "connected" and "disconnected" by itself. MQTT in the field drops and returns constantly,
786
+ and a banner for each would be worse than the word changing quietly.
787
+ - *Removing a line from a graph.* Chart.js's legend does this natively — click a label to hide its
788
+ dataset — and the legend is already enabled.
789
+
790
+ **Still outstanding, and worth deciding before deploying:**
791
+
792
+ | | |
793
+ |---|---|
794
+ | `nodeview.js` retirement | `index-old.html` is the only thing that still loads it. Retiring both is a decision, not more work |
795
+ | Publishing `html-element-extended` 0.1.7 | It is linked, not published. Until it is, a deployment needs the linked copy, and the service worker's `CACHE_NAME` has to be bumped |
796
+
797
+ **Not a bug in our code:** the page intermittently failing to load was
798
+ `frugal-iot-server/public/service-worker.js`, registered at scope `/` and cache-first with no
799
+ revalidation, holding `/node_modules/html-element-extended/htmlelementextended.js` from before the
800
+ `renderRoot` change. Devtools "disable cache" and force-reload bypass the HTTP cache but not a
801
+ service worker answering from Cache Storage. `CACHE_NAME` gets bumped at release, which is the
802
+ designed mechanism; during development, unregister the worker.
803
+
804
+
805
+ Bottom sheets for the card back and the mobile graph (O-8); 150–200ms transitions honouring
806
+ `prefers-reduced-motion`; `Intl.RelativeTimeFormat` for "2h ago" (O-7); the four-language sweep for
807
+ every new string; the §9 visual pass including `--fi-*` tokens and fixing the invalid
808
+ `border: 1px,black,solid` declarations.
809
+
810
+ ## 5. Risks
811
+
812
+ **A bug found and fixed while doing this.** The control summary ended with
813
+ `trueFalseSymbol(this.state.on)`, but no control module has an `on` leaf. The firmware's
814
+ `Control_Hysteresis` publishes an `OUTbool` named **`out`** (`src/control/hysteresis.cpp:58`), and
815
+ the `on` topic in `topics.yaml` is `rw: w` — an *actuator's* leaf, as on `relay` and `ledbuiltin`.
816
+ So that symbol had always rendered `?`. Fixed in three places:
817
+
818
+ - `MqttTopicGroupControlHysteresis.summaryText()` reads `state.out`;
819
+ - the element's `observedAttributes`/`boolAttributes` observe `out` rather than `on`, or the summary
820
+ would not refresh when the output changed;
821
+ - `dashboard_example.html` had the same bug twice — its on/off dot read `groupMt.state.on`, so it has
822
+ never lit.
823
+
824
+ The snapshot records the fix: `Relay = SHT:Temperature > 32 +/- 3 ✓`.
825
+
826
+ | Risk | Bites when | Mitigation |
827
+ |---|---|---|
828
+ | Old UI regresses unnoticed | phases 3–4 change shared code | tier 2 snapshots cover both layouts from phase 0 |
829
+ | `html-element-extended` change breaks other sites | on publish | `npm link` during development; publish only at the end; the change is a no-op when a shadow root exists |
830
+ | Schema edits diverge across 4 files | every phase 2 touch | one script edits master then copies; `md5` the four files as the check |
831
+ | The split changes behaviour by accident | phase 3 | snapshots must be byte-identical before and after |
832
+ | `frontRows` precedence grows into a tangle | phase 4 | it is one getter with one test file; if it needs a second place, the design is wrong |
833
+ | Chart.js / esptool-js slow the card page | phase 3 | dynamic `import()` for both |
834
+ | `width`/`units` pass stalls the UI work | phase 2 | defaults exist (1dp float, 0 int, no unit); cards look wrong but work |
835
+
836
+ ## 6. Open plan questions
837
+
838
+ - **P-1** Playwright for tier 2 browser snapshots, or tier 0+1 only?
839
+ - ~~**P-2** New page name~~ — resolved: `dashboard.html`.
840
+
841
+ Neither blocks starting phase 0.