The rail is a mode, not a screen size

Item 7 said to confirm before building, and confirming is what mattered.

The rail's 348px threshold is measured against a fixed 720px column centred
in the pane. The doc-list sidebar is 280px, so at her 1517px viewport the
right margin is 258 with it open and 406 without — either side of the
threshold. What moves between them is distraction-free mode, which engages on
its own when the editor takes focus. The rail therefore appears when she
starts writing and disappears when she stops; items 4 and 5 disagreed about
whether it exists at 1517px only because they caught it in different states.

Re-centring a fixed-width column changes its position and not its size, so
the wrapper's ResizeObserver reported nothing and no window resize fired.
railEnabled kept whatever value it last had. Leaving distraction-free with
the rail up left a 300px column in a 266px margin: overhanging the viewport
by 66px, cards clipped mid-sentence, the page scrolling sideways. Entering it
with the rail down opened 406px of margin and put nothing in it. Both
persisted until something else happened to resize the window.

Observe the scrollport too — it spans the pane, so it resizes whenever the
chrome around the editor does. That covers any future chrome that moves the
editor, which threading focusMode down as a prop would not.

Clicking a highlight now opens the anchored card even when the rail is up.
That is the item's own acceptance criterion and was previously false by
design; the measured distance from the first flagged span to its rail card is
651px, not the ~400 the review guessed. Hover still defers to the rail, since
the reasoning against an unbidden second card was about hover and still
holds — but a click is her asking to deal with that word. The rail card glows
instead of expanding, so nothing is ever open twice.

Verified in Chrome at the review's own 1517x810, driving the rule pack from
item 3b so no model was involved: the rail follows the mode in both
directions with no resize event anywhere; the popover lands 6px under the
word with the full explanation, Ask Petal, Accept and Dismiss; accepting from
it applied the edit and took the rail 6 cards to 5, leaving the rest with
their ids, positions and wording intact.

