oficina / docs Public

click-to-place-caret: click the paper and the caret goes there, and no ring around the page closed

On branch worktree-click-to-place-caret

Two commits: a click anywhere on the page now places the caret, and the browser's focus ring no longer traces a blue box around the editable area. Reported by Paul from a screenshot of the editor.

What was actually wrong

Neither symptom was a bug in anything this app had written. Both were the default behaviour of an editable <div> that nobody had told to behave like a page.

The blue box was the browser's own focus ring. Every browser draws one around a focused contenteditable, and on a white sheet of paper it read as a text box sitting on the document rather than as the document itself.

The dead clicks were a geometry problem. [docs-editor] is the same element as [docs-text], so it was only ever as tall as its own prose. The page's 1in margin and the empty space below the last paragraph belonged to [docs-page] — not to anything editable — so a click there landed on nothing at all and did nothing. That reads as a broken document rather than a short one.

The fix, in two halves, because the two regions are not the same problem

The empty space below the last line can be given to ProseMirror, so it is: [docs-page] becomes a flex column and the editor a grown child (flex: 1 0 auto), which stretches the editable node to the full height of the page's text area. Basis auto with shrink 0 so a document longer than one page still lays out at its own height and pushes the page taller rather than being squeezed back into 11in.

The margins can never be given to it — they are [docs-page]'s own padding, and an editable node has no way to reach outside itself. So placeCaretFromPage (fixtures/editor-entry.js) handles those on mousedown: it clamps the click's coordinates into the editable node's rectangle and asks posAtCoords, which is what turns "beside the text" into "at the nearest end of the nearest line". It reads currentView at event time rather than a captured view, so a resync — which destroys the whole EditorView and builds another — does not leave it dispatching into a dead one.

outline: none is scoped to [docs-editor] alone. The header's sign-out button and the save-status chrome keep rastrillo's own focus styling untouched.

The review round, and the one finding that mattered

Reviewed as a single diff by Fable per AGENTS.md. Verdict: safe to merge, no blocking or high findings. It confirmed the things most likely to be wrong and were not — the listener survives resync without leaking (registered once on an element that outlives every view), the selection-only transaction produces no Y update so the save queue never sees it and no row is spent against core.go's maxRetainedUpdates, the committed bundle hash is genuinely its own content, and drag-select inside the editor, right-click and middle-click paste all still work because the handler returns early for in-editor targets and only ever claims button 0.

The finding worth acting on was an accessibility one, and it is worth writing down because the argument for removing a focus ring is exactly the kind that is true in the case you are looking at and false in the case you are not.

Dropping the ring rests on the caret being a stronger focus indicator than a box. That holds for a click, which puts the caret where you just looked. It does not hold for the keyboard: Tab in from the sign-out button on a document scrolled away from wherever the selection was last left, and ProseMirror restores that selection without scrolling to it — its focus() does not scroll, deliberately — so focus arrives with no visible indicator anywhere in the viewport. The ring, whose edges would have been on screen, used to cover that case for free. AGENTS.md's rule is WCAG 2.2 AA with no exemptions, so this is not a trade to make quietly.

An empty transaction now carries a scrollIntoView on focus. The subtlety is that it must fire only for focus that did not arrive by pointer: a focus event is dispatched before ProseMirror has set the selection the click asked for, so scrolling on every focus scrolls to the stale selection instead — click into the third page of a document and the view yanks back to the first, which is a worse bug than the one being closed. :focus-visible cannot make that distinction, because a contenteditable takes keyboard input and so matches it whatever the modality was. Hence a flag that a pointer press sets and the next macrotask clears.

Two smaller findings, both fixed: TextSelection.near is not an API — TextSelection defines no static near of its own in prosemirror-state 1.4.4, so it silently resolved to the inherited Selection.near while reading like a promise that the result is a TextSelection, which near does not make. And one comment reasoned from something untrue (an empty document does not lack a line to be near; it holds an empty paragraph that resolves fine) — the fallback was right, its stated why was not.

