feat: Control Limits — let each display hide "parent" controls - #166
Open
mrramam wants to merge 4 commits into
Open
feat: Control Limits — let each display hide "parent" controls#166mrramam wants to merge 4 commits into
mrramam wants to merge 4 commits into
Conversation
added 4 commits
September 12, 2026 15:17
Every decision about which parent-facing controls a display offers, in one pure
module so it is testable: this project's vitest config is node-env and matches
`src/**/*.test.js`, so nothing rendered can be unit-tested and the logic has to
live somewhere that can be.
Storage is mode over snapshot:
{ mode: 'showAll' | 'hideAll', except: ['core:addChore', ...] }
`mode` governs every control NOT named in `except`, including controls that do not
exist yet. A stored list of hidden ids cannot express that, so "Wall display"
would have been a snapshot of whatever existed when it was clicked, and a plugin
installed next month would appear there until someone revisited the screen.
The cost is that `except` means the opposite thing in each mode: the hidden ids
under showAll, the surviving visible ones under hideAll. That inversion is written
down exactly once, in `isHiddenUnder`, and nothing outside this module reads
`except`. `toggleControl` derives it by asking that function rather than
restating it, so a second expression of the rule cannot drift from the first.
Decisions worth knowing:
- Nothing-stored stays distinguishable from explicitly-configured. Both hide
nothing today; only the first follows a household default that changes
tomorrow.
- A missing `mode` yields null rather than a guess, because guessing invents a
policy for every control nobody has enumerated.
- The Unlocked exemption requires a PIN to exist. Removing the household PIN
leaves `adminPinRemembered` behind on every device, and honoring a stale flag
would disable the whole feature household-wide.
- An unresolved PIN check exempts, deliberately: controls that render, vanish and
return read as a broken display. The obligation to resolve it is the caller's,
and the JSDoc says so.
- `editorState` answers what is CONFIGURED and cannot see the device blob or the
PIN status, so an exempt display still draws its real configuration. It does
take the control catalog, because a catalog is not in-force state and without it
the function could not name a plugin control, pushing the mode inversion back
out to every caller.
- Reads report whether the settings were actually read. Labelling a display we
failed to read as "inheriting" is a claim about configuration we do not have.
- Every failure path resolves to hiding nothing. Unreadable settings must not
strip a parent's buttons, and there is no attacker here to fail closed against.
91 tests. Each behavior above was mutation-checked: 30 deliberate breaks
attempted, 30 caught.
app.jsx resolves once and passes the result down. It keeps the raw device settings blob, because the hydrated views discard the keys this needs; resolves whether an admin PIN exists; and builds the known-control catalog from the core list plus each installed plugin's declared hideableControls. The PIN check retries four times over ~6s and then polls every five minutes while the answer is still unresolved, because an unresolved answer exempts every display that remembers the PIN and the only other trigger is an event this window's own Admin Panel fires — which nobody opens on a wall display. A kiosk that booted while the API was still starting would otherwise stay exempt until someone reloaded it. It never asserts `false` from a request that did not answer. Five guards in the chores widget, two of which are not simply "do not render": - suppressing both long-press menu items folds into the condition that opens the menu, so a long-press does nothing rather than opening an empty one - the avatar keeps rendering when clam redemption is hidden, losing only its handler and pointer affordance, because it is how a child sees their own balance An effect closes any dialog whose control disappears while it is open, each updater returning the previous state unchanged when already closed so it cannot churn renders. PluginWidgetWrapper appends `hide=` to the iframe src with that plugin's ids **unprefixed**: the `plugin:<id>:` namespace is core's storage concern and must not leak to plugin authors. Prefix-stripping lives in one helper, and the param is omitted entirely when nothing is hidden, so a plugin that knows nothing about this sees no change. Known and deliberate: transfer and snooze close their dialog before the PIN modal, so an action verified after its control was hidden still completes. The parent initiated it while the control was visible, this is visibility rather than a security boundary, and both sites carry a comment saying so.
A household default block and one card per display, each a preset picker over a
switch list. A switch ON means the control is SHOWN; storage is the inverse, so the
label reads the way a parent thinks.
Every switch position comes from `editorState`, which reports what is configured
rather than what is in force. That distinction is the whole reason the form works:
an exempt display hides nothing in force, so drawing the in-force answer in an
editable switch would make the form appear dead while its writes landed correctly.
Three states a row can be in, and none may impersonate another. A configured
display shows its preset. An inheriting one shows what it would inherit, read-only,
since editing it would silently un-inherit the display. A display whose settings
could not be read says so, draws no switch positions at all, and is read-only —
toggling from an unknown base defaults to showAll and would overwrite a restrictive
configuration nobody has seen.
A display that remembers the admin PIN carries a notice that its settings are saved
but not in effect, resolved per row from that display's own blob rather than only
for the screen the admin is sitting at.
Writes are guarded against losing a household's work:
- Reads fold in per row rather than replacing the map, keeping any row with a write
open or one that settled after the read began. Otherwise a read landing after a
write reverted the row in the UI while the server kept the change, and the next
toggle computed from the stale entry and wrote the loss back.
- Choosing a preset, or retracting to the household default, discards every
per-control exception. Both now confirm, naming how many switches would move,
counted from rendered positions so it names something the admin can check.
- Busy and error state are per row, not shared scalars, so one display's write
cannot re-enable another mid-flight or clear an error nobody has read.
- A failed write holds its request and reports in the dialog rather than in a card
that may be scrolled away, or absent entirely when the device list failed to load.
- "Keep full control here" is not offered when the current display's settings could
not be read, since the offer would PATCH over a configuration we never saw.
Plugin controls appear per plugin from `manifest.hideableControls`. Every manifest
value that reaches JSX is coerced to a string first: the server validates none of
them, there is no error boundary anywhere in this client, and an author writing
`"label": {"en": ...}` would otherwise take down the whole Admin Panel — including
the only page that can uninstall their plugin. Non-string control ids are dropped
rather than becoming `plugin:x:undefined`, which the id pattern accepts.
Each editor states whether controls added later default to on or off. Without it
the preset reads "Custom" for no visible reason when a wall display has every
control switched back on — correct, since that differs from Full control for a
control installed tomorrow, but unexplained.
Accessibility: switch groups carry `role="group"` and a label association so a
screen reader can tell which plugin a control belongs to, spinners are named, cards
carry `aria-busy`, and error alerts sit in live regions — marked presentational
inside, since MUI's Alert already carries `role="alert"` and two overlapping live
regions would announce one message twice.
Author-supplied plugin labels render untranslated, matching how declared plugin
settings already behave.
Two audiences, two documents. `guides/control-limits.md` is forty lines, because most of what a first draft wanted to say is visible in the admin panel already. What survives is what a reader cannot work out from the screen: set the household default first and promote the few screens you administer from, so a browser whose data was cleared arrives following your policy rather than fully capable; Wall display is a policy and not a snapshot, so a plugin installed next month is hidden there without anyone revisiting the screen; and a display that remembers the admin PIN ignores its limits while still saving them, which presents as a settings page that looks broken. The plugin-developer section in `guides/plugin-development.md` was written first and then rewritten, after an agent was handed it as its only specification and returned fifteen places it was insufficient. The worst were not omissions but misdirection: `hideableControls` was absent from the manifest reference table, which reads as exhaustive and is immediately followed by "validation is strict and loud", so an author would never learn the field exists and would assume an undocumented key gets rejected — it is silently ignored. The admin PIN was not mentioned at all, and a display that remembers it receives an empty hide list whatever is configured, which on a dev machine is the likeliest reason an author's own test appears to do nothing. It now also covers: retrofitting this onto a published plugin silently removes those controls from every display already set to Wall display, because the mode governs controls that did not exist when it was set; read the hide list at parse time or the control flashes on every iframe reload; other code holding a reference to a node you removed will throw, which is the one way adopting this can break a plugin; ids are stored verbatim so renaming one orphans every display's configuration; and the cheapest honest test is appending `?hide=...` to the widget URL yourself, which the first draft steered authors away from.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
Every display that opens the dashboard gets the same controls. A wall-mounted screen in the
kitchen offers Add Chore, chore transfer, snooze, prize approval, and tapping a child's avatar to
deduct clams to anyone walking past. The admin PIN gates the admin panel, not the widgets, so the
only options today are a PIN prompt on every action or nothing.
What this adds
Admin → Security → Controls on Displays. Per display, a list of switches over a preset. A
suppressed control is not rendered no prompt is offered in its place, because a prompt a parent
can satisfy is the old behavior with an extra step.
Ships inert: the default shows everything, so nothing changes until a household configures it.
This is visibility, not access control, and the UI and docs say so. The API has no per-device
authentication. What makes a limit stick is that Admin already sits behind the PIN; with no PIN set it
is decluttering, which is worth having but is not a lock.
The one design decision worth your attention
Storage is a mode, not a snapshot:
modegoverns every control not named inexceptincluding controls that do not exist yet. Astored list of hidden ids cannot express that, so "Wall display" would freeze the ids that existed when
it was clicked, and a plugin installed next month would appear on that wall display until someone
revisited the screen.
The cost is that
exceptmeans the opposite thing in each mode: the hidden ids undershowAll, thesurviving visible ones under
hideAll. That inversion is written down exactly once, inisHiddenUnder, and nothing outsidedisplayControls.jsreadsexcept.Plugins can participate
A plugin declares what a display may hide, and does the hiding itself — the host cannot reach into a
sandboxed iframe that may be a different origin:
The dashboard passes that display's hidden ids on the iframe URL as
hide=, unprefixed, alongside thetheme,deviceandlangparams already there. A plugin that ignores it is unaffected, and theparam is omitted when nothing is hidden. Documented in the plugin development guide, including the
parts that are easy to get wrong: read it at parse time or the control flashes on reload, and other
code holding a reference to a node you removed will throw.
Scope
server/is untouched.the obvious next candidates and are deliberately not included.
parent controls on a screen a parent administers from is unhelpful.
site data was cleared, which would otherwise mint a new display with everything available.
Testing
91 unit tests on the decision module, which is pure and holds every decision so that it can be tested
at all, the client vitest config is node-env and matches
src/**/*.test.js, so nothing rendered isunit-testable here. Every behavior was mutation-checked: 30 deliberate breaks attempted, 30 caught.
The React layer has no tests for that reason, and that is where every bug found during development
was. It was reviewed adversarially and walked in a browser twice, and has been running in production
on a real household since 2026-09-12. Six of the bugs found were data loss in the form, a read
landing after a write reverting a row, a preset click discarding every per-control exception without
confirmation, and an unvalidated plugin manifest value rendering as a React child and unmounting the
whole admin panel. All fixed before this PR.
One thing that may interest you independently of this feature: there are no error boundaries
anywhere in the client, so any component that throws takes the whole tree down. That is how the
manifest-value bug became "the admin panel is blank, and the only page that can uninstall the
offending plugin is the one that no longer renders."