railFit.test.ts pins the threshold to the margins actually measured. The
observer wiring has no unit test and can't have a useful one: jsdom has no
layout, so every rect is zero and the rail branch is unreachable there. That
half is browser-verified only, and the doc says so.
This commit is contained in:
prosolis
2026-07-28 06:34:27 -07:00
parent ce5de68b9b
commit f082a930cb
4 changed files with 208 additions and 23 deletions
+117 -8
View File
@@ -531,6 +531,99 @@ whichever half is missing.
Accept, without needing the rail; hovering a rail card glows its span and
vice versa.
### 7 — DONE (seventh session). The rail isn't a screen-size fact; it's a mode.
The item says to confirm first and fix whichever half is missing. Confirming
first is what mattered, because the interesting defect wasn't either half.
**Settled first: the contradiction items 4 and 5 left behind.** The fifth
session measured rail cards at 1517px; the sixth found no rail at all at the
same width and wrote down a margin of 258. Both were right. The editor is a
fixed 720px column centred in the pane, and the doc-list sidebar is 280px, so
at 1517px the right margin is **258 with the sidebar open and 406 without**
either side of the rail's 348 threshold. What moves between them is
distraction-free mode, which engages *on its own* the moment the editor takes
focus. So the rail is not a property of her screen. It appears when she starts
writing and disappears when she stops, and both sessions had simply caught it
in different states.
**The bug that fell out of that.** `recomputeRail` was triggered by a
ResizeObserver on the wrapper, a window `resize`, or a change to the suggestion
set. Entering or leaving distraction-free mode is none of the three: the
wrapper is a fixed 720px column, so re-centring it changes its *position* and
never its *size*, and a ResizeObserver reports only size. `railEnabled` therefore
kept whatever value it last had.
Leaving distraction-free with the rail up is the bad direction, and it is not
subtle — measured in Chrome at 1517×810: the 300px column stayed mounted in the
266px margin the restored sidebar left behind, **overhanging the viewport by
66px**, cards clipped mid-sentence ("use "an": "a…"), and the page grew a
horizontal scrollbar it never has otherwise. The other direction is only a loss:
she starts typing, the margin opens to 406, and no rail arrives. Both persisted
indefinitely — dispatching a lone `resize` event was enough to correct either,
which is what proved the measurement was the only thing missing.
**Implemented:**
- `EditorCore.tsx` — the ResizeObserver now watches `.petal-scrollport` as well
as the wrapper. The scrollport spans the pane, so it resizes whenever the
chrome around the editor does; the wrapper, being fixed-width, never does. It
is the element the sticky-pin code already reaches for, so it needed no new
handle, and unlike threading `focusMode` down as a prop it also covers any
future chrome that moves the editor.
- `railFit.ts``RAIL_MIN_MARGIN` and `railFitsBeside` lifted out of the
measurement callback. A bare `>=` doesn't need a name; this one earns it,
because the number picks between two entirely different suggestion surfaces
and the margin it reads moves for reasons unrelated to window size.
- `EditorCore.tsx` — **clicking a highlight now opens the anchored card even
when the rail is up**, which is the item's own acceptance criterion and was
previously false by design. Hover still defers to the rail: an unbidden
floating card next to a margin card saying the same thing is noise, and that
earlier reasoning was about hover and still holds. A click isn't. The rail card
glows instead of expanding, so the suggestion is never open in two places, and
a click-opened card keeps its glow after the pointer leaves (it closes on a
click away) so the margin and the open card don't disagree about what she's
reading.
**Measured, not estimated.** The item guessed ~400px of eye travel from
underline to rail card. At 1517px in distraction-free mode the real distance
from the first flagged span's right edge to its card is **651px**. After the
change the card lands 6px under the word.
**Verified in a real browser at the review's own 1517×810**, driving the local
build with the rule pack from item 3b so no model or VPN was involved:
- *Rail follows the mode, with no resize event anywhere.* Click into the prose →
sidebar collapses, margin 406, rail mounts with its cards, no overflow. Escape
→ sidebar restores, margin 258, rail unmounts, no overflow, no horizontal
scroll. Re-focus → it comes back. Re-run after the `railFit` extraction.
- *Click with the rail up.* Popover opens flush under "a apple" (6px gap, left
edges aligned), carrying the type pill, the diff, the full explanation, Ask
Petal, Accept and Dismiss; it fits the viewport; exactly one rail card glows
and **none is expanded**.
- *Accept from that popover.* Text became "an apple", the popover closed, the
rail went 6 cards → 5, and the other four kept their id, position and wording
— item 2's stability holding under a path it hadn't been exercised on.
- *The two halves the item asked about were already fine.* Span hover lights its
rail card, card hover lights its span (both directions, checked via the
`-active` classes). And with the rail off, clicking an underline already gave
an anchored popover — richer than the item's "one-line reason + more", since
it carries the whole explanation and Ask Petal. Nothing to build there.
**Deliberately not done:** no "more" affordance linking the popover to a rail
card. The item imagined the popover as a teaser for the rail's fuller version;
there is no fuller version — both surfaces render the same explanation, and the
popover has Ask Petal too. Adding a control that expands a second copy of what
she is already reading would be the redundancy the hover rule exists to avoid.
Coverage: `railFit.test.ts` pins the threshold to the margins actually measured
in Chrome — 406 fits, 258 and the mid-animation 266 don't, the bound is
inclusive, 1920-with-sidebar fits, narrow windows never do. **The observer wiring
itself has no unit test and can't have a useful one**: jsdom has no layout, so
every `getBoundingClientRect()` is zero, `railFitsBeside(0, 0)` is false, and the
rail branch is unreachable there. That half is browser-verified only, and is
written down as such rather than covered by a test that would pass regardless.
## 8. Smaller items (each small, do opportunistically)
- **Accept All per category.** Five tense fixes = five clicks today. Add
@@ -611,6 +704,16 @@ renders invisibly, and at her actual viewport the rail is disabled — the inlin
hover card is what she sees, which inverts item 7's premise. Untouched: 6, 7, 8,
item 3's incremental half.)*
*(Seventh session: item 7 done — see the subsection under it. Two things there
are worth carrying forward. First, **the rail is a mode, not a screen size**:
distraction-free engages by itself on editor focus and moves the margin across
the rail's threshold, so "does she see the rail?" has no fixed answer at a given
width — items 4 and 5 disagreed only because they caught it in different states.
Second, the layout invariant that bit here is the same shape as the one item 4
recorded: **a fixed-width column that gets re-centred changes position without
changing size**, and neither a ResizeObserver on it nor a window resize will say
so. Untouched: 6, 8, item 3's incremental half.)*
**Migration 0015 on the live database.** It rebuilds the suggestions table, so
unlike 0014 it could have dropped her rows. Backed up first — and the backup
had to be the whole WAL set (`petal.db`, `-wal`, `-shm` in
@@ -625,14 +728,20 @@ CHECK, and every existing row still carrying the label she has already read (the
seven `clarity` rows include the mislabelled Chinese one — by design, only new
findings get the new type; if you want that card relabelled, edit the sentence).
**Suggested next (sixth session onward):** items 6, 7, 8 are all untouched
and all small; item 5's re-scoped Translate card type is the cheapest
*visible* win (see its Status note — the span is already detected and
already rendered in English, it's only mislabeled as Clarity), and
`internal/suggestions/translate.go` already exists — read it before
designing a new type. Item 3's incremental-surfacing half now has the
chunking it was waiting on, but still needs streaming, which the current
`/check` response shape doesn't do.
**Suggested next (seventh session onward):** **item 6** is the obvious pick —
it is the last small one, it is self-contained (prompt the answer path to reply
in both pair languages, then let the answer area grow to ~50vh instead of a
~100px scrollbox), and item 7 just made the surface it lives on more prominent:
Ask Petal now opens inside a card anchored at the word in *both* layouts, so a
cramped English-only answer is more visible than when the review was written.
Item 8's four are all still small and independent; **dismissal persistence** is
the one with real value now that item 2 gives suggestions stable identity across
checks. Item 3's incremental-surfacing half still needs streaming, which the
current `/check` response shape doesn't do — it remains the largest of what's
left.
*(Superseded, kept for the reading list: the sixth session recommended item 5's
Translate card type, which is now done and live.)*
*The advice below was written for the second session and is kept for its
reading list, not its recommendation: item 3b is done and deployed.* It was