Skip to content

Phase 4: layers, bookmarks, typed notes, search - #6

Merged
ewraj merged 11 commits into
mainfrom
feat/phase-4-organisation
Oct 7, 2026
Merged

ewraj merged 11 commits into
mainfrom
feat/phase-4-organisation

Conversation

@ewraj

@ewraj ewraj commented Oct 6, 2026 •

Copy link
Copy Markdown
Owner

Builds Phase 4 of IMPLEMENTATION_PLAN.md — organisation — in the order set out in PHASE_4_ORGANISATION.md, one commit per piece.

  • Plan (PHASE_4_ORGANISATION.md), including the calls made without a conversation and how to reverse each
  • goTo: one navigation primitive; jumps centre the line and light it
  • Layers: create, rename, show/hide, delete or merge into another layer (palette ⋯ menu)
  • Export annotations to JSON (same menu)
  • Bookmarks: toolbar toggle, a margin left of the line numbers, a sidebar list; anchored like ink
  • Typed notes: a Note tool, chips at the end of the line, an edit card that follows its line
  • Search: every note and bookmark in the source, every word must match, the path counts; Ctrl/⌘+Shift+F
  • On a phone, the sidebar sheet closes after you pick a file, bookmark or search result, so it doesn't cover where you landed

Done 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 logs finds 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

  • The palette's ⋯ now opens a menu (Layers, Export, Hide tools) instead of collapsing the palette directly.
  • Two collisions fixed on the way: the mouse-down after a Note tap stole focus from the card it had opened, and Ctrl+Z inside a text field undid the last stroke.
  • ensureDefaultLayer now shares an in-flight call per source; the ink loader and the layers store could each create a "Notes" layer on a fresh source.

ewraj added 7 commits October 6, 2026 23:58
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.
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.
ewraj and others added 3 commits October 7, 2026 21:11
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
aashishaxo marked this pull request as ready for review October 7, 2026 15:53
@ewraj
ewraj merged commit 22e9257 into main Oct 7, 2026
2 checks passed
@ewraj
ewraj deleted the feat/phase-4-organisation branch October 7, 2026 16:11
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