// Shared client helpers: auth bootstrap, fetch wrapper, DOM utils, SVG icons.
const ICONS = {
file: '',
folder: '',
star: '',
tree: '',
key: '',
todo: '',
table: '',
eye: '',
eyeoff: '',
caret: '',
back: '',
sun: '',
moon: '',
serif: '',
docpage: '',
comment: '',
// 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: '',
edit: '',
image: '',
link: '',
// the link glyph with its middle struck through: remove this link
unlink:
'',
download: '',
copy: '',
check: '',
x: '',
reply: '',
send: '',
trash: '',
quote: '',
undo: '',
redo: '',
zoomIn: '',
zoomOut: '',
}
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]))
}
export function renderMentionText(text) {
return esc(text).replace(/@([a-z0-9][a-z0-9_.-]{1,38})/g, '@$1')
}
// --- 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('', 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
}