@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.
Files changed (117) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +8 -0
  3. package/README.md +83 -2
  4. package/dist/Blueprint.d.ts +14 -0
  5. package/dist/Blueprint.d.ts.map +1 -0
  6. package/dist/Blueprint.js +18 -0
  7. package/dist/Blueprint.js.map +1 -0
  8. package/dist/RendererRegistry.d.ts +9 -0
  9. package/dist/RendererRegistry.d.ts.map +1 -0
  10. package/dist/RendererRegistry.js +39 -0
  11. package/dist/RendererRegistry.js.map +1 -0
  12. package/dist/catalog.d.ts +129 -0
  13. package/dist/catalog.d.ts.map +1 -0
  14. package/dist/catalog.js +435 -0
  15. package/dist/catalog.js.map +1 -0
  16. package/dist/coverage.d.ts +18 -0
  17. package/dist/coverage.d.ts.map +1 -0
  18. package/dist/coverage.js +22 -0
  19. package/dist/coverage.js.map +1 -0
  20. package/dist/customWidget.d.ts +10 -0
  21. package/dist/customWidget.d.ts.map +1 -0
  22. package/dist/customWidget.js +34 -0
  23. package/dist/customWidget.js.map +1 -0
  24. package/dist/esm/Blueprint.js +15 -0
  25. package/dist/esm/Blueprint.js.map +1 -0
  26. package/dist/esm/RendererRegistry.js +35 -0
  27. package/dist/esm/RendererRegistry.js.map +1 -0
  28. package/dist/esm/catalog.js +430 -0
  29. package/dist/esm/catalog.js.map +1 -0
  30. package/dist/esm/coverage.js +19 -0
  31. package/dist/esm/coverage.js.map +1 -0
  32. package/dist/esm/customWidget.js +31 -0
  33. package/dist/esm/customWidget.js.map +1 -0
  34. package/dist/esm/identity.js +116 -0
  35. package/dist/esm/identity.js.map +1 -0
  36. package/dist/esm/index.js +15 -0
  37. package/dist/esm/index.js.map +1 -0
  38. package/dist/esm/irVersion.js +150 -0
  39. package/dist/esm/irVersion.js.map +1 -0
  40. package/dist/esm/layout.js +63 -0
  41. package/dist/esm/layout.js.map +1 -0
  42. package/dist/esm/normalize.js +115 -0
  43. package/dist/esm/normalize.js.map +1 -0
  44. package/dist/esm/pins.js +82 -0
  45. package/dist/esm/pins.js.map +1 -0
  46. package/dist/esm/traverseBlueprint.js +38 -0
  47. package/dist/esm/traverseBlueprint.js.map +1 -0
  48. package/dist/esm/traverseBlueprintAsync.js +48 -0
  49. package/dist/esm/traverseBlueprintAsync.js.map +1 -0
  50. package/dist/esm/types.js +2 -0
  51. package/dist/esm/types.js.map +1 -0
  52. package/dist/esm/widgetStatus.js +137 -0
  53. package/dist/esm/widgetStatus.js.map +1 -0
  54. package/dist/esm/widgets.js +105 -0
  55. package/dist/esm/widgets.js.map +1 -0
  56. package/dist/identity.d.ts +58 -0
  57. package/dist/identity.d.ts.map +1 -0
  58. package/dist/identity.js +122 -0
  59. package/dist/identity.js.map +1 -0
  60. package/dist/index.d.ts +24 -0
  61. package/dist/index.d.ts.map +1 -0
  62. package/dist/index.js +61 -0
  63. package/dist/index.js.map +1 -0
  64. package/dist/irVersion.d.ts +23 -0
  65. package/dist/irVersion.d.ts.map +1 -0
  66. package/dist/irVersion.js +153 -0
  67. package/dist/irVersion.js.map +1 -0
  68. package/dist/layout.d.ts +57 -0
  69. package/dist/layout.d.ts.map +1 -0
  70. package/dist/layout.js +71 -0
  71. package/dist/layout.js.map +1 -0
  72. package/dist/normalize.d.ts +29 -0
  73. package/dist/normalize.d.ts.map +1 -0
  74. package/dist/normalize.js +126 -0
  75. package/dist/normalize.js.map +1 -0
  76. package/dist/pins.d.ts +27 -0
  77. package/dist/pins.d.ts.map +1 -0
  78. package/dist/pins.js +86 -0
  79. package/dist/pins.js.map +1 -0
  80. package/dist/traverseBlueprint.d.ts +27 -0
  81. package/dist/traverseBlueprint.d.ts.map +1 -0
  82. package/dist/traverseBlueprint.js +40 -0
  83. package/dist/traverseBlueprint.js.map +1 -0
  84. package/dist/traverseBlueprintAsync.d.ts +18 -0
  85. package/dist/traverseBlueprintAsync.d.ts.map +1 -0
  86. package/dist/traverseBlueprintAsync.js +51 -0
  87. package/dist/traverseBlueprintAsync.js.map +1 -0
  88. package/dist/types.d.ts +176 -0
  89. package/dist/types.d.ts.map +1 -0
  90. package/dist/types.js +3 -0
  91. package/dist/types.js.map +1 -0
  92. package/dist/widgetStatus.d.ts +66 -0
  93. package/dist/widgetStatus.d.ts.map +1 -0
  94. package/dist/widgetStatus.js +141 -0
  95. package/dist/widgetStatus.js.map +1 -0
  96. package/dist/widgets.d.ts +101 -0
  97. package/dist/widgets.d.ts.map +1 -0
  98. package/dist/widgets.js +115 -0
  99. package/dist/widgets.js.map +1 -0
  100. package/package.json +60 -3
  101. package/src/Blueprint.ts +28 -0
  102. package/src/RendererRegistry.ts +38 -0
  103. package/src/catalog.ts +638 -0
  104. package/src/coverage.ts +52 -0
  105. package/src/customWidget.ts +42 -0
  106. package/src/identity.ts +178 -0
  107. package/src/index.ts +84 -0
  108. package/src/irVersion.ts +195 -0
  109. package/src/layout.ts +105 -0
  110. package/src/normalize.ts +130 -0
  111. package/src/pins.ts +84 -0
  112. package/src/traverseBlueprint.bench.ts +47 -0
  113. package/src/traverseBlueprint.ts +94 -0
  114. package/src/traverseBlueprintAsync.ts +92 -0
  115. package/src/types.ts +195 -0
  116. package/src/widgetStatus.ts +231 -0
  117. 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
+ }
@@ -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";
@@ -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
+ }