runlist 0.96.0 → 0.97.0

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.
Files changed (41) hide show
  1. package/README.md +170 -43
  2. package/assets/app/app.css +189 -3
  3. package/assets/app/app.mjs +49 -31
  4. package/assets/app/document-create.mjs +3 -2
  5. package/assets/app/document-lifecycle.mjs +17 -8
  6. package/assets/app/document-stages.mjs +3 -1
  7. package/assets/app/document-views.mjs +23 -1
  8. package/assets/app/editor-navigation.mjs +16 -23
  9. package/assets/app/filing-navigation.mjs +18 -16
  10. package/assets/app/git-changes.mjs +11 -2
  11. package/assets/app/index.html +34 -23
  12. package/assets/app/library-navigation.mjs +29 -16
  13. package/assets/app/record-navigation.mjs +68 -23
  14. package/assets/app/recovery-center.mjs +8 -7
  15. package/assets/app/settings.mjs +48 -10
  16. package/assets/app/template-editor.mjs +22 -12
  17. package/assets/app/transport.mjs +1 -2
  18. package/bin/runlist.mjs +64 -15
  19. package/package.json +1 -1
  20. package/plugins/runlist-codex/.codex-plugin/plugin.json +1 -1
  21. package/plugins/runlist-codex/skills/runlist/SKILL.md +3 -1
  22. package/src/app.mjs +9 -4
  23. package/src/baton.mjs +49 -11
  24. package/src/brief.mjs +145 -0
  25. package/src/commands.mjs +4 -3
  26. package/src/config.mjs +16 -26
  27. package/src/doctor.mjs +15 -0
  28. package/src/extractors.mjs +7 -14
  29. package/src/flags.mjs +5 -3
  30. package/src/hud.mjs +32 -63
  31. package/src/index.mjs +2 -0
  32. package/src/install.mjs +6 -4
  33. package/src/installation.mjs +117 -0
  34. package/src/new.mjs +27 -7
  35. package/src/prompt-handoff.mjs +132 -0
  36. package/src/prompts.mjs +112 -16
  37. package/src/section.mjs +46 -11
  38. package/src/show.mjs +132 -16
  39. package/src/template-store.mjs +16 -5
  40. package/src/update.mjs +7 -28
  41. package/src/util.mjs +7 -0
