// 🤖 The WEB renderer for schema-described apps — the DOM twin of SwiftUI's // SchemaAppView. It consumes the same resolved models (listModel / rowModel) // from schema-engine.js, so the app-specific part stays ALL in the JSON // (docs/app-schema.md) and the two platforms can't drift on behaviour: one // schema, one engine per runtime, one renderer per platform. // // An app adopts it by wiring defineApp to the three factories below: // events: schemaEvents(schema) — fold handlers from the schema // render: schemaRender(schema, opts) — this renderer // chatMessage: (kept hand-written where wording predates the schema) // opts.parsers supplies named parsers ({ reminder: parseReminder }) — the one // escape hatch schemas have into real code. // // Deliberate web-only choices, mirroring the pre-schema hand-written panes: // destructive row buttons act immediately (no confirm — the pre-schema × was // instant; native confirms because swipe/menu idioms expect it), and rows with // tap.detail don't navigate (the web pane has no detail surface yet). import { listModel, rowModel, runAction, tickerRules, foldSchemaEvent, initialState, evalCond, evalValue, } from "./schema-engine.js"; // ---- defineApp factories --------------------------------------------------- // The events map for defineApp: every schema event type folds via the engine. // defineApp derives the app's owned types from these keys, exactly as before. export function schemaEvents(schema) { const out = {}; for (const type of Object.keys(schema.events || {})) { out[type] = (s, e) => foldSchemaEvent(schema, s, e); } return out; } export function schemaInitialState(schema) { return () => initialState(schema); } export function schemaRender(schema, opts = {}) { // The host passes the raw folded state, which is undefined for a thread with // no events yet (state is only seeded on the first fold). The pre-schema // hand-written renders guarded that themselves; guard it here for every // schema app, so a virgin thread renders as the empty state, not an error. return (state, el, ctx) => render(schema, state || initialState(schema), el, ctx, opts); } // ---- transient UI state (search query, collapsed sections) ----------------- // Per app-thread, module scope so it survives repaints and re-renders — it's // UI state, not fold state (the same rule completedOpen/previousOpen had). const uiByThread = {}; function ui(ctx, schema) { const key = ctx.threadId + "|" + schema.name; return (uiByThread[key] ||= { query: "", collapsed: {} }); } // ---- the renderer ----------------------------------------------------------- function render(schema, state, el, ctx, opts) { const view = schema.views && schema.views.main; if (!view) return; const u = ui(ctx, schema); const partition = schema.partition; const pane = document.createElement("div"); pane.className = "sapp-pane"; el.appendChild(pane); const io = { emit: (ev) => ctx.emit(ev), schedule: (ev, at) => ctx.schedule(ev, at), unschedule: (sid) => ctx.unschedule(sid), newId: () => ctx.id(), parse: (kind, text) => (opts.parsers && opts.parsers[kind] ? opts.parsers[kind](text) : null), hint: (t) => showHint(t), }; const act = (name, scope) => runAction(schema, name, scope, io); // -- composer (never rebuilt by paint, so typing survives repaints) -------- let hintEl = null; let showHint = () => {}; if (view.composer) { const form = document.createElement("form"); form.className = "sapp-form"; const input = document.createElement("input"); input.className = "sapp-input"; input.type = "text"; input.placeholder = view.composer.placeholder || ""; if (view.composer.maxLength) input.maxLength = view.composer.maxLength; input.setAttribute("aria-label", view.composer.placeholder || "Add"); const add = document.createElement("button"); add.type = "submit"; add.className = "sapp-add"; add.textContent = view.composer.button || "Add"; form.append(input, add); pane.appendChild(form); hintEl = document.createElement("p"); hintEl.className = "sapp-hint"; pane.appendChild(hintEl); const restingHint = () => { hintEl.classList.remove("problem"); hintEl.textContent = view.composer.hint || ""; hintEl.style.display = view.composer.hint ? "" : "none"; }; showHint = (t) => { hintEl.classList.add("problem"); hintEl.textContent = t; hintEl.style.display = ""; }; restingHint(); input.oninput = restingHint; form.onsubmit = async (ev) => { ev.preventDefault(); const text = input.value.trim(); if (!text || add.disabled) return; add.disabled = true; // the schedule step is a real round trip — hold Add const ok = await act(view.composer.action, { input: text }); add.disabled = false; if (ok) input.value = ""; input.focus(); }; } // -- summary + search (search filters live; summary repaints with state) --- const summaryEl = document.createElement("div"); summaryEl.className = "sapp-summary"; pane.appendChild(summaryEl); if (view.search) { const search = document.createElement("input"); search.className = "sapp-search"; search.type = "search"; search.placeholder = "Search"; search.setAttribute("aria-label", "Search"); search.value = u.query; search.oninput = () => { u.query = search.value; paint(); }; pane.appendChild(search); } const sectionsEl = document.createElement("div"); pane.appendChild(sectionsEl); // -- paint: rebuild summary + sections only (never the form/search) -------- const graceAt = (i) => (partition && i[partition.flag] ? i[partition.at] || 1 : 0); let archivedKey = null; const paint = () => { const now = Date.now(); const model = listModel(schema, state, now, { query: u.query, collapsed: u.collapsed }); summaryEl.textContent = model.summary; summaryEl.style.display = model.summary ? "" : "none"; sectionsEl.replaceChildren(); for (const section of model.sections) { if (!section.rows.length) continue; const wrap = document.createElement("div"); wrap.className = "sapp-section"; if (section.collapsible) { const toggle = document.createElement("button"); toggle.type = "button"; toggle.className = "sapp-section-toggle"; toggle.textContent = `${section.collapsed ? "▸" : "▾"} ${section.title}`; toggle.onclick = () => { u.collapsed[section.id] = !section.collapsed; paint(); }; wrap.appendChild(toggle); if (!section.collapsed) wrap.appendChild(buildList(section, now)); } else { if (section.title) { const h = document.createElement("div"); h.className = "sapp-section-title"; h.textContent = section.title; wrap.appendChild(h); } wrap.appendChild(buildList(section, now)); } sectionsEl.appendChild(wrap); } if (model.empty) { const empty = document.createElement("div"); empty.className = "sapp-empty"; empty.textContent = model.empty; sectionsEl.appendChild(empty); } archivedKey = partition ? model.buckets.archived.map((i) => i.id).sort().join(",") : ""; }; const buildList = (section, now) => { const ul = document.createElement("ul"); ul.className = "sapp-list"; if (section.collapsible) ul.dataset.collapsible = "1"; for (const row of section.rows) ul.appendChild(buildRow(section, row, now)); if (section.reorder) wireReorder(ul, section); return ul; }; const keyField = ((schema.state || {})[schema.collection] || {}).key || "id"; const buildRow = (section, { item, grace }, now) => { const rowSpec = view.row || {}; const model = rowModel(schema, item, now); const li = document.createElement("li"); li.className = "sapp-row" + (model.strike ? " done" : "") + (grace ? " grace" : ""); li.dataset.id = item[keyField]; if (rowSpec.leading && rowSpec.leading.kind === "check") { const on = evalCond({ truthy: rowSpec.leading.on }, { item }); const check = document.createElement("button"); check.type = "button"; check.className = "sapp-check"; check.setAttribute("aria-label", on ? "Reopen" : "Mark done"); check.textContent = on ? "✓" : ""; // No own handler when the row tap runs the same action — the click // bubbles to the row (keyboard focus + Space/Enter still work). if (!(rowSpec.tap && rowSpec.tap.action === rowSpec.leading.action)) { check.onclick = (e) => { e.stopPropagation(); act(rowSpec.leading.action, { item }); }; } li.appendChild(check); } const main = document.createElement("div"); main.className = "sapp-main"; const title = document.createElement("div"); title.className = "sapp-title"; title.textContent = model.title; // user content — textContent, never innerHTML main.appendChild(title); if (model.subtitle) { const sub = document.createElement("div"); sub.className = "sapp-sub" + (model.accent ? " due" : ""); sub.textContent = model.subtitle; main.appendChild(sub); } if (model.note) { const note = document.createElement("div"); note.className = "sapp-note"; note.textContent = model.note; main.appendChild(note); } li.appendChild(main); for (const btn of rowSpec.trailing || []) { if (!btn.action) continue; const b = document.createElement("button"); b.type = "button"; b.className = "sapp-trailing" + (btn.role === "destructive" ? " destructive" : ""); b.setAttribute("aria-label", btn.label || "Remove"); b.textContent = btn.glyph || "×"; b.onclick = (e) => { e.stopPropagation(); act(btn.action, { item }); }; li.appendChild(b); } if (rowSpec.tap && rowSpec.tap.action) { li.classList.add("tappable"); if (grace) li.title = "Tap to undo"; li.onclick = () => act(rowSpec.tap.action, { item }); } else if (rowSpec.tap && rowSpec.tap.url) { // tap.url: the row is a LINK OUT (views — docs/views.md). https only, // re-checked here at click time whatever the fold stored; new tab, // noopener. A row whose item has no usable url simply isn't tappable. // (Web-only for now: the native runtime ignores tap.url — additive.) const url = evalValue(rowSpec.tap.url, { item }); if (typeof url === "string" && /^https:\/\/[^\s]+$/.test(url)) { li.classList.add("tappable"); li.setAttribute("role", "link"); li.tabIndex = 0; let host = ""; try { host = new URL(url).hostname; } catch {} li.setAttribute("aria-label", host ? `${model.title} — opens ${host}` : model.title); const open = () => window.open(url, "_blank", "noopener"); li.onclick = open; li.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); open(); } }; } } if (section.reorder) li.draggable = true; return li; }; // -- drag reorder (sections that declare it; desktop pointers) ------------- // The drop maps to the event wire's array anchor: the id of the item the // moved row now FOLLOWS in the collection — in a descending (newest-first) // section that's the row now BELOW it; ascending, the row above; the edge // is the array's start ("") — the same mapping SchemaAppView.moveRows pins. const wireReorder = (ul, section) => { let draggingId = null; ul.addEventListener("dragstart", (e) => { const li = e.target.closest(".sapp-row"); if (!li) return; draggingId = li.dataset.id; li.classList.add("dragging"); e.dataTransfer.effectAllowed = "move"; try { e.dataTransfer.setData("text/plain", draggingId); } catch {} }); ul.addEventListener("dragover", (e) => { if (!draggingId) return; e.preventDefault(); // allow the drop const dragging = ul.querySelector(".dragging"); const over = e.target.closest(".sapp-row"); if (!dragging || !over || over === dragging) return; const rect = over.getBoundingClientRect(); const before = e.clientY < rect.top + rect.height / 2; ul.insertBefore(dragging, before ? over : over.nextSibling); }); const finish = (commit) => { const dragging = ul.querySelector(".dragging"); if (dragging) dragging.classList.remove("dragging"); if (!commit || !draggingId) { draggingId = null; return; } const ids = [...ul.querySelectorAll(".sapp-row")].map((li) => li.dataset.id); const i = ids.indexOf(draggingId); const anchor = section.descending ? (i + 1 < ids.length ? ids[i + 1] : "") : (i > 0 ? ids[i - 1] : ""); const item = (state[schema.collection] || []).find((x) => x[keyField] === draggingId); draggingId = null; if (item) act(section.reorder, { item, after: anchor }); }; ul.addEventListener("drop", (e) => { e.preventDefault(); finish(true); }); ul.addEventListener("dragend", () => finish(false)); }; paint(); // -- grace tuck: rows fold up at their precise expiry, then repaint -------- // (the todo.js fold-up beat, generalised: height/padding/margin ease to 0 so // the rows below slide smoothly, THEN the item tucks into its section). if (partition) { let folding = false; let tuckTimer = null; const scheduleTuck = () => { if (tuckTimer) clearTimeout(tuckTimer); tuckTimer = null; const now = Date.now(); const expiries = items() .map((i) => graceAt(i)) .filter((at) => at && now - at < partition.graceMs) .map((at) => at + partition.graceMs); if (!expiries.length) return; tuckTimer = setTimeout(tuckExpired, Math.max(0, Math.min(...expiries) - now) + 25); }; const tuckExpired = () => { tuckTimer = null; if (folding) return; const now = Date.now(); const archived = items().filter((i) => { const at = graceAt(i); return at && now - at >= partition.graceMs; }); const key = archived.map((i) => i[keyField]).sort().join(","); if (key === archivedKey) { scheduleTuck(); return; } // Fold only rows still sitting in a NON-collapsible list (a lapsed grace // row awaiting its tuck) — rows already inside the expanded archive // section are home; folding them too would empty it on every expiry. const nowArchived = new Set(archived.map((i) => i[keyField])); const rows = [...sectionsEl.querySelectorAll(".sapp-list:not([data-collapsible]) .sapp-row")].filter( (li) => nowArchived.has(li.dataset.id), ); if (!rows.length || reducedMotion()) { paint(); scheduleTuck(); return; } folding = true; let remaining = rows.length; const done = () => { if (--remaining > 0) return; folding = false; paint(); scheduleTuck(); }; rows.forEach((li) => foldUpRow(li, done)); }; scheduleTuck(); ctx.onCleanup(() => { if (tuckTimer) clearTimeout(tuckTimer); }); } // -- refresh + ticker: live relative labels; due per-item rules fire ------- if (view.refreshSeconds || schema.ticker) { const firedLocally = new Set(); ctx.interval(() => { const now = Date.now(); // Relabel in place (never a full repaint — that would eat a drag). for (const li of sectionsEl.querySelectorAll(".sapp-row")) { const item = (state[schema.collection] || []).find((x) => x[keyField] === li.dataset.id); if (!item) continue; const model = rowModel(schema, item, now); const sub = li.querySelector(".sapp-sub"); if (sub && model.subtitle) { sub.textContent = model.subtitle; sub.classList.toggle("due", model.accent); } } for (const ev of tickerRules(schema, state, now, firedLocally)) io.emit(ev); if (partition) { const key = items() .filter((i) => { const at = graceAt(i); return at && now - at >= partition.graceMs; }) .map((i) => i[keyField]) .sort() .join(","); if (key !== archivedKey) paint(); } }, Math.max(view.refreshSeconds || 1, 1) * 1000); } function items() { return state[schema.collection] || []; } } // ---- shared styles (injected once per app via defineApp's styles) ---------- export const schemaStyles = ` .sapp-pane { max-width: 560px; margin: 0 auto; padding: 16px; width: 100%; box-sizing: border-box; } .sapp-form { display: flex; gap: 6px; margin-bottom: 6px; } .sapp-input { flex: 1 1 auto; min-width: 0; padding: 8px 11px; font: inherit; font-size: 14px; color: var(--ink); background: var(--panel); border: 1px solid var(--border); border-radius: 10px; } .sapp-input::placeholder { color: var(--muted); } .sapp-input:focus { outline: none; border-color: var(--accent); } .sapp-add { flex: 0 0 auto; padding: 8px 14px; font: inherit; font-size: 14px; font-weight: 600; color: var(--accent-ink); background: var(--accent); border: none; border-radius: 10px; cursor: var(--ui-cursor, pointer); } .sapp-add:disabled { opacity: 0.6; cursor: default; } .sapp-hint { font-size: 12px; color: var(--muted); margin: 0 2px 10px; } .sapp-hint.problem { color: var(--accent); } .sapp-summary { font-size: 12px; font-weight: 600; color: var(--muted); letter-spacing: 0.02em; margin: 2px 2px 8px; } .sapp-search { width: 100%; box-sizing: border-box; margin: 0 0 10px; padding: 6px 10px; font: inherit; font-size: 13px; color: var(--ink); background: var(--bubble-them); border: 1px solid transparent; border-radius: 8px; } .sapp-search::placeholder { color: var(--muted); } .sapp-search:focus { outline: none; border-color: var(--accent); background: var(--panel); } .sapp-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; } .sapp-row { display: flex; align-items: center; gap: 10px; padding: 8px; margin: 0 -8px 2px; border-radius: 8px; } .sapp-row.tappable { cursor: var(--ui-cursor, pointer); } .sapp-row:hover { background: var(--bubble-them); } .sapp-row.dragging { opacity: 0.5; } .sapp-check { position: relative; flex: 0 0 auto; width: 20px; height: 20px; border-radius: 50%; border: 1.5px solid var(--border); background: transparent; color: var(--accent-ink); font-size: 13px; line-height: 1; display: flex; align-items: center; justify-content: center; cursor: var(--ui-cursor, pointer); padding: 0; } .sapp-row.done .sapp-check { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); } .sapp-main { flex: 1 1 auto; min-width: 0; } .sapp-title { font-size: 14px; color: var(--ink); overflow-wrap: anywhere; } .sapp-row.done .sapp-title { text-decoration: line-through; color: var(--muted); } .sapp-sub { margin-top: 2px; font-size: 12.5px; color: var(--muted); } .sapp-sub.due { color: var(--accent); font-weight: 600; } .sapp-note { margin-top: 1px; font-size: 12px; color: var(--muted); } .sapp-trailing { flex: 0 0 auto; border: none; background: transparent; color: var(--muted); font-size: 20px; line-height: 1; cursor: var(--ui-cursor, pointer); padding: 0 4px; opacity: 0; transition: opacity 0.12s; } .sapp-row:hover .sapp-trailing, .sapp-trailing:focus-visible { opacity: 1; } @media (pointer: coarse) { .sapp-trailing { opacity: 1; } } .sapp-section { margin-top: 4px; } .sapp-section-title { font-size: 13px; font-weight: 600; color: var(--muted); padding: 6px 2px; } .sapp-section-toggle { background: none; border: none; padding: 8px 2px; font: inherit; font-size: 13px; font-weight: 600; color: var(--muted); cursor: var(--ui-cursor, pointer); } .sapp-section-toggle:hover { color: var(--ink); } .sapp-empty { color: var(--muted); font-size: 13px; text-align: center; padding: 18px 0; line-height: 1.5; } `; // Whether the OS asks us to minimise motion — the fold-up is skipped if so. function reducedMotion() { try { return typeof matchMedia !== "undefined" && matchMedia("(prefers-reduced-motion: reduce)").matches; } catch { return false; } } // foldUpRow collapses a row to nothing with an ease-in curve — height, padding // and margin all animate to 0 (overflow hidden) so the rows below slide // smoothly up; done() fires when the height transition ends (timeout backstop). function foldUpRow(li, done) { const h = li.offsetHeight; li.style.overflow = "hidden"; li.style.height = h + "px"; void li.offsetHeight; // reflow so the start height sticks before we transition li.style.transition = "height 0.26s ease-in, opacity 0.2s ease-in, margin 0.26s ease-in, padding 0.26s ease-in"; li.style.height = "0px"; li.style.opacity = "0"; li.style.marginTop = "0"; li.style.marginBottom = "0"; li.style.paddingTop = "0"; li.style.paddingBottom = "0"; let called = false; const fin = () => { if (called) return; called = true; done(); }; li.addEventListener("transitionend", (e) => { if (e.propertyName === "height") fin(); }, { once: true }); setTimeout(fin, 320); }