fdeops 3.23.0 → 3.24.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.
@@ -0,0 +1,166 @@
1
+ 'use strict'
2
+
3
+ // Embedded verbatim in the offline fieldbook. No imports, network, or record writes.
4
+ module.exports = function fieldbookClient() {
5
+ const all = selector => Array.from(document.querySelectorAll(selector))
6
+ const views = all('.fb-view')
7
+ const nav = all('.fb-nav')
8
+ const search = document.getElementById('fb-search')
9
+ const main = document.querySelector('.fb-main')
10
+ const status = document.getElementById('fb-status')
11
+ const palette = document.getElementById('fb-palette-dialog')
12
+ const paletteInput = document.getElementById('fb-palette-input')
13
+ const items = all('.fb-palette-item')
14
+ const promptDialog = document.getElementById('fb-prompt-dialog')
15
+ let returnFocus = null
16
+ let active = 0
17
+ const currentId = () => document.querySelector('.fb-view:not([hidden])').id.replace('view-', '')
18
+ function selectView(id, updateHash = true, focus = false) {
19
+ if (id === 'fb-main') { id = 'today'; updateHash = false }
20
+ if (!document.getElementById('view-' + id)) id = 'today'
21
+ views.forEach(v => { v.hidden = v.id !== 'view-' + id })
22
+ nav.forEach(n => {
23
+ const selected = n.dataset.target === id
24
+ n.classList.toggle('active', selected)
25
+ if (selected) n.setAttribute('aria-current', 'page')
26
+ else n.removeAttribute('aria-current')
27
+ })
28
+ if (updateHash && location.hash !== '#' + id) location.hash = id
29
+ main.scrollTop = 0
30
+ if (focus) document.querySelector('.fb-view:not([hidden]) h1').focus()
31
+ }
32
+ const hashId = () => location.hash.slice(1)
33
+ selectView(hashId() || 'today', false)
34
+ window.addEventListener('hashchange', () => selectView(hashId(), false))
35
+ const skip = document.querySelector('.fb-skip')
36
+ if (skip) skip.addEventListener('click', event => {
37
+ event.preventDefault()
38
+ main.focus()
39
+ })
40
+ nav.forEach(n => n.addEventListener('click', () => selectView(n.dataset.target, true, true)))
41
+ all('[data-nav]').forEach(n => n.addEventListener('click', () => selectView(n.dataset.nav, true, true)))
42
+
43
+ // Only escaped fragments become markup; engagement strings never become code.
44
+ const esc = s => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
45
+ function snippet(raw, term) {
46
+ const i = raw.indexOf(term)
47
+ const start = Math.max(0, i - 24), end = Math.min(raw.length, i + term.length + 40)
48
+ return (start ? '…' : '') + esc(raw.slice(start, i)) + '<mark>' + esc(raw.slice(i, i + term.length)) + '</mark>' + esc(raw.slice(i + term.length, end)) + (end < raw.length ? '…' : '')
49
+ }
50
+ search.addEventListener('input', () => {
51
+ const term = search.value.trim().toLowerCase()
52
+ let count = 0
53
+ nav.filter(n => n.dataset.target !== 'today').forEach(n => {
54
+ const raw = n.dataset.search || ''
55
+ const hit = !term || raw.includes(term)
56
+ n.classList.toggle('hide', !hit)
57
+ if (hit) count++
58
+ const reason = n.querySelector('.fb-nav-reason')
59
+ if (term && hit) reason.innerHTML = snippet(raw, term)
60
+ else reason.textContent = n.dataset.defaultReason || ''
61
+ reason.classList.toggle('hide', !reason.textContent)
62
+ })
63
+ document.getElementById('fb-search-empty').hidden = !term || count > 0
64
+ document.getElementById('fb-search-count').textContent = term ? count + ' matching engagement' + (count === 1 ? '' : 's') : ''
65
+ })
66
+
67
+ function toggleTheme() {
68
+ const next = document.documentElement.dataset.fdeTheme === 'dark' ? 'light' : 'dark'
69
+ document.documentElement.dataset.fdeTheme = next
70
+ try { localStorage.setItem('fde-fieldbook-theme', next) } catch (_) {}
71
+ themeLabel()
72
+ }
73
+ function themeLabel() {
74
+ const dark = document.documentElement.dataset.fdeTheme === 'dark'
75
+ document.getElementById('fde-theme-btn').textContent = dark ? 'Light theme' : 'Dark theme'
76
+ }
77
+ themeLabel()
78
+ document.getElementById('fde-theme-btn').addEventListener('click', toggleTheme)
79
+ document.getElementById('fb-print-btn').addEventListener('click', () => window.print())
80
+
81
+ function openDialog(dialog) {
82
+ returnFocus = document.activeElement
83
+ dialog.showModal()
84
+ }
85
+ ;[palette, promptDialog].forEach(dialog => {
86
+ dialog.addEventListener('close', () => { if (returnFocus) returnFocus.focus() })
87
+ dialog.addEventListener('click', event => { if (event.target === dialog) dialog.close() })
88
+ // Native modal dialogs isolate background controls; contain Tab explicitly
89
+ // so keyboard focus never escapes into browser chrome between controls.
90
+ dialog.addEventListener('keydown', event => {
91
+ if (event.key !== 'Tab') return
92
+ const controls = Array.from(dialog.querySelectorAll('input, textarea, button')).filter(el => !el.hidden && !el.classList.contains('hide'))
93
+ const first = controls[0], last = controls[controls.length - 1]
94
+ if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus() }
95
+ else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
96
+ })
97
+ })
98
+ all('[data-close-dialog]').forEach(button => button.addEventListener('click', () => button.closest('dialog').close()))
99
+ const visibleItems = () => items.filter(it => !it.classList.contains('hide'))
100
+ function highlight() {
101
+ const visible = visibleItems()
102
+ items.forEach(it => it.classList.remove('active'))
103
+ if (visible[active]) visible[active].classList.add('active')
104
+ }
105
+ function filterPalette() {
106
+ const term = paletteInput.value.trim().toLowerCase()
107
+ items.forEach(it => it.classList.toggle('hide', !it.textContent.toLowerCase().includes(term)))
108
+ active = 0
109
+ highlight()
110
+ document.getElementById('fb-palette-empty').hidden = visibleItems().length > 0
111
+ }
112
+ function openPalette() { openDialog(palette); paletteInput.value = ''; filterPalette(); paletteInput.focus() }
113
+ function runItem(it) {
114
+ if (!it) return
115
+ palette.close()
116
+ if (it.dataset.target === '__theme__') toggleTheme()
117
+ else selectView(it.dataset.target, true, true)
118
+ }
119
+ document.getElementById('fb-palette-btn').addEventListener('click', openPalette)
120
+ paletteInput.addEventListener('input', filterPalette)
121
+ items.forEach(it => it.addEventListener('click', () => runItem(it)))
122
+ paletteInput.addEventListener('keydown', event => {
123
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
124
+ event.preventDefault()
125
+ active = Math.max(0, Math.min(visibleItems().length - 1, active + (event.key === 'ArrowDown' ? 1 : -1)))
126
+ highlight()
127
+ if (visibleItems()[active]) visibleItems()[active].scrollIntoView({ block: 'nearest' })
128
+ }
129
+ if (event.key === 'Enter') { event.preventDefault(); runItem(visibleItems()[active]) }
130
+ })
131
+
132
+ all('[data-prompt]').forEach(button => button.addEventListener('click', async () => {
133
+ const text = button.dataset.prompt
134
+ try {
135
+ await navigator.clipboard.writeText(text)
136
+ status.textContent = 'Prompt copied. Paste it into your coding agent; review changes before saving.'
137
+ } catch (_) {
138
+ const input = document.getElementById('fb-prompt-text')
139
+ input.value = text
140
+ openDialog(promptDialog)
141
+ input.focus(); input.select()
142
+ }
143
+ }))
144
+ document.addEventListener('keydown', event => {
145
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
146
+ event.preventDefault()
147
+ if (!promptDialog.open) { if (palette.open) palette.close(); else openPalette() }
148
+ return
149
+ }
150
+ if (palette.open || promptDialog.open || event.metaKey || event.ctrlKey || event.altKey) return
151
+ const typing = event.target.matches('input, textarea, select') || event.target.isContentEditable
152
+ if (typing) {
153
+ if (event.key === 'Escape') { search.value = ''; search.dispatchEvent(new Event('input')); event.target.blur() }
154
+ return
155
+ }
156
+ if (event.target.closest('.fb-table-scroll')) return
157
+ if (event.key === '/') { event.preventDefault(); search.focus(); return }
158
+ if (event.key === 'j' || event.key === 'k' || (event.target.closest('.fb-rail') && ['ArrowDown', 'ArrowUp'].includes(event.key))) {
159
+ event.preventDefault()
160
+ const ids = nav.filter(n => !n.classList.contains('hide')).map(n => n.dataset.target)
161
+ const delta = event.key === 'j' || event.key === 'ArrowDown' ? 1 : -1
162
+ const next = ids[Math.max(0, Math.min(ids.length - 1, ids.indexOf(currentId()) + delta))]
163
+ if (next) selectView(next)
164
+ }
165
+ })
166
+ }
package/bin/lib/render.js CHANGED
@@ -243,6 +243,49 @@ strong{font-weight:600}
243
243
  .fb-palette-hint{margin-left:auto;font-family:'Geist Mono',monospace;font-size:11px;color:var(--ink-faint)}
