granite-mem 0.1.7 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,50 +1,54 @@
1
1
  /**
2
2
  * Granite — App Controller
3
- *
4
- * Sidebar, HTML note rendering, wikilink previews,
5
- * note creation, graph toggle, keyboard navigation.
6
3
  */
7
-
8
4
  (function () {
9
5
  // ── State ──
10
6
  let allNotes = [];
11
7
  let currentNote = null;
12
8
  let currentType = '';
13
- let searchTimeout = null;
14
- let isSearchMode = false;
15
- let currentView = 'note'; // 'note' | 'graph'
16
- let isCreateOpen = false;
9
+ let currentView = 'note';
17
10
  let graphData = null;
18
11
  let previewCache = {};
19
12
  let previewTimeout = null;
20
- let keyboardIndex = -1;
13
+ let commandIndex = -1;
14
+ let commandResults = [];
15
+ let releaseTrap = null;
21
16
 
22
17
  // ── DOM ──
23
- const sidebar = document.getElementById('sidebar');
24
- const searchBox = document.getElementById('search-box');
25
- const typeFilters = document.getElementById('type-filters');
26
18
  const noteList = document.getElementById('note-list');
27
- const noteHeader = document.getElementById('note-header');
19
+ const typeFilters = document.getElementById('type-filters');
20
+ const noteView = document.getElementById('note-view');
21
+ const noteScroll = document.getElementById('note-scroll');
22
+ const noteTypeLine = document.getElementById('note-type-line');
28
23
  const noteTitle = document.getElementById('note-title');
29
- const noteBreadcrumb = document.getElementById('note-breadcrumb');
30
- const noteTypeBadge = document.getElementById('note-type-badge');
31
24
  const noteDate = document.getElementById('note-date');
32
25
  const noteTags = document.getElementById('note-tags');
33
26
  const noteBody = document.getElementById('note-body');
34
- const backlinksPanel = document.getElementById('backlinks-panel');
27
+ const backlinks = document.getElementById('backlinks');
35
28
  const backlinksList = document.getElementById('backlinks-list');
36
29
  const backlinksCount = document.getElementById('backlinks-count');
37
30
  const emptyState = document.getElementById('empty-state');
38
- const contentWrapper = document.getElementById('content-wrapper');
39
31
  const graphView = document.getElementById('graph-view');
40
32
  const graphCanvas = document.getElementById('graph-canvas');
41
- const createPanel = document.getElementById('create-panel');
42
33
  const previewEl = document.getElementById('wikilink-preview');
43
34
 
35
+ // Command bar
36
+ const commandOverlay = document.getElementById('command-bar-overlay');
37
+ const commandInput = document.getElementById('command-input');
38
+ const commandResultsEl = document.getElementById('command-results');
39
+
40
+ // Create
41
+ const createOverlay = document.getElementById('create-overlay');
42
+
44
43
  // ── API ──
45
44
  async function api(url, opts) {
46
- const res = await fetch(url, opts);
47
- return res.json();
45
+ try {
46
+ const res = await fetch(url, opts);
47
+ if (!res.ok) return { error: `HTTP ${res.status}` };
48
+ return res.json();
49
+ } catch (err) {
50
+ return { error: err.message };
51
+ }
48
52
  }
49
53
 
50
54
  // ── Init ──
@@ -52,176 +56,207 @@
52
56
  const { types } = await api('/api/types');
53
57
  for (const name of Object.keys(types)) {
54
58
  const btn = document.createElement('button');
55
- btn.className = 'type-chip';
59
+ btn.className = 'type-pill';
56
60
  btn.dataset.type = name;
57
61
  btn.textContent = name;
58
62
  btn.addEventListener('click', () => filterByType(name));
59
63
  typeFilters.appendChild(btn);
60
64
  }
65
+ typeFilters.querySelector('[data-type=""]').addEventListener('click', () => filterByType(''));
61
66
 
62
- // Populate create panel type selector
63
- const createTypeSelect = document.getElementById('create-type');
67
+ const createType = document.getElementById('create-type');
64
68
  for (const name of Object.keys(types)) {
65
69
  const opt = document.createElement('option');
66
70
  opt.value = name;
67
71
  opt.textContent = name;
68
- createTypeSelect.appendChild(opt);
72
+ createType.appendChild(opt);
69
73
  }
70
74
 
71
- typeFilters.querySelector('[data-type=""]').addEventListener('click', () => filterByType(''));
72
-
73
75
  await loadNotes();
74
- setupSearch();
75
76
  setupKeyboard();
76
- setupSidebar();
77
+ setupNav();
77
78
  setupCreate();
79
+ setupCommand();
78
80
  setupWikilinkPreviews();
81
+ initGrain();
82
+ }
83
+
84
+ // ═══ GRAIN TEXTURE (static, rendered once) ═══
85
+ function initGrain() {
86
+ const c = document.getElementById('grain');
87
+ const ctx = c.getContext('2d');
88
+ // Render a small 256x256 noise tile, CSS will repeat it
89
+ const size = 256;
90
+ c.width = size;
91
+ c.height = size;
92
+ c.style.width = '100%';
93
+ c.style.height = '100%';
94
+ c.style.imageRendering = 'auto';
95
+ const imageData = ctx.createImageData(size, size);
96
+ const data = imageData.data;
97
+ for (let i = 0; i < data.length; i += 4) {
98
+ const v = Math.random() * 255;
99
+ data[i] = v; data[i + 1] = v; data[i + 2] = v; data[i + 3] = 255;
100
+ }
101
+ ctx.putImageData(imageData, 0, 0);
79
102
  }
80
103
 
81
- // ═══════════════════════════════════
82
- // SEARCH
83
- // ═══════════════════════════════════
84
-
85
- function setupSearch() {
86
- searchBox.addEventListener('input', () => {
87
- clearTimeout(searchTimeout);
88
- searchTimeout = setTimeout(() => {
89
- const q = searchBox.value.trim();
90
- if (q.length > 0) {
91
- searchNotes(q);
92
- isSearchMode = true;
93
- } else {
94
- isSearchMode = false;
95
- renderNoteList(allNotes);
104
+ // ═══ COMMAND BAR ═══
105
+ function setupCommand() {
106
+ let debounce = null;
107
+ commandInput.addEventListener('input', () => {
108
+ clearTimeout(debounce);
109
+ debounce = setTimeout(async () => {
110
+ const q = commandInput.value.trim();
111
+ if (q.length === 0) {
112
+ // Show all notes
113
+ renderCommandResults(allNotes.map(n => ({
114
+ slug: n.slug, title: n.title, type: n.type, modified: n.modified,
115
+ })));
116
+ return;
96
117
  }
97
- keyboardIndex = -1;
98
- }, 180);
118
+ const data = await api(`/api/search?q=${encodeURIComponent(q)}`);
119
+ renderCommandResults(data.results.map(r => ({
120
+ slug: r.slug, title: r.title, snippet: r.snippet,
121
+ })));
122
+ }, 120);
99
123
  });
100
124
 
101
- searchBox.addEventListener('keydown', (e) => {
102
- if (e.key === 'Escape') {
103
- searchBox.value = '';
104
- searchBox.blur();
105
- isSearchMode = false;
106
- renderNoteList(allNotes);
107
- keyboardIndex = -1;
108
- }
125
+ commandInput.addEventListener('keydown', (e) => {
126
+ if (e.key === 'Escape') { closeCommand(); return; }
127
+ if (e.key === 'ArrowDown') { e.preventDefault(); navigateCommand(1); }
128
+ if (e.key === 'ArrowUp') { e.preventDefault(); navigateCommand(-1); }
109
129
  if (e.key === 'Enter') {
110
- const items = noteList.querySelectorAll('.note-item');
111
- const target = keyboardIndex >= 0 ? items[keyboardIndex] : items[0];
112
- if (target) target.click();
113
- }
114
- if (e.key === 'ArrowDown') {
115
130
  e.preventDefault();
116
- navigateList(1);
117
- }
118
- if (e.key === 'ArrowUp') {
119
- e.preventDefault();
120
- navigateList(-1);
131
+ const item = commandResults[commandIndex >= 0 ? commandIndex : 0];
132
+ if (item) { closeCommand(); loadNote(item.slug); }
121
133
  }
122
134
  });
123
- }
124
135
 
125
- // ═══════════════════════════════════
126
- // KEYBOARD NAVIGATION
127
- // ═══════════════════════════════════
136
+ commandOverlay.addEventListener('click', (e) => {
137
+ if (e.target === commandOverlay) closeCommand();
138
+ });
139
+ }
128
140
 
129
- function navigateList(delta) {
130
- const items = noteList.querySelectorAll('.note-item');
131
- if (items.length === 0) return;
141
+ function openCommand() {
142
+ commandOverlay.classList.remove('hidden');
143
+ commandInput.value = '';
144
+ commandInput.focus();
145
+ commandInput.setAttribute('aria-expanded', 'true');
146
+ releaseTrap = trapFocus(document.getElementById('command-bar'));
147
+ commandIndex = -1;
148
+ // Show all notes initially
149
+ renderCommandResults(allNotes.map(n => ({
150
+ slug: n.slug, title: n.title, type: n.type, modified: n.modified,
151
+ })));
152
+ }
132
153
 
133
- // Clear old focus
134
- items.forEach(el => el.classList.remove('keyboard-focus'));
154
+ function closeCommand() {
155
+ if (releaseTrap) { releaseTrap(); releaseTrap = null; }
156
+ commandOverlay.classList.add('hidden');
157
+ commandInput.setAttribute('aria-expanded', 'false');
158
+ commandInput.removeAttribute('aria-activedescendant');
159
+ commandResultsEl.innerHTML = '';
160
+ commandResults = [];
161
+ }
135
162
 
136
- keyboardIndex += delta;
137
- if (keyboardIndex < 0) keyboardIndex = items.length - 1;
138
- if (keyboardIndex >= items.length) keyboardIndex = 0;
163
+ function renderCommandResults(results) {
164
+ commandResults = results;
165
+ commandIndex = -1;
166
+ commandResultsEl.innerHTML = '';
167
+ for (let i = 0; i < results.length; i++) {
168
+ const r = results[i];
169
+ const el = document.createElement('div');
170
+ el.className = 'command-result';
171
+ el.setAttribute('role', 'option');
172
+ el.id = `cmd-result-${i}`;
173
+ let html = `<div class="command-result-title">${esc(r.title)}</div>`;
174
+ if (r.type || r.modified) {
175
+ const parts = [];
176
+ if (r.type) parts.push(r.type);
177
+ if (r.modified) parts.push(formatDate(r.modified));
178
+ html += `<div class="command-result-meta">${parts.join(' · ')}</div>`;
179
+ }
180
+ if (r.snippet) {
181
+ html += `<div class="command-result-snippet">${r.snippet.replace(/>>>/g, '<mark>').replace(/<<</g, '</mark>')}</div>`;
182
+ }
183
+ el.innerHTML = html;
184
+ el.addEventListener('click', () => { closeCommand(); loadNote(r.slug); });
185
+ commandResultsEl.appendChild(el);
186
+ }
187
+ }
139
188
 
140
- items[keyboardIndex].classList.add('keyboard-focus');
141
- items[keyboardIndex].scrollIntoView({ block: 'nearest' });
189
+ function navigateCommand(delta) {
190
+ const items = commandResultsEl.querySelectorAll('.command-result');
191
+ if (!items.length) return;
192
+ items.forEach(el => el.classList.remove('focused'));
193
+ commandIndex += delta;
194
+ if (commandIndex < 0) commandIndex = items.length - 1;
195
+ if (commandIndex >= items.length) commandIndex = 0;
196
+ items[commandIndex].classList.add('focused');
197
+ items[commandIndex].scrollIntoView({ block: 'nearest' });
198
+ commandInput.setAttribute('aria-activedescendant', items[commandIndex].id);
142
199
  }
143
200
 
201
+ // ═══ KEYBOARD ═══
144
202
  function setupKeyboard() {
145
203
  document.addEventListener('keydown', (e) => {
146
- // ⌘K — focus search
147
204
  if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
148
205
  e.preventDefault();
149
- if (sidebar.classList.contains('collapsed')) toggleSidebar();
150
- closeCreate();
151
- searchBox.focus();
152
- searchBox.select();
206
+ openCommand();
153
207
  }
154
-
155
- // ⌘N — new note
156
208
  if ((e.metaKey || e.ctrlKey) && e.key === 'n') {
157
209
  e.preventDefault();
158
- toggleCreate();
210
+ openCreate();
159
211
  }
160
-
161
- // ⌘G — toggle graph
162
212
  if ((e.metaKey || e.ctrlKey) && e.key === 'g') {
163
213
  e.preventDefault();
164
214
  toggleGraph();
165
215
  }
166
-
167
- // Escape close panels / deselect
168
- if (e.key === 'Escape' && document.activeElement !== searchBox) {
169
- if (isCreateOpen) {
170
- closeCreate();
171
- } else if (currentView === 'graph') {
172
- setView('note');
173
- } else if (currentNote) {
174
- currentNote = null;
175
- showEmptyState();
176
- }
177
- }
178
-
179
- // ↑/↓ in note list (when not focused on inputs)
180
- if (!['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) {
181
- if (e.key === 'ArrowDown') { e.preventDefault(); navigateList(1); }
182
- if (e.key === 'ArrowUp') { e.preventDefault(); navigateList(-1); }
183
- if (e.key === 'Enter' && keyboardIndex >= 0) {
184
- const items = noteList.querySelectorAll('.note-item');
185
- if (items[keyboardIndex]) items[keyboardIndex].click();
186
- }
216
+ if (e.key === 'Escape') {
217
+ if (!commandOverlay.classList.contains('hidden')) { closeCommand(); return; }
218
+ if (!createOverlay.classList.contains('hidden')) { closeCreate(); return; }
219
+ if (currentView === 'graph') { setView('note'); return; }
187
220
  }
188
221
  });
189
222
  }
190
223
 
191
- // ═══════════════════════════════════
192
- // SIDEBAR
193
- // ═══════════════════════════════════
194
-
195
- function setupSidebar() {
196
- document.getElementById('rail-toggle').addEventListener('click', toggleSidebar);
197
- document.getElementById('rail-search').addEventListener('click', () => {
198
- if (sidebar.classList.contains('collapsed')) toggleSidebar();
199
- closeCreate();
200
- setTimeout(() => { searchBox.focus(); searchBox.select(); }, 200);
201
- });
202
- document.getElementById('rail-graph').addEventListener('click', toggleGraph);
203
- document.getElementById('rail-new').addEventListener('click', toggleCreate);
204
- document.getElementById('brand-mark').addEventListener('click', () => {
205
- currentNote = null;
206
- setView('note');
207
- showEmptyState();
208
- });
224
+ // ═══ NAV ═══
225
+ const nav = document.getElementById('nav');
226
+ const navBackdrop = document.getElementById('nav-backdrop');
227
+
228
+ function setupNav() {
229
+ document.getElementById('nav-search').addEventListener('click', openCommand);
230
+ document.getElementById('nav-new').addEventListener('click', openCreate);
231
+ document.getElementById('nav-graph').addEventListener('click', toggleGraph);
232
+
233
+ // Mobile
234
+ const mobileMenu = document.getElementById('mobile-menu');
235
+ const mobileSearch = document.getElementById('mobile-search');
236
+ if (mobileMenu) mobileMenu.addEventListener('click', openMobileNav);
237
+ if (mobileSearch) mobileSearch.addEventListener('click', openCommand);
238
+ if (navBackdrop) navBackdrop.addEventListener('click', closeMobileNav);
209
239
  }
210
240
 
211
- function toggleSidebar() {
212
- sidebar.classList.toggle('expanded');
213
- sidebar.classList.toggle('collapsed');
241
+ function openMobileNav() {
242
+ nav.classList.add('open');
243
+ navBackdrop.classList.remove('hidden');
244
+ requestAnimationFrame(() => navBackdrop.classList.add('visible'));
214
245
  }
215
246
 
216
- // ═══════════════════════════════════
217
- // NOTE CREATION
218
- // ═══════════════════════════════════
247
+ function closeMobileNav() {
248
+ nav.classList.remove('open');
249
+ navBackdrop.classList.remove('visible');
250
+ setTimeout(() => navBackdrop.classList.add('hidden'), 200);
251
+ }
219
252
 
253
+ // ═══ CREATE ═══
220
254
  function setupCreate() {
221
- document.getElementById('create-panel-close').addEventListener('click', closeCreate);
255
+ document.getElementById('create-close').addEventListener('click', closeCreate);
222
256
  document.getElementById('create-submit').addEventListener('click', submitCreate);
223
-
224
- // Enter in title field submits if body is empty
257
+ createOverlay.addEventListener('click', (e) => {
258
+ if (e.target === createOverlay) closeCreate();
259
+ });
225
260
  document.getElementById('create-title').addEventListener('keydown', (e) => {
226
261
  if (e.key === 'Enter' && !e.shiftKey) {
227
262
  e.preventDefault();
@@ -230,51 +265,39 @@
230
265
  });
231
266
  }
232
267
 
233
- function toggleCreate() {
234
- if (isCreateOpen) {
235
- closeCreate();
236
- } else {
237
- openCreate();
238
- }
239
- }
240
-
268
+ let releaseCreateTrap = null;
241
269
  function openCreate() {
242
- if (sidebar.classList.contains('collapsed')) toggleSidebar();
243
- isCreateOpen = true;
244
- noteList.style.display = 'none';
245
- document.getElementById('type-filters').style.display = 'none';
246
- createPanel.style.display = 'flex';
247
- document.getElementById('rail-new').classList.add('active');
248
- setTimeout(() => document.getElementById('create-title').focus(), 100);
270
+ createOverlay.classList.remove('hidden');
271
+ releaseCreateTrap = trapFocus(document.getElementById('create-dialog'));
272
+ setTimeout(() => document.getElementById('create-title').focus(), 50);
249
273
  }
250
-
251
274
  function closeCreate() {
252
- isCreateOpen = false;
253
- createPanel.style.display = 'none';
254
- noteList.style.display = '';
255
- document.getElementById('type-filters').style.display = '';
256
- document.getElementById('rail-new').classList.remove('active');
257
- // Reset form
275
+ if (releaseCreateTrap) { releaseCreateTrap(); releaseCreateTrap = null; }
276
+ createOverlay.classList.add('hidden');
258
277
  document.getElementById('create-title').value = '';
259
278
  document.getElementById('create-body').value = '';
260
279
  }
261
280
 
281
+ let isSubmitting = false;
262
282
  async function submitCreate() {
283
+ if (isSubmitting) return;
263
284
  const type = document.getElementById('create-type').value;
264
285
  const title = document.getElementById('create-title').value.trim();
265
286
  const body = document.getElementById('create-body').value;
266
-
267
- if (!title) {
268
- document.getElementById('create-title').focus();
269
- return;
270
- }
271
-
287
+ if (!title) { document.getElementById('create-title').focus(); return; }
288
+ const btn = document.getElementById('create-submit');
289
+ isSubmitting = true;
290
+ btn.disabled = true;
291
+ btn.textContent = 'Creating...';
272
292
  const result = await api('/api/notes', {
273
293
  method: 'POST',
274
294
  headers: { 'Content-Type': 'application/json' },
275
295
  body: JSON.stringify({ type, title, body }),
276
296
  });
277
-
297
+ isSubmitting = false;
298
+ btn.disabled = false;
299
+ btn.textContent = 'Create';
300
+ if (result.error) return;
278
301
  if (result.slug) {
279
302
  closeCreate();
280
303
  await loadNotes();
@@ -282,79 +305,57 @@
282
305
  }
283
306
  }
284
307
 
285
- // ═══════════════════════════════════
286
- // GRAPH
287
- // ═══════════════════════════════════
288
-
308
+ // ═══ GRAPH ═══
289
309
  function toggleGraph() {
290
- if (currentView === 'graph') {
291
- setView('note');
292
- } else {
293
- setView('graph');
294
- }
310
+ setView(currentView === 'graph' ? 'note' : 'graph');
295
311
  }
296
312
 
297
313
  function setView(view) {
298
314
  currentView = view;
299
- const graphBtn = document.getElementById('rail-graph');
300
-
315
+ const graphBtn = document.getElementById('nav-graph');
301
316
  if (view === 'graph') {
302
317
  graphBtn.classList.add('active');
303
- contentWrapper.style.display = 'none';
318
+ noteView.style.display = 'none';
304
319
  emptyState.style.display = 'none';
305
- graphView.style.display = 'flex';
320
+ graphView.classList.remove('hidden');
306
321
  loadGraph();
307
322
  } else {
308
323
  graphBtn.classList.remove('active');
309
- graphView.style.display = 'none';
324
+ graphView.classList.add('hidden');
310
325
  GraphEngine.stop();
311
326
  if (currentNote) {
312
- contentWrapper.style.display = 'flex';
327
+ noteView.style.display = 'flex';
313
328
  emptyState.style.display = 'none';
314
329
  } else {
315
- contentWrapper.style.display = 'none';
330
+ noteView.style.display = 'none';
316
331
  emptyState.style.display = 'flex';
317
332
  }
318
333
  }
319
334
  }
320
335
 
321
336
  async function loadGraph() {
322
- if (!graphData) {
323
- graphData = await api('/api/graph');
324
- }
337
+ if (!graphData) graphData = await api('/api/graph');
325
338
  GraphEngine.init(graphCanvas, graphData, {
326
339
  activeSlug: currentNote?.slug || null,
327
- onNavigate: (slug) => {
328
- loadNote(slug);
329
- setView('note');
330
- },
340
+ onNavigate: (slug) => { loadNote(slug); setView('note'); },
331
341
  });
332
342
  }
333
343
 
334
- // ═══════════════════════════════════
335
- // WIKILINK PREVIEWS
336
- // ═══════════════════════════════════
337
-
344
+ // ═══ WIKILINK PREVIEWS ═══
338
345
  function setupWikilinkPreviews() {
339
346
  document.addEventListener('mouseover', (e) => {
340
347
  const link = e.target.closest('.wikilink');
341
348
  if (!link) return;
342
-
343
349
  clearTimeout(previewTimeout);
344
350
  previewTimeout = setTimeout(() => showPreview(link), 300);
345
351
  });
346
-
347
352
  document.addEventListener('mouseout', (e) => {
348
353
  const link = e.target.closest('.wikilink');
349
- if (link || e.relatedTarget?.closest?.('.wikilink-preview')) return;
350
-
354
+ if (link || e.relatedTarget?.closest?.('#wikilink-preview')) return;
351
355
  clearTimeout(previewTimeout);
352
356
  hidePreview();
353
357
  });
354
-
355
358
  previewEl.addEventListener('mouseleave', hidePreview);
356
-
357
- // Click navigation
358
359
  document.addEventListener('click', (e) => {
359
360
  const link = e.target.closest('.wikilink');
360
361
  if (!link) return;
@@ -368,41 +369,29 @@
368
369
  async function showPreview(linkEl) {
369
370
  const slug = linkEl.dataset.slug;
370
371
  if (!slug) return;
371
-
372
372
  let data = previewCache[slug];
373
373
  if (!data) {
374
374
  data = await api(`/api/notes/${slug}`);
375
375
  if (data.error) return;
376
376
  previewCache[slug] = data;
377
377
  }
378
-
379
- // Position
380
378
  const rect = linkEl.getBoundingClientRect();
381
- const previewWidth = 280;
382
379
  let left = rect.left;
383
380
  let top = rect.bottom + 8;
384
-
385
- // Keep on screen
386
- if (left + previewWidth > window.innerWidth - 16) {
387
- left = window.innerWidth - previewWidth - 16;
388
- }
381
+ if (left + 280 > window.innerWidth - 16) left = window.innerWidth - 296;
389
382
  if (top + 160 > window.innerHeight) {
390
383
  top = rect.top - 8;
391
384
  previewEl.style.transform = 'translateY(-100%)';
392
385
  } else {
393
386
  previewEl.style.transform = '';
394
387
  }
395
-
396
388
  previewEl.style.left = left + 'px';
397
389
  previewEl.style.top = top + 'px';
398
-
399
- // Content
400
390
  previewEl.querySelector('.preview-type').textContent = data.type || '';
401
391
  previewEl.querySelector('.preview-title').textContent = data.title || '';
402
392
  previewEl.querySelector('.preview-snippet').textContent = (data.body || '').slice(0, 150).replace(/[#*_`>\[\]]/g, '');
403
393
  const tagsEl = previewEl.querySelector('.preview-tags');
404
394
  tagsEl.innerHTML = (data.tags || []).map(t => `<span class="preview-tag">${esc(t)}</span>`).join('');
405
-
406
395
  previewEl.classList.add('visible');
407
396
  }
408
397
 
@@ -411,166 +400,130 @@
411
400
  previewEl.classList.remove('visible');
412
401
  }
413
402
 
414
- // ═══════════════════════════════════
415
- // DATA LOADING
416
- // ═══════════════════════════════════
417
-
403
+ // ═══ DATA ═══
418
404
  async function loadNotes() {
419
405
  const url = currentType ? `/api/notes?type=${currentType}` : '/api/notes';
420
406
  const data = await api(url);
421
407
  allNotes = data.notes;
422
- graphData = null; // Invalidate graph cache
408
+ graphData = null;
423
409
  renderNoteList(allNotes);
424
410
  }
425
411
 
426
412
  function filterByType(type) {
427
413
  currentType = type;
428
- typeFilters.querySelectorAll('.type-chip').forEach(btn => {
414
+ typeFilters.querySelectorAll('.type-pill').forEach(btn => {
429
415
  btn.classList.toggle('active', btn.dataset.type === type);
430
416
  });
431
- keyboardIndex = -1;
432
417
  loadNotes();
433
418
  }
434
419
 
435
- async function searchNotes(query) {
436
- const data = await api(`/api/search?q=${encodeURIComponent(query)}`);
437
- renderNoteList(data.results.map(r => ({
438
- slug: r.slug,
439
- title: r.title,
440
- type: '',
441
- snippet: r.snippet,
442
- })));
443
- }
444
-
445
- // ═══════════════════════════════════
446
- // RENDER NOTE LIST
447
- // ═══════════════════════════════════
448
-
420
+ // ═══ RENDER ═══
449
421
  function renderNoteList(notes) {
450
422
  noteList.innerHTML = '';
451
- keyboardIndex = -1;
452
-
453
423
  if (notes.length === 0) {
454
- noteList.innerHTML = '<div style="padding: 16px 12px; color: var(--text-3); font-size: 12px;">No notes found</div>';
424
+ noteList.innerHTML = '<div style="padding:16px 12px;color:var(--text-3);font-size:12px;">No notes</div>';
455
425
  return;
456
426
  }
457
-
458
427
  for (const note of notes) {
459
428
  const item = document.createElement('div');
460
429
  item.className = 'note-item' + (currentNote?.slug === note.slug ? ' active' : '');
461
-
430
+ item.setAttribute('role', 'listitem');
431
+ item.setAttribute('tabindex', '0');
432
+ item.setAttribute('aria-label', `${note.title}${note.type ? ', type ' + note.type : ''}`);
462
433
  let meta = '';
463
- if (note.type) {
464
- meta += `<span class="note-item-type ${note.type}">${note.type}</span>`;
465
- }
434
+ if (note.type) meta += `<span class="note-item-type ${note.type}">${note.type}</span>`;
466
435
  if (note.modified) {
467
436
  if (note.type) meta += '<span class="note-item-dot"></span>';
468
437
  meta += `<span>${formatDate(note.modified)}</span>`;
469
438
  }
470
-
471
- let snippet = '';
472
- if (note.snippet) {
473
- snippet = `<div class="note-item-snippet">${note.snippet.replace(/>>>/g, '<mark>').replace(/<<</g, '</mark>')}</div>`;
474
- }
475
-
476
439
  item.innerHTML = `
477
440
  <div class="note-item-title">${esc(note.title)}</div>
478
441
  <div class="note-item-meta">${meta}</div>
479
- ${snippet}
480
442
  `;
481
- item.addEventListener('click', () => loadNote(note.slug));
443
+ item.addEventListener('click', () => { closeMobileNav(); loadNote(note.slug); });
444
+ item.addEventListener('keydown', (e) => {
445
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); closeMobileNav(); loadNote(note.slug); }
446
+ });
482
447
  noteList.appendChild(item);
483
448
  }
484
449
  }
485
450
 
486
- // ═══════════════════════════════════
487
- // LOAD + RENDER NOTE
488
- // ═══════════════════════════════════
489
-
490
451
  async function loadNote(slug) {
491
452
  const note = await api(`/api/notes/${slug}`);
492
453
  if (note.error) return;
493
-
494
454
  currentNote = note;
495
455
 
496
- // Switch to note view if in graph
497
- if (currentView === 'graph') {
498
- setView('note');
499
- }
456
+ if (currentView === 'graph') setView('note');
500
457
 
501
- // Show content, hide empty state
502
458
  emptyState.style.display = 'none';
503
- contentWrapper.style.display = 'flex';
504
- noteHeader.style.display = 'block';
505
- noteBody.style.display = 'block';
459
+ noteView.style.display = 'flex';
506
460
 
507
- // Animate header
508
- noteHeader.style.animation = 'none';
509
- noteHeader.offsetHeight;
510
- noteHeader.style.animation = 'fadeSlideIn 0.35s var(--ease-out)';
461
+ // Re-trigger animation via class toggle
462
+ const article = document.getElementById('note-article');
463
+ article.classList.remove('animate');
464
+ requestAnimationFrame(() => {
465
+ requestAnimationFrame(() => article.classList.add('animate'));
466
+ });
511
467
 
512
- // Header content
513
- noteBreadcrumb.textContent = note.type;
468
+ noteTypeLine.textContent = note.type;
514
469
  noteTitle.textContent = note.title;
515
- noteTypeBadge.textContent = note.type;
516
- noteTypeBadge.className = 'meta-badge';
517
470
  noteDate.textContent = formatDate(note.created);
518
471
  noteTags.innerHTML = (note.tags || []).map(t => `<span class="tag">${esc(t)}</span>`).join('');
519
472
 
520
- // Render markdown body as HTML
521
- noteBody.style.animation = 'none';
522
- noteBody.offsetHeight;
523
- noteBody.style.animation = 'fadeSlideIn 0.45s var(--ease-out) 0.05s both';
524
473
  noteBody.innerHTML = MarkdownRenderer.render(note.body, note.outgoing_links);
525
474
 
526
- // Backlinks
527
475
  if (note.backlinks && note.backlinks.length > 0) {
528
- backlinksPanel.style.display = 'block';
476
+ backlinks.style.display = 'block';
529
477
  backlinksCount.textContent = note.backlinks.length;
530
478
  backlinksList.innerHTML = '';
531
479
  for (const bl of note.backlinks) {
532
480
  const item = document.createElement('div');
533
481
  item.className = 'backlink-item';
482
+ item.setAttribute('role', 'link');
483
+ item.setAttribute('tabindex', '0');
534
484
  item.innerHTML = `
535
- <div class="backlink-title">
536
- <span class="backlink-arrow">←</span>
537
- ${esc(bl.source_title)}
538
- </div>
485
+ <div class="backlink-title"><span class="backlink-arrow">\u2190</span>${esc(bl.source_title)}</div>
539
486
  ${bl.context ? `<div class="backlink-context">${esc(bl.context)}</div>` : ''}
540
487
  `;
541
488
  item.addEventListener('click', () => loadNote(bl.source_slug));
489
+ item.addEventListener('keydown', (e) => {
490
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); loadNote(bl.source_slug); }
491
+ });
542
492
  backlinksList.appendChild(item);
543
493
  }
544
494
  } else {
545
- backlinksPanel.style.display = 'none';
495
+ backlinks.style.display = 'none';
546
496
  }
547
497
 
548
- // Update active state in list
549
498
  noteList.querySelectorAll('.note-item').forEach((el, idx) => {
550
- const noteData = isSearchMode ? null : allNotes[idx];
551
- el.classList.toggle('active', noteData?.slug === slug);
499
+ el.classList.toggle('active', allNotes[idx]?.slug === slug);
552
500
  });
553
501
 
554
- // Update graph if it was loaded
555
- if (graphData) {
556
- GraphEngine.setActiveSlug(slug);
557
- }
558
-
559
- contentWrapper.scrollTop = 0;
502
+ if (graphData) GraphEngine.setActiveSlug(slug);
503
+ noteScroll.scrollTop = 0;
560
504
  }
561
505
 
562
- function showEmptyState() {
563
- noteHeader.style.display = 'none';
564
- noteBody.style.display = 'none';
565
- backlinksPanel.style.display = 'none';
566
- contentWrapper.style.display = 'none';
567
- emptyState.style.display = 'flex';
506
+ // ═══ FOCUS TRAP ═══
507
+ function trapFocus(container) {
508
+ const focusable = container.querySelectorAll(
509
+ 'input, select, textarea, button:not([disabled]), [tabindex]:not([tabindex="-1"])'
510
+ );
511
+ if (!focusable.length) return null;
512
+ const first = focusable[0];
513
+ const last = focusable[focusable.length - 1];
514
+ function handler(e) {
515
+ if (e.key !== 'Tab') return;
516
+ if (e.shiftKey) {
517
+ if (document.activeElement === first) { e.preventDefault(); last.focus(); }
518
+ } else {
519
+ if (document.activeElement === last) { e.preventDefault(); first.focus(); }
520
+ }
521
+ }
522
+ container.addEventListener('keydown', handler);
523
+ return () => container.removeEventListener('keydown', handler);
568
524
  }
569
525
 
570
- // ═══════════════════════════════════
571
- // HELPERS
572
- // ═══════════════════════════════════
573
-
526
+ // ═══ HELPERS ═══
574
527
  function esc(str) {
575
528
  const d = document.createElement('div');
576
529
  d.textContent = str;
@@ -583,6 +536,5 @@
583
536
  return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
584
537
  }
585
538
 
586
- // ── Boot ──
587
539
  init();
588
540
  })();