Skip to content

fix(ui): in-page anchor navigation for headings#605

Merged
backnotprop merged 4 commits intobacknotprop:mainfrom
dgrissen2:bug/anchor-problems
Apr 23, 2026
Merged

fix(ui): in-page anchor navigation for headings#605
backnotprop merged 4 commits intobacknotprop:mainfrom
dgrissen2:bug/anchor-problems

Conversation

@dgrissen2
Copy link
Copy Markdown
Contributor

Summary

  • Intercept #-prefixed link clicks in InlineMarkdown and smooth-scroll
    within the sticky scroll viewport. The native browser jump didn't work
    because plan content lives inside a scrollable container, not the
    document root.
  • Expose both a canonical slug and a legacy slug per heading via
    data-anchor-aliases so previously shared URLs keep resolving. Canonical
    collapses runs of separators; legacy preserves them.
  • Handle hashchange and the initial hash so direct links land on the
    right heading after load.
  • New packages/ui/utils/anchors.ts with normalizeAnchorText,
    decodeAnchorHash, slugifyHeadingAnchor, legacySlugifyHeadingAnchor,
    getHeadingAnchorAliases + unit tests.
  • Thread onNavigateAnchor through BlockRenderer, AlertBlock,
    Callout, TableBlock, TablePopout, proseBody.
  • Ignore .serena/ tooling state.

Test plan

  • bun test packages/ui/utils/anchors.test.ts
  • Render a plan with ## Part I: Overview: Scope and click a TOC link
    like [Scope](#part-i-overview-scope) — scrolls smoothly, sticky
    header isn't overlapping the target.
  • Open a shared URL that uses the legacy double-hyphen slug
    (#part-i--overview--scope) — still resolves via alias.
  • Load a page with #some-heading in the URL directly — auto-scrolls
    on first render.
  • Anchor links inside tables, callouts, alerts, and blockquotes all
    navigate in-page.

Intercept `#`-prefixed link clicks and smooth-scroll inside the sticky
scroll viewport instead of letting the browser jump (which broke when the
content lives inside a scrollable container). Headings now expose both a
canonical slug and a legacy slug via `data-anchor-aliases` so older shared
URLs keep resolving. Also listen for `hashchange` / initial hash so direct
links land on the right heading.

Adds `packages/ui/utils/anchors.ts` with `normalizeAnchorText`,
`decodeAnchorHash`, `slugifyHeadingAnchor`, `legacySlugifyHeadingAnchor`,
and `getHeadingAnchorAliases` (+ tests).

Also ignore `.serena/` tooling state.
@backnotprop
Copy link
Copy Markdown
Owner

what's the legacy slug alias for? heading links never worked before this PR so we shouldn't have old URLs to preserve.

@dgrissen2
Copy link
Copy Markdown
Contributor Author

Sloppy coding, good call out. Will work on addressing and removing some complexity.

Anchor navigation didn't work before this branch, so no shared URLs exist
using any slug format — the legacy slug alias was preserving nothing.

The canonical slug was also a redefinition: heading ids are already
written by `utils/slugify.ts` (unicode-aware, `\p{L}\p{N}`). The new
ASCII-only `slugifyHeadingAnchor` would miss unicode headings ("Café"
writes `id="café"` but the nav code looked for `cafe`).

Nav path is now just: decode the hash and `getElementById`. The
sticky-header offset math — the part that actually mattered — stays.
…ocks

Two issues flagged by cross-model review of 9978a40:

1. `useSharing` treated any non-empty `window.location.hash` as a share
   payload. Plain anchor hashes like `#section-overview` fell into
   `parseShareHash`, failed decompression, and popped the "Shared Plan
   Could Not Be Loaded" dialog. Added an `isPlainAnchorHash` guard
   (lowercase ASCII + digits + hyphen) that short-circuits before the
   share path in both the initial load and the hashchange listener.
   Share payloads are base64url-encoded deflate output, so they'll
   always contain uppercase/`=`/`_` and never match the guard.

2. `HtmlBlock` (raw `<details>`, `<summary>`, etc.) skipped the new
   nav handler. `rewriteRelativeRefs` returned early for any `#` href,
   so anchors inside raw HTML fell back to native browser jump which
   doesn't target the OverlayScrollArea. Now intercepts those clicks
   and routes through `onNavigateAnchor` to match the `InlineMarkdown`
   path. Threaded the callback through `BlockRenderer`.
Previous guard rejected any hash that wasn't lowercase ASCII + digits +
hyphen, so Unicode heading ids (`#café`, `#中文-标题`) and raw HTML ids
(`#MySection`) still fell into `parseShareHash` and popped the shared-
plan error dialog.

Flip the predicate: a share payload is base64url (`[A-Za-z0-9_-]`),
realistically ≥30 chars, and virtually always contains at least one
uppercase letter because deflate output has high entropy. Anything else
is handed back to Viewer to scroll to (or silently ignored).

Also add tests/test-fixtures/14-anchor-links.md exercising the full
character range — ASCII, Unicode (accented, CJK, Cyrillic), numeric
start, raw HTML ids — across paragraph, list, blockquote, alert,
directive, table, and raw `<details>` contexts.
@backnotprop backnotprop merged commit 5a41a21 into backnotprop:main Apr 23, 2026
7 checks passed
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.

2 participants