@@ -19,10 +19,10 @@ import { escapeHtml as esc, splitSource, editedSource, markdownHtml, lineDiff, d
19
19
  const $ = id => document.getElementById(id);
20
20
  const state = { doc:null, base:null, body:'', csrf:null, mode:'read', dirty:false, draft:null, recovery:null, conflict:null, pending:null, lastSave:null, busy:false, plans:[] };
21
21
  const clientId = sessionStorage.runlistClientId ||= crypto.randomUUID();
22
- let checkoutKey = '', draftTimer, persistence = Promise.resolve();
22
+ let checkoutKey = '', draftTimer, persistence = Promise.resolve(), noticeError = false;
23
23
  let editorPath;
24
24
  const blockEditor = new BlockEditor($('block-editor'),{onChange:body=>bodyChanged(body),resolveLink:linked});
25
- const navigation = editorNavigation({state,editor:blockEditor,checkout:()=>checkoutKey,$,view,notice});
25
+ const navigation = editorNavigation({state,editor:blockEditor,$,view,notice});
26
26
  templateEditor({api,$,checkout:()=>checkoutKey,notice});
27
27
  const library = libraryNavigation({state,$,api,open,showLibrary,checkout:()=>checkoutKey,notice});
28
28
  const settings=appSettings({$,api,checkout:()=>checkoutKey,library,notice});
@@ -30,7 +30,7 @@ const lifecycle=documentLifecycle({state,$,api,checkout:()=>checkoutKey,open,lib
30
30
  const yardstick=documentYardstick({state,$,api,checkout:()=>checkoutKey,open,library,notice});
31
31
  const filing=filingNavigation({state,$,api,open,showLibrary,checkout:()=>checkoutKey,notice});
32
32
  const records = recordNavigation({state,$,api,open,showLibrary,checkout:()=>checkoutKey,preferences:()=>settings.read(),notice,create:template=>creation.show({template})});
33
- const changes=gitChanges({state,$,api,open,showLibrary,checkout:()=>checkoutKey,notice,returnToDraft:()=>{view(state.doc.editable?'edit':'read');const url=new URL(location.href);url.searchParams.delete('view');url.searchParams.set('path',state.doc.path);history.replaceState(null,'',url);}});
33
+ const changes=gitChanges({state,$,api,open,showLibrary,checkout:()=>checkoutKey,notice,returnToDraft:()=>{view(state.doc.editable?'edit':'read',{focus:true});const url=new URL(location.href);url.searchParams.delete('view');url.searchParams.set('path',state.doc.path);history.replaceState(null,'',url);}});
34
34
  const commits=gitCommit({$,api,checkout:()=>checkoutKey,selection:changes.selectedPaths,changed:()=>changes.invalidate(),notice});
35
35
  $('changes-commit').onclick=()=>{if(!$('changes-commit').disabled)return commits.show().catch(error=>notice(error.message,true));};
36
36
  const views = documentViews({$,view});
@@ -49,7 +49,8 @@ const quick=quickNavigation({$,api,state,library,open,jump:section=>navigation.j
49
49
  {title:'Templates',run:()=>$('open-templates').click()},{title:'Settings',run:()=>$('open-settings').click()},
50
50
  ...(library.savedViews?.()??[]).map(v=>({title:`View: ${v.name}`,detail:'Saved library filters',run:()=>library.applyView(v.id)})),
51
51
  ]});
52
- function notice(message, error = false) { $('notice').textContent = message; $('notice').classList.toggle('error', error); }
52
+ let noticeTimer;
53
+ function notice(message = '', error = false, {persistent=false} = {}) { clearTimeout(noticeTimer); $('notice').textContent = message; $('notice').hidden = !message; $('notice').classList.toggle('error', error); noticeError = error; if(message&&!error&&!persistent)noticeTimer=setTimeout(()=>notice(),3200); }
53
54
  async function api(route,body) {const result=await request(route,body,state.csrf);if(body!==undefined&&['save','undo','create/commit','lifecycle/commit','yardstick/commit','native/action','flags/triage'].includes(route)){changes.invalidate();filing.invalidate();}return result;}
54
55
  function guarded(fn) { return async () => { try { await fn(); } catch (error) { notice(error.message, true); } }; }
55
56
  function key(path = state.doc?.path) { return `runlist:recovery:${checkoutKey}:${encodeURIComponent(path)}:${clientId}`; }
@@ -83,22 +84,29 @@ function updateDirty() {
83
84
  state.dirty = candidate() !== state.base.source;
84
85
  const rows = lineDiff(state.base.source, candidate()), adds = rows.filter(r => r.kind === 'add').length, removes = rows.filter(r => r.kind === 'remove').length;
85
86
  $('change-count').textContent = state.dirty ? `${adds + removes}` : '';
86
- $('review-summary').textContent = `${adds} added ${adds === 1 ? 'line' : 'lines'} · ${removes} removed ${removes === 1 ? 'line' : 'lines'} · ${state.doc.path}`;
87
+ $('review-summary').textContent = `${adds} added ${adds === 1 ? 'line' : 'lines'} · ${removes} removed ${removes === 1 ? 'line' : 'lines'}`;
87
88
  const claimed = state.doc.claim?.state === 'owned' || state.doc.claim?.corrupt;
88
89
  $('save').disabled = !state.dirty || !state.doc.editable || claimed || !!state.conflict || state.busy;
89
90
  $('edit').disabled = !state.doc.editable || state.busy;
91
+ $('edit').textContent = state.dirty || state.pending ? 'Review changes' : 'Edit document';
92
+ $('edit').hidden = state.mode === 'edit' || state.mode === 'review';
93
+ $('document-edit-actions').hidden = !state.dirty && !state.pending;
90
94
  $('tab-edit').disabled = !state.doc.editable || state.busy;
95
+ $('tab-review').disabled = (!state.dirty && !state.pending) || state.busy;
96
+ $('tab-review').hidden = !state.dirty && !state.pending;
91
97
  $('undo').disabled = !state.lastSave || state.dirty || claimed || state.busy;
92
98
  $('body-editor').readOnly = !!state.pending;
93
99
  blockEditor.setReadOnly(!!state.pending || !state.doc.editable);
94
- $('toggle-markdown').disabled = !!state.pending;
100
+ $('toggle-markdown').disabled = !!state.pending || !state.doc.editable || state.busy;
95
101
  $('pending-note').hidden = !state.pending;
96
102
  stages.update(candidate());
97
103
  yardstick.update();
98
104
  $('save').textContent = state.pending ? state.pending.kind === 'undo' ? 'Retry undo' : 'Retry save' : 'Save changes';
99
105
  }
100
- function view(mode) {
106
+ function view(mode, {focus=false} = {}) {
101
107
  if (!state.doc) return;
108
+ if(mode==='review'&&!state.pending&&candidate()===state.base.source)mode=state.mode==='edit'?'edit':'read';
109
+ records.beforeLeave?.();
102
110
  state.mode = mode;
103
111
  $('records-home').hidden=true;records.deactivate();changes.deactivate();filing.deactivate();
104
112
  $('workspace').classList.remove('library-start');$('empty').hidden=true;$('document').hidden=false;
@@ -109,10 +117,11 @@ function view(mode) {
109
117
  $('full-source').textContent = state.doc.source;
110
118
  $('diff').innerHTML = diffHtml(state.base.source, source);
111
119
  updateDirty();
112
- if (mode === 'read' && state.dirty) notice('Previewing your draft. It has not been saved to the file.');
120
+ if (!noticeError) notice();
113
121
  if (mode === 'edit' && (editorPath !== state.doc.path || blockEditor.source() !== state.body)) { blockEditor.set(state.body,{readOnly:!!state.pending || !state.doc.editable}); editorPath=state.doc.path; }
114
122
  if (mode !== 'edit') blockEditor.hideTools();
115
123
  navigation.update();library.render();
124
+ if (focus) views.focus(mode);
116
125
  }
117
126
  function renderList() { library.render(); }
118
127
 
@@ -124,7 +133,7 @@ function renderDocumentSummary() {
124
133
  function renderEvidence() {
125
134
  const doc = state.doc;
126
135
  renderConnections();
127
- $('file-info').innerHTML = `<dl><dt>Source file</dt><dd>${esc(doc.path)}</dd><dt>Revision</dt><dd><code>${esc(doc.revision.slice(0,23))}…</code></dd><dt>Actor</dt><dd>${esc($('identity').textContent)}</dd></dl><p class="muted">Source is the record. Save changes the file; derived views read it again.</p>`;
136
+ $('file-info').innerHTML = `<dl><dt>Source file</dt><dd>${esc(doc.path)}</dd><dt>Revision</dt><dd><code>${esc(doc.revision.slice(0,23))}…</code></dd><dt>Actor</dt><dd>${esc($('identity').textContent)}</dd></dl>`;
128
137
  $('flags').innerHTML = doc.flags.length ? doc.flags.map(flag => `<section class="flag"><span class="flag-tag">${esc(flag.id)} · ${esc(flag.severity)} · ${esc(flag.state)}</span><h3>${esc(flag.text)}</h3><h4>ORIGINAL OBSERVATION</h4><small>${esc(flag.original.file)}${flag.original.line ? `:${flag.original.line}` : ''}<br>${esc(flag.original.at ?? '')}</small><pre>${esc(flag.original.quote || 'File-level observation; no original quote was recorded.')}</pre><small>${esc(flag.original.revisionReason)}</small><h4>CURRENT LOCATION · ${esc(flag.current.status.toUpperCase())}</h4><pre class="current-quote">${esc(flag.current.quote ?? 'Current evidence is unavailable or ambiguous.')}</pre><small>${flag.current.line ? `Line ${flag.current.line} · ` : ''}${esc(flag.current.revision?.slice(0,23) ?? 'No current revision')}<br>Original evidence is retained.</small></section>`).join('') : '<p class="muted">No related flags recorded for this document.</p>';
129
138
  }
130
139
  function renderConnections() {
@@ -136,10 +145,11 @@ function renderConnections() {
136
145
  }
137
146
  async function open(path,selection={}) {
138
147
  if (state.busy) throw new Error('Wait for the current operation before switching plans.');
148
+ records.beforeLeave?.();
139
149
  await yardstick.beforeLeave();
140
150
  clearTimeout(draftTimer);
141
151
  if (state.doc && (state.dirty || state.pending)) { storeLocal(); await persistDraft(); if(!state.recoveryStored)throw new Error('Preserve your current draft before switching documents.'); }
142
- notice('Opening document…');
152
+ notice();
143
153
  path=await lifecycle.resolveInitial(path);
144
154
  path=await yardstick.resolveInitial(path);
145
155
  const doc = await api(`document?path=${encodeURIComponent(path)}`);
@@ -158,13 +168,13 @@ async function open(path,selection={}) {
158
168
  if (recovery?.lastSave) state.lastSave = recovery.lastSave;
159
169
  const recoverable = recovery && (recovery.source !== recovery.baseSource || recovery.pending);
160
170
  $('recovery').hidden = !recoverable;
161
- $('recovery-label').textContent = recoverable ? 'A locally preserved draft is available. Recover it to review changes; the Markdown file is unchanged.' : '';
162
- $('draft-status').textContent = recoverable ? 'Draft available' : 'Saved source';
171
+ $('recovery-label').textContent = recoverable ? 'Unsaved draft available.' : '';
172
+ $('draft-status').textContent = ''; $('draft-status').title = '';
163
173
  $('claim').hidden = !(doc.claim?.corrupt || doc.claim?.state === 'owned' || !doc.editable);
164
174
  $('claim').textContent = doc.claim?.corrupt ? 'Ownership is corrupt. Repair it through the CLI before saving.' : doc.claim?.state === 'owned' ? `Owned by session ${doc.claim.sessionId}. You can keep a draft; coordinate or release the claim through the CLI before saving.` : !doc.editable ? 'This source is read only. Its format or metadata needs repair before saving.' : '';
165
175
  $('conflict').hidden = true; $('operation-repair').hidden = true; $('empty').hidden = true; $('document').hidden = false;$('workspace').classList.remove('library-start');
166
176
  $('doc-title').textContent = doc.title; $('doc-path').textContent = doc.path; $('doc-status').textContent = doc.status ?? '';
167
- $('body-editor').value = state.body; $('markdown-fallback').hidden=true; $('block-editor').hidden=false; $('toggle-markdown').textContent='Edit Markdown'; $('toggle-markdown').setAttribute('aria-expanded','false'); renderList(); renderEvidence(); view(doc.editable?'edit':'read');
177
+ $('body-editor').value = state.body; $('markdown-fallback').hidden=true; $('block-editor').hidden=false; $('toggle-markdown').textContent='Edit Markdown'; $('toggle-markdown').setAttribute('aria-expanded','false'); renderList(); renderEvidence(); view('read');
168
178
  const url = new URL(location.href); url.searchParams.delete('ref');url.searchParams.delete('from');url.searchParams.delete('view');url.searchParams.delete('record');url.searchParams.set('path',doc.path); history.replaceState(null,'',url);
169
179
  library.opened();
170
180
  await yardstick.opened();
@@ -178,7 +188,7 @@ async function open(path,selection={}) {
178
188
  else sourceNotice='The recorded line is unavailable. Current hub source is open.';
179
189
  }
180
190
  }
181
- $('sidebar').classList.remove('open');$('toggle-plans').setAttribute('aria-expanded','false');if(!selection.line)window.scrollTo(0,0); notice(sourceNotice??(doc.editable?'Your document is ready to edit. Changes are kept as a draft until you save.':'This document is read only. Its source is preserved.'),!!sourceNotice);
191
+ $('sidebar').classList.remove('open');$('toggle-plans').setAttribute('aria-expanded','false');if(!selection.line)window.scrollTo(0,0); notice(sourceNotice??'',!!sourceNotice);
182
192
  }
183
193
  async function persistDraft() {
184
194
  if (!state.doc || !state.base || (!state.dirty && !state.pending)) return;
@@ -192,8 +202,8 @@ async function persistDraft() {
192
202
  if (state.doc?.path !== path || state.draft?.id !== id) return;
193
203
  request.expectedDraftRevision = state.draft.revision;
194
204
  const stored = await api('draft/write', request);
195
- if (state.doc?.path === path && state.draft?.id === id) { state.draft.revision = stored.revision; storeLocal(); state.recoveryStored=true; $('draft-status').textContent = 'Draft preserved locally · not saved to Markdown'; }
196
- } catch (error) { $('draft-status').textContent = desktop?'Draft kept in Runlist':'Draft kept in browser'; notice(error.code === 'draft-conflict' ? 'Another editor updated the disk draft. Your text is kept locally; recover or copy it before proceeding.' : `Draft kept ${desktop?'in Runlist':'in browser'}. ${error.message}`, true); }
205
+ if (state.doc?.path === path && state.draft?.id === id) { state.draft.revision = stored.revision; storeLocal(); state.recoveryStored=true; $('draft-status').textContent = state.dirty ? 'Unsaved' : ''; $('draft-status').title = state.dirty ? 'Draft stored on this computer' : ''; }
206
+ } catch (error) { $('draft-status').textContent = 'Unsaved'; $('draft-status').title = 'Local draft storage failed'; notice(error.code === 'draft-conflict' ? 'Another editor updated the disk draft. Your text is kept locally; recover or copy it before proceeding.' : `Draft kept ${desktop?'in Runlist':'in browser'}. ${error.message}`, true); }
197
207
  };
198
208
  persistence = persistence.then(run,run); await persistence;
199
209
  }
@@ -210,8 +220,9 @@ async function recover() {
210
220
  state.base = { source:r.baseSource, revision:r.baseRevision }; state.draftSource = r.source; state.body = splitSource(r.source).body.replaceAll('\r\n','\n');
211
221
  state.draft = r.location === key() ? r.draft : null; state.pending = r.pending; state.lastSave = r.lastSave;
212
222
  $('body-editor').value = state.body; $('recovery').hidden = true; updateDirty(); storeLocal(); view('edit');
213
- if (state.pending) notice('An interrupted save is retained. Review changes and retry to learn its original outcome.');
214
- else notice('Draft recovered. Review changes before saving.');
223
+ if (state.pending) notice('An interrupted save is retained. Review changes and retry to learn its original outcome.',false,{persistent:true});
224
+ else notice('Draft recovered.');
225
+ $('draft-status').textContent = 'Unsaved'; $('draft-status').title = 'Recovered draft';
215
226
  if (state.base.revision !== state.doc.revision && !state.pending) conflict({ details:{ currentRevision:state.doc.revision, currentSource:state.doc.source } });
216
227
  }
217
228
  async function clearDraft() {
@@ -226,7 +237,7 @@ async function save() {
226
237
  // Retain the original request identity on disk before publication, even if renderer storage is later lost.
227
238
  await persistDraft();
228
239
  if(!state.recoveryStored)throw new Error('Preserve the draft and operation identity before saving.');
229
- storeLocal(); state.busy = true; $('workspace').inert = true; updateDirty(); notice('Saving the reviewed source…');
240
+ storeLocal(); state.busy = true; $('workspace').inert = true; updateDirty(); notice('Saving…',false,{persistent:true});
230
241
  try {
231
242
  const result = await api(state.pending.kind === 'undo' ? 'undo' : 'save',state.pending), pending = state.pending;
232
243
  const current = await api(`document?path=${encodeURIComponent(state.doc.path)}`);
@@ -244,8 +255,8 @@ async function save() {
244
255
  state.doc = current; state.draftSource = current.source; state.base = { source:current.source, revision:current.revision }; state.body = splitSource(current.source).body.replaceAll('\r\n','\n');
245
256
  state.dirty = false; storeLocal(); let cleanupError = null;
246
257
  try { await clearDraft(); } catch (error) { cleanupError = error; }
247
- $('body-editor').value = state.body; $('draft-status').textContent = 'Saved source'; storeLocal(); renderDocumentSummary(); renderEvidence(); view('edit');
248
- notice(cleanupError ? `Saved to Markdown. Recovery draft retained: ${cleanupError.message}` : pending.kind === 'undo' ? 'Undid the save. The CLI reads the same file.' : 'Saved to Markdown. The CLI reads the same file.', !!cleanupError);
258
+ $('body-editor').value = state.body; $('draft-status').textContent = ''; $('draft-status').title = ''; storeLocal(); renderDocumentSummary(); renderEvidence(); view('edit');
259
+ notice(cleanupError ? `Saved to Markdown. Recovery draft retained: ${cleanupError.message}` : pending.kind === 'undo' ? 'Undid the save.' : 'Saved to Markdown.', !!cleanupError);
249
260
  } catch (error) {
250
261
  if (error.code === 'revision-conflict') { state.pending = null; storeLocal(); conflict(error); }
251
262
  else if (['repair-required','operation-pending'].includes(error.code)) {
@@ -283,51 +294,57 @@ async function bootstrap(token) {
283
294
  }
284
295
  async function showLibrary() {
285
296
  if(state.busy)throw new Error('Wait for the current operation before returning to the library.');
297
+ records.beforeLeave?.();
286
298
  await yardstick.beforeLeave();
287
299
  clearTimeout(draftTimer);
288
300
  if(state.doc&&(state.dirty||state.pending)){storeLocal();await persistDraft();if(!state.recoveryStored)throw new Error('Preserve your current draft before leaving the document.');}
289
301
  state.mode='home';blockEditor.hideTools();$('records-home').hidden=true;records.deactivate();changes.deactivate();filing.deactivate();
290
- $('workspace').classList.add('library-start');$('evidence').classList.remove('outline-open');
302
+ $('workspace').classList.add('library-start');$('evidence').classList.remove('outline-open');$('toggle-outline').setAttribute('aria-expanded','false');
291
303
  $('document').hidden=true;$('empty').hidden=false;$('doc-path').textContent='';
292
304
  $('sidebar').classList.remove('open');$('toggle-plans').setAttribute('aria-expanded','false');
293
305
  const url=new URL(location.href);for(const key of ['path','ref','from','view','record'])url.searchParams.delete(key);history.replaceState(null,'',url);
294
306
  library.render();window.scrollTo(0,0);
295
- notice(state.doc&&(state.dirty||state.pending)?'Library ready. Your document draft stays available when you reopen it.':'Library ready.');
307
+ notice();
296
308
  }
297
309
  $('document-changes').onclick=guarded(()=>changes.show());
310
+ // Native details keeps checkout utilities available by mouse and keyboard.
311
+ document.addEventListener('click',event=>{const menu=$('workspace-menu');if(menu.open&&(!menu.contains(event.target)||event.target.closest('button')))menu.open=false;});
312
+ document.addEventListener('keydown',event=>{const menu=$('workspace-menu');if(event.key==='Escape'&&menu.open){menu.open=false;menu.querySelector('summary').focus();}});
298
313
  $('library-home').onclick=guarded(showLibrary);
299
314
  $('back-library').onclick=guarded(showLibrary);
300
315
  $('toggle-plans').onclick = () => $('toggle-plans').setAttribute('aria-expanded',String($('sidebar').classList.toggle('open')));
301
316
  for (const name of ['read','edit','review','source']) $('tab-' + name).onclick = guarded(() => view(name));
302
- $('edit').onclick = guarded(() => view(state.dirty ? 'review' : 'edit')); $('review').onclick = guarded(() => view('review')); $('back-edit').onclick = () => view('edit');
317
+ $('edit').onclick = guarded(() => view(state.dirty || state.pending ? 'review' : 'edit',{focus:true})); $('review').onclick = guarded(() => view('review',{focus:true})); $('back-edit').onclick = guarded(() => view('edit',{focus:true}));
303
318
  function bodyChanged(body) {
304
319
  state.body = body;
305
320
  navigation.update();
306
321
  $('body-editor').value = body;
307
322
  storeLocal();
308
- try { updateDirty(); storeLocal(); $('draft-status').textContent = state.dirty ? 'Saving draft…' : 'Saved source'; clearTimeout(draftTimer); if(state.dirty) draftTimer = setTimeout(() => persistDraft(),500); }
323
+ try { updateDirty(); storeLocal(); if(!noticeError)notice(); $('draft-status').textContent = state.dirty ? 'Unsaved' : ''; $('draft-status').title = state.dirty ? 'Not saved to the file' : ''; clearTimeout(draftTimer); if(state.dirty) draftTimer = setTimeout(() => persistDraft(),500); }
309
324
  catch (error) { $('save').disabled = true; notice(error.message,true); }
310
325
  }
311
326
  $('body-editor').oninput = () => bodyChanged($('body-editor').value);
312
327
  $('toggle-markdown').onclick = () => {
328
+ if(state.mode!=='edit')view('edit');
313
329
  const showing = $('markdown-fallback').hidden;
314
330
  $('markdown-fallback').hidden = !showing; $('block-editor').hidden = showing;
315
331
  $('toggle-markdown').textContent = showing ? 'Back to document' : 'Edit Markdown';
316
332
  $('toggle-markdown').setAttribute('aria-expanded',String(showing));
333
+ $('evidence').classList.remove('outline-open');$('toggle-outline').setAttribute('aria-expanded','false');
317
334
  if (showing) { $('body-editor').value=state.body; $('body-editor').focus(); }
318
335
  else { blockEditor.set(state.body,{readOnly:!!state.pending}); editorPath=state.doc.path; }
319
336
  };
320
337
  $('save').onclick = guarded(save);
321
- $('cancel').onclick = guarded(async () => { await persistDraft(); view('read'); notice(state.dirty ? 'Reading your draft preview. Changes are not saved to the file.' : 'Reading saved source.'); });
322
- $('discard').onclick = guarded(async () => { if (state.pending) throw new Error('Inspect or retry the interrupted save before discarding its recovery data.'); await clearDraft(); state.body = splitSource(state.doc.source).body.replaceAll('\r\n','\n'); state.base = { source:state.doc.source, revision:state.doc.revision }; state.draftSource = state.doc.source; state.pending = null; state.conflict = null; $('conflict').hidden = true; $('body-editor').value = state.body; storeLocal(); $('draft-status').textContent = 'Saved source'; view('edit'); notice('Changes discarded. The Markdown file is unchanged.'); });
338
+ $('cancel').onclick = guarded(async () => { await persistDraft(); view('read'); });
339
+ $('discard').onclick = guarded(async () => { if (state.pending) throw new Error('Inspect or retry the interrupted save before discarding its recovery data.'); await clearDraft(); state.body = splitSource(state.doc.source).body.replaceAll('\r\n','\n'); state.base = { source:state.doc.source, revision:state.doc.revision }; state.draftSource = state.doc.source; state.pending = null; state.conflict = null; $('conflict').hidden = true; $('body-editor').value = state.body; storeLocal(); $('draft-status').textContent = ''; $('draft-status').title = ''; view('edit'); notice('Changes discarded.'); });
323
340
  $('recover').onclick = guarded(recover);
324
341
  $('discard-recovery').onclick = guarded(async () => {
325
342
  const r = state.recovery;
326
343
  if (r?.pending) throw new Error('This draft contains an interrupted save. Recover it and inspect the outcome before discarding.');
327
344
  if (r?.draft?.revision) await api('draft/discard',{ path:state.doc.path,draftId:r.draft.id,expectedDraftRevision:r.draft.revision });
328
- if (r) localStorage.removeItem(r.location); state.recovery = null; $('recovery').hidden = true; notice('Recovery draft discarded. The file is unchanged.');
345
+ if (r) localStorage.removeItem(r.location); state.recovery = null; $('recovery').hidden = true; notice('Draft discarded.');
329
346
  });
330
- $('review-merge').onclick = guarded(async () => { const c = state.conflict; if (!c) return; state.draftSource = rebaseSourceStage(state.base.source,state.draftSource ?? state.base.source,c.currentSource); state.base = { source:c.currentSource, revision:c.currentRevision }; state.pending = null; state.conflict = null; $('conflict').hidden = true; storeLocal(); await persistDraft(); view('review'); notice('Review the merged draft against the current file before saving.'); });
347
+ $('review-merge').onclick = guarded(async () => { const c = state.conflict; if (!c) return; state.draftSource = rebaseSourceStage(state.base.source,state.draftSource ?? state.base.source,c.currentSource); state.base = { source:c.currentSource, revision:c.currentRevision }; state.pending = null; state.conflict = null; $('conflict').hidden = true; storeLocal(); await persistDraft(); view('review',{focus:true}); notice('Review the merged draft against the current file before saving.'); });
331
348
  $('use-current').onclick = guarded(async () => { await persistDraft(); state.doc = await api(`document?path=${encodeURIComponent(state.doc.path)}`); $('reading').innerHTML = markdownHtml(state.doc.source,linked); view('source'); notice('Current source is shown. Your draft and conflict review are retained.'); });
332
349
  $('inspect-pending').onclick = guarded(() => inspectOperation(state.pending.operationId));
333
350
  $('settle-operation').onclick = guarded(async () => {
@@ -354,13 +371,14 @@ $('undo').onclick = guarded(async () => {
354
371
  } finally { state.busy = false; $('workspace').inert = false; updateDirty(); }
355
372
  });
356
373
  $('reading').addEventListener('click',event=>{const anchor=event.target.closest('a');if(!anchor||event.metaKey||event.ctrlKey||event.shiftKey)return;const url=new URL(anchor.href,location.href);if(url.origin!==location.origin||!url.searchParams.has('ref'))return;event.preventDefault();guarded(async()=>{const target=await api(`link?${new URLSearchParams({ref:url.searchParams.get('ref'),from:url.searchParams.get('from')??''})}`);if(!target)throw new Error('This linked document is unavailable or outside this library.');await open(target.path);})();});
357
- $('copy-source').onclick = guarded(async () => { await copyText(state.doc.source); notice('Canonical Markdown source copied.'); });
374
+ $('copy-source').onclick = guarded(async () => { await copyText(state.doc.source); notice('Source copied.'); });
358
375
  $('disconnect').onclick = guarded(async () => { await beforeDesktopLeave(); await api('logout',{}); $('workspace').hidden = true; $('connect-panel').hidden = false; for(const id of ['disconnect','new-document','quick-open','open-recovery'])$(id).hidden=true; notice('Disconnected. Private drafts are retained locally.'); await desktopDisconnected(); });
359
376
  $('connect-form').onsubmit = event => { event.preventDefault(); guarded(async () => { const value = $('access').value.trim(); let token = value; try { token = new URLSearchParams(new URL(value).hash.slice(1)).get('connect') ?? value; } catch { /* A raw token is also accepted. */ } await bootstrap(token); $('access').value = ''; })(); };
360
377
  window.addEventListener('beforeunload', event => { if (state.dirty || state.pending) { storeLocal(); if (!state.recoveryStored) { event.preventDefault(); event.returnValue = ''; } } });
361
- document.addEventListener('keydown', event => { if (document.querySelector('dialog[open]')) return; if ((event.metaKey || event.ctrlKey) && event.key === 's') { event.preventDefault(); if (state.doc && state.dirty) guarded(() => view('review'))(); } });
378
+ document.addEventListener('keydown', event => { if (document.querySelector('dialog[open]')) return; if ((event.metaKey || event.ctrlKey) && event.key === 's') { event.preventDefault(); if (state.doc && state.dirty) guarded(() => view('review',{focus:true}))(); } });
362
379
  async function beforeDesktopLeave() {
363
380
  if(state.busy)throw new Error('Wait for the current operation before switching folders or quitting.');
381
+ records.beforeLeave?.();
364
382
  await yardstick.beforeLeave();
365
383
  if($('template-dialog').open||$('lifecycle-dialog')?.open||$('create-dialog').open||$('git-commit-dialog').open||$('filter-dialog').open||$('record-review')&&!$('record-review').hidden)throw new Error('Close the current review or template editor before switching folders or quitting.');
366
384
  clearTimeout(draftTimer);
@@ -54,7 +54,7 @@ export function documentCreation({$,api,checkout,state,beforeNavigate,open,libra
54
54
  if(template&&!catalog.templates.some(t=>t.name===template))throw new Error(catalog.recordError??'This record template is unavailable.');
55
55
  $('create-template').replaceChildren(...catalog.templates.map(t=>new Option(({hub:'Hub',plan:'Plan',flag:'Flag',decision:'Decision'})[t.name]??'Document',t.name)));
56
56
  $('create-folders').replaceChildren(...catalog.roots.map(root=>{const option=document.createElement('option');option.value=root;return option;}));
57
- $('create-form').reset();setOptions();chooseTemplate();filenameEdited=false;review=null;request=null;feedback('');$('create-diagnostics').replaceChildren();
57
+ $('create-form').reset();$('create-file-options').removeAttribute('open');setOptions();chooseTemplate();filenameEdited=false;review=null;request=null;feedback('');$('create-diagnostics').replaceChildren();
58
58
  try {
59
59
  const stored=JSON.parse(localStorage.getItem(key())??'null');
60
60
  if(stored){if(catalog.templates.some(t=>t.name===stored.form?.template)){$('create-template').value=stored.form.template;chooseTemplate();}for(const [field,id] of Object.entries({title:'create-name',folder:'create-folder',filename:'create-filename',status:'create-status',body:'create-body',severity:'create-severity'}))if(typeof stored.form?.[field]==='string')$(id).value=stored.form[field];if(Array.isArray(stored.form?.options)){setOptions(stored.form.options);chooseTemplate();}review=stored.review;request=stored.request;filenameEdited=!!stored.form?.filename;}
@@ -65,6 +65,7 @@ export function documentCreation({$,api,checkout,state,beforeNavigate,open,libra
65
65
  $('create-preview-button').disabled=!catalog.templates.length;renderReview();dialog.showModal();(review?$('create-confirm'):$('create-name')).focus();
66
66
  if(!catalog.templates.length){$('create-preview-button').disabled=true;feedback('This checkout has no supported document templates. Configure a plan or doc type through the CLI.',true);}
67
67
  }
68
+ $('create-form').addEventListener('invalid',event=>{if(event.target.closest('#create-file-options'))$('create-file-options').setAttribute('open','');},true);
68
69
  $('create-template').onchange=()=>{chooseTemplate();keep();};
69
70
  $('create-filename').oninput=()=>{filenameEdited=true;keep();};
70
71
  $('create-name').oninput=()=>{if(!filenameEdited&&!selected()?.native){const slug=$('create-name').value.toLowerCase().replace(/[\s_]+/g,'-').replace(/[^a-z0-9-]/g,'').replace(/-+/g,'-').replace(/^-|-$/g,'').slice(0,120);$('create-filename').value=slug?slug+'.md':'';}keep();};
@@ -75,7 +76,7 @@ export function documentCreation({$,api,checkout,state,beforeNavigate,open,libra
75
76
  $('create-inspect').onclick=()=>busy(async()=>{const inspected=await api('create/inspect',{operationId:review.operationId});current();review=inspected;keep();feedback(review?`Creation is ${review.state}.${review.currentRevision&&review.currentRevision!==review.revision?' The destination changed and will not be overwritten.':''}`:'No creation review found.');});
76
77
  $('create-confirm').onclick=()=>busy(async()=>{
77
78
  if(review.state!=='committed') {const committed=await api('create/commit',{operationId:review.operationId});current();review=committed;keep();}
78
- current();const path=review.path,record=review.record;localStorage.removeItem(key());dialog.close();state.busy=false;await library.load({refresh:true});current();if(record&&createdRecord)await createdRecord(record);else await open(path);notice(record?`Created an open ${record.type}. Ready for review.`:'Created local Markdown. Ready to edit.');
79
+ current();const path=review.path,record=review.record;localStorage.removeItem(key());dialog.close();state.busy=false;await library.load({refresh:true});current();if(record&&createdRecord)await createdRecord(record);else await open(path);notice(record?`${record.type==='flag'?'Flag':'Decision'} created.`:'Document created.');
79
80
  });
80
81
  $('create-discard').onclick=()=>busy(async()=>{if(review)await api('create/discard',{operationId:review.operationId});current();localStorage.removeItem(key());review=null;request=null;dialog.close();notice('New document draft discarded.');});
81
82
  $('create-close').onclick=()=>{if(!state.busy&&keep()){sequence++;dialog.close();}};
@@ -1,5 +1,7 @@
1
1
  export function documentLifecycle({state,$,api,checkout,open,library,notice}){
2
2
  const key=path=>`runlist:lifecycle:${checkout()}:${encodeURIComponent(path)}`,dialog=$('lifecycle-dialog');let pending=null,blocked=false,formEnabled=true;
3
+ const canApply=()=>!blocked&&pending?.state==='reviewed';
4
+ const remember=()=>localStorage.setItem(key(pending.path),JSON.stringify(pending));
3
5
  const retained=path=>{try{const direct=JSON.parse(localStorage.getItem(key(path))??'null');if(direct)return direct;for(let i=0;i<localStorage.length;i++){const location=localStorage.key(i);if(location.startsWith(`runlist:lifecycle:${checkout()}:`)){const value=JSON.parse(localStorage.getItem(location));if(value?.newPath===path)return value;}}}catch{}return null;};
4
6
  async function resolveInitial(initial){const old=retained(initial);if(old&&old.path===initial){const result=await api('lifecycle/inspect',{operationId:old.operationId});if(result?.state==='committed'){library.relocated(old.path,result.newPath);localStorage.removeItem(key(old.path));return result.newPath;}if(result?.current&&result.newPath!==initial)return result.newPath;}return initial;}
5
7
  async function show(){if(!state.doc||state.busy)return;const doc=state.doc,info=await api(`lifecycle?path=${encodeURIComponent(doc.path)}`);$('lifecycle-path').textContent=`${doc.title} · ${doc.path}`;const root=$('lifecycle-content');root.replaceChildren();
@@ -8,16 +10,23 @@ export function documentLifecycle({state,$,api,checkout,open,library,notice}){
8
10
  for(const status of info.statuses.filter(s=>s!==info.current))form.querySelector('select').add(new Option(status,status));root.append(form);
9
11
  blocked=!info.enabled||state.dirty||state.pending;formEnabled=info.enabled;form.hidden=!formEnabled;const message=document.createElement('p');message.id='lifecycle-unavailable';message.className='inline-note';message.setAttribute('role','status');if(blocked){message.textContent=!info.enabled?info.reason:'Save or discard the document draft before changing its lifecycle status.';root.append(message);for(const input of form.querySelectorAll('input,select,textarea,button'))input.disabled=true;}
10
12
  const review=document.createElement('section');review.id='lifecycle-review';review.hidden=true;root.append(review);pending=retained(doc.path);if(pending&&![pending.path,pending.newPath].includes(doc.path))pending=null;if(pending)renderReview();
11
- form.onsubmit=async event=>{event.preventDefault();if(state.busy||blocked)return;state.busy=true;$('lifecycle-close').disabled=true;$('lifecycle-review-button').disabled=true;try{const request={path:doc.path,status:$('lifecycle-status').value,note:$('lifecycle-note').value,expectedRevision:doc.revision,operationId:crypto.randomUUID()};pending={...request,report:'Review not yet received.'};localStorage.setItem(key(pending.path),JSON.stringify(pending));const result=await api('lifecycle/preview',request);pending={...pending,...result};localStorage.setItem(key(pending.path),JSON.stringify(pending));renderReview();}catch(error){if(pending){renderReview();$('lifecycle-feedback').textContent=`${error.message} Inspect this operation, or return to the form to review again.`;}notice(error.message,true);}finally{state.busy=false;$('lifecycle-close').disabled=false;$('lifecycle-review-button').disabled=false;}};
13
+ form.onsubmit=async event=>{event.preventDefault();if(state.busy||blocked)return;state.busy=true;$('lifecycle-close').disabled=true;$('lifecycle-review-button').disabled=true;try{const request={path:doc.path,status:$('lifecycle-status').value,note:$('lifecycle-note').value,expectedRevision:doc.revision,operationId:crypto.randomUUID()};pending={...request,state:'preparing',report:'Review not yet received.'};localStorage.setItem(key(pending.path),JSON.stringify(pending));const result=await api('lifecycle/preview',request);pending={...pending,...result};localStorage.setItem(key(pending.path),JSON.stringify(pending));renderReview();}catch(error){if(pending){renderReview();$('lifecycle-feedback').textContent=`${error.message} Inspect this operation, or return to the form to review again.`;}notice(error.message,true);}finally{state.busy=false;$('lifecycle-close').disabled=false;$('lifecycle-review-button').disabled=false;}};
12
14
  dialog.showModal();
13
15
  }
14
- function renderReview(){$('lifecycle-form').hidden=true;const root=$('lifecycle-review');root.hidden=false;root.replaceChildren();const heading=document.createElement('h3');heading.textContent=`Change to ${pending.status}`;const report=document.createElement('pre');report.className='lifecycle-report';report.textContent=pending.report;const confirm=document.createElement('button');confirm.id='lifecycle-confirm';confirm.className='primary';confirm.textContent='Apply status change';confirm.disabled=blocked;const back=document.createElement('button');back.className='quiet';back.textContent='Back to form';back.id='lifecycle-back';const inspect=document.createElement('button');inspect.className='quiet';inspect.textContent='Inspect operation';inspect.id='lifecycle-inspect';const feedback=document.createElement('p');feedback.id='lifecycle-feedback';feedback.setAttribute('role','status');root.append(heading,report,confirm,back,inspect,feedback);
15
- back.onclick=()=>{const previous=pending;localStorage.removeItem(key(pending.path));pending=null;root.hidden=true;$('lifecycle-form').hidden=!formEnabled;$('lifecycle-status').value=previous.status;$('lifecycle-note').value=previous.note;};
16
- inspect.onclick=async()=>{try{const result=await api('lifecycle/inspect',{operationId:pending.operationId});report.textContent=result?.report??'No lifecycle review was retained. Return to the form and review again.';feedback.textContent=result?`Operation is ${result.state}.`:'No operation found.';if(result?.failure)feedback.textContent+=` ${result.failure.message}`;if(result?.state==='running'){feedback.textContent+=' Inspect CLI transactions before starting another change.';if(result.canAcknowledge)acknowledge(result,root,feedback);else feedback.textContent+=' The runner is still active or cannot be verified as stopped.';}}catch(error){feedback.textContent=error.message;}};
17
- confirm.onclick=async()=>{if(state.busy||blocked)return;state.busy=true;for(const button of root.querySelectorAll('button'))button.disabled=true;$('lifecycle-close').disabled=true;
18
- try{const result=await api('lifecycle/commit',{operationId:pending.operationId}),old=pending.path;library.relocated(old,result.newPath);localStorage.removeItem(key(pending.path));pending=null;state.busy=false;dialog.close();await open(result.newPath);await library.load({refresh:true});notice('Lifecycle status saved. Source, references and history follow the CLI rules.');}
19
- catch(error){feedback.textContent=pending?`${error.message} Your review is retained. Inspect the operation or reload the document before reviewing again.`:`Status saved, but the view could not be refreshed. ${error.message}`;notice(feedback.textContent,true);}
20
- finally{state.busy=false;for(const button of root.querySelectorAll('button'))button.disabled=false;confirm.disabled=blocked;$('lifecycle-close').disabled=false;}
16
+ function renderReview(){$('lifecycle-form').hidden=true;const root=$('lifecycle-review');root.hidden=false;root.replaceChildren();const heading=document.createElement('h3');heading.textContent=`Change to ${pending.status}`;const report=document.createElement('pre');report.className='lifecycle-report';report.textContent=pending.report;const confirm=document.createElement('button');confirm.id='lifecycle-confirm';confirm.className='primary';confirm.textContent='Apply status change';confirm.disabled=!canApply();const back=document.createElement('button');back.className='quiet';back.textContent='Back to form';back.id='lifecycle-back';const inspect=document.createElement('button');inspect.className='quiet';inspect.textContent='Inspect operation';inspect.id='lifecycle-inspect';const feedback=document.createElement('p');feedback.id='lifecycle-feedback';feedback.setAttribute('role','status');root.append(heading,report,confirm,back,inspect,feedback);
17
+ back.onclick=()=>{if(state.busy)return;const previous=pending;localStorage.removeItem(key(pending.path));pending=null;root.hidden=true;$('lifecycle-form').hidden=!formEnabled;$('lifecycle-status').value=previous.status;$('lifecycle-note').value=previous.note;};
18
+ inspect.onclick=async()=>{
19
+ if(state.busy)return;state.busy=true;root.querySelector('#lifecycle-acknowledge')?.remove();for(const button of root.querySelectorAll('button'))button.disabled=true;$('lifecycle-close').disabled=true;
20
+ try{const result=await api('lifecycle/inspect',{operationId:pending.operationId});pending={...pending,...result,state:result?.state??'missing'};remember();report.textContent=result?.report??'No lifecycle review was retained. Return to the form and review again.';feedback.textContent=result?`Operation is ${result.state}.`:'No operation found.';
21
+ if(result?.state==='committed'){const old=pending.path;library.relocated(old,result.newPath);localStorage.removeItem(key(old));pending=null;state.busy=false;dialog.close();await open(result.newPath);await library.load({refresh:true});notice('The status change was already saved.');return;}
22
+ if(result?.failure)feedback.textContent+=` ${result.failure.message}`;if(result?.state==='running'){feedback.textContent+=' Inspect CLI transactions before starting another change.';if(result.canAcknowledge)acknowledge(result,root,feedback);else feedback.textContent+=' The runner is still active or cannot be verified as stopped.';}
23
+ }catch(error){if(pending){pending.state='uncertain';remember();}feedback.textContent=error.message;}
24
+ finally{state.busy=false;for(const button of root.querySelectorAll('button'))button.disabled=false;confirm.disabled=!canApply();$('lifecycle-close').disabled=false;}
25
+ };
26
+ confirm.onclick=async()=>{if(state.busy||!canApply())return;state.busy=true;for(const button of root.querySelectorAll('button'))button.disabled=true;$('lifecycle-close').disabled=true;
27
+ try{pending.state='submitting';remember();const result=await api('lifecycle/commit',{operationId:pending.operationId}),old=pending.path;library.relocated(old,result.newPath);localStorage.removeItem(key(pending.path));pending=null;state.busy=false;dialog.close();await open(result.newPath);await library.load({refresh:true});notice('Lifecycle status saved. Source, references and history follow the CLI rules.');}
28
+ catch(error){if(pending){pending.state='uncertain';remember();}feedback.textContent=pending?`${error.message} Your review is retained. Inspect the operation or reload the document before reviewing again.`:`Status saved, but the view could not be refreshed. ${error.message}`;notice(feedback.textContent,true);}
29
+ finally{state.busy=false;for(const button of root.querySelectorAll('button'))button.disabled=false;confirm.disabled=!canApply();$('lifecycle-close').disabled=false;}
21
30
  };
22
31
  }
23
32
  function acknowledge(result,root,feedback){
@@ -11,12 +11,14 @@ export function documentStages({state,$,changed}) {
11
11
  if(!plan)return;
12
12
  current=readSourceStage(source);
13
13
  const definitions=state.doc.stageDefinitions??[],known=definitions.some(stage=>stage.word===current.word);
14
+ $('document-stage-fields').hidden=!definitions.length&&!current.word&&!current.invalid;
14
15
  select.replaceChildren(new Option('Unset · no stage',''),...definitions.map(stage=>new Option(stage.word,`word:${stage.word}`)));
15
16
  if(current.word&&!known){const option=new Option(`${current.word} · unknown`,`word:${current.word}`);option.disabled=true;select.add(option);}
16
17
  if(current.invalid){const option=new Option('Invalid metadata','@invalid');option.disabled=true;select.add(option);}
17
18
  select.value=current.invalid?'@invalid':current.word?`word:${current.word}`:'';
18
19
  select.disabled=!state.doc.editable||!!state.pending||state.busy;
19
20
  const definition=definitions.find(stage=>stage.word===current.word);
20
- meaning.textContent=current.invalid?'This stage has unsupported metadata. Choose a configured stage or Unset, then review the change.':current.word&&!known?'This stage is not configured in this repository. Its current value is preserved until you choose a replacement.':definition?.meaning||(!definitions.length?'No stages are configured. Define taxonomy.milestones in runlist.config.mjs to choose a stage.':'Stage describes when this plan should ship. Changes stay in your draft until saved.');
21
+ meaning.textContent=current.invalid?'This stage has unsupported metadata. Choose a configured stage or Unset, then review the change.':current.word&&!known?'This stage is not configured in this repository. Its current value is preserved until you choose a replacement.':definition?.meaning||(!definitions.length?'No stages configured.':'');
22
+ meaning.hidden=!meaning.textContent;
21
23
  }};
22
24
  }
@@ -4,6 +4,23 @@ const modes = Object.keys(panels);
4
4
  // Keep the active tab focusable, associate each tab with its panel, and provide
5
5
  // the conventional arrow/Home/End navigation without capturing editor keys.
6
6
  export function documentViews({$, view}) {
7
+ // Measure the actual chrome: titles and larger text can wrap.
8
+ const header = document.querySelector('.app-header'), toolbar = document.querySelector('.document-toolbar');
9
+ const tabs = document.querySelector('.view-tabs');
10
+ const measure = () => {
11
+ const height = node => node?.getBoundingClientRect?.().height ?? 0;
12
+ const style = document.documentElement?.style;
13
+ if (!style) return;
14
+ style.setProperty('--app-header-height', `${height(header)}px`);
15
+ style.setProperty('--workspace-top', `${height(header)}px`);
16
+ style.setProperty('--document-toolbar-height', `${height(toolbar)}px`);
17
+ style.setProperty('--document-tabs-height', `${height(tabs)}px`);
18
+ };
19
+ if (typeof ResizeObserver !== 'undefined') {
20
+ const observer = new ResizeObserver(measure);
21
+ for (const node of [header, toolbar, tabs]) if (node) observer.observe(node);
22
+ }
23
+ measure();
7
24
  $('document-tabs').addEventListener('keydown', event => {
8
25
  const current = modes.findIndex(mode => event.target === $('tab-' + mode));
9
26
  if (current < 0) return;
@@ -22,7 +39,12 @@ export function documentViews({$, view}) {
22
39
  next = (next + step + modes.length) % modes.length;
23
40
  }
24
41
  });
25
- return {update(mode) {
42
+ return {focus(mode) {
43
+ const panel = $(panels[mode]);
44
+ // Only explicit primary actions move focus; rerenders preserve the caret.
45
+ const editor = mode === 'edit' ? (!$('markdown-fallback').hidden ? $('body-editor') : panel.querySelector('[data-editable][contenteditable="true"]')) : null;
46
+ (editor ?? panel).focus({preventScroll:true});
47
+ },update(mode) {
26
48
  for (const name of modes) {
27
49
  const selected = name === mode, tab = $('tab-' + name);
28
50
  tab.setAttribute('aria-selected', String(selected));
@@ -1,50 +1,43 @@
1
1
  import { documentOutline } from './outline.mjs';
2
- export function editorNavigation({state,editor,checkout,$,view,notice}) {
3
- let entries=[], bookmarks=[], path='', active=null, previous=[],previousModel;
4
- const storageKey=()=>`runlist:bookmarks:${checkout()}:${encodeURIComponent(state.doc.path)}`;
5
- function persist() { try { localStorage.setItem(storageKey(),JSON.stringify(bookmarks)); } catch { notice('Bookmarks could not be saved in local editor storage.',true); } }
2
+ export function editorNavigation({state,editor,$,view,notice}) {
3
+ let entries=[], path='', active=null;
6
4
  function jump(entry) {
7
5
  if (!entry || !state.doc) return;
8
6
  if (!['read','edit'].includes(state.mode)) view('edit');
9
7
  let target;
10
- if (state.mode==='read') target=[...$('reading').querySelectorAll('h1,h2,h3,h4,h5,h6')][entries.indexOf(entry)];
8
+ if (state.mode==='read') { target=[...$('reading').querySelectorAll('h1,h2,h3,h4,h5,h6')][entries.indexOf(entry)]; if(target){target.tabIndex=-1;target.focus({preventScroll:true});} }
11
9
  else if (!$('markdown-fallback').hidden) { target=$('body-editor'); target.focus(); target.setSelectionRange(entry.offset,entry.offset); const lineHeight=parseFloat(getComputedStyle(target).lineHeight)||22; target.scrollTop=Math.max(0,(entry.line-3)*lineHeight); }
12
10
  else { target=[...editor.root.querySelectorAll('[data-block]')].find(e=>e.dataset.block===entry.id); if (!entry.locked) editor.focus(entry.id); }
13
- target?.scrollIntoView({block:'center',behavior:'smooth'}); active=entry.key; highlight(); $('evidence').classList.remove('outline-open');
11
+ target?.scrollIntoView({block:'center',behavior:'smooth'}); active=entry.key; highlight(); outline(false);
14
12
  }
15
13
  function highlight() { for (const button of $('outline').querySelectorAll('[data-section]')) { const selected=button.dataset.section===active; button.setAttribute('aria-current',String(selected)); } }
16
- function toggle(entry) { const index=bookmarks.findIndex(b=>b.key===entry.key); if(index<0) bookmarks.push({key:entry.key,title:entry.title}); else bookmarks.splice(index,1); persist(); render(); }
17
- function row(entry, saved=false) {
14
+ function row(entry) {
18
15
  const div=document.createElement('div'); div.className='outline-row'; div.style.setProperty('--heading-level',entry.level ?? 1);
19
16
  const button=document.createElement('button'); button.className='section-link quiet'; button.textContent=entry.title; button.dataset.section=entry.key;
20
- const found=entries.find(e=>e.key===entry.key); button.disabled=!found; button.title=found ? `Jump to ${entry.title} · line ${found.line}` : 'This heading was renamed or removed. Remove this bookmark or bookmark the new heading.';
21
- button.onclick=()=>jump(found);
22
- const star=document.createElement('button'); star.className='section-bookmark quiet'; const marked=bookmarks.some(b=>b.key===entry.key); star.textContent=marked?'★':'☆'; star.setAttribute('aria-label',`${marked?'Remove bookmark for':'Bookmark'} ${entry.title}`);star.setAttribute('aria-pressed',String(marked));star.onclick=()=>{if(found)toggle(found);else{bookmarks=bookmarks.filter(b=>b.key!==entry.key);persist();render();}};
23
- div.append(button,star); if(saved&&!found) {const small=document.createElement('small');small.textContent='Heading unavailable';div.append(small);}return div;
17
+ button.title=`Jump to ${entry.title} · line ${entry.line}`;button.onclick=()=>jump(entry);
18
+ div.append(button);return div;
24
19
  }
25
20
  function render() {
26
21
  const query=$('section-filter').value.toLowerCase();
27
22
  $('outline').replaceChildren(...entries.filter(e=>e.title.toLowerCase().includes(query)).map(e=>row(e)));
28
23
  if(!$('outline').children.length)$('outline').textContent=query?'No matching headings.':'Add a heading to create a section.';
29
- $('bookmarks').replaceChildren(...bookmarks.map(b=>row(b,true)));$('bookmark-empty').hidden=bookmarks.length>0;
30
24
  $('section-count').textContent=entries.length;highlight();
31
25
  }
32
26
  function update() {
33
27
  if(!state.doc)return;
34
- const samePath=path===state.doc.path;
35
- if(!samePath){path=state.doc.path;active=null;previous=[];try{const stored=JSON.parse(localStorage.getItem(storageKey())??'[]');bookmarks=Array.isArray(stored)?stored.filter(b=>typeof b.key==='string'&&typeof b.title==='string'):[];}catch{bookmarks=[];}$('section-filter').value='';}
28
+ if(path!==state.doc.path){path=state.doc.path;active=null;$('section-filter').value='';}
36
29
  const model=editor.source()===state.body?editor.blocks:undefined;
37
- entries=documentOutline(state.body,model);
38
- // Inline block identity survives typing and insertions, so heading renames
39
- // also update their bookmarks. Source replacements leave unmatched bookmarks explicit.
40
- if(samePath&&model&&model===previousModel){let changed=false;for(const b of bookmarks){const old=previous.find(e=>e.key===b.key),fresh=old&&entries.find(e=>e.id===old.id);if(fresh&&fresh.key!==b.key){b.key=fresh.key;b.title=fresh.title;changed=true;}}if(changed)persist();}
41
- previous=entries;previousModel=model;render();
30
+ entries=documentOutline(state.body,model);render();
42
31
  }
43
32
  $('section-filter').oninput=render;
44
- $('toggle-outline').onclick=()=>{$('evidence').classList.toggle('outline-open');$('section-filter').focus();};
45
- $('close-outline').onclick=()=>$('evidence').classList.remove('outline-open');
33
+ function outline(show,restoreFocus=false){
34
+ $('evidence').classList.toggle('outline-open',show);$('toggle-outline').setAttribute('aria-expanded',String(show));
35
+ if(show)$('section-filter').focus();else if(restoreFocus)$('toggle-outline').focus();
36
+ }
37
+ $('toggle-outline').onclick=()=>outline(!$('evidence').classList.contains('outline-open'));
38
+ $('close-outline').onclick=()=>outline(false,true);
46
39
  $('add-section').onclick=()=>{if(!state.doc||state.busy||state.pending||!state.doc.editable)return;view('edit');if(!$('markdown-fallback').hidden)$('toggle-markdown').click();editor.mutate(()=>{const last=editor.blocks.filter(b=>!b.locked&&!['gap','comment'].includes(b.kind)).at(-1);const fresh=editor.insertAfter(last,'h2','New section');editor.render();editor.focus(fresh.id);});update();jump(entries.find(e=>e.id===editor.activeId));};
47
- document.addEventListener('keydown',event=>{if(!state.doc||['home','records','filing'].includes(state.mode)||document.querySelector('dialog[open]'))return;if((event.metaKey||event.ctrlKey)&&event.shiftKey&&event.key.toLowerCase()==='j'){event.preventDefault();$('evidence').classList.add('outline-open');$('section-filter').focus();}if(event.altKey&&['PageDown','PageUp'].includes(event.key)){event.preventDefault();const index=entries.findIndex(e=>e.key===active);jump(entries[Math.max(0,Math.min(entries.length-1,index+(event.key==='PageDown'?1:-1)))]);}if(event.key==='Escape')$('evidence').classList.remove('outline-open');});
40
+ document.addEventListener('keydown',event=>{if(!state.doc||['home','records','filing'].includes(state.mode)||document.querySelector('dialog[open]'))return;if((event.metaKey||event.ctrlKey)&&event.shiftKey&&event.key.toLowerCase()==='j'){event.preventDefault();outline(true);}if(event.altKey&&['PageDown','PageUp'].includes(event.key)){event.preventDefault();const index=entries.findIndex(e=>e.key===active);jump(entries[Math.max(0,Math.min(entries.length-1,index+(event.key==='PageDown'?1:-1)))]);}if(event.key==='Escape'&&$('evidence').classList.contains('outline-open'))outline(false,true);});
48
41
  $('section-filter').onkeydown=event=>{if(event.key==='Enter'){event.preventDefault();jump(entries.find(e=>e.title.toLowerCase().includes($('section-filter').value.toLowerCase())));}};
49
42
  let scheduled=false;window.addEventListener('scroll',()=>{if(scheduled||!state.doc||!['read','edit'].includes(state.mode)||!$('markdown-fallback').hidden)return;scheduled=true;requestAnimationFrame(()=>{scheduled=false;const nodes=state.mode==='read'?[...$('reading').querySelectorAll('h1,h2,h3,h4,h5,h6')]:entries.map(e=>[...editor.root.querySelectorAll('[data-block]')].find(n=>n.dataset.block===e.id));let current=entries[0];for(let i=0;i<nodes.length;i++)if(nodes[i]?.getBoundingClientRect().top<260)current=entries[i];if(current){active=current.key;highlight();}});},{passive:true});
50
43
  return {update,jumpTo:section=>{update();const entry=entries.find(e=>e.line===section.line&&e.title.slice(0,200)===section.title)||entries.find(e=>e.title.slice(0,200)===section.title);if(entry)jump(entry);else notice('This section changed since the search. Use the document outline to find it.',true);}};