Skip to content

spaces: finish the writing surface — arrow navigation, a spoken Tab refusal, block controls - #444

Open
nyblnet wants to merge 1 commit into
mainfrom
spaces-editing-basics
Open

spaces: finish the writing surface — arrow navigation, a spoken Tab refusal, block controls#444
nyblnet wants to merge 1 commit into
mainfrom
spaces-editing-basics

Conversation

@nyblnet

@nyblnet nyblnet commented Sep 11, 2026

Copy link
Copy Markdown
Owner

Twelve advanced features were built on top of a writing surface that was never
finished. The maintainer tested the app and hit three things in a row; all three
are absent rather than broken, and they are one piece of work because they are
the same surface. Branched off origin/main deliberately — this depends on none
of the open branches.

1. Arrow navigation, which had never existed

Every block is its own contenteditable host — that is what keeps a Selection
block-scoped, so a split or a merge can never re-mint an id, and ids are what
links, backlinks and collaboration key on. The price was that the browser's own
caret movement stops dead at a block boundary. The only ArrowUp/ArrowDown
handlers in the editor were inside the slash menu, for picking a menu item.

Within the block first. ↓ from the first line of a wrapped paragraph means
its second line, not the next block, so the question asked is never "which block
is this" but "is the caret on the edge VISUAL line of its host" — answered from
the caret's own rectangle against the host's line boxes. Counting characters
cannot answer it: where a line wraps depends on the font, the width, the
language and the marks.

Two things had to be got right and neither could be found by reading code:

  • Range.getClientRects() returns a rect per inline BOX, not per line. The
    starter space's first paragraph is two visual lines and five rects, because
    of the <strong> in the middle of the first one. Unmerged, the edge test read
    the first fragment as "the first line", and the goal-column clamp could not
    reach past it. Boxes are merged per line now.
  • A range collapsed at an ELEMENT boundary reports no rects and an all-zero
    bounding box. Measured at the end of that same paragraph: the caret at the end
    of a two-line paragraph decided it was still on line one and never left the
    block — the exact bug this branch exists to fix, reintroduced by a fallback.
    The box is now measured by widening one unit and reading the edge.

A goal column, kept across consecutive vertical steps and dropped by a
pointer — a click used to leave the previous run's column live, measured by
entering a table three times at three columns and landing in the first one every
time. ← at the start of a block goes to the end of the one before, → at the end
to the start of the next. Callouts, toggles and canvas cards come free (they
render in document order). A table is stepped by its grid, because cells are
row-major in the DOM and column-major on the screen; entering one from above or
below lands in the column the goal column is over. The table's own ⇥ and ⏎ are
untouched.

2. Tab refuses on the first block at its level — out loud

A space expresses nesting with one field: parent, pointing at a preceding
sibling. Everything downstream reads it as structural — the renderer's indent,
the markdown export's levels, the outline, the graph, mergeBack re-homing
orphans, the CRDT's per-node diff. So the first block at its level has nothing
to nest under, and indent() walked backwards, found none, and returned. That
was correct and completely silent, which reads as a broken app rather than a
gesture that does not apply. The silence was the bug.

It now says the rule in the status line (the app's existing transient-message
mechanism — a second one is one too many), nudges the block so the message is
connected to the keystroke, and shows the reason on the indent control before
the key is pressed.

The alternative — storing an indent LEVEL, as Google Docs does — was rejected,
and NOT on taste.
It is a permanent format addition and a second way to express
nesting, which every reader above would have to reconcile forever: what a
level: 2 block with a parent means, which wins, what the export emits for an
orphan at level 2, what two replicas do when one sets parent and the other sets
level. A silent Tab costs an afternoon; a duplicate nesting model costs every
future version of the reader. Notion, Workflowy, Bear and Logseq refuse the same
case for the same reason. No format change. ⇧Tab still outdents (and now
explains itself when there is nothing to outdent from), and Tab still commits a
showing calc answer before it considers indenting.

3. Block controls, which existed on no surface at all

The formatting bar is inline-only and appears on a SELECTION — the wrong trigger
for "make this a bullet", which you want with a caret and nothing selected. So
the block's own format is a row at the top of the block menu: per-block by
construction, already a bottom sheet on a phone, and already the home of every
other whole-block action. Text, H1–H3, bulleted, numbered, to-do, quote, outdent,
indent — reusing setType and indent, no second path. Indent and outdent had
no control at any width before this; ⇥ and ⇧⇥ were their only gesture, and a
phone keyboard has neither. ⌘/ opens the menu from the keyboard, because the
gutter is hover-revealed and ⇥ inside a block is indent. The disabled control
keeps aria-disabled rather than disabled, so a keyboard can still reach the
label that explains why it is off.

Verification

node scripts/test-spaces.mjs        all 9 spaces rigs passed
node scripts/test-spaces-caret.ts   45/45 checks
tsc --noEmit                        clean
build:single + shell-gate           conformance gate OK
build-spaces-i18n.mjs --check       606 strings × 8 locales, all complete

