@nanobpm/urban 0.22.0 → 0.23.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.
@@ -361,6 +361,9 @@ table.pc-grid th { font-weight:600; opacity:.75; }
361
361
  .pc-subform-title { font-weight:600; font-size:.85rem; margin-bottom:.4rem; }
362
362
  .pc-prompt { font-size:.85rem; opacity:.8; margin-bottom:.4rem; white-space:pre-wrap; }
363
363
  .pc-textarea { width:100%; min-height:4rem; padding:.5rem; border:1px solid var(--pc-edge); border-radius:.4rem; font:inherit; }
364
+ .pc-collapse-header { display:flex; align-items:center; gap:.5rem; width:100%; margin:0 0 .75rem; padding:0; background:transparent; border:0; color:inherit; font:inherit; font-size:1rem; font-weight:600; cursor:pointer; text-align:left; }
365
+ .pc-chevron-inline { opacity:.6; font-size:.75rem; width:1em; }
366
+ .pc-card-body[hidden] { display:none; }
364
367
  `;
365
368
  // The schema-driven browser renderer (ADR 0042 §3). Plain ES module string served at
366
369
  // /app/runtime.js — it does NOT ship Craft.js (authoring is console-side only). It
@@ -744,11 +747,83 @@ function renderDataGrid(node) {
744
747
 
745
748
  const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid };
746
749
 
750
+ // Durable per-node UI state. localStorage is keyed by the home page id + node
751
+ // id so two grids on the same page (or the same grid across pages) don't clash,
752
+ // and the collapsed state survives a full reload / new session — not just the
753
+ // refresh poll. Every access is guarded: private-mode or storage-disabled
754
+ // browsers throw on localStorage, and a UI preference must never break render.
755
+ function readCollapsed(key, dflt) {
756
+ try {
757
+ const v = localStorage.getItem(key);
758
+ return v === null ? dflt : v === "1";
759
+ } catch (e) {
760
+ return dflt;
761
+ }
762
+ }
763
+ function writeCollapsed(key, val) {
764
+ try {
765
+ localStorage.setItem(key, val ? "1" : "0");
766
+ } catch (e) {
767
+ // Preference is best-effort; ignore storage failures.
768
+ }
769
+ }
770
+
771
+ // Wrap a rendered node so its card can be collapsed from a clickable header.
772
+ // Opt-in via the node's "collapsible" prop; "defaultCollapsed" seeds the state
773
+ // the first time (before the user has toggled it). Generic across node types —
774
+ // the body (everything the renderer produced, minus its own <h2> title, which
775
+ // becomes the header label) is hidden as one unit.
776
+ function makeCollapsible(node, card) {
777
+ const props = node.props || {};
778
+ if (!props.collapsible) return card;
779
+ // Normalize to a card container. Card renderers already return
780
+ // <section class="pc-card"> — we unwrap those in place (lift out the <h2>
781
+ // title, reparent the rest into the body). A non-card renderer (e.g. text
782
+ // returning <p>/<h1>) is nested whole inside a fresh section instead:
783
+ // appending a <button>/<div> directly into a <p> is invalid markup the
784
+ // browser silently re-parents, breaking layout and click handling.
785
+ const isCard = card.tagName === "SECTION";
786
+ const container = isCard ? card : el("section", { class: "pc-card" });
787
+ const h2 = isCard && card.querySelector ? card.querySelector("h2") : null;
788
+ const titleText = props.title || (h2 ? h2.textContent : "") || "Section";
789
+ if (h2) h2.remove();
790
+ const body = el("div", { class: "pc-card-body" });
791
+ if (isCard) {
792
+ while (card.firstChild) body.append(card.firstChild);
793
+ } else {
794
+ body.append(card);
795
+ }
796
+ const chevron = el("span", { class: "pc-chevron-inline" }, "▾");
797
+ const header = el(
798
+ "button",
799
+ { class: "pc-collapse-header", type: "button" },
800
+ chevron,
801
+ el("span", { class: "pc-collapse-title" }, titleText),
802
+ );
803
+ const storageKey = "pc:collapsed:" + HOME + ":" + (node.id || titleText);
804
+ let collapsed = readCollapsed(storageKey, !!props.defaultCollapsed);
805
+ function apply() {
806
+ body.hidden = collapsed;
807
+ chevron.textContent = collapsed ? "▸" : "▾";
808
+ header.setAttribute("aria-expanded", String(!collapsed));
809
+ }
810
+ header.addEventListener("click", () => {
811
+ collapsed = !collapsed;
812
+ writeCollapsed(storageKey, collapsed);
813
+ apply();
814
+ });
815
+ apply();
816
+ container.append(header, body);
817
+ return container;
818
+ }
819
+
747
820
  async function main() {
748
821
  try {
749
822
  const doc = await getJSON("/app/pages/" + encodeURIComponent(HOME));
750
823
  if (doc.title) document.title = doc.title;
751
- root.replaceChildren(...(doc.nodes || []).map((n) => (RENDERERS[n.type] || (() => el("div")))(n)));
824
+ root.replaceChildren(
825
+ ...(doc.nodes || []).map((n) => makeCollapsible(n, (RENDERERS[n.type] || (() => el("div")))(n))),
826
+ );
752
827
  } catch (e) {
753
828
  root.replaceChildren(el("p", { class: "pc-msg err" }, "Failed to load page: " + String(e.message || e)));
754
829
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.22.0",
3
+ "version": "0.23.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",
@@ -138,6 +138,37 @@ test("the renderer honours numeric actionForm fields", async () => {
138
138
  assert.match(js, /if \(!Number\.isFinite\(num\)\) continue;/);
139
139
  });
140
140
 
141
+ test("renderer makes collapsible nodes persist their state across sessions", async () => {
142
+ // A node with `collapsible` gets a clickable header that hides/shows the card
143
+ // body, and the collapsed state is written to localStorage keyed by the home
144
+ // page id + node id so it survives a full reload / new session (not just the
145
+ // refresh poll). Storage access is guarded so private-mode browsers can't
146
+ // break render.
147
+ const res = await dispatch("GET", "/app/runtime.js");
148
+ const js = res.body ?? "";
149
+ assert.match(js, /function makeCollapsible\(node, card\)/);
150
+ assert.match(js, /if \(!props\.collapsible\) return card;/);
151
+ // Seed from defaultCollapsed the first time, then from persisted state.
152
+ assert.match(js, /readCollapsed\(storageKey, !!props\.defaultCollapsed\)/);
153
+ // Namespaced, per-node storage key.
154
+ assert.match(js, /"pc:collapsed:" \+ HOME \+ ":" \+ \(node\.id/);
155
+ // Persist on toggle.
156
+ assert.match(js, /writeCollapsed\(storageKey, collapsed\)/);
157
+ // Every storage access is wrapped so a throwing localStorage can't break the UI.
158
+ assert.match(js, /localStorage\.getItem\(key\)/);
159
+ assert.match(js, /localStorage\.setItem\(key, val \? "1" : "0"\)/);
160
+ assert.match(js, /catch \(e\) \{\s*return dflt;/);
161
+ // And the wrapper is actually applied at the dispatch layer.
162
+ assert.match(js, /makeCollapsible\(n, \(RENDERERS\[n\.type\]/);
163
+ // Non-card renderer output (e.g. text -> <p>) is nested whole inside a fresh
164
+ // <section class="pc-card"> rather than having a <button>/<div> injected into
165
+ // it — so the feature is valid markup across every node type, not just cards.
166
+ assert.match(js, /const isCard = card\.tagName === "SECTION";/);
167
+ assert.match(js, /const container = isCard \? card : el\("section", \{ class: "pc-card" \}\)/);
168
+ assert.match(js, /body\.append\(card\)/);
169
+ assert.match(js, /container\.append\(header, body\);\s*return container;/);
170
+ });
171
+
141
172
  test("GET /app/pages/<id> returns the page json, 404 for unknown", async () => {
142
173
  const ok = await dispatch("GET", "/app/pages/home");
143
174
  assert.equal(ok.status, 200);
@@ -391,6 +391,9 @@ table.pc-grid th { font-weight:600; opacity:.75; }
391
391
  .pc-subform-title { font-weight:600; font-size:.85rem; margin-bottom:.4rem; }
392
392
  .pc-prompt { font-size:.85rem; opacity:.8; margin-bottom:.4rem; white-space:pre-wrap; }
393
393
  .pc-textarea { width:100%; min-height:4rem; padding:.5rem; border:1px solid var(--pc-edge); border-radius:.4rem; font:inherit; }
394
+ .pc-collapse-header { display:flex; align-items:center; gap:.5rem; width:100%; margin:0 0 .75rem; padding:0; background:transparent; border:0; color:inherit; font:inherit; font-size:1rem; font-weight:600; cursor:pointer; text-align:left; }
395
+ .pc-chevron-inline { opacity:.6; font-size:.75rem; width:1em; }
396
+ .pc-card-body[hidden] { display:none; }
394
397
  `;
