@nanobpm/urban 0.27.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 {
@@ -400,6 +437,22 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
400
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; }
401
438
  .pc-chevron-inline { color:var(--nano-text-faint); font-size:.75rem; width:1em; }
402
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; }
403
456
  `;
404
457
  // The schema-driven browser renderer (ADR 0042 §3). Plain ES module string served at
405
458
  // /app/runtime.js — it does NOT ship Craft.js (authoring is console-side only). It
@@ -409,6 +462,37 @@ const RENDERER_JS = String.raw `
409
462
  const root = document.getElementById("page");
410
463
  const HOME = root.dataset.home || "home";
411
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
+
412
496
  // Theme bridge for the Nano console embed. The app runs in a sandboxed,
413
497
  // same-origin iframe, so the console's --nano-* custom properties and its
414
498
  // data-appearance never cascade in. Instead the console postMessages its
@@ -857,15 +941,71 @@ function renderDataGrid(node) {
857
941
  }
858
942
  }
859
943
  document.addEventListener("pc:refresh", refresh);
944
+ disposers.push(() => document.removeEventListener("pc:refresh", refresh));
860
945
  // Skip the automatic poll while the user is typing in the grid — an explicit pc:refresh
861
946
  // (e.g. after submitting an answer) still runs — so rows never shift under an in-progress
862
- // answer and the detail input keeps focus across ticks.
863
- 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
+ }
864
953
  refresh();
865
954
  return card;
866
955
  }
867
956
 
868
- 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 };
869
1009
 
870
1010
  // Durable per-node UI state. localStorage is keyed by the home page id + node
871
1011
  // id so two grids on the same page (or the same grid across pages) don't clash,
@@ -920,7 +1060,7 @@ function makeCollapsible(node, card) {
920
1060
  chevron,
921
1061
  el("span", { class: "pc-collapse-title" }, titleText),
922
1062
  );
923
- const storageKey = "pc:collapsed:" + HOME + ":" + (node.id || titleText);
1063
+ const storageKey = "pc:collapsed:" + CURRENT + ":" + (node.id || titleText);
924
1064
  let collapsed = readCollapsed(storageKey, !!props.defaultCollapsed);
925
1065
  function apply() {
926
1066
  body.hidden = collapsed;
@@ -937,16 +1077,30 @@ function makeCollapsible(node, card) {
937
1077
  return container;
938
1078
  }
939
1079
 
940
- 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();
941
1087
  try {
942
- const doc = await getJSON("/app/pages/" + encodeURIComponent(HOME));
943
- if (doc.title) document.title = doc.title;
944
- root.replaceChildren(
945
- ...(doc.nodes || []).map((n) => makeCollapsible(n, (RENDERERS[n.type] || (() => el("div")))(n))),
946
- );
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
+ }
947
1100
  } catch (e) {
948
- 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)));
949
1102
  }
950
1103
  }
951
- main();
1104
+ window.addEventListener("hashchange", renderPage);
1105
+ renderPage();
952
1106
  `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.27.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",
@@ -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 };
@@ -212,8 +214,9 @@ test("renderer makes collapsible nodes persist their state across sessions", asy
212
214
  assert.match(js, /if \(!props\.collapsible\) return card;/);
213
215
  // Seed from defaultCollapsed the first time, then from persisted state.
214
216
  assert.match(js, /readCollapsed\(storageKey, !!props\.defaultCollapsed\)/);
215
- // Namespaced, per-node storage key.
216
- 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/);
217
220
  // Persist on toggle.
218
221
  assert.match(js, /writeCollapsed\(storageKey, collapsed\)/);
219
222
  // Every storage access is wrapped so a throwing localStorage can't break the UI.
@@ -239,6 +242,77 @@ test("GET /app/pages/<id> returns the page json, 404 for unknown", async () => {
239
242
  assert.equal(miss.status, 404);
240
243
  });
241
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
+
242
316
  test("GET /app/data/<source>/<table> returns rows", async () => {
243
317
  const res = await dispatch("GET", "/app/data/app/orders");
244
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 {
@@ -430,6 +471,22 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
430
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; }
431
472
  .pc-chevron-inline { color:var(--nano-text-faint); font-size:.75rem; width:1em; }
432
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; }
433
490
  `;
434
491
 
435
492
  // The schema-driven browser renderer (ADR 0042 §3). Plain ES module string served at
@@ -440,6 +497,37 @@ const RENDERER_JS = String.raw`
440
497
  const root = document.getElementById("page");
441
498
  const HOME = root.dataset.home || "home";
442
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
+
443
531
  // Theme bridge for the Nano console embed. The app runs in a sandboxed,
444
532
  // same-origin iframe, so the console's --nano-* custom properties and its
445
533
  // data-appearance never cascade in. Instead the console postMessages its
@@ -888,15 +976,71 @@ function renderDataGrid(node) {
888
976
  }
889
977
  }
890
978
  document.addEventListener("pc:refresh", refresh);
979
+ disposers.push(() => document.removeEventListener("pc:refresh", refresh));
891
980
  // Skip the automatic poll while the user is typing in the grid — an explicit pc:refresh
892
981
  // (e.g. after submitting an answer) still runs — so rows never shift under an in-progress
893
- // answer and the detail input keeps focus across ticks.
894
- 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
+ }
895
988
  refresh();
896
989
  return card;
897
990
  }
898
991
 
899
- 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 };
900
1044
 
901
1045
  // Durable per-node UI state. localStorage is keyed by the home page id + node
902
1046
  // id so two grids on the same page (or the same grid across pages) don't clash,
@@ -951,7 +1095,7 @@ function makeCollapsible(node, card) {
951
1095
  chevron,
952
1096
  el("span", { class: "pc-collapse-title" }, titleText),
953
1097
  );
954
- const storageKey = "pc:collapsed:" + HOME + ":" + (node.id || titleText);
1098
+ const storageKey = "pc:collapsed:" + CURRENT + ":" + (node.id || titleText);
955
1099
  let collapsed = readCollapsed(storageKey, !!props.defaultCollapsed);
956
1100
  function apply() {
957
1101
  body.hidden = collapsed;
@@ -968,16 +1112,30 @@ function makeCollapsible(node, card) {
968
1112
  return container;
969
1113
  }
970
1114
 
971
- 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();
972
1122
  try {
973
- const doc = await getJSON("/app/pages/" + encodeURIComponent(HOME));
974
- if (doc.title) document.title = doc.title;
975
- root.replaceChildren(
976
- ...(doc.nodes || []).map((n) => makeCollapsible(n, (RENDERERS[n.type] || (() => el("div")))(n))),
977
- );
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
+ }
978
1135
  } catch (e) {
979
- 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)));
980
1137
  }
981
1138
  }
982
- main();
1139
+ window.addEventListener("hashchange", renderPage);
1140
+ renderPage();
983
1141
  `;