Skip to content

feat: Control Limits — let each display hide "parent" controls - #166

Open
mrramam wants to merge 4 commits into
jherforth:mainfrom
mrramam:feat/control-limits
Open

feat: Control Limits — let each display hide "parent" controls#166
mrramam wants to merge 4 commits into
jherforth:mainfrom
mrramam:feat/control-limits

Conversation

@mrramam

@mrramam mrramam commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

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:

controlLimits: { 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 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 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 displayControls.js reads except.

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:

"hideableControls": [{ "id": "editPoll", "label": "Create and edit polls" }]

The dashboard passes that display's hidden ids on the iframe URL as hide=, unprefixed, alongside the
theme, device and lang params already there. A plugin that ignores it is unaffected, and the
param 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

  • No server change. No migration. server/ is untouched.
  • Five core controls, all in the chores widget. Photo upload, prize management and plugin install are
    the obvious next candidates and are deliberately not included.
  • A display that remembers the admin PIN is exempt entirely, and says so in the form, since hiding
    parent controls on a screen a parent administers from is unhelpful.
  • A household default covers displays with no configuration of their own including a browser whose
    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 is
unit-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."

mrramam 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

1 participant