thatcher 1.0.4 → 1.0.6

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 (62) hide show
  1. package/package.json +18 -1
  2. package/src/app/api/debug/[[...path]]/route.js +217 -0
  3. package/src/app/api/formula/[[...path]]/route.js +142 -0
  4. package/src/cli.js +0 -1
  5. package/src/config/spec-helpers.js +6 -0
  6. package/src/engine.server.js +2 -2
  7. package/src/index.js +21 -11
  8. package/src/lib/auth-middleware.js +4 -2
  9. package/src/lib/busybase-adapter.js +162 -0
  10. package/src/lib/config-field-helpers.js +1 -1
  11. package/src/lib/config-generator-engine.js +24 -0
  12. package/src/lib/crud-handlers.js +21 -1
  13. package/src/lib/database-core.js +4 -7
  14. package/src/lib/database-migrations.js +254 -0
  15. package/src/lib/date-utils.js +164 -0
  16. package/src/lib/debug-registry.js +165 -0
  17. package/src/lib/error-handler.js +78 -0
  18. package/src/lib/export-sink.js +226 -0
  19. package/src/lib/field-iterator.js +126 -0
  20. package/src/lib/hyperformula-service.js +304 -0
  21. package/src/lib/metrics-collector.js +196 -0
  22. package/src/lib/observability-bootstrap.js +121 -0
  23. package/src/lib/perf-profiler.js +238 -0
  24. package/src/lib/query-engine.js +47 -0
  25. package/src/lib/query-string-adapter.js +97 -2
  26. package/src/lib/request-tracing.js +216 -0
  27. package/src/lib/response-formatter.js +0 -2
  28. package/src/lib/route-resolver.js +10 -0
  29. package/src/lib/status-helpers.js +128 -0
  30. package/src/lib/tracing.js +287 -0
  31. package/src/lib/utils.js +81 -0
  32. package/src/lib/validate.js +124 -1
  33. package/src/lib/xstate-workflow-engine.js +478 -0
  34. package/src/plugins/index.js +27 -0
  35. package/src/server/server.js +23 -4
  36. package/src/services/permission.service.js +6 -2
  37. package/src/ui/advanced-search-renderer.js +3 -3
  38. package/src/ui/advanced-widgets.js +4 -4
  39. package/src/ui/common-handlers.js +4 -2
  40. package/src/ui/dashboard-renderer.js +4 -4
  41. package/src/ui/engagement-cards.js +10 -10
  42. package/src/ui/engagement-grid-renderer.js +11 -8
  43. package/src/ui/entity-renderer.js +4 -2
  44. package/src/ui/event-delegation.js +348 -8
  45. package/src/ui/file-dialogs.js +9 -9
  46. package/src/ui/format-helpers.js +37 -3
  47. package/src/ui/highlight-threading-renderer.js +9 -9
  48. package/src/ui/job-management-renderer.js +6 -6
  49. package/src/ui/monitoring-dashboard-client.js +17 -3
  50. package/src/ui/notifications-renderer.js +6 -7
  51. package/src/ui/page-handler.js +9 -0
  52. package/src/ui/render-helpers.js +8 -1
  53. package/src/ui/review-comparison-renderer.js +1 -1
  54. package/src/ui/review-detail-renderer.js +6 -6
  55. package/src/ui/review-mwr-renderer.js +13 -10
  56. package/src/ui/review-widgets.js +5 -5
  57. package/src/ui/rfi-detail-renderer.js +9 -9
  58. package/src/ui/settings-renderer-advanced.js +13 -13
  59. package/src/ui/settings-renderer-teams.js +10 -9
  60. package/src/ui/settings-renderer.js +25 -24
  61. package/src/ui/spacing-system.js +8 -4
  62. package/src/ui/widgets.js +2 -2
@@ -1,6 +1,6 @@
1
1
  import { page } from '@/ui/layout.js'
2
2
  import { getQuickActions } from '@/ui/permissions-ui.js'
3
- import { esc, stagePill, statusPill, TABLE_SCRIPT } from '@/ui/render-helpers.js'
3
+ import { esc, stagePill, statusPill, TABLE_SCRIPT, emptyRow } from '@/ui/render-helpers.js'
4
4
  import { SPACING, renderCard } from '@/ui/spacing-system.js'
5
5
 
6
6
  export function renderDashboard(user, stats = {}) {
@@ -31,7 +31,7 @@ export function renderDashboard(user, stats = {}) {
31
31
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
32
32
  <span><strong>${stats.overdueRfis.length} overdue RFI${stats.overdueRfis.length !== 1 ? 's' : ''}</strong> require attention</span>
33
33
  </div>
34
- <a href="/rfi" class="btn-danger-clean" style="font-size:0.8125rem;padding:6px 14px;min-height:32px">View RFIs →</a>
34
+ <a href="/rfi" class="btn-danger-clean" style="font-size:0.8125rem;padding:6px 14px;min-height:32px">View RFIs</a>
35
35
  </div>` : '';
36
36
 
37
37
  const quickActions = getQuickActions(user);
@@ -96,7 +96,7 @@ export function renderAuditDashboard(user, auditData = {}) {
96
96
  <td data-col="user">${a.user_name||a.user_id||'-'}</td>
97
97
  <td data-col="reason" style="font-size:12px;color:var(--color-text-muted)">${a.reason||'-'}</td>
98
98
  </tr>`
99
- ).join('') || `<tr><td colspan="6" style="text-align:center;padding:${SPACING.xl};color:var(--color-text-muted)">No audit records found</td></tr>`;
99
+ ).join('') || emptyRow(6, 'No audit records found');
100
100
 
