067: The settings page is an overview, with a page one step in for each backend, kind and option
Status: accepted, 29 October 2026, after three rounds of independent review (see What changed after the reviews, after the second reviews and after the third review). The owner accepted each default in Open questions for the owner. Built: all 11 units of How it is built are live, 29 October 2026; what each did is in 067-results.md.
The prototype is 067-settings-page/prototype.html: one file, opened in any browser, made from bb-box’s own components. Its source is in 067-settings-page/prototype. How to click through it is under The prototype.
Context
The owner, on the page in production: “I think the settings page is really bad, both the UX and the UI. And we should take our time to design a very good settings page, befitting of this project.”
The page is BB’s Settings > BB Box. BB draws its header: the plugin’s icon,
name, description and on-off switch. bb-box draws everything below it, as
one settingsSection (src/app/settings.tsx and its settings-*.tsx
siblings). It reads config.get and writes the whole configuration with
config.apply against the revision it read (decision 029). The design
before this one is docs/design/settings.md.
Who uses the page, and for what
One person uses it: the operator of a BB install, who is often the only
user. Today that is the owner, every day. After bb-box is open-sourced it is
also a newcomer setting bb-box up for the first time. Agents do not use the
page; they use bb box config and bb box image, so the page must show
what they set.
The tasks, most frequent first. The order is estimated from the decisions’ history and the owner’s notes, not measured.
| # | Task | How often |
|---|---|---|
| 1 | See whether everything works, and why a box could not be made | Daily, and after each failure in a thread |
| 2 | Turn a backend, a kind or a Docker machine on or off for a while, such as a paid cloud backend | Weekly |
| 3 | Tune a kind: its sizes, warm boxes and spares | Weekly while tuning |
| 4 | Change a backend: a new template after a rebuild, a new Mac, a new token | Monthly |
| 5 | Rebuild images, or see which are due | Monthly |
| 6 | Set preferences once: default kind, boxes per thread, video, Monitor, tailnet, files for every box | Once, then rarely |
| 7 | Set bb-box up for the first time, or add a backend | Once per backend |
| 8 | Expert settings: device streaming, test device, export and import of the configuration | Rarely |
What is wrong today
Measured in the owner’s browser on 29 October 2026, looking only, at a viewport of 1440 by 1000. The screenshots are cropped to bb-box’s part of the page, and machine, host and thread names are replaced with generic ones.
- One long page with no shape. Ten sections of equal weight, 4,242 px tall, about four and a half screens. The most frequent task, “does it all work?”, has no answer at a glance: nine backend rows each say “Ready … checked 6 hours ago”, and one in trouble would be one amber dot among them. (top of the page)
- Machines are on it twice. The Machines section repeats the Boxes page’s Machines tab, with the same rows and actions, and it comes first, above the configuration.
- “Set up” on every backend, working ones included. It opens a form inside the list: 22 fields and a 25-line check report push the rest of the page three screens down, and the Save button sits far from the row it belongs to. (setup form, check report)
- The check report is hard to read. Each check’s name and detail run together (“Node Boxes run on pve-1”), and every passed check is shown in full, so a failure has to be found among twenty green ticks.
- Kinds are misnamed and dense. A kind’s row is named by its OS, so the Android emulator kind reads “Linux” and the iOS Simulator kind reads “macOS”. Rows list every backend in a sentence (“From Proxmox LXC and SSH host pool and Docker and Proxmox QEMU and Docker and microsandbox and Hetzner Cloud and Namespace”) and every size in another. Kind switches sit on the left, backend switches on the right. (kinds, more kinds)
- Docker on BB machines lists every machine. Six of its nine rows say “No Docker”, and it is a section apart from the Docker backends it makes. (Docker on BB machines)
- Four ways to save. Switches save at once. A backend’s form and a kind’s editor save with their own Save. The tailnet has a Save button of its own. Boxes per thread and the refresh interval save, silently, when the field loses focus. Nothing says “Saved”. (general, Monitor and tailnet)
- “General” is a drawer. The default kind, the thread limit, enrolment, video and the nightly rebuild share one card.
- Settings with no place. The test device, the iOS encoder, the
encoder ledger and projects’ images are in the configuration but not on
the page; only
bb box configreaches them. A backend cannot be renamed or removed from the page. - A change made elsewhere shows as a banner at the top of the page, far from the form, and asks the person to make their change again.
- No way to find a setting. The owner’s configuration holds over 300 values across 9 backends and 12 kinds. BB’s own search does not reach them.
What BB lets a plugin do here
A plugin’s settingsSection gets no props and no route of its own: it is
rendered below BB’s header on the one page /settings/plugins/bb-box
(Plugin SDK 0.6.15, and still so in 0.6.94). Anything deeper than one page
is the plugin’s own state. A navPanel, such as the Boxes page, owns a
route and its sub-paths.
Above the plugin’s section BB draws, on every view of the page: its “Plugin details” link, the plugin’s icon, name, description and on-off switch, any BB notice (such as “Machines cannot reach this bb yet”), and a “Configuration” heading. The page cannot hide or replace them.
Unit 0: the URL hash in a real BB
Both reviews asked for this before anything else. It was run on 29 October
2026 in a separate BB in a box: BB 0.48.0 from a fresh npx bb-app with
no account and its own data folder, bb-box installed from main
(aaee83ca), and a small probe plugin whose settings section reported the
hash, how many times it had mounted, and its own state. A script drove it
in headless Chromium. Each result is the URL and the probe’s values after
the step. The probe’s settings section and the script are in
067-settings-page/unit-0.
| Step | Result |
|---|---|
Open /settings/plugins/hashprobe#backend/x directly |
The hash is kept; the section mounts once and reads it |
history.pushState with BB’s router state kept, or with none, and location.hash = … |
The URL changes; the section is not remounted and keeps its state |
| Browser back and forward over those entries | Each returns to the right hash, inside the page, without a remount |
| Reload | The hash is kept; the section starts afresh, as expected |
| Leave by BB’s sidebar, then come back with back | The hash is back; the section remounts, and module-level memory survives |
BB’s UrlLink to the same page with another hash |
The URL changes, but no hashchange or popstate reaches the section |
UrlLink to bb-box’s settings with a hash, from another page |
The hash is kept and read on mount |
So the hash works for deep links, back, forward and reload, and no
fallback is needed. Three rules follow. The page moves between its own
views with pushState and its own listener, never with UrlLink. Links
from other parts of bb-box that may be on screen while the settings page
shows, such as the Monitor panel and the Box panel, use one shared helper,
openSettingsView, which pushes the hash and sends an event the section
listens to; the section also re-reads the hash when the window regains
focus or becomes visible. And anything that must outlive the section, such
as drafts, is kept outside React state.
A link to something that does not exist, such as #kind/no-such-kind or
a removed backend, shows “This kind is not here” with a link to the
overview, never a blank page.
Decision
1. One page in BB’s Settings: an overview, and a page one step in for everything else
The page stays where BB puts plugin settings, under BB’s own chrome. It is an overview with one page one step in for each backend, each kind, each group of options, Add backend, and setting up a backend. Every setting is at most one step from the overview, and nothing is on the page twice.
Each view has a hash, such as #backend/proxmox-lxc, #kind/linux,
#option/monitor, #add or #setup/tart. The Box panel, the Boxes page
and the Monitor link straight to a view. The browser’s back and forward
buttons walk the views.
Below BB’s “Configuration” heading, every inner view starts with one row: where you are (“BB Box settings › Proxmox LXC on pve-1”, the first part a link to the overview) and Find a setting. When the person came from another inner view, such as from a kind to one of its backends, the row also offers “Back to Linux desktop (GPU)”, and back returns there. The overview has Find alone, at full width.
On opening a view, the page moves focus to its heading. Going back puts focus on the row the person opened, and the scroll position where it was. After a new backend is saved, the setup view is replaced in history by the backend’s page, so back never returns to an empty setup form. (overview)
2. The overview, in order
| Part | At a glance | One step in |
|---|---|---|
| Find a setting | A field. Typing lists matching settings with where each lives and its value | The setting’s page, scrolled to it, focused and highlighted |
| Status | One honest line: how many backends are ready, how many are off or not checked since they changed, the kinds on offer, and how old the checks are, with Check all. With anything wrong, a callout lists each thing with its fix in words and Open | The page that fixes it |
| Backends | One row each: icon, name, host chip, one state line, an “Unsaved changes” chip when a draft is waiting, and the switch | The backend’s page |
| Kinds | One line each in three groups (Linux; macOS and iOS; Windows and Android): name and chips, default size, how many backends make it, warm and spare counts, and the switch | The kind’s page |
| Options | One row each, with its value: Boxes, Images, Devices, Network, Monitor (with its switch on the row), Configuration, and Your machines, which opens the Boxes page on its Machines tab | The option’s page |
The status line is never more certain than the checks. It says “All 8 backends passed their last check · checked 6 hours ago”, not “ready”, when the checks are older than an hour, and Check all becomes the main button. One backend checked a minute ago does not freshen the rest: the line gives the range (“checked between 4 min and 6 hours ago”).
The callout covers everything that can stop a box, not only failed checks: backends that fail or need attention, backends saved but not finished, the default kind turned off, kinds that backends that are on cannot make now, warm pools short for want of room, images whose rebuild failed, and Docker on a BB machine that answers but cannot run containers. (with problems)
Kinds that are off fold into “2 kinds off”, and kinds no backend you have makes fold, quietly and without amber, into “3 kinds no backend you have makes”. A kind switched off on this visit stays where it is, dimmed, until the person leaves the page. (a kind just turned off)
Machines leave the settings page: the Boxes page’s Machines tab already does all they did. Docker on BB machines joins Backends: a machine in use is a Docker row with a “BB machine” chip and its image build on its state line; machines that could host boxes are offered under Add backend and in one line under the list.
3. Each page
A backend’s page (page):
- Header: icon, name, host chip, description, “Takes new boxes” switch, and a menu: Rename…, Copy saved settings as JSON, Open the setup guide, and Remove backend…. Under it: “Saved changes reach new boxes; boxes already made keep theirs”, and the saving rule (section 4).
- Health: the same honest state as its row. “Last check passed · 6 hours ago, so it may have changed since”; “Not checked since it changed”, when its settings changed after the last check; “Not finished” when saved off with problems. Failed checks and warnings come first, each with its fix. Passed checks fold. With unsaved changes the button says “Check with my changes”, and the card says it checked the unsaved form.
- In use: boxes now; the kinds it makes, each a link; warm boxes, with “0 of 1 ready: no room” where the pool is short.
- The driver’s fields, in cards: Connection, Credentials, Boxes, then Shared iOS hosts and Advanced, both folded with a summary of what is set. A card opens by itself when it holds a problem, a conflict, an edit or the setting Find opened.
- Each field shows the first sentence of its help, with More for the rest. A blank field shows the value it takes inside the input, as a number that fits, and under it where that comes from: “Empty: 4096, the default”, “Empty: https://…, from Proxmox LXC”, or for a Mac “Empty: 10 cores, bb-box’s estimate for mac-1”. A template or image field that turns a kind off when empty has an Offer checkbox instead: unticked, it is saved as off, never as “use the default”.
- Tart’s Macs: one row each with its own switch, which acts at once. A row opens to its address, user, host keys and “Its own shared iOS host size”, with the effective value beside each blank field. Add Mac adds a staged Mac, saved with the form; Remove asks, with what follows (section 4).
A kind’s page (page):
- Header: name and chips, description, Make default, and the “Offered to threads” switch. The default kind’s switch is disabled everywhere, with “The default kind stays on. Choose another default to turn it off” and a link to the choice. If the configuration has the default kind off (an agent can do it), the page and Status say so as a problem.
- Sizes: Small, Medium, Large and Largest by cores, memory and disk, with the default chosen in its column. On a narrow screen each size becomes a block of three labelled fields with their units. (narrow, with an error)
- Where it runs: each backend that can make it, in the order a new box tries them, with how its boxes are made, whether it makes it now, its warm count, and any shortfall with its reason.
- Spare boxes: the count, and how many are ready or being made. On the device kinds it says spares are off by default and why.
Options’ pages:
| Page | Holds |
|---|---|
| Boxes | Default kind (a menu that acts at once); boxes per thread (a field); boxes join BB as machines and Linux desktops as video (switches); Files for every box |
| Images | Rebuild each night; the standard images, each with its age, why it is due, and what last night’s run did to it (rebuilt, waited for room, failed with why, skipped, not started); Rebuild what is due with its progress; projects’ images with their kinds, projects, source, script, check script, when they are built ahead, and Remove… |
| Devices | iOS video encoding; share each Mac’s encoder fairly; links to the shared iOS hosts on each backend and to the spares of each device kind; the test device |
| Network | The tailnet: whether machines join (two radio choices), tags, OAuth client ID and secret, saved together |
| Monitor | Machine status (also on its overview row), refresh every, background monitoring, pressure notifications; after the switch changes, “The sidebar’s machine status changes when BB next loads” with Reload BB now. The Monitor panel links to this page |
| Configuration | The revision and what happens when two people save; Export (no secret in it, and which secrets must be pasted again to set BB Box up from it); Import, which checks a pasted file and lists what it changes against the current revision before Apply; the copy of BB’s earlier settings and Remove copy…; the bb box config commands |
Add backend (page shows its entry): Docker on your BB machines first, as it is ready now, with machines whose Docker fails shown as “Cannot host boxes” with the reason and Check again; then every backend, grouped by where boxes run, each saying what it makes and what it needs. A backend that cannot be added twice says “Added: open it”.
Setting up a backend (page): the backend’s form in four numbered steps: 1, Prepare: the guide and its scripts in the order it runs them; 2, Connect: connection, credentials and Macs; 3, Choose what it makes, with “It will make …” kept up to date as Offer boxes change; 4, Check it: “Runs all of Proxmox LXC’s checks against this form, as it will be saved, up to 30 s. Nothing is saved.” The bar offers Save and turn on, Save, keep it off, and Discard and go back. Saving lands on the backend’s page. A backend saved off with problems shows as “Not finished”, with what is missing, on its row, in Status, and in first run. (not finished)
4. Saving: one rule, and a merge that never loses anyone’s change
The rule, shown under each page header that has a form: “Switches, and menus that stand alone, act at once. Fields, checkboxes and radio choices wait for Save.”
- A switch is an independent decision and acts at once: a backend or a kind on or off, a saved Mac on or off, an option such as video or background monitoring. A menu that stands alone acts at once too: the default kind, iOS video encoding, when a project’s image is built ahead. Each shows “Saved” beside it for a moment, only once BB Box confirms it. A refusal, a lost request or three refusals in a row put it back and say why under its row.
- Everything that belongs together is a form with one explicit Save: a backend’s fields, a kind’s sizes with its default size, warm counts and spares, the tailnet’s choice with its tags and credentials, a new Mac with its fields and its “Takes boxes” checkbox, a file’s path with its content. In a form, on-or-off values are checkboxes and choices are radio buttons, never switches, so a switch always means “at once”. (the tailnet waits)
- The first change in a form brings up a bar: “2 changes to Proxmox LXC on pve-1 not saved”, with Discard and Save, and Check on a backend’s page. Enter in a field saves. A changed field has a “Changed” chip. Discard puts back the saved values of the fields changed here; switches already turned stay as they are. In setting up a backend Enter does nothing, as whether to turn it on is the person’s choice.
- Removing something asks first and says what follows: a backend (its secrets are deleted and cannot be got back, its warm boxes go, there is no undo, and it is refused while it has boxes); a Mac (staged until Save, refused while it has boxes); a file; a project’s image; the copy of BB’s earlier settings. (remove a backend) If the removal is refused, the dialog closes and the reason shows where the person is: “Namespace was not removed: …”.
- Every write goes through one path with the same outcomes: a form’s Save, a switch, a standalone menu, Rename, a removal, a project image’s change, an import and the copy’s removal. None says “Saved” or closes quietly without BB Box’s confirmation.
The merge. Agents change the configuration while the page is open, with
bb box config apply and bb box image set, so the page must never send
back a value it read before someone else changed it.
- A draft is a set of edits, each a setting’s path, such as
backends/proxmox-lxc/fields/node, with the value typed and the stored value it started from. A “part” is the set of paths the form writes, not a section of the document: a kind’s form writes its sizes and each backend’s warm count for it. - The page reloads the configuration on bb-box’s change signal, as today. A field nobody edited here simply shows the new stored value. A field edited here that also changed elsewhere is a conflict, shown on its row at once, not at Save: “Changed elsewhere to 8192 while you edited”, with Use theirs and Keep mine. Each acts on that field only; every other edit stays. Save waits until each conflict is chosen. (conflict on the row)
- Secrets are never compared by value, which the page never has. The
server gives each secret an opaque change version, the revision that
last set it, with
{ set, version }. A secret replaced here whose version moved since the edit began is a conflict: “Replaced elsewhere while you edited, at revision 216. Saving yours replaces that one”, with Keep the saved one and Keep mine. (secret conflict) - Every edited path has the same controls, a Mac’s own fields and a file’s content included: a file being replaced whose content changed elsewhere since the edit began is a conflict like a secret’s. The bar’s Show opens what holds the first conflict or problem, such as a folded card or a closed Mac, scrolls to it and focuses it. (a Mac’s field)
- Save sends the edits applied to the latest configuration the page has, with that configuration’s revision. When the server says someone saved first, the page reloads, checks the edits against the new values, and either saves on the new revision (“Saved at revision 216, on top of a change made elsewhere”) or stops with the conflicts on their rows. It never retries with a revision it has not checked. After three refusals in a row it stops and says so: “Not saved: the configuration changed three times while saving. Your changes are still here”.
- A refusal keeps the draft: “Not saved: … Your changes are still here”, with Save again. (refused)
- No success without proof. Each write carries an operation ID that the
page makes.
config.applyrecords the ID with the revision it produced, replies with it, and lists its receipts inconfig.get. A write sent again with the same ID is never applied twice: the server returns the first receipt. The receipt is stored in the same write as the document, and holds a hash of the submission; the same ID sent with a different submission is refused, never confirmed. Receipts are kept for 24 hours and at least the last 100. A pending operation older than that is never sent again: its edits become an ordinary draft, checked against the stored values. - An operation freezes what it submits. Save takes a copy of the part’s edits at that moment, the submission, and binds the operation ID to it. Every retry rebases that frozen submission on a freshly loaded document, never on what the editor holds now. Typing after Save is a separate draft: it is not sent by this operation, and it needs its own Save, which is a new operation with a new ID. After an unknown outcome, Save again resends the frozen submission with its first ID, and newer typing waits for the next Save.
- An unknown outcome is called neither a failure nor a success.
config.applystores the document before it replies, so a lost reply may hide a save. The bar says “Save not confirmed: BB Box did not reply. Looking for this save’s receipt…” and reloads. With the receipt, it says “Saved at revision 215. The reply was lost; BB Box’s receipt for this save confirms it”. Without one, it says “Save not confirmed: BB Box has no receipt for it yet. Your changes are still here. Saving again resends the same request, which BB Box applies at most once”. A changed secret version is never taken as proof: it only shows that someone set the secret. (receipt, then a racing change, no receipt) - After a confirmed save the page says what changed elsewhere since: “Since then, changed elsewhere: API token secret (revision 216)”, so a person whose save landed and was then overtaken knows it.
- “Saved” belongs to what was sent. Each edit sent is cleared only if it is still what was sent; typing during a save stays a change, for secrets and files too: “Saved. Your newer change is not saved yet.” The comparison is made against the draft registry’s current edits (see section 5), never against a copy held by the page that pressed Save. (newer secret typing)
- Check and Save build the same candidate: the latest configuration with every edit applied, a Mac’s fields and the presence of a new secret included. A check’s result is bound to that candidate’s fingerprint (the revision and a hash of the edits) and cleared by any later edit: “Your form changed after you checked it, so that result was cleared”. (check cleared)
- New things have stable draft identities until saved, never their
names: a new Mac is
backends/tart/hosts/@new-k3x9, a new filefiles/@new-p2m4. Typing a new Mac’s name only changes a field. - A provisional identity becomes the saved one when its save is
confirmed. The submission records which name or path each provisional
identity was sent as. On the receipt, the registry maps
hosts/@new-k3x9tohosts/review-macat the receipt’s revision, and moves any edits kept from newer typing to the saved identity, with that revision’s stored values as their base. A second Save then updates the Mac it created, through the same conflict checks as any saved item, and never creates another. - Names and paths cannot collide. A new Mac named like one already on the backend is refused at its field: “mac-1 is already a Mac on Tart”. A new file given the path of an existing file becomes a replacement of that file, with its content version as the base, so a change made elsewhere is a conflict like any other: “~/.cfg/a.json is already a file here. Saving replaces it”. An empty path is refused at its field. The server’s own checks stay, and refuse the same things.
- A problem the server reports names the path it was sent as, such as
backends/tart/hosts/review-mac/fields/ios-hosts-cores. The page resolves it through the same map to the draft identity, so Show opens the new Mac, scrolls to its field and focuses it, and the error sits under that field.
5. Drafts survive everything
A draft lives outside the page’s components, so it outlives them.
- Unit 0 showed the section remounts when the person leaves by BB’s
sidebar, and that module memory survives. Drafts are kept there, and
each part’s non-secret edits are also kept in
sessionStorage, so a reload keeps them too. - Secrets and file contents are kept in memory only: they never go into browser storage, which other plugins on the page can read. After a reload the page says so: “Your unsaved changes to Proxmox LXC on pve-1 are back: 1 change. For safety the API token secret you pasted was not kept; paste it again”, with Keep them and Discard them. (restored after a reload)
- Leaving a page with a draft does not ask: the draft waits, and the overview marks its row “Unsaved changes”. (unsaved chip) Closing or reloading the tab with a draft asks first, through the browser’s own warning.
- The same holds for every editor, through one draft registry: a backend, its setup, a kind, Boxes, Monitor, the tailnet, per-Mac fields, a file being added or replaced, and an import being checked. A file’s path is kept in session storage and its content in memory only; after a reload the page says “the content of ~/.cfg/a.json you pasted was not kept; paste it again”. A draft is cleared only by a confirmed save or by Discard. A refused file save keeps the editor open with its text.
- An import’s raw text may hold secrets: a pasted configuration can carry tokens, keys and file contents, and text that does not parse cannot be checked for them. So the raw text is kept in memory only, like a secret, and never goes into browser storage, valid or not. After a reload the page says “The configuration you pasted was not kept, as it may hold secrets; paste it again”. Only a form that has passed the schema check, with every secret and file content taken out, may be kept, and only to say what the import would have changed.
- The registry owns pending operations as well as edits. Each part’s entry holds its edits and, while a save is out, that save’s operation ID and frozen submission. The completion runs in the registry, not in the page that pressed Save, so it still applies if the person left and came back, and the page they return to shows the save’s state from the registry: “Saving…”, “Save not confirmed” or the result. The completion compares the submission with the registry’s edits as they are then, and clears only the edits confirmed saved. Every editor works this way, a file and an import included: an import applied while its text was edited again keeps the newer text, “Applied. Your newer text is not applied yet”.
- On a reload, the pending operation’s ID and the non-secret part of its submission are kept with the draft, so the page looks for the receipt and says either “Saved at revision 215” or “Save not confirmed”. If the submission held a secret or file content, it cannot be sent again as it was; the page asks for it again and the next Save is a new operation. (file kept, import back after a reload)
6. First run stays until something works
First run shows until a backend that is on makes at least one kind, not
merely until a backend exists. That moment is recorded once, as “setup
done” in bb-box’s storage, so first run is about setup, not about today’s
availability: an established install whose backends are all off or all
failing keeps its overview, with “Every backend is off” or each failure in
Status, never the setup card again.
(all off) It keeps today’s guidance from
first-run.tsx, word for word where it is good:
- Docker on BB’s server is usable: “Use Docker on this machine”, with the build’s progress, then “Docker on this machine is ready”, what to do next, and Status saying “Docker on this machine is ready” rather than “All 1 backend ready”.
- Docker is missing: the command to install it, Check again, and a BB machine that has Docker, when there is one. (missing)
- Docker is there but BB’s user cannot use it: the command to add it to the docker group, the restart line, and Check again. (no access)
- Too little disk, a failed build, or machine access BB cannot reach: today’s words, their fix and Check again or Try again.
- A backend saved but not finished: “Finish setting up Proxmox LXC”, with what is missing, stays in the card. (resume)
- Kinds and Options fold under “Sizes and options … for later”. No amber “Not offered” appears for kinds no backend makes, nor for kinds a backend was set up not to make: Use Docker on this machine makes Linux only, and its empty desktop images are a choice, not a fault.
- Problem cards are titled for the problem: “bb-1 has no Docker”, “BB cannot use Docker on bb-1”, “Not enough disk on bb-1”. (first run)
7. States of each part
| State | A backend’s row and page | A kind | Status |
|---|---|---|---|
| Off | “Off · takes no new boxes” | “Off: no new boxes”, dimmed | Counted as off |
| Not finished | “Not finished” and what is missing | — | Listed, with Open |
| Not checked yet | “Not checked yet” | — | Counted |
| Not checked since it changed | “Not checked since it changed”: its settings changed after the last check; the check is bound to the configuration it tested | — | Counted, and Check all becomes the main button |
| Checking | “Checking…” with BB’s spinner, up to 30 s | — | “Checking 8 backends, up to 30 s each, side by side…” |
| Last check passed, over an hour ago | “Last check passed · 6 hours ago” | — | “passed their last check”, with the range of ages |
| Ready | “Ready”, checked within the hour | Its summary | “All 8 backends ready”. Never “ready” for a pass over an hour old: “6 of 8 backends passed their last check”, “Docker on this machine passed its last check”. When every check has the same age, rows drop it and Status gives it |
| Needs attention | Amber, with the warning | — | Listed with its fix |
| Not working | Red, with the failure | “Not offered now: its backends cannot make it” | Listed with its fix |
| Warm pool short | “0 of 1 ready: no room” in In use | On the kind’s page with the reason | Listed |
| Building images | The step on the state line | — | — |
8. Keyboard, screen readers and narrow screens
- Every row, link and button has a visible focus ring, as the Boxes page does. (focus)
- Every switch has a name of its own: “Linux desktop (GPU): offered to threads”, “Proxmox LXC on pve-1: takes new boxes”, with its row’s state as its description. Kinds are named in full everywhere, never by a chip alone.
- Help and errors are tied to their field with
aria-describedby, the size grid’s cells included; a field with a problem is marked invalid. On a refused save the first invalid field is opened, scrolled into view and focused; the bar’s Show does the same. - Every name is unique: each default-size radio says its size (“Small is the default size”); help links say what they open (“More about TLS fingerprint”); a Mac’s fields carry the Mac (“Cores of a shared iOS host on mac-1”).
- Saving, conflicts and Find’s result count are announced through polite live regions. A secret’s buttons say what they do: “Replace the API token secret”.
- Pages change in place, with no slide, by pointer or keyboard (the owner removed the slide on 29 October 2026).
- Below 640 px each setting stacks its label over its control, inputs take the full width, the size grid becomes blocks, the header’s controls wrap under the title, and the save bar wraps its buttons under its words. (narrow backend)
9. Find a setting
Find is in the top row of every view. Its index is built from the drivers’ field descriptors and the option pages, not kept by hand: every field of every backend, each Mac’s own fields, each kind’s sizes, largest size, spares and warm count on each backend, and every option. Each word of the query must match the setting’s label, place, help or key, or a synonym added beside it (memory and RAM, cores and CPU); words are not replaced. Synonyms are matched against labels only, so “RAM” finds memory settings and not every help text that says memory. Results show each setting’s value. Arrow keys move, but never past the results shown; Enter opens, Escape clears, and the count is announced, with “the first 40 are listed” when there are more. Opening a result scrolls to the setting after the page has settled, opens its folded card or its Mac, focuses it, and highlights it for a moment, on another page or on the same one; the query stays in the field when the person comes back. Find does not take the browser’s own Find shortcut. (from the same page) (Find, opened a Mac’s field)
10. Words
Headings, labels and help are plain English and short, in sentence case.
| Today | Becomes | Why |
|---|---|---|
| Configuration, over everything | BB’s own “Configuration” heading only | BB draws it |
| Set up, on every backend | The row opens the backend’s page; “Set up” only for a new backend | A working backend is not being set up |
| Needs setting up | Not finished | It was saved part way, on purpose |
| Ready · checked 6 hours ago | Last check passed · 6 hours ago | It may have changed since |
| Needs attention, for a failure | Not working (fail), Needs attention (warning) | Two different things |
| Linux, for the Android emulator; macOS, for the iOS Simulator | The kind’s name, in full: Android emulator, Linux desktop (GPU) | The OS is not the kind |
| From Proxmox LXC and SSH host pool and Docker and … | “6 backends”, named on the kind’s page | Scannable |
| Backends that can make it, first with room first | A new box goes to the first backend in this list with room | Says the order |
| iOS video auto · encoder shared | iOS video: automatic | Plain |
| No backend you have can make it. Add one of: proxmox-qemu | No backend you have makes it. Proxmox QEMU does. Add a backend | Labels, not IDs, and a way on |
| A save made from an older revision is refused, so two people never overwrite each other unseen | Each save names the revision it was made from. When someone saved first, the page checks your changes against theirs | Says what happens now |
| Enrol boxes as BB machines | Boxes join BB as machines | Plain |
| Refresh interval | Refresh every … s | The unit is beside the number |
| All 1 backend ready | Docker on this machine is ready | Names it |
The drivers’ help text is part of the build (unit 4): each field’s first sentence must stand alone, and stale words go, such as Tart’s “ios boxes are not offered yet” on a page that offers them. The owner approves the new words with that unit.
11. Look and motion
The page uses BB’s tokens and bb-box’s own parts, as the Boxes page and the
Monitor do: hairline cards, BB’s status dot and chips, one state line per
row, BB’s button, switch, input, select, menu and alert dialog from
components/ui, and StatusDot, Meta and Badge from
src/app/common.tsx. Motion is quiet: pages change in place with no slide
(the owner removed it on 29 October 2026); the save bar rises 8 px over 200 ms; “Saved” fades
after 1.6 s; a found setting’s ring fades over 1.4 s. Typing, switching
and keyboard actions do not animate.
12. How it is built
Kept from today’s code: useLive("config") and the change signal;
config.get, config.check with test, config.apply with the
revision; the helpers in src/app/config.ts; FieldControl‘s parts;
TailnetSection, BoxFilesSection, RebuildNow with its run states,
FirstRun and SetupProgress with their guidance; the Docker hosts’
hostState, buildWords and dockerFacts; sparesWords and
warmShortWords; and Report, restyled.
New in the app: SettingsView (hash routes, the top row, focus and scroll
restore), Overview, BackendPage, KindPage, the option pages,
AddBackend, SetupBackend, FindSettings with its index; and the
editing engine: the draft registry, which owns each part’s edits and its
pending operation with the frozen submission, the map from provisional
to saved identities, and draft storage with its rule that secrets, file
contents and raw import text stay in memory; useDraft over it (paths,
merge, conflicts, secret versions, checked retries, lost-reply
reconcile); useInstant, SaveBar, ConflictNote and RestoredNote.
Changes on the server, all small:
- Operation receipts:
config.applytakes an operation ID, applies it at most once, stores the receipt in the same write as the document with a hash of the submission, refuses the same ID with a different submission, returns it with the revision, andconfig.getlists the receipts of the last 24 hours, at least the last 100. - A “setup done” marker, set the first time a backend that is on makes a kind, so first run never returns to an established install.
- Each secret in
config.getcarries an opaque change version, the revision that last set or cleared it ({ set, version }), never reused;config.applysets it for each secret it replaces or clears. No value ever leaves bb-box. - A backend’s state carries the revision its configuration last changed at, and each doctor report the revision it tested, so “not checked since it changed” is exact.
- A field group
ios-hostsfor the shared iOS host fields of Tart, the SSH host pool and Namespace. draftFieldskeeps an explicit empty value for fields where empty means off, instead of dropping it and so restoring a default.- An RPC
config.migrated.remove, asbb box config migrated --remove. - For live checks only, a test switch that makes
config.applydrop its reply after storing, so a lost reply can be shown on a real BB.
Build units, each one PR with its tests, a keyboard-only pass, screenshots
at 1440, 760 and 390 px beside the Boxes page, and a live check on a
separate BB in a box: a fresh npx bb-app with no account, bb-box
installed from the unit’s branch. Until a unit lands, today’s form for
that part stays reachable from its new page, so no release loses a way to
edit anything.
| Unit | Change | Live check |
|---|---|---|
| 0 | The hash test | Done, above |
| 1 | SettingsView, the overview’s rows, switches and honest Status, Options rows with the Monitor switch, Machines as a link to the Boxes page’s Machines tab, the hash and focus rules; each part’s page opens today’s form for it, unchanged |
Deep links, back, forward, reload, leaving by BB’s sidebar; a link from the Monitor panel while settings show another view; a link to a kind or backend that does not exist; switches refused, saved and refused three times; keyboard to every row |
| 2 | The editing engine on the two simplest pages, Boxes and Monitor, and the shared write path for every switch, menu and removal: the draft registry with pending operations, frozen submissions, provisional identities, merge, conflicts, checked retries, receipts, draft storage and its memory-only rule; the server’s secret versions, operation receipts and the test switch | An agent’s bb box config apply while a draft is open; two tabs saving the same field and different fields; a lost reply after a store, with a secret replaced in another tab before the page looks; a request that never arrives, then Save again applied once; the same request sent twice at once, applied once; a late reply after leaving the page; a receipt aged out, said as not confirmed; three refusals in a row for a form and for a switch; typing during a save, a file included; a reload with a draft; gates G1 to G5 below |
| 3 | The backend’s page on the engine: health card with check revisions, facts, field cards, help one step in, effective values, explicit off, Macs with their own sizes, the menu and confirmed removal; ios-hosts group |
A Docker backend edited, refused and saved; a secret replaced in two tabs; a Tart backend with a real Mac’s own sizes on a Mac box, a Mac added by name, and Check with a Mac’s change; a check cleared by a later edit; remove refused with boxes and confirmed without; gates G1 with a secret, G6 and G7 below |
| 4 | The drivers’ help text, trimmed for the owner’s approval | The pages read in full |
| 5 | Docker on BB machines folded into Backends and Add backend, with “Cannot host boxes” | A second BB machine in a box with Docker, used, broken (no docker group), fixed, turned off |
| 6 | The kind’s page, the default-kind rule everywhere, kinds staying in place | A kind edit racing a warm-count edit in another tab; the default kind’s switch on both surfaces |
| 7 | First run, Add backend and setting up a backend | A fresh BB with Docker missing, without access, with too little disk, and ready; a Linux-only Docker setup with no warning about desktops; a backend saved on in one go with its secret; a backend saved off, resumed and turned on; every backend turned off afterwards, without first run returning |
| 8 | Images, with every run state and projects’ images | A run with a waiting and a failing image; a project’s image removed |
| 9 | Devices and Network | The tailnet’s choice and credentials saved together and refused part way |
| 10 | Configuration: export, import with its change list, config.migrated.remove |
Export, change, import against a moved revision; gates G8 and G9 below |
| 11 | Find | Searches across every field, a Mac’s own field, a kind’s size, several words, no match, from the overview and from the target’s own page, with the setting on screen each time; arrow keys at the end of the list; keyboard only |
Named gates. These are the third review’s reproductions of the saving engine’s edge cases. The prototype fails them (see The prototype); each unit passes its gates on a separate BB before it lands, and before any later unit moves another page onto the engine.
| Gate | Unit | Steps | Passes when |
|---|---|---|---|
| G1 retry-submitted-version | 2, and 3 with a secret | Set a field to A and Save; make an independent write so the save retries; type B while it retries; let the retry finish. Repeat with a file’s content in unit 2 and a secret in unit 3 | A is stored; B is not; B shows as an unsaved change; the bar names only what changed elsewhere |
| G2 new-file-overwrites-existing | 2 | Add a file with the path of an existing file; change that file’s content elsewhere; Save files | A conflict on the file, with both choices; nothing is replaced until one is chosen |
| G3 new-file-followup-conflict | 2 | Add a new file and Save; type newer content while it saves; change the file elsewhere; Save again | The second Save meets a conflict on the saved file; no edit keeps an @new- identity after its save is confirmed |
| G4 save-leave-return-edit | 2 | On Monitor set refresh to 12 and Save with a lost reply; leave through BB’s sidebar; come back and type 15 before the save finishes; leave and come back again | 12 is stored once; 15 is still shown and still an unsaved change; the page shows the first save’s state |
| G5 file-save-across-remount | 2 | Replace a file’s content and Save; leave and come back while it saves; type newer content | The editor stays open with the newer content, marked unsaved |
| G6 new-mac-two-saves | 3 | Add a Mac named review-mac and Save; set its cores to 8 while it saves; Save again | One review-mac with 8 cores; the second Save updated it |
| G7 new-mac-invalid-reveal | 3 | Add a Mac with 1 core and Save; then a Mac named like an existing one | The first is refused with the error under the new Mac’s cores field, and Show opens that Mac and focuses the field; the second is refused at its name field |
| G8 import-newer-typing | 10 | Check an import that sets refresh to 15; Apply; change the text to 20 before it completes | 15 is stored; the editor stays open with 20, “Applied. Your newer text is not applied yet” |
| G9 import-secret-storage | 10 | Paste an import holding a made-up token, a private key, a file’s content, and, separately, text that is not valid JSON; reload without Check or Apply | None of the pasted text is in session or local storage; the page asks for the text again |
docs/design/settings.md is rewritten to this design in unit 1, and the
reference and the skill name any new words.
Every setting, and where it goes
Nothing is lost. The left column is how the configuration names it.
| Setting | Today | New place |
|---|---|---|
| BB Box on or off | BB’s header | BB’s header, unchanged |
First run (setup.offer: Use Docker on this machine, missing Docker, no access, too little disk, failed build, machine access, a remote machine with Docker) |
Top of the page | Overview, “Set up BB Box”, until a backend that is on makes a kind, with every one of today’s states (section 6) |
| Image build progress after first run | Top of the page | Overview’s first-run card, and the Docker backend’s state line |
| Your machines (036): list, add, start, stop, resize, remove, open desktop and terminal, copy address, threads on each | Machines section | The Boxes page’s Machines tab, which already has them all through the shared MachineRow; the Options row opens that tab, empty or not |
backends[].enabled |
Backend row switch | Backend row switch, and the backend page’s header |
backends[].label |
Not on the page | Backend page, menu, Rename… |
backends[].id and adding another of a type |
Not on the page, but for one row per type | Add backend; a type that allows several, such as Docker and microsandbox, can be added again |
| Removing a backend | Not on the page | Backend page, menu, Remove backend, when it has no boxes |
backends[].fields |
Set up area | Backend page, by card; every field is listed below |
backends[].hosts (Tart’s Macs: name, on or off, fields) |
Set up area, Hosts | Backend page, Macs |
backends[].warm (warm boxes per kind), and shortfalls for want of room |
Kind editor, Warm boxes, with “Has 0 of 1 ready: no room” | Kind page, Where it runs, with the shortfall and its reason; the backend page’s In use card; Status |
Docker on BB machines (docker.host.set), with its image build, offline and failing states |
Its own section, every BB machine | Backend rows for machines in use, with the build and offline state on the state line; Add backend for machines with Docker, and “Cannot host boxes” with the reason for Docker that fails; one line for machines with no Docker; docker.host.set still does the work, images included |
| Doctor: Check, last report, notes | Set up area | Backend page’s health card, bound to the revision it tested; Check all and the callout on the overview |
| Setup guide and scripts | Set up area, top | Setting up a backend, step 1; the foot of the backend page; its menu |
kinds.<id>.enabled |
Kind row switch | Kind row switch, and the kind page’s header |
kinds.<id>.presets (cores, memory, disk) |
Kind editor | Kind page, Sizes |
kinds.<id>.default |
Kind editor | Kind page, Sizes, Default column |
kinds.<id>.largest |
Kind editor | Kind page, Sizes, Largest |
kinds.<id>.spares, device spares included, and spares ready or being made |
Kind editor and its status line | Kind page, Spare boxes, with how many are ready or being made; linked from Devices for iOS and Android |
general.defaultKind |
General | Boxes; and Make default on a kind’s page |
general.boxesPerThread |
General | Boxes |
general.enrol |
General | Boxes, “Boxes join BB as machines” |
general.video |
General | Boxes |
general.nightlyImages |
General | Images, “Rebuild each night” |
Rebuild now (images.list, images.refresh, images.progress), with run states rebuilt, waiting, failed, skipped and not started |
General, beside the switch | Images: each standard image with what the last run did, Rebuild what is due with each image’s progress, and a failed rebuild in Status |
general.files |
Files for every box | Boxes, Files for every box |
general.tailnet (mode, tags, OAuth client ID and secret) |
Tailnet | Network |
general.testDevice |
Not on the page | Devices, For testing |
general.encoderLedger |
Not on the page | Devices, “Share each Mac’s encoder fairly” |
general.iosEncoder |
Not on the page | Devices, “iOS video encoding” |
monitor.enabled |
Monitor | Monitor, “Machine status” |
monitor.refreshIntervalSeconds |
Monitor | Monitor, “Refresh every” |
monitor.backgroundMonitoring |
Monitor | Monitor |
monitor.pressureNotifications |
Monitor | Monitor |
images.<name> (projects’ images: kinds, projects, script, check, bake, source, updated) |
Not on the page; bb box image |
Images, Projects’ images: script and check shown, when built ahead changed at once, Remove… with what follows. Set stays with bb box image set, as agents set them from a project’s files |
| Revision, and a change made elsewhere | A banner | Field-level merge with conflicts on their rows (section 4); Configuration says how it works |
Export and import (bb box config show --json, config apply) |
Not on the page | Configuration |
The copy of BB’s earlier settings (config.migrated) |
Not on the page | Configuration, Remove copy |
| Per-Mac and per-backend host sizes: Tart’s per-Mac shared iOS host cores, memory and slots; each backend’s memory and disk kept free; Hetzner’s server types; Namespace’s largest machine | Set up area | Each backend’s page: under each Mac; Shared iOS hosts; Advanced; Boxes |
iOS shared hosts (ios-hosts-* on Tart, the SSH host pool and Namespace) |
Set up area, Boxes and Advanced | Each of those backend’s pages, Shared iOS hosts; linked from Devices |
Clipboard: there is no clipboard setting in the configuration. Copy and paste are per viewer, in each desktop’s and device’s own menu, and stay there. Nor are a viewer’s own choices settings (video or VNC, which display, the device frame, the New box form’s last choices, the Monitor panel’s shape): each browser keeps them, and they stay where they are.
Every backend field, by backend. Fields named ios-hosts-* move from Boxes
or Advanced into the new Shared iOS hosts card; every other field keeps its
group.
| Backend | Today, in its Set up area | On its own page, by card |
|---|---|---|
| Proxmox LXC | 22 fields: Connection 4, Credentials 3, Boxes 5, Advanced (folded) 10 | Connection: API URL; Node; TLS fingerprint; CA certificate Credentials: API token ID; API token secret; SSH private key Boxes: Resource pool; Linux templates; GPU desktop templates; CPU desktop templates; CPU Wayland desktop templates Advanced (folded): Desktops per GPU; Network bridge; Subnet; Gateway; Box addresses; First box VMID; Last box VMID; SSH user; Memory kept free (MiB); Disk kept free (GiB) |
| Proxmox QEMU | 23 fields: Connection 4, Credentials 3, Boxes 7, Advanced (folded) 9 | Connection: API URL; Node; TLS fingerprint; CA certificate Credentials: API token ID; API token secret; SSH private key Boxes: Resource pool; Windows templates; Linux templates; Linux desktop templates; Android templates; Windows boxes as BB machines; KVM in Linux VM boxes Advanced (folded): Network bridge; Subnet; Gateway; Box addresses; First box VMID; Last box VMID; SSH user; Memory kept free (MiB); Disk kept free (GiB) |
| SSH host pool | 7 fields: Connection 1, Credentials 1, Boxes 1, Advanced (folded) 4 | Connection: Host keys Credentials: SSH private key Boxes: Machines Shared iOS hosts: Slots for ios boxes per Mac; Ready slots for ios boxes kept on a Mac; Slots a Mac sweeps at once; Disk a Mac keeps free of slots (GiB) |
| Tart | 22 fields: Credentials 1, Boxes 9, Advanced (folded) 12, Hosts (6 fields each) | Credentials: SSH private key Boxes: macOS image; macOS desktop image; Linux image (arm64); Linux desktop image (arm64); Windows image (arm64); iOS image; Windows boxes as BB machines; Video by default for macOS desktops Shared iOS hosts: Shared iOS hosts; Slots per shared iOS host; Ready slots kept on a shared iOS host; Slots a shared iOS host sweeps at once; Cores of a shared iOS host; Memory of a shared iOS host (GiB); Shared iOS hosts per Mac; Disk a shared iOS host keeps free (GiB) Advanced (folded): VM user; VMs per Mac; Memory kept free (MiB); Disk kept free (GiB); Hours a macOS warm VM or spare is kept Macs: name; on or off; Address; User; Host keys; and under each Mac its own Slots per shared iOS host; Cores of a shared iOS host; Memory of a shared iOS host (GiB) |
| Docker | 10 fields: Connection 1, Boxes 5, Advanced (folded) 4 | Connection: Host Boxes: Linux image; Desktop image; Wayland desktop image; Docker in boxes; GPU desktops Advanced (folded): Desktops per GPU; Network; Most boxes; Memory kept free (MiB) |
| microsandbox | 12 fields: Connection 1, Boxes 5, Advanced (folded) 6 | Connection: Host Boxes: Linux image; Desktop image; Linux image (arm64); Desktop image (arm64); Docker in boxes Advanced (folded): Docker disk (GiB); Root disk (GiB); Network profiles; Most boxes; Memory kept free (MiB); Memory overcommit |
| Hetzner Cloud | 16 fields: Connection 1, Credentials 1, Boxes 9, Advanced (folded) 5 | Connection: Location Credentials: API token Boxes: Server type; Arm server type; Most hosts; Idle minutes; Monthly limit (EUR); Linux image; Desktop image; Wayland desktop image; Docker in boxes Advanced (folded): Host image; SSH from; Most boxes; Memory kept free (MiB); Network |
| Namespace | 11 fields: Connection 2, Boxes 5, Advanced (folded) 4 | Connection: API token; API region Boxes: macOS version; Most machines; Longest life (hours); Largest machine Shared iOS hosts: Shared iOS hosts; Slots a host lends at most; Slots a host keeps ready; Slots a host resets at once; Disk a host keeps free (GiB) |
Open questions for the owner
Both reviews agreed with each default, and the owner accepted all four on 29 October 2026. The prototype shows them, and the build follows them.
- Machines leave the settings page for the Boxes page’s Machines tab, with a row in Options that opens that tab, empty or not. Default: yes.
- Backends that are not set up are no longer rows; they are found under Add backend. Backends that are off or not finished stay rows. Default: yes.
- Kinds that are off fold into one row, and so do kinds no backend you have makes. A kind switched off on this visit stays in place until the person leaves. Default: yes.
- The default kind’s switch is disabled on every surface, with the reason and a link to choose another default. Default: yes.
Alternatives considered
- Settings in the Boxes page, which owns a route. Not needed: unit 0 showed the hash works on BB’s settings page, and settings stay where BB keeps every plugin’s.
- A leave dialog that asks before any navigation. A plugin cannot stop BB’s sidebar or search, so it would only sometimes ask. Drafts that survive leaving, with the browser’s warning on close or reload, cover every path.
- Saving every change at once, or every change with Save. Each breaks something: settings that belong together would be applied half done, or a quick switch would need a second step. The rule in section 4 keeps each where it belongs and makes the difference visible.
- Tabs across the top, one page with a table of contents, and a drawer for a backend’s form: rejected in the first round, for hiding Status, shortening nothing, and not being linkable.
The prototype
Open 067-settings-page/prototype.html
in a browser. It is one file with nothing to install, and it saves nothing
outside the browser tab. A fake server answers in about 0.7 s, with the
owner’s backends and kinds under generic names, and behaves as bb-box does:
revisions, secret versions, a write before the reply, refusals by field.
Add ?light to the address for the light theme. The view is in the URL’s
hash, so back, forward, reload and links to a view work as in BB.
BB’s sidebar works: choose General to leave and BB Box to come back, or use the browser’s back button. Unsaved changes stay.
The dashed Prototype panel, bottom left (top right on a phone), sets:
- the scenario: All ready, Problems (Tart’s Mac does not answer, a Docker machine is offline, a warm pool is short, a kind is not offered, an image failed to rebuild), or First run;
- what the next save does: works, is refused, saves but loses its reply, or never arrives;
- An agent runs bb box config apply: changes the configuration while the page is open, as an agent would, including a secret, a Mac’s own size and every file’s content;
- Someone saves first, 3 times running: makes the next three writes meet a newer revision;
- Bring mac-1 back, in Problems, so Check again recovers;
- Docker on BB’s server, in First run: ready, missing, no access, or low disk;
- dark or light, and Reload the page.
Worth trying:
- On Proxmox LXC’s page, change Node and Memory kept free, then press the agent button: the memory field shows its conflict, Node keeps your value. Choose Use theirs, then Save.
- Replace the API token secret, press the agent button: the secret’s conflict names the revision.
- Set the next save to lose its reply, replace the API token secret, Save, and press the agent button at once: the receipt confirms your save, and the bar says the secret changed elsewhere since. Set it to “never arrives”: “no receipt”, then Save again saves once.
- On Monitor, type 10, Save, and at once type 15.
- Change a field, choose General in BB’s sidebar, come back: the row says “Unsaved changes”. Reload: the change comes back, and the pasted secret is asked for again.
- First run: Choose another backend, Proxmox LXC, Save and turn on with nothing filled in, then Save, keep it off, then back.
- Find: “iOS memory”, “CPU desktop”, “turn off monitor”, “mac-1 cores”.
- Tab through the overview, Enter on a row, then back.
- On Tart, Add Mac, name it and save; open mac-1, change its own cores, press the agent button, then Show.
- On any inner page, choose “Machine status: its settings” in BB’s sidebar: the stand-in for the Monitor panel’s link.
The prototype has done its job and will not change again. It does not have the four rules the third review asked for (see What changed after the third review): it keeps raw import text in session storage, a retry reads the live editor, a provisional identity is never mapped to the saved one, and a pending save belongs to the page that pressed Save. Where the prototype and this note differ, the build follows the note, and gates G1 to G9 prove it.
To rebuild it, on a box: npm ci at the repository’s root, then npm ci
and npm run build in docs/decisions/067-settings-page/prototype, which
writes prototype.html beside the folder. The prototype’s own packages are
its build tools; React and the components come from the root.
npx tsc -p tsconfig.json --noEmit there typechecks it.
What changed after the reviews
Two independent reviews (Opus and Astra, 29 October 2026) asked for changes before building, not a rethink. Each finding, what changed, and where to see it.
| Finding | Change | In the prototype |
|---|---|---|
| Both: edits do not follow changes made elsewhere; Use the saved value drops other edits; a “part” is not a subtree | Field-level merge from where each edit began; untouched fields follow the stored value; conflicts on their rows at once; each choice acts on its field only; a part is the paths a form writes (section 4) | Proxmox LXC, try 1 |
| Astra: secrets cannot be compared; retries must use a checked revision | Opaque secret change versions from the server; a stale secret is a conflict; every retry re-checks the latest revision first (sections 4, 12) | Try 2; Configuration page |
| Astra: “Nothing was saved” is not known; “Saved” beside newer typing | “Save not confirmed”, then reconcile with what is stored; “Saved” covers what was sent (section 4) | Tries 3 and 4 |
| Both: one saving rule that holds; dependent fields; Enter; Discard; removals | Switches act at once; forms use checkboxes and radio choices and one Save; Enter saves; Discard’s effect stated; every removal confirms its consequences (section 4) | Network page; Tart’s Macs; Remove backend… |
| Both: drafts lost on BB’s navigation, setup, files | Drafts kept outside the page, non-secret parts in session storage, secrets in memory only, restored with a note; browser warning on close; files keep their text on refusal (section 5) | Try 5; Boxes, Add file with the next save refused |
| Both: default kind off from the overview; rows vanishing | Default kind’s switch disabled everywhere with a reason and a link; a default kind found off is a problem; a kind turned off stays in place this visit (sections 2, 3) | Overview, Kinds |
| Both: hash unproven; BB’s header missing on inner pages; back to an empty setup form | Unit 0 run in a real BB: the hash works; BB’s chrome shown on every view; a top row with where you are, Back to the previous view, and Find; setup replaced by the backend’s page in history; focus and scroll restored (section 1) | Any inner page; try 6 and 8 |
| Both: Status overstates freshness and covers too little | Honest states: ready, last check passed and its age, not checked since it changed, checking, off, not finished; ranges of ages; Check all as main button when stale; the callout covers kinds, warm shortfalls, image failures, failing Docker machines, unfinished backends and the default kind (sections 2, 7) | Overview in All ready and Problems |
| Both: first run noisy, ends too early, loses today’s guidance; “Check the connection” ran everything | First run until a backend makes a kind; today’s missing, no-access, low-disk, failed-build and remote-machine guidance; resume an unfinished backend; quiet folded kinds; “Docker on this machine is ready”; setup’s check is its own step, named for what it does (sections 3, 6) | First run, with the Docker choices |
| Both: backend pages are walls of text | First sentence of help, More for the rest; Shared iOS hosts and Advanced folded with a summary; driver help trimmed in unit 4 (sections 3, 10) | Proxmox LXC, Tart |
| Astra: effective values; blank means four things; the new-boxes note disappears | Placeholders give the value and its source; Offer checkboxes for empty-means-off; draftFields keeps an explicit empty; the note stays (sections 3, 12) |
Proxmox LXC’s Boxes card; Tart’s Mac |
| Both: no focus ring on rows; focus lost; duplicate names; errors not tied or off screen; motion on keyboard | Focus rings on every row; heading focused on open, row on back; full names on switches; help and errors described by; first error scrolled into view and focused; no motion for keyboard or reduced motion (section 8) | Try 8; a narrow kind with a bad size |
| Astra: narrow layout | Rows stack below 640 px; sizes become labelled blocks; header and bar wrap (section 8) | Any page at 390 px wide |
| Both: Find misses fields, takes one word, loses the query, shows no values, overview only | Index from descriptors with every field, Mac field, size and warm count; each word matched, synonyms added; values shown; keyboard and live count; query kept; in every view; browser Find left alone (section 9) | Try 7 |
| Opus: Status above Find | Find sits in the same top row on every view, one line high; Status is the first thing below it on the overview, so the answer to task 1 stays in the first screen (sections 1, 2) | Overview |
| Opus: Monitor off is slower | The switch on the Monitor row; Reload BB now after it changes; the panel links to the page (sections 2, 3) | Overview, Options |
| Opus: small losses | Warm shortfalls, “Cannot host boxes”, spares being made, rebuild run states, docker.host.set with its build, Copy saved settings (not the draft) |
Problems; Add backend; Images |
| Astra: placeholders | Rename, Import with its change list and Remove copy are working flows, and Your machines opens the Boxes page’s Machines tab (a stand-in page in the prototype); export says which secrets to paste again; a project image shows its check script; the fake server checks a Mac’s sizes as bb-box does | Backend menu; Configuration; Images |
| Both: build units too broad; unsafe middle releases; missing checks | Unit 0 done; eleven smaller units; today’s forms stay reachable until replaced; the engine first on simple pages; checks for lost replies, two-tab secrets, racing warm counts, refused files, first-run failures, real per-Mac sizes, keyboard and screenshots at three widths (section 12) | — |
| Both: words | The words table in section 10 | Throughout |
What changed after the second reviews
Opus found all 13 first findings answered and asked for five small
changes (N1 to N5). Astra found 3 answered, 9 partly and 7 new, several of
them real defects in the prototype’s saving engine. Each is answered
below, with the scripted test that shows it, in
067-settings-page/tests/checks.mjs; all 13
tests pass, and the first round’s 41 checks in tests/flows.mjs still
pass.
| Finding | Change | Test |
|---|---|---|
| Astra N1: a changed secret version falsely confirmed a lost save | Operation IDs with receipts, applied at most once; a lost reply is confirmed only by its receipt; a secret changed since is named (section 4) | 1a lost reply, racing secret |
| Astra N2, lead 1: a switch said “Saved” after three refusals; a removal refused silently; other writes the same | One write path for every form, switch, menu, removal, image, import and the copy; three refusals is a failure shown; a removal’s refusal shows on the page (section 4) | 1c retries exhausted (switch); 1d removal, image and import refusals |
| Lead 1: an unknown outcome with no receipt | “Save not confirmed: BB Box has no receipt for it yet”, the draft kept, Save again resending the same operation | 1b request never arrives |
| Astra N3, lead 2: newer secret and file typing lost | An edit sent is cleared only if it is still what was sent, for secrets and files too | 2 newer secret and file typing |
| Astra N4, lead 3: a new backend could not be saved on; Add Mac crashed | The server applies secrets before it validates; new Macs and files have stable draft keys, never their names | 3 creation end to end (backend on, Mac added) |
| Astra N5, lead 4: Check left out Mac fields and outlived later edits | Check and Save build one candidate; its result is bound to the candidate’s fingerprint and cleared by any edit | 4 check binds and clears |
| Astra previous 1, lead 5: a Mac’s own fields had no conflict choices; Show did not open the Mac; no stale-content check on a file | The same conflict controls on every path; Show opens, scrolls to and focuses the field; a file’s content is versioned like a secret | 5 conflict controls and reveal |
| Astra previous 4, lead 6: files and imports outside the draft lifecycle | One draft registry for files and imports, with the reload warning and the note on what to paste again | 6 files and imports keep their drafts |
| Opus N1, Astra previous 10, lead 7: Find did not scroll; not on the same page; keys past the list; “RAM” too broad | Reveal on every target, after the page settles; keys bounded to the results shown; synonyms against labels only | 7 Find |
| Opus N4, Astra previous 6, lead 8: “ready” for old passes in mixed and single summaries | “passed their last check” whenever any pass is over an hour old, in every form of the headline | 8 status words |
| Astra N6, lead 8: an established install with every backend off returned to first run; a Linux-only Docker setup warned about desktops | A “setup done” marker separates setup from availability; kinds a backend was set up not to make are not warnings | 8 established all-off, Linux-only Docker |
| Astra previous 8, Opus N3, lead 9: radio names, error ties, Show focus, help links, a Mac’s field names | Radios say their size; size errors tied by aria-describedby; Show focuses; “More about TLS fingerprint”; “Address on mac-1” |
9 accessibility |
| Astra previous 11, lead 10: estimates clipped inside inputs | A number that fits inside, and “Empty: 10 cores, bb-box’s estimate for mac-1” under the input | 10 smaller: effective values |
| Astra previous 3, lead 10: “choices below wait for Save” above a menu that saves at once | “Switches, and menus that stand alone, act at once. Fields, checkboxes and radio choices wait for Save” | 10 smaller: rule words |
| Opus N2, Astra previous 7, lead 10: links into an open settings page | openSettingsView for links from the Monitor and Box panels; the hash re-read on focus and visibility; a unit 1 live check |
10 smaller: Monitor link |
| Astra N7: an invalid route blanked the page | Unknown kinds and setup types show “This kind is not here” with a way to the overview | 10 smaller: invalid route |
| Opus N5: first-run titles, a double full stop, ages on every row, Find’s breadth, the health line at 390 px | Titled for the problem; one full stop; rows drop the age when Status gives it; synonyms narrowed; the host on its own line on narrow screens | 10 smaller: titles, full stops; 7 Find |
| Astra: a cleared secret reused the version “none” | A clear gets the revision that made it, like a set, never reused (section 12) | Covered by 1a and 5, which compare versions |
What changed after the third review
Astra’s third review found the design ready to build after four changes, all in the saving engine’s edge cases, and all 13 checks passing. Opus’s second review had already said build after small changes. The design is settled, so these are changes to this note only: the prototype stays as it was, and each rule is gated in the units that build it.
| Finding | Change | Test |
|---|---|---|
| R1: an import’s raw text, which may hold secrets, was kept in session storage | Raw import text is memory only, valid or not, with a note after a reload; only a schema-checked form with secrets and file contents taken out may be kept (section 5) | Gated in unit 10: G9; the storage rule in unit 2 |
| R2: a retry sent typing made after Save | An operation freezes its submission; retries rebase that submission on a fresh document; the operation ID is bound to it, and the server refuses the ID with another submission; newer typing is a separate draft (sections 4, 12) | Gated in unit 2: G1, and in unit 3 with a secret |
| R3: a new Mac or file kept its provisional identity after saving, so a second save duplicated it or skipped conflicts; a new Mac’s error could not be reached | A provisional identity maps to the saved one and its revision on confirmation, and retained edits move to it; name and path collisions are refused or enter the existing file’s conflict flow; server problems resolve through the same map (section 4) | Gated in unit 2: G2, G3; in unit 3: G6, G7 |
| R4: a save finishing after the person left and came back erased their newer edits; an applied import erased newer text | The registry owns pending operations as well as edits; completion compares with the registry’s current edits and clears only what was confirmed, for every editor, import included (section 5) | Gated in unit 2: G4, G5; in unit 10: G8 |
| Astra: test receipt storage, duplicate requests, late replies and receipt expiry on a real BB | The receipt is stored in the same write as the document; receipts kept for 24 hours and at least the last 100; an expired pending save becomes an ordinary draft (sections 4, 12) | Unit 2’s live check |