@veil-runtime/ui 0.0.0-stage → 0.3.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.
- package/CHANGELOG.md +56 -0
- package/LICENSE +201 -0
- package/NOTICE +6 -0
- package/README.md +100 -2
- package/SECURITY.md +11 -0
- package/dist/application.d.ts +56 -0
- package/dist/application.js +9 -0
- package/dist/build-manifest.json +95 -0
- package/dist/capability-view-derivation.d.ts +40 -0
- package/dist/capability-view-derivation.js +185 -0
- package/dist/capability-view.d.ts +170 -0
- package/dist/capability-view.js +1 -0
- package/dist/composed-surface.d.ts +67 -0
- package/dist/composed-surface.js +1 -0
- package/dist/composition-inspection.d.ts +51 -0
- package/dist/composition-inspection.js +102 -0
- package/dist/composition.d.ts +43 -0
- package/dist/composition.js +227 -0
- package/dist/derive-input-fields.d.ts +4 -0
- package/dist/derive-input-fields.js +63 -0
- package/dist/derive-output-view.d.ts +26 -0
- package/dist/derive-output-view.js +97 -0
- package/dist/dom.d.ts +31 -0
- package/dist/dom.js +554 -0
- package/dist/execution-request.d.ts +13 -0
- package/dist/execution-request.js +58 -0
- package/dist/experience-orchestration.d.ts +13 -0
- package/dist/experience-orchestration.js +31 -0
- package/dist/format-semantic-value.d.ts +4 -0
- package/dist/format-semantic-value.js +15 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +20 -0
- package/dist/input-schema.d.ts +25 -0
- package/dist/input-schema.js +131 -0
- package/dist/interaction-resolution.d.ts +27 -0
- package/dist/interaction-resolution.js +179 -0
- package/dist/interface-transaction.d.ts +9 -0
- package/dist/interface-transaction.js +14 -0
- package/dist/interface.d.ts +196 -0
- package/dist/interface.js +375 -0
- package/dist/output-presentation-suggestion-schema.d.ts +12 -0
- package/dist/output-presentation-suggestion-schema.js +8 -0
- package/dist/output-schema.d.ts +26 -0
- package/dist/output-schema.js +213 -0
- package/dist/presentation-composition.d.ts +29 -0
- package/dist/presentation-composition.js +25 -0
- package/dist/presentation-context.d.ts +6 -0
- package/dist/presentation-context.js +1 -0
- package/dist/presentation-suggestion-schema.d.ts +15 -0
- package/dist/presentation-suggestion-schema.js +10 -0
- package/dist/registry.d.ts +21 -0
- package/dist/registry.js +71 -0
- package/dist/relationship-interpretation.d.ts +9 -0
- package/dist/relationship-interpretation.js +16 -0
- package/dist/validate-application.d.ts +24 -0
- package/dist/validate-application.js +253 -0
- package/dist/validation-safety.d.ts +6 -0
- package/dist/validation-safety.js +90 -0
- package/dist/workflow.d.ts +18 -0
- package/dist/workflow.js +3 -0
- package/docs/API.md +19 -0
- package/docs/COMPOSITION.md +67 -0
- package/docs/DECISIONS.md +16 -0
- package/docs/RELEASE-0.3.0-rc.1.md +25 -0
- package/docs/RELEASE-0.3.0-rc.2.md +9 -0
- package/docs/RELEASE-0.3.0.md +7 -0
- package/docs/THIRD_PARTY.md +16 -0
- package/docs/UNIVERSAL-INTERFACE.md +105 -0
- package/docs/third-party/ZOD-LICENSE.txt +21 -0
- package/examples/composed/app.mjs +44 -0
- package/examples/composed/build.mjs +7 -0
- package/examples/composed/index.html +34 -0
- package/examples/standalone/index.mjs +78 -0
- package/examples/standalone/package.json +8 -0
- package/examples/universal/agent.mjs +52 -0
- package/examples/universal/application.mjs +16 -0
- package/examples/universal/build.mjs +6 -0
- package/examples/universal/human.mjs +16 -0
- package/examples/universal/index.html +4 -0
- package/examples/universal/main.mjs +7 -0
- package/package.json +90 -4
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { deriveUIField } from "./derive-input-fields.js";
|
|
2
|
+
import { validateRichCapabilityInput, validateRichInputSchema, validateRichInputValue } from "./input-schema.js";
|
|
3
|
+
import { assertBoundedData, assertSafeKey, copyData } from "./validation-safety.js";
|
|
4
|
+
const builtins = new Set(["Stack", "Form", "Field", "Submit", "Text", "Status"]);
|
|
5
|
+
const identifier = /^[A-Za-z][A-Za-z0-9._-]{0,127}$/;
|
|
6
|
+
export function defineComposedApplication(definition) {
|
|
7
|
+
return definition;
|
|
8
|
+
}
|
|
9
|
+
export function validateComposedApplication(definition, customComponents = []) {
|
|
10
|
+
assertBoundedData(definition);
|
|
11
|
+
if (typeof definition.id !== "string" || !identifier.test(definition.id) || typeof definition.title !== "string" || !definition.title.trim())
|
|
12
|
+
throw new Error("Invalid application identity");
|
|
13
|
+
if (!definition.fields || typeof definition.fields !== "object" || Array.isArray(definition.fields))
|
|
14
|
+
throw new Error("Invalid field schema");
|
|
15
|
+
validateRichInputSchema(definition.fields);
|
|
16
|
+
for (const [name, field] of Object.entries(definition.fields)) {
|
|
17
|
+
if (!identifier.test(name) || typeof field.required !== "boolean" || typeof field.description !== "string")
|
|
18
|
+
throw new Error(`Invalid field contract: ${name}`);
|
|
19
|
+
if (field.enum !== undefined && (!Array.isArray(field.enum) || field.enum.length === 0 || new Set(field.enum).size !== field.enum.length))
|
|
20
|
+
throw new Error(`Invalid enum: ${name}`);
|
|
21
|
+
if (field.format !== undefined && !["date", "date-time", "multiline"].includes(field.format))
|
|
22
|
+
throw new Error(`Invalid format: ${name}`);
|
|
23
|
+
if (field.semantic !== undefined && field.semantic !== "currency")
|
|
24
|
+
throw new Error(`Invalid semantic: ${name}`);
|
|
25
|
+
if (field.currency !== undefined && (typeof field.currency !== "string" || !/^[A-Z]{3}$/.test(field.currency)))
|
|
26
|
+
throw new Error(`Invalid currency: ${name}`);
|
|
27
|
+
for (const key of ["minLength", "maxLength", "minItems", "maxItems"]) {
|
|
28
|
+
const value = field[key];
|
|
29
|
+
if (value !== undefined && (!Number.isSafeInteger(value) || value < 0))
|
|
30
|
+
throw new Error(`Invalid ${key}: ${name}`);
|
|
31
|
+
}
|
|
32
|
+
for (const key of ["minimum", "maximum"]) {
|
|
33
|
+
const value = field[key];
|
|
34
|
+
if (value !== undefined && (typeof value !== "number" || !Number.isFinite(value)))
|
|
35
|
+
throw new Error(`Invalid ${key}: ${name}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (!Array.isArray(definition.actions) || new Set(definition.actions).size !== definition.actions.length || definition.actions.some((id) => typeof id !== "string" || !identifier.test(id)))
|
|
39
|
+
throw new Error("Invalid action list");
|
|
40
|
+
for (const name of customComponents) {
|
|
41
|
+
if (!identifier.test(name))
|
|
42
|
+
throw new Error(`Invalid component name: ${name}`);
|
|
43
|
+
assertSafeKey(name);
|
|
44
|
+
}
|
|
45
|
+
const known = new Set([...builtins, ...customComponents]);
|
|
46
|
+
if (known.size !== builtins.size + customComponents.length)
|
|
47
|
+
throw new Error("Component names must be unique and cannot replace built-ins");
|
|
48
|
+
const fields = new Set();
|
|
49
|
+
const actions = new Set();
|
|
50
|
+
function visit(node, insideForm) {
|
|
51
|
+
if (!node || typeof node !== "object" || !known.has(node.component))
|
|
52
|
+
throw new Error(`Unknown component: ${String(node?.component)}`);
|
|
53
|
+
if (node.children !== undefined && !Array.isArray(node.children))
|
|
54
|
+
throw new Error("Component children must be an array");
|
|
55
|
+
if (node.component === "Form" && insideForm)
|
|
56
|
+
throw new Error("Nested forms are unsupported");
|
|
57
|
+
if (node.component === "Field") {
|
|
58
|
+
if (!insideForm || !node.field || !Object.hasOwn(definition.fields, node.field) || fields.has(node.field))
|
|
59
|
+
throw new Error(`Invalid or duplicate field: ${node.field}`);
|
|
60
|
+
const type = definition.fields[node.field]?.type;
|
|
61
|
+
if (type === "object" || type === "array")
|
|
62
|
+
throw new Error(`Structured field requires a custom component: ${node.field}`);
|
|
63
|
+
fields.add(node.field);
|
|
64
|
+
}
|
|
65
|
+
else if (node.field !== undefined)
|
|
66
|
+
throw new Error("Only Field nodes accept a field binding");
|
|
67
|
+
if (node.component === "Submit") {
|
|
68
|
+
if (!insideForm || !node.action || !definition.actions.includes(node.action) || actions.has(node.action))
|
|
69
|
+
throw new Error(`Invalid or duplicate action: ${node.action}`);
|
|
70
|
+
actions.add(node.action);
|
|
71
|
+
}
|
|
72
|
+
else if (node.action !== undefined)
|
|
73
|
+
throw new Error("Only Submit nodes accept an action");
|
|
74
|
+
if (node.text !== undefined && (typeof node.text !== "string" || node.text.length > 4096))
|
|
75
|
+
throw new Error("Invalid component text");
|
|
76
|
+
if (node.component === "Field" || node.component === "Submit" || node.component === "Text" || node.component === "Status") {
|
|
77
|
+
if (node.children?.length)
|
|
78
|
+
throw new Error(`${node.component} cannot contain children`);
|
|
79
|
+
}
|
|
80
|
+
for (const child of node.children ?? [])
|
|
81
|
+
visit(child, insideForm || node.component === "Form");
|
|
82
|
+
}
|
|
83
|
+
visit(definition.screen, false);
|
|
84
|
+
if (fields.size !== Object.keys(definition.fields).length || actions.size !== definition.actions.length)
|
|
85
|
+
throw new Error("All fields and actions must appear in the screen");
|
|
86
|
+
if (definition.initialState !== undefined) {
|
|
87
|
+
if (!definition.initialState || typeof definition.initialState !== "object" || Array.isArray(definition.initialState))
|
|
88
|
+
throw new Error("Invalid initial state");
|
|
89
|
+
for (const [key, value] of Object.entries(definition.initialState)) {
|
|
90
|
+
const field = Object.hasOwn(definition.fields, key) ? definition.fields[key] : undefined;
|
|
91
|
+
if (!field)
|
|
92
|
+
throw new Error(`Unknown initial field: ${key}`);
|
|
93
|
+
validateRichInputValue(value, { ...field, required: false }, key);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export function createCompositionController(definition, handlers, customComponents = []) {
|
|
98
|
+
validateComposedApplication(definition, customComponents);
|
|
99
|
+
const source = copyData(definition);
|
|
100
|
+
for (const action of source.actions)
|
|
101
|
+
if (!Object.hasOwn(handlers, action) || typeof handlers[action] !== "function")
|
|
102
|
+
throw new Error(`Missing action handler: ${action}`);
|
|
103
|
+
const listeners = new Set();
|
|
104
|
+
const values = Object.fromEntries(Object.keys(source.fields).map((name) => [name, source.initialState && Object.hasOwn(source.initialState, name) ? source.initialState[name] : (source.fields[name]?.type === "boolean" ? false : "")]));
|
|
105
|
+
let errors = {};
|
|
106
|
+
let status = "idle";
|
|
107
|
+
let message = "";
|
|
108
|
+
let disposed = false;
|
|
109
|
+
const snapshot = () => copyData({ values, errors, status, message });
|
|
110
|
+
const assertSnapshot = (nextValues, nextErrors, nextStatus, nextMessage) => {
|
|
111
|
+
assertBoundedData({ values: nextValues, errors: nextErrors, status: nextStatus, message: nextMessage });
|
|
112
|
+
};
|
|
113
|
+
const errorBudget = Object.fromEntries(Object.entries(source.fields).map(([name, field]) => [name, `Check ${deriveUIField(name, field).label}`]));
|
|
114
|
+
const assertValuesResource = (nextValues) => {
|
|
115
|
+
assertSnapshot(nextValues, errorBudget, "error", "Unable to save. Please try again.");
|
|
116
|
+
};
|
|
117
|
+
assertValuesResource(values);
|
|
118
|
+
const emit = () => { for (const listener of listeners) {
|
|
119
|
+
try {
|
|
120
|
+
listener(snapshot());
|
|
121
|
+
}
|
|
122
|
+
catch { /* Host listener failures cannot change the transaction. */ }
|
|
123
|
+
} };
|
|
124
|
+
return {
|
|
125
|
+
get definition() { return copyData(source); },
|
|
126
|
+
snapshot,
|
|
127
|
+
field(name) {
|
|
128
|
+
const field = Object.hasOwn(source.fields, name) ? source.fields[name] : undefined;
|
|
129
|
+
if (!field)
|
|
130
|
+
throw new Error(`Unknown field: ${name}`);
|
|
131
|
+
return deriveUIField(name, field);
|
|
132
|
+
},
|
|
133
|
+
setField(name, value) {
|
|
134
|
+
if (disposed)
|
|
135
|
+
return;
|
|
136
|
+
const field = Object.hasOwn(source.fields, name) ? source.fields[name] : undefined;
|
|
137
|
+
if (!field)
|
|
138
|
+
throw new Error(`Unknown field: ${name}`);
|
|
139
|
+
if (status === "submitting")
|
|
140
|
+
return;
|
|
141
|
+
assertBoundedData(value);
|
|
142
|
+
const nextValues = { ...values, [name]: copyData(value) };
|
|
143
|
+
const next = { ...errors };
|
|
144
|
+
try {
|
|
145
|
+
validateRichInputValue(value, field, name);
|
|
146
|
+
delete next[name];
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
next[name] = `Check ${deriveUIField(name, field).label}`;
|
|
150
|
+
}
|
|
151
|
+
assertValuesResource(nextValues);
|
|
152
|
+
Object.assign(values, nextValues);
|
|
153
|
+
errors = next;
|
|
154
|
+
status = "idle";
|
|
155
|
+
message = "";
|
|
156
|
+
emit();
|
|
157
|
+
},
|
|
158
|
+
async submit(action) {
|
|
159
|
+
if (disposed)
|
|
160
|
+
return false;
|
|
161
|
+
if (!source.actions.includes(action))
|
|
162
|
+
throw new Error(`Unknown action: ${action}`);
|
|
163
|
+
if (status === "submitting")
|
|
164
|
+
return false;
|
|
165
|
+
const next = {};
|
|
166
|
+
for (const [name, field] of Object.entries(source.fields)) {
|
|
167
|
+
try {
|
|
168
|
+
validateRichInputValue(values[name], field, name);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
next[name] = `Check ${deriveUIField(name, field).label}`;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
assertSnapshot(values, next, Object.keys(next).length ? "error" : "submitting", Object.keys(next).length ? "Review the highlighted fields." : "Submitting…");
|
|
175
|
+
errors = next;
|
|
176
|
+
if (Object.keys(next).length) {
|
|
177
|
+
status = "error";
|
|
178
|
+
message = "Review the highlighted fields.";
|
|
179
|
+
emit();
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
const input = copyData(values);
|
|
183
|
+
validateRichCapabilityInput(source.fields, input);
|
|
184
|
+
status = "submitting";
|
|
185
|
+
message = "Submitting…";
|
|
186
|
+
emit();
|
|
187
|
+
try {
|
|
188
|
+
const result = await handlers[action](input);
|
|
189
|
+
if (disposed)
|
|
190
|
+
return false;
|
|
191
|
+
assertBoundedData(result);
|
|
192
|
+
if (!result || typeof result !== "object" || Object.keys(result).length !== 1 || (result.ok !== true && result.ok !== false))
|
|
193
|
+
throw new Error("Invalid action result");
|
|
194
|
+
if (!result.ok)
|
|
195
|
+
throw new Error("Action failed");
|
|
196
|
+
assertSnapshot(values, errors, "success", "Saved successfully.");
|
|
197
|
+
status = "success";
|
|
198
|
+
message = "Saved successfully.";
|
|
199
|
+
emit();
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
if (disposed)
|
|
204
|
+
return false;
|
|
205
|
+
assertSnapshot(values, errors, "error", "Unable to save. Please try again.");
|
|
206
|
+
status = "error";
|
|
207
|
+
message = "Unable to save. Please try again.";
|
|
208
|
+
emit();
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
subscribe(listener) {
|
|
213
|
+
if (disposed)
|
|
214
|
+
throw new Error("Composition controller is disposed");
|
|
215
|
+
listeners.add(listener);
|
|
216
|
+
try {
|
|
217
|
+
listener(snapshot());
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
listeners.delete(listener);
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
return () => { listeners.delete(listener); };
|
|
224
|
+
},
|
|
225
|
+
dispose() { disposed = true; listeners.clear(); },
|
|
226
|
+
};
|
|
227
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { UIField } from "./capability-view.js";
|
|
2
|
+
import type { RichCapabilityInputField, RichCapabilityInputSchema } from "./input-schema.js";
|
|
3
|
+
export declare function deriveUIField(name: string, field: RichCapabilityInputField): UIField;
|
|
4
|
+
export declare function deriveUIFields(schema: RichCapabilityInputSchema): UIField[];
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
function humanise(value) {
|
|
2
|
+
return value.replace(/[._-]+/g, " ").replace(/([a-z])([A-Z])/g, "$1 $2")
|
|
3
|
+
.replace(/\b\w/g, (character) => character.toUpperCase());
|
|
4
|
+
}
|
|
5
|
+
function interaction(field) {
|
|
6
|
+
if (field.enum)
|
|
7
|
+
return "select";
|
|
8
|
+
if (field.type === "object")
|
|
9
|
+
return "object";
|
|
10
|
+
if (field.type === "array")
|
|
11
|
+
return "array";
|
|
12
|
+
if (field.format === "date")
|
|
13
|
+
return "date";
|
|
14
|
+
if (field.format === "date-time")
|
|
15
|
+
return "datetime";
|
|
16
|
+
if (field.format === "multiline")
|
|
17
|
+
return "textarea";
|
|
18
|
+
if (field.semantic === "currency")
|
|
19
|
+
return "currency";
|
|
20
|
+
if (field.type === "number" || field.type === "integer")
|
|
21
|
+
return "number";
|
|
22
|
+
if (field.type === "boolean")
|
|
23
|
+
return "checkbox";
|
|
24
|
+
return "text";
|
|
25
|
+
}
|
|
26
|
+
export function deriveUIField(name, field) {
|
|
27
|
+
return {
|
|
28
|
+
name,
|
|
29
|
+
label: humanise(name),
|
|
30
|
+
type: interaction(field),
|
|
31
|
+
required: field.required,
|
|
32
|
+
description: field.description,
|
|
33
|
+
authoritativeType: field.type,
|
|
34
|
+
...(field.enum ? { options: [...field.enum] } : {}),
|
|
35
|
+
...(field.format ? { format: field.format } : {}),
|
|
36
|
+
...(field.semantic ? { semantic: field.semantic, currency: field.currency } : {}),
|
|
37
|
+
...(field.minLength !== undefined ? { minLength: field.minLength } : {}),
|
|
38
|
+
...(field.maxLength !== undefined ? { maxLength: field.maxLength } : {}),
|
|
39
|
+
...(field.minimum !== undefined ? { minimum: field.minimum } : {}),
|
|
40
|
+
...(field.maximum !== undefined ? { maximum: field.maximum } : {}),
|
|
41
|
+
...(field.minItems !== undefined ? { minItems: field.minItems } : {}),
|
|
42
|
+
...(field.maxItems !== undefined ? { maxItems: field.maxItems } : {}),
|
|
43
|
+
...(field.fields ? {
|
|
44
|
+
fields: Object.entries(field.fields).map(([child, definition]) => deriveUIField(child, definition)),
|
|
45
|
+
} : {}),
|
|
46
|
+
...(field.items ? { item: deriveUIField("item", field.items) } : {}),
|
|
47
|
+
derivation: {
|
|
48
|
+
source: "authoritative-input-schema",
|
|
49
|
+
reason: field.enum
|
|
50
|
+
? "finite-enum"
|
|
51
|
+
: field.type === "object"
|
|
52
|
+
? "nested-object"
|
|
53
|
+
: field.type === "array"
|
|
54
|
+
? "structured-array"
|
|
55
|
+
: field.semantic === "currency"
|
|
56
|
+
? "currency-semantic"
|
|
57
|
+
: field.format ?? "primitive-type",
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
export function deriveUIFields(schema) {
|
|
62
|
+
return Object.entries(schema).map(([name, field]) => deriveUIField(name, field));
|
|
63
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type CapabilityOutputSchema } from "./output-schema.js";
|
|
2
|
+
import type { UIDetailField, UIAggregateMetric, UIAction, UITableColumn, CapabilityView } from "./capability-view.js";
|
|
3
|
+
type CapabilityViewTimeSeries = NonNullable<CapabilityView["timeSeries"]>;
|
|
4
|
+
export interface DerivedOutputView {
|
|
5
|
+
surface: "form" | "table" | "detail" | "aggregate" | "time-series" | "category-series";
|
|
6
|
+
derivation: {
|
|
7
|
+
source: "input-schema" | "output-schema";
|
|
8
|
+
reason: "no-output-schema" | "single-top-level-array-of-objects" | "flat-output-object" | "structured-output-object" | "authoritative-aggregate-object" | "authoritative-time-series" | "authoritative-category-series";
|
|
9
|
+
};
|
|
10
|
+
table?: {
|
|
11
|
+
dataPath: string;
|
|
12
|
+
columns: UITableColumn[];
|
|
13
|
+
actions?: UIAction[];
|
|
14
|
+
};
|
|
15
|
+
detail?: {
|
|
16
|
+
fields: UIDetailField[];
|
|
17
|
+
actions?: UIAction[];
|
|
18
|
+
};
|
|
19
|
+
aggregate?: {
|
|
20
|
+
metrics: UIAggregateMetric[];
|
|
21
|
+
};
|
|
22
|
+
timeSeries?: CapabilityViewTimeSeries;
|
|
23
|
+
categorySeries?: NonNullable<CapabilityView["categorySeries"]>;
|
|
24
|
+
}
|
|
25
|
+
export declare function deriveOutputView(schema?: CapabilityOutputSchema): DerivedOutputView;
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { getOutputSchemaFields, isAggregateOutputSchema, } from "./output-schema.js";
|
|
2
|
+
function humanise(value) {
|
|
3
|
+
return value
|
|
4
|
+
.replace(/[._-]+/g, " ")
|
|
5
|
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
|
6
|
+
.replace(/\b\w/g, (character) => character.toUpperCase());
|
|
7
|
+
}
|
|
8
|
+
function deriveTableColumns(item) {
|
|
9
|
+
if (item.type !== "object" ||
|
|
10
|
+
!item.fields) {
|
|
11
|
+
return [];
|
|
12
|
+
}
|
|
13
|
+
return Object.keys(item.fields).map((key) => ({
|
|
14
|
+
key,
|
|
15
|
+
label: humanise(key),
|
|
16
|
+
}));
|
|
17
|
+
}
|
|
18
|
+
function deriveDetailFields(schema) {
|
|
19
|
+
return Object.entries(getOutputSchemaFields(schema)).map(([key, definition]) => ({
|
|
20
|
+
key,
|
|
21
|
+
label: humanise(key),
|
|
22
|
+
...(definition.semantic && definition.semantic !== "time-series" && definition.semantic !== "category-series" ? { semantic: definition.semantic } : {}),
|
|
23
|
+
...(definition.currency ? { currency: definition.currency } : {}),
|
|
24
|
+
}));
|
|
25
|
+
}
|
|
26
|
+
export function deriveOutputView(schema) {
|
|
27
|
+
if (!schema) {
|
|
28
|
+
return {
|
|
29
|
+
surface: "form",
|
|
30
|
+
derivation: { source: "input-schema", reason: "no-output-schema" },
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
if (isAggregateOutputSchema(schema)) {
|
|
34
|
+
return {
|
|
35
|
+
surface: "aggregate",
|
|
36
|
+
derivation: { source: "output-schema", reason: "authoritative-aggregate-object" },
|
|
37
|
+
aggregate: {
|
|
38
|
+
metrics: Object.entries(schema.fields).map(([key, definition]) => ({
|
|
39
|
+
key,
|
|
40
|
+
label: humanise(key),
|
|
41
|
+
authoritativeType: definition.type,
|
|
42
|
+
semantic: definition.semantic,
|
|
43
|
+
...(definition.currency ? { currency: definition.currency } : {}),
|
|
44
|
+
derivation: { source: "authoritative-output-schema", reason: "metric-semantics" },
|
|
45
|
+
})),
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
const entries = Object.entries(getOutputSchemaFields(schema));
|
|
50
|
+
const categorySeries = entries.filter(([, definition]) => definition.semantic === "category-series");
|
|
51
|
+
if (categorySeries.length === 1) {
|
|
52
|
+
const [dataPath, collection] = categorySeries[0], itemFields = Object.entries(collection.items.fields), [dimensionKey, dimension] = itemFields.find(([, field]) => field.role === "dimension"), [measureKey, measure] = itemFields.find(([, field]) => field.role === "measure");
|
|
53
|
+
return { surface: "category-series", derivation: { source: "output-schema", reason: "authoritative-category-series" }, categorySeries: { dataPath, visualization: "bar", ordering: "source-order", dimension: { key: dimensionKey, label: humanise(dimensionKey), type: "string", ...(dimension.enum ? { enum: dimension.enum } : {}), role: "dimension" }, measure: { key: measureKey, label: humanise(measureKey), type: measure.type, semantic: measure.semantic, ...(measure.currency ? { currency: measure.currency } : {}), role: "measure" }, derivation: { source: "authoritative-output-schema", reason: "categorical-dimension-with-numeric-measure" } } };
|
|
54
|
+
}
|
|
55
|
+
const timeSeries = entries.filter(([, definition]) => definition.semantic === "time-series");
|
|
56
|
+
if (timeSeries.length === 1) {
|
|
57
|
+
const [dataPath, collection] = timeSeries[0];
|
|
58
|
+
const itemFields = Object.entries(collection.items.fields);
|
|
59
|
+
const [dimensionKey, dimension] = itemFields.find(([, field]) => field.role === "dimension");
|
|
60
|
+
const [measureKey, measure] = itemFields.find(([, field]) => field.role === "measure");
|
|
61
|
+
return { surface: "time-series", derivation: { source: "output-schema", reason: "authoritative-time-series" }, timeSeries: { dataPath, visualization: "line", dimension: { key: dimensionKey, label: humanise(dimensionKey), type: "string", format: dimension.format, role: "dimension" }, measure: { key: measureKey, label: humanise(measureKey), type: measure.type, semantic: measure.semantic, ...(measure.currency ? { currency: measure.currency } : {}), role: "measure" }, derivation: { source: "authoritative-output-schema", reason: "temporal-dimension-with-numeric-measure" } } };
|
|
62
|
+
}
|
|
63
|
+
// A single top-level array of objects naturally maps to a table.
|
|
64
|
+
if (entries.length === 1) {
|
|
65
|
+
const [key, definition] = entries[0];
|
|
66
|
+
if (definition.type === "array" &&
|
|
67
|
+
definition.items?.type === "object") {
|
|
68
|
+
return {
|
|
69
|
+
surface: "table",
|
|
70
|
+
derivation: { source: "output-schema", reason: "single-top-level-array-of-objects" },
|
|
71
|
+
table: {
|
|
72
|
+
dataPath: key,
|
|
73
|
+
columns: deriveTableColumns(definition.items),
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// A flat object naturally maps to a detail surface.
|
|
79
|
+
const isFlatObject = entries.every(([, definition]) => definition.type !== "array" &&
|
|
80
|
+
definition.type !== "object");
|
|
81
|
+
if (isFlatObject) {
|
|
82
|
+
return {
|
|
83
|
+
surface: "detail",
|
|
84
|
+
derivation: { source: "output-schema", reason: "flat-output-object" },
|
|
85
|
+
detail: {
|
|
86
|
+
fields: deriveDetailFields(schema),
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
surface: "detail",
|
|
92
|
+
derivation: { source: "output-schema", reason: "structured-output-object" },
|
|
93
|
+
detail: {
|
|
94
|
+
fields: deriveDetailFields(schema),
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
package/dist/dom.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ComponentNode, CompositionController } from "./composition.js";
|
|
2
|
+
import { type InterfaceController, type InterfaceDefinition, type InterfaceEvent, type InterfaceNode, type InterfaceSnapshot } from "./interface.js";
|
|
3
|
+
export type DOMComponent = (node: Readonly<ComponentNode>, context: {
|
|
4
|
+
controller: CompositionController;
|
|
5
|
+
renderChildren: () => HTMLElement[];
|
|
6
|
+
}) => HTMLElement;
|
|
7
|
+
export type DOMComponentRegistry = Readonly<Record<string, DOMComponent>>;
|
|
8
|
+
/** Mount one composed screen. Caller owns the controller, mount point, and disposal. */
|
|
9
|
+
export declare function mountComposedApplication(target: HTMLElement, controller: CompositionController, components?: DOMComponentRegistry): () => void;
|
|
10
|
+
export interface InterfaceDOMComponentInstance {
|
|
11
|
+
element: HTMLElement;
|
|
12
|
+
update?: (node: Readonly<InterfaceNode>, snapshot: InterfaceSnapshot) => void;
|
|
13
|
+
dispose?: () => void;
|
|
14
|
+
}
|
|
15
|
+
export type InterfaceDOMComponent = (node: Readonly<InterfaceNode>, context: {
|
|
16
|
+
renderChildren: () => HTMLElement[];
|
|
17
|
+
snapshot: InterfaceSnapshot;
|
|
18
|
+
signal: AbortSignal;
|
|
19
|
+
}) => HTMLElement | InterfaceDOMComponentInstance;
|
|
20
|
+
export type InterfaceDOMRegistry = Readonly<Record<string, InterfaceDOMComponent>>;
|
|
21
|
+
/** Own a controller and one DOM mount with a single teardown. Component factories remain trusted host code. */
|
|
22
|
+
export declare function createMountedInterface(target: HTMLElement, definition: InterfaceDefinition, options?: {
|
|
23
|
+
components?: InterfaceDOMRegistry;
|
|
24
|
+
allowedActions?: readonly string[];
|
|
25
|
+
onEvent?: (event: InterfaceEvent) => void | Promise<void>;
|
|
26
|
+
onError?: (error: unknown) => void;
|
|
27
|
+
}): InterfaceController & {
|
|
28
|
+
unmount(): void;
|
|
29
|
+
};
|
|
30
|
+
/** Mount a framework-neutral interface. The caller owns and disposes the controller separately. */
|
|
31
|
+
export declare function mountInterface(target: HTMLElement, controller: InterfaceController, components?: InterfaceDOMRegistry, onError?: (error: unknown) => void): () => void;
|