395
398
 
396
399
  // The schema-driven browser renderer (ADR 0042 §3). Plain ES module string served at
@@ -775,11 +778,83 @@ function renderDataGrid(node) {
775
778
 
776
779
  const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid };
777
780
 
781
+ // Durable per-node UI state. localStorage is keyed by the home page id + node
782
+ // id so two grids on the same page (or the same grid across pages) don't clash,
783
+ // and the collapsed state survives a full reload / new session — not just the
784
+ // refresh poll. Every access is guarded: private-mode or storage-disabled
785
+ // browsers throw on localStorage, and a UI preference must never break render.
786
+ function readCollapsed(key, dflt) {
787
+ try {
788
+ const v = localStorage.getItem(key);
789
+ return v === null ? dflt : v === "1";
790
+ } catch (e) {
791
+ return dflt;
792
+ }
793
+ }
794
+ function writeCollapsed(key, val) {
795
+ try {
796
+ localStorage.setItem(key, val ? "1" : "0");
797
+ } catch (e) {
798
+ // Preference is best-effort; ignore storage failures.
799
+ }
800
+ }
801
+
802
+ // Wrap a rendered node so its card can be collapsed from a clickable header.
803
+ // Opt-in via the node's "collapsible" prop; "defaultCollapsed" seeds the state
804
+ // the first time (before the user has toggled it). Generic across node types —
805
+ // the body (everything the renderer produced, minus its own <h2> title, which
806
+ // becomes the header label) is hidden as one unit.
807
+ function makeCollapsible(node, card) {
808
+ const props = node.props || {};
809
+ if (!props.collapsible) return card;
810
+ // Normalize to a card container. Card renderers already return
811
+ // <section class="pc-card"> — we unwrap those in place (lift out the <h2>
812
+ // title, reparent the rest into the body). A non-card renderer (e.g. text
813
+ // returning <p>/<h1>) is nested whole inside a fresh section instead:
814
+ // appending a <button>/<div> directly into a <p> is invalid markup the
815
+ // browser silently re-parents, breaking layout and click handling.
816
+ const isCard = card.tagName === "SECTION";
817
+ const container = isCard ? card : el("section", { class: "pc-card" });
818
+ const h2 = isCard && card.querySelector ? card.querySelector("h2") : null;
819
+ const titleText = props.title || (h2 ? h2.textContent : "") || "Section";
820
+ if (h2) h2.remove();
821
+ const body = el("div", { class: "pc-card-body" });
822
+ if (isCard) {
823
+ while (card.firstChild) body.append(card.firstChild);
824
+ } else {
825
+ body.append(card);
826
+ }
827
+ const chevron = el("span", { class: "pc-chevron-inline" }, "▾");
828
+ const header = el(
829
+ "button",
830
+ { class: "pc-collapse-header", type: "button" },
831
+ chevron,
832
+ el("span", { class: "pc-collapse-title" }, titleText),
833
+ );
834
+ const storageKey = "pc:collapsed:" + HOME + ":" + (node.id || titleText);
835
+ let collapsed = readCollapsed(storageKey, !!props.defaultCollapsed);
836
+ function apply() {
837
+ body.hidden = collapsed;
838
+ chevron.textContent = collapsed ? "▸" : "▾";
839
+ header.setAttribute("aria-expanded", String(!collapsed));
840
+ }
841
+ header.addEventListener("click", () => {
842
+ collapsed = !collapsed;
843
+ writeCollapsed(storageKey, collapsed);
844
+ apply();
845
+ });
846
+ apply();
847
+ container.append(header, body);
848
+ return container;
849
+ }
850
+
778
851
  async function main() {
779
852
  try {
780
853
  const doc = await getJSON("/app/pages/" + encodeURIComponent(HOME));
781
854
  if (doc.title) document.title = doc.title;
782
- root.replaceChildren(...(doc.nodes || []).map((n) => (RENDERERS[n.type] || (() => el("div")))(n)));
855
+ root.replaceChildren(
856
+ ...(doc.nodes || []).map((n) => makeCollapsible(n, (RENDERERS[n.type] || (() => el("div")))(n))),
857
+ );
783
858
  } catch (e) {
784
859
  root.replaceChildren(el("p", { class: "pc-msg err" }, "Failed to load page: " + String(e.message || e)));
785
860
  }