side-notes/openspec/changes/archive/2026-08-03-add-notes-extension/design.md

51 lines
6.4 KiB
Markdown
Raw Permalink Normal View History

2026-08-03 17:54:16 +02:00
## 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.