Skip to content

Inbox: the Workspaces contents rail, moved into @plannotator/ui, for documents - #2384

Merged
backnotprop merged 14 commits into
mainfrom
fix/inbox-document-contents
Oct 30, 2026
Merged

backnotprop merged 14 commits into
mainfrom
fix/inbox-document-contents

Conversation

@backnotprop

@backnotprop backnotprop commented Oct 30, 2026 •

Copy link
Copy Markdown
Owner

A plan or Markdown file opened in the Inbox shows its Contents. On a desktop it is the Workspaces contents rail, moved into @plannotator/ui (owner approval 2026-10-30, answer A). This is a re-cut of the Workspaces code, not a rebuild and not a redesign. Side by side on a laptop, the text column narrows so the rail fits beside it (owner's pick B, 2026-10-30).

The rail (packages/ui)

components/MarkdownContentsRail.tsx and components/DeferredMarkdownContentsRail.tsx, from Workspaces' apps/web/src/plannotator/ (approved 2026-10-03). Each file's header names the source and lists the port changes.

  • A 36 px dock of ticks in the right gutter, 24 px right of the reading column, vertically centred. It never changes the document's width.
  • It shows with 4 or more headings (levels 1 to 4) and a pane at least 480 px wide. It yields when the gutter is under 68 px.
  • At most 10 ticks: a long document is condensed into 10 spans.
  • The current tick has aria-current="location".
  • A tick (or arrows, Home, End, Enter while a tick has focus) opens the 256 px Contents panel to the LEFT of the dock. A pick is marked at once and smooth-scrolls there (instant under reduced motion).
  • Escape on a tick closes the panel. Escape elsewhere also closes a panel opened by pointer, except when focus is in an editable control or a dialog. The key is preventDefault-ed, never stopped.
  • No hover reveal. No left/right setting.
  • The mount is deferred (requestIdleCallback, 2 s cap) and the rail is a lazy import.

Workspaces-only imports moved into the package: parseBlocksShared (utils/blockParse.ts) and usePrefersReducedMotion (hooks/). The floating-surface chrome and the EYEBROW title are now rail rules on the same tokens.

Stylesheet: @plannotator/ui/contents-rail.css. It is its own file and is not part of theme.css or styles.css. Import it outside any CSS cascade layer:

@import "@plannotator/ui/contents-rail.css";

It is exported in package.json ("./contents-rail.css"), packed in files, and checked by package-smoke.

Optional host props (the defaults reproduce the Workspaces rail):

  • blocks: already parsed blocks.
  • contentSelector: the reading column. Default [data-document-read-content].
  • hidden and onAvailableChange: a host's own hide choice, and a fallback where the rail yields.
  • panelAction: one control in the open panel's title row.

Scroll-spies: one per surface

Both mechanisms are in the package, and a surface never has two.

  • Plan review keeps hooks/useActiveSection, byte-identical to main (git diff origin/main -- packages/ui/hooks/useActiveSection.ts is empty).
    • TableOfContents keeps its literal 80 px offset.
    • useActiveSection.positions.test.tsx pins plan review's active heading at 8 scroll positions.
    • These positions tell an 80 px band top from 52, 100 or 110 px, and a 20% bottom from 30%. Each of those mutations fails the test.
  • The rail's surface (the rail, and DocumentContents where the rail yields) uses the rail's own scroll math. It moved into utils/headingScrollSpy.ts as it was in Workspaces:
    • Heading tops are cached when the rail measures.
    • Every scroll frame, a binary search finds the current heading.
    • At the document's end, the last heading is current.
    • A pick is committed at once.
    • Only the rail's own heading ids are read, so an embedded document's headings never move the mark.
    • Any DOM change in the scroll element re-binds the headings. This covers a remounted reading column with the same heading count.
    • One change: the activation line is 80 px (it was 52), so a pick lands where plan review's does.
    • hooks/useHeadingScrollSpy is the same math for a list.
  • Why: a band tracker keeps its last answer while no heading is in the band. So scrolling up, a jump (scrollbar drag, scroll restore, Home) and short panes (no band at 400 px or less) mark the wrong heading. Plan review is not changed.

The Inbox

  • Desktop, Side by side and Full screen: the rail, in .ib-docarea, keyed by the file. The comments column and the full-screen drawer are outside the document scroll, so the rail yields to them.
  • Side by side on a laptop (pick B):
    • The column keeps its left edge and stops 68 px short of the page's right edge, so the rail fits (583 px of text at 1440, 625 px at 1512).
    • A page wide enough to centre 640 px with that gutter is unchanged.
    • This applies only to a file with a rail that is not hidden, and only where the page is at least 480 px wide (the rail's own rule).
    • A file without a rail keeps today's column.
  • Fallback: the Contents button and sheet show only on phones, where a rail has not drawn 2.5 s after a file opens (or after Hide or Show; before that no stand-in flashes), and where the gutter is truly short. In practice that is full screen in an 820 to 880 px window: below 960 px the Inbox switches to full screen, whose 760 px column is not narrowed. Side by side always has room once the column narrows (the page is at least 496 px wide from 960 px up). The sheet lists the rail's headings (levels 1 to 4) and marks them by the rail's math.
  • Popouts:
    • While a table or diagram popout is open, the rail is hidden. It is neither on top nor clickable.
    • .ib-docarea is a stacking context in Side by side too, and it rises with the popout over the stepping bar.
  • Hide and Show:
    • The open panel's Hide (panelAction) hides the rail. The choice is saved in its own cookie, plannotator-inbox-contents; plannotator-toc-enabled is untouched.
    • In Side by side, the layout (not the hidden rail's own measure, taken without the gutter) says whether the rail fits. So Hide always leaves Show.
    • Hide moves focus to the Show button, and Show moves focus to the rail's current tick once it has drawn.
  • Keys and tooltips: no new Inbox shortcuts, and no native title attributes.
  • Plan review: not changed, and it has no rail.

Tests

DOM (DOM_TESTS=1 bun test --isolate, one file at a time):

  • MarkdownContentsRail 23. These cover:
    • Workspaces' scrollTop 500 case, restored.
    • A pick marked at once, with a smooth-scroll mock that does not jump.
    • The last tick marked at the bottom.
    • A heading with an id the rail does not list (an embedded document's): the index is kept.
    • A remounted reading column, with the same heading count, is re-bound.
    • A short pane.
    • Scrolling up by a jump.
    • Escape cases: not stopped; a textarea's, a dialog's and a listbox's Escape left alone (menus too).
    • Blur leaving the panel.
  • DeferredMarkdownContentsRail 2.
  • useActiveSection.positions 1, which fails under the 52, 100, 110 and -70% mutations.
  • Inbox documentContents 14. These cover:
    • The level 4 sheet.
    • A rail that never draws, which leaves the button.
    • Hide and Show focus.
    • The sheet following scroll, up included.
  • Unchanged and passing:
    • useActiveSection 1, TableOfContents.linkedDoc 3, SidebarContainer.mobile 4, App.compactToc.mobile 1.
    • fullView 25, imageSwipe 8, dom-test-allowlist 1, entry-assets 48.

e2e:

  • inbox-contents.spec.ts 11 pass. It covers:
    • Hide then Show at 1440 side by side, with focus following each, and a reload with the rail hidden offering Show, which works.
    • Side by side at 1440 (light, dark) and 1512: the rail is visible and clear of the text, and the left edge is kept.
    • A file without a rail keeps today's column at 1440.
    • Fallback in an 880 px full screen.
    • A table popout covers the rail. This test fails with the popout CSS removed.
    • The phone sheet.
    • Plan review is unchanged.
  • inbox-tooltips.spec.ts 6 pass.

Type checks are clean for packages/ui, the strict consumer, packages/inbox and apps/inbox.

What Workspaces needs in order to switch to the package

  1. Bump @plannotator/ui to the release that carries this PR.
  2. Styles: import @plannotator/ui/contents-rail.css outside the plannotator CSS layer, after the base reset. Prove it with a Workspaces frame before deleting the ws-markdown-contents-* rules. Class names are pn-contents-rail-* (were ws-markdown-contents-*), and custom properties are --pn-contents-* (were --ws-contents-*).
  3. Import boundary: keep a thin src/plannotator/DeferredMarkdownContentsRail.tsx that imports @/plannotator/setup and re-exports @plannotator/ui/components/DeferredMarkdownContentsRail. Add the specifier to ALLOWED_UI_IMPORTS (plannotator-import-boundary.test.ts).
  4. Props are unchanged: markdown, scrollViewport, documentPath. Keep data-document-read-content on the reading column; it is the default contentSelector. The rail re-binds on DOM change, so it does not need a key by contentRenderKey.
  5. Required: block-parse.ts re-exports the package's parse cache (@plannotator/ui/utils/blockParse's parseBlocksShared), so the Viewer and the rail share one parse per save.
  6. Keep Workspaces' approved-behaviour tests (scroll positions, pick landing, last tick at the bottom) and point them at the package part. Two values change: the activation line is 80 px, not 52, and a pick lands 80 px below the top.
  7. Delete the local MarkdownContentsRail.tsx and the ws- CSS only after step 2's frame proof.
  8. Behaviour that is new or different:
    • Escape also closes a pointer-opened panel, unless focus is in an editable control, a dialog, a menu or a listbox. It is not stopped.
    • Blur closes the panel when focus leaves the panel, which is the Workspaces rule.

A Markdown file with 3 or more headings opened in the Inbox now shows the
headings list plan review shows. The Inbox built its document view from
@plannotator/ui's leaf parts in #1759 and the Contents list is wired only
inside plan review's App, so it was never mounted there.

- ui: tocHeadings() is the one place the list's headings are picked
  (buildTocHierarchy and plan review's hasTocEntries use it);
  TableOfContents takes an optional scrollViewport (default: the context,
  plan review unchanged); DocumentContents binds the list and
  useActiveSection to one scroll element.
- Inbox: beside the thread, the top of the comments column above
  Annotations (the page keeps its width); full screen, a 240 px rail on the
  left with the page and toolstrip moved right of it; narrow, phone and the
  phone surface, a Contents button and a sheet. Hide is remembered.
- Tests: documentContents.test.tsx (DOM), inbox-contents.spec.ts (e2e).
…by file area, modal sheet

- DocumentContents marks the last heading scrolled above the band when it mounts
  mid-document (sheet open, column shown again, placement or file change) until
  the observer reports one, and keeps the marked entry in view in its list.
- Full screen decides rail vs sheet from .ib-docarea, so an open comments drawer
  never squeezes the page under the rail.
- The sheet is modal: aria-modal, Tab wraps, Escape closes it first from the
  window's capture phase, focus returns to the Contents button.
- A phone's sheet rows are 44 px in 15 px type, like the phone surface.
- DOM tests: mid-document mark (sheet, column), list follows the mark, narrow
  full-screen fallback, focus and Escape, the phone surface. e2e: phone sheet.
useActiveSection (plan review's) stays the package's only scroll-spy and is
unchanged: ACTIVE_SECTION_BAND_TOP names its 80 px band top, which
TableOfContents already scrolled a pick to. useDocumentActiveSection wraps it
for a list that mounts mid-document (DocumentContents now; the contents rail
next): it reads the band once on mount, then the observer wins.

useActiveSection.positions.test.tsx pins plan review's active heading at fixed
scroll positions (a geometric IntersectionObserver in test-setup); the same
test passes against origin/main's hook.
MarkdownContentsRail and DeferredMarkdownContentsRail, from the Workspaces
app (approved 2026-10-03; the move approved 2026-10-30): a 36 px dock of
ticks 24 px right of the reading column, at most 10, the Contents panel
opening 256 px to the left on click, Escape closes. Same geometry, yield
rules (480 px pane, 68 px gutter), condensing and keys.

Port: its Workspaces imports move into the package (utils/blockParse,
hooks/usePrefersReducedMotion; the floating-surface chrome and eyebrow
become pn-contents-rail-* rules in theme.css on the same tokens, with
fallbacks). Headings from tocHeadings(blocks, 4). The heading being read
comes from the package's scroll-spy, so the rail and TableOfContents agree.
Optional host seams: blocks, contentSelector, hidden + onAvailableChange,
panelAction. Escape closes the open panel wherever focus is.

Tests ported from Workspaces to bun:test, plus agreement with
DocumentContents, Escape order and the seams.
Side by side and Full screen: ui's contents rail in the page's right gutter
(deferred, keyed by the file). Removes #2384's Contents list in the
Annotations column and its full-screen left rail; the page keeps its width
and the toolstrip its place.

Kept from #2384: tocHeadings as the one picker (a file has Contents with 4+
headings of levels 1 to 4, the rail's rule), the hide choice in its own
cookie (Hide in the open panel; a Contents button shows the rail again),
and the phone button and sheet (44 px rows, focus trap, Escape order, the
mark seeded on mount). Where the rail yields (gutter under 68 px, pane
under 480 px) the Contents button opens the sheet, so Contents is always
reachable.
inbox-files-and-guides.md and the packages/ui README (the rail, the one
scroll-spy, DocumentContents) and a HANDOFF row. inbox-contents.spec.ts
proves the rail in Side by side (1680) and Full screen: 24 px right of the
column, centred, clear of the toolstrip and the comments drawer, a tick
opens Contents, a pick scrolls and is marked, Escape closes the panel and
leaves full screen alone; at 1440 side by side it yields to the Contents
button and sheet; the phone sheet as before; plan review draws no rail.
Shots to PLANNOTATOR_CONTENTS_SHOTS.
@backnotprop backnotprop changed the title Inbox: documents show their Contents (headings) list Inbox: the Workspaces contents rail, moved into @plannotator/ui, for documents Oct 30, 2026
…eActiveSection

One mechanism per surface (workspaces-ops' review of #2384): plan review's
useActiveSection is back to main's, byte for byte, and its positions test
now tells an 80 px band top from 52 or 100 px and a 20% bottom from 30%.

The rail's own scroll math moves into utils/headingScrollSpy as it was in
Workspaces (cached heading tops, the binary search, the last heading at the
document's end, a pick marked at once), with an 80 px activation line so a
pick lands where plan review's does. It re-binds on any DOM change in the
scroll element (a remounted reading column with the same heading count),
reads only its own heading ids (an embed's never move the mark), and works
in a pane of any height. DocumentContents, the list on the rail's surface,
uses the same math (useHeadingScrollSpy) and the rail's levels 1 to 4.

Escape closes a pointer-opened panel without stopPropagation, and not while
focus is in an editable control or a dialog; blur closes on leaving the
panel only, as in Workspaces.
@plannotator/ui/contents-rail.css, out of theme.css (and so out of
styles.css), exported and packed. A host imports it outside any CSS layer:
Workspaces loads the package sheet in its first layer, and its later base
reset would beat the rail's margins and paddings. The Inbox imports it in
apps/inbox/index.css.
…ck and focus

Side by side (the owner's pick B): a file with a rail that is not hidden
keeps its column's left edge and narrows it on the right so the rail's
68 px gutter fits (583 px of text at 1440, 625 px at 1512), where the page
is at least 480 px wide. A file without a rail keeps today's column.

A table or diagram popout hides the rail; .ib-docarea is a stacking
context in Side by side too and rises with the popout over the stepping
bar. The Contents button and sheet stand in until the rail says it fits,
so a rail that never loads still leaves a way; the sheet lists the rail's
headings (levels 1 to 4). Hide moves focus to Show, and Show to the rail's
current tick.

e2e: the rail at 1440 (light, dark) and 1512, clear of the text with the
left edge kept; a file without a rail at 1440; the fallback in an 880 px
full screen; a popout over the rail (fails without the CSS).
The rail's Escape for a pointer-opened panel now also leaves the key alone
while focus is in [role=menu] or [role=listbox] (delta review N5).
…rail

Side by side, whether the rail fits is the layout's to say (the page at
least 480 px, the gutter then made by the narrowed column), not the hidden
rail's own measure, which it takes without the gutter. So Hide at 1440 and
1512 leaves Show, focused, and Show brings the rail back. The focus request
waits until its target has drawn (the rail measures again before its ticks
draw). The 480 px rule is measured on the rail's own box.

No stand-in button while the rail may still be on its way (2.5 s from a file
opening, a Hide or a Show); past that, a rail that never drew leaves the
Contents button and sheet.

e2e at 1440: Hide then Show with focus following, and a reload with the
rail hidden offering Show, which works. The popout layering comment says
where the area rises (Side by side only).
@backnotprop backnotprop added the ci:full Run the heavy CI tier on this PR (added by the merge script) label Oct 30, 2026
@backnotprop
backnotprop merged commit 96825e4 into main Oct 30, 2026
83 of 85 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci:full Run the heavy CI tier on this PR (added by the merge script)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant