File size: 20,910 Bytes
b60973d
 
 
8a9d88a
 
3f3e761
b04fa88
e8f61c8
2ad2aff
 
e8f61c8
 
f35d3f6
b60973d
8012b6f
 
2460317
 
b60973d
f9ca812
 
 
 
 
b60973d
 
 
fbcd415
 
 
3b2fb14
b60973d
 
 
 
 
 
 
45ebf29
 
 
 
b60973d
 
 
 
 
 
 
 
 
99a44ac
 
ec6b346
 
99a44ac
3a73292
 
 
 
 
 
 
 
 
 
 
 
99a44ac
704a62b
99a44ac
 
 
 
 
 
 
 
 
 
 
93abf36
99a44ac
 
 
 
 
 
 
 
 
 
 
3387889
b60973d
 
3387889
99a44ac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8012b6f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2460317
 
 
 
 
 
 
f6cf611
 
 
 
2460317
 
 
 
 
 
 
 
 
 
 
 
 
f6cf611
2460317
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d3283b8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99a44ac
 
 
b60973d
99a44ac
 
 
 
 
b60973d
 
 
 
99a44ac
 
53148eb
 
 
e8f61c8
53148eb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b60973d
 
99a44ac
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
// Shared client helpers: auth bootstrap, fetch wrapper, DOM utils, SVG icons.

