oficina / docs Public

formatting-and-export: a formatting bar, font options, and PDF + Markdown export open

On branch formatting-and-export

Paul asked for "the maximally viable checklist — a formatting bar, some font options, at least PDF and Markdown export". All four are on this branch, working end to end in a browser. Plan: docs/superpowers/plans/2026-08-31-formatting-and-export.md.

Five commits, read as one diff. .amadan/ci.d/10-gate is green locally.

What is here

The core reports structure. fragment.rs gains fragment_json, a third renderer over the same walk, emitting ProseMirror-shaped JSON. Markdown and PDF both need to know where a list ends and which run is bold, and Go's standard library has no HTML parser to re-derive that from the markup. ABI 2 → 3.

Font and size are marks. font_sans, font_mono, size_sm/lg/xl, all attribute-free, rendered as spans with fixed class names. Store.HTML returns template.HTML because fragment.rs escapes every text run and puts no byte from a document into attribute position; POST /d/{sqid}/updates accepts arbitrary Yjs bytes that never met ProseMirror's schema, so a mark carrying its own value would spend that property. Serif and normal carry no mark — they are what a document is when nobody has said otherwise. Link is cut for the same reason (an href is a value in attribute position plus a scheme allowlist — a decision, not a row in a table).

A core font set. Liberation Serif/Sans/Mono, twelve faces, OFL, embedded and served at content-hashed paths, declared as @font-face on the document page. The PDF exporter measures with the same font programs the browser is served, which is what §11.1 is actually for. Two deviations, both recorded in the plan as D1: Liberation rather than the Carlito/Caladea §11.1 names (those are DOCX-import substitutions and there is no DOCX import), and TTF rather than woff2 (no compressor in this build; a browser fetches only the faces a page uses).

Markdown and PDF. internal/export writes both. internal/pdf is a real PDF writer — xref, Flate streams, Type0/CIDFontType2 with Identity-H and a ToUnicode CMap so exported text can be selected and copied rather than being a picture. The subsetter keeps glyph ids where they are and empties unused glyphs, which is still a real subset (~25KB of a 385KB face) and removes the two places renumbering goes wrong.

A formatting bar. fixtures/toolbar.mjs, its own module, wired into editor-entry.js in a few lines; the resync path is untouched. Undo/redo, a style select, font and size selects, bold/italic/code/lists/quote. Labels all from the catalog.

Two export routes, inside RequireSession, owner-scoped through the same Store.Get as every other document route, with a sanitised download filename.

The thing worth reading this branch for

Mid-branch the editor stopped working entirely. Typing did nothing. Every command did nothing. No error in the page, none in the server log, the Go suite green throughout, and a screenshot that looked correct — because the bar renders fine and the document looks untouched when it is untouched.

y-prosemirror's exported undo is:

export const undo = state => yUndoPluginKey.getState(state)?.undoManager?.undo() != null

It takes no dispatch. ProseMirror's command contract is that a null dispatch performs nothing and only reports whether it could — which is exactly how a toolbar decides whether to grey a button out, on every state update, because that is when the answer changes. So the bar asked "could I undo?" after every change and the library answered by undoing it. undoCommand/redoCommand are the dispatch-aware pair. The keymap keeps the bare undo/redo, correctly: a keymap never passes a null dispatch.

It is in AGENTS.md now, under tools that answer wrongly instead of failing. The general form is worth more than the instance: a convention is not a guarantee, and the functions that break one are the ones that look most like they honour it.

The gate that found it did not exist, so it does now. scripts/browser-drive.sh + fixtures/browser-drive.mjs drive the real editor in headless Chrome over the DevTools protocol, with real input events (a scripted DOM Range is not the selection ProseMirror reads; an element.click() skips the mousedown the toolbar depends on). Twelve checks. It was verified to go red on the broken build before being kept — a new test that has never been seen to fail cannot tell "no" from "I can't see it". Spec §12 asks for this gate; this is the first half of it.

It is deliberately not in .amadan/ci.d/10-gate: it needs a Chrome on the runner, and that is a decision for whoever owns the runner, not a side effect of this branch. Flagging it as a decision for Paul.

What is verified, and what is not

Verified: the full gate locally; the browser drive 12/12; a real browser editing a seeded document, the edit surviving the CRDT round trip and coming back out of the server-rendered read view with the right classes; and both exports served from the running app with the right headers and content.

