PageSourceSearch

https://smartcone.ai/src/shell/types/appviewer/index.js

js smartcone.ai collected 2026-10-05 15:06:29 UTC 43,498 bytes, 788 lines download raw bytes

1// App Viewer – renders the production preview of a designed project inside the studio.
2import { APPBUILDER_LAYOUT_KEY, buildProductionHTML } from "../../../repo/productionBuilder.js";
3import { ensureToolbarBaseStyles } from "../../common/toolbar.js";
4import { callAdminProc } from "../../shared/zdbAdminClient.js";
5
6let _stylesInjected = false;
7
8function ensureAppViewerStyles() {
9  if (_stylesInjected) return;
10  _stylesInjected = true;
11  const style = document.createElement("style");
12  style.textContent = `
13.av-shell { display:flex; flex-direction:column; width:100%; height:100%; overflow:hidden; background:#f1f5f9; }
14.av-toolbar {
15  display:flex; align-items:center; gap:6px; flex-shrink:0;
16  padding:6px 10px; background:#1e293b; border-bottom:1px solid #334155;
17}
18.av-label { color:#94a3b8; font:11px/1 system-ui; white-space:nowrap; }
19.av-input {
20  padding:4px 8px; border:1px solid #475569; border-radius:5px;
21  background:#0f172a; color:#e2e8f0; font:12px system-ui; width:110px;
22  outline:none;
23}
24.av-input:focus { border-color:#60a5fa; }
25.av-sep { width:1px; height:20px; background:#334155; margin:0 4px; }
26.av-btn {
27  padding:4px 10px; border:1px solid #475569; border-radius:5px;
28  background:#1e293b; color:#cbd5e1; font:12px/1.4 system-ui;
29  cursor:pointer; white-space:nowrap;
30}
31.av-btn:hover { background:#334155; color:#f1f5f9; }
32.av-btn-primary { background:#2563eb; border-color:#2563eb; color:#fff; }
33.av-btn-primary:hover { background:#1d4ed8; }
34.av-btn-active { background:#0f172a; border-color:#60a5fa; color:#60a5fa; }
35.av-viewport {
36  flex:1; overflow:auto; padding:12px; display:flex;
37  justify-content:center; background:#e2e8f0;
38}
39.av-iframe-wrap { width:100%; min-height:100%; flex-shrink:0; }
40.av-iframe { display:block; width:100%; height:100%; min-height:500px; border:none; background:#fff; }
41  `;
42  document.head.appendChild(style);
43}
44
45/** Device-width presets (null = full width) */
46const DEVICES = [
47  { id: "desktop", label: "Desktop", title: "Desktop – full width",   width: null },
48  { id: "tablet",  label: "Tablet",  title: "Tablet – 768 px",         width: 768  },
49  { id: "mobile",  label: "Mobile",  title: "Mobile – 390 px",         width: 390  },
50];
51
52export function mount(el, body, opts = {}) {
53  ensureAppViewerStyles();
54
55  // Run-mode inputs: when opened for a published app (apps.html → ?run=<docId>),
56  // load the SERVER-saved appbuilder NDJSON by id instead of localStorage.
57  const runDocId = (opts.docId || "").trim();
58  let serverNdjson = opts.layoutNdjson || null;
59
60  // ── Root layout wrapper ────────────────────────────────────────────────────
61  const shell = document.createElement("div");
62  shell.className = "av-shell";
63
64  // ── Toolbar ────────────────────────────────────────────────────────────────
65  const toolbar = document.createElement("div");
66  toolbar.className = "av-toolbar";
67
68  const projLabel = document.createElement("span");
69  projLabel.className = "av-label";
70  projLabel.textContent = "Project";
71
72  const projInput = document.createElement("input");
73  projInput.className = "av-input";
74  projInput.placeholder = "MainProject";
75  projInput.value = (document.getElementById("repoProject")?.value || "").trim() || "MainProject";
76  projInput.title = "Project name";
77
78  const layoutLabel = document.createElement("span");
79  layoutLabel.className = "av-label";
80  layoutLabel.textContent = "Layout";
81
82  const layoutInput = document.createElement("input");
83  layoutInput.className = "av-input";
84  layoutInput.placeholder = "Main";
85  layoutInput.value = (document.getElementById("repoLayout")?.value || "").trim() || "Main";
86  layoutInput.title = "Layout name";
87
88  const previewBtn = document.createElement("button");
89  previewBtn.className = "av-btn av-btn-primary";
90  previewBtn.textContent = "▶ Preview";
91  previewBtn.title = "Build and show the production preview";
92
93  // Reload lived only in the (now-removed) sidebar; keep it on the top bar so no
94  // control is lost. For a srcdoc preview, rebuilding via loadPreview is the
95  // reliable refresh (re-fetches the doc and re-renders).
96  const reloadBtn = document.createElement("button");
97  reloadBtn.className = "av-btn";
98  reloadBtn.textContent = "↻ Reload";
99  reloadBtn.title = "Reload the preview";
100
101  const sep = document.createElement("div");
102  sep.className = "av-sep";
103
104  // Device toggle buttons
105  const deviceBtns = DEVICES.map(d => {
106    const btn = document.createElement("button");
107    btn.className = "av-btn" + (d.id === "desktop" ? " av-btn-active" : "");
108    btn.textContent = d.label;
109    btn.title = d.title;
110    btn.dataset.device = d.id;
111    return btn;
112  });
113
114  const sep2 = document.createElement("div");
115  sep2.className = "av-sep";
116
117  const helpBtn = document.createElement("button");
118  helpBtn.className = "av-btn";
119  helpBtn.textContent = "?";
120  helpBtn.title = "App Viewer Help";
121  helpBtn.setAttribute("aria-label", "App Viewer Help");
122  helpBtn.addEventListener("click", () => {
123    document.dispatchEvent(new CustomEvent("assistant:help-topic", { detail: { topic: "appviewer" } }));
124  });
125
126  toolbar.append(projLabel, projInput, layoutLabel, layoutInput, previewBtn, reloadBtn, sep, ...deviceBtns, sep2, helpBtn);
127
128  // ── Viewport + iframe ──────────────────────────────────────────────────────
129  const viewport = document.createElement("div");
130  viewport.className = "av-viewport";
131
132  const iframeWrap = document.createElement("div");
133  iframeWrap.className = "av-iframe-wrap";
134
135  const iframe = document.createElement("iframe");
136  iframe.className = "av-iframe";
137  iframe.setAttribute("sandbox", "allow-scripts allow-modals allow-popups allow-forms");
138  iframe.title = "App Preview";
139  let previewRelayState = null;
140  let currentPreviewToken = "";
141  let readRelay = { reads: [], timer: null };
142  let streamsRegistryPromise = null;
143  const PREVIEW_CHILD_ORIGIN = "null";
144
145  iframeWrap.appendChild(iframe);
146  viewport.appendChild(iframeWrap);
147
148  // ── Body row (viewport only) ──────────────────────────────────────────────
149  // The left palette used to duplicate the top toolbar's Device (Desktop/Tablet/
150  // Mobile) and Actions (Preview/Reload); it was removed so the top bar is the
151  // single control surface.
152  ensureToolbarBaseStyles();
153  const avBodyRow = document.createElement("div");
154  avBodyRow.style.cssText = "display:flex;flex-direction:row;flex:1 1 auto;overflow:hidden;min-height:0;";
155  avBodyRow.append(viewport);
156  shell.append(toolbar, avBodyRow);
157  body.appendChild(shell);
158
159  const getStreamsRegistry = async () => {
160    if (!streamsRegistryPromise) streamsRegistryPromise = import("../../../streams/registry.js").catch(() => null);
161    return streamsRegistryPromise;
162  };
163  const cleanupPreviewRelay = () => {
164    if (!previewRelayState) return;
165    (previewRelayState.unsubscribes || []).forEach((unsub) => { try { unsub(); } catch (_) {} });
166    previewRelayState = null;
167  };
168  const makePreviewRelayToken = () => {
169    try {
170      if (window.crypto?.randomUUID) return window.crypto.randomUUID();
171    } catch (_) {}
172    return `preview-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
173  };
174
175  // ── Read relay (auto-refresh) ──────────────────────────────────────────────
176  // The sandboxed preview can't reach the DB. Its read widgets announce what
177  // they're bound to (ab-preview-reads-init); the host polls the FeBe filter and
178  // pushes fresh counts/rows back. Also refetched immediately after a write, so
179  // adding a row updates the table without a manual reload. (WSS is the transport,
180  // but FeBe reads are request/response — this is polling, not server push.)
181  const stopReadRelay = () => {
182    if (readRelay.timer) { clearInterval(readRelay.timer); }
183    readRelay = { reads: [], timer: null };
184  };
185  // Relative-time tokens in a saved FeBe predicate: "$now" / "$now-<seconds>",
186  // resolved against the CLIENT clock at each poll.
187  //
188  // A time-series chart bound to a growing table has no other way to stay
189  // bounded: a literal epoch is frozen at save time, so the window it selects
190  // widens by a minute every minute and the aggregate's row count -- and the
191  // payload crossing the link on every 6s poll -- grows without limit. On a
192  // 60s-bucket sensor table at 48h retention that is ~2880 groups (~200KB)
193  // per poll, forever, to draw a chart nobody is reading past the right edge.
194  // "$now-10800" instead pins it to a rolling 3h / 180 groups.
195  //
196  // Client clock, not the engine's: the value only ever selects a display
197  // window. Nothing is stored from it, so a skewed browser clock shifts what
198  // the viewer sees and cannot corrupt a record.
199  const resolveWhereTokens = (where) => {
200    if (!where || typeof where !== "object") return where;
201    const raw = String(where.value ?? "");
202    const m = /^\$now(?:\s*-\s*(\d+))?$/.exec(raw.trim());
203    if (!m) return where;
204    const now = Math.floor(Date.now() / 1000);
205    return { ...where, value: String(now - (m[1] ? Number(m[1]) : 0)) };
206  };
207  // Same tokens inside a filter-lane predicate tree ({join, conditions:[…]},
208  // nestable), so "last 3h" means the same thing to a table as to a chart.
209  const resolvePredicateTokens = (node) => {
210    if (!node || typeof node !== "object") return node;
211    if (!Array.isArray(node.conditions)) return resolveWhereTokens(node);
212    return { ...node, conditions: node.conditions.map(resolvePredicateTokens) };
213  };
214  // ── The SCENE lane (video overlay) ─────────────────────────────────────────
215  // A per-frame detection corpus is the one read shape the FeBe lanes cannot serve,
216  // for two measured reasons rather than a preference:
217  //
218  //  1. pFeBeFilterHandler accepts ONE condition ("only one condition supported"), and a
219  //     scene window needs two at minimum -- the run AND the frame range. Asking for just
220  //     the run returns the whole corpus (204,935 rows on VidBrain_dev run 1).
221  //  2. The filter lane emits one JSON frame PER ROW. pViFeed emits CSV between a `begin`
222  //     frame that DECLARES the column count and a `result` frame that DECLARES the row
223  //     count, so streamCsvLane can reconcile received-vs-declared and REFUSE a 
223short
224  //     stream. A truncated overlay is a picture missing bodies, which is the one failure
225  //     this path exists not to have.
226  //
227  // ⛔ THE PROC IS ALLOWLISTED, exactly like WRITE_HANDLERS. The reads come from a
228  // sandboxed iframe, so the compId/db/proc in a read config are UNTRUSTED input; without
229  // this the child could name any proc in any database and the host would run it holding
230  // the operator's JWT.
231  const READ_LANE_PROCS = new Set(["pViFeed"]);
232
233  // ── The scene lane is a HELD SESSION with a CHUNK CACHE, not a fetch ─────────────────
234  // What this replaces: one `streamCsvLane` call per window, each opening a WebSocket
235  // (csvLane.js:102) that the relay answers by spawning a `zimprompt -e --stream` child,
236  // and closing it again (csvLane.js:78) once the window arrived. With `setInterval`
237  // re-driving it, that is a fresh engine child every couple of seconds — TLS handshake,
238  // fork/exec, ZIM session open, dictionary open and proc load, all to answer one query,
239  // and every one of them a session left for the transport to reap. zimserver is a session
240  // manager: the connection IS the session, so it is opened once and kept.
241  //
242  // ⛔ AND THE OLD SHAPE STALLED THE PICTURE BY CONSTRUCTION. A 1800-frame window is
243  // ~19,000 rows; the player consumed it, ran to the edge, and only THEN asked for the
244  // next one — so every window boundary was a burst, a wait, and an overlay drawing from
245  // nothing. Smaller chunks fetched AHEAD of the playhead cost the same rows in total and
246  // remove the wait: by the time the playhead crosses a boundary the chunk is already in
247  // memory. Chunks also bound the damage of a failure to one chunk instead of one window.
248  // ⛔ THE CHUNK SIZE IS MEASURED, NOT CHOSEN. On .200 2026-08-17, both halves of an obs
249  // query timed SEPARATELY, big window first, on disjoint regions so none pre-warmed another:
250  //     window   rows     find     emit      (set strategy on: cost 0.4392, Index on ffRun,
251  //     3600 f   42,987   0.46 s   7.42 s     IDENTICAL plan and cost at all three sizes)
252  //      300 f    3,030   0.45 s   0.55 s
253  //     1200 f   14,387   0.45 s   2.41 s
254  // find is CONSTANT — the frame span contributes nothing to ZIM's cost, because the ffRun
255  // equality index is what gets chosen and the span is a residual filter. emit is LINEAR at
256  // ~5,800 rows/s (72 B/row, ~418 KB/s), flat across a 14x range. So a chunk costs
257  // 0.45 + rows/5800, and that fixed 0.45 s is paid AGAIN for every extra query issued.
258  // Work per second of playback: 300 f = 0.100, 900 f = 0.067, 1200 f = 0.072, 3600 f = 0.066.
259  // ⛔ 300 WAS THE WORST OF THE FOUR — and 300 frames at 30 fps is exactly the 10-second
260  // stall that was reported. 900 takes the win (1.5x) without going near the two ceilings:
261  // pViFeed REFUSES a span over 1200, and `li all` is one unbreakable statement, so a
262  // 3600-frame chunk blocks the connection for 7.4 s with no way to be told to stop.
263  const SCENE_CHUNK = 900;        // frames per chunk = 30 s of playback (~9,600 rows)
264  // 2 ahead + current = 90 s of lookahead. The engine emits 5,800 rows/s where playback
265  // consumes 321 (10.7 rows/frame x 30 fps) — 18x headroom, so depth buys nothing further.
266  const SCENE_AHEAD = 2;          // chunks kept in front of the playhead
267  // ⛔ KEEP IS A MEMORY BUDGET, NOT A CACHE POLICY. 8 chunks of 900 would hold ~77,000 rows
268  // in the browser; 3 holds ~29,000 and still covers a 90 s scrub back. The smallest target
269  // this ships to is a watch, and rows retained here are rows that cannot be evicted.
270  const SCENE_KEEP  = 3;          // chunks retained before eviction
271
272  // One session per (db, proc) for the life of this view.
273  const sceneSessions = new Map();
274  // key `${db}|${proc}|${run}|${chunkStart}` -> {state:"loading"|"ready"|"error", rows, err}
275  const sceneChunks = new Map();
276
277  const sceneSessionFor = async (db, proc) => {
278    const key = `${db}|${proc}`;
279    if (sceneSessions.has(key)) return sceneSessions.get(key);
280    const { openSession }
280 = await import("../../../streams/zdbSession.js");
281    const s = openSession("", { db, proc });
282    sceneSessions.set(key, s);
283    return s;
284  };
285
286  const closeSceneSessions = () => {
287    sceneSessions.forEach((s) => { try { s.close(); } catch (_) {} });
288    sceneSessions.clear();
289    sceneChunks.clear();
290  };
291
292  const chunkStartFor = (frame) => {
293    // ⛔ NEVER ASK FOR FRAME 0. MEASURED on .200 2026-08-16: pViFeed reads f0=0/f1=0 as
294    // "no window given" and answers with the ENTIRE corpus — 204,935 rows for run 1 where
295    // the caller asked for one chunk. Flooring at 1 keeps a chunk a chunk.
296    return Math.max(1, Math.floor(Math.max(1, frame) / SCENE_CHUNK) * SCENE_CHUNK);
297  };
298
299  // Fetch one chunk over the held session and push it to the child the moment it lands.
300  // ⛔ EACH CHUNK IS POSTED ON ARRIVAL rather than gathered and posted together: the point
301  // of chunking is that the picture fills progressively, and batching them back up would
302  // reinstate the burst this replaces one layer higher.
303  const loadSceneChunk = async (rd, start) => {
304    const proc = String(rd.proc || "pViFeed");
305    if (!READ_LANE_PROCS.has(proc)) throw new Error(`read lane not allowed: ${proc}`);
306    const key = `${rd.db}|${proc}|${rd.run}|${start}`;
307    const cached = sceneChunks.get(key);
308    if (cached && (cached.state === "loading" || cached.state === "ready")) return cached;
309
310    const entry = { state: "loading", rows: null, err: null };
311    sceneChunks.set(key, entry);
312    const f0 = start;
313    const f1 = start + SCENE_CHUNK - 1;
314    try {
315      const session = await sceneSessionFor(rd.db, proc);
316      const rows = [];
317      const r = await session.query("obs", { run: rd.run, f0, f1 }, {
318        expectCols: Number(rd.cols) || 18,
319        onRow: (fields) => rows.push(fields),
320        timeoutMs: Number(rd.timeoutMs) || 30000,
321      });
322      entry.state = "ready";
323      entry.rows = rows;
324      postToChild({
325        type: "ab-preview-read", token: currentPreviewToken, compId: rd.compId,
326        kind: "scene", chunk: start, rows, f0, f1,
327        declared: r.declared, received: r.received,
328      });
329    } catch (err) {
330      // ⛔ A FAILED CHUNK IS REPORTED, NOT SWALLOWED. An absent overlay over moving video
331      // reads as "the engine looked and found nothing" — the most reassuring thing a
332      // safety display can say, and a lie. The child needs the difference between an empty
333      // scene and one nobody could fetch. The entry is left in "error" rather than deleted
334      // so a permanently bad chunk is not re-requested on every frame; a seek clears it.
335      entry.state = "error";
336      entry.err = (err && err.message) || "scene lane failed";
337      postToChild({
338        type: "ab-preview-read", token: currentPreviewToken, compId: rd.compId,
339        kind: "scene", chunk: start, error: entry.err, f0, f1,
340      });
341    }
342    return entry;
343  };
344
345  // Evict chunks well behind the playhead. Bounded memory matters here: a 10-minute clip
346  // is 20 chunks and ~200,000 rows, which is not something to hold because nobody wrote
347  // the line that drops it.
348  // ⛔ ONLY CHUNKS BEHIND ARE EVICTED. `here - start` is negative for everything the
349  // prefetch just queued, so eviction can never race the prefetch and throw away a chunk
350  // that is still in flight — which would show up as a permanent stall, not a slow one.
351  const evictSceneChunks = (rd, here) => {
352    const keep = SCENE_KEEP * SCENE_CHUNK;
353    sceneChunks.forEach((_v, k) => {
354      const parts = k.split("|");
355      if (parts[0] !== String(rd.db) || parts[2] !== String(rd.run)) return;
356      const start = Number(parts[3]);
357      if (here - start > keep) sceneChunks.delete(k);
358    });
359  };
360
361  // The prefetch itself: the chunk under the playhead first, then the ones ahead of it.
362  // Requests are issued in order and the session serialises them, so "first" really is
363  // first — the chunk being drawn is never queued behind speculative work.
364  // ⛔ THE POSITION COMES FROM rd.frame, WHICH THE CHILD WRITES. It used to be read from
365  // rd.f0 — the child's own idea of a window start — and that only worked while the two
366  // sides chunked identically. A child aligned to 1800 and a host chunking at 300 would
367  // both be internally consistent and up to 1,799 frames apart, which shows up as prefetch
368  // that is always behind the playhead rather than as anything that looks like a fault.
369  // rd.f0 is still honoured as a fallback so a page baked before this change still loads.
370  const primeSceneChunks = (rd) => {
371    const here = chunkStartFor(Number(rd.frame) || Number(rd.f0) || 1);
372    for (let i = 0; i <= SCENE_AHEAD; i += 1) {
373      // ⛔ THE AHEAD CHUNKS MUST BE RE-ALIGNED, NOT JUST OFFSET. chunkStartFor FLOORS AT 1
374      // (frame 0 makes pViFeed answer with the entire corpus), so `here` is 1 at the start of
375      // a run while every later chunk lands on a multiple of SCENE_CHUNK. Adding the stride
376      // to an unaligned `here` therefore primed 1, 901, 1801, 2701 while playback asked for
377      // 1, 900, 1800, 2700 — so EVERY boundary was a cache miss that fetched the chunk a
378      // SECOND time, synchronously, at the exact moment it was needed. That is a visible
379      // freeze once per chunk and a straight 2x on engine load, and neither reports an error:
380      // the rows are correct, the counts reconcile, it is just all done twice. Re-flooring
381      // each candidate through chunkStartFor puts the prefetch on the same grid as the ask.
382      const start = chunkStartFor(here + i * SCENE_CHUNK);
383      const key = `${rd.db}|${String(rd.proc || "pViFeed")}|${rd.run}|${start}`;
384      const c = sceneChunks.get(key);
385      if (c && (c.state === "loading" || c.state === "ready")) {
386        // Already have it (or it is coming) — re-push a ready chunk so a child that
387        // remounted or lost the message is not left waiting for data the host holds.
388        if (c.state === "ready") {
389          postToChild({
390            type: "ab-preview-read", token: currentPreviewToken, compId: rd.compId,
391            kind: "scene", chunk: start, rows: c.rows,
392            f0: start, f1: start + SCENE_CHUNK - 1,
393            declared: c.rows.length, received: c.rows.length,
394          });
395        }
396        continue;
397      }
398      loadSceneChunk(rd, start);   // deliberately not awaited: chunks land as they land
399    }
400    evictSceneChunks(rd, here);
401  };
402
403  const postToChild = (msg) => {
404    try { iframe.contentWindow && iframe.contentWindow.postMessage(msg, "*"); } catch (_) {}
405  };
406
407  // ⛔ A PLAYHEAD MOVING IS NOT NEWS TO A TABLE. The child re-announces the SAME reads with
408  // only `frame` changed every time playback crosses a chunk boundary, and this handler used
409  // to answer that by re-running EVERY read on the page — each of which opened its own ws-dba
410  // connection, i.e. forked its own engine child. MEASURED on .200 2026-08-17 from the relay's
411  // own log during ONE video playing: `connId=4337` … `connId=4365`, 28 children in 44 s, the
412  // last two exactly 30 s apart, which is exactly one chunk crossing. Nothing had changed but
413  // the playhead. Strip the playhead fields and compare: identical means "prime the scene,
414  // touch nothing else" — and priming is idempotent, so an already-held chunk costs nothing.
415  //
416  // Compare on a stripped copy rather than trusting a flag from the child: the child cannot be
417  // relied upon to say WHY it re-announced, and a widget genuinely rebinding must still get a
418  // full re-init. A spurious mismatch here falls back to the old full path, which is safe.
419  const readsSignature = (reads) =>
420    JSON.stringify((Array.isArray(reads) ? reads : []).map((r) => {
421      const { frame, f0, f1, ...rest } = r || {};
422      return rest;
423    }));
424
425  const refetchReads = async (opts = {}) => {
426    const sceneOnly = opts && opts.sceneOnly === true;
427    if (!iframe.contentWindow || !readRelay.reads.length) return;
428    for (const rd of readRelay.reads) {
429      try {
430        // The scene lane is windowed and the CHILD owns the window: it re-announces its
431        // reads (same ab-preview-reads-init message) with new f0/f1 as playback moves, so
432        // no new protocol is needed for it to page through a 10-minute clip.
433        if (rd.kind === "scene") {
434          // ⛔ THE SCENE IS NOT POLLED AND MUST NOT BE. It is driven by where the playhead
435          // is, which the child announces; a timer asking again for a corpus that only
436          // changes when the driver publishes is the REST reflex, and it was what turned
437          // a connect-per-call into a new engine child every couple of seconds.
438          // primeSceneChunks is idempotent — chunks already held or in flight are skipped,
439          // so arriving here from a re-announce costs nothing but the ones genuinely
440          // missing. It does not await: chunks are pushed to the child as they land.
441          primeSceneChunks(rd);
442          continue;
443        }
444        // sceneOnly = "the playhead moved and nothing else did". Every read past this line
445        // costs a round trip, so the playhead must not be allowed to buy one.
446        if (sceneOnly) continue;
447        // Series (charts) route to the aggregate/bucket lanes, which return a row
448        // SET (one row per group/bucket) rather than the filter lane's records.
449        // Without this a published chart is frozen at whatever was baked on save.
450        if (rd.kind === "series") {
451          const proc = rd.proc === "pFeBeBucket" ? "pFeBeBucket" : "pFeBeAggregate";
452          let data;
453          let op;
454          if (proc === "pFeBeBucket") {
455            data = { entity: rd.entity, bucket: rd.bucket || {} };
456            op = "bucket";
457          } else {
458            data = { entity: rd.entity };
459            if (rd.groupBy) data.groupBy = rd.groupBy;
460            if (Array.isArray(rd.aggregates) && rd.aggregates.length) data.aggregates = rd.aggregates;
461            if (rd.where) data.where = resolveWhereTokens(rd.where);
462            op = "aggregate";
463          }
464          const sr = await callAdminProc("", { proc, op, data, db: rd.db || "ZimAdmin" });
465          const srows = Array.isArray(sr && sr.rows) ? sr.rows : [];
466          const cm = rd.chartMap || {};
467          const firstKeys = Object.keys(srows[0] || {});
468          const xKey = cm.x || (proc === "pFeBeBucket" ? "bucket" : (rd.groupBy || firstKeys[0] || ""));
469          const yKey = cm.y || (proc === "pFeBeBucket"
470            ? "count"
471            : (firstKeys.find((k) => k !== xKey && Number.isFinite(Number((srows[0] || {})[k]))) || firstKeys[1] || firstKeys[0] || ""));
472          try {
473            iframe.contentWindow.postMessage(
474              { type: "ab-preview-read", token: currentPreviewToken, compId: rd.compId, kind: "series", rows: srows, xKey, yKey },
475              "*"
476            );
477          } catch (_) { /* iframe gone */ }
478          continue;
479        }
480        // Scalar counter served by the aggregate lane: one row out of the engine
481        // instead of the whole entity into the browser. Same "count" push shape,
482        // so the sandboxed runtime renders it with no change.
483        if (rd.kind === "count" && rd.proc === "pFeBeAggregate") {
484          const data = { entity: rd.entity, aggregates: rd.aggregates || [{ fn: "count", field: "", as: "n" }] };
485          if (rd.where) data.where = resolveWhereTokens(rd.where);
486          const ar = await callAdminProc("", { proc: "pFeBeAggregate", op: "aggregate", data, db: rd.db || "ZimAdmin" });
487          const row0 = (Array.isArray(ar && ar.rows) ? ar.rows : [])[0] || {};
488          const picked = Number(row0[rd.pick]);
489          try {
490            iframe.contentWindow.postMessage(
491              { type: "ab-preview-read", token: currentPreviewToken, compId: rd.compId, kind: "count", value: Number.isFinite(picked) ? picked : 0 },
492              "*"
493            );
494          } catch (_) { /* iframe gone */ }
495          continue;
496        }
497        const fieldMap = rd.kind === "rows"
498          ? (rd.columns || []).filter((c) => String(c.key || "").includes(".")).map((c) => ({ dbField: c.key, formField: c.key }))
499          : (rd.fieldMap && rd.fieldMap.length ? rd.fieldMap : [{ dbField: `${rd.entity}.x`, formField: "field0" }]);
500        // Pass the saved query through. pFeBeFilterHandler reads predicate/sort/
501        // limit/offset (sort is an ARRAY — "sort[1]" — not an object); dropping
502        // them makes every poll a full-entity scan whose rows are then discarded
503        // client-side. Relative-time tokens resolve here, same as the series lane.
504        const filterData = { entity: rd.entity, fieldMap, filter: {} };
505        if (rd.predicate) filterData.predicate = resolvePredicateTokens(rd.predicate);
506        if (Array.isArray(rd.sort) && rd.sort.length) filterData.sort = rd.sort;
507        if (Number.isFinite(Number(rd.limit)) && Number(rd.limit) > 0) filterData.limit = Number(rd.limit);
508        if (Number.isFinite(Number(rd.offset)) && Number(rd.offset) > 0) filterData.offset = Number(rd.offset);
509        const r = await callAdminProc("", { proc: "pFeBeFilterHandler", op: "filter", data: filterData, db: rd.db || "ZimAdmin" });
510        let msg;
511        if (rd.kind === "count") {
512          const n = (r && r.result && Number.isFinite(r.result.count)) ? r.result.count : (Array.isArray(r && r.rows) ? r.rows.length : 0);
513          msg = { type: "ab-preview-read", token: currentPreviewToken, compId: rd.compId, kind: "count", value: n };
514        } else {
515          const rows = Array.isArray(r && r.rows) ? r.rows : [];
516          const cols = rd.columns || [];
517          const cells = rows.slice(0, 50).map((row) => cols.map((c) => { const v = row[c.key]; return v == null ? "" : v; }));
518          msg = { type: "ab-preview-read", token: currentPreviewToken, compId: rd.compId, kind: "rows", columns: cols.map((c) => c.label), cells };
519        }
520        try { iframe.contentWindow.postMessage(msg, "*"); } catch (_) {}
521      } catch (err) {
522        // ⛔ A SWALLOWED READ IS THE WORST OF THE THREE OUTCOMES. Skipping silently leaves
523        // the baked save-time markup on screen, so a table whose fetch just failed still
524        // reads "No rows" -- i.e. it reports the entity as EMPTY, as a fact, when nobody
525        // got an answer. Tell the widget, and let it say which one happened.
526        try {
527          iframe.contentWindow.postMessage(
528            { type: "ab-preview-read", token: currentPreviewToken, compId: rd.compId,
529              kind: rd.kind, error: String((err && err.message) || err || "read failed") },
530            "*"
531          );
532        } catch (_) { /* iframe gone */ }
533      }
534    }
535  };
536  const extractStreamIdsFromNdjson = (layoutNdjson) => {
537    if (!layoutNdjson || typeof layoutNdjson !== "string") return [];
538    const ids = new Set();
539    layoutNdjson.split("\n").map((l) => l.trim()).filter(Boolean).forEach((line) => {
540      try {
541        const row = JSON.parse(line);
542        if (row?.type !== "component_config" || !row?.dataBindings || typeof row.dataBindings !== "object") return;
543        Object.values(row.dataBindings).forEach((bindingKey) => {
544          const streamId = String(bindingKey || "").trim().split(".")[0];
545          if (streamId) ids.add(streamId);
546        });
547      } catch (_) {}
548    });
549    return [...ids];
550  };
551  const setupPreviewRelay = async (layoutNdjson, token) => {
552    cleanupPreviewRelay();
553    const streamIds = extractStreamIdsFromNdjson(layoutNdjson);
554    if (!streamIds.length) return;
555    const registry = await getStreamsRegistry();
556    if (!registry) return;
557    const state = { frameReady: false, latestByStream: new Map(), unsubscribes: [], token: token || "" };
558    const postRecord = (streamId, record) => {
559      if (!state.frameReady || !iframe.contentWindow) return;
560      try { iframe.contentWindow.postMessage({ type: "ab-preview-stream-data", token: state.token, streamId, record }, PREVIEW_CHILD_ORIGIN); } catch (_) {}
561    };
562    streamIds.forEach((streamId) => {
563      try {
564        const unsub = registry.subscribe(streamId, (records) => {
565          if (!records?.length) return;
566          const latest = records[records.length - 1];
567          state.latestByStream.set(streamId, latest);
568          postRecord(streamId, latest);
569        });
570        state.unsubscribes.push(unsub);
571      } catch (_) {}
572      try {
573        const cached = registry.getRecords?.(streamId);
574        if (Array.isArray(cached) && cached.length) state.latestByStream.set(streamId, cached[cached.length - 1]);
575      } catch (_) {}
576    });
577    previewRelayState = state;
578  };
579  // ── Write relay ──────────────────────────────────────────────────────────
580  // A form in the sandboxed preview iframe can't reach the DB. It posts its
581  // payload here; the host (same-origin, holds the JWT) runs the FeBe write
582  // handler over ws-dba and posts the result back into the iframe. Only the two
583  // known write handlers are allowed, and the request is token-gated.
584  const WRITE_HANDLERS = new Set(["pFeBeSubmitHandler", "pFeBeSaveHandler"]);
585  window.addEventListener("message", async (event) => {
586    if (!iframe.contentWindow || event.source !== iframe.contentWindow) return;
587    if (event.origin !== PREVIEW_CHILD_ORIGIN) return;
588    const data = event.data;
589    if (!data || data.type !== "ab-preview-write") return;
590    if (!currentPreviewToken || data.token !== currentPreviewToken) return;
591    const w = (data && data.write) || {};
592    const post = (ok, message) => {
593      try {
594        // The preview iframe is sandboxed → OPAQUE origin. You cannot target an
595        // opaque origin with the string "null"; postMessage silently drops it.
596        // "*" is required (the payload carries no secrets and the child re-checks
597        // the token). Posting with PREVIEW_CHILD_ORIGIN here was why the form hung
598        // on "Submitting…" even though the row was written.
599        iframe.contentWindow.postMessage(
600          { type: "ab-preview-write-result", token: currentPreviewToken, formId: data.formId, ok, message },
601          "*"
602        );
603      } catch (_) {}
604    };
605    if (!WRITE_HANDLERS.has(w.handler)) { post(false, "handler not allowed"); return; }
606    if (!w.entity || !Array.isArray(w.fieldMap)) { post(false, "invalid form config"); return; }
607    try {
608      const op = w.handler === "pFeBeSaveHandler" ? "save" : "submit";
609      await callAdminProc("", { proc: w.handler, op, data: { entity: w.entity, fieldMap: w.fieldMap, payload: w.payload || {} }, db: w.db || "ZimAdmin" });
610      post(true, `Saved to ${w.entity}`);
611      refetchReads();   // a new row landed — refresh bound tables/counters now
612    } catch (err) {
613      post(false, (err && err.message) || "Write failed");
614    }
615  });
616
617  // Read widgets announce their bindings once the preview script is up. Start the
618  // poll and do an immediate fetch so the widgets show fresh data promptly.
619  window.addEventListener("message", (event) => {
620    if (!iframe.contentWindow || event.source !== iframe.contentWindow) return;
621    if (event.origin !== PREVIEW_CHILD_ORIGIN) return;
622    const d = event.data;
623    if (!d || d.type !== "ab-preview-reads-init") return;
624    if (!currentPreviewToken || d.token !== currentPreviewToken) return;
625    const reads = Array.isArray(d.reads) ? d.reads : [];
626
627    // Playhead re-announce: same reads, new frame. Adopt the new frame and prime the scene.
628    // ⛔ THE TIMER IS DELIBERATELY LEFT RUNNING. stopReadRelay/setInterval here would restart
629    // the poll clock on every chunk crossing, so a page whose chunks cross faster than its
630    // poll period would never actually poll — a bug that hides itself by looking quiet.
631    if (readRelay.reads.length && readsSignature(reads) === readsSignature(readRelay.reads)) {
632      readRelay.reads = reads;
633      refetchReads({ sceneOnly: true });
634      return;
635    }
636
637    stopReadRelay();
638    readRelay = { reads, timer: null };
639    refetchReads();
640    // Poll no faster than the SLOWEST widget on the page asks for. 6s suits a
641    // filter-lane count; it is the wrong cadence for a grouped aggregate, whose
642    // group discovery walks every distinct key in the entity on every call
643    // (MEASURED on the EWT unit: ~6.3s of Pi CPU for a 202-group pass). Polling
644    // that every 6s pins a core permanently to redraw a chart — on a device whose
645    // whole design goal is not burning power on avoidable work. A lane declares
646    // its own cadence via febelane.pollMs; the floor stops a bad value from
647    // turning the viewer into a load generator.
648    // ⛔ SCENE READS ARE EXCLUDED FROM THE TIMER ENTIRELY. They are driven by the playhead
649    // (the child re-announces as it moves) and served from the held session's chunk cache,
650    // so a timer would re-ask for a corpus that changes only when the driver publishes. An
651    // app whose only read is a scene now starts NO interval at all, rather than one that
652    // does nothing but keep the page awake.
653    const polled = readRelay.reads.filter((r) => r && r.kind !== "scene");
654    if (!polled.length) return;
655    const periods = polled
656      .map((r) => Number(r && r.pollMs))
657      .filter((n) => Number.isFinite(n) && n > 0);
658    // ⛔⛔ POLLING IS NOW OPT-IN, AND THIS IS A BEHAVIOUR CHANGE BEYOND VI. A read widget that
659    // does not DECLARE a pollMs used to be refreshed every 6 s forever; it now loads ONCE.
660    // The default was a REST reflex wearing a WebSocket: this engine is a session manager and
661    // the socket is bidirectional, so the correct shape for "the data changed" is the engine
662    // PUSHING, not the page asking on a clock. Until a given lane has that push, a widget that
663    // genuinely needs a repeating read must SAY SO (febelane.pollMs), which also makes every
664    // recurring cost on the page visible in the app definition instead of implicit here.
665    // The 2000 ms floor still stops a bad declared value turning the viewer into a load
666    // generator; the difference is that no value at all now means no timer at all.
667    if (!periods.length) return;
668    const period = Math.max(2000, Math.max(...periods));
669    readRelay.timer = setInterval(refetchReads, period);
670  });
671
672  window.addEventListener("message", (event) => {
673    if (!previewRelayState || !iframe.contentWindow || event.source !== iframe.contentWindow) return;
674    if (event.data?.type !== "ab-preview-ready") return;
675    if (event.origin !== PREVIEW_CHILD_ORIGIN) return;
676    if (!event.data?.token || event.data.token !== previewRelayState.token) return;
677    previewRelayState.frameReady = true;
678    for (const [streamId, latest] of previewRelayState.latestByStream.entries()) {
679      try { iframe.contentWindow.postMessage({ type: "ab-preview-stream-data", token: previewRelayState.token, streamId, record: latest }, PREVIEW_CHILD_ORIGIN); } catch (_) {}
680    }
681  });
682
683  // ── Device switching ───────────────────────────────────────────────────────
684  function applyDevice(deviceId) {
685    deviceBtns.forEach(b => b.classList.toggle("av-btn-active", b.dataset.device === deviceId));
686    const preset = DEVICES.find(d => d.id === deviceId);
687    if (preset?.width) {
688      iframeWrap.style.width = `${preset.width}px`;
689      iframeWrap.style.maxWidth = "100%";
690      iframeWrap.style.boxShadow = "0 4px 24px rgba(0,0,0,.2)";
691      iframeWrap.style.borderRadius = "8px";
692      iframeWrap.style.overflow = "hidden";
693    } else {
694      iframeWrap.style.width = "100%";
695      iframeWrap.style.maxWidth = "";
696      iframeWrap.style.boxShadow = "";
697      iframeWrap.style.borderRadius = "";
698      iframeWrap.style.overflow = "";
699    }
700  }
701
702  deviceBtns.forEach(btn =>
703    btn.addEventListener("click", () => applyDevice(btn.dataset.device))
704  );
705
706  // ── Preview builder ────────────────────────────────────────────────────────
707  async function loadPreview() {
708    stopReadRelay();   // clear any prior poll before the new frame announces itself
709    // ⛔ AND CLOSE THE HELD SESSIONS. Loading a different app into the same viewer replaces
710    // the iframe, so the old child's sessions have nobody left to serve — but nothing closes
711    // them: they are held BY DESIGN across queries, which is exactly why they cannot be left
712    // to an implicit teardown. Every session is an engine child holding a dictionary and a
713    // pool slot, so a session leaked per document swap is a leak an operator produces just by
714    // browsing. dispose() covers the page going away; this covers the far more common case
715    // of it staying and showing something else.
716    closeSceneSessions();
717    const project = projInput.value.trim() || "MainProject";
718    const layout  = layoutInput.value.trim() || "Main";
719    let layoutNdjson = serverNdjson;
720    if (!layoutNdjson && runDocId) {
721      try {
722        const { loadDoc } = await import("../../common/shellDocStore.js");
723        layoutNdjson = await loadDoc({ shellType: "appbuilder", docId: runDocId });
724        serverNdjson = layoutNdjson;            // cache for device re-renders
725      } catch (e) { console.warn("[AppViewer] server load failed:", e); }
726    }
727    if (!layoutNdjson) {
728      try {
729        layoutNdjson = localStorage.getItem(APPBUILDER_LAYOUT_KEY(project, layout));
730      } catch (_) {}
731    }
732    // Server-doc fallback: derive the appbuilder docId from the current
733    // project/layout and load it from the shared doc store. localStorage is
734    // per-browser-profile, so an app saved in another session/device is
735    // invisible to it — without this, opening the viewer on a fresh browser
736    // shows "No content to preview" even though the app IS saved on the server.
737    if (!layoutNdjson) {
738      try {
739        const { loadDoc, makeDocId } = await import("../../common/shellDocStore.js");
740        const raw = `${project || "app"}-${layout || "main"}`
741          .toLowerCase().replace(/[^a-z0-9-]/g, "-").slice(0, 36);
742        layoutNdjson = await loadDoc({ shellType: "appbuilder", docId: makeDocId("appbuilder", raw) });
743        serverNdjson = layoutNdjson;   // cache for device re-renders
744      } catch (e) { console.warn("[AppViewer] derived server-doc load failed:", e); }
745    }
746    let html;
747    const previewRelayToken = makePreviewRelayToken();
748    currentPreviewToken = previewRelayToken;   // used by the write-relay handler
749    const previewParentOrigin = window.location.origin;
750    try {
751      ({ html } = buildProductionHTML(project, layout, { layoutNdjson, previewRelayToken, previewParentOrigin }));
752    } catch (err) {
753      console.error("[AppViewer] buildProductionHTML failed:", err);
754      html = `<body style="font:14px/1.6 system-ui;padding:2rem;color:#1e293b">
755        <h2 style="color:#dc2626">Preview failed</h2>
756        <pre style="background:#fef2f2;padding:1rem;border-radius:6px;overflow:auto">${err.message}</pre>
757        <p>Make sure the Project and Layout names match what is on the canvas.</p>
758      </body>`;
759    }
760    // Use srcdoc so the iframe is reloaded cleanly without document.write quirks.
761    // The sandbox attribute (no allow-same-origin) already prevents the iframe
762    // from accessing the parent document context.
763    iframe.srcdoc = html;
764    setupPreviewRelay(layoutNdjson, previewRelayToken);
765  }
766
767  previewBtn.addEventListener("click", loadPreview);
768  reloadBtn.addEventListener("click", loadPreview);
769
770  // Allow pressing Enter in either input field to trigger preview
771  [projInput, layoutInput].forEach(input =>
772    input.addEventListener("keydown", e => { if (e.key === "Enter") loadPreview(); })
773  );
774
775  // Auto-load on first mount
776  loadPreview();
777
778  // ⛔ THE SESSION MUST BE CLOSED WITH THE VIEW. A held connection is the whole point of
779  // this lane, and it is also the whole risk: an unheld one leaked a child per query, an
780  // unclosed one leaks a child per VIEW and never gets it back. Every engine child holds a
781  // ZIM session, a dictionary and a pool slot, so a viewer opened and closed a dozen times
782  // would strand a dozen of them — a slower leak than the one this replaced, and a harder
783  // one to see, because it only shows up after a day of ordinary use.
784  return { api: { dispose: () => { cleanupPreviewRelay(); stopReadRelay(); closeSceneSessions(); } } };
785}
786
787export function extendToolbar() { /* no-op */ }
788export function update()        { /* no-op */ }

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.