@nanobpm/urban 0.26.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -23,6 +23,12 @@ export interface PagesDeps {
23
23
  engine: EngineClient;
24
24
  /** Read a page file; injectable for tests. */
25
25
  readPage(path: string): Promise<string>;
26
+ /**
27
+ * List the available page ids (the `*.page.json` basenames under `pagesDir`),
28
+ * powering the `/app/pages` index endpoint and a `nav` node's `items: "auto"`.
29
+ * Optional; when absent the index is empty. Injectable for tests.
30
+ */
31
+ listPages?(): Promise<string[]>;
26
32
  }
27
33
  /**
28
34
  * Build the page-runtime routes over an injected datasource + engine. Pure over its deps
@@ -34,7 +34,7 @@ export function createPagesRoutes(opts, deps) {
34
34
  const rowLimitRaw = opts.rowLimit ?? 200;
35
35
  const rowLimit = Number.isFinite(rowLimitRaw) ? Math.max(0, Math.floor(rowLimitRaw)) : 200;
36
36
  const sourceName = opts.sourceName ?? "app";
37
- const { db, engine, readPage } = deps;
37
+ const { db, engine, readPage, listPages } = deps;
38
38
  // The table-name whitelist is memoised: an Urban app runs its migrations at boot
39
39
  // (before serving), so the schema is stable for the process lifetime, and the renderer
40
40
  // refreshes grids repeatedly — re-introspecting on every `/app/data` hit would be a hot
@@ -75,6 +75,36 @@ export function createPagesRoutes(opts, deps) {
75
75
  source: "surface:pages",
76
76
  handler: () => javascript(RENDERER_JS),
77
77
  });
78
+ // ── GET /app/pages (index of available pages) ───────────────────────────
79
+ // Powers multi-page navigation: a `nav` node with `items: "auto"` lists every
80
+ // page here, and it lets any client enumerate the app's screens. Each entry
81
+ // carries the page's own `title` (best-effort — an unreadable/!JSON page still
82
+ // appears by id) and a `home` flag. Registered as an exact path so it never
83
+ // shadows the `/app/pages/<id>` prefix route below.
84
+ routes.push({
85
+ method: "GET",
86
+ path: "/app/pages",
87
+ source: "surface:pages",
88
+ handler: async () => {
89
+ const ids = listPages ? await listPages().catch(() => []) : [];
90
+ const pages = [];
91
+ for (const id of ids) {
92
+ if (!/^[A-Za-z0-9_-]+$/.test(id))
93
+ continue;
94
+ let title = id;
95
+ try {
96
+ const doc = JSON.parse(await readPage(`${pagesDir}/${id}.page.json`));
97
+ if (isRecord(doc) && typeof doc.title === "string" && doc.title)
98
+ title = doc.title;
99
+ }
100
+ catch {
101
+ // Unreadable or non-JSON page: still list it by id.
102
+ }
103
+ pages.push({ id, title, home: id === homePage });
104
+ }
105
+ return json({ pages, home: homePage });
106
+ },
107
+ });
78
108
  // ── GET /app/pages/<id> ─────────────────────────────────────────────────
79
109
  routes.push({
80
110
  method: "GET",
@@ -297,6 +327,13 @@ export function mountPages(ctx, app) {
297
327
  db: app.data.open(sourceName),
298
328
  engine: app.engine,
299
329
  readPage: (p) => ctx.host.readTextFile(p),
330
+ listPages: async () => {
331
+ const dir = opts.pagesDir ?? "pages";
332
+ const names = await ctx.host.listDir(dir).catch(() => []);
333
+ return names
334
+ .filter((n) => n.endsWith(".page.json"))
335
+ .map((n) => n.slice(0, -".page.json".length));
336
+ },
300
337
  });
301
338
  ctx.host.log("info", "pages surface mounted", { source: sourceName, home: opts.homePage ?? "home" });
302
339
  return {
@@ -323,47 +360,99 @@ function rendererShell(homePage) {
323
360
  </body>
324
361
  </html>`;
325
362
  }
363
+ // The app's stylesheet resolves every colour through the Nano console's
364
+ // semantic `--nano-*` token contract (see the Magikcraft/nano-bpm repo's
365
+ // `console/src/theme/tokens.css` — MIRROR: keep the palettes below in sync). When the app is embedded in the
366
+ // console it inherits nothing across the iframe boundary, so the console
367
+ // postMessages its resolved tokens + appearance and the runtime lays them onto
368
+ // this document's :root (see RENDERER_JS). Standalone (CLI on :3000) there is no
369
+ // console, so the defaults below stand on their own: dark by default, light when
370
+ // the OS asks for it and nothing has themed us. `--pc-*` are kept as thin
371
+ // aliases so existing class rules re-resolve through the shared tokens.
326
372
  const RENDERER_CSS = `
327
- :root { color-scheme: light dark; --pc-edge:#d0d0d8; --pc-accent:#3b5bdb; }
373
+ :root {
374
+ color-scheme: dark;
375
+ --nano-app:#0b0b10; --nano-panel:#10101a; --nano-raised:#16161f; --nano-inset:#08080c; --nano-hover:#1e1e2a;
376
+ --nano-edge:#24242f; --nano-edge-strong:#383848;
377
+ --nano-text:#f2f2f7; --nano-text-muted:#a3a3b2; --nano-text-faint:#6e6e80;
378
+ --nano-accent:#8b5cf6; --nano-accent-strong:#a78bfa; --nano-accent-2:#22d3ee; --nano-on-accent:#ffffff;
379
+ --nano-ok:#34d399; --nano-warn:#fbbf24; --nano-danger:#fb7185; --nano-info:#38bdf8;
380
+ }
381
+ :root[data-appearance="light"] {
382
+ color-scheme: light;
383
+ --nano-app:#f5f5f9; --nano-panel:#fdfdfe; --nano-raised:#ffffff; --nano-inset:#ededf3; --nano-hover:#e8e8f0;
384
+ --nano-edge:#e2e2ea; --nano-edge-strong:#c5c5d4;
385
+ --nano-text:#1a1a22; --nano-text-muted:#565664; --nano-text-faint:#8c8c9c;
386
+ --nano-accent:#7c3aed; --nano-accent-strong:#6d28d9; --nano-accent-2:#0891b2; --nano-on-accent:#ffffff;
387
+ --nano-ok:#059669; --nano-warn:#b45309; --nano-danger:#e11d48; --nano-info:#0369a1;
388
+ }
389
+ @media (prefers-color-scheme: light) {
390
+ /* Standalone only: follow the OS until the console (which sets data-appearance) themes us. */
391
+ :root:not([data-appearance]) {
392
+ color-scheme: light;
393
+ --nano-app:#f5f5f9; --nano-panel:#fdfdfe; --nano-raised:#ffffff; --nano-inset:#ededf3; --nano-hover:#e8e8f0;
394
+ --nano-edge:#e2e2ea; --nano-edge-strong:#c5c5d4;
395
+ --nano-text:#1a1a22; --nano-text-muted:#565664; --nano-text-faint:#8c8c9c;
396
+ --nano-accent:#7c3aed; --nano-accent-strong:#6d28d9; --nano-accent-2:#0891b2; --nano-on-accent:#ffffff;
397
+ --nano-ok:#059669; --nano-warn:#b45309; --nano-danger:#e11d48; --nano-info:#0369a1;
398
+ }
399
+ }
400
+ :root { --pc-edge:var(--nano-edge); --pc-accent:var(--nano-accent); }
328
401
  * { box-sizing: border-box; }
329
- body { margin:0; font:15px/1.5 system-ui,sans-serif; padding:2rem; max-width:64rem; margin-inline:auto; }
330
- .pc-empty { opacity:.6; }
402
+ body { margin:0; font:15px/1.5 system-ui,sans-serif; padding:2rem; max-width:64rem; margin-inline:auto; background:var(--nano-app); color:var(--nano-text); }
403
+ .pc-empty { color:var(--nano-text-faint); }
331
404
  .pc-heading { font-size:1.6rem; font-weight:650; margin:0 0 .25rem; }
