@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 {
|
|
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 {
|
|
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 {
|
|
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(--
|
|
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;
|
|
338
|
-
.pc-field input { padding:.5rem .6rem; border:1px solid var(--
|
|
339
|
-
.pc-btn { padding:.5rem .9rem; border:0; border-radius:.4rem; background:var(--
|
|
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
|
|
343
|
-
.pc-msg.ok { color
|
|
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(--
|
|
346
|
-
table.pc-grid th { font-weight:600;
|
|
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(--
|
|
349
|
-
.pc-tab.active { background:var(--
|
|
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(--
|
|
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 {
|
|
356
|
-
.pc-link { color:var(--
|
|
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;
|
|
359
|
-
.pc-transcript { white-space:pre-wrap; max-height:22rem; overflow:auto; background:
|
|
360
|
-
.pc-subform { margin-top:.75rem; padding:.6rem; border:1px dashed var(--
|
|
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;
|
|
363
|
-
.pc-textarea { width:100%; min-height:4rem; padding:.5rem; border:1px solid var(--
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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:" +
|
|
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
|
-
|
|
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(
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
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
|
-
|
|
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.
|
|
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/
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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(--
|
|
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;
|
|
368
|
-
.pc-field input { padding:.5rem .6rem; border:1px solid var(--
|
|
369
|
-
.pc-btn { padding:.5rem .9rem; border:0; border-radius:.4rem; background:var(--
|
|
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
|
|
373
|
-
.pc-msg.ok { color
|
|
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(--
|
|
376
|
-
table.pc-grid th { font-weight:600;
|
|
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(--
|
|
379
|
-
.pc-tab.active { background:var(--
|
|
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(--
|
|
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 {
|
|
386
|
-
.pc-link { color:var(--
|
|
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;
|
|
389
|
-
.pc-transcript { white-space:pre-wrap; max-height:22rem; overflow:auto; background:
|
|
390
|
-
.pc-subform { margin-top:.75rem; padding:.6rem; border:1px dashed var(--
|
|
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;
|
|
393
|
-
.pc-textarea { width:100%; min-height:4rem; padding:.5rem; border:1px solid var(--
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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:" +
|
|
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
|
-
|
|
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(
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
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
|
-
|
|
1139
|
+
window.addEventListener("hashchange", renderPage);
|
|
1140
|
+
renderPage();
|
|
915
1141
|
`;
|