Note heading links should survive heading renames #881

Open
opened 2026-10-02 17:30:42 +00:00 by kayg · 10 comments
Owner

Evidence

The Note editor builds each heading URL from slugify(title) (apps/web/src/lib/notes/headingLinks.ts:56-61), and noteRoute stores that slug in the fragment (apps/web/src/lib/notes/paths.ts:52-60). Renaming a heading changes the target of its copied link.

Rule conflict

DESIGN §33 specifies /n/<id>#<heading-slug>, but also says links survive renames. CLAUDE.md, Deep links, requires stable identities rather than names that change. A heading slug is derived from mutable text.

Expected behavior

A copied Note-heading link continues to open the same heading after its title changes. Reconcile the fragment grammar with the stable-link rule; a stable heading or block identity can be used, with compatibility for old slug links decided as part of the route change.

Test idea

Copy a heading link, rename the heading, then open the old link in a fresh page. Verify that the same heading is selected and scrolled into view. Also verify that an old slug link remains handled according to the chosen compatibility rule.

## Evidence The Note editor builds each heading URL from `slugify(title)` (`apps/web/src/lib/notes/headingLinks.ts:56-61`), and `noteRoute` stores that slug in the fragment (`apps/web/src/lib/notes/paths.ts:52-60`). Renaming a heading changes the target of its copied link. ## Rule conflict DESIGN §33 specifies `/n/<id>#<heading-slug>`, but also says links survive renames. CLAUDE.md, Deep links, requires stable identities rather than names that change. A heading slug is derived from mutable text. ## Expected behavior A copied Note-heading link continues to open the same heading after its title changes. Reconcile the fragment grammar with the stable-link rule; a stable heading or block identity can be used, with compatibility for old slug links decided as part of the route change. ## Test idea Copy a heading link, rename the heading, then open the old link in a fresh page. Verify that the same heading is selected and scrolled into view. Also verify that an old slug link remains handled according to the chosen compatibility rule.
Author
Owner

Owner decision (2026-10-02): headings link by a stable block ID (hidden permanent ID, like Log entries); links survive renames. A readable slug may still be shown, but the ID decides. Update DESIGN §33 grammar accordingly.

Owner decision (2026-10-02): headings link by a **stable block ID** (hidden permanent ID, like Log entries); links survive renames. A readable slug may still be shown, but the ID decides. Update DESIGN §33 grammar accordingly.
Author
Owner

Starting #881 on branch job/headings-881, based on c4faf184df726a9375ae0c13bdfb6018ac2cf57e. I’m tracing the existing block-ID assignment and route resolution so heading links use the shared stable ID mechanism, while keeping note opens read-only.

Starting #881 on branch `job/headings-881`, based on `c4faf184df726a9375ae0c13bdfb6018ac2cf57e`. I’m tracing the existing block-ID assignment and route resolution so heading links use the shared stable ID mechanism, while keeping note opens read-only.
Author
Owner