scripts/test-spaces-caret.ts is new and registered in both the runner and CI.
Eight sabotages, each red on exactly the intended assertions — including
restoring the shipped bug (fall off the loop with no reason), stepping a table
along its DOM row, comparing line tops for equality, and measuring column
distance to centres instead of to boxes.

The Tab assertions are deliberately the cases a happy path skips: the first item,
the only block, the first child of a container, a block whose only predecessor is
at another level, a block not on the page. A Tab test written against the SECOND
list item passes against the broken code exactly as happily as against the fixed
code
— which is how this shipped.

Caret movement cannot be asserted in node, so it was driven in a real browser
over http://127.0.0.1, a unique filename per build and a computed-style build
marker checked on every probe. Verified case by case: within a wrapped paragraph
(real keys), out of its last line, goal column preserved through a short line,
first/only/empty/last block, nested block, block inside a callout, into and out
of a table by column, table ⇥ still walking the grid, the slash menu, ⌘K,
markdown autoformat (- , ## ), and the calc ghost committing on Tab.

Byte delta, built side by side against origin/main: 277,769 → 282,881 =
+5,112 bytes (+1.84%).

Could not verify: the real phone bottom sheet — the browser pane pins
innerWidth at 1600 and window resizes do not reach the viewport, so the sheet
path was checked by measuring the row's wrap at 288px and 358px containers with
the coarse-pointer 44px targets forced (two rows, no horizontal overflow) rather
than by producing a real 320px viewport.

…efusal, block controls

Three things a reader hits in the first minute, all of them absent rather than
broken, and they are one piece of work because they are the same surface.

ARROW NAVIGATION, which had never existed. Every block is its own
contenteditable host — that is what keeps a Selection block-scoped so a split
or a merge can never re-mint an id — and the price was that the browser's caret
movement stopped at a block boundary. The only ArrowUp/ArrowDown handlers in
the editor were inside the slash menu.

The rule is within the block first: down from the first line of a wrapped
paragraph means its second line, so the question is whether the caret is on the
EDGE VISUAL LINE of its host, measured from the caret's own rectangle against
the host's line boxes. Two things had to be got right and were only found in a
browser: getClientRects() returns a rect per inline BOX, not per line (the
starter space's first paragraph is two lines and five rects), so the boxes are
merged per line before either the edge test or the goal-column clamp reads
them; and a range collapsed at an element boundary reports no rects and an
all-zero bounding box, which had the caret at the end of a wrapped paragraph
deciding it was still on line one and never leaving.

The goal column is kept across consecutive vertical steps and dropped by a
pointer. Left at the start of a block goes to the end of the one before, right
at the end to the start of the next. Containers come free (they render in
document order); a table is stepped by its grid, and entering one lands in the
column the goal column is over. The table's own Tab and Enter are untouched.

TAB REFUSES, OUT LOUD. Nesting is one field — parent, pointing at a preceding
sibling — so the first block at its level has nothing to nest under, and
indent() fell off its backwards walk and returned in silence. The refusal is
right; the silence was the bug. It now says the rule in the status line, nudges
the block, and disables the control with the reason as its label.

The alternative was to store an indent level, which is a permanent format
addition and a second way to express nesting that the renderer, the markdown
export, the outline, the graph and the CRDT would each have to reconcile
forever. Rejected on that; the argument is in spaces/src/nesting.ts and
docs/DECISIONS.md. No format change. Shift+Tab still outdents and now explains
itself too, and Tab still commits a showing calc answer first.

BLOCK CONTROLS. Bullet, numbered, to-do, quote, headings, indent and outdent
had no control anywhere — the formatting bar is inline-only and appears on a
selection, which is the wrong trigger for making a list. They are a row at the
top of the block menu: per-block by construction, a bottom sheet on a phone,
already the home of every other whole-block action. Cmd+/ is its keyboard
route, since the gutter is hover-revealed and Tab inside a block is indent.
Indent and outdent existed on no surface at all before this.

New logic is in new files (caret.ts, nesting.ts, blockbar.ts); the edits to
editor.ts, formatbar.ts and styles.css are kept small and local.
scripts/test-spaces-caret.ts asserts the pure halves — 45 checks, every Tab
case a happy-path test skips, eight sabotages each red on the intended
assertion. Caret movement itself was verified in a real browser over http, with
a build marker on every probe.
@github-actions

Copy link
Copy Markdown

Build size

main (0100083) → spaces-editing-basics (59430a0)

app base PR change
bento/slides 677.9 KiB 677.9 KiB 0.0 KiB (0.00%)
bento/spaces 271.3 KiB 276.3 KiB +5.0 KiB (+1.84%)
bento/dash 424.1 KiB 424.1 KiB 0.0 KiB (0.00%)

Updated: 2026-09-11T18:33:07Z

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant