side-notes/openspec/changes/add-notes-extension/design.md

6.4 KiB

Context

Greenfield Manifest V3 Chrome extension (no existing code). See proposal.md for motivation. Must also run on Brave and other Chromium-based browsers, which rules out relying on chrome.storage.sync (Google-account-bound) for any future sync story, and puts some weight on verifying newer APIs (chrome.sidePanel) behave the same outside Chrome itself.

Goals / Non-Goals

Goals:

  • Establish a storage layer and data model that a future sync change can build on without reshaping existing records.
  • Keep the editing surface's text-interaction behavior indistinguishable from native browser text editing.
  • Reuse a single URL-to-key function everywhere a note needs to be located (saving, loading, and matching against currently open tabs), so key derivation logic never drifts between call sites.

Non-Goals:

  • Implementing remote/multi-device sync (data model only).
  • Search or delete UI within the notes-list page.
  • Markdown rendering/syntax highlighting in the editor.
  • Handling modifier-click (open-in-new-tab) semantics on notes-list entries.
  • A UI for changing the URL-key derivation rule (code-level only).

Decisions

Architecture: a background service worker (tab/navigation event handling, tab-focus-or-navigate logic for notes-list clicks), a side panel page (per-tab, via chrome.sidePanel), and a separate notes-list extension page (its own tab). All three share one storage module so key derivation and the IndexedDB schema aren't duplicated.

URL key derivation: one pure function, buildNoteKey(url, config), used identically by (a) the side panel when loading/saving a note for the active tab, and (b) the notes-list page's click handler when checking whether any open tab already matches the clicked note. Default config: scheme://domain/path?search, hash excluded. The function and its config live in one module so a future code-level change to the rule is a single edit, not a hunt across call sites.

In-page navigation detection: chrome.webNavigation.onHistoryStateUpdated (pushState/replaceState) and onReferenceFragmentUpdated (hash-only changes) re-trigger key resolution for a tab without a full page load. This is the "naive" SPA support agreed on — it only reacts to URL changes; a SPA that changes visible content without ever changing the URL has nothing for this extension to key off, and that's fine since notes are keyed by URL. Requires the webNavigation permission.

Storage: IndexedDB, one object store (notes), keyed by the note's own id (not by URL key), with a non-unique index on the derived URL key and an index on updatedAt. Non-unique on the key (rather than enforcing uniqueness in the DB) because soft-deleted records for a URL may coexist with a later, newer record for the same URL once delete ships in a future change; the application invariant ("at most one non-deleted note per key") is enforced by retrieval logic (filter deletedAt == null), not by a DB constraint. The updatedAt index lets the notes-list page pull a reverse-chronological cursor directly instead of loading everything into memory to sort.

Editor: a plain <textarea> is the only interactive element (see specs/note-editor for why). Autosave is a debounced write to IndexedDB on keystroke. In-panel find intercepts Ctrl+F/Cmd+F at the document level while the panel has focus, searches textarea.value directly, and on a match uses setSelectionRange plus a scroll calculation to bring the match into view — no separate highlight styling is needed since the browser's native selection rendering is the highlight.

Cross-panel consistency: a BroadcastChannel (shared by all extension pages, same origin) carries { key, updatedAt } messages on every save. A panel showing that key reloads from IndexedDB, unless its own textarea currently has focus, in which case it defers the reload until the textarea loses focus.

Notes-list click handling: query chrome.tabs.query({}), compute buildNoteKey for each tab's current URL, and compare to the clicked note's key.

  • Match found → chrome.tabs.update(tabId, { active: true }) + chrome.windows.update(windowId, { focused: true }), then chrome.sidePanel.open({ tabId }).
  • No match → navigate the tab currently hosting the notes-list page to the note's URL, then chrome.sidePanel.open({ tabId }) on that same tab.

Manifest permissions: sidePanel, tabs, webNavigation, unlimitedStorage (IndexedDB itself needs no permission, but removing the default storage quota ceiling is worth doing proactively since note content is expected to accumulate over the extension's lifetime).

Risks / Trade-offs

  • [Risk] Changing the URL-key derivation rule after notes already exist orphans previously saved notes (their old keys no longer match freshly computed keys for the same pages) → Mitigation: none in this change; this is an accepted, documented limitation of the current code-level-only configuration.
  • [Risk] chrome.sidePanel.open() requires being called within a live user gesture; a click handler that awaits async work (e.g., tab creation) before calling it risks the gesture window having expired → Mitigation: call sidePanel.open() as the first synchronous statement in the click handler, verify empirically in both Chrome and Brave during implementation.
  • [Risk] MV3 background service workers are terminated when idle, so any tab-to-key mapping cached only in worker memory would be lost → Mitigation: derive the mapping on demand from chrome.tabs.query/navigation events rather than caching it.
  • [Risk] Two tabs editing the same note at the literal same instant can still race at the IndexedDB write level (last write wins), even with the focus-based refresh guard → Mitigation: accepted for this change; real conflict resolution is deferred to future sync work.
  • [Risk] chrome.sidePanel is a newer API; Brave/other Chromium forks may lag or differ subtly (per-tab setOptions, gesture requirements for open()) → Mitigation: manual verification during implementation (see Open Questions).

Open Questions

  • Does chrome.sidePanel (per-tab setOptions, gesture requirements for open()) behave identically in Brave and other Chromium forks as in Chrome? To be verified empirically during implementation; a difference would change the implementation approach for that platform, not the specified behavior.
  • Exact autosave debounce interval — a tuning detail with no effect on any requirement.