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

68 lines
6.4 KiB
Markdown
Raw Normal View 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).