332
- .pc-sub { opacity:.7; margin:.25rem 0 1rem; }
405
+ .pc-sub { color:var(--nano-text-muted); margin:.25rem 0 1rem; }
333
406
  .pc-body { margin:.5rem 0; }
334
- .pc-card { border:1px solid var(--pc-edge); border-radius:.6rem; padding:1rem 1.15rem; margin:1rem 0; }
407
+ .pc-card { border:1px solid var(--nano-edge); border-radius:.6rem; padding:1rem 1.15rem; margin:1rem 0; background:var(--nano-panel); }
335
408
  .pc-card h2 { font-size:1rem; margin:0 0 .75rem; }
336
409
  .pc-field { display:flex; flex-direction:column; gap:.25rem; margin-bottom:.6rem; }
337
- .pc-field label { font-size:.8rem; opacity:.75; }
338
- .pc-field input { padding:.5rem .6rem; border:1px solid var(--pc-edge); border-radius:.4rem; font:inherit; }
339
- .pc-btn { padding:.5rem .9rem; border:0; border-radius:.4rem; background:var(--pc-accent); color:#fff; font:inherit; cursor:pointer; }
410
+ .pc-field label { font-size:.8rem; color:var(--nano-text-muted); }
411
+ .pc-field input { padding:.5rem .6rem; border:1px solid var(--nano-edge); border-radius:.4rem; font:inherit; background:var(--nano-inset); color:var(--nano-text); }
412
+ .pc-btn { padding:.5rem .9rem; border:0; border-radius:.4rem; background:var(--nano-accent); color:var(--nano-on-accent); font:inherit; cursor:pointer; }
340
413
  .pc-btn:disabled { opacity:.5; cursor:default; }
341
414
  .pc-msg { font-size:.85rem; margin-top:.5rem; min-height:1.2em; }
342
- .pc-msg.err { color:#c0392b; }
343
- .pc-msg.ok { color:#2b8a3e; }
415
+ .pc-msg.err { color:var(--nano-danger); }
416
+ .pc-msg.ok { color:var(--nano-ok); }
344
417
  table.pc-grid { width:100%; border-collapse:collapse; font-size:.9rem; }
345
- table.pc-grid th, table.pc-grid td { text-align:left; padding:.4rem .6rem; border-bottom:1px solid var(--pc-edge); }
346
- table.pc-grid th { font-weight:600; opacity:.75; }
418
+ table.pc-grid th, table.pc-grid td { text-align:left; padding:.4rem .6rem; border-bottom:1px solid var(--nano-edge); }
419
+ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
347
420
  .pc-tabs { display:flex; gap:.5rem; margin-bottom:.75rem; }
348
- .pc-tab { padding:.35rem .8rem; border:1px solid var(--pc-edge); border-radius:.4rem; background:transparent; color:inherit; font:inherit; cursor:pointer; }
349
- .pc-tab.active { background:var(--pc-accent); color:#fff; border-color:var(--pc-accent); }
421
+ .pc-tab { padding:.35rem .8rem; border:1px solid var(--nano-edge); border-radius:.4rem; background:transparent; color:inherit; font:inherit; cursor:pointer; }
422
+ .pc-tab.active { background:var(--nano-accent); color:var(--nano-on-accent); border-color:var(--nano-accent); }
350
423
  .pc-btn-sm { padding:.25rem .55rem; font-size:.8rem; margin-right:.3rem; }
351
- .pc-chevron { background:transparent; color:inherit; border:1px solid var(--pc-edge); }
424
+ .pc-chevron { background:transparent; color:inherit; border:1px solid var(--nano-edge); }
352
425
  .pc-row-actions { white-space:nowrap; text-align:right; }
353
426
  .pc-detail { padding:.75rem .25rem; }
354
427
  .pc-detail-field { display:flex; gap:.5rem; font-size:.85rem; margin:.15rem 0; }
355
- .pc-detail-label { opacity:.7; min-width:8rem; }
356
- .pc-link { color:var(--pc-accent); }
428
+ .pc-detail-label { color:var(--nano-text-muted); min-width:8rem; }
429
+ .pc-link { color:var(--nano-accent); }
357
430
  .pc-child { margin:.6rem 0; }
358
- .pc-child-title { font-size:.8rem; font-weight:600; opacity:.7; margin-bottom:.25rem; }
359
- .pc-transcript { white-space:pre-wrap; max-height:22rem; overflow:auto; background:rgba(120,120,160,.08); padding:.5rem; border-radius:.4rem; font-size:.8rem; margin-top:.4rem; }
360
- .pc-subform { margin-top:.75rem; padding:.6rem; border:1px dashed var(--pc-edge); border-radius:.5rem; }
431
+ .pc-child-title { font-size:.8rem; font-weight:600; color:var(--nano-text-muted); margin-bottom:.25rem; }
432
+ .pc-transcript { white-space:pre-wrap; max-height:22rem; overflow:auto; background:var(--nano-inset); padding:.5rem; border-radius:.4rem; font-size:.8rem; margin-top:.4rem; }
433
+ .pc-subform { margin-top:.75rem; padding:.6rem; border:1px dashed var(--nano-edge); border-radius:.5rem; }
361
434
  .pc-subform-title { font-weight:600; font-size:.85rem; margin-bottom:.4rem; }
362
- .pc-prompt { font-size:.85rem; opacity:.8; margin-bottom:.4rem; white-space:pre-wrap; }
363
- .pc-textarea { width:100%; min-height:4rem; padding:.5rem; border:1px solid var(--pc-edge); border-radius:.4rem; font:inherit; }
435
+ .pc-prompt { font-size:.85rem; color:var(--nano-text-muted); margin-bottom:.4rem; white-space:pre-wrap; }
436
+ .pc-textarea { width:100%; min-height:4rem; padding:.5rem; border:1px solid var(--nano-edge); border-radius:.4rem; font:inherit; background:var(--nano-inset); color:var(--nano-text); }
364
437
  .pc-collapse-header { display:flex; align-items:center; gap:.5rem; width:100%; margin:0 0 .75rem; padding:0; background:transparent; border:0; color:inherit; font:inherit; font-size:1rem; font-weight:600; cursor:pointer; text-align:left; }
365
- .pc-chevron-inline { opacity:.6; font-size:.75rem; width:1em; }
438
+ .pc-chevron-inline { color:var(--nano-text-faint); font-size:.75rem; width:1em; }
366
439
  .pc-card-body[hidden] { display:none; }
440
+ /* Multi-page navigation: a horizontal menu bar or a vertical side rail. */
441
+ .pc-layout { display:flex; gap:1.5rem; align-items:flex-start; }
442
+ .pc-rail-wrap { flex:0 0 12rem; position:sticky; top:1rem; align-self:flex-start; }
443
+ .pc-main-col { flex:1 1 auto; min-width:0; }
444
+ .pc-nav-title { font-weight:650; }
445
+ .pc-bar { display:flex; align-items:center; gap:.6rem; flex-wrap:wrap; border-bottom:1px solid var(--nano-edge); padding-bottom:.6rem; margin-bottom:1.25rem; }
446
+ .pc-bar .pc-nav-title { margin-right:.5rem; }
447
+ .pc-bar .pc-nav-items { display:flex; gap:.25rem; flex-wrap:wrap; }
448
+ .pc-rail { display:flex; flex-direction:column; gap:.5rem; }
449
+ .pc-rail .pc-nav-title { margin-bottom:.25rem; padding:0 .7rem; }
450
+ .pc-rail .pc-nav-items { display:flex; flex-direction:column; gap:.15rem; }
451
+ .pc-nav-link { display:flex; align-items:center; gap:.4rem; padding:.4rem .7rem; border-radius:.4rem; color:var(--nano-text-muted); text-decoration:none; font-size:.9rem; }
452
+ .pc-nav-link:hover { background:var(--nano-hover); color:var(--nano-text); }
453
+ .pc-nav-link.active { background:var(--nano-accent); color:var(--nano-on-accent); }
454
+ .pc-nav-icon { line-height:1; }
455
+ .pc-nav-empty { color:var(--nano-text-faint); font-size:.85rem; padding:.4rem .7rem; }
367
456
  `;
368
457
  // The schema-driven browser renderer (ADR 0042 §3). Plain ES module string served at
369
458
  // /app/runtime.js — it does NOT ship Craft.js (authoring is console-side only). It
@@ -373,6 +462,69 @@ const RENDERER_JS = String.raw `
373
462
  const root = document.getElementById("page");
374
463
  const HOME = root.dataset.home || "home";
375
464
 
465
+ // ── Multi-page routing (hash-based, reverse-proxy safe) ──────────────────
466
+ // Pages are selected by the URL fragment (#/<page>) so navigation never hits
467
+ // the server and works identically at the origin root and under the Nano
468
+ // console's /console/app-view/<name>/ path-prefixed proxy (the hash is never
469
+ // sent upstream). An empty/invalid hash falls back to the home page. Only a
470
+ // safe id charset is accepted so the fragment can't smuggle a path/URL.
471
+ const PAGE_ID = /^[A-Za-z0-9_-]+$/;
472
+ function safePageId(value) {
473
+ return typeof value === "string" && PAGE_ID.test(value);
474
+ }
475
+ function currentPage() {
476
+ try {
477
+ const raw = decodeURIComponent((location.hash || "").replace(/^#\/?/, ""));
478
+ return safePageId(raw) ? raw : HOME;
479
+ } catch (e) {
480
+ return HOME;
481
+ }
482
+ }
483
+ let CURRENT = currentPage();
484
+
485
+ // Per-render teardown. Each dataGrid registers its poll interval + pc:refresh
486
+ // listener here; navigating to another page runs every disposer before the new
487
+ // page renders, so a switched-away grid stops polling (otherwise every visited
488
+ // page would leave a live setInterval fetching forever).
489
+ const disposers = [];
490
+ function teardown() {
491
+ while (disposers.length) {
492
+ try { disposers.pop()(); } catch (e) { /* best-effort cleanup */ }
493
+ }
494
+ }
495
+
496
+ // Theme bridge for the Nano console embed. The app runs in a sandboxed,
497
+ // same-origin iframe, so the console's --nano-* custom properties and its
498
+ // data-appearance never cascade in. Instead the console postMessages its
499
+ // resolved tokens; we lay them onto this document's :root and mirror the
500
+ // appearance. Standalone (no console parent) nothing is posted, so the CSS
501
+ // defaults stand. We announce readiness so the console can push the theme even
502
+ // if its first message raced our listener install.
503
+ function applyTheme(msg) {
504
+ if (!msg || msg.type !== "nano-theme") return;
505
+ const el = document.documentElement;
506
+ if (msg.appearance === "light" || msg.appearance === "dark") el.dataset.appearance = msg.appearance;
507
+ const vars = msg.vars;
508
+ if (vars && typeof vars === "object") {
509
+ for (const [k, v] of Object.entries(vars)) {
510
+ // Only accept the shared token namespace; ignore anything else.
511
+ if (typeof k === "string" && k.startsWith("--nano-") && typeof v === "string") {
512
+ el.style.setProperty(k, v);
513
+ }
514
+ }
515
+ }
516
+ }
517
+ if (window.parent && window.parent !== window) {
518
+ window.addEventListener("message", (ev) => {
519
+ // Same-origin proxy (posture A): only trust a message from the framing
520
+ // parent AND from our own origin. ev.source alone is insufficient — a
521
+ // cross-origin page that frames a standalone run becomes window.parent, so
522
+ // an origin check is what actually pins the trust boundary.
523
+ if (ev.origin === window.location.origin && ev.source === window.parent) applyTheme(ev.data);
524
+ });
525
+ window.parent.postMessage({ type: "nano-app-ready" }, window.location.origin);
526
+ }
527
+
376
528
  // Resolve every app endpoint against where THIS module was actually served from,
377
529
  // so absolute-looking "/app/…" paths work both at the origin root (direct run,
378
530
  // e.g. the CLI on :3000) and under a path-prefixed reverse proxy (the Nano
@@ -789,15 +941,71 @@ function renderDataGrid(node) {
789
941
  }
790
942
  }
791
943
  document.addEventListener("pc:refresh", refresh);
944
+ disposers.push(() => document.removeEventListener("pc:refresh", refresh));
792
945
  // Skip the automatic poll while the user is typing in the grid — an explicit pc:refresh
793
946
  // (e.g. after submitting an answer) still runs — so rows never shift under an in-progress
794
- // answer and the detail input keeps focus across ticks.
795
- if (p.refreshMs && p.refreshMs > 0) setInterval(() => { if (!editingInGrid()) refresh(); }, p.refreshMs);
947
+ // answer and the detail input keeps focus across ticks. The interval is registered as a
948
+ // disposer so a page switch (hashchange → renderPage → teardown) stops it.
949
+ if (p.refreshMs && p.refreshMs > 0) {
950
+ const timer = setInterval(() => { if (!editingInGrid()) refresh(); }, p.refreshMs);
951
+ disposers.push(() => clearInterval(timer));
952
+ }
796
953
  refresh();
797
954
  return card;
798
955
  }
799
956
 
800
- const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid };
957
+ // A navigation node — a horizontal menu bar (variant "bar", default) or a
958
+ // vertical side rail (variant "rail"). Each item links either to another page
959
+ // ({ label, page } -> an in-app #/<page> hash link, highlighted when it's the
960
+ // current page) or to an external URL ({ label, href } -> a hardened new-tab
961
+ // link; only http(s) is honoured). With items:"auto" (or omitted) the nav
962
+ // lists every page from the /app/pages index, using each page's title.
963
+ function navLink(item) {
964
+ const isPage = safePageId(item.page);
965
+ const isExt = !isPage && typeof item.href === "string" && /^https?:\/\//i.test(item.href);
966
+ if (!isPage && !isExt) return null;
967
+ const attrs = { class: "pc-nav-link" };
968
+ if (isPage) {
969
+ attrs.href = "#/" + encodeURIComponent(item.page);
970
+ if (item.page === CURRENT) { attrs.class += " active"; attrs["aria-current"] = "page"; }
971
+ } else {
972
+ attrs.href = item.href; attrs.target = "_blank"; attrs.rel = "noopener noreferrer";
973
+ }
974
+ const kids = [];
975
+ if (item.icon != null) kids.push(el("span", { class: "pc-nav-icon" }, String(item.icon)));
976
+ kids.push(el("span", { class: "pc-nav-label" }, String(item.label != null ? item.label : (item.page || item.href))));
977
+ return el("a", attrs, ...kids);
978
+ }
979
+ function fillNav(list, items) {
980
+ list.replaceChildren();
981
+ let n = 0;
982
+ for (const item of items || []) {
983
+ if (!item || typeof item !== "object") continue;
984
+ const a = navLink(item);
985
+ if (a) { list.append(a); n++; }
986
+ }
987
+ if (!n) list.append(el("span", { class: "pc-nav-empty" }, "No pages"));
988
+ }
989
+ function renderNav(node) {
990
+ const p = node.props || {};
991
+ const rail = p.variant === "rail";
992
+ const nav = el("nav", { class: rail ? "pc-nav pc-rail" : "pc-nav pc-bar" });
993
+ if (p.title != null) nav.append(el("div", { class: "pc-nav-title" }, String(p.title)));
994
+ const list = el("div", { class: "pc-nav-items" });
995
+ nav.append(list);
996
+ const items = p.items;
997
+ if (Array.isArray(items)) {
998
+ fillNav(list, items);
999
+ } else {
1000
+ // "auto" (or unspecified): enumerate every page and link to each.
1001
+ getJSON("/app/pages")
1002
+ .then((res) => fillNav(list, (res.pages || []).map((pg) => ({ label: pg.title || pg.id, page: pg.id }))))
1003
+ .catch(() => list.append(el("span", { class: "pc-nav-empty" }, "No pages")));
1004
+ }
1005
+ return nav;
1006
+ }
1007
+
1008
+ const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid, nav: renderNav };
801
1009
 
802
1010
  // Durable per-node UI state. localStorage is keyed by the home page id + node
803
1011
  // id so two grids on the same page (or the same grid across pages) don't clash,
@@ -852,7 +1060,7 @@ function makeCollapsible(node, card) {
852
1060
  chevron,
853
1061
  el("span", { class: "pc-collapse-title" }, titleText),
854
1062
  );
855
- const storageKey = "pc:collapsed:" + HOME + ":" + (node.id || titleText);
1063
+ const storageKey = "pc:collapsed:" + CURRENT + ":" + (node.id || titleText);
856
1064
  let collapsed = readCollapsed(storageKey, !!props.defaultCollapsed);
857
1065
  function apply() {
858
1066
  body.hidden = collapsed;
@@ -869,16 +1077,30 @@ function makeCollapsible(node, card) {
869
1077
  return container;
870
1078
  }
871
1079
 
872
- async function main() {
1080
+ // Render the page named by the current hash route. A rail nav is lifted into a
1081
+ // left <aside> and the rest of the nodes flow into a content column; otherwise
1082
+ // (bar nav or none) nodes render in document order. Re-runs on every hashchange,
1083
+ // tearing down the previous page's grid polls first.
1084
+ async function renderPage() {
1085
+ teardown();
1086
+ CURRENT = currentPage();
873
1087
  try {
874
- const doc = await getJSON("/app/pages/" + encodeURIComponent(HOME));
875
- if (doc.title) document.title = doc.title;
876
- root.replaceChildren(
877
- ...(doc.nodes || []).map((n) => makeCollapsible(n, (RENDERERS[n.type] || (() => el("div")))(n))),
878
- );
1088
+ const doc = await getJSON("/app/pages/" + encodeURIComponent(CURRENT));
1089
+ document.title = (doc && doc.title) || "Urban App";
1090
+ const nodes = (doc && doc.nodes) || [];
1091
+ const built = nodes.map((n) => ({ n, node: makeCollapsible(n, (RENDERERS[n.type] || (() => el("div")))(n)) }));
1092
+ const rail = built.find((b) => b.n.type === "nav" && b.n.props && b.n.props.variant === "rail");
1093
+ if (rail) {
1094
+ const col = el("div", { class: "pc-main-col" });
1095
+ for (const b of built) if (b !== rail) col.append(b.node);
1096
+ root.replaceChildren(el("div", { class: "pc-layout" }, el("aside", { class: "pc-rail-wrap" }, rail.node), col));
1097
+ } else {
1098
+ root.replaceChildren(...built.map((b) => b.node));
1099
+ }
879
1100
  } catch (e) {
880
- root.replaceChildren(el("p", { class: "pc-msg err" }, "Failed to load page: " + String(e.message || e)));
1101
+ root.replaceChildren(el("p", { class: "pc-msg err" }, "Failed to load page: " + String((e && e.message) || e)));
881
1102
  }
882
1103
  }
883
- main();
1104
+ window.addEventListener("hashchange", renderPage);
1105
+ renderPage();
884
1106
  `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.26.0",
3
+ "version": "0.28.0",
4
4
  "description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -19,7 +19,7 @@
19
19
  },
20
20
  "repository": {
21
21
  "type": "git",
22
- "url": "git+https://github.com/jwulf/nano-ide.git",
22
+ "url": "git+https://github.com/nanobpm/nano-ide.git",
23
23
  "directory": "packages/urban"
24
24
  },
25
25
  "engines": {
@@ -65,12 +65,14 @@ function build(dbOverrides: Partial<PagesDataSource> = {}) {
65
65
  const db = fakeDb(dbOverrides);
66
66
  const readPage = async (path: string) => {
67
67
  if (path === "pages/home.page.json") return JSON.stringify({ title: "Home", nodes: [] });
68
+ if (path === "pages/epic.page.json") return JSON.stringify({ title: "Epic Coordination", nodes: [] });
68
69
  throw new Error("not found");
69
70
  };
70
71
  const routes = createPagesRoutes({ pagesDir: "pages", homePage: "home", sourceName: "app" }, {
71
72
  db,
72
73
  engine,
73
74
  readPage,
75
+ listPages: async () => ["home", "epic"],
74
76
  });
75
77
  const router = makeRouter(routes);
76
78
  return { router, calls };
@@ -94,6 +96,20 @@ test("GET / serves the renderer shell with the home marker", async () => {
94
96
  assert.doesNotMatch(res.body ?? "", /src="\/app\/runtime\.js"/);
95
97
  });
96
98
 
99
+ test("the shell styles the app through the shared --nano-* token contract", async () => {
100
+ const res = await dispatch("GET", "/");
101
+ const html = res.body ?? "";
102
+ // Body colours resolve through the console's token vocabulary, not hardcoded hex.
103
+ assert.match(html, /background:var\(--nano-app\)/);
104
+ assert.match(html, /color:var\(--nano-text\)/);
105
+ assert.match(html, /--pc-accent:var\(--nano-accent\)/);
106
+ // Self-contained defaults for standalone (CLI) runs: dark base + a light
107
+ // palette gated on data-appearance, and prefers-color-scheme only until themed.
108
+ assert.match(html, /--nano-accent:#8b5cf6/);
109
+ assert.match(html, /\[data-appearance="light"\]/);
110
+ assert.match(html, /:root:not\(\[data-appearance\]\)/);
111
+ });
112
+
97
113
  test("GET /app/runtime.js serves the renderer module", async () => {
98
114
  const res = await dispatch("GET", "/app/runtime.js");
99
115
  assert.match(res.headers?.["content-type"] ?? "", /javascript/);
@@ -104,6 +120,24 @@ test("GET /app/runtime.js serves the renderer module", async () => {
104
120
  assert.match(res.body ?? "", /function apiUrl\(u\)/);
105
121
  });
106
122
 
123
+ test("the renderer bridges the console theme over postMessage", async () => {
124
+ const res = await dispatch("GET", "/app/runtime.js");
125
+ const js = res.body ?? "";
126
+ // Announces readiness to the framing console (same-origin, posture A).
127
+ assert.match(js, /postMessage\(\{ type: "nano-app-ready" \}, window\.location\.origin\)/);
128
+ // Only trusts the framing parent, and only accepts nano-theme messages.
129
+ assert.match(js, /ev\.source === window\.parent/);
130
+ // Same-origin posture A: a cross-origin framer is rejected before applyTheme.
131
+ assert.match(js, /ev\.origin === window\.location\.origin/);
132
+ assert.match(js, /msg\.type !== "nano-theme"/);
133
+ // Mirrors the console appearance + lays --nano-* tokens onto :root; ignores
134
+ // any property outside the shared token namespace.
135
+ assert.match(js, /el\.dataset\.appearance = msg\.appearance/);
136
+ assert.match(js, /k\.startsWith\("--nano-"\)/);
137
+ assert.match(js, /el\.style\.setProperty\(k, v\)/);
138
+ });
139
+
140
+
107
141
  test("renderer preserves grid row-detail expansion across refreshes", async () => {
108
142
  // Regression: the 5s poll rebuilds the whole tbody, which used to collapse an open
109
143
  // detail row (e.g. the escalation-answer form). The client now tracks open rowKeys
@@ -180,8 +214,9 @@ test("renderer makes collapsible nodes persist their state across sessions", asy
180
214
  assert.match(js, /if \(!props\.collapsible\) return card;/);
181
215
  // Seed from defaultCollapsed the first time, then from persisted state.
182
216
  assert.match(js, /readCollapsed\(storageKey, !!props\.defaultCollapsed\)/);
183
- // Namespaced, per-node storage key.
184
- assert.match(js, /"pc:collapsed:" \+ HOME \+ ":" \+ \(node\.id/);
217
+ // Namespaced, per-node storage key — keyed by the *current* page id so two
218
+ // pages that reuse a node id don't share collapse state across navigation.
219
+ assert.match(js, /"pc:collapsed:" \+ CURRENT \+ ":" \+ \(node\.id/);
185
220
  // Persist on toggle.
186
221
  assert.match(js, /writeCollapsed\(storageKey, collapsed\)/);
187
222
  // Every storage access is wrapped so a throwing localStorage can't break the UI.
@@ -207,6 +242,77 @@ test("GET /app/pages/<id> returns the page json, 404 for unknown", async () => {
207
242
  assert.equal(miss.status, 404);
208
243
  });
209
244
 
245
+ test("GET /app/pages indexes the available pages with titles + home flag", async () => {
246
+ const res = await dispatch("GET", "/app/pages");
247
+ assert.equal(res.status, 200);
248
+ const body = JSON.parse(res.body ?? "{}");
249
+ assert.equal(body.home, "home");
250
+ const byId = Object.fromEntries((body.pages ?? []).map((p: { id: string }) => [p.id, p]));
251
+ assert.deepEqual(byId.home, { id: "home", title: "Home", home: true });
252
+ assert.deepEqual(byId.epic, { id: "epic", title: "Epic Coordination", home: false });
253
+ // The exact index path must not be shadowed by the /app/pages/<id> prefix route.
254
+ assert.equal((await dispatch("GET", "/app/pages/epic")).status, 200);
255
+ });
256
+
257
+ test("GET /app/pages is empty (not an error) when listing is unavailable", async () => {
258
+ // listPages is optional — without it the index is an empty list, not a 500.
259
+ const routes = createPagesRoutes({ pagesDir: "pages", homePage: "home", sourceName: "app" }, {
260
+ db: fakeDb(),
261
+ engine: fakeEngine().engine,
262
+ readPage: async () => "{}",
263
+ });
264
+ const res = await makeRouter(routes)(req("GET", "/app/pages"));
265
+ assert.equal(res.status, 200);
266
+ assert.deepEqual(JSON.parse(res.body ?? "{}"), { pages: [], home: "home" });
267
+ });
268
+
269
+ test("the renderer routes between pages by hash and tears down grid polls", async () => {
270
+ const res = await dispatch("GET", "/app/runtime.js");
271
+ const js = res.body ?? "";
272
+ // A page is chosen by the URL fragment (#/<page>), validated to a safe id, and
273
+ // re-rendered on hashchange — proxy-safe because the hash never hits the server.
274
+ assert.match(js, /const PAGE_ID = \/\^\[A-Za-z0-9_-/);
275
+ assert.match(js, /function safePageId\(value\)/);
276
+ assert.match(js, /function currentPage\(\)/);
277
+ assert.match(js, /try \{\s*const raw = decodeURIComponent/s);
278
+ assert.match(js, /catch \(e\) \{\s*return HOME;/s);
279
+ assert.match(js, /location\.hash/);
280
+ assert.match(js, /window\.addEventListener\("hashchange", renderPage\)/);
281
+ assert.match(js, /async function renderPage\(\)/);
282
+ // Navigation runs every registered disposer first, so a switched-away grid's
283
+ // poll interval + pc:refresh listener are removed rather than leaking forever.
284
+ assert.match(js, /const disposers = \[\]/);
285
+ assert.match(js, /function teardown\(\)/);
286
+ assert.match(js, /disposers\.push\(\(\) => document\.removeEventListener\("pc:refresh", refresh\)\)/);
287
+ assert.match(js, /disposers\.push\(\(\) => clearInterval\(timer\)\)/);
288
+ });
289
+
290
+ test("the renderer ships a nav node (menu bar / rail) with in-app + external links", async () => {
291
+ const res = await dispatch("GET", "/app/runtime.js");
292
+ const js = res.body ?? "";
293
+ // nav is a first-class node type.
294
+ assert.match(js, /nav: renderNav/);
295
+ assert.match(js, /function renderNav\(node\)/);
296
+ // "rail" variant renders a side rail; anything else is a horizontal bar.
297
+ assert.match(js, /p\.variant === "rail"/);
298
+ assert.match(js, /"pc-nav pc-rail"/);
299
+ assert.match(js, /"pc-nav pc-bar"/);
300
+ // A page item becomes an in-app hash link, active-highlighted on the current page.
301
+ assert.match(js, /const isPage = safePageId\(item\.page\)/);
302
+ assert.match(js, /attrs\.href = "#\/" \+ encodeURIComponent\(item\.page\)/);
303
+ assert.match(js, /item\.page === CURRENT/);
304
+ assert.match(js, /aria-current/);
305
+ // An external item is an http(s)-only, hardened new-tab link.
306
+ assert.match(js, /\^https\?:\\\/\\\//);
307
+ assert.match(js, /attrs\.rel = "noopener noreferrer"/);
308
+ // items:"auto" (or omitted) enumerates every page from the index endpoint.
309
+ assert.match(js, /getJSON\("\/app\/pages"\)/);
310
+ // A rail is lifted into a left <aside>; the rest flow into a content column.
311
+ assert.match(js, /pc-rail-wrap/);
312
+ assert.match(js, /pc-layout/);
313
+ assert.match(js, /pc-main-col/);
314
+ });
315
+
210
316
  test("GET /app/data/<source>/<table> returns rows", async () => {
211
317
  const res = await dispatch("GET", "/app/data/app/orders");
212
318
  assert.equal(res.status, 200);
@@ -43,6 +43,12 @@ export interface PagesDeps {
43
43
  engine: EngineClient;
44
44
  /** Read a page file; injectable for tests. */
45
45
  readPage(path: string): Promise<string>;
46
+ /**
47
+ * List the available page ids (the `*.page.json` basenames under `pagesDir`),
48
+ * powering the `/app/pages` index endpoint and a `nav` node's `items: "auto"`.
49
+ * Optional; when absent the index is empty. Injectable for tests.
50
+ */
51
+ listPages?(): Promise<string[]>;
46
52
  }
47
53
 
48
54
  /** A JavaScript-body response (the renderer module served at /app/runtime.js). */
@@ -64,7 +70,7 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
64
70
  const rowLimitRaw = opts.rowLimit ?? 200;
65
71
  const rowLimit = Number.isFinite(rowLimitRaw) ? Math.max(0, Math.floor(rowLimitRaw)) : 200;
66
72
  const sourceName = opts.sourceName ?? "app";
67
- const { db, engine, readPage } = deps;
73
+ const { db, engine, readPage, listPages } = deps;
68
74
 
69
75
  // The table-name whitelist is memoised: an Urban app runs its migrations at boot
70
76
  // (before serving), so the schema is stable for the process lifetime, and the renderer
@@ -113,6 +119,34 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
113
119
  handler: () => javascript(RENDERER_JS),
114
120
  });
115
121
 
122
+ // ── GET /app/pages (index of available pages) ───────────────────────────
123
+ // Powers multi-page navigation: a `nav` node with `items: "auto"` lists every
124
+ // page here, and it lets any client enumerate the app's screens. Each entry
125
+ // carries the page's own `title` (best-effort — an unreadable/!JSON page still
126
+ // appears by id) and a `home` flag. Registered as an exact path so it never
127
+ // shadows the `/app/pages/<id>` prefix route below.
128
+ routes.push({
129
+ method: "GET",
130
+ path: "/app/pages",
131
+ source: "surface:pages",
132
+ handler: async () => {
133
+ const ids = listPages ? await listPages().catch(() => []) : [];
134
+ const pages: { id: string; title: string; home: boolean }[] = [];
135
+ for (const id of ids) {
136
+ if (!/^[A-Za-z0-9_-]+$/.test(id)) continue;
137
+ let title = id;
138
+ try {
139
+ const doc = JSON.parse(await readPage(`${pagesDir}/${id}.page.json`));
140
+ if (isRecord(doc) && typeof doc.title === "string" && doc.title) title = doc.title;
141
+ } catch {
142
+ // Unreadable or non-JSON page: still list it by id.
143
+ }
144
+ pages.push({ id, title, home: id === homePage });
145
+ }
146
+ return json({ pages, home: homePage });
147
+ },
148
+ });
149
+
116
150
  // ── GET /app/pages/<id> ─────────────────────────────────────────────────
117
151
  routes.push({
118
152
  method: "GET",
@@ -324,6 +358,13 @@ export function mountPages(ctx: RuntimeContext, app: AppApi): PagesHandle {
324
358
  db: app.data.open(sourceName),
325
359
  engine: app.engine,
326
360
  readPage: (p) => ctx.host.readTextFile(p),
361
+ listPages: async () => {
362
+ const dir = opts.pagesDir ?? "pages";
363
+ const names = await ctx.host.listDir(dir).catch(() => []);
364
+ return names
365
+ .filter((n) => n.endsWith(".page.json"))
366
+ .map((n) => n.slice(0, -".page.json".length));
367
+ },
327
368
  });
328
369
  ctx.host.log("info", "pages surface mounted", { source: sourceName, home: opts.homePage ?? "home" });
329
370
  return {
@@ -353,47 +394,99 @@ function rendererShell(homePage: string): string {
353
394
  </html>`;
354
395
  }
355
396
 
397
+ // The app's stylesheet resolves every colour through the Nano console's
398
+ // semantic `--nano-*` token contract (see the Magikcraft/nano-bpm repo's
399
+ // `console/src/theme/tokens.css` — MIRROR: keep the palettes below in sync). When the app is embedded in the
400
+ // console it inherits nothing across the iframe boundary, so the console
401
+ // postMessages its resolved tokens + appearance and the runtime lays them onto
402
+ // this document's :root (see RENDERER_JS). Standalone (CLI on :3000) there is no
403
+ // console, so the defaults below stand on their own: dark by default, light when
404
+ // the OS asks for it and nothing has themed us. `--pc-*` are kept as thin
405
+ // aliases so existing class rules re-resolve through the shared tokens.
356
406
  const RENDERER_CSS = `
357
- :root { color-scheme: light dark; --pc-edge:#d0d0d8; --pc-accent:#3b5bdb; }
407
+ :root {
408
+ color-scheme: dark;
409
+ --nano-app:#0b0b10; --nano-panel:#10101a; --nano-raised:#16161f; --nano-inset:#08080c; --nano-hover:#1e1e2a;
410
+ --nano-edge:#24242f; --nano-edge-strong:#383848;
411
+ --nano-text:#f2f2f7; --nano-text-muted:#a3a3b2; --nano-text-faint:#6e6e80;
412
+ --nano-accent:#8b5cf6; --nano-accent-strong:#a78bfa; --nano-accent-2:#22d3ee; --nano-on-accent:#ffffff;
413
+ --nano-ok:#34d399; --nano-warn:#fbbf24; --nano-danger:#fb7185; --nano-info:#38bdf8;
414
+ }
415
+ :root[data-appearance="light"] {
416
+ color-scheme: light;
417
+ --nano-app:#f5f5f9; --nano-panel:#fdfdfe; --nano-raised:#ffffff; --nano-inset:#ededf3; --nano-hover:#e8e8f0;
418
+ --nano-edge:#e2e2ea; --nano-edge-strong:#c5c5d4;
419
+ --nano-text:#1a1a22; --nano-text-muted:#565664; --nano-text-faint:#8c8c9c;
420
+ --nano-accent:#7c3aed; --nano-accent-strong:#6d28d9; --nano-accent-2:#0891b2; --nano-on-accent:#ffffff;
421
+ --nano-ok:#059669; --nano-warn:#b45309; --nano-danger:#e11d48; --nano-info:#0369a1;
422
+ }
423
+ @media (prefers-color-scheme: light) {
424
+ /* Standalone only: follow the OS until the console (which sets data-appearance) themes us. */
425
+ :root:not([data-appearance]) {
426
+ color-scheme: light;
427
+ --nano-app:#f5f5f9; --nano-panel:#fdfdfe; --nano-raised:#ffffff; --nano-inset:#ededf3; --nano-hover:#e8e8f0;
428
+ --nano-edge:#e2e2ea; --nano-edge-strong:#c5c5d4;
429
+ --nano-text:#1a1a22; --nano-text-muted:#565664; --nano-text-faint:#8c8c9c;
430
+ --nano-accent:#7c3aed; --nano-accent-strong:#6d28d9; --nano-accent-2:#0891b2; --nano-on-accent:#ffffff;
431
+ --nano-ok:#059669; --nano-warn:#b45309; --nano-danger:#e11d48; --nano-info:#0369a1;
432
+ }
433
+ }
434
+ :root { --pc-edge:var(--nano-edge); --pc-accent:var(--nano-accent); }
358
435
  * { box-sizing: border-box; }
359
- body { margin:0; font:15px/1.5 system-ui,sans-serif; padding:2rem; max-width:64rem; margin-inline:auto; }
360
- .pc-empty { opacity:.6; }
436
+ body { margin:0; font:15px/1.5 system-ui,sans-serif; padding:2rem; max-width:64rem; margin-inline:auto; background:var(--nano-app); color:var(--nano-text); }
437
+ .pc-empty { color:var(--nano-text-faint); }
361
438
  .pc-heading { font-size:1.6rem; font-weight:650; margin:0 0 .25rem; }
362
- .pc-sub { opacity:.7; margin:.25rem 0 1rem; }
439
+ .pc-sub { color:var(--nano-text-muted); margin:.25rem 0 1rem; }
363
440
  .pc-body { margin:.5rem 0; }
364
- .pc-card { border:1px solid var(--pc-edge); border-radius:.6rem; padding:1rem 1.15rem; margin:1rem 0; }
441
+ .pc-card { border:1px solid var(--nano-edge); border-radius:.6rem; padding:1rem 1.15rem; margin:1rem 0; background:var(--nano-panel); }
365
442
  .pc-card h2 { font-size:1rem; margin:0 0 .75rem; }
366
443
  .pc-field { display:flex; flex-direction:column; gap:.25rem; margin-bottom:.6rem; }
367
- .pc-field label { font-size:.8rem; opacity:.75; }
368
- .pc-field input { padding:.5rem .6rem; border:1px solid var(--pc-edge); border-radius:.4rem; font:inherit; }
369
- .pc-btn { padding:.5rem .9rem; border:0; border-radius:.4rem; background:var(--pc-accent); color:#fff; font:inherit; cursor:pointer; }
444
+ .pc-field label { font-size:.8rem; color:var(--nano-text-muted); }
445
+ .pc-field input { padding:.5rem .6rem; border:1px solid var(--nano-edge); border-radius:.4rem; font:inherit; background:var(--nano-inset); color:var(--nano-text); }
446
+ .pc-btn { padding:.5rem .9rem; border:0; border-radius:.4rem; background:var(--nano-accent); color:var(--nano-on-accent); font:inherit; cursor:pointer; }
370
447
  .pc-btn:disabled { opacity:.5; cursor:default; }
371
448
  .pc-msg { font-size:.85rem; margin-top:.5rem; min-height:1.2em; }
372
- .pc-msg.err { color:#c0392b; }
373
- .pc-msg.ok { color:#2b8a3e; }
449
+ .pc-msg.err { color:var(--nano-danger); }
450
+ .pc-msg.ok { color:var(--nano-ok); }
374
451
  table.pc-grid { width:100%; border-collapse:collapse; font-size:.9rem; }
375
- table.pc-grid th, table.pc-grid td { text-align:left; padding:.4rem .6rem; border-bottom:1px solid var(--pc-edge); }
376
- table.pc-grid th { font-weight:600; opacity:.75; }
452
+ table.pc-grid th, table.pc-grid td { text-align:left; padding:.4rem .6rem; border-bottom:1px solid var(--nano-edge); }
453
+ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
377
454
  .pc-tabs { display:flex; gap:.5rem; margin-bottom:.75rem; }
378
- .pc-tab { padding:.35rem .8rem; border:1px solid var(--pc-edge); border-radius:.4rem; background:transparent; color:inherit; font:inherit; cursor:pointer; }
379
- .pc-tab.active { background:var(--pc-accent); color:#fff; border-color:var(--pc-accent); }
455
+ .pc-tab { padding:.35rem .8rem; border:1px solid var(--nano-edge); border-radius:.4rem; background:transparent; color:inherit; font:inherit; cursor:pointer; }
456
+ .pc-tab.active { background:var(--nano-accent); color:var(--nano-on-accent); border-color:var(--nano-accent); }
380
457
  .pc-btn-sm { padding:.25rem .55rem; font-size:.8rem; margin-right:.3rem; }
381
- .pc-chevron { background:transparent; color:inherit; border:1px solid var(--pc-edge); }
458
+ .pc-chevron { background:transparent; color:inherit; border:1px solid var(--nano-edge); }
382
459
  .pc-row-actions { white-space:nowrap; text-align:right; }
383
460
  .pc-detail { padding:.75rem .25rem; }
384
461
  .pc-detail-field { display:flex; gap:.5rem; font-size:.85rem; margin:.15rem 0; }
385
- .pc-detail-label { opacity:.7; min-width:8rem; }
386
- .pc-link { color:var(--pc-accent); }
462
+ .pc-detail-label { color:var(--nano-text-muted); min-width:8rem; }
463
+ .pc-link { color:var(--nano-accent); }
387
464
  .pc-child { margin:.6rem 0; }
388
- .pc-child-title { font-size:.8rem; font-weight:600; opacity:.7; margin-bottom:.25rem; }
389
- .pc-transcript { white-space:pre-wrap; max-height:22rem; overflow:auto; background:rgba(120,120,160,.08); padding:.5rem; border-radius:.4rem; font-size:.8rem; margin-top:.4rem; }
390
- .pc-subform { margin-top:.75rem; padding:.6rem; border:1px dashed var(--pc-edge); border-radius:.5rem; }
465
+ .pc-child-title { font-size:.8rem; font-weight:600; color:var(--nano-text-muted); margin-bottom:.25rem; }
466
+ .pc-transcript { white-space:pre-wrap; max-height:22rem; overflow:auto; background:var(--nano-inset); padding:.5rem; border-radius:.4rem; font-size:.8rem; margin-top:.4rem; }
467
+ .pc-subform { margin-top:.75rem; padding:.6rem; border:1px dashed var(--nano-edge); border-radius:.5rem; }
391
468
  .pc-subform-title { font-weight:600; font-size:.85rem; margin-bottom:.4rem; }
392
- .pc-prompt { font-size:.85rem; opacity:.8; margin-bottom:.4rem; white-space:pre-wrap; }
393
- .pc-textarea { width:100%; min-height:4rem; padding:.5rem; border:1px solid var(--pc-edge); border-radius:.4rem; font:inherit; }
469
+ .pc-prompt { font-size:.85rem; color:var(--nano-text-muted); margin-bottom:.4rem; white-space:pre-wrap; }
470
+ .pc-textarea { width:100%; min-height:4rem; padding:.5rem; border:1px solid var(--nano-edge); border-radius:.4rem; font:inherit; background:var(--nano-inset); color:var(--nano-text); }
394
471
  .pc-collapse-header { display:flex; align-items:center; gap:.5rem; width:100%; margin:0 0 .75rem; padding:0; background:transparent; border:0; color:inherit; font:inherit; font-size:1rem; font-weight:600; cursor:pointer; text-align:left; }
395
- .pc-chevron-inline { opacity:.6; font-size:.75rem; width:1em; }
472
+ .pc-chevron-inline { color:var(--nano-text-faint); font-size:.75rem; width:1em; }
396
473
  .pc-card-body[hidden] { display:none; }
474
+ /* Multi-page navigation: a horizontal menu bar or a vertical side rail. */
475
+ .pc-layout { display:flex; gap:1.5rem; align-items:flex-start; }
476
+ .pc-rail-wrap { flex:0 0 12rem; position:sticky; top:1rem; align-self:flex-start; }
477
+ .pc-main-col { flex:1 1 auto; min-width:0; }
478
+ .pc-nav-title { font-weight:650; }
479
+ .pc-bar { display:flex; align-items:center; gap:.6rem; flex-wrap:wrap; border-bottom:1px solid var(--nano-edge); padding-bottom:.6rem; margin-bottom:1.25rem; }
480
+ .pc-bar .pc-nav-title { margin-right:.5rem; }
481
+ .pc-bar .pc-nav-items { display:flex; gap:.25rem; flex-wrap:wrap; }
482
+ .pc-rail { display:flex; flex-direction:column; gap:.5rem; }
483
+ .pc-rail .pc-nav-title { margin-bottom:.25rem; padding:0 .7rem; }
484
+ .pc-rail .pc-nav-items { display:flex; flex-direction:column; gap:.15rem; }
485
+ .pc-nav-link { display:flex; align-items:center; gap:.4rem; padding:.4rem .7rem; border-radius:.4rem; color:var(--nano-text-muted); text-decoration:none; font-size:.9rem; }
486
+ .pc-nav-link:hover { background:var(--nano-hover); color:var(--nano-text); }
487
+ .pc-nav-link.active { background:var(--nano-accent); color:var(--nano-on-accent); }
488
+ .pc-nav-icon { line-height:1; }
489
+ .pc-nav-empty { color:var(--nano-text-faint); font-size:.85rem; padding:.4rem .7rem; }
397
490
  `;
398
491
 
399
492
  // The schema-driven browser renderer (ADR 0042 §3). Plain ES module string served at
@@ -404,6 +497,69 @@ const RENDERER_JS = String.raw`
404
497
  const root = document.getElementById("page");
405
498
  const HOME = root.dataset.home || "home";
406
499
 
500
+ // ── Multi-page routing (hash-based, reverse-proxy safe) ──────────────────
501
+ // Pages are selected by the URL fragment (#/<page>) so navigation never hits
502
+ // the server and works identically at the origin root and under the Nano
503
+ // console's /console/app-view/<name>/ path-prefixed proxy (the hash is never
504
+ // sent upstream). An empty/invalid hash falls back to the home page. Only a
505
+ // safe id charset is accepted so the fragment can't smuggle a path/URL.
506
+ const PAGE_ID = /^[A-Za-z0-9_-]+$/;
507
+ function safePageId(value) {
508
+ return typeof value === "string" && PAGE_ID.test(value);
509
+ }
510
+ function currentPage() {
511
+ try {
512
+ const raw = decodeURIComponent((location.hash || "").replace(/^#\/?/, ""));
513
+ return safePageId(raw) ? raw : HOME;
514
+ } catch (e) {
515
+ return HOME;
516
+ }
517
+ }
518
+ let CURRENT = currentPage();
519
+
520
+ // Per-render teardown. Each dataGrid registers its poll interval + pc:refresh
521
+ // listener here; navigating to another page runs every disposer before the new
522
+ // page renders, so a switched-away grid stops polling (otherwise every visited
523
+ // page would leave a live setInterval fetching forever).
524
+ const disposers = [];
525
+ function teardown() {
526
+ while (disposers.length) {
527
+ try { disposers.pop()(); } catch (e) { /* best-effort cleanup */ }
528
+ }
529
+ }
530
+
531
+ // Theme bridge for the Nano console embed. The app runs in a sandboxed,
532
+ // same-origin iframe, so the console's --nano-* custom properties and its
533
+ // data-appearance never cascade in. Instead the console postMessages its
534
+ // resolved tokens; we lay them onto this document's :root and mirror the
535
+ // appearance. Standalone (no console parent) nothing is posted, so the CSS
536
+ // defaults stand. We announce readiness so the console can push the theme even
537
+ // if its first message raced our listener install.
538
+ function applyTheme(msg) {
539
+ if (!msg || msg.type !== "nano-theme") return;
540
+ const el = document.documentElement;
541
+ if (msg.appearance === "light" || msg.appearance === "dark") el.dataset.appearance = msg.appearance;
542
+ const vars = msg.vars;
543
+ if (vars && typeof vars === "object") {
544
+ for (const [k, v] of Object.entries(vars)) {
545
+ // Only accept the shared token namespace; ignore anything else.
546
+ if (typeof k === "string" && k.startsWith("--nano-") && typeof v === "string") {
547
+ el.style.setProperty(k, v);
548
+ }
549
+ }
550
+ }
551
+ }
552
+ if (window.parent && window.parent !== window) {
553
+ window.addEventListener("message", (ev) => {
554
+ // Same-origin proxy (posture A): only trust a message from the framing
555
+ // parent AND from our own origin. ev.source alone is insufficient — a
556
+ // cross-origin page that frames a standalone run becomes window.parent, so
557
+ // an origin check is what actually pins the trust boundary.
558
+ if (ev.origin === window.location.origin && ev.source === window.parent) applyTheme(ev.data);
559
+ });
560
+ window.parent.postMessage({ type: "nano-app-ready" }, window.location.origin);
561
+ }
562
+
407
563
  // Resolve every app endpoint against where THIS module was actually served from,
408
564
  // so absolute-looking "/app/…" paths work both at the origin root (direct run,
409
565
  // e.g. the CLI on :3000) and under a path-prefixed reverse proxy (the Nano
@@ -820,15 +976,71 @@ function renderDataGrid(node) {
820
976
  }
821
977
  }
822
978
  document.addEventListener("pc:refresh", refresh);
979
+ disposers.push(() => document.removeEventListener("pc:refresh", refresh));
823
980
  // Skip the automatic poll while the user is typing in the grid — an explicit pc:refresh
824
981
  // (e.g. after submitting an answer) still runs — so rows never shift under an in-progress
825
- // answer and the detail input keeps focus across ticks.
826
- if (p.refreshMs && p.refreshMs > 0) setInterval(() => { if (!editingInGrid()) refresh(); }, p.refreshMs);
982
+ // answer and the detail input keeps focus across ticks. The interval is registered as a
983
+ // disposer so a page switch (hashchange → renderPage → teardown) stops it.
984
+ if (p.refreshMs && p.refreshMs > 0) {
985
+ const timer = setInterval(() => { if (!editingInGrid()) refresh(); }, p.refreshMs);
986
+ disposers.push(() => clearInterval(timer));
987
+ }
827
988
  refresh();
828
989
  return card;
829
990
  }
830
991
 
831
- const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid };
992
+ // A navigation node — a horizontal menu bar (variant "bar", default) or a
993
+ // vertical side rail (variant "rail"). Each item links either to another page
994
+ // ({ label, page } -> an in-app #/<page> hash link, highlighted when it's the
995
+ // current page) or to an external URL ({ label, href } -> a hardened new-tab
996
+ // link; only http(s) is honoured). With items:"auto" (or omitted) the nav
997
+ // lists every page from the /app/pages index, using each page's title.
998
+ function navLink(item) {
999
+ const isPage = safePageId(item.page);
1000
+ const isExt = !isPage && typeof item.href === "string" && /^https?:\/\//i.test(item.href);
1001
+ if (!isPage && !isExt) return null;
1002
+ const attrs = { class: "pc-nav-link" };
1003
+ if (isPage) {
1004
+ attrs.href = "#/" + encodeURIComponent(item.page);
1005
+ if (item.page === CURRENT) { attrs.class += " active"; attrs["aria-current"] = "page"; }
1006
+ } else {
1007
+ attrs.href = item.href; attrs.target = "_blank"; attrs.rel = "noopener noreferrer";
1008
+ }
1009
+ const kids = [];
1010
+ if (item.icon != null) kids.push(el("span", { class: "pc-nav-icon" }, String(item.icon)));
1011
+ kids.push(el("span", { class: "pc-nav-label" }, String(item.label != null ? item.label : (item.page || item.href))));
1012
+ return el("a", attrs, ...kids);
1013
+ }
1014
+ function fillNav(list, items) {
1015
+ list.replaceChildren();
1016
+ let n = 0;
1017
+ for (const item of items || []) {
1018
+ if (!item || typeof item !== "object") continue;
1019
+ const a = navLink(item);
1020
+ if (a) { list.append(a); n++; }
1021
+ }
1022
+ if (!n) list.append(el("span", { class: "pc-nav-empty" }, "No pages"));
1023
+ }
1024
+ function renderNav(node) {
1025
+ const p = node.props || {};
1026
+ const rail = p.variant === "rail";
1027
+ const nav = el("nav", { class: rail ? "pc-nav pc-rail" : "pc-nav pc-bar" });
1028
+ if (p.title != null) nav.append(el("div", { class: "pc-nav-title" }, String(p.title)));
1029
+ const list = el("div", { class: "pc-nav-items" });
1030
+ nav.append(list);
1031
+ const items = p.items;
1032
+ if (Array.isArray(items)) {
1033
+ fillNav(list, items);
1034
+ } else {
1035
+ // "auto" (or unspecified): enumerate every page and link to each.
1036
+ getJSON("/app/pages")
1037
+ .then((res) => fillNav(list, (res.pages || []).map((pg) => ({ label: pg.title || pg.id, page: pg.id }))))
1038
+ .catch(() => list.append(el("span", { class: "pc-nav-empty" }, "No pages")));
1039
+ }
1040
+ return nav;
1041
+ }
1042
+
1043
+ const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid, nav: renderNav };
832
1044
 
833
1045
  // Durable per-node UI state. localStorage is keyed by the home page id + node
834
1046
  // id so two grids on the same page (or the same grid across pages) don't clash,
@@ -883,7 +1095,7 @@ function makeCollapsible(node, card) {
883
1095
  chevron,
884
1096
  el("span", { class: "pc-collapse-title" }, titleText),
885
1097
  );
886
- const storageKey = "pc:collapsed:" + HOME + ":" + (node.id || titleText);
1098
+ const storageKey = "pc:collapsed:" + CURRENT + ":" + (node.id || titleText);
887
1099
  let collapsed = readCollapsed(storageKey, !!props.defaultCollapsed);
888
1100
  function apply() {
889
1101
  body.hidden = collapsed;
@@ -900,16 +1112,30 @@ function makeCollapsible(node, card) {
900
1112
  return container;
901
1113
  }
902
1114
 
903
- async function main() {
1115
+ // Render the page named by the current hash route. A rail nav is lifted into a
1116
+ // left <aside> and the rest of the nodes flow into a content column; otherwise
1117
+ // (bar nav or none) nodes render in document order. Re-runs on every hashchange,
1118
+ // tearing down the previous page's grid polls first.
1119
+ async function renderPage() {
1120
+ teardown();
1121
+ CURRENT = currentPage();
904
1122
  try {
905
- const doc = await getJSON("/app/pages/" + encodeURIComponent(HOME));
906
- if (doc.title) document.title = doc.title;
907
- root.replaceChildren(
908
- ...(doc.nodes || []).map((n) => makeCollapsible(n, (RENDERERS[n.type] || (() => el("div")))(n))),
909
- );
1123
+ const doc = await getJSON("/app/pages/" + encodeURIComponent(CURRENT));
1124
+ document.title = (doc && doc.title) || "Urban App";
1125
+ const nodes = (doc && doc.nodes) || [];
1126
+ const built = nodes.map((n) => ({ n, node: makeCollapsible(n, (RENDERERS[n.type] || (() => el("div")))(n)) }));
1127
+ const rail = built.find((b) => b.n.type === "nav" && b.n.props && b.n.props.variant === "rail");
1128
+ if (rail) {
1129
+ const col = el("div", { class: "pc-main-col" });
1130
+ for (const b of built) if (b !== rail) col.append(b.node);
1131
+ root.replaceChildren(el("div", { class: "pc-layout" }, el("aside", { class: "pc-rail-wrap" }, rail.node), col));
1132
+ } else {
1133
+ root.replaceChildren(...built.map((b) => b.node));
1134
+ }
910
1135
  } catch (e) {
911
- root.replaceChildren(el("p", { class: "pc-msg err" }, "Failed to load page: " + String(e.message || e)));
1136
+ root.replaceChildren(el("p", { class: "pc-msg err" }, "Failed to load page: " + String((e && e.message) || e)));
912
1137
  }
913
1138
  }
914
- main();
1139
+ window.addEventListener("hashchange", renderPage);
1140
+ renderPage();
915
1141
  `;