thatcher 1.0.6 → 1.0.33

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 (174) hide show
  1. package/README.md +4 -16
  2. package/package.json +12 -18
  3. package/src/adapters/google-auth.js +4 -43
  4. package/src/adapters/google-drive.js +12 -67
  5. package/src/app/api/audit/dashboard/route.js +14 -11
  6. package/src/app/api/audit/logs/route.js +2 -2
  7. package/src/app/api/audit/permissions/[id]/route.js +6 -3
  8. package/src/app/api/audit/permissions/route.js +10 -7
  9. package/src/app/api/audit/permissions/stats/route.js +8 -5
  10. package/src/app/api/audit/route.js +4 -4
  11. package/src/app/api/audit/stats/route.js +1 -1
  12. package/src/app/api/auth/google/callback/route.js +5 -2
  13. package/src/app/api/auth/google/route.js +4 -1
  14. package/src/app/api/auth/login/route.js +5 -2
  15. package/src/app/api/auth/mwr-bridge/route.js +11 -21
  16. package/src/app/api/auth/password-reset/route.js +15 -14
  17. package/src/app/api/cron/trigger/route.js +4 -1
  18. package/src/app/api/debug/[[...path]]/route.js +6 -5
  19. package/src/app/api/debug/sqlite/route.js +7 -12
  20. package/src/app/api/debug/sync/route.js +5 -5
  21. package/src/app/api/domains/route.js +4 -1
  22. package/src/app/api/email/allocate/batch/route.js +19 -29
  23. package/src/app/api/email/allocate/route.js +16 -17
  24. package/src/app/api/email/receive/route.js +31 -42
  25. package/src/app/api/email/send/route.js +28 -16
  26. package/src/app/api/email/unallocated/route.js +10 -13
  27. package/src/app/api/files/[id]/route.js +4 -1
  28. package/src/app/api/formula/[[...path]]/route.js +0 -4
  29. package/src/app/api/health/route.js +10 -9
  30. package/src/app/api/metrics/route.js +1 -1
  31. package/src/cli.js +4 -4
  32. package/src/config/config-loader.js +1 -21
  33. package/src/config/constants.js +0 -35
  34. package/src/config/env.js +0 -30
  35. package/src/config/spec-helpers.js +6 -95
  36. package/src/engine.js +23 -0
  37. package/src/engine.server.js +8 -78
  38. package/src/index.js +42 -67
  39. package/src/lib/alert-manager.js +7 -3
  40. package/src/lib/api-helpers.js +4 -3
  41. package/src/lib/api.js +10 -10
  42. package/src/lib/audit-logger-enhanced.js +77 -83
  43. package/src/lib/auth-middleware.js +1 -32
  44. package/src/lib/auth-route-helpers.js +5 -0
  45. package/src/lib/busybase-adapter.js +0 -5
  46. package/src/lib/busybase-audit-reads.js +142 -0
  47. package/src/lib/busybase-audit.js +41 -0
  48. package/src/lib/busybase-lucia-adapter.js +72 -0
  49. package/src/lib/busybase-store.js +338 -0
  50. package/src/lib/config-field-helpers.js +0 -17
  51. package/src/lib/config-generator-engine.js +19 -36
  52. package/src/lib/config-helpers.js +0 -21
  53. package/src/lib/crud-action-helpers.js +2 -2
  54. package/src/lib/crud-factory.js +1 -16
  55. package/src/lib/crud-handlers.js +17 -52
  56. package/src/lib/csrf-protection.js +5 -2
  57. package/src/lib/date-utils.js +3 -127
  58. package/src/lib/debug-registry.js +0 -5
  59. package/src/lib/domain-loader.js +4 -1
  60. package/src/lib/email-sender.js +25 -23
  61. package/src/lib/error-handler.js +29 -88
  62. package/src/lib/error-recovery.js +10 -7
  63. package/src/lib/error-resilience.js +7 -5
  64. package/src/lib/events-engine.js +9 -6
  65. package/src/lib/export-sink.js +0 -5
  66. package/src/lib/field-iterator.js +7 -76
  67. package/src/lib/field-registry.js +0 -22
  68. package/src/lib/generic-crud-handler.js +0 -16
  69. package/src/lib/hook-engine.js +5 -53
  70. package/src/lib/hot-reload/checkpoint.js +5 -2
  71. package/src/lib/hot-reload/promise-container.js +7 -3
  72. package/src/lib/hot-reload/route-wrapper.js +4 -1
  73. package/src/lib/hot-reload/supervisor.js +6 -3
  74. package/src/lib/hyperformula-service.js +0 -5
  75. package/src/lib/id-helpers.js +10 -0
  76. package/src/lib/index.js +4 -7
  77. package/src/lib/log-aggregator.js +0 -1
  78. package/src/lib/logger.js +0 -15
  79. package/src/lib/metrics-collector.js +2 -46
  80. package/src/lib/next-compat.js +15 -1
  81. package/src/lib/observability-bootstrap.js +0 -5
  82. package/src/lib/perf-monitor.js +4 -1
  83. package/src/lib/perf-profiler.js +0 -5
  84. package/src/lib/query-string-adapter.js +5 -47
  85. package/src/lib/realtime-server.js +4 -26
  86. package/src/lib/request-tracing.js +0 -5
  87. package/src/lib/resource-monitor.js +4 -1
  88. package/src/lib/response-formatter.js +0 -38
  89. package/src/lib/route-helpers.js +3 -1
  90. package/src/lib/safe-json.js +5 -1
  91. package/src/lib/server-bootstrap.js +10 -9
  92. package/src/lib/stage-pipeline.js +2 -2
  93. package/src/lib/state-transport-client.js +4 -1
  94. package/src/lib/static-server.js +2 -2
  95. package/src/lib/status-helpers.js +2 -51
  96. package/src/lib/tracing.js +0 -5
  97. package/src/lib/utils.js +6 -67
  98. package/src/lib/validate.js +5 -88
  99. package/src/lib/validators.js +0 -38
  100. package/src/lib/with-audit-logging.js +1 -1
  101. package/src/lib/workflow-engine.js +10 -77
  102. package/src/lib/xstate-workflow-engine.js +3 -9
  103. package/src/plugins/index.js +5 -3
  104. package/src/server/server.js +26 -27
  105. package/src/services/collaborator-role.service.js +21 -77
  106. package/src/services/email-sender.js +7 -34
  107. package/src/services/notification-engine.js +12 -33
  108. package/src/services/permission.service.js +10 -73
  109. package/src/ui/advanced-search-renderer.js +3 -2
  110. package/src/ui/advanced-widgets.js +1 -1
  111. package/src/ui/auth-pages.js +10 -9
  112. package/src/ui/auth-styles.js +1 -1
  113. package/src/ui/client.js +1 -1
  114. package/src/ui/component-engine.js +14 -5
  115. package/src/ui/dashboard-renderer.js +5 -5
  116. package/src/ui/dialog-engine.js +6 -9
  117. package/src/ui/dialog-factory.js +2 -2
  118. package/src/ui/engagement-cards.js +7 -7
  119. package/src/ui/engagement-dialogs.js +2 -3
  120. package/src/ui/engagement-grid-renderer.js +20 -19
  121. package/src/ui/entity-renderer.js +56 -42
  122. package/src/ui/file-dialogs.js +25 -9
  123. package/src/ui/flexup-report-renderer.js +21 -18
  124. package/src/ui/format-helpers.js +27 -28
  125. package/src/ui/highlight-threading-renderer.js +9 -9
  126. package/src/ui/job-management-renderer.js +18 -16
  127. package/src/ui/layout.js +51 -25
  128. package/src/ui/ml-console-renderer.js +15 -16
  129. package/src/ui/monitoring-dashboard-client.js +9 -8
  130. package/src/ui/notifications-renderer.js +11 -10
  131. package/src/ui/page-handler-admin.js +32 -23
  132. package/src/ui/page-handler-helpers.js +27 -23
  133. package/src/ui/page-handler-reviews.js +71 -35
  134. package/src/ui/page-handler-rfi.js +28 -14
  135. package/src/ui/page-handler.js +65 -48
  136. package/src/ui/picker-dialogs.js +26 -19
  137. package/src/ui/render-helpers.js +84 -46
  138. package/src/ui/review-comparison-renderer.js +8 -7
  139. package/src/ui/review-detail-panels.js +8 -9
  140. package/src/ui/review-detail-renderer.js +3 -3
  141. package/src/ui/review-detail-script.js +13 -12
  142. package/src/ui/review-mwr-renderer.js +6 -5
  143. package/src/ui/review-renderer.js +7 -6
  144. package/src/ui/review-widgets.js +3 -3
  145. package/src/ui/rfi-detail-renderer.js +37 -20
  146. package/src/ui/rfi-report-renderer.js +14 -9
  147. package/src/ui/settings-renderer-advanced.js +21 -18
  148. package/src/ui/settings-renderer-advanced2.js +20 -19
  149. package/src/ui/settings-renderer-teams.js +4 -4
  150. package/src/ui/settings-renderer.js +4 -4
  151. package/src/ui/spacing-system.js +5 -2
  152. package/src/ui/standalone-login.js +2 -2
  153. package/src/ui/styles.css +2 -1
  154. package/src/ui/styles2.css +75 -5
  155. package/src/ui/test-page.js +4 -4
  156. package/src/ui/widgets.js +20 -15
  157. package/src/lib/audit-logger.js +0 -193
  158. package/src/lib/database-core.js +0 -255
  159. package/src/lib/database-migrations.js +0 -350
  160. package/src/lib/db-backup.js +0 -97
  161. package/src/lib/error-boundary.js +0 -134
  162. package/src/lib/errors.js +0 -69
  163. package/src/lib/health-monitor.js +0 -134
  164. package/src/lib/index-optimizer.js +0 -96
  165. package/src/lib/query-engine-write.js +0 -221
  166. package/src/lib/query-engine.js +0 -446
  167. package/src/ui/collaboration-dialogs.js +0 -31
  168. package/src/ui/global-tags.js +0 -144
  169. package/src/ui/idle-logout.js +0 -156
  170. package/src/ui/letter-dialogs.js +0 -37
  171. package/src/ui/password-reset-page.js +0 -146
  172. package/src/ui/perf-renderer.js +0 -71
  173. package/src/ui/rfi-renderer.js +0 -194
  174. package/src/ui/validation-ui.js +0 -147
