@nanobpm/urban 0.66.2 → 0.68.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.
@@ -0,0 +1,144 @@
1
+ // Slice S6 CORE (pii guard-core) — the MANDATORY pre-commit PII guard.
2
+ //
3
+ // This is the enforcement seam S3 (git substrate / governance) consumes. It
4
+ // wraps the pure classifier (`./classifier.ts`) in a default-DENY policy: a
5
+ // candidate carrying PII is REJECTED (the guard throws); clean content passes
6
+ // through untouched.
7
+ //
8
+ // ┌────────────────────────────────────────────────────────────────────────┐
9
+ // │ CONTRACT — read before wiring: │
10
+ // │ guard.assert(candidate) → returns void when clean, THROWS │
11
+ // │ PiiGuardError when PII is detected. │
12
+ // │ guard.inspect(candidate) → non-throwing; returns the classification. │
13
+ // │ │
14
+ // │ S3 MUST register `preCommitPiiGuard` (or a `createPiiGuard()` instance) │
15
+ // │ as the NON-OPTIONAL, default pre-commit step on EVERY write path, so │
16
+ // │ PII is blocked BY CONSTRUCTION — not merely when a caller opts in. The │
17
+ // │ later S6 CI slice adds a build-time classification workflow + │
18
+ // │ erasure/immutability docs on TOP of this in-process guard; it must not │
19
+ // │ weaken or bypass it. │
20
+ // └────────────────────────────────────────────────────────────────────────┘
21
+ //
22
+ // This slice owns the classifier + guard ONLY. It has no git / network access
23
+ // and does NOT import `../git` (S3 imports us — the dependency points one way to
24
+ // keep the graph acyclic).
25
+
26
+ import {
27
+ classifyPii,
28
+ type PiiCandidate,
29
+ type PiiClassification,
30
+ type PiiFinding,
31
+ } from "./classifier.ts";
32
+
33
+ /**
34
+ * Thrown by {@link PiiGuard.assert} (and the default guard) when a candidate
35
+ * carries PII. Carries the located {@link PiiFinding}s so callers can log /
36
+ * surface exactly what was rejected without re-running the classifier.
37
+ */
38
+ export class PiiGuardError extends Error {
39
+ /** The located detections that caused the rejection (never empty). */
40
+ readonly findings: readonly PiiFinding[];
41
+
42
+ constructor(findings: readonly PiiFinding[]) {
43
+ const summary = findings
44
+ .map((f) => `${f.kind}${f.path ? ` @ ${f.path}` : ""}`)
45
+ .join(", ");
46
+ super(`PII pre-commit guard rejected the write: ${summary}`);
47
+ this.name = "PiiGuardError";
48
+ // Defensively snapshot: a caller mutating the array it passed in must never
49
+ // retroactively change what this error reports it rejected.
50
+ this.findings = Object.freeze([...findings]);
51
+ }
52
+ }
53
+
54
+ /**
55
+ * A pre-commit guard. The published seam S3 registers as its default,
56
+ * non-bypassable write-path check.
57
+ *
58
+ * Implementations MUST be default-DENY: {@link assert} throws on any detected
59
+ * PII and returns normally only for clean content.
60
+ */
61
+ export interface PiiGuard {
62
+ /** Stable identifier (useful when a registry holds several guards). */
63
+ readonly name: string;
64
+ /**
65
+ * Non-throwing classification — returns the decision so a caller can inspect
66
+ * findings without catching. Does not enforce policy on its own.
67
+ */
68
+ inspect(candidate: PiiCandidate): PiiClassification;
69
+ /**
70
+ * Default-DENY enforcement. Returns `void` when the candidate is clean;
71
+ * THROWS {@link PiiGuardError} (carrying the findings) when PII is detected.
72
+ * This is the method a write path calls immediately before committing.
73
+ */
74
+ assert(candidate: PiiCandidate): void;
75
+ }
76
+
77
+ /**
78
+ * Options for {@link createPiiGuard}. Additive and backward-compatible; the
79
+ * default (no options) is the mandatory, no-PII-by-construction guard.
80
+ */
81
+ export interface PiiGuardOptions {
82
+ /** Override the guard's `name` (defaults to `"pre-commit-pii-guard"`). */
83
+ readonly name?: string;
84
+ /**
85
+ * An ADDITIONAL classifier, unioned with the built-in {@link classifyPii}.
86
+ * The built-in classifier ALWAYS runs; this override's findings are merged on
87
+ * top, so an override can only make the guard STRICTER — it can never suppress
88
+ * a built-in detection or otherwise disable enforcement (a guard is always
89
+ * default-DENY). Intended for tests and for a future stricter policy.
90
+ */
91
+ readonly classify?: (candidate: PiiCandidate) => PiiClassification;
92
+ }
93
+
94
+ /**
95
+ * Build a default-DENY PII guard. With no options this returns the same policy
96
+ * as {@link preCommitPiiGuard}: it classifies with the built-in classifier and
97
+ * rejects any candidate that carries PII. A caller-supplied `classify` runs IN
98
+ * ADDITION to the built-in classifier (findings are unioned), so it can only
99
+ * ever make the guard stricter — never weaker.
100
+ */
101
+ export function createPiiGuard(options: PiiGuardOptions = {}): PiiGuard {
102
+ const name = options.name ?? "pre-commit-pii-guard";
103
+ const override = options.classify;
104
+ // The built-in classifier ALWAYS runs; an override can only ADD findings.
105
+ // This makes the `classify` option unable to disable the default-DENY guard.
106
+ const classify = (candidate: PiiCandidate): PiiClassification => {
107
+ const base = classifyPii(candidate);
108
+ if (override === undefined) return base;
109
+ const extra = override(candidate);
110
+ const findings = [...base.findings, ...extra.findings];
111
+ // Derive the verdict from the merged findings — the single source of truth.
112
+ // A guard is default-DENY, so any `!clean` classification MUST carry the
113
+ // findings that justify it; a non-clean result with no findings would make
114
+ // `assert` throw a PiiGuardError whose summary is empty, contradicting the
115
+ // "findings are never empty" contract. Surface that override bug loudly
116
+ // rather than as a confusing, empty rejection.
117
+ if (findings.length === 0 && (!base.clean || !extra.clean)) {
118
+ throw new TypeError(
119
+ "PII classifier override returned a non-clean classification with no findings; " +
120
+ "an override may only ADD located findings, never report PII without locating it.",
121
+ );
122
+ }
123
+ return findings.length === 0
124
+ ? { clean: true, findings: [] }
125
+ : { clean: false, findings };
126
+ };
127
+ return {
128
+ name,
129
+ inspect(candidate) {
130
+ return classify(candidate);
131
+ },
132
+ assert(candidate) {
133
+ const result = classify(candidate);
134
+ if (!result.clean) throw new PiiGuardError(result.findings);
135
+ },
136
+ };
137
+ }
138
+
139
+ /**
140
+ * The ready-to-use default guard. S3 registers THIS instance as the
141
+ * non-optional, default pre-commit step on every write path so that PII is
142
+ * blocked by construction. Do not gate it behind a caller opt-in.
143
+ */
144
+ export const preCommitPiiGuard: PiiGuard = createPiiGuard();
@@ -1,2 +1,39 @@
1
- // Slice S6 (pii guard-core, then S6 CI) — owner replaces this placeholder barrel.
2
- export {};
1
+ // @nanobpm/urban/context/pii — slice S6 CORE (pii guard-core).
2
+ //
3
+ // The enforcement seam of the Urban context layer. It publishes two things:
4
+ //
5
+ // 1. A PURE PII classifier (`classifyPii`) — inspects candidate record content
6
+ // and returns a located clean/violation decision. Reusable on any hot path
7
+ // and by the S6 CI slice's build-time scan.
8
+ // 2. A MANDATORY, default-DENY pre-commit guard (`preCommitPiiGuard` /
9
+ // `createPiiGuard`) built on the classifier. S3 (git substrate / governance)
10
+ // registers this as the NON-OPTIONAL default pre-commit step on every write
11
+ // path, so PII is blocked BY CONSTRUCTION — not merely when a caller opts in.
12
+ //
13
+ // Contract summary (see ./guard.ts and ./NOTES.md for detail):
14
+ // guard.assert(candidate) → void when clean, THROWS PiiGuardError on PII.
15
+ // guard.inspect(candidate) → non-throwing PiiClassification.
16
+ //
17
+ // The MVP substrate is no-PII by construction; this slice builds neither a
18
+ // mutable/erasure backend nor the CI workflow (that is the later S6 CI slice,
19
+ // which layers a build-time net + erasure/immutability docs on top of this).
20
+ //
21
+ // This slice imports the record types from `../schema` but NOT `../git`: S3
22
+ // imports us, so the dependency points one way and the graph stays acyclic.
23
+
24
+ export {
25
+ type PiiKind,
26
+ type PiiFinding,
27
+ type PiiClassification,
28
+ type PiiCandidate,
29
+ classifyPii,
30
+ isPiiClean,
31
+ } from "./classifier.ts";
32
+
33
+ export {
34
+ type PiiGuard,
35
+ type PiiGuardOptions,
36
+ PiiGuardError,
37
+ createPiiGuard,
38
+ preCommitPiiGuard,
39
+ } from "./guard.ts";
@@ -1550,9 +1550,17 @@ function renderProse(node) {
1550
1550
  // Build a detail/escalation answer form for a single grid row. This is the ONE
1551
1551
  // implementation of the `detail.form` contract — shared verbatim by the
1552
1552
  // top-level grid's detail panel AND a dataGrid child-grid row (#333) so the two
1553
- // can never drift. The form has a single free-text field (f.inputKey), an
1554
- // optional prompt echoed from the row (f.promptField), and a route-driven submit
1555
- // (f.action) whose path/body interpolate {{form.*}} / {{row.*}} tokens. It
1553
+ // can never drift. The form is SCHEMA-DERIVED (#372): its fields come from the
1554
+ // element's `.form` contract, not hand-rolled markup. A form may declare either a
1555
+ // single free-text field (legacy: f.inputKey, rendered exactly as before) or an
1556
+ // array of field descriptors (f.fields) — each a `select` (enumerated
1557
+ // value/label options), `textarea`, or `text` widget bound to its own
1558
+ // f.inputKey, optionally gated by a per-field `conditional.hide` FEEL expression
1559
+ // measured against the form's other field values. Every visible field is
1560
+ // assembled into ONE `{{form}}` body ({ inputKey: value, … }); a hidden field is
1561
+ // omitted. There is an optional prompt echoed from the row (f.promptField), and a
1562
+ // route-driven submit (f.action) whose path/body interpolate {{form.*}} /
1563
+ // {{row.*}} tokens. It
1556
1564
  // renders only when the row's f.showWhenField is truthy (an answerable
1557
1565
  // escalation), returning null otherwise. On a successful submit it invokes
1558
1566
  // onSuccess — the top-level grid re-polls the whole page (pc:refresh) so the
@@ -1580,6 +1588,181 @@ function setChevronOpen(btn, open) {
1580
1588
  btn.setAttribute("aria-expanded", String(open));
1581
1589
  }
1582
1590
 
1591
+ // Normalise a `detail.form` descriptor to a list of field descriptors so the
1592
+ // legacy single-field form (f.inputKey + free-text textarea) and the
1593
+ // schema-derived multi-field form (f.fields) share ONE render/submit path (no
1594
+ // drift). A descriptor carrying f.fields is used verbatim; otherwise the whole
1595
+ // form collapses to a single textarea field bound to f.inputKey — which renders
1596
+ // byte-for-byte as the pre-#372 form.
1597
+ /** @param {any} f
1598
+ * @returns {any[]}
1599
+ */
1600
+ function normalizeDetailFields(f) {
1601
+ if (Array.isArray(f.fields) && f.fields.length > 0) return f.fields;
1602
+ return [{ inputKey: f.inputKey, type: "textarea", inputLabel: f.inputLabel }];
1603
+ }
1604
+
1605
+ // Normalise a select field's enumerated values (from the `.form` contract) to
1606
+ // {value,label} pairs. Accepts either objects ({ value, label } — label
1607
+ // defaults to the value) or bare scalars (value === label), so an adopter can
1608
+ // author the terse or the labelled form without a bespoke shape.
1609
+ /** @param {any} options
1610
+ * @returns {Array<{value: string, label: string}>}
1611
+ */
1612
+ function normalizeDetailOptions(options) {
1613
+ /** @type {Array<{value: string, label: string}>} */
1614
+ const out = [];
1615
+ for (const opt of Array.isArray(options) ? options : []) {
1616
+ if (opt && typeof opt === "object") {
1617
+ const value = opt.value != null ? String(opt.value) : "";
1618
+ const label = opt.label != null ? String(opt.label) : value;
1619
+ out.push({ value, label });
1620
+ } else {
1621
+ out.push({ value: String(opt), label: String(opt) });
1622
+ }
1623
+ }
1624
+ return out;
1625
+ }
1626
+
1627
+ // ── conditional.hide evaluation (a tiny, SAFE FEEL subset) ──────────────────
1628
+ // A `.form` field can carry `conditional.hide` — a FEEL boolean (optionally with
1629
+ // the FEEL `=` prefix, e.g. `=resolution != "answer"`) that, when true, hides
1630
+ // the field from the operator AND drops it from the submitted `{{form}}` body.
1631
+ // We evaluate it against the form's CURRENT field values with a purpose-built
1632
+ // recursive-descent parser — never `eval`/`new Function` — so a schema-supplied
1633
+ // string can't execute arbitrary code. The grammar is deliberately small:
1634
+ // or := and ( "or" and )*
1635
+ // and := cmp ( "and" cmp )*
1636
+ // cmp := primary ( op primary )? op ∈ = == != < <= > >=
1637
+ // primary := ident | string | number | true | false | null | "(" or ")"
1638
+ // An identifier resolves to the same-form field's current value. Anything the
1639
+ // parser can't understand fails OPEN (returns false → the field stays visible),
1640
+ // because erroneously HIDING a field could silently drop a required input.
1641
+ /** @param {string} src
1642
+ * @returns {any[]}
1643
+ */
1644
+ function tokenizeDetailCondition(src) {
1645
+ /** @type {any[]} */
1646
+ const toks = [];
1647
+ const s = String(src);
1648
+ let i = 0;
1649
+ while (i < s.length) {
1650
+ const ch = s[i];
1651
+ if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") { i++; continue; }
1652
+ if (ch === '"' || ch === "'") {
1653
+ const quote = ch; let j = i + 1; let str = ""; let closed = false;
1654
+ while (j < s.length) {
1655
+ if (s[j] === quote) { closed = true; break; }
1656
+ if (s[j] === "\\" && j + 1 < s.length) { str += s[j + 1]; j += 2; } else { str += s[j]; j++; }
1657
+ }
1658
+ // An unterminated string literal can't be part of the grammar; abandon
1659
+ // parsing so the whole expression fails open (field stays visible) rather
1660
+ // than treating a truncated FEEL string as a valid token.
1661
+ if (!closed) throw new Error("unterminated string in condition");
1662
+ toks.push({ t: "val", v: str }); i = j + 1; continue;
1663
+ }
1664
+ if (ch === "(") { toks.push({ t: "(" }); i++; continue; }
1665
+ if (ch === ")") { toks.push({ t: ")" }); i++; continue; }
1666
+ if (ch === "!" && s[i + 1] === "=") { toks.push({ t: "op", v: "!=" }); i += 2; continue; }
1667
+ if (ch === "=" && s[i + 1] === "=") { toks.push({ t: "op", v: "=" }); i += 2; continue; }
1668
+ if (ch === "<" && s[i + 1] === "=") { toks.push({ t: "op", v: "<=" }); i += 2; continue; }
1669
+ if (ch === ">" && s[i + 1] === "=") { toks.push({ t: "op", v: ">=" }); i += 2; continue; }
1670
+ if (ch === "=") { toks.push({ t: "op", v: "=" }); i++; continue; }
1671
+ if (ch === "<") { toks.push({ t: "op", v: "<" }); i++; continue; }
1672
+ if (ch === ">") { toks.push({ t: "op", v: ">" }); i++; continue; }
1673
+ if (/[0-9]/.test(ch) || (ch === "-" && /[0-9]/.test(s[i + 1] || ""))) {
1674
+ let j = i + 1;
1675
+ while (j < s.length && /[0-9.]/.test(s[j])) j++;
1676
+ toks.push({ t: "val", v: Number(s.slice(i, j)) }); i = j; continue;
1677
+ }
1678
+ if (/[A-Za-z_]/.test(ch)) {
1679
+ let j = i + 1;
1680
+ while (j < s.length && /[A-Za-z0-9_.]/.test(s[j])) j++;
1681
+ const word = s.slice(i, j);
1682
+ const lower = word.toLowerCase();
1683
+ if (lower === "and" || lower === "or") toks.push({ t: lower });
1684
+ else if (lower === "true") toks.push({ t: "val", v: true });
1685
+ else if (lower === "false") toks.push({ t: "val", v: false });
1686
+ else if (lower === "null") toks.push({ t: "val", v: null });
1687
+ else toks.push({ t: "id", v: word });
1688
+ i = j; continue;
1689
+ }
1690
+ // An unrecognised character can't be part of the grammar; abandon parsing so
1691
+ // the whole expression fails open (field stays visible) rather than silently
1692
+ // mis-parsing.
1693
+ throw new Error("unexpected character in condition");
1694
+ }
1695
+ return toks;
1696
+ }
1697
+ /** @param {any} a @param {any} b @returns {boolean} */
1698
+ function detailLooseEq(a, b) {
1699
+ if (a === b) return true;
1700
+ if (a == null || b == null) return a == null && b == null;
1701
+ return String(a) === String(b);
1702
+ }
1703
+ /** @param {string} op @param {any} a @param {any} b @returns {boolean} */
1704
+ function detailCompare(op, a, b) {
1705
+ if (op === "=") return detailLooseEq(a, b);
1706
+ if (op === "!=") return !detailLooseEq(a, b);
1707
+ const na = Number(a); const nb = Number(b);
1708
+ if (!Number.isFinite(na) || !Number.isFinite(nb)) return false;
1709
+ if (op === "<") return na < nb;
1710
+ if (op === "<=") return na <= nb;
1711
+ if (op === ">") return na > nb;
1712
+ if (op === ">=") return na >= nb;
1713
+ return false;
1714
+ }
1715
+ // A bare (op-less) primary used where a boolean is expected — a FEEL flag like
1716
+ // `=isUrgent`. Only an explicit boolean true / the string "true" reads as true.
1717
+ /** @param {any} v @returns {boolean} */
1718
+ function detailTruthy(v) { return v === true || v === "true"; }
1719
+ /** @param {any} f
1720
+ * @param {Record<string, any>} values
1721
+ * @returns {boolean}
1722
+ */
1723
+ function evalDetailCondition(f, values) {
1724
+ /** @param {any} p @returns {boolean} */
1725
+ const parseOr = (p) => {
1726
+ let left = parseAnd(p);
1727
+ while (p.i < p.toks.length && p.toks[p.i].t === "or") { p.i++; const right = parseAnd(p); left = left || right; }
1728
+ return left;
1729
+ };
1730
+ /** @param {any} p @returns {boolean} */
1731
+ function parseAnd(p) {
1732
+ let left = parseCmp(p);
1733
+ while (p.i < p.toks.length && p.toks[p.i].t === "and") { p.i++; const right = parseCmp(p); left = left && right; }
1734
+ return left;
1735
+ }
1736
+ /** @param {any} p @returns {boolean} */
1737
+ function parseCmp(p) {
1738
+ const left = parsePrimary(p);
1739
+ const tok = p.toks[p.i];
1740
+ if (tok && tok.t === "op") { p.i++; const right = parsePrimary(p); return detailCompare(tok.v, left, right); }
1741
+ return detailTruthy(left);
1742
+ }
1743
+ /** @param {any} p @returns {any} */
1744
+ function parsePrimary(p) {
1745
+ const tok = p.toks[p.i];
1746
+ if (!tok) throw new Error("unexpected end of condition");
1747
+ if (tok.t === "(") { p.i++; const r = parseOr(p); if (!p.toks[p.i] || p.toks[p.i].t !== ")") throw new Error("missing )"); p.i++; return r; }
1748
+ if (tok.t === "id") { p.i++; return values[tok.v]; }
1749
+ if (tok.t === "val") { p.i++; return tok.v; }
1750
+ throw new Error("unexpected token in condition");
1751
+ }
1752
+ try {
1753
+ let src = String(f == null ? "" : f).trim();
1754
+ if (src === "") return false;
1755
+ if (src.startsWith("=")) src = src.slice(1);
1756
+ const p = { toks: tokenizeDetailCondition(src), i: 0 };
1757
+ if (p.toks.length === 0) return false;
1758
+ const result = parseOr(p);
1759
+ if (p.i !== p.toks.length) return false;
1760
+ return result === true;
1761
+ } catch (e) {
1762
+ return false;
1763
+ }
1764
+ }
1765
+
1583
1766
  /** @param {any} f
1584
1767
  * @param {Record<string, any>} row
1585
1768
  * @param {() => void | Promise<void>} onSuccess
@@ -1592,19 +1775,74 @@ function buildDetailForm(f, row, onSuccess) {
1592
1775
  if (f.promptField && row[f.promptField] != null) {
1593
1776
  box.append(el("div", { class: "pc-prompt" }, String(row[f.promptField])));
1594
1777
  }
1595
- const input = el("textarea", { class: "pc-textarea", placeholder: f.inputLabel || f.inputKey, "aria-label": f.inputLabel || f.inputKey });
1778
+ // Multi-field only when the descriptor supplies f.fields; a legacy single-field
1779
+ // form renders exactly as before (one bare textarea, no visible <label>).
1780
+ const multi = Array.isArray(f.fields) && f.fields.length > 0;
1781
+ const fields = normalizeDetailFields(f);
1596
1782
  // role=status + aria-live so the "Sending…"/success/error transitions are
1597
1783
  // announced to screen readers instead of silently changing text.
1598
1784
  const msg = el("p", { class: "pc-msg", role: "status", "aria-live": "polite" });
1599
1785
  const btn = el("button", { class: "pc-btn pc-btn-sm", type: "button" }, f.submitLabel || "Submit");
1786
+ /** @type {Array<{key: string, input: any, wrap: any, hideExpr: any}>} */
1787
+ const controls = [];
1788
+ for (const fd of fields) {
1789
+ const key = fd.inputKey != null ? fd.inputKey : fd.key;
1790
+ const label = fd.inputLabel || fd.label || key;
1791
+ const type = fd.type === "select" ? "select" : fd.type === "text" ? "text" : "textarea";
1792
+ let input;
1793
+ if (type === "select") {
1794
+ input = el("select", { class: "pc-select", "aria-label": label });
1795
+ for (const opt of normalizeDetailOptions(fd.options)) input.append(el("option", { value: opt.value }, opt.label));
1796
+ if (fd.default != null) input.value = String(fd.default);
1797
+ } else if (type === "text") {
1798
+ input = el("input", { type: "text", class: "pc-input", placeholder: label, "aria-label": label });
1799
+ if (fd.default != null) input.value = String(fd.default);
1800
+ } else {
1801
+ input = el("textarea", { class: "pc-textarea", placeholder: label, "aria-label": label });
1802
+ if (fd.default != null) input.value = String(fd.default);
1803
+ }
1804
+ // A multi-field form gets a visible <label> per widget; the legacy single
1805
+ // free-text form keeps its bare textarea (unchanged DOM).
1806
+ const wrap = multi
1807
+ ? el("div", { class: "pc-field" }, el("label", {}, label), input)
1808
+ : el("div", { class: "pc-field" }, input);
1809
+ const hideExpr = fd.conditional && typeof fd.conditional.hide === "string" ? fd.conditional.hide : null;
1810
+ controls.push({ key, input, wrap, hideExpr });
1811
+ box.append(wrap);
1812
+ }
1813
+ // Snapshot every field's current value, then hide/show each conditional field.
1814
+ // A field with no conditional.hide is always visible; the value map is keyed by
1815
+ // inputKey so a hide expression can reference a sibling field (e.g. `answer`
1816
+ // hidden when `resolution != "answer"`).
1817
+ const applyConditions = () => {
1818
+ /** @type {Record<string, any>} */
1819
+ const values = Object.create(null);
1820
+ for (const c of controls) values[c.key] = c.input.value;
1821
+ for (const c of controls) {
1822
+ const hidden = c.hideExpr != null && evalDetailCondition(c.hideExpr, values);
1823
+ c.wrap.hidden = hidden;
1824
+ }
1825
+ };
1826
+ // Re-evaluate the conditions whenever any field changes so a select flip
1827
+ // reveals/hides its dependent field live.
1828
+ if (controls.some((c) => c.hideExpr != null)) {
1829
+ for (const c of controls) {
1830
+ c.input.addEventListener("change", applyConditions);
1831
+ c.input.addEventListener("input", applyConditions);
1832
+ }
1833
+ applyConditions();
1834
+ }
1600
1835
  btn.addEventListener("click", async () => {
1601
1836
  btn.disabled = true; msg.className = "pc-msg"; msg.textContent = "Sending…";
1602
1837
  try {
1603
- // The textarea value is the form's single field (f.inputKey); route + body
1604
- // template come from f.action, e.g. { path: "/app/actions/message",
1838
+ // Assemble every VISIBLE field into one form body, keyed by its inputKey;
1839
+ // route + body template come from f.action, e.g. { path: "/app/actions/message",
1605
1840
  // body: { name, correlationKey: "{{row.pr_key}}", variables: "{{form}}" } }.
1606
- // Null-proto so an f.inputKey of "__proto__"/"constructor" can't mutate a prototype.
1607
- const form = Object.create(null); form[f.inputKey] = input.value;
1841
+ // Null-proto so an inputKey of "__proto__"/"constructor" can't mutate a
1842
+ // prototype. A hidden (conditional.hide) field is omitted so a stale value
1843
+ // for an inapplicable choice never reaches the action.
1844
+ const form = Object.create(null);
1845
+ for (const c of controls) { if (c.wrap.hidden) continue; form[c.key] = c.input.value; }
1608
1846
  await runRoute(f.action, { form, row });
1609
1847
  msg.className = "pc-msg ok"; msg.textContent = (f.action && f.action.successLabel) || "Sent";
1610
1848
  // Await onSuccess so a refresh that rejects (e.g. a child grid's re-fetch
@@ -1617,7 +1855,7 @@ function buildDetailForm(f, row, onSuccess) {
1617
1855
  btn.disabled = false; msg.className = "pc-msg err"; msg.textContent = String(e.message || e);
1618
1856
  }
1619
1857
  });
1620
- box.append(el("div", { class: "pc-field" }, input), btn, msg);
1858
+ box.append(btn, msg);
1621
1859
  return box;
1622
1860
  }
1623
1861
 
@@ -2468,4 +2706,4 @@ function boot() {
2468
2706
  // renderer functions / RENDERERS registry can be imported directly.
2469
2707
  if (typeof document !== "undefined" && document.getElementById("page")) boot();
2470
2708
 
2471
- export { RENDERERS, renderText, renderButton, renderProse, renderNav, navLink, wireNavBadge, applyNavBadge, teardown, fmtCellValue, gridCell, buildDetailForm, chevronToggle, setChevronOpen };
2709
+ export { RENDERERS, renderText, renderButton, renderProse, renderNav, navLink, wireNavBadge, applyNavBadge, teardown, fmtCellValue, gridCell, buildDetailForm, evalDetailCondition, normalizeDetailOptions, chevronToggle, setChevronOpen };