Repository navigation
Phase 4: layers, bookmarks, typed notes, search - #6
Merged
Merged
Conversation
The data model has carried Layer, Bookmark, kind 'note' and layerId since Phase 0, and Phases 2 and 3 never exercised them. So Phase 4 needs no new store, no schema bump and no second anchoring path: notes ride in the same placed list as strokes, bookmarks resolve through the same ladder, and hiding a layer is a render-time filter rather than a reload. The document also lists the calls made without a conversation - where layers live, how a note is created, where it is drawn - with the cheapest way to reverse each one.
One navigation primitive for everything in Phase 4 that points at code: open the file if it is not open, then land on the line. A jump is not a restored reading position, and they now behave differently. Restoring puts the line back at the top of the screen, where it was being read from. A jump centres the line and moves the cursor onto it, so the active-line highlight shows where you arrived - otherwise a jump into the middle of a long file leaves you hunting for the line you asked for. goTo takes an optional anchor and resolves it against the file's text once it has loaded. A bookmark or note in a file that changed since it was written still lands on its code; when the ladder gives up, the stored line is used as given. Checked in headless Chrome: a jump into another file centres line 91 and lights it, and a jump within the open file does the same.
Layers live in the palette's overflow menu, which the plan reserved for them; the menu also takes over hiding the palette, which is what the dots used to do on their own. One layer per source is active, persisted in meta, and every new stroke is written into it. Visibility is a render-time filter. Hiding a layer is a repaint: nothing is reloaded, nothing in the database changes, and the undo stack is left alone, so showing it again cannot lose anything. Hidden ink is not drawn and cannot be erased - rubbing out something you cannot see is destruction with no feedback. Drawing on a hidden active layer turns it back on rather than laying down ink that vanishes as it is written. Deleting asks once, inline, and offers to move the layer's annotations into another layer instead. That is the plan's "reassign", at the only granularity there is without a lasso. The merge is one transaction, so a failure halfway cannot leave annotations in a layer the user was told is gone. Removing a layer also drops the ink undo stack: an entry holds whole annotations, and replaying one would resurrect it in the deleted layer. The layers store now owns the default layer. ensureDefaultLayer shares an in-flight call per source, because the store and the ink loader used to race on a fresh source and could each write a "Notes" layer. Checked in headless Chrome: draw, add a layer and write on it, hide one and see only its ink go, erase across hidden ink and lose nothing, merge on delete, reload and find visibility and the active layer as left.
exportSource has gathered a codebase's records since Phase 0 with nothing calling it. The plan calls the export the answer to every storage risk - eviction, quota, a browser that forgets - and an answer nobody can reach is not one. It is now "Export annotations" in the palette's menu: one JSON file with the source, its files, layers, annotations and bookmarks, named after the codebase and the day. Import is not part of this. Checked in headless Chrome: draw a stroke, export, and the downloaded file holds the source, its layer and the stroke.
Two ways to set one. The ribbon button in the toolbar marks the cursor's
line when the cursor is on screen, and otherwise the top line of what is
showing - a reader who scrolled without clicking means "here", not
wherever the cursor was left, which on a fresh file is line 1. A narrow
margin left of the line numbers takes a click on any line, shows a grey
ghost under the pointer, and is the way in on a tablet.
A new bookmark is named after its own line. "export async function
runJob(job) {" says more than "Bookmark 3" and costs nothing to accept;
it can be renamed in the list, and coloured from the palette's swatches.
Bookmarks are anchored, not numbered. Each one already carried an Anchor,
and now it is used: resolved through the ladder when its file opens,
carried across edits by the same line mapper as ink, and re-anchored on
save. One the ladder cannot place is listed as "moved?" rather than
pointed at a line it no longer describes. The margin's ribbons are a
CodeMirror range set, so between store updates CodeMirror maps them
through edits itself.
The sidebar gains Files and Bookmarks views, the way a PDF reader's does,
instead of more buttons in the one strip of chrome the code shares the
screen with. Bookmark gets the optional updatedAt every other record has.
Checked in headless Chrome: set from the toolbar and the margin, clear
from the margin, mark the top line after scrolling away, rename and
recolour from the list, jump to it from another file, type two lines above
it and watch the ribbon follow, save, reload, and find it on the moved
line.
A fourth tool in the palette. Arm it and tap a line to write a note on it; tap a note - with the tool armed or with none at all - to open it again. Keeping note-taking in the palette means it works the same way with a stylus as everything else does. A note is an annotation like a stroke: kind 'note', in the active layer, in the same placed list. So it is carried across an edit by the same line mapper, re-anchored by the same save, hidden with its layer, and sent to the displaced tray when the ladder gives up - none of that needed writing twice. It is not on the ink undo stack, where undo means "the last stroke"; a note has its own Delete, and emptying one deletes it. Notes are drawn as CodeMirror widget decorations after the code on their line, not as an overlay positioned by hand, so they scroll with the text by construction and there is nothing to keep in sync. The chip's height is held inside the line box: a chip that grew its line would move every stroke anchored below it, and the check for that is now part of the browser run. The card a note is written in opens under its line, or over it when there is no room, and follows the line as the document scrolls. A tap anywhere else keeps what was typed - tapping a second line commits the first note on the way - and Escape is the only way to throw it away. Leaving a file commits too, before the ink moves on to the next one. Two collisions found on the way. The mouse-down that follows a tap would move focus to the page and away from the card the tap had just opened, so the drawing surface now swallows it while the Note tool is armed. And Ctrl+Z inside a note field undid the last stroke instead of the last keystroke; the ink's undo now leaves text fields alone. The displaced tray shows a note's own words rather than the code beside it - for typed notes the words are the thing worth keeping. Checked in headless Chrome: write a note, see the chip on its line with no line height changed, edit it from the chip, move to another line mid-note and keep both, discard with Escape, delete by emptying, hide and show its layer, Ctrl+Z inside the field, type two lines above it and watch it follow, save, reload, and find it on the moved line.
The plan's test for Phase 4 is "you can find a note you wrote last week by typing three words", and the matcher is built to that sentence. Every word typed must match, in any order. Case and accents do not matter, because nobody remembers how they capitalised a note. The file path counts, so "runner webcontainer" finds the WebContainer note in runner.ts - where you wrote something is part of how you remember it. Hits in the words rank above hits only in the path, and notes above bookmarks. It is the sidebar's third view, and Ctrl/Cmd+Shift+F opens it from anywhere with the field focused; Ctrl/Cmd+F stays CodeMirror's search within the file. With nothing typed the view is the index itself: every note and bookmark, grouped by file. Results show why they matched - the words highlighted, and a long note cut down to the part around its first match. Enter jumps to the best one. Notes on hidden layers are found and say so. Search is how you find what you are not currently looking at. Notes come from the database through the source's layers, a new listNotes; the open file's are taken from the working set instead, so their lines follow unsaved edits and a note written a moment ago does not wait for the next read to be findable. Jumping to a note in a closed file resolves its anchor through the ladder once the file has loaded. Sidebar state moved into a small store, because the shortcut has to open it on the right view from outside it. The matcher is pure and tested table-style: folding, terms, matching, ranking, highlighting that round-trips the text exactly, and snippets. Checked in headless Chrome: four notes across two files and two layers and a bookmark; one word finds the note and the bookmark that mention it, three words including one from the path find exactly one note, a note on a hidden layer is still found, clicking a result opens its file on its line, and Enter jumps to the first.
This was referenced Oct 6, 2026
Below 720px the sidebar is a sheet over the document rather than a column beside it. Choosing a file, a bookmark or a search result left it open, covering the very line just jumped to; on a phone the jump looked like it had done nothing. It now closes itself after any of the three, on narrow screens only - beside the code, on a wide screen, it stays where it is. Checked in headless Chrome at 390px with touch: the sheet starts open, closes when a file is chosen, and opens again from the toolbar; the desktop runs for bookmarks and search are unchanged.
The gist's MVP lists creating files, and the Phase 3 commit left create, rename and delete undone. "New file" sits at the top of the tree and starts in the open file's folder; type a path, press Enter, and the file opens in Edit mode - "filename.ts, and then the editor opens it". Rename and delete are on each row, rename in place with just the name selected, delete confirmed under the list with what goes with the file said plainly. What a source can change follows what it can write back to, as edits already do. In a picked folder every file can be renamed or deleted, on disk. In a GitHub repository or an uploaded folder a new file is created here only, marked "new", and only those can be renamed or deleted: an upstream file would come straight back the next time the source opened, and a tree that let you delete it would be lying. Opening a repository again by URL keeps the files created in it. A file's id is its path - a re-walked folder can only name files by where they are - so a rename moves everything keyed to the old id onto the new one in a single transaction: the cached text, annotations, bookmarks and the reading position. Notes and bookmarks on a renamed file come with it. Paths are checked against what a GitHub tree, Windows and a Mac will all accept, case-insensitively, with a reason the user can act on. Two bugs found by the browser run and fixed before this commit. The folder adapter pushed new files onto the very array the app was rendering, so a created file appeared twice; it keeps its own copy now. And renaming a new file after typing into it put its empty starting text back, because the app's copy of the entry was older than the last save; the stored text now always wins, and entries no longer carry text at all. Checked in headless Chrome, a GitHub source and a writable folder (the origin-private file system, which has the picker's API): create, type, save, note, rename, reload, delete, and a refused duplicate; and on disk, create, write through, rename and delete, each verified against the files themselves.
* Finish Phase 3: create, rename and delete files The gist's MVP lists creating files, and the Phase 3 commit left create, rename and delete undone. "New file" sits at the top of the tree and starts in the open file's folder; type a path, press Enter, and the file opens in Edit mode - "filename.ts, and then the editor opens it". Rename and delete are on each row, rename in place with just the name selected, delete confirmed under the list with what goes with the file said plainly. What a source can change follows what it can write back to, as edits already do. In a picked folder every file can be renamed or deleted, on disk. In a GitHub repository or an uploaded folder a new file is created here only, marked "new", and only those can be renamed or deleted: an upstream file would come straight back the next time the source opened, and a tree that let you delete it would be lying. Opening a repository again by URL keeps the files created in it. A file's id is its path - a re-walked folder can only name files by where they are - so a rename moves everything keyed to the old id onto the new one in a single transaction: the cached text, annotations, bookmarks and the reading position. Notes and bookmarks on a renamed file come with it. Paths are checked against what a GitHub tree, Windows and a Mac will all accept, case-insensitively, with a reason the user can act on. Two bugs found by the browser run and fixed before this commit. The folder adapter pushed new files onto the very array the app was rendering, so a created file appeared twice; it keeps its own copy now. And renaming a new file after typing into it put its empty starting text back, because the app's copy of the entry was older than the last save; the stored text now always wins, and entries no longer carry text at all. Checked in headless Chrome, a GitHub source and a writable folder (the origin-private file system, which has the picker's API): create, type, save, note, rename, reload, delete, and a refused duplicate; and on disk, create, write through, rename and delete, each verified against the files themselves. * Add an end-to-end suite, and run it in CI The plan put Playwright in CI in Phase 0, for the one thing unit tests cannot see: ink drifting off the code it was written on, which it calls the project's highest risk. It never arrived. This is it, and it covers the Phase 3 and 4 flows that cross the editor, the database and the UI. e2e/ink-scroll measures a stroke against its line at five scroll offsets, after the sidebar closes and moves the code left, and after a reload, and fails on a pixel of drift. The rest drive layers, notes, bookmarks, search, export and file operations the way a reader would, and check the database and - for a picked folder - the files on disk. Each test gets a fresh browser context and seeds IndexedDB with exactly what it needs through e2e/app.ts, then opens it from the recents list, so nothing touches the network. Database checks poll rather than read once, because writes land a moment after the click that caused them. The picked folder is the origin-private file system in a persistent profile: in Playwright's default off-the-record context, storing a folder handle in IndexedDB closes the page, which no reader's browser does. CI runs it as a second job beside verify, uploading traces when it fails; deploys do not, since main has already passed it in review. The suite is typechecked with the app. 29 tests, run three times over locally with no flakes, about 13 seconds a pass.
Resolve the import conflict in src/store/session.ts: keep the anchor imports used by goTo alongside the ReadingPosition type used by the position flush from #7. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
aashishaxo
marked this pull request as ready for review
October 7, 2026 15:53
aashishaxo
approved these changes
Oct 7, 2026
aashishaxo
approved these changes
Oct 7, 2026
aashishaxo
approved these changes
Oct 7, 2026
aashishaxo
approved these changes
Oct 7, 2026
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.
Builds Phase 4 of
IMPLEMENTATION_PLAN.md— organisation — in the order set out inPHASE_4_ORGANISATION.md, one commit per piece.PHASE_4_ORGANISATION.md), including the calls made without a conversation and how to reverse eachgoTo: one navigation primitive; jumps centre the line and light it⋯menu)Ctrl/⌘+Shift+FDone when (from the plan): you can find a note you wrote last week by typing three words. The browser run does exactly that —
retry five logsfinds the one note, on a hidden layer, in another file.How it was checked
Each commit typechecks, passes the suite (161 → 222 tests) and builds on its own. Each feature was also driven in headless Chrome against the dev server before it was pushed — 66 checks across navigation, layers, export, bookmarks, notes and search, all passing on the final commit. Among them: hiding a layer removes only its ink, the eraser cannot touch hidden ink, a note chip changes no line's height, ribbons and chips follow an edit two lines down, save re-anchors them, and a reload finds them on the moved lines.
Follow-ups stacked on this: #8 (create/rename/delete files) and #9 (Playwright end-to-end suite in CI, which re-checks all of this in a real browser).
Stylus feel still wants a real tablet — the Note tool especially, since it is new input handling.
Worth a look in review
⋯now opens a menu (Layers, Export, Hide tools) instead of collapsing the palette directly.Ctrl+Zinside a text field undid the last stroke.ensureDefaultLayernow shares an in-flight call per source; the ink loader and the layers store could each create a "Notes" layer on a fresh source.