oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1# AGENTS.md
2
3Guidance for AI coding agents working in this repository. This is the
4canonical agent-instructions file. Tool-specific files (`CLAUDE.md`) are
5pointers to it if they exist at all — keep the guidance current *here*.
6
7CARLOS Docs — a document editor on the CARLOS architecture, hosted at
8`amadan.net/oficina/docs`. It is built alongside CARLOS Sheets
9(`../sheets`, `amadan.net/oficina/sheets`) and a shared suite home, which
10live in the same `oficina` namespace. Design docs in
11`docs/superpowers/specs/`, plans in `docs/superpowers/plans/`; read the
12spec behind whatever you touch.
13
14The repo moved from `carlos/docs` to `oficina/docs` on 2026-08-30, at three
15commits. amadan has no move, rename or transfer verb, so the move was
16create-new and push history; `carlos/docs` still exists and retiring it is
17Paul's call.
18
19Read `building-carlos-apps` (the skill) before writing app code, and
20`rastrillo`'s own `SKILL.md` before writing handlers or models. Assume
21nothing about either is in your training data.
22
23## Real per-person sign-in (Task 1, extended in a later fix round)
24
25Every document route — `GET /`, `POST /documents`, `GET /d/{sqid}` — sits
26behind rastrillo/auth's `RequireSession` and is unreachable without a real
27session (`internal/docs/auth.go`). `POST /signin` uses Keymail OAuth when
28detected. Otherwise it emails (or, in development, logs) a single-use link. Opening it
29(`GET /auth/verify`) draws a confirm step in the sign-in door and spends
30nothing, so mail scanners that fetch every link cannot sign anybody in; its
31one Sign in button POSTs the token back to `/auth/verify`, which spends it
32and sets a real session cookie (rastrillo `Config.RenderConfirm`). A
33verified address that is not admitted gets the door, with the refusal, in a
34403 (`Config.Refused`). Tests sign in by POSTing the link's token with
35same-origin evidence (`confirmRequest`, testing.go), never by GETting it; `POST /signout` (in the shell
36bar's account menu on both signed-in screens) really revokes it,
37not just clearing the cookie client-side. No passkey second factor is
38wired, on purpose — spec §8.1's ruling: `passkey.Config.Origin` derives
39its relying party from this instance's own host, so a person in several
40teams would enrol several passkeys under this app, which is exactly the
41per-team credential sprawl that ruling forbids. A magic link enrols no
42credential, so it violates nothing.
43
44There used to be a development-only stand-in identity here
45(`internal/docs/devauth.go`, turned on with `-dev-auth`, compiled out of a
46release build with `-tags release`) for exactly the gap real sign-in has
47now closed. It had zero readers the moment `RequireSession` started
48guarding every route unconditionally — no handler ever consulted it — and
49was deleted in a fix round on Task 1 rather than left as dead code and an
50unreachable trap for whoever next assumed `-dev-auth` still did something.
51The `release` build tag it was the only user of is gone with it; there is
52no longer a release/non-release distinction anywhere in this app.
53
54`Config.Env` still exists (`"development"` or `"production"`), but it now
55governs exactly one thing, independent of any identity flag: whether
56`InstanceKey` and `Mailer` (`internal/docs/auth.go`) may default — an
57empty `$DOCS_INSTANCE_KEY` or unconfigured mail is fine only in
58development (a fixed dev key, and magic links logged instead of mailed,
59clearly marked); anywhere else it is a startup refusal, the same
60fail-closed shape `Origin` already used. `cmd/docs/main.go`'s `-env` flag
61sets it; `$DOCS_ENV` is consulted too but can never select
62`"development"` on its own (fix round 2 on Task 1 — platform instance
63config *is* environment variables, so a bare `$DOCS_ENV=development`
64sitting beside `$DOCS_ORIGIN`/`$DOCS_INSTANCE_KEY`/`$DOCS_SMTP_*` would
65be a production-reachable path to the public dev `InstanceKey` and to
66every magic link, token included, landing in the instance's own log —
67see `chooseEnv`'s own doc comment). The default, with no flag and no
68env var (or with `$DOCS_ENV=development` and no flag), is
69`"production"` — `"development"` always takes an explicit `-env`
70flag, never a silent fallback.
71
72### Sign-in allowlist (Task 4 security fix, pre-launch)
73
74Real sign-in above closed "who can get a session" but left a separate
75hole open: `rastrillo/auth`'s `Config.Authorize` — the admission gate —
76is consulted only in its own callback handler, after `POST /signin`
77(`Begin`) has already emailed the link. With `Authorize` left `nil` (as
78it was through Task 3), that meant anyone who could reach the instance
79could POST any address and get a working sign-in link mailed to it —
80an open relay on our own SES sending identity, and unrestricted account
81creation on an app whose whole premise is instance-per-team. Found by
82the CARLOS Sheets session on their own deployed instance; every existing
83test passed because they all tested admission, none tested delivery.
84
85The fix is `$DOCS_ALLOWED_ADDRESSES` (`docs.Config.AllowedAddresses`,
86`internal/docs/auth.go`), a normalized allowlist checked by both
87`rauth.Config.Authorize` and `admissionMailer`. The mailer suppresses delivery
88for refused addresses and failed grant lookups. Classification and rate limits
89run independently of admission, so an OAuth start does not disclose membership.
90Refused email starts still receive `303 ?sent=1`; their expiring tokens are never
91delivered. Callback authorization and per-request admission remain required.
92
93Detected Keymail addresses use OAuth, as §8.1 requires and Paul reaffirmed on
942026-09-09. `force=1` remains an explicit email fallback. The OAuth start ends
95with a `200` continuation page (meta refresh plus link), preserving the pending
96cookie and the existing CSP: Safari enforces `form-action` across redirects.
97Never widen that directive to arbitrary Keymail hosts. Account linking still
98requires a fresh provider `AuthTime`; an ordinary OAuth session is not enough.
99
100Empty is allowed only in development (admits everyone, so `TestSignIn`
101and `run-dev.sh` need no setup); anywhere else it is a startup refusal,
102the same fail-closed shape `Origin`/`InstanceKey`/`Mailer` already use,
103chosen over silently admitting nobody because a silently-empty allowlist
104would boot looking identical to a working instance.
105
106## Running it locally
107
108`./scripts/run-dev.sh` builds and runs the app in one command: `-env
109development`, a fixed port (`localhost:8099`, override with
110`$DOCS_DEV_PORT`), and a scratch SQLite database that starts empty every
111run. It prints the URL to open, and signing in there gets a real session
112via a magic link logged to the terminal instead of mailed — the same flow
113a real deployment uses, just not actually delivered anywhere.
114
115**Origin is not optional in anything but development — but know what it
116actually guards.** rastrillo v0.21.0's `csrf.Protect` checks
117`Sec-Fetch-Site` *first*, when a browser sends it — which every current
118browser does on a state-changing request. `same-origin`/`none` pass
119immediately, anything else is refused immediately, and `Config.Origin` is
120never read in that path at all. Only when `Sec-Fetch-Site` is entirely
121absent (a legacy browser, or a non-browser client like curl) does the
122check fall back to comparing `Origin`/`Referer` against `Config.Origin`
123exactly. So a wrong `Config.Origin` does not make a real browser's genuine
124same-origin request fail, and it does not open up a genuine cross-site
125request either — both are decided by `Sec-Fetch-Site` alone. What it
126actually breaks is the legacy fallback path, silently: a misconfigured
127origin will not show up as "the app looks broken" in ordinary manual
128testing with a real browser, which is exactly why it still needs to be
129right rather than merely untested. `cmd/docs/main.go` reads `$DOCS_ORIGIN`
130(the real per-instance origin belongs there — spec §9.2's
131`<team>.docs.oficina.works`) and falls back to deriving one from `-addr`
132only for local/dev use; with neither set, or with a value that cannot
133parse as an absolute `http(s)://host[:port]`, `internal/docs.New` refuses
134to start rather than silently defaulting or booting on something
135malformed. `run-dev.sh` never needs `$DOCS_ORIGIN` set by hand — its fixed
136`-addr` derives it.
137
138## Review by the highest available capability model (Paul's rule, 2026-08-30)
139
140**Specs and whole branches are reviewed by the highest capability model
141available at the time.** Today that is **Fable** (`claude-fable-5`). The
142rule is "the highest available", not "Fable" — when a more capable model
143ships, the rule follows it up the ladder without an edit here.
144
145Two things get that review, and they are the two where a mistake is
146expensive to unwind:
147
148- **A spec, before it becomes a plan.** A spec is the document every
149 later decision inherits from. Reviewing it after implementation means
150 reviewing the code's rationalisation of it.
151- **A branch, before it squash-merges to `main`.** Read as a single
152 diff, not commit by commit — that is the one reading that catches a
153 branch which is individually-sensible commits adding up to the wrong
154 thing.
155
156Name the model explicitly when dispatching a review. An omitted model
157silently inherits the session's, which is how a review quietly happens on
158a weaker model than the rule requires.
159
160The rest of the delegation ladder, unchanged from the family's policy:
161
162- **Haiku** — transcription-grade work: the plan contains the exact code,
163 single-file mechanical fixes, doc edits.
164- **Sonnet** — implementation from a precise brief: 1–3 files, clear
165 interfaces, established conventions to follow.
166- **Opus** — design judgment, multi-file integration, anything security-
167 or production-touching.
168- **Fable** — spec review, whole-branch review, and implementation whose
169 correctness rests on several interacting invariants at once. It burns
170 usage budget fastest; never spend it where a precise Sonnet brief plus
171 an Opus review already fits.
172
173A precise brief beats a capable model. Exact values, file paths and
174interfaces go in the brief.
175
176## Shipping cadence (Paul's rule, 2026-08-30)
177
178**Work happens on a worktree, on a branch. Never on `main`.**
179
1801. **Worktree per piece of work.** `git worktree add` (or the harness's
181 worktree isolation) so `main` stays clean and two pieces of work never
182 share a checkout. The branch is created with the worktree, not after.
1832. **Push regular commits to the branch.** Commit as you go and push
184 often — small commits, pushed, so the work is visible, recoverable,
185 and reviewable as a sequence. Do not sit on a large uncommitted
186 working tree, and do not squash locally as you go; the branch's
187 history is the record of how the work happened.
1883. **Squash merge to `main` when, and only when, all three are true:**
189 - the work is **done** — not "done except", not "done pending a
190 follow-up commit";
191 - it is **approved** — the branch has had its highest-capability-model
192 review above, and Paul has approved anything that was his call;
193 - **CI is green** — `amadan ci status -branch <n>` exits 0. Exit 2 is
194 pending, not green. Exit 3 means nothing has run, which is not green
195 either.
196
197Nothing reaches `main` any other way: no direct commit, no local
198fast-forward, no "it's only a docs change". That includes doc-only edits,
199one-line fixes, and reverts.
200
201The reason is timing rather than ceremony. A change merged locally still
202faces the same gates, but only *after* it is on `main`, where a red run is
203an incident to clean up instead of a merge that simply does not happen.
204
205**The review record lives on amadan.** The pull-request equivalent is a
206branch discussion — see below. **CI is `.amadan/ci.d/10-gate`**, triggered
207by a push to the remote, and it is the same script you can run locally. It
208covers the Rust crate, the wasm artifact, Yjs wire compatibility in both
209directions, and the Go build and tests. `amadan ci status -branch <n>`
210exit 0 is the merge gate; exit 2 is pending and exit 3 means nothing ran,
211and neither is green.
212
213**Finishing a branch is always the same two steps, in this order** (Paul's
214rule, 2026-08-30): **push and open the branch discussion, then squash-merge
215to `main`.** Never merge without the discussion existing first, and never
216merge locally instead of pushing.
217
218The order is the point rather than the ceremony. A squash merge deliberately
219flattens the commit history that carried the branch's reasoning — the
220rulings, the reversals, the things that turned out to be wrong twice. The
221discussion is where that survives. If it does not exist before the merge,
222the reasoning is not scattered, it is gone.
223
224## Working through amadan
225
226This repo is hosted on an amadan hub (`AMADAN_HUB=https://amadan.net`),
227not GitHub. There is no `gh`, no pull request, and no GitHub Actions. The
228full house rules are installed as a skill at
229`.claude/skills/amadan/SKILL.md` — read it. What follows is what this repo
230does with them.
231
232**Post as the agent account, never as Paul.** An agent account is
233`<name>~<operator keymail>`; everything it writes is badged and
234attributable. The CLI detects a harness and refuses a human credential.
235`-as-human` exists for a person genuinely typing, and it warns every time
236— an agent reaching for it is the antipattern the badge exists to prevent.
237
238**The prompt ledger is on, at `prompts` level.** `amadan ledger init` has
239installed the post-commit hook, the Claude Code hooks in
240`.claude/settings.json`, and the ledger refspecs, so prompts record to
241`refs/amadan/ledger/<branch>` with nothing to do per commit. Two
242consequences worth knowing:
243
244- **A bare `git push` now pushes HEAD *and* the ledger refs**, because
245 `remote.origin.push` is set and `push.default` no longer applies.
246- **Never edit ledger refs by hand.** `amadan ledger redact` and
247 `amadan ledger withhold` are the only sanctioned removals, and both
248 leave a visible mark. That is the point of them.
249
250**Every branch gets a discussion, and it is the review record.**
251`amadan discuss new <ns>/docs -branch <n>` opens it; that thread is where
252the branch is read as one piece, where the highest-capability-model review
253is posted, and where approval is recorded. One thread per topic — reply on
254it rather than opening a sibling. Branch discussions belong to the branch;
255repo-level ones are for anything wider. Close what you opened.
256
257**Tasks are a checklist, not a log.** `amadan task add -branch <n>` for
258work someone will do, `amadan task advance` when it is done. Commentary
259belongs in the discussion, not in task titles. A branch with real work on
260it should have tasks that a reader can scan to see what is left.
261
262**Rebuilding the editor bundle is not hand-mergeable** (branch
263convention, agreed 2026-08-31 between the three sessions then working in
264this repo). `internal/docs/static/editor.<hash>.js` is a single
265committed artifact whose name is the hash of its own contents, and
266`assets.go` panics at init if it finds more or fewer than one. Two
267branches that both ran `fixtures/build-editor.mjs` therefore conflict
268mechanically even when no source line overlaps. Whoever squash-merges
269second discards their own bundle from the merge and re-runs the build on
270top of `main`. Never reconcile the minified file by hand.
271
272**When something is refused, say so in the discussion rather than working
273around it.** An `agent` grant is writer-equivalent within one namespace:
274push, post, manage tasks, nothing beyond. Refusals are usually a missing
275grant or a genuine boundary, and both are worth surfacing rather than
276routing around.
277
278## Working with the sibling sessions
279
280Docs is one of three moving parts and the other two are live:
281
282- **`../sheets`** (`amadan.net/oficina/sheets`) — shares the suite home,
283 the design-system primitives, and several rulings. Its spec is
284 `docs/superpowers/specs/2026-08-30-sheets-design.md`. Docs is a
285 declared consumer of the home contract in its §9.
286- **The design system** — `rastrillo.org/design-system`, spec at
287 `docs/superpowers/specs/2026-08-28-design-system-design.md` in the
288 rastrillo repo. Vendor tokens through `ui.TokensCSS()` and
289 `ui.ThemeCSS(name)` rather than copying files by hand.
290
291When a decision affects one of them, tell that session rather than
292writing around it. Several of this project's better decisions came from
293one session's question exposing another's bug.
294
295## Conventions that are already settled
296
297- **Markup uses the bare `rst-*` attribute grammar** (`<div rst-inspector>`),
298 variants in the value matched with `~=`. Unparked by Paul 2026-08-31, once
299 both of his conditions were met and verified: `rastrillo v0.21.0` styles
300 **both** spellings (`ui/tokens.css` carries 343 bare-attribute selectors
301 alongside 398 class ones), and the design-system guidance exists.
302 Two constraints come with it. **Adopt per screen, not per element** —
303 mixed authoring inside one component matches neither spelling by design.
304 And **pin `v0.21.0` or later**, because on anything earlier attributes
305 render unstyled.
306 *(This reversed twice before landing here, both times by applying a
307 conclusion drawn about one repo to a different one without re-deriving it.
308 The reasoning is in spec §10.1.1.)*
309- **Anything Docs contributes to estilo takes an `estilo-*` prefix, never
310 `rst-*`.** That namespace is the framework's and a future version may claim
311 a name we took — rastrillo's own migration found `rst-form__foot` and
312 `rst-form-foot` both live inside `ui`.
313
314- **A spec ruling, code on `main`, and code in the latest release are three
315 different things.** Reading one tells you nothing about the other two.
316 `v0.19.0` (2026-08-24) is 18 commits behind rastrillo's `main` and lacks
317 the themes, shells and locales the design-system spec describes, so
318 `go get` alone will not give you them. Say which of the three you mean
319 whenever you cite the framework, and stamp any commit count with the sha
320 and date you took it at — a bare number ages into a wrong fact.
321
322### Watch for tools that answer wrongly instead of failing
323
324A tool that errors teaches you something. A tool that returns a confident
325wrong answer does not, and both of the ones below cost this project real
326time in a single afternoon:
327
328- **Citing your working tree instead of `origin/main`.** A checkout parked
329 on a feature branch gives you *true statements about the wrong tree*,
330 complete with real line numbers that verify. Cite `origin/main` after a
331 fetch, or a tag, and say which. Never your checkout.
332- **`git branch --merged` against a squash-merging repo.** It returns an
333 empty list that reads as "never merged" while `main` carries every line,
334 because a squashed branch is not an ancestor. Compare content, not
335 ancestry.
336- **`carlos channels` after a promote.** It reports which release a
337 channel *points at*, which is not the same question as which release
338 the box is *serving*. Adoption runs on an hourly timer, so the two can
339 disagree for up to an hour after `carlos promote`: observed
340 2026-09-01, `edge` reading the new version while the live host served
341 the previous one for five minutes straight, closed within seconds by
342 `carlos restart`. Neither command fails and nothing says "not live
343 yet". Trust `x-carlos-version` on the real host, or use `carlos
344 deploy`, which ships, promotes and then watches the URL until it
345 actually serves the new version. `carlos channels` cannot distinguish
346 "serving it" from "pointed at it".
347 *(Recorded from the live-sync deploy; not re-verified since, so the
348 timer's exact behaviour is one observation rather than a measurement.)*
349
350- **A library function that ignores its own "dry run" argument.**
351 `y-prosemirror`'s exported `undo` is `state => ...undoManager?.undo()`
352 — it takes no `dispatch` and undoes immediately. ProseMirror's command
353 contract says a null `dispatch` performs nothing and only reports
354 whether it could, which is how any toolbar decides whether to grey a
355 button out — on every state update, because that is when the answer
356 changes. So the formatting bar asked "could I undo?" after every
357 change and the library answered by undoing it: typing did nothing,
358 every command did nothing, no error anywhere, Go suite green.
359 (`undoCommand`/`redoCommand` are the dispatch-aware pair.) The general
360 form: **a convention is not a guarantee**, and the functions that break
361 one are the ones that look most like they honour it. Found 2026-09-01
362 on the formatting-and-export branch, by a browser drive, after an
363 evening of looking at the wrong file.
364
365The shape to watch for is a verification that cannot distinguish "no" from
366"I can't see it". When you catch one, write it here rather than resolving
367to be more careful — care is not a mechanism.
368
369**A new test must be seen to fail.** Corollary of the above, and cheap:
370after writing one, break the thing it covers and watch it go red, then
371put it back. `scripts/browser-drive.sh` was kept only after it was
372confirmed to fail on the build whose bug it had just found — without
373that step a green run means "passed" and "could not see" equally.
374
375**And it must fail for the right reason.** Watching it go red is not
376enough — read *why* it went red. Three of these were found in one
377evening on the `list-thumbnails` branch, in three different shapes:
378
379- A test that could not fail at all. `TestPageOneDoesNotDisturbPDFExport`
380 exported a document, ran the thumbnail layout, exported again and
381 compared bytes — but the two calls share no state, so only a
382 package-level global could ever have reddened it. It stayed green
383 while a deliberate `l.cursor += 100` inside the code it claimed to
384 guard reddened a different test. Two comments and a commit message
385 cited it as proof. Deleted.
386- A mutation that reddened the right test for the wrong reason. Proving
387 a canary test could fail, the injected leak used a template variable
388 that was not in scope, so the page 500ed and the test failed on its
389 status check without the canary ever firing — a red test proving
390 nothing. Redone with a leak that actually renders. **If your mutation
391 changes the response code, you have tested the mutation, not the
392 test.**
393- A hit test that measured something else. Recorded in `list.html`:
394 restated declarations "fixed" row clicking, and the probe was hitting
395 rastrillo's own overlay both before and after.
396
397The generalisation, and the question worth asking of any gate: **what
398would make this go red, and have I actually done it?** A CSS rule keyed
399on an attribute nothing writes is the same defect in another costume —
400it matches no element, so it is invisible to every test and every
401screenshot, and any bug inside it is unreachable. The Sheets session
402found one of those the same night, hiding a second defect behind it.
403Worth a grep when touching a stylesheet; not worth a permanent gate,
404because it has enough false positives (comments quoting old selectors,
405framework-emitted hooks) that people would learn to skim it — and a
406gate nobody reads is another probe that answers the same in both
407worlds.
408
409**Do not pass on somebody else's finding as your own verification.** A
410report from another session is evidence about what they saw, not a fact
411you have checked, and relaying it flatly launders it into one. Say where
412it came from, or check it — and note that "two of us noticed
413independently" is not corroboration when the second of you is the one
414who mentioned it first. (Written after exactly that: a merge commit on
415`main` was reported here as a breach of the shipping cadence and passed
416on to Paul as one, when `amadan branch merge` produces a merge commit
417itself and the branch had landed correctly. One minute with the tool
418would have caught it.)
419- **rastrillo is `amadan.net/rastrillo/rastrillo`.** The old path,
420 `github.com/carlosframework/rastrillo`, stops at v0.23.0: code importing
421 it gets no fix, and its own checks cannot tell (Docs zoomed iOS on every
422 tap for a month that way). `TestNoPreMoveRastrillo` (internal/lint)
423 allows one import of it, native.go's bridge to rastrillo/native's
424 server, until that server moves. The CLI is pinned as a `tool` in
425 go.mod: run it as `go tool rastrillo …`, never a `rastrillo` from PATH.
426 `scripts/build-release.sh` stamps `amadan.net/rastrillo/rastrillo.BuildVersion`;
427 the old symbol leaves `/api/version` answering "dev"
428 (`TestReleaseBinaryReportsItsVersion`).
429- **Docs serves rastrillo's and estilo's assets from memory**
430 (render.go's `vendoredStaticFS`), so `go tool rastrillo doctor` finds
431 nothing vendored to check. If a future `doctor --fix` ever writes a
432 rastrillo.js, busy.js or tokens.css to disk, delete it: doctor's shim
433 carries the old busy rule, which fights estilo's busy.js.
434 `TestBusyRuleIsLinkedAndServed` fails if one is left behind.
435- **iOS zooms into any text control under 16px.** tokens.css lifts
436 controls to 16px under `(pointer: coarse)`; a Docs rule that sets a
437 control's font with a heavier selector must restate
438 `font-size: max(1rem, 1em)` there too. `TestNoZoomOnTap` (browser tag:
439 `go test -tags browser -run OnTap ./internal/docs/`, needs a Chromium via
440 `RASTRILLO_CHROME` or a Playwright cache) measures every field.
441- **Re-copy `tokens.css` deliberately on every rastrillo upgrade.** It is
442 frozen into `static/` at scaffold time while the partials upgrade with the
443 module. `rastrillo doctor` will make this mechanical; until it lands it is
444 a manual step in the upgrade runbook.
445- **Every user-visible string is a catalog key**, resolved at render time.
446 Twelve base locales; a hardcoded English string is a bug.
447- **WCAG 2.2 AA, no exemptions**: 24×24 targets, 320px reflow, and colour
448 never carries meaning alone.
449- **An ARIA role is a promise about behaviour, and an unimplemented one
450 is worse than none.** `role="toolbar"` says "this is a single tab stop
451 and the arrow keys move within it". Written without the roving
452 tabindex behind it — as the formatting bar was, on this branch, until
453 it was caught in review — it tells a screen-reader user that arrow
454 keys are the way in, so those are what they try first, and the silence
455 that follows reads as their own mistake rather than as a missing
456 feature. Omitting the role would have been more honest and less
457 harmful. Whenever you write a role, implement its keyboard contract in
458 the same commit or do not write it.
459 Two things that pattern hides, both found the same way:
460 **a roving tab stop parked on a `disabled` control makes the whole
461 group unreachable**, not merely awkward, and disabled is often the
462 *default* state (undo and redo start that way on every page load) — so
463 the "which control is the tab stop" decision has to re-run whenever
464 anything is enabled or disabled, not on load. And **a closed native
465 `<select>` changes its own value on Left/Right**, so a toolbar
466 containing one must consume those keys and leave Up/Down alone.
467 Neither is in the ARIA practices; both are unmissable in a browser and
468 invisible in a unit test, which is what `scripts/browser-drive.sh` is
469 for.
470
471## Work in a worktree, and catch main up first
472
473Take a worktree for the work. Do not work in the shared checkout.
474
475 git -C <repo> fetch origin
476 git -C <repo> worktree add ../<repo>-<task> -b <branch> origin/main
477
478Both halves matter, for different reasons.
479
480**The worktree**, because several sessions share this machine and some
481share this repository, and a checkout has one HEAD, one index and one
482working tree between all of them. A branch switch under somebody else's
483uncommitted work destroys it with nothing on screen to say so, and two
484sessions editing the same paths disagree quietly instead of conflicting
485loudly. A worktree gives the work its own HEAD and its own files, and
486costs a directory.
487
488**From `origin/main`, after a fetch** — not from whatever the checkout
489happens to be sitting on. A branch cut from a stale main is a merge
490conflict scheduled for later, against files somebody else has renamed in
491the meantime. A branch cut from another task's branch is worse: it
492carries that task's commits into yours, where they are reviewed as
493though you wrote them, and the two can only be separated by hand.
494
495Remove it when the work has landed — `git worktree remove` — so that
496`git worktree list` keeps saying something true.
497
498Written down because of 2026-09-17, in `meet`. The shared checkout was
499sitting on one task's branch while holding a *different* task's feature
500uncommitted on disk — some four hundred lines, a new migration among
501them — put there by a session nobody else knew was working in that tree.
502Two other sessions were meanwhile coordinating over the same checkout by
503asking each other not to touch it, which is the arrangement a worktree
504makes unnecessary. Nothing was lost. Nothing about the arrangement would
505have said so if it had been, and by then the same feature existed twice,
506once on each side of a rename, because the two sessions had branched
507from different starting points.
508