The focused regression reproduced the defect: ensureAnchorAt returned { kind: 'heading', slug: ... }, so a copied heading link depended on mutable text. The heading link path now resolves through ensureAnchorAt and the existing ^block-id writer; opening a slug still calls the read-only resolver. I chose ID-only new heading fragments (#^<block-id>), with no readable slug suffix; old slug fragments remain accepted for compatibility.

The focused regression reproduced the defect: `ensureAnchorAt` returned `{ kind: 'heading', slug: ... }`, so a copied heading link depended on mutable text. The heading link path now resolves through `ensureAnchorAt` and the existing `^block-id` writer; opening a slug still calls the read-only resolver. I chose ID-only new heading fragments (`#^<block-id>`), with no readable slug suffix; old slug fragments remain accepted for compatibility.
Author
Owner

Verification update: CURRENT was absent, so I started the local server build with the required job limits. The first attempt stopped because sccache pointed to another worktree's target/tmp; the retry runs without the wrapper. It is in the calternal-server link step. The rust-lld process has waited on disk I/O for about 12 minutes (folio_wait_bit_common, 565 MB read) with no build output. No Rust source changed. I will run the focused real-server browser check when the build exits.

Verification update: `CURRENT` was absent, so I started the local server build with the required job limits. The first attempt stopped because sccache pointed to another worktree's `target/tmp`; the retry runs without the wrapper. It is in the `calternal-server` link step. The `rust-lld` process has waited on disk I/O for about 12 minutes (`folio_wait_bit_common`, 565 MB read) with no build output. No Rust source changed. I will run the focused real-server browser check when the build exits.
Author
Owner

The focused deep-links run stopped before the #881 section in the existing Task move fixture. Evidence: seed returns Notes/20261003-deep-link-follow-up-c7af8732.md; rename keeps that Notes/ directory, but the next move request asks for Tasks/renamed-deep-link-task.md, so the real server returns 404 file not found. The expected destination remains Tasks/Projects/renamed-deep-link-task.md; I will correct only the move request's source path to match the renamed file's actual directory.

The focused deep-links run stopped before the #881 section in the existing Task move fixture. Evidence: seed returns `Notes/20261003-deep-link-follow-up-c7af8732.md`; rename keeps that `Notes/` directory, but the next move request asks for `Tasks/renamed-deep-link-task.md`, so the real server returns 404 `file not found`. The expected destination remains `Tasks/Projects/renamed-deep-link-task.md`; I will correct only the move request's source path to match the renamed file's actual directory.
Author
Owner

After correcting the Task fixture's move source, the existing Task scenario progresses farther but then times out after 30 seconds waiting for getByRole('article', { name: 'Task' }).getByRole('button', { name: 'Copy link to this task' }) to be visible. This is outside #881. I am keeping its existing assertion unchanged and isolating the #881 Note flow ahead of that scenario.

After correcting the Task fixture's move source, the existing Task scenario progresses farther but then times out after 30 seconds waiting for `getByRole('article', { name: 'Task' }).getByRole('button', { name: 'Copy link to this task' })` to be visible. This is outside #881. I am keeping its existing assertion unchanged and isolating the #881 Note flow ahead of that scenario.
Author
Owner

#881 update: the browser diagnostic found that the earlier run served the server binary's embedded SPA, which predates this branch's UI bundle. I rebuilt apps/web and enabled the existing routeCurrentBuild override so current production assets load while API and Notes WebSocket traffic still use the real server. The isolated e2e then reached server startup but could not pass /readyz within 60 seconds; calternal-server was in Linux D state at folio_wait_bit_common during the attempt. No heading assertion ran in that attempt. The focused ProseMirror widget regression passes.

#881 update: the browser diagnostic found that the earlier run served the server binary's embedded SPA, which predates this branch's UI bundle. I rebuilt `apps/web` and enabled the existing `routeCurrentBuild` override so current production assets load while API and Notes WebSocket traffic still use the real server. The isolated e2e then reached server startup but could not pass `/readyz` within 60 seconds; `calternal-server` was in Linux `D` state at `folio_wait_bit_common` during the attempt. No heading assertion ran in that attempt. The focused ProseMirror widget regression passes.
Author
Owner

Correction to my prior #881 progress note: the server did eventually pass /readyz, register the owner and seed the real fixtures. The latest diagnostic run reached its first page navigation, but I terminated the Bun process while inspecting the long startup because I mistook the delay for a persistent hang; that stopped the browser and server before heading assertions ran. The server logged slow SQLite connection/query warnings under current host load. I am rerunning the isolated Note flow without interrupting it.

Correction to my prior #881 progress note: the server did eventually pass `/readyz`, register the owner and seed the real fixtures. The latest diagnostic run reached its first page navigation, but I terminated the Bun process while inspecting the long startup because I mistook the delay for a persistent hang; that stopped the browser and server before heading assertions ran. The server logged slow SQLite connection/query warnings under current host load. I am rerunning the isolated Note flow without interrupting it.
Author
Owner

#881 implementation report

Branch: job/headings-881
Head: fc52a14ca38654f60425fb9dfa31d13750ced215

Built stable heading-link parsing and lazy Block ID assignment on copy, kept old heading slugs read-only, added fallback-editor URL anchor restoration, and added a focused production browser flow. DESIGN §33 and CONTEXT document the grammar and glossary. DESIGN §§10, 13, 18, and 23 now state that the AI Agent uses scoped CLI/API access and no FUSE.

The browser run proved the fallback editor now scrolls an old slug heading into view, but it stopped at the primary action: the production DOM has an empty ProseMirror widget and no Copy link button. The stable copy/rename/reopen assertions did not run. The six macOS-emulated screenshots (390, 820, 1440 px in light and dark) were not captured. This is an open UX gap and blocks calling #881 complete.

Production browser evidence:

heading action DOM {
  html: "<h2 class=\"linked-heading linked-heading-touch-always\"><span class=\"linked-heading-text\">Milestones</span><span contenteditable=\"false\" class=\"ProseMirror-widget\"></span><img class=\"ProseMirror-separator\" alt=\"\"><br class=\"ProseMirror-trailingBreak\"></h2>",
  buttons: []
}
TimeoutError: click: Timeout 30000ms exceeded.
Call log:
  - waiting for getByRole('button', { name: 'Copy link to Milestones' })
CSP REPORTS deeplinks: 0 across 2 pages

Gates and checks:

bun run check output:

$ node scripts/check-user-storage.mjs && node scripts/check-type-tokens.mjs && node scripts/check-motion-tokens.mjs && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json
User browser caches use userStorage; only documented device/public-link exceptions remain.
Text sizes and UI shape values use shared role tokens.
UI transitions and animation options use shared motion tokens or documented exceptions.
Loading svelte-check in workspace: /home/kayg/Developer/calternal-wt/headings-881/apps/web
Getting Svelte diagnostics...

svelte-check found 0 errors and 0 warnings

Focused widget regression output:

 RUN  v5.0.1 /home/kayg/Developer/calternal-wt/headings-881/apps/web

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  01:48:50
   Duration  30.74s (transform 52%, environment 30%, setup 9%, import 7%, tests 2%)

node --check apps/web/e2e/deeplinks.mjs: exit 0.
Production web build completed: ✓ built in 2m 24s.
No Rust source changed, so Rust fmt/clippy/test gates were not run. The full web suite, full e2e suite, adversarial matrix, and perf measurement remain for the merge round under the verification policy. The #881 bench profile exists but was not measured because this is not a performance issue.

Decision not explicit in DESIGN: use the compact #^<block-id> form without a readable slug suffix. The stable Block ID decides the target and survives a rename. The AI Agent documentation follows the explicit owner decision: scoped CLI/API access only, no FUSE.

UX gaps closed: legacy slug resolution does not write the Note; fallback-editor slug navigation scrolls the target into view. UX gaps left: the production fallback editor does not render its Copy link action, so stable copy, rename/reopen, and screenshot evidence remain unverified.

#881 implementation report Branch: `job/headings-881` Head: `fc52a14ca38654f60425fb9dfa31d13750ced215` Built stable heading-link parsing and lazy Block ID assignment on copy, kept old heading slugs read-only, added fallback-editor URL anchor restoration, and added a focused production browser flow. DESIGN §33 and CONTEXT document the grammar and glossary. DESIGN §§10, 13, 18, and 23 now state that the AI Agent uses scoped CLI/API access and no FUSE. The browser run proved the fallback editor now scrolls an old slug heading into view, but it stopped at the primary action: the production DOM has an empty ProseMirror widget and no Copy link button. The stable copy/rename/reopen assertions did not run. The six macOS-emulated screenshots (390, 820, 1440 px in light and dark) were not captured. This is an open UX gap and blocks calling #881 complete. Production browser evidence: ``` heading action DOM { html: "<h2 class=\"linked-heading linked-heading-touch-always\"><span class=\"linked-heading-text\">Milestones</span><span contenteditable=\"false\" class=\"ProseMirror-widget\"></span><img class=\"ProseMirror-separator\" alt=\"\"><br class=\"ProseMirror-trailingBreak\"></h2>", buttons: [] } TimeoutError: click: Timeout 30000ms exceeded. Call log: - waiting for getByRole('button', { name: 'Copy link to Milestones' }) CSP REPORTS deeplinks: 0 across 2 pages ``` Gates and checks: `bun run check` output: ``` $ node scripts/check-user-storage.mjs && node scripts/check-type-tokens.mjs && node scripts/check-motion-tokens.mjs && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json User browser caches use userStorage; only documented device/public-link exceptions remain. Text sizes and UI shape values use shared role tokens. UI transitions and animation options use shared motion tokens or documented exceptions. Loading svelte-check in workspace: /home/kayg/Developer/calternal-wt/headings-881/apps/web Getting Svelte diagnostics... svelte-check found 0 errors and 0 warnings ``` Focused widget regression output: ``` RUN v5.0.1 /home/kayg/Developer/calternal-wt/headings-881/apps/web Test Files 1 passed (1) Tests 1 passed (1) Start at 01:48:50 Duration 30.74s (transform 52%, environment 30%, setup 9%, import 7%, tests 2%) ``` `node --check apps/web/e2e/deeplinks.mjs`: exit 0. Production web build completed: `✓ built in 2m 24s`. No Rust source changed, so Rust fmt/clippy/test gates were not run. The full web suite, full e2e suite, adversarial matrix, and perf measurement remain for the merge round under the verification policy. The #881 bench profile exists but was not measured because this is not a performance issue. Decision not explicit in DESIGN: use the compact `#^<block-id>` form without a readable slug suffix. The stable Block ID decides the target and survives a rename. The AI Agent documentation follows the explicit owner decision: scoped CLI/API access only, no FUSE. UX gaps closed: legacy slug resolution does not write the Note; fallback-editor slug navigation scrolls the target into view. UX gaps left: the production fallback editor does not render its Copy link action, so stable copy, rename/reopen, and screenshot evidence remain unverified.
Author
Owner

#881 blocker fixed on job/headings-881 (not pushed). Head 83510e969.

Root cause: ProseMirror redraws a heading when its DOM changes (here: the URL-anchor reveal added cal-anchor-flash straight to the heading element). On a redraw it calls the widget toDOM again, then destroys the old DOM. The heading widget kept one shared component slot, so destroying the old DOM unmounted the NEW Copy link control and left an empty ProseMirror-widget. This hit the fallback editor (and any redraw in the live editor).

Commits:

  • 18cab7405 fix(notes): keep heading Copy link after a widget redraw — a WeakMap from widget DOM to its mounted LinkedHeading; destroy(node) unmounts only that one. Same shared LinkedHeading/CopyLink component as the main editor.
  • 96d54fd14 fix(notes): flash revealed anchors through a ProseMirror decoration — revealAnchor registers a small plugin on first use (live and fallback editor) and applies cal-anchor-flash as a node decoration, so ProseMirror no longer redraws the heading and the highlight is actually visible.
  • 83510e969 test(notes): run the heading link flow on the current build — e2e fixes (user-storage seam for the theme helper, reload after theme change, mkdir before Note move, longer fallback-save deadline on a busy host, heading text match).

Regression tests: headingLinks.svelte.test.ts covers a redraw (fails without the fix: 0 buttons) and a reveal flash that keeps exactly one Copy link.

Gates: bun run check 0 errors, 0 warnings. Vitest headingLinks + anchors + copy-link-resolver: 8 passed.
Production browser run (CALTERNAL_E2E_ASSET_OVERRIDE=1 bun e2e/deeplinks.mjs --heading-881 --screenshots …, prebuilt merge-round-7a server, current assets, fallback editor because the Notes WebSocket was blocked in headless Chromium):

PASS heading Copy link survives a heading rename, old slugs open without writes, and the target is highlighted
CSP REPORTS deeplinks: 0 across 4 pages
heading links e2e: passed

Screenshots: artifacts/headings-881/heading-881-{paper-white,tokyo-night}-{390,820,1440}.png.

Seen, not fixed here: in the fallback editor at 390 px the text is clipped at the left edge and paragraphs have no block spacing; the editor heading's accessible name includes the Copy link label ("Stable link plan Copy link to Stable link plan").

#881 blocker fixed on `job/headings-881` (not pushed). Head `83510e969`. Root cause: ProseMirror redraws a heading when its DOM changes (here: the URL-anchor reveal added `cal-anchor-flash` straight to the heading element). On a redraw it calls the widget `toDOM` again, then destroys the old DOM. The heading widget kept one shared component slot, so destroying the old DOM unmounted the NEW Copy link control and left an empty `ProseMirror-widget`. This hit the fallback editor (and any redraw in the live editor). Commits: - `18cab7405` fix(notes): keep heading Copy link after a widget redraw — a `WeakMap` from widget DOM to its mounted `LinkedHeading`; `destroy(node)` unmounts only that one. Same shared `LinkedHeading`/`CopyLink` component as the main editor. - `96d54fd14` fix(notes): flash revealed anchors through a ProseMirror decoration — `revealAnchor` registers a small plugin on first use (live and fallback editor) and applies `cal-anchor-flash` as a node decoration, so ProseMirror no longer redraws the heading and the highlight is actually visible. - `83510e969` test(notes): run the heading link flow on the current build — e2e fixes (user-storage seam for the theme helper, reload after theme change, mkdir before Note move, longer fallback-save deadline on a busy host, heading text match). Regression tests: `headingLinks.svelte.test.ts` covers a redraw (fails without the fix: 0 buttons) and a reveal flash that keeps exactly one Copy link. Gates: `bun run check` 0 errors, 0 warnings. Vitest headingLinks + anchors + copy-link-resolver: 8 passed. Production browser run (`CALTERNAL_E2E_ASSET_OVERRIDE=1 bun e2e/deeplinks.mjs --heading-881 --screenshots …`, prebuilt merge-round-7a server, current assets, fallback editor because the Notes WebSocket was blocked in headless Chromium): ``` PASS heading Copy link survives a heading rename, old slugs open without writes, and the target is highlighted CSP REPORTS deeplinks: 0 across 4 pages heading links e2e: passed ``` Screenshots: `artifacts/headings-881/heading-881-{paper-white,tokyo-night}-{390,820,1440}.png`. Seen, not fixed here: in the fallback editor at 390 px the text is clipped at the left edge and paragraphs have no block spacing; the editor heading's accessible name includes the Copy link label ("Stable link plan Copy link to Stable link plan").
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/calternal#881
No description provided.