Not verified: no third-party PDF reader has opened one of these files. None is available in this build, and headless Chromium renders PDFs blank here — which cannot distinguish a bad file from no viewer, so I am not counting it. The PDF is checked by reading it back through its own xref offsets and reconstructing the text through each font's ToUnicode CMap, plus the three structural properties that do not depend on agreeing with a reader written by the same hand (dangling references, /Size, xref offsets). DOCS_PDF_SAMPLE=<path> go test ./internal/export -run TestWriteSamplePDF writes one to open by hand. Worth someone opening one in Preview or Acrobat before this ships.

For Paul

Three things are yours rather than mine:

  1. The copy. Every new user-facing string is a catalog key in render.go (docs.editor.toolbar.*, docs.export.*) and none has been through a copy review. The full list is in that file; they are the toolbar's control names and two download links.
  2. The title on the exported page. Both exports put the document title at the top — the PDF as an H1 on page 1, Markdown as its own H1. The screen does not: the title is chrome around the paper, not on it. I chose consistency between the two exports over avoiding the occasional duplicate for someone who also typed their title as a heading. Easy to reverse.
  3. Whether the browser drive joins the gate, per above.

Not merging until this has had its highest-capability-model review and Paul has ruled on those three.

— claude~paul@keymail.dev, session "checklist formatting and export"

Follow-up: the browser drive now covers caret placement as well, at 8aa8701.

The caret-placement session sent over the six assertions they had actually run when verifying fd1d21a — that branch shipped with no regression coverage, because it was checked with throwaway scripts that were then deleted. They are in fixtures/browser-drive.mjs now, so the coverage outlives the session that wrote the fix. 19 checks, one command.

Every one was confirmed red against deliberately broken code before being kept, which is the rule this branch added to AGENTS.md and matters more than the checks themselves:

  • neutering placeCaretFromPage turns both margin checks and the typing-lands-there check red;
  • removing the focus handler's pointer guard yanks the view 1508px and turns the no-yank check red;
  • the "click below the last paragraph" check stays green under both, correctly — that path is inside the editable node and belongs to ProseMirror, not to the margin handler.

Three things had to be fixed in the checks on the way, all the same mistake in different clothes — a probe that could not tell "no" from "I cannot see it". The caret probe walked past the editor when the caret sat on it between two blocks and reported -1, which reads as a placement bug. Two checks aimed at fixed coordinates: a point below the viewport receives a click and does nothing, and a double click between words selects nothing, both of which look exactly like the failures being tested for. And two checks asserted on the document's whole markup rather than on the block being changed, so "does the HTML contain <h2>" passed without the control having done anything on a document that already contained one — and its undo was then impossible to observe. All three were found by this branch's own checks failing on a document that was not the one they were written against, which is an argument for the drive picking the longest seeded document rather than the first.

Also now verified rather than assumed: the window is the scrolling element on this layout. The no-yank check asserts on window.scrollY, and on a layout where some inner element did the scrolling it would have passed while measuring nothing at all.

One consequence of this branch worth flagging to whoever reviews it: the formatting bar changes the tab order. It sits between the page header and the paper, so Tab from sign-out now lands on the toolbar rather than in the document. That seems right — chrome before content, and the bar's controls need to be keyboard-reachable — but it is a change to a path the caret branch had reasoned about explicitly, so it should be a deliberate decision rather than a side effect. The sixth check was written as "Tab in from sign-out"; the guarantee underneath it is "a focus that did not come from a pointer scrolls the caret into view", so it is asserted that way instead and survives the next change to the tab order.

Nothing else changed: no product code in this commit, only the harness.

— claude~paul@keymail.dev, session "checklist formatting and export"

The tab-order finding turned out to be a real defect in what I shipped, not just an annoyance, and it is fixed at beb6bbc.

role="toolbar" is a promise — a single tab stop, arrow keys moving between the controls inside it — and I had written the attribute without implementing the pattern behind it. So the bar announced itself to a screen reader as something whose arrow keys work, and they did not. Eleven tab stops between the page header and the first word of a document, on every page load, for someone who only wanted to type. The drive measured nine presses from sign-out to the editor before the fix, which is a better argument than my estimate was.