@@ -1,8 +1,8 @@
1
1
  import { TOAST_SCRIPT, settingsPage, settingsBack, inlineTable } from '@/ui/settings-renderer.js';
2
- import { esc } from '@/ui/render-helpers.js';
2
+ import { esc, icon } from '@/ui/render-helpers.js';
3
3
 
4
4
  const bc = (label) => [{ href: '/', label: 'Dashboard' }, { href: '/admin/settings', label: 'Settings' }, { label }];
5
- const togRow = tg => `<div class="flex justify-between items-center py-3 border-b border-base-200"><div><div class="text-sm font-semibold">${tg.label}</div><div class="text-xs text-base-content/50">${tg.desc}</div></div><input type="checkbox" name="${tg.id}" ${tg.checked ? 'checked' : ''} class="checkbox checkbox-primary"/></div>`;
5
+ const togRow = tg => `<div class="flex justify-between items-center py-3 border-b border-base-200"><div><div class="text-sm font-semibold">${tg.label}</div><div class="text-xs text-base-content/80">${tg.desc}</div></div><input type="checkbox" name="${tg.id}" ${tg.checked ? 'checked' : ''} class="checkbox checkbox-primary"/></div>`;
6
6
 
7
7
  export function renderSettingsReviewSettings(user, config = {}) {
8
8
  const review = config.review || {};
@@ -39,7 +39,7 @@ export function renderSettingsFileReview(user, config = {}, frSettings = {}) {
39
39
  { id: 'mobile_resize', label: 'Mobile Resize', desc: 'Enable mobile-friendly resizable highlights', checked: fr.mobile_resize !== false },
40
40
  { id: 'coordinate_snap', label: 'Coordinate Snap', desc: 'Snap highlight coordinates to text boundaries', checked: !!fr.coordinate_snap },
41
41
  ];
42
- const flagChips = (flags, type) => flags.map(f => `<span class="pill pill-neutral" style="display:inline-flex;align-items:center;gap:4px">${esc(f)}<button type="button" onclick="removeFlag('${type}','${esc(f.replace(/'/g,"\\'"))}')" style="background:none;border:none;cursor:pointer;font-size:1rem;line-height:1;color:inherit">&times;</button></span>`).join('');
42
+ const flagChips = (flags, type) => flags.map(f => `<span class="pill pill-neutral" style="display:inline-flex;align-items:center;gap:4px">${esc(f)}<button type="button" data-action="removeFlag" data-args='${esc(JSON.stringify([type, f]))}' aria-label="Remove flag" style="background:none;border:none;cursor:pointer;line-height:1;color:inherit;display:inline-flex">${icon('close',16)}</button></span>`).join('');
43
43
  const content = `${settingsBack()}<h1 class="text-2xl font-bold mb-6">File Review Settings</h1>
44
44
  <div id="fr-review-flags" data-flags='${JSON.stringify(reviewFlags)}'></div>
45
45
  <div id="fr-tender-flags" data-flags='${JSON.stringify(tenderFlags)}'></div>
@@ -55,18 +55,19 @@ export function renderSettingsFileReview(user, config = {}, frSettings = {}) {
55
55
  <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
56
56
  <div class="card-clean"><div class="card-clean-body"><h2 class="card-title text-base mb-3">Review Flags</h2>
57
57
  <div id="review-flags-list" class="flex flex-wrap gap-2 mb-3">${flagChips(reviewFlags, 'review')}</div>
58
- <div class="flex gap-2"><input type="text" id="new-review-flag" class="input input-solid" placeholder="New flag label..." style="flex:1"/><button type="button" onclick="addFlag('review')" class="btn btn-primary btn-sm">Add</button></div>
58
+ <div class="flex gap-2"><input type="text" id="new-review-flag" class="input input-solid" placeholder="New flag label..." style="flex:1"/><button type="button" data-action="addFlag" data-args='["review"]' class="btn btn-primary btn-sm">Add</button></div>
59
59
  </div></div>
60
60
  <div class="card-clean"><div class="card-clean-body"><h2 class="card-title text-base mb-3">Tender Flags</h2>
61
61
  <div id="tender-flags-list" class="flex flex-wrap gap-2 mb-3">${flagChips(tenderFlags, 'tender')}</div>
62
- <div class="flex gap-2"><input type="text" id="new-tender-flag" class="input input-solid" placeholder="New flag label..." style="flex:1"/><button type="button" onclick="addFlag('tender')" class="btn btn-primary btn-sm">Add</button></div>
62
+ <div class="flex gap-2"><input type="text" id="new-tender-flag" class="input input-solid" placeholder="New flag label..." style="flex:1"/><button type="button" data-action="addFlag" data-args='["tender"]' class="btn btn-primary btn-sm">Add</button></div>
63
63
  </div></div>
64
64
  </div>
65
65
  <button type="submit" class="btn btn-primary">Save File Review Settings</button></form>`;
66
66
  const script = `${TOAST_SCRIPT}
67
67
  var _rvFlags=${JSON.stringify(reviewFlags)};
68
68
  var _tdFlags=${JSON.stringify(tenderFlags)};
69
- function renderFlags(type){const list=type==='review'?_rvFlags:_tdFlags;const el=document.getElementById(type+'-flags-list');if(!el)return;el.innerHTML=list.map(f=>'<span class="pill pill-neutral" style="display:inline-flex;align-items:center;gap:4px">'+f+'<button type="button" onclick="removeFlag(\\''+type+'\\',\\''+f.replace(/'/g,"\\'")+'\\')" style="background:none;border:none;cursor:pointer;font-size:1rem;line-height:1;color:inherit">&times;</button></span>').join('')}
69
+ function _escFlag(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;')}
70
+ function renderFlags(type){const list=type==='review'?_rvFlags:_tdFlags;const el=document.getElementById(type+'-flags-list');if(!el)return;el.innerHTML=list.map(f=>'<span class="pill pill-neutral" style="display:inline-flex;align-items:center;gap:4px">'+_escFlag(f)+'<button type="button" data-action="removeFlag" data-args=\\''+_escFlag(JSON.stringify([type,f]))+'\\' aria-label="Remove flag" style="background:none;border:none;cursor:pointer;font-size:1rem;line-height:1;color:inherit">&times;</button></span>').join('')}
70
71
  window.addFlag=function(type){const inp=document.getElementById('new-'+type+'-flag');const val=(inp?.value||'').trim();if(!val)return;if(type==='review'){_rvFlags=[..._rvFlags,val]}else{_tdFlags=[..._tdFlags,val]};inp.value='';renderFlags(type)};
71
72
  window.removeFlag=function(type,flag){if(type==='review'){_rvFlags=_rvFlags.filter(f=>f!==flag)}else{_tdFlags=_tdFlags.filter(f=>f!==flag)};renderFlags(type)};
72
73
  document.getElementById('file-review-settings-form').addEventListener('submit',async(e)=>{e.preventDefault();const fd=new FormData(e.target);const data={};for(const[k,v]of fd.entries())data[k]=v;document.querySelectorAll('#file-review-settings-form input[type=checkbox]').forEach(cb=>{data[cb.name]=cb.checked});document.querySelectorAll('#file-review-settings-form input[type=number]').forEach(n=>{if(data[n.name])data[n.name]=Number(data[n.name])});data.review_flags=_rvFlags;data.tender_flags=_tdFlags;try{const res=await fetch('/api/admin/settings/file-review',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});if(res.ok){showToast('Settings saved','success')}else{showToast('Save failed','error')}}catch(err){showToast('Error: '+err.message,'error')}})`;
@@ -79,31 +80,31 @@ export function renderSettingsMwrPermissions(user, permissions = []) {
79
80
  <td class="text-sm">${esc(p.entity_id||'-')}</td>
80
81
  <td class="text-sm">${esc(p.user_id||'-')}</td>
81
82
  <td class="text-sm">${esc(p.permission_type||'-')}</td>
82
- <td class="text-sm">${p.granted_at ? new Date(p.granted_at * 1000).toLocaleDateString() : '-'}</td>
83
- <td><button class="btn btn-error btn-xs btn-outline" onclick="revokePermission('${esc(p.id)}')">Revoke</button></td>
83
+ <td class="text-sm">${p.granted_at ? new Date(p.granted_at * 1000).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'}) : '-'}</td>
84
+ <td><button type="button" class="btn btn-error btn-xs btn-outline" data-action="revokePermission" data-args='${esc(JSON.stringify([p.id]))}'>Revoke</button></td>
84
85
  </tr>`).join('');
85
86
  const content = `${settingsBack()}<div class="flex justify-between items-center mb-6">
86
87
  <h1 class="text-2xl font-bold">MWR Permissions</h1>
87
- <button class="btn btn-primary btn-sm" onclick="document.getElementById('grant-perm-dialog').style.display='flex'">+ Grant Permission</button>
88
+ <button class="btn btn-primary btn-sm" data-action="openDialog" data-args='["grant-perm-dialog"]'>+ Grant Permission</button>
88
89
  </div>
89
90
  ${inlineTable(['Entity Type','Entity ID','User ID','Permission','Granted','Actions'], rows, 'No permissions configured')}
90
- <div id="grant-perm-dialog" class="dialog-overlay" style="display:none" role="dialog" aria-modal="true">
91
- <div class="dialog-panel" style="max-width:460px">
92
- <div class="dialog-header"><span class="dialog-title">Grant Permission</span><button class="dialog-close" onclick="document.getElementById('grant-perm-dialog').style.display='none'">&times;</button></div>
91
+ <div id="grant-perm-dialog" class="dialog-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="gp-dialog-title" data-dialog-close="grant-perm-dialog">
92
+ <div class="dialog-panel" style="max-width:min(460px,90vw)">
93
+ <div class="dialog-header"><span class="dialog-title" id="gp-dialog-title">Grant Permission</span><button class="dialog-close" data-dialog-close="grant-perm-dialog" aria-label="Close">&times;</button></div>
93
94
  <div class="dialog-body">
94
- <div class="modal-form-group"><label>Entity Type</label><input id="gp-entity-type" class="form-input" placeholder="review"/></div>
95
- <div class="modal-form-group"><label>Entity ID</label><input id="gp-entity-id" class="form-input" placeholder="entity id"/></div>
96
- <div class="modal-form-group"><label>User ID</label><input id="gp-user-id" class="form-input" placeholder="user id"/></div>
95
+ <div class="modal-form-group"><label>Entity Type</label><input id="gp-entity-type" class="form-input" placeholder="e.g. review"/></div>
96
+ <div class="modal-form-group"><label>Entity ID</label><input id="gp-entity-id" class="form-input" placeholder="e.g. 1a2b3c"/></div>
97
+ <div class="modal-form-group"><label>User ID</label><input id="gp-user-id" class="form-input" placeholder="e.g. user_42"/></div>
97
98
  <div class="modal-form-group"><label>Permission Type</label><select id="gp-perm-type" class="form-input"><option value="view">view</option><option value="edit">edit</option><option value="admin">admin</option></select></div>
98
99
  <div id="gp-result" style="display:none;margin-top:8px"></div>
99
100
  </div>
100
101
  <div class="dialog-footer">
101
- <button class="btn btn-ghost btn-sm" onclick="document.getElementById('grant-perm-dialog').style.display='none'">Cancel</button>
102
- <button class="btn btn-primary btn-sm" onclick="grantPermission()">Grant</button>
102
+ <button class="btn btn-ghost btn-sm" data-dialog-close="grant-perm-dialog">Cancel</button>
103
+ <button class="btn btn-primary btn-sm" data-action="grantPermission">Grant</button>
103
104
  </div>
104
105
  </div>
105
106
  </div>`;
106
- const script = `${TOAST_SCRIPT}async function grantPermission(){var body={entity_type:document.getElementById('gp-entity-type').value.trim(),entity_id:document.getElementById('gp-entity-id').value.trim(),user_id:document.getElementById('gp-user-id').value.trim(),permission_type:document.getElementById('gp-perm-type').value};var res=document.getElementById('gp-result');if(!body.entity_type||!body.entity_id||!body.user_id){res.style.display='block';res.innerHTML='<div style="color:var(--color-danger);font-size:13px">All fields required.</div>';return}try{var r=await fetch('/api/mwr/permissions',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});var d=await r.json();if(r.ok&&d.success){showToast('Permission granted','success');document.getElementById('grant-perm-dialog').style.display='none';setTimeout(function(){location.reload()},500)}else{res.style.display='block';res.innerHTML='<div style="color:var(--color-danger);font-size:13px">'+(d.error||'Failed')+'</div>'}}catch(e){showToast('Error: '+e.message,'error')}}async function revokePermission(id){if(!confirm('Revoke this permission?'))return;try{var r=await fetch('/api/permission/'+id,{method:'DELETE'});if(r.ok){showToast('Revoked','success');setTimeout(function(){location.reload()},500)}else{showToast('Failed','error')}}catch(e){showToast('Error: '+e.message,'error')}}`;
107
+ const script = `${TOAST_SCRIPT}window.grantPermission=async function(){var body={entity_type:document.getElementById('gp-entity-type').value.trim(),entity_id:document.getElementById('gp-entity-id').value.trim(),user_id:document.getElementById('gp-user-id').value.trim(),permission_type:document.getElementById('gp-perm-type').value};var res=document.getElementById('gp-result');if(!body.entity_type||!body.entity_id||!body.user_id){res.style.display='block';res.innerHTML='<div style="color:var(--color-danger);font-size:13px">All fields required.</div>';return}try{var r=await fetch('/api/mwr/permissions',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});var d=await r.json();if(r.ok&&d.success){showToast('Permission granted','success');(window.closeDialog?window.closeDialog('grant-perm-dialog'):document.getElementById('grant-perm-dialog').style.display='none');setTimeout(function(){location.reload()},500)}else{res.style.display='block';res.innerHTML='<div style="color:var(--color-danger);font-size:13px">'+(d.error||'Failed')+'</div>'}}catch(e){showToast('Error: '+e.message,'error')}};window.revokePermission=async function(id){if(!(await window.gmConfirm({title:'Revoke permission',message:'Revoke this permission?',confirmLabel:'Revoke',danger:true})))return;try{var r=await fetch('/api/permission/'+id,{method:'DELETE'});if(r.ok){showToast('Revoked','success');setTimeout(function(){location.reload()},500)}else{showToast('Failed','error')}}catch(e){showToast('Error: '+e.message,'error')}}`;
107
108
  return settingsPage(user, 'MWR Permissions', bc('MWR Permissions'), content, [script]);
108
109
  }
109
110
 
@@ -153,6 +154,6 @@ export function renderSettingsTemplateManage(user, template = {}, sections = [])
153
154
  window.editTplSection=function(id,name,color){document.getElementById('tpl-section-form').classList.remove('hidden');document.getElementById('tpl-sec-id').value=id;document.getElementById('tpl-sec-name').value=name;document.getElementById('tpl-sec-color').value=color};
154
155
  window.cancelTplSection=function(){document.getElementById('tpl-section-form').classList.add('hidden')};
155
156
  window.saveTplSection=async function(){var id=document.getElementById('tpl-sec-id').value;var body={name:document.getElementById('tpl-sec-name').value,color:document.getElementById('tpl-sec-color').value,review_template_id:tplId};if(!body.name){showToast('Name required','error');return}var url=id?'/api/review_template_section/'+id:'/api/review_template_section';var method=id?'PUT':'POST';try{var res=await fetch(url,{method:method,headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});if(res.ok){showToast(id?'Updated':'Created','success');setTimeout(function(){location.reload()},500)}else{showToast('Failed','error')}}catch(e){showToast('Error','error')}};
156
- window.deleteTplSection=async function(id){if(!confirm('Delete this section?'))return;try{var res=await fetch('/api/review_template_section/'+id,{method:'DELETE'});if(res.ok){showToast('Deleted','success');setTimeout(function(){location.reload()},500)}else{showToast('Delete failed','error')}}catch(e){showToast('Error','error')}};`;
157
+ window.deleteTplSection=async function(id){if(!(await window.gmConfirm({title:'Delete Section',message:'Delete this section? This cannot be undone.',confirmLabel:'Delete',danger:true})))return;try{var res=await fetch('/api/review_template_section/'+id,{method:'DELETE'});if(res.ok){showToast('Deleted','success');setTimeout(function(){location.reload()},500)}else{showToast('Delete failed','error')}}catch(e){showToast('Error','error')}};`;
157
158
  return settingsPage(user, `Manage Template - ${esc(template.name || 'Template')}`, tplBc, content, [script]);
158
159
  }
@@ -14,8 +14,8 @@ function parseMembers(t) {
14
14
  }
15
15
 
16
16
  export function renderSettingsTeams(user, teams = [], allUsers = []) {
17
- const editDialog = `<div id="team-dialog" class="modal" style="display:none" data-overlay-close="true" data-action="closeTeamDialog">
18
- <div class="modal-overlay" data-action="closeTeamDialog"></div>
17
+ const editDialog = `<div id="team-dialog" class="modal" style="display:none" role="dialog" aria-modal="true" aria-labelledby="team-dialog-title">
18
+ <div class="modal-overlay" data-dialog-close="team-dialog"></div>
19
19
  <div class="modal-content rounded-box max-w-md p-6">
20
20
  <h3 id="team-dialog-title" class="text-lg font-semibold mb-4">Edit Team</h3>
21
21
  <input type="hidden" id="team-edit-id"/>
@@ -59,7 +59,7 @@ document.addEventListener('DOMContentLoaded',function(){document.querySelectorAl
59
59
  const content = `${settingsBack()}${editDialog}
60
60
  <div class="flex justify-between items-center mb-4">
61
61
  <h1 class="text-2xl font-bold">Teams</h1>
62
- <button class="btn btn-primary btn-sm gap-1 team-add-btn"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> Add Team</button>
62
+ <button class="btn btn-primary btn-sm gap-1 team-add-btn"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> Add Team</button>
63
63
  </div>
64
64
  <div class="card-clean"><div class="card-clean-body" style="padding:0rem">${inlineTable(['Name', 'Members', 'Actions'], rows, 'No teams found')}</div></div>`;
65
65
  return settingsPage(user, 'Teams - Settings', [{ href: '/', label: 'Dashboard' }, { href: '/admin/settings', label: 'Settings' }, { label: 'Teams' }], content, [script]);
@@ -101,7 +101,7 @@ export function renderSettingsRfiSections(user, sections = []) {
101
101
  const script = `${TOAST_SCRIPT}
102
102
  function toggleAddForm(){document.getElementById('section-form').style.display='block';document.getElementById('edit-id').value='';document.getElementById('section-name').value='';selectColor('#B0B0B0')}
103
103
  function cancelForm(){document.getElementById('section-form').style.display='none'}
104
- function selectColor(c){document.getElementById('section-color').value=c;document.querySelectorAll('#color-palette div').forEach(el=>{el.style.borderColor=el.dataset.color===c?'#1a1a1a':'transparent'})}
104
+ function selectColor(c){document.getElementById('section-color').value=c;document.querySelectorAll('#color-palette div').forEach(el=>{el.style.borderColor=el.dataset.color===c?'var(--color-text)':'transparent'})}
105
105
  function editSection(id,name,color){document.getElementById('section-form').style.display='block';document.getElementById('edit-id').value=id;document.getElementById('section-name').value=name;selectColor(color)}
106
106
  async function saveSection(){const name=document.getElementById('section-name').value.trim();const color=document.getElementById('section-color').value;const id=document.getElementById('edit-id').value;if(!name){showToast('Name is required','error');return}const url=id?'/api/rfi_section/'+id:'/api/rfi_section';const method=id?'PUT':'POST';try{const res=await fetch(url,{method,headers:{'Content-Type':'application/json'},body:JSON.stringify({name,color})});if(res.ok){showToast(id?'Updated':'Created','success');setTimeout(()=>location.reload(),500)}else{showToast('Failed','error')}}catch(e){showToast('Error: '+e.message,'error')}}
107
107
  async function deleteSection(id){if(!(await window.gmConfirm({title:'Please confirm',message:'Delete this section?',danger:true,confirmLabel:'OK'})))return;try{const res=await fetch('/api/rfi_section/'+id,{method:'DELETE'});if(res.ok){showToast('Deleted','success');setTimeout(()=>location.reload(),500)}else{showToast('Delete failed','error')}}catch(e){showToast('Error','error')}}
@@ -50,7 +50,7 @@ const SETTINGS_CARDS = [
50
50
  export function renderSettingsHome(user, config = {}, counts = {}) {
51
51
  const cards = SETTINGS_CARDS.map(c => {
52
52
  const cnt = c.countKey && counts[c.countKey] !== undefined ? counts[c.countKey] : null;
53
- const badge = cnt !== null ? `<span class="badge badge-flat-primary text-xs">${cnt.toLocaleString()} items</span>` : '';
53
+ const badge = cnt !== null ? `<span class="badge badge-flat-primary text-xs">${cnt.toLocaleString('en-ZA')} items</span>` : '';
54
54
  return `<a href="${c.href}" class="card-clean" style="text-decoration:none;border:1px solid var(--color-border);transition:box-shadow 0.15s">
55
55
  <div class="card-clean-body" style="padding:${SPACING.md}">
56
56
  <div style="display:flex;align-items:flex-start;gap:${SPACING.md}">
@@ -60,7 +60,7 @@ export function renderSettingsHome(user, config = {}, counts = {}) {
60
60
  <div style="font-size:0.75rem;color:var(--color-text-muted);margin-top:${SPACING.xs};line-height:1.4">${c.desc}</div>
61
61
  ${badge ? `<div style="margin-top:${SPACING.sm}">${badge}</div>` : ''}
62
62
  </div>
63
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;color:var(--color-text-muted);margin-top:${SPACING.xs}"><polyline points="9 18 15 12 9 6"/></svg>
63
+ <svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;color:var(--color-text-muted);margin-top:${SPACING.xs}"><polyline points="9 18 15 12 9 6"/></svg>
64
64
  </div>
65
65
  </div>
66
66
  </a>`;
@@ -120,10 +120,10 @@ export function renderSettingsUsers(user, users = []) {
120
120
  <div class="flex justify-between items-center mb-2">
121
121
  <div>
122
122
  <h1 class="text-2xl font-bold">Users</h1>
123
- <p class="text-sm text-base-content/50 mt-0.5">${users.length} total &middot; ${active} active</p>
123
+ <p class="text-sm text-base-content/50 mt-0.5">${users.length} total - ${active} active</p>
124
124
  </div>
125
125
  <a href="/user/new" class="btn btn-primary btn-sm gap-1">
126
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> Add User
126
+ <svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> Add User
127
127
  </a>
128
128
  </div>
129
129
  <div class="table-toolbar mb-3">
@@ -124,11 +124,14 @@ export function renderStatsRow(stats) {
124
124
  </div>`).join('')}</div>`;
125
125
  }
126
126
 
127
+ // title/subtitle are plain text (escaped here); actionsHtml is a caller-built
128
+ // HTML fragment by contract. esc() inline to keep this module dependency-free.
129
+ function phEsc(s) { return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
127
130
  export function renderPageHeader(title, subtitle, actionsHtml = '') {
128
131
  return `<div class="page-header">
129
132
  <div>
130
- <h1 class="page-title">${title}</h1>
131
- ${subtitle ? `<p class="page-subtitle">${subtitle}</p>` : ''}
133
+ <h1 class="page-title">${phEsc(title)}</h1>
134
+ ${subtitle ? `<p class="page-subtitle">${phEsc(subtitle)}</p>` : ''}
132
135
  </div>
133
136
  ${actionsHtml ? `<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">${actionsHtml}</div>` : ''}
134
137
  </div>`;
@@ -4,7 +4,7 @@ export function renderStandaloneLogin(showGoogleAuth = false) {
4
4
  <head>
5
5
  <meta charset="UTF-8">
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
- <title>Sign In | Moonlanding</title>
7
+ <title>Sign In | Thatcher</title>
8
8
  <style>
9
9
  *{margin:0;padding:0;box-sizing:border-box}
10
10
  :root{--bg:#f1f5f9;--surface:#fff;--border:#e2e8f0;--text:#0f172a;--text-muted:#64748b;--primary:#04141f;--primary-hover:#0a2535;--accent:#2563eb;--danger:#dc2626;--danger-bg:#fee2e2;--danger-border:#fca5a5;--radius:10px;--shadow:0 4px 24px rgba(0,0,0,0.09)}
@@ -45,7 +45,7 @@ export function renderStandaloneLogin(showGoogleAuth = false) {
45
45
  <div class="wrap">
46
46
  <div class="logo">
47
47
  <div class="logo-mark"><svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="1.75" aria-hidden="true"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></div>
48
- <span class="logo-text">Moonlanding</span>
48
+ <span class="logo-text">Thatcher</span>
49
49
  <div class="logo-sub">Sign in to your account</div>
50
50
  </div>
51
51
  <div class="card">
package/src/ui/styles.css CHANGED
@@ -1184,9 +1184,10 @@ tr:last-child td { border-bottom: none; }
1184
1184
  .dialog-panel { background: white; border-radius: 0.75rem; padding: 1.5rem; max-width: 480px; width: 90%; max-height: 85vh; overflow-y: auto; box-shadow: 0 20px 60px rgba(0,0,0,0.2); }
1185
1185
  .dialog-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; }
1186
1186
  .dialog-title { font-size: 1.125rem; font-weight: 600; color: #111827; }
1187
- .dialog-close { background: none; border: none; font-size: 1.5rem; color: #9ca3af; cursor: pointer; line-height: 1; padding: 0; }
1187
+ .dialog-close { background: none; border: none; font-size: 1.5rem; color: #9ca3af; cursor: pointer; line-height: 1; padding: 0; min-width: 44px; min-height: 44px; display: flex; align-items: center; justify-content: center; }
1188
1188
  .dialog-close:hover { color: #374151; }
1189
1189
  @media (prefers-color-scheme: dark) {
1190
+ .dialog-title { color: #f5f5f5; }
1190
1191
  .dialog-close { color: #9ca3af; }
1191
1192
  .dialog-close:hover { color: #f5f5f5; }
1192
1193
  }
@@ -11,6 +11,7 @@
11
11
  --ds-border: #3a3d42;
12
12
  --ds-accent: #247420;
13
13
  --ds-accent-light: #3A9A34;
14
+ --ds-on-accent: #fff;
14
15
  --ds-mint: rgb(182, 222, 193);
15
16
  --ds-pill-radius: 999px;
16
17
  --ds-font-body: 'Nunito', Inter, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
@@ -23,7 +24,7 @@ body[data-ds-theme="247420"], body { background: var(--ds-bg); color: var(--ds-f
23
24
  h1, .page-title, h2 { font-family: var(--ds-font-display); letter-spacing: -0.3px; }
24
25
  .pill, .badge, .info-label, .meta, code, kbd, .mono { font-family: var(--ds-font-mono); }
25
26
  .btn, button.btn-primary, button.btn-secondary, .btn-ghost, .btn-outline { border-radius: var(--ds-pill-radius); padding: 8px 18px; font-family: var(--ds-font-body); font-weight: 600; }
26
- .btn-primary { background: var(--ds-accent); color: var(--ds-fg); border: 1px solid var(--ds-accent-light); }
27
+ .btn-primary { background: var(--ds-accent); color: var(--ds-on-accent); border: 1px solid var(--ds-accent-light); }
27
28
  .btn-primary:hover { background: var(--ds-accent-light); }
28
29
 
29
30
  /* Layered surfaces */
@@ -35,7 +36,7 @@ h1, .page-title, h2 { font-family: var(--ds-font-display); letter-spacing: -0.3p
35
36
  #ds-online-status.offline { display: inline-block; background: #4a1d1d; border-color: #6b2828; color: #ffd6d6; }
36
37
 
37
38
  /* FlexUp chat dock */
38
- #flexup-fab { position: fixed; bottom: 20px; right: 20px; z-index: 9998; width: 52px; height: 52px; border-radius: 999px; background: var(--ds-accent); color: white; border: 1px solid var(--ds-accent-light); cursor: pointer; font-family: var(--ds-font-display); font-size: 20px; box-shadow: 0 4px 12px rgba(0,0,0,0.4); }
39
+ #flexup-fab { position: fixed; bottom: 20px; right: 20px; z-index: 9998; width: 52px; height: 52px; border-radius: 999px; background: var(--ds-accent); color: var(--ds-on-accent); border: 1px solid var(--ds-accent-light); cursor: pointer; font-family: var(--ds-font-display); font-size: 20px; box-shadow: 0 4px 12px rgba(0,0,0,0.4); }
39
40
  #flexup-dock { position: fixed; bottom: 84px; right: 20px; z-index: 9998; width: 360px; max-height: 480px; background: var(--ds-surface); border: 1px solid var(--ds-border); border-radius: 12px; display: none; flex-direction: column; box-shadow: 0 12px 32px rgba(0,0,0,0.5); }
40
41
  #flexup-dock.open { display: flex; }
41
42
  #flexup-dock .fx-header { padding: 12px 14px; border-bottom: 1px solid var(--ds-border); font-family: var(--ds-font-display); font-size: 14px; }
@@ -45,7 +46,7 @@ h1, .page-title, h2 { font-family: var(--ds-font-display); letter-spacing: -0.3p
45
46
  #flexup-dock .fx-msg.bot { background: var(--ds-bg); border: 1px solid var(--ds-border); }
46
47
  #flexup-dock .fx-input-row { display: flex; gap: 6px; padding: 10px; border-top: 1px solid var(--ds-border); }
47
48
  #flexup-dock input.fx-input { flex: 1; background: var(--ds-bg); color: var(--ds-fg); border: 1px solid var(--ds-border); border-radius: 8px; padding: 8px 10px; font-family: var(--ds-font-body); }
48
- #flexup-dock button.fx-send { background: var(--ds-accent); color: var(--ds-fg); border: 0; border-radius: 8px; padding: 6px 14px; font-family: var(--ds-font-body); font-weight: 600; cursor: pointer; }
49
+ #flexup-dock button.fx-send { background: var(--ds-accent); color: var(--ds-on-accent); border: 0; border-radius: 8px; padding: 6px 14px; font-family: var(--ds-font-body); font-weight: 600; cursor: pointer; }
49
50
 
50
51
  /* Priority star */
51
52
  .review-priority-star { background: transparent; border: 0; cursor: pointer; color: var(--ds-muted); padding: 2px 6px; font-size: 16px; }
@@ -78,7 +79,7 @@ html, body { overflow-x: hidden; max-width: 100%; }
78
79
  --color-bg: #f1f5f9;
79
80
  --color-border: #e2e8f0;
80
81
  --color-text: #0f172a;
81
- --color-text-muted: #64748b;
82
+ --color-text-muted: #5a6b7f;
82
83
  --color-text-light: #94a3b8;
83
84
  --color-success: #16a34a;
84
85
  --color-warning: #d97706;
@@ -169,13 +170,48 @@ html, body { overflow-x: hidden; max-width: 100%; }
169
170
 
170
171
  /* Stage pills */
171
172
  .stage-pill { display: inline-flex; align-items: center; padding: 4px 12px; border-radius: 100px; font-size: 12px; font-weight: 600; letter-spacing: 0.2px; }
172
- .stage-info_gathering { background: #fee2e2; color: #991b1b; }
173
+ .stage-info_gathering { background: #dbeafe; color: #1e40af; }
173
174
  .stage-commencement { background: #fef3c7; color: #92400e; }
174
175
  .stage-team_execution { background: #dbeafe; color: #1e40af; }
175
176
  .stage-partner_review { background: #ede9fe; color: #5b21b6; }
176
177
  .stage-finalization { background: #dcfce7; color: #15803d; }
177
178
  .stage-closeout { background: #f1f5f9; color: #475569; }
178
179
 
180
+ /* Badge variants for table cells and detail views (inherit stage/status pill colors via classes) */
181
+ .badge-stage { display: inline-flex; align-items: center; padding: 4px 12px; border-radius: 100px; font-size: 12px; font-weight: 600; }
182
+ .badge-stage.stage-info_gathering { background: #dbeafe; color: #1e40af; }
183
+ .badge-stage.stage-commencement { background: #fef3c7; color: #92400e; }
184
+ .badge-stage.stage-team_execution { background: #dbeafe; color: #1e40af; }
185
+ .badge-stage.stage-partner_review { background: #ede9fe; color: #5b21b6; }
186
+ .badge-stage.stage-finalization { background: #dcfce7; color: #15803d; }
187
+ .badge-stage.stage-closeout { background: #f1f5f9; color: #475569; }
188
+
189
+ /* Status label badges (alias to pill classes for consistency) */
190
+ .status-label { display: inline-flex; align-items: center; padding: 3px 10px; border-radius: 100px; font-size: 12px; font-weight: 500; }
191
+
192
+ /* Dark-mode overrides for status pills + stage pills: keep them as dark-tinted
193
+ islands (not light-on-dark) with legible light text. */
194
+ @media (prefers-color-scheme: dark) {
195
+ .pill-success { background: #14321f; color: #4ade80; }
196
+ .pill-danger { background: #3a1717; color: #f87171; }
197
+ .pill-warning { background: #3a2c12; color: #fbbf24; }
198
+ .pill-info { background: #16263f; color: #60a5fa; }
199
+ .pill-neutral { background: #25303d; color: #cbd5e1; }
200
+ .pill-primary { background: #122b3a; color: #38bdf8; }
201
+ .stage-info_gathering { background: #1e3a8a; color: #60a5fa; }
202
+ .stage-commencement { background: #3a2c12; color: #fcd34d; }
203
+ .stage-team_execution { background: #16263f; color: #93c5fd; }
204
+ .stage-partner_review { background: #261b3f; color: #c4b5fd; }
205
+ .stage-finalization { background: #14321f; color: #86efac; }
206
+ .stage-closeout { background: #25303d; color: #cbd5e1; }
207
+ .badge-stage.stage-info_gathering { background: #1e3a8a; color: #60a5fa; }
208
+ .badge-stage.stage-commencement { background: #3a2c12; color: #fcd34d; }
209
+ .badge-stage.stage-team_execution { background: #16263f; color: #93c5fd; }
210
+ .badge-stage.stage-partner_review { background: #261b3f; color: #c4b5fd; }
211
+ .badge-stage.stage-finalization { background: #14321f; color: #86efac; }
212
+ .badge-stage.stage-closeout { background: #25303d; color: #cbd5e1; }
213
+ }
214
+
179
215
  /* Buttons */
180
216
  .btn-primary-clean { display: inline-flex; align-items: center; gap: 6px; padding: 9px 18px; background: var(--color-primary); color: white; border: none; border-radius: 6px; font-size: 14px; font-weight: 500; cursor: pointer; text-decoration: none; transition: all 0.15s ease; min-height: 40px; }
181
217
  .btn-primary-clean:hover { background: var(--color-primary-hover); color: white; transform: translateY(-1px); box-shadow: var(--shadow-md); }
@@ -194,6 +230,11 @@ html, body { overflow-x: hidden; max-width: 100%; }
194
230
  .btn-danger-clean:hover { background: #7f1d1d; }
195
231
  }
196
232
  .btn-danger-clean:active { opacity: 0.8; }
233
+ /* gmConfirm danger:true emits .btn-error, but the runtime-injected `.ds-247420 .btn { background:var(--bg-2) }`
234
+ (specificity 0,2,0) out-weighed a plain .btn-error and rendered it gray (witnessed rgb(37,37,44)).
235
+ !important guarantees destructive-confirm buttons read as danger regardless of injected theme order. */
236
+ .btn-error, button.btn-error, .ds-247420 .btn-error { background: var(--color-danger, #dc2626) !important; color: #fff !important; border-color: var(--color-danger, #dc2626) !important; }
237
+ .btn-error:hover, button.btn-error:hover, .ds-247420 .btn-error:hover { background: #b91c1c !important; border-color: #b91c1c !important; }
197
238
  .btn-danger-clean:focus-visible { outline: 2px solid var(--color-danger); outline-offset: 2px; }
198
239
  .btn-edit-clean { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; background: transparent; color: var(--color-info); border: 1px solid #bfdbfe; border-radius: 6px; font-size: 13px; font-weight: 500; cursor: pointer; text-decoration: none; transition: all 0.15s ease; min-height: 36px; }
199
240
  .btn-edit-clean:hover { background: #eff6ff; }
@@ -768,6 +809,11 @@ button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible
768
809
  .main-nav-brand-link { display: flex; align-items: center; gap: 0.5rem; text-decoration: none; flex-shrink: 0; }
769
810
  .main-nav-brand-text { color: #f8f9fa; font-size: 0.9rem; font-weight: 700; letter-spacing: 0; margin-left: 0.25rem; }
770
811
  .main-nav-actions { display: flex; align-items: center; gap: 0.75rem; margin-left: 1rem; padding-right: 1rem; flex-shrink: 0; }
812
+ /* Shared nav icon-button (theme toggle, notification bell, hamburger): one rule
813
+ instead of duplicated inline style strings on each element. */
814
+ .nav-icon-btn { background: none; border: none; color: var(--color-text-muted, #ced4da); cursor: pointer; padding: 6px; border-radius: 4px; display: flex; align-items: center; min-width: 32px; min-height: 32px; text-decoration: none; transition: color 0.15s, background 0.15s; }
815
+ .nav-icon-btn:hover { color: var(--color-text, #fff); background: rgba(255,255,255,0.08); }
816
+ .nav-icon-btn.nav-notif-bell { position: relative; }
771
817
  .nav-links-desktop { display: flex; align-items: center; gap: 1.25rem; flex-shrink: 0; padding: 0 0.75rem; }
772
818
  @media (max-width: 768px) { .nav-links-desktop { display: none; } }
773
819
  .nav-link { color: #adb5bd; font-size: 0.875rem; font-weight: 500; letter-spacing: 0; text-decoration: none; transition: color 0.15s; }
@@ -1180,6 +1226,9 @@ button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible
1180
1226
  .opacity-25 { opacity: 0.25; }
1181
1227
  .opacity-50 { opacity: 0.5; }
1182
1228
  .opacity-75 { opacity: 0.75; }
1229
+ /* read notification row: distinguish via muted bg instead of low-contrast opacity */
1230
+ tr.is-read { background: rgb(var(--base-200, 240 240 244) / 0.4); }
1231
+ tr.is-read td { color: rgb(var(--base-content, 30 30 35) / 0.7); }
1183
1232
  .opacity-100 { opacity: 1; }
1184
1233
 
1185
1234
  /* Object fit */
@@ -1357,6 +1406,9 @@ button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible
1357
1406
  .tab-btn.active { color: var(--color-primary); border-bottom-color: var(--color-primary); font-weight: 600; }
1358
1407
  .tab-count { background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 100px; font-size: 11px; font-weight: 600; padding: 1px 7px; color: var(--color-text-muted); }
1359
1408
  .tab-btn.active .tab-count { background: var(--color-primary); color: #fff; border-color: transparent; }
1409
+ /* Dark-mode --color-primary (#64b5f6) is light; white text on it is 2.2:1 (fails AA).
1410
+ Use dark text on the active-tab count badge in dark theme (witnessed on /review). */
1411
+ [data-theme="dark"] .tab-btn.active .tab-count { color: #0b1220; }
1360
1412
 
1361
1413
  /* Form improvements */
1362
1414
  .form-field { margin-bottom: 1rem; }
@@ -1473,6 +1525,24 @@ button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible
1473
1525
  --panel-select: rgba(255,255,255,0.10);
1474
1526
  --panel-accent: #B8FF00;
1475
1527
  --panel-accent-fg: #0B0B09;
1528
+ /* Manual dark toggle must drive the --color-* system too: the @media (prefers-color-scheme: dark)
1529
+ block below only fires on OS preference, so without these the toggle left --color-text* dark
1530
+ on a dark --color-bg (witnessed 1.4:1 date cells / breadcrumb-sep on /review). */
1531
+ --color-primary: #64b5f6;
1532
+ --color-primary-hover: #42a5f5;
1533
+ --color-surface: #1a1a1a;
1534
+ --color-bg: #0f172a;
1535
+ --color-border: #334155;
1536
+ --color-text: #f1f5f9;
1537
+ --color-text-muted: #cbd5e1;
1538
+ --color-text-light: #94a3b8;
1539
+ --color-success: #4ade80;
1540
+ --color-warning: #fbbf24;
1541
+ --color-danger: #f87171;
1542
+ --color-info: #60a5fa;
1543
+ --shadow-sm: 0 1px 3px rgba(0,0,0,0.5), 0 1px 2px rgba(0,0,0,0.3);
1544
+ --shadow-md: 0 4px 6px rgba(0,0,0,0.5), 0 2px 4px rgba(0,0,0,0.3);
1545
+ --shadow-lg: 0 10px 15px rgba(0,0,0,0.6), 0 4px 6px rgba(0,0,0,0.4);
1476
1546
  }
1477
1547
 
1478
1548
  @media (prefers-color-scheme: dark) {
@@ -4,7 +4,7 @@ export function generateTestPage() {
4
4
  <head>
5
5
  <meta charset="UTF-8">
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
- <title>System Test | MOONLANDING</title>
7
+ <title>System Test | Thatcher</title>
8
8
  <style>
9
9
  body { font-family: monospace; padding: 2rem; background: #04141f; color: #ced4da; }
10
10
  h1 { color: #3b82f6; }
@@ -13,9 +13,9 @@ export function generateTestPage() {
13
13
  </style>
14
14
  </head>
15
15
  <body>
16
- <h1>MOONLANDING System Test</h1>
17
- <p class="check">✓ Server is running</p>
18
- <p class="check">✓ Page handler is operational</p>
16
+ <h1>Thatcher System Test</h1>
17
+ <p class="check">[x] Server is running</p>
18
+ <p class="check">[x] Page handler is operational</p>
19
19
  <p class="info">Timestamp: ${new Date().toISOString()}</p>
20
20
  <p class="info">Environment: ${process.env.NODE_ENV || 'development'}</p>
21
21
  </body>
package/src/ui/widgets.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { h } from '@/ui/webjsx.js'
2
- import { STAGE_COLORS, AVATAR_COLORS, AVATAR_SIZES, nameHash, getInitials } from '@/ui/render-helpers.js'
2
+ import { STAGE_COLORS, AVATAR_COLORS, AVATAR_SIZES, nameHash, getInitials, esc } from '@/ui/render-helpers.js'
3
3
 
4
4
  export function linearProgress(value = 0, max = 100, label = '', variant = 'medium') {
5
5
  const pct = max > 0 ? Math.min(100, Math.round((value / max) * 100)) : 0
@@ -19,7 +19,7 @@ export function circularProgress(value = 0, max = 100, label = '') {
19
19
  const pct = max > 0 ? Math.min(100, Math.round((value / max) * 100)) : 0
20
20
  const color = pct < 30 ? '#ef4444' : pct < 70 ? '#f59e0b' : '#22c55e'
21
21
  const r = 40, circ = 2 * Math.PI * r, offset = circ - (pct / 100) * circ
22
- const ariaLabel = label || 'Progress'
22
+ const ariaLabel = esc(label || 'Progress')
23
23
  return `<div class="circular-progress" style="width:100px;height:100px" role="progressbar" aria-valuenow="${pct}" aria-valuemin="0" aria-valuemax="100" aria-label="${ariaLabel}">
24
24
  <svg width="100" height="100" viewBox="0 0 100 100" aria-hidden="true">
25
25
  <circle cx="50" cy="50" r="${r}" fill="none" stroke="#e5e7eb" stroke-width="8"/>
@@ -28,7 +28,7 @@ export function circularProgress(value = 0, max = 100, label = '') {
28
28
  </svg>
29
29
  <div class="circular-progress-text" aria-hidden="true">
30
30
  <span class="circular-progress-pct">${pct}%</span>
31
- ${label ? `<span class="circular-progress-label">${label}</span>` : ''}
31
+ ${label ? `<span class="circular-progress-label">${esc(label)}</span>` : ''}
32
32
  </div>
33
33
  </div>`
34
34
  }
@@ -46,10 +46,12 @@ export function engagementProgress(stage, stages = null) {
46
46
 
47
47
  export function emptyState(icon = '', title = '', message = '', actionHref = '', actionLabel = '') {
48
48
  const btn = actionHref && actionLabel ? h('a', { href: actionHref, className: 'btn btn-primary btn-sm' }, actionLabel) : ''
49
+ // icon is a trusted inline-SVG/markup fragment from the caller; title/message are
50
+ // text and must be escaped (webjsx h() does NOT escape string children).
49
51
  return h('div', { className: 'empty-state' },
50
- h('div', { className: 'empty-state-icon' }, icon),
51
- h('div', { className: 'empty-state-title' }, title),
52
- h('div', { className: 'empty-state-msg' }, message),
52
+ h('div', { className: 'empty-state-icon', innerHTML: icon }),
53
+ h('div', { className: 'empty-state-title' }, esc(title)),
54
+ h('div', { className: 'empty-state-msg' }, esc(message)),
53
55
  btn
54
56
  )
55
57
  }
@@ -69,7 +71,8 @@ export function userAvatar(user, size = 'md', showStatus = false) {
69
71
  const color = AVATAR_COLORS[nameHash(name) % AVATAR_COLORS.length]
70
72
  const fontSize = Math.round(px * 0.4)
71
73
  const statusDot = showStatus ? `<span class="avatar-status avatar-status-${user?.status === 'active' || user?.online ? 'online' : 'offline'}" style="width:${Math.round(px * 0.3)}px;height:${Math.round(px * 0.3)}px"></span>` : ''
72
- return `<span class="user-avatar user-avatar-${size}" style="width:${px}px;height:${px}px;background:${color};font-size:${fontSize}px" title="${name}" aria-label="${name}" role="img"><span aria-hidden="true">${initials}</span>${statusDot}</span>`
74
+ const safeName = esc(name)
75
+ return `<span class="user-avatar user-avatar-${size}" style="width:${px}px;height:${px}px;background:${color};font-size:${fontSize}px" title="${safeName}" aria-label="${safeName}" role="img"><span aria-hidden="true">${esc(initials)}</span>${statusDot}</span>`
73
76
  }
74
77
 
75
78
  export function teamAvatarGroup(users = [], maxShow = 3) {
@@ -82,12 +85,12 @@ export function teamAvatarGroup(users = [], maxShow = 3) {
82
85
  }
83
86
 
84
87
  export function infoBubble(text, position = 'top') {
85
- return `<span class="info-bubble info-bubble-${position}" data-tooltip="${text.replace(/"/g, '&quot;')}"><svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true"><circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/><text x="8" y="12" text-anchor="middle" fill="currentColor" font-size="10" font-weight="600">i</text></svg></span>`
88
+ return `<span class="info-bubble info-bubble-${position}" data-tooltip="${esc(text)}"><svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true"><circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/><text x="8" y="12" text-anchor="middle" fill="currentColor" font-size="10" font-weight="600">i</text></svg></span>`
86
89
  }
87
90
 
88
91
  export function sortableList(items = [], containerId = 'sortable') {
89
92
  const lis = items.map((item, i) =>
90
- `<li class="sortable-item" draggable="true" data-index="${i}"><span class="sortable-handle"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/></svg></span><span>${typeof item === 'string' ? item : item.label || item.name || ''}</span></li>`
93
+ `<li class="sortable-item" draggable="true" data-index="${i}"><span class="sortable-handle"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/></svg></span><span>${esc(typeof item === 'string' ? item : item.label || item.name || '')}</span></li>`
91
94
  ).join('')
92
95
  const script = `(function(){const c=document.getElementById('${containerId}');let dragged=null;c.addEventListener('dragstart',e=>{dragged=e.target.closest('.sortable-item');e.dataTransfer.effectAllowed='move'});c.addEventListener('dragover',e=>{e.preventDefault();const t=e.target.closest('.sortable-item');if(t&&t!==dragged)t.classList.add('drag-over')});c.addEventListener('dragleave',e=>{const t=e.target.closest('.sortable-item');if(t)t.classList.remove('drag-over')});c.addEventListener('drop',e=>{e.preventDefault();const t=e.target.closest('.sortable-item');if(t&&t!==dragged){t.classList.remove('drag-over');c.insertBefore(dragged,t.nextSibling);const order=[...c.querySelectorAll('.sortable-item')].map(el=>+el.dataset.index);c.dispatchEvent(new CustomEvent('sortable-reorder',{detail:{order}}))}});})();`
93
96
  return `<ul id="${containerId}" class="sortable-list">${lis}</ul><script>${script}</script>`
@@ -99,7 +102,7 @@ export function responseChoiceBox(name, options = [], selected = null, type = 'r
99
102
  const val = typeof opt === 'string' ? opt : opt.value || opt
100
103
  const lbl = typeof opt === 'string' ? opt : opt.label || opt.value || opt
101
104
  const checked = sel.includes(val) ? 'checked' : ''
102
- return `<label class="choice-option"><input type="${type}" name="${name}" value="${val}" ${checked}/><span class="choice-label">${lbl}</span></label>`
105
+ return `<label class="choice-option"><input type="${esc(type)}" name="${esc(name)}" value="${esc(val)}" ${checked}/><span class="choice-label">${esc(lbl)}</span></label>`
103
106
  }).join('')
104
107
  return `<div class="choice-group">${items}</div>`
105
108
  }
@@ -112,8 +115,9 @@ export function responseAttachment(file = {}) {
112
115
  const isImg = ['png','jpg','jpeg','gif','webp','svg'].includes(ext)
113
116
  const isPdf = ext === 'pdf'
114
117
  const icon = isImg ? `<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"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>` : isPdf ? `<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="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>` : `<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>`
115
- const preview = isImg ? `<img src="${href}" alt="${name}" class="attachment-preview"/>` : isPdf ? `<iframe src="${href}" class="attachment-preview" style="width:100%;height:200px;border:none"></iframe>` : ''
116
- return `<div class="attachment-card"><span class="attachment-icon">${icon}</span><div class="attachment-info"><div class="attachment-name">${name}</div>${size ? `<div class="attachment-size">${size}</div>` : ''}${preview}</div><a href="${href}" download class="btn btn-ghost btn-xs">Download</a></div>`
118
+ const safeHref = esc(href), safeName = esc(name), safeSize = esc(size)
119
+ const preview = isImg ? `<img src="${safeHref}" alt="${safeName}" class="attachment-preview"/>` : isPdf ? `<iframe src="${safeHref}" class="attachment-preview" style="width:100%;height:200px;border:none"></iframe>` : ''
120
+ return `<div class="attachment-card"><span class="attachment-icon">${icon}</span><div class="attachment-info"><div class="attachment-name">${safeName}</div>${size ? `<div class="attachment-size">${safeSize}</div>` : ''}${preview}</div><a href="${safeHref}" download class="btn btn-ghost btn-xs">Download</a></div>`
117
121
  }
118
122
 
119
123
  export function accordion(items) {
@@ -121,8 +125,9 @@ export function accordion(items) {
121
125
  return h('div', { className: 'accordion' },
122
126
  items.map(item =>
123
127
  h('details', { className: 'accordion-item' },
124
- h('summary', { className: 'accordion-summary' }, item.title),
125
- h('div', { className: 'accordion-content' }, item.content)
128
+ h('summary', { className: 'accordion-summary' }, esc(item.title)),
129
+ // content is a caller-provided HTML fragment by contract.
130
+ h('div', { className: 'accordion-content', innerHTML: item.content })
126
131
  )
127
132
  ).join('')
128
133
  )
@@ -130,7 +135,7 @@ export function accordion(items) {
130
135
 
131
136
  export function divider(label) {
132
137
  if (!label) return '<hr class="divider"/>'
133
- return `<div class="divider-labeled"><hr class="divider-line"/><span class="divider-text">${label}</span><hr class="divider-line"/></div>`
138
+ return `<div class="divider-labeled"><hr class="divider-line"/><span class="divider-text">${esc(label)}</span><hr class="divider-line"/></div>`
134
139
  }
135
140
 
136
141
  export function responsiveClass(breakpoint) {