Links say what happened to their note; day links open their day; # tag picker; contrast (#448, #449, #450) #472

Closed
kayg wants to merge 0 commits from issue/448-links-app-3 into dev
Owner

Builds kayg's approved links, tags and contrast design (mockup PR #457, links.html) into the app. Do not merge: kayg gives the final go. Replaces #465 (which replaced #464) (same work, plus the review fixes below).

Review fixes since #464

  • Day links: a link to a day with no note yet keeps its day icon and opens that day through daily.open (the Journal's way, id day-YYYY-MM-DD), never an ordinary note named after the date. Weeks, months and years keep their icon too.
  • Live looks: a path asked about again while a request for it was out is asked once more when the answer comes back (that answer could be from before the change), and a link whose note leaves the list asks at once. New test: a link follows made → trashed → restored → deleted for good, all done through the API as another device would (repeated 4× in Chromium and WebKit, green).
  • The simulator review showed stale looks from two causes: my earlier session there had restored "Old budget" and made "Calternal ideas", and Safari's service worker was still serving the previous build. After a fresh server, a reseed and clearing the service worker, every state shows correctly (shot on #448).

Closes #448, closes #449, closes #450.

What it does

Links read like words (#450, chip=icon-accent). No pill and no underline at rest: a small icon for what the link points at (note, day, photo, file, web) and the words in a deep tint of the accent. The pill shows on hover or with the caret inside; its room is always there, so no word moves, and an equal negative margin means nothing sits between a link and the full stop after it.

Links to notes that aren't there (#448).

  • Never written: a note-with-+ icon at 70% ink (icondim). A click, or ⌘↵ / Ctrl+Enter with the caret inside, makes the note at the path the link points to, titled with the link's words, opens it (caret ready under the title on a wide screen; on a phone the keyboard goes, so the toast stays), and toasts "Made “X” · Undo". Undo is the job's undo: the note is deleted for real (not to the Trash) if nobody typed in it, and the link reads as never written again.
  • In the Trash: the trash icon replaces the note icon. A card shows the note's first lines read only, with Restore ⌘↵ (the link works again) and Open (the whole note, in the card; shown when there is more than the first lines).
  • Deleted for good: "“X” was deleted on 12 Oct", with Create a new note ⌘↵ and Remove link (the words stay; Undo puts the link back).
  • Moved outside the app: found by its id, the server repoints the links in the open note, and one toast says "Link updated". Renames in the app already repoint every link (unchanged).
  • Cards and pickers use the shared #319 glass (Glass dense / float-glass-behind dense), and close on Esc (caret back where it was), a click elsewhere, the caret leaving what they complete, and any move to another note or screen. ⌘↵ shows a card at once; a click springs it out of the link.
  • There was no "Linked from … · unlinked mentions" line above the title in the app; a test now keeps it that way.

Tags (#449). # and a letter opens the same glass list as [[ / @: prefix matches first, then loose ones; nested tags one level at a time (Tab on area gives #area/ and its children); each row shows its note count; Enter or Tab takes a row, Space keeps typing, Esc closes; no "create" row; #12 never opens it. Counts come from the note list, worked out once per list change.

Handle and → ⋯ pill (#450, handle=ink). Full-ink icons on the dense floating glass.

How (plugins, small files)

  • Server (crates/notes): history/departed.rs keeps one row per path a note left (renamed, moved outside, sent to the Trash; marked when deleted for good), fed from the notes events as they are recorded. missing.rs answers notes.link-states {paths} (≤200, in order, paths never echoed) with one index lookup per path and one read of that table for the rest: never a walk of the Home. missing/make.rs is notes.make-from-link {path, title} (a one-step job); missing/relink.rs is notes.relink {id, from} (through the room, keyed, so two devices change the text once). Outside renames now carry from in their event. Per user throughout; the isolation gate now also attacks paths with Alice's own file name.
  • Editor (packages/editor): link kinds and a linkState facet the app answers; linkAt; transforms/picker.ts (one autocompletion for [[, @ and #), tags.ts, tag-rank.ts (unit-tested); a surface class option for the margin controls.
  • App (apps/web/src/lib/links/): states.ts (answers kept per path for the page, asked in 30 ms batches, re-asked quietly 400 ms after a note comes, goes or moves), cm-links.ts (drawing, press-release follow, ⌘↵), follow.ts, card.svelte.ts + LinkCard.svelte, relink.ts, peek-lines.ts, resolve.ts. The old regex scan of visible lines on every change (missingLinks) is gone.
  • Docs: ADR 0040, CONTEXT.md, TASTE.md.

Performance

Nothing runs per keystroke: drawing a link reads two maps; the server is asked once per batch of unknown paths, never while typing (a test types into a note with 60 never-written links and counts 0 requests).

Typing cost in a note with 200 links (half to existing notes, half never written), in-page dispatch-to-layout per keystroke, 300 keystrokes, 3 runs each, host load 25–30 on 12 cores:

before p50 / p95 after p50 / p95
Chromium, at the end 6.8–8.3 / 23–31 ms 5.7–6.8 / 18–21 ms
Chromium, mid-note 8.8–9.9 / 31–48 ms 7.6–8.7 / 15–25 ms
WebKit, at the end 7–10 / 37–46 ms 6–8 / 22–54 ms
WebKit, mid-note 7–9 / 30–39 ms 7–8 / 20–24 ms

(The existing keystroke-to-paint @perf test was also run before: on this loaded host it measured 300+ ms p50 either way, so the in-page probe is what's comparable.)

light paper dark paper light photo, page 0% dark photo, page 0%
link words / icon 9.2 / 9.2 7.3 / 7.3 9.0 / 9.1 6.1 / 6.1
never written, words / icon 5.4 / 5.4 7.0 / 7.0 4.9 / 5.6 6.3 / 5.7
in the Trash, words / icon 5.4 / 5.4 7.0 / 7.0 5.0 / 4.9 6.3 / 6.4
handle / → / ⋯ 14.3 / 14.3 / 14.3 13.7 / 13.5 / 13.5 13.4 / 10.0 / 10.3 7.8 / 9.5 / 9.4

One deviation for kayg to judge: over a photo with the page at 0%, the approved no-pill look measured 1.2–1.5:1 at the worst spots (the photo here is busier than the mockup's). On pages under 30% opacity only, a link now sits on a soft wash of paper (no edge, no shadow); on paper nothing changes. The editor's text also gets the faint-page halo it was meant to have (it targeted the mockup's .body, not .cm-content).

Tests

  • New: missing-links.spec.ts (10), links-look.spec.ts (4), tag-picker.spec.ts (4), link-contrast.spec.ts (contrast project), crates/notes/src/tests/missing.rs (7), packages/editor/test/tag-rank.test.ts (5).
  • Rust: cargo fmt --check clean, clippy --workspace --all-targets -D warnings clean, workspace tests 429 passed, 0 failed (incl. the isolation gate, which now also attacks paths). Editor unit tests 117 passed. svelte-check 0 errors; action types match the contract.
  • Browser after the review fixes: link specs (missing-links, links-look, tag-picker, links, chips, picker, trash) 24/24 chromium-390, 24/24 webkit-390, 34/34 chromium-1440, 34/34 webkit-1440; contrast project 1/1.
  • Earlier full run (--no-deps, chromium-390, webkit-390, chromium-1440, webkit-1440; the new specs plus links, chips, picker, tags, tag-rename, trash, editor, shortcuts, keys-, bar-, glass): full run all green apart from three on a host at load 30–55: trash.spec "a deleted note waits in the Trash" (WebKit 1440, also fails on the base branch: "element is not stable"), and timeouts in bar-focus (WebKit 390) and tag-rename Undo (WebKit 1440), both passing when rerun on this branch. The link specs were rerun on all four projects after the last fixes: 17/17, 17/17, 27/27, 26/27 (the same Trash test).
  • Contrast project: link-contrast.spec.ts passes (table above).
  • Two existing tests changed: bar-stays clicked the middle of a line whose middle is a link (a click now follows a link, so it clicks the line's start), and trash.spec looks for the Trash icon class instead of the old cm-link-missing.

Screenshots and the simulator run are on #448, #449 and #450.

🤖 Generated with Claude Code

https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T

Changes since #465 (kayg's decisions)

  • No paper wash behind links on pages under 30% opacity: its CSS is gone. Links look the same at every opacity (the approved icon-accent look). The contrast spec still runs, but only on paper; it no longer measures over a photo.
  • Card buttons side by side. Trash card: Restore + Open in one row. Deleted card: Create a new note + Remove link in one row. If the row does not fit the card, the obvious button (Restore, Create a new note) becomes icon-only (aria-label, tooltip with its shortcut, 44 px target on touch) and the other keeps icon + words. At 320 wide: the Trash card fits both with words; the deleted card shows Create as an icon only. Cards are now 380 px wide at most for both.
  • Rebased onto current dev (merged conflicts in crates/notes: scan keeps the chunked events and carries the from path).

Do not merge: kayg gives the final go.

Builds kayg's approved links, tags and contrast design (mockup PR #457, `links.html`) into the app. **Do not merge: kayg gives the final go.** Replaces #465 (which replaced #464) (same work, plus the review fixes below). ## Review fixes since #464 - **Day links:** a link to a day with no note yet keeps its day icon and opens that day through `daily.open` (the Journal's way, id `day-YYYY-MM-DD`), never an ordinary note named after the date. Weeks, months and years keep their icon too. - **Live looks:** a path asked about again while a request for it was out is asked once more when the answer comes back (that answer could be from before the change), and a link whose note leaves the list asks at once. New test: a link follows made → trashed → restored → deleted for good, all done through the API as another device would (repeated 4× in Chromium and WebKit, green). - The simulator review showed stale looks from two causes: my earlier session there had restored "Old budget" and made "Calternal ideas", and Safari's service worker was still serving the previous build. After a fresh server, a reseed and clearing the service worker, every state shows correctly (shot on #448). Closes #448, closes #449, closes #450. ## What it does **Links read like words (#450, `chip=icon-accent`).** No pill and no underline at rest: a small icon for what the link points at (note, day, photo, file, web) and the words in a deep tint of the accent. The pill shows on hover or with the caret inside; its room is always there, so no word moves, and an equal negative margin means nothing sits between a link and the full stop after it. **Links to notes that aren't there (#448).** - Never written: a note-with-+ icon at 70% ink (`icondim`). A click, or ⌘↵ / Ctrl+Enter with the caret inside, makes the note at the path the link points to, titled with the link's words, opens it (caret ready under the title on a wide screen; on a phone the keyboard goes, so the toast stays), and toasts "Made “X” · Undo". Undo is the job's undo: the note is deleted for real (not to the Trash) if nobody typed in it, and the link reads as never written again. - In the Trash: the trash icon replaces the note icon. A card shows the note's first lines read only, with Restore ⌘↵ (the link works again) and Open (the whole note, in the card; shown when there is more than the first lines). - Deleted for good: "“X” was deleted on 12 Oct", with Create a new note ⌘↵ and Remove link (the words stay; Undo puts the link back). - Moved outside the app: found by its id, the server repoints the links in the open note, and one toast says "Link updated". Renames in the app already repoint every link (unchanged). - Cards and pickers use the shared #319 glass (`Glass dense` / `float-glass-behind dense`), and close on Esc (caret back where it was), a click elsewhere, the caret leaving what they complete, and any move to another note or screen. ⌘↵ shows a card at once; a click springs it out of the link. - There was no "Linked from … · unlinked mentions" line above the title in the app; a test now keeps it that way. **Tags (#449).** `#` and a letter opens the same glass list as `[[` / `@`: prefix matches first, then loose ones; nested tags one level at a time (Tab on `area` gives `#area/` and its children); each row shows its note count; Enter or Tab takes a row, Space keeps typing, Esc closes; no "create" row; `#12` never opens it. Counts come from the note list, worked out once per list change. **Handle and → ⋯ pill (#450, `handle=ink`).** Full-ink icons on the dense floating glass. ## How (plugins, small files) - Server (`crates/notes`): `history/departed.rs` keeps one row per path a note left (renamed, moved outside, sent to the Trash; marked when deleted for good), fed from the notes events as they are recorded. `missing.rs` answers `notes.link-states {paths}` (≤200, in order, paths never echoed) with one index lookup per path and one read of that table for the rest: never a walk of the Home. `missing/make.rs` is `notes.make-from-link {path, title}` (a one-step job); `missing/relink.rs` is `notes.relink {id, from}` (through the room, keyed, so two devices change the text once). Outside renames now carry `from` in their event. Per user throughout; the isolation gate now also attacks `paths` with Alice's own file name. - Editor (`packages/editor`): link kinds and a `linkState` facet the app answers; `linkAt`; `transforms/picker.ts` (one autocompletion for `[[`, `@` and `#`), `tags.ts`, `tag-rank.ts` (unit-tested); a `surface` class option for the margin controls. - App (`apps/web/src/lib/links/`): `states.ts` (answers kept per path for the page, asked in 30 ms batches, re-asked quietly 400 ms after a note comes, goes or moves), `cm-links.ts` (drawing, press-release follow, ⌘↵), `follow.ts`, `card.svelte.ts` + `LinkCard.svelte`, `relink.ts`, `peek-lines.ts`, `resolve.ts`. The old regex scan of visible lines on every change (`missingLinks`) is gone. - Docs: ADR 0040, CONTEXT.md, TASTE.md. ## Performance Nothing runs per keystroke: drawing a link reads two maps; the server is asked once per batch of unknown paths, never while typing (a test types into a note with 60 never-written links and counts 0 requests). Typing cost in a note with 200 links (half to existing notes, half never written), in-page dispatch-to-layout per keystroke, 300 keystrokes, 3 runs each, host load 25–30 on 12 cores: | | before p50 / p95 | after p50 / p95 | |---|---|---| | Chromium, at the end | 6.8–8.3 / 23–31 ms | 5.7–6.8 / 18–21 ms | | Chromium, mid-note | 8.8–9.9 / 31–48 ms | 7.6–8.7 / 15–25 ms | | WebKit, at the end | 7–10 / 37–46 ms | 6–8 / 22–54 ms | | WebKit, mid-note | 7–9 / 30–39 ms | 7–8 / 20–24 ms | (The existing keystroke-to-paint @perf test was also run before: on this loaded host it measured 300+ ms p50 either way, so the in-page probe is what's comparable.) ## Contrast (measured, `e2e/link-contrast.spec.ts`, worst spot) | | light paper | dark paper | light photo, page 0% | dark photo, page 0% | |---|---|---|---|---| | link words / icon | 9.2 / 9.2 | 7.3 / 7.3 | 9.0 / 9.1 | 6.1 / 6.1 | | never written, words / icon | 5.4 / 5.4 | 7.0 / 7.0 | 4.9 / 5.6 | 6.3 / 5.7 | | in the Trash, words / icon | 5.4 / 5.4 | 7.0 / 7.0 | 5.0 / 4.9 | 6.3 / 6.4 | | handle / → / ⋯ | 14.3 / 14.3 / 14.3 | 13.7 / 13.5 / 13.5 | 13.4 / 10.0 / 10.3 | 7.8 / 9.5 / 9.4 | **One deviation for kayg to judge:** over a photo with the page at 0%, the approved no-pill look measured 1.2–1.5:1 at the worst spots (the photo here is busier than the mockup's). On pages under 30% opacity only, a link now sits on a soft wash of paper (no edge, no shadow); on paper nothing changes. The editor's text also gets the faint-page halo it was meant to have (it targeted the mockup's `.body`, not `.cm-content`). ## Tests - New: `missing-links.spec.ts` (10), `links-look.spec.ts` (4), `tag-picker.spec.ts` (4), `link-contrast.spec.ts` (contrast project), `crates/notes/src/tests/missing.rs` (7), `packages/editor/test/tag-rank.test.ts` (5). - Rust: `cargo fmt --check` clean, `clippy --workspace --all-targets -D warnings` clean, workspace tests 429 passed, 0 failed (incl. the isolation gate, which now also attacks `paths`). Editor unit tests 117 passed. `svelte-check` 0 errors; action types match the contract. - Browser after the review fixes: link specs (missing-links, links-look, tag-picker, links, chips, picker, trash) 24/24 chromium-390, 24/24 webkit-390, 34/34 chromium-1440, 34/34 webkit-1440; contrast project 1/1. - Earlier full run (`--no-deps`, chromium-390, webkit-390, chromium-1440, webkit-1440; the new specs plus links, chips, picker, tags, tag-rename, trash, editor, shortcuts, keys-*, bar-*, glass): full run all green apart from three on a host at load 30–55: `trash.spec` "a deleted note waits in the Trash" (WebKit 1440, also fails on the base branch: "element is not stable"), and timeouts in `bar-focus` (WebKit 390) and `tag-rename` Undo (WebKit 1440), both passing when rerun on this branch. The link specs were rerun on all four projects after the last fixes: 17/17, 17/17, 27/27, 26/27 (the same Trash test). - Contrast project: `link-contrast.spec.ts` passes (table above). - Two existing tests changed: `bar-stays` clicked the middle of a line whose middle is a link (a click now follows a link, so it clicks the line's start), and `trash.spec` looks for the Trash icon class instead of the old `cm-link-missing`. Screenshots and the simulator run are on #448, #449 and #450. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T ## Changes since #465 (kayg's decisions) - **No paper wash behind links** on pages under 30% opacity: its CSS is gone. Links look the same at every opacity (the approved `icon-accent` look). The contrast spec still runs, but only on paper; it no longer measures over a photo. - **Card buttons side by side.** Trash card: Restore + Open in one row. Deleted card: Create a new note + Remove link in one row. If the row does not fit the card, the obvious button (Restore, Create a new note) becomes icon-only (aria-label, tooltip with its shortcut, 44 px target on touch) and the other keeps icon + words. At 320 wide: the Trash card fits both with words; the deleted card shows Create as an icon only. Cards are now 380 px wide at most for both. - Rebased onto current dev (merged conflicts in `crates/notes`: scan keeps the chunked events and carries the `from` path). Do not merge: kayg gives the final go.
- Links read like words: accent words after a kind icon (note, day, photo, file, web), no pill or
  underline at rest, the pill on hover or with the caret inside, nothing before the full stop.
- A link to a note never written makes it at that path (notes.make-from-link, a job Undo takes
  back); one in the Trash opens a card with its first lines, Restore and Open; one deleted for good
  names the day, with Create a new note and Remove link. ⌘↵ does what a click does.
- History remembers where notes used to live (departed); notes.link-states answers a batch of
  paths with index lookups and one read; notes.relink repoints links to a note moved outside.
- `#` and a letter offers the tags that exist, nested one level at a time, with counts.
- The drag handle and the → ⋯ pill are ink icons on the dense floating glass.
- Faint pages give the editor's text its halo, and links a wash of paper, to keep 4.5:1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
- The caret lands at the end of a note opened to type in (a note made from a link, under its title).
- Answers are asked again 400 ms after a burst of notes coming, going or moving, not per event.
- Tests: toasts found by their words at every width; a day link to a day that exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
The editor is one view for every note, so a tap on a link kept the caret, and the phone's text bar
swept the "Made" toast and its Undo away. Following a link now lets the keyboard go (except for a
card, which keeps the caret to come back to), and the toast is sent before the move so it rides
along to the new note's bar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
- A link to a day with no note yet keeps its day icon and opens that day through daily.open (the
  Journal's way), never an ordinary note named after the date. Weeks, months and years keep their
  icon too.
- An answer asked for again while a request for the same path was out is asked once more when it
  comes back (its answer could be from before the change), and a link whose note leaves the list
  asks at once: made, trashed, restored or deleted for good on another device, the look follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
Links: no paper wash behind links on faint pages; card buttons side by side, the obvious one icon-only when they don't fit (#448)
Some checks failed
CI / server (pull_request) Successful in 29s
CI / rust (pull_request) Successful in 2m1s
CI / web (pull_request) Failing after 50m0s
3e1a255d6c
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
Author
Owner

Replaced by #473 (card actions right-aligned).

Replaced by #473 (card actions right-aligned).
kayg closed this pull request 2026-10-16 06:26:09 +00:00
Some checks failed
CI / server (pull_request) Successful in 29s
CI / rust (pull_request) Successful in 2m1s
CI / web (pull_request) Failing after 50m0s

Pull request closed

Sign in to join this conversation.
No reviewers
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!472
No description provided.