eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1// πŸ€– The WEB renderer for schema-described apps β€” the DOM twin of SwiftUI's
2// SchemaAppView. It consumes the same resolved models (listModel / rowModel)
3// from schema-engine.js, so the app-specific part stays ALL in the JSON
4// (docs/app-schema.md) and the two platforms can't drift on behaviour: one
5// schema, one engine per runtime, one renderer per platform.
6//
7// An app adopts it by wiring defineApp to the three factories below:
8// events: schemaEvents(schema) β€” fold handlers from the schema
9// render: schemaRender(schema, opts) β€” this renderer
10// chatMessage: (kept hand-written where wording predates the schema)
11// opts.parsers supplies named parsers ({ reminder: parseReminder }) β€” the one
12// escape hatch schemas have into real code.
13//
14// Deliberate web-only choices, mirroring the pre-schema hand-written panes:
15// destructive row buttons act immediately (no confirm β€” the pre-schema Γ— was
16// instant; native confirms because swipe/menu idioms expect it), and rows with
17// tap.detail don't navigate (the web pane has no detail surface yet).
18
19import {
20 listModel,
21 rowModel,
22 runAction,
23 tickerRules,
24 foldSchemaEvent,
25 initialState,
26 evalCond,
27 evalValue,
28} from "./schema-engine.js";
29
30// ---- defineApp factories ---------------------------------------------------
31
32// The events map for defineApp: every schema event type folds via the engine.
33// defineApp derives the app's owned types from these keys, exactly as before.
34export function schemaEvents(schema) {
35 const out = {};
36 for (const type of Object.keys(schema.events || {})) {
37 out[type] = (s, e) => foldSchemaEvent(schema, s, e);
38 }
39 return out;
40}
41
42export function schemaInitialState(schema) {
43 return () => initialState(schema);
44}
45
46export function schemaRender(schema, opts = {}) {
47 // The host passes the raw folded state, which is undefined for a thread with
48 // no events yet (state is only seeded on the first fold). The pre-schema
49 // hand-written renders guarded that themselves; guard it here for every
50 // schema app, so a virgin thread renders as the empty state, not an error.
51 return (state, el, ctx) => render(schema, state || initialState(schema), el, ctx, opts);
52}
53
54// ---- transient UI state (search query, collapsed sections) -----------------
55// Per app-thread, module scope so it survives repaints and re-renders β€” it's
56// UI state, not fold state (the same rule completedOpen/previousOpen had).
57
58const uiByThread = {};
59function ui(ctx, schema) {
60 const key = ctx.threadId + "|" + schema.name;
61 return (uiByThread[key] ||= { query: "", collapsed: {} });
62}
63
64// ---- the renderer -----------------------------------------------------------
65
66function render(schema, state, el, ctx, opts) {
67 const view = schema.views && schema.views.main;
68 if (!view) return;
69 const u = ui(ctx, schema);
70 const partition = schema.partition;
71
72 const pane = document.createElement("div");
73 pane.className = "sapp-pane";
74 el.appendChild(pane);
75
76 const io = {
77 emit: (ev) => ctx.emit(ev),
78 schedule: (ev, at) => ctx.schedule(ev, at),
79 unschedule: (sid) => ctx.unschedule(sid),
80 newId: () => ctx.id(),
81 parse: (kind, text) => (opts.parsers && opts.parsers[kind] ? opts.parsers[kind](text) : null),
82 hint: (t) => showHint(t),
83 };
84 const act = (name, scope) => runAction(schema, name, scope, io);
85
86 // -- composer (never rebuilt by paint, so typing survives repaints) --------
87 let hintEl = null;
88 let showHint = () => {};
89 if (view.composer) {
90 const form = document.createElement("form");
91 form.className = "sapp-form";
92 const input = document.createElement("input");
93 input.className = "sapp-input";
94 input.type = "text";
95 input.placeholder = view.composer.placeholder || "";
96 if (view.composer.maxLength) input.maxLength = view.composer.maxLength;
97 input.setAttribute("aria-label", view.composer.placeholder || "Add");
98 const add = document.createElement("button");
99 add.type = "submit";
100 add.className = "sapp-add";
101 add.textContent = view.composer.button || "Add";
102 form.append(input, add);
103 pane.appendChild(form);
104
105 hintEl = document.createElement("p");
106 hintEl.className = "sapp-hint";
107 pane.appendChild(hintEl);
108 const restingHint = () => {
109 hintEl.classList.remove("problem");
110 hintEl.textContent = view.composer.hint || "";
111 hintEl.style.display = view.composer.hint ? "" : "none";
112 };
113 showHint = (t) => {
114 hintEl.classList.add("problem");
115 hintEl.textContent = t;
116 hintEl.style.display = "";
117 };
118 restingHint();
119 input.oninput = restingHint;
120
121 form.onsubmit = async (ev) => {
122 ev.preventDefault();
123 const text = input.value.trim();
124 if (!text || add.disabled) return;
125 add.disabled = true; // the schedule step is a real round trip β€” hold Add
126 const ok = await act(view.composer.action, { input: text });
127 add.disabled = false;
128 if (ok) input.value = "";
129 input.focus();
130 };
131 }
132
133 // -- summary + search (search filters live; summary repaints with state) ---
134 const summaryEl = document.createElement("div");
135 summaryEl.className = "sapp-summary";
136 pane.appendChild(summaryEl);
137
138 if (view.search) {
139 const search = document.createElement("input");
140 search.className = "sapp-search";
141 search.type = "search";
142 search.placeholder = "Search";
143 search.setAttribute("aria-label", "Search");
144 search.value = u.query;
145 search.oninput = () => {
146 u.query = search.value;
147 paint();
148 };
149 pane.appendChild(search);
150 }
151
152 const sectionsEl = document.createElement("div");
153 pane.appendChild(sectionsEl);
154
155 // -- paint: rebuild summary + sections only (never the form/search) --------
156 const graceAt = (i) => (partition && i[partition.flag] ? i[partition.at] || 1 : 0);
157 let archivedKey = null;
158 const paint = () => {
159 const now = Date.now();
160 const model = listModel(schema, state, now, { query: u.query, collapsed: u.collapsed });
161 summaryEl.textContent = model.summary;
162 summaryEl.style.display = model.summary ? "" : "none";
163
164 sectionsEl.replaceChildren();
165 for (const section of model.sections) {
166 if (!section.rows.length) continue;
167 const wrap = document.createElement("div");
168 wrap.className = "sapp-section";
169 if (section.collapsible) {
170 const toggle = document.createElement("button");
171 toggle.type = "button";
172 toggle.className = "sapp-section-toggle";
173 toggle.textContent = `${section.collapsed ? "β–Έ" : "β–Ύ"} ${section.title}`;
174 toggle.onclick = () => {
175 u.collapsed[section.id] = !section.collapsed;
176 paint();
177 };
178 wrap.appendChild(toggle);
179 if (!section.collapsed) wrap.appendChild(buildList(section, now));
180 } else {
181 if (section.title) {
182 const h = document.createElement("div");
183 h.className = "sapp-section-title";
184 h.textContent = section.title;
185 wrap.appendChild(h);
186 }
187 wrap.appendChild(buildList(section, now));
188 }
189 sectionsEl.appendChild(wrap);
190 }
191
192 if (model.empty) {
193 const empty = document.createElement("div");
194 empty.className = "sapp-empty";
195 empty.textContent = model.empty;
196 sectionsEl.appendChild(empty);
197 }
198 archivedKey = partition
199 ? model.buckets.archived.map((i) => i.id).sort().join(",")
200 : "";
201 };
202
203 const buildList = (section, now) => {
204 const ul = document.createElement("ul");
205 ul.className = "sapp-list";
206 if (section.collapsible) ul.dataset.collapsible = "1";
207 for (const row of section.rows) ul.appendChild(buildRow(section, row, now));
208 if (section.reorder) wireReorder(ul, section);
209 return ul;
210 };
211
212 const keyField = ((schema.state || {})[schema.collection] || {}).key || "id";
213
214 const buildRow = (section, { item, grace }, now) => {
215 const rowSpec = view.row || {};
216 const model = rowModel(schema, item, now);
217 const li = document.createElement("li");
218 li.className = "sapp-row" + (model.strike ? " done" : "") + (grace ? " grace" : "");
219 li.dataset.id = item[keyField];
220
221 if (rowSpec.leading && rowSpec.leading.kind === "check") {
222 const on = evalCond({ truthy: rowSpec.leading.on }, { item });
223 const check = document.createElement("button");
224 check.type = "button";
225 check.className = "sapp-check";
226 check.setAttribute("aria-label", on ? "Reopen" : "Mark done");
227 check.textContent = on ? "βœ“" : "";
228 // No own handler when the row tap runs the same action β€” the click
229 // bubbles to the row (keyboard focus + Space/Enter still work).
230 if (!(rowSpec.tap && rowSpec.tap.action === rowSpec.leading.action)) {
231 check.onclick = (e) => {
232 e.stopPropagation();
233 act(rowSpec.leading.action, { item });
234 };
235 }
236 li.appendChild(check);
237 }
238
239 const main = document.createElement("div");
240 main.className = "sapp-main";
241 const title = document.createElement("div");
242 title.className = "sapp-title";
243 title.textContent = model.title; // user content β€” textContent, never innerHTML
244 main.appendChild(title);
245 if (model.subtitle) {
246 const sub = document.createElement("div");
247 sub.className = "sapp-sub" + (model.accent ? " due" : "");
248 sub.textContent = model.subtitle;
249 main.appendChild(sub);
250 }
251 if (model.note) {
252 const note = document.createElement("div");
253 note.className = "sapp-note";
254 note.textContent = model.note;
255 main.appendChild(note);
256 }
257 li.appendChild(main);
258
259 for (const btn of rowSpec.trailing || []) {
260 if (!btn.action) continue;
261 const b = document.createElement("button");
262 b.type = "button";
263 b.className = "sapp-trailing" + (btn.role === "destructive" ? " destructive" : "");
264 b.setAttribute("aria-label", btn.label || "Remove");
265 b.textContent = btn.glyph || "Γ—";
266 b.onclick = (e) => {
267 e.stopPropagation();
268 act(btn.action, { item });
269 };
270 li.appendChild(b);
271 }
272
273 if (rowSpec.tap && rowSpec.tap.action) {
274 li.classList.add("tappable");
275 if (grace) li.title = "Tap to undo";
276 li.onclick = () => act(rowSpec.tap.action, { item });
277 } else if (rowSpec.tap && rowSpec.tap.url) {
278 // tap.url: the row is a LINK OUT (views β€” docs/views.md). https only,
279 // re-checked here at click time whatever the fold stored; new tab,
280 // noopener. A row whose item has no usable url simply isn't tappable.
281 // (Web-only for now: the native runtime ignores tap.url β€” additive.)
282 const url = evalValue(rowSpec.tap.url, { item });
283 if (typeof url === "string" && /^https:\/\/[^\s]+$/.test(url)) {
284 li.classList.add("tappable");
285 li.setAttribute("role", "link");
286 li.tabIndex = 0;
287 let host = "";
288 try {
289 host = new URL(url).hostname;
290 } catch {}
291 li.setAttribute("aria-label", host ? `${model.title} β€” opens ${host}` : model.title);
292 const open = () => window.open(url, "_blank", "noopener");
293 li.onclick = open;
294 li.onkeydown = (e) => {
295 if (e.key === "Enter" || e.key === " ") {
296 e.preventDefault();
297 open();
298 }
299 };
300 }
301 }
302
303 if (section.reorder) li.draggable = true;
304 return li;
305 };
306
307 // -- drag reorder (sections that declare it; desktop pointers) -------------
308 // The drop maps to the event wire's array anchor: the id of the item the
309 // moved row now FOLLOWS in the collection β€” in a descending (newest-first)
310 // section that's the row now BELOW it; ascending, the row above; the edge
311 // is the array's start ("") β€” the same mapping SchemaAppView.moveRows pins.
312 const wireReorder = (ul, section) => {
313 let draggingId = null;
314 ul.addEventListener("dragstart", (e) => {
315 const li = e.target.closest(".sapp-row");
316 if (!li) return;
317 draggingId = li.dataset.id;
318 li.classList.add("dragging");
319 e.dataTransfer.effectAllowed = "move";
320 try {
321 e.dataTransfer.setData("text/plain", draggingId);
322 } catch {}
323 });
324 ul.addEventListener("dragover", (e) => {
325 if (!draggingId) return;
326 e.preventDefault(); // allow the drop
327 const dragging = ul.querySelector(".dragging");
328 const over = e.target.closest(".sapp-row");
329 if (!dragging || !over || over === dragging) return;
330 const rect = over.getBoundingClientRect();
331 const before = e.clientY < rect.top + rect.height / 2;
332 ul.insertBefore(dragging, before ? over : over.nextSibling);
333 });
334 const finish = (commit) => {
335 const dragging = ul.querySelector(".dragging");
336 if (dragging) dragging.classList.remove("dragging");
337 if (!commit || !draggingId) {
338 draggingId = null;
339 return;
340 }
341 const ids = [...ul.querySelectorAll(".sapp-row")].map((li) => li.dataset.id);
342 const i = ids.indexOf(draggingId);
343 const anchor = section.descending
344 ? (i + 1 < ids.length ? ids[i + 1] : "")
345 : (i > 0 ? ids[i - 1] : "");
346 const item = (state[schema.collection] || []).find((x) => x[keyField] === draggingId);
347 draggingId = null;
348 if (item) act(section.reorder, { item, after: anchor });
349 };
350 ul.addEventListener("drop", (e) => {
351 e.preventDefault();
352 finish(true);
353 });
354 ul.addEventListener("dragend", () => finish(false));
355 };
356
357 paint();
358
359 // -- grace tuck: rows fold up at their precise expiry, then repaint --------
360 // (the todo.js fold-up beat, generalised: height/padding/margin ease to 0 so
361 // the rows below slide smoothly, THEN the item tucks into its section).
362 if (partition) {
363 let folding = false;
364 let tuckTimer = null;
365 const scheduleTuck = () => {
366 if (tuckTimer) clearTimeout(tuckTimer);
367 tuckTimer = null;
368 const now = Date.now();
369 const expiries = items()
370 .map((i) => graceAt(i))
371 .filter((at) => at && now - at < partition.graceMs)
372 .map((at) => at + partition.graceMs);
373 if (!expiries.length) return;
374 tuckTimer = setTimeout(tuckExpired, Math.max(0, Math.min(...expiries) - now) + 25);
375 };
376 const tuckExpired = () => {
377 tuckTimer = null;
378 if (folding) return;
379 const now = Date.now();
380 const archived = items().filter((i) => {
381 const at = graceAt(i);
382 return at && now - at >= partition.graceMs;
383 });
384 const key = archived.map((i) => i[keyField]).sort().join(",");
385 if (key === archivedKey) {
386 scheduleTuck();
387 return;
388 }
389 // Fold only rows still sitting in a NON-collapsible list (a lapsed grace
390 // row awaiting its tuck) β€” rows already inside the expanded archive
391 // section are home; folding them too would empty it on every expiry.
392 const nowArchived = new Set(archived.map((i) => i[keyField]));
393 const rows = [...sectionsEl.querySelectorAll(".sapp-list:not([data-collapsible]) .sapp-row")].filter(
394 (li) => nowArchived.has(li.dataset.id),
395 );
396 if (!rows.length || reducedMotion()) {
397 paint();
398 scheduleTuck();
399 return;
400 }
401 folding = true;
402 let remaining = rows.length;
403 const done = () => {
404 if (--remaining > 0) return;
405 folding = false;
406 paint();
407 scheduleTuck();
408 };
409 rows.forEach((li) => foldUpRow(li, done));
410 };
411 scheduleTuck();
412 ctx.onCleanup(() => {
413 if (tuckTimer) clearTimeout(tuckTimer);
414 });
415 }
416
417 // -- refresh + ticker: live relative labels; due per-item rules fire -------
418 if (view.refreshSeconds || schema.ticker) {
419 const firedLocally = new Set();
420 ctx.interval(() => {
421 const now = Date.now();
422 // Relabel in place (never a full repaint β€” that would eat a drag).
423 for (const li of sectionsEl.querySelectorAll(".sapp-row")) {
424 const item = (state[schema.collection] || []).find((x) => x[keyField] === li.dataset.id);
425 if (!item) continue;
426 const model = rowModel(schema, item, now);
427 const sub = li.querySelector(".sapp-sub");
428 if (sub && model.subtitle) {
429 sub.textContent = model.subtitle;
430 sub.classList.toggle("due", model.accent);
431 }
432 }
433 for (const ev of tickerRules(schema, state, now, firedLocally)) io.emit(ev);
434 if (partition) {
435 const key = items()
436 .filter((i) => {
437 const at = graceAt(i);
438 return at && now - at >= partition.graceMs;
439 })
440 .map((i) => i[keyField])
441 .sort()
442 .join(",");
443 if (key !== archivedKey) paint();
444 }
445 }, Math.max(view.refreshSeconds || 1, 1) * 1000);
446 }
447
448 function items() {
449 return state[schema.collection] || [];
450 }
451}
452
453// ---- shared styles (injected once per app via defineApp's styles) ----------
454
455export const schemaStyles = `
456.sapp-pane { max-width: 560px; margin: 0 auto; padding: 16px; width: 100%; box-sizing: border-box; }
457.sapp-form { display: flex; gap: 6px; margin-bottom: 6px; }
458.sapp-input {
459 flex: 1 1 auto; min-width: 0; padding: 8px 11px; font: inherit; font-size: 14px;
460 color: var(--ink); background: var(--panel);
461 border: 1px solid var(--border); border-radius: 10px;
462}
463.sapp-input::placeholder { color: var(--muted); }
464.sapp-input:focus { outline: none; border-color: var(--accent); }
465.sapp-add {
466 flex: 0 0 auto; padding: 8px 14px; font: inherit; font-size: 14px; font-weight: 600;
467 color: var(--accent-ink); background: var(--accent);
468 border: none; border-radius: 10px; cursor: var(--ui-cursor, pointer);
469}
470.sapp-add:disabled { opacity: 0.6; cursor: default; }
471.sapp-hint { font-size: 12px; color: var(--muted); margin: 0 2px 10px; }
472.sapp-hint.problem { color: var(--accent); }
473.sapp-summary { font-size: 12px; font-weight: 600; color: var(--muted); letter-spacing: 0.02em; margin: 2px 2px 8px; }
474.sapp-search {
475 width: 100%; box-sizing: border-box; margin: 0 0 10px; padding: 6px 10px;
476 font: inherit; font-size: 13px; color: var(--ink);
477 background: var(--bubble-them); border: 1px solid transparent; border-radius: 8px;
478}
479.sapp-search::placeholder { color: var(--muted); }
480.sapp-search:focus { outline: none; border-color: var(--accent); background: var(--panel); }
481.sapp-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
482.sapp-row { display: flex; align-items: center; gap: 10px; padding: 8px; margin: 0 -8px 2px; border-radius: 8px; }
483.sapp-row.tappable { cursor: var(--ui-cursor, pointer); }
484.sapp-row:hover { background: var(--bubble-them); }
485.sapp-row.dragging { opacity: 0.5; }
486.sapp-check {
487 position: relative; flex: 0 0 auto; width: 20px; height: 20px; border-radius: 50%;
488 border: 1.5px solid var(--border); background: transparent; color: var(--accent-ink);
489 font-size: 13px; line-height: 1; display: flex; align-items: center; justify-content: center;
490 cursor: var(--ui-cursor, pointer); padding: 0;
491}
492.sapp-row.done .sapp-check { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
493.sapp-main { flex: 1 1 auto; min-width: 0; }
494.sapp-title { font-size: 14px; color: var(--ink); overflow-wrap: anywhere; }
495.sapp-row.done .sapp-title { text-decoration: line-through; color: var(--muted); }
496.sapp-sub { margin-top: 2px; font-size: 12.5px; color: var(--muted); }
497.sapp-sub.due { color: var(--accent); font-weight: 600; }
498.sapp-note { margin-top: 1px; font-size: 12px; color: var(--muted); }
499.sapp-trailing {
500 flex: 0 0 auto; border: none; background: transparent; color: var(--muted);
501 font-size: 20px; line-height: 1; cursor: var(--ui-cursor, pointer); padding: 0 4px;
502 opacity: 0; transition: opacity 0.12s;
503}
504.sapp-row:hover .sapp-trailing, .sapp-trailing:focus-visible { opacity: 1; }
505@media (pointer: coarse) { .sapp-trailing { opacity: 1; } }
506.sapp-section { margin-top: 4px; }
507.sapp-section-title { font-size: 13px; font-weight: 600; color: var(--muted); padding: 6px 2px; }
508.sapp-section-toggle {
509 background: none; border: none; padding: 8px 2px; font: inherit; font-size: 13px; font-weight: 600;
510 color: var(--muted); cursor: var(--ui-cursor, pointer);
511}
512.sapp-section-toggle:hover { color: var(--ink); }
513.sapp-empty { color: var(--muted); font-size: 13px; text-align: center; padding: 18px 0; line-height: 1.5; }
514`;
515
516// Whether the OS asks us to minimise motion β€” the fold-up is skipped if so.
517function reducedMotion() {
518 try {
519 return typeof matchMedia !== "undefined" && matchMedia("(prefers-reduced-motion: reduce)").matches;
520 } catch {
521 return false;
522 }
523}
524
525// foldUpRow collapses a row to nothing with an ease-in curve β€” height, padding
526// and margin all animate to 0 (overflow hidden) so the rows below slide
527// smoothly up; done() fires when the height transition ends (timeout backstop).
528function foldUpRow(li, done) {
529 const h = li.offsetHeight;
530 li.style.overflow = "hidden";
531 li.style.height = h + "px";
532 void li.offsetHeight; // reflow so the start height sticks before we transition
533 li.style.transition = "height 0.26s ease-in, opacity 0.2s ease-in, margin 0.26s ease-in, padding 0.26s ease-in";
534 li.style.height = "0px";
535 li.style.opacity = "0";
536 li.style.marginTop = "0";
537 li.style.marginBottom = "0";
538 li.style.paddingTop = "0";
539 li.style.paddingBottom = "0";
540 let called = false;
541 const fin = () => {
542 if (called) return;
543 called = true;
544 done();
545 };
546 li.addEventListener("transitionend", (e) => {
547 if (e.propertyName === "height") fin();
548 }, { once: true });
549 setTimeout(fin, 320);
550}
551