fixtures/toolbar.mjs now implements the WAI-ARIA pattern: every control is tabindex="-1" in the server-rendered markup and the module promotes one; Left/Right and Home/End move between them. Left/Right rather than all four arrows, with preventDefault on the keys handled, because a closed native <select> changes its own value on Left/Right — Up/Down are left alone, which is how the three selects stay operable from the keyboard.

Not by reordering the DOM. The bar sits above the paper visually, so DOM order already matches reading order, and pulling focus order away from visual order to save tab presses would trade a small annoyance for a 2.4.3 Focus Order violation. Nothing touches focus visibility either: the controls keep rastrillo's ring, and [docs-editor] remains the only element the ring is removed from.

One trap the pattern sets, handled: undo and redo start disabled, a disabled control cannot take focus at all, and a tab stop parked on one makes the whole bar unreachable rather than merely awkward. ensureReachable runs after every sync, because sync is what enables and disables things.

Three new checks in the drive (22 total), each confirmed red before being kept:

| broken thing | check that caught it | |---|---| | a focus trap (preventDefault on Tab) | reachability: twelve presses without arriving | | the pre-fix behaviour, every control a stop | single tab stop: nine presses | | arrow navigation removed | focus went "from undo to undo" |

The reachability check closes a gap in my previous commit that I had not seen: asserting the non-pointer guarantee by focusing the editor programmatically succeeds whether or not a keyboard user could ever have got there, so a bar that trapped focus would have left the document unreachable to anyone without a mouse while every check still passed. It tabs in from the sign-out button instead, bounded at twelve presses rather than asserting an exact count.

Full gate green locally; CI running on beb6bbc.

This closes branch task 14 — but it is worth someone confirming the decision rather than only the implementation, since "the toolbar is one tab stop and arrow keys move within it" is a behaviour people either expect or do not, and it is easier to change now than after anyone has learned it.

— claude~paul@keymail.dev, session "checklist formatting and export"

Task 13 was filed on a weaker version of its own reason, and the live-sync session found the stronger one. Recorded at f0a81b3.

"The browser drive needs a Chrome on the runner" understated it. Chromium cannot start at all inside the agent sandbox these sessions run under — its process-singleton socket is refused, and ~/.config/chromium is read-only. So putting scripts/browser-drive.sh in .amadan/ci.d/10-gate is not a packaging question. It is a question of whether a gate step may run with the sandbox off, which is a real decision about what CI is permitted to do rather than a dependency to install. Task 13 should be read that way.

Also recorded, in fixtures/browser-drive.mjs, for whoever writes the cross-tab check: headless Chromium refuses more than one startup target ("Multiple targets are not supported in headless mode"), so a second tab has to come from CDP's Target.createTarget or the /json/new endpoint, not from a second URL on the command line.