244
244
  @media(max-width:760px){.fb-rail{flex:0 0 220px}.fb-main-inner{padding:20px}}
245
245
  @media(max-width:640px){.fb-body{flex-direction:column}.fb-rail{flex:0 0 auto;max-height:40vh;border-right:none;border-bottom:1px solid var(--line)}.fb-main-inner{padding:16px}}
246
+ /* Daily work: evidence is the focal point; chrome stays quiet. */
247
+ :root{--ink-faint:#68665f;--amber:#86530d}
248
+ html[data-fde-theme="dark"]{--ink-faint:#a39f94}
249
+ button:focus-visible,input:focus-visible,textarea:focus-visible,summary:focus-visible,a:focus-visible,[tabindex="0"]:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
250
+ .fb-h1{font-family:'Geist',system-ui,sans-serif;font-size:32px;letter-spacing:-.035em}
251
+ .fb-h1:focus{outline:none}
252
+ .fb-sec{font-family:inherit;font-size:14px;font-weight:600;letter-spacing:0;text-transform:none;color:var(--ink)}
253
+ .fb-main{min-width:0}.fb-main-inner{max-width:1280px}.fb-top-grid>*{min-width:0}
254
+ .fb-meta-line{line-height:1.6}.fb-eyebrow{font-family:inherit;font-size:12px}
255
+ .fb-snapshot{font-size:11px;color:var(--ink-faint);line-height:1.6;margin-bottom:24px}
256
+ .fb-actions{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-top:20px}
257
+ .fb-action{padding:9px 13px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--ink);font-size:12px;font-weight:500;cursor:pointer}
258
+ .fb-action:first-child{background:var(--accent);color:#fff;border-color:var(--accent)}
259
+ html[data-fde-theme="dark"] .fb-action:first-child{color:#102219}
260
+ .fb-action:hover{border-color:var(--accent)}.fb-actions>span{font-size:11px;color:var(--ink-faint);margin-left:4px}
261
+ .fb-queue-row,.fb-flag-row{border:0;background:transparent;width:100%;text-align:left;color:var(--ink)}
262
+ .fb-queue-row{padding:14px 10px;align-items:center}.fb-queue-slug{font-family:inherit;font-weight:600;color:var(--ink)}
263
+ .fb-flag-row{padding:10px}.fb-flag-text{font-family:inherit;font-size:13px}
264
+ .fb-value-table{min-width:700px;table-layout:fixed;margin:0}.fb-value-table th,.fb-value-table td{padding:14px 12px;overflow-wrap:anywhere;line-height:1.5}
265
+ .fb-value-table thead th{font-family:inherit;text-transform:none;letter-spacing:0;font-size:12px;font-weight:500}
266
+ .fb-value-table tbody th{font-family:inherit;text-transform:none;letter-spacing:0;font-size:13px;color:var(--ink)}
267
+ .fb-value-state{display:block;margin-top:5px;font-size:11px;font-weight:400}
268
+ .fb-table-scroll{overflow-x:auto;border:1px solid var(--line);border-radius:8px;margin-top:12px}
269
+ .fb-evidence-note{font-size:12px;line-height:1.5;color:var(--ink-faint);margin:8px 0}
270
+ .fb-empty-evidence{padding:18px;border:1px dashed var(--line);border-radius:8px;margin-top:12px;font-size:14px}
271
+ .fb-empty-evidence p{color:var(--ink-soft);max-width:65ch;line-height:1.6;margin:6px 0 0}
272
+ .fb-copy-status{font-size:13px;color:var(--accent);line-height:1.5}.fb-copy-status:empty{display:none}
273
+ .fb-dialog{padding:0;width:520px;max-width:calc(100vw - 32px);max-height:80vh;border:1px solid var(--line);border-radius:12px;background:var(--bg);color:var(--ink);box-shadow:var(--shadow)}
274
+ .fb-dialog::backdrop{background:rgba(0,0,0,.45)}
275
+ .fb-dialog-head{display:flex;align-items:center;justify-content:space-between;padding:14px 16px;font-size:14px;font-weight:600;gap:12px;border-bottom:1px solid var(--line)}
276
+ .fb-dialog-help{padding:0 16px;font-size:13px;line-height:1.6;color:var(--ink-soft)}
277
+ #fb-prompt-text{display:block;width:calc(100% - 32px);margin:16px;padding:12px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--ink);font:13px/1.6 inherit;resize:vertical}
278
+ .fb-palette-item{width:100%;border:0;background:transparent;text-align:left;color:var(--ink)}
279
+ .fb-empty-search{font-size:13px;line-height:1.6;color:var(--ink-soft);padding:12px}.fb-empty-search[hidden]{display:none}
280
+ .sr-only,.fb-skip:not(:focus){position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}
281
+ .fb-skip:focus{position:fixed;top:8px;left:8px;z-index:100;background:var(--bg);color:var(--ink);padding:10px}
282
+ .fb-more-body{overflow-x:auto}.fb-title-row{flex-wrap:wrap}.fb-person{flex-wrap:wrap}.fb-person-note{min-width:140px}
283
+ @media(max-width:1100px){.fb-main-inner{padding:24px}.fb-rail{flex-basis:240px}.fb-grid-wrap{grid-template-columns:1fr}.fb-need{display:none}}
284
+ @media(max-width:640px){.fb-header{padding:12px;gap:8px;flex-wrap:wrap}.fb-today{display:none}.fb-brand{margin-right:auto}.fb-tagline{font-family:inherit}.fb-btn{min-height:34px}.fb-rail{max-height:190px;flex-basis:auto}.fb-main-inner{padding:20px 16px}.fb-h1{font-size:28px}.fb-queue-row{flex-wrap:wrap;gap:8px}.fb-queue-slug{flex-basis:calc(100% - 24px)}.fb-queue-action{flex-basis:100%;padding-left:16px}.fb-queue-touched{padding-left:16px}.fb-flag-row{flex-direction:column;gap:4px}.fb-flag-slug{flex-basis:auto}.fb-grid-wrap{grid-template-columns:minmax(0,1fr)}.fb-log{flex-wrap:wrap}.fb-log-text{flex-basis:100%}.fb-hints-spacer{margin-left:0}.fb-snapshot{margin-bottom:18px}.fb-body{min-height:0}.fb-actions>span{width:100%;margin-top:4px}.fb-app{height:100dvh}}
285
+ @media(max-width:390px){.fb-value-table{min-width:0}.fb-person-name,.fb-person-role{flex:1 1 100%}.fb-rail{max-height:150px}.fb-main-inner{padding:16px 12px}}
286
+ @media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;scroll-behavior:auto!important}}
287
+ @media print{@page{margin:12mm}html,body,.fb-app{height:auto;overflow:visible;background:#fff;color:#000}.fb-header,.fb-rail,.fb-actions,.fb-hints,.fb-copy-status,.fb-dialog,.fb-skip{display:none!important}.fb-body,.fb-main{display:block;overflow:visible}.fb-main-inner{padding:0;max-width:none}.fb-top-grid{grid-template-columns:1fr}.fb-table-scroll{overflow:visible}.fb-value-table{min-width:0;table-layout:fixed;font-size:10px}.fb-value-table th,.fb-value-table td{padding:6px}.fb-view[hidden]{display:none!important}.fb-block{break-inside:avoid}.fb-vitals{background:#fff}*{color:#000!important;box-shadow:none!important}}
288
+
246
289
  `
247
290
  }
248
291
 
@@ -253,143 +296,7 @@ strong{font-weight:600}
253
296
  // command palette are wired the same way the theme toggle already was here -
254
297
  // addEventListener, guarded null checks, no inline handlers.
255
298
  function dashScript() {
256
- return [
257
- "(function(){",
258
- "'use strict';",
259
- "var SEL_KEY='fde-fieldbook-sel';",
260
- "function esc(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}",
261
- "function all(sel){return Array.prototype.slice.call(document.querySelectorAll(sel));}",
262
- "",
263
- "var views=all('.fb-view');",
264
- "var navItems=all('.fb-nav');",
265
- "var searchInput=document.getElementById('fb-search');",
266
- "",
267
- "function selectView(id){",
268
- " if(!document.getElementById('view-'+id))return;",
269
- " views.forEach(function(v){v.hidden=(v.id!=='view-'+id);});",
270
- " navItems.forEach(function(n){n.classList.toggle('active',n.getAttribute('data-target')===id);});",
271
- " try{localStorage.setItem(SEL_KEY,id);}catch(e){}",
272
- "}",
273
- "navItems.forEach(function(n){n.addEventListener('click',function(){selectView(n.getAttribute('data-target'));});});",
274
- "all('[data-nav]').forEach(function(n){n.addEventListener('click',function(){selectView(n.getAttribute('data-nav'));});});",
275
- "",
276
- "var startSel='today';",
277
- "try{var s=localStorage.getItem(SEL_KEY);if(s&&document.getElementById('view-'+s))startSel=s;}catch(e){}",
278
- "selectView(startSel);",
279
- "",
280
- "// search: filters the rail, shows a match snippet in place of the default reason line",
281
- "// NOTE: snippetFor() below returns a string built ONLY from esc()-escaped",
282
- "// fragments plus hardcoded '<mark>' literals - never raw untrusted markup -",
283
- "// so assigning it via innerHTML cannot inject a tag from engagement content.",
284
- "function snippetFor(raw,term){",
285
- " var i=raw.indexOf(term);",
286
- " if(i<0)return '';",
287
- " var start=Math.max(0,i-24),end=Math.min(raw.length,i+term.length+40);",
288
- " var pre=esc(raw.slice(start,i)),hit=esc(raw.slice(i,i+term.length)),post=esc(raw.slice(i+term.length,end));",
289
- " return (start>0?'\\u2026':'')+pre+'<mark>'+hit+'</mark>'+post+(end<raw.length?'\\u2026':'');",
290
- "}",
291
- "if(searchInput){searchInput.addEventListener('input',function(){",
292
- " var term=searchInput.value.trim().toLowerCase();",
293
- " navItems.forEach(function(n){",
294
- " if(n.getAttribute('data-target')==='today')return;",
295
- " var raw=(n.getAttribute('data-search')||'').toLowerCase();",
296
- " var hit=!term||raw.indexOf(term)>-1;",
297
- " n.classList.toggle('hide',!hit);",
298
- " var reason=n.querySelector('.fb-nav-reason');",
299
- " if(!reason)return;",
300
- " if(term&&hit){var snip=snippetFor(raw,term);reason.innerHTML=snip;reason.classList.toggle('hide',!snip);}",
301
- " else{reason.innerHTML=n.getAttribute('data-default-reason')||'';reason.classList.toggle('hide',!reason.innerHTML);}",
302
- " });",
303
- "});}",
304
- "",
305
- "// keyboard: j/k/arrows move rail selection, / focuses search, Esc blurs/closes",
306
- "function visibleIds(){return navItems.filter(function(n){return !n.classList.contains('hide');}).map(function(n){return n.getAttribute('data-target');});}",
307
- "function currentId(){var v=document.querySelector('.fb-view:not([hidden])');return v?v.id.replace('view-',''):'today';}",
308
- "document.addEventListener('keydown',function(ev){",
309
- " if((ev.metaKey||ev.ctrlKey)&&(ev.key==='k'||ev.key==='K')){ev.preventDefault();openPalette();return;}",
310
- " if(paletteIsOpen()){if(ev.key==='Escape'){ev.preventDefault();closePalette();}return;}",
311
- " var t=ev.target,typing=t&&(t.tagName==='INPUT'||t.tagName==='TEXTAREA'||t.isContentEditable);",
312
- " if(typing){if(ev.key==='Escape')t.blur();return;}",
313
- " if(ev.key==='/'){ev.preventDefault();if(searchInput)searchInput.focus();return;}",
314
- " var down=ev.key==='ArrowDown'||ev.key==='j',up=ev.key==='ArrowUp'||ev.key==='k';",
315
- " if(down||up){",
316
- " ev.preventDefault();",
317
- " var ids=visibleIds();",
318
- " var idx=ids.indexOf(currentId());",
319
- " var next=ids[Math.min(ids.length-1,Math.max(0,idx+(down?1:-1)))];",
320
- " if(next)selectView(next);",
321
- " }",
322
- "});",
323
- "",
324
- "// command palette: search-filterable nav (today + each client + theme toggle)",
325
- "var paletteBackdrop=document.getElementById('fb-palette-backdrop');",
326
- "var paletteInput=document.getElementById('fb-palette-input');",
327
- "var paletteInner=document.querySelector('.fb-palette');",
328
- "var paletteItems=all('.fb-palette-item');",
329
- "var pActive=0;",
330
- "function paletteIsOpen(){return !!(paletteBackdrop&&paletteBackdrop.classList.contains('open'));}",
331
- "function visiblePaletteItems(){return paletteItems.filter(function(it){return !it.classList.contains('hide');});}",
332
- "function highlightPalette(){",
333
- " var visible=visiblePaletteItems();",
334
- " paletteItems.forEach(function(it){it.classList.remove('active');});",
335
- " if(visible[pActive]){visible[pActive].classList.add('active');visible[pActive].scrollIntoView({block:'nearest'});}",
336
- "}",
337
- "function filterPalette(q){",
338
- " q=q.toLowerCase();",
339
- " paletteItems.forEach(function(it){",
340
- " var label=(it.getAttribute('data-label')||'').toLowerCase();",
341
- " var kind=(it.getAttribute('data-kind')||'').toLowerCase();",
342
- " it.classList.toggle('hide',!(!q||label.indexOf(q)>-1||kind.indexOf(q)>-1));",
343
- " });",
344
- " pActive=0;highlightPalette();",
345
- "}",
346
- "function openPalette(){",
347
- " if(!paletteBackdrop)return;",
348
- " paletteBackdrop.classList.add('open');",
349
- " if(paletteInput){paletteInput.value='';paletteInput.focus();}",
350
- " filterPalette('');",
351
- "}",
352
- "function closePalette(){if(paletteBackdrop)paletteBackdrop.classList.remove('open');}",
353
- "function runActive(){",
354
- " var it=visiblePaletteItems()[pActive];",
355
- " if(!it)return;",
356
- " var target=it.getAttribute('data-target');",
357
- " if(target==='__theme__')toggleTheme();",
358
- " else if(target)selectView(target);",
359
- " closePalette();",
360
- "}",
361
- "if(paletteBackdrop)paletteBackdrop.addEventListener('click',function(ev){if(ev.target===paletteBackdrop)closePalette();});",
362
- "if(paletteInner)paletteInner.addEventListener('click',function(ev){ev.stopPropagation();});",
363
- "var paletteBtn=document.getElementById('fb-palette-btn');",
364
- "if(paletteBtn)paletteBtn.addEventListener('click',openPalette);",
365
- "paletteItems.forEach(function(it){",
366
- " it.addEventListener('click',function(){pActive=visiblePaletteItems().indexOf(it);runActive();});",
367
- " it.addEventListener('mouseenter',function(){var v=visiblePaletteItems();var i=v.indexOf(it);if(i>-1){pActive=i;highlightPalette();}});",
368
- "});",
369
- "if(paletteInput){",
370
- " paletteInput.addEventListener('input',function(){filterPalette(paletteInput.value);});",
371
- " paletteInput.addEventListener('keydown',function(ev){",
372
- " if(ev.key==='Escape'){closePalette();return;}",
373
- " if(ev.key==='ArrowDown'){ev.preventDefault();pActive=Math.min(visiblePaletteItems().length-1,pActive+1);highlightPalette();return;}",
374
- " if(ev.key==='ArrowUp'){ev.preventDefault();pActive=Math.max(0,pActive-1);highlightPalette();return;}",
375
- " if(ev.key==='Enter'){ev.preventDefault();runActive();}",
376
- " });",
377
- "}",
378
- "",
379
- "// theme toggle - local only, no network; pre-paint script in <head> avoids a flash",
380
- "var themeBtn=document.getElementById('fde-theme-btn');",
381
- "function currentTheme(){return document.documentElement.getAttribute('data-fde-theme')==='dark'?'dark':'light';}",
382
- "function setThemeLabel(){if(themeBtn)themeBtn.textContent=(currentTheme()==='dark'?'light':'dark');}",
383
- "function toggleTheme(){",
384
- " var next=currentTheme()==='dark'?'light':'dark';",
385
- " try{localStorage.setItem('fde-fieldbook-theme',next);}catch(e){}",
386
- " document.documentElement.setAttribute('data-fde-theme',next);",
387
- " setThemeLabel();",
388
- "}",
389
- "setThemeLabel();",
390
- "if(themeBtn)themeBtn.addEventListener('click',toggleTheme);",
391
- "})();",
392
- ].join('\n')
299
+ return '(' + require('./fieldbook-client').toString() + ')()'
393
300
  }
394
301
 
395
302
  // ---- HTML builders (one per fieldbook widget) - kept small and named so the
@@ -399,7 +306,7 @@ function railItemHtml(e) {
399
306
  const dotClass = dotClassFor(e.signals.trust)
400
307
  const touchedClass = e.quiet ? 't-amber' : 't-faint'
401
308
  const preview = e.next.length > 58 ? e.next.slice(0, 56) + '…' : e.next
402
- const defaultReason = e.signals.trust !== 'green' && preview ? escapeHtml(preview) : ''
309
+ const defaultReason = escapeHtml((e.attention || [])[0]?.text || preview)
403
310
  return `<button class="fb-nav" data-target="eng-${e.slug}" data-search="${e.searchBlob}" data-default-reason="${defaultReason}">
404
311
  <div class="fb-nav-row"><span class="dot dot-sm ${dotClass}"></span><span class="fb-nav-name">${inlineMd(e.name)}</span></div>
405
312
  <div class="fb-nav-meta">${escapeHtml(e.phaseLabel)} &middot; <span class="${touchedClass}">${escapeHtml(e.signals.updated)}</span></div>
@@ -410,46 +317,88 @@ function railItemHtml(e) {
410
317
  function queueRowHtml(e) {
411
318
  const dotClass = dotClassFor(e.signals.trust)
412
319
  const noAction = !e.hasNext
413
- const actionText = noAction ? 'no next action set - open and add one' : e.next
320
+ const actionText = noAction ? 'Set the next action with your agent' : e.next
414
321
  const touchedClass = e.quiet ? 't-amber' : 't-faint'
415
- return `<div class="fb-row fb-queue-row" data-nav="eng-${e.slug}">
322
+ return `<button type="button" class="fb-row fb-queue-row" data-nav="eng-${e.slug}">
416
323
  <span class="dot dot-md ${dotClass}"></span>
417
324
  <span class="fb-queue-slug">${inlineMd(e.name)}</span>
418
325
  <span class="fb-queue-action${noAction ? ' t-amber' : ''}">${inlineMd(actionText)}</span>
419
326
  <span class="fb-queue-touched ${touchedClass}">${escapeHtml(e.signals.updated)}</span>
420
- </div>`
327
+ </button>`
421
328
  }
422
329
 
423
330
  function flagRowHtml(e, text, colorClass) {
424
- return `<div class="fb-row fb-flag-row" data-nav="eng-${e.slug}">
331
+ return `<button type="button" class="fb-row fb-flag-row" data-nav="eng-${e.slug}">
425
332
  <span class="fb-flag-slug">${inlineMd(e.name)}</span>
426
333
  <span class="fb-flag-text t-${colorClass}">${escapeHtml(text)}</span>
427
- </div>`
334
+ </button>`
428
335
  }
429
336
 
430
337
  function todayViewHtml({ today, total, attentionCount, highRiskTotal, todayQueue, flagsHtml, hasEngagements }) {
431
338
  if (!hasEngagements) {
432
339
  return `<div id="view-today" class="fb-view">
433
- <div class="fb-eyebrow">portfolio</div>
434
- <h1 class="fb-h1">Today</h1>
340
+ <div class="fb-eyebrow">Your engagements</div>
341
+ <h1 tabindex="-1" class="fb-h1">Today</h1>
435
342
  <p class="empty">No engagements yet. Start one with <code>fde resume --init &lt;client-name&gt;</code>, then re-run <code>fde dashboard</code>.</p>
436
343
  </div>`
437
344
  }
438
345
  return `<div id="view-today" class="fb-view">
439
- <div class="fb-eyebrow">portfolio</div>
440
- <h1 class="fb-h1">Today</h1>
346
+ <div class="fb-eyebrow">Your engagements</div>
347
+ <h1 tabindex="-1" class="fb-h1">Today</h1>
441
348
  <div class="fb-meta-line">${escapeHtml(today)} &middot; ${total} engagement${total === 1 ? '' : 's'} &middot; ${attentionCount} need you &middot; ${highRiskTotal} high risk${highRiskTotal === 1 ? '' : 's'} open</div>
442
349
  <div class="fb-block">
443
- <div class="fb-sec">Queue - next action per client, worst first</div>
350
+ <div class="fb-sec">Next actions</div>
444
351
  ${todayQueue}
445
352
  </div>
446
353
  ${flagsHtml ? `<div class="fb-block">
447
- <div class="fb-sec">Flags</div>
354
+ <div class="fb-sec">Needs attention</div>
448
355
  ${flagsHtml}
449
356
  </div>` : ''}
450
357
  </div>`
451
358
  }
452
359
 
360
+ function attentionFor(e) {
361
+ const items = []
362
+ const add = (text, tone = 'amber') => items.push({ text, tone })
363
+ if (e.signals.memoryWarn) add('Record needs repair: ' + e.signals.memoryWarn, 'red')
364
+ if (e.highRisks) add(`${e.highRisks} high risk${e.highRisks === 1 ? '' : 's'} open`, 'red')
365
+ if (e.signals.trust === 'RED' || e.signals.trust === 'amber') add('Check in with the customer: trust is ' + trustWord(e.signals.trust), e.signals.trust === 'RED' ? 'red' : 'amber')
366
+ if (e.signals.trust === 'new') add('No dated trust signal yet - ask someone')
367
+ if (!e.hasNext) add('Set the next action')
368
+ const rows = e.valueRows || []
369
+ const missingEvidence = rows.filter(r => r.evidenceMissing).length
370
+ if (missingEvidence) add(`${missingEvidence} outcome${missingEvidence === 1 ? '' : 's'} missing evidence`)
371
+ const claimed = rows.filter(r => r.state === 'claimed').length
372
+ const unmeasured = rows.filter(r => r.state === 'unmeasured').length
373
+ if (claimed) add(`${claimed} measured outcome${claimed === 1 ? '' : 's'} awaiting acceptance`)
374
+ if (unmeasured) add(`${unmeasured} outcome${unmeasured === 1 ? '' : 's'} not yet measured`)
375
+ if (e.signals.stale) add('Reconfirm the dated trust signal')
376
+ if (e.quiet) add('Record last updated ' + e.signals.updated)
377
+ return items
378
+ }
379
+
380
+ function agentActionsHtml(e) {
381
+ const prefix = `@fde Confirm the workspace is bound to ${JSON.stringify(e.name)} before using its records. `
382
+ const actions = [
383
+ ['Prepare meeting', 'Prepare me for the next customer meeting. Use the recorded decisions, open risks, delivery evidence and next action. Cite sources and flag unknowns.'],
384
+ ['Debrief notes', 'Help me debrief a customer meeting. Ask me for the notes, then show proposed changes and conflicts for review before saving.'],
385
+ ['Review outcome', 'Review what we promised, measured and recorded as accepted. Check the evidence and customer approver; do not infer acceptance. Draft a concise customer readout.'],
386
+ ]
387
+ return `<div class="fb-actions" aria-label="Continue in your coding agent">${actions.map(([label, prompt]) => `<button type="button" class="fb-action" data-prompt="${escapeHtml(prefix + prompt)}">${label}</button>`).join('')}<span>Copy a prompt into your agent</span></div>`
388
+ }
389
+
390
+ function deliveryHtml(e) {
391
+ const rows = e.valueRows || []
392
+ const labels = { unmeasured: 'Not yet measured', claimed: 'Awaiting acceptance', accepted: 'Acceptance recorded' }
393
+ const pending = value => !value || /^(?:pending|tbd|unknown|n\/a|none|awaiting)(?:\b|$)/i.test(value)
394
+ const cell = (value, fallback) => pending(value) ? `<span class="t-faint">${escapeHtml(value || fallback)}</span>` : inlineMd(value)
395
+ return `<section class="fb-block fb-delivery" aria-label="Delivery evidence">
396
+ <div class="fb-sec-row"><h2 class="fb-sec">Delivery evidence</h2><span class="fb-count">${rows.length} outcome${rows.length === 1 ? '' : 's'}</span></div>
397
+ <p class="fb-evidence-note">Recorded in delivery.md. Acceptance reflects the saved record, not independent verification.</p>
398
+ ${rows.length ? `<div class="fb-table-scroll" role="region" aria-label="Promised, measured and accepted outcomes" tabindex="0"><table class="fb-table fb-value-table"><thead><tr><th scope="col">Outcome</th><th scope="col">Promised</th><th scope="col">Measured</th><th scope="col">Acceptance</th><th scope="col">Evidence</th></tr></thead><tbody>${rows.map(r => `<tr><th scope="row">${inlineMd(r.slice || 'Outcome')}<span class="fb-value-state t-${r.state === 'accepted' ? 'green' : 'amber'}">${labels[r.state]}</span></th><td>${cell(r.promised, 'Not recorded')}</td><td>${cell(r.measured, 'Not yet measured')}</td><td>${cell(r.accepted, 'Not recorded')}</td><td>${cell(r.evidence, 'Not recorded')}</td></tr>`).join('')}</tbody></table></div>` : `<div class="fb-empty-evidence"><strong>No delivery evidence yet</strong><p>Ask your agent to define one useful increment: the expected result, how to measure it, and who will accept it.</p></div>`}
399
+ </section>`
400
+ }
401
+
453
402
  function clientViewHtml(e) {
454
403
  const dotClass = dotClassFor(e.signals.trust)
455
404
  const sectorLine = [
@@ -460,8 +409,8 @@ function clientViewHtml(e) {
460
409
  ].filter(Boolean).join(' &middot; ')
461
410
 
462
411
  const nowBlock = `<div class="fb-block">
463
- <div class="fb-sec">Now</div>
464
- <div class="fb-now-action${e.hasNext ? '' : ' t-amber'}">${e.hasNext ? inlineMd(e.next) : 'next action not set'}</div>
412
+ <div class="fb-sec">Next action</div>
413
+ <div class="fb-now-action${e.hasNext ? '' : ' t-amber'}">${e.hasNext ? inlineMd(e.next) : 'Set the next action'}</div>
465
414
  ${e.lastSession ? `<div class="fb-now-session">${inlineMd(e.lastSession)}</div>` : ''}
466
415
  </div>`
467
416
 
@@ -470,7 +419,7 @@ ${e.lastSession ? `<div class="fb-now-session">${inlineMd(e.lastSession)}</div>`
470
419
  // contrast disappears. Each gets its own line, its own room to finish a
471
420
  // thought, not a race to fit before an ellipsis.
472
421
  const whyBlock = (e.brief || e.reality || e.realityMissing) ? `<div class="fb-block">
473
- <div class="fb-sec">Why</div>
422
+ <div class="fb-sec">The problem to solve</div>
474
423
  ${e.brief ? `<p class="fb-why"><span class="t-faint">What they asked for:</span> ${inlineMd(e.brief)}</p>` : ''}
475
424
  ${e.realityMissing ? `<p class="fb-why fb-why-missing"><span class="fb-accent-label">What's actually true:</span> ${escapeHtml(e.realityMissing)}</p>` : e.reality ? `<p class="fb-why fb-why-reality"><span class="fb-accent-label">What's actually true:</span> ${inlineMd(e.reality)}</p>` : ''}
476
425
  </div>` : ''
@@ -524,7 +473,7 @@ ${e.stakeholders.map(p => `<div class="fb-row fb-person">
524
473
  </div>` : ''
525
474
 
526
475
  const riskBlock = e.risks.length ? `<div class="fb-block">
527
- <div class="fb-sec-row"><div class="fb-sec">Risk</div><span class="fb-count">${e.risks.length} open</span></div>
476
+ <div class="fb-sec-row"><div class="fb-sec">Open risks</div><span class="fb-count">${e.risks.length} open</span></div>
528
477
  <div class="fb-list">
529
478
  ${e.risks.map(r => `<div class="fb-row fb-risk">
530
479
  <span class="fb-risk-sev t-${r.severity === 'high' ? 'red' : 'amber'}">${escapeHtml(r.severity)}</span>
@@ -534,7 +483,7 @@ ${e.risks.map(r => `<div class="fb-row fb-risk">
534
483
  </div>` : ''
535
484
 
536
485
  const logBlock = e.log.length ? `<div class="fb-block">
537
- <div class="fb-sec">Log</div>
486
+ <div class="fb-sec">Decisions &amp; activity</div>
538
487
  <div class="fb-list">
539
488
  ${e.log.map(g => `<div class="fb-row fb-log">
540
489
  <span class="fb-log-date">${escapeHtml(formatLogDate(g.date))}</span>
@@ -561,15 +510,16 @@ ${e.moreSections.map(s => `<details class="fb-more"><summary class="fb-sec fb-mo
561
510
  return `<div id="view-eng-${e.slug}" class="fb-view" hidden>
562
511
  <div class="fb-eyebrow">.fde/${escapeHtml(e.slug)}</div>
563
512
  <div class="fb-title-row">
564
- <h1 class="fb-h1">${inlineMd(e.name)}</h1>
513
+ <h1 tabindex="-1" class="fb-h1">${inlineMd(e.name)}</h1>
565
514
  <span class="fb-trust"><span class="dot dot-lg ${dotClass}"></span><span class="t-${dotClass}">${trustWord(e.signals.trust)}</span></span>
566
515
  </div>
567
516
  <div class="fb-meta-line">${sectorLine}</div>
517
+ ${agentActionsHtml(e)}
568
518
  <div class="fb-top-grid">
569
519
  <div>${nowBlock}${whyBlock}</div>
570
520
  ${vitalsBlock}
571
521
  </div>
572
- ${pairedBlock}${logBlock}${moreBlock}
522
+ ${deliveryHtml(e)}${pairedBlock}${logBlock}${moreBlock}
573
523
  </div>`
574
524
  }
575
525
 
@@ -579,26 +529,26 @@ function paletteItemsHtml(ordered) {
579
529
  ...ordered.map(e => ({ kind: 'open', label: e.slug, hint: trustWord(e.signals.trust), target: 'eng-' + e.slug })),
580
530
  { kind: 'action', label: 'toggle theme', hint: 'light / dark', target: '__theme__' },
581
531
  ]
582
- return items.map(it => `<div class="fb-palette-item" data-target="${escapeHtml(it.target)}" data-label="${escapeHtml(it.label)}" data-kind="${escapeHtml(it.kind)}">
532
+ return items.map(it => `<button type="button" class="fb-palette-item" data-target="${escapeHtml(it.target)}" data-label="${escapeHtml(it.label)}" data-kind="${escapeHtml(it.kind)}">
583
533
  <span class="fb-palette-kind">${escapeHtml(it.kind)}</span>
584
534
  <span class="fb-palette-label">${escapeHtml(it.label)}</span>
585
535
  <span class="fb-palette-hint">${escapeHtml(it.hint)}</span>
586
- </div>`).join('\n')
536
+ </button>`).join('\n')
587
537
  }
588
538
 
589
- function buildFieldbookHtml({ engagements, today }) {
539
+ function buildFieldbookHtml({ engagements, today, generatedAt = '' }) {
540
+ engagements = engagements.map(e => ({ ...e, attention: attentionFor(e) }))
590
541
  // rail + Today queue share one order: trust-first (red, amber, green)
591
542
  const tierRank = { RED: 0, amber: 1, green: 2, new: 3 }
592
- const ordered = engagements.slice().sort((a, b) => tierRank[a.signals.trust] - tierRank[b.signals.trust])
593
- const attentionCount = engagements.filter(e => e.signals.trust !== 'green' && e.signals.trust !== 'new').length
543
+ const ordered = engagements.slice().sort((a, b) => Number(b.attention.length > 0) - Number(a.attention.length > 0) || tierRank[a.signals.trust] - tierRank[b.signals.trust])
544
+ const attentionCount = engagements.filter(e => e.attention.length > 0).length
594
545
  const highRiskTotal = engagements.reduce((n, e) => n + e.highRisks, 0)
595
546
 
596
547
  const railItems = ordered.map(railItemHtml).join('\n')
597
548
  const todayQueue = ordered.map(queueRowHtml).join('\n') || '<p class="empty">No engagements yet.</p>'
598
549
  const flagRows = []
599
550
  ordered.forEach(e => {
600
- if (e.quiet) flagRows.push(flagRowHtml(e, `quiet - last touched ${e.signals.updated}`, 'amber'))
601
- if (e.highRisks) flagRows.push(flagRowHtml(e, `${e.highRisks} high risk${e.highRisks > 1 ? 's' : ''} open`, 'red'))
551
+ e.attention.forEach(item => flagRows.push(flagRowHtml(e, item.text, item.tone)))
602
552
  })
603
553
 
604
554
  const todayView = todayViewHtml({
@@ -612,47 +562,58 @@ function buildFieldbookHtml({ engagements, today }) {
612
562
  <meta name="viewport" content="width=device-width,initial-scale=1">
613
563
  <script>try{var t=localStorage.getItem("fde-fieldbook-theme");if(t==="dark"||(!t&&window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches))document.documentElement.setAttribute("data-fde-theme","dark");}catch(e){}</script>
614
564
  <title>FDE Fieldbook</title><style>${dashStyles()}</style></head><body>
565
+ <a class="fb-skip" href="#fb-main">Skip to content</a>
615
566
  <div class="fb-app">
616
567
  <header class="fb-header">
617
568
  <div class="fb-brand">
618
569
  <span class="fb-wordmark">FDEOPS</span>
619
- <span class="fb-tagline">fieldbook</span>
570
+ <span class="fb-tagline">Fieldbook</span>
620
571
  ${engagements.length ? `<span class="fb-need">${attentionCount}/${engagements.length} need you</span>` : ''}
621
572
  </div>
622
573
  <div class="fb-spacer"></div>
623
574
  <span class="fb-today">${escapeHtml(today)}</span>
624
- <button id="fb-palette-btn" class="fb-btn" type="button" title="Command palette (Ctrl/Cmd+K)">&#8984;K</button>
575
+ <button id="fb-print-btn" class="fb-btn" type="button">Print view</button>
576
+ <button id="fb-palette-btn" aria-label="Open command palette" class="fb-btn" type="button" title="Command palette (Ctrl/Cmd+K)">&#8984;K</button>
625
577
  <button id="fde-theme-btn" class="fb-btn" type="button">dark</button>
626
578
  </header>
627
579
  <div class="fb-body">
628
- <aside class="fb-rail">
629
- <div class="fb-rail-search"><input id="fb-search" class="fb-search" placeholder="search clients, notes, risks…" spellcheck="false"></div>
580
+ <aside class="fb-rail" aria-label="Engagement navigation">
581
+ <div class="fb-rail-search"><input id="fb-search" aria-label="Search engagements and records" class="fb-search" placeholder="search clients, notes, risks…" spellcheck="false"></div>
630
582
  <div class="fb-rail-list fb-scroll">
631
583
  <button class="fb-nav" data-target="today">
632
584
  <div class="fb-nav-row"><span class="fb-nav-today">today</span>${engagements.length ? `<span class="fb-nav-count">${attentionCount} need you</span>` : ''}</div>
633
585
  </button>
634
586
  ${railItems}
587
+ <p id="fb-search-empty" class="fb-empty-search" hidden>No matching engagements. Try a person, decision, or delivery.</p>
588
+ <span id="fb-search-count" class="sr-only" role="status"></span>
635
589
  </div>
636
590
  </aside>
637
- <main class="fb-main fb-scroll">
591
+ <main id="fb-main" tabindex="-1" class="fb-main fb-scroll">
638
592
  <div class="fb-main-inner">
593
+ <div class="fb-snapshot">Snapshot generated <time datetime="${escapeHtml(generatedAt)}">${escapeHtml(generatedAt ? generatedAt.replace('T', ' ').replace(/\.\d+Z$/, ' UTC') : today)}</time>. Re-run <code>fde dashboard</code> after updating your records.</div>
639
594
  ${todayView}
640
595
  ${clientViews}
596
+ <p id="fb-status" class="fb-copy-status" role="status" aria-live="polite"></p>
641
597
  <div class="fb-hints">
642
598
  <span><span class="t-soft">&uarr;&darr; / j k</span> client</span>
643
599
  <span><span class="t-soft">/</span> search</span>
644
600
  <span><span class="t-soft">&#8984;K</span> palette</span>
645
- <span class="fb-hints-spacer">deterministic render of .fde/ - edit the markdown, re-run fde dashboard</span>
601
+ <span class="fb-hints-spacer">Local records. No network. Changes are reviewed in your coding agent.</span>
646
602
  </div>
647
603
  </div>
648
604
  </main>
649
605
  </div>
650
- <div id="fb-palette-backdrop" class="fb-palette-backdrop">
651
- <div class="fb-palette">
652
- <input id="fb-palette-input" class="fb-palette-input" placeholder="jump to client or action…" spellcheck="false">
606
+ <dialog id="fb-palette-dialog" class="fb-dialog" role="dialog" aria-modal="true" aria-label="Command palette">
607
+ <div class="fb-dialog-head"><span>Jump to an engagement</span><button type="button" class="fb-btn" data-close-dialog>Close</button></div>
608
+ <input id="fb-palette-input" class="fb-palette-input" aria-label="Find an engagement or action" placeholder="Find an engagement or action" spellcheck="false">
653
609
  <div class="fb-palette-list fb-scroll">${paletteItems}</div>
654
- </div>
655
- </div>
610
+ <p id="fb-palette-empty" class="fb-empty-search" hidden>No matching commands.</p>
611
+ </dialog>
612
+ <dialog id="fb-prompt-dialog" class="fb-dialog" role="dialog" aria-modal="true" aria-labelledby="fb-prompt-title">
613
+ <div class="fb-dialog-head"><span id="fb-prompt-title">Copy into your coding agent</span><button type="button" class="fb-btn" data-close-dialog>Close</button></div>
614
+ <p class="fb-dialog-help">Automatic copy is unavailable. Copy the selected prompt, then paste it into your agent. Review any proposed changes there.</p>
615
+ <textarea id="fb-prompt-text" aria-label="Agent prompt" readonly rows="7"></textarea>
616
+ </dialog>
656
617
  </div>
657
618
  <script>${dashScript()}</script>
658
619
  </body></html>`