@nanobpm/urban 0.72.2 → 0.73.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.
- package/dist/runtime/core/modules/pages.js +10 -0
- package/dist/runtime/core/modules/runtime.gen.js +1 -1
- package/dist/runtime/core/page-nodes.d.ts +12 -0
- package/dist/runtime/core/page-nodes.js +32 -0
- package/package.json +1 -1
- package/src/runtime/browser/runtime.browser.js +101 -9
- package/src/runtime/browser/runtime.browser.test.ts +119 -2
- package/src/runtime/core/modules/pages.ts +10 -0
- package/src/runtime/core/modules/runtime.gen.ts +1 -1
- package/src/runtime/core/page-nodes.ts +38 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page-node types this runtime renders that are NOT (yet) in the canonical schema.
|
|
3
|
+
*
|
|
4
|
+
* When a token here lands in `@nanobpm/nano-app-schema`'s `PAGE_NODE_TYPES` and this
|
|
5
|
+
* package's dep is bumped, drop it from this list (and, once the list is empty, delete
|
|
6
|
+
* this module and re-export `PAGE_NODE_TYPES`/`PageNodeType` straight from the schema).
|
|
7
|
+
*/
|
|
8
|
+
export declare const LOCAL_PAGE_NODE_TYPES: readonly ["appView"];
|
|
9
|
+
/** The full set of page-node types the browser renderer draws: schema ∪ local bridge. */
|
|
10
|
+
export declare const PAGE_NODE_TYPES: readonly ["text", "nav", "actionForm", "dataGrid", "prose", "button", "appView"];
|
|
11
|
+
/** A node type the composed-page renderer can draw — one of {@link PAGE_NODE_TYPES}. */
|
|
12
|
+
export type PageNodeType = (typeof PAGE_NODE_TYPES)[number];
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// The page-node type registry the browser renderer draws — the schema's canonical
|
|
2
|
+
// set, extended locally with the node types this runtime ships ahead of the schema.
|
|
3
|
+
//
|
|
4
|
+
// The App page-node contract is owned by @nanobpm/nano-app-schema (ADR 0027): its
|
|
5
|
+
// `PAGE_NODE_TYPES` is the canonical registry the Console's Page Composer binds to.
|
|
6
|
+
// This runtime's browser renderer must draw exactly that set — a schema type with
|
|
7
|
+
// no renderer (or a renderer with no schema type) renders an accepted node as a
|
|
8
|
+
// blank <div>, the silent failure #416 fixed for `appView`.
|
|
9
|
+
//
|
|
10
|
+
// `appView` (ADR 0057 — an embedded App View iframe) ships in this runtime *before*
|
|
11
|
+
// it lands in the published schema. Rather than silently diverge, we bridge it here
|
|
12
|
+
// — the same "mirror locally until the canonical schema folds it in, then delete the
|
|
13
|
+
// bridge and re-export" pattern used for `network` / `terminalStatuses` in
|
|
14
|
+
// `manifest.ts` (No Drift Surfaces). This module is the single source of truth for
|
|
15
|
+
// the extended set: the browser renderer's `RENDERERS satisfies Record<PageNodeType,
|
|
16
|
+
// Renderer>` lock and the runtime drift test both consume `PAGE_NODE_TYPES` from
|
|
17
|
+
// here, so the renderer registry and the type union cannot drift.
|
|
18
|
+
import { PAGE_NODE_TYPES as SCHEMA_PAGE_NODE_TYPES } from "@nanobpm/nano-app-schema";
|
|
19
|
+
/**
|
|
20
|
+
* Page-node types this runtime renders that are NOT (yet) in the canonical schema.
|
|
21
|
+
*
|
|
22
|
+
* When a token here lands in `@nanobpm/nano-app-schema`'s `PAGE_NODE_TYPES` and this
|
|
23
|
+
* package's dep is bumped, drop it from this list (and, once the list is empty, delete
|
|
24
|
+
* this module and re-export `PAGE_NODE_TYPES`/`PageNodeType` straight from the schema).
|
|
25
|
+
*/
|
|
26
|
+
export const LOCAL_PAGE_NODE_TYPES = [
|
|
27
|
+
// #416 / ADR 0057: an embedded App View — an <iframe> hosting a self-contained view
|
|
28
|
+
// document (e.g. the agent cockpit's embed.html) served alongside the page.
|
|
29
|
+
"appView",
|
|
30
|
+
];
|
|
31
|
+
/** The full set of page-node types the browser renderer draws: schema ∪ local bridge. */
|
|
32
|
+
export const PAGE_NODE_TYPES = [...SCHEMA_PAGE_NODE_TYPES, ...LOCAL_PAGE_NODE_TYPES];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.73.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",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// That lets the renderer functions and the RENDERERS registry be imported and
|
|
17
17
|
// unit-tested with a fake DOM, and lets the string-generation build load it.
|
|
18
18
|
|
|
19
|
-
/** @typedef {import("
|
|
19
|
+
/** @typedef {import("../core/page-nodes.ts").PageNodeType} PageNodeType */
|
|
20
20
|
/**
|
|
21
21
|
* A page node from a `*.page.json` document. `props` is an open, author-supplied
|
|
22
22
|
* bag whose shape is owned/validated by `@nanobpm/nano-app-schema`, so it is
|
|
@@ -2532,16 +2532,108 @@ function renderNav(node) {
|
|
|
2532
2532
|
return nav;
|
|
2533
2533
|
}
|
|
2534
2534
|
|
|
2535
|
+
// A page-relative URL that resolves correctly whether the app is served at the
|
|
2536
|
+
// origin root (CLI on :3000) or under the Nano console's path-prefixed App-View
|
|
2537
|
+
// proxy (/console/app-view/<name>/). Same reasoning as the shell's `badgeHref`
|
|
2538
|
+
// and mount.js's reportUrl handling: a root-absolute "/x" would escape the proxy
|
|
2539
|
+
// prefix and hit the CONSOLE origin, so a single leading slash is stripped to
|
|
2540
|
+
// rebase onto the mount root; an already document-relative ("./x", "x") or an
|
|
2541
|
+
// absolute ("http(s)://…") / protocol-relative ("//host") URL is left untouched.
|
|
2542
|
+
// The result becomes an <iframe> src, so a scheme URL is only honoured when it is
|
|
2543
|
+
// http/https: a hostile/malformed page doc could otherwise smuggle an executable
|
|
2544
|
+
// `javascript:`/`data:`/`vbscript:` src, so any other scheme is rejected ("" →
|
|
2545
|
+
// caller omits the attribute). Input is trimmed and ASCII tab/newline stripped
|
|
2546
|
+
// FIRST (browsers strip those before parsing a URL, so `java\tscript:` would still
|
|
2547
|
+
// execute) so scheme detection cannot be bypassed by embedded whitespace.
|
|
2548
|
+
/** @param {string} url */
|
|
2549
|
+
function baseRelativeUrl(url) {
|
|
2550
|
+
if (typeof url !== "string") return "";
|
|
2551
|
+
const cleaned = url.replace(/[\t\n\r]/g, "").trim();
|
|
2552
|
+
if (cleaned === "") return "";
|
|
2553
|
+
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(cleaned);
|
|
2554
|
+
if (scheme) {
|
|
2555
|
+
const proto = scheme[1].toLowerCase();
|
|
2556
|
+
return proto === "http" || proto === "https" ? cleaned : ""; // reject javascript:/data:/vbscript:/file:/…
|
|
2557
|
+
}
|
|
2558
|
+
if (cleaned.startsWith("//")) return cleaned; // protocol-relative
|
|
2559
|
+
if (cleaned.startsWith("/")) return cleaned.slice(1); // root-absolute → document-relative (rebase onto mount root)
|
|
2560
|
+
return cleaned; // already relative
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
// `appView` (ADR 0057 / #416): mount an embedded App View — an <iframe> hosting a
|
|
2564
|
+
// self-contained view document (e.g. the agent cockpit's embed.html) served
|
|
2565
|
+
// alongside the page. This is the runtime counterpart of the console's App-View
|
|
2566
|
+
// surface: the SAME embed document renders here (in the app's own UI) and, when the
|
|
2567
|
+
// console embeds the app, there. Before this renderer existed an `appView` node fell
|
|
2568
|
+
// through to a blank <div> (RENDERERS[n.type] || (() => el("div"))), so an entire
|
|
2569
|
+
// embedded view — the live agent cockpit — was SILENTLY invisible with no error.
|
|
2570
|
+
/** @param {PageNode} node */
|
|
2571
|
+
function renderAppView(node) {
|
|
2572
|
+
const p = node.props || {};
|
|
2573
|
+
const title = p.title != null ? String(p.title) : "";
|
|
2574
|
+
const fill = p.fill === true;
|
|
2575
|
+
const src = baseRelativeUrl(typeof p.embed === "string" ? p.embed : "");
|
|
2576
|
+
|
|
2577
|
+
const frame = /** @type {HTMLIFrameElement} */ (
|
|
2578
|
+
el("iframe", {
|
|
2579
|
+
class: "pc-appview-frame",
|
|
2580
|
+
// Base-relative so the embed resolves under both the app root and the console
|
|
2581
|
+
// App-View proxy prefix (see baseRelativeUrl). Omitted (about:blank) when no
|
|
2582
|
+
// usable `embed` is declared — an empty `src=""` resolves to the current
|
|
2583
|
+
// document URL in browsers, risking a recursive self-embed. The node still
|
|
2584
|
+
// renders its chrome rather than throwing.
|
|
2585
|
+
src: src || undefined,
|
|
2586
|
+
title: title || "App view",
|
|
2587
|
+
loading: "lazy",
|
|
2588
|
+
referrerpolicy: "same-origin",
|
|
2589
|
+
})
|
|
2590
|
+
);
|
|
2591
|
+
|
|
2592
|
+
// Forward the App-View endpoint config to the embed's own window (ADR 0057:
|
|
2593
|
+
// embed.html reads `window.__NANO_APP_VIEW__`). The console injects this when IT
|
|
2594
|
+
// hosts the embed; here — the same-origin app render — mount.js already defaults
|
|
2595
|
+
// every endpoint to the app's own origin, so the cockpit is fully functional with
|
|
2596
|
+
// nothing injected. We seed the frame's window on load only to thread through
|
|
2597
|
+
// explicit per-node overrides (props.reportUrl / relayUrl / transcriptsUrl), so a
|
|
2598
|
+
// page can point the embed at a non-default endpoint without editing the embed
|
|
2599
|
+
// document. Best-effort and guarded: a cross-origin embed is left to its defaults.
|
|
2600
|
+
/** @type {Record<string, string>} */
|
|
2601
|
+
const overrides = {};
|
|
2602
|
+
if (typeof p.reportUrl === "string") overrides.reportUrl = p.reportUrl;
|
|
2603
|
+
if (typeof p.relayUrl === "string") overrides.relayUrl = p.relayUrl;
|
|
2604
|
+
if (typeof p.transcriptsUrl === "string") overrides.transcriptsUrl = p.transcriptsUrl;
|
|
2605
|
+
frame.addEventListener("load", () => {
|
|
2606
|
+
try {
|
|
2607
|
+
const w = /** @type {any} */ (frame.contentWindow);
|
|
2608
|
+
if (w) {
|
|
2609
|
+
w.__NANO_APP_VIEW__ = {
|
|
2610
|
+
...(w.__NANO_APP_VIEW__ || {}),
|
|
2611
|
+
host: (typeof location !== "undefined" && location.origin) || "",
|
|
2612
|
+
...overrides,
|
|
2613
|
+
};
|
|
2614
|
+
}
|
|
2615
|
+
} catch (_e) {
|
|
2616
|
+
// Cross-origin embed: we cannot (and need not) touch its window.
|
|
2617
|
+
}
|
|
2618
|
+
});
|
|
2619
|
+
|
|
2620
|
+
const section = el("section", { class: "pc-appview" + (fill ? " pc-appview-fill" : "") });
|
|
2621
|
+
if (title) section.append(el("div", { class: "pc-appview-title" }, title));
|
|
2622
|
+
section.append(frame);
|
|
2623
|
+
return section;
|
|
2624
|
+
}
|
|
2625
|
+
|
|
2535
2626
|
// The browser renderer's dispatch table: the runtime source of truth for which
|
|
2536
2627
|
// page-node types can be drawn. `@satisfies Record<PageNodeType, Renderer>` locks
|
|
2537
|
-
// it, at compile time, to
|
|
2538
|
-
//
|
|
2539
|
-
//
|
|
2540
|
-
//
|
|
2541
|
-
//
|
|
2542
|
-
// string
|
|
2628
|
+
// it, at compile time, to the shared `PAGE_NODE_TYPES` registry (`../core/page-nodes.ts`
|
|
2629
|
+
// — the canonical @nanobpm/nano-app-schema set, extended with the node types this
|
|
2630
|
+
// runtime ships ahead of the schema, e.g. `appView` #416). Adding a page-node type
|
|
2631
|
+
// without a renderer here (or vice-versa) is now a type error, so the table can no
|
|
2632
|
+
// longer silently drift from the registry (this replaces the #290 regex guard, which
|
|
2633
|
+
// had to string-scrape these keys precisely because this used to be a string). The
|
|
2634
|
+
// drift is also covered by a real-import unit test.
|
|
2543
2635
|
/** @satisfies {Record<PageNodeType, Renderer>} */
|
|
2544
|
-
const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid, prose: renderProse, nav: renderNav, button: renderButton };
|
|
2636
|
+
const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid, prose: renderProse, nav: renderNav, button: renderButton, appView: renderAppView };
|
|
2545
2637
|
|
|
2546
2638
|
// Durable per-node UI state. localStorage is keyed by the home page id + node
|
|
2547
2639
|
// id so two grids on the same page (or the same grid across pages) don't clash,
|
|
@@ -2706,4 +2798,4 @@ function boot() {
|
|
|
2706
2798
|
// renderer functions / RENDERERS registry can be imported directly.
|
|
2707
2799
|
if (typeof document !== "undefined" && document.getElementById("page")) boot();
|
|
2708
2800
|
|
|
2709
|
-
export { RENDERERS, renderText, renderButton, renderProse, renderNav, navLink, wireNavBadge, applyNavBadge, teardown, fmtCellValue, gridCell, buildDetailForm, evalDetailCondition, normalizeDetailOptions, chevronToggle, setChevronOpen };
|
|
2801
|
+
export { RENDERERS, renderText, renderButton, renderProse, renderNav, renderAppView, navLink, wireNavBadge, applyNavBadge, teardown, fmtCellValue, gridCell, buildDetailForm, evalDetailCondition, normalizeDetailOptions, chevronToggle, setChevronOpen };
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
// a real import, and adds the first true renderer-level unit coverage.
|
|
7
7
|
import { test } from "node:test";
|
|
8
8
|
import assert from "node:assert/strict";
|
|
9
|
-
import { PAGE_NODE_TYPES } from "
|
|
10
|
-
import { RENDERERS, renderText, navLink, wireNavBadge, applyNavBadge, teardown, fmtCellValue, gridCell } from "./runtime.browser.js";
|
|
9
|
+
import { PAGE_NODE_TYPES } from "../core/page-nodes.ts";
|
|
10
|
+
import { RENDERERS, renderText, renderAppView, navLink, wireNavBadge, applyNavBadge, teardown, fmtCellValue, gridCell } from "./runtime.browser.js";
|
|
11
11
|
|
|
12
12
|
// ── Minimal fake DOM ────────────────────────────────────────────────────────
|
|
13
13
|
// Just enough of the Element/Document surface that el()/renderText touch:
|
|
@@ -77,6 +77,16 @@ function installFakeDom(): () => void {
|
|
|
77
77
|
};
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// A renderer statically returns HTMLElement but at runtime produces a FakeElement
|
|
81
|
+
// (the installed fake `document.createElement`). Narrow to FakeElement so a test can
|
|
82
|
+
// walk `.children` — without a banned `as` cast — by asserting the runtime shape.
|
|
83
|
+
function asFake(node: unknown): FakeElement {
|
|
84
|
+
if (!(node instanceof FakeElement)) {
|
|
85
|
+
throw new Error("expected a FakeElement (is the fake DOM installed?)");
|
|
86
|
+
}
|
|
87
|
+
return node;
|
|
88
|
+
}
|
|
89
|
+
|
|
80
90
|
test("#291: RENDERERS is a real importable registry keyed exactly by PAGE_NODE_TYPES", () => {
|
|
81
91
|
// The drift lock is enforced at compile time by `RENDERERS satisfies
|
|
82
92
|
// Record<PageNodeType, Renderer>` in the source; this asserts the same at
|
|
@@ -117,6 +127,113 @@ test("#291: renderText degrades to empty text (never throws) on a missing text p
|
|
|
117
127
|
assert.equal(node.textContent, "");
|
|
118
128
|
});
|
|
119
129
|
|
|
130
|
+
test("#416: appView is a rendered node type (registry + bridged PAGE_NODE_TYPES)", () => {
|
|
131
|
+
// The whole point of #416: an `appView` page node must have a real renderer, not
|
|
132
|
+
// fall through to a blank <div>. It is in the RENDERERS registry and — since it
|
|
133
|
+
// ships here ahead of the canonical schema — in the locally-bridged PAGE_NODE_TYPES.
|
|
134
|
+
assert.ok(PAGE_NODE_TYPES.includes("appView"), "appView must be in the bridged PAGE_NODE_TYPES");
|
|
135
|
+
assert.equal(typeof RENDERERS.appView, "function", "appView must have a renderer");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("#416: renderAppView mounts a base-relative iframe honoring embed/title/fill", (t) => {
|
|
139
|
+
t.after(installFakeDom());
|
|
140
|
+
const section = asFake(
|
|
141
|
+
renderAppView({
|
|
142
|
+
type: "appView",
|
|
143
|
+
props: { title: "Agent networks", embed: "./embed.html", fill: true },
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
146
|
+
assert.equal(section.tagName, "SECTION");
|
|
147
|
+
// fill → the tall flex container variant so the embed gets viewport height.
|
|
148
|
+
assert.equal(section.className, "pc-appview pc-appview-fill");
|
|
149
|
+
|
|
150
|
+
const frame = section.children.find(
|
|
151
|
+
(c): c is FakeElement => c instanceof FakeElement && c.tagName === "IFRAME",
|
|
152
|
+
);
|
|
153
|
+
assert.ok(frame, "an <iframe> must be mounted");
|
|
154
|
+
// The iframe src is the node's embed, left document-relative so it resolves under
|
|
155
|
+
// both the app root and the console App-View proxy prefix.
|
|
156
|
+
assert.equal(frame.getAttribute("src"), "./embed.html");
|
|
157
|
+
assert.equal(frame.getAttribute("title"), "Agent networks");
|
|
158
|
+
assert.equal(frame.className, "pc-appview-frame");
|
|
159
|
+
|
|
160
|
+
// The title also renders as a visible label above the frame.
|
|
161
|
+
const label = section.children.find(
|
|
162
|
+
(c): c is FakeElement => c instanceof FakeElement && c.className === "pc-appview-title",
|
|
163
|
+
);
|
|
164
|
+
assert.ok(label, "the title renders as a label");
|
|
165
|
+
assert.equal(label.textContent, "Agent networks");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("#416: renderAppView rebases a root-absolute embed onto the mount root", (t) => {
|
|
169
|
+
t.after(installFakeDom());
|
|
170
|
+
// A root-absolute "/embed.html" would escape the console proxy prefix and hit the
|
|
171
|
+
// console origin; the renderer strips the single leading slash to rebase it.
|
|
172
|
+
const section = asFake(renderAppView({ type: "appView", props: { embed: "/embed.html" } }));
|
|
173
|
+
const frame = section.children.find(
|
|
174
|
+
(c): c is FakeElement => c instanceof FakeElement && c.tagName === "IFRAME",
|
|
175
|
+
);
|
|
176
|
+
assert.ok(frame);
|
|
177
|
+
assert.equal(frame.getAttribute("src"), "embed.html");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("#416: renderAppView is non-fill by default and never throws on missing props", (t) => {
|
|
181
|
+
t.after(installFakeDom());
|
|
182
|
+
const section = asFake(renderAppView({ type: "appView", props: {} }));
|
|
183
|
+
assert.equal(section.tagName, "SECTION");
|
|
184
|
+
assert.equal(section.className, "pc-appview");
|
|
185
|
+
const frame = section.children.find(
|
|
186
|
+
(c): c is FakeElement => c instanceof FakeElement && c.tagName === "IFRAME",
|
|
187
|
+
);
|
|
188
|
+
assert.ok(frame, "an <iframe> is still mounted with no embed");
|
|
189
|
+
// No usable embed → src is OMITTED (about:blank), not src="" (which resolves to
|
|
190
|
+
// the current document URL in browsers and risks a recursive self-embed).
|
|
191
|
+
assert.equal(frame.getAttribute("src"), null);
|
|
192
|
+
// No title → no label element rendered.
|
|
193
|
+
const label = section.children.find(
|
|
194
|
+
(c): c is FakeElement => c instanceof FakeElement && c.className === "pc-appview-title",
|
|
195
|
+
);
|
|
196
|
+
assert.equal(label, undefined);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("#416: renderAppView neutralizes a dangerous-scheme embed (no executable iframe src)", (t) => {
|
|
200
|
+
t.after(installFakeDom());
|
|
201
|
+
// A hostile/malformed page doc must not be able to smuggle an executable iframe
|
|
202
|
+
// src via a javascript:/data:/vbscript: embed, nor bypass the check with embedded
|
|
203
|
+
// whitespace/tabs (browsers strip those before parsing the URL).
|
|
204
|
+
for (const embed of [
|
|
205
|
+
"javascript:alert(1)",
|
|
206
|
+
"data:text/html,<script>alert(1)</script>",
|
|
207
|
+
"vbscript:msgbox(1)",
|
|
208
|
+
" javascript:alert(1) ",
|
|
209
|
+
"java\tscript:alert(1)",
|
|
210
|
+
"JAVASCRIPT:alert(1)",
|
|
211
|
+
]) {
|
|
212
|
+
const section = asFake(renderAppView({ type: "appView", props: { embed } }));
|
|
213
|
+
const frame = section.children.find(
|
|
214
|
+
(c): c is FakeElement => c instanceof FakeElement && c.tagName === "IFRAME",
|
|
215
|
+
);
|
|
216
|
+
assert.ok(frame, `an <iframe> is still mounted for ${JSON.stringify(embed)}`);
|
|
217
|
+
assert.equal(
|
|
218
|
+
frame.getAttribute("src"),
|
|
219
|
+
null,
|
|
220
|
+
`dangerous embed ${JSON.stringify(embed)} must not become an iframe src`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("#416: renderAppView keeps a legit http(s) embed and trims surrounding whitespace", (t) => {
|
|
226
|
+
t.after(installFakeDom());
|
|
227
|
+
const section = asFake(
|
|
228
|
+
renderAppView({ type: "appView", props: { embed: " https://example.test/embed.html " } }),
|
|
229
|
+
);
|
|
230
|
+
const frame = section.children.find(
|
|
231
|
+
(c): c is FakeElement => c instanceof FakeElement && c.tagName === "IFRAME",
|
|
232
|
+
);
|
|
233
|
+
assert.ok(frame);
|
|
234
|
+
assert.equal(frame.getAttribute("src"), "https://example.test/embed.html");
|
|
235
|
+
});
|
|
236
|
+
|
|
120
237
|
test('#327: fmtCellValue "datetime" renders an ISO timestamp in the viewer\'s local time as "h:mmam Mon D"', () => {
|
|
121
238
|
const iso = "2026-08-18T23:42:47.788Z";
|
|
122
239
|
const out = fmtCellValue("datetime", iso);
|
|
@@ -686,6 +686,16 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
|
|
|
686
686
|
.pc-md-code { background:var(--nano-inset); border-radius:.3rem; padding:.05rem .3rem; font:.85em ui-monospace,SFMono-Regular,Menlo,monospace; }
|
|
687
687
|
.pc-md-hr { border:0; border-top:1px solid var(--nano-edge); margin:1rem 0; }
|
|
688
688
|
.pc-prose-body a { color:var(--nano-accent-strong); }
|
|
689
|
+
/* Embedded App View (renderAppView, #416 / ADR 0057). An <iframe> hosting a
|
|
690
|
+
self-contained view document (e.g. the agent cockpit's embed.html) served
|
|
691
|
+
alongside the page. A plain node sizes to a comfortable default; a "fill" node
|
|
692
|
+
grows to a tall flex column so the embed (a live dashboard/terminal) gets the
|
|
693
|
+
viewport height it needs instead of a squat fixed box. */
|
|
694
|
+
.pc-appview { margin:1rem 0; }
|
|
695
|
+
.pc-appview-title { font-size:.8rem; font-weight:600; color:var(--nano-text-muted); text-transform:uppercase; letter-spacing:.03em; margin:0 0 .35rem; }
|
|
696
|
+
.pc-appview-frame { display:block; width:100%; min-height:24rem; border:1px solid var(--nano-edge); border-radius:.6rem; background:var(--nano-inset); }
|
|
697
|
+
.pc-appview-fill { display:flex; flex-direction:column; min-height:min(80vh,48rem); }
|
|
698
|
+
.pc-appview-fill .pc-appview-frame { flex:1 1 auto; min-height:0; }
|
|
689
699
|
/* A standalone button node + the modal it opens (e.g. a copy-pasteable prompt). */
|
|
690
700
|
.pc-buttonrow { margin:1rem 0; }
|
|
691
701
|
.pc-btn-ghost { background:transparent; color:var(--nano-text-muted); border:1px solid var(--nano-edge); }
|