Both were found by the live-sync session hitting the same walls while building its own two-tab driver, and are the sort of thing that costs an hour every time nobody writes it down. Their branch (worktree-live-sync, discussion #12) carries a scratch two-tab driver deliberately not committed as a second harness; the intent is to fold it into this one as a second page once this branch lands, which is their task 16.

One thing for Paul that is nobody's branch

main currently carries fd1d21a, "Merge branch 'worktree-click-to-place-caret' into main" — a merge commit. AGENTS.md's shipping cadence says squash-merge only: "Nothing reaches main any other way: no direct commit, no local fast-forward, no 'it's only a docs change'."

Flagging, not fixing. Rewriting main's history is not something a session should do unasked, the branch it came from is finished and its session has torn down, and the code itself is fine and deployed. It is worth knowing because the rule exists for a reason Paul wrote down, and because a merge commit on main makes the next squash-merge's diff read against a different base than the cadence assumes. Whether it needs anything at all is his call. (Independently noticed by the live-sync session, which also left it alone.)

— claude~paul@keymail.dev, session "checklist formatting and export"

Correction to my previous reply on this thread. I flagged that main carries a merge commit (fd1d21a) as a breach of AGENTS.md's squash-only cadence. That was wrong, and I should not have put it on this record as a rule somebody broke.

amadan branch merge — the sanctioned way to land a branch — produces a merge commit itself, and offers no squash option. fd1d21a is the caret branch landing through exactly the gate the cadence tells you to use. Nobody merged by hand and nobody bypassed anything. (Confirmed by the live-sync session, whose own branch landed the same way as a6fa843, two parents, from that command and nothing else; they raised the original flag, checked it, and corrected it.)

What is real, and smaller, is a discrepancy between what AGENTS.md says ("squash-merge to main", "never merge locally instead of pushing") and what the tool that lands branches actually does. That is a wording-or-tooling question for Paul to settle in whichever direction he prefers, not an incident. It is filed as task 19 on the live-sync branch; I am not duplicating it here.

I relayed the original version to Paul directly as well, and have corrected it there too.

The general point, since this thread is the record: I passed on a second-hand claim about a rule being broken without checking it myself, and "two sessions noticed it independently" is not evidence when the second session is the one who told me. Checking amadan branch merge's own behaviour would have taken a minute.

— claude~paul@keymail.dev, session "checklist formatting and export"

Whole-branch review by Fable, and what it changed — 797149b

The review is done, per AGENTS.md's highest-capability rule, read as one diff. It found one blocking defect and a set of real non-blocking ones. All are fixed; gate green, browser drive still 22/22, CI confirming.

It also did the verification this branch admitted it could not. It installed pypdf (strict mode) and fontTools (checkChecksums=2) and parsed the exported PDF and its embedded subsets independently: the file opens, one page, correct title metadata, text extracts correctly including "café", em dashes and curly quotes, all five font subsets parse with valid table checksums and exact /Length1, composite components present, and the xref offsets, /Length, object numbering and trailer /Size are right against the specification. So the Identity-H + ToUnicode construction is confirmed by something nobody on this branch wrote. That was the branch's largest open honesty gap and it is now substantially closed.

BLOCKING — the PDF export was logging document content

internal/docs/export.go logged Report.String() on a substitution — the actual missing characters. An instance's logs reach the platform's own store (carlos logs), and spec §2.1's leak list, item 5, is categorical: a handler never interpolates document content into a log or an error.

The failure is worse than it first reads. For a short document in an unshipped script — Chinese, say — the sorted, deduplicated set of its characters approximates the document. So this would have leaked most of one in plaintext, out of a system whose whole premise is that documents are sealed at rest, and it would have done it to exactly the people the missing fonts already disadvantage.

Now logs a count and Unicode block names, which is what an operator can act on anyway — blocks say which faces to add. Report.String() keeps its behaviour for the §11.2 report screen and gains a comment saying what it must never be used for.

I wrote that line. The instinct was right one file over — documentForExport logs only the error — which is the sort of inconsistency a second reader catches and the author does not.

The determinism test could not see what it claimed to

TestPDFIsDeterministic compared with strings.EqualFold. A PDF is mostly Flate, which is mostly invalid UTF-8, and every invalid byte decodes to U+FFFD on both sides and compares equal — so two files with genuinely different compressed bodies passed. bytes.Equal now.

That is AGENTS.md's own shape — a check that answers "yes" when it cannot see — appearing in the single test written to rule it out. Worth noting for the record rather than quietly fixing.

And the nondeterminism it was too weak to catch was real: a run carrying two size marks (impossible through the schema, possible through raw POST /updates bytes) picked one by ranging a map with a break. Fixed order, plus a test that exports eight times.

A silently missing number in an ordered list

A list item whose first line falls on a new page lost its marker entirely — the guard compared an offset on the old page against one on the new, which is meaningless. In an ordered list that is a missing "7.": a document saying something its author did not write, in a branch whose stated module rule is never to lose the words.

The marker now follows the line wherever it lands. Verified by restoring the original logic and watching items 28 and 55 — the two page breaks — lose their numbers.

The rest

  • The three toolbar selects were never disabled, though a comment claimed they were — so with no JavaScript a person could set Font to Monospace and watch the document ignore them. And Size listed "Small" first, so it displayed the wrong current state until the first sync, and permanently without script. Both fixed. The branch's own AGENTS.md addition about comments applies to its own comment.
  • The subset dropped cvt /fpgm/prep. Liberation's glyph programs call functions defined in fpgm and read values from cvt , so the embedded font executed instructions against definitions absent from the file. ISO 32000-1 §9.9 requires them; ~15KB a face.
  • Get's fallback comment said "regular serif" and returned Liberation Mono Bold — faces is sorted by served path. Now asks by name.
  • parseCmap12 skipped rather than stopped on a short group table, so a hostile count could spin billions of no-op iterations, in a parser whose own header advertises it is one step from untrusted input.
  • toolbar.mjs was 350 lines against §12's 300-line cap, which this branch's plan promised to respect. Split: the roving-tabindex logic is now fixtures/toolbar-roving.mjs (273 + 86), a genuinely separate concern that knows nothing about ProseMirror.

Still yours, unchanged

The reviewer's judgement is that the branch should merge once the blocking fix lands and D1/D2 are recorded and approved here — Liberation instead of §11.1's Carlito/Caladea, and TTF instead of woff2. Both are §11.1 deviations and both are your call, which is why the plan reserved them for this thread rather than deciding them.

Also still open: the copy review (tasks 10–14), and whether the browser drive joins CI — which needs the sandbox off, not just Chrome installed.

Not carried, listed for a follow-up rather than silently dropped: hard_break is unknown to all three renderers and vanishes (pre-existing for HTML, inherited consistently by both exporters); a literal newline in a text run can inject Markdown block syntax mid-paragraph (fidelity, not security); and a pasted heading-4 blanks the block select.

— claude~paul@keymail.dev, session "checklist formatting and export"

Merged to main. Paul approved after reading the review outcomes; both branches met the cadence's three conditions — done, reviewed by the highest available model and approved, CI green.

  • formatting-and-export → eefa1a4
  • search → 81f64d4
  • main's own CI: passed at 81f64d4

Both were stale first and both had main merged in before landing, which is where the real work was. formatting-and-export had missed live sync and the Impeccable pass; search had then missed formatting-and-export. Conflicts were additive on every side — two route registrations, two sets of showPage fields, two imports in editor-entry.js, and two rules appended to the same paragraph of AGENTS.md — with one genuine adaptation: ApplyUpdate now returns the accepted row's seq alongside its error, so the export test's call site takes both.

The thing worth recording is what was verified rather than what merged. views/show.html now carries the formatting bar and the jump-to palette at once, from two separate bundles reaching for the same keyboard on the same page — so both drives were run against the merged tree rather than against either side of it: the committed browser drive at 22/22, including the three keyboard checks that are exactly where a second bundle could tread on the first, and the palette's own 17/17, with Control-K still opening from inside a document that now has a toolbar above it. "Both merged cleanly" and "both still work" are different claims and only the second one matters.

Decisions that merged as built

These were flagged for Paul and merged with his go-ahead rather than settled first. All are reversible, which is why they were not worth blocking on:

  • Liberation instead of §11.1's Carlito and Caladea, and TTF instead of woff2. Documented deviations with their reasoning in the plan (D1); Carlito and Caladea have a job when DOCX import exists to name Calibri, and nothing here forecloses adding them.
  • Accent folding: cafe does not match café. A one-line change with a test asserting the current behaviour, so reversing it is a deliberate act rather than a regression.
  • The copy on both branches is unreviewed — docs.editor.toolbar.*, docs.export.*, docs.list.search.*, docs.jumpto.*. Catalog strings, changeable without touching code.
  • Control-K as the palette's shortcut.
  • scripts/browser-drive.sh is still not in CI. It needs the sandbox off, not merely a Chrome on the runner — that is a decision about what CI may do, and it stays open.

Open follow-ups live on tasks 23 and 24: hard_break vanishing in all three renderers, a newline in a run injecting Markdown block syntax, the search cap truncating silently, no NFC normalisation, and an expired session reading as an outage in the palette.

Nothing is deployed. carlos deploy, or promote-then-restart, is a separate step — and carlos channels cannot be trusted to say a release is live (AGENTS.md's own entry now, from the live-sync session's deploy).

— claude~paul@keymail.dev, session "checklist formatting and export"

Overnight triage: task 23 is fixed, and it was two different bugs

Paul asked for a triage-and-fix pass on everything open. The work is on worktree-discussions-triage, discussion #26; this is what it means for this thread.

Task 23 is done, and the two halves had different fates.

hard_break vanishing in all three renderers — already fixed, by the typography branch, before I got here. Verified against origin/main rather than taken from this thread's own note, which is the check this repo's rules ask for.

A newline in a run injecting Markdown block syntax — was still live, and is fixed. escapeMarkdown decided "the start of a line" by asking whether the byte index was 0, which is the start of the run. A run can carry a literal newline — POST /d/{sqid}/updates takes Yjs bytes that never met ProseMirror's schema, and a paste can carry one — so everything after it was treated as mid-sentence:

"intro\n# not a heading"   exported as a real heading
"intro\n- not a bullet"    exported as a real bullet
"intro\n1. not a list"     exported as a real ordered list

This thread filed it as "fidelity, not security", and that is right — nothing crosses an owner boundary. But it is fidelity in the direction that matters: the words change rather than disappear, and an exported document says something its author did not write. Same family as the ordered-list marker that went missing at a page break, which Fable's review caught here.

The escaper tracks where the line actually begins now. The non-escaping cases are asserted alongside the injections, because a fix that escapes its way out would make every hyphen in every export unreadable and would have passed a test that only checked the bad cases.

Two things found in the files while there:

  • jsonString in the test helpers claimed to quote "the way a JSON encoder would" and handled only " and \. A fixture with a newline produced invalid JSON and failed inside Parse — a fixture bug wearing a parser bug's costume, and the reason this case was untestable here at all. It asks encoding/json now.
  • The list and blockquote paths were already correct for a literal newline and were uncovered. Now covered, and confirmed red by removing writeList's continuation indent.

Task 13 is unchanged, and worth more than it was. I ran scripts/browser-drive.sh throughout tonight, outside the sandbox — Chromium still cannot start inside it, exactly as this thread recorded. So the question is still "may a gate step run with the sandbox off", not "is Chrome installed". What changed is the stake: the drive is at 44 checks now, and it is the only thing in the repo that looks at 320px, which is where tonight's live WCAG failure on main was hiding.

Tasks 10, 11, 12, 14 remain yours. Unchanged, and I have not touched them:

  • 10 — the copy review. Now the blocker for four threads at once. Two more items are queued behind it (see #26): the search cap needs a "more matched" signal and the palette needs a "sign in again" message, and both are one catalog key each. I did not write either, because writing copy you have not seen and then having twelve locales translate it is worse than waiting for a five-minute pass.
  • 11 — open an exported PDF in a third-party reader. Fable's pypdf/fontTools verification closed most of this, but "no human has opened one in Preview" is still literally true. DOCS_PDF_SAMPLE=<path> go test ./internal/export -run TestWriteSamplePDF still writes one.
  • 12 — the exported title. Still open, still reversible.
  • 14 — the toolbar's tab order. The implementation is done and checked; what is open is whether "one tab stop, arrow keys within it" is the behaviour you want, and that is easier to change now than after anyone has learned it.

Leaving this thread open for those four.

— claude~paul@keymail.dev, session "discussions triage overnight"

Task 23 is done and deployed — merged to main as 1b6d392, live at https://docs.bds.oncarlos.com running that version (confirmed by asking the process, GET /api/version, not the platform's bookkeeping).

Both halves closed: hard_break was already fixed by the typography branch, and the newline-injects-Markdown half shipped here. The Fable review caught that my first fix was incomplete — it handled only column zero, and CommonMark allows a block marker up to three spaces in, so "intro\n # heading" still injected. = (setext, which promotes the paragraph above it) and ~ (a fence that swallows the rest of the file) were never escaped at all. All fixed, seven cases in the table.

Recorded rather than fixed, with a test pinning it: four leading spaces make an indented code block, and no escaping prevents that — the block is made by the whitespace. Fixing it means rewriting the author's own leading whitespace, which is a different decision.

Tasks 10, 11, 12 and 13 are still yours and still open. Two notes on them after tonight:

  • Task 11 — I sent Paul a real export from the current code (61,912 bytes, no font substitutions) so the "open one in Preview or Acrobat" step is a two-minute job rather than a build. Fable also verified the file independently with pypdf in strict mode and fontTools with checksum validation: it opens, one page, correct metadata, text extracts including "café", em dashes and curly quotes, all five font subsets parse with valid table checksums. So what remains is genuinely just a human looking at it.
  • Task 13 — worth more than when it was asked. The drive is at 44 checks now, it carries the only 320px coverage in the repo, and tonight it caught a live WCAG failure that a Fable whole-branch review and a 22-check drive had both walked past. The blocker is unchanged and is not "install Chrome": Chromium cannot start inside the agent sandbox at all, so this is a decision about whether a gate step may run with the sandbox off.

Leaving open for 10–13.

— claude~paul@keymail.dev, session "discussions triage overnight"