const ICONS = {
  file: '<path d="M6 3.5h7l3 3v12H6z"/><path d="M13 3.5v3h3"/>',
  folder: '<path d="M3.5 6.5h6l2-2h7v13h-15z"/>',
  star: '<path d="M11 3.5l2.2 4.5 5 .7-3.6 3.5.9 5-4.5-2.4-4.5 2.4.9-5-3.6-3.5 5-.7z"/>',
  tree: '<path d="M4 5h14M9 11h9M9 17h9M4 5v9.5a2.5 2.5 0 0 0 2.5 2.5H6"/>',
  key: '<circle cx="7.5" cy="14.5" r="3.5"/><path d="M10 12l8-8M14.5 4.5l3 3M12 7l2.5 2.5"/>',
  todo: '<rect x="4" y="4" width="6" height="6" rx="1.5"/><path d="M5.2 7l1.3 1.3L8.8 6"/><path d="M13 6.5h6M13 11h6M13 15.5h6"/><rect x="4" y="12.5" width="6" height="6" rx="1.5"/>',
  table: '<rect x="3.5" y="4.5" width="15" height="13" rx="1.5"/><path d="M3.5 9h15M3.5 13.5h15M8.5 4.5v13M13.5 4.5v13"/>',
  eye: '<path d="M2.5 11s3.2-5.5 8.5-5.5 8.5 5.5 8.5 5.5-3.2 5.5-8.5 5.5S2.5 11 2.5 11z"/><circle cx="11" cy="11" r="2.4"/>',
  eyeoff: '<path d="M4 4l14 14M9 5.8A8.7 8.7 0 0 1 11 5.5c5.3 0 8.5 5.5 8.5 5.5a15 15 0 0 1-2.7 3.2M6 7.5A14 14 0 0 0 2.5 11S5.7 16.5 11 16.5a8.6 8.6 0 0 0 3-.5"/>',
  caret:  '<path d="M6 9l5 5 5-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
  back: '<path d="M15 4l-7 7 7 7"/>',
  sun: '<circle cx="11" cy="11" r="4"/><path d="M11 2.5v2M11 17.5v2M2.5 11h2M17.5 11h2M5 5l1.4 1.4M15.6 15.6L17 17M17 5l-1.4 1.4M6.4 15.6L5 17"/>',
  moon: '<path d="M17 13.2A7 7 0 0 1 8.8 5a7.2 7.2 0 1 0 8.2 8.2z"/>',
  serif: '<path d="M4.5 6V4.5h13V6M11 4.5v13M8 17.5h6"/>',
  docpage: '<rect x="4.5" y="3.5" width="13" height="15" rx="1.5"/><path d="M7.5 8h7M7.5 11.5h7M7.5 15h4"/>',
  comment: '<path d="M19 12.5a6.5 6.5 0 0 1-6.5 6.5H5l-1.8 1.8V12.5a7.3 7.3 0 0 1 7.3-7.3h1.5A6.5 6.5 0 0 1 19 12.5z"/>',
  // The same bubble with a plus in it: "leave a comment here", as opposed to
  // `comment`, which marks one that already exists. The bubble is redrawn a
  // little smaller and rounder rather than reused, because the plus needs room
  // in the middle and the pointed tail reads as noise at 15px.
  commentadd: '<path d="M18.5 11.5a6 6 0 0 1-6 6H7l-3.5 2.5v-8.5a6 6 0 0 1 6-6h3a6 6 0 0 1 6 6z"/><path d="M11 8.5v6M8 11.5h6"/>',
  edit: '<path d="M14.5 4.5l3 3L8 17l-4 1 1-4 9.5-9.5z"/>',
  image: '<rect x="3.5" y="4.5" width="15" height="13" rx="2"/><circle cx="8" cy="9" r="1.4"/><path d="M18 14l-4-4-7 7"/>',
  link: '<path d="M9 12.5a3.5 3.5 0 0 0 5 0l3-3a3.5 3.5 0 0 0-5-5l-1.2 1.2"/><path d="M13 9.5a3.5 3.5 0 0 0-5 0l-3 3a3.5 3.5 0 0 0 5 5l1.2-1.2"/>',
  // the link glyph with its middle struck through: remove this link
  unlink:
    '<path d="M9.5 13a3.5 3.5 0 0 0 4.3-.5l2.7-2.7a3.5 3.5 0 0 0-4.7-5.1"/><path d="M12.5 9a3.5 3.5 0 0 0-4.3.5l-2.7 2.7a3.5 3.5 0 0 0 4.7 5.1"/><path d="M4 4l14 14"/>',
  download: '<path d="M11 3.5v10"/><path d="M6.5 9.5l4.5 4.5 4.5-4.5"/><path d="M4.5 17.5h13"/>',
  copy: '<rect x="8" y="8" width="10" height="11" rx="2"/><path d="M5.5 14.5H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7.5a2 2 0 0 1 2 2v.5"/>',
  check: '<path d="M4.5 11.5l4.5 4.5 8.5-9.5"/>',
  x: '<path d="M6 6l10 10M16 6L6 16"/>',
  reply: '<path d="M18 5v4a4 4 0 0 1-4 4H5"/><path d="M8.5 9.5L5 13l3.5 3.5"/>',
  send: '<path d="M4 11l14-6-4.5 14-3-6L4 11z"/>',
  trash: '<path d="M4.5 6.5h13M9 6V4.5h4V6M6.5 6.5l.8 12h7.4l.8-12"/>',
  quote: '<path d="M8.5 6.5c-2.5 1-4 3-4 6.5v3h4.5v-5h-2.7c.2-2 1-3.3 2.2-4.5zM18 6.5c-2.5 1-4 3-4 6.5v3h4.5v-5h-2.7c.2-2 1-3.3 2.2-4.5z"/>',
  undo: '<path d="M8.5 5.5L4 10l4.5 4.5"/><path d="M4 10h9a5 4.5 0 0 1 0 9h-3"/>',
  redo: '<path d="M13.5 5.5L18 10l-4.5 4.5"/><path d="M18 10H9a5 4.5 0 0 0 0 9h3"/>',
  zoomIn: '<circle cx="10" cy="10" r="6.5"/><path d="M15 15l4 4M10 7.5v5M7.5 10h5"/>',
  zoomOut: '<circle cx="10" cy="10" r="6.5"/><path d="M15 15l4 4M7.5 10h5"/>',
}

export function icon(name) {
  const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
  svg.setAttribute('viewBox', '0 0 22 22')
  svg.setAttribute('class', 'icon')
  svg.innerHTML = ICONS[name] || ''
  return svg
}

