// 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 }