PageSourceSearch

https://jayreddinjr-jr-odysseus-hf.hf.space/static/js/notes.js

js jayreddinjr-jr-odysseus-hf.hf.space collected 2026-10-04 16:59:57 UTC 237,713 bytes, 5,124 lines download raw bytes

1/**
2 * Notes Module — Google Keep-style notes and todos.
3 * Renders as a sidebar panel (like document editor), not a modal.
4 */
5
6import uiModule from './ui.js';
7import { spawnConfetti } from './compare/vote.js';
8import * as Modals from './modalManager.js';
9import { attachColorPicker } from './colorPicker.js';
10import { makeWindowDraggable } from './windowDrag.js';
11import { snapModalToZone } from './tileManager.js';
12import { applyEdgeDock, clearDockSide } from './modalSnap.js';
13
14const API_BASE = window.location.origin;
15let _open = false;
16let _notes = [];
17let _editingId = null;
18let _selectedIds = new Set();
19let _activeLabel = null;
20let _activeFilter = null; // null | 'default' | 'reminders' | 'no-reminders'
21// Cycle order for the Reminders chip: each click on it advances reminders →
22// null → no-reminders → null → reminders → ... This var tracks which non-null
23// state the next click should land on after passing through null.
24let _reminderChipNext = 'reminders';
25let _searchQuery = '';
26let _viewMode = (typeof localStorage !== 'undefined' && localStorage.getItem('odysseus-notes-view')) || 'list'; // 'list' or 'grid'
27let _showingArchived = false;
28let _selectMode = false;
29let _reminderTimer = null;
30// Tracks the global keydown listener so closePanel can remove it
31// (previously leaked one per openPanel; on multi-open sessions this
32// stacked dozens of identical handlers).
33let _notesKeydownHandler = null;
34// Capture-phase "Esc cancels select mode" listener on document — tracked so it
35// is removed on close instead of leaking +1 per panel open/close cycle.
36let _notesSelectEscHandler = null;
37const REMINDER_FIRED_KEY = 'odysseus-notes-reminder-fired';
38// Note IDs already shown with the entry-glow once. Re-set when the user
39// reschedules the reminder so the new firing glows again on next open.
40const REMINDER_GLOWED_KEY = 'odysseus-notes-reminder-glowed';
41// IDs of notes whose reminders fired while the notes panel was closed. On the
42// next open of the panel we briefly glow those cards so the user can spot them.
43const REMINDER_PENDING_HIGHLIGHT_KEY = 'odysseus-notes-reminder-pending-highlight';
44const REMINDER_ACTIVE_HIGHLIGHT_KEY = 'odysseus-notes-reminder-active-highlight';
45// Timestamp of the last time the user opened the notes panel — used to gate
46// the rail "fired" badge so old reminders don't re-fire on every page reload.
47const REMINDER_DISMISSED_AT_KEY = 'odysseus-notes-reminder-dismissed-at';
48const NOTES_FIRST_OPEN_HINT_KEY = 'odysseus-notes-first-open-hint-v1';
49
50function _forceCloseNotesPanel() {
51  _open = false;
52  _editingId = null;
53  try { _commitOpenInPlaceEditor(); } catch {}
54  try { _closeMobileFullscreenEdit({ save: true }); } catch {}
55  try { _clearViewedReminderGlows(); } catch {}
56  if (_notesKeydownHandler) {
57    document.removeEventListener('keydown', _notesKeydownHandler);
58    _notesKeydownHandler = null;
59  }
60  if (_notesSelectEscHandler) {
61    document.removeEventListener('keydown', _notesSelectEscHandler, true);
62    _notesSelectEscHandler = null;
63  }
64  if (_reminderTimer) {
65    clearInterval(_reminderTimer);
66    _reminderTimer = null;
67  }
68  document.body.classList.remove('notes-view', 'notes-mobile-mode', 'notes-drag-mode');
69  document.getElementById('tool-notes-btn')?.classList.remove('active');
70  try { Modals.unregister('notes-panel'); } catch {}
71  try { document.getElementById('notes-pane')?.remove(); } catch {}
72  try { document.getElementById('notes-pane-backdrop')?.remove(); } catch {}
73  try { window._restoreSidebarIfRouteCollapsed?.(); } catch {}
74}
75
76function _showNotesFirstOpenHint(pane) {
77  if (!pane || typeof localStorage === 'undefined') return;
78  try {
79    if (localStorage.getItem(NOTES_FIRST_OPEN_HINT_KEY)) return;
80    localStorage.setItem(NOTES_FIRST_OPEN_HINT_KEY, '1');
81  } catch {
82    return;
83  }
84
85  document.getElementById('notes-first-open-hint')?.remove();
86  const hint = document.createElement('div');
87  hint.id = 'notes-first-open-hint';
88  hint.className = 'tour-hint';
89  hint.innerHTML = `
90    <div class="tour-hint-text"><b>Notes</b> is your basic todo list, and also where reminders are managed.</div>
91    <button type="button" class="tour-hint-dismiss">OK</button>
92  `;
93  document.body.appendChild(hint);
94
95  const place = () => {
96    const r = pane.getBoundingClientRect();
97    const hw = hint.offsetWidth || 260;
98    hint.style.top = Math.max(12, r.top + 58) + 'px';
99    hint.style.left = Math.min(window.innerWidth - hw - 12, Math.max(12, r.left + 18)) + 'px';
100  };
101  const close = () => {
102    window.removeEventListener('resize', place);
103    hint.classList.add('tour-hint-out');
104    setTimeout(() => hint.remove(), 180);
105  };
106
107  requestAnimationFrame(() => {
108    place();
109    hint.classList.add('tour-hint-in');
110  });
111  window.addEventListener('resize', place);
112  hint.querySelector('.tour-hint-dismiss')?.addEventListener('click', close);
113  setTimeout(close, 6500);
114}
115
116function _notesFullscreenSafeRect() {
117  const vw = window.innerWidth || document.documentElement.clientWidth || 0;
118  const vh = window.innerHeight || document.documentElement.clientHeight || 0;
119  let left = 0;
120  let right = vw;
121
122  const sidebar = document.getElementById('sidebar');
123  const rail = document.getElementById('icon-rail');
124  const hamburgerRight = document.body.classList.contains('hamburger-right')
125    || sidebar?.classList.contains('right-side')
126    || rail?.classList.contains('right-side');
127
128  const reserve = (el) => {
129    if (!el || getComputedStyle(el).display === 'none') return;
130    const rect = el.getBoundingClientRect();
131    if (rect.width <= 0 || rect.height <= 0) return;
132    if (hamburgerRight) right = Math.min(right, rect.left);
133    else left = Math.max(left, rect.right);
134  };
135
136  if (sidebar && !sidebar.classList.contains('hidden')) reserve(sidebar);
137  reserve(rail);
138
139  // The fixed hamburger can remain visible even when the rail/sidebar is
140  // collapsed. Reserve its strip too so fullscreen Notes does not sit beneath it.
141  const hamburger = document.getElementById('hamburger-btn');
142  if (hamburger && getComputedStyle(hamburger).display !== 'none') {
143    const rect = hamburger.getBoundingClientRect();
144    const pad = 8;
145    if (hamburgerRight) right = Math.min(right, rect.left - pad);
146    else left = Math.max(left, rect.right + pad);
147  }
148
149  left = Math.max(0, Math.min(left, vw - 80));
150  right = Math.max(left + 80, Math.min(right, vw));
151  return { left, top: 0, width: right - left, height: vh };
152}
153
154function _wireNotesWindow(pane) {
155  if (!pane || pane.dataset.windowDragWired === '1') return;
156  const header = pane.querySelector('.notes-pane-header');
157  if (!header) return;
158  pane.dataset.windowDragWired = '1';
159  makeWindowDraggable(pane, {
160    content: pane,
161    header,
162    fsClass: 'notes-window-fullscreen',
163    skipSelector: 'button, input, select, textarea, label, .notes-mobile-grabber',
164    enableDock: true,
165    enableLeftDock: true,
166    onEnterFullscreen: () => {
167      pane.classList.add('notes-window-fullscreen');
168      snapModalToZone(pane, {
169        name: 'fullscreen',
170        rect: _notesFullscreenSafeRect(),
171      });
172    },
173    onExitFullscreen: () => {
174      _restoreNotesSidebarDock(pane);
175    },
176  });
177}
178
179function _clearNotesSnapStyles(pane) {
180  if (!pane) return;
181  const hadLeft = pane.classList.contains('modal-left-docked');
182  const hadRight = pane.classList.contains('modal-right-docked');
183  pane.classList.remove('notes-window-fullscreen', 'modal-left-docked', 'modal-right-docked');
184  if (hadLeft) clearDockSide('left', pane);
185  if (hadRight) clearDockSide('right', pane);
186  ['position', 'left', 'top', 'right', 'bottom', 'width', 'max-width', 'height',
187    'max-height', 'margin', 'transform', 'border-radius']
188    .forEach((prop) => pane.style.removeProperty(prop));
189  delete pane.dataset._tilePreSnap;
190  delete pane.dataset._tileZone;
191  delete pane._preDockSnapshot;
192  delete pane._dockSide;
193  delete pane._dockSuspended;
194}
195
196function _restoreNotesSidebarDock(pane) {
197  if (!pane || window.innerWidth <= 768) return;
198  _clearNotesSnapStyles(pane);
199  if (!pane.isConnected) return;
200  applyEdgeDock(pane, 'right');
201}
202
203function _loadPendingHighlights() {
204  try { return new Set(JSON.parse(localStorage.getItem(REMINDER_PENDING_HIGHLIGHT_KEY) || '[]')); }
205  catch { return new Set(); }
206}
207function _loadGlowedReminders() {
208  try { return new Set(JSON.parse(localStorage.getItem(REMINDER_GLOWED_KEY) || '[]')); }
209  catch { return new Set(); }
210}
211function _saveGlowedReminders(set) {
212  try { localStorage.setItem(REMINDER_GLOWED_KEY, JSON.stringify([...set])); } catch {}
213}
214function _loadActiveHighlights() {
215  try { return new Set(JSON.parse(localStorage.getItem(REMINDER_ACTIVE_HIGHLIGHT_KEY) || '[]')); }
216  catch { return new Set(); }
217}
218function _saveActiveHighlights(set) {
219  try { localStorage.setItem(REMINDER_ACTIVE_HIGHLIGHT_KEY, JSON.stringify([...set])); } catch {}
220}
221function _clearViewedReminderGlows() {
222  const active = _loadActiveHighlights();
223  if (!active.size) return;
224  _saveActiveHighlights(new Set());
225  document.querySelectorAll('.note-card-reminder-fired-sticky').forEach(card => {
226    card.classList.remove('note-card-reminder-fired-sticky');
227  });
228}
229function _setReminderCardGlow(noteId, on = true) {
230  if (!noteId) return;
231  const active = _loadActiveHighlights();
232  if (on) active.add(noteId);
233  else active.delete(noteId);
234  _saveActiveHighlights(active);
235  document.querySelectorAll(`.note-card[data-note-id="${noteId}"]`).forEach(card => {
236    card.classList.toggle('note-card-reminder-fired-sticky', on);
237  });
238}
239// A note has an active reminder when its due time has passed and the user
240// hasn't archived or fully completed it. Used for both sorting (bumped above
241// the rest of the unpinned section) and the entry-glow flush.
242function _hasActiveReminder(n) {
243  if (!n || n.archived || _isNoteFullyDone(n)) return false;
244  if (!n.due_date) return false;
245  const t = new Date(n.due_date).getTime();
246  return !isNaN(t) && t <= Date.now();
247}
248function _savePendingHighlights(set) {
249  try { localStorage.setItem(REMINDER_PENDING_HIGHLIGHT_KEY, JSON.stringify([...set])); }
250  catch {}
251}
252function _queuePendingHighlight(noteId) {
253  const set = _loadPendingHighlights();
254  set.add(noteId);
255  _savePendingHighlights(set);
256}
257function _flushPendingHighlights() {
258  // Fresh firings (queued by the background loop while the panel was closed)
259  // glow unconditionally — a notification just told the user something
260  // happened, so we always point at the note even if it was glowed before.
261  const queued = _loadPendingHighlights();
262  const glowed = _loadGlowedReminders();
263  const toGlow = new Set(queued);
264  // For notes that are merely overdue at open time (no fresh firing event),
265  // only glow the ones we haven't already shown — otherwise reopening the
266  // panel keeps lighting up old reminders forever.
267  for (const n of _notes) {
268    if (!_hasActiveReminder(n) || !_hasTimeComponent(n.due_date)) continue;
269    if (queued.has(n.id) || !glowed.has(n.id)) toGlow.add(n.id);
270  }
271  // Always consume the queue.
272  _savePendingHighlights(new Set());
273  if (!toGlow.size) return;
274  let firstCard = null;
275  for (const id of toGlow) {
276    const card = document.querySelector(`.note-card[data-note-id="${id}"]`);
277    if (!card) continue;
278    _setReminderCardGlow(id, true);
279    if (!firstCard) firstCard = card;
280    glowed.add(id);
281  }
282  _saveGlowedReminders(glowed);
283  // Bring the first one into view so it can't get buried below the fold.
284  if (firstCard) {
285    requestAnimationFrame(() => {
286      try { firstCard.scrollIntoView({ block: 'center', behavior: 'smooth' }); }
287      catch { firstCard.scrollIntoView(); }
288    });
289  }
290}
291
292const COLORS = [
293  { name: 'none',    value: '' },
294  { name: 'red',     value: 'red' },
295  { name: 'orange',  value: 'orange' },
296  { name: 'yellow',  value: 'yellow' },
297  { name: 'green',   value: 'green' },
298  { name: 'blue',    value: 'blue' },
299  { name: 'purple',  value: 'purple' },
300  { name: 'custom',  value: 'custom' },  // sentinel — clicking opens native color picker
301];
302
303const _CUSTOM_GRADIENT = 'conic-gradient(from 0deg, #e06c75, #d19a66, #e5c07b, #98c379, #61afef, #c678dd, #e06c75)';
304
305// A note's color is one of: '' (none), a preset name (red/orange/…), or a
306// sentinel "bg:<image-url>" for a custom background image uploaded by the user.
307function _isBgImage(c) { return typeof c === 'string' && c.startsWith('bg:'); }
308function _bgImageUrl(c) { return _isBgImage(c) ? c.slice(3) : ''; }
309
310function _dotBg(value, noteColor) {
311  if (value === 'custom') {
312    const url = _bgImageUrl(noteColor);
313    return url ? `center/cover no-repeat url('${url}')` : _CUSTOM_GRADIENT;
314  }
315  return COLOR_HEX[value];
316}
317
318function _dotIsActive(value, noteColor) {
319  if (value === 'custom') return _isBgImage(noteColor);
320  return value === (noteColor || '');
321}
322
323// Inline style for a note card/form when color is a custom bg image.
324function _customColorStyle(c) {
325  if (!_isBgImage(c)) return '';
326  const url = _bgImageUrl(c);
327  return `background-image: linear-gradient(color-mix(in srgb, var(--panel) 60%, transparent), color-mix(in srgb, var(--panel) 60%, transparent)), url('${url}'); background-size: cover; background-position: center; border-color: color-mix(in srgb, var(--fg) 25%, var(--border));`;
328}
329
330// Open a file picker, upload the chosen image, and resolve with the URL.
331function _pickCustomBgImage() {
332  return new Promise(resolve => {
333    const input = document.createElement('input');
334    input.type = 'file';
335    input.accept = 'image/*';
336    input.style.cssText = 'position:fixed; left:-9999px; top:-9999px;';
337    document.body.appendChild(input);
338    let done = false;
339    const finish = (v) => { if (done) return; done = true; input.remove(); resolve(v); };
340    input.addEventListener('change', async () => {
341      const file = input.files?.[0];
342      if (!file) return finish(null);
343      const fd = new FormData();
344      fd.append('files', file);
345      try {
346        const res = await fetch(`${API_BASE}/api/upload`, { method: 'POST', body: fd, credentials: 'same-origin' });
347        const data = await res.json();
348        const fileId = data.files?.[0]?.id;
349        if (!fileId) throw new Error('Upload failed');
350        finish(`${API_BASE}/api/upload/${fileId}`);
351      } catch { finish(null); }
352    });
353    // Best-effort cleanup if user dismisses the dialog.
354    setTimeout(() => { if (!done && !input.files?.length) finish(null); }, 30000);
355    input.click();
356  });
357}
358
359const COLOR_HEX = {
360  '':       'var(--border)',
361  // Pale/pastel palette — matches the calendar event color picker.
362  red:      '#f0b5ba',
363  orange:   '#e8ccb2',
364  yellow:   '#f2dfbd',
365  green:    '#cce0bc',
366  blue:     '#b0d7f7',
367  purple:   '#e2bcee',
368};
369
370// ---- API ----
371
372let _loading = false;
373// Undo stack — most recent action is at the end. We cap it small because the
374// only entries that survive a panel reload are in-memory anyway.
375const _undoStack = [];
376function _pushUndo(entry) {
377  _undoStack.push(entry);
378  if (_undoStack.length > 20) _undoStack.shift();
379}
380function _popAndRunUndo() {
381  const entry = _undoStack.pop();
382  if (entry) entry.run();
383  return !!entry;
384}
385
386function _undoArchive(note, prevIdx) {
387  // Re-insert at original position and clear archived flag on the server.
388  const safeIdx = Math.min(Math.max(prevIdx, 0), _notes.length);
389  _notes.splice(safeIdx, 0, { ...note, archived: false });
390  _renderNotes();
391  _patchNote(note.id, { archived: false }).catch(() => {
392    // Roll back local insertion if the server refuses
393    const i = _notes.findIndex(n => n.id === note.id);
394    if (i >= 0) _notes.splice(i, 1);
395    _renderNotes();
396    uiModule.showError('Undo failed');
397  });
398}
399
400async function _fetchNotes() {
401  _loading = true;
402  try {
403    const url = `${API_BASE}/api/notes${_showingArchived ? '?archived=true' : ''}`;
404    const res = await fetch(url, { credentials: 'same-origin' });
405    if (!res.ok) { _notes = []; return; }
406    const data = await res.json();
407    _notes = data.notes || data || [];
408  } catch (e) {
409    console.error('Failed to fetch notes:', e);
410    _notes = [];
411  } finally {
412    _loading = false;
413  }
414}
415
416async function _saveNote(note) {
417  const method = note.id ? 'PUT' : 'POST';
418  const url = note.id ? `${API_BASE}/api/notes/${note.id}` : `${API_BASE}/api/notes`;
419  const res = await fetch(url, {
420    method, credentials: 'same-origin',
421    headers: { 'Content-Type': 'application/json' },
422    body: JSON.stringify(note),
423  });
424  if (!res.ok) throw new Error('Failed to save note');
425  return await res.json();
426}
427
428async function _deleteNoteApi(id) {
429  // v2 review — used to swallow 4xx/5xx silently. Throw so callers can
430  // distinguish success vs failure and toast accordingly.
431  const r = await fetch(`${API_BASE}/api/notes/${id}`, { method: 'DELETE', credentials: 'same-origin' });
432  if (!r.ok) throw new Error('HTTP ' + r.status);
433}
434
435async function _patchNote(id, patch) {
436  const res = await fetch(`${API_BASE}/api/notes/${id}`, {
437    method: 'PUT', credentials: 'same-origin',
438    headers: { 'Content-Type': 'application/json' },
439    body: JSON.stringify(patch),
440  });
441  if (!res.ok) throw new Error('Failed to update note');
442  return await res.json();
443}
444
445// ---- Helpers ----
446
447function _esc(s) { return uiModule.esc ? uiModule.esc(s || '') : (s || '').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
448function _attrEsc(s) {
449  return String(s || '')
450    .replace(/"/g, '&quot;')
451    .replace(/'/g, '&#39;')
452    .replace(/</g, '&lt;')
453    .replace(/>/g, '&gt;')
454    .replace(/`/g, '&#96;');
455}
456// Image src guard — reject anything that isn't a relative path, http(s), or
457// raster data URL so an AI-saved note can't slip script-capable media into the
458// rendered <img>.
459function _safeImgSrc(s) {
460  const v = (s || '').trim();
461  if (!v) return '';
462  if (v.startsWith('/') || v.startsWith('./') || v.startsWith('../')) return v;
463  if (/^https?:\/\//i.test(v) || /^data:image\/(?:png|jpe?g|gif|webp);base64,/i.test(v)) return v;
464  return '';
465}
466
467// Escape then turn http(s)://... URLs into clickable anchors. XSS-safe.
468// Allow balanced `(...)` inside the URL (Wikipedia, MD links) by accepting
469// `(` in the body, then trim a trailing unmatched `)` afterwards.
470function _linkify(s) {
471  const escaped = _esc(s);
472  const urlRe = /\b((?:https?:\/\/|www\.)[^\s<>"']+[^\s<>"'.,;:!?\]])/g;
473  return escaped.replace(urlRe, (m) => {
474    let url = m;
475    // Trim a trailing ')' that doesn't have a matching '(' inside the URL
476    if (url.endsWith(')') && (url.match(/\(/g) || []).length < (url.match(/\)/g) || []).length) {
477      url = url.slice(0, -1);
478    }
479    const href = url.startsWith('www.') ? `https://${url}` : url;
480    return `<a href="${_attrEsc(href)}" class="note-link" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">${url}</a>` + (url !== m ? m.slice(url.length) : '');
481  });
482}
483function _uid() { return Math.random().toString(36).slice(2, 10); }
484
485// Mobile swipe-to-dismiss for the notes sheet. Mirrors the document panel
486// gesture (finger-following, velocity-based dismiss, rubber-band, snap-back)
487// so both sheets feel identical; dismisses via the notes closePanel('down').
488function _wireNotesSwipeDismiss(el, pane) {
489  if (!el || !pane) return;
490  const DISMISS_THRESHOLD = 50, VELOCITY_THRESHOLD = 0.3, RUBBER = 0.35;
491  let startY = 0, startX = 0, lastY = 0, lastT = 0, velocity = 0;
492  let dragging = false, cancelled = false;
493
494  el.addEventListener('touchstart', (e) => {
495    if (window.innerWidth > 768 || e.touches.length !== 1) return;
496    if (e.target.closest('button, input, select, label, textarea')) return;
497    const t = e.touches[0];
498    startY = t.clientY; startX = t.clientX; lastY = startY; lastT = e.timeStamp;
499    velocity = 0; dragging = false; cancelled = false;
500  }, { passive: true });
501
502  el.addEventListener('touchmove', (e) => {
503    if (cancelled || window.innerWidth > 768) return;
504    const t = e.touches[0];
505    const dx = Math.abs(t.clientX - startX);
506    const dy = t.clientY - startY;
507    if (!dragging) {
508      if (dx > 40 && dx > Math.abs(dy) * 2) { cancelled = true; return; }
509      if (Math.abs(dy) > 8) {
510        dragging = true;
511        pane.style.animation = 'none';
512        pane.style.transition = 'none';
513        pane.style.willChange = 'transform';
514      } else return;
515    }
516    const dt = e.timeStamp - lastT;
517    if (dt > 0) velocity = velocity * 0.6 + ((t.clientY - lastY) / dt) * 0.4;
518    lastY = t.clientY; lastT = e.timeStamp;
519    e.preventDefault();
520    pane.style.transform = dy > 0 ? `translateY(${dy}px)` : `translateY(${dy * RUBBER}px)`;
521  }, { passive: false });
522
523  const endSwipe = () => {
524    if (!dragging) return;
525    dragging = false;
526    pane.style.willChange = '';
527    const dy = lastY - startY;
528    if (dy > DISMISS_THRESHOLD || (dy > 20 && velocity > VELOCITY_THRESHOLD)) {
529      // Slide fully off-screen, then minimise. Keep it translated down (don't
530      // reset) so it doesn't flash back before closePanel removes it.
531      pane.style.transition = 'transform 0.2s cubic-bezier(0.2, 0, 0.4, 1)';
532      pane.style.transform = 'translateY(100%)';
533      setTimeout(() => closePanel('down'), 200);
534    } else {
535      pane.style.transition = 'transform 0.25s cubic-bezier(0.2, 0.9, 0.3, 1.05)';
536      pane.style.transform = '';
537      setTimeout(() => { pane.style.transition = ''; }, 260);
538    }
539  };
540  el.addEventListener('touchend', endSwipe, { passive: true });
541  el.addEventListener('touchcancel', endSwipe, { passive: true });
542}
543
544function _hasTimeComponent(dateStr) {
545  return typeof dateStr === 'string' && /T\d{2}:\d{2}/.test(dateStr);
546}
547
548function _formatDueDate(dateStr) {
549  if (!dateStr) return '';
550  const d = new Date(dateStr);
551  if (isNaN(d)) return '';
552  const now = new Date();
553  const hasTime = _hasTimeComponent(dateStr);
554  const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
555  const due = new Date(d.getFullYear(), d.getMonth(), d.getDate());
556  const diffDays = Math.round((due - today) / 86400000);
557  const timeStr = hasTime ? d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) : '';
558  if (hasTime && d < now) return 'overdue';
559  if (!hasTime && diffDays < 0) return 'overdue';
560  if (diffDays === 0) return hasTime ? timeStr : 'today';
561  if (diffDays === 1) return hasTime ? `tmrw ${timeStr}` : 'tomorrow';
562  const dateLabel = d.toLocaleDateString([], { month: 'short', day: 'numeric' });
563  return hasTime ? `${dateLabel} ${timeStr}` : dateLabel;
564}
565
566function _isDueOverdue(dateStr) {
567  if (!dateStr) return false;
568  const d = new Date(dateStr);
569  if (isNaN(d)) return false;
570  if (_hasTimeComponent(dateStr)) return d < new Date();
571  return d < new Date(new Date().toDateString());
572}
573
574function _isDueTodayOrOverdue(dateStr) {
575  if (!dateStr) return false;
576  const d = new Date(dateStr);
577  const now = new Date();
578  const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
579  const due = new Date(d.getFullYear(), d.getMonth(), d.getDate());
580  return due <= today;
581}
582
583function _isNoteFullyDone(note) {
584  if (_hasItems(note) && Array.isArray(note.items) && note.items.length > 0) {
585    return note.items.every(it => it.done);
586  }
587  return false;
588}
589
590// A "checklist note" — todo or goal — has structured items[] that the cards
591// render as checkboxes and that "fully done" / progress logic reads from.
592function _hasItems(note) {
593  return note && (note.note_type === 'todo' || note.note_type === 'goal');
594}
595
596// Compact " N/M" progress string for a goal's checklist. Empty when the goal
597// has no steps yet (e.g. AI breakdown is still in flight or was cancelled).
598function _goalProgress(note) {
599  if (!Array.isArray(note?.items) || note.items.length === 0) return '';
600  const done = note.items.filter(it => it.done).length;
601  return ` ${done}/${note.items.length}`;
602}
603
604// The next unchecked step in a goal, or null if all done / no items.
605function _nextGoalStep(note) {
606  if (!Array.isArray(note?.items)) return null;
607  for (let i = 0; i < note.items.length; i++) {
608    if (!note.items[i].done) return { idx: i, item: note.items[i] };
609  }
610  return null;
611}
612
613// ---- Reminder presets ----
614
615function _laterTodayDate() {
616  const now = new Date();
617  const eight = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 18, 0); // 6pm today
618  // If less than 1 hour before 6pm, push to "in 3 hours" instead
619  if (eight - now < 60 * 60 * 1000) return new Date(now.getTime() + 3 * 60 * 60 * 1000);
620  return eight;
621}
622function _tomorrowDate() {
623  const t = new Date();
624  t.setDate(t.getDate() + 1);
625  t.setHours(8, 0, 0, 0);
626  return t;
627}
628function _nextWeekDate() {
629  const t = new Date();
630  const daysUntilMon = (8 - t.getDay()) % 7 || 7;
631  t.setDate(t.getDate() + daysUntilMon);
632  t.setHours(8, 0, 0, 0);
633  return t;
634}
635function _toLocalDatetimeStr(d) {
636  // Format as YYYY-MM-DDTHH:MM (local, no TZ)
637  const pad = n => String(n).padStart(2, '0');
638  return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
639}
640function _formatReminderTag(dateStr) {
641  if (!dateStr) return '';
642  const d = new Date(dateStr);
643  if (isNaN(d)) return '';
644  const now = new Date();
645  const sameDay = d.toDateString() === now.toDateString();
646  const tomorrow = new Date(now); tomorrow.setDate(tomorrow.getDate() + 1);
647  const isTomorrow = d.toDateString() === tomorrow.toDateString();
648  const time = d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
649  if (sameDay) return `Today, ${time}`;
650  if (isTomorrow) return `Tomorrow, ${time}`;
651  const dateLabel = d.toLocaleDateString([], { month: 'short', day: 'numeric' });
652  return `${dateLabel}, ${time}`;
653}
654// Build a human label for a date's nth-weekday-of-month, e.g. "2nd Tuesday"
655const _ORDINALS = ['1st', '2nd', '3rd', '4th', '5th'];
656const _DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
657function _nthWeekdayLabel(d) {
658  const n = Math.ceil(d.getDate() / 7); // 1..5
659  return `${_ORDINALS[n - 1] || `${n}th`} ${_DAYS[d.getDay()]}`;
660}
661function _isLastWeekdayOfMonth(d) {
662  const test = new Date(d);
663  test.setDate(d.getDate() + 7);
664  return test.getMonth() !== d.getMonth();
665}
666// Find the Nth occurrence of `weekday` in a given year/month. n=1..5.
667// If n=5 and there's no 5th occurrence, returns the 4th (so "5th Monday" still works).
668function _nthWeekdayOfMonth(year, month, weekday, n) {
669  const first = new Date(year, month, 1);
670  const offset = (weekday - first.getDay() + 7) % 7;
671  let day = 1 + offset + (n - 1) * 7;
672  // Last day of month
673  const lastDay = new Date(year, month + 1, 0).getDate();
674  if (day > lastDay) day -= 7;
675  return new Date(year, month, day, 0, 0, 0);
676}
677function _lastWeekdayOfMonth(year, month, weekday) {
678  const lastDay = new Date(year, month + 1, 0);
679  const back = (lastDay.getDay() - weekday + 7) % 7;
680  return new Date(year, month, lastDay.getDate() - back, 0, 0, 0);
681}
682
683// Snap a chosen datetime forward to the next slot matching a normalized
684// recurrence pattern (preserving time-of-day, strictly in the future).
685// Anchors to the user's chosen date when it's in the future (so picking a
686// recurrence on a far-future date doesn't drag it back to today); otherwise
687// anchors to "now". Returns null for daily/yearly/none.
688function _snapToRepeat(currentDate, normRepeat) {
689  const hh = currentDate.getHours();
690  const mm = currentDate.getMinutes();
691  const now = Date.now();
692  const anchor = currentDate.getTime() > now ? currentDate : new Date();
693  const parts = normRepeat.split(':');
694  const kind = parts[0];
695  if (kind === 'weekly') {
696    const targetWd = parseInt(parts[1], 10);
697    if (isNaN(targetWd)) return null;
698    const d = new Date(anchor.getFullYear(), anchor.getMonth(), anchor.getDate(), hh, mm, 0, 0);
699    const delta = (targetWd - d.getDay() + 7) % 7;
700    d.setDate(d.getDate() + delta);
701    if (d.getTime() <= now) d.setDate(d.getDate() + 7);
702    return d;
703  }
704  if (kind === 'monthly') {
705    const sub = parts[1];
706    let y = anchor.getFullYear();
707    let m = anchor.getMonth();
708    // Walk forward up to 14 months to find the next matching slot.
709    for (let tries = 0; tries < 14; tries++) {
710      let target;
711      if (sub === 'day') {
712        const wantDay = parseInt(parts[2], 10);
713        if (isNaN(wantDay)) return null;
714        const lastDay = new Date(y, m + 1, 0).getDate();
715        target = new Date(y, m, Math.min(wantDay, lastDay));
716      } else if (sub === 'nth') {
717        const n = parseInt(parts[2], 10);
718        const wd = parseInt(parts[3], 10);
719        if (isNaN(n) || isNaN(wd)) return null;
720        target = _nthWeekdayOfMonth(y, m, wd, n);
721      } else if (sub === 'last') {
722        const wd = parseInt(parts[2], 10);
723        if (isNaN(wd)) return null;
724        target = _lastWeekdayOfMonth(y, m, wd);
725      } else {
726        return null;
727      }
728      target.setHours(hh, mm, 0, 0);
729      if (target.getTime() > now && target.getTime() >= anchor.getTime()) return target;
730      m++;
731      if (m > 11) { m = 0; y++; }
732    }
733    return null;
734  }
735  return null;
736}
737
738// Render a repeat value as a human-readable label.
739// originalDate is required only to interpret legacy bare values ("weekly", "monthly", ...).
740// All call sites pass it; missing it would silently misinterpret legacy values.
741function _formatRepeatLabel(repeat, originalDate) {
742  if (!repeat || repeat === 'none') return '';
743  const norm = _normalizeRepeat(repeat, originalDate);
744  if (norm === 'daily') return 'Daily';
745  if (norm === 'yearly') return 'Yearly';
746  const parts = norm.split(':');
747  if (parts[0] === 'weekly') {
748    const wd = parseInt(parts[1], 10);
749    if (isNaN(wd)) return 'Weekly';
750    return `Weekly on ${_DAYS[wd]}s`;
751  }
752  if (parts[0] === 'monthly') {
753    if (parts[1] === 'day') return `Monthly on day ${parts[2]}`;
754    if (parts[1] === 'nth') {
755      const n = parseInt(parts[2], 10);
756      const wd = parseInt(parts[3], 10);
757      return `Monthly on ${_ORDINALS[n - 1] || `${n}th`} ${_DAYS[wd]}`;
758    }
759    if (parts[1] === 'last') {
760      const wd = parseInt(parts[2], 10);
761      return `Monthly on last ${_DAYS[wd]}`;
762    }
763  }
764  return norm;
765}
766
767// ---- Reminders ----
768
769function _loadFiredReminders() {
770  try { return new Set(JSON.parse(localStorage.getItem(REMINDER_FIRED_KEY) || '[]')); }
771  catch { return new Set(); }
772}
773
774function _saveFiredReminders(set) {
775  try { localStorage.setItem(REMINDER_FIRED_KEY, JSON.stringify([...set])); }
776  catch {}
777}
778
779async function _ensureNotificationPermission() {
780  if (!('Notification' in window)) return false;
781  if (Notification.permission === 'granted') return true;
782  if (Notification.permission === 'denied') return false;
783  try { const p = await Notification.requestPermission(); return p === 'granted'; }
784  catch { return false; }
785}
786
787// Repeat format:
788//   none
789//   daily
790//   weekly:W              W = 0-6 (Sun..Sat)
791//   monthly:day:D         D = 1-31 (calendar day)
792//   monthly:nth:N:W       N = 1-4 (1st..4th), W = 0-6 (weekday)
793//   monthly:last:W        W = 0-6 (last weekday of month)
794//   yearly
795// Legacy "weekly", "monthly", "monthly_nth_weekday", "monthly_last_weekday"
796// are normalized using the original due_date's weekday/Nth.
797function _normalizeRepeat(repeat, originalDate) {
798  if (!repeat || repeat === 'none') return 'none';
799  if (repeat === 'daily' || repeat === 'yearly') return repeat;
800  if (/^(weekly|monthly):/.test(repeat)) return repeat;
801  // Legacy bare values — derive params from the original date
802  const wd = originalDate.getDay();
803  const n = Math.ceil(originalDate.getDate() / 7);
804  if (repeat === 'weekly') return `weekly:${wd}`;
805  if (repeat === 'monthly') return `monthly:day:${originalDate.getDate()}`;
806  if (repeat === 'monthly_nth_weekday') return `monthly:nth:${n}:${wd}`;
807  if (repeat === 'monthly_last_weekday') return `monthly:last:${wd}`;
808  return repeat;
809}
810
811function _advanceRecurring(dateStr, repeat) {
812  const orig = new Date(dateStr);
813  const hh = orig.getHours();
814  const mm = orig.getMinutes();
815  let d = new Date(orig);
816  const norm = _normalizeRepeat(repeat, orig);
817  if (norm === 'none') return null;
818
819  function step() {
820    if (norm === 'daily') {
821      d.setDate(d.getDate() + 1);
822      return;
823    }
824    if (norm === 'yearly') {
825      d.setFullYear(d.getFullYear() + 1);
826      return;
827    }
828    const parts = norm.split(':');
829    const kind = parts[0];
830    if (kind === 'weekly') {
831      // Snap to the requested weekday in the next 1-7 days
832      const targetWd = parseInt(parts[1], 10);
833      let delta = (targetWd - d.getDay() + 7) % 7;
834      if (delta === 0) delta = 7;
835      d.setDate(d.getDate() + delta);
836      d.setHours(hh, mm, 0, 0);
837      return;
838    }
839    if (kind === 'monthly') {
840      const sub = parts[1];
841      const ny = d.getFullYear() + (d.getMonth() === 11 ? 1 : 0);
842      const nm = (d.getMonth() + 1) % 12;
843      let target;
844      if (sub === 'day') {
845        const wantDay = parseInt(parts[2], 10);
846        const lastDay = new Date(ny, nm + 1, 0).getDate();
847        target = new Date(ny, nm, Math.min(wantDay, lastDay));
848      } else if (sub === 'nth') {
849        const n = parseInt(parts[2], 10);
850        const wd = parseInt(parts[3], 10);
851        target = _nthWeekdayOfMonth(ny, nm, wd, n);
852      } else if (sub === 'last') {
853        const wd = parseInt(parts[2], 10);
854        target = _lastWeekdayOfMonth(ny, nm, wd);
855      } else {
856        d = null; return;
857      }
858      target.setHours(hh, mm, 0, 0);
859      d = target;
860      return;
861    }
862    d = null;
863  }
864
865  step();
866  if (d === null) return null;
867  const now = Date.now();
868  // Cap catch-up to avoid runaway on a malformed/very-old date.
869  let guard = 5000;
870  while (d.getTime() <= now) {
871    if (--guard <= 0) return null;
872    step();
873    if (d === null) return null;
874  }
875  return _toLocalDatetimeStr(d);
876}
877
878function _checkReminders() {
879  if (!_notes.length) return;
880  const now = Date.now();
881  const fired = _loadFiredReminders();
882  let changed = false;
883  for (const note of _notes) {
884    if (!note.due_date || note.archived) continue;
885    if (!_hasTimeComponent(note.due_date)) continue;
886    if (fired.has(note.id)) continue;
887    const due = new Date(note.due_date).getTime();
888    if (isNaN(due)) continue;
889    if (due <= now && due > now - 60000) {
890      _fireReminder(note);
891      // Recurring? advance the due_date instead of marking as fired
892      if (note.repeat && note.repeat !== 'none') {
893        const next = _advanceRecurring(note.due_date, note.repeat);
894        if (next) {
895          note.due_date = next;
896          _patchNote(note.id, { due_date: next }).catch(() => {});
897          // Don't add to fired — new due_date is in the future
898          continue;
899        }
900      }
901      fired.add(note.id);
902      changed = true;
903    } else if (due <= now - 60000) {
904      // Past, never seen — silently advance recurring or mark fired
905      if (note.repeat && note.repeat !== 'none') {
906        const next = _advanceRecurring(note.due_date, note.repeat);
907        if (next) {
908          note.due_date = next;
909          _patchNote(note.id, { due_date: next }).catch(() => {});
910          continue;
911        }
912      }
913      fired.add(note.id);
914      changed = true;
915    }
916  }
917  if (changed) _saveFiredReminders(fired);
918  // Always refresh badge — fired state may have changed visually without note mutation
919  _updateRailBadge();
920}
921
922function _fireReminder(note) {
923  const title = note.title || 'Note reminder';
924  // Include the verbatim note content so the email/notification actually
925  // shows what to do, not just a count. Cap the per-item lines (8 max) and
926  // total length so the body stays inbox-friendly.
927  let rawBody;
928  if (_hasItems(note)) {
929    const pending = (note.items || [])
930      .filter(i => !i.done && !i.checked)
931      .map(i => (i.text || '').trim())
932      .filter(Boolean);
933    if (pending.length) {
934      const shown = pending.slice(0, 8).map(t => `- ${t}`).join('\n');
935      const extra = pending.length > 8 ? `\n…and ${pending.length - 8} more` : '';
936      rawBody = `Pending (${pending.length}):\n${shown}${extra}`;
937    } else {
938      rawBody = `${(note.items || []).length} item${(note.items || []).length === 1 ? '' : 's'}`;
939    }
940  } else {
941    rawBody = (note.content || '').slice(0, 400);
942  }
943
944  // Ask the server to dispatch according to user settings. The server may
945  // return an LLM-written synthesis line and/or send an email. We still show
946  // a local browser notification so the user gets immediate feedback even if
947  // the server path is disabled or slow.
948  const showLocal = (body) => {
949    if ('Notification' in window && Notification.permission === 'granted') {
950      try {
951        const n = new Notification(title, { body, tag: 'note-' + note.id, icon: '/static/favicon.ico' });
952        n.onclick = () => { window.focus();
952 openPanel(); n.close(); };
953      } catch {}
954    }
955    if (uiModule?.showToast) uiModule.showToast(title);
956  };
957
958  // Fire-and-forget server dispatch. If synthesis comes back quickly enough,
959  // use it as the notification body; otherwise the local notification has
960  // already shown with the raw body.
961  let shown = false;
962  const timer = setTimeout(() => { if (!shown) { shown = true; showLocal(rawBody); } }, 1500);
963
964  fetch('/api/notes/fire-reminder', {
965    method: 'POST',
966    credentials: 'same-origin',
967    headers: { 'Content-Type': 'application/json' },
968    body: JSON.stringify({ note_id: note.id, title, body: rawBody }),
969  })
970    .then(r => r.ok ? r.json() : null)
971    .then(data => {
972      clearTimeout(timer);
973      if (shown) return;
974      shown = true;
975      const body = (data && data.synthesis) ? data.synthesis : rawBody;
976      showLocal(body);
977    })
978    .catch(() => {
979      clearTimeout(timer);
980      if (!shown) { shown = true; showLocal(rawBody); }
981    });
982
983  // Pulse the card if visible; otherwise queue it so the next time the user
984  // opens the notes panel the card gets a brief glow.
985  _setReminderCardGlow(note.id, true);
986  const card = document.querySelector(`.note-card[data-note-id="${note.id}"]`);
987  if (card) {
988    card.classList.add('note-card-reminder-fired');
989    setTimeout(() => card.classList.remove('note-card-reminder-fired'), 3000);
990  } else {
991    _queuePendingHighlight(note.id);
992  }
993}
994
995function _startReminderLoop() {
996  if (_reminderTimer) return;
997  _reminderTimer = setInterval(_checkReminders, 30000);
998  _checkReminders(); // run once immediately
999}
1000
1001function _countDueReminders() {
1002  return _notes.filter(n => !n.archived && _isDueTodayOrOverdue(n.due_date) && !_isNoteFullyDone(n)).length;
1003}
1004
1005let _firedDotDismissedAt = (() => {
1006  try {
1007    const v = parseInt(localStorage.getItem(REMINDER_DISMISSED_AT_KEY) || '0', 10);
1008    return Number.isFinite(v) && v > 0 ? v : 0;
1009  } catch { return 0; }
1010})();
1011
1012function _countFiredReminders() {
1013  // Reminders whose time has actually passed (not just date-today),
1014  // and which fired after the last user dismissal.
1015  const now = Date.now();
1016  return _notes.filter(n => {
1017    if (n.archived || _isNoteFullyDone(n)) return false;
1018    if (!n.due_date || !_hasTimeComponent(n.due_date)) return false;
1019    const t = new Date(n.due_date).getTime();
1020    if (isNaN(t) || t > now) return false;
1021    return t > _firedDotDismissedAt;
1022  }).length;
1023}
1024
1025export function dismissFiredReminderDot() {
1026  _firedDotDismissedAt = Date.now();
1027  try { localStorage.setItem(REMINDER_DISMISSED_AT_KEY, String(_firedDotDismissedAt)); } catch {}
1028  _updateRailBadge();
1029}
1030
1031function _updateRailBadge() {
1032  const fired = _countFiredReminders();
1033  // Rail (mini sidebar) — only show the count when reminders have ACTUALLY
1034  // fired since the last dismissal (i.e. you haven't opened notes yet).
1035  // Showing every overdue note forever made the badge feel permanent.
1036  const railBtn = document.getElementById('rail-notes');
1037  if (railBtn) {
1038    let badge = railBtn.querySelector('.rail-notes-badge');
1039    if (fired > 0) {
1040      if (!badge) {
1041        badge = document.createElement('span');
1042        badge.className = 'rail-notes-badge';
1043        railBtn.appendChild(badge);
1044      }
1045      badge.textContent = fired > 99 ? '99+' : String(fired);
1046      badge.classList.add('fired');
1047    } else if (badge) {
1048      badge.remove();
1049    }
1050  }
1051  // Main sidebar button
1052  const sidebarBtn = document.getElementById('tool-notes-btn');
1053  if (sidebarBtn) {
1054    let dot = sidebarBtn.querySelector('.tool-notes-dot');
1055    if (fired > 0) {
1056      if (!dot) {
1057        dot = document.createElement('span');
1058        dot.className = 'tool-notes-dot';
1059        sidebarBtn.appendChild(dot);
1060      }
1061    } else if (dot) {
1062      dot.remove();
1063    }
1064  }
1065  // Individual note cards — pulse ones with fired reminders
1066  document.querySelectorAll('.note-card').forEach(card => {
1067    const id = card.dataset.noteId;
1068    const note = _notes.find(n => n.id === id);
1069    if (!note || note.archived || _isNoteFullyDone(note)) {
1070      card.classList.remove('note-card-reminder-due');
1071      return;
1072    }
1073    if (note.due_date && _hasTimeComponent(note.due_date)) {
1074      const t = new Date(note.due_date).getTime();
1075      card.classList.toggle('note-card-reminder-due', !isNaN(t) && t <= Date.now());
1076    } else {
1077      card.classList.remove('note-card-reminder-due');
1078    }
1079  });
1080}
1081
1082export async function refreshDueBadge(opts = {}) {
1083  // Usually lightweight, but callers that just created a note reminder can
1084  // force a refresh so the background reminder loop sees it immediately.
1085  if (opts.force || _notes.length === 0) {
1086    try {
1087      const wasArchived = _showingArchived;
1088      _showingArchived = false;
1089      await _fetchNotes();
1090      _showingArchived = wasArchived;
1091    } catch {}
1092  }
1093  _updateRailBadge();
1094}
1095
1096// ---- Panel ----
1097
1098export function openPanel() {
1099  if (_open) return;
1100  _open = true;
1101  _editingId = null;
1102  _clearViewedReminderGlows();
1103  _firedDotDismissedAt = Date.now();
1104  try { localStorage.setItem(REMINDER_DISMISSED_AT_KEY, String(_firedDotDismissedAt)); } catch {}
1105
1106  const container = document.getElementById('chat-container');
1107  if (!container) return;
1108
1109  document.body.classList.add('notes-view');
1110
1111  // On mobile the notes panel takes the whole screen — auto-close the
1112  // sidebar so the panel isn't cropped underneath it.
1113  if (window.innerWidth <= 768) {
1114    const sb = document.getElementById('sidebar');
1115    if (sb) sb.classList.add('hidden');
1116    document.body.classList.add('sidebar-collapsed');
1117  }
1118  // Mobile mode: tiles become read-only previews (no inline checkbox /
1119  // edit / archive / etc.), tap opens a fullscreen edit overlay,
1120  // long-press enters drag-to-reorder mode. See _bindCardEvents +
1121  // .notes-mobile-mode CSS rules.
1122  if (_isNotesMobileMode()) document.body.classList.add('notes-mobile-mode');
1123
1124  // Toggle button state
1125  const btn = document.getElementById('tool-notes-btn');
1126  if (btn) btn.classList.add('active');
1127
1128  // Create panel
1129  const pane = document.createElement('div');
1130  pane.id = 'notes-pane';
1131  pane.className = 'notes-pane';
1132  pane.innerHTML = `
1133    <div class="notes-mobile-grabber" id="notes-mobile-grabber" aria-hidden="true"></div>
1134    <div class="notes-pane-header">
1135      <h4 class="notes-pane-title"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2.5px;margin-right:6px"><path d="M5 3h10l4 4v14H5z"/><path d="M15 3v5h5"/><path d="M8 17.5 15.5 10l2.5 2.5L10.5 20H8z"/></svg>Notes</h4>
1136      <span style="flex:1"></span>
1137      <button id="notes-archive-toggle" class="doc-action-icon-btn notes-header-text-btn" title="View archive" style="opacity:0.8;gap:5px;">
1138        <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 002 2h12a2 2 0 002-2V8"/><path d="M10 12h4"/></svg>
1139        <span class="notes-header-btn-label">Archive</span>
1140      </button>
1141      <button id="notes-view-toggle" class="doc-action-icon-btn notes-header-text-btn" title="Toggle view" style="opacity:0.8;gap:5px;">
1142        <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
1143        <span class="notes-header-btn-label">Toggle</span>
1144      </button>
1145      <button id="notes-minimize-btn" class="modal-minimize-btn" title="Minimize" aria-label="Minimize notes" style="position:relative;left:2px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.4" stroke-linecap="round" aria-hidden="true"><line x1="6" y1="18" x2="18" y2="18"/></svg></button>
1146    </div>
1147    <div class="notes-search-bar">
1148      <input type="text" id="notes-search" class="memory-search-input" placeholder="Search notes…" autocomplete="off" />
1149      <button id="notes-select-btn" class="notes-select-trigger" type="button">Select</button>
1150    </div>
1151    <div id="notes-bulk-bar" class="memory-bulk-bar hidden">
1152      <label class="memory-bulk-check-all"><input type="checkbox" id="notes-select-all" /> All</label>
1153      <span id="notes-selected-count">0 Selected</span>
1154      <span style="flex:1"></span>
1155      <button id="notes-bulk-archive" class="memory-toolbar-btn" disabled>
1156        <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 002 2h12a2 2 0 002-2V8"/><path d="M10 12h4"/></svg>Archive
1157      </button>
1158      <button id="notes-bulk-delete" class="memory-toolbar-btn danger" disabled>
1159        <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
1159<line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>Delete
1160      </button>
1161    </div>
1162    <div class="notes-pane-body"></div>
1163  `;
1164
1165  // On mobile open as a full-screen bottom sheet (slide up), not the desktop
1166  // side panel. Set inline so it wins over the base .notes-pane rule regardless
1167  // of cascade specifics (the CSS @media override wasn't reliably applying,
1168  // which left it as a side panel squeezing the chat).
1169  if (window.innerWidth <= 768) {
1170    pane.style.position = 'fixed';
1171    pane.style.inset = '0';
1172    pane.style.width = '100%';
1173    pane.style.maxWidth = '100%';
1174    pane.style.zIndex = '170';
1175    pane.style.borderRadius = '14px 14px 0 0';
1176    pane.style.animation = 'sheet-enter 0.25s cubic-bezier(0.2, 0.8, 0.2, 1) both';
1177    pane.style.transformOrigin = 'bottom center';
1178  }
1179
1180  // Mount on body so Notes can behave like the other draggable windows. On
1181  // desktop it is immediately docked to the right by _restoreNotesSidebarDock.
1182  const backdrop = document.createElement('div');
1183  backdrop.className = 'notes-pane-backdrop';
1184  backdrop.id = 'notes-pane-backdrop';
1185  backdrop.addEventListener('click', (ev) => {
1186    if (ev.target === backdrop) closePanel('down');
1187  });
1188  backdrop.appendChild(pane);
1189  document.body.appendChild(backdrop);
1190  _wireNotesWindow(pane);
1191  _restoreNotesSidebarDock(pane);
1192
1193  // Events
1194  // (Close chevron removed — swipe down on mobile, tool-rail toggle on de
1194sktop.)
1195
1196  // Mobile: swipe the grab handle / header down to dismiss (minimise to chip).
1197  // Mirrors the document sheet gesture — finger-following, velocity-based
1198  // dismiss, rubber-band on up-drag, spring snap-back.
1199  _wireNotesSwipeDismiss(pane.querySelector('.notes-mobile-grabber'), pane);
1200  _wireNotesSwipeDismiss(pane.querySelector('.notes-pane-header'), pane);
1201
1202  const minBtn = document.getElementById('notes-minimize-btn');
1203  if (minBtn) minBtn.addEventListener('click', (e) => {
1204    e.preventDefault();
1205    e.stopPropagation();
1206    closePanel('down');
1207  });
1208  // Search
1209  const searchEl = document.getElementById('notes-search');
1210  if (searchEl) {
1211    searchEl.addEventListener('input', () => {
1212      _searchQuery = searchEl.value.trim().toLowerCase();
1213      _renderNotes();
1214    });
1215  }
1216
1217  // View toggle
1218  const archiveBtn = document.getElementById('notes-archive-toggle');
1219  if (archiveBtn) {
1220    const ARCHIVE_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 002 2h12a2 2 0 002-2V8"/><path d="M10 12h4"/></svg><span class="notes-header-btn-label">Archive</span>';
1221    const CLOSE_ICON   = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg><span class="notes-header-btn-label">Archive</span>';
1222    const syncArchiveBtn = () => {
1223      archiveBtn.classList.toggle('active', _showingArchived);
1224      archiveBtn.title = _showingArchived ? 'Exit archive' : 'View archive';
1225      archiveBtn.style.opacity = _showingArchived ? '1' : '0.8';
1226      // Swap to an X while in archive view so it doubles as a close-back-
1227      // to-active-notes toggle.
1228      archiveBtn.innerHTML = _showingArchived ? CLOSE_ICON : ARCHIVE_ICON;
1229      // Tint the whole pane so it's obvious you're not in the active list.
1230      pane.classList.toggle('notes-pane-archive', _showingArchived);
1231    };
1232    syncArchiveBtn();
1233    archiveBtn.addEventListener('click', async () => {
1234      _showingArchived = !_showingArchived;
1235      _selectedIds.clear();
1236      syncArchiveBtn();
1237      // Brief fade so the body content swap doesn't snap — the bg-tint
1238      // change is already eased by CSS transitions on .notes-pane*.
1239      const _bodyEl = document.querySelector('#notes-pane .notes-pane-body');
1240      if (_bodyEl) {
1241        _bodyEl.style.transition = 'opacity 0.18s ease';
1242        _bodyEl.style.opacity = '0.25';
1243      }
1244      await _fetchNotes();
1245      _renderNotes();
1246      if (_bodyEl) {
1247        requestAnimationFrame(() => {
1248          _bodyEl.style.opacity = '';
1249          _bodyEl.addEventListener('transitionend', () => { _bodyEl.style.transition = ''; }, { once: true });
1250        });
1251      }
1252    });
1253  }
1254  const viewBtn = document.getElementById('notes-view-toggle');
1255  if (viewBtn) {
1256    pane.classList.toggle('notes-view-grid', _viewMode === 'grid');
1257    // Label shows what you'll switch TO — "Grid" while in list, "List" while in grid.
1258    const _setViewLabel = () => {
1259      const lbl = viewBtn.querySelector('.notes-header-btn-label');
1260      if (lbl) lbl.textContent = _viewMode === 'grid' ? 'List' : 'Grid';
1261    };
1262    _setViewLabel();
1263    requestAnimationFrame(() => _applyMasonry(document.querySelector('#notes-pane .notes-pane-body')));
1264    viewBtn.addEventListener('click', () => {
1265      _viewMode = _viewMode === 'grid' ? 'list' : 'grid';
1266      try { localStorage.setItem('odysseus-notes-view', _viewMode); } catch {}
1267      pane.classList.toggle('notes-view-grid', _viewMode === 'grid');
1268      _setViewLabel();
1269      requestAnimationFrame(() => _applyMasonry(document.querySelector('#notes-pane .notes-pane-body')));
1270    });
1271  }
1272  // Select mode
1273  document.getElementById('notes-select-btn').addEventListener('click', () => {
1274    if (_selectMode) _exitSelectMode(); else _enterSelectMode();
1275  });
1276  // Esc cancels select mode. Notes uses a toggle "Select" button rather
1277  // than a *-bulk-cancel button, so the global Esc-cancel handler in
1278  // keyboard-shortcuts.js can't reach it — handle it here. Capture phase
1279  // + stopPropagation so Esc cancels select instead of closing the panel.
1280  if (_notesSelectEscHandler) {
1281    document.removeEventListener('keydown', _notesSelectEscHandler, true);
1282  }
1283  _notesSelectEscHandler = (e) => {
1284    if (e.key === 'Escape' && _selectMode) {
1285      e.preventDefault();
1286      e.stopPropagation();
1287      _exitSelectMode();
1288    }
1289  };
1290  document.addEventListener('keydown', _notesSelectEscHandler, true);
1291  document.getElementById('notes-select-all').addEventListener('change', (e) => {
1292    if (e.target.checked) _notes.forEach(n => _selectedIds.add(n.id));
1293    else _selectedIds.clear();
1294    _renderNotes();
1295    _updateBulkBar();
1296  });
1297  document.getElementById('notes-bulk-archive').addEventListener('click', async () => {
1298    const ids = [..._selectedIds];
1299    if (!ids.length) return;
1300    await Promise.all(ids.map(id => _patchNote(id, { archived: true }).catch(() => {})));
1301    _exitSelectMode();
1302    await _fetchNotes();
1303    _renderNotes();
1304    uiModule.showToast(`Archived ${ids.length}`);
1305  });
1306  document.getElementById('notes-bulk-delete').addEventListener('click', async () => {
1307    const ids = [..._selectedIds];
1308    if (!ids.length) return;
1309    if (uiModule && uiModule.styledConfirm) {
1310      const ok = await uiModule.styledConfirm(`Delete ${ids.length} note${ids.length === 1 ? '' : 's'}?`, { confirmText: 'Delete', danger: true });
1311      if (!ok) return;
1312    }
1313    await Promise.all(ids.map(id => _deleteNoteApi(id).catch(() => {})));
1314    _exitSelectMode();
1315    await _fetchNotes();
1316    _renderNotes();
1317    uiModule.showToast(`Deleted ${ids.length}`);
1318  });
1319  // Escape: exit select mode first (if active), otherwise close the panel.
1320  // Skip when the user is editing a form field — those have their own
1321  // ESC-to-cancel handlers and we don't want to nuke the whole panel
1322  // mid-edit.
1323  // Idempotent: remove any previous handler from a prior openPanel so
1324  // re-opening doesn't stack multiple handlers.
1325  if (_notesKeydownHandler) {
1326    document.removeEventListener('keydown', _notesKeydownHandler);
1327    _notesKeydownHandler = null;
1328  }
1329  _notesKeydownHandler = (e) => {
1330    if (!_open) return;
1331    const t = e.target;
1332    const inField = t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable);
1333    // Ctrl/Cmd+Z anywhere in the panel — undo the last note action. Skip when
1334    // typing in a field so the browser's normal text-undo still works.
1335    if ((e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'Z') && !e.shiftKey) {
1336      if (inField) return;
1337      if (_undoStack.length === 0) return;
1338      e.preventDefault();
1339      _popAndRunUndo();
1340      return;
1341    }
1342    // Ctrl/Cmd+C while hovering a note card → copy that note. Skip when the
1343    // user is editing or has an active text selection (let the browser handle
1344    // a real text copy in that case).
1345    if ((e.ctrlKey || e.metaKey) && (e.key === 'c' || e.key === 'C') && !e.shiftKey && !e.altKey) {
1346      if (inField) return;
1347      const sel = window.getSelection?.();
1348      if (sel && sel.toString && sel.toString().length > 0) return;
1349      const hovered = document.querySelector('.note-card:hover');
1350      if (!hovered) return;
1351      const id = hovered.dataset.noteId;
1352      if (!id) return;
1353      e.preventDefault();
1354      // Flash the ⋯ menu button (copy now lives in that menu).
1355      const btn = hovered.querySelector('.note-card-corner-menu');
1356      _copyNote(id, btn);
1357      return;
1358    }
1359    if (e.key !== 'Escape') return;
1360    if (inField) return;
1361    if (_selectMode) { _exitSelectMode(); return; }
1362    if (_showingArchived) {
1363      // Mirror the archive toggle button: flip back to active notes.
1364      document.getElementById('notes-archive-toggle')?.click();
1365      return;
1366    }
1367    _forceCloseNotesPanel();
1368  };
1369  document.addEventListener('keydown', _notesKeydownHandler);
1370
1371  // Load — show skeleton immediately, then fetch
1372  _renderLoadingSkeleton();
1373  // Defer the highlight flush to the next frame so it runs *after* the cards
1374  // are committed to the DOM (and any FLIP animations have settled), giving
1375  // the querySelector lookups inside something to find.
1376  _fetchNotes().then(() => {
1377    _renderNotes();
1378    requestAnimationFrame(() => _flushPendingHighlights());
1379    _startReminderLoop();
1380    _showNotesFirstOpenHint(pane);
1381  });
1382}
1383
1384function _renderLoadingSkeleton() {
1385  const body = document.querySelector('#notes-pane .notes-pane-body');
1386  if (!body) return;
1387  body.innerHTML = '';
1388  _renderLabelsInto(body);
1389  _renderQuickAdd(body);
1390  const skel = document.createElement('div');
1391  skel.className = 'notes-skeleton';
1392  skel.innerHTML = `
1393    <div class="notes-skeleton-card"></div>
1394    <div class="notes-skeleton-card"></div>
1395    <div class="notes-skeleton-card short"></div>
1396    <div class="notes-skeleton-card"></div>
1397  `;
1398  body.appendChild(skel);
1399}
1400
1401function _enterSelectMode() {
1402  _selectMode = true;
1403  _selectedIds.clear();
1404  const bar = document.getElementById('notes-bulk-bar');
1405  const btn = document.getElementById('notes-select-btn');
1406  if (bar) bar.classList.remove('hidden');
1407  if (btn) { btn.classList.add('active'); btn.textContent = 'Cancel'; }
1408  _renderNotes();
1409  _updateBulkBar();
1410}
1411
1412function _exitSelectMode() {
1413  _selectMode = false;
1414  _selectedIds.clear();
1415  const bar = document.getElementById('notes-bulk-bar');
1416  const btn = document.getElementById('notes-select-btn');
1417  const all = document.getElementById('notes-select-all');
1418  if (bar) bar.classList.add('hidden');
1419  if (btn) { btn.classList.remove('active'); btn.textContent = 'Select'; }
1420  if (all) all.checked = false;
1421  _renderNotes();
1422}
1423
1424function _updateBulkBar() {
1425  const count = _selectedIds.size;
1426  const countEl = document.getElementById('notes-selected-count');
1427  const archiveBtn = document.getElementById('notes-bulk-archive');
1428  const deleteBtn = document.getElementById('notes-bulk-delete');
1429  const allEl = document.getElementById('notes-select-all');
1430  if (countEl) countEl.textContent = `${count} Selected`;
1431  if (archiveBtn) archiveBtn.disabled = count === 0;
1432  if (deleteBtn) deleteBtn.disabled = count === 0;
1433  if (allEl) allEl.checked = _notes.length > 0 && _notes.every(n => _selectedIds.has(n.id));
1434  // Toggle select-mode class so todo dots don't react to hover
1435  const pane = document.getElementById('notes-pane');
1436  if (pane) pane.classList.toggle('notes-select-mode', count > 0);
1437}
1438
1439// A note's label field may hold multiple space-separated tags. Split + dedupe.
1440function _noteTags(n) {
1441  const tags = [];
1442  if (n?.label) tags.push(...n.label.trim().split(/\s+/).filter(Boolean));
1443  if (n?.due_date && _hasTimeComponent(n.due_date)) tags.push('reminder');
1444  return [...new Set(tags.map(t => t.replace(/^#+/, '').trim()).filter(Boolean))];
1445}
1446
1447function _visibleNoteTags(n) {
1448  return _noteTags(n).filter(t => t !== 'reminder');
1449}
1450
1451function _isPastReminder(n) {
1452  if (!n?.due_date || !_hasTimeComponent(n.due_date)) return false;
1453  const due = new Date(n.due_date).getTime();
1454  return !isNaN(due) && due <= Date.now();
1455}
1456
1457async function _clearPastReminders() {
1458  const targets = _notes.filter(n => !n.archived && _isPastReminder(n));
1459  if (!targets.length) {
1460    uiModule.showToast?.('No past reminders to clear');
1461    return;
1462  }
1463  const ok = uiModule?.styledConfirm
1464    ? await uiModule.styledConfirm(`Delete ${targets.length} past reminder${targets.length === 1 ? '' : 's'}?`, { confirmText: 'Delete', danger: true })
1465    : confirm(`Delete ${targets.length} past reminder${targets.length === 1 ? '' : 's'}?`);
1466  if (!ok) return;
1467  await Promise.all(targets.map(n => _deleteNoteApi(n.id).catch(() => {})));
1468  await _fetchNotes();
1469  _renderNotes();
1470  uiModule.showToast?.(`Cleared ${targets.length} past reminder${targets.length === 1 ? '' : 's'}`);
1471}
1472
1473function _renderLabels(root = document) {
1474  const bar = root.querySelector?.('.notes-labels-bar') || document.querySelector('.notes-labels-bar');
1475  if (!bar) return;
1476  const labels = new Set();
1477  for (const n of _notes) for (const t of _visibleNoteTags(n)) labels.add(t);
1478  const sortedLabels = [...labels].sort();
1479  // Count active reminders (not archived, has datetime due_date)
1480  const reminderCount = _notes.filter(n => !n.archived && n.due_date && _hasTimeComponent(n.due_date)).length;
1481  const pastReminderCount = _notes.filter(n => !n.archived && _isPastReminder(n)).length;
1482  const defaultCount = _notes.filter(n => !n.archived && _visibleNoteTags(n).length === 0).length;
1483  // Active goals = non-archived goal notes. Today view lists pending steps
1484  // from each, so we surface the count next to the chip.
1485  const goalCount = _notes.filter(n => n.note_type === 'goal' && !n.archived).length;
1486  const todayCount = _notes.filter(n => n.note_type === 'goal' && !n.archived && _nextGoalStep(n)).length;
1487  bar.style.display = '';
1488  const allActive = _activeLabel === null && _activeFilter === null;
1489  let html = `<button class="notes-label-chip${allActive ? ' active' : ''}" data-action="all">All</button>`;
1490  html += `<button class="notes-label-chip${_activeFilter === 'default' ? ' active' : ''}" data-action="default" title="Show notes without tags">Default <span class="notes-label-chip-count">${defaultCount}</span></button>`;
1491  if (todayCount > 0) {
1492    const isOn = _activeFilter === 'today';
1493    const icon = '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:2px"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>';
1494    html += `<button class="notes-label-chip notes-label-chip-today${isOn ? ' active' : ''}" data-action="today" title="Next step from every goal">${icon}Today <span class="notes-label-chip-count">${todayCount}</span></button>`;
1495  }
1496  if (goalCount > 0) {
1497    const isOn = _activeFilter === 'goals';
1498    const icon = '<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:2px"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>';
1499    html += `<button class="notes-label-chip notes-label-chip-goals${isOn ? ' active' : ''}" data-action="goals" title="Show only goals">${icon}Goals <span class="notes-label-chip-count">${goalCount}</span></button>`;
1500  }
1501  const isReminderOn = _activeFilter === 'reminders';
1502  const isReminderOff = _activeFilter === 'no-reminders';
1503  const reminderCls = `notes-label-chip notes-label-chip-reminders${isReminderOn ? ' active' : ''}${isReminderOff ? ' active negated' : ''}`;
1504  const reminderIcon = isReminderOff
1505    // bell-off icon
1506    ? '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:2px"><path d="M13.73 21a2 2 0 0 1-3.46 0"/><path d="M18.63 13A17.89 17.89 0 0 1 18 8"/><path d="M6.26 6.26A5.86 5.86 0 0 0 6 8c0 7-3 9-3 9h14"/><path d="M18 8a6 6 0 0 0-9.33-5"/><line x1="1" y1="1" x2="23" y2="23"/></svg>'
1507    : '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:2px"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>';
1508  html += `<button class="${reminderCls}" data-action="reminders" title="${isReminderOn ? 'Showing only reminders — click to show all' : isReminderOff ? 'Hiding reminders — click to show only reminders' : 'Click to filter reminders'}">${reminderIcon}Reminders <span class="notes-label-chip-count">${reminderCount}</span></button>`;
1509  const showingReminders = _activeFilter === 'reminders';
1510  if (showingReminders && pastReminderCount > 0) {
1511    html += `<button class="notes-label-chip notes-label-clear-past" data-action="clear-past-reminders" title="Delete reminders whose time has passed"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
1511Clear past <span class="notes-label-chip-count">${pastReminderCount}</span></button>`;
1512  }
1513  for (const lbl of sortedLabels) {
1514    html += `<button class="notes-label-chip${_activeLabel === lbl ? ' active' : ''}" data-label="${_esc(lbl)}">#${_esc(lbl)}</button>`;
1515  }
1516  bar.innerHTML = html;
1517  bar.querySelectorAll('.notes-label-chip').forEach(chip => {
1518    chip.addEventListener('click', () => {
1519      if (chip.dataset.action === 'all') {
1520        _activeLabel = null;
1521        _activeFilter = null;
1522      } else if (chip.dataset.action === 'today') {
1523        _activeLabel = null;
1524        _activeFilter = (_activeFilter === 'today') ? null : 'today';
1525      } else if (chip.dataset.action === 'goals') {
1526        _activeLabel = null;
1527        _activeFilter = (_activeFilter === 'goals') ? null : 'goals';
1528      } else if (chip.dataset.action === 'default') {
1529        _activeLabel = null;
1530        _activeFilter = (_activeFilter === 'default') ? null : 'default';
1531      } else if (chip.dataset.action === 'reminders') {
1532        _activeLabel = null;
1533        // Cycle: null → reminders → null → no-reminders → null → reminders → ...
1534        if (_activeFilter === null) {
1535          _activeFilter = _reminderChipNext;
1536          _reminderChipNext = (_reminderChipNext === 'reminders') ? 'no-reminders' : 'reminders';
1537        } else {
1538          _activeFilter = null;
1539        }
1540      } else if (chip.dataset.action === 'clear-past-reminders') {
1541        _clearPastReminders();
1542        return;
1543      } else {
1544        _activeFilter = null;
1545        _activeLabel = chip.dataset.label || null;
1546      }
1547      _renderNotes();
1548    });
1549  });
1550}
1551
1552function _renderLabelsInto(_body) {
1553  if (!_body) return;
1554  let bar = _body.querySelector(':scope > .notes-labels-bar');
1555  if (!bar) {
1556    bar = document.createElement('div');
1557    bar.className = 'notes-labels-bar';
1558    _body.appendChild(bar);
1559  }
1560  _renderLabels(_body);
1561}
1562
1563function _ensureNotesChipRegistered() {
1564  if (Modals.isRegistered('notes-panel')) return;
1565  Modals.register('notes-panel', {
1566    railBtnId: 'rail-notes',
1567    sidebarBtnId: 'tool-notes-btn',
1568    restoreFn: () => { openPanel(); },
1569    closeFn: () => { _forceCloseNotesPanel(); },
1570  });
1571}
1572
1573// `direction === 'down'` (mobile swipe-down) MINIMIZES the panel to a
1574// dock chip instead of fully closing — tapping the chip reopens it.
1575// Any other call (close button, programmatic) is a full close.
1576export function closePanel(direction) {
1577  if (!_open) return;
1578  _open = false;
1579  _editingId = null;
1580  _clearViewedReminderGlows();
1581  const _minimize = direction === 'down';
1582  if (_minimize) {
1583    _ensureNotesChipRegistered();
1584  } else if (Modals.isRegistered('notes-panel')) {
1585    Modals.unregister('notes-panel');
1586  }
1587
1588  // Drop the document keydown listener and the 30s reminder interval —
1589  // both leaked across open/close cycles in the v2 review.
1590  if (_notesKeydownHandler) {
1591    document.removeEventListener('keydown', _notesKeydownHandler);
1592    _notesKeydownHandler = null;
1593  }
1594  if (_notesSelectEscHandler) {
1595    document.removeEventListener('keydown', _notesSelectEscHandler, true);
1596    _notesSelectEscHandler = null;
1597  }
1598  if (_reminderTimer) {
1599    clearInterval(_reminderTimer);
1600    _reminderTimer = null;
1601  }
1602
1603  document.body.classList.remove('notes-view');
1604  document.body.classList.remove('notes-mobile-mode');
1605  document.body.classList.remove('notes-drag-mode');
1606  // Closing the panel should PRESERVE in-progress edits, not drop them.
1607  // Commit any open in-place editor, and close the mobile fullscreen
1608  // overlay with save=true so the note is persisted.
1609  try { _commitOpenInPlaceEditor(); } catch {}
1610  _closeMobileFullscreenEdit({ save: true });
1611  // /notes route may have collapsed the wide sidebar to a rail; restore.
1612  try { window._restoreSidebarIfRouteCollapsed?.(); } catch (_) {}
1613
1614  const btn = document.getElementById('tool-notes-btn');
1615  if (btn) btn.classList.remove('active');
1616
1617  const pane = document.getElementById('notes-pane');
1618  const backdrop = document.getElementById('notes-pane-backdrop');
1619  if (pane) {
1620    // Scale-out + fade. Match the enter animation duration so close feels
1621    // like the same gesture played backwards.
1622    pane.classList.add('notes-pane-leaving');
1623    const _cleanup = () => {
1624      try { pane.remove(); } catch {}
1625      try { backdrop?.remove(); } catch {}
1626    };
1627    pane.addEventListener('animationend', _cleanup, { once: true });
1628    // Belt-and-braces: if animation is skipped (reduced motion / detached
1629    // tab) the listener won't fire; remove after the expected duration.
1630    setTimeout(_cleanup, 220);
1631  } else if (backdrop) {
1632    backdrop.remove();
1633  }
1634  // Show the dock chip for a swipe-down minimize (tap it to reopen).
1635  if (_minimize) { try { Modals.minimize('notes-panel'); } catch {} }
1636}
1637
1638export function togglePanel() {
1639  if (_open) closePanel();
1640  else openPanel();
1641}
1642
1643export function isPanelOpen() { return _open; }
1644
1645// ---- Render ----
1646
1647// FLIP animation — capture positions before render, animate back after
1648function _captureCardPositions() {
1649  const body = document.querySelector('#notes-pane .notes-pane-body');
1650  if (!body) return null;
1651  const positions = new Map();
1652  body.querySelectorAll('.note-card').forEach(card => {
1653    const id = card.dataset.noteId;
1654    if (id) positions.set(id, card.getBoundingClientRect());
1655  });
1656  return positions;
1657}
1658
1659function _animateReflow(prevPositions) {
1660  if (!prevPositions || !prevPositions.size) return;
1661  const body = document.querySelector('#notes-pane .notes-pane-body');
1662  if (!body) return;
1663  body.querySelectorAll('.note-card').forEach(card => {
1664    const id = card.dataset.noteId;
1665    const prev = prevPositions.get(id);
1666    if (!prev) return;
1667    const next = card.getBoundingClientRect();
1668    const dx = prev.left - next.left;
1669    const dy = prev.top - next.top;
1670    if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return;
1671    // Invert: jump back to old position
1672    card.style.transition = 'none';
1673    card.style.transform = `translate(${dx}px, ${dy}px)`;
1674    // Play: animate to 0
1675    requestAnimationFrame(() => {
1676      card.style.transition = 'transform 0.25s cubic-bezier(0.34, 1.2, 0.64, 1)';
1677      card.style.transform = '';
1678      card.addEventListener('transitionend', () => {
1679        card.style.transition = '';
1680      }, { once: true });
1681    });
1682  });
1683}
1684
1685function _renderNotes() {
1686  _updateRailBadge();
1687  const body = document.querySelector('#notes-pane .notes-pane-body');
1688  if (!body) return;
1689  const prevPositions = _captureCardPositions();
1690  const activeReminderHighlights = _loadActiveHighlights();
1691
1692  let filtered = _activeLabel ? _notes.filter(n => _noteTags(n).includes(_activeLabel)) : _notes;
1693  if (_activeFilter === 'reminders') {
1694    filtered = filtered.filter(n => n.due_date && _hasTimeComponent(n.due_date));
1695  } else if (_activeFilter === 'no-reminders') {
1696    filtered = filtered.filter(n => !(n.due_date && _hasTimeComponent(n.due_date)));
1697  } else if (_activeFilter === 'default') {
1698    filtered = filtered.filter(n => _visibleNoteTags(n).length === 0);
1699  } else if (_activeFilter === 'goals') {
1700    filtered = filtered.filter(n => n.note_type === 'goal' && !n.archived);
1701  } else if (_activeFilter === 'today') {
1702    // Today view: only goals that still have an unchecked step.
1703    filtered = filtered.filter(n => n.note_type === 'goal' && !n.archived && _nextGoalStep(n));
1704  }
1705  if (_searchQuery) {
1706    filtered = filtered.filter(n => {
1707      const q = _searchQuery;
1708      if ((n.title || '').toLowerCase().includes(q)) return true;
1709      if ((n.content || '').toLowerCase().includes(q)) return true;
1710      if ((n.label || '').toLowerCase().includes(q)) return true;
1711      if (Array.isArray(n.items) && n.items.some(it => (it.text || '').toLowerCase().includes(q))) return true;
1712      return false;
1713    });
1714  }
1715  const sorted = [...filtered].sort((a, b) => {
1716    // In reminders view: sort by due date ascending (soonest first)
1717    if (_activeFilter === 'reminders') {
1718      const da = new Date(a.due_date || 0).getTime();
1719      const db = new Date(b.due_date || 0).getTime();
1720      return da - db;
1721    }
1722    // Archived view: newest archived first (ignore manual sort_order).
1723    if (_showingArchived) {
1724      return new Date(b.updated_at || 0) - new Date(a.updated_at || 0);
1725    }
1726    if (a.pinned && !b.pinned) return -1;
1727    if (!a.pinned && b.pinned) return 1;
1728    // Active reminders (due date in the past, not done/archived) rank
1729    // immediately under the pinned block.
1730    const aActive = _hasActiveReminder(a);
1731    const bActive = _hasActiveReminder(b);
1732    if (aActive && !bActive) return -1;
1733    if (!aActive && bActive) return 1;
1734    const so = (a.sort_order || 0) - (b.sort_order || 0);
1735    if (so !== 0) return so;
1736    return new Date(b.updated_at || 0) - new Date(a.updated_at || 0);
1737  });
1738
1739  let html = '';
1740  // Today view: render a compact card listing the next-unchecked step from
1741  // each active goal. Tapping a step toggles it done (same idx-based wiring
1742  // as regular checkboxes). Tapping the title opens the goal note for full
1743  // editing.
1744  if (_activeFilter === 'today') {
1745    body.innerHTML = '';
1746    _renderLabelsInto(body);
1747    _renderQuickAdd(body);
1748    if (sorted.length === 0) {
1749      body.insertAdjacentHTML('beforeend', `<div class="notes-empty">All caught up — no pending goal steps right now.</div>`);
1750    } else {
1751      let todayHtml = `<div class="notes-today-wrap">
1752        <div class="notes-today-header">
1753          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
1754          <span>Today &middot; one step per goal</span>
1755        </div>
1756        <div class="notes-today-list">`;
1757      for (const note of sorted) {
1758        const next = _nextGoalStep(note);
1759        if (!next) continue;
1760        const progress = _goalProgress(note).trim();
1761        todayHtml += `<div class="notes-today-row" data-note-id="${note.id}">
1762          <span class="note-check-dot" data-note-id="${note.id}" data-idx="${next.idx}" title="Mark step done"></span>
1763          <div class="notes-today-text">
1764            <div class="notes-today-title" data-action="edit" data-note-id="${note.id}">${_esc(note.title || '(untitled goal)')}</div>
1765            <div class="notes-today-step">${_linkify(next.item.text || '')}</div>
1766          </div>
1767          <span class="notes-today-progress">${_esc(progress)}</span>
1768        </div>`;
1769      }
1770      todayHtml += `</div></div>`;
1771      body.insertAdjacentHTML('beforeend', todayHtml);
1772    }
1773    _wireTodayView(body);
1774    return;
1775  }
1776  for (const note of sorted) {
1777    if (_editingId === note.id) continue; // skip — form is shown instead
1778    const borderColor = COLOR_HEX[note.color || ''] || 'var(--border)';
1779    const dueFmt = _formatDueDate(note.due_date);
1780    const overdue = _isDueOverdue(note.due_date);
1781
1782    let contentHtml = '';
1783    if (_hasItems(note) && Array.isArray(note.items)) {
1784      // Goal notes can carry a free-form description above the step list —
1785      // todos rarely do, but the same render works for both.
1786      if (note.note_type === 'goal' && (note.content || '').trim()) {
1787        const fullText = note.content || '';
1788        const preview = fullText.length > 300 ? fullText.slice(0, 300) + '…' : fullText;
1789        contentHtml += `<div class="note-goal-desc">${_esc(preview)}</div>`;
1790      }
1791      contentHtml += '<div class="note-checklist-preview">';
1792      // Show ALL items — the preview container is scrollable (CSS caps
1793      // its max-height + overflow-y:auto), so there's no need to truncate.
1794      for (let i = 0; i < note.items.length; i++) {
1795        const item = note.items[i];
1796        const doneClass = item.done ? ' done' : '';
1797        const indent = Math.min(item.indent || 0, 3);
1798        contentHtml += `<div class="note-checkbox${doneClass}" data-note-id="${note.id}" data-idx="${i}" style="padding-left:${indent * 16}px">
1799          <span class="note-check-dot" title="Mark done"></span>
1800          <span class="note-check-text">${_linkify(item.text)}</span>
1801          <button class="note-checkbox-rm" data-note-id="${note.id}" data-idx="${i}" title="Delete item">
1802            <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
1803          </button>
1804        </div>`;
1805      }
1806      contentHtml += '</div>';
1807    } else {
1808      const fullText = note.content || '';
1809      const preview = fullText.length > 600 ? fullText.slice(0, 600) + '…' : fullText;
1810      // _linkify already calls _esc internally, so URLs become clickable
1811      // anchors (used by e.g. the "remind me to reply" email deep-link).
1812      contentHtml = preview ? `<div class="note-content-preview">${_linkify(preview)}</div>` : '';
1813    }
1814
1815    const isBg = _isBgImage(note.color);
1816    const cc = (note.color && !isBg) ? ' note-color-' + note.color : '';
1817    const cardStyle = isBg ? ` style="${_customColorStyle(note.color)}"` : '';
1818    const sel = _selectedIds.has(note.id) ? ' note-card-selected' : '';
1819    const reminderTagHtml = note.due_date && _hasTimeComponent(note.due_date)
1820      ? `<div class="note-card-reminder${overdue ? ' overdue' : ''}">
1821          <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
1822          <span>${_esc(_formatReminderTag(note.due_date))}${note.repeat && note.repeat !== 'none' ? ' · ' + _esc(_formatRepeatLabel(note.repeat, new Date(note.due_date))) : ''}</span>
1823        </div>`
1824      : '';
1825    const noteTags = _visibleNoteTags(note);
1826    const dueBadge = dueFmt && !_hasTimeComponent(note.due_date) ? `<span class="note-due-inline${overdue ? ' note-due-overdue' : ''}">${dueFmt}</span>` : '';
1827    const colorDots = COLORS.map(c => `<span class="note-card-color-dot${_dotIsActive(c.value, note.color) ? ' active' : ''}" data-color="${c.value}" style="background:${_dotBg(c.value, note.color)}" title="${c.name || 'default'}"></span>`).join('');
1828    const goalClass = note.note_type === 'goal' ? ' note-card-goal' : '';
1829    const reminderGlowClass = activeReminderHighlights.has(note.id) && _hasActiveReminder(note) ? ' note-card-reminder-fired-sticky' : '';
1830    const goalPill = note.note_type === 'goal'
1831      ? `<span class="note-goal-pill" title="AI-broken-down goal">
1832          <svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>
1833          Goal${_goalProgress(note)}
1834        </span>`
1835      : '';
1836    html += `<div class="note-card${note.pinned ? ' note-card-pinned' : ''}${cc}${sel}${goalClass}${reminderGlowClass}${_selectMode ? ' note-card-selectmode' : ''}" draggable="${(_selectMode || _isNotesMobileMode()) ? 'false' : 'true'}" data-note-id="${note.id}"${cardStyle}>
1837      ${_selectMode ? `<input type="checkbox" class="memory-select-cb note-card-cb" data-note-id="${note.id}" ${_selectedIds.has(note.id) ? 'checked' : ''} />` : ''}
1838      ${goalPill}
1839      <button class="note-card-pin${note.pinned ? ' active' : ''}" data-note-id="${note.id}" title="${note.pinned ? 'Unpin' : 'Pin'}">
1840        <svg width="16" height="16" viewBox="0 0 24 28" fill="${note.pinned ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"${note.pinned ? ' style="color:var(--accent,var(--red));"' : ''}><g transform="rotate(${note.pinned ? 0 : 45} 12 14)" style="transition:transform 0.2s ease;"><line x1="12" y1="17" x2="12" y2="27"/><path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V17z"/></g></svg>
1841      </button>
1842      ${_showingArchived
1843        ? `<button class="note-card-corner-trash" data-note-id="${note.id}" title="Delete forever" aria-label="Delete forever">
1844            <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
1845          </button>
1846          <button class="note-card-corner-unarchive" data-note-id="${note.id}" title="Unarchive" aria-label="Unarchive note">
1847            <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 14l-5-5 5-5"/><path d="M4 9h11a5 5 0 0 1 5 5v0a5 5 0 0 1-5 5H9"/></svg>
1848          </button>`
1849        : `<button class="note-card-done" data-note-id="${note.id}" title="Mark done" aria-label="Mark done">
1850            <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
1851          </button>
1852          ${_hasItems(note) ? `<button class="note-card-copy note-card-copy-corner" data-note-id="${note.id}" title="Copy all items" aria-label="Copy all items">
1853            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
1854          </button>` : ''}`}
1855      <div class="note-card-header">
1856        <div class="note-card-title${note.title ? '' : ' empty'}" data-action="edit">${_esc(note.title || '')}</div>
1857        ${dueBadge}
1858      </div>
1859      ${_safeImgSrc(note.image_url) ? `<img class="note-card-image" src="${_esc(_safeImgSrc(note.image_url))}" alt="" draggable="false" />` : ''}
1860      ${contentHtml}
1861      ${_hasItems(note) ? `<div class="note-cl-quickadd"><input type="text" class="note-cl-quickadd-input" placeholder="+ Add item" data-note-id="${note.id}" /></div>` : ''}
1862      ${reminderTagHtml}
1863      ${noteTags.length ? `<div class="note-card-label">${noteTags.map(t => `<button type="button" class="note-card-label-chip" data-note-label-filter="${_esc(t)}" title="Filter #${_esc(t)}">#${_esc(t)}</button>`).join(' ')}</div>` : ''}
1864      ${note.agent_session_id ? `<button class="note-agent-tag" data-note-id="${note.id}
1864" data-session-id="${_esc(note.agent_session_id)}" title="Open the agent's chat for this note">
1865        <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M2 14h2M20 14h2M15 13v2M9 13v2"/></svg>
1866        <span>Agent</span>
1867      </button>` : ''}
1868      <div class="note-card-actions">
1869        <div class="note-card-colors">${colorDots}</div>
1870        <span style="flex:1"></span>
1871        ${_showingArchived ? `
1872        <button class="note-card-action note-card-delete" data-note-id="${note.id}" title="Delete permanently">
1873          <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
1874        </button>
1875        <button class="note-card-action note-card-unarchive" data-note-id="${note.id}" title="Unarchive">
1876          <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 3-6.7"/><polyline points="3 4 3 10 9 10"/></svg>
1877        </button>` : `
1878        ${_hasItems(note) ? `
1879        <button class="note-card-action note-card-copy" data-note-id="${note.id}" title="Copy all items">
1880          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
1881        </button>` : ''}
1882        <button class="note-card-action note-card-archive" data-note-id="${note.id}" title="Save (archive)">
1883          <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
1884        </button>
1885        <button class="note-card-action note-card-delete" data-note-id="${note.id}" title="Delete">
1886          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
1887        </button>
1888        <button class="note-card-action note-card-corner-menu" data-note-id="${note.id}" title="More" aria-label="More actions">
1889          <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="19" cy="12" r="1.7"/></svg>
1890        </button>`}
1891      </div>
1892    </div>`;
1893  }
1894
1895  // Always render quick-add at top (collapsed unless user is typing)
1896  const existingForm = body.querySelector('.note-form');
1897  if (existingForm && _editingId === '__new__') {
1898    // Keep the expanded form, replace cards after it
1899    const next = [...body.children].filter(c => c !== existingForm);
1900    next.forEach(c => c.remove());
1901    if (sorted.length === 0) {
1902      body.insertAdjacentHTML('beforeend', '<div class="notes-empty-msg">No notes <span style="vertical-align:-3px;margin-left:4px;">' + uiModule.emptyStateIcon('smiley') + '</span></div>');
1903    } else {
1904      existingForm.insertAdjacentHTML('afterend', html);
1905    }
1906  } else {
1907    body.innerHTML = '';
1908    _renderLabelsInto(body);
1909    _renderQuickAdd(body);
1910    if (sorted.length === 0) {
1911      body.insertAdjacentHTML('beforeend', '<div class="notes-empty-msg">No notes yet <span style="vertical-align:-3px;margin-left:4px;">' + uiModule.emptyStateIcon('smiley') + '</span></div>');
1912    } else {
1913      body.insertAdjacentHTML('beforeend', html);
1914    }
1915  }
1916
1917  _bindCardEvents(body);
1918  _animateReflow(prevPositions);
1919  _applyMasonry(body);
1920}
1921
1922// In grid view, lay out the cards as masonry by
1923// computing each card's `grid-row-end: span N` from its measured height
1924// (rows are 4px tall + 8px row gap simulated via card margin-bottom). The
1925// grid's `grid-auto-flow: dense` packs columns independently so left/right
1926// lanes no longer share row heights.
1927//
1928// Re-runs on layout-affecting changes via ResizeObserver bound per-card.
1929let _masonryObserver = null;
1930function _applyMasonry(body) {
1931  if (!body) return;
1932  const pane = body.closest('.notes-pane');
1933  const isGrid = pane?.classList.contains('notes-view-grid');
1934  const isMobileGrid = isGrid && window.matchMedia('(max-width: 768px)').matches;
1935  // Tear down any prior observer (defensive — _renderNotes wipes body.innerHTML).
1936  if (_masonryObserver) { try { _masonryObserver.disconnect(); } catch {} _masonryObserver = null; }
1937  if (!isGrid) {
1938    // Clear any leftover inline spans so list view lays out normally.
1939    body.querySelectorAll('.note-card, .notes-labels-bar, .notes-quick-add, .note-form').forEach(c => { c.style.gridRowEnd = ''; });
1940    return;
1941  }
1942  const ROW_PX = 4;
1943  const spanForHeight = (h) => Math.max(1, Math.ceil(h / ROW_PX));
1944  const recomputeFullRows = () => {
1945    const quickAdd = body.querySelector('.notes-quick-add');
1946    const labelsBar = body.querySelector('.notes-labels-bar');
1947    if (labelsBar && getComputedStyle(labelsBar).display !== 'none') {
1948      const shave = isMobileGrid ? 4 : 0;
1949      labelsBar.style.gridRowEnd = `span ${Math.max(1, spanForHeight(labelsBar.scrollHeight) - shave)}`;
1950    }
1951    if (quickAdd) {
1952      const shave = isMobileGrid ? 4 : 0;
1953      quickAdd.style.gridRowEnd = `span ${Math.max(1, spanForHeight(quickAdd.scrollHeight + 10) - shave)}`;
1954    }
1955    body.querySelectorAll('.note-form').forEach(form => {
1956      form.style.gridColumn = '1 / -1';
1957      const isDrawForm = !!form.querySelector('.note-form-type-seg.is-draw');
1958      const minSpan = isMobileGrid ? (isDrawForm ? 104 : 64) : 1;
1959      const renderedHeight = form.getBoundingClientRect?.().height || 0;
1960      const drawReserve = isDrawForm && isMobileGrid ? 12 : 12;
1961      const measuredHeight = Math.max(form.scrollHeight, renderedHeight) + drawReserve;
1962      form.style.gridRowEnd = `span ${Math.max(minSpan, spanForHeight(measuredHeight))}`;
1963    });
1964  };
1965  const recompute = (card) => {
1966    // scrollHeight returns the natural content height — card.getBoundingClientRect()
1967    // would return the grid cell height (collapsed to 4px until the span is set,
1968    // which is the value we're trying to compute).
1969    const h = card.scrollHeight + (isMobileGrid ? 6 : 8);
1970    if (h <= 0) return;
1971    card.style.gridRowEnd = `span ${spanForHeight(h)}`;
1972  };
1973  recomputeFullRows();
1974  body.querySelectorAll('.note-card').forEach(recompute);
1975  // Watch masonry participants — content can grow (image load, todo edits,
1976  // quick-add/form expansion), and stale spans are what cause visual merging.
1977  if ('ResizeObserver' in window) {
1978    _masonryObserver = new ResizeObserver(entries => {
1979      let fullRowsChanged = false;
1980      for (const e of entries) {
1981        if (e.target.classList.contains('note-card')) recompute(e.target);
1982        else fullRowsChanged = true;
1983      }
1984      if (fullRowsChanged) recomputeFullRows();
1985    });
1986    body.querySelectorAll('.note-card').forEach(c => _masonryObserver.observe(c));
1987    body.querySelectorAll('.notes-labels-bar, .notes-quick-add, .note-form').forEach(c => _masonryObserver.observe(c));
1988  }
1989}
1990
1991// Wire the Today aggregated view: tap a step's dot toggles it done; tap
1992// the goal title opens the full note for editing. Done steps fade and the
1993// next pending step rotates in on the next render.
1994function _wireTodayView(body) {
1995  body.querySelectorAll('.notes-today-row .note-check-dot').forEach(dot => {
1996    dot.addEventListener('click', async (e) => {
1997      e.stopPropagation();
1998      const id = dot.dataset.noteId;
1999      const idx = parseInt(dot.dataset.idx);
2000      const note = _notes.find(n => n.id === id);
2001      if (!note || !Array.isArray(note.items) || !note.items[idx]) return;
2002      note.items[idx].done = !note.items[idx].done;
2003      const row = dot.closest('.notes-today-row');
2004      if (row) row.classList.add('done');
2005      try {
2006        await _patchNote(id, { items: note.items });
2007        // Re-render so the next pending step bubbles up (or the row drops
2008        // out entirely if the goal is fully done now).
2009        _renderNotes();
2010        // Confetti when ALL items just turned done.
2011        if (note.items.every(it => it.done)) {
2012          const r = (row || dot).getBoundingClientRect();
2013          spawnConfetti(r.left + r.width / 2, r.top + r.height / 2, 60);
2014        }
2015      } catch {
2016        note.items[idx].done = !note.items[idx].done;
2017      }
2018    });
2019  });
2020  body.querySelectorAll('.notes-today-title').forEach(el => {
2021    el.addEventListener('click', () => {
2022      const id = el.dataset.noteId;
2023      if (!id) return;
2024      // Drop the Today filter first so the regular card list is rendered;
2025      // _editNote needs to find a .note-card in the DOM to replace with
2026      // the editor form.
2027      _activeFilter = null;
2028      _renderNotes();
2029      _editNote(id);
2030    });
2031  });
2032}
2033
2034function _renderQuickAdd(body) {
2035  const wrap = document.createElement('div');
2036  wrap.className = 'notes-quick-add';
2037  // 2-pill Note/Todo toggle mirrors the full form's type-seg (minus Draw —
2038  // drawing happens in the expanded form). The pill that's active steers
2039  // both the placeholder and the type the form opens in.
2040  wrap.innerHTML = `
2041    <div class="notes-quick-type-seg is-todo" role="group" aria-label="New item type">
2042      <button type="button" class="notes-quick-type-pill" data-type="note" aria-label="Note" aria-pressed="false" title="Note">
2043        <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="14" y2="18"/></svg>
2044      </button>
2045      <button type="button" class="notes-quick-type-pill active" data-type="todo" aria-label="To-do" aria-pressed="true" title="To-do">
2046        <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
2047      </button>
2048    </div>
2049    <input type="text" class="notes-quick-input" placeholder="Add a to-do…" />
2050    <button class="notes-quick-icon" data-action="photo" title="Attach photo">
2051      <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
2052    </button>
2053  `;
2054  body.appendChild(wrap);
2055
2056  const input = wrap.querySelector('.notes-quick-input');
2057  const seg = wrap.querySelector('.notes-quick-type-seg');
2058  let currentType = 'todo';
2059  const setType = (t) => {
2060    if (t !== 'note' && t !== 'todo') return;
2061    currentType = t;
2062    seg.classList.toggle('is-todo', t === 'todo');
2063    seg.classList.toggle('is-note', t === 'note');
2064    seg.querySelectorAll('.notes-quick-type-pill').forEach(p => {
2065      const on = p.dataset.type === t;
2066      p.classList.toggle('active', on);
2067      p.setAttribute('aria-pressed', on ? 'true' : 'false');
2068    });
2069    input.placeholder = t === 'note' ? 'Add a note…' : 'Add a to-do…';
2070  };
2071  seg.querySelectorAll('.notes-quick-type-pill').forEach(p => {
2072    p.addEventListener('click', (e) => {
2073      e.stopPropagation();
2074      setType(p.dataset.type);
2075    });
2076  });
2077  // Click input or type → expand to full form
2078  const expandToForm = (initialType = 'note', initialText = '') => {
2079    _editingId = '__new__';
2080    const form = _buildForm({ note_type: initialType });
2081    form.classList.add('note-form-new');
2082    if (initialText) {
2083      const titleEl = form.querySelector('.note-form-title');
2084      if (titleEl) titleEl.value = initialText;
2085    }
2086    const mobileGrid = body.closest('.notes-pane')?.classList.contains('notes-view-grid')
2087      && window.matchMedia('(max-width: 768px)').matches;
2088    if (mobileGrid) {
2089      form.style.gridColumn = '1 / -1';
2090      form.style.gridRowEnd = 'span 64';
2091    }
2092    wrap.replaceWith(form);
2093    _applyMasonry(body);
2094    requestAnimationFrame(() => _applyMasonry(body));
2095    const titleEl = form.querySelector('.note-form-title');
2096    if (titleEl) {
2097      titleEl.focus();
2098      // Move caret to end
2099      titleEl.setSelectionRange(titleEl.value.length, titleEl.value.length);
2100    }
2101  };
2102  // Expand only on real intent: a click directly on the input, or actual
2103  // typing. Focus alone — including focus stolen from a missed nearby
2104  // click — no longer creates an empty form.
2105  input.addEventListener('click', () => expandToForm(currentType, input.value));
2106  input.addEventListener('input', () => expandToForm(currentType, input.value));
2107  wrap.querySelector('[data-action="photo"]').addEventListener('click', (e) => {
2108    e.stopPropagation();
2109    expandToForm(currentType);
2110    // Trigger photo input on the new form
2111    setTimeout(() => document.querySelector('.note-form-photo-btn')?.click(), 50);
2112  });
2113}
2114
2115function _bindCardEvents(body) {
2116  const tapToEditOrSelect = (cardEl) => {
2117    const id = cardEl.dataset.noteId;
2118    if (_selectMode) {
2119      const cb = cardEl.querySelector('.note-card-cb');
2120      if (cb) {
2121        cb.checked = !cb.checked;
2122        cb.dispatchEvent(new Event('change'));
2123      }
2124    } else if (_isNotesMobileMode()) {
2125      // Mobile: open the per-note fullscreen edit overlay instead of the
2126      // in-place form. Tiles on mobile are read-only previews.
2127      _openMobileFullscreenEdit(id, cardEl);
2128    } else {
2129      _editNote(id);
2130    }
2131  };
2132  // Mobile: long-press anywhere on a note card → enter drag-to-reorder mode.
2133  // Cancelled by movement (so it doesn't interfere with vertical scrolling)
2134  // or by lifting the finger before the timer fires.
2135  if (_isNotesMobileMode()) {
2136    body.querySelectorAll('.note-card').forEach(card => _bindLongPressDrag(card));
2137  }
2138  body.querySelectorAll('.note-card.note-card-reminder-fired-sticky').forEach(card => {
2139    card.addEventListener('click', () => _setReminderCardGlow(card.dataset.noteId, false), true);
2140  });
2141  // Click title — edit, or toggle select in select mode
2142  body.querySelectorAll('.note-card-title[data-action="edit"]').forEach(el => {
2143    el.addEventListener('click', (e) => { e.stopPropagation(); tapToEditOrSelect(el.closest('.note-card')); });
2144  });
2145  // Click content — edit, or toggle select in select mode
2146  body.querySelectorAll('.note-content-preview').forEach(el => {
2147    el.addEventListener('click', (e) => { e.stopPropagation(); tapToEditOrSelect(el.closest('.note-card')); });
2148  });
2149  // Click empty area of checklist preview (not on checkbox/X) — edit
2150  body.querySelectorAll('.note-checklist-preview').forEach(el => {
2151    el.addEventListener('click', (e) => {
2152      if (e.target.closest('.note-checkbox, .note-checkbox-rm, .note-cl-quickadd, input')) return;
2153      e.stopPropagation();
2154      tapToEditOrSelect(el.closest('.note-card'));
2155    });
2156  });
2157  // Clicking todo item text now toggles its checkbox — let the click bubble
2158  // up to the parent .note-checkbox row handler. To open the editor, the
2159  // user clicks the pencil corner.
2160  // (No-op block kept as a marker — removing the listener entirely means
2161  // clicks naturally bubble to the row toggle below.)
2162  // In select mode, clicking anywhere on the card toggles selection
2163  if (_selectMode) {
2164    body.querySelectorAll('.note-card').forEach(card => {
2165      card.addEventListener('click', (e) => {
2166        if (e.target.closest('.note-card-cb')) return; // checkbox handles itself
2167        e.stopPropagation();
2168        tapToEditOrSelect(card);
2169      });
2170    });
2171  }
2172  // Mobile, non-select: tapping anywhere on the card body (not on an
2173  // interactive child — buttons, pin, checkbox, color dot, reminder pill,
2174  // agent tag, links) opens the fullscreen editor. Previously only the
2175  // title / content preview triggered edit, so padding + empty gutters were
2176  // dead zones that felt broken on mobile.
2177  if (_isNotesMobileMode() && !_selectMode) {
2178    const _INTERACTIVE = 'button, a, input, label, .note-card-color-dot, .note-checkbox, .note-checkbox-rm, .note-cl-quickadd, .note-agent-tag, .note-card-pin, .note-card-corner-trash, .note-card-corner-menu, .note-card-corner-unarchive, .note-card-edit-corner, .note-card-reminder, .note-card-cb';
2179    body.querySelectorAll('.note-card').forEach(card => {
2180      card.addEventListener('click', (e) => {
2181        if (e.target.closest(_INTERACTIVE)) return;
2182        e.stopPropagation();
2183        tapToEditOrSelect(card);
2184      });
2185    });
2186  }
2187  // Multi-select checkbox (only in select mode)
2188  body.querySelectorAll('.note-card-cb').forEach(cb => {
2189    cb.addEventListener('click', (e) => e.stopPropagation());
2190    cb.addEventListener('change', () => {
2191      const id = cb.dataset.noteId;
2192      if (cb.checked) _selectedIds.add(id);
2193      else _selectedIds.delete(id);
2194      cb.closest('.note-card').classList.toggle('note-card-selected', cb.checked);
2195      _updateBulkBar();
2196    });
2197  });
2198  // Pin toggle (optimistic)
2199  body.querySelectorAll('.note-card-pin').forEach(btn => {
2200    btn.addEventListener('click', (e) => {
2201      e.stopPropagation();
2202      const id = btn.dataset.noteId;
2203      const note = _notes.find(n => n.id === id);
2204      if (!note) return;
2205      const prevPinned = note.pinned;
2206      const prevSortOrder = note.sort_order;
2207      note.pinned = !prevPinned;
2208      const patch = { pinned: note.pinned };
2209      if (note.pinned) {
2210        const minPinned = _notes
2211          .filter(n => n.pinned && n.id !== id)
2212          .reduce((m, n) => Math.min(m, n.sort_order || 0), 0);
2213        note.sort_order = minPinned - 1;
2214        patch.sort_order = note.sort_order;
2215      }
2216      _renderNotes();
2217      _patchNote(id, patch).catch(() => {
2218        note.pinned = prevPinned;
2219        note.sort_order = prevSortOrder;
2220        _renderNotes();
2221        uiModule.showError('Failed to pin');
2222      });
2223    });
2224  });
2225  // Color picker
2226  const _applyCardColor = async (card, id, newColor) => {
2227    const isBg = _isBgImage(newColor);
2228    COLORS.forEach(c => { if (c.value && c.value !== 'custom') card.classList.remove('note-color-' + c.value); });
2229    if (newColor && !isBg) card.classList.add('note-color-' + newColor);
2230    if (isBg) card.setAttribute('style', _customColorStyle(newColor));
2231    else card.removeAttribute('style');
2232    card.querySelectorAll('.note-card-color-dot').forEach(d => {
2233      d.classList.toggle('active', _dotIsActive(d.dataset.color, newColor));
2234      d.style.background = _dotBg(d.dataset.color, newColor);
2235    });
2236    try { await _patchNote(id, { color: newColor || null }); const note = _notes.find(n => n.id === id); if (note) note.color = newColor; }
2237    catch { uiModule.showError('Failed to update color'); }
2238  };
2239  body.querySelectorAll('.note-card-color-dot').forEach(dot => {
2240    dot.addEventListener('click', (e) => {
2241      e.stopPropagation();
2242      const card = dot.closest('.note-card');
2243      const id = card.dataset.noteId;
2244      if (dot.dataset.color === 'custom') {
2245        _pickCustomBgImage().then(url => { if (url) _applyCardColor(card, id, 'bg:' + url); });
2246        return;
2247      }
2248      _applyCardColor(card, id, dot.dataset.color);
2249    });
2250  });
2251  // Plain pencil corner → open editor. The unarchive corner shares the
2252  // .note-card-edit-corner class for styling, so :not() keeps the edit
2253  // handler off it.
2254  body.querySelectorAll('.note-card-edit-corner:not(.note-card-unarchive-corner)').forEach(btn => {
2255    btn.addEventListener('click', (e) => {
2256      e.stopPropagation();
2257      const id = btn.dataset.noteId;
2258      if (id) _editNote(id);
2259    });
2260  });
2261  // Copy corner — bottom-right, just left of the Done check. Shared with
2262   // the Ctrl/Cmd+C shortcut wired further down so both code paths run the
2263   // same serializer + feedback flash.
2264  // ⋯ corner menu — Copy + Agent (solve-this-todo).
2265  body.querySelectorAll('.note-card-corner-menu').forEach(btn => {
2266    btn.addEventListener('click', (e) => {
2267      e.preventDefault();
2268      e.stopPropagation();
2269      _openNoteCornerMenu(btn);
2270    });
2271  });
2272  // Agent tag — opens the chat session the agent ran for this note.
2273  body.querySelectorAll('.note-agent-tag').forEach(tag => {
2274    tag.addEventListener('click', (e) => {
2275      e.preventDefault();
2276      e.stopPropagation();
2277      const sid = tag.dataset.sessionId;
2278      const _sm = window.sessionModule;
2279      if (sid && _sm && _sm.selectSession) { closePanel(); _sm.selectSession(sid); }
2280    });
2281  });
2282  body.querySelectorAll('.note-card-label-chip').forEach(chip => {
2283    chip.addEventListener('click', (e) => {
2284      e.preventDefault();
2285      e.stopPropagation();
2286      const label = chip.dataset.noteLabelFilter;
2287      if (!label) return;
2288      if (_activeLabel === label && _activeFilter === null) {
2289        _activeLabel = null;
2290      } else {
2291        _activeFilter = null;
2292        _activeLabel = label;
2293      }
2294      _renderNotes();
2295    });
2296  });
2297  // Done (✓) at bottom-right — only visible on hover for active notes.
2298  body.querySelectorAll('.note-card-done').forEach(btn => {
2299    btn.addEventListener('click', (e) => {
2300      e.stopPropagation();
2301      const id = btn.dataset.noteId;
2302      const card = btn.closest('.note-card');
2303      const idx = _notes.findIndex(n => n.id === id);
2304      if (idx < 0) return;
2305      // Celebrate completion — same confetti shower the bulk-archive uses.
2306      if (card) {
2307        const r = card.getBoundingClientRect();
2308        spawnConfetti(r.left + r.width / 2, r.top + r.height / 2, 80);
2309      }
2310      const removed = _notes.splice(idx, 1)[0];
2311      const undo = () => _undoArchive(removed, idx);
2312      _pushUndo({ label: 'archive', run: undo });
2313      const _undoIcon = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle;"><polyline points="9 14 4 9 9 4"/><path d="M4 9h11a5 5 0 0 1 5 5v0a5 5 0 0 1-5 5H9"/></svg>';
2314      const finish = () => {
2315        _renderNotes();
2316        _patchNote(id, { archived: true }).then(() => {
2317          uiModule.showToast('Archived', { duration: 6000, action: 'Undo', actionIcon: _undoIcon, onAction: undo, actionHint: 'Ctrl+Z' });
2318        }).catch(() => {
2319          _notes.splice(idx, 0, removed);
2320          _renderNotes();
2321          uiModule.showError('Failed to archive');
2322        });
2323      };
2324      if (card) {
2325        card.classList.add('note-card-sliding-out');
2326        let done = false;
2327        const once = () => { if (done) return; done = true; finish(); };
2328        card.addEventListener('transitionend', once, { once: true });
2329        setTimeout(once, 400);
2330      } else {
2331        finish();
2332      }
2333    });
2334  });
2335  // Unarchive corner — only visible in archive view.
2336  body.querySelectorAll('.note-card-corner-unarchive').forEach(btn => {
2337    btn.addEventListener('click', (e) => {
2338      e.stopPropagation();
2339      const id = btn.dataset.noteId;
2340      const idx = _notes.findIndex(n => n.id === id);
2341      if (idx < 0) return;
2342      const removed = _notes.splice(idx, 1)[0];
2343      _renderNotes();
2344      _patchNote(id, { archived: false }).then(() => uiModule.showToast('Unarchived')).catch(() => {
2345        _notes.splice(idx, 0, removed);
2346        _renderNotes();
2347        uiModule.showError('Failed to unarchive');
2348      });
2349    });
2350  });
2351  // Trash corner — archive view only. Permanent delete, no confirmation.
2352  body.querySelectorAll('.note-card-corner-trash').forEach(btn => {
2353    btn.addEventListener('click', (e) => {
2354      e.stopPropagation();
2355      const id = btn.dataset.noteId;
2356      const idx = _notes.findIndex(n => n.id === id);
2357      if (idx < 0) return;
2358      const removed = _notes.splice(idx, 1)[0];
2359      _renderNotes();
2360      _deleteNoteApi(id).then(() => uiModule.showToast('Deleted')).catch(() => {
2361        _notes.splice(idx, 0, removed);
2362        _renderNotes();
2363        uiModule.showError('Failed to delete');
2364      });
2365    });
2366  });
2367
2368  body.querySelectorAll('.note-card-archive').forEach(btn => {
2369    btn.addEventListener('click', (e) => {
2370      e.stopPropagation();
2371      const id = btn.dataset.noteId;
2372      if (!id) return;
2373      const note = _notes.find(n => n.id === id);
2374      const card = btn.closest('.note-card');
2375      // Confetti when archiving a fully-completed checklist (todo or goal).
2376      if (note && _hasItems(note) && card) {
2377        const undone = (note.items || []).filter(i => !i.done);
2378        if (undone.length === 0) {
2379          const r = card.getBoundingClientRect();
2380          spawnConfetti(r.left + r.width / 2, r.top + r.height / 2, 80);
2381        }
2382      }
2383      let done = false;
2384      const finishRemove = () => {
2385        if (done) return;
2386        done = true;
2387        const curIdx = _notes.findIndex(n => n.id === id);
2388        if (curIdx < 0) return;
2389        const removed = _notes.splice(curIdx, 1)[0];
2390        _renderNotes();
2391        const undo = () => _undoArchive(removed, curIdx);
2392        _pushUndo({ label: 'archive', run: undo });
2393        const _undoIcon = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle;"><polyline points="9 14 4 9 9 4"/><path d="M4 9h11a5 5 0 0 1 5 5v0a5 5 0 0 1-5 5H9"/></svg>';
2394        _patchNote(id, { archived: true }).then(() => {
2395          uiModule.showToast('Archived', { duration: 6000, action: 'Undo', actionIcon: _undoIcon, onAction: undo, actionHint: 'Ctrl+Z' });
2396        }).catch(() => {
2397          _notes.splice(curIdx, 0, removed);
2398          _renderNotes();
2399          uiModule.showError('Failed to archive');
2400        });
2401      };
2402      if (card) {
2403        card.classList.add('note-card-sliding-out');
2404        card.addEventListener('transitionend', finishRemove, { once: true });
2405        setTimeout(finishRemove, 400);
2406      } else {
2407        finishRemove();
2408      }
2409    });
2410  });
2411  // Unarchive (optimistic) — only present in archive view
2412  body.querySelectorAll('.note-card-unarchive').forEach(btn => {
2413    btn.addEventListener('click', (e) => {
2414      e.stopPropagation();
2415      const id = btn.dataset.noteId;
2416      const idx = _notes.findIndex(n => n.id === id);
2417      if (idx < 0) return;
2418      const removed = _notes.splice(idx, 1)[0];
2419      _renderNotes();
2420      _patchNote(id, { archived: false }).then(() => uiModule.showToast('Unarchived')).catch(() => {
2421        _notes.splice(idx, 0, removed);
2422        _renderNotes();
2423        uiModule.showError('Failed to unarchive');
2424      });
2425    });
2426  });
2427  // Delete (optimistic)
2428  body.querySelectorAll('.note-card-delete, .note-card-x').forEach(btn => {
2429    btn.addEventListener('click', (e) => {
2430      e.stopPropagation();
2431      const id = btn.dataset.noteId;
2432      const idx = _notes.findIndex(n => n.id === id);
2433      if (idx < 0) return;
2434      const removed = _notes.splice(idx, 1)[0];
2435      _renderNotes();
2436      _deleteNoteApi(id).catch(() => {
2437        _notes.splice(idx, 0, removed);
2438        _renderNotes();
2439        uiModule.showError('Failed to delete');
2440      });
2441    });
2442  });
2443  // Copy entire checklist (title + items, markdown-style)
2444  body.querySelectorAll('.note-card-copy').forEach(btn => {
2445    btn.addEventListener('click', async (e) => {
2446      e.preventDefault();
2447      e.stopPropagation();
2448      e.stopImmediatePropagation();
2449      const id = btn.dataset.noteId;
2450      const note = _notes.find(n => n.id === id);
2451      if (!note) return;
2452      const lines = [];
2453      if (note.title) lines.push(note.title);
2454      if (note.content) lines.push(note.content);
2455      if (lines.length) lines.push('');
2456      for (const it of (note.items || [])) {
2457        if (!it || !(it.text || '').trim()) continue;
2458        lines.push(`- [${it.done ? 'x' : ' '}] ${(it.text || '').trim()}`);
2459      }
2460      const text = lines.join('\n').trim();
2461      try {
2462        await navigator.clipboard.writeText(text);
2463        uiModule.showToast?.(`Copied ${(note.items || []).filter(i => (i?.text || '').trim()).length} items`);
2464      } catch {
2465        // Fallback for browsers blocking the async API
2466        const ta = document.createElement('textarea');
2467        ta.value = text;
2468        ta.style.position = 'fixed'; ta.style.left = '-9999px';
2469        document.body.appendChild(ta);
2470        ta.select();
2471        try { document.execCommand('copy'); uiModule.showToast?.('Copied'); }
2472        catch { uiModule.showError?.('Copy failed'); }
2473        ta.remove();
2474      }
2475    });
2476  });
2477
2478  // Remove a single checklist item (hover X)
2479  body.querySelectorAll('.note-checkbox-rm').forEach(btn => {
2480    btn.addEventListener('click', (e) => {
2481      e.stopPropagation();
2482      if (_selectMode) return;
2483      const noteId = btn.dataset.noteId;
2484      const idx = parseInt(btn.dataset.idx);
2485      const note = _notes.find(n => n.id === noteId);
2486      if (!note || !Array.isArray(note.items) || !note.items[idx]) return;
2487      const removed = note.items[idx];
2488      note.items = note.items.filter((_, i) => i !== idx);
2489      _renderNotes();
2490      _patchNote(noteId, { items: note.items }).catch(() => {
2491        note.items.splice(idx, 0, removed);
2492        _renderNotes();
2493        uiModule.showError('Failed to remove item');
2494      });
2495    });
2496  });
2497
2498  // Quick-add new checklist item (hover input at bottom of todo cards)
2499  body.querySelectorAll('.note-cl-quickadd-input').forEach(input => {
2500    input.addEventListener('click', (e) => e.stopPropagation());
2501    input.addEventListener('keydown', async (e) => {
2502      e.stopPropagation();
2503      if (e.key !== 'Enter') return;
2504      e.preventDefault();
2505      const text = input.value.trim();
2506      if (!text) return;
2507      const noteId = input.dataset.noteId;
2508      const note = _notes.find(n => n.id === noteId);
2509      if (!note) return;
2510      const items = Array.isArray(note.items) ? [...note.items] : [];
2511      items.push({ id: _uid(), text, done: false });
2512      note.items = items;
2513      input.value = '';
2514      _renderNotes();
2515      // Refocus the input on the same card
2516      setTimeout(() => {
2517        const next = document.querySelector(`.note-cl-quickadd-input[data-note-id="${noteId}"]`);
2518        if (next) next.focus();
2519      }, 0);
2520      _patchNote(noteId, { items }).catch(() => {
2521        note.items = items.slice(0, -1);
2522        _renderNotes();
2523        uiModule.showError('Failed to add item');
2524      });
2525    });
2526  });
2527
2528  // Checkboxes (dot toggle, optimistic) — disabled in select mode
2529  body.querySelectorAll('.note-checkbox').forEach(el => {
2530    el.addEventListener('click', (e) => {
2531      if (_selectMode) return; // let card-level handler take over
2532      e.stopPropagation();
2533      const noteId = el.dataset.noteId;
2534      const idx = parseInt(el.dataset.idx);
2535      const note = _notes.find(n => n.id === noteId);
2536      if (!note || !note.items || !note.items[idx]) return;
2537      const wasAllDone = note.items.length > 0 && note.items.every(it => it.done);
2538      note.items[idx].done = !note.items[idx].done;
2539      el.classList.toggle('done', note.items[idx].done);
2540      const isAllDone = note.items.length > 0 && note.items.every(it => it.done);
2541      if (!wasAllDone && isAllDone) {
2542        const card = el.closest('.note-card');
2543        if (card) {
2544          const r = card.getBoundingClientRect();
2545          spawnConfetti(r.left + r.width / 2, r.top + r.height / 2, 60);
2546        }
2547      }
2548      _patchNote(noteId, { items: note.items }).catch(() => {
2549        note.items[idx].done = !note.items[idx].done;
2550        el.classList.toggle('done', note.items[idx].done);
2551      });
2552    });
2553  });
2554
2555  // Drag-reorder notes on pointer/mouse devices. Mobile uses the custom
2556  // placeholder sorter below `_bindLongPressDrag`; native HTML5 dragging is
2557  // unreliable on touch browsers and can compete with the long-press flow.
2558  if (!_isNotesMobileMode()) {
2559    body.querySelectorAll('.note-card').forEach(card => {
2560      card.addEventListener('dragstart', (e) => {
2561        if (e.target.closest('.note-checkbox, .note-card-x, .note-card-select, .note-card-pin, .note-card-action, .note-card-color-dot, .note-card-title, .note-card-edit, .
2561note-card-edit-corner, .note-card-done, .note-card-corner-menu, .note-agent-tag, .note-card-label-chip')) {
2562          e.preventDefault();
2563          return;
2564        }
2565        card.classList.add('dragging');
2566        body.classList.add('drag-active');
2567        e.dataTransfer.effectAllowed = 'move';
2568        try { e.dataTransfer.setData('text/plain', card.dataset.noteId); } catch {}
2569      });
2570      card.addEventListener('dragend', async () => {
2571        card.classList.remove('dragging');
2572        body.classList.remove('drag-active');
2573        body.querySelectorAll('.drop-before, .drop-after').forEach(el => el.classList.remove('drop-before', 'drop-after'));
2574        const ids = [...body.querySelectorAll('.note-card')].map(c => c.dataset.noteId);
2575        try { await fetch(`${API_BASE}/api/notes/reorder`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids }) }); }
2576        catch {}
2577      });
2578    });
2579  }
2580  // Track which card we last swapped with so a single hover-over triggers
2581  // one swap, not a jitter as the pointer keeps moving inside the same card.
2582  let _lastSwapId = null;
2583  function _maybeSwap(dragging, clientX, clientY) {
2584    const target = document.elementFromPoint(clientX, clientY)?.closest('.note-card');
2585    if (!target || target === dragging || !body.contains(target)) return;
2586    const id = target.dataset.noteId;
2587    if (id === _lastSwapId) return;
2588    // FLIP across ALL siblings. In list view only `target` moves visually, but
2589    // in grid view (2-col) and when the pinned-section grid-column-start rule
2590    // shifts, several cards reflow at once. Capture every card's pre-swap rect,
2591    // do the DOM swap, then animate any that actually moved. The dragging card
2592    // is excluded — it's already finger-tracked via translate3d.
2593    const cards = [...body.querySelectorAll('.note-card')].filter(c => c !== dragging);
2594    const prevRects = new Map(cards.map(c => [c, c.getBoundingClientRect()]));
2595    const draggingNext = dragging.nextSibling === target ? dragging : dragging.nextSibling;
2596    body.insertBefore(dragging, target);
2597    body.insertBefore(target, draggingNext);
2598    for (const c of cards) {
2599      const prev = prevRects.get(c);
2600      const next = c.getBoundingClientRect();
2601      const dx = prev.left - next.left;
2602      const dy = prev.top - next.top;
2603      if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) continue;
2604      c.style.transition = 'none';
2605      c.style.transform = `translate(${dx}px, ${dy}px)`;
2606      requestAnimationFrame(() => {
2607        c.style.transition = 'transform 0.22s cubic-bezier(0.34, 1.2, 0.64, 1)';
2608        c.style.transform = '';
2609        c.addEventListener('transitionend', () => { c.style.transition = ''; }, { once: true });
2610      });
2611    }
2612    _lastSwapId = id;
2613  }
2614  body.addEventListener('dragover', (e) => {
2615    e.preventDefault();
2616    const dragging = body.querySelector('.note-card.dragging');
2617    if (!dragging) return;
2618    _maybeSwap(dragging, e.clientX, e.clientY);
2619  });
2620  body.addEventListener('dragend', () => { _lastSwapId = null; });
2621
2622  // Legacy touch drag for larger touch devices only. Phone-sized Notes uses
2623  // the placeholder sorter wired by `_bindLongPressDrag`; running both flows
2624  // makes one press start two independent drag sessions.
2625  if (!_isNotesMobileMode() && 'ontouchstart' in window && !body.dataset.touchDragBound) {
2626    body.dataset.touchDragBound = '1';
2627    let dragCard = null;
2628    let isDragging = false;
2629    let longPressTimer = null;
2630    let startX = 0, startY = 0;
2631    const LONG_PRESS_MS = 350;
2632    const MOVE_THRESHOLD_PX = 8;
2633    const _selectorSkip = '.note-checkbox, .note-card-x, .note-card-select, .note-card-pin, .note-card-action, .note-card-color-dot, .note-card-title, .note-card-edit, .note-card-edit-corner, .note-card-done, .note-card-corner-menu, .note-agent-tag, .note-card-label-chip, input, textarea, button, a';
2634
2635    // Anchor for the finger-follow transform. Recomputed after every swap so
2636    // the card stays under the finger across reorderings.
2637    let anchorX = 0, anchorY = 0;
2638    const _follow = (clientX, clientY) => {
2639      if (!dragCard) return;
2640      const dx = clientX - anchorX;
2641      const dy = clientY - anchorY;
2642      // Compose with the CSS .dragging transform (scale + rotate).
2643      dragCard.style.transform = `translate3d(${dx}px, ${dy}px, 0) scale(1.03) rotate(-0.6deg)`;
2644    };
2645    const _reanchor = (clientX, clientY) => {
2646      if (!dragCard) return;
2647      // Clear any prior translate so the card resettles in its natural slot,
2648      // then anchor at the finger's current position. Subsequent _follow calls
2649      // translate by (finger - anchor), keeping the finger at the same relative
2650      // spot on the card it had when we re-anchored.
2651      dragCard.style.transform = '';
2652      anchorX = clientX;
2653      anchorY = clientY;
2654    };
2655
2656    const _endDrag = (committed) => {
2657      clearTimeout(longPressTimer);
2658      longPressTimer = null;
2659      if (dragCard) {
2660        dragCard.classList.remove('dragging');
2661        dragCard.style.transform = '';
2662      }
2663      body.classList.remove('drag-active');
2664      _lastSwapId = null;
2665      if (isDragging) {
2666        document.documentElement.style.touchAction = '';
2667        if (committed) {
2668          const ids = [...body.querySelectorAll('.note-card')].map(c => c.dataset.noteId);
2669          fetch(`${API_BASE}/api/notes/reorder`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids }) }).catch(() => {});
2670        }
2671      }
2672      dragCard = null;
2673      isDragging = false;
2674    };
2675
2676    body.addEventListener('touchstart', (e) => {
2677      if (_selectMode) return;
2678      const card = e.target.closest('.note-card');
2679      if (!card) return;
2680      if (e.target.closest(_selectorSkip)) return;
2681      const t = e.touches[0];
2682      startX = t.clientX; startY = t.clientY;
2683      dragCard = card;
2684      longPressTimer = setTimeout(() => {
2685        if (!dragCard) return;
2686        isDragging = true;
2687        dragCard.classList.add('dragging');
2688        body.classList.add('drag-active');
2689        document.documentElement.style.touchAction = 'none';
2690        _reanchor(startX, startY);
2691        try { if (navigator.vibrate) navigator.vibrate(15); } catch {}
2692      }, LONG_PRESS_MS);
2693    }, { passive: true });
2694
2695    body.addEventListener('touchmove', (e) => {
2696      if (!dragCard) return;
2697      const t = e.touches[0];
2698      if (!isDragging) {
2699        // Movement before long-press fires = user is scrolling; cancel pickup.
2700        if (Math.abs(t.clientX - startX) > MOVE_THRESHOLD_PX || Math.abs(t.clientY - startY) > MOVE_THRESHOLD_PX) {
2701          clearTimeout(longPressTimer);
2702          longPressTimer = null;
2703          dragCard = null;
2704        }
2705        return;
2706      }
2707      e.preventDefault();
2708      // Live-follow the finger first, then check for a swap. After a swap the
2709      // card's natural slot moves, so we re-anchor and re-apply the offset.
2710      _follow(t.clientX, t.clientY);
2711      const before = dragCard.parentNode && [...dragCard.parentNode.children].indexOf(dragCard);
2712      _maybeSwap(dragCard, t.clientX, t.clientY);
2713      const after = dragCard.parentNode && [...dragCard.parentNode.children].indexOf(dragCard);
2714      if (before !== after) {
2715        _reanchor(t.clientX, t.clientY);
2716        _follow(t.clientX, t.clientY);
2717      }
2718    }, { passive: false });
2719
2720    body.addEventListener('touchend', () => _endDrag(true));
2721    body.addEventListener('touchcancel', () => _endDrag(false));
2722  }
2723}
2724
2725// ── Draft autosave ──────────────────────────────────────────────────
2726// While a note is open in the editor, its form is snapshotted to
2727// localStorage on every change (debounced). If the connection drops, the
2728// tab closes, or the page reloads before Save is hit, reopening that note
2729// restores the unsaved text. Drafts are cleared on an explicit Save or
2730// Cancel. Survives offline because it never touches the network.
2731const _DRAFT_PREFIX = 'odysseus-note-draft-';
2732function _draftKey(id) { return _DRAFT_PREFIX + (id || '__new__'); }
2733function _loadDraft(id) {
2734  try { return JSON.parse(localStorage.getItem(_draftKey(id)) || 'null'); } catch { return null; }
2735}
2736function _clearDraft(id) { try { localStorage.removeItem(_draftKey(id)); } catch {} }
2737function _collectFormDraft(form) {
2738  if (!form) return null;
2739  const type = form.querySelector('.note-form-type-pill.active')?.dataset.type || 'note';
2740  const d = {
2741    _ts: Date.now(),
2742    note_type: type,
2743    title: form.querySelector('.note-form-title')?.value || '',
2744    label: form.querySelector('.note-form-label')?.value || '',
2745    due_date: form.querySelector('.note-form-due')?.value || null,
2746    repeat: form.querySelector('.note-form-repeat')?.value || 'none',
2747  };
2748  if (type === 'note') d.content = form.querySelector('.note-form-content')?.value || '';
2749  else if (type === 'goal') { d.content = form.querySelector('.note-form-goal-desc')?.value || ''; d.items = _collectItems(form); }
2750  else d.items = _collectItems(form);
2751  return d;
2752}
2753function _isDraftEmpty(d) {
2754  if (!d) return true;
2755  if ((d.title || '').trim()) return false;
2756  if ((d.content || '').trim()) return false;
2757  if (Array.isArray(d.items) && d.items.some(it => (it.text || '').trim())) return false;
2758  return true;
2759}
2760function _wireDraftAutosave(form, id) {
2761  let t = null;
2762  const save = () => {
2763    const d = _collectFormDraft(form);
2764    if (_isDraftEmpty(d)) { _clearDraft(id); return; }
2765    try { localStorage.setItem(_draftKey(id), JSON.stringify(d)); } catch {}
2766  };
2767  form._flushDraft = () => { clearTimeout(t); save(); };
2768  const sched = () => { clearTimeout(t); t = setTimeout(save, 600); };
2769  form.addEventListener('input', sched);
2770  form.addEventListener('change', sched);
2771}
2772
2773// Commit whatever in-place editor is open (called when the panel closes
2774// or another note is opened) so edits aren't lost when the user navigates
2775// away without clicking Save. Empty notes are discarded instead of saved.
2776function _commitOpenInPlaceEditor() {
2777  const form = document.querySelector('#notes-pane .note-form');
2778  if (!form) return;
2779  const d = _collectFormDraft(form);
2780  if (_isDraftEmpty(d)) { form.querySelector('.note-form-cancel')?.click(); return; }
2781  form.querySelector('.note-form-save')?.click();
2782}
2783// Merge a stored draft over a note so _buildForm renders the unsaved edits.
2784function _applyDraftToNote(note, id) {
2785  const d = _loadDraft(id);
2786  if (_isDraftEmpty(d)) return { note, restored: false };
2787  const merged = { ...(note || {}) };
2788  ['note_type', 'title', 'label', 'due_date', 'repeat', 'content', 'items'].forEach(k => {
2789    if (d[k] !== undefined) merged[k] = d[k];
2790  });
2791  return { note: merged, restored: true };
2792}
2793
2794// ---- Create / Edit Form ----
2795
2796function _buildForm(note = null) {
2797  const isEdit = note && note.id;
2798  const type = note?.note_type || 'note';
2799  const color = note?.color || '';
2800  const items = note?.items || [{ id: _uid(), text: '', done: false }];
2801
2802  const form = document.createElement('div');
2803  form.className = 'note-form';
2804  if (color && !_isBgImage(color)) form.classList.add('note-color-' + color);
2805  if (_isBgImage(color)) form.setAttribute('style', _customColorStyle(color));
2806  let currentImageUrl = _safeImgSrc(note?.image_url || '');
2807  form.innerHTML = `
2808    <div class="note-form-header">
2809      <input type="text" class="note-form-title" placeholder="Title" value="${_esc(note?.title || '')}" />
2810      <button type="button" class="note-form-icon-btn note-form-remind-btn${note?.due_date ? ' has-date' : ''}" title="Remind me">
2811        <svg width="31" height="31" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
2812      </button>
2813      <input type="hidden" class="note-form-due" value="${note?.due_date || ''}" />
2814      <input type="hidden" class="note-form-repeat" value="${note?.repeat || 'none'}" />
2815    </div>
2816    ${currentImageUrl && type !== 'draw' ? `<div class="note-form-image-wrap"><img class="note-form-image" src="${_esc(currentImageUrl)}" draggable="false" /><button class="note-form-image-rm" title="Remove">&times;</button></div>` : ''}
2817    <div class="note-form-body">
2818      ${type === 'note'
2819        ? `<textarea class="note-form-content" placeholder="Take a note..." rows="4">${_esc(note?.content || '')}</textarea>`
2820        : type === 'draw'
2821        ? _buildDrawHtml()
2822        : type === 'goal'
2823        ? _buildGoalHtml(note, items)
2824        : _buildChecklistHtml(items)}
2825    </div>
2826    <div class="note-form-reminder-tags"></div>
2827    <div class="note-form-meta">
2828      <div class="note-form-type-seg${type === 'todo' ? ' is-todo' : type === 'draw' ? ' is-draw' : ''}" role="group">
2829        <button type="button" class="note-form-type-pill${type === 'note' ? ' active' : ''}" data-type="note">
2830          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="14" y2="18"/></svg>
2831          <span>Note</span>
2832        </button>
2833        <button type="button" class="note-form-type-pill${type === 'todo' ? ' active' : ''}" data-type="todo">
2834          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
2835          <span>Todo</span>
2836        </button>
2837        <button type="button" class="note-form-type-pill${type === 'draw' ? ' active' : ''}" data-type="draw">
2838          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/><path d="M2 2l7.586 7.586"/><circle cx="11" cy="11" r="2"/></svg>
2839          <span>Draw</span>
2840        </button>
2841      </div>
2842      <button class="note-form-photo-btn" title="Attach photo">
2843        <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
2844      </button>
2845      <input type="file" class="note-form-photo-input" accept="image/*" capture="environment" style="display:none" />
2846      <div class="note-color-picker">
2847        ${COLORS.map(c => `<span class="note-color-dot${_dotIsActive(c.value, color) ? ' active' : ''}" data-color="${c.value}" style="background:${_dotBg(c.value, color)}" title="${c.name || 'default'}"></span>`).join('')}
2848      </div>
2849      <input type="text" class="note-form-label" value="${_esc(note?.label || '')}" placeholder="#tag1 #tag2" title="Tag(s) — space-separated" />
2850      <div class="note-form-actions-group">
2851        ${isEdit ? `
2852        <button type="button" class="note-form-text-btn note-form-archive-btn note-form-collapsible" title="Archive">
2853          <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 002 2h12a2 2 0 002-2V8"/><path d="M10 12h4"/></svg><span class="nft-label">Archive</span>
2854        </button>
2855        <button type="button" class="note-form-text-btn note-form-delete-btn note-form-collapsible danger" title="Delete">
2856          <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg><span class="nft-label">Delete</span>
2857        </button>
2858        ` : ''}
2859        <span class="note-form-actions-spacer"></span>
2860        <button class="note-form-cancel note-form-text-btn note-form-collapsible" title="Cancel">
2861          <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg><span class="nft-label">Cancel</span>
2862        </button>
2863        <button class="note-form-save note-form-text-btn" title="${isEdit ? 'Update' : 'Save'}">
2864          <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg><span class="nft-label">${isEdit ? 'Update' : 'Save'}</span>
2865        </button>
2866      </div>
2867    </div>
2868  `;
2869
2870  let currentType = type;
2871  let currentColor = color;
2872  // Stash original-form values so round-trips (Note→Todo→Note) restore the
2873  // user's hand-formatted text instead of a join of generated items. Same the
2874  // other way: if you started in todo, switch to note, switch back, items
2875  // come back unchanged.
2876  let _stashedNoteText = (type === 'note') ? (note?.content || '') : null;
2877  let _stashedTodoItems = (type === 'todo' && Array.isArray(note?.items)) ? note.items.slice() : null;
2878  // Goal mode kept its own pair of stashes (description + steps) so a
2879  // Todo→Goal→Todo round-trip wouldn't lose either side. The Goal pill in
2880  // the type picker was later removed, so the only entry point now is
2881  // *editing* an existing goal-typed note — but the switch handler still
2882  // accepts Goal→Todo/Note transitions (downgrading legacy goals), so
2883  // these stashes still earn their keep.
2884  let _stashedGoalDesc = (type === 'goal') ? (note?.content || '') : null;
2885  let _stashedGoalItems = (type === 'goal' && Array.isArray(note?.items)) ? note.items.slice() : null;
2886
2887  // Drawing also stashes the saved image URL so it survives Note↔Draw flips.
2888  let _stashedDrawUrl = (type === 'draw') ? (_safeImgSrc(note?.image_url) || null) : null;
2889  const _refreshFormLayout = () => {
2890    const body = form.closest('.notes-pane-body');
2891    if (!body) return;
2892    _applyMasonry(body);
2893    requestAnimationFrame(() => {
2894      _applyMasonry(body);
2895      requestAnimationFrame(() => _applyMasonry(body));
2896    });
2897  };
2898
2899  // Type segmented control — Note | Todo | Draw
2900  form.querySelectorAll('.note-form-type-pill').forEach(pill => {
2901    pill.addEventListener('click', () => {
2902      const newType = pill.dataset.type;
2903      if (newType === currentType) return;
2904      const bodyEl = form.querySelector('.note-form-body');
2905      // Stash whatever the user has in the current mode before swapping it
2906      // out, so a subsequent flip back restores their work.
2907      if (currentType === 'note') {
2908        _stashedNoteText = form.querySelector('.note-form-content')?.value || '';
2909      } else if (currentType === 'todo') {
2910        _stashedTodoItems = _collectItems(form);
2911      } else if (currentType === 'goal') {
2912        _stashedGoalDesc = form.querySelector('.note-form-goal-desc')?.value || '';
2913        _stashedGoalItems = _collectItems(form);
2914      } else if (currentType === 'draw') {
2915        const c = form.querySelector('.note-form-canvas');
2916        if (c) { try { _stashedDrawUrl = c.toDataURL('image/png'); } catch {} }
2917      }
2918      // Render the new mode's body and re-wire its inputs.
2919      if (newType === 'todo') {
2920        let nextItems;
2921        if (_stashedTodoItems && _stashedTodoItems.length) {
2922          nextItems = _stashedTodoItems;
2923        } else if (_stashedGoalItems && _stashedGoalItems.length) {
2924          // Going Goal→Todo keeps the AI-generated steps as a plain checklist.
2925          nextItems = _stashedGoalItems;
2926        } else if (_stashedNoteText) {
2927          const lines = _stashedNoteText.split('\n').map(s => s.trim()).filter(Boolean);
2928          nextItems = lines.length ? lines.map(t => ({ id: _uid(), text: t, done: false })) : [{ id: _uid(), text: '', done: false }];
2929        } else {
2930          nextItems = [{ id: _uid(), text: '', done: false }];
2931        }
2932        bodyEl.innerHTML = _buildChecklistHtml(nextItems);
2933        _wireChecklist(bodyEl);
2934      } else if (newType === 'draw') {
2935        bodyEl.innerHTML = _buildDrawHtml();
2936        // If the user just attached a photo (via the photo button) and then
2937        // toggled to Draw, paint that photo onto the canvas so they can draw
2938        // on top of it. _stashedDrawUrl wins if they were drawing earlier in
2939        // the same edit session.
2940        _wireCanvas(bodyEl, _stashedDrawUrl || currentImageUrl || _safeImgSrc(note?.image_url) || null);
2941      } else {
2942        const text = (_stashedNoteText !== null && _stashedNoteText !== undefined && _stashedNoteText !== '')
2943          ? _stashedNoteText
2944          : (_stashedGoalDesc && _stashedGoalDesc)
2945          || (_stashedTodoItems || _stashedGoalItems || []).map(i => i.text).join('\n');
2946        bodyEl.innerHTML = `<textarea class="note-form-content" placeholder="Take a note..." rows="4">${_esc(text)}</textarea>`;
2947        _wireHashtag(bodyEl.querySelector('.note-form-content'));
2948      }
2949      const focusEl = newType === 'note'
2950        ? bodyEl.querySelector('.note-form-content')
2951        : newType === 'todo'
2952          ? bodyEl.querySelector('.note-cl-text')
2953          : null;
2954      if (focusEl) {
2955        requestAnimationFrame(() => {
2956          focusEl.focus({ preventScroll: true });
2957          try {
2958            const end = focusEl.value.length;
2959            focusEl.setSelectionRange(end, end);
2960          } catch {}
2961        });
2962      }
2963      currentType = newType;
2964      const seg = form.querySelector('.note-form-type-seg');
2965      seg?.classList.toggle('is-todo', newType === 'todo');
2966      seg?.classList.toggle('is-draw', newType === 'draw');
2967      form.querySelectorAll('.note-form-type-pill').forEach(p => p.classList.toggle('active', p.dataset.type === newType));
2968      // The standalone image preview (form-image-wrap) and the canvas would
2969      // otherwise both show the same image_url when editing a drawn note.
2970      // Hide it in draw mode, restore it when leaving draw mode.
2971      const imgWrap = form.querySelector('.note-form-image-wrap');
2972      if (imgWrap) imgWrap.style.display = (newType === 'draw') ? 'none' : '';
2973      // The background-color dots set the note card's bg — they make no sense
2974      // for a drawn note (the canvas image IS the card content), so hide them.
2975      const bgPicker = form.querySelector('.note-color-picker');
2976      if (bgPicker) bgPicker.style.display = (newType === 'draw') ? 'none' : '';
2977      if (form.closest('.notes-pane.notes-view-grid') && window.matchMedia('(max-width: 768px)').matches) {
2978        form.style.gridColumn = '1 / -1';
2979        form.style.gridRowEnd = newType === 'draw' ? 'span 152' : 'span 64';
2980      }
2981      _refreshFormLayout();
2982    });
2983  });
2984
2985  // Slide a finger across the Note/Todo/Draw control to switch modes (mobile).
2986  // On touchmove we find the pill under the finger and click it — reusing the
2987  // pill click handler above, so the body re-renders + content stashing all
2988  // work. Only fires when crossing into a *different* pill.
2989  const _typeSeg = form.querySelector('.note-form-type-seg');
2990  if (_typeSeg) {
2991    let _sliding = false;
2992    const _activateAt = (x, y) => {
2993      const pill = document.elementFromPoint(x, y)?.closest?.('.note-form-type-pill');
2994      if (pill && !pill.classList.contains('active')) pill.click();
2995    };
2996    _typeSeg.addEventListener('touchstart', () => { _sliding = true; }, { passive: true });
2997    _typeSeg.addEventListener('touchmove', (e) => {
2998      if (_sliding && e.touches[0]) _activateAt(e.touches[0].clientX, e.touches[0].clientY);
2999    }, { passive: true });
3000    _typeSeg.addEventListener('touchend', () => { _sliding = false; });
3001    _typeSeg.addEventListener('touchcancel', () => { _sliding = false; });
3002  }
3003
3004  // Color dots — apply to entire form immediately
3005  const _applyFormColor = (newColor) => {
3006    currentColor = newColor || '';
3007    const isBg = _isBgImage(currentColor);
3008    COLORS.forEach(c => { if (c.value && c.value !== 'custom') form.classList.remove('note-color-' + c.value); });
3009    if (currentColor && !isBg) form.classList.add('note-color-' + currentColor);
3010    if (isBg) form.setAttribute('style', _customColorStyle(currentColor));
3011    else form.removeAttribute('style');
3012    form.querySelectorAll('.note-color-dot').forEach(d => {
3013      d.classList.toggle('active', _dotIsActive(d.dataset.color, currentColor));
3014      d.style.background = _dotBg(d.dataset.color, currentColor);
3015    });
3016  };
3017  form.querySelectorAll('.note-color-dot').forEach(dot => {
3018    dot.addEventListener('click', () => {
3019      if (dot.dataset.color === 'custom') {
3020        _pickCustomBgImage().then(url => { if (url) _applyFormColor('bg:' + url); });
3021        return;
3022      }
3023      _applyFormColor(dot.dataset.color);
3024    });
3025  });
3026
3027  if (currentType === 'todo') _wireChecklist(form.querySelector('.note-form-body'));
3028  if (currentType === 'goal') _wireGoalForm(form, form.querySelector('.note-form-body'));
3029  if (currentType === 'draw') {
3030    _wireCanvas(form.querySelector('.note-form-body'), _safeImgSrc(note?.image_url) || null);
3031    // Same hides we apply on type-switch — keep them consistent on initial open.
3032    const _ip = form.querySelector('.note-form-image-wrap'); if (_ip) _ip.style.display = 'none';
3033    const _cp = form.querySelector('.note-color-picker'); if (_cp) _cp.style.display = 'none';
3034  }
3035
3036  // Auto-grow the plain-note textarea so editing longer notes is
3037  // comfortable — it expands with the content (up to a cap) instead of
3038  // staying a cramped 4-row box. The user can still drag-resize too.
3039  const _contentTa = form.querySelector('.note-form-content');
3040  if (_contentTa) {
3041    const _grow = () => {
3042      _contentTa.style.height = 'auto';
3043      // Inline form: cap at ~50vh so a huge note doesn't push the action
3044      // buttons off-screen. Fullscreen mobile overlay: the body scrolls and
3045      // there are no inline buttons crowding, so allow nearly the full height
3046      // — capping at 50vh there clipped longer notes ("part disappears").
3047      const inFullscreen = !!_contentTa.closest('.note-fullscreen-overlay');
3048      const max = Math.round(window.innerHeight * (inFullscreen ? 0.9 : 0.5));
3049      _contentTa.style.height = Math.min(_contentTa.scrollHeight, max) + 'px';
3050    };
3051    _contentTa.addEventListener('input', _grow);
3052    // Grow on open so existing content is fully visible. Run again after the
3053    // fullscreen overlay's open animation settles — measuring mid-animation
3054    // (the overlay starts scaled/transitioning) can under-size the box.
3055    setTimeout(_grow, 0);
3056    setTimeout(_grow, 360);
3057  }
3058
3059  // Reminder bell — opens dropdown menu
3060  const remindBtn = form.querySelector('.note-form-remind-btn');
3061  const dueInput = form.querySelector('.note-form-due');
3062  const repeatInput = form.querySelector('.note-form-repeat');
3063  const tagsEl = form.querySelector('.note-form-reminder-tags');
3064
3065  function _renderReminderTag() {
3066    if (!tagsEl) return;
3067    const v = dueInput.value;
3068    const rep = repeatInput.value || 'none';
3069    if (!v) { tagsEl.innerHTML = ''; return; }
3070    const label = _formatReminderTag(v);
3071    const repLabel = rep !== 'none' ? ` · ${_formatRepeatLabel(rep, new Date(v))}` : '';
3072    tagsEl.innerHTML = `<button class="note-reminder-tag" type="button" title="Edit reminder">
3073      <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
3074      <span>${_esc(label)}${_esc(repLabel)}</span>
3075      <span class="note-reminder-tag-x" title="Remove">×</span>
3076    </button>`;
3077    tagsEl.querySelector('.note-reminder-tag').addEventListener('click', (e) => {
3078      if (e.target.classList.contains('note-reminder-tag-x')) {
3079        dueInput.value = '';
3080        repeatInput.value = 'none';
3081        _renderReminderTag();
3082        return;
3083      }
3084      _openReminderMenu(remindBtn || tagsEl, true);
3085    });
3086  }
3087
3088  function _openReminderMenu(anchor, isEdit = false) {
3089    // Close any existing menu
3090    document.querySelectorAll('.note-reminder-menu').forEach(m => m.remove());
3091    const menu = document.createElement('div');
3092    menu.className = 'note-reminder-menu';
3093    document.body.appendChild(menu);
3094
3095    const presetItems = [
3096      { label: 'Later today', sub: _laterTodayDate().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }), action: () => _setReminder(_toLocalDatetimeStr(_laterTodayDate())) },
3097      { label: 'Tomorrow', sub: _tomorrowDate().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }), action: () => _setReminder(_toLocalDatetimeStr(_tomorrowDate())) },
3098      { label: 'Next week', sub: _nextWeekDate().toLocaleDateString([], { weekday: 'short' }) + ' ' + _nextWeekDate().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }), action: () => _setReminder(_toLocalDatetimeStr(_nextWeekDate())) },
3099      { label: 'Select date and time', sub: '', action: () => _pickCustomDate() },
3100    ];
3101
3102    // Sub-page state for the repeat picker. null = top page.
3103    // 'weekly' | 'monthly' | 'monthly_nth'
3104    let subMode = null;
3105    // Temporary state for monthly_nth so user can click N then weekday (or vice versa)
3106    // before committing.
3107    let nthDraft = { n: 0, w: -1 };
3108
3109    const DAY_SHORT = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
3110
3111    function getNorm() {
3112      if (!dueInput.value) return 'none';
3113      return _normalizeRepeat(repeatInput.value || 'none', new Date(dueInput.value));
3114    }
3115
3116    function commit(val) {
3117      repeatInput.value = val;
3118      _renderReminderTag();
3119      menu.remove();
3120    }
3121
3122    // Like commit, but first snaps dueInput.value forward to the next matching
3123    // slot for the chosen recurrence. Use for weekly/monthly variants where the
3124    // current due date may not match the chosen pattern (e.g. user picks
3125    // "weekly on Mondays" while the date is a Wednesday).
3126    function snapAndCommit(val) {
3127      if (dueInput.value) {
3128        const cur = new Date(dueInput.value);
3129        const norm = _normalizeRepeat(val, cur);
3130        const snapped = _snapToRepeat(cur, norm);
3131        if (snapped) {
3132          dueInput.value = _toLocalDatetimeStr(snapped);
3133          if (remindBtn) remindBtn.classList.add('has-date');
3134        }
3135      }
3136      commit(val);
3137    }
3138
3139    function reposition() {
3140      const rect = anchor.getBoundingClientRect();
3141      const vw = window.innerWidth;
3142      const vh = window.innerHeight;
3143      const mw = menu.offsetWidth || 220;
3144      const mh = menu.offsetHeight || 280;
3145      let top = rect.bottom + 4;
3146      let left = rect.left;
3147      if (top + mh > vh - 8) top = Math.max(8, rect.top - mh - 4);
3148      if (left + mw > vw - 8) left = Math.max(8, vw - mw - 8);
3149      if (left < 8) left = 8;
3150      menu.style.top = top + 'px';
3151      menu.style.left = left + 'px';
3152    }
3153
3154    function render() {
3155      let html = '';
3156
3157      if (subMode === null) {
3158        html += '<div class="note-reminder-menu-title">Remind me later</div>';
3159        for (let i = 0; i < presetItems.length; i++) {
3160          const it = presetItems[i];
3161          html += `<button class="note-reminder-menu-item" data-action="preset" data-i="${i}"><span>${it.label}</span><span class="note-reminder-menu-sub">${it.sub}</span></button>`;
3162        }
3163        if (isEdit && dueInput.value) {
3164          const norm = getNorm();
3165          html += '<div class="note-reminder-menu-divider"></div>';
3166          html += '<div class="note-reminder-menu-title">Repeat</div>';
3167          // None
3168          html += `<button class="note-reminder-menu-item${norm === 'none' ? ' active' : ''}" data-action="set" data-val="none"><span>Doesn't repeat</span>${norm === 'none' ? '<span class="note-reminder-menu-check">✓</span>' : ''}</button>`;
3169          // Daily
3170          html += `<button class="note-reminder-menu-item${norm === 'daily' ? ' active' : ''}" data-action="set" data-val="daily"><span>Daily</span>${norm === 'daily' ? '<span class="note-reminder-menu-check">✓</span>' : ''}</button>`;
3171          // Weekly →
3172          {
3173            const isW = norm.startsWith('weekly:');
3174            const wd = isW ? parseInt(norm.split(':')[1], 10) : null;
3175            const sub = isW && !isNaN(wd) ? `<span class="note-reminder-menu-sub">${_DAYS[wd]}</span>` : '';
3176            html += `<button class="note-reminder-menu-item${isW ? ' active' : ''}" data-action="sub" data-sub="weekly"><span>Weekly</span>${sub}<span class="note-reminder-menu-arrow">›</span></button>`;
3177          }
3178          // Monthly →
3179          {
3180            const isM = norm.startsWith('monthly:');
3181            const sub = isM ? `<span class="note-reminder-menu-sub">${_monthlyShortDescriptor(norm)}</span>` : '';
3182            html += `<button class="note-reminder-menu-item${isM ? ' active' : ''}" data-action="sub" data-sub="monthly"><span>Monthly</span>${sub}<span class="note-reminder-menu-arrow">›</span></button>`;
3183          }
3184          // Yearly
3185          html += `<button class="note-reminder-menu-item${norm === 'yearly' ? ' active' : ''}" data-action="set" data-val="yearly"><span>Yearly</span>${norm === 'yearly' ? '<span class="note-reminder-menu-check">✓</span>' : ''}</button>`;
3186        }
3187      } else if (subMode === 'weekly') {
3188        const norm = getNorm();
3189        const curWd = norm.startsWith('weekly:') ? parseInt(norm.split(':')[1], 10) : -1;
3190        html += `<button class="note-reminder-menu-back" data-action="back"><span class="note-reminder-menu-arrow-back">‹</span> Repeat</button>`;
3191        html += '<div class="note-reminder-menu-title">Weekly on…</div>';
3192        html += '<div class="note-reminder-weekday-row">';
3193        for (let i = 0; i < 7; i++) {
3194          html += `<button class="note-reminder-day-chip${curWd === i ? ' active' : ''}" data-action="weekly-pick" data-wd="${i}" title="${_DAYS[i]}">${DAY_SHORT[i]}</button>`;
3195        }
3196        html += '</div>';
3197      } else if (subMode === 'monthly') {
3198        const norm = getNorm();
3199        const dueDate = new Date(dueInput.value);
3200        const dayN = dueDate.getDate();
3201        html += `<button class="note-reminder-menu-back" data-action="back"><span class="note-reminder-menu-arrow-back">‹</span> Repeat</button>`;
3202        html += '<div class="note-reminder-menu-title">Monthly on…</div>';
3203        // Day N — uses the chosen date's day. Always offered.
3204        const dayVal = `monthly:day:${dayN}`;
3205        html += `<button class="note-reminder-menu-item${norm === dayVal ? ' active' : ''}" data-action="set" data-val="${dayVal}"><span>Day ${dayN} every month</span>${norm === dayVal ? '<span class="note-reminder-menu-check">✓</span>' : ''}</button>`;
3206        // Nth weekday →
3207        {
3208          const isNth = norm.startsWith('monthly:nth:');
3209          const sub = isNth ? `<span class="note-reminder-menu-sub">${_monthlyShortDescriptor(norm)}</span>` : '';
3210          html += `<button class="note-reminder-menu-item${isNth ? ' active' : ''}" data-action="sub" data-sub="monthly_nth"><span>Nth weekday</span>${sub}<span class="note-reminder-menu-arrow">›</span></button>`;
3211        }
3212      } else if (subMode === 'monthly_nth') {
3213        // Pick ordinal (1..4) and weekday (0..6); commit when both chosen.
3214        html += `<button class="note-reminder-menu-back" data-action="back-monthly"><span class="note-reminder-menu-arrow-back">‹</span> Monthly</button>`;
3215        html += '<div class="note-reminder-menu-title">Nth weekday of month</div>';
3216        html += '<div class="note-reminder-menu-sublabel">Which one</div>';
3217        html += '<div class="note-reminder-weekday-row">';
3218        for (let i = 1; i <= 4; i++) {
3219          html += `<button class="note-reminder-day-chip wide${nthDraft.n === i ? ' active' : ''}" data-action="nth-n" data-n="${i}">${_ORDINALS[i - 1]}</button>`;
3220        }
3221        html += '</div>';
3222        html += '<div class="note-reminder-menu-sublabel">Weekday</div>';
3223        html += '<div class="note-reminder-weekday-row">';
3224        for (let i = 0; i < 7; i++) {
3225          html += `<button class="note-reminder-day-chip${nthDraft.w === i ? ' active' : ''}" data-action="nth-w" data-wd="${i}" title="${_DAYS[i]}">${DAY_SHORT[i]}</button>`;
3226        }
3227        html += '</div>';
3228        html += '<div class="note-reminder-menu-divider"></div>';
3229        const ready = nthDraft.n > 0 && nthDraft.w >= 0;
3230        const lbl = ready ? `Save: ${_ORDINALS[nthDraft.n - 1]} ${_DAYS[nthDraft.w]}` : 'Pick week and weekday';
3231        html += `<button class="note-reminder-menu-item note-reminder-menu-confirm${ready ? '' : ' disabled'}" data-action="nth-save" ${ready ? '' : 'disabled'}><span>${lbl}</span></button>`;
3232      }
3233
3234      menu.innerHTML = html;
3235      reposition();
3236      wire();
3237    }
3238
3239    function wire() {
3240      menu.querySelectorAll('[data-action]').forEach(el => {
3241        el.addEventListener('click', (e) => {
3242          e.stopPropagation();
3243          const a = el.dataset.action;
3244          if (a === 'preset') {
3245            const it = presetItems[parseInt(el.dataset.i, 10)];
3246            it.action();
3247            menu.remove();
3248          } else if (a === 'set') {
3249            snapAndCommit(el.dataset.val);
3250          } else if (a === 'sub') {
3251            subMode = el.dataset.sub;
3252            // Seed nth draft from saved value only on first entry — preserve
3253            // in-progress picks across back-trips (Nth → back → Nth again).
3254            if (subMode === 'monthly_nth' && nthDraft.n === 0 && nthDraft.w === -1) {
3255              const norm = getNorm();
3256              const m = norm.match(/^monthly:nth:(\d):(\d)$/);
3257              if (m) nthDraft = { n: parseInt(m[1], 10), w: parseInt(m[2], 10) };
3258            }
3259            render();
3260          } else if (a === 'back') {
3261            subMode = null;
3262            render();
3263          } else if (a === 'back-monthly') {
3264            subMode = 'monthly';
3265            render();
3266          } else if (a === 'weekly-pick') {
3267            snapAndCommit(`weekly:${el.dataset.wd}`);
3268          } else if (a === 'nth-n') {
3269            nthDraft.n = parseInt(el.dataset.n, 10);
3270            render();
3271          } else if (a === 'nth-w') {
3272            nthDraft.w = parseInt(el.dataset.wd, 10);
3273            render();
3274          } else if (a === 'nth-save') {
3275            if (nthDraft.n > 0 && nthDraft.w >= 0) {
3276              snapAndCommit(`monthly:nth:${nthDraft.n}:${nthDraft.w}`);
3277            }
3278          }
3279        });
3280      });
3281    }
3282
3283    render();
3284    // Click outside to close (single global handler attached after first paint)
3285    setTimeout(() => {
3286      const close = (e) => {
3287        if (!menu.isConnected) { document.removeEventListener('click', close); return; }
3288        if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click', close); }
3289      };
3290      document.addEventListener('click', close);
3291    }, 0);
3292  }
3293
3294  function _monthlyShortDescriptor(norm) {
3295    const parts = norm.split(':');
3296    if (parts[1] === 'day') return `Day ${parts[2]}`;
3297    if (parts[1] === 'nth') {
3298      const n = parseInt(parts[2], 10);
3299      const wd = parseInt(parts[3], 10);
3300      return `${_ORDINALS[n - 1] || `${n}th`} ${_DAYS[wd].slice(0, 3)}`;
3301    }
3302    if (parts[1] === 'last') {
3303      const wd = parseInt(parts[2], 10);
3304      return `Last ${_DAYS[wd].slice(0, 3)}`;
3305    }
3306    return '';
3307  }
3308
3309  function _setReminder(datetimeLocalStr) {
3310    dueInput.value = datetimeLocalStr;
3311    if (remindBtn) {
3312      remindBtn.classList.add('has-date');
3313      // Jingle the bell. CSS handles the animation; remove + reflow + re-add
3314      // so it replays every time the user sets/changes a reminder.
3315      const _bell = remindBtn.querySelector('svg');
3316      if (_bell) {
3317        _bell.classList.remove('jingling');
3318        void _bell.offsetWidth;
3319        _bell.classList.add('jingling');
3320        setTimeout(() => _bell.classList.remove('jingling'), 700);
3321      }
3322    }
3323    _renderReminderTag();
3324    _ensureNotificationPermission();
3325  }
3326
3327  function _pickCustomDate() {
3328    // Replace the dropdown menu with a small inline picker
3329    document.querySelectorAll('.note-reminder-menu').forEach(m => m.remove());
3330    const menu = document.createElement('div');
3331    menu.className = 'note-reminder-menu';
3332    const initial = dueInput.value || _toLocalDatetimeStr(_tomorrowDate());
3333    menu.innerHTML = `
3334      <div class="note-reminder-menu-title">Pick date and time</div>
3335      <div class="note-reminder-menu-picker">
3336        <input type="datetime-local" class="note-reminder-date-input" value="${initial}" />
3337      </div>
3338      <div class="note-reminder-menu-divider"></div>
3339      <button class="note-reminder-menu-item note-reminder-menu-confirm">
3340        <span>Save</span>
3341      </button>
3342    `;
3343    document.body.appendChild(menu);
3344    // Position next to the bell button
3345    const anchor = remindBtn || form.querySelector('.note-form-reminder-tags');
3346    const rect = anchor.getBoundingClientRect();
3347    const vw = window.innerWidth;
3348    const vh = window.innerHeight;
3349    const mw = menu.offsetWidth || 240;
3350    const mh = menu.offsetHeight || 200;
3351    let top = rect.bottom + 4;
3352    let left = rect.left;
3353    if (top + mh > vh - 8) top = Math.max(8, rect.top - mh - 4);
3354    if (left + mw > vw - 8) left = Math.max(8, vw - mw - 8);
3355    if (left < 8) left = 8;
3356    menu.style.top = top + 'px';
3357    menu.style.left = left + 'px';
3358    const dInput = menu.querySelector('.note-reminder-date-input');
3359    dInput.focus();
3360    if (typeof dInput.showPicker === 'function') {
3361      try { dInput.showPicker(); } catch {}
3362    }
3363    menu.querySelector('.note-reminder-menu-confirm').addEventListener('click', () => {
3364      if (dInput.value) _setReminder(dInput.value);
3365      menu.remove();
3366    });
3367    setTimeout(() => {
3368      const close = (e) => { if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click', close); } };
3369      document.addEventListener('click', close);
3370    }, 0);
3371  }
3372
3373  if (remindBtn) remindBtn.addEventListener('click', (e) => { e.stopPropagation(); _openReminderMenu(remindBtn, !!dueInput.value); });
3374  _renderReminderTag();
3375
3376  // Photo upload
3377  const photoBtn = form.querySelector('.note-form-photo-btn');
3378  const photoInput = form.querySelector('.note-form-photo-input');
3379  if (photoBtn && photoInput) {
3380    photoBtn.addEventListener('click', () => photoInput.click());
3381    photoInput.addEventListener('change', async () => {
3382      const file = photoInput.files?.[0];
3383      if (!file) return;
3384      const fd = new FormData();
3385      fd.append('files', file);
3386      try {
3387        const res = await fetch(`${API_BASE}/api/upload`, { method: 'POST', body: fd, credentials: 'same-origin' });
3388        const data = await res.json();
3389        const fileId = data.files?.[0]?.id;
3390        if (!fileId) throw new Error('Upload failed');
3391        currentImageUrl = `${API_BASE}/api/upload/${fileId}`;
3392        // Only ever keep the latest attached photo — drop any existing wrap
3393        // before inserting a fresh one. Picking a second photo replaces the
3394        // first instead of stacking.
3395        form.querySelector('.note-form-image-wrap')?.remove();
3396        const wrap = document.createElement('div');
3397        wrap.className = 'note-form-image-wrap';
3398        wrap.innerHTML = `<img class="note-form-image" draggable="false" /><button class="note-form-image-rm" title="Remove">&times;</button>`;
3399        // Insert AFTER the whole header (a flex-row), not after the
3400        // title input itself — otherwise the image lands as a sibling
3401        // of the title inside the header and flex puts them side-by-side.
3402        form.querySelector('.note-form-header').after(wrap);
3403        wrap.querySelector('.note-form-image-rm').addEventListener('click', () => { wrap.remove(); currentImageUrl = ''; });
3404        wrap.querySelector('img').src = currentImageUrl;
3405      } catch (err) { uiModule.showError('Image upload failed'); }
3406      photoInput.value = '';
3407    });
3408  }
3409  // Existing image remove
3410  form.querySelector('.note-form-image-rm')?.addEventListener('click', () => {
3411    form.querySelector('.note-form-image-wrap')?.remove();
3412    currentImageUrl = '';
3413  });
3414
3415  // Title Enter -> focus body (textarea or first checklist item)
3416  form.querySelector('.note-form-title').addEventListener('keydown', (e) => {
3417    if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
3418      e.preventDefault();
3419      const ta = form.querySelector('.note-form-content');
3420      if (ta) { ta.focus(); return; }
3421      const firstItem = form.querySelector('.note-cl-text');
3422      if (firstItem) firstItem.focus();
3423    }
3424  });
3425
3426  // Hashtag → label: typing "#foo " in title/content appends "foo" to the
3427  // space-separated tag list. Repeats are deduplicated, so #foo #foo only
3428  // keeps one. Tags already present in the label field are left alone.
3429  const labelInput = form.querySelector('.note-form-label');
3430  const _hashtagRe = /(^|\s)#([A-Za-z0-9][\w-]*)\s$/;
3431  function _wireHashtag(el) {
3432    if (!el || !labelInput) return;
3433    el.addEventListener('input', () => {
3434      const m = _hashtagRe.exec(el.value);
3435      if (!m) return;
3436      const tag = m[2];
3437      // Dedup against the stripped form — labelInput may already hold `#tag`
3438      // (after Enter normalised), so includes(tag) on the raw split would
3439      // miss the duplicate and append a bare `tag` next to `#tag`.
3440      const existing = labelInput.value.trim().split(/\s+/).filter(Boolean);
3441      const stripped = existing.map(t => t.replace(/^#+/, ''));
3442      if (!stripped.includes(tag)) {
3443        existing.push('#' + tag);
3444        labelInput.value = existing.join(' ');
3445        labelInput.classList.add('flash-once');
3446        setTimeout(() => labelInput.classList.remove('flash-once'), 600);
3447      }
3448      const cut = el.value.length - m[0].length + m[1].length;
3449      el.value = el.value.slice(0, cut);
3450    });
3451  }
3452  _wireHashtag(form.querySelector('.note-form-title'));
3453  _wireHashtag(form.querySelector('.note-form-content'));
3454  // Pressing Enter in the tag field commits the current word as its own tag
3455  // and parks the cursor after a trailing space, so the next word becomes a
3456  // separate tag rather than overwriting the previous one.
3457  labelInput?.addEventListener('keydown', (e) => {
3458    if (e.key !== 'Enter' || e.shiftKey || e.ctrlKey || e.metaKey) return;
3459    e.preventDefault();
3460    e.stopPropagation();
3461    // Strip any leading #s the user typed, dedupe, then re-prepend exactly
3462    // one #. So typing "foo" or "#foo" both end up as "#foo " in the input;
3463    // the save handler keeps stripping #s before storing so DB stays clean.
3464    const tags = [...new Set(labelInput.value.split(/\s+/).map(t =>
3464 t.replace(/^#+/, '').trim()).filter(Boolean))];
3465    if (!tags.length) return;
3466    labelInput.value = tags.map(t => '#' + t).join(' ') + ' ';
3467    labelInput.setSelectionRange(labelInput.value.length, labelInput.value.length);
3468    labelInput.classList.add('flash-once');
3469    setTimeout(() => labelInput.classList.remove('flash-once'), 600);
3470  });
3471
3472  // Shift+Enter (or Cmd/Ctrl+Enter) anywhere in the form -> save
3473  // Escape -> cancel edit
3474  form.addEventListener('keydown', (e) => {
3475    if (e.key === 'Enter' && (e.shiftKey || e.ctrlKey || e.metaKey)) {
3476      e.preventDefault();
3477      form.querySelector('.note-form-save')?.click();
3478    } else if (e.key === 'Escape') {
3479      e.preventDefault();
3480      e.stopPropagation();
3481      form.querySelector('.note-form-cancel')?.click();
3482    }
3483  });
3484
3485  // Save. Prevent the button from stealing focus on press: on mobile, the
3486  // first tap would otherwise just blur the focused textarea/input (closing
3487  // the keyboard and shifting layout), so the tap never reached the button and
3488  // you had to tap "Done" twice. mousedown preventDefault keeps focus put while
3489  // still letting the click fire.
3490  const _saveBtnEl0 = form.querySelector('.note-form-save');
3491  _saveBtnEl0.addEventListener('mousedown', (e) => e.preventDefault());
3492  _saveBtnEl0.addEventListener('click', async () => {
3493    // Guard against spam-clicks: the drawing save AWAITS a canvas upload before
3494    // the optimistic re-render removes the form, so without this a slow upload
3495    // let repeated clicks create duplicate notes.
3496    const _saveBtn = form.querySelector('.note-form-save');
3497    if (_saveBtn._saving) return;
3498    // Mobile: when an existing note is opened and closed without edits, the
3499    // Update (✓) button morphs into Archive (set up below). Route the click
3500    // to the hidden archive button so the existing archive flow + undo toast
3501    // run unchanged.
3502    if (_saveBtn.classList.contains('archive-mode')) {
3503      form.querySelector('.note-form-archive-btn')?.click();
3504      return;
3505    }
3506    _saveBtn._saving = true; _saveBtn.disabled = true; _saveBtn.style.opacity = '0.5';
3507    try {
3508    const title = form.querySelector('.note-form-title').value.trim();
3509    // Normalize tag input: split on whitespace, strip leading #s, dedupe,
3510    // re-join with single spaces. Empty → null.
3511    const _rawLabel = form.querySelector('.note-form-label')?.value || '';
3512    const _tags = [...new Set(_rawLabel.split(/\s+/).map(t => t.replace(/^#+/, '').trim()).filter(Boolean))];
3513    if (form.querySelector('.note-form-due').value && !_tags.includes('reminder')) _tags.push('reminder');
3514    const labelVal = _tags.length ? _tags.join(' ') : null;
3515    const payload = {
3516      title,
3517      note_type: currentType,
3518      color: currentColor,
3519      label: labelVal,
3520      due_date: form.querySelector('.note-form-due').value || null,
3521      repeat: form.querySelector('.note-form-repeat')?.value || 'none',
3522      image_url: currentImageUrl || null,
3523    };
3524    if (currentType === 'note') {
3525      payload.content = form.querySelector('.note-form-content')?.value || '';
3526    } else if (currentType === 'draw') {
3527      // Upload the canvas PNG before saving so image_url points to a
3528      // persistent file. We block the save until upload completes — drawings
3529      // can't be re-rendered later without the URL.
3530      const canvas = form.querySelector('.note-form-canvas');
3531      const url = await _uploadCanvasAsPng(canvas);
3532      if (!url) { uiModule.showError('Failed to save drawing'); return; }
3533      payload.image_url = url;
3534    } else if (currentType === 'goal') {
3535      // Legacy: existing goal-type notes still edit through this branch.
3536      // No AI involvement — save as a normal note with description + items.
3537      payload.content = form.querySelector('.note-form-goal-desc')?.value || '';
3538      payload.items = _collectItems(form);
3539    } else {
3540      payload.items = _collectItems(form);
3541    }
3542    if (isEdit) payload.id = note.id;
3543    // Reset fired reminder if due_date changed (so re-arm works), and also
3544    // clear the entry-glow seen flag so the new firing glows again on the
3545    // next time the user opens the panel.
3546    if (isEdit && note.due_date !== payload.due_date) {
3547      const fired = _loadFiredReminders();
3548      fired.delete(note.id);
3549      _saveFiredReminders(fired);
3550      const glowed = _loadGlowedReminders();
3551      glowed.delete(note.id);
3552      _saveGlowedReminders(glowed);
3553      _setReminderCardGlow(note.id, false);
3554    }
3555    // Edited notes move to the top of their section (under pinned). Compute
3556    // sort_order = (min unpinned sort_order) - 1 so the saved note sorts above
3557    // siblings; the pin block keeps its own ordering above this.
3558    if (!payload.pinned) {
3559      // Both edits AND newly-created notes anchor above the rest of the
3560      // unpinned section. Without this, freshly created notes sit at the
3561      // bottom because manually-reordered siblings already carry negative
3562      // sort_order values.
3563      const minUnpinned = _notes
3564        .filter(n => !n.pinned && (!isEdit || n.id !== note.id))
3565        .reduce((m, n) => Math.min(m, n.sort_order || 0), 0);
3566      payload.sort_order = minUnpinned - 1;
3567    }
3568    // Optimistic update — update local state first, render, then save in background
3569    _editingId = null;
3570    _clearDraft(isEdit ? note.id : '__new__');  // saved → discard the draft
3571    if (isEdit) {
3572      const idx = _notes.findIndex(n => n.id === note.id);
3573      if (idx >= 0) _notes[idx] = { ..._notes[idx], ...payload };
3574    } else {
3575      _notes.unshift({ ...payload, id: 'tmp_' + _uid(), created_at: new Date().toISOString(), updated_at: new Date().toISOString() });
3576    }
3577    _renderNotes();
3578    // Background save
3579    _saveNote(payload).then(saved => {
3580      if (!isEdit && saved && saved.id) {
3581        // Replace temp ID with real one from server. AND re-render — the
3582        // existing card's `data-note-id="tmp_xxx"` is stale after Object.assign
3583        // bumps the in-memory id, so all subsequent clicks (edit, done, copy,
3584        // archive, delete) silently fail to find the note in `_notes`.
3585        const tmp = _notes.find(n => n.id.startsWith('tmp_'));
3586        if (tmp) Object.assign(tmp, saved);
3587        _renderNotes();
3588      }
3589    }).catch(err => {
3590      uiModule.showError('Save failed: ' + err.message);
3591      _fetchNotes().then(() => _renderNotes());
3592    });
3593    } finally {
3594      // Re-enable on early returns / errors. On success the form is removed by
3595      // the optimistic re-render, so re-enabling the detached button is a no-op.
3596      _saveBtn._saving = false; _saveBtn.disabled = false; _saveBtn.style.opacity = '';
3597    }
3598  });
3599
3600  // Mobile-only: when editing an existing note, the Update (✓) button starts in
3601  // archive-mode (visually + behaviorally) and flips to Update on the first
3602  // edit. Lets the user tap a note to skim, then tap ✓ to archive without ever
3603  // touching a separate Archive button.
3604  if (isEdit && window.innerWidth <= 768) {
3605    const _saveLabelEl = _saveBtnEl0.querySelector('.nft-label');
3606    const _enterArchive = () => {
3607      _saveBtnEl0.classList.add('archive-mode');
3608      if (_saveLabelEl) _saveLabelEl.textContent = 'Archive';
3609      _saveBtnEl0.title = 'Archive';
3610    };
3611    const _enterUpdate = () => {
3612      if (!_saveBtnEl0.classList.contains('archive-mode')) return;
3613      _saveBtnEl0.classList.remove('archive-mode');
3614      if (_saveLabelEl) _saveLabelEl.textContent = 'Update';
3615      _saveBtnEl0.title = 'Update';
3616    };
3617    _enterArchive();
3618    form.addEventListener('input', _enterUpdate, true);
3619    form.addEventListener('change', _enterUpdate, true);
3620  }
3621
3622  // Cancel
3623  form.querySelector('.note-form-cancel').addEventListener('click', () => { _clearDraft(isEdit ? note.id : '__new__'); _editingId = null; _renderNotes(); });
3624
3625  // Archive / Delete — edit-mode-only buttons, mirror the (now-hidden) card actions.
3626  form.querySelector('.note-form-archive-btn')?.addEventListener('click', () => {
3627    if (!isEdit) return;
3628    const id = note.id;
3629    const idx = _notes.findIndex(n => n.id === id);
3630    if (idx < 0) return;
3631    const removed = _notes.splice(idx, 1)[0];
3632    _editingId = null;
3633    _renderNotes();
3634    const undo = () => _undoArchive(removed, idx);
3635    _pushUndo({ label: 'archive', run: undo });
3636    const _undoIcon = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle;"><polyline points="9 14 4 9 9 4"/><path d="M4 9h11a5 5 0 0 1 5 5v0a5 5 0 0 1-5 5H9"/></svg>';
3637    _patchNote(id, { archived: true }).then(() => {
3638      uiModule.showToast('Archived', { duration: 6000, action: 'Undo', actionIcon: _undoIcon, onAction: undo, actionHint: 'Ctrl+Z' });
3639    }).catch(() => {
3640      _notes.splice(idx, 0, removed);
3641      _renderNotes();
3642      uiModule.showError('Failed to archive');
3643    });
3644  });
3645  form.querySelector('.note-form-delete-btn')?.addEventListener('click', async () => {
3646    if (!isEdit) return;
3647    const id = note.id;
3648    if (uiModule.styledConfirm) {
3649      const ok = await uiModule.styledConfirm('Delete this note?', { confirmText: 'Delete', danger: true });
3650      if (!ok) return;
3651    } else if (!confirm('Delete this note?')) {
3652      return;
3653    }
3654    const idx = _notes.findIndex(n => n.id === id);
3655    if (idx >= 0) _notes.splice(idx, 1);
3656    _editingId = null;
3657    _renderNotes();
3658    _deleteNoteApi(id).then(() => uiModule.showToast('Deleted')).catch(() => {
3659      uiModule.showError('Failed to delete');
3660      _fetchNotes().then(() => _renderNotes());
3661    });
3662  });
3663
3664  // Autosave a draft to localStorage on every change so unsaved edits
3665  // survive connection loss / reload / accidental close.
3666  _wireDraftAutosave(form, isEdit ? note.id : '__new__');
3667
3668  return form;
3669}
3670
3671// Legacy goal-typed notes still render through this branch so existing
3672// data isn't lost. The "Goal" type is no longer exposed in the form picker
3673// or quick-add — these notes show with a description + manual checklist
3674// editor, just like a regular todo with a body.
3675function _buildGoalHtml(note, items) {
3676  const desc = (note?.content || '').toString();
3677  return `
3678    <div class="note-form-goal">
3679      <textarea class="note-form-goal-desc" placeholder="Description (optional)" rows="3">${_esc(desc)}</textarea>
3680      ${_buildChecklistHtml(items)}
3681    </div>
3682  `;
3683}
3684
3685function _wireGoalForm(form, container) {
3686  if (!container) return;
3687  // _wireHashtag is a closure inside _buildForm — out of scope here. Inline
3688  // the same behavior (type "#foo " in the description → tag added to the
3689  // form's label input) so editing a goal note doesn't ReferenceError.
3690  const desc = container.querySelector('.note-form-goal-desc');
3691  const labelInput = form?.querySelector('.note-form-label');
3692  if (desc && labelInput) {
3693    const tagRe = /(^|\s)#([A-Za-z0-9][\w-]*)\s$/;
3694    desc.addEventListener('input', () => {
3695      const m = tagRe.exec(desc.value);
3696      if (!m) return;
3697      const tag = m[2];
3698      // Same dedup-after-stripping fix as the plain note hashtag handler.
3699      const existing = labelInput.value.trim().split(/\s+/).filter(Boolean);
3700      const stripped = existing.map(t => t.replace(/^#+/, ''));
3701      if (!stripped.includes(tag)) {
3702        existing.push('#' + tag);
3703        labelInput.value = existing.join(' ');
3704        labelInput.classList.add('flash-once');
3705        setTimeout(() => labelInput.classList.remove('flash-once'), 600);
3706      }
3707      const cut = desc.value.length - m[0].length + m[1].length;
3708      desc.value = desc.value.slice(0, cut);
3709    });
3710  }
3711  // Always wire the checklist. The previous gate on a `note-form-goal-fresh`
3712  // class was dead code — nothing ever set that class, so the editor never
3713  // hooked up add/drag/Tab.
3714  _wireChecklist(container);
3715}
3716
3717function _buildChecklistHtml(items) {
3718  let html = '<div class="note-checklist-inputs">';
3719  for (const item of items) {
3720    const indent = Math.min(item.indent || 0, 3);
3721    html += `<div class="note-cl-row${item.done ? ' done' : ''}" draggable="true" data-item-id="${item.id || _uid()}" data-indent="${indent}" style="padding-left:${indent * 16}px">
3722      <span class="note-cl-grip" title="Drag to reorder">⋮⋮</span>
3723      <span class="note-cl-dot"></span>
3724      <input type="text" class="note-cl-text" value="${_esc(item.text)}" placeholder="Item..." />
3725      <button type="button" class="note-cl-rm">&times;</button>
3726    </div>`;
3727  }
3728  // `type="button"` matters on mobile — without it some browsers treat
3729  // bare <button> as form-submit and the click handler never fires inside
3730  // certain containers. Also bumped tap target so fingers don't miss.
3731  html += `<button type="button" class="note-cl-add">+ Add</button></div>`;
3732  return html;
3733}
3734
3735function _wireRow(row, container) {
3736  row.querySelector('.note-cl-rm')?.addEventListener('click', () => row.remove());
3737  row.querySelector('.note-cl-dot')?.addEventListener('click', () => {
3738    const wasDone = row.classList.contains('done');
3739    row.classList.toggle('done');
3740    const becameDone = !wasDone;  // we just flipped it
3741    const dot = row.querySelector('.note-cl-dot');
3742    const dRect = (dot || row).getBoundingClientRect();
3743    // Small confetti burst on each fresh check so the user gets a
3744    // "well done" beat per item, not just the grand-finale on all-done.
3745    if (becameDone) {
3746      spawnConfetti(dRect.left + dRect.width / 2, dRect.top + dRect.height / 2, 16);
3747    }
3748    // Bigger burst when the whole list is now done.
3749    const rows = [...container.querySelectorAll('.note-cl-row')];
3750    const hasText = rows.some(r => (r.querySelector('.note-cl-text')?.value || '').trim().length > 0);
3751    if (hasText && rows.every(r => r.classList.contains('done') || !(r.querySelector('.note-cl-text')?.value || '').trim())) {
3752      spawnConfetti(dRect.left + dRect.width / 2, dRect.top + dRect.height / 2, 60);
3753    }
3754  });
3755  const txt = row.querySelector('.note-cl-text');
3756  txt?.addEventListener('keydown', (e) => {
3757    if (e.key === 'Enter') { e.preventDefault(); container.querySelector('.note-cl-add')?.click(); }
3758    else if (e.key === 'Tab') {
3759      e.preventDefault();
3760      const cur = parseInt(row.dataset.indent || '0');
3761      const next = e.shiftKey ? Math.max(0, cur - 1) : Math.min(3, cur + 1);
3762      row.dataset.indent = String(next);
3763      row.style.paddingLeft = (next * 16) + 'px';
3764    } else if (e.key === 'Backspace' && txt.value === '') {
3765      e.preventDefault();
3766      const prev = row.previousElementSibling;
3767      row.remove();
3768      if (prev && prev.classList.contains('note-cl-row')) prev.querySelector('.note-cl-text')?.focus();
3769    }
3770  });
3771  // Drag handlers
3772  row.addEventListener('dragstart', (e) => {
3773    row.classList.add('dragging');
3774    e.dataTransfer.effectAllowed = 'move';
3775    try { e.dataTransfer.setData('text/plain', row.dataset.itemId); } catch {}
3776  });
3777  row.addEventListener('dragend', () => {
3778    row.classList.remove('dragging');
3779    container.querySelectorAll('.drop-before, .drop-after').forEach(el => el.classList.remove('drop-before', 'drop-after'));
3780  });
3781}
3782
3783function _wireChecklist(container) {
3784  if (!container) return;
3785  // Delegate the + Add click off the container so re-renders + mobile
3786  // touch quirks don't leave the button dead. The previous direct
3787  // `addEventListener` on the button silently broke on mobile when
3788  // _wireChecklist ran more than once (or before the button was in DOM).
3789  if (!container._addDelegated) {
3790    container._addDelegated = true;
3791    container.addEventListener('click', (ev) => {
3792      const addBtn = ev.target.closest('.note-cl-add');
3793      if (!addBtn || !container.contains(addBtn)) return;
3794      ev.preventDefault();
3795      ev.stopPropagation();
3796      const inputs = container.querySelector('.note-checklist-inputs');
3797      if (!inputs) return;
3798      const row = document.createElement('div');
3799      row.className = 'note-cl-row';
3800      row.draggable = true;
3801      row.dataset.itemId = _uid();
3802      row.dataset.indent = '0';
3803      row.innerHTML = `<span class="note-cl-grip" title="Drag">⋮⋮</span><span class="note-cl-dot"></span><input type="text" class="note-cl-text" placeholder="Item..." /><button type="button" class="note-cl-rm">&times;</button>`;
3804      inputs.insertBefore(row, addBtn);
3805      row.querySelector('.note-cl-text').focus();
3806      _wireRow(row, container);
3807    });
3808  }
3809  container.querySelectorAll('.note-cl-row').forEach(row => _wireRow(row, container));
3810
3811  // Drag-over handler on the inputs container
3812  const inputs = container.querySelector('.note-checklist-inputs');
3813  if (inputs) {
3814    inputs.addEventListener('dragover', (e) => {
3815      e.preventDefault();
3816      const dragging = inputs.querySelector('.note-cl-row.dragging');
3817      if (!dragging) return;
3818      inputs.querySelectorAll('.drop-before, .drop-after').forEach(el => el.classList.remove('drop-before', 'drop-after'));
3819      const rows = [...inputs.querySelectorAll('.note-cl-row:not(.dragging)')];
3820      const after = rows.find(r => {
3821        const box = r.getBoundingClientRect();
3822        return e.clientY < box.top + box.height / 2;
3823      });
3824      if (after) {
3825        after.classList.add('drop-before');
3826        inputs.insertBefore(dragging, after);
3827      } else if (rows.length) {
3828        rows[rows.length - 1].classList.add('drop-after');
3829        inputs.insertBefore(dragging, container.querySelector('.note-cl-add'));
3830      }
3831    });
3832    inputs.addEventListener('dragleave', (e) => {
3833      if (!inputs.contains(e.relatedTarget)) {
3834        inputs.querySelectorAll('.drop-before, .drop-after').forEach(el => el.classList.remove('drop-before', 'drop-after'));
3835      }
3836    });
3837  }
3838}
3839
3840function _collectItems(form) {
3841  const items = [];
3842  form.querySelectorAll('.note-cl-row').forEach(row => {
3843    const text = row.querySelector('.note-cl-text')?.value?.trim();
3844    if (text) items.push({
3845      id: row.dataset.itemId || _uid(),
3846      text,
3847      done: row.classList.contains('done'),
3848      indent: parseInt(row.dataset.indent || '0'),
3849    });
3850  });
3851  return items;
3852}
3853
3854// ---- Draw mode (canvas) ----
3855
3856function _buildDrawHtml() {
3857  return `
3858    <div class="note-form-draw-wrap">
3859      <canvas class="note-form-canvas" width="600" height="320"></canvas>
3860      <div class="note-form-draw-toolbar">
3861        <input type="color" class="note-form-draw-color" title="Stroke color" value="#222222" />
3862        <label class="note-form-draw-tool note-form-draw-size-wrap" title="Stroke size">
3863          <input type="range" class="note-form-draw-size" min="1" max="24" value="3" />
3864        </label>
3865        <div class="note-form-draw-be" role="group">
3866          <button type="button" class="note-form-draw-be-btn note-form-draw-brush active" data-mode="pen" title="Brush">
3867            <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04 0-1.67-1.34-3.02-3-3.02z"/></svg>
3868          </button>
3869          <button type="button" class="note-form-draw-be-btn note-form-draw-eraser" data-mode="eraser" title="Eraser">
3870            <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 21-4.3-4.3c-1-1-1-2.5 0-3.4l9.6-9.6c1-1 2.5-1 3.4 0l5.6 5.6c1 1 1 2.5 0 3.4L13 21"/><path d="M22 21H7"/><path d="m5 11 9 9"/></svg>
3871          </button>
3872        </div>
3873        <button type="button" class="note-form-draw-text" title="Add text — click to cycle size">T<span class="note-form-draw-text-badge"></span></button>
3874        <button type="button" class="note-form-draw-line" title="Line — click to cycle size">
3875          <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="20" x2="20" y2="4"/></svg>
3876          <span class="note-form-draw-shape-badge"></span>
3877        </button>
3878        <button type="button" class="note-form-draw-circle" title="Circle — click to cycle size">
3879          <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/></svg>
3880          <span class="note-form-draw-shape-badge"></span>
3881        </button>
3882        <button type="button" class="note-form-draw-undo" title="Undo">
3883          <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 14 4 9 9 4"/><path d="M4 9h11a5 5 0 0 1 5 5v0a5 5 0 0 1-5 5H9"/></svg>
3884        </button>
3885      </div>
3886    </div>
3887  `;
3888}
3889
3890// Attach drawing handlers to the canvas inside `container`. Optionally loads
3891// `initialImageUrl` as a background, so editing an existing drawing keeps it.
3892function _wireCanvas(container, initialImageUrl) {
3893  const canvas = container.querySelector('.note-form-canvas');
3894  if (!canvas) return;
3895  // Bump the backing-store resolution for retina displays so strokes stay
3896  // crisp. Set only style.width — leaving style.height to auto so the canvas
3897  // scales uniformly via its intrinsic aspect ratio. If both CSS dimensions
3898  // are pinned, max-width:100% shrinks the width only, leaving rasterized
3899  // glyphs visibly stretched relative to their on-screen input.
3900  const dpr = Math.min(window.devicePixelRatio || 1, 2);
3901  const cssW = canvas.width;
3902  const cssH = canvas.height;
3903  // Fill the container up to the logical width (don't pin a hard 600px,
3904  // which on a narrow phone forces the card wider than the viewport and
3905  // pushes the drawing outside the note). _pos() scales pointer coords by
3906  // the actual displayed width, so accuracy is preserved at any size.
3907  canvas.style.width = '100%';
3908  canvas.style.maxWidth = cssW + 'px';
3909  canvas.style.height = 'auto';
3910  canvas.style.aspectRatio = cssW + ' / ' + cssH;
3911  canvas.width = cssW * dpr;
3912  canvas.height = cssH * dpr;
3913  const ctx = canvas.getContext('2d');
3914  ctx.scale(dpr, dpr);
3915  ctx.fillStyle = '#ffffff';
3916  ctx.fillRect(0, 0, cssW, cssH);
3917  ctx.lineCap = 'round';
3918  ctx.lineJoin = 'round';
3919
3920  // Load prior drawing as starting point so consecutive edits compose.
3921  const safeInitialImageUrl = _safeImgSrc(initialImageUrl);
3922  if (safeInitialImageUrl) {
3923    const img = new Image();
3924    img.crossOrigin = 'anonymous';
3925    img.onload = () => { try { ctx.drawImage(img, 0, 0, cssW, cssH); } catch {} };
3926    img.src = safeInitialImageUrl;
3927    // Float an X over the canvas so the user can blank it out and go back to
3928    // a clean draw surface. Removes itself once clicked.
3929    const wrap = container.querySelector('.note-form-draw-wrap');
3930    if (wrap && !wrap.querySelector('.note-form-draw-bg-rm')) {
3931      const rm = document.createElement('button');
3932      rm.type = 'button';
3933      rm.className = 'note-form-draw-bg-rm';
3934      rm.title = 'Clear photo (regular draw)';
3935      rm.innerHTML = '&times;';
3936      rm.addEventListener('click', (e) => {
3937        e.preventDefault();
3938        e.stopPropagation();
3939        ctx.fillStyle = '#ffffff';
3940        ctx.fillRect(0, 0, cssW, cssH);
3941        rm.remove();
3942      });
3943      wrap.appendChild(rm);
3944    }
3945  }
3946
3947  const colorInput = container.querySelector('.note-form-draw-color');
3948  // Swap the native browser color dialog for the in-house HSV picker
3949  // (same one used by Themes + the gallery editor). Existing `input` event
3950  // listeners + .value reads keep working — see colorPicker.js.
3951  if (colorInput) attachColorPicker(colorInput);
3952  const sizeInput = container.querySelector('.note-form-draw-size');
3953  const beSeg = container.querySelector('.note-form-draw-be');
3954  const brushBtn = container.querySelector('.note-form-draw-brush');
3955  const eraserBtn = container.querySelector('.note-form-draw-eraser');
3956  const textBtn = container.querySelector('.note-form-draw-text');
3957  const lineBtn = container.querySelector('.note-form-draw-line');
3958  const circleBtn = container.querySelector('.note-form-draw-circle');
3959  const undoBtn = container.querySelector('.note-form-draw-undo');
3960  // Single source of truth for what clicks/drags do. Other booleans are
3961  // derived from this so we never end up with conflicting "eraser AND text"
3962  // states (the bug that made T appear broken after using the eraser).
3963  // Modes: 'pen' | 'eraser' | 'text-s' | 'text-m' | 'text-l' | 'line' | 'circle'
3964  let mode = 'pen';
3965  let drawing = false;
3966  let last = null;
3967  // The text tool has three preset sizes (CSS px font-size).
3968  const TEXT_SIZES = { 's': 16, 'm': 26, 'l': 40 };
3969  // Line / circle stroke widths in logical pixels — three crisp options.
3970  const SHAPE_WIDTHS = { 's': 2, 'm': 5, 'l': 10 };
3971  // Snapshot taken at the start of a shape drag so the preview can repaint
3972  // cleanly on each move without accumulating intermediate strokes.
3973  let _shapeSnapshot = null;
3974
3975  // Per-canvas undo stack. We snapshot the bitmap (as ImageData) BEFORE each
3976  // operation — stroke, text commit, or future operations — and pop+restore
3977  // on Undo. Cap to 30 to keep memory bounded.
3978  const _undoStack = [];
3979  const UNDO_LIMIT = 30;
3980  const _snapshot = () => {
3981    try {
3982      const w = canvas.width, h = canvas.height;
3983      _undoStack.push(ctx.getImageData(0, 0, w, h));
3984      if (_undoStack.length > UNDO_LIMIT) _undoStack.shift();
3985    } catch {}
3986  };
3987  const _undo = () => {
3988    const prev = _undoStack.pop();
3989    if (!prev) return;
3990    // Restore against the raw backing store: temporarily reset the active
3991    // ctx scale, paint the snapshot 1:1, then reapply our standard transform.
3992    ctx.save();
3993    ctx.setTransform(1, 0, 0, 1, 0, 0);
3994    ctx.putImageData(prev, 0, 0);
3995    ctx.restore();
3996  };
3997
3998  const _pos = (e) => {
3999    // CSS can shrink the canvas (max-width:100%) without changing its logical
4000    // size, so compute the displayed-to-logical scale per axis. Pointer coords
4001    // are in CSS pixels; the dpr-scaled ctx expects logical (cssW × cssH).
4002    const r = canvas.getBoundingClientRect();
4003    const sx = cssW / r.width;
4004    const sy = cssH / r.height;
4005    const t = e.touches ? e.touches[0] : e;
4006    return { x: (t.clientX - r.left) * sx, y: (t.clientY - r.top) * sy };
4007  };
4008  const _begin = (e) => {
4009    if (mode.startsWith('text-')) {
4010      // Stop the event so the browser doesn't synthesize a follow-up click
4011      // that would blur the input we're about to create.
4012      e.preventDefault?.();
4013      e.stopPropagation?.();
4014      _openTextInput(e);
4015      return;
4016    }
4017    _snapshot();
4018    last = _pos(e);
4019    drawing = true;
4020    if (mode.startsWith('line-') || mode.startsWith('circle-')) {
4021      // Capture the backing pixels so the preview can replay each move from
4022      // the same starting state (otherwise live shapes accumulate).
4023      try { _shapeSnapshot = ctx.getImageData(0, 0, canvas.width, canvas.height); } catch {}
4024      return;
4025    }
4026    ctx.beginPath();
4027    ctx.moveTo(last.x, last.y);
4028  };
4029
4030  // Drop an HTML input at the click position so the user can type, then
4031  // rasterize the text onto the canvas at blur/Enter. Mirrors how PDF form
4032  // annotations work in our doc editor.
4033  let _activeTextInput = null;
4034  const _openTextInput = (e) => {
4035    // Commit any prior pending input before starting a new one — otherwise
4036    // the first click leaves an orphaned input the user thinks "didn't work".
4037    if (_activeTextInput) { try { _activeTextInput.blur(); } catch {} }
4038    const r = canvas.getBoundingClientRect();
4039    const t = e.touches ? e.touches[0] : e;
4040    // Position is anchored to the wrap, not the canvas; since the canvas is
4041    // the first child of the wrap and the wrap has no padding, they share an
4042    // origin, so we offset from the canvas rect directly.
4043    const px = t.clientX - r.left;
4044    const py = t.clientY - r.top;
4045    const logical = _pos(e);
4046    // Size is decided by which T variant is active (S/M/L), not the stroke
4047    // slider — those are independent dials.
4048    const sizeKey = mode.startsWith('text-') ? mode.slice(-1) : 'm';
4049    const sizeCss = TEXT_SIZES[sizeKey] || TEXT_SIZES.m;
4050    const wrap = container.querySelector('.note-form-draw-wrap');
4051    if (!wrap) return;
4052    if (getComputedStyle(wrap).position === 'static') wrap.style.position = 'relative';
4053    const input = document.createElement('input');
4054    input.type = 'text';
4055    input.className = 'note-form-draw-textinput';
4056    input.placeholder = 'type then Enter';
4057    const color = colorInput?.value || '#222';
4058    const maxW = Math.max(120, Math.floor(r.width - px - 4));
4059    input.style.cssText = [
4060      'position:absolute',
4061      `left:${px}px`,
4062      `top:${Math.max(0, py - sizeCss * 0.7)}px`,
4063      `font:${sizeCss}px Arial, sans-serif`,
4064      `color:${color}`,
4065      'background:#ffffff',
4066      'border:2px solid var(--accent)',
4067      'border-radius:4px',
4068      'outline:none',
4069      'padding:2px 6px',
4070      'min-width:120px',
4071      `max-width:${maxW}px`,
4072      'z-index:1000',
4073      'box-shadow:0 2px 8px rgba(0,0,0,0.25)',
4074      'pointer-events:auto',
4075    ].join(';');
4076    wrap.appendChild(input);
4077    _activeTextInput = input;
4078    // Focus synchronously so the call still counts as inside the user gesture
4079    // on iOS / Android, then re-focus on the next frame in case a competing
4080    // synthetic event (touch → click) moved focus away.
4081    input.focus();
4082    requestAnimationFrame(() => { if (document.activeElement !== input) input.focus(); });
4083    let committed = false;
4084    const commit = () => {
4085      if (committed) return;
4086      committed = true;
4087      const text = input.value;
4088      if (_activeTextInput === input) _activeTextInput = null;
4089      input.remove();
4090      if (!text) return;
4091      // Snapshot BEFORE rasterizing so Undo removes the text in one step.
4092      _snapshot();
4093      ctx.save();
4094      ctx.globalCompositeOperation = 'source-over';
4095      ctx.fillStyle = color;
4096      // Canvas now scales uniformly (style.height: auto), so a single ratio
4097      // suffices for picking the logical font size that matches what the
4098      // user just saw in the HTML input.
4099      const sx = cssW / r.width;
4100      const logicalSize = sizeCss * sx;
4101      ctx.font = `${logicalSize}px sans-serif`;
4102      ctx.textBaseline = 'top';
4103      ctx.fillText(text, logical.x, logical.y - logicalSize * 0.7);
4104      ctx.restore();
4105    };
4106    input.addEventListener('blur', commit);
4107    input.addEventListener('keydown', (ev) => {
4108      if (ev.key === 'Enter') { ev.preventDefault(); input.blur(); }
4109      else if (ev.key === 'Escape') { input.value = ''; input.blur(); }
4110    });
4111  };
4112  const _move = (e) => {
4113    if (!drawing) return;
4114    e.preventDefault?.();
4115    const p = _pos(e);
4116    if (mode.startsWith('line-') || mode.startsWith('circle-')) {
4117      // Restore the pre-shape bitmap, then redraw the preview shape from
4118      // anchor → current pointer.
4119      if (_shapeSnapshot) {
4120        ctx.save();
4121        ctx.setTransform(1, 0, 0, 1, 0, 0);
4122        ctx.putImageData(_shapeSnapshot, 0, 0);
4123        ctx.restore();
4124      }
4125      ctx.globalCompositeOperation = 'source-over';
4126      ctx.strokeStyle = colorInput?.value || '#222';
4127      const sizeKey = mode.slice(-1);
4128      ctx.lineWidth = SHAPE_WIDTHS[sizeKey] || SHAPE_WIDTHS.m;
4129      ctx.beginPath();
4130      if (mode.startsWith('line-')) {
4131        ctx.moveTo(last.x, last.y);
4132        ctx.lineTo(p.x, p.y);
4133      } else {
4134        const dx = p.x - last.x;
4135        const dy = p.y - last.y;
4136        const radius = Math.hypot(dx, dy);
4137        ctx.arc(last.x, last.y, radius, 0, Math.PI * 2);
4138      }
4139      ctx.stroke();
4140      return;
4141    }
4142    const erasing = mode === 'eraser';
4143    ctx.globalCompositeOperation = erasing ? 'destination-out' : 'source-over';
4144    ctx.strokeStyle = erasing ? 'rgba(0,0,0,1)' : (colorInput?.value || '#222');
4145    ctx.lineWidth = Number(sizeInput?.value || 3) * (erasing ? 2.5 : 1);
4146    ctx.lineTo(p.x, p.y);
4147    ctx.stroke();
4148    last = p;
4149  };
4150  const _end = () => { drawing = false; last = null; _shapeSnapshot = null; };
4151
4152  canvas.addEventListener('mousedown', _begin);
4153  canvas.addEventListener('mousemove', _move);
4154  window.addEventListener('mouseup', _end);
4155  // Non-passive so text mode can preventDefault — otherwise the synthetic
4156  // mousedown/click that follows a touch can blur the freshly-created text
4157  // input on iOS Safari, making T look like a no-op.
4158  canvas.addEventListener('touchstart', (e) => { if (mode.startsWith('text-')) e.preventDefault(); _begin(e); }, { passive: false });
4159  canvas.addEventListener('touchmove', _move, { passive: false });
4160  canvas.addEventListener('touchend', _end);
4161  canvas.addEventListener('touchcancel', _end);
4162
4163  // Single mode setter — keeps the toolbar, swatch, and cursor in sync, and
4164  // makes sure exiting the eraser restores the user's chosen color whether
4165  // they exit by clicking another tool or by toggling eraser off.
4166  let _preEraseColor = null;
4167  const _setMode = (next) => {
4168    const wasEraser = mode === 'eraser';
4169    mode = next;
4170    const isEraser = next === 'eraser';
4171    const isPen = next === 'pen';
4172    const isText = next.startsWith('text-');
4173    // Swatch: white while erasing, restored as soon as we leave that mode.
4174    if (isEraser && !wasEraser && colorInput) {
4175      _preEraseColor = colorInput.value;
4176      colorInput.value = '#ffffff';
4177    } else if (!isEraser && wasEraser && colorInput && _preEraseColor) {
4178      colorInput.value = _preEraseColor;
4179      _preEraseColor = null;
4180    }
4181    // Brush/Eraser segmented pill — slides to the active side. When a non-
4182    // pen/eraser tool (T / line / circle) is active, neither half is
4183    // highlighted, but the pill still indicates which side the user was on
4184    // last so they can return with one click.
4185    const isLine = next.startsWith('line-');
4186    const isCircle = next.startsWith('circle-');
4187    beSeg?.classList.toggle('is-eraser', isEraser);
4188    brushBtn?.classList.toggle('active', isPen);
4189    eraserBtn?.classList.toggle('active', isEraser);
4190    textBtn?.classList.toggle('active', isText);
4191    lineBtn?.classList.toggle('active', isLine);
4192    circleBtn?.classList.toggle('active', isCircle);
4193    // Per-button size badges (S/M/L), driven off the mode suffix.
4194    const tBadge = textBtn?.querySelector('.note-form-draw-text-badge');
4195    if (tBadge) tBadge.textContent = isText ? next.slice(-1).toUpperCase() : '';
4196    const lBadge = lineBtn?.querySelector('.note-form-draw-shape-badge');
4197    if (lBadge) lBadge.textContent = isLine ? next.slice(-1).toUpperCase() : '';
4198    const cBadge = circleBtn?.querySelector('.note-form-draw-shape-badge');
4199    if (cBadge) cBadge.textContent = isCircle ? next.slice(-1).toUpperCase() : '';
4200    // Reflect the chosen size in the icon itself — thicker line/circle stroke
4201    // for M+L, larger T glyph for M+L. CSS rules read `.size-s/.size-m/.size-l`.
4202    const _sz = next.slice(-1);
4203    [textBtn, lineBtn, circleBtn].forEach(b => b?.classList.remove('size-s', 'size-m', 'size-l'));
4204    if (isText && /[sml]/.test(_sz)) textBtn?.classList.add('size-' + _sz);
4205    if (isLine && /[sml]/.test(_sz)) lineBtn?.classList.add('size-' + _sz);
4206    if (isCircle && /[sml]/.test(_sz)) circleBtn?.classList.add('size-' + _sz);
4207    canvas.style.cursor = isText ? 'text' : 'crosshair';
4208  };
4209  brushBtn?.addEventListener('click', () => _setMode('pen'));
4210  eraserBtn?.addEventListener('click', () => _setMode('eraser'));
4211  // T / Line / Circle: each cycles its own three sizes, then back to pen.
4212  const _cycle = (prefix) => {
4213    const seq = ['s', 'm', 'l'];
4214    if (!mode.startsWith(prefix)) return prefix + 's';
4215    const cur = mode.slice(-1);
4216    const next = seq[seq.indexOf(cur) + 1];
4217    return next ? prefix + next : 'pen';
4218  };
4219  textBtn?.addEventListener('click', () => _setMode(_cycle('text-')));
4220  lineBtn?.addEventListener('click', () => _setMode(_cycle('line-')));
4221  circleBtn?.addEventListener('click', () => _setMode(_cycle('circle-')));
4222  undoBtn?.addEventListener('click', () => _undo());
4223
4224  // Stash so the save handler can read it later without re-resolving DOM.
4225  canvas._cssW = cssW;
4226  canvas._cssH = cssH;
4227  return canvas;
4228}
4229
4230// Export the canvas as a PNG dataURL, upload it via the existing /api/upload
4231// endpoint, and resolve to a persistent URL. Returns null on failure.
4232async function _uploadCanvasAsPng(canvas) {
4233  if (!canvas) return null;
4234  const blob = await new Promise(r => canvas.toBlob(r, 'image/png'));
4235  if (!blob) return null;
4236  const fd = new FormData();
4237  fd.append('files', blob, 'drawing.png');
4238  try {
4239    const res = await fetch(`${API_BASE}/api/upload`, { method: 'POST', body: fd, credentials: 'same-origin' });
4240    const data = await res.json();
4241    const id = data.files?.[0]?.id;
4242    return id ? `${API_BASE}/api/upload/${id}` : null;
4243  } catch { return null; }
4244}
4245
4246// ---- Create / Edit / Delete ----
4247
4248function _createNote(type = 'todo') {
4249  const body = document.querySelector('#notes-pane .notes-pane-body');
4250  if (!body || _editingId === '__new__') return;
4251  _editingId = '__new__';
4252  // Restore an unsaved new-note draft if one survived a prior close/loss.
4253  const { note: _n, restored } = _applyDraftToNote({ note_type: type }, '__new__');
4254  const form = _buildForm(_n);
4255  form.classList.add('note-form-new');
4256  body.prepend(form);
4257  form.querySelector('.note-form-title').focus();
4258  if (restored) uiModule.showToast('Restored unsaved note');
4259}
4260
4261// Build the plain-text/markdown form of a note for clipboard copy.
4262function _serializeNoteForCopy(note) {
4263  const lines = [];
4264  if (note.title) lines.push(note.title);
4265  if (note.content) lines.push(note.content);
4266  if (Array.isArray(note.items) && note.items.length) {
4267    if (lines.length) lines.push('');
4268    for (const it of note.items) {
4269      if (!it || !(it.text || '').trim()) continue;
4270      lines.push(`- [${it.done ? 'x' : ' '}] ${(it.text || '').trim()}`);
4271    }
4272  }
4273  return lines.join('\n').trim();
4274}
4275
4276// Copy a note to the clipboard, briefly swap btnEl's icon to a checkmark, and
4277// toast. Shared by the corner-copy button click and the Ctrl/Cmd+C shortcut.
4278// ── ⋯ corner menu (Copy + Agent) ───────────────────────────────────
4279function _openNoteCornerMenu(btn) {
4280  document.querySelectorAll('.note-corner-menu-dropdown').forEach(d => d.remove());
4281  const id = btn.dataset.noteId;
4282  const note = _notes.find(n => n.id === id);
4283  if (!note) return;
4284  const menu = document.createElement('div');
4285  menu.className = 'note-corner-menu-dropdown';
4286  menu.innerHTML = `
4287    <button type="button" class="ncm-item" data-act="copy">
4288      <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
4289      <span>Copy</span>
4290    </button>
4291    <button type="button" class="ncm-item" data-act="agent">
4292      <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M2 14h2M20 14h2M15 13v2M9 13v2"/></svg>
4293      <span>${note.agent_session_id ? 'Re-run agent' : 'Agent: solve this'}</span>
4294    </button>`;
4295  document.body.appendChild(menu);
4296  const r = btn.getBoundingClientRect();
4297  // Right-align to the ⋯ button, clamped to the viewport.
4298  const mw = 168;
4299  let left = Math.min(r.right - mw, window.innerWidth - mw - 8);
4300  left = Math.max(8, left);
4301  // Drop down by default; flip up if there isn't room below (the button
4302  // sits at the card's bottom edge now).
4303  const mh = menu.offsetHeight || 96;
4304  const below = window.innerHeight - r.bottom;
4305  const top = (below < mh + 8 && r.top > mh + 8) ? (r.top - mh - 4) : (r.bottom + 4);
4306  menu.style.cssText += `position:fixed;z-index:11000;top:${Math.round(top)}px;left:${Math.round(left)}px;`;
4307  const close = (ev) => {
4308    if (ev && menu.contains(ev.target)) return;
4309    menu.remove();
4310    document.removeEventListener('click', close, true);
4311  };
4312  setTimeout(() => document.addEventListener('click', close, true), 0);
4313  menu.querySelector('[data-act="copy"]').addEventListener('click', () => { menu.remove(); _copyNote(id, btn); });
4314  menu.querySelector('[data-act="agent"]').addEventListener('click', () => { menu.remove(); _agentSolveNote(id); });
4315}
4316
4317// Build the prompt the agent gets from a note: title + body, plus any
4318// not-yet-done checklist items.
4319function _noteToAgentPrompt(note) {
4320  const parts = [];
4321  if ((note.title || '').trim()) parts.push(note.title.trim());
4322  if ((note.content || '').trim()) parts.push(note.content.trim());
4323  if (Array.isArray(note.items)) {
4324    note.items.filter(it => !it.done && (it.text || '').trim())
4325      .forEach(it => parts.push('- ' + it.text.trim()));
4326  }
4327  const body = parts.join('\n');
4328  return body ? `Help me get this done:\n\n${body}` : '';
4329}
4330
4331// Agent-solve: create a chat session server-side, kick off an agent run
4332// on it IN THE BACKGROUND (the user stays in notes), and link the session
4333// to the note via a clickable tag. Tapping the tag later opens the chat.
4334async function _agentSolveNote(id) {
4335  const note = _notes.find(n => n.id === id);
4336  if (!note) return;
4337  const prompt = _noteToAgentPrompt(note);
4338  if (!prompt) { uiModule.showToast('Nothing to solve — note is empty'); return; }
4339  try {
4340    const dc = await (await fetch(`${API_BASE}/api/default-chat`, { credentials: 'same-origin' })).json();
4341    if (!dc.endpoint_url || !dc.model) { uiModule.showError('No default chat model configured'); return; }
4342
4343    // 1. Create the session server-side (no UI switch). skip_validation
4344    //    avoids re-probing — the default-chat endpoint is already known good.
4345    const label = (note.title || (Array.isArray(note.items) && note.items[0]?.text) || 'todo').slice(0, 40);
4346    const csFd = new FormData();
4347    csFd.append('name', 'Agent: ' + label);
4348    csFd.append('endpoint_url', dc.endpoint_url);
4349    csFd.append('model', dc.model);
4350    if (dc.endpoint_id) csFd.append('endpoint_id', dc.endpoint_id);
4351    csFd.append('skip_validation', 'true');
4352    const csRes = await fetch(`${API_BASE}/api/session`, { method: 'POST', credentials: 'same-origin', body: csFd });
4353    if (!csRes.ok) { uiModule.showError('Could not create agent session'); return; }
4354    const sess = await csRes.json();
4355    const sid = sess.id;
4356
4357    // 2. Link the session to the note right away so the tag appears.
4358    const n = _notes.find(x => x.id === id);
4359    if (n) n.agent_session_id = sid;
4360    _renderNotes();
4361    _patchNote(id, { agent_session_id: sid }).catch(() => {});
4362
4363    // 3. Kick off the agent run in the background. POST to chat_stream in
4364    //    agent mode and drain the SSE so the server runs the loop to
4365    //    completion + saves — without rendering anything in the chat UI.
4366    const fd = new FormData();
4367    fd.append('message', prompt);
4368    fd.append('session', sid);
4369    fd.append('mode', 'agent');
4370    fetch(`${API_BASE}/api/chat_stream`, { method: 'POST', credentials: 'same-origin', body: fd })
4371      .then(async (res) => {
4372        if (!res.ok || !res.body) return;
4373        const reader = res.body.getReader();
4374        // Drain to completion (server finishes + persists the run).
4375        while (true) { const { done } = await reader.read(); if (done) break; }
4376        if (window.sessionModule && window.sessionModule.markStreamComplete) {
4377          try { window.sessionModule.markStreamComplete(sid); } catch {}
4378        }
4379      })
4380      .catch(() => {});
4381
4382    uiModule.showToast('Agent working in background — tap the Agent tag when ready');
4383  } catch (e) {
4384    uiModule.showError('Agent failed: ' + (e.message || e));
4385  }
4386}
4387
4388async function _copyNote(noteId, btnEl) {
4389  const note = _notes.find(n => n.id === noteId);
4390  if (!note) return false;
4391  const text = _serializeNoteForCopy(note);
4392  if (!text) return false;
4393  let ok = false;
4394  try {
4395    await navigator.clipboard.writeText(text);
4396    ok = true;
4397  } catch {
4398    const ta = document.createElement('textarea');
4399    ta.value = text;
4400    ta.style.position = 'fixed'; ta.style.left = '-9999px';
4401    document.body.appendChild(ta);
4402    ta.select();
4403    try { ok = document.execCommand('copy'); } catch { ok = false; }
4404    ta.remove();
4405  }
4406  if (ok) {
4407    if (btnEl && !btnEl._copyFlashing) {
4408      const original = btnEl.innerHTML;
4409      btnEl._copyFlashing = true;
4410      btnEl.innerHTML = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
4411      btnEl.classList.add('copied');
4412      setTimeout(() => {
4413        btnEl.innerHTML = original;
4414        btnEl.classList.remove('copied');
4415        btnEl._copyFlashing = false;
4416      }, 1200);
4417    }
4418    uiModule.showToast?.('Copied');
4419  } else {
4420    uiModule.showError?.('Copy failed');
4421  }
4422  return ok;
4423}
4424
4425function _editNote(id) {
4426  const note = _notes.find(n => n.id === id);
4427  if (!note) return;
4428  _editingId = id;
4429  const card = document.querySelector(`.note-card[data-note-id="${id}"]`);
4430  if (!card) return;
4431  // Restore an unsaved draft (from a prior connection loss / close) over
4432  // the saved note so the user picks up where they left off.
4433  const { note: _n, restored } = _applyDraftToNote(note, id);
4434  const form = _buildForm(_n);
4435  card.replaceWith(form);
4436  if (restored) uiModule.showToast('Restored unsaved changes');
4437  // Pinned notes live in the first masonry column — the edit form has
4438  // column-span:all, which can leave the form rendered above the fold or
4439  // visually buried under neighboring pinned cards. Bring it into view
4440  // (and onto a higher stacking context) so editing a pinned note always
4441  // pops to the top.
4442  form.style.position = 'relative';
4443  form.style.zIndex = '5';
4444  // Grid view: the form keeps the CSS default `grid-row-end: span 16` (64px)
4445  // after replaceWith, which is much shorter than the actual form. Recompute
4446  // masonry so the form gets the correct row span and cards below stop
4447  // overlapping it. ResizeObserver inside _applyMasonry keeps it in sync as
4448  // the user types / adds checklist items.
4449  const _body = form.closest('.notes-pane-body');
4450  if (_body) {
4451    _applyMasonry(_body);
4452    requestAnimationFrame(() => _applyMasonry(_body));
4453  }
4454  requestAnimationFrame(() => {
4455    try { form.scrollIntoView({ block: 'center', behavior: 'smooth' }); }
4456    catch { form.scrollIntoView(); }
4457  });
4458  // Pick the most useful field to focus. On phones especially, the user
4459  // taps Edit to type — landing in the title when there's already a title
4460  // (and likely a body to extend) loses momentum. Prefer the body textarea
4461  // for plain notes, the first checklist item for todos, fall back to title.
4462  const _focusBest = () => {
4463    if (note.note_type === 'note' || !note.note_type) {
4464      const ta = form.querySelector('.note-form-content');
4465      if (ta) { ta.focus(); try { ta.setSelectionRange(ta.value.length, ta.value.length); } catch {} return; }
4466    }
4467    if (note.note_type === 'todo' || note.note_type === 'goal' || note.note_type === 'checklist') {
4468      // Last non-empty checklist row, or the first row if all empty.
4469      const rows = form.querySelectorAll('.note-cl-row .note-cl-text');
4470      let target = null;
4471      for (const inp of rows) { if ((inp.value || '').trim()) target = inp; }
4472      target = target || rows[0];
4473      if (target) { target.focus(); try { target.setSelectionRange(target.value.length, target.value.length); } catch {} return; }
4474    }
4475    const titleEl = form.querySelector('.note-form-title');
4476    if (titleEl) titleEl.focus();
4477  };
4478  _focusBest();
4479}
4480
4481async function _deleteNote(id) {
4482  const ok = uiModule?.styledConfirm
4483    ? await uiModule.styledConfirm('Delete this note?', { confirmText: 'Delete', danger: true })
4484    : confirm('Delete this note?');
4485  if (!ok) return;
4486  try { await _deleteNoteApi(id); await _fetchNotes(); _renderNotes(); uiModule.showToast('Deleted'); }
4487  catch (err) { uiModule.showError(err.message); }
4488}
4489
4490// ────────────────────────────────────────────────────────────────────
4491// MOBILE NOTES UX — fullscreen tap-to-edit + long-press drag-to-reorder.
4492// On a touch device ≤768px wide, note tiles become read-only previews;
4493// a single tap opens the note in a full-bleed overlay (where all real
4494// editing happens), and a long-press flips the whole grid into
4495// rearrange mode where tiles can be dragged to a new sort_order.
4496// ────────────────────────────────────────────────────────────────────
4497
4498function _isNotesMobileMode() {
4499  return ('ontouchstart' in window) && window.innerWidth <= 768;
4500}
4501
4502// ── Fullscreen single-note edit overlay ──────────────────────────────
4503let _mobileFsOverlay = null;
4504let _mobileFsNoteId = null;
4505
4506function _openMobileFullscreenEdit(id, fromCard) {
4507  const note = _notes.find(n => n.id === id);
4508  if (!note) return;
4509  // Tear down any previous overlay (defensive).
4510  _closeMobileFullscreenEdit({ save: false });
4511  _mobileFsNoteId = id;
4512  _editingId = id;
4513
4514  const overlay = document.createElement('div');
4515  overlay.className = 'note-fullscreen-overlay';
4516  overlay.innerHTML = `
4517    <div class="note-fullscreen-header">
4518      <button type="button" class="note-fullscreen-back" title="Back">
4519        <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"></polyline></svg>
4520      </button>
4521      <div class="note-fullscreen-actions"></div>
4522    </div>
4523    <div class="note-fullscreen-body"></div>
4524  `;
4525  const body = overlay.querySelector('.note-fullscreen-body');
4526  // Reuse the same edit form the in-place flow builds. Save buttons,
4527  // checklist toggles, etc. all work as-is. Restore any unsaved draft.
4528  const { note: _n, restored } = _applyDraftToNote(note, id);
4529  const form = _buildForm(_n);
4530  body.appendChild(form);
4531  if (restored) uiModule.showToast('Restored unsaved changes');
4532  document.body.appendChild(overlay);
4533  _mobileFsOverlay = overlay;
4534
4535  // Animate up from the tapped tile's position so the transition reads
4536  // as a zoom rather than a hard cut.
4537  if (fromCard) {
4538    const r = fromCard.getBoundingClientRect();
4539    const vw = window.innerWidth, vh = window.innerHeight;
4540    overlay.style.transformOrigin =
4541      `${((r.left + r.width / 2) / vw) * 100}% ${((r.top + r.height / 2) / vh) * 100}%`;
4542  }
4543  overlay.classList.add('opening');
4544  requestAnimationFrame(() => overlay.classList.add('open'));
4545
4546  // Wire the back button — saves whatever the form has and closes.
4547  // mousedown preventDefault so it doesn't blur the input on first tap (which
4548  // would eat the tap and require a second one).
4549  const _backBtn = overlay.querySelector('.note-fullscreen-back');
4550  _backBtn.addEventListener('mousedown', (e) => e.preventDefault());
4551  _backBtn.addEventListener('click', () => {
4552    _closeMobileFullscreenEdit({ save: true });
4553  });
4554
4555  // The form's built-in Cancel only resets the in-place edit state; in
4556  // the overlay context it does nothing visible. Replace its handler so
4557  // Cancel actually dismisses the overlay without saving.
4558  const cancelBtn = form.querySelector('.note-form-cancel');
4559  if (cancelBtn) {
4560    const fresh = cancelBtn.cloneNode(true);
4561    cancelBtn.parentNode.replaceChild(fresh, cancelBtn);
4562    fresh.addEventListener('mousedown', (e) => e.preventDefault());
4563    fresh.addEventListener('click', (e) => {
4564      e.preventDefault();
4565      e.stopPropagation();
4566      _closeMobileFullscreenEdit({ save: false });
4567    });
4568  }
4569  // The built-in Save handler does the API call + refresh, but doesn't
4570  // dismiss our overlay. Augment it (do NOT replace — the original is
4571  // async and we'd lose the API call) to schedule a close once the
4572  // save+render has had time to complete.
4573  const saveBtn = form.querySelector('.note-form-save');
4574  if (saveBtn) {
4575    saveBtn.addEventListener('click', () => {
4576      setTimeout(() => _closeMobileFullscreenEdit({ save: false }), 350);
4577    });
4578  }
4579  // Make the checklist row drag handle (⋮⋮) actually work on touch.
4580  // The form's default uses HTML5 native draggable which never fires
4581  // on iOS/Android. Wire touch-based reorder for any row inside the
4582  // overlay's checklist.
4583  _wireChecklistTouchReorder(form);
4584
4585  // For each todo row whose text contains a URL, swap the bare <input>
4586  // for a linkified <span> so URLs are tappable. Tapping anywhere that
4587  // ISN'T a link flips back to the input for editing.
4588  form.querySelectorAll('.note-cl-row').forEach(_addRowReadMode);
4589
4590  // Move Archive + Delete from the form's footer actions row up into
4591  // the header bar (to the right of the back chevron) so they're
4592  // always reachable without scrolling and free up the footer for
4593  // Cancel/Save only. Handlers stay attached when nodes move.
4594  const headerActions = overlay.querySelector('.note-fullscreen-actions');
4595  const archiveBtn = form.querySelector('.note-form-archive-btn');
4596  const deleteBtn  = form.querySelector('.note-form-delete-btn');
4597  if (headerActions && archiveBtn) headerActions.appendChild(archiveBtn);
4598  if (headerActions && deleteBtn)  headerActions.appendChild(deleteBtn);
4599  // The built-in archive/delete handlers re-render the notes grid but
4600  // leave THIS overlay sitting in front of it — looks like nothing
4601  // happened. Add follow-up listeners that close the overlay so the
4602  // user sees the action take effect.
4603  if (archiveBtn) {
4604    archiveBtn.addEventListener('click', () => {
4605      setTimeout(() => _closeMobileFullscreenEdit({ save: false }), 200);
4606    });
4607  }
4608  if (deleteBtn) {
4609    deleteBtn.addEventListener('click', () => {
4610      // Delete shows a styled confirm — give it room to resolve before
4611      // we try to dismiss the overlay.
4612      setTimeout(() => _closeMobileFullscreenEdit({ save: false }), 500);
4613    });
4614  }
4615
4616  // Tuck the tags input into the bottom actions row (Cancel / Update),
4617  // pinned to the LEFT. Frees the meta row of an extra wrapping line
4618  // and groups all the "exit" controls together.
4619  const actionsGroup = form.querySelector('.note-form-actions-group');
4620  const tagsInput    = form.querySelector('.note-form-label');
4621  if (actionsGroup && tagsInput) {
4622    actionsGroup.insertBefore(tagsInput, actionsGroup.firstChild);
4623  }
4624
4625  // For checklist-type notes, move the photo (attach image) button into
4626  // the same row as the + Add button (right side) — keeps the meta row
4627  // tidy and puts the camera within thumb-reach of the active edit.
4628  const addBtn   = form.querySelector('.note-cl-add');
4629  const photoBtn = form.querySelector('.note-form-photo-btn');
4630  if (addBtn && photoBtn) {
4631    const addRow = document.createElement('div');
4632    addRow.className = 'note-cl-add-row';
4633    addBtn.parentNode.insertBefore(addRow, addBtn);
4634    addRow.appendChild(addBtn);
4635    addRow.appendChild(photoBtn);
4636    // Tapping anywhere on the row (the empty gap, the dashed border,
4637    // the "+ Add" label) triggers add. The photo button keeps its own
4638    // click target so attach-image isn't ambushed.
4639    addRow.addEventListener('click', (e) => {
4640      if (e.target.closest('.note-form-photo-btn')) return;
4641      if (e.target === addBtn || addBtn.contains(e.target)) return;
4642      addBtn.click();
4643    });
4644    // The form's delegated "+ Add" handler does
4645    //   inputs.insertBefore(newRow, addBtn)
4646    // — but addBtn is no longer a direct child of `.note-checklist-inputs`
4647    // now that we wrapped it. Bind a fresh handler that does the same
4648    // thing but inserts before the WRAPPING row, and stop propagation
4649    // so the broken delegate never runs.
4650    addBtn.addEventListener('click', (e) => {
4651      e.preventDefault();
4652      e.stopPropagation();
4653      const inputs = form.querySelector('.note-checklist-inputs');
4654      if (!inputs) return;
4655      const newRow = document.createElement('div');
4656      newRow.className = 'note-cl-row';
4657      newRow.draggable = true;
4658      newRow.dataset.itemId = _uid();
4659      newRow.dataset.indent = '0';
4660      newRow.innerHTML = '<span class="note-cl-grip" title="Drag">⋮⋮</span><span class="note-cl-dot"></span><input type="text" class="note-cl-text" placeholder="Item..." /><button type="button" class="note-cl-rm">&times;</button>';
4661      inputs.insertBefore(newRow, addRow);
4662      _wireRow(newRow, inputs);
4663      // Touch reorder on the freshly-added row's grip.
4664      _wireChecklistTouchReorder(form);
4665      newRow.querySelector('.note-cl-text')?.focus();
4666    }, { capture: true });
4667  }
4668
4669  // Read-mode overlay for plain notes: render the content as a div with
4670  // clickable hyperlinks, layered above the textarea. Tapping anywhere
4671  // in the overlay that ISN'T a link hides the overlay and focuses the
4672  // textarea so the user can start editing. Tapping a link opens it.
4673  const ta = form.querySelector('.note-form-content');
4674  if (ta && (note.content || '').trim()) {
4675    const reader = document.createElement('div');
4676    reader.className = 'note-form-content-reader';
4677    reader.innerHTML = _linkify(note.content || '');
4678    ta.style.display = 'none';
4679    ta.insertAdjacentElement('beforebegin', reader);
4680    reader.addEventListener('click', (e) => {
4681      if (e.target.closest('a')) return;  // let links open normally
4682      reader.remove();
4683      ta.style.display = '';
4684      // Let the browser place the cursor naturally — forcing
4685      // setSelectionRange right after focus() raced with the underlying
4686      // tap event and produced inconsistent cursor positions on mobile.
4687      ta.focus({ preventScroll: true });
4688    });
4689  }
4690
4691  // Opening an EXISTING note → read mode, no keyboard pop. Only a
4692  // brand-new note (created via the + button) should auto-focus an
4693  // input field. The user can tap the content to switch to edit.
4694  // (New-note creation flows through _createNote, not this function.)
4695}
4696
4697function _closeMobileFullscreenEdit(opts = {}) {
4698  if (!_mobileFsOverlay) return;
4699  const overlay = _mobileFsOverlay;
4700  _mobileFsOverlay = null;
4701  // If the form has a Save button, click it on close so edits aren't lost
4702  // when the user uses the back arrow instead of an explicit Save.
4703  if (opts.save) {
4704    const saveBtn = overlay.querySelector('.note-form-save, [data-action="save"]');
4705    if (saveBtn) try { saveBtn.click(); } catch {}
4706  }
4707  overlay.classList.remove('open');
4708  overlay.classList.add('closing');
4709  setTimeout(() => {
4710    if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
4711    _mobileFsNoteId = null;
4712    _editingId = null;
4713    // Refresh the grid so any save the user made is reflected.
4714    if (opts.save !== false) { _fetchNotes().then(_renderNotes).catch(() => {}); }
4715  }, 220);
4716}
4717
4718// ── Long-press drag-to-reorder ───────────────────────────────────────
4719function _bindLongPressDrag(card) {
4720  let pressTimer = null;
4721  let startX = 0, startY = 0;
4722  let armed = false;
4723  const CANCEL_PX = 8;
4724  const HOLD_MS = 450;
4725
4726  card.addEventListener('touchstart', (e) => {
4727    // Don't fight scroll on touchpoints over real interactive children
4728    // (in mobile-mode they're CSS-hidden anyway, but be defensive).
4729    if (e.target.closest('button, input, a, .note-form')) return;
4730    if (e.touches.length !== 1) return;
4731    armed = true;
4732    startX = e.touches[0].clientX;
4733    startY = e.touches[0].clientY;
4734    // Capture the touch object so the timer callback can pass it to
4735    // _enterDragMode → _beginGrab. The finger is still held down, so
4736    // the drag starts the instant the timer fires.
4737    const heldTouch = { clientX: startX, clientY: startY };
4738    pressTimer = setTimeout(() => {
4739      if (!armed) return;
4740      try { navigator.vibrate?.(15); } catch {}
4741      _enterDragMode(card, heldTouch);
4742    }, HOLD_MS);
4743  }, { passive: true });
4744  card.addEventListener('touchmove', (e) => {
4745    if (!armed) return;
4746    const t = e.touches[0];
4747    if (Math.abs(t.clientX - startX) > CANCEL_PX || Math.abs(t.clientY - startY) > CANCEL_PX) {
4748      armed = false;
4749      if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; }
4750    }
4751  }, { passive: true });
4752  const cancel = () => {
4753    armed = false;
4754    if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; }
4755  };
4756  card.addEventListener('touchend', cancel, { passive: true });
4757  card.addEventListener('touchcancel', cancel, { passive: true });
4758}
4759
4760// Lift-and-placeholder drag implementation. The dragged card detaches
4761// from the grid (position:fixed, anchored to the finger) while a same-
4762// sized placeholder takes its slot. Only the PLACEHOLDER moves between
4763// siblings as the finger crosses midpoints — the card never re-parents
4764// during the drag, which eliminates the oscillation/jumping the
4765// previous swap-on-every-frame implementation had.
4766
4767let _dragState = null;          // { card, placeholder, grabOffsetX, grabOffsetY, grid, prevStyle }
4768let _docDragHandlersBound = false;
4769
4770function _enterDragMode(initialCard, initialTouch) {
4771  document.body.classList.add('notes-drag-mode');
4772  document.querySelectorAll('.note-card').forEach(_setupDragForCard);
4773  if (!_docDragHandlersBound) {
4774    document.addEventListener('touchmove', _onDocTouchMove, { passive: false });
4775    document.addEventListener('touchend',  _onDocTouchEnd,  { passive: true });
4776    document.addEventListener('touchcancel', _onDocTouchEnd, { passive: true });
4777    _docDragHandlersBound = true;
4778  }
4779  // Auto-grab the card the user long-pressed — they're already holding
4780  // their finger on it, so begin the drag straight away. Releasing
4781  // (touchend) commits the reorder AND exits drag mode in one motion.
4782  if (initialCard && initialTouch) {
4783    _beginGrab(initialCard, initialTouch);
4784  }
4785}
4786
4787function _exitDragMode() {
4788  document.body.classList.remove('notes-drag-mode');
4789  if (_dragState) {
4790    // Defensive: if exit fires while a drag is in flight, snap the card back.
4791    _onDocTouchEnd();
4792  }
4793  // Leave _docDragHandlersBound true so re-entering drag mode reuses them.
4794}
4795
4796function _setupDragForCard(card) {
4797  if (card.dataset.dragBound === '1') return;
4798  card.dataset.dragBound = '1';
4799  card.addEventListener('touchstart', (e) => {
4800    if (!document.body.classList.contains('notes-drag-mode')) return;
4801    if (e.touches.length !== 1) return;
4802    if (_dragState) return;
4803    e.preventDefault();
4804    e.stopPropagation();
4805    _beginGrab(card, e.touches[0]);
4806  }, { passive: false });
4807}
4808
4809function _beginGrab(card, touch) {
4810  const rect = card.getBoundingClientRect();
4811  const prevStyle = card.getAttribute('style') || '';
4812  // Placeholder fills the card's old slot so the grid layout doesn't reflow.
4813  const placeholder = document.createElement('div');
4814  placeholder.className = 'note-card-placeholder';
4815  placeholder.style.width = rect.width + 'px';
4816  placeholder.style.height = rect.height + 'px';
4817  placeholder.style.margin = getComputedStyle(card).margin;
4818  if (card.style.gridRowEnd) placeholder.style.gridRowEnd = card.style.gridRowEnd;
4819  const grid = card.parentNode;
4820  grid.insertBefore(placeholder, card);
4821
4822  // Detach the card visually — fixed-position, anchored to the finger.
4823  card.classList.add('note-card-dragging');
4824  card.style.position = 'fixed';
4825  card.style.left = rect.left + 'px';
4826  card.style.top  = rect.top + 'px';
4827  card.style.width  = rect.width + 'px';
4828  card.style.height = rect.height + 'px';
4829  card.style.margin = '0';
4830  card.style.zIndex = '10001';
4831  // pointer-events:none so elementFromPoint sees the card BENEATH the finger
4832  card.style.pointerEvents = 'none';
4833
4834  _dragState = {
4835    card, placeholder, grid, prevStyle,
4836    grabOffsetX: touch.clientX - rect.left,
4837    grabOffsetY: touch.clientY - rect.top,
4838  };
4839  try { navigator.vibrate?.(8); } catch {}
4840}
4841
4842function _onDocTouchMove(e) {
4843  if (!_dragState) return;
4844  if (e.touches.length !== 1) return;
4845  e.preventDefault();
4846  const touch = e.touches[0];
4847  const { card, placeholder, grid } = _dragState;
4848  card.style.left = (touch.clientX - _dragState.grabOffsetX) + 'px';
4849  const quickAdd = grid.querySelector('.notes-quick-add');
4850  const minTop = quickAdd ? quickAdd.getBoundingClientRect().bottom + 4 : grid.getBoundingClientRect().top;
4851  const maxTop = Math.max(minTop, window.innerHeight - card.getBoundingClientRect().height - 8);
4852  const nextTop = Math.max(minTop, Math.min(maxTop, touch.clientY - _dragState.grabOffsetY));
4853  card.style.top = nextTop + 'px';
4854
4855  const hitY = Math.max(minTop + 1, Math.min(window.innerHeight - 1, touch.clientY));
4856  const under = document.elementFromPoint(touch.clientX, hitY);
4857  const target = under && under.closest
4858    ? under.closest('.note-card:not(.note-card-dragging)')
4859    : null;
4860  if (!target || target === card) return;
4861  if (target.parentNode !== grid) return;
4862
4863  // Move the PLACEHOLDER (not the card) above or below the target depending
4864  // on which half of the target the finger is in. This is the hysteresis
4865  // that stops the oscillation — once the placeholder moves past a card,
4866  // the cursor has to cross THAT card's midpoint in the other direction
4867  // to swap back.
4868  const tRect = target.getBoundingClientRect();
4869  const targetMidY = tRect.top + tRect.height / 2;
4870  if (touch.clientY < targetMidY) {
4871    if (placeholder.nextElementSibling !== target) {
4872      grid.insertBefore(placeholder, target);
4873    }
4874  } else {
4875    if (target.nextElementSibling !== placeholder) {
4876      grid.insertBefore(placeholder, target.nextElementSibling);
4877    }
4878  }
4879}
4880
4881function _onDocTouchEnd() {
4882  if (!_dragState) return;
4883  const { card, placeholder, grid, prevStyle } = _dragState;
4884  _dragState = null;
4885  // Animate the card from its current fixed position to where the
4886  // placeholder sits, then re-parent and clear inline styles. Drag
4887  // mode auto-exits once the snap finishes — release = done.
4888  const phRect = placeholder.getBoundingClientRect();
4889  card.style.transition = 'left 0.2s ease, top 0.2s ease';
4890  card.style.left = phRect.left + 'px';
4891  card.style.top  = phRect.top + 'px';
4892  setTimeout(() => {
4893    placeholder.parentNode.insertBefore(card, placeholder);
4894    placeholder.remove();
4895    card.classList.remove('note-card-dragging');
4896    // Restore the card's pre-drag inline styles. Mobile masonry stores
4897    // grid-row-end inline, and custom backgrounds use inline style too; wiping
4898    // cssText made dropped cards collapse into neighboring notes in grid view.
4899    if (prevStyle) card.setAttribute('style', prevStyle);
4900    else card.removeAttribute('style');
4901    _applyMasonry(grid);
4902    _commitNoteReorder();
4903    // One drag, one exit — release ends the rearrange session entirely.
4904    if (document.body.classList.contains('notes-drag-mode')) {
4905      document.body.classList.remove('notes-drag-mode');
4906    }
4907  }, 210);
4908}
4909
4910// Per-row read mode for todo items — replaces the plain <input> with
4911// a linkified <span> when the value contains a URL, so tapping a link
4912// opens it instead of just placing the caret. Tapping non-link area
4913// restores the input for editing.
4914function _addRowReadMode(row) {
4915  const txt = row.querySelector('.note-cl-text');
4916  if (!txt) return;
4917  const val = txt.value || '';
4918  if (!/(https?:\/\/|www\.)/i.test(val)) return;
4919  if (row.querySelector('.note-cl-text-reader')) return;  // already wired
4920  const span = document.createElement('span');
4921  span.className = 'note-cl-text-reader';
4922  span.innerHTML = _linkify(val);
4923  txt.style.display = 'none';
4924  txt.insertAdjacentElement('beforebegin', span);
4925  span.addEventListener('click', (e) => {
4926    if (e.target.closest('a')) return;  // let the link open
4927    span.remove();
4928    txt.style.display = '';
4929    txt.focus({ preventScroll: true });
4930  });
4931}
4932
4933// ── Checklist row reorder via touch (inside fullscreen edit) ────────
4934// The default checklist drag uses HTML5 `draggable="true"`, which is
4935// desktop-mouse-only. Wire touch handlers on each `.note-cl-grip` so
4936// the user can long-grab a row and drag it to a new position in the
4937// checklist. Uses the same lift-and-placeholder pattern as the card
4938// drag (no oscillation when hovering between siblings).
4939
4940let _clDrag = null;          // { row, placeholder, container, grabOffsetX, grabOffsetY }
4941let _clDocBound = false;
4942
4943function _wireChecklistTouchReorder(form) {
4944  const container = form.querySelector('.note-checklist-inputs');
4945  if (!container) return;
4946  container.querySelectorAll('.note-cl-grip').forEach(grip => {
4947    if (grip.dataset.touchBound === '1') return;
4948    grip.dataset.touchBound = '1';
4949    grip.addEventListener('touchstart', (e) => {
4950      if (e.touches.length !== 1) return;
4951      e.preventDefault();
4952      const row = grip.closest('.note-cl-row');
4953      if (!row) return;
4954      _beginChecklistGrab(row, container, e.touches[0]);
4955    }, { passive: false });
4956  });
4957  if (!_clDocBound) {
4958    document.addEventListener('touchmove', _onClTouchMove, { passive: false });
4959    document.addEventListener('touchend',  _onClTouchEnd,  { passive: true });
4960    document.addEventListener('touchcancel', _onClTouchEnd, { passive: true });
4961    _clDocBound = true;
4962  }
4963}
4964
4965function _beginChecklistGrab(row, container, touch) {
4966  if (_clDrag) return;
4967  const rect = row.getBoundingClientRect();
4968  const placeholder = document.createElement('div');
4969  placeholder.className = 'note-cl-row-placeholder';
4970  placeholder.style.height = rect.height + 'px';
4971  container.insertBefore(placeholder, row);
4972
4973  row.classList.add('note-cl-row-dragging');
4974  row.style.position = 'fixed';
4975  row.style.left = rect.left + 'px';
4976  row.style.top  = rect.top + 'px';
4977  row.style.width = rect.width + 'px';
4978  row.style.zIndex = '10002';
4979  row.style.pointerEvents = 'none';
4980
4981  _clDrag = {
4982    row, placeholder, container,
4983    grabOffsetX: touch.clientX - rect.left,
4984    grabOffsetY: touch.clientY - rect.top,
4985  };
4986  try { navigator.vibrate?.(8); } catch {}
4987}
4988
4989function _onClTouchMove(e) {
4990  if (!_clDrag) return;
4991  if (e.touches.length !== 1) return;
4992  e.preventDefault();
4993  const t = e.touches[0];
4994  const { row, placeholder, container } = _clDrag;
4995  row.style.left = (t.clientX - _clDrag.grabOffsetX) + 'px';
4996  row.style.top  = (t.clientY - _clDrag.grabOffsetY) + 'px';
4997
4998  const under = document.elementFromPoint(t.clientX, t.clientY);
4999  const target = under && under.closest
5000    ? under.closest('.note-cl-row:not(.note-cl-row-dragging)')
5001    : null;
5002  if (!target || target === row) return;
5003  if (target.parentNode !== container) return;
5004
5005  const tRect = target.getBoundingClientRect();
5006  const targetMidY = tRect.top + tRect.height / 2;
5007  if (t.clientY < targetMidY) {
5008    if (placeholder.nextElementSibling !== target) {
5009      container.insertBefore(placeholder, target);
5010    }
5011  } else {
5012    if (target.nextElementSibling !== placeholder) {
5013      container.insertBefore(placeholder, target.nextElementSibling);
5014    }
5015  }
5016}
5017
5018function _onClTouchEnd() {
5019  if (!_clDrag) return;
5020  const { row, placeholder } = _clDrag;
5021  _clDrag = null;
5022  const phRect = placeholder.getBoundingClientRect();
5023  row.style.transition = 'left 0.18s ease, top 0.18s ease';
5024  row.style.left = phRect.left + 'px';
5025  row.style.top  = phRect.top + 'px';
5026  setTimeout(() => {
5027    placeholder.parentNode.insertBefore(row, placeholder);
5028    placeholder.remove();
5029    row.classList.remove('note-cl-row-dragging');
5030    row.style.cssText = '';
5031    // Order is persisted as part of the form's normal save (rows are
5032    // re-serialized in DOM order via _collectItems).
5033  }, 200);
5034}
5035
5036async function _commitNoteReorder() {
5037  const grid = document.querySelector('#notes-pane .notes-pane-body');
5038  if (!grid) return;
5039  const ids = Array.from(grid.querySelectorAll('.note-card')).map(c => c.dataset.noteId).filter(Boolean);
5040  if (!ids.length) return;
5041  try {
5042    await fetch(`${API_BASE}/api/notes/reorder`, {
5043      method: 'POST',
5044      credentials: 'same-origin',
5045      headers: { 'Content-Type': 'application/json' },
5046      body: JSON.stringify({ ids }),
5047    });
5048    // Update local sort_order so subsequent renders agree with the server.
5049    ids.forEach((nid, i) => {
5050      const n = _notes.find(nn => nn.id === nid);
5051      if (n) n.sort_order = i;
5052    });
5053  } catch (e) {
5054    console.warn('reorder failed', e);
5055  }
5056}
5057
5058
5059// Background reminder loop — runs whether panel is open or not
5060async function _initReminders() {
5061  try {
5062    const res = await fetch(`${API_BASE}/api/notes`, { credentials: 'same-origin' });
5063    if (res.ok) {
5064      const data = await res.json();
5065      _notes = data.notes || data || [];
5066      _startReminderLoop();
5067    }
5068  } catch {}
5069}
5070
5071// Open the notes panel and scroll/flash the matching note card. Used
5072// by chatRenderer.js when the user clicks a [View note](#note-<id>)
5073// link the agent emits after a manage_notes create. Falls back to
5074// just opening the panel when the card isn't found (panel still
5075// loading, note in a different filter, etc.).
5076async function openNote(noteId) {
5077  // If the panel is already open, openPanel() short-circuits and does
5078  // nothing — including no re-fetch — so a freshly-created note added
5079  // server-side never shows up. Force a refresh by closing first when
5080  // open, then re-opening. Clicking the sidebar Notes button as a
5081  // last resort keeps this working even if the module state got out
5082  // of sync (rare but seen during HMR or after a stuck modal).
5083  try {
5084    if (isPanelOpen && isPanelOpen()) {
5085      closePanel();
5086      // give the close animation a frame to settle
5087      await new Promise(r => setTimeout(r, 30));
5088    }
5089  } catch (_) {}
5090  openPanel();
5091  // openPanel() kicks off _fetchNotes() asynchronously, so the cards
5092  // for newly-created notes may not be in the DOM yet. Also poll the
5093  // _notes module array directly — if the note IS loaded but the
5094  // active filter (e.g. archive view) is hiding it, we can still
5095  // surface a confirmation toast.
5096  if (!noteId) return;
5097  let tries = 0;
5098  const findAndFlash = () => {
5099    const card = document.querySelector(`.note-card[data-note-id="${noteId}"]`)
5100      || document.querySelector(`.note-card[data-note-id^="${noteId.slice(0, 8)}"]`);
5101    if (card) {
5102      try { card.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (_) {}
5103      card.classList.add('note-card-flash');
5104      setTimeout(() => card.classList.remove('note-card-flash'), 1600);
5105      return true;
5106    }
5107    return false;
5108  };
5109  const tryNext = () => {
5110    if (findAndFlash()) return;
5111    if (++tries < 20) setTimeout(tryNext, 200);
5112  };
5113  setTimeout(tryNext, 120);
5114}
5115
5116const notesModule = { openPanel, closePanel, togglePanel, isPanelOpen, openNote, openNotes: openPanel, closeNotes: closePanel, isNotesOpen: isPanelOpen, refreshDueBadge };
5117export default notesModule;
5118export { openPanel as openNotes, closePanel as closeNotes, isPanelOpen as isNotesOpen, openNote };
5119window.notesModule = notesModule;
5120
5121// Start reminder loop on module load (after a short delay so app loads first)
5122if (typeof window !== 'undefined') {
5123  setTimeout(_initReminders, 3000);
5124}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.