export async function initAuth(nextPath) {
  // the shell inlines this; the fetch is only for shells served without it
  const res = window.__BOOT || (await fetch('/api/me').then(r => r.json()))
  const whoami = document.getElementById('whoami')
  // Opened through a public link: not signed in, but not turned away either.
  // No username, so every "is this mine?" check in the app answers no.
  if (!res.user && res.share) {
    whoami.replaceChildren(el('span', { class: 'muted' }, 'Read-only link'))
    if (res.oauth) {
      const inLink = document.createElement('a')
      inLink.href = `/auth/login?next=${encodeURIComponent(nextPath || '/')}`
      inLink.textContent = 'sign in'
      whoami.append(document.createTextNode(' · '), inLink)
    }
    return { username: null, name: 'Viewer', avatar: null, viewer: true, isAdmin: false, buildId: res.build_id || null }
  }
  if (res.user) {
    res.user.buildId = res.build_id || null
    whoami.replaceChildren()
    if (res.user.avatar) {
      const img = document.createElement('img')
      img.src = res.user.avatar
      whoami.appendChild(img)
    }
    whoami.appendChild(document.createTextNode(res.user.username + ' '))
    const out = document.createElement('a')
    out.href = '/auth/logout'
    out.textContent = 'sign out'
    whoami.appendChild(out)
    return { ...res.user, isAdmin: res.is_admin === true }
  }
  const overlay = document.getElementById('signin')
  overlay.classList.remove('hidden')
  if (!res.oauth) {
    document.getElementById('signin-btn').classList.add('hidden')
    document.getElementById('dev-login').classList.remove('hidden')
    document.getElementById('dev-go').addEventListener('click', () => {
      const u = document.getElementById('dev-user').value.trim()
      if (u) location.href = `/auth/dev?u=${encodeURIComponent(u)}&next=${encodeURIComponent(nextPath)}`
    })
  } else {
    // Plain in-place navigation, also inside the huggingface.co iframe: the HF
    // OAuth page renders there, while target="_top" clicks get blocked by the
    // iframe sandbox.
    document.getElementById('signin-btn').href = `/auth/login?next=${encodeURIComponent(nextPath)}`
  }
  return null
}

export async function api(path, { method = 'GET', body } = {}) {
  const res = await fetch(path, {
    method,
    headers: body ? { 'content-type': 'application/json' } : undefined,
    body: body ? JSON.stringify(body) : undefined,
  })
  try {
    return await res.json()
  } catch {
    return { error: `HTTP ${res.status}` }
  }
}

export function el(tag, attrs = {}, text) {
  const node = document.createElement(tag)
  for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v)
  if (text != null) node.textContent = text
  return node
}

export function esc(s) {
  return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
}

export function renderMentionText(text) {
  return esc(text).replace(/@([a-z0-9][a-z0-9_.-]{1,38})/g, '<span class="mention">@$1</span>')
}

// --- theme ------------------------------------------------------------------
// The head script has already stamped data-theme. This wires the button that
// overrides it, and keeps following the OS while no explicit choice is stored.
export function wireTheme(btn) {
  const media = matchMedia('(prefers-color-scheme: dark)')
  const stored = () => {
    try {
      const t = localStorage.getItem('cw-theme')
      return t === 'dark' || t === 'light' ? t : null
    } catch {
      return null
    }
  }
  const paint = () => {
    const dark = document.documentElement.dataset.theme === 'dark'
    if (!btn) return
    btn.replaceChildren(icon(dark ? 'sun' : 'moon'), el('span', { class: 'lbl' }, dark ? 'Light' : 'Dark'))
    btn.title = dark ? 'Switch to light theme' : 'Switch to dark theme'
    btn.setAttribute('aria-label', btn.title)
  }
  const apply = theme => {
    document.documentElement.dataset.theme = theme
    paint()
  }
  if (!stored()) apply(media.matches ? 'dark' : 'light')
  paint()
  media.addEventListener('change', e => {
    if (!stored()) apply(e.matches ? 'dark' : 'light')
  })
  btn?.addEventListener('click', () => {
    const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'
    try {
      localStorage.setItem('cw-theme', next)
    } catch {}
    apply(next)
  })
}

// --- document style ---------------------------------------------------------
// What the document itself is set in: 'docs' (Arial 11pt on a letter sheet) or
// 'reading' (a serif article column). Unlike the theme this has no OS signal to
// follow, so the default is simply 'docs'. The head script has already stamped
// the stored choice; this wires the button that changes it.
export const DOC_STYLES = ['docs', 'reading']

