@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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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 Magikcraft/nano-bpm repo's
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
- /* dataGrid → card list. Once cells are display:block the fixed colgroup width
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.93.1",
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.9.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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
665
680
  }
666
681
 
667
- function rendererShell(homePage: string, apiDocsPath?: string): string {
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 Magikcraft/nano-bpm repo's
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
- /* dataGrid → card list. Once cells are display:block the fixed colgroup width
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
+ });