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_UX.md ADDED
@@ -0,0 +1,1022 @@
1
+ # Device Cards — UX Design
2
+
3
+ Step 1 of 3: **UX design** → implementation plan (`CARDS_PLAN.md`) → implementation.
4
+
5
+ This document is the thing to argue with. Every place marked **D-n** is a decision that is open;
6
+ each carries a recommendation so it can be accepted by deleting the alternatives. Implementation
7
+ notes accumulate in the appendix and move to `CARDS_PLAN.md` in step 2.
8
+
9
+ ---
10
+
11
+ ## 1. What this replaces
12
+
13
+ Today a project renders as a vertical stack of `mqtt-node` elements, each containing one
14
+ `<details>` drop-down per module (`MqttGroup`), each drop-down showing a summary line when closed
15
+ and its topic widgets when open. Nesting is two levels deep (`insidefrugaliot` modules hide inside
16
+ the `frugal_iot` drop-down). To read one device's temperature you open the device, find the SHT
17
+ drop-down, open it.
18
+
19
+ The new model: **one device = one card, with three display modes.** Drop-downs disappear from the
20
+ main path.
21
+
22
+ ## 2. Vocabulary
23
+
24
+ | UI word | Code word | Note |
25
+ |---|---|---|
26
+ | Device | node / `MqttNode` / `MqttTopicNode` | UI text says "device"; code keeps `node` |
27
+ | Card | (new) `mqtt-devicecard` | one per device |
28
+ | Reading | topic with `rw: r` | a measurement, e.g. `sht/temperature` |
29
+ | Actuator | topic with `rw: w` | drives hardware directly — `relay/on`, `ledbuiltin/on`, `brightness` |
30
+ | Control | a control module | a loop with both inputs and outputs, e.g. `controlhysteresis`, `controlblinken` |
31
+ | Module | group / `MqttGroup` | e.g. `sht`, `relay`, `controlhysteresis` |
32
+
33
+ "Actuator" and "control" are deliberately separate. An actuator is one writable topic that changes
34
+ the world when you set it. A control is a whole module that reads inputs, applies a rule, and drives
35
+ an output — so it is never a single topic, and its inputs (`limit`, `greater`, `hysteresis`) are
36
+ settings, not actuators.
37
+
38
+ > **Detecting a control module.** The code tests `groupId.startsWith('control')`
39
+ > (`MqttTopic.controlGroups`, and the `frugaliot:controlgroup` dispatch in
40
+ > `MqttTopicNode.addGroupFromTemplate`). That test used to miss `climate` — a control module by every
41
+ > structural measure that did not start with "control". **`climate` has since been deleted** as
42
+ > legacy (see D-16), so the prefix test is correct again for every module that exists today.
43
+ >
44
+ > It is still correct only by luck: the next control module named `thermostat` or `irrigation`
45
+ > breaks it silently. A module-level `control: true` in `modules.yaml` is three lines of YAML and
46
+ > removes the naming dependency — now a robustness fix rather than a bug fix. **D-16**
47
+
48
+ ## 3. The three modes
49
+
50
+ ```
51
+ ┌──────────┐ click card ┌──────────┐ click ⚙ / gear ┌──────────┐
52
+ │ SUMMARY │ ────────────► │ FRONT │ ────────────────► │ BACK │
53
+ │ 1 line │ ◄──────────── │ readings │ ◄──────────────── │ settings │
54
+ └──────────┘ click ⌄ └──────────┘ click ✕ / done └──────────┘
55
+ ```
56
+
57
+ One `<mqtt-devicecard mode="summary|front|back">`. Mode is per-device, remembered (§7).
58
+ Several cards may be on FRONT at once — users compare devices, so this is not an accordion. **D-1**
59
+
60
+ ### 3.1 SUMMARY — "is it alive and roughly what is it saying"
61
+
62
+ Full-width row on mobile; a responsive grid cell on desktop. The whole card is one click target.
63
+
64
+ ```
65
+ ┌─────────────────────────────────┐ ┌─────────────────────────────────┐
66
+ │ ● Greenhouse North 78% │ │ ● Pond Pump 91% │
67
+ │ 30.1 °C 85 %RH │ │ Pump ✓ Soil 38 % │
68
+ └─────────────────────────────────┘ └─────────────────────────────────┘
69
+ ┌─────────────────────────────────┐
70
+ │ ◌ Shed Sensor 2h ago │ ← stale: values dimmed, age instead of battery
71
+ │ 29.4 °C 81 %RH │
72
+ └─────────────────────────────────┘
73
+ ```
74
+
75
+ - Line 1: status dot, device name (`frugal_iot/name`, falling back to the device id), battery % or,
76
+ when stale, "2h ago".
77
+ - Line 2: the summary chips (§4.1).
78
+ - Nothing on SUMMARY is interactive — the card is a single tap target. A relay toggle sitting in a
79
+ summary invites mis-taps; controls are reachable one tap away. **D-2**
80
+
81
+ ### 3.2 FRONT — "what is it doing, in detail"
82
+
83
+ **No module structure on the front.** No module headers, no grouping, no nesting — a flat ordered
84
+ list of rows. Which sensor a reading came from is a fact about the hardware, not something the
85
+ person watching the greenhouse needs. Module structure returns on the back, where it is the right
86
+ organising principle. **D-17**
87
+
88
+ ```
89
+ ┌───────────────────────────────────────────────┐
90
+ │ ● Greenhouse North ⌄ ⚙ │ ⌄ collapse ⚙ flip to back
91
+ ├───────────────────────────────────────────────┤
92
+ │ Temperature 30.1 °C 📈│
93
+ │ ├────────────────────────█──────────────────┤ │
94
+ │ 0 50 │
95
+ │ Humidity 85 %RH 📈│
96
+ │ ├───────────────────────────────█───────────┤ │
97
+ │ 0 100 │
98
+ │ Heating ●──○ ON 📈 │
99
+ │ Control Temperature > 32 ±3 ✓ ON │
100
+ │ Vent light ○──● OFF 📈 │
101
+ ├───────────────────────────────────────────────┤
102
+ │ 🔋 3.94 V · seen 12s ago │
103
+ └───────────────────────────────────────────────┘
104
+ ```
105
+
106
+ Exactly three kinds of row, in whatever order the device declares (§4.2):
107
+
108
+ 1. **Reading** — label, value + unit, and the widget its `display:` calls for (bar here), with
109
+ min/max end labels and the graph icon `mqtt-bar` already has.
110
+ 2. **Actuator** — label and a live toggle or slider. **Tappable on the front**: turning a pump on is
111
+ the commonest thing anyone does in the field, and making them flip the card to do it is a real
112
+ cost. The mis-tap worry of D-2 was about the *summary*, where the whole card is one target; here
113
+ the card is already open and the toggle is a deliberate, properly sized target. **D-3**
114
+ 3. **Control** — read-only. Shows the rule and the resulting output state
115
+ (`Temperature > 32 ±3 ✓ ON`). Editing the rule happens on the back. So the split is *act on
116
+ the front, configure on the back*, which keeps the symmetry rather than breaking it.
117
+
118
+ **Actuators and controls stay separate rows**, even when a control drives that actuator. Merging
119
+ them into one row was considered and dropped: it hides the fact that two distinct things exist, and
120
+ the interesting question — what happens when you tap an actuator a control is driving — has its own
121
+ UX that is deliberately **deferred** (§14). Until that lands, tapping a control-driven actuator will
122
+ be overwritten by the loop on its next cycle. That is a known rough edge, recorded rather than
123
+ designed around. **D-18**
124
+
125
+ Footer strip: battery, last seen. Small, gray, always present.
126
+
127
+ ### 3.3 BACK — "settings and background"
128
+
129
+ ```
130
+ ┌───────────────────────────────────────────────┐
131
+ │ ⚙ Greenhouse North ✕ │
132
+ ├───────────────────────────────────────────────┤
133
+ │ DEVICE │
134
+ │ Name [Greenhouse North ] │
135
+ │ Description [Bed 3, north end ] │
136
+ │ Device id esp8266-fb94bb │
137
+ │ Last seen 12s ago (14:32:05) │
138
+ │ Battery 3.94 V Wifi ▂▄▆ "shed-ap" │
139
+ │ OTA key a1b2c3d4 ⧉ │
140
+ ├───────────────────────────────────────────────┤
141
+ │ CONTROL — Fan │
142
+ │ Input [Temperature (SHT) ▾] 30.1 │
143
+ │ When [>] [ 32.0 ] ± [ 3.0 ] │
144
+ │ Output [Relay 1 ▾] ✓ ON │
145
+ │ Manual override [ ] │
146
+ ├───────────────────────────────────────────────┤
147
+ │ SHT │
148
+ │ Temperature 30.1 °C min 0 max 50 📈 │
149
+ │ Humidity 85 %RH min 0 max 100 📈 │
150
+ ├───────────────────────────────────────────────┤
151
+ │ RELAY │
152
+ │ Relay 1 [x] on 📈 │
153
+ ├───────────────────────────────────────────────┤
154
+ │ ADVANCED ⌄ │
155
+ └───────────────────────────────────────────────┘
156
+ ```
157
+
158
+ - Flat sections in a fixed order — **no drop-downs**, agreeing with the instinct in the brief that
159
+ someone on the back of a card wants everything about that sensor. Order: Device → Controls →
160
+ Readings by module → Advanced.
161
+ - ADVANCED is the one thing still collapsed: raw MQTT topic paths, wiring internals, flash/OTA
162
+ actions. It is for debugging, not for the field. **D-4**
163
+ - Editable min/max per reading is shown above as a stretch (`min 0 max 50`); the schema already
164
+ carries `min`/`max` per topic, and per-device overrides arrive on `…/set/…/max`. **D-5**
165
+ - **The control line follows `dashboard_example.html`, not the old node UI.** One inline row,
166
+ `[>] [ 32.0 ] ± [ 3.0 ]`: the comparison is a two-state control showing `<` or `>`, the limit and
167
+ the hysteresis are bare number fields, and the hysteresis is labelled with the `±` symbol rather
168
+ than the word "Hysteresis". The dashboard already builds precisely this — an `mqtt-toggle` with
169
+ `labels: '<,>'` plus two `mqtt-text` fields with `label: ''` and `label: '±'`. A two-state toggle
170
+ is one tap where a dropdown is two, so prefer the toggle over a `▾` menu. **D-19**
171
+
172
+ ### 3.4 How the flip behaves
173
+
174
+ - **Desktop**: a real CSS `rotateY` flip. This requires front and back to occupy the same box, so
175
+ the back scrolls internally at the front's height rather than growing the card. Same width, same
176
+ height, no layout jump for neighbouring cards.
177
+ - **Mobile**: no flip. The back opens as a full-screen sheet sliding up from the bottom, with the
178
+ device name and a ✕ in its header. A 3D flip in a 340px-wide column, with form fields on the
179
+ reverse, is worse than the sheet that every mobile OS has trained people on. **D-6**
180
+ - The metaphor survives either way: front = watch, back = adjust.
181
+
182
+ ## 4. How modules accumulate into a card
183
+
184
+ This is the core question in the brief. Three levels of declaration, each answering one question:
185
+
186
+ | Level | File | Question it answers |
187
+ |---|---|---|
188
+ | Topic | `topics.yaml` | how is one reading rendered (`display`, `units`, `width`, `color`, `min`, `max`) |
189
+ | Module | `modules.yaml` | which of *my* topics belong in *my* summary |
190
+ | Device | org/project config | what appears on the summary and the front of *this* device, in what order |
191
+
192
+ Note what is **not** here: nothing declares a render type twice. `display:` already carries
193
+ `bar|gauge|text|toggle|slider|color` per topic, so the summary and front lists say only *which*
194
+ topics and *in what order* — never *how*.
195
+
196
+ ### 4.1 Summary content — a list of twigs, not a format string
197
+
198
+ The brief suggests a format string:
199
+ `'${sht/temperature} ${sht/temperature/unit} ${frugaliot/battery}${frugaliot/battery/unit}'`
200
+
201
+ **Recommendation: an ordered list of twigs instead.** **D-7**
202
+
203
+ ```yaml
204
+ # in a device / project config
205
+ summary: [sht/temperature, sht/humidity, battery/battery]
206
+ ```
207
+
208
+ Why not a format string:
209
+ - Units, names and number formatting all need translating and localising; baking them into a
210
+ string in YAML puts untranslatable text in the config.
211
+ - A missing value (sensor absent, device never seen) has to render as something sensible — a
212
+ format string gives `undefined °C`.
213
+ - Values want individual styling: schema colour, dimming when stale, red when out of range.
214
+ - A list is a strict subset of what a format string can express, minus arbitrary literal text —
215
+ which nothing in the summary actually needs.
216
+
217
+ **Default when nothing is declared**: use each module's existing `summaryText()`. The machinery is
218
+ already built — `MqttGroupSht` returns `30.1°C 85%RH`, `MqttGroupRelay` returns `✓`,
219
+ `MqttGroupControlHysteresis` returns the whole control line.
220
+
221
+ **A module opts *out*, not in** — `summary: false`. **D-37** An earlier draft had `summary: true` as
222
+ the opt-in; writing it revealed that 23 of the 33 modules would carry the flag, which is a flag doing
223
+ no work. Inverted, five modules carry it: `button` (transient), `captive`, `carousel` and `lcd` (not
224
+ readings), and `frugal_iot` (the card identity, not a module on it). `insidefrugaliot` modules are excluded structurally and need no flag. (`gps` was in that list on
225
+ first pass and taken out — a position does belong on a summary.)
226
+
227
+ The polarity also fails in the right direction: a newly added sensor module shows up in summaries by
228
+ default, rather than being invisible until someone remembers a flag.
229
+
230
+ **Done, and without the YAML.** L-3 set out to replace the hand-written summaries with a
231
+ `summary: [leaves]` list per module. What it actually needed was a sensible default:
232
+ `MqttTopicGroup.summaryText()` now returns the module's own readings, formatted by the schema and
233
+ capped at two. That is what §4.1 always said the fallback should be, and it gives **26 of the 33
234
+ modules** a summary rather than the ten that had one written by hand — a device with no `devices.yaml`
235
+ entry used to show a blank line if its modules happened to be among the other twenty.
236
+
237
+ Four of the hand-written subclasses were deleted because the generic version produces exactly what
238
+ they did. Three remain, and each earns it: `relay` wants its name beside the tick, `ota` shows a key
239
+ rather than a reading, and a control shows a rule rather than a list of values. No `modules.yaml`
240
+ change was needed at all. **D-44**
241
+
242
+ The paragraphs below describe the interim state and are kept for the reasoning:
243
+
244
+ **This is explicitly an interim step.** Only a handful of modules have a hand-coded `summaryText()`
245
+ (`sht`, `dht`, `ht`, `soil`, `ds18b20`, `battery`, `relay`, `ledbuiltin`, `ota`,
246
+ `controlhysteresis`) — every other module has no summary at all, and each one that does required a
247
+ subclass. The intent is to delete those subclasses. The migration needs no new mechanism, because
248
+ the *same twig list* works one level up:
249
+
250
+ ```yaml
251
+ sht:
252
+ name: SHT
253
+ summary: [temperature, humidity] # leaves, not twigs — replaces MqttGroupSht.summaryText()
254
+ ```
255
+
256
+ So there is one mechanism at both levels: a module declares its own default summary as a list of
257
+ leaves, a device may override with a list of twigs. As each module gains a `summary:` list, its
258
+ hand-coded class goes. **D-20**
259
+
260
+ **Controls are the exception and stay hand-coded.** `MqttGroupControlHysteresis.summaryText()`
261
+ produces `Fan = Temperature > 32 ±3 ✓` — a structural sentence with wiring lookups and a comparison
262
+ operator in the middle, not a list of values. A list cannot express it and a format string would
263
+ express it badly. Control modules keep a purpose-built renderer; only *sensor* summaries become
264
+ declarative.
265
+
266
+ **Fallback order** for a module with no `summary:` list and no `summaryText()`: its `graphable`
267
+ readings, capped at two, else nothing. A module contributing nothing is not an error — a device
268
+ whose summary line is empty still shows its name and status, which is the important part.
269
+
270
+ ### 4.2 Front content — a per-device ordered list
271
+
272
+ An earlier draft put `front: bar|value|none` on each topic in `modules.yaml`. That was wrong: it
273
+ duplicates `display:`, which already says `bar|gauge|text|toggle|slider|color`. Two keys meaning
274
+ "how do I render" is one key too many, and they would eventually disagree.
275
+
276
+ What is actually needed is *which* topics appear and *in what order* — and that is a per-device
277
+ question, because a temperature logger wants the temperature at the top with its control beneath,
278
+ while another device wants something else entirely. Same shape as the summary list: **D-21**
279
+
280
+ ```yaml
281
+ # device level
282
+ front:
283
+ - sht/temperature
284
+ - controlhysteresis # a bare module id = that control's row
285
+ - sht/humidity
286
+ - relay/on
287
+ ```
288
+
289
+ An entry is either a **twig** (a reading or an actuator, rendered per its `display:`) or a **module
290
+ id** (a control module, rendered as its one control row). Order is the list order, so readings,
291
+ actuators and controls interleave freely — no fixed section order to fight.
292
+
293
+ **Default when no `front:` is declared**, so a newly discovered device is never blank:
294
+
295
+ 1. every reading with `graphable: true`, in module declaration order, then
296
+ 2. every actuator (`rw: w`, not inside a control module), then
297
+ 3. one row per control module.
298
+
299
+ Excluded from (1) and (2): modules marked `insidefrugaliot` — `battery`, `health` — which feed the
300
+ status strip instead (§4.4), and topics belonging to a control module, which the control's own row
301
+ already represents. `graphable: true` turns out to be a good proxy for "a real measurement rather
302
+ than a diagnostic field": it is set on 33 of the 54 topics in `topics.yaml`, and the ones it leaves
303
+ out are exactly the writable settings and the text/metadata fields. **D-22**
304
+
305
+ If that default proves wrong in practice, the fix is a module-level boolean, not a second render
306
+ key.
307
+
308
+ ### 4.3 Labels
309
+
310
+ On the **front**, a row is labelled by the topic's `name` from `topics.yaml` — `Temperature`,
311
+ `Humidity` — with no module name attached, per §3.2.
312
+
313
+ Where two front rows would carry the same label, the collision is resolved by using the **module's**
314
+ `name` instead of the topic's: a device with both an SHT and a DS18B20 shows `Temperature` and
315
+ `Soil Temperature`, because `ds18b20` is already named "Soil Temperature" in `modules.yaml`. This is
316
+ better than `Temperature (SHT)` / `Temperature (DS18B20)` — it reads as a description of what is
317
+ being measured rather than as an exposed part number. A device-level `label:` on a `front:` entry
318
+ overrides both. **D-8**
319
+
320
+ On the **back**, readings sit under a module heading, so the label is always just the topic `name` —
321
+ the heading carries the module identity.
322
+
323
+ ### 4.4 `insidefrugaliot` modules
324
+
325
+ `battery`, `health` and friends already nest inside the `frugal_iot` group. They do not become
326
+ sections; they feed the status strip (battery, wifi, last seen) on SUMMARY/FRONT and the DEVICE
327
+ section on BACK. `frugal_iot` itself is never a section — it *is* the card's identity.
328
+
329
+ ### 4.5 Units are not usable as they stand
330
+
331
+ The brief's example summary, "SHT 30° 85%", assumes a unit after the number. Two problems:
332
+
333
+ - **The client never reads `units`.** The key exists in `topics.yaml` on 18 topics; `webcomponents.js`
334
+ contains no reference to it at all. Today's UI shows bare numbers, and the `°C`/`%RH` in
335
+ `MqttGroupSht.summaryText()` is a hard-coded string in the class.
336
+ - **The values are SenML codes, not display symbols.** `units: Cel`, `deg`, `lx`, `lat`, `lon`,
337
+ `kOhm`, `mg/L`. Rendered literally that reads "30.1 Cel".
338
+
339
+ So the card UI needs a small SenML-code → display-symbol map (`Cel` → `°C`, `deg` → `°`, `%RH` and
340
+ `mV` pass through unchanged), and `units:` needs filling in on the topics that lack it. Neither is
341
+ large, but neither is free, and the summary line does not work without both.
342
+
343
+ **Recommendation: a separate lookup table, not the `languages` table.** **D-23** Three reasons, the
344
+ first decisive:
345
+
346
+ 1. **`el()`'s translation filters actively reject unit symbols.** Look at the two guards at
347
+ `webcomponents.js:1092-1094`: a value containing `/` is skipped, and a value whose first
348
+ character is not an ASCII letter is skipped. That excludes `°C`, `%RH`, `m/s`, `mm/hr` and
349
+ `mg/L` outright. Units would have to special-case their way around the very mechanism they were
350
+ put into.
351
+ 2. **`getString` fails unreadably for codes.** It falls back to the tag itself, so a missing entry
352
+ renders the literal `Cel`. A missing *English string* at least still reads as English; a missing
353
+ *code* renders as noise. Keying a lookup table by code is fine; keying a translation table by
354
+ code conflates "identifier → display" with "English → other language".
355
+ 3. **It keeps unit *conversion* separate from unit *translation*.** °C → °F is a user preference
356
+ that has nothing to do with language — a French speaker wants °C, an American English speaker
357
+ may want °F. Folding units into `languages` invites conflating the two and closes that door.
358
+
359
+ The escape hatch survives either way: if a unit ever genuinely needs translating (`%RH` → `%HR` in
360
+ French), the table's *value* can be a `getString()` tag. Starting separate does not prevent that;
361
+ starting inside `languages` does force four-language duplication for all ~18 entries today.
362
+
363
+ ### 4.6 Which `devices.yaml` entry applies
364
+
365
+ The OTA key is built by the firmware as `SYSTEM_OTA_PREFIX "_" SYSTEM_OTA_SUFFIX`
366
+ (`src/system/ota.cpp:220`), where the prefix is the application and the suffix is the board. One app
367
+ across ten boards therefore produces ten keys — `sht30_c3_pico`, `sht30_d1_mini`,
368
+ `sht30_s2_mini_4x`, … — all wanting the same card layout, because the layout follows the
369
+ *application*, not the board.
370
+
371
+ So `devices.yaml` keys **match by prefix**, resolved in this order: **D-34**
372
+
373
+ 1. an exact match on the **device id** (`esp8266-fb94bb`) — one device overriding its build;
374
+ 2. an exact match on the **full OTA key** (`sht30_c3_pico`) — one board differing from its siblings;
375
+ 3. the **longest key** `k` for which `otakey.startsWith(k)` (`sht30`) — the normal case.
376
+
377
+ Longest-match, not file order, so adding a more specific entry never depends on where it is placed
378
+ and `sht30` correctly beats `sht` when both exist.
379
+
380
+ Prefix matching is not merely convenient here, it is necessary: suffixes themselves contain
381
+ underscores (`c3_pico`, `s2_mini_4x`), so `key.split('_')[0]` does not recover the application name.
382
+ `startsWith` does.
383
+
384
+ The practical effect is that `devices.yaml` has roughly one entry per *example application*, not per
385
+ device or per build.
386
+
387
+ ### 4.7 Precedence for the summary line
388
+
389
+ Three sources, first match wins: **D-36**
390
+
391
+ 1. the device entry's `summary:` list;
392
+ 2. otherwise the **first two entries of its `front:` list** — so the common device declares `front:`
393
+ only and gets a sensible summary for free;
394
+ 3. otherwise every contributing module (§4.1).
395
+
396
+ Rules (2) and (3) are capped at **four** chips, which is enough for temperature + humidity + air
397
+ quality + a control — two was too few, and an ENS160 device could not say what it was for. A declared
398
+ `summary:` list is not capped: if an author asks for six, they get six. **D-36**
399
+
400
+ A **control contributes a chip, not its rule**: `Relay ✓`, where the front row shows
401
+ `Relay = Temperature > 32 ±3 ✓`. Two methods, `summaryShort()` and `summaryText()`, because a
402
+ sentence in a chip row wraps the card to three lines. **D-38**
403
+
404
+ A **control that drives nothing is left off the summary altogether** — a device often carries a
405
+ control that was never wired up, and an idle one should not take a place on a four-chip line. It
406
+ still appears on the front, which is where you would go to wire it. **D-40**
407
+
408
+ `summary:` is kept as a separate key rather than always derived, because the one-line view genuinely
409
+ differs from the full one often enough to matter — a soil sensor whose summary is the moisture but
410
+ whose front leads with the control, or a relay whose summary is just its state while its front leads
411
+ with the sensor driving it. Rule (2) means most devices never write it.
412
+
413
+ Entries in both lists are a **twig** (contains `/`) or a **module id** (does not), as §4.2.
414
+
415
+ ## 5. Status, staleness and number formatting
416
+
417
+ Three things the brief does not cover but which decide whether the UI is usable in the field.
418
+
419
+ **Status.** A frugal device's most important property is whether it is still reporting. One dot
420
+ with three states, shape-differentiated as well as coloured (sunlight, colour blindness):
421
+
422
+ | State | Dot | Meaning |
423
+ |---|---|---|
424
+ | Live | ● green | seen within the watchdog interval |
425
+ | Stale | ◌ amber | seen, but not recently |
426
+ | Offline | ○ gray | watchdog expired / never seen |
427
+
428
+ **Stale values stay visible, dimmed, with their age.** A two-hour-old reading is still
429
+ information; hiding it is a regression from today's behaviour, which greys the whole node.
430
+
431
+ **Number formatting — `width`.** `30.142857 °C` is unusable and is what MQTT currently delivers.
432
+ The node/firmware already has the concept of **width**, so use the same word rather than inventing
433
+ `precision`: `width` is the total character count of the rendered number, **including the sign and
434
+ the decimal point, excluding units**. Add it to `topics.yaml`, overridable per module in
435
+ `modules.yaml` like any other topic field. **D-9**
436
+
437
+ **Only `float` and `exponential` topics need it** — 25 of the 54 topics in `topics.yaml`, not all of
438
+ them. **D-35** An integer has no decimals to decide, so `width` would carry no information a
439
+ renderer cannot already derive: the field width for right-alignment is
440
+ `max(len(formatInt(min)), len(formatInt(max)))`, and an int with no declared range is simply rendered
441
+ as it arrives. `bool`, `text` and `color` never take it.
442
+
443
+ Decimals are derived, not declared:
444
+
445
+ ```
446
+ intWidth = max(len(formatInt(min)), len(formatInt(max))) # formatInt includes a leading "-"
447
+ decimals = max(0, width - intWidth - 1) # the -1 is the decimal point
448
+ ```
449
+
450
+ Derived from `min`/`max` rather than from the current value, so the number of decimals is **stable
451
+ as the value moves** — `9.9` does not become `10` the moment it crosses ten. Worked example, a
452
+ temperature with `min: 0, max: 50, width: 4`: `intWidth` = 2, so `decimals` = 1, giving `30.1`. Give
453
+ the same topic `min: -40, max: 125, width: 5` and `intWidth` = 3, `decimals` = 1, giving `-40.0` and
454
+ `125.0` — both exactly 5 characters.
455
+
456
+ - Right-align in `width` characters with tabular numerals, so digits do not jitter as values update.
457
+ - **Never truncate a number to fit.** A sensor reporting `-999` in a `width: 4` field overflows the
458
+ field rather than rendering `-99`. Overflowing looks broken, which it is; truncating lies.
459
+ - Default when `width` is absent: 1 decimal for `float`, 0 for `int`, matching current practice.
460
+
461
+ > **Naming hazard.** `MqttBar` already has `get width()`, returning the bar fill as a percentage.
462
+ > A schema `width` arriving on `mt` would sit confusingly close to it. Rename the getter to
463
+ > `fillPercent` while doing this work. **D-24**
464
+
465
+ **Out of range.** A reading past its `min`/`max` gets a red value and a filled-to-the-end bar
466
+ rather than an overflowing one. Cheap, and it catches broken sensors. **D-10**
467
+
468
+ ## 6. Graphs
469
+
470
+ Not a fourth card mode. The graph icon on a reading (already on `mqtt-bar`) adds that series to the
471
+ **shared graph panel below the grid** — which is the existing behaviour and the right one, because
472
+ the interesting comparison is usually across devices ("both greenhouses, last 3 days"), which an
473
+ in-card graph cannot show.
474
+
475
+ Alternative considered and rejected: graph inside the card. It would make the card tall, fight the
476
+ flip's fixed box, and lose cross-device comparison. **D-11**
477
+
478
+ ## 7. Layout, ordering and memory
479
+
480
+ ### 7.1 One mechanism, not two
481
+
482
+ The brief asks for free dragging on desktop and reordering on mobile. **Recommendation: an ordered
483
+ list on both**, i.e. drag-to-reorder inside a responsive flow grid, not free XY placement. **D-12**
484
+
485
+ Free XY placement needs fixed-size cards (cards change size when they open), breaks on window
486
+ resize, has no meaning on a one-column phone, and needs a second persisted format. Drag-to-reorder
487
+ gives one interaction model, one storage format (`[nodeId, …]`), and behaves under resize.
488
+
489
+ ```
490
+ desktop: grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
491
+ a FRONT card spans 2 columns where there is room
492
+ mobile: one column; a FRONT card is simply taller
493
+ ```
494
+
495
+ ### 7.2 Drag mechanics
496
+
497
+ - Pointer Events, not HTML5 drag-and-drop (which is unreliable on Android browsers — the target
498
+ platform).
499
+ - Desktop: grab anywhere on the card header and drag; live gap where it will land.
500
+ - Mobile: long-press to lift, or the always-visible ▲▼ buttons in the card header. The buttons are
501
+ not a fallback nobody uses — on a low-end phone they are more reliable than a long-press. **D-13**
502
+ - Keyboard: focus a card, Space to grab, arrows to move, Space to drop.
503
+
504
+ ### 7.3 Persistence — `localStorage`, not a cookie
505
+
506
+ The brief says cookie; **recommend `localStorage`**. A cookie is sent on every HTTP request to the
507
+ server (wasteful where bandwidth is the constraint), is capped near 4 KB, and this state never
508
+ needs to reach the server. `localStorage` is same-origin, synchronous, and ~5 MB. **D-14**
509
+
510
+ ```
511
+ key: frugaliot:layout:v1:{org}/{project}
512
+ value: { order: ["esp8266-fb94bb", …], mode: { "esp8266-fb94bb": "front" }, pinned: [] }
513
+ ```
514
+
515
+ - New devices appear at the end of the order, never reshuffling what the user arranged.
516
+ - Unknown ids in a stored order are ignored (device retired) rather than erroring.
517
+ - Mode is remembered per device, so returning to a project restores the same open cards.
518
+ - A project with one or two devices opens them on FRONT by default — a grid of one summary card is
519
+ a wasted screen. **D-15**
520
+ - Later, optionally, sync the layout to the logged-in user's account so it follows them across
521
+ devices. Explicitly out of scope for now.
522
+
523
+ ## 8. Page chrome
524
+
525
+ ```
526
+ ┌───────────────────────────────────────────────────────────────┐
527
+ │ Frugal IoT [dev ▾] [lotus ▾] ● connected EN ▾ ⚙ │ sticky
528
+ ├───────────────────────────────────────────────────────────────┤
529
+ │ ┌ card ┐ ┌ card ┐ ┌ card ┐ │
530
+ │ ┌ card ┐ ┌ card ┐ │
531
+ ├───────────────────────────────────────────────────────────────┤
532
+ │ Graph panel (only once something has been added to it) │
533
+ └───────────────────────────────────────────────────────────────┘
534
+ ```
535
+
536
+ Org and project selectors already exist on `MqttWrapper`; they move into a compact sticky header.
537
+ The trailing ⚙ is app/admin settings (existing `mqtt-admin`), distinct from a card's own ⚙.
538
+
539
+ ### 8.1 States that must be designed, not left to chance
540
+
541
+ | State | Treatment |
542
+ |---|---|
543
+ | No project chosen | centred prompt with the project selector, nothing else |
544
+ | Project chosen, no devices yet | "Waiting for devices…" with a spinner and the broker name |
545
+ | Device discovered, no readings | card in SUMMARY with the name and "no readings yet" |
546
+ | MQTT disconnected | banner across the top, grid dimmed, values frozen not blanked |
547
+ | Device offline | card as §5, still draggable, still openable |
548
+
549
+ ## 9. Visual style suggestions
550
+
551
+ Offered because the brief asks for them; all cheap to change later if they are wrong.
552
+
553
+ - **Battery**: on the summary, the level icon alone — the summary is a glanceable view and the level
554
+ is already in the icon's shape, so the number is clutter. The percentage stays on the icon's title
555
+ and appears as text on the front. A bare "47%" beside a device name was the worst of both: it does
556
+ not say what is 47%, and it reads as a humidity or a soil moisture.
557
+ - **Branding**: the project's greens from `frugaliot.naturalinnovation.org` — `#2ecc71` leaf,
558
+ `#27ae60` deeper — used as a **pale tint behind near-black text**, not as a fill under white text
559
+ the way the main site's hero panels do. These screens get read in direct sun and in dim sheds;
560
+ `#1a1a1a` on `#f3faf6` is about 16:1 and survives both, where white on green does not. The green
561
+ appears as the card tint, a thin band under the header, the section rules, and the live status
562
+ dot. **D-42**
563
+ - **Logo**: the icon the main site uses in its own header, top left, linking back to it.
564
+ - **Card**: pale green (`#f3faf6`), 8px radius, 1px hairline border, plus a 1px soft shadow. Not the
565
+ heavy black border of today. (Note: the current CSS writes `border: 1px,black,solid` in several
566
+ places, which is invalid CSS and silently does nothing — worth fixing while here.)
567
+ - **Hierarchy**: the *value* is the loudest thing on the card. Label small, uppercase-ish, gray
568
+ (`#6b6b6b`); value large, near-black, tabular numerals so digits do not jitter as they update.
569
+ These devices get read outdoors on a phone in sunlight — favour contrast over subtlety.
570
+ - **Colour**: use the topic's schema `color` for the bar fill only. Never let colour be the only
571
+ carrier of meaning; the number is always shown.
572
+ - **Bars**: keep them. They are cheap, fast, readable at a glance, and already implemented. Add
573
+ min/max end labels so the value has context.
574
+ - **Touch targets** ≥ 44px. Today's CSS reaches mobile by bumping `font-size` to `xxx-large` at
575
+ `max-width: 1001px`, which is coarse; replace with CSS custom properties for spacing/type scale
576
+ set once per breakpoint.
577
+ - **Back tint**: give the back a faintly different background so it is obvious which side you are
578
+ on without reading it.
579
+ - **Dark mode**: not now, but define colours as custom properties from the start so
580
+ `prefers-color-scheme` is a small diff later.
581
+ - **Motion**: 150–200ms on the summary↔front expand and the flip; respect
582
+ `prefers-reduced-motion` by cutting to the end state.
583
+
584
+ ## 10. Permissions — the WRITE capability
585
+
586
+ A new org-scoped `WRITE` capability, required for **any** change: toggling an actuator, editing a
587
+ control's limit, renaming a device, setting a wire. Capabilities are free text in the
588
+ `permissions(id, capability, org)` table, so adding one needs no schema change — only the client's
589
+ dropdown at `webcomponents.js:3060`, which today offers `OTAUPDATE`/`ADMIN`/`READ`. **D-25**
590
+
591
+ The full set after this work — the dropdown must offer all five: **D-31**
592
+
593
+ | Capability | Grants |
594
+ |---|---|
595
+ | `READ` | see an organization's projects and devices at all |
596
+ | `WRITE` | change anything: actuators, control settings, names, wiring |
597
+ | `OTAUPDATE` | upload and manage OTA binaries |
598
+ | `OTAFLASH` | flash a device over USB from the browser |
599
+ | `ADMIN` | permissions, projects, the API tools, raw message publishing |
600
+
601
+ `OTAFLASH` and `OTAUPDATE` are **independent — neither implies the other**, and `OTAFLASH` will be
602
+ the more widely granted of the two: flashing requires physically holding the device, which is its own
603
+ access control, whereas pushing an OTA binary reaches every device in the organization at once.
604
+
605
+ ### 10.1 What a user without WRITE sees
606
+
607
+ The proposal was that the whole back is hidden. **Recommendation: show the back, read-only,
608
+ instead.** **D-26**
609
+
610
+ The back is "settings *and background info*" (§3.3) and the background half is read-only by nature —
611
+ device id, last seen, battery, wifi SSID, OTA key, every reading with its min/max. That is exactly
612
+ what someone diagnosing a quiet device needs, and none of it is on the front. Hiding it means a
613
+ read-only user cannot answer "when did it last report, and what is its battery doing" without an
614
+ admin.
615
+
616
+ So each element degrades rather than disappearing, using display forms the codebase already has:
617
+
618
+ | With WRITE | Without WRITE |
619
+ |---|---|
620
+ | actuator toggle on the front | state indicator (`✓` / `✗`, per `trueFalseSymbol`) |
621
+ | `mqtt-text` limit field | the value as text |
622
+ | `mqtt-choosetopic` wiring dropdown | the wired topic's `usableName` as text |
623
+ | device name / description inputs | plain text |
624
+ | ADVANCED: flash, OTA actions | section omitted entirely |
625
+
626
+ The gear icon still appears, because the back still has something to show. If a device ever has
627
+ *nothing* read-only worth showing, omit the gear rather than opening an empty card.
628
+
629
+ ### 10.2 What WRITE does not govern
630
+
631
+ Card order, which cards are open, and summary/front choice are **local presentation**, stored in
632
+ `localStorage` (§7.3). They are not writes to the system and stay available to everyone. A read-only
633
+ user can still arrange their own dashboard and drive the graph.
634
+
635
+ ### 10.3 This is decluttering, not security — and must be labelled as such
636
+
637
+ Every user of an organization connects to the broker with the **same** credentials: `setClientCredentials`
638
+ sets `username = org` and `password = orgConfig.mqtt_password`, and that password reaches the browser
639
+ inside `/config.json`. So a user without `WRITE` still holds working publish credentials and can
640
+ change anything with any MQTT client.
641
+
642
+ Hiding controls is therefore worth doing — it prevents accidents, and it stops a viewer being offered
643
+ actions that are not theirs — but it must not be described to anyone as a permission boundary. Real
644
+ enforcement needs per-user broker credentials, or a server-side publish proxy that checks `WRITE`.
645
+ Either is a substantial piece of work on the server and broker, and belongs on the deferred list
646
+ rather than in this UI change. **D-27**
647
+
648
+ ## 11. The project back — administration
649
+
650
+ The card metaphor extends up a level: the **project** has a front and a back too. Front is the grid
651
+ of device cards; the gear in the project header flips to the back, which is a grid of
652
+ **administration cards**. This is what becomes of the rest of `<mqtt-admin>` — today a
653
+ `tabbed-display` of Dashboard / OTA / Admin / Nodes / API with the project UI buried inside tab 0.
654
+ Tabs become cards, and the Dashboard tab stops being a tab because it *is* the front. **D-28**
655
+
656
+ ```
657
+ ┌───────────────────────────────────────────────────────────────┐
658
+ │ ⚙ dev / lotus ✕ │
659
+ ├───────────────────────────────────────────────────────────────┤
660
+ │ ┌ OTA ─────────┐ ┌ Flash ───────┐ ┌ Publish ─────┐ │
661
+ │ └──────────────┘ └──────────────┘ └──────────────┘ │
662
+ │ ┌ Permissions ─┐ ┌ Projects ────┐ ┌ Nodes ───────┐ │
663
+ │ └──────────────┘ └──────────────┘ └──────────────┘ │
664
+ │ ┌ API ─────────┐ │
665
+ │ └──────────────┘ │
666
+ └───────────────────────────────────────────────────────────────┘
667
+ ```
668
+
669
+ Each card is shown only if the user's capabilities allow it:
670
+
671
+ | Card | Capability | Note |
672
+ |---|---|---|
673
+ | Info | none | broker, organization, connection — reference information anyone may want |
674
+ | OTA upload and manage | `OTAUPDATE` | existing OTA tab, unchanged |
675
+ | Flash | `OTAFLASH` | **new capability**; today `mqtt-flash` sits inside the OTA tab and so is gated on `OTAUPDATE` — it moves out |
676
+ | Permissions | `ADMIN` | existing Admin tab, unchanged |
677
+ | Projects | `ADMIN` | existing Admin tab, unchanged |
678
+ | Publish Message | `ADMIN` | ⚠ a **loosening** — see below |
679
+ | Nodes | `ADMIN` | unchanged; `READ` would be defensible, see below |
680
+ | API | `ADMIN` | unchanged — `apiRestContent()` includes a "Node Actions" section that writes, so this is not a read-only tool |
681
+
682
+ **Publish Message is deliberately not on `WRITE`.** A user who can change things through the UI
683
+ should not thereby get a raw publish box — the UI constrains them to topics and values that mean
684
+ something, and an arbitrary publish breaks things. Note it is gated *more* tightly than `ADMIN`
685
+ today: `server_config.user.id !== 1 ? null : …`, i.e. superuser only, with the comment "not really a
686
+ feature, more for testing and debugging". Moving it to `ADMIN` is therefore a widening, deliberate but
687
+ worth seeing plainly. **D-32**
688
+
689
+ **Nodes stays on `ADMIN`.** Its content is genuinely read-only — `nodesRestContent()` is a heading
690
+ and a table — so `READ` would be defensible. But a UI redesign is the wrong vehicle for widening who
691
+ can see an organization's device inventory; if that is wanted it should be its own decision. **D-33**
692
+
693
+ The **Info card is ungated**, and is where the connection details went when they left the header:
694
+ which broker, as which organization, and whether it is connected. Wanted once, not in the corner of
695
+ every screen — and the natural place for more of the same later, bridge status and so on. **D-43**
696
+
697
+ Because Info is always there the back is never empty, so the gear is offered as soon as an
698
+ organization is chosen. D-29's rule — omit the gear rather than open onto nothing — still stands and
699
+ is still implemented; it simply no longer triggers. **D-29**
700
+
701
+ **An admin card has a summary and a back, and no front.** There is nothing to watch at a glance in
702
+ an OTA uploader, so the two-stage open of a device card makes no sense here: the card is its name
703
+ until you open it, and opening it shows the whole function. Seven expanded at once is a wall of
704
+ forms. The content is built on first open and then kept, so moving between cards does not discard a
705
+ half-filled one. **D-41**
706
+
707
+ **API stays as it is.** It is a lightweight surface whose only purpose is developers poking at their
708
+ own API while testing. Wrap the existing markup in a card and change nothing else — no restyle, no
709
+ i18n pass, no responsive work. Spending design effort there is spending it in the wrong place. **D-30**
710
+
711
+ **Flash** is already designed in `FLASH_PLAN.md` and implemented as `mqtt-flash`; it becomes a card
712
+ without redesign. Note it is the one admin function that is *device*-specific as well as
713
+ project-level, so it also earns a link from a device's back under ADVANCED (§3.3).
714
+
715
+ ## 12. Out of scope for this design
716
+
717
+ Named so they do not creep in: multi-project dashboards on one screen, per-user server-side layout
718
+ sync, alerts/notifications, dark mode, historical CSV browsing beyond the existing graph panel,
719
+ grouping cards into user-defined sections, and the flash/provisioning UI (`mqtt-flash`, already
720
+ designed in `FLASH_PLAN.md`) beyond a link on the back's ADVANCED section.
721
+
722
+ ---
723
+
724
+ ## 13. Decisions — all confirmed 2026-08-28
725
+
726
+ D-1 … D-24 are settled as below and are inputs to the implementation plan, not open questions.
727
+ Reopening one means revisiting this document, not deciding it in code.
728
+
729
+ | # | Decision | Resolution |
730
+ |---|---|---|
731
+ | D-1 | Many cards open at once, or accordion? | Many |
732
+ | D-2 | Interactive controls on SUMMARY? | No — whole card is one tap target |
733
+ | D-3 | Actuators tappable on FRONT? | Yes — front acts, back configures. Controls stay read-only on the front |
734
+ | D-4 | Anything still collapsed on BACK? | Only ADVANCED |
735
+ | D-5 | Edit per-reading min/max on BACK? | Yes, where the topic is writable |
736
+ | D-6 | Literal flip on mobile? | No — full-screen sheet; flip on desktop only |
737
+ | D-7 | Summary as format string or twig list? | Twig list, defaulting to module `summaryText()` for now |
738
+ | D-8 | How to disambiguate two same-named readings? | Use the module's `name` ("Soil Temperature"), not "(DS18B20)" |
739
+ | D-9 | `precision:` or `width:` in `topics.yaml`? | `width:`, matching the node; decimals derived from `min`/`max` |
740
+ | D-10 | Out-of-range styling? | Yes, red value + clamped bar |
741
+ | D-11 | Graph in-card or shared panel? | Shared panel below the grid |
742
+ | D-12 | Free XY drag on desktop? | No — drag-to-reorder in a flow grid, both platforms |
743
+ | D-13 | ▲▼ buttons always visible on mobile? | Yes, alongside long-press drag |
744
+ | D-14 | Cookie or `localStorage`? | `localStorage` |
745
+ | D-15 | Auto-open FRONT for small projects? | Yes, ≤2 devices |
746
+ | D-16 | Detect a control module by name prefix or flag? | `climate` deleted ✔; `control: true` still worth adding for robustness |
747
+ | D-17 | Module structure on the FRONT? | None. Flat ordered rows; modules return on the BACK |
748
+ | D-18 | Actuator driven by a control — merge the rows? | No. Separate rows; the override UX is deferred (§14) |
749
+ | D-19 | Control row style on the BACK? | `[>] [ 32.0 ] ± [ 3.0 ]`, per `dashboard_example.html` |
750
+ | D-20 | Replace hand-coded `summaryText()`? | Yes, eventually — module-level `summary: [leaves]`, same mechanism |
751
+ | D-21 | Who declares FRONT content? | The device, as an ordered list of twigs and control-module ids |
752
+ | D-22 | Default FRONT when undeclared? | `graphable` readings, then actuators, then controls |
753
+ | D-23 | Units in the `languages` table or their own? | Their own table — `el()`'s filters actively reject `°C`, `%RH`, `m/s` |
754
+ | D-24 | Rename `MqttBar.get width()`? | Yes → `fillPercent`, to free the name for the schema's `width` |
755
+ | D-25 | Add a `WRITE` capability? | Yes, org-scoped, required for every change including an actuator tap |
756
+ | D-26 | Hide the whole back without WRITE? | No — show it read-only; omit only ADVANCED's actions |
757
+ | D-27 | Is client-side WRITE gating a security boundary? | No, and must not be described as one — all org users share one broker credential |
758
+ | D-28 | What becomes of `<mqtt-admin>`'s other tabs? | Cards on the project's back, reached by the project gear |
759
+ | D-29 | Project gear when the user can do nothing? | Omit the gear rather than open an empty back |
760
+ | D-30 | Redesign the API tab? | No — wrap the existing markup in a card, change nothing |
761
+ | D-31 | Capability dropdown contents | Add `WRITE` and `OTAFLASH` to the existing `OTAUPDATE`/`ADMIN`/`READ` |
762
+ | D-32 | Publish Message gating | `ADMIN` — not `WRITE`; note this widens it from today's superuser-only |
763
+ | D-33 | Nodes card gating | `ADMIN`, as today — do not widen access as a side effect of a redesign |
764
+ | D-34 | `devices.yaml` key matching | device id, then exact OTA key, then longest prefix of the OTA key |
765
+ | D-35 | Which topics need `width`? | `float` and `exponential` only — 25 topics; ints derive their field width from `min`/`max` |
766
+ | D-36 | Is a device `summary:` list required? | No — defaults to the first four entries of `front:`; a declared list is uncapped |
767
+ | D-37 | Module summary flag polarity | `summary: false` to opt out — opt-in would have flagged 23 of 33 modules |
768
+ | D-38 | A control on the summary line | A chip (`Relay ✓`) via `summaryShort()`, not its whole rule |
769
+ | D-39 | Battery on the summary line | The level icon alone; the percentage lives on its title and on the front |
770
+ | D-40 | An unwired control on the summary | Omitted — it is doing nothing. Still shown on the front |
771
+ | D-41 | Do admin cards have a front? | No — summary and back only; collapsed to a name until opened |
772
+ | D-42 | How the brand greens are used | A pale tint behind near-black text, never a fill under white — sunlight and dim sheds |
773
+ | D-43 | Where the connection details live | An ungated Info card on the project's back, not an expandable in the header |
774
+ | D-44 | How a module without a hand-written summary gets one | A generic default — its own readings, capped at two — not a list per module |
775
+
776
+ ---
777
+
778
+ ## 14. Deferred to a later step
779
+
780
+ Intended, but explicitly **after** the card UI switch is complete. Distinct from §12, which is not
781
+ planned at all. This list is expected to grow as the plan and the implementation turn things up.
782
+
783
+ | # | Deferred | Why it waits |
784
+ |---|---|---|
785
+ | L-1 | **Overriding a control-driven actuator** — tapping a relay a control loop is driving | Has its own UX thinking in progress; depends on L-2. Interim behaviour: the loop overwrites the tap (D-18) |
786
+ | L-2 | **Setting `manual` from the UI** | Node side exists (`Control_Sonoff`), UX side does not. Needs a reverse lookup `mt.drivenBy` — for an actuator, the control whose `out` is wired to it. Note `out.wired` holds a *set* path, so the comparison must normalise as `findTopic` does |
787
+ | ~~L-4~~ | ~~`control: true` in `modules.yaml`~~ | **Not doing.** Control modules start with "control"; if that ever stops being true, add the flag then |
788
+ | ~~L-5~~ | ~~`TODO-213` — a module declaring dynamically-added outputs~~ | **Already supported.** What remains is merging `manual` into `controlhysteresis` — non-trivial, not urgent, and part of L-1/L-2 |
789
+ | L-6 | **Per-user server-side layout sync** (§7.3) | **Unlikely.** It would mean server-side database changes to hold user preferences, which is not a direction worth taking for this; `localStorage` covers the common case |
790
+ | L-7 | **Real enforcement of `WRITE`** (§10.3) | A known, separate project — per-user broker credentials or a server-side publish proxy. Until it lands, the UI gating is decluttering only and must be described that way |
791
+
792
+ ---
793
+
794
+ ## 15. Open issues for the implementation plan
795
+
796
+ Not UX decisions — things the plan has to answer. **All resolved 2026-08-28**; recorded here with
797
+ the reasoning because the plan depends on them.
798
+
799
+ ### O-1 Where does the per-device `front:` / `summary:` config actually live? — RESOLVED
800
+
801
+ §4 says "org/project config, or a new `devices.yaml`" and that is not good enough to build from.
802
+ What the code says today:
803
+
804
+ - The org yamls hold **no** per-node config at all — `winam.yaml` is three lines.
805
+ - `p.nodes` in `/config.json` is built dynamically by `addLoggedNodesToConfig()` from
806
+ `mqttLogger.reportNodes()`, i.e. `{nodeid: lastseen}`. There is nowhere static to hang a layout.
807
+ - The **OTA key is a firmware-build identifier**, not a secret: binaries live at
808
+ `ota/<org>/<project>/<key>` (e.g. `ota/dev/lotus/sonoff_r4`), the device publishes it as
809
+ `ota/key`, and the admin form labels the field "OTA Key or Device ID".
810
+
811
+ **Resolution: a new `config.d/schema/devices.yaml`, keyed by OTA key** — sitting alongside
812
+ `modules.yaml` and `topics.yaml`. Keys match by **prefix**, so one entry covers an application across
813
+ every board it is built for; see §4.6 for the resolution order. Served in `/config.json` next to
814
+ `schema.modules` and `schema.topics`; the client reads `ota/key` off MQTT and looks the layout up.
815
+ This is exactly the brief's "a table per OTA-key" instinct, and it means **zero per-device admin** —
816
+ every device flashed with `sonoff_r4` gets the same cards. Server cost is small: load one more YAML
817
+ into `config.schema`.
818
+
819
+ *Rejected alternative:* the device declares its own layout in its discovery message. It puts a purely
820
+ presentational preference on the most constrained side of the system, costs flash and MQTT bytes, and
821
+ makes restyling a card require a reflash.
822
+
823
+ ### O-2 Does the card use Shadow DOM? — RESOLVED, and neither fork is needed
824
+
825
+ The concern was that `html-element-extended` presumes Shadow DOM, so light-DOM cards would mean
826
+ either an option flag in a library other sites depend on, or a second version kept in sync. Reading
827
+ the library, neither is necessary.
828
+
829
+ **`HTMLElementExtendedMinimum` already exists, is already exported, and does not attach a shadow
830
+ root.** The shadow root is attached in exactly one place — the `HTMLElementExtended` subclass
831
+ constructor:
832
+
833
+ ```js
834
+ class HTMLElementExtended extends HTMLElementExtendedMinimum {
835
+ constructor(props) { super(props); this.attachShadow({ mode: 'open' }); }
836
+ }
837
+ ```
838
+
839
+ The only thing stopping the Minimum class from rendering is that `renderAndReplace()` names
840
+ `this.shadowRoot` unconditionally — in **three places, all inside that one method** (lines 295, 297,
841
+ 301). Change them to `(this.shadowRoot || this)` and the library supports both, with no flag and no
842
+ fork.
843
+
844
+ **It is backwards compatible by construction:** every existing consumer extends
845
+ `HTMLElementExtended`, so `this.shadowRoot` is truthy and behaviour is byte-identical. Nothing in
846
+ `webcomponents.js` references `shadowRoot` at all, so the client side is free too.
847
+
848
+ So, per element:
849
+
850
+ | Element | Base | Stylesheet |
851
+ |---|---|---|
852
+ | `mqtt-devicecard`, `mqtt-devicegrid` | `HTMLElementExtendedMinimum` — light DOM | `frugaliot.css` on the page; **no `CssUrl` link node** |
853
+ | `mqtt-bar`, `mqtt-text`, `mqtt-toggle`, … | `HTMLElementExtended` — unchanged | as today |
854
+
855
+ That also removes, for the new elements, the "first node rendered is a link to a common stylesheet"
856
+ boilerplate — there are 18 such `href: CssUrl` nodes in `webcomponents.js` today.
857
+
858
+ **Correcting my earlier framing:** I implied drag-and-drop, CSS grid and a fixed-position sheet need
859
+ light DOM. They do not — pointer events cross shadow boundaries fine with `composedPath()`, and
860
+ `position: fixed` works inside a shadow root. The honest arguments are styling ergonomics (the notes
861
+ at the top of `frugaliot.css` are a standing record of the `:host-context` cost) and not duplicating
862
+ a stylesheet into every root. Worth being straight about, since this touches a shared library.
863
+
864
+ **Two plan consequences.** The change lands in
865
+ `~/git/github_mitra42/html-element-extended` — a repo outside the frugal-iot tree, to be called out
866
+ when edited. And the client depends on the *published* package (`^0.1.6`; the local repo and
867
+ `node_modules` copy are currently identical), so it needs a version bump and publish, or an `npm
868
+ link` while developing.
869
+
870
+ *Optional follow-up, not required:* the widgets that keep Shadow DOM could share one constructible
871
+ stylesheet via `adoptedStyleSheets` instead of a `<link>` per root — twelve devices is on the order of
872
+ 80 roots each parsing the same 10 KB. Needs a `<link>` fallback for Safari before 16.4.
873
+
874
+ ### O-3 Cutover, or parallel? — RESOLVED: a parallel page, not a flag
875
+
876
+ **A new page alongside `index.html`**, not a `layout=` attribute on `mqtt-project`. `index.html`
877
+ retires once the cards cover everything it does — the admin functions especially.
878
+
879
+ Worth being precise about what `index.html` currently *is*, because it is not the project UI: its
880
+ body is a single `<mqtt-admin>`, whose render puts a `tabbed-display` with tabs
881
+ Dashboard / OTA / Admin / Nodes / API, and tab 0 "Dashboard" contains the `<mqtt-wrapper>`. So the
882
+ project UI is nested inside the admin element, and the four non-Dashboard tabs are exactly the
883
+ functions that must be covered before `index.html` can go.
884
+
885
+ This is simpler than the flag: no branch inside `mqtt-project`, and the old path stays untouched
886
+ rather than modified, so there is nothing to regress while the cards are being built.
887
+
888
+ ### O-8 Where does the graph panel go on a phone? — RESOLVED: try the sheet
889
+
890
+ **What a "sheet" is** (it appears in §3.4 for the card back too): a panel that slides up from the
891
+ bottom edge and sits *over* the page, which dims behind it. It covers part or all of the screen, and
892
+ is dismissed by swiping it down, tapping the dimmed area, or a ✕. Every mobile OS uses them — the
893
+ iOS share sheet, an Android "open with" chooser — so the gesture needs no teaching. The reason it
894
+ suits us: it appears *where the thumb already is*, regardless of how far down the page you had
895
+ scrolled, which is precisely the "graph is eight cards below the bar you just tapped" problem.
896
+
897
+ **Resolution: on mobile the graph opens as a sheet; on desktop it stays a panel below the grid.**
898
+ Same mechanism as the card back, so it is built once. If it proves wrong in the hand it is a
899
+ contained change — the graph panel keeps working as a panel, so falling back costs a media query.
900
+
901
+ ### O-4 Is the card a new element, or a rewrite of `MqttNode`?
902
+
903
+ New element, `mqtt-devicecard`, bound to the same `MqttTopicNode` through the existing
904
+ `el.mt` / `mt.element` convention, extending `HTMLElementExtendedMinimum` per O-2. `MqttNode` and
905
+ `MqttGroup` are not modified at all — with O-3 putting the cards on their own page, the two never
906
+ coexist in one document, so `mt.element` has a single owner and the old path cannot regress while the
907
+ new one is built.
908
+
909
+ ### O-5 How is this developed and reviewed without hardware? — RESOLVED: yes, step 0
910
+
911
+ There is no test infrastructure — `npm test` is `echo "Error: no test specified"`. **A mock harness is
912
+ step 0 of the plan**: a `mock.js` that replays canned discovery and value messages into the data tree
913
+ with no broker, so every state can be put on screen in seconds — offline, stale, no readings yet,
914
+ out-of-range, control wired and unwired, one device, twelve devices. Cards cannot be built honestly
915
+ against a live broker that only shows the two or three states that happen to occur while watching.
916
+ This is the highest-leverage item in the plan and belongs before any card code.
917
+
918
+ **It must cover the existing UI, not just the cards.** The same canned message set drives
919
+ `mqtt-wrapper` → `MqttNode` → `MqttGroup` and the new card page, so the old UX has a regression check
920
+ for the whole of the build rather than being verified by hand at the end. Two consequences for how it
921
+ is built:
922
+
923
+ - The mock feeds the **data tree**, not the card, so it is layout-agnostic by construction — the
924
+ seam is `mqtt_subscribe`/`message_received`, below both UIs.
925
+ - Assertions need to be about rendered output, not internals, or they will not survive the refactor
926
+ they exist to protect. Snapshot the text content and key attributes of a node's rendered tree for a
927
+ given message set; a diff is then either an intended change or a regression.
928
+
929
+ This also makes the file split in the appendix safe: run the snapshots before and after, and a purely
930
+ mechanical split has to produce identical output.
931
+
932
+ ### O-6 Who fills in `width:` and `units:` across 54 topics?
933
+
934
+ Mechanical, but needs per-sensor judgement, so it is its own pass in the server schema, done *before*
935
+ the cards land — the cards look wrong without it, though they will not break (defaults exist).
936
+ Extend `check-schema.js` to warn on any displayable topic missing either.
937
+
938
+ ### O-7 Relative time in four languages
939
+
940
+ "2h ago", "3 days ago" and their plurals would mean a pile of new `languages` entries. Use
941
+ `Intl.RelativeTimeFormat` instead — available in every target browser, needs no translation entries,
942
+ and gets plural rules right in HI and ID where hand-rolling would not.
943
+
944
+ ### O-9 `index-embedded.html` is a third consumer, and constrains the split
945
+
946
+ It uses a bare `mqtt-client` plus `mqtt-bar` / `mqtt-toggle` with explicit topic paths — no
947
+ `mqtt-wrapper`, no project tree, no `server_config`. So `core.js` + `widgets.js` must work standalone,
948
+ which the §Appendix split allows but must not regress. Separately: `index.html` links
949
+ `/manifest.json`, which lives in the *server's* `public/`, so the client directory is not
950
+ self-serving.
951
+
952
+ ### O-10 Discovery order versus stored order
953
+
954
+ Cards arrive asynchronously as discovery messages land, so the stored order has to be applied per
955
+ arrival, not once at load, and a device with no stored position and no name yet still needs a stable
956
+ slot. Append in arrival order; re-sort only on an explicit drag. Unplanned, this is where the grid
957
+ flickers.
958
+
959
+ ---
960
+
961
+ ## Appendix — implementation notes parked for step 2
962
+
963
+ Not a plan; a scratchpad so nothing is lost while the UX is still moving.
964
+
965
+ **Schema changes.** The master copy is `../frugal-iot-server/config.d/schema/`. Edit there first,
966
+ then propagate outwards to the `../frugal-iot-logger/examples/*/config.d/schema/` copies — never the
967
+ other way round.
968
+ - `modules.yaml`: module-level `summary: true` now, `summary: [leaves]` later (D-20);
969
+ module-level `control: true` (D-16). **No `front:` key here** — it would duplicate `display:`.
970
+ - `topics.yaml`: `width:` (D-9); fill in missing `units:` (D-23).
971
+ - Device/project level (org yaml, or a new `devices.yaml`): `summary: [twigs]`,
972
+ `front: [twigs and control-module ids]`, optional per-entry `label:`.
973
+ - `scripts/check-schema.js` in the logger should learn the new keys.
974
+
975
+ **Splitting `webcomponents.js`** (5.7k lines, one file). Candidate split, as a mechanical
976
+ no-behaviour-change commit *before* any card work:
977
+ - `core.js` — mqtt connection & subscriptions, `MqttTopic`/`MqttTopicProject`/`Node`/`Group` data
978
+ tree, `server_config`, `el()`, i18n, `Watchdog`, helpers. Everything a headless dashboard needs.
979
+ - `widgets.js` — `MqttBar`, `MqttText`, `MqttToggle`, `MqttGauge`, `MqttSlider`, `MqttColor`,
980
+ `MqttChooseTopic`, `MqttGraph`, `MqttGraphDataset`. Shared by all UIs including
981
+ `dashboard_example.html`.
982
+ - `cards.js` — new: `mqtt-devicecard`, `mqtt-devicegrid`, layout store.
983
+ - `admin.js` — `MqttAdmin`, `MqttFlash`, `MqttLogin`, `TabbedDisplay`.
984
+ - `index.js` — the entry point `index.html` loads; imports the above and defines nothing.
985
+ - `dashboard_example.html` then imports `core.js` + `widgets.js` only, which is the separation the
986
+ brief asks for.
987
+
988
+ **Card ↔ data tree.** The card is a DOM element bound to an `MqttTopicNode`, following the existing
989
+ convention (`el.mt` / `mt.element`, pre-bind before `appendChild`). `MqttGroup` keeps its role as
990
+ the roll-up/`summaryText()` holder but stops rendering `<details>`; the card decides layout and asks
991
+ groups for their summary strings. `MqttNode.render()` is replaced by the card.
992
+
993
+ **Reuse, do not rewrite**: `summaryText()` per module, `mqtt-bar`/`mqtt-text`/`mqtt-toggle`,
994
+ `mqtt-choosetopic` for wiring on the back, `Watchdog` for online/stale/offline, the graph panel. The
995
+ control row on the back is already built in `dashboard_example.html`'s `wireUpDashboard` — lift it
996
+ rather than rewriting it (D-19).
997
+
998
+ **New getters needed on the data tree** (per the house rule that derived values are getters on
999
+ `MqttTopic`, not dashboard logic):
1000
+ - `mt.formatted` — the value rendered per `width` (D-9) plus its unit symbol (D-23).
1001
+ - `mt.frontRows` / `mt.summaryChips` on the node topic — the resolved ordered lists from §4.1/§4.2,
1002
+ so the card reads a list rather than re-deriving the rules.
1003
+ - `nodeMt.isControlModule(groupId)` — reading `control: true` rather than the name prefix, in both
1004
+ `MqttTopic.controlGroups` and the `frugaliot:controlgroup` dispatch in
1005
+ `MqttTopicNode.addGroupFromTemplate`. Deferred as L-4, so the prefix test stands for now.
1006
+
1007
+ **`manual` — node side exists, UX side does not.** The node implements it: `examples/sonoff/sonoff.ino`
1008
+ defines `Control_Sonoff extends Control_Hysteresis` with an extra `manual` OUTbool, where cycling the
1009
+ output sets manual and a long button press drops out of it. `topics.yaml` defines the topic and
1010
+ `MqttGroupControlHysteresis` observes it and renders "Manual". What is missing is any UX for *setting*
1011
+ it.
1012
+
1013
+ Note the `TODO-213` on that line — "handle case of valid topics but not in a module" — which is
1014
+ exactly why `manual` is in `topics.yaml` while no module declares it: `Control_Sonoff` adds the output
1015
+ dynamically in a subclass, so there is no module template for the schema to describe. Worth resolving
1016
+ alongside `control: true` (D-16), since both are about a module declaring what it actually publishes.
1017
+
1018
+ Setting `manual` from the UI is **deferred** (§14), so nothing above blocks the card work.
1019
+
1020
+ **Open technical questions**: whether cards keep Shadow DOM (styling across shadow boundaries is
1021
+ already awkward — see the notes at the top of `frugaliot.css`); whether the layout store belongs in
1022
+ `core.js` or `cards.js`; how the mobile sheet coexists with `position: sticky` header.