side-notes/openspec/changes/sidepanel-ux-improvements/design.md

6.4 KiB
Raw Permalink Blame History

Design: Side panel UX improvements

Context

The side panel (sidepanel/sidepanel.js) is a single <textarea> editor with debounced autosave (500ms) into IndexedDB via shared/storage.js. Cross-panel consistency uses a BroadcastChannel('side-notes'): every save posts {type:'save', key, updatedAt}, and any other panel showing the same key reloads editor.value — which resets that panel's caret to the start. Note records are plain JS objects (IndexedDB is schemaless), so adding fields needs no version bump or migration. See proposal.md for motivation.

Goals / Non-Goals

Goals:

  • Focus + caret/scroll restoration on every loadNoteForTab path (panel open, tab switch, navigation) with one code path.
  • Caret persistence that never disturbs other open panels.
  • Font-size adjustment (buttons + shortcuts) persisted in localStorage.

Non-Goals:

  • Restoring selection direction or multi-range selections (collapsed caret and simple ranges only).
  • Applying the font size to the notes-list page.
  • Syncing the font-size preference across devices (localStorage is per-machine).

Decisions

1. Caret/scroll live on the note record

Store caretStart, caretEnd, scrollTop on the note record in IndexedDB rather than in localStorage keyed by note key. The position is conceptually part of the note's state, travels with the record, and avoids a second lookup store. saveNote gains an optional caret parameter; when present, the fields are written on the record; when absent, existing fields are preserved (spread of existing already does this on update).

Alternative considered: localStorage map of key → position. Simpler writes, but duplicates the keying logic, can leak entries for deleted notes, and splits note state across two stores.

2. Three write paths for the caret

typing ─────────► existing 500ms autosave ──► saveNote(key, content, meta, {caret, scrollTop}) + broadcast 'save'
caret move only ─► 800ms debounced caret save ► saveNote(..., {caretOnly: true}) — no broadcast
blur / visibilitychange(hidden) ──► flush pending caret save immediately

Caret-only movement is tracked via the selectionchange event (fires on the document when the textarea selection changes) plus scroll on the editor. The caret-only save calls saveNote with the current content (unchanged) and new caret fields — it reuses the existing update-in-place path, so content, timestamps, and metadata are untouched. updatedAt is refreshed; that is acceptable and keeps "last touched" honest.

Alternative considered: writing caret fields via a dedicated storage function. Rejected — a second write path to the same record invites lost-update races with autosave; funneling both through saveNote serializes them naturally.

3. Caret-only saves do not broadcast

The cross-panel reload exists to propagate content changes. A caret-only save broadcasting type:'save' would make every other panel reload editor.value and lose its own caret — two panels would fight. So caret-only saves post nothing. As defense in depth (and fixing the latent bug where a blur-triggered reload in panel B discards B's caret even when content is identical), reloadFromDB compares fetched content to editor.value and skips the assignment when identical, updating only its local caret if unfocused.

Alternative considered: a caretOnly: true flag on the broadcast so receivers can update their stored caret for future restores. Unnecessary — each panel persists its own caret on its own save/blur, so remote caret values are never needed live; they're read on load.

4. Restore is part of loadNoteForTab, clamped

After setting editor.value, restore via editor.setSelectionRange(min(caretStart, len), min(caretEnd, len)) and editor.scrollTop = savedScrollTop, then editor.focus(). Focus is applied only in loadNoteForTab paths triggered by panel init and explicit loads — the chrome.tabs.onActivated handler also calls loadNoteForTab, but focusing there is still correct per the spec (switching tabs restores that note's caret; the panel only updates when the panel itself is focused/visible, since side panels are per-window). autofocus is not used: it only runs on first page load and can't express the caret restore.

Clamping handles the case where another panel shortened the note after the caret was saved. Legacy records without caret fields default to 0/0/scroll 0.

5. Font size: CSS variable + localStorage, buttons and shortcuts

  • Default 16px; range 10–24px; step 1px. Applied as editor.style.fontSize (inline style beats the stylesheet; line-height stays the unitless 1.6 so it scales automatically — selectMatch's computed-line-height math keeps working).
  • Persisted under a single localStorage key (e.g. side-notes:fontSize), read at init before first paint of content.
  • Header gains two buttons labeled A- and A+ placed before the existing ☰ button, styled like it.
  • Shortcuts handled in the existing document keydown listener alongside Ctrl+F: Ctrl/Cmd+= (also accept Ctrl/Cmd+'+' for shift variants), Ctrl/Cmd+-, Ctrl/Cmd+0 reset — all preventDefault()ed. Extension pages aren't affected by browser zoom shortcuts, but preventDefault is harmless insurance.

Risks / Trade-offs

  • selectionchange fires very often (every arrow key) → 800ms debounce, plus the guard that a caret-only save is skipped if a content autosave is pending (the autosave will capture the caret anyway).
  • Caret-only save resets nothing visible but does write IndexedDB frequently → writes are tiny records on a local DB; debounce keeps this to ~1/second max while moving.
  • Chrome may not grant focus to the panel when opened without a user gesture → editor.focus() is best-effort; worst case the caret is still restored and one click focuses. Verify manually on open-via-toolbar and open-via-context-menu.
  • Restoring scrollTop immediately after setting value can be clamped by layout timing → if restore lands before layout, wrap the scroll assignment in requestAnimationFrame.

Migration Plan

No data migration: IndexedDB schema is unchanged (no new indexes, no version bump); old records simply lack the caret fields and default to offset 0. Rollback is a revert — stray caret fields on records are harmless to older code (ignored on read, preserved on update via the ...existing spread).