Repository navigation
Inbox: the Workspaces contents rail, moved into @plannotator/ui, for documents - #2384
Merged
Merged
Conversation
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.
…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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.tsxandcomponents/DeferredMarkdownContentsRail.tsx, from Workspaces'apps/web/src/plannotator/(approved 2026-10-03). Each file's header names the source and lists the port changes.aria-current="location".preventDefault-ed, never stopped.requestIdleCallback, 2 s cap) and the rail is a lazy import.Workspaces-only imports moved into the package:
parseBlocksShared(utils/blockParse.ts) andusePrefersReducedMotion(hooks/). Thefloating-surfacechrome and theEYEBROWtitle are now rail rules on the same tokens.Stylesheet:
@plannotator/ui/contents-rail.css. It is its own file and is not part oftheme.cssorstyles.css. Import it outside any CSS cascade layer:It is exported in
package.json("./contents-rail.css"), packed infiles, and checked bypackage-smoke.Optional host props (the defaults reproduce the Workspaces rail):
blocks: already parsed blocks.contentSelector: the reading column. Default[data-document-read-content].hiddenandonAvailableChange: 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.
hooks/useActiveSection, byte-identical to main (git diff origin/main -- packages/ui/hooks/useActiveSection.tsis empty).TableOfContentskeeps its literal 80 px offset.useActiveSection.positions.test.tsxpins plan review's active heading at 8 scroll positions.DocumentContentswhere the rail yields) uses the rail's own scroll math. It moved intoutils/headingScrollSpy.tsas it was in Workspaces:hooks/useHeadingScrollSpyis the same math for a list.The Inbox
.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..ib-docareais a stacking context in Side by side too, and it rises with the popout over the stepping bar.panelAction) hides the rail. The choice is saved in its own cookie,plannotator-inbox-contents;plannotator-toc-enabledis untouched.titleattributes.Tests
DOM (
DOM_TESTS=1 bun test --isolate, one file at a time):MarkdownContentsRail23. These cover:DeferredMarkdownContentsRail2.useActiveSection.positions1, which fails under the 52, 100, 110 and -70% mutations.documentContents14. These cover:useActiveSection1,TableOfContents.linkedDoc3,SidebarContainer.mobile4,App.compactToc.mobile1.fullView25,imageSwipe8,dom-test-allowlist1,entry-assets48.e2e:
inbox-contents.spec.ts11 pass. It covers:inbox-tooltips.spec.ts6 pass.Type checks are clean for
packages/ui, the strict consumer,packages/inboxandapps/inbox.What Workspaces needs in order to switch to the package
@plannotator/uito the release that carries this PR.@plannotator/ui/contents-rail.cssoutside theplannotatorCSS layer, after the base reset. Prove it with a Workspaces frame before deleting thews-markdown-contents-*rules. Class names arepn-contents-rail-*(werews-markdown-contents-*), and custom properties are--pn-contents-*(were--ws-contents-*).src/plannotator/DeferredMarkdownContentsRail.tsxthat imports@/plannotator/setupand re-exports@plannotator/ui/components/DeferredMarkdownContentsRail. Add the specifier toALLOWED_UI_IMPORTS(plannotator-import-boundary.test.ts).markdown,scrollViewport,documentPath. Keepdata-document-read-contenton the reading column; it is the defaultcontentSelector. The rail re-binds on DOM change, so it does not need a key bycontentRenderKey.block-parse.tsre-exports the package's parse cache (@plannotator/ui/utils/blockParse'sparseBlocksShared), so the Viewer and the rail share one parse per save.MarkdownContentsRail.tsxand thews-CSS only after step 2's frame proof.