@tinkerlandai/indiana-iam 0.3.0 → 0.4.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,214 @@
1
+ import { JSDOM, VirtualConsole } from "jsdom";
2
+ import { documentDom } from "./dom-payload.mjs";
3
+ import { createStateMachine } from "./ui-state-machine.mjs";
4
+ import { previewUiDocument } from "./ui-preview.mjs";
5
+ import {graphSource} from './graph-sources.mjs';
6
+
7
+ /** Build thumbnail states and edges by dispatching the IAM's actual scalar transitions. */
8
+ export function tabUiScenes(manifest, tabKey) {
9
+ const graph = manifest.routeTree,
10
+ tab = graph.ui?.tabs.find((t) => t.tabKey === tabKey);
11
+ if (!tab || !tab.variableKey) throw Error("Unknown controlled source Tab");
12
+ const render = graph.renders.find((r) => r.renderKey === tab.renderKey),
13
+ document = manifest.documents.find(
14
+ (d) => d.documentKey === render.documentKey,
15
+ );
16
+ const dom = new JSDOM(documentDom(document).html, {
17
+ virtualConsole: new VirtualConsole(),
18
+ });
19
+ try {
20
+ const doc = dom.window.document,
21
+ nodes = new Map(
22
+ [...doc.querySelectorAll("[data-iam-node]")].map((n) => [
23
+ n.getAttribute("data-iam-node"),
24
+ n,
25
+ ]),
26
+ );
27
+ const ancestors = [];
28
+ let current = nodes.get(tab.nodeId);
29
+ while (current && ancestors.length < 128) {
30
+ ancestors.push(current);
31
+ current = current.parentElement;
32
+ }
33
+ const conditions = {},
34
+ regions = {},
35
+ values = {},
36
+ ids = new Set(ancestors.map((n) => n.getAttribute("data-iam-node")));
37
+ for (const node of ancestors) {
38
+ if (node.hasAttribute("data-iam-condition")) {
39
+ const truthy = node.getAttribute("data-iam-alternative") === "true",
40
+ root = truthy ? node : node.previousElementSibling;
41
+ if (
42
+ !root ||
43
+ root.getAttribute("data-iam-condition") !==
44
+ node.getAttribute("data-iam-condition")
45
+ )
46
+ throw Error("Invalid source condition pair");
47
+ conditions[root.getAttribute("data-iam-node")] = truthy;
48
+ }
49
+ if (node.hasAttribute("data-iam-region-value")) {
50
+ const region = (graph.ui.regions ?? []).find((r) =>
51
+ r.options.some(
52
+ (o) => o.nodeId === node.getAttribute("data-iam-node"),
53
+ ),
54
+ );
55
+ if (region)
56
+ regions[region.regionKey] = node.getAttribute(
57
+ "data-iam-region-value",
58
+ );
59
+ }
60
+ }
61
+ for (const binding of graph.bindings.filter(
62
+ (b) =>
63
+ b.renderKey === tab.renderKey &&
64
+ b.property === "visible" &&
65
+ ids.has(b.nodeId),
66
+ )) {
67
+ if (binding.value.op === "var") values[binding.value.key] = true;
68
+ if (binding.value.op === "not" && binding.value.arg.op === "var")
69
+ values[binding.value.arg.key] = false;
70
+ if (
71
+ binding.value.op === "eq" &&
72
+ binding.value.left.op === "var" &&
73
+ binding.value.right.op === "const"
74
+ )
75
+ values[binding.value.left.key] = binding.value.right.value;
76
+ }
77
+ const scope = nodes.get(tab.scopeNodeId ?? tab.nodeId),
78
+ overlay = scope?.getAttribute("role") === "dialog",
79
+ width = Number(
80
+ /\bw-\[(\d+)px\]/.exec(scope?.getAttribute("class") ?? "")?.[1] || 0,
81
+ );
82
+ const viewport = overlay
83
+ ? {
84
+ width: width ? Math.max(320, width + 48) : 640,
85
+ height: width ? 480 : 400,
86
+ }
87
+ : { width: 480, height: 760 };
88
+ const base = {
89
+ strategy: "content",
90
+ conditions,
91
+ regions,
92
+ values,
93
+ focusNodeId: tab.scopeNodeId ?? tab.nodeId,
94
+ viewport,
95
+ };
96
+ const initial = createStateMachine(
97
+ graph,
98
+ tab.routeKey,
99
+ tab.renderKey,
100
+ ).snapshot(),
101
+ scenes = [];
102
+ for (const item of tab.items) {
103
+ const machine = createStateMachine(graph, tab.routeKey, tab.renderKey);
104
+ machine.preview(values);
105
+ const transition = graph.transitions.find(
106
+ (t) =>
107
+ t.renderKey === tab.renderKey &&
108
+ t.trigger.nodeId === item.nodeId &&
109
+ t.trigger.event === "click",
110
+ );
111
+ const result = transition
112
+ ? machine.dispatch(item.nodeId, "click", {})
113
+ : null;
114
+ if (!result || machine.snapshot()[tab.variableKey] !== item.value)
115
+ continue;
116
+ const changed = Object.fromEntries(
117
+ Object.entries(machine.snapshot()).filter(
118
+ ([key, value]) => initial[key] !== value || key === tab.variableKey,
119
+ ),
120
+ );
121
+ const options = { ...base, values: { ...values, ...changed } },
122
+ preview = previewUiDocument(manifest, document, options),
123
+ sceneDom = new JSDOM(preview.html, {
124
+ virtualConsole: new VirtualConsole(),
125
+ });
126
+ const focused = [
127
+ ...sceneDom.window.document.querySelectorAll("[data-iam-node]"),
128
+ ]
129
+ .find((n) => n.getAttribute("data-iam-node") === base.focusNodeId)
130
+ ?.cloneNode(true);
131
+ focused
132
+ ?.querySelectorAll("[hidden],[role=tablist]")
133
+ .forEach((n) => n.remove());
134
+ const headings = focused
135
+ ? [...focused.querySelectorAll("h1,h2,h3,h4,label,[role=columnheader]")]
136
+ .map((n) => n.textContent.replace(/\s+/g, " ").trim())
137
+ .filter(Boolean)
138
+ : [];
139
+ const content = focused?.textContent.replace(/\s+/g, " ").trim() ?? "";
140
+ const fields = focused
141
+ ? [...focused.querySelectorAll("input,textarea,select")].map((n) => ({
142
+ tag: n.tagName.toLowerCase(),
143
+ label: n.getAttribute("aria-label") ?? "",
144
+ placeholder: n.getAttribute("placeholder") ?? "",
145
+ }))
146
+ : [];
147
+ const allowedIds = new Set([
148
+ ...ancestors.map((n) => n.getAttribute("data-iam-node")),
149
+ ...(scope
150
+ ? [scope, ...scope.querySelectorAll("[data-iam-node]")].map((n) =>
151
+ n.getAttribute("data-iam-node"),
152
+ )
153
+ : []),
154
+ ]);
155
+ scenes.push({
156
+ assumptions: preview.assumptions.filter((a) =>
157
+ allowedIds.has(a.groupKey),
158
+ ),
159
+ value: item.value,
160
+ label:
161
+ nodes.get(item.nodeId)?.textContent.replace(/\s+/g, " ").trim() ||
162
+ item.value,
163
+ options,
164
+ headings: [...new Set(headings)].slice(0, 8),
165
+ content: content.slice(0, 1000),
166
+ fields,
167
+ collections: preview.collectionChoices.filter((c) => {
168
+ const row = (graph.ui.collections ?? []).find(
169
+ (r) => r.collectionKey === c.collectionKey,
170
+ ),
171
+ node = row && nodes.get(row.nodeId);
172
+ return node && (scope === node || scope?.contains(node));
173
+ }),
174
+ unobserved: preview.unobserved.filter((u) =>
175
+ allowedIds.has(u.groupKey),
176
+ ),
177
+ transitionKey: result.transitionKey,
178
+ changedNodeIds: tab.contentNodeIds ?? [],
179
+ });
180
+ sceneDom.window.close();
181
+ }
182
+ const edges = [];
183
+ for (const from of scenes)
184
+ for (const to of scenes) {
185
+ if (from.value === to.value) continue;
186
+ const item = tab.items.find((i) => i.value === to.value),
187
+ machine = createStateMachine(graph, tab.routeKey, tab.renderKey);
188
+ machine.preview(from.options.values);
189
+ const result = machine.dispatch(item.nodeId, "click", {});
190
+ if (!result || machine.snapshot()[tab.variableKey] !== to.value)
191
+ continue;
192
+ const transition = graph.transitions.find(
193
+ (t) => t.transitionKey === result.transitionKey,
194
+ );
195
+ edges.push({
196
+ from: from.value,
197
+ to: to.value,
198
+ transitionKey: result.transitionKey,
199
+ event: transition.trigger.event,
200
+ variableKey: tab.variableKey,
201
+ writes: transition.stateWrites.map((w) => ({
202
+ variableKey: w.variableKey,
203
+ label: graph.variables.find((v) => v.variableKey === w.variableKey)
204
+ .label,
205
+ value: machine.snapshot()[w.variableKey],
206
+ })),
207
+ sourceRefs: [graphSource(graph,transition), tab.scopeSource],
208
+ });
209
+ }
210
+ return { tabKey, scenes, edges };
211
+ } finally {
212
+ dom.window.close();
213
+ }
214
+ }
@@ -0,0 +1,225 @@
1
+ import { JSDOM, VirtualConsole } from "jsdom";
2
+ import { documentDom } from "./dom-payload.mjs";
3
+ import { createStateMachine } from "./ui-state-machine.mjs";
4
+ import { applySourceAttributes } from "./source-attributes.mjs";
5
+ import {
6
+ applySourceCollections,
7
+ markUnobservedData,
8
+ } from "./source-collections.mjs";
9
+
10
+ /** Inert source alternatives for UI inspection. This never changes the admitted render or SDK targets. */
11
+ export function previewUiDocument(manifest, document, options = {}) {
12
+ const graph = manifest.routeTree,
13
+ render = graph.renders.find((r) => r.documentKey === document.documentKey);
14
+ if (!render) throw Error("Unknown UI preview render");
15
+ const dom = new JSDOM(documentDom(document).html, {
16
+ virtualConsole: new VirtualConsole(),
17
+ });
18
+ try {
19
+ const doc = dom.window.document;
20
+ const nodes = new Map(
21
+ [...doc.querySelectorAll("[data-iam-node]")].map((n) => [
22
+ n.getAttribute("data-iam-node"),
23
+ n,
24
+ ]),
25
+ );
26
+ const sourceConditions = new Map(
27
+ (graph.ui?.conditions ?? []).map((c) => [c.conditionKey, c]),
28
+ );
29
+ const conditions = [
30
+ ...doc.querySelectorAll(
31
+ '[data-iam-condition][data-iam-alternative="true"]',
32
+ ),
33
+ ].map((node) => {
34
+ const source = sourceConditions.get(
35
+ node.getAttribute("data-iam-condition"),
36
+ );
37
+ if (!source) throw Error("Unknown source condition");
38
+ const other = node.nextElementSibling;
39
+ return {
40
+ ...source,
41
+ defaultChoice:
42
+ node.getAttribute("data-iam-condition-default") === "false"
43
+ ? false
44
+ : undefined,
45
+ groupKey: node.getAttribute("data-iam-node"),
46
+ truthyNodeId: node.getAttribute("data-iam-node"),
47
+ falsyNodeId:
48
+ other?.getAttribute("data-iam-condition") === source.conditionKey &&
49
+ other.getAttribute("data-iam-alternative") === "false"
50
+ ? other.getAttribute("data-iam-node")
51
+ : null,
52
+ };
53
+ });
54
+ const selections = new Map();
55
+ const groups = new Map();
56
+ for (const row of conditions) {
57
+ if (!groups.has(row.groupKey)) groups.set(row.groupKey, []);
58
+ groups.get(row.groupKey).push(row);
59
+ }
60
+ if (options.strategy !== undefined && options.strategy !== "content")
61
+ throw Error("Unknown UI preview strategy");
62
+ for (const [key, rows] of groups) {
63
+ const supplied = options.conditions?.[key];
64
+ if (supplied !== undefined && typeof supplied !== "boolean")
65
+ throw Error("Invalid UI condition choice");
66
+ if (supplied !== undefined) selections.set(key, supplied);
67
+ else if (options.strategy === "content") {
68
+ if (rows[0].observation) continue;
69
+ if (rows[0].defaultChoice !== undefined) {
70
+ selections.set(key, rows[0].defaultChoice);
71
+ continue;
72
+ }
73
+ const score = (id) => {
74
+ const node = nodes.get(id);
75
+ return node
76
+ ? node.textContent.trim().length +
77
+ node.querySelectorAll("input,button,a,textarea,select").length *
78
+ 24
79
+ : 0;
80
+ };
81
+ selections.set(
82
+ key,
83
+ rows.reduce(
84
+ (n, r) => n + score(r.truthyNodeId) - score(r.falsyNodeId),
85
+ 0,
86
+ ) >= 0,
87
+ );
88
+ }
89
+ }
90
+ for (const key of Object.keys(options.conditions ?? {}))
91
+ if (!groups.has(key)) throw Error("Unknown UI condition");
92
+ for (const row of conditions)
93
+ if (selections.has(row.groupKey)) {
94
+ nodes
95
+ .get(row.truthyNodeId)
96
+ ?.toggleAttribute("hidden", !selections.get(row.groupKey));
97
+ if (row.falsyNodeId)
98
+ nodes
99
+ .get(row.falsyNodeId)
100
+ ?.toggleAttribute("hidden", selections.get(row.groupKey));
101
+ }
102
+ const machine = createStateMachine(
103
+ graph,
104
+ render.routeKey,
105
+ render.renderKey,
106
+ );
107
+ if (options.values) machine.preview(options.values);
108
+ for (const binding of machine.bindings()) {
109
+ const node = nodes.get(binding.nodeId);
110
+ if (!node) continue;
111
+ if (binding.property === "visible")
112
+ node.toggleAttribute("hidden", !binding.value);
113
+ else if (binding.property === "text")
114
+ node.textContent = String(binding.value);
115
+ else if (binding.property === "className")
116
+ node.setAttribute("class", String(binding.value));
117
+ else if (["disabled", "checked"].includes(binding.property))
118
+ node.toggleAttribute(binding.property, Boolean(binding.value));
119
+ else if (
120
+ binding.property === "value" &&
121
+ node.getAttribute("type") !== "password"
122
+ )
123
+ node.setAttribute("value", String(binding.value));
124
+ }
125
+ const values = machine.snapshot();
126
+ for (const tab of graph.ui?.tabs ?? [])
127
+ if (tab.renderKey === render.renderKey) {
128
+ const selected = tab.variableKey
129
+ ? values[tab.variableKey]
130
+ : tab.initialValue;
131
+ for (const item of tab.items)
132
+ nodes
133
+ .get(item.nodeId)
134
+ ?.setAttribute("aria-selected", String(item.value === selected));
135
+ }
136
+ const regions = (graph.ui?.regions ?? []).filter(
137
+ (r) => r.renderKey === render.renderKey,
138
+ ),
139
+ componentChoices = [];
140
+ for (const key of Object.keys(options.regions ?? {}))
141
+ if (!regions.some((r) => r.regionKey === key))
142
+ throw Error("Unknown component region");
143
+ for (const region of regions) {
144
+ const requested = options.regions?.[region.regionKey],
145
+ stateValue = region.variableKey ? values[region.variableKey] : null;
146
+ const selected =
147
+ requested ??
148
+ (options.strategy === "content"
149
+ ? region.options.some((o) => o.value === stateValue)
150
+ ? stateValue
151
+ : (region.initialValue ?? region.options[0].value)
152
+ : null);
153
+ if (
154
+ selected !== null &&
155
+ !region.options.some((o) => o.value === selected)
156
+ )
157
+ throw Error("Unknown source component alternative");
158
+ if (selected !== null) {
159
+ for (const option of region.options)
160
+ nodes
161
+ .get(option.nodeId)
162
+ ?.toggleAttribute("hidden", option.value !== selected);
163
+ componentChoices.push({
164
+ regionKey: region.regionKey,
165
+ value: selected,
166
+ source: region.source,
167
+ expression: region.expression,
168
+ });
169
+ }
170
+ }
171
+ applySourceAttributes(doc, graph.ui, render.renderKey, values);
172
+ const focus = options.focusNodeId ? nodes.get(options.focusNodeId) : null;
173
+ const collectionUi = focus
174
+ ? {
175
+ ...graph.ui,
176
+ collections: (graph.ui?.collections ?? []).filter((row) => {
177
+ const node = nodes.get(row.nodeId);
178
+ return node && focus.contains(node);
179
+ }),
180
+ }
181
+ : graph.ui;
182
+ const collectionChoices = applySourceCollections(
183
+ doc,
184
+ collectionUi,
185
+ render.renderKey,
186
+ values,
187
+ options.strategy === "content" || Boolean(options.values),
188
+ );
189
+ const unobserved = markUnobservedData(
190
+ doc,
191
+ graph.ui,
192
+ options.strategy === "content",
193
+ selections,
194
+ );
195
+ const assumptions = [...groups].flatMap(([key, rows]) => {
196
+ if (!selections.has(key)) return [];
197
+ const row = rows.find((r) => {
198
+ const node = nodes.get(
199
+ selections.get(key) ? r.truthyNodeId : r.falsyNodeId,
200
+ );
201
+ return node && !node.parentElement?.closest("[hidden]");
202
+ });
203
+ return row
204
+ ? [
205
+ {
206
+ groupKey: key,
207
+ expression: row.expression,
208
+ selected: selections.get(key),
209
+ source: row.source,
210
+ },
211
+ ]
212
+ : [];
213
+ });
214
+ return {
215
+ html: doc.documentElement.outerHTML,
216
+ assumptions,
217
+ values,
218
+ componentChoices,
219
+ collectionChoices,
220
+ unobserved,
221
+ };
222
+ } finally {
223
+ dom.window.close();
224
+ }
225
+ }
@@ -0,0 +1,76 @@
1
+ /* Generated from assets/state-machine.js by build-runtime.mjs. */
2
+ /* Indiana declarative UI state machine. Pure computation; no merchant callbacks. */
3
+ export function createStateMachine(graph, routeKey, renderKey) {
4
+ {
5
+ renderKey ??= graph.routes.find(route => route.routeKey === routeKey)?.initialRenderKey;
6
+ if (!graph.renders.some(render => render.renderKey === renderKey && render.routeKey === routeKey)) throw new Error("Unknown UI render");
7
+ }
8
+ const owns = row => row.routeKey === routeKey && row.renderKey === renderKey;
9
+ const variables = new Map(graph.variables.filter(owns).map(v => [v.variableKey, v]));
10
+ const transitions = graph.transitions.filter(t => owns(t) && t.simulation === "implemented");
11
+ let values = Object.fromEntries([...variables].map(([key, v]) => [key, v.initialValue]));
12
+ const read = (expression, payload, snapshot, depth = 0) => {
13
+ if (depth > 24) throw new Error("Expression budget exceeded");
14
+ const next = e => read(e, payload, snapshot, depth + 1);
15
+ switch (expression.op) {
16
+ case "const": return expression.value;
17
+ case "var": {
18
+ if (!variables.has(expression.key)) throw new Error("Unknown variable");
19
+ return snapshot[expression.key];
20
+ }
21
+ case "event": {
22
+ if (!Object.hasOwn(payload, expression.key)) throw new Error("Missing event input");
23
+ return payload[expression.key];
24
+ }
25
+ case "not": return !next(expression.arg);
26
+ case "eq": return next(expression.left) === next(expression.right);
27
+ case "ne": return next(expression.left) !== next(expression.right);
28
+ case "add":
29
+ case "subtract": {
30
+ const left = next(expression.left), right = next(expression.right);
31
+ if (typeof left !== "number" || typeof right !== "number" || !Number.isFinite(left) || !Number.isFinite(right)) throw new Error("Invalid numeric expression");
32
+ const value = expression.op === "add" ? left + right : left - right;
33
+ if (!Number.isFinite(value)) throw new Error("Invalid numeric result");
34
+ return value;
35
+ }
36
+ case "all": return expression.args.every(e => next(e) === true);
37
+ case "any": return expression.args.some(e => next(e) === true);
38
+ case "classes": return expression.parts.filter(part=>next(part.when)===true).map(part=>part.value).join(' ');
39
+ default: throw new Error("Unsupported expression");
40
+ }
41
+ };
42
+ return {
43
+ snapshot: () => ({ ...values }),
44
+ preview(input) {
45
+ if (!input || Array.isArray(input) || typeof input !== "object" || Object.keys(input).length > variables.size) throw new Error("Invalid preview values");
46
+ const writes = Object.create(null);
47
+ for (const [key, value] of Object.entries(input)) {
48
+ const variable = variables.get(key);
49
+ if (!variable || typeof value !== variable.valueType || typeof value === "string" && value.length > 4000 || typeof value === "number" && !Number.isFinite(value)) throw new Error("Invalid preview value");
50
+ writes[key] = value;
51
+ }
52
+ values = { ...values, ...writes };
53
+ return { values: { ...values }, evidence: "manual-ui-preview" };
54
+ },
55
+ reset: () => { values = Object.fromEntries([...variables].map(([key, v]) => [key, v.initialValue])); },
56
+ bindings: () => graph.bindings.filter(owns).map(b => ({ ...b, value: read(b.value, {}, values) })),
57
+ dispatch(nodeId, event, payload = {}) {
58
+ const candidates = transitions.filter(t => t.trigger.nodeId === nodeId && t.trigger.event === event);
59
+ for (const t of candidates) {
60
+ const expected = Object.keys(t.trigger.payload);
61
+ if (Object.keys(payload).some(key => !expected.includes(key)) || expected.some(key => typeof payload[key] !== t.trigger.payload[key] || typeof payload[key] === "string" && payload[key].length > 4000)) throw new Error("Invalid event input");
62
+ }
63
+ const eligible = candidates.filter(t => read(t.guard, payload, values) === true);
64
+ if (eligible.length > 1) throw new Error("Ambiguous UI transition");
65
+ if (!eligible.length) return null;
66
+ const t = eligible[0], writes = Object.create(null);
67
+ for (const w of t.stateWrites) {
68
+ const value = read(w.value, payload, values), variable = variables.get(w.variableKey);
69
+ if (!variable || typeof value !== variable.valueType || typeof value === "string" && value.length > 4000 || typeof value === "number" && !Number.isFinite(value)) throw new Error("Invalid state write");
70
+ writes[w.variableKey] = value;
71
+ }
72
+ values = { ...values, ...writes };
73
+ return { transitionKey: t.transitionKey, values: { ...values } };
74
+ },
75
+ };
76
+ }