// onChange fires after a switch has been stamped, for the things that read
// --doc-size back out of the stylesheet — the reader text-size readout is set in
// px, and 100% of Docs and 100% of Reading are not the same number of them.
export function wireDocStyle(btn, onChange) {
  const paint = () => {
    const reading = document.documentElement.dataset.docStyle === 'reading'
    if (!btn) return
    btn.replaceChildren(icon(reading ? 'docpage' : 'serif'), el('span', { class: 'lbl' }, reading ? 'Docs' : 'Reading'))
    btn.title = reading ? 'Set the document in Arial, like Google Docs' : 'Set the document in a serif reading column'
    btn.setAttribute('aria-label', btn.title)
  }
  const apply = style => {
    document.documentElement.dataset.docStyle = style
    // the serif is only fetched once something on the page asks for it, so the
    // first switch is also the first request — warm it alongside the repaint
    if (style === 'reading') preloadSerif()
    paint()
    onChange?.(style)
  }
  paint()
  btn?.addEventListener('click', () => {
    const next = document.documentElement.dataset.docStyle === 'reading' ? 'docs' : 'reading'
    try {
      localStorage.setItem('cw-doc-style', next)
    } catch {}
    apply(next)
  })
}

function preloadSerif() {
  if (document.getElementById('serif-preload')) return
  const l = document.createElement('link')
  l.id = 'serif-preload'
  l.rel = 'preload'
  l.as = 'font'
  l.type = 'font/woff2'
  l.crossOrigin = 'anonymous'
  l.href = '/fonts/libre-baskerville-latin.woff2'
  document.head.appendChild(l)
}

// --- in-app dialogs ---------------------------------------------------------
// Native confirm/alert/prompt cannot be used in this app. It is served inside an
// iframe on huggingface.co, and Chrome suppresses dialogs from cross-origin
// iframes: confirm() returns false without ever asking, so every action guarded
// by one silently did nothing (that is what "the remove command doesn't work"
// was). These are the replacements — a real modal, and a toast for errors that
// do not need an acknowledgement.
// Their styles are injected from here rather than added to app.css so the
// helpers stay one self-contained unit; they only use the app's own variables.

const DIALOG_CSS = `
.ui-modal-scrim {
  position: fixed; inset: 0; z-index: 200; padding: 20px;
  background: var(--scrim, rgba(18, 18, 24, 0.4)); backdrop-filter: blur(2px);
  display: flex; align-items: center; justify-content: center;
}
.ui-modal {
  width: min(430px, 100%); padding: 18px 18px 14px;
  background: var(--surface, #fff); color: var(--text, #1a1a1e);
  border: 1px solid var(--border, #e7e7ea); border-radius: 12px;
  box-shadow: var(--shadow-md, 0 16px 48px rgba(0, 0, 0, 0.22));
  font: 13.5px/1.5 var(--ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif);
  animation: ui-modal-in 0.13s ease-out;
}
@keyframes ui-modal-in { from { opacity: 0; transform: translateY(-4px) scale(0.99); } }
@media (prefers-reduced-motion: reduce) { .ui-modal { animation: none; } }
.ui-modal-title { font-size: 15px; font-weight: 600; letter-spacing: -0.01em; margin-bottom: 4px; }
.ui-modal-msg { color: var(--text-2, #55555e); white-space: pre-wrap; overflow-wrap: anywhere; }
.ui-modal-field { display: block; margin-top: 12px; }
.ui-modal-field input {
  width: 100%; margin-top: 5px; padding: 8px 10px; font: inherit;
  color: var(--text, #1a1a1e); background: var(--input-bg, #fff);
  border: 1px solid var(--border-strong, #d9d9de); border-radius: 8px;
}
.ui-modal-field input:focus { outline: none; border-color: var(--accent, #1d5c45); }
.ui-modal-bar { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
.ui-modal .btn.ui-danger {
  background: var(--danger, #b03030); border-color: var(--danger, #b03030); color: var(--on-primary, #fff);
}
.ui-modal .btn.ui-danger:hover { filter: brightness(1.08); }
.ui-toast-host {
  position: fixed; left: 50%; bottom: 22px; transform: translateX(-50%); z-index: 210;
  display: flex; flex-direction: column; align-items: center; gap: 6px; pointer-events: none;
}
.ui-toast {
  max-width: min(520px, 88vw); padding: 8px 14px; border-radius: 999px;
  background: var(--tooltip-bg, #223029); color: #fff;
  font: 12.5px/1.45 var(--ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif);
  box-shadow: var(--shadow-md, 0 4px 16px rgba(20, 20, 30, 0.3));
  opacity: 0; transform: translateY(6px); transition: opacity 0.16s ease, transform 0.16s ease;
}
.ui-toast.in { opacity: 1; transform: none; }
`

