@loomwork/blueprint-runtime 0.0.0-stage → 0.1.1
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/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +83 -2
- package/dist/Blueprint.d.ts +14 -0
- package/dist/Blueprint.d.ts.map +1 -0
- package/dist/Blueprint.js +18 -0
- package/dist/Blueprint.js.map +1 -0
- package/dist/RendererRegistry.d.ts +9 -0
- package/dist/RendererRegistry.d.ts.map +1 -0
- package/dist/RendererRegistry.js +39 -0
- package/dist/RendererRegistry.js.map +1 -0
- package/dist/catalog.d.ts +129 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/catalog.js +435 -0
- package/dist/catalog.js.map +1 -0
- package/dist/coverage.d.ts +18 -0
- package/dist/coverage.d.ts.map +1 -0
- package/dist/coverage.js +22 -0
- package/dist/coverage.js.map +1 -0
- package/dist/customWidget.d.ts +10 -0
- package/dist/customWidget.d.ts.map +1 -0
- package/dist/customWidget.js +34 -0
- package/dist/customWidget.js.map +1 -0
- package/dist/esm/Blueprint.js +15 -0
- package/dist/esm/Blueprint.js.map +1 -0
- package/dist/esm/RendererRegistry.js +35 -0
- package/dist/esm/RendererRegistry.js.map +1 -0
- package/dist/esm/catalog.js +430 -0
- package/dist/esm/catalog.js.map +1 -0
- package/dist/esm/coverage.js +19 -0
- package/dist/esm/coverage.js.map +1 -0
- package/dist/esm/customWidget.js +31 -0
- package/dist/esm/customWidget.js.map +1 -0
- package/dist/esm/identity.js +116 -0
- package/dist/esm/identity.js.map +1 -0
- package/dist/esm/index.js +15 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/irVersion.js +150 -0
- package/dist/esm/irVersion.js.map +1 -0
- package/dist/esm/layout.js +63 -0
- package/dist/esm/layout.js.map +1 -0
- package/dist/esm/normalize.js +115 -0
- package/dist/esm/normalize.js.map +1 -0
- package/dist/esm/pins.js +82 -0
- package/dist/esm/pins.js.map +1 -0
- package/dist/esm/traverseBlueprint.js +38 -0
- package/dist/esm/traverseBlueprint.js.map +1 -0
- package/dist/esm/traverseBlueprintAsync.js +48 -0
- package/dist/esm/traverseBlueprintAsync.js.map +1 -0
- package/dist/esm/types.js +2 -0
- package/dist/esm/types.js.map +1 -0
- package/dist/esm/widgetStatus.js +137 -0
- package/dist/esm/widgetStatus.js.map +1 -0
- package/dist/esm/widgets.js +105 -0
- package/dist/esm/widgets.js.map +1 -0
- package/dist/identity.d.ts +58 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +122 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +61 -0
- package/dist/index.js.map +1 -0
- package/dist/irVersion.d.ts +23 -0
- package/dist/irVersion.d.ts.map +1 -0
- package/dist/irVersion.js +153 -0
- package/dist/irVersion.js.map +1 -0
- package/dist/layout.d.ts +57 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +71 -0
- package/dist/layout.js.map +1 -0
- package/dist/normalize.d.ts +29 -0
- package/dist/normalize.d.ts.map +1 -0
- package/dist/normalize.js +126 -0
- package/dist/normalize.js.map +1 -0
- package/dist/pins.d.ts +27 -0
- package/dist/pins.d.ts.map +1 -0
- package/dist/pins.js +86 -0
- package/dist/pins.js.map +1 -0
- package/dist/traverseBlueprint.d.ts +27 -0
- package/dist/traverseBlueprint.d.ts.map +1 -0
- package/dist/traverseBlueprint.js +40 -0
- package/dist/traverseBlueprint.js.map +1 -0
- package/dist/traverseBlueprintAsync.d.ts +18 -0
- package/dist/traverseBlueprintAsync.d.ts.map +1 -0
- package/dist/traverseBlueprintAsync.js +51 -0
- package/dist/traverseBlueprintAsync.js.map +1 -0
- package/dist/types.d.ts +176 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/widgetStatus.d.ts +66 -0
- package/dist/widgetStatus.d.ts.map +1 -0
- package/dist/widgetStatus.js +141 -0
- package/dist/widgetStatus.js.map +1 -0
- package/dist/widgets.d.ts +101 -0
- package/dist/widgets.d.ts.map +1 -0
- package/dist/widgets.js +115 -0
- package/dist/widgets.js.map +1 -0
- package/package.json +60 -3
- package/src/Blueprint.ts +28 -0
- package/src/RendererRegistry.ts +38 -0
- package/src/catalog.ts +638 -0
- package/src/coverage.ts +52 -0
- package/src/customWidget.ts +42 -0
- package/src/identity.ts +178 -0
- package/src/index.ts +84 -0
- package/src/irVersion.ts +195 -0
- package/src/layout.ts +105 -0
- package/src/normalize.ts +130 -0
- package/src/pins.ts +84 -0
- package/src/traverseBlueprint.bench.ts +47 -0
- package/src/traverseBlueprint.ts +94 -0
- package/src/traverseBlueprintAsync.ts +92 -0
- package/src/types.ts +195 -0
- package/src/widgetStatus.ts +231 -0
- package/src/widgets.ts +213 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { DataBinding, Widget } from "./types";
|
|
2
|
+
|
|
3
|
+
function stableId(kind: string, props: object): string {
|
|
4
|
+
const str = kind + JSON.stringify(props, Object.keys(props as Record<string, unknown>).sort());
|
|
5
|
+
let hash = 0;
|
|
6
|
+
for (let i = 0; i < str.length; i++) {
|
|
7
|
+
hash = (Math.imul(31, hash) + str.charCodeAt(i)) | 0;
|
|
8
|
+
}
|
|
9
|
+
return `${kind}-${(hash >>> 0).toString(16)}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface CustomWidgetConfig {
|
|
13
|
+
kind: string;
|
|
14
|
+
title?: string;
|
|
15
|
+
binding?: DataBinding;
|
|
16
|
+
props?: Record<string, unknown>;
|
|
17
|
+
id?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Builder for user-defined widget kinds that live outside the WidgetKind union.
|
|
21
|
+
// Register a matching renderer in your RendererRegistry under the same `kind` string.
|
|
22
|
+
//
|
|
23
|
+
// const HeatmapWidget = customWidget$({
|
|
24
|
+
// kind: "Heatmap",
|
|
25
|
+
// title: "CPU Heatmap",
|
|
26
|
+
// binding: { projectionId: "metrics", path: "cpu.history" },
|
|
27
|
+
// props: { buckets: 20 },
|
|
28
|
+
// });
|
|
29
|
+
// registry.register("Heatmap", HeatmapComponent);
|
|
30
|
+
export function customWidget$(config: CustomWidgetConfig): Widget {
|
|
31
|
+
const propsRecord = config.props ?? {};
|
|
32
|
+
return {
|
|
33
|
+
_tag: "Widget",
|
|
34
|
+
id:
|
|
35
|
+
config.id ??
|
|
36
|
+
stableId(config.kind, { title: config.title, binding: config.binding, ...propsRecord }),
|
|
37
|
+
kind: config.kind,
|
|
38
|
+
...(config.title !== undefined && { title: config.title }),
|
|
39
|
+
props: propsRecord,
|
|
40
|
+
...(config.binding !== undefined && { binding: config.binding }),
|
|
41
|
+
};
|
|
42
|
+
}
|
package/src/identity.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { hasLayoutId } from "./layout";
|
|
2
|
+
import { asCell } from "./normalize";
|
|
3
|
+
import type { Blueprint, GridCell, Layout, Widget } from "./types";
|
|
4
|
+
|
|
5
|
+
// ── Node identity across a structural change ─────────────────────────────────
|
|
6
|
+
//
|
|
7
|
+
// `validateBlueprint` checks one tree against a catalogue, including the ids WITHIN it
|
|
8
|
+
// (`duplicate-node-id`). Nothing checked one tree against ANOTHER, and that is where node
|
|
9
|
+
// identity is actually lost.
|
|
10
|
+
//
|
|
11
|
+
// It is lost silently, because ids are derived by default: `stableId` hashes kind + props,
|
|
12
|
+
// so a node keeps its identity only while its props stay byte-identical. Two situations
|
|
13
|
+
// depend on that and neither gets a warning today:
|
|
14
|
+
//
|
|
15
|
+
// • REVISION — a composer re-emits a view with one prop edited. The edited node is a new
|
|
16
|
+
// node as far as every consumer is concerned: it remounts, and any pin, annotation or
|
|
17
|
+
// agent reference keyed on the old id dangles. (`docs/composed-surfaces.md` says supply
|
|
18
|
+
// an explicit id; this is how you find out you didn't.)
|
|
19
|
+
// • VARIANTS — two trees generated separately are meant to share a set of nodes: the
|
|
20
|
+
// "floor" every variant must contain, a panel a runbook step names. Vary a title
|
|
21
|
+
// between variants and the two trees quietly stop agreeing about what the node IS.
|
|
22
|
+
//
|
|
23
|
+
// Both reduce to one question — which ids did this change drop? — so this is one function
|
|
24
|
+
// over id sets rather than a blueprint-shaped API: the second consumer is A2UI's
|
|
25
|
+
// `recompose`, whose components are a flat list, not a Blueprint.
|
|
26
|
+
//
|
|
27
|
+
// It reports rather than throws, and for the same reason `validateBlueprint` does: dropping
|
|
28
|
+
// a node is legitimate (a subtree replacement, a variant that genuinely drops a panel) and
|
|
29
|
+
// illegitimate (a re-identification nobody intended), and only the caller knows which.
|
|
30
|
+
|
|
31
|
+
/** Why a node id went missing. Shares `validateBlueprint`'s vocabulary deliberately. */
|
|
32
|
+
export interface IdentityFinding {
|
|
33
|
+
readonly code: "dropped-node-id";
|
|
34
|
+
readonly severity: "error";
|
|
35
|
+
readonly nodeId: string;
|
|
36
|
+
readonly message: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface IdentityReport {
|
|
40
|
+
/** True iff no id present before is missing after. */
|
|
41
|
+
readonly ok: boolean;
|
|
42
|
+
readonly findings: readonly IdentityFinding[];
|
|
43
|
+
/**
|
|
44
|
+
* Ids the change introduced. Not a defect — adding is how a variant sits above a floor,
|
|
45
|
+
* and how a recompose adds a panel — but a `dropped` finding beside an `added` id of the
|
|
46
|
+
* same component is what a RE-IDENTIFICATION looks like, and telling those apart is the
|
|
47
|
+
* caller's job.
|
|
48
|
+
*/
|
|
49
|
+
readonly added: readonly string[];
|
|
50
|
+
/**
|
|
51
|
+
* Ids compared (the size of `before`). The denominator: `ok: true` over an empty `before`
|
|
52
|
+
* is not evidence that identity was preserved, and this is how a caller tells.
|
|
53
|
+
*/
|
|
54
|
+
readonly compared: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The one total, iterative, pre-order walk behind every id list below. `onWidget` sees each
|
|
59
|
+
* widget id; `onLayout` each WELL-FORMED layout id (IR 2.4.0, ADR 0101) — an absent or malformed
|
|
60
|
+
* one is skipped, exactly as a renderer treats it as absent.
|
|
61
|
+
*/
|
|
62
|
+
function walkNodeIds(
|
|
63
|
+
blueprint: Blueprint,
|
|
64
|
+
onWidget: ((id: string) => void) | undefined,
|
|
65
|
+
onLayout: ((id: string) => void) | undefined
|
|
66
|
+
): void {
|
|
67
|
+
// Iterative, matching `eachNode` in catalog.ts: a deep composed tree must not be able
|
|
68
|
+
// to blow the call stack.
|
|
69
|
+
//
|
|
70
|
+
// Length-driven, not `pop() !== undefined`: a blueprint is data an agent or a composer
|
|
71
|
+
// may have authored, so a layout child CAN legitimately be `undefined` (a malformed
|
|
72
|
+
// `cell(6, undefined)`, a hole from a revision) — and `stack.pop()` returns `undefined`
|
|
73
|
+
// both for "the stack is empty" and for "the next element IS `undefined`". A sentinel
|
|
74
|
+
// loop conflates the two and stops early, silently dropping every id not yet visited.
|
|
75
|
+
// Rejecting the hole is `validateBlueprint`'s job (`malformed-node`); this walk stays
|
|
76
|
+
// total and skips only the one non-object child.
|
|
77
|
+
const stack: unknown[] = [blueprint.layout];
|
|
78
|
+
while (stack.length > 0) {
|
|
79
|
+
const node = stack.pop();
|
|
80
|
+
if (typeof node !== "object" || node === null) continue;
|
|
81
|
+
if ("_tag" in node && (node as Widget)._tag === "Widget") {
|
|
82
|
+
onWidget?.((node as Widget).id);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const layout = node as Layout;
|
|
86
|
+
if (!Array.isArray(layout.children)) continue;
|
|
87
|
+
if (onLayout !== undefined && hasLayoutId(layout)) onLayout(layout.id);
|
|
88
|
+
for (let i = layout.children.length - 1; i >= 0; i--) {
|
|
89
|
+
stack.push(
|
|
90
|
+
layout.type === "grid"
|
|
91
|
+
? asCell(layout.children[i] as GridCell).node
|
|
92
|
+
: (layout.children[i] as Layout | Widget)
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Every WIDGET id in a blueprint, in pre-order. Duplicates are `validateBlueprint`'s job.
|
|
100
|
+
*
|
|
101
|
+
* Widget-only by contract, also after IR 2.4.0: callers read its output as widget ids (a
|
|
102
|
+
* renderer's remount report, the a2ui bridge's `identityDiff`). Layout ids are
|
|
103
|
+
* `blueprintLayoutIds`; `preservesBlueprintNodes` compares both.
|
|
104
|
+
*/
|
|
105
|
+
export function blueprintNodeIds(blueprint: Blueprint): string[] {
|
|
106
|
+
const ids: string[] = [];
|
|
107
|
+
walkNodeIds(blueprint, (id) => ids.push(id), undefined);
|
|
108
|
+
return ids;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Every well-formed LAYOUT id in a blueprint (IR 2.4.0, ADR 0101), in pre-order, the root
|
|
113
|
+
* included. Absent and malformed ids are skipped. Duplicates are `validateBlueprint`'s job.
|
|
114
|
+
*/
|
|
115
|
+
export function blueprintLayoutIds(blueprint: Blueprint): string[] {
|
|
116
|
+
const ids: string[] = [];
|
|
117
|
+
walkNodeIds(blueprint, undefined, (id) => ids.push(id));
|
|
118
|
+
return ids;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Which of `before`'s node ids survive into `after`.
|
|
123
|
+
*
|
|
124
|
+
* Two readings, one function:
|
|
125
|
+
* - *"did this revision keep the nodes it kept?"* — `before` is the previous tree;
|
|
126
|
+
* - *"does this variant contain the floor?"* — `before` is the floor's ids, and any finding
|
|
127
|
+
* is a floor violation. Nodes above the floor land in `added`, which is exactly right:
|
|
128
|
+
* a variant may add, never subtract.
|
|
129
|
+
*
|
|
130
|
+
* One pass over each side, `Set`-backed: O(|before| + |after|).
|
|
131
|
+
*/
|
|
132
|
+
export function preservesNodes(before: Iterable<string>, after: Iterable<string>): IdentityReport {
|
|
133
|
+
const afterIds = new Set(after);
|
|
134
|
+
const findings: IdentityFinding[] = [];
|
|
135
|
+
const seen = new Set<string>();
|
|
136
|
+
|
|
137
|
+
for (const id of before) {
|
|
138
|
+
// A repeated id in `before` is one node's worth of identity, so report it once —
|
|
139
|
+
// otherwise a duplicated id inflates the finding count for a single lost node.
|
|
140
|
+
if (seen.has(id)) continue;
|
|
141
|
+
seen.add(id);
|
|
142
|
+
if (!afterIds.has(id)) {
|
|
143
|
+
findings.push({
|
|
144
|
+
code: "dropped-node-id",
|
|
145
|
+
severity: "error",
|
|
146
|
+
nodeId: id,
|
|
147
|
+
message: `node "${id}" is present before the change and absent after — either it was removed, or a prop edit re-identified it (ids are derived from kind + props unless supplied explicitly)`,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const added: string[] = [];
|
|
153
|
+
for (const id of afterIds) {
|
|
154
|
+
if (!seen.has(id)) added.push(id);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return { ok: findings.length === 0, findings, added, compared: seen.size };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Widget ids and layout ids together, in one pre-order (document) walk. */
|
|
161
|
+
function allNodeIds(blueprint: Blueprint): string[] {
|
|
162
|
+
const ids: string[] = [];
|
|
163
|
+
const push = (id: string) => {
|
|
164
|
+
ids.push(id);
|
|
165
|
+
};
|
|
166
|
+
walkNodeIds(blueprint, push, push);
|
|
167
|
+
return ids;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* `preservesNodes` over two blueprints — the common case, spelled once. Compares widget ids AND
|
|
172
|
+
* layout ids (IR 2.4.0, ADR 0101): they are one identity axis, so a re-identified container is a
|
|
173
|
+
* `dropped-node-id` finding rather than a silent loss of the state under it. A `before` with no
|
|
174
|
+
* layout ids (any pre-2.4.0 artifact) contributes none, so upgrading only adds to `added`.
|
|
175
|
+
*/
|
|
176
|
+
export function preservesBlueprintNodes(before: Blueprint, after: Blueprint): IdentityReport {
|
|
177
|
+
return preservesNodes(allNodeIds(before), allNodeIds(after));
|
|
178
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export { blueprint$ } from "./Blueprint";
|
|
2
|
+
export { grid, flex, stack, isLayoutId, isLayoutRole } from "./layout";
|
|
3
|
+
export {
|
|
4
|
+
asCell,
|
|
5
|
+
cell,
|
|
6
|
+
upgradeBlueprint,
|
|
7
|
+
blueprintIrVersion,
|
|
8
|
+
isBindingStatus,
|
|
9
|
+
isWidget,
|
|
10
|
+
resolveWidgetStatus,
|
|
11
|
+
IR_VERSION_LEGACY,
|
|
12
|
+
} from "./normalize";
|
|
13
|
+
export {
|
|
14
|
+
MetricCard,
|
|
15
|
+
Timeline,
|
|
16
|
+
ApprovalCard,
|
|
17
|
+
StatusBadge,
|
|
18
|
+
ProgressBar,
|
|
19
|
+
DataTable,
|
|
20
|
+
AlertCard,
|
|
21
|
+
StreamingText,
|
|
22
|
+
} from "./widgets";
|
|
23
|
+
export { customWidget$ } from "./customWidget";
|
|
24
|
+
export { BLUEPRINT_IR_SHAPE, BLUEPRINT_IR_VERSION } from "./irVersion";
|
|
25
|
+
export {
|
|
26
|
+
validateBlueprint,
|
|
27
|
+
defineWidgetCatalog,
|
|
28
|
+
builtinCatalog,
|
|
29
|
+
BUILTIN_CATALOG_ID,
|
|
30
|
+
} from "./catalog";
|
|
31
|
+
export { catalogCoverage } from "./coverage";
|
|
32
|
+
export {
|
|
33
|
+
blueprintLayoutIds,
|
|
34
|
+
blueprintNodeIds,
|
|
35
|
+
preservesBlueprintNodes,
|
|
36
|
+
preservesNodes,
|
|
37
|
+
} from "./identity";
|
|
38
|
+
export { blueprintPins, checkBlueprintPins } from "./pins";
|
|
39
|
+
// Re-exported from @loomwork/core so a consumer of the blueprint spelling types its report
|
|
40
|
+
// from the package it already imports — `IdentityReport` does not send anyone elsewhere
|
|
41
|
+
// either. The comparison itself stays in core, where its other consumer can reach it.
|
|
42
|
+
export type { PinFinding, PinFindingCode, PinReport, VersionPins } from "@loomwork/core";
|
|
43
|
+
export { RendererRegistry } from "./RendererRegistry";
|
|
44
|
+
export { traverseBlueprint, resolveWidgetValue, resolvePath } from "./traverseBlueprint";
|
|
45
|
+
export { describeWidgetFrame, describeWidgetStatus } from "./widgetStatus";
|
|
46
|
+
export type {
|
|
47
|
+
WidgetShownState,
|
|
48
|
+
WidgetStatusDescriptor,
|
|
49
|
+
WidgetStatusFrame,
|
|
50
|
+
WidgetStatusMarker,
|
|
51
|
+
WidgetValueCheck,
|
|
52
|
+
} from "./widgetStatus";
|
|
53
|
+
export { traverseBlueprintAsync } from "./traverseBlueprintAsync";
|
|
54
|
+
export type {
|
|
55
|
+
AsyncTraversalOptions,
|
|
56
|
+
IncrementalRenderer,
|
|
57
|
+
} from "./traverseBlueprintAsync";
|
|
58
|
+
export type {
|
|
59
|
+
BindingState,
|
|
60
|
+
BindingStatus,
|
|
61
|
+
Blueprint,
|
|
62
|
+
DataBinding,
|
|
63
|
+
FlexLayout,
|
|
64
|
+
GridCell,
|
|
65
|
+
GridLayout,
|
|
66
|
+
Layout,
|
|
67
|
+
StackLayout,
|
|
68
|
+
Widget,
|
|
69
|
+
WidgetKind,
|
|
70
|
+
WidgetSource,
|
|
71
|
+
WidgetAction,
|
|
72
|
+
WidgetRendererProps,
|
|
73
|
+
} from "./types";
|
|
74
|
+
export type { DataClass } from "@loomwork/classify";
|
|
75
|
+
export type { ContainerContext, BlueprintTraversalOptions } from "./traverseBlueprint";
|
|
76
|
+
export type {
|
|
77
|
+
CatalogEntry,
|
|
78
|
+
WidgetCatalog,
|
|
79
|
+
BlueprintFinding,
|
|
80
|
+
BlueprintFindingCode,
|
|
81
|
+
BlueprintValidation,
|
|
82
|
+
} from "./catalog";
|
|
83
|
+
export type { CoverageReport } from "./coverage";
|
|
84
|
+
export type { IdentityFinding, IdentityReport } from "./identity";
|
package/src/irVersion.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BindingState,
|
|
3
|
+
BindingStatus,
|
|
4
|
+
Blueprint,
|
|
5
|
+
DataBinding,
|
|
6
|
+
FlexLayout,
|
|
7
|
+
GridCell,
|
|
8
|
+
GridLayout,
|
|
9
|
+
Layout,
|
|
10
|
+
StackLayout,
|
|
11
|
+
Widget,
|
|
12
|
+
WidgetKind,
|
|
13
|
+
WidgetSource,
|
|
14
|
+
} from "./types";
|
|
15
|
+
|
|
16
|
+
// ── The Blueprint as a stable rendering IR (ADR 0096) ─────────────────────────
|
|
17
|
+
//
|
|
18
|
+
// A `Blueprint` used to be a value one app authored and one renderer drew. Once an external
|
|
19
|
+
// framework COMPILES to it, its shape is an interface others depend on, and a field added or
|
|
20
|
+
// renamed here is a breaking change in a repository nothing in this workspace builds.
|
|
21
|
+
//
|
|
22
|
+
// So the shape is declared twice, on purpose. The types below are the truth; the manifests are a
|
|
23
|
+
// second spelling that `tsc` checks against them exhaustively — `AllKeys<T>` makes every key
|
|
24
|
+
// required, and `satisfies` on an object literal rejects an excess key — so any change to
|
|
25
|
+
// `Widget`, `Layout` or `Blueprint` fails the build until the manifest agrees. `irVersion.test.ts`
|
|
26
|
+
// then pins a digest of the manifests to BLUEPRINT_IR_VERSION, so the manifest cannot change
|
|
27
|
+
// without the version changing either. Neither half is sufficient alone.
|
|
28
|
+
//
|
|
29
|
+
// What this guards: field presence, field optionality (a required↔optional flip fails the
|
|
30
|
+
// build), the WidgetKind literals, the layout `type` literals. What it does not: a field's type
|
|
31
|
+
// being narrowed or widened, nor a `readonly` change. A consumer's typecheck catches those.
|
|
32
|
+
//
|
|
33
|
+
// Adding a Layout variant means adding its field manifest here too; `Record<Layout["type"], true>`
|
|
34
|
+
// catches the variant, not its fields.
|
|
35
|
+
|
|
36
|
+
export const BLUEPRINT_IR_VERSION = "2.5.0";
|
|
37
|
+
|
|
38
|
+
type OptionalKeys<T> = { [K in keyof T]-?: object extends Pick<T, K> ? K : never }[keyof T];
|
|
39
|
+
/** Every key of T, each tagged by whether T declares it optional. `satisfies` rejects a missing key,
|
|
40
|
+
* an excess key, AND an optional↔required flip — the three shape changes a compile target can break on. */
|
|
41
|
+
type KeyShape<T> = {
|
|
42
|
+
readonly [K in keyof T]-?: K extends OptionalKeys<T> ? "optional" : "required";
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const WIDGET = {
|
|
46
|
+
_tag: "required",
|
|
47
|
+
id: "required",
|
|
48
|
+
kind: "required",
|
|
49
|
+
title: "optional",
|
|
50
|
+
props: "required",
|
|
51
|
+
binding: "optional",
|
|
52
|
+
source: "optional",
|
|
53
|
+
} as const satisfies KeyShape<Widget>;
|
|
54
|
+
|
|
55
|
+
const DATA_BINDING = {
|
|
56
|
+
projectionId: "required",
|
|
57
|
+
path: "required",
|
|
58
|
+
status: "optional",
|
|
59
|
+
sensitivity: "optional",
|
|
60
|
+
} as const satisfies KeyShape<DataBinding>;
|
|
61
|
+
|
|
62
|
+
const BINDING_STATUS_REF = {
|
|
63
|
+
path: "required",
|
|
64
|
+
} as const satisfies KeyShape<NonNullable<DataBinding["status"]>>;
|
|
65
|
+
|
|
66
|
+
const BINDING_STATUS = {
|
|
67
|
+
state: "required",
|
|
68
|
+
seq: "optional",
|
|
69
|
+
finding: "optional",
|
|
70
|
+
} as const satisfies KeyShape<BindingStatus>;
|
|
71
|
+
|
|
72
|
+
// The NESTED shapes, manifested in their own right. `KeyShape<BindingStatus>` only records that
|
|
73
|
+
// `finding` exists and is optional — a key added to, removed from or flipped inside the finding
|
|
74
|
+
// (or a `degradedFrom` entry) is a change to the compile target that the parent manifest cannot
|
|
75
|
+
// see. A consumer reads `finding.code`/`d.type`, so those key sets are part of the contract.
|
|
76
|
+
const BINDING_FINDING = {
|
|
77
|
+
code: "required",
|
|
78
|
+
message: "required",
|
|
79
|
+
} as const satisfies KeyShape<NonNullable<BindingStatus["finding"]>>;
|
|
80
|
+
|
|
81
|
+
const WIDGET_SOURCE = {
|
|
82
|
+
type: "required",
|
|
83
|
+
version: "required",
|
|
84
|
+
degradedFrom: "optional",
|
|
85
|
+
redacted: "optional",
|
|
86
|
+
} as const satisfies KeyShape<WidgetSource>;
|
|
87
|
+
|
|
88
|
+
const DEGRADED_FROM = {
|
|
89
|
+
type: "required",
|
|
90
|
+
version: "required",
|
|
91
|
+
} as const satisfies KeyShape<NonNullable<WidgetSource["degradedFrom"]>[number]>;
|
|
92
|
+
|
|
93
|
+
const GRID_CELL = {
|
|
94
|
+
span: "optional",
|
|
95
|
+
row: "optional",
|
|
96
|
+
rowSpan: "optional",
|
|
97
|
+
column: "optional",
|
|
98
|
+
node: "required",
|
|
99
|
+
} as const satisfies KeyShape<GridCell>;
|
|
100
|
+
|
|
101
|
+
const BINDING_STATES = {
|
|
102
|
+
pending: true,
|
|
103
|
+
ready: true,
|
|
104
|
+
stale: true,
|
|
105
|
+
error: true,
|
|
106
|
+
empty: true,
|
|
107
|
+
} as const satisfies Record<BindingState, true>;
|
|
108
|
+
|
|
109
|
+
const GRID_LAYOUT = {
|
|
110
|
+
type: "required",
|
|
111
|
+
id: "optional",
|
|
112
|
+
columns: "required",
|
|
113
|
+
gap: "optional",
|
|
114
|
+
title: "optional",
|
|
115
|
+
role: "optional",
|
|
116
|
+
children: "required",
|
|
117
|
+
} as const satisfies KeyShape<GridLayout>;
|
|
118
|
+
|
|
119
|
+
const FLEX_LAYOUT = {
|
|
120
|
+
type: "required",
|
|
121
|
+
id: "optional",
|
|
122
|
+
direction: "required",
|
|
123
|
+
gap: "optional",
|
|
124
|
+
title: "optional",
|
|
125
|
+
role: "optional",
|
|
126
|
+
children: "required",
|
|
127
|
+
} as const satisfies KeyShape<FlexLayout>;
|
|
128
|
+
|
|
129
|
+
const STACK_LAYOUT = {
|
|
130
|
+
type: "required",
|
|
131
|
+
id: "optional",
|
|
132
|
+
gap: "optional",
|
|
133
|
+
title: "optional",
|
|
134
|
+
role: "optional",
|
|
135
|
+
children: "required",
|
|
136
|
+
} as const satisfies KeyShape<StackLayout>;
|
|
137
|
+
|
|
138
|
+
const BLUEPRINT = {
|
|
139
|
+
_tag: "required",
|
|
140
|
+
irVersion: "optional",
|
|
141
|
+
id: "required",
|
|
142
|
+
title: "required",
|
|
143
|
+
layout: "required",
|
|
144
|
+
projections: "required",
|
|
145
|
+
projectionVersions: "optional",
|
|
146
|
+
} as const satisfies KeyShape<Blueprint>;
|
|
147
|
+
|
|
148
|
+
const WIDGET_KINDS = {
|
|
149
|
+
MetricCard: true,
|
|
150
|
+
Timeline: true,
|
|
151
|
+
ApprovalCard: true,
|
|
152
|
+
StatusBadge: true,
|
|
153
|
+
ProgressBar: true,
|
|
154
|
+
DataTable: true,
|
|
155
|
+
AlertCard: true,
|
|
156
|
+
StreamingText: true,
|
|
157
|
+
} as const satisfies Record<WidgetKind, true>;
|
|
158
|
+
|
|
159
|
+
const LAYOUT_TYPES = { grid: true, flex: true, stack: true } as const satisfies Record<
|
|
160
|
+
Layout["type"],
|
|
161
|
+
true
|
|
162
|
+
>;
|
|
163
|
+
|
|
164
|
+
// Field manifests emit `"<key>:<required|optional>"` so an optional↔required flip changes the
|
|
165
|
+
// digest even though the key set itself is unchanged. The two literal-union manifests (kinds,
|
|
166
|
+
// layout types) have no optionality to capture, so they stay plain key lists.
|
|
167
|
+
const keysOf = (manifest: Record<string, "required" | "optional">): readonly string[] =>
|
|
168
|
+
Object.entries(manifest)
|
|
169
|
+
.map(([key, shape]) => `${key}:${shape}`)
|
|
170
|
+
.sort();
|
|
171
|
+
|
|
172
|
+
const plainKeysOf = (manifest: Record<string, true>): readonly string[] =>
|
|
173
|
+
Object.keys(manifest).sort();
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The shape of the Blueprint IR at `BLUEPRINT_IR_VERSION`, as sorted key sets. Digest this
|
|
177
|
+
* (`fnv1a64(canonicalJson(BLUEPRINT_IR_SHAPE))`) to compare two builds' IR without a type checker.
|
|
178
|
+
*/
|
|
179
|
+
export const BLUEPRINT_IR_SHAPE = {
|
|
180
|
+
widget: keysOf(WIDGET),
|
|
181
|
+
dataBinding: keysOf(DATA_BINDING),
|
|
182
|
+
bindingStatusRef: keysOf(BINDING_STATUS_REF),
|
|
183
|
+
bindingStatus: keysOf(BINDING_STATUS),
|
|
184
|
+
bindingFinding: keysOf(BINDING_FINDING),
|
|
185
|
+
widgetSource: keysOf(WIDGET_SOURCE),
|
|
186
|
+
degradedFrom: keysOf(DEGRADED_FROM),
|
|
187
|
+
gridCell: keysOf(GRID_CELL),
|
|
188
|
+
gridLayout: keysOf(GRID_LAYOUT),
|
|
189
|
+
flexLayout: keysOf(FLEX_LAYOUT),
|
|
190
|
+
stackLayout: keysOf(STACK_LAYOUT),
|
|
191
|
+
blueprint: keysOf(BLUEPRINT),
|
|
192
|
+
widgetKinds: plainKeysOf(WIDGET_KINDS),
|
|
193
|
+
layoutTypes: plainKeysOf(LAYOUT_TYPES),
|
|
194
|
+
bindingStates: plainKeysOf(BINDING_STATES),
|
|
195
|
+
} as const;
|
package/src/layout.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { asCell } from "./normalize";
|
|
2
|
+
import type { FlexLayout, GridCell, GridLayout, Layout, StackLayout, Widget } from "./types";
|
|
3
|
+
|
|
4
|
+
// ADR 0099 D2 — a role is a registry KEY, so it has a key's shape: a lowercase ASCII letter,
|
|
5
|
+
// then lowercase letters, digits or hyphens, at most 64 characters in all. No `_`, `.` or
|
|
6
|
+
// uppercase, so `__proto__`/`constructor` can never be one and a role never needs escaping in a
|
|
7
|
+
// CSS attribute selector (`[data-container-role=stepped]`) or a log line.
|
|
8
|
+
const LAYOUT_ROLE = /^[a-z][a-z0-9-]{0,63}$/;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Whether `value` is a well-formed layout role (ADR 0099 D2): `/^[a-z][a-z0-9-]{0,63}$/`. A
|
|
12
|
+
* renderer looks a container's role up only when this holds; anything else — absent, empty,
|
|
13
|
+
* a non-string, `"Stepped"`, a 65-character string — draws the plain container, exactly as an
|
|
14
|
+
* unknown role does. It is the ONE spelling of the rule, so a compiler's schema and a
|
|
15
|
+
* renderer's lookup cannot drift apart.
|
|
16
|
+
*/
|
|
17
|
+
export function isLayoutRole(value: unknown): value is string {
|
|
18
|
+
return typeof value === "string" && LAYOUT_ROLE.test(value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Whether `value` is a well-formed layout id (IR 2.4.0, ADR 0101): the same rule as `Widget.id`,
|
|
23
|
+
* a non-empty string — no charset, no length cap. Anything else — absent, `""`, a number, `null`
|
|
24
|
+
* — is treated as ABSENT by every reader: the container is keyed positionally, the identity walk
|
|
25
|
+
* skips it, and it collides with nothing. It is the ONE spelling of the rule, so a compiler's
|
|
26
|
+
* schema and a renderer's keying cannot drift apart (as `isLayoutRole` is for roles).
|
|
27
|
+
*/
|
|
28
|
+
export function isLayoutId(value: unknown): value is string {
|
|
29
|
+
return typeof value === "string" && value !== "";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `isLayoutId` on a node's `id`, narrowing the node. Internal. */
|
|
33
|
+
export function hasLayoutId(layout: unknown): layout is { id: string } {
|
|
34
|
+
return isLayoutId((layout as { id?: unknown } | null | undefined)?.id);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface GridOptions {
|
|
38
|
+
/** This container's stable identity, one namespace with widget ids (IR 2.4.0, ADR 0101). */
|
|
39
|
+
id?: string;
|
|
40
|
+
columns: number;
|
|
41
|
+
gap?: number;
|
|
42
|
+
/** A section heading (IR 2.1.0). */
|
|
43
|
+
title?: string;
|
|
44
|
+
/** What this container is to a renderer that knows it (IR 2.2.0; see `isLayoutRole`). */
|
|
45
|
+
role?: string;
|
|
46
|
+
children: Array<GridCell | Widget | Layout>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function grid(options: GridOptions): GridLayout {
|
|
50
|
+
return {
|
|
51
|
+
type: "grid",
|
|
52
|
+
...(options.id !== undefined && { id: options.id }),
|
|
53
|
+
columns: options.columns,
|
|
54
|
+
...(options.gap !== undefined && { gap: options.gap }),
|
|
55
|
+
...(options.title !== undefined && { title: options.title }),
|
|
56
|
+
...(options.role !== undefined && { role: options.role }),
|
|
57
|
+
children: options.children.map(asCell),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface FlexOptions {
|
|
62
|
+
/** This container's stable identity, one namespace with widget ids (IR 2.4.0, ADR 0101). */
|
|
63
|
+
id?: string;
|
|
64
|
+
direction?: "row" | "column";
|
|
65
|
+
gap?: number;
|
|
66
|
+
/** A section heading (IR 2.1.0). */
|
|
67
|
+
title?: string;
|
|
68
|
+
/** What this container is to a renderer that knows it (IR 2.2.0; see `isLayoutRole`). */
|
|
69
|
+
role?: string;
|
|
70
|
+
children: Array<Widget | Layout>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function flex(options: FlexOptions): FlexLayout {
|
|
74
|
+
return {
|
|
75
|
+
type: "flex",
|
|
76
|
+
...(options.id !== undefined && { id: options.id }),
|
|
77
|
+
direction: options.direction ?? "row",
|
|
78
|
+
...(options.gap !== undefined && { gap: options.gap }),
|
|
79
|
+
...(options.title !== undefined && { title: options.title }),
|
|
80
|
+
...(options.role !== undefined && { role: options.role }),
|
|
81
|
+
children: options.children,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface StackOptions {
|
|
86
|
+
/** This container's stable identity, one namespace with widget ids (IR 2.4.0, ADR 0101). */
|
|
87
|
+
id?: string;
|
|
88
|
+
gap?: number;
|
|
89
|
+
/** A section heading (IR 2.1.0). */
|
|
90
|
+
title?: string;
|
|
91
|
+
/** What this container is to a renderer that knows it (IR 2.2.0; see `isLayoutRole`). */
|
|
92
|
+
role?: string;
|
|
93
|
+
children: Array<Widget | Layout>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function stack(options: StackOptions): StackLayout {
|
|
97
|
+
return {
|
|
98
|
+
type: "stack",
|
|
99
|
+
...(options.id !== undefined && { id: options.id }),
|
|
100
|
+
...(options.gap !== undefined && { gap: options.gap }),
|
|
101
|
+
...(options.title !== undefined && { title: options.title }),
|
|
102
|
+
...(options.role !== undefined && { role: options.role }),
|
|
103
|
+
children: options.children,
|
|
104
|
+
};
|
|
105
|
+
}
|