101
101
  const statsHtml = `<div class="stats-row">${[
102
102
  { label: 'Total Actions (30d)', value: summary.total_actions || 0 },
@@ -125,7 +125,7 @@ export function renderSystemHealth(user, healthData = {}) {
125
125
  const { database = {}, server: srv = {}, entities = {} } = healthData;
126
126
  const entRows = Object.entries(entities).map(([n, c]) =>
127
127
  `<tr data-row><td data-col="entity">${n}</td><td data-col="count" style="text-align:right">${c}</td></tr>`
128
- ).join('') || `<tr><td colspan="2" style="text-align:center;padding:${SPACING.xl};color:var(--color-text-muted)">No data</td></tr>`;
128
+ ).join('') || emptyRow(2, 'No data');
129
129
 
130
130
  const statsHtml = `<div class="stats-row">${[
131
131
  { label: 'Server Status', value: 'Online', sub: 'Port: '+(srv.port||3000) },
@@ -1,5 +1,5 @@
1
1
  import { h } from '@/ui/webjsx.js'
2
- import { STAGE_COLORS, statusLabel } from '@/ui/render-helpers.js'
2
+ import { STAGE_COLORS, statusLabel, esc } from '@/ui/render-helpers.js'
3
3
  import { teamAvatarGroup } from '@/ui/widgets.js'
4
4
  import { canEdit } from '@/ui/permissions-ui.js'
5
5
 
@@ -12,13 +12,13 @@ export function engagementCard(engagement, user) {
12
12
  const team = e.team?.length ? `<div class="engagement-card-team">${teamAvatarGroup(e.team, 3)}</div>` : ''
13
13
  const dueDate = e.due_date ? `<span class="text-xs text-gray-500">${typeof e.due_date === 'number' ? new Date(e.due_date * 1000).toLocaleDateString() : e.due_date}</span>` : ''
14
14
  const clientName = e.client_name || e.client?.name || ''
15
- return `<div class="engagement-card card-clean" data-navigate="/engagement/${e.id}" style="cursor:pointer">
15
+ return `<div class="engagement-card card-clean" data-navigate="/engagement/${esc(e.id)}" style="cursor:pointer">
16
16
  <div class="card-clean-body">
17
- <div class="flex justify-between items-center mb-2">${clientName ? `<span class="text-xs text-gray-500">${clientName}</span>` : '<span></span>'}${sts}</div>
18
- <h3 class="font-medium mb-2">${e.name || e.title || 'Untitled'}</h3>
17
+ <div class="flex justify-between items-center mb-2">${clientName ? `<span class="text-xs text-gray-500">${esc(clientName)}</span>` : '<span></span>'}${sts}</div>
18
+ <h3 class="font-medium mb-2">${esc(e.name || e.title || 'Untitled')}</h3>
19
19
  <div class="flex items-center gap-2 mb-2">${stageBadge}${dueDate}</div>
20
20
  ${progress}
21
- <div class="flex justify-between items-center mt-2">${team}<div class="flex gap-1"><a href="/engagement/${e.id}" class="btn btn-xs btn-ghost" data-stop-propagation="true">View</a>${canEdit(user, 'engagement') ? `<a href="/engagement/${e.id}/edit" class="btn btn-xs btn-outline" data-stop-propagation="true">Edit</a>` : ''}</div></div>
21
+ <div class="flex justify-between items-center mt-2">${team}<div class="flex gap-1"><a href="/engagement/${esc(e.id)}" class="btn btn-xs btn-ghost" data-stop-propagation="true">View</a>${canEdit(user, 'engagement') ? `<a href="/engagement/${esc(e.id)}/edit" class="btn btn-xs btn-outline" data-stop-propagation="true">Edit</a>` : ''}</div></div>
22
22
  </div></div>`
23
23
  }
24
24
 
@@ -29,8 +29,8 @@ export function mobileEngagementCard(engagement) {
29
29
  const sts = e.status ? statusLabel(e.status) : ''
30
30
  return `<div class="mobile-card mobile-engagement-card card-clean">
31
31
  <div class="card-clean-body">
32
- <div class="flex justify-between items-center mb-2"><h3 class="font-medium text-sm">${e.name || e.title || 'Untitled'}</h3>${sts}</div>
33
- ${e.client_name ? `<div class="text-xs text-gray-500 mb-1">${e.client_name}</div>` : ''}
32
+ <div class="flex justify-between items-center mb-2"><h3 class="font-medium text-sm">${esc(e.name || e.title || 'Untitled')}</h3>${sts}</div>
33
+ ${e.client_name ? `<div class="text-xs text-gray-500 mb-1">${esc(e.client_name)}</div>` : ''}
34
34
  <div class="flex items-center gap-2 mb-2">${stageBadge}${e.due_date ? `<span class="text-xs text-gray-500">${typeof e.due_date === 'number' ? new Date(e.due_date * 1000).toLocaleDateString() : e.due_date}</span>` : ''}</div>
35
35
  <div class="flex gap-1 mt-2"><a href="/engagement/${e.id}" class="btn btn-xs btn-primary">View</a><a href="/engagement/${e.id}/edit" class="btn btn-xs btn-outline">Edit</a></div>
36
36
  </div></div>`
@@ -51,13 +51,13 @@ export function stagePipeline(currentStage, stages = null, clickable = false, on
51
51
  export function activityTimeline(activities = [], showAll = false) {
52
52
  const maxItems = showAll ? activities.length : 10
53
53
  const items = activities.slice(0, maxItems)
54
- const iconMap = { created: '&#43;', updated: '&#9998;', status_changed: '&#9679;', commented: '&#128172;', assigned: '&#128100;', uploaded: '&#128206;' }
54
+ const iconMap = { created: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>`, updated: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4z"/></svg>`, status_changed: `<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="0" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="6"/></svg>`, commented: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`, assigned: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`, uploaded: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>` }
55
55
  const timelineItems = items.map(a => {
56
56
  const action = a.action || 'updated'
57
- const icon = iconMap[action] || '&#9679;'
57
+ const icon = iconMap[action] || `<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true"><circle cx="12" cy="12" r="6"/></svg>`
58
58
  const ts = a.timestamp || a.created_at
59
59
  const timeStr = ts ? (typeof ts === 'number' && ts > 1e9 ? new Date(ts * 1000).toLocaleString() : new Date(ts).toLocaleString()) : ''
60
- return `<div class="act-tl-item"><div class="act-tl-icon act-tl-${action}">${icon}</div><div class="act-tl-content"><div class="act-tl-header"><span class="act-tl-desc">${a.description || a.action || ''}</span></div><div class="act-tl-time">${timeStr}${a.user_name ? ' by ' + a.user_name : ''}</div></div></div>`
60
+ return `<div class="act-tl-item"><div class="act-tl-icon act-tl-${action}">${icon}</div><div class="act-tl-content"><div class="act-tl-header"><span class="act-tl-desc">${esc(a.description || a.action || '')}</span></div><div class="act-tl-time">${timeStr}${a.user_name ? ' by ' + esc(a.user_name) : ''}</div></div></div>`
61
61
  }).join('')
62
62
  const moreBtn = !showAll && activities.length > maxItems ? `<div class="act-tl-more"><button class="btn btn-ghost btn-sm" data-action="showAllActivity" aria-label="Show ${activities.length - maxItems} more activities">Show ${activities.length - maxItems} more</button></div>` : ''
63
63
  return `<div class="act-timeline">${timelineItems}</div>${moreBtn}`
@@ -9,9 +9,12 @@ function engRow(e) {
9
9
  const year = esc(e.year || '-');
10
10
  const team = esc(e.team_name || e.team_id_display || e.team_id || '-');
11
11
  const deadline = e.deadline ? new Date(e.deadline).toLocaleDateString() : '-';
12
- const stageLbl = STAGE_CONFIG.find(s => s.key === e.stage)?.label || (e.stage || '-');
13
- return `<tr data-row data-navigate="/engagement/${esc(e.id)}" style="cursor:pointer">
14
- <td data-col="name"><strong>${name}</strong></td>
12
+ const stageCfg = STAGE_CONFIG.find(s => s.key === e.stage);
13
+ const stageLbl = stageCfg?.label || (e.stage || '-');
14
+ // 247420 indicator-rail: 3px left edge tinted by stage color (replaces border)
15
+ const railColor = stageCfg?.color || (e.color || 'var(--panel-border,#eee5d3)');
16
+ return `<tr data-row data-navigate="/engagement/${esc(e.id)}" style="cursor:pointer" data-stage="${esc(e.stage || '')}">
17
+ <td data-col="name" style="box-shadow:inset 3px 0 0 ${railColor}"><strong>${name}</strong></td>
15
18
  <td data-col="client">${client}</td>
16
19
  <td data-col="type" class="eng-col-type">${type}</td>
17
20
  <td data-col="year" class="eng-col-year">${year}</td>
@@ -64,19 +67,19 @@ export function renderEngagementGrid(user, engagements, options = {}) {
64
67
 
65
68
  const emailReceiveDialog = `<div id="email-receive-dialog" class="dialog-overlay" style="display:none" role="dialog" aria-modal="true">
66
69
  <div class="dialog-panel" style="max-width:540px">
67
- <div class="dialog-header"><span class="dialog-title">Receive Email</span><button class="dialog-close" onclick="document.getElementById('email-receive-dialog').style.display='none'">&times;</button></div>
70
+ <div class="dialog-header"><span class="dialog-title">Receive Email</span><button class="dialog-close" aria-label="Close dialog" data-dialog-close="email-receive-dialog">&times;</button></div>
68
71
  <div class="dialog-body">
69
72
  <div class="modal-form-group"><label>Email Content</label><textarea id="email-receive-content" class="form-input" rows="8" placeholder="Paste raw email content here..."></textarea></div>
70
73
  <div id="email-receive-result" style="display:none;margin-top:8px"></div>
71
74
  </div>
72
75
  <div class="dialog-footer">
73
- <button class="btn btn-ghost btn-sm" onclick="document.getElementById('email-receive-dialog').style.display='none'">Cancel</button>
74
- <button class="btn btn-primary btn-sm" onclick="submitEmailReceive()">Process Email</button>
76
+ <button class="btn btn-ghost btn-sm" data-dialog-close="email-receive-dialog">Cancel</button>
77
+ <button class="btn btn-primary btn-sm" data-action="submitEmailReceive">Process Email</button>
75
78
  </div>
76
79
  </div>
77
80
  </div>`;
78
81
 
79
- const emailReceiveScript = `async function submitEmailReceive(){var content=document.getElementById('email-receive-content').value.trim();var res=document.getElementById('email-receive-result');if(!content){res.style.display='block';res.innerHTML='<div style="color:var(--color-danger);font-size:13px">Email content required.</div>';return}res.style.display='block';res.innerHTML='<div style="font-size:13px">Processing...</div>';try{var r=await fetch('/api/email/receive',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({content:content})});var d=await r.json();if(r.ok&&d.success){res.innerHTML='<div style="color:var(--color-success);font-size:13px">Email processed successfully.'+(d.engagement_id?' Matched engagement: '+d.engagement_id:'')+(d.message?' '+d.message:'')+'</div>'}else{res.innerHTML='<div style="color:var(--color-danger);font-size:13px">'+(d.error||d.message||'Failed to process email')+'</div>'}}catch(e){res.innerHTML='<div style="color:var(--color-danger);font-size:13px">Error: '+e.message+'</div>'}}`;
82
+ const emailReceiveScript = `window.submitEmailReceive=async function(){var content=document.getElementById('email-receive-content').value.trim();var res=document.getElementById('email-receive-result');if(!content){res.style.display='block';res.innerHTML='<div style="color:var(--color-danger);font-size:13px">Email content required.</div>';return}res.style.display='block';res.innerHTML='<div style="font-size:13px">Processing...</div>';try{var r=await fetch('/api/email/receive',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({content:content})});var d=await r.json();if(r.ok&&d.success){res.innerHTML='<div style="color:var(--color-success);font-size:13px">Email processed successfully.'+(d.engagement_id?' Matched engagement: '+d.engagement_id:'')+(d.message?' '+d.message:'')+'</div>'}else{res.innerHTML='<div style="color:var(--color-danger);font-size:13px">'+(d.error||d.message||'Failed to process email')+'</div>'}}catch(e){res.innerHTML='<div style="color:var(--color-danger);font-size:13px">Error: '+e.message+'</div>'}};`;
80
83
 
81
84
  const content = `<div class="page-header">
82
85
  <div><h1 class="page-title">Engagements</h1><p class="page-subtitle">${engagements.length} total engagements</p></div>
@@ -89,7 +92,7 @@ export function renderEngagementGrid(user, engagements, options = {}) {
89
92
  ${stageOpts ? `<div class="table-filter"><select data-filter="stage-raw" id="filter-stage"><option value="">All Stages</option>${stageOpts}</select></div>` : ''}
90
93
  ${teamOpts ? `<div class="table-filter"><select data-filter="team" id="filter-team"><option value="">All Teams</option>${teamOpts}</select></div>` : ''}
91
94
  ${yearOpts ? `<div class="table-filter"><select data-filter="year" id="filter-year"><option value="">All Years</option>${yearOpts}</select></div>` : ''}
92
- <button class="btn btn-ghost btn-sm" onclick="document.getElementById('email-receive-dialog').style.display='flex'">Receive Email</button>
95
+ <button class="btn btn-ghost btn-sm" data-action="openDialog" data-args='["email-receive-dialog"]'>Receive Email</button>
93
96
  <span class="table-count" id="row-count">${engagements.length} items</span>
94
97
  </div>
95
98
  <table class="data-table">
@@ -63,7 +63,7 @@ function formatFieldValue(k, v, entityName) {
63
63
  return `<span class="pill ${cls}">${v ? v.charAt(0).toUpperCase() + v.slice(1) : '-'}</span>`
64
64
  }
65
65
  if (entityName === 'user' && k === 'email' && v) return `<a href="mailto:${v}" class="text-primary hover:underline">${v}</a>`
66
- if (k === 'photo_url' && v && v.startsWith('http')) return `<img src="${v}" style="width:2.5rem;height:2.5rem;border-radius:50%;object-fit:cover" alt="avatar" onerror="this.style.display='none'/>`
66
+ if (k === 'photo_url' && v && v.startsWith('http')) return `<img src="${v}" style="width:2.5rem;height:2.5rem;border-radius:50%;object-fit:cover" alt="avatar" onerror="this.style.display='none'"/>`
67
67
  return fmtVal(v, k)
68
68
  }
69
69
 
@@ -125,9 +125,11 @@ export function renderEntityForm(entityName, item, spec, user, isNew = false, re
125
125
  const fields = spec?.fields || {}
126
126
  const lbl = (k, f, req) => `<label class="form-label" for="field-${k}">${f.label||k}${req ? '<span class="req">*</span>' : ''}</label>`
127
127
  const formFields = Object.entries(fields).filter(([k, f]) => k !== 'id' && !f.auto && !f.readOnly && !f.auto_generate && k !== 'password_hash').map(([k, f]) => {
128
- const val = item?.[k] ?? f.default ?? ''
128
+ let val = item?.[k] ?? f.default ?? ''
129
129
  const req = f.required ? 'required' : ''
130
130
  const type = f.type === 'number' || f.type === 'int' || f.type === 'decimal' ? 'number' : f.type === 'email' ? 'email' : f.type === 'timestamp' || f.type === 'date' ? 'date' : f.type === 'bool' ? 'checkbox' : 'text'
131
+ // <input type=date> only populates from a strict YYYY-MM-DD; coerce a stored timestamp/ISO value so the existing date pre-fills (else it renders blank and saves empty).
132
+ if (type === 'date' && val) { const d = new Date(val); if (!isNaN(d)) val = d.toISOString().slice(0, 10) }
131
133
  const placeholder = `placeholder="Enter ${(f.label||k).toLowerCase()}"`
132
134
  if (entityName === 'user' && k === 'role') {
133
135
  const opts = ['partner','manager','clerk','client_admin','client_user'].map(o => `<option value="${o}" ${val===o?'selected':''}>${o.charAt(0).toUpperCase()+o.slice(1).replace('_',' ')}</option>`).join('')
@@ -14,16 +14,246 @@ window.__events = window.__events || {
14
14
  }
15
15
  };
16
16
 
17
+ // --- Dialog focus management -------------------------------------------------
18
+ // A dialog is any visible [role=dialog] / .dialog-overlay / .modal / .modal-overlay.
19
+ // We trap Tab within the topmost open dialog, move focus inside on open, and
20
+ // restore focus to the opener on close. This works regardless of HOW the dialog
21
+ // was opened (central openDialog OR a window.show*Dialog setter that flips
22
+ // display=flex), because a MutationObserver watches visibility transitions.
23
+ const dialogFocus = {
24
+ openerStack: [],
25
+ FOCUSABLE: 'button:not([disabled]), [href], input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
26
+
27
+ isDialog(el) {
28
+ return el && el.nodeType === 1 && (
29
+ el.getAttribute('role') === 'dialog' ||
30
+ el.classList.contains('dialog-overlay') ||
31
+ el.classList.contains('dialog-backdrop') ||
32
+ el.classList.contains('modal') ||
33
+ el.classList.contains('modal-overlay')
34
+ );
35
+ },
36
+
37
+ isVisible(el) {
38
+ if (!el) return false;
39
+ // offsetParent is null for position:fixed elements even when visible, so we
40
+ // rely on computed display/visibility plus a non-zero box.
41
+ const cs = (typeof getComputedStyle === 'function') ? getComputedStyle(el) : null;
42
+ if (cs) {
43
+ if (cs.display === 'none' || cs.visibility === 'hidden') return false;
44
+ return el.getClientRects().length > 0;
45
+ }
46
+ return el.style.display !== 'none';
47
+ },
48
+
49
+ topOpenDialog() {
50
+ const dialogs = document.querySelectorAll('[role="dialog"], .dialog-overlay, .dialog-backdrop, .modal, .modal-overlay');
51
+ let top = null;
52
+ dialogs.forEach((d) => { if (this.isVisible(d)) top = d; });
53
+ return top;
54
+ },
55
+
56
+ focusables(container) {
57
+ return Array.from(container.querySelectorAll(this.FOCUSABLE))
58
+ .filter((el) => el.getClientRects().length > 0 || el === document.activeElement);
59
+ },
60
+
61
+ onOpen(dialog) {
62
+ // idempotent: openDialog and the MutationObserver can both report the same
63
+ // open; only the first wins so the opener stack stays balanced.
64
+ if (this.openerStack.some((s) => s.dialog === dialog)) return;
65
+ // remember the element that had focus so we can restore it on close
66
+ const opener = document.activeElement && document.activeElement !== document.body
67
+ ? document.activeElement : null;
68
+ this.openerStack.push({ dialog, opener });
69
+ const f = this.focusables(dialog);
70
+ const first = dialog.querySelector('[autofocus]') || f[0] || dialog;
71
+ setTimeout(() => { try { first.focus(); } catch (_) {} }, 0);
72
+ },
73
+
74
+ onClose(dialog) {
75
+ for (let i = this.openerStack.length - 1; i >= 0; i--) {
76
+ if (this.openerStack[i].dialog === dialog) {
77
+ const { opener } = this.openerStack[i];
78
+ this.openerStack.splice(i, 1);
79
+ if (opener && document.contains(opener)) {
80
+ try { opener.focus(); } catch (_) {}
81
+ }
82
+ break;
83
+ }
84
+ }
85
+ }
86
+ };
87
+
88
+ // Exposed for live debugging of dialog focus management via the browser verb.
89
+ if (typeof window !== 'undefined') window.__dialogFocus = dialogFocus;
90
+
91
+ // --- gmPrompt: styled replacement for native window.prompt ---------------------
92
+ // Returns a Promise that resolves to the entered string (trimmed) or null if
93
+ // cancelled. Lazily builds one reusable dialog so any page (incl. standalone) can
94
+ // call window.gmPrompt({title, label, value, placeholder, type, confirmLabel}).
95
+ // Participates in dialogFocus (Escape/Tab-trap/focus-restore) like any dialog.
96
+ if (typeof window !== 'undefined' && !window.gmPrompt) {
97
+ window.gmPrompt = function (opts) {
98
+ opts = opts || {};
99
+ return new Promise((resolve) => {
100
+ let dlg = document.getElementById('gm-prompt-dialog');
101
+ if (!dlg) {
102
+ dlg = document.createElement('div');
103
+ dlg.id = 'gm-prompt-dialog';
104
+ dlg.className = 'dialog-overlay';
105
+ dlg.style.display = 'none';
106
+ dlg.setAttribute('role', 'dialog');
107
+ dlg.setAttribute('aria-modal', 'true');
108
+ dlg.setAttribute('aria-labelledby', 'gm-prompt-title');
109
+ dlg.innerHTML =
110
+ '<div class="dialog-panel" style="max-width:420px">' +
111
+ '<div class="dialog-header"><span class="dialog-title" id="gm-prompt-title"></span>' +
112
+ '<button class="dialog-close" type="button" aria-label="Close dialog" data-gm-prompt="cancel">&times;</button></div>' +
113
+ '<div class="dialog-body"><div class="modal-form-group">' +
114
+ '<label id="gm-prompt-label" for="gm-prompt-input"></label>' +
115
+ '<input id="gm-prompt-input" class="input input-bordered w-full"/></div>' +
116
+ '<div id="gm-prompt-error" class="text-error text-sm mt-1" style="display:none"></div></div>' +
117
+ '<div class="dialog-footer">' +
118
+ '<button class="btn btn-ghost btn-sm" type="button" data-gm-prompt="cancel">Cancel</button>' +
119
+ '<button class="btn btn-primary btn-sm" type="button" data-gm-prompt="ok"></button></div>';
120
+ document.body.appendChild(dlg);
121
+ }
122
+ const titleEl = dlg.querySelector('#gm-prompt-title');
123
+ const labelEl = dlg.querySelector('#gm-prompt-label');
124
+ const input = dlg.querySelector('#gm-prompt-input');
125
+ const errEl = dlg.querySelector('#gm-prompt-error');
126
+ const okBtn = dlg.querySelector('[data-gm-prompt="ok"]');
127
+ titleEl.textContent = opts.title || 'Enter value';
128
+ labelEl.textContent = opts.label || opts.title || 'Value';
129
+ input.type = opts.type || 'text';
130
+ input.value = opts.value != null ? String(opts.value) : '';
131
+ input.placeholder = opts.placeholder || '';
132
+ okBtn.textContent = opts.confirmLabel || 'OK';
133
+ errEl.style.display = 'none';
134
+ errEl.textContent = '';
135
+
136
+ let settled = false;
137
+ const finish = (val) => {
138
+ if (settled) return;
139
+ settled = true;
140
+ dlg.style.display = 'none';
141
+ dialogFocus.onClose(dlg);
142
+ dlg.removeEventListener('click', onClick);
143
+ input.removeEventListener('keydown', onKey);
144
+ dlg.removeEventListener('keydown', onEsc);
145
+ resolve(val);
146
+ };
147
+ const submit = () => {
148
+ const v = input.value.trim();
149
+ if (opts.required !== false && !v) {
150
+ errEl.textContent = (opts.label || 'Value') + ' is required.';
151
+ errEl.style.display = 'block';
152
+ return;
153
+ }
154
+ if (opts.validate) {
155
+ const msg = opts.validate(v);
156
+ if (msg) { errEl.textContent = msg; errEl.style.display = 'block'; return; }
157
+ }
158
+ finish(v);
159
+ };
160
+ const onClick = (e) => {
161
+ const b = e.target.closest('[data-gm-prompt]');
162
+ if (b) { e.preventDefault(); b.dataset.gmPrompt === 'ok' ? submit() : finish(null); return; }
163
+ if (e.target === dlg) finish(null); // backdrop click cancels
164
+ };
165
+ const onKey = (e) => {
166
+ if (e.key === 'Enter') { e.preventDefault(); submit(); }
167
+ };
168
+ // Escape is handled by the central dialogFocus keydown (hides + onClose);
169
+ // we mirror it here so the Promise also resolves to null on Escape.
170
+ const onEsc = (e) => { if (e.key === 'Escape') finish(null); };
171
+ dlg.addEventListener('keydown', onEsc);
172
+ dlg.addEventListener('click', onClick);
173
+ input.addEventListener('keydown', onKey);
174
+ dlg.style.display = 'flex';
175
+ dialogFocus.onOpen(dlg);
176
+ });
177
+ };
178
+ }
179
+
180
+ // --- gmConfirm: styled replacement for native window.confirm ------------------
181
+ // Returns a Promise that resolves to true (confirmed) or false (cancelled).
182
+ // Lazily builds one reusable dialog so any page (incl. standalone) can call
183
+ // window.gmConfirm({title, message, confirmLabel, cancelLabel, danger}).
184
+ // Participates in dialogFocus (Escape/Tab-trap/focus-restore) like any dialog.
185
+ // Backdrop click and Escape both resolve false (the safe default for a guard).
186
+ if (typeof window !== 'undefined' && !window.gmConfirm) {
187
+ window.gmConfirm = function (opts) {
188
+ opts = opts || {};
189
+ return new Promise((resolve) => {
190
+ let dlg = document.getElementById('gm-confirm-dialog');
191
+ if (!dlg) {
192
+ dlg = document.createElement('div');
193
+ dlg.id = 'gm-confirm-dialog';
194
+ dlg.className = 'dialog-overlay';
195
+ dlg.style.display = 'none';
196
+ dlg.setAttribute('role', 'alertdialog');
197
+ dlg.setAttribute('aria-modal', 'true');
198
+ dlg.setAttribute('aria-labelledby', 'gm-confirm-title');
199
+ dlg.setAttribute('aria-describedby', 'gm-confirm-message');
200
+ dlg.innerHTML =
201
+ '<div class="dialog-panel" style="max-width:440px">' +
202
+ '<div class="dialog-header"><span class="dialog-title" id="gm-confirm-title"></span>' +
203
+ '<button class="dialog-close" type="button" aria-label="Close dialog" data-gm-confirm="cancel">&times;</button></div>' +
204
+ '<div class="dialog-body"><p id="gm-confirm-message" style="margin:0"></p></div>' +
205
+ '<div class="dialog-footer">' +
206
+ '<button class="btn btn-ghost btn-sm" type="button" data-gm-confirm="cancel"></button>' +
207
+ '<button class="btn btn-primary btn-sm" type="button" data-gm-confirm="ok"></button></div>';
208
+ document.body.appendChild(dlg);
209
+ }
210
+ const titleEl = dlg.querySelector('#gm-confirm-title');
211
+ const msgEl = dlg.querySelector('#gm-confirm-message');
212
+ const okBtn = dlg.querySelector('[data-gm-confirm="ok"]');
213
+ const cancelBtn = dlg.querySelector('[data-gm-confirm="cancel"].btn-ghost');
214
+ titleEl.textContent = opts.title || 'Are you sure?';
215
+ msgEl.textContent = opts.message || '';
216
+ okBtn.textContent = opts.confirmLabel || 'Confirm';
217
+ if (cancelBtn) cancelBtn.textContent = opts.cancelLabel || 'Cancel';
218
+ // danger styling for irreversible/destructive actions
219
+ okBtn.className = opts.danger ? 'btn btn-error btn-sm' : 'btn btn-primary btn-sm';
220
+
221
+ let settled = false;
222
+ const finish = (val) => {
223
+ if (settled) return;
224
+ settled = true;
225
+ dlg.style.display = 'none';
226
+ dialogFocus.onClose(dlg);
227
+ dlg.removeEventListener('click', onClick);
228
+ dlg.removeEventListener('keydown', onEsc);
229
+ resolve(val);
230
+ };
231
+ const onClick = (e) => {
232
+ const b = e.target.closest('[data-gm-confirm]');
233
+ if (b) { e.preventDefault(); finish(b.dataset.gmConfirm === 'ok'); return; }
234
+ if (e.target === dlg) finish(false); // backdrop click cancels
235
+ };
236
+ // Escape is handled by central dialogFocus keydown (hides + onClose);
237
+ // we mirror it here so the Promise also resolves to false on Escape.
238
+ const onEsc = (e) => { if (e.key === 'Escape') finish(false); };
239
+ dlg.addEventListener('keydown', onEsc);
240
+ dlg.addEventListener('click', onClick);
241
+ dlg.style.display = 'flex';
242
+ dialogFocus.onOpen(dlg);
243
+ });
244
+ };
245
+ }
246
+
17
247
  const eventDelegation = {
18
248
  closeDialog(dialogId) {
19
249
  const el = document.getElementById(dialogId);
20
- if (el) el.style.display = 'none';
250
+ if (el) { el.style.display = 'none'; dialogFocus.onClose(el); }
21
251
  },
22
252
 
23
253
  openDialog(e, target, params) {
24
254
  const id = params?.dialogId || (target?.dataset?.args ? JSON.parse(target.dataset.args)[0] : null);
25
255
  const el = id ? document.getElementById(id) : null;
26
- if (el) el.style.display = 'flex';
256
+ if (el) { el.style.display = 'flex'; dialogFocus.onOpen(el); }
27
257
  },
28
258
 
29
259
  printPage() { window.print(); },
@@ -78,16 +308,27 @@ document.addEventListener('click', (e) => {
78
308
 
79
309
  if (target.classList.contains('dialog-overlay') && e.target === target) {
80
310
  target.style.display = 'none';
311
+ dialogFocus.onClose(target);
81
312
  return;
82
313
  }
83
314
 
84
315
  if (target.dataset.dialogClose) {
85
- eventDelegation.closeDialog(target.dataset.dialogClose);
86
- if (target.dataset.action) {
87
- const params = target.dataset.params ? JSON.parse(target.dataset.params) : {};
88
- window.__events.dispatch(target.dataset.action, e, target, params);
316
+ // Backdrop/overlay containers carry data-dialog-close to close on backdrop
317
+ // click; they must NOT close when the click originated inside the panel.
318
+ // Explicit close controls (buttons/links) close regardless of bubbling.
319
+ const isBackdropContainer = target.classList.contains('dialog-backdrop') || target.classList.contains('dialog-overlay') || target.classList.contains('modal') || target.classList.contains('modal-overlay');
320
+ if (isBackdropContainer && e.target !== target) {
321
+ // click bubbled up from inside the panel — ignore, let inner handlers run
322
+ } else {
323
+ eventDelegation.closeDialog(target.dataset.dialogClose);
324
+ if (target.dataset.action) {
325
+ const params = target.dataset.params ? JSON.parse(target.dataset.params) : {};
326
+ window.__events.dispatch(target.dataset.action, e, target, params);
327
+ }
328
+ return;
89
329
  }
90
- } else if (target.dataset.navigate) {
330
+ }
331
+ if (target.dataset.navigate) {
91
332
  eventDelegation.navigate(target.dataset.navigate);
92
333
  } else if (target.dataset.toggle) {
93
334
  eventDelegation.toggle(target.dataset.toggle);
@@ -98,11 +339,110 @@ document.addEventListener('click', (e) => {
98
339
  }, true);
99
340
 
100
341
  document.addEventListener('keydown', (e) => {
342
+ // Escape closes the topmost open dialog and restores focus to its opener.
101
343
  if (e.key === 'Escape') {
102
- const dialog = document.querySelector('[role="dialog"]:not([style*="display:none"])');
344
+ const dialog = dialogFocus.topOpenDialog();
103
345
  if (dialog) {
104
346
  dialog.style.display = 'none';
347
+ dialogFocus.onClose(dialog);
105
348
  e.preventDefault();
106
349
  }
350
+ return;
351
+ }
352
+
353
+ // Tab is trapped inside the topmost open dialog so focus cannot escape behind it.
354
+ if (e.key === 'Tab') {
355
+ const dialog = dialogFocus.topOpenDialog();
356
+ if (!dialog) return;
357
+ const f = dialogFocus.focusables(dialog);
358
+ if (!f.length) { e.preventDefault(); return; }
359
+ const first = f[0], last = f[f.length - 1];
360
+ const active = document.activeElement;
361
+ if (!dialog.contains(active)) { e.preventDefault(); first.focus(); return; }
362
+ if (e.shiftKey && active === first) { e.preventDefault(); last.focus(); }
363
+ else if (!e.shiftKey && active === last) { e.preventDefault(); first.focus(); }
364
+ return;
365
+ }
366
+
367
+ // Enter/Space activates a focused keyboard-navigable row ([data-navigate] on a
368
+ // non-natively-focusable element that we made focusable with tabindex).
369
+ if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
370
+ const t = e.target;
371
+ if (t && t.dataset && t.dataset.navigate &&
372
+ t.tagName !== 'A' && t.tagName !== 'BUTTON' && t.tagName !== 'INPUT' &&
373
+ t.tagName !== 'SELECT' && t.tagName !== 'TEXTAREA') {
374
+ e.preventDefault();
375
+ eventDelegation.navigate(t.dataset.navigate);
376
+ }
107
377
  }
108
378
  });
379
+
380
+ // Make non-natively-focusable navigable rows keyboard-operable: a <tr> (or any
381
+ // non-link/button element) carrying data-navigate gets tabindex+role=link so it
382
+ // can be focused and activated with Enter/Space (handled in the keydown above).
383
+ function upgradeNavigableRows(root) {
384
+ (root || document).querySelectorAll('[data-navigate]').forEach((el) => {
385
+ const tag = el.tagName;
386
+ if (tag === 'A' || tag === 'BUTTON' || tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') return;
387
+ if (el.hasAttribute('tabindex')) return;
388
+ el.setAttribute('tabindex', '0');
389
+ el.setAttribute('role', 'link');
390
+ });
391
+ }
392
+
393
+ // A bare <button> defaults to type=submit; inside a <form> a close/action button
394
+ // then submits the form by accident. We set type=button on every button that is
395
+ // clearly not a submit control (carries a delegated action, or is not inside a
396
+ // form), leaving genuine submit buttons (type=submit, or the form's own submit)
397
+ // untouched.
398
+ function upgradeButtonTypes(root) {
399
+ (root || document).querySelectorAll('button:not([type])').forEach((b) => {
400
+ const isAction = b.hasAttribute('data-action') || b.hasAttribute('data-dialog-close') ||
401
+ b.hasAttribute('data-navigate') || b.hasAttribute('data-toggle') ||
402
+ b.classList.contains('dialog-close');
403
+ if (isAction || !b.closest('form')) b.setAttribute('type', 'button');
404
+ // A button inside a form with no action attrs is left as-is so a real submit
405
+ // button keeps working; authors mark non-submit form buttons with data-action.
406
+ });
407
+ }
408
+
409
+ function upgradeAll(root) { upgradeNavigableRows(root); upgradeButtonTypes(root); }
410
+ // The script may load from <head> (before the body markup exists) or at end of
411
+ // body (after). Cover every ordering: run now, on DOMContentLoaded, on load, and
412
+ // once more after a tick so server-rendered rows are definitely present. All
413
+ // idempotent (queries are :not([type]) / :not([tabindex])).
414
+ function runUpgrades() { try { upgradeAll(document); } catch (_) {} }
415
+ runUpgrades();
416
+ document.addEventListener('DOMContentLoaded', runUpgrades);
417
+ window.addEventListener('load', runUpgrades);
418
+ setTimeout(runUpgrades, 0);
419
+
420
+ // Catch dialogs opened by direct display flips (window.show*Dialog setters that
421
+ // bypass the central openDialog) and by direct display:none closes — so focus
422
+ // management and the Tab-trap apply uniformly however a dialog is toggled.
423
+ (function observeDialogs() {
424
+ if (typeof MutationObserver === 'undefined') return;
425
+ const wasVisible = new WeakMap();
426
+ const sync = (el) => {
427
+ if (!dialogFocus.isDialog(el)) return;
428
+ const now = dialogFocus.isVisible(el);
429
+ const before = wasVisible.get(el) || false;
430
+ if (now && !before) dialogFocus.onOpen(el);
431
+ else if (!now && before) dialogFocus.onClose(el);
432
+ wasVisible.set(el, now);
433
+ };
434
+ const observer = new MutationObserver((mutations) => {
435
+ for (const m of mutations) {
436
+ if (m.type === 'attributes' && (m.attributeName === 'style' || m.attributeName === 'class')) {
437
+ sync(m.target);
438
+ }
439
+ }
440
+ });
441
+ const start = () => {
442
+ document.querySelectorAll('[role="dialog"], .dialog-overlay, .dialog-backdrop, .modal, .modal-overlay')
443
+ .forEach((el) => { wasVisible.set(el, dialogFocus.isVisible(el)); });
444
+ observer.observe(document.body, { attributes: true, subtree: true, attributeFilter: ['style', 'class'] });
445
+ };
446
+ if (document.body) start();
447
+ else document.addEventListener('DOMContentLoaded', start);
448
+ })();