function ensureDialogCss() {
  if (document.getElementById('ui-dialog-css')) return
  const style = document.createElement('style')
  style.id = 'ui-dialog-css'
  style.textContent = DIALOG_CSS
  document.head.appendChild(style)
}

const FOCUSABLE = 'button, input, select, textarea, a[href], [tabindex]:not([tabindex="-1"])'

// The one modal primitive: builds the box, traps the keyboard in it, and
// resolves once through `done`. Enter confirms, Escape cancels, a click on the
// scrim cancels, and focus returns to whatever had it before.
function modal({ title, message, fields = [], confirmLabel, cancelLabel, danger, onConfirm, cancelValue }) {
  ensureDialogCss()
  return new Promise(resolve => {
    const wasFocused = document.activeElement
    const scrim = el('div', { class: 'ui-modal-scrim' })
    const box = el('div', { class: 'ui-modal', role: 'dialog', 'aria-modal': 'true' })
    if (title) {
      const h = el('div', { class: 'ui-modal-title', id: 'ui-modal-title' }, title)
      box.appendChild(h)
      box.setAttribute('aria-labelledby', 'ui-modal-title')
    }
    if (message) box.appendChild(el('div', { class: 'ui-modal-msg' }, message))
    for (const f of fields) box.appendChild(f)
    const bar = el('div', { class: 'ui-modal-bar' })
    const confirm = el('button', { class: 'btn small' + (danger ? ' ui-danger' : ' primary'), type: 'button' }, confirmLabel || 'OK')
    let settled = false
    const close = value => {
      if (settled) return
      settled = true
      document.removeEventListener('keydown', onKey, true)
      scrim.remove()
      try {
        wasFocused?.focus?.({ preventScroll: true })
      } catch {}
      resolve(value)
    }
    const cancel = cancelLabel === null ? null : el('button', { class: 'btn small', type: 'button' }, cancelLabel || 'Cancel')
    cancel?.addEventListener('click', () => close(cancelValue))
    confirm.addEventListener('click', () => close(onConfirm ? onConfirm() : true))
    // While this is open the app must not also see the keys: Escape closes
    // panels, Enter sends composers, and the editor takes everything else.
    const onKey = e => {
      if (e.key === 'Escape') {
        e.preventDefault()
        e.stopPropagation()
        close(cancelValue)
        return
      }
      if (e.key === 'Enter' && !e.shiftKey) {
        e.preventDefault()
        e.stopPropagation()
        close(onConfirm ? onConfirm() : true)
        return
      }
      if (e.key === 'Tab') {
        const items = [...box.querySelectorAll(FOCUSABLE)].filter(n => !n.disabled)
        if (!items.length) return
        const first = items[0]
        const last = items[items.length - 1]
        const active = document.activeElement
        if (e.shiftKey && (active === first || !box.contains(active))) {
          e.preventDefault()
          last.focus()
        } else if (!e.shiftKey && (active === last || !box.contains(active))) {
          e.preventDefault()
          first.focus()
        }
        e.stopPropagation()
        return
      }
      if (!box.contains(e.target)) e.stopPropagation()
    }
    document.addEventListener('keydown', onKey, true)
    scrim.addEventListener('mousedown', e => {
      if (e.target === scrim) close(cancelValue)
    })
    if (cancel) bar.append(cancel, confirm)
    else bar.appendChild(confirm)
    box.appendChild(bar)
    scrim.appendChild(box)
    document.body.appendChild(scrim)
    // the destructive button is never what the keyboard lands on first
    const input = box.querySelector('input')
    if (input) input.select()
    else if (danger && cancel) cancel.focus()
    else confirm.focus()
  })
}

