@nanobpm/urban 0.93.0 → 0.94.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.
@@ -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,9 @@ 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
+ // `in`-narrowed so this reads the key whether or not the pinned @nanobpm/nano-app-schema already
575
+ // declares it (nanobpm/nano-bpm#1301); an undeclared/invalid value falls back to "auto".
576
+ gridLayout: parseGridLayout("gridLayout" in decl ? decl.gridLayout : undefined),
570
577
  rowLimit: typeof decl.rowLimit === "number" ? decl.rowLimit : undefined,
571
578
  sourceName: typeof decl.sourceName === "string" ? decl.sourceName : undefined,
572
579
  // Link the shell's "API docs" badge to the app's Swagger UI when it declares an `api`
@@ -599,7 +606,7 @@ export function mountPages(ctx, app) {
599
606
  function escapeAttr(s) {
600
607
  return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
601
608
  }
602
- function rendererShell(homePage, apiDocsPath) {
609
+ function rendererShell(homePage, apiDocsPath, gridLayout) {
603
610
  // A persistent "API docs" badge (spec-first apps get their Swagger UI linked from their own
604
611
  // UI for free). `target="_blank"` + hardened `rel` so the docs open without giving the docs
605
612
  // tab a handle back to this window (reverse-tabnabbing).
@@ -625,7 +632,7 @@ function rendererShell(homePage, apiDocsPath) {
625
632
  <title>Urban App</title>
626
633
  <style>${RENDERER_CSS}</style>
627
634
  </head>
628
- <body>${apiDocsBadge}
635
+ <body data-grid-layout="${gridLayout}">${apiDocsBadge}
629
636
  <main id="page" data-home="${escapeAttr(homePage)}"><p class="pc-empty">Loading…</p></main>
630
637
  <script>${FORMJS_JS}</script>
631
638
  <script type="module" src=".${RUNTIME_JS_PATH}"></script>
@@ -633,7 +640,7 @@ function rendererShell(homePage, apiDocsPath) {
633
640
  </html>`;
634
641
  }
635
642
  // The app's stylesheet resolves every colour through the Nano console's
636
- // semantic `--nano-*` token contract (see the Magikcraft/nano-bpm repo's
643
+ // semantic `--nano-*` token contract (see the nanobpm/nano-bpm repo's
637
644
  // `console/src/theme/tokens.css` — MIRROR: keep the palettes below in sync). When the app is embedded in the
638
645
  // console it inherits nothing across the iframe boundary, so the console
639
646
  // postMessages its resolved tokens + appearance and the runtime lays them onto
@@ -647,6 +654,73 @@ function rendererShell(homePage, apiDocsPath) {
647
654
  // stacking) AND the runtime's `matchMedia` mode-switch (which only Tier-2's
648
655
  // optional page-level `mobile` layout variant needs), so the two can never
649
656
  // drift apart on where "narrow" begins.
657
+ /**
658
+ * The dataGrid → card-list rules (#268), in ONE place. They are emitted twice from this single source:
659
+ * unscoped inside the mobile `@media` block (every app, narrow viewports) and scoped to
660
+ * `[data-grid-layout="cards"]` at top level (apps that opt into cards at every width, nano-ide#572).
661
+ * `scope` is prefixed onto every selector of every rule, so the two copies can never drift.
662
+ */
663
+ function gridCardRules(scope) {
664
+ const css = `
665
+ /* dataGrid → card list. Once cells are display:block the fixed colgroup width
666
+ is irrelevant, so a wide table stops overflowing the phone. table-layout is
667
+ reset to auto so the block cells size to content. */
668
+ table.pc-grid { table-layout:auto; }
669
+ table.pc-grid, table.pc-grid tbody, table.pc-grid tr, table.pc-grid td { display:block; width:auto; }
670
+ table.pc-grid colgroup, table.pc-grid thead { display:none; }
671
+ table.pc-grid tr { border:1px solid var(--nano-edge); border-radius:.6rem; padding:.55rem .75rem; margin:.6rem 0; background:var(--nano-panel); }
672
+ table.pc-grid td { border-bottom:0; padding:.15rem 0; display:flex; gap:.75rem; justify-content:space-between; align-items:baseline; }
673
+ /* label:value — the column header (data-label) prefixes the value as a muted
674
+ caption on the same line. Suppressed for the title, chip, actions and any
675
+ colspan (group/empty/error) cells below. */
676
+ 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; }
677
+ /* The derived (or mobile:{priority:"primary"}-hinted) card title leads the
678
+ card: larger, full width, no label caption. */
679
+ table.pc-grid td.pc-mcell-primary { display:block; font-size:1rem; font-weight:650; padding-top:.15rem; }
680
+ table.pc-grid td.pc-mcell-primary::before { content:none; }
681
+ /* A badge column becomes a left-aligned status chip with no caption. */
682
+ table.pc-grid td.pc-mcell-chip { justify-content:flex-start; }
683
+ table.pc-grid td.pc-mcell-chip::before { content:none; }
684
+ /* An empty badge cell (row value blank) still carries the chip class but has no
685
+ child content; without this it renders as a blank flex row (padding/gap) that
686
+ leaves a stray empty line in the card. Collapse those empty chip cells (#268). */
687
+ table.pc-grid td.pc-mcell-chip:empty { display:none; }
688
+ /* Low-value (mobile:{priority:"hidden"}) columns drop off the card until the
689
+ row's "More" toggle opens them; on desktop they render as normal columns. */
690
+ table.pc-grid td.pc-mcell-hidden { display:none; }
691
+ table.pc-grid tr.pc-open td.pc-mcell-hidden { display:flex; }
692
+ .pc-mcard-toggle { display:block; padding:.3rem 0 0; }
693
+ .pc-mcard-toggle .pc-more { width:100%; margin-right:0; }
694
+ /* Row actions stack full-width under the card body. */
695
+ table.pc-grid td.pc-row-actions { display:flex; flex-wrap:wrap; gap:.4rem; text-align:left; padding-top:.45rem; white-space:normal; }
696
+ table.pc-grid td.pc-row-actions::before { content:none; }
697
+ table.pc-grid td.pc-row-actions .pc-btn { flex:1 1 auto; margin-right:0; }
698
+ /* Group-header + empty/error rows span the full card width with no caption. */
699
+ table.pc-grid td[colspan] { display:block; }
700
+ table.pc-grid td[colspan]::before { content:none; }
701
+ .pc-group-header td { padding:0; }
702
+ `;
703
+ if (scope === "")
704
+ return css.replace(/^\n/, "");
705
+ // The scoped copy drops the comments (they document the mobile copy) and scopes each rule line.
706
+ return css
707
+ .replace(/\/\*[\s\S]*?\*\//g, "")
708
+ .split("\n")
709
+ .filter((line) => line.trim() !== "")
710
+ .map((line) => {
711
+ const brace = line.indexOf("{");
712
+ if (brace < 0)
713
+ return line;
714
+ const selectors = line
715
+ .slice(0, brace)
716
+ .split(",")
717
+ .map((sel) => `${scope} ${sel.trim()}`)
718
+ .join(", ");
719
+ return ` ${selectors} ${line.slice(brace)}`;
720
+ })
721
+ .join("\n")
722
+ .concat("\n");
723
+ }
650
724
  const MOBILE_MAX_WIDTH = "640px";
651
725
  const RENDERER_CSS = `
652
726
  :root {
@@ -896,44 +970,7 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
896
970
  matchMedia. */
897
971
  @media (max-width:${MOBILE_MAX_WIDTH}) {
898
972
  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
973
+ ${gridCardRules("")} /* Nav → horizontally-scrollable bar (bar) / inline stack (rail). The bar keeps
937
974
  its normal top-of-flow position and scrolls sideways when the pages overflow;
938
975
  an opt-in sticky bar stays pinned to the top via the desktop .pc-sticky rule
939
976
  (top:0). A rail drops its fixed side column and flows inline. */
@@ -951,7 +988,8 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
951
988
  .pc-modal-actions { flex-direction:column; }
952
989
  .pc-modal-actions .pc-btn { width:100%; }
953
990
  }
954
- `;
991
+ /* App-wide card layout (nano-ide#572): the same card rules, at every width. */
992
+ ${gridCardRules('[data-grid-layout="cards"]')}`;
955
993
  // The schema-driven browser renderer (ADR 0042 §3). Plain ES module string served at
956
994
  // /app/runtime.js — it does NOT ship Craft.js (authoring is console-side only). It
957
995
  // 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.0",
3
+ "version": "0.94.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",
@@ -103,7 +103,7 @@
103
103
  },
104
104
  "dependencies": {
105
105
  "@modelcontextprotocol/sdk": "^1.30.0",
106
- "@nanobpm/agentic": "^0.13.0",
106
+ "@nanobpm/agentic": "^0.14.0",
107
107
  "@nanobpm/nano-app-schema": "^0.9.0",
108
108
  "@nanobpm/nano-sdk": "^1.2.5",
109
109
  "@nanobpm/workflow": "^0.14.0",
@@ -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,9 @@ 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
+ // `in`-narrowed so this reads the key whether or not the pinned @nanobpm/nano-app-schema already
648
+ // declares it (nanobpm/nano-bpm#1301); an undeclared/invalid value falls back to "auto".
649
+ gridLayout: parseGridLayout("gridLayout" in decl ? decl.gridLayout : undefined),
633
650
  rowLimit: typeof decl.rowLimit === "number" ? decl.rowLimit : undefined,
634
651
  sourceName: typeof decl.sourceName === "string" ? decl.sourceName : undefined,
635
652
  // Link the shell's "API docs" badge to the app's Swagger UI when it declares an `api`
@@ -664,7 +681,7 @@ function escapeAttr(s: string): string {
664
681
  return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
665
682
  }
666
683
 
667
- function rendererShell(homePage: string, apiDocsPath?: string): string {
684
+ function rendererShell(homePage: string, apiDocsPath: string | undefined, gridLayout: GridLayout): string {
668
685
  // A persistent "API docs" badge (spec-first apps get their Swagger UI linked from their own
669
686
  // UI for free). `target="_blank"` + hardened `rel` so the docs open without giving the docs
670
687
  // tab a handle back to this window (reverse-tabnabbing).
@@ -691,7 +708,7 @@ function rendererShell(homePage: string, apiDocsPath?: string): string {
691
708
  <title>Urban App</title>
692
709
  <style>${RENDERER_CSS}</style>
693
710
  </head>
694
- <body>${apiDocsBadge}
711
+ <body data-grid-layout="${gridLayout}">${apiDocsBadge}
695
712
  <main id="page" data-home="${escapeAttr(homePage)}"><p class="pc-empty">Loading…</p></main>
696
713
  <script>${FORMJS_JS}</script>
697
714
  <script type="module" src=".${RUNTIME_JS_PATH}"></script>
@@ -700,7 +717,7 @@ function rendererShell(homePage: string, apiDocsPath?: string): string {
700
717
  }
701
718
 
702
719
  // The app's stylesheet resolves every colour through the Nano console's
703
- // semantic `--nano-*` token contract (see the Magikcraft/nano-bpm repo's
720
+ // semantic `--nano-*` token contract (see the nanobpm/nano-bpm repo's
704
721
  // `console/src/theme/tokens.css` — MIRROR: keep the palettes below in sync). When the app is embedded in the
705
722
  // console it inherits nothing across the iframe boundary, so the console
706
723
  // postMessages its resolved tokens + appearance and the runtime lays them onto
@@ -714,6 +731,72 @@ function rendererShell(homePage: string, apiDocsPath?: string): string {
714
731
  // stacking) AND the runtime's `matchMedia` mode-switch (which only Tier-2's
715
732
  // optional page-level `mobile` layout variant needs), so the two can never
716
733
  // drift apart on where "narrow" begins.
734
+ /**
735
+ * The dataGrid → card-list rules (#268), in ONE place. They are emitted twice from this single source:
736
+ * unscoped inside the mobile `@media` block (every app, narrow viewports) and scoped to
737
+ * `[data-grid-layout="cards"]` at top level (apps that opt into cards at every width, nano-ide#572).
738
+ * `scope` is prefixed onto every selector of every rule, so the two copies can never drift.
739
+ */
740
+ function gridCardRules(scope: string): string {
741
+ const css = `
742
+ /* dataGrid → card list. Once cells are display:block the fixed colgroup width
743
+ is irrelevant, so a wide table stops overflowing the phone. table-layout is
744
+ reset to auto so the block cells size to content. */
745
+ table.pc-grid { table-layout:auto; }
746
+ table.pc-grid, table.pc-grid tbody, table.pc-grid tr, table.pc-grid td { display:block; width:auto; }
747
+ table.pc-grid colgroup, table.pc-grid thead { display:none; }
748
+ table.pc-grid tr { border:1px solid var(--nano-edge); border-radius:.6rem; padding:.55rem .75rem; margin:.6rem 0; background:var(--nano-panel); }
749
+ table.pc-grid td { border-bottom:0; padding:.15rem 0; display:flex; gap:.75rem; justify-content:space-between; align-items:baseline; }
750
+ /* label:value — the column header (data-label) prefixes the value as a muted
751
+ caption on the same line. Suppressed for the title, chip, actions and any
752
+ colspan (group/empty/error) cells below. */
753
+ 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; }
754
+ /* The derived (or mobile:{priority:"primary"}-hinted) card title leads the
755
+ card: larger, full width, no label caption. */
756
+ table.pc-grid td.pc-mcell-primary { display:block; font-size:1rem; font-weight:650; padding-top:.15rem; }
757
+ table.pc-grid td.pc-mcell-primary::before { content:none; }
758
+ /* A badge column becomes a left-aligned status chip with no caption. */
759
+ table.pc-grid td.pc-mcell-chip { justify-content:flex-start; }
760
+ table.pc-grid td.pc-mcell-chip::before { content:none; }
761
+ /* An empty badge cell (row value blank) still carries the chip class but has no
762
+ child content; without this it renders as a blank flex row (padding/gap) that
763
+ leaves a stray empty line in the card. Collapse those empty chip cells (#268). */
764
+ table.pc-grid td.pc-mcell-chip:empty { display:none; }
765
+ /* Low-value (mobile:{priority:"hidden"}) columns drop off the card until the
766
+ row's "More" toggle opens them; on desktop they render as normal columns. */
767
+ table.pc-grid td.pc-mcell-hidden { display:none; }
768
+ table.pc-grid tr.pc-open td.pc-mcell-hidden { display:flex; }
769
+ .pc-mcard-toggle { display:block; padding:.3rem 0 0; }
770
+ .pc-mcard-toggle .pc-more { width:100%; margin-right:0; }
771
+ /* Row actions stack full-width under the card body. */
772
+ table.pc-grid td.pc-row-actions { display:flex; flex-wrap:wrap; gap:.4rem; text-align:left; padding-top:.45rem; white-space:normal; }
773
+ table.pc-grid td.pc-row-actions::before { content:none; }
774
+ table.pc-grid td.pc-row-actions .pc-btn { flex:1 1 auto; margin-right:0; }
775
+ /* Group-header + empty/error rows span the full card width with no caption. */
776
+ table.pc-grid td[colspan] { display:block; }
777
+ table.pc-grid td[colspan]::before { content:none; }
778
+ .pc-group-header td { padding:0; }
779
+ `;
780
+ if (scope === "") return css.replace(/^\n/, "");
781
+ // The scoped copy drops the comments (they document the mobile copy) and scopes each rule line.
782
+ return css
783
+ .replace(/\/\*[\s\S]*?\*\//g, "")
784
+ .split("\n")
785
+ .filter((line) => line.trim() !== "")
786
+ .map((line) => {
787
+ const brace = line.indexOf("{");
788
+ if (brace < 0) return line;
789
+ const selectors = line
790
+ .slice(0, brace)
791
+ .split(",")
792
+ .map((sel) => `${scope} ${sel.trim()}`)
793
+ .join(", ");
794
+ return ` ${selectors} ${line.slice(brace)}`;
795
+ })
796
+ .join("\n")
797
+ .concat("\n");
798
+ }
799
+
717
800
  const MOBILE_MAX_WIDTH = "640px";
718
801
 
719
802
  const RENDERER_CSS = `
@@ -964,44 +1047,7 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
964
1047
  matchMedia. */
965
1048
  @media (max-width:${MOBILE_MAX_WIDTH}) {
966
1049
  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
1050
+ ${gridCardRules("")} /* Nav → horizontally-scrollable bar (bar) / inline stack (rail). The bar keeps
1005
1051
  its normal top-of-flow position and scrolls sideways when the pages overflow;
1006
1052
  an opt-in sticky bar stays pinned to the top via the desktop .pc-sticky rule
1007
1053
  (top:0). A rail drops its fixed side column and flows inline. */
@@ -1019,7 +1065,8 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
1019
1065
  .pc-modal-actions { flex-direction:column; }
1020
1066
  .pc-modal-actions .pc-btn { width:100%; }
1021
1067
  }
1022
- `;
1068
+ /* App-wide card layout (nano-ide#572): the same card rules, at every width. */
1069
+ ${gridCardRules('[data-grid-layout="cards"]')}`;
1023
1070
 
1024
1071
  // The schema-driven browser renderer (ADR 0042 §3). Plain ES module string served at
1025
1072
  // /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
+ });