@nanobpm/urban 0.93.1 → 0.94.1
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.
|
@@ -2,6 +2,10 @@ import type { AppApi, RuntimeContext } from "../context.ts";
|
|
|
2
2
|
import type { EngineClient } from "../host.ts";
|
|
3
3
|
import { type Route } from "../router.ts";
|
|
4
4
|
import { type CancelInstanceResult } from "./cancel.ts";
|
|
5
|
+
/** The app-wide dataGrid layout (`surfaces.pages.gridLayout`, nano-ide#572). */
|
|
6
|
+
export type GridLayout = "auto" | "cards";
|
|
7
|
+
/** Narrow an untyped manifest value to a declared {@link GridLayout}; anything else is undefined (→ `"auto"`). */
|
|
8
|
+
export declare function parseGridLayout(value: unknown): GridLayout | undefined;
|
|
5
9
|
/** The subset of the datasource gateway the page runtime needs. */
|
|
6
10
|
export interface PagesDataSource {
|
|
7
11
|
query(sql: string, params?: unknown[]): Promise<Record<string, unknown>[]>;
|
|
@@ -14,6 +18,12 @@ export interface PagesOptions {
|
|
|
14
18
|
pagesDir?: string;
|
|
15
19
|
/** The page served at `/`. Default `home`. */
|
|
16
20
|
homePage?: string;
|
|
21
|
+
/**
|
|
22
|
+
* How every `dataGrid` lays out (nano-ide#572). `"auto"` (default): a table, flipping to the card
|
|
23
|
+
* list below the mobile breakpoint. `"cards"`: the card list at every width — for apps whose
|
|
24
|
+
* fixed-max-width shell would otherwise clip wide grids.
|
|
25
|
+
*/
|
|
26
|
+
gridLayout?: GridLayout;
|
|
17
27
|
/** Max rows a `dataGrid` fetch returns. Default 200. */
|
|
18
28
|
rowLimit?: number;
|
|
19
29
|
/** The injected default datasource name (the alias apps bind to). Default `app`. */
|
|
@@ -33,6 +33,10 @@ import { completeUserTaskResponse, resolveFormResponse } from "./forms.js";
|
|
|
33
33
|
import { quoteIdent } from "./gateway.js";
|
|
34
34
|
import { RENDERER_JS } from "./runtime.gen.js";
|
|
35
35
|
import { isSqlIdentifier } from "../read-model.js";
|
|
36
|
+
/** Narrow an untyped manifest value to a declared {@link GridLayout}; anything else is undefined (→ `"auto"`). */
|
|
37
|
+
export function parseGridLayout(value) {
|
|
38
|
+
return value === "auto" || value === "cards" ? value : undefined;
|
|
39
|
+
}
|
|
36
40
|
/** A JavaScript-body response (the renderer module served at the fingerprinted
|
|
37
41
|
* `/app/runtime.<hash>.js`, and its `/app/runtime.js` back-compat route). */
|
|
38
42
|
function javascript(body, status = 200, extraHeaders) {
|
|
@@ -177,7 +181,7 @@ export function createPagesRoutes(opts, deps) {
|
|
|
177
181
|
return p;
|
|
178
182
|
};
|
|
179
183
|
const routes = [];
|
|
180
|
-
const shell = html(rendererShell(homePage, opts.apiDocsPath));
|
|
184
|
+
const shell = html(rendererShell(homePage, opts.apiDocsPath, opts.gridLayout ?? "auto"));
|
|
181
185
|
// The shell must never be pinned by the browser: it carries the *current* fingerprinted
|
|
182
186
|
// runtime URL, so a stale shell would keep pointing at an old module hash. `no-cache` forces
|
|
183
187
|
// a revalidation on every load; the shell body is tiny, and the expensive module it references
|
|
@@ -567,6 +571,7 @@ export function mountPages(ctx, app) {
|
|
|
567
571
|
const opts = {
|
|
568
572
|
pagesDir: typeof decl.pagesDir === "string" ? decl.pagesDir : undefined,
|
|
569
573
|
homePage: typeof decl.homePage === "string" ? decl.homePage : undefined,
|
|
574
|
+
gridLayout: parseGridLayout(decl.gridLayout),
|
|
570
575
|
rowLimit: typeof decl.rowLimit === "number" ? decl.rowLimit : undefined,
|
|
571
576
|
sourceName: typeof decl.sourceName === "string" ? decl.sourceName : undefined,
|
|
572
577
|
// Link the shell's "API docs" badge to the app's Swagger UI when it declares an `api`
|
|
@@ -599,7 +604,7 @@ export function mountPages(ctx, app) {
|
|
|
599
604
|
function escapeAttr(s) {
|
|
600
605
|
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
601
606
|
}
|
|
602
|
-
function rendererShell(homePage, apiDocsPath) {
|
|
607
|
+
function rendererShell(homePage, apiDocsPath, gridLayout) {
|
|
603
608
|
// A persistent "API docs" badge (spec-first apps get their Swagger UI linked from their own
|
|
604
609
|
// UI for free). `target="_blank"` + hardened `rel` so the docs open without giving the docs
|
|
605
610
|
// tab a handle back to this window (reverse-tabnabbing).
|
|
@@ -625,7 +630,7 @@ function rendererShell(homePage, apiDocsPath) {
|
|
|
625
630
|
<title>Urban App</title>
|
|
626
631
|
<style>${RENDERER_CSS}</style>
|
|
627
632
|
</head>
|
|
628
|
-
<body>${apiDocsBadge}
|
|
633
|
+
<body data-grid-layout="${gridLayout}">${apiDocsBadge}
|
|
629
634
|
<main id="page" data-home="${escapeAttr(homePage)}"><p class="pc-empty">Loading…</p></main>
|
|
630
635
|
<script>${FORMJS_JS}</script>
|
|
631
636
|
<script type="module" src=".${RUNTIME_JS_PATH}"></script>
|
|
@@ -633,7 +638,7 @@ function rendererShell(homePage, apiDocsPath) {
|
|
|
633
638
|
</html>`;
|
|
634
639
|
}
|
|
635
640
|
// The app's stylesheet resolves every colour through the Nano console's
|
|
636
|
-
// semantic `--nano-*` token contract (see the
|
|
641
|
+
// semantic `--nano-*` token contract (see the nanobpm/nano-bpm repo's
|
|
637
642
|
// `console/src/theme/tokens.css` — MIRROR: keep the palettes below in sync). When the app is embedded in the
|
|
638
643
|
// console it inherits nothing across the iframe boundary, so the console
|
|
639
644
|
// postMessages its resolved tokens + appearance and the runtime lays them onto
|
|
@@ -647,6 +652,73 @@ function rendererShell(homePage, apiDocsPath) {
|
|
|
647
652
|
// stacking) AND the runtime's `matchMedia` mode-switch (which only Tier-2's
|
|
648
653
|
// optional page-level `mobile` layout variant needs), so the two can never
|
|
649
654
|
// drift apart on where "narrow" begins.
|
|
655
|
+
/**
|
|
656
|
+
* The dataGrid → card-list rules (#268), in ONE place. They are emitted twice from this single source:
|
|
657
|
+
* unscoped inside the mobile `@media` block (every app, narrow viewports) and scoped to
|
|
658
|
+
* `[data-grid-layout="cards"]` at top level (apps that opt into cards at every width, nano-ide#572).
|
|
659
|
+
* `scope` is prefixed onto every selector of every rule, so the two copies can never drift.
|
|
660
|
+
*/
|
|
661
|
+
function gridCardRules(scope) {
|
|
662
|
+
const css = `
|
|
663
|
+
/* dataGrid → card list. Once cells are display:block the fixed colgroup width
|
|
664
|
+
is irrelevant, so a wide table stops overflowing the phone. table-layout is
|
|
665
|
+
reset to auto so the block cells size to content. */
|
|
666
|
+
table.pc-grid { table-layout:auto; }
|
|
667
|
+
table.pc-grid, table.pc-grid tbody, table.pc-grid tr, table.pc-grid td { display:block; width:auto; }
|
|
668
|
+
table.pc-grid colgroup, table.pc-grid thead { display:none; }
|
|
669
|
+
table.pc-grid tr { border:1px solid var(--nano-edge); border-radius:.6rem; padding:.55rem .75rem; margin:.6rem 0; background:var(--nano-panel); }
|
|
670
|
+
table.pc-grid td { border-bottom:0; padding:.15rem 0; display:flex; gap:.75rem; justify-content:space-between; align-items:baseline; }
|
|
671
|
+
/* label:value — the column header (data-label) prefixes the value as a muted
|
|
672
|
+
caption on the same line. Suppressed for the title, chip, actions and any
|
|
673
|
+
colspan (group/empty/error) cells below. */
|
|
674
|
+
table.pc-grid td[data-label]::before { content:attr(data-label); color:var(--nano-text-muted); font-size:.72rem; font-weight:600; text-transform:uppercase; letter-spacing:.03em; flex:0 0 auto; }
|
|
675
|
+
/* The derived (or mobile:{priority:"primary"}-hinted) card title leads the
|
|
676
|
+
card: larger, full width, no label caption. */
|
|
677
|
+
table.pc-grid td.pc-mcell-primary { display:block; font-size:1rem; font-weight:650; padding-top:.15rem; }
|
|
678
|
+
table.pc-grid td.pc-mcell-primary::before { content:none; }
|
|
679
|
+
/* A badge column becomes a left-aligned status chip with no caption. */
|
|
680
|
+
table.pc-grid td.pc-mcell-chip { justify-content:flex-start; }
|
|
681
|
+
table.pc-grid td.pc-mcell-chip::before { content:none; }
|
|
682
|
+
/* An empty badge cell (row value blank) still carries the chip class but has no
|
|
683
|
+
child content; without this it renders as a blank flex row (padding/gap) that
|
|
684
|
+
leaves a stray empty line in the card. Collapse those empty chip cells (#268). */
|
|
685
|
+
table.pc-grid td.pc-mcell-chip:empty { display:none; }
|
|
686
|
+
/* Low-value (mobile:{priority:"hidden"}) columns drop off the card until the
|
|
687
|
+
row's "More" toggle opens them; on desktop they render as normal columns. */
|
|
688
|
+
table.pc-grid td.pc-mcell-hidden { display:none; }
|
|
689
|
+
table.pc-grid tr.pc-open td.pc-mcell-hidden { display:flex; }
|
|
690
|
+
.pc-mcard-toggle { display:block; padding:.3rem 0 0; }
|
|
691
|
+
.pc-mcard-toggle .pc-more { width:100%; margin-right:0; }
|
|
692
|
+
/* Row actions stack full-width under the card body. */
|
|
693
|
+
table.pc-grid td.pc-row-actions { display:flex; flex-wrap:wrap; gap:.4rem; text-align:left; padding-top:.45rem; white-space:normal; }
|
|
694
|
+
table.pc-grid td.pc-row-actions::before { content:none; }
|
|
695
|
+
table.pc-grid td.pc-row-actions .pc-btn { flex:1 1 auto; margin-right:0; }
|
|
696
|
+
/* Group-header + empty/error rows span the full card width with no caption. */
|
|
697
|
+
table.pc-grid td[colspan] { display:block; }
|
|
698
|
+
table.pc-grid td[colspan]::before { content:none; }
|
|
699
|
+
.pc-group-header td { padding:0; }
|
|
700
|
+
`;
|
|
701
|
+
if (scope === "")
|
|
702
|
+
return css.replace(/^\n/, "");
|
|
703
|
+
// The scoped copy drops the comments (they document the mobile copy) and scopes each rule line.
|
|
704
|
+
return css
|
|
705
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
706
|
+
.split("\n")
|
|
707
|
+
.filter((line) => line.trim() !== "")
|
|
708
|
+
.map((line) => {
|
|
709
|
+
const brace = line.indexOf("{");
|
|
710
|
+
if (brace < 0)
|
|
711
|
+
return line;
|
|
712
|
+
const selectors = line
|
|
713
|
+
.slice(0, brace)
|
|
714
|
+
.split(",")
|
|
715
|
+
.map((sel) => `${scope} ${sel.trim()}`)
|
|
716
|
+
.join(", ");
|
|
717
|
+
return ` ${selectors} ${line.slice(brace)}`;
|
|
718
|
+
})
|
|
719
|
+
.join("\n")
|
|
720
|
+
.concat("\n");
|
|
721
|
+
}
|
|
650
722
|
const MOBILE_MAX_WIDTH = "640px";
|
|
651
723
|
const RENDERER_CSS = `
|
|
652
724
|
:root {
|
|
@@ -896,44 +968,7 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
|
|
|
896
968
|
matchMedia. */
|
|
897
969
|
@media (max-width:${MOBILE_MAX_WIDTH}) {
|
|
898
970
|
body { padding:1rem; }
|
|
899
|
-
/*
|
|
900
|
-
is irrelevant, so a wide table stops overflowing the phone. table-layout is
|
|
901
|
-
reset to auto so the block cells size to content. */
|
|
902
|
-
table.pc-grid { table-layout:auto; }
|
|
903
|
-
table.pc-grid, table.pc-grid tbody, table.pc-grid tr, table.pc-grid td { display:block; width:auto; }
|
|
904
|
-
table.pc-grid colgroup, table.pc-grid thead { display:none; }
|
|
905
|
-
table.pc-grid tr { border:1px solid var(--nano-edge); border-radius:.6rem; padding:.55rem .75rem; margin:.6rem 0; background:var(--nano-panel); }
|
|
906
|
-
table.pc-grid td { border-bottom:0; padding:.15rem 0; display:flex; gap:.75rem; justify-content:space-between; align-items:baseline; }
|
|
907
|
-
/* label:value — the column header (data-label) prefixes the value as a muted
|
|
908
|
-
caption on the same line. Suppressed for the title, chip, actions and any
|
|
909
|
-
colspan (group/empty/error) cells below. */
|
|
910
|
-
table.pc-grid td[data-label]::before { content:attr(data-label); color:var(--nano-text-muted); font-size:.72rem; font-weight:600; text-transform:uppercase; letter-spacing:.03em; flex:0 0 auto; }
|
|
911
|
-
/* The derived (or mobile:{priority:"primary"}-hinted) card title leads the
|
|
912
|
-
card: larger, full width, no label caption. */
|
|
913
|
-
table.pc-grid td.pc-mcell-primary { display:block; font-size:1rem; font-weight:650; padding-top:.15rem; }
|
|
914
|
-
table.pc-grid td.pc-mcell-primary::before { content:none; }
|
|
915
|
-
/* A badge column becomes a left-aligned status chip with no caption. */
|
|
916
|
-
table.pc-grid td.pc-mcell-chip { justify-content:flex-start; }
|
|
917
|
-
table.pc-grid td.pc-mcell-chip::before { content:none; }
|
|
918
|
-
/* An empty badge cell (row value blank) still carries the chip class but has no
|
|
919
|
-
child content; without this it renders as a blank flex row (padding/gap) that
|
|
920
|
-
leaves a stray empty line in the card. Collapse those empty chip cells (#268). */
|
|
921
|
-
table.pc-grid td.pc-mcell-chip:empty { display:none; }
|
|
922
|
-
/* Low-value (mobile:{priority:"hidden"}) columns drop off the card until the
|
|
923
|
-
row's "More" toggle opens them; on desktop they render as normal columns. */
|
|
924
|
-
table.pc-grid td.pc-mcell-hidden { display:none; }
|
|
925
|
-
table.pc-grid tr.pc-open td.pc-mcell-hidden { display:flex; }
|
|
926
|
-
.pc-mcard-toggle { display:block; padding:.3rem 0 0; }
|
|
927
|
-
.pc-mcard-toggle .pc-more { width:100%; margin-right:0; }
|
|
928
|
-
/* Row actions stack full-width under the card body. */
|
|
929
|
-
table.pc-grid td.pc-row-actions { display:flex; flex-wrap:wrap; gap:.4rem; text-align:left; padding-top:.45rem; white-space:normal; }
|
|
930
|
-
table.pc-grid td.pc-row-actions::before { content:none; }
|
|
931
|
-
table.pc-grid td.pc-row-actions .pc-btn { flex:1 1 auto; margin-right:0; }
|
|
932
|
-
/* Group-header + empty/error rows span the full card width with no caption. */
|
|
933
|
-
table.pc-grid td[colspan] { display:block; }
|
|
934
|
-
table.pc-grid td[colspan]::before { content:none; }
|
|
935
|
-
.pc-group-header td { padding:0; }
|
|
936
|
-
/* Nav → horizontally-scrollable bar (bar) / inline stack (rail). The bar keeps
|
|
971
|
+
${gridCardRules("")} /* Nav → horizontally-scrollable bar (bar) / inline stack (rail). The bar keeps
|
|
937
972
|
its normal top-of-flow position and scrolls sideways when the pages overflow;
|
|
938
973
|
an opt-in sticky bar stays pinned to the top via the desktop .pc-sticky rule
|
|
939
974
|
(top:0). A rail drops its fixed side column and flows inline. */
|
|
@@ -951,7 +986,8 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
|
|
|
951
986
|
.pc-modal-actions { flex-direction:column; }
|
|
952
987
|
.pc-modal-actions .pc-btn { width:100%; }
|
|
953
988
|
}
|
|
954
|
-
|
|
989
|
+
/* App-wide card layout (nano-ide#572): the same card rules, at every width. */
|
|
990
|
+
${gridCardRules('[data-grid-layout="cards"]')}`;
|
|
955
991
|
// The schema-driven browser renderer (ADR 0042 §3). Plain ES module string served at
|
|
956
992
|
// /app/runtime.js — it does NOT ship Craft.js (authoring is console-side only). It
|
|
957
993
|
// fetches the home page's page.json and renders text / actionForm / dataGrid / prose
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.94.1",
|
|
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",
|
|
@@ -104,7 +104,7 @@
|
|
|
104
104
|
"dependencies": {
|
|
105
105
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
106
106
|
"@nanobpm/agentic": "^0.14.0",
|
|
107
|
-
"@nanobpm/nano-app-schema": "^0.
|
|
107
|
+
"@nanobpm/nano-app-schema": "^0.11.0",
|
|
108
108
|
"@nanobpm/nano-sdk": "^1.2.5",
|
|
109
109
|
"@nanobpm/workflow": "^0.14.0",
|
|
110
110
|
"bpmn-auto-layout": "^2.0.0-alpha.2",
|
|
@@ -6,7 +6,7 @@ import { tmpdir } from "node:os";
|
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import type { EngineClient, HttpRequest, HttpResponse } from "../host.ts";
|
|
8
8
|
import { makeRouter } from "../router.ts";
|
|
9
|
-
import { createPagesRoutes, type PagesDataSource, type PagesDeps } from "./pages.ts";
|
|
9
|
+
import { createPagesRoutes, type GridLayout, type PagesDataSource, type PagesDeps, parseGridLayout } from "./pages.ts";
|
|
10
10
|
|
|
11
11
|
function req(
|
|
12
12
|
method: string,
|
|
@@ -2072,3 +2072,54 @@ test("the pages shell embeds the shared form-js renderer (NanoFormJs) for engine
|
|
|
2072
2072
|
assert.match(res.body ?? "", /NanoFormJs/, "the shell exposes the shared renderer as a global");
|
|
2073
2073
|
assert.match(res.body ?? "", /function renderForm/, "the shared renderer is embedded, not forked");
|
|
2074
2074
|
});
|
|
2075
|
+
|
|
2076
|
+
// nano-ide#572 — an app-wide opt-in (`surfaces.pages.gridLayout: "cards"`) renders every dataGrid as
|
|
2077
|
+
// the card list at ANY viewport width, not just below the mobile breakpoint.
|
|
2078
|
+
async function shellFor(gridLayout?: GridLayout): Promise<HttpResponse> {
|
|
2079
|
+
const routes = createPagesRoutes({ pagesDir: "pages", homePage: "home", sourceName: "app", gridLayout }, {
|
|
2080
|
+
db: fakeDb(),
|
|
2081
|
+
engine: fakeEngine().engine,
|
|
2082
|
+
readPage: async () => "{}",
|
|
2083
|
+
});
|
|
2084
|
+
return await makeRouter(routes)(req("GET", "/"));
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
test("#572: gridLayout \"cards\" stamps the shell so dataGrids render as cards at every width", async () => {
|
|
2088
|
+
const html = (await shellFor("cards")).body ?? "";
|
|
2089
|
+
assert.match(html, /<body data-grid-layout="cards">/);
|
|
2090
|
+
});
|
|
2091
|
+
|
|
2092
|
+
test("#572: the default (and \"auto\") keep today's table-on-desktop layout", async () => {
|
|
2093
|
+
for (const layout of [undefined, "auto"] as const) {
|
|
2094
|
+
const html = (await shellFor(layout)).body ?? "";
|
|
2095
|
+
assert.match(html, /<body data-grid-layout="auto">/);
|
|
2096
|
+
}
|
|
2097
|
+
});
|
|
2098
|
+
|
|
2099
|
+
test("#572: the always-cards rules are DERIVED from the mobile card rules — identical, just scoped", async () => {
|
|
2100
|
+
const html = (await shellFor("cards")).body ?? "";
|
|
2101
|
+
const media = (html.match(/@media \(max-width:640px\) \{[\s\S]*?\n\}/) ?? [""])[0];
|
|
2102
|
+
const gridRules = (css: string) =>
|
|
2103
|
+
css
|
|
2104
|
+
.split("\n")
|
|
2105
|
+
.map((l) => l.trim())
|
|
2106
|
+
.filter((l) => /(pc-grid|pc-mcard-toggle|pc-group-header)[^{]*\{/.test(l) && !l.startsWith("/*"));
|
|
2107
|
+
const mobile = gridRules(media);
|
|
2108
|
+
assert.ok(mobile.length > 5, "the mobile block carries the card rules");
|
|
2109
|
+
const scoped = gridRules(html).filter((l) => l.startsWith('[data-grid-layout="cards"]'));
|
|
2110
|
+
const unscoped = scoped.map((l) => l.replace(/\[data-grid-layout="cards"\] /g, ""));
|
|
2111
|
+
assert.deepEqual(unscoped, mobile, "every card rule appears once, scoped to the cards layout, byte-identical");
|
|
2112
|
+
// Every selector in a comma list is scoped, so no rule leaks onto the "auto" layout.
|
|
2113
|
+
for (const l of scoped) {
|
|
2114
|
+
const selectors = l.slice(0, l.indexOf("{")).split(",");
|
|
2115
|
+
for (const s of selectors) assert.match(s.trim(), /^\[data-grid-layout="cards"\] /, `unscoped selector in: ${l}`);
|
|
2116
|
+
}
|
|
2117
|
+
});
|
|
2118
|
+
|
|
2119
|
+
test("#572: parseGridLayout accepts only the declared layouts (anything else is the \"auto\" default)", () => {
|
|
2120
|
+
assert.equal(parseGridLayout("cards"), "cards");
|
|
2121
|
+
assert.equal(parseGridLayout("auto"), "auto");
|
|
2122
|
+
assert.equal(parseGridLayout(undefined), undefined);
|
|
2123
|
+
assert.equal(parseGridLayout("grid"), undefined);
|
|
2124
|
+
assert.equal(parseGridLayout(1), undefined);
|
|
2125
|
+
});
|
|
@@ -37,6 +37,14 @@ import { quoteIdent } from "./gateway.ts";
|
|
|
37
37
|
import { RENDERER_JS } from "./runtime.gen.ts";
|
|
38
38
|
import { isSqlIdentifier } from "../read-model.ts";
|
|
39
39
|
|
|
40
|
+
/** The app-wide dataGrid layout (`surfaces.pages.gridLayout`, nano-ide#572). */
|
|
41
|
+
export type GridLayout = "auto" | "cards";
|
|
42
|
+
|
|
43
|
+
/** Narrow an untyped manifest value to a declared {@link GridLayout}; anything else is undefined (→ `"auto"`). */
|
|
44
|
+
export function parseGridLayout(value: unknown): GridLayout | undefined {
|
|
45
|
+
return value === "auto" || value === "cards" ? value : undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
40
48
|
/** The subset of the datasource gateway the page runtime needs. */
|
|
41
49
|
export interface PagesDataSource {
|
|
42
50
|
query(sql: string, params?: unknown[]): Promise<Record<string, unknown>[]>;
|
|
@@ -48,6 +56,12 @@ export interface PagesOptions {
|
|
|
48
56
|
pagesDir?: string;
|
|
49
57
|
/** The page served at `/`. Default `home`. */
|
|
50
58
|
homePage?: string;
|
|
59
|
+
/**
|
|
60
|
+
* How every `dataGrid` lays out (nano-ide#572). `"auto"` (default): a table, flipping to the card
|
|
61
|
+
* list below the mobile breakpoint. `"cards"`: the card list at every width — for apps whose
|
|
62
|
+
* fixed-max-width shell would otherwise clip wide grids.
|
|
63
|
+
*/
|
|
64
|
+
gridLayout?: GridLayout;
|
|
51
65
|
/** Max rows a `dataGrid` fetch returns. Default 200. */
|
|
52
66
|
rowLimit?: number;
|
|
53
67
|
/** The injected default datasource name (the alias apps bind to). Default `app`. */
|
|
@@ -248,7 +262,7 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
|
|
|
248
262
|
};
|
|
249
263
|
|
|
250
264
|
const routes: Route[] = [];
|
|
251
|
-
const shell = html(rendererShell(homePage, opts.apiDocsPath));
|
|
265
|
+
const shell = html(rendererShell(homePage, opts.apiDocsPath, opts.gridLayout ?? "auto"));
|
|
252
266
|
// The shell must never be pinned by the browser: it carries the *current* fingerprinted
|
|
253
267
|
// runtime URL, so a stale shell would keep pointing at an old module hash. `no-cache` forces
|
|
254
268
|
// a revalidation on every load; the shell body is tiny, and the expensive module it references
|
|
@@ -630,6 +644,7 @@ export function mountPages(ctx: RuntimeContext, app: AppApi): PagesHandle {
|
|
|
630
644
|
const opts: PagesOptions = {
|
|
631
645
|
pagesDir: typeof decl.pagesDir === "string" ? decl.pagesDir : undefined,
|
|
632
646
|
homePage: typeof decl.homePage === "string" ? decl.homePage : undefined,
|
|
647
|
+
gridLayout: parseGridLayout(decl.gridLayout),
|
|
633
648
|
rowLimit: typeof decl.rowLimit === "number" ? decl.rowLimit : undefined,
|
|
634
649
|
sourceName: typeof decl.sourceName === "string" ? decl.sourceName : undefined,
|
|
635
650
|
// Link the shell's "API docs" badge to the app's Swagger UI when it declares an `api`
|
|
@@ -664,7 +679,7 @@ function escapeAttr(s: string): string {
|
|
|
664
679
|
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
665
680
|
}
|
|
666
681
|
|
|
667
|
-
function rendererShell(homePage: string, apiDocsPath
|
|
682
|
+
function rendererShell(homePage: string, apiDocsPath: string | undefined, gridLayout: GridLayout): string {
|
|
668
683
|
// A persistent "API docs" badge (spec-first apps get their Swagger UI linked from their own
|
|
669
684
|
// UI for free). `target="_blank"` + hardened `rel` so the docs open without giving the docs
|
|
670
685
|
// tab a handle back to this window (reverse-tabnabbing).
|
|
@@ -691,7 +706,7 @@ function rendererShell(homePage: string, apiDocsPath?: string): string {
|
|
|
691
706
|
<title>Urban App</title>
|
|
692
707
|
<style>${RENDERER_CSS}</style>
|
|
693
708
|
</head>
|
|
694
|
-
<body>${apiDocsBadge}
|
|
709
|
+
<body data-grid-layout="${gridLayout}">${apiDocsBadge}
|
|
695
710
|
<main id="page" data-home="${escapeAttr(homePage)}"><p class="pc-empty">Loading…</p></main>
|
|
696
711
|
<script>${FORMJS_JS}</script>
|
|
697
712
|
<script type="module" src=".${RUNTIME_JS_PATH}"></script>
|
|
@@ -700,7 +715,7 @@ function rendererShell(homePage: string, apiDocsPath?: string): string {
|
|
|
700
715
|
}
|
|
701
716
|
|
|
702
717
|
// The app's stylesheet resolves every colour through the Nano console's
|
|
703
|
-
// semantic `--nano-*` token contract (see the
|
|
718
|
+
// semantic `--nano-*` token contract (see the nanobpm/nano-bpm repo's
|
|
704
719
|
// `console/src/theme/tokens.css` — MIRROR: keep the palettes below in sync). When the app is embedded in the
|
|
705
720
|
// console it inherits nothing across the iframe boundary, so the console
|
|
706
721
|
// postMessages its resolved tokens + appearance and the runtime lays them onto
|
|
@@ -714,6 +729,72 @@ function rendererShell(homePage: string, apiDocsPath?: string): string {
|
|
|
714
729
|
// stacking) AND the runtime's `matchMedia` mode-switch (which only Tier-2's
|
|
715
730
|
// optional page-level `mobile` layout variant needs), so the two can never
|
|
716
731
|
// drift apart on where "narrow" begins.
|
|
732
|
+
/**
|
|
733
|
+
* The dataGrid → card-list rules (#268), in ONE place. They are emitted twice from this single source:
|
|
734
|
+
* unscoped inside the mobile `@media` block (every app, narrow viewports) and scoped to
|
|
735
|
+
* `[data-grid-layout="cards"]` at top level (apps that opt into cards at every width, nano-ide#572).
|
|
736
|
+
* `scope` is prefixed onto every selector of every rule, so the two copies can never drift.
|
|
737
|
+
*/
|
|
738
|
+
function gridCardRules(scope: string): string {
|
|
739
|
+
const css = `
|
|
740
|
+
/* dataGrid → card list. Once cells are display:block the fixed colgroup width
|
|
741
|
+
is irrelevant, so a wide table stops overflowing the phone. table-layout is
|
|
742
|
+
reset to auto so the block cells size to content. */
|
|
743
|
+
table.pc-grid { table-layout:auto; }
|
|
744
|
+
table.pc-grid, table.pc-grid tbody, table.pc-grid tr, table.pc-grid td { display:block; width:auto; }
|
|
745
|
+
table.pc-grid colgroup, table.pc-grid thead { display:none; }
|
|
746
|
+
table.pc-grid tr { border:1px solid var(--nano-edge); border-radius:.6rem; padding:.55rem .75rem; margin:.6rem 0; background:var(--nano-panel); }
|
|
747
|
+
table.pc-grid td { border-bottom:0; padding:.15rem 0; display:flex; gap:.75rem; justify-content:space-between; align-items:baseline; }
|
|
748
|
+
/* label:value — the column header (data-label) prefixes the value as a muted
|
|
749
|
+
caption on the same line. Suppressed for the title, chip, actions and any
|
|
750
|
+
colspan (group/empty/error) cells below. */
|
|
751
|
+
table.pc-grid td[data-label]::before { content:attr(data-label); color:var(--nano-text-muted); font-size:.72rem; font-weight:600; text-transform:uppercase; letter-spacing:.03em; flex:0 0 auto; }
|
|
752
|
+
/* The derived (or mobile:{priority:"primary"}-hinted) card title leads the
|
|
753
|
+
card: larger, full width, no label caption. */
|
|
754
|
+
table.pc-grid td.pc-mcell-primary { display:block; font-size:1rem; font-weight:650; padding-top:.15rem; }
|
|
755
|
+
table.pc-grid td.pc-mcell-primary::before { content:none; }
|
|
756
|
+
/* A badge column becomes a left-aligned status chip with no caption. */
|
|
757
|
+
table.pc-grid td.pc-mcell-chip { justify-content:flex-start; }
|
|
758
|
+
table.pc-grid td.pc-mcell-chip::before { content:none; }
|
|
759
|
+
/* An empty badge cell (row value blank) still carries the chip class but has no
|
|
760
|
+
child content; without this it renders as a blank flex row (padding/gap) that
|
|
761
|
+
leaves a stray empty line in the card. Collapse those empty chip cells (#268). */
|
|
762
|
+
table.pc-grid td.pc-mcell-chip:empty { display:none; }
|
|
763
|
+
/* Low-value (mobile:{priority:"hidden"}) columns drop off the card until the
|
|
764
|
+
row's "More" toggle opens them; on desktop they render as normal columns. */
|
|
765
|
+
table.pc-grid td.pc-mcell-hidden { display:none; }
|
|
766
|
+
table.pc-grid tr.pc-open td.pc-mcell-hidden { display:flex; }
|
|
767
|
+
.pc-mcard-toggle { display:block; padding:.3rem 0 0; }
|
|
768
|
+
.pc-mcard-toggle .pc-more { width:100%; margin-right:0; }
|
|
769
|
+
/* Row actions stack full-width under the card body. */
|
|
770
|
+
table.pc-grid td.pc-row-actions { display:flex; flex-wrap:wrap; gap:.4rem; text-align:left; padding-top:.45rem; white-space:normal; }
|
|
771
|
+
table.pc-grid td.pc-row-actions::before { content:none; }
|
|
772
|
+
table.pc-grid td.pc-row-actions .pc-btn { flex:1 1 auto; margin-right:0; }
|
|
773
|
+
/* Group-header + empty/error rows span the full card width with no caption. */
|
|
774
|
+
table.pc-grid td[colspan] { display:block; }
|
|
775
|
+
table.pc-grid td[colspan]::before { content:none; }
|
|
776
|
+
.pc-group-header td { padding:0; }
|
|
777
|
+
`;
|
|
778
|
+
if (scope === "") return css.replace(/^\n/, "");
|
|
779
|
+
// The scoped copy drops the comments (they document the mobile copy) and scopes each rule line.
|
|
780
|
+
return css
|
|
781
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
782
|
+
.split("\n")
|
|
783
|
+
.filter((line) => line.trim() !== "")
|
|
784
|
+
.map((line) => {
|
|
785
|
+
const brace = line.indexOf("{");
|
|
786
|
+
if (brace < 0) return line;
|
|
787
|
+
const selectors = line
|
|
788
|
+
.slice(0, brace)
|
|
789
|
+
.split(",")
|
|
790
|
+
.map((sel) => `${scope} ${sel.trim()}`)
|
|
791
|
+
.join(", ");
|
|
792
|
+
return ` ${selectors} ${line.slice(brace)}`;
|
|
793
|
+
})
|
|
794
|
+
.join("\n")
|
|
795
|
+
.concat("\n");
|
|
796
|
+
}
|
|
797
|
+
|
|
717
798
|
const MOBILE_MAX_WIDTH = "640px";
|
|
718
799
|
|
|
719
800
|
const RENDERER_CSS = `
|
|
@@ -964,44 +1045,7 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
|
|
|
964
1045
|
matchMedia. */
|
|
965
1046
|
@media (max-width:${MOBILE_MAX_WIDTH}) {
|
|
966
1047
|
body { padding:1rem; }
|
|
967
|
-
/*
|
|
968
|
-
is irrelevant, so a wide table stops overflowing the phone. table-layout is
|
|
969
|
-
reset to auto so the block cells size to content. */
|
|
970
|
-
table.pc-grid { table-layout:auto; }
|
|
971
|
-
table.pc-grid, table.pc-grid tbody, table.pc-grid tr, table.pc-grid td { display:block; width:auto; }
|
|
972
|
-
table.pc-grid colgroup, table.pc-grid thead { display:none; }
|
|
973
|
-
table.pc-grid tr { border:1px solid var(--nano-edge); border-radius:.6rem; padding:.55rem .75rem; margin:.6rem 0; background:var(--nano-panel); }
|
|
974
|
-
table.pc-grid td { border-bottom:0; padding:.15rem 0; display:flex; gap:.75rem; justify-content:space-between; align-items:baseline; }
|
|
975
|
-
/* label:value — the column header (data-label) prefixes the value as a muted
|
|
976
|
-
caption on the same line. Suppressed for the title, chip, actions and any
|
|
977
|
-
colspan (group/empty/error) cells below. */
|
|
978
|
-
table.pc-grid td[data-label]::before { content:attr(data-label); color:var(--nano-text-muted); font-size:.72rem; font-weight:600; text-transform:uppercase; letter-spacing:.03em; flex:0 0 auto; }
|
|
979
|
-
/* The derived (or mobile:{priority:"primary"}-hinted) card title leads the
|
|
980
|
-
card: larger, full width, no label caption. */
|
|
981
|
-
table.pc-grid td.pc-mcell-primary { display:block; font-size:1rem; font-weight:650; padding-top:.15rem; }
|
|
982
|
-
table.pc-grid td.pc-mcell-primary::before { content:none; }
|
|
983
|
-
/* A badge column becomes a left-aligned status chip with no caption. */
|
|
984
|
-
table.pc-grid td.pc-mcell-chip { justify-content:flex-start; }
|
|
985
|
-
table.pc-grid td.pc-mcell-chip::before { content:none; }
|
|
986
|
-
/* An empty badge cell (row value blank) still carries the chip class but has no
|
|
987
|
-
child content; without this it renders as a blank flex row (padding/gap) that
|
|
988
|
-
leaves a stray empty line in the card. Collapse those empty chip cells (#268). */
|
|
989
|
-
table.pc-grid td.pc-mcell-chip:empty { display:none; }
|
|
990
|
-
/* Low-value (mobile:{priority:"hidden"}) columns drop off the card until the
|
|
991
|
-
row's "More" toggle opens them; on desktop they render as normal columns. */
|
|
992
|
-
table.pc-grid td.pc-mcell-hidden { display:none; }
|
|
993
|
-
table.pc-grid tr.pc-open td.pc-mcell-hidden { display:flex; }
|
|
994
|
-
.pc-mcard-toggle { display:block; padding:.3rem 0 0; }
|
|
995
|
-
.pc-mcard-toggle .pc-more { width:100%; margin-right:0; }
|
|
996
|
-
/* Row actions stack full-width under the card body. */
|
|
997
|
-
table.pc-grid td.pc-row-actions { display:flex; flex-wrap:wrap; gap:.4rem; text-align:left; padding-top:.45rem; white-space:normal; }
|
|
998
|
-
table.pc-grid td.pc-row-actions::before { content:none; }
|
|
999
|
-
table.pc-grid td.pc-row-actions .pc-btn { flex:1 1 auto; margin-right:0; }
|
|
1000
|
-
/* Group-header + empty/error rows span the full card width with no caption. */
|
|
1001
|
-
table.pc-grid td[colspan] { display:block; }
|
|
1002
|
-
table.pc-grid td[colspan]::before { content:none; }
|
|
1003
|
-
.pc-group-header td { padding:0; }
|
|
1004
|
-
/* Nav → horizontally-scrollable bar (bar) / inline stack (rail). The bar keeps
|
|
1048
|
+
${gridCardRules("")} /* Nav → horizontally-scrollable bar (bar) / inline stack (rail). The bar keeps
|
|
1005
1049
|
its normal top-of-flow position and scrolls sideways when the pages overflow;
|
|
1006
1050
|
an opt-in sticky bar stays pinned to the top via the desktop .pc-sticky rule
|
|
1007
1051
|
(top:0). A rail drops its fixed side column and flows inline. */
|
|
@@ -1019,7 +1063,8 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
|
|
|
1019
1063
|
.pc-modal-actions { flex-direction:column; }
|
|
1020
1064
|
.pc-modal-actions .pc-btn { width:100%; }
|
|
1021
1065
|
}
|
|
1022
|
-
|
|
1066
|
+
/* App-wide card layout (nano-ide#572): the same card rules, at every width. */
|
|
1067
|
+
${gridCardRules('[data-grid-layout="cards"]')}`;
|
|
1023
1068
|
|
|
1024
1069
|
// The schema-driven browser renderer (ADR 0042 §3). Plain ES module string served at
|
|
1025
1070
|
// /app/runtime.js — it does NOT ship Craft.js (authoring is console-side only). It
|
|
@@ -721,3 +721,39 @@ test("threads an injected scheduler into the instance-tracking reconciler loop",
|
|
|
721
721
|
await rm(dir, { recursive: true, force: true });
|
|
722
722
|
}
|
|
723
723
|
});
|
|
724
|
+
|
|
725
|
+
// nano-ide#572 — the app-wide dataGrid card layout is opt-in via `surfaces.pages.gridLayout` in the
|
|
726
|
+
// MANIFEST. These drive the full manifest→shell wiring (createUrbanApp → mountSurfaces → mountPages)
|
|
727
|
+
// end-to-end, so a regression in that bridge — e.g. the reflective `"gridLayout" in decl` read or the
|
|
728
|
+
// `parseGridLayout` fallback — is caught here even though the focused pages.test.ts tests inject the
|
|
729
|
+
// option directly into createPagesRoutes (which bypasses the manifest read).
|
|
730
|
+
async function shellGridLayout(pages: Record<string, unknown>): Promise<string> {
|
|
731
|
+
const dir = await makeFixture({ surfaces: { pages: { enabled: true, ...pages } } });
|
|
732
|
+
const host = createNodeHost({ cwd: dir, log: () => {} });
|
|
733
|
+
const engine = new FakeEngine();
|
|
734
|
+
const app = await createUrbanApp({ host, engine, root: ".", port: 0 });
|
|
735
|
+
await app.start();
|
|
736
|
+
try {
|
|
737
|
+
const res = await fetch(`http://localhost:${app.httpPort!}/`);
|
|
738
|
+
assert.equal(res.status, 200);
|
|
739
|
+
const body = await res.text();
|
|
740
|
+
const m = body.match(/<body data-grid-layout="([^"]*)">/);
|
|
741
|
+
assert.ok(m, `shell body carries a data-grid-layout attribute: ${body.slice(0, 200)}`);
|
|
742
|
+
return m[1];
|
|
743
|
+
} finally {
|
|
744
|
+
await app.stop();
|
|
745
|
+
await rm(dir, { recursive: true, force: true });
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
test('#572: surfaces.pages.gridLayout "cards" reaches the shell body attribute through the manifest', async () => {
|
|
750
|
+
assert.equal(await shellGridLayout({ gridLayout: "cards" }), "cards");
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
test('#572: an omitted surfaces.pages.gridLayout falls back to the "auto" layout', async () => {
|
|
754
|
+
assert.equal(await shellGridLayout({}), "auto");
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
test('#572: an invalid surfaces.pages.gridLayout falls back to the "auto" layout', async () => {
|
|
758
|
+
assert.equal(await shellGridLayout({ gridLayout: "grid" }), "auto");
|
|
759
|
+
});
|