Left undone, on purpose

Shift-click in the margin collapses a selection rather than extending it, and a drag that starts in the margin places a caret but does not grow a selection as the pointer moves. Both are refinements to margin behaviour that did not work at all before this branch, neither regresses what was reported, and inventing selection semantics from coordinates is its own piece of work rather than a loose end of this one.

How it was verified

Not by the test suite, which cannot see any of this. Driven in Chromium against scripts/run-dev.sh, signed in for real through a magic link, on the longest seeded document (5,528 characters):

left-margin click, level with the 3rd block -> caret at offset 0 of that block
click below the last line                   -> caret at end of the last block
bottom-margin click                         -> caret at end of the last block
click at the top, selection left at the end -> scrollY stays 0 (no yank)
Tab in, selection off-screen                -> scrolled to it, caret on screen
focused editor                              -> outline-style: none
typing                                      -> lands at the caret

go test ./internal/... ./cmd/... and node --test fixtures/*.test.mjs pass. Full CI gate green.

Deploying to https://docs.bds.oncarlos.com straight after the merge — the edge channel is what that host follows.

Merged as fd1d21a and live at https://docs.bds.oncarlos.com. Two things about the deploy are worth recording, because both are the "confident wrong answer" shape AGENTS.md warns about and both would have let a broken deploy pass as a good one.

carlos deploy reported "live" while the old binary was still serving. It shipped fd1d21a, promoted it to edge, printed live, and carlos instances list then showed VERSION fd1d21a / Running. All of that was true about the adopted release and false about the running process: GET /api/version still answered 855f95b, and — the unambiguous probe — the instance still served /static/editor.15280b7eedd1868f.js (200) and 404'd the new bundle name. The route had been Asleep; adoption is not a restart. carlos restart --app docs --account bds cycled it, after which the new bundle served 200 and the old one 404'd.

The bundle name is a better probe than either the header or the version endpoint here, and it is free: the committed bundle's filename is the sha256 of its own content (fixtures/build-editor.mjs), and internal/docs/assets.go panics unless exactly one exists, so exactly one bundle name can ever be served by a given binary. Asking for the new name is asking the running process which build it is, with no cooperation from the platform's own bookkeeping.

The ship build line in the signin-and-samples plan (step 3) does not stamp the version. rastrillo.BuildVersion defaults to the literal "dev" (serve.go:45) and is exactly what /api/version prints, so a binary built with the plan's -ldflags "-s -w" answers dev and distinguishes nothing — which is precisely the check discussion #7 relied on to confirm the last deploy. The first build here did that, and after the restart the endpoint went from a real sha to dev: an observability regression, not a functional one, but it blinds the only probe that asks the process itself.

Rebuilt with -X github.com/carlosframework/rastrillo.BuildVersion=$(git rev-parse --short HEAD). Versions are immutable — carlos ship refused to redefine fd1d21a with different content, correctly — so the stamped build went out as fd1d21a-1. The release is therefore named fd1d21a-1 while the process reports fd1d21a; both name commit fd1d21a, and the stamp names the commit rather than the build iteration on purpose, since the commit is the thing anyone reading it wants.

Anyone updating that plan's step 3 should add the -X to it.

Verified from outside, signed out:

GET /api/version                          -> fd1d21a
GET /static/editor.98af6ed37bba8906.js    -> 200, sha256 prefix 98af6ed37bba8906
GET /static/editor.15280b7eedd1868f.js    -> 404 (the old bundle is gone)
GET /                                     -> 303 to sign-in

The served bundle's sha256 equals its own filename and the committed file byte for byte, so what is live is the reviewed code and not a rebuild of it.

The in-browser behaviour was verified before the merge against scripts/run-dev.sh rather than against this instance, because the live allowlist admits real addresses only and a magic link is a credential I should not be handling.