A Reading-Position Table of Contents for LFM Articles
Why Care?
Long-form content on these sites has outgrown the scrollbar. A recipe with five job sections and a dozen sub-headings gives a reader no way to see the shape of the argument, jump to the part they came for, or know where they are once they're deep in it.
Everything needed to fix that already exists except the component. remarkHeadingIds (LFM 0.4.0) assigns every heading a stable, deduped anchor and attaches an ordered outline to the tree at tree.data.headings — described in its own source as "ready to render a table of contents." No site renders it.
The one ToC in the tree, on lossless-monorepo/site, predates LFM: roughly 1,800 lines across TableOfContents.astro and MobileTableOfContents.astro, with the outline built by scraping the DOM at runtime (querySelectorAll('h1[id], h2[id], …')) because nothing upstream handed it one. That works, and it is the wrong shape now.
What this is
One component family, three viewport-driven states, one reading-position tracker.
| State | Viewport | Placement | Affordance |
| Rail | Wide desktop | Persistent left column, always visible | None — it's just there |
| Collapsed | Laptop / narrow desktop | Hamburger button, left edge | Click expands rightward as an overlay over the content |
| Header | Mobile | Bar pinned at top showing the current heading | Tap expands downward; auto-collapses on selection |
Reading-position tracking runs in all three. On mobile it isn't just a highlight — the current heading is the collapsed button's label, so the bar answers "where am I" without being opened.
The three states in detail
Rail — wide desktop
The default and the simplest. A persistent left column beside the article, no toggle, no overlay. Nested by heading depth. The active heading is highlighted; the rail scrolls internally if the outline is longer than the viewport, without scrolling the page.
Collapsed — laptop and narrow desktop
Below the rail breakpoint the ToC gives up its column rather than crushing the article, and becomes a hamburger button at the left edge.
It expands rightward as an overlay — it does not push the article. Reflowing body text on every ToC open is disorienting mid-read, and the reader opened it to jump somewhere, not to re-read a re-laid-out paragraph. The overlay dismisses on selection, on Esc, and on click-outside.
The trigger is a window width, not a device: the case that matters is a laptop browser sharing the screen with something else, so the breakpoint should be set where the article column starts to suffer, around half a typical laptop width. See open questions — this needs a real number, not a guess.
Header — mobile
A bar pinned to the top of the viewport whose label is the heading the reader is currently inside. Tapping expands the full outline downward; selecting a heading scrolls to it and auto-collapses the bar, because leaving a full-height outline covering the destination defeats the jump.
Reading-position tracking
One mechanism serves all three states.
IntersectionObserverover the heading elements, not a scroll handler — cheaper and steadier.When several headings are in view, the topmost wins. A reader scrolled to the middle of a long section should see that section as active, not the next one that happens to have entered the viewport.
The active entry updates the rail highlight, the overlay highlight, and the mobile bar label from the same state.
Degrade gracefully. With JavaScript disabled the ToC is still a list of working anchor links; only the tracking and the collapse behavior are enhancements.
Respect
prefers-reduced-motionfor scroll-to and expand/collapse transitions.
Data contract
Input is tree.data.headings — LfmHeading[] from LFM ≥ 0.4.0:
interface LfmHeading {
id: string; // final, deduped anchor id; matches the heading node's data.id
text: string; // plain text, markup stripped
depth: 1 | 2 | 3 | 4 | 5 | 6;
duplicateOf?: string; // slug collided with an earlier heading — diagnostics
synthetic?: boolean; // text slugified to nothing; positional id used
}Three consequences the component owns:
The outline is flat. Nesting
<ul><li><ul>fromdepthis the renderer's job.syntheticentries should probably be skipped in the ToC while keeping their anchor — a heading whose text slugified to nothing has no useful label to show.duplicateOfis diagnostics, not display. Render the text; the id already disambiguates.
Non-goals
Not a share-link affordance. That is
HeadingAnchor.astro, specified in the LFM anchors decision doc. Same data, different component.Not per-site restyling beyond tokens. Colors, spacing, and type come from the site's semantic tokens; the component ships structure and behavior.
Not a replacement for
lossless-monorepo/site's ToC in the first pass. That site has the highest link-breakage exposure and goes last.
A note on naming — why this is not TableOfContents--{Variant}.astro
The house convention puts author-selectable variants in -- modifier siblings (PersonCard--Thumb, ImageCarousel--Peek). This component deliberately does not follow that pattern, because its three states are viewport-driven, not author-chosen. An author never picks "the mobile one" — all three exist simultaneously in one render and CSS decides which is visible.
Proposed shape follows the Callout / ImageCarousel split instead:
TableOfContents.astro structure for all three states
table-of-contents.css shared block + element + state styles
table-of-contents.client.ts IntersectionObserver, collapse/expand, label sync
toc-types.ts nesting helper, depth filter, LfmHeading re-exportIf a genuine author-facing variant appears later — a --Inline summary block, say — that takes the modifier suffix.
Dependencies and sequencing
Order matters, and the LFM 0.4.0 release notes are explicit: "Do not adopt the new AstroMarkdown.astro before upgrading the package — on 0.3.x data.id is undefined, so headings would lose their ids entirely."
Upgrade the consuming site to LFM ≥ 0.4.1.
fullstack-vcis pinned at 0.3.0, which predatesremarkHeadingIdsentirely.Delete the site's local slugify.
AstroMarkdown.astrocurrently computes its own heading ids ([^a-z0-9\s-]strip, spaces to dashes) — exactly the driftremarkHeadingIdsexists to end. Readdata.idinstead.Accept the anchor churn. LFM's default slugifier is bug-for-bug compatible with
lossless-monorepo/site's algorithm, which differs from what astro-knots sites compute today. The anchors doc counted 646 anchors that move across astro-knots — judged acceptable because those sites have no share UI, so their fragments are near-exclusively internal ToC jumps that regenerate at build. Verify that still holds before shipping.Build the component against
tree.data.headings.Rewire
lossless-monorepo/sitelast, from DOM-scraping to the data outline.
Open questions
What is the Rail → Collapsed breakpoint, in pixels? "Approaches half-screen" is the intent; it needs a measured number, taken from where the article column starts to suffer rather than from a device table.
Which depths appear?
h2+h3is the common answer for readability; the data supports 1–6. Deep recipes may wanth4. Probably a prop with a sane default.Headings inside callouts and directives — this is the real blocker. A
> [!info]body can contain an###. It deserves an anchor; it almost never deserves a top-level ToC entry.data.headingscannot currently tell the two apart, so a naive ToC over-collects and shows structure that isn't structure. The fix is package-side (inContaineron the outline entry) and is specified inlfm/context-v/Maintain-Table-of-Contents-from-the-Heading-Outline.md. Resolve it there before building here — the alternative is a per-site tree walk, which is precisely the divergence the anchors decision existed to stop.Opt-in or automatic? Every guide, or a frontmatter flag? A three-heading page does not want a rail.
Does the mobile bar stack with existing chrome? Site headers are already sticky on some surfaces; two stacked sticky bars is a real risk.
Acceptance criteria
Renders from
tree.data.headingswith no DOM scraping anywhereAll three states reachable by resizing a single browser window
Collapsed state overlays the article rather than reflowing it
Mobile bar label always names the heading currently being read
Selecting a heading on mobile scrolls and collapses
Topmost visible heading wins when several are in view
Usable with JavaScript disabled — anchors still navigate
Escand click-outside dismiss the overlay; focus returns to the triggerHonors
prefers-reduced-motionCorrect in light, dark, and vibrant modes
Landed in the consuming site's
/design-systemcatalog in the same change
Related
lfm/context-v/Maintain-Table-of-Contents-from-the-Heading-Outline.md— the package-side half of this spec: what the outline contract guarantees, the proposedinContainerflag this is blocked on, and the possiblenestHeadingshelper. Read together; that one owns the data, this one owns the reader.lfm/context-v/Maintain-Heading-Anchors-and-Share-Links.md— the decision that producedremarkHeadingIds, the 646-anchor migration count, and theHeadingAnchor.astrosibling affordanceAn Internet Friendly Responsive UI for Longform Writing — the reader-UI spec this navigates within
Codifying a Comprehensive Extended Markdown Flavor and Shared Package — the LFM spec that owns the data contract
Prior art to replace:
lossless-monorepo/site/src/components/markdown/TableOfContents.astroandMobileTableOfContents.astro, plussrc/utils/markdown/remark-toc.ts(anmdast-util-tocnode injector, superseded bytree.data.headings)