export function uiConfirm({ title, message, confirmLabel, cancelLabel, danger = false } = {}) {
  return modal({ title, message, confirmLabel: confirmLabel || (danger ? 'Delete' : 'OK'), cancelLabel, danger, cancelValue: false })
}

export function uiAlert(message, { title, confirmLabel } = {}) {
  const text = String(message ?? '')
  // clicking a failing action twice must not stack the same warning twice
  const open = [...document.querySelectorAll('.ui-modal-scrim .ui-modal-msg')].some(n => n.textContent === text)
  if (open) return Promise.resolve()
  return modal({ title, message: text, confirmLabel: confirmLabel || 'OK', cancelLabel: null, cancelValue: undefined })
}

// The confirm modal with one text field. Resolves to the string, or null when
// it was dismissed — same contract as the prompt() it replaces.
export function uiPrompt({ title, message, label, value = '', placeholder = '', confirmLabel } = {}) {
  const field = el('label', { class: 'ui-modal-field' })
  if (label) field.appendChild(document.createTextNode(label))
  const input = el('input', { type: 'text', placeholder })
  input.value = value
  field.appendChild(input)
  return modal({
    title,
    message,
    fields: [field],
    confirmLabel: confirmLabel || 'OK',
    onConfirm: () => input.value.trim(),
    cancelValue: null,
  })
}

// Non-blocking errors: nothing to acknowledge, nothing to click through.
export function uiToast(message, { ms = 3200 } = {}) {
  ensureDialogCss()
  let host = document.getElementById('ui-toast-host')
  if (!host) {
    host = el('div', { class: 'ui-toast-host', id: 'ui-toast-host', role: 'status', 'aria-live': 'polite' })
    document.body.appendChild(host)
  }
  const toast = el('div', { class: 'ui-toast' }, String(message ?? ''))
  host.appendChild(toast)
  requestAnimationFrame(() => toast.classList.add('in'))
  setTimeout(() => {
    toast.classList.remove('in')
    setTimeout(() => toast.remove(), 200)
  }, ms)
  return toast
}

export function colorFor(name) {
  let h = 0
  for (const c of String(name)) h = (h * 31 + c.charCodeAt(0)) % 360
  return `hsl(${h}, 32%, 46%)`
}

export function timeAgo(ts) {
  if (!ts) return ''
  const s = Math.floor((Date.now() - ts) / 1000)
  if (s < 60) return 'now'
  if (s < 3600) return `${Math.floor(s / 60)}m`
  if (s < 86400) return `${Math.floor(s / 3600)}h`
  return `${Math.floor(s / 86400)}d`
}

// One-time display of a freshly issued agent key, with copy actions.
export function agentKeyBox({ handle, key, docId }) {
  const box = el('div', { class: 'key-box' })
  box.appendChild(el('div', { class: 'key-label' }, `Key for @${handle} — copy it now, it won't be shown again:`))
  box.appendChild(el('code', {}, key))
  const row = el('div', { class: 'row' })
  const copyKey = el('button', { class: 'btn small', type: 'button' }, 'Copy key')
  copyKey.addEventListener('click', async () => {
    await navigator.clipboard.writeText(key)
    copyKey.textContent = 'Copied'
    setTimeout(() => (copyKey.textContent = 'Copy key'), 1500)
  })
  const copyPrompt = el('button', { class: 'btn small primary', type: 'button' }, 'Copy agent prompt (incl. key)')
  copyPrompt.addEventListener('click', async () => {
    const text = await fetch(`/api/agent-prompt?handle=${handle}${docId ? `&doc=${docId}` : ''}`).then(r => r.text())
    await navigator.clipboard.writeText(text.replace('<the key>', key))
    copyPrompt.textContent = 'Copied'
    setTimeout(() => (copyPrompt.textContent = 'Copy agent prompt (incl. key)'), 1500)
  })
  const dismiss = el('button', { class: 'btn small ghost', type: 'button' }, 'Done')
  dismiss.addEventListener('click', () => box.remove())
  row.append(copyKey, copyPrompt, dismiss)
  box.appendChild(row)
  return box
}

export function statusLabel(status) {
  return { pending: 'pending', claimed: 'seen', done: 'handled', failed: 'failed' }[status] || status
}