@cynodia/axiom-core 0.5.0-alpha.1 → 0.5.2-alpha.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/README.md CHANGED
@@ -6,13 +6,17 @@ framework.
6
6
  **Status: experimental / alpha.** The API may change between alpha releases.
7
7
 
8
8
  The Application Graph and its semantic model: nodes, fields, structured types,
9
- expressions, **locations** (addressable writable positions), edge derivation,
10
- validation and type inference.
9
+ expressions, **locations** (addressable writable positions), edge derivation, validation
10
+ and type inference. It also owns the presentation and UX layer — semantic roles, layout and
11
+ spacing tokens, device classes, value formats and the `Theme` — with presentation
12
+ resolution and validation.
11
13
 
12
- It also owns the presentation and UX layer — semantic roles, layout, spacing and sizing
13
- tokens, device classes, value formats and the `Theme` — together with presentation
14
- resolution and validation. Presentation lives here because it is part of the canonical
15
- graph: it is intent, not styling, and it names no colour, length or CSS property.
14
+ Presentation lives here because it is part of the canonical graph: it is intent, not
15
+ styling, and it names no colour, length or CSS property.
16
+
17
+ Main exports: `ApplicationGraph`, `TypeRef` builders, expression builders, `Location`
18
+ builders, `Presentation`, `Theme`, `resolvePresentationMap`, `validateGraph`,
19
+ `VALIDATION_CODES`, `ApplicationIR`.
16
20
 
17
21
  ## Installation
18
22
 
@@ -26,6 +30,13 @@ Most applications should install the facade package instead, which re-exports th
26
30
  npm install @cynodia/axiom@alpha
27
31
  ```
28
32
 
33
+
34
+ ## Documentation
35
+
36
+ The canonical operational contract lives in the `docs/` directory of the
37
+ [`@cynodia/axiom`](https://www.npmjs.com/package/@cynodia/axiom) package, and in
38
+ [the repository](https://github.com/cynodia/axiom). Start with `docs/AGENT_REFERENCE.md`.
39
+
29
40
  ## License
30
41
 
31
42
  MIT
package/dist/graph.d.ts CHANGED
@@ -10,9 +10,15 @@ export interface EdgeQuery {
10
10
  kinds?: readonly EdgeKind[];
11
11
  }
12
12
  /**
13
- * The Application Graph is the canonical representation of an application. Reads return
14
- * deep clones, so a node retrieved from the graph must be written back with
15
- * `updateNode` for the change to take effect.
13
+ * The Application Graph is the canonical representation of an application. Everything else
14
+ * — the IR, the emitted page, the DOM — is derived from it and is never edited.
15
+ *
16
+ * Reads return **deep clones**, so a node retrieved from the graph must be written back
17
+ * with `updateNode` for the change to take effect.
18
+ *
19
+ * Relationships are derived from the current nodes on demand and cached against a revision
20
+ * counter, so an edge cannot fall out of step with the nodes it describes and no
21
+ * correctness property depends on resynchronizing anything.
16
22
  */
17
23
  export declare class ApplicationGraph {
18
24
  private data;
package/dist/graph.js CHANGED
@@ -5,9 +5,15 @@ function clone(value) {
5
5
  return structuredClone(value);
6
6
  }
7
7
  /**
8
- * The Application Graph is the canonical representation of an application. Reads return
9
- * deep clones, so a node retrieved from the graph must be written back with
10
- * `updateNode` for the change to take effect.
8
+ * The Application Graph is the canonical representation of an application. Everything else
9
+ * — the IR, the emitted page, the DOM — is derived from it and is never edited.
10
+ *
11
+ * Reads return **deep clones**, so a node retrieved from the graph must be written back
12
+ * with `updateNode` for the change to take effect.
13
+ *
14
+ * Relationships are derived from the current nodes on demand and cached against a revision
15
+ * counter, so an edge cannot fall out of step with the nodes it describes and no
16
+ * correctness property depends on resynchronizing anything.
11
17
  */
12
18
  export class ApplicationGraph {
13
19
  data;
@@ -17,7 +23,7 @@ export class ApplicationGraph {
17
23
  /** Bumped by every change, so the derived edge index can never serve stale data. */
18
24
  revision = 0;
19
25
  semanticIndex;
20
- constructor(id, name, version = '0.5.0') {
26
+ constructor(id, name, version = '0.5.2') {
21
27
  this.data = { id, name, version, nodes: {}, edges: {} };
22
28
  }
23
29
  get id() {
package/dist/infer.js CHANGED
@@ -131,6 +131,15 @@ export function inferExpressionType(expression, context, scope) {
131
131
  return primitiveType('string');
132
132
  case 'now':
133
133
  return primitiveType('datetime');
134
+ case 'coalesce': {
135
+ // The type of the first argument, with its optionality removed: a fallback is
136
+ // what makes the value present. Inferring this is what lets a repeat over
137
+ // `coalesce(field(...), [])` still know how to identify its members.
138
+ const first = expression.arguments[0]
139
+ ? inferExpressionType(expression.arguments[0], context, scope)
140
+ : undefined;
141
+ return first?.kind === 'optional' ? first.valueType : first;
142
+ }
134
143
  default:
135
144
  return undefined;
136
145
  }
package/dist/ir.d.ts CHANGED
@@ -55,6 +55,15 @@ export interface ApplicationIR {
55
55
  * renderer can mark it without re-deriving the model.
56
56
  */
57
57
  locationRequired: Record<NodeId, boolean>;
58
+ /**
59
+ * The field that distinguishes the members of each `repeat` node's collection, resolved
60
+ * here so a runtime carries no type inference of its own.
61
+ *
62
+ * A renderer uses it to give every rendered repeat instance a stable identity. A repeat
63
+ * whose member type cannot be resolved statically is absent from this map, and the
64
+ * renderer falls back to the iteration index.
65
+ */
66
+ repeatIdentityFields: Record<NodeId, FieldId>;
58
67
  /** The application's visual identity, completed against the default theme. */
59
68
  theme: Theme;
60
69
  /**
package/dist/nodes.d.ts CHANGED
@@ -8,10 +8,23 @@ export interface NodeBase {
8
8
  name?: string;
9
9
  metadata?: Record<string, unknown>;
10
10
  }
11
+ /**
12
+ * A field of an entity.
13
+ *
14
+ * Fields are independently identifiable and globally unique across the graph, and
15
+ * **instance data is keyed by `FieldId`, never by `name`**: a record looks like
16
+ * `{ [F_TITLE]: 'Dune' }`, not `{ title: 'Dune' }`. `name` is metadata for humans and
17
+ * resolves nothing.
18
+ */
11
19
  export interface FieldDef {
12
20
  id: FieldId;
13
21
  name?: string;
14
22
  valueType: TypeRef;
23
+ /**
24
+ * The value must be **present** in every canonical instance — not `null` and not
25
+ * `undefined`. It says nothing about emptiness: `0`, `false`, `''` and `[]` all satisfy
26
+ * it. Express "must not be blank" as a `ConstraintDef` using `non-empty`.
27
+ */
15
28
  required?: boolean;
16
29
  defaultValue?: LiteralValue;
17
30
  metadata?: Record<string, unknown>;
@@ -19,10 +32,21 @@ export interface FieldDef {
19
32
  export type LiteralValue = string | number | boolean | null | LiteralValue[] | {
20
33
  [key: string]: LiteralValue;
21
34
  };
35
+ /**
36
+ * A record type. Instances of it are found wherever a state's declared type says they
37
+ * live — including nested inside collections and inside other entities — and are validated
38
+ * there.
39
+ */
22
40
  export interface EntityDef extends NodeBase {
23
41
  kind: 'entity';
24
42
  fields: FieldDef[];
25
- /** Field used to distinguish instances of this entity. */
43
+ /**
44
+ * Field used to distinguish instances of this entity.
45
+ *
46
+ * Required in order to address an instance by identity (`identitySelector`), and
47
+ * required by every `TransitionConstraintDef` on this entity — without it such a rule is
48
+ * **silently skipped**. Declare it on every entity stored in a collection.
49
+ */
26
50
  identityFieldId?: FieldId;
27
51
  }
28
52
  export type StatePersistence = {
@@ -34,11 +58,34 @@ export type StatePersistence = {
34
58
  kind: 'remote';
35
59
  sourceId: NodeId;
36
60
  };
61
+ /**
62
+ * A named application value: stored, or computed from other state.
63
+ *
64
+ * A state with no `derivation` is stored, and one that is neither `draft` nor `ephemeral`
65
+ * is **canonical** — the state entity constraints and schema conformance apply to. Stored
66
+ * values are deeply frozen on entry to the store, and every read hands out a copy.
67
+ */
37
68
  export interface StateDef extends NodeBase {
38
69
  kind: 'state';
39
70
  valueType: TypeRef;
71
+ /**
72
+ * Seed data, keyed by `FieldId` wherever it contains a record. It is walked against
73
+ * `valueType` recursively at validation time, so data keyed by field *name* is rejected
74
+ * rather than surfacing later as an inexplicably empty UI.
75
+ *
76
+ * Absent, the state starts at the default for its type: `optional` → `null`,
77
+ * `collection` → `[]`, `number` → `0`, `boolean` → `false`, other primitive → `''`,
78
+ * `enum` → its first value, `entity` → `null`.
79
+ */
40
80
  initialValue?: LiteralValue;
41
- /** When present the state is computed rather than stored. */
81
+ /**
82
+ * When present the state is computed rather than stored.
83
+ *
84
+ * Derived state is **read-only**: a write is rejected by `validateGraph` and by the
85
+ * runtime. It is recomputed on demand and handed out as a frozen deep copy, so nothing
86
+ * can work by sharing an object with the state it was derived from. Instance validation
87
+ * skips it, because the data is already validated where it is stored.
88
+ */
42
89
  derivation?: Expression;
43
90
  /**
44
91
  * Marks a state that holds work in progress. Draft instances are incomplete by
@@ -72,6 +119,18 @@ export interface ActionGuard {
72
119
  condition: Expression;
73
120
  failureMode?: FailureMode;
74
121
  }
122
+ /**
123
+ * Behaviour expressed as data, executed as a transaction.
124
+ *
125
+ * An invocation proceeds: resolve the action, bind parameters, evaluate preconditions in
126
+ * order, ask for confirmation if required — **none of which opens a transaction, so a
127
+ * refusal at any of those stages mutates nothing** — then begin a transaction, run the
128
+ * operations sequentially against provisional state, evaluate entity constraints,
129
+ * transition constraints and postconditions, and either commit everything or roll back
130
+ * every mutation.
131
+ *
132
+ * `invokeAction` returns the diagnostics of that invocation.
133
+ */
75
134
  export interface ActionDef extends NodeBase {
76
135
  kind: 'action';
77
136
  parameters?: ActionParameter[];
@@ -99,30 +158,53 @@ export type OperationKind = Operation['kind'];
99
158
  export declare const OPERATION_KINDS: readonly OperationKind[];
100
159
  /** Every mutation is a set, an insert or a remove against an addressed Location. */
101
160
  export type MutationOperation = SetOperation | InsertOperation | RemoveOperation;
161
+ /**
162
+ * Writes a value to an addressed position.
163
+ *
164
+ * A missing field along the path is created. A missing **collection item** is not: an
165
+ * identity selector that matches nothing reports `LOCATION_RESOLUTION_FAILED`.
166
+ */
102
167
  export interface SetOperation {
103
168
  kind: 'set';
104
169
  target: Location;
105
170
  value: Expression;
106
171
  }
172
+ /**
173
+ * Adds a member to a collection. The constructed value is deep-cloned before it is stored.
174
+ *
175
+ * A newly inserted entity instance has no previous state, so **no transition constraint is
176
+ * evaluated for it**. Govern creation with an action guard or an entity constraint.
177
+ */
107
178
  export interface InsertOperation {
108
179
  kind: 'insert';
109
- /** A location addressing a collection. */
180
+ /** A location addressing a collection. A non-array current value is treated as `[]`. */
110
181
  target: Location;
111
182
  value: Expression;
183
+ /** Defaults to `'end'`. */
112
184
  position?: 'start' | 'end';
113
185
  }
186
+ /**
187
+ * Removes one member of a collection.
188
+ *
189
+ * A selector that matches nothing is a **no-op**: no mutation, no log entry and no
190
+ * diagnostic. Removing an existing instance *is* a transition, with the proposed value
191
+ * bound to nothing.
192
+ */
114
193
  export interface RemoveOperation {
115
194
  kind: 'remove';
116
195
  target: CollectionItemLocation;
117
196
  }
118
197
  /**
119
- * Performs a set of mutations once per member of a collection. The iteration is not a
120
- * transaction of its own: it runs inside the action's transaction, so a failure in any
121
- * iteration rolls the whole action back.
198
+ * Performs a set of mutations once per member of a collection.
199
+ *
200
+ * Iteration N observes provisional writes from previous iterations. The loop executes
201
+ * inside the containing action's transaction and opens none of its own, so any failure
202
+ * rolls back the complete action — every iteration included. The collection itself is
203
+ * evaluated **once**, before the first member is mutated.
122
204
  *
123
205
  * `scopeId` introduces an iteration scope. Nested expressions refer to the current member
124
206
  * as `ref(scopeId)`, and nested locations may use it to address the canonical record the
125
- * member points at.
207
+ * member points at. Nested operations must be mutations only.
126
208
  */
127
209
  export interface ForEachOperation {
128
210
  kind: 'for-each';
@@ -170,11 +252,22 @@ export interface NativeOperation {
170
252
  resultTarget?: Location;
171
253
  declaredEffects?: NativeEffect[];
172
254
  }
255
+ /**
256
+ * An invariant over proposed state, evaluated after every governed mutation.
257
+ *
258
+ * A constraint that cannot be evaluated counts as **violated**, never as satisfied.
259
+ */
173
260
  export interface ConstraintDef extends NodeBase {
174
261
  kind: 'constraint';
175
262
  expression: Expression;
176
- /** When set, the expression is evaluated once per instance of this entity. */
263
+ /**
264
+ * When set, the expression is evaluated once per canonical instance of this entity, with
265
+ * the instance bound to `ref(entityId)` — wherever that instance is stored, including
266
+ * nested inside another entity. Without it the expression is evaluated once, in the root
267
+ * scope.
268
+ */
177
269
  entityId?: NodeId;
270
+ /** Defaults to `'error'`. A `'warning'` is advice and **never blocks a write**. */
178
271
  severity?: 'error' | 'warning';
179
272
  message?: string;
180
273
  }
@@ -189,6 +282,13 @@ export interface ConstraintDef extends NodeBase {
189
282
  *
190
283
  * `previousScopeId` and `proposedScopeId` bind those two instances for the expression.
191
284
  * When the instance is being removed, the proposed scope is bound to nothing.
285
+ *
286
+ * "Previous" means committed state as it stood immediately before the **outermost**
287
+ * transaction began — not the previous operation, and not the previous iteration.
288
+ *
289
+ * A **newly inserted** instance has no previous state and is therefore **not evaluated**.
290
+ * Govern creation with an action guard or an entity constraint. The entity must declare
291
+ * `identityFieldId`; without one this rule is silently skipped.
192
292
  */
193
293
  export interface TransitionConstraintDef extends NodeBase {
194
294
  kind: 'transition-constraint';
@@ -70,9 +70,28 @@ export type PaddingIntent = SpacingToken | {
70
70
  };
71
71
  export type SurfaceRole = 'transparent' | 'base' | 'subtle' | 'raised' | 'inset';
72
72
  export declare const SURFACE_ROLES: readonly SurfaceRole[];
73
- /** Semantic text roles. A theme maps each to font metrics; the graph names no sizes. */
73
+ /**
74
+ * Semantic text roles. A theme maps each to font metrics; the graph names no sizes.
75
+ *
76
+ * A text role is a **typographic** decision only. Whether the value is a document heading
77
+ * is `headingLevel`, which is separate on purpose: a large monetary total or a dashboard
78
+ * statistic wants `display` type at `headingLevel: 'none'`.
79
+ */
74
80
  export type TextRole = 'body' | 'caption' | 'label' | 'heading' | 'title' | 'display';
75
81
  export declare const TEXT_ROLES: readonly TextRole[];
82
+ /**
83
+ * Where a value sits in the document outline, independently of how large it is drawn.
84
+ * `'none'` means the value is not a heading at all.
85
+ */
86
+ export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6 | 'none';
87
+ export declare const HEADING_LEVELS: readonly HeadingLevel[];
88
+ /**
89
+ * The outline level a text role implies when nothing says otherwise.
90
+ *
91
+ * This is the 0.5.0 mapping, kept so existing applications do not lose their heading
92
+ * structure. An explicit `headingLevel` always wins.
93
+ */
94
+ export declare const TEXT_ROLE_HEADING_LEVELS: Readonly<Record<TextRole, HeadingLevel>>;
76
95
  /**
77
96
  * Why a node exists in the interface. A UX role is high-information: `toolbar` implies a
78
97
  * horizontal, wrapping, centre-aligned group, so an author states the role rather than
@@ -165,6 +184,12 @@ export interface Presentation {
165
184
  emphasis?: Emphasis;
166
185
  density?: Density | LegacyDensity;
167
186
  textRole?: TextRole;
187
+ /**
188
+ * The document-outline level, independent of `textRole`. `'none'` renders the value as
189
+ * ordinary text however large it is drawn. Omitted, it is inferred from `textRole` by
190
+ * `TEXT_ROLE_HEADING_LEVELS`.
191
+ */
192
+ headingLevel?: HeadingLevel;
168
193
  uxRole?: UxRole;
169
194
  surface?: SurfaceRole;
170
195
  treatment?: Treatment;
@@ -241,6 +266,8 @@ export interface ResolvedPresentation {
241
266
  emphasis: Emphasis;
242
267
  density: Density;
243
268
  textRole: TextRole;
269
+ /** `'none'` when the value is not part of the document outline. */
270
+ headingLevel: HeadingLevel;
244
271
  uxRole?: UxRole;
245
272
  surface: SurfaceRole;
246
273
  treatment: Treatment;
@@ -26,6 +26,21 @@ export const JUSTIFICATIONS = [...ALIGNMENTS, 'between'];
26
26
  export const LAYOUT_KINDS = ['vertical', 'horizontal', 'grid', 'stack'];
27
27
  export const SURFACE_ROLES = ['transparent', 'base', 'subtle', 'raised', 'inset'];
28
28
  export const TEXT_ROLES = ['body', 'caption', 'label', 'heading', 'title', 'display'];
29
+ export const HEADING_LEVELS = [1, 2, 3, 4, 5, 6, 'none'];
30
+ /**
31
+ * The outline level a text role implies when nothing says otherwise.
32
+ *
33
+ * This is the 0.5.0 mapping, kept so existing applications do not lose their heading
34
+ * structure. An explicit `headingLevel` always wins.
35
+ */
36
+ export const TEXT_ROLE_HEADING_LEVELS = {
37
+ display: 1,
38
+ title: 2,
39
+ heading: 3,
40
+ body: 'none',
41
+ caption: 'none',
42
+ label: 'none',
43
+ };
29
44
  export const UX_ROLES = [
30
45
  'primary-action',
31
46
  'secondary-action',
@@ -1,7 +1,7 @@
1
1
  import type { NodeId } from './ids.js';
2
2
  import type { ActionDef } from './nodes.js';
3
3
  import type { AnyNode } from './types.js';
4
- import type { Density, PresentationOrigin, ResolvedLayout, ResolvedPadding, ResolvedPresentation, ResolvedSizing, UxRole, ValueFormat } from './presentation.js';
4
+ import type { Density, HeadingLevel, PresentationOrigin, ResolvedLayout, ResolvedPadding, ResolvedPresentation, ResolvedSizing, UxRole, ValueFormat } from './presentation.js';
5
5
  import type { Theme } from './theme.js';
6
6
  /**
7
7
  * Presentation resolution, §40.
@@ -28,6 +28,7 @@ interface Layer {
28
28
  emphasis?: ResolvedPresentation['emphasis'];
29
29
  density?: Density;
30
30
  textRole?: ResolvedPresentation['textRole'];
31
+ headingLevel?: HeadingLevel;
31
32
  uxRole?: UxRole;
32
33
  surface?: ResolvedPresentation['surface'];
33
34
  treatment?: ResolvedPresentation['treatment'];
@@ -1,5 +1,5 @@
1
1
  import { isUINode, uiChildIds } from './ui.js';
2
- import { normalizeDensity, normalizeLayout, normalizePadding, normalizeRole, } from './presentation.js';
2
+ import { TEXT_ROLE_HEADING_LEVELS, normalizeDensity, normalizeLayout, normalizePadding, normalizeRole, } from './presentation.js';
3
3
  import { DEFAULT_THEME } from './theme.js';
4
4
  /**
5
5
  * Presentation resolution, §40.
@@ -68,8 +68,14 @@ function buildIndex(nodes) {
68
68
  }
69
69
  return { nodes: byId, parentOf, formOf, fieldTypes };
70
70
  }
71
- /** What a node's kind implies, before anything about the application is considered. */
72
- function kindLayer(node) {
71
+ /**
72
+ * What a node's kind implies, before anything about the application is considered.
73
+ *
74
+ * A control carries its own internal arrangement — a button is a centred row containing an
75
+ * icon and a label, not a bare box. Getting this right here is what stops every
76
+ * application from restating the same corrective layout and padding on every button.
77
+ */
78
+ function kindLayer(node, theme) {
73
79
  switch (node.kind) {
74
80
  case 'view':
75
81
  return { origin: 'inferred', layout: { kind: 'vertical', gap: 'large' }, sizing: { width: 'fill' } };
@@ -92,6 +98,8 @@ function kindLayer(node) {
92
98
  return { origin: 'inferred', layout: { kind: 'vertical', gap: 'small' }, sizing: { width: 'fill' } };
93
99
  case 'conditional':
94
100
  return { origin: 'inferred', layout: { kind: 'vertical', gap: 'medium' }, sizing: { width: 'fill' } };
101
+ case 'diagnostic':
102
+ return { origin: 'inferred', layout: { kind: 'vertical', gap: 'xsmall' }, sizing: { width: 'fill' } };
95
103
  case 'field-display':
96
104
  return {
97
105
  origin: 'inferred',
@@ -102,8 +110,23 @@ function kindLayer(node) {
102
110
  return { origin: 'inferred', sizing: { width: 'content' } };
103
111
  case 'input':
104
112
  return { origin: 'inferred', layout: { kind: 'vertical', gap: 'xsmall' }, sizing: { width: 'fill' } };
105
- case 'button':
106
- return { origin: 'inferred', role: 'secondary', sizing: { width: 'content' } };
113
+ case 'button': {
114
+ const buttons = theme.buttons;
115
+ return {
116
+ origin: 'inferred',
117
+ role: 'secondary',
118
+ sizing: { width: 'content' },
119
+ layout: {
120
+ kind: buttons.layout,
121
+ gap: buttons.gap,
122
+ align: buttons.align,
123
+ justify: buttons.justify,
124
+ wrap: false,
125
+ },
126
+ // Control padding comes from the theme's control metrics, not from spacing tokens.
127
+ padding: { horizontal: 'none', vertical: 'none' },
128
+ };
129
+ }
107
130
  default:
108
131
  return { origin: 'inferred' };
109
132
  }
@@ -213,6 +236,13 @@ function statusLayer(role, icon) {
213
236
  * destructive action is presented as destructive without the graph saying so twice.
214
237
  */
215
238
  function semanticLayer(node, index) {
239
+ if (node.kind === 'diagnostic') {
240
+ // A region that reports a refusal is a status region of the matching severity.
241
+ return {
242
+ origin: 'inferred',
243
+ uxRole: node.severity === 'warning' ? 'warning-state' : 'error-state',
244
+ };
245
+ }
216
246
  if (node.kind !== 'button') {
217
247
  return { origin: 'inferred' };
218
248
  }
@@ -279,6 +309,9 @@ function nodeLayer(presentation) {
279
309
  if (presentation.textRole) {
280
310
  layer.textRole = presentation.textRole;
281
311
  }
312
+ if (presentation.headingLevel !== undefined) {
313
+ layer.headingLevel = presentation.headingLevel;
314
+ }
282
315
  if (presentation.uxRole) {
283
316
  layer.uxRole = presentation.uxRole;
284
317
  }
@@ -333,6 +366,7 @@ function applyLayers(nodeId, layers) {
333
366
  emphasis: 'normal',
334
367
  density: 'comfortable',
335
368
  textRole: 'body',
369
+ headingLevel: 'none',
336
370
  surface: 'transparent',
337
371
  treatment: 'plain',
338
372
  layout: { ...DEFAULT_LAYOUT },
@@ -348,6 +382,7 @@ function applyLayers(nodeId, layers) {
348
382
  'emphasis',
349
383
  'density',
350
384
  'textRole',
385
+ 'headingLevel',
351
386
  'uxRole',
352
387
  'surface',
353
388
  'treatment',
@@ -402,6 +437,14 @@ function applyLayers(nodeId, layers) {
402
437
  origins.padding = layer.origin;
403
438
  }
404
439
  }
440
+ // The outline level follows the type scale unless something stated it. This is the
441
+ // 0.5.0 mapping, kept so an existing application does not lose its heading structure.
442
+ if (origins.headingLevel === undefined) {
443
+ resolved.headingLevel = TEXT_ROLE_HEADING_LEVELS[resolved.textRole];
444
+ if (resolved.headingLevel !== 'none') {
445
+ origins.headingLevel = 'inferred';
446
+ }
447
+ }
405
448
  // Anything no layer had an opinion about came from the renderer's own baseline.
406
449
  void layoutKindDeclared;
407
450
  const record = (key, value) => {
@@ -512,7 +555,7 @@ export function resolvePresentationMap(nodes, theme = DEFAULT_THEME) {
512
555
  if (inherited) {
513
556
  layers.push(inherited);
514
557
  }
515
- layers.push(kindLayer(node));
558
+ layers.push(kindLayer(node, theme));
516
559
  const uxRole = declared?.uxRole ?? semanticLayer(node, index).uxRole;
517
560
  if (uxRole) {
518
561
  layers.push(uxRoleLayer(uxRole));
package/dist/theme.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { BoundedSize, Density, Emphasis, IconName, SpacingToken, SurfaceRole, TextRole } from './presentation.js';
1
+ import type { Alignment, BoundedSize, Density, Emphasis, IconName, Justification, LayoutKind, SpacingToken, SurfaceRole, TextRole } from './presentation.js';
2
2
  /**
3
3
  * A theme is the translation layer between semantic presentation intent and visual
4
4
  * rendering. It is the one place concrete values are allowed to live, because it is not
@@ -69,6 +69,23 @@ export interface SurfaceStyle {
69
69
  elevation: 0 | 1 | 2 | 3;
70
70
  radius: RadiusToken;
71
71
  }
72
+ /**
73
+ * How an ordinary button is put together internally.
74
+ *
75
+ * These are the affordances every button needs and none should have to restate. An
76
+ * application annotates a button only where it differs from this.
77
+ */
78
+ export interface ButtonTheme {
79
+ /** Direction of a button's own contents. Horizontal keeps an icon beside its label. */
80
+ layout: LayoutKind;
81
+ gap: SpacingToken;
82
+ align: Alignment;
83
+ justify: Justification;
84
+ /** Horizontal padding, relative to `controls.paddingX`. */
85
+ paddingScale: number;
86
+ /** Where an icon sits relative to the label. */
87
+ iconPlacement: 'leading' | 'trailing';
88
+ }
72
89
  export interface ControlTheme {
73
90
  /** Control height per density. */
74
91
  height: Record<Density, number>;
@@ -114,6 +131,8 @@ export interface Theme {
114
131
  sizes: Record<BoundedSize, number>;
115
132
  surfaces: Record<SurfaceRole, SurfaceStyle>;
116
133
  controls: ControlTheme;
134
+ /** Internal arrangement of an ordinary button. See `ButtonTheme`. */
135
+ buttons: ButtonTheme;
117
136
  /** How much the density scales spacing and control metrics. */
118
137
  density: Record<Density, {
119
138
  scale: number;
package/dist/theme.js CHANGED
@@ -73,6 +73,14 @@ export const DEFAULT_THEME = {
73
73
  borderWidth: 1,
74
74
  focusWidth: 3,
75
75
  },
76
+ buttons: {
77
+ layout: 'horizontal',
78
+ gap: 'xsmall',
79
+ align: 'center',
80
+ justify: 'center',
81
+ paddingScale: 1.15,
82
+ iconPlacement: 'leading',
83
+ },
76
84
  density: { compact: { scale: 0.7 }, comfortable: { scale: 1 }, spacious: { scale: 1.35 } },
77
85
  colors: {
78
86
  light: {
package/dist/ui.d.ts CHANGED
@@ -2,12 +2,18 @@ import type { Expression } from './expressions.js';
2
2
  import type { FieldId, NodeId } from './ids.js';
3
3
  import type { Location } from './location.js';
4
4
  import type { Presentation } from './presentation.js';
5
- export type UINodeKind = 'view' | 'container' | 'text' | 'repeat' | 'field-display' | 'form' | 'input' | 'button' | 'conditional';
5
+ export type UINodeKind = 'view' | 'container' | 'text' | 'repeat' | 'field-display' | 'form' | 'input' | 'button' | 'conditional' | 'diagnostic';
6
6
  export declare const UI_NODE_KINDS: readonly UINodeKind[];
7
7
  export interface UIBase {
8
8
  id: NodeId;
9
9
  kind: UINodeKind;
10
10
  name?: string;
11
+ /**
12
+ * Whether the node is rendered. This is interaction behaviour, **not authorization**:
13
+ * hidden is not forbidden, and a governed write is checked whether or not any control
14
+ * for it is visible. A rule belongs in an action guard, a `ConstraintDef` or a
15
+ * `TransitionConstraintDef`.
16
+ */
11
17
  visibleWhen?: Expression;
12
18
  /** Presentation and UX intent. Entirely optional; defaults do the rest. */
13
19
  presentation?: Presentation;
@@ -34,8 +40,12 @@ export interface TextNode extends UIBase {
34
40
  value: string | Expression;
35
41
  }
36
42
  /**
37
- * Repeats `templateId` over `source`. The current item is bound to this node's own id,
43
+ * Repeats `templateId` over `source`. The current item is bound to **this node's own id**,
38
44
  * so templates reference it with `{ kind: 'ref', targetId: <repeat node id> }`.
45
+ *
46
+ * `itemAlias` is metadata for humans and resolves nothing. A `source` that evaluates to
47
+ * `null` fails the evaluation rather than rendering nothing — use
48
+ * `coalesce(..., literal([]))` where a collection may legitimately be absent.
39
49
  */
40
50
  export interface RepeatNode extends UIBase {
41
51
  kind: 'repeat';
@@ -50,12 +60,37 @@ export interface FieldDisplayNode extends UIBase {
50
60
  fieldId: FieldId;
51
61
  label?: string;
52
62
  }
63
+ /**
64
+ * Groups controls, and optionally submits an action.
65
+ *
66
+ * There are two ways to give a form a submit control:
67
+ *
68
+ * - `submitActionId` (+ `submitLabel`) is the simple form: the renderer generates the
69
+ * button. Nothing else has to be declared.
70
+ * - `submitButtonId` is the advanced form: a declared `ButtonNode` inside the form becomes
71
+ * the submit control. It stays an ordinary graph node — queryable, positionable in an
72
+ * action group, and able to carry its own presentation and icon — while still receiving
73
+ * native form-submit behaviour.
74
+ *
75
+ * `target` is the record the form is about. It is a read: it does **not** decide where the
76
+ * children write, because every input carries its own location.
77
+ */
53
78
  export interface FormNode extends UIBase {
54
79
  kind: 'form';
55
80
  target: Expression;
56
81
  children: NodeId[];
82
+ /**
83
+ * The action the form submits. Optional when `submitButtonId` is given, in which case
84
+ * that button's own `actionId` is the submit action; if both are given they must agree.
85
+ */
57
86
  submitActionId?: NodeId;
87
+ /** Label for the generated submit button. Ignored when `submitButtonId` is given. */
58
88
  submitLabel?: string;
89
+ /**
90
+ * A `ButtonNode` among this form's descendants to use as the submit control instead of a
91
+ * generated one.
92
+ */
93
+ submitButtonId?: NodeId;
59
94
  }
60
95
  /**
61
96
  * An input writes to an addressed Location, never to whatever object an expression
@@ -64,6 +99,19 @@ export interface FormNode extends UIBase {
64
99
  export interface InputBinding {
65
100
  location: Location;
66
101
  }
102
+ /**
103
+ * An input's write goes through the same mutation engine and the same transaction
104
+ * machinery as an action; there is no second write path inside the renderer.
105
+ *
106
+ * What governs it depends on what the location is **rooted in**. Rooted in canonical
107
+ * state, the write is transactional with respect to hard invariants: a value that would
108
+ * break one is rolled back and the control re-renders with what is actually stored. Rooted
109
+ * in a `draft` or `ephemeral` state it is not guarded per keystroke, because such a value
110
+ * is incomplete by definition while it is being filled in.
111
+ *
112
+ * Transition constraints apply either way. Binding an input to a protected location does
113
+ * not bypass anything.
114
+ */
67
115
  export type InputHint = 'text' | 'email' | 'number' | 'password' | 'date' | 'checkbox' | 'multiline' | 'select';
68
116
  /**
69
117
  * Offers a choice drawn from application data rather than a fixed enum, which is how a
@@ -79,7 +127,11 @@ export interface InputOptionsSource {
79
127
  export interface InputNode extends UIBase {
80
128
  kind: 'input';
81
129
  binding: InputBinding;
82
- /** Presentation hint only; the runtime infers a control from the field type otherwise. */
130
+ /**
131
+ * The 0.2 spelling of control intent, shaped after HTML input types.
132
+ * `presentation.control` supersedes it and is consulted first; absent both, the runtime
133
+ * infers a control from the type of the bound location.
134
+ */
83
135
  inputHint?: InputHint;
84
136
  options?: InputOptionsSource;
85
137
  label?: string;
@@ -103,10 +155,49 @@ export interface ConditionalNode extends UIBase {
103
155
  whenTrue: NodeId[];
104
156
  whenFalse?: NodeId[];
105
157
  }
106
- export type UINode = ViewNode | ContainerNode | TextNode | RepeatNode | FieldDisplayNode | FormNode | InputNode | ButtonNode | ConditionalNode;
158
+ /**
159
+ * Presents why an action refused.
160
+ *
161
+ * The runtime already knows an action failed and why. This node makes that available to
162
+ * the semantic application model, so an application never has to duplicate an action's
163
+ * guards as derived state merely to explain them, inspect console output, or copy an
164
+ * `ActionResult` into its own state.
165
+ *
166
+ * It presents the diagnostics of the referenced action's **most recent invocation**:
167
+ *
168
+ * - a failed invocation replaces whatever was there with its own diagnostics;
169
+ * - a successful one replaces them with nothing, so the message clears;
170
+ * - a declined confirmation is recorded as `cancelled` and likewise presents nothing;
171
+ * - the record is cleared by `clearDiagnostics()` and by navigating to another route.
172
+ *
173
+ * The messages come from the structured diagnostics — `failureMode.message` for a refused
174
+ * guard, `ConstraintDef.message` for a broken invariant — never from wording in the
175
+ * renderer.
176
+ */
177
+ export interface DiagnosticNode extends UIBase {
178
+ kind: 'diagnostic';
179
+ /** The action whose most recent invocation is reported. */
180
+ actionId: NodeId;
181
+ /**
182
+ * The lowest severity presented. `'error'` (the default) presents only errors;
183
+ * `'warning'` presents warnings as well.
184
+ */
185
+ severity?: 'error' | 'warning';
186
+ }
187
+ export type UINode = ViewNode | ContainerNode | TextNode | RepeatNode | FieldDisplayNode | FormNode | InputNode | ButtonNode | ConditionalNode | DiagnosticNode;
107
188
  export declare function isUINode(node: {
108
189
  kind: string;
109
190
  }): node is UINode;
191
+ /**
192
+ * Children along the **primary render path**: the arrangement that appears when every
193
+ * collection has members and every condition holds.
194
+ *
195
+ * An empty template and a false branch are *alternatives* to that path, not part of it.
196
+ * Analysis of structure that is on screen together — a document outline, the sections of a
197
+ * form — walks this rather than `uiChildIds`, which is what keeps it free of findings about
198
+ * content that never appears at the same time.
199
+ */
200
+ export declare function primaryChildIds(node: UINode): NodeId[];
110
201
  /** Child ids declared by a UI node, in render order. */
111
202
  export declare function uiChildIds(node: UINode): NodeId[];
112
203
  //# sourceMappingURL=ui.d.ts.map
package/dist/ui.js CHANGED
@@ -8,10 +8,30 @@ export const UI_NODE_KINDS = [
8
8
  'input',
9
9
  'button',
10
10
  'conditional',
11
+ 'diagnostic',
11
12
  ];
12
13
  export function isUINode(node) {
13
14
  return UI_NODE_KINDS.includes(node.kind);
14
15
  }
16
+ /**
17
+ * Children along the **primary render path**: the arrangement that appears when every
18
+ * collection has members and every condition holds.
19
+ *
20
+ * An empty template and a false branch are *alternatives* to that path, not part of it.
21
+ * Analysis of structure that is on screen together — a document outline, the sections of a
22
+ * form — walks this rather than `uiChildIds`, which is what keeps it free of findings about
23
+ * content that never appears at the same time.
24
+ */
25
+ export function primaryChildIds(node) {
26
+ switch (node.kind) {
27
+ case 'repeat':
28
+ return [node.templateId];
29
+ case 'conditional':
30
+ return [...node.whenTrue];
31
+ default:
32
+ return uiChildIds(node);
33
+ }
34
+ }
15
35
  /** Child ids declared by a UI node, in render order. */
16
36
  export function uiChildIds(node) {
17
37
  switch (node.kind) {
@@ -1,5 +1,5 @@
1
1
  import { VALIDATION_CODES } from './diagnostics.js';
2
- import { inferLocationType, locationCapabilities } from './infer.js';
2
+ import { inferExpressionType, inferLocationType, locationCapabilities } from './infer.js';
3
3
  import { locationRootStateId } from './location.js';
4
4
  /**
5
5
  * Structural validation of a location: does every state, field and selector it names
@@ -58,6 +58,15 @@ function walk(location, context, report) {
58
58
  return;
59
59
  }
60
60
  if (location.selector.kind !== 'identity') {
61
+ // An index has to be a number. Type inference is partial, so this rejects only
62
+ // what is statically certain to be wrong.
63
+ const indexType = inferExpressionType(location.selector.index, context);
64
+ const resolvedIndex = indexType?.kind === 'optional' ? indexType.valueType : indexType;
65
+ const numeric = resolvedIndex === undefined ||
66
+ (resolvedIndex.kind === 'primitive' && resolvedIndex.primitive === 'number');
67
+ if (!numeric) {
68
+ report(VALIDATION_CODES.invalidSelectorType, `An index selector must be a number, but this one is a ${resolvedIndex.kind === 'primitive' ? resolvedIndex.primitive : resolvedIndex.kind} value`);
69
+ }
61
70
  return;
62
71
  }
63
72
  const entry = context.getField(location.selector.fieldId);
@@ -1,8 +1,8 @@
1
1
  import { VALIDATION_CODES } from './diagnostics.js';
2
- import { ALIGNMENTS, BOUNDED_SIZES, CONTROL_VARIANTS, DENSITIES, DEVICE_CLASSES, EMPHASIS_LEVELS, ICON_NAMES, JUSTIFICATIONS, LAYOUT_KINDS, PRESENTATION_ROLES, SIZING_VALUES, SPACING_TOKENS, SURFACE_ROLES, TEXT_ROLES, TREATMENTS, UX_ROLES, VALUE_FORMAT_KINDS, } from './presentation.js';
2
+ import { ACTION_UX_ROLES, ALIGNMENTS, BOUNDED_SIZES, CONTROL_VARIANTS, DENSITIES, DEVICE_CLASSES, EMPHASIS_LEVELS, ICON_NAMES, JUSTIFICATIONS, LAYOUT_KINDS, PRESENTATION_ROLES, SIZING_VALUES, SPACING_TOKENS, SURFACE_ROLES, TEXT_ROLES, TREATMENTS, HEADING_LEVELS, UX_ROLES, VALUE_FORMAT_KINDS, } from './presentation.js';
3
3
  import { isDestructiveAction } from './resolve-presentation.js';
4
4
  import { APPEARANCES, RADIUS_TOKENS, SEMANTIC_COLOR_ROLES } from './theme.js';
5
- import { isUINode, uiChildIds } from './ui.js';
5
+ import { isUINode, primaryChildIds, uiChildIds } from './ui.js';
6
6
  /** More than this many controls side by side stops being a group and becomes a wall. */
7
7
  const HORIZONTAL_ACTION_LIMIT = 5;
8
8
  /** Below this a non-wrapping row is not yet a responsive problem. */
@@ -12,6 +12,7 @@ const PRESENTATION_KEYS = [
12
12
  'emphasis',
13
13
  'density',
14
14
  'textRole',
15
+ 'headingLevel',
15
16
  'uxRole',
16
17
  'surface',
17
18
  'treatment',
@@ -158,6 +159,9 @@ function checkPresentationTokens(bag, nodeId, presentation) {
158
159
  checkToken(bag, nodeId, `${path}.emphasis`, declared.emphasis, EMPHASIS_LEVELS);
159
160
  checkToken(bag, nodeId, `${path}.density`, declared.density, ACCEPTED_DENSITIES);
160
161
  checkToken(bag, nodeId, `${path}.textRole`, declared.textRole, TEXT_ROLES);
162
+ if (declared.headingLevel !== undefined && !HEADING_LEVELS.includes(declared.headingLevel)) {
163
+ unknown(bag, nodeId, `${path}.headingLevel`, declared.headingLevel, HEADING_LEVELS.map(String));
164
+ }
161
165
  checkToken(bag, nodeId, `${path}.uxRole`, declared.uxRole, UX_ROLES);
162
166
  checkToken(bag, nodeId, `${path}.surface`, declared.surface, SURFACE_ROLES);
163
167
  checkToken(bag, nodeId, `${path}.treatment`, declared.treatment, TREATMENTS);
@@ -215,6 +219,7 @@ function checkTheme(bag, theme) {
215
219
  'sizes',
216
220
  'surfaces',
217
221
  'controls',
222
+ 'buttons',
218
223
  'density',
219
224
  'colors',
220
225
  'responsive',
@@ -274,6 +279,25 @@ function checkTheme(bag, theme) {
274
279
  checkKeys(bag, undefined, 'theme.typography.emphasis', declared.typography.emphasis, EMPHASIS_LEVELS);
275
280
  }
276
281
  }
282
+ if (isRecord(declared.buttons)) {
283
+ const buttons = declared.buttons;
284
+ checkKeys(bag, undefined, 'theme.buttons', buttons, [
285
+ 'layout',
286
+ 'gap',
287
+ 'align',
288
+ 'justify',
289
+ 'paddingScale',
290
+ 'iconPlacement',
291
+ ]);
292
+ checkToken(bag, undefined, 'theme.buttons.layout', buttons.layout, LAYOUT_KINDS);
293
+ checkToken(bag, undefined, 'theme.buttons.gap', buttons.gap, SPACING_TOKENS);
294
+ checkToken(bag, undefined, 'theme.buttons.align', buttons.align, ALIGNMENTS);
295
+ checkToken(bag, undefined, 'theme.buttons.justify', buttons.justify, JUSTIFICATIONS);
296
+ checkToken(bag, undefined, 'theme.buttons.iconPlacement', buttons.iconPlacement, [
297
+ 'leading',
298
+ 'trailing',
299
+ ]);
300
+ }
277
301
  if (isRecord(declared.responsive)) {
278
302
  checkKeys(bag, undefined, 'theme.responsive', declared.responsive, DEVICE_CLASSES);
279
303
  }
@@ -281,15 +305,43 @@ function checkTheme(bag, theme) {
281
305
  function buildIndex(nodes) {
282
306
  const byId = new Map();
283
307
  const children = new Map();
308
+ const primaryChildren = new Map();
309
+ const fieldTypes = new Map();
284
310
  for (const node of nodes) {
285
311
  byId.set(node.id, node);
312
+ if (node.kind === 'entity') {
313
+ for (const field of node.fields) {
314
+ fieldTypes.set(field.id, field.valueType);
315
+ }
316
+ }
286
317
  }
287
318
  for (const node of byId.values()) {
288
319
  if (isUINode(node)) {
289
320
  children.set(node.id, uiChildIds(node));
321
+ primaryChildren.set(node.id, primaryChildIds(node));
290
322
  }
291
323
  }
292
- return { nodes: byId, children };
324
+ return { nodes: byId, children, primaryChildren, fieldTypes };
325
+ }
326
+ /** UI nodes along the primary render path, in render order. */
327
+ function primaryDescendants(index, id) {
328
+ const found = [];
329
+ const seen = new Set([id]);
330
+ const visit = (current) => {
331
+ for (const childId of index.primaryChildren.get(current) ?? []) {
332
+ if (seen.has(childId)) {
333
+ continue;
334
+ }
335
+ seen.add(childId);
336
+ const child = index.nodes.get(childId);
337
+ if (child && isUINode(child)) {
338
+ found.push(child);
339
+ visit(childId);
340
+ }
341
+ }
342
+ };
343
+ visit(id);
344
+ return found;
293
345
  }
294
346
  function descendants(index, id) {
295
347
  const found = [];
@@ -366,6 +418,7 @@ export function validatePresentation(nodes, theme, resolved) {
366
418
  checkRigidLayout(bag, index, node, view);
367
419
  checkAccessibleName(bag, node);
368
420
  checkDestructive(bag, node, view, actions);
421
+ checkNodeCompatibility(bag, index, node, actions);
369
422
  checkEmptyState(bag, index, node, view);
370
423
  }
371
424
  checkPrimaryActions(bag, index, resolved);
@@ -431,6 +484,128 @@ function checkAccessibleName(bag, node) {
431
484
  nodeId: node.id,
432
485
  });
433
486
  }
487
+ /** UX roles that describe a control's place in the action hierarchy. */
488
+ const CONTROL_ONLY_UX_ROLES = new Set(ACTION_UX_ROLES);
489
+ /** UX roles that describe a region containing other nodes. */
490
+ const REGION_UX_ROLES = new Set([
491
+ 'form-section',
492
+ 'action-group',
493
+ 'navigation-group',
494
+ 'toolbar',
495
+ 'sidebar',
496
+ 'content-region',
497
+ 'header-region',
498
+ 'footer-region',
499
+ ]);
500
+ /** Node kinds that can contain other UI nodes. */
501
+ const CONTAINING_KINDS = new Set(['view', 'container', 'form', 'conditional', 'repeat']);
502
+ /** Node kinds that render a value, and can therefore be formatted or given a treatment. */
503
+ const VALUE_KINDS = new Set(['text', 'field-display']);
504
+ /** Whether an action navigates, directly or through one level of invocation. */
505
+ function navigates(action, actions, depth = 1) {
506
+ for (const operation of action.operations ?? []) {
507
+ if (operation.kind === 'navigate') {
508
+ return true;
509
+ }
510
+ if (operation.kind === 'invoke' && depth > 0) {
511
+ const target = actions.get(operation.actionId);
512
+ if (target && navigates(target, actions, depth - 1)) {
513
+ return true;
514
+ }
515
+ }
516
+ }
517
+ return false;
518
+ }
519
+ /** Whether a format could describe a value of this type. */
520
+ function formatFits(format, type) {
521
+ if (!type) {
522
+ return true;
523
+ }
524
+ const resolved = type.kind === 'optional' ? type.valueType : type;
525
+ if (resolved.kind === 'collection' || resolved.kind === 'entity') {
526
+ return format.kind === 'text';
527
+ }
528
+ if (resolved.kind === 'enum') {
529
+ return format.kind === 'text' || format.kind === 'boolean';
530
+ }
531
+ if (resolved.kind !== 'primitive') {
532
+ return true;
533
+ }
534
+ switch (format.kind) {
535
+ case 'number':
536
+ case 'currency':
537
+ case 'percentage':
538
+ return resolved.primitive === 'number';
539
+ case 'boolean':
540
+ return resolved.primitive === 'boolean';
541
+ case 'date':
542
+ case 'datetime':
543
+ return resolved.primitive === 'date' || resolved.primitive === 'datetime';
544
+ default:
545
+ return true;
546
+ }
547
+ }
548
+ /**
549
+ * Presentation that contradicts what the node it sits on actually is.
550
+ *
551
+ * Every check here is decided from the graph alone — a role that only a control can have,
552
+ * a region role on something that holds no children, a format that could never describe
553
+ * the declared type. Nothing here is a heuristic about taste.
554
+ */
555
+ function checkNodeCompatibility(bag, index, node, actions) {
556
+ const declared = node.presentation;
557
+ if (!declared) {
558
+ return;
559
+ }
560
+ const conflict = (message, details) => {
561
+ bag.warnings.push({
562
+ code: VALIDATION_CODES.presentationSemanticConflict,
563
+ message: `${node.name ?? node.id} ${message}`,
564
+ nodeId: node.id,
565
+ details: { kind: node.kind, ...details },
566
+ });
567
+ };
568
+ if (declared.uxRole && CONTROL_ONLY_UX_ROLES.has(declared.uxRole) && node.kind !== 'button') {
569
+ conflict(`is a ${node.kind}, which cannot be a "${declared.uxRole}"`, { uxRole: declared.uxRole });
570
+ }
571
+ if (declared.uxRole && REGION_UX_ROLES.has(declared.uxRole) && !CONTAINING_KINDS.has(node.kind)) {
572
+ conflict(`is presented as a "${declared.uxRole}" but holds no children`, {
573
+ uxRole: declared.uxRole,
574
+ });
575
+ }
576
+ if (declared.uxRole === 'navigation-action' && node.kind === 'button') {
577
+ const action = actions.get(node.actionId);
578
+ if (action && !navigates(action, actions)) {
579
+ conflict(`is presented as navigation but ${action.name ?? action.id} does not navigate`, {
580
+ uxRole: 'navigation-action',
581
+ actionId: node.actionId,
582
+ });
583
+ }
584
+ }
585
+ if (declared.treatment && declared.treatment !== 'plain' && !VALUE_KINDS.has(node.kind)) {
586
+ conflict(`is a ${node.kind}, which renders no value to present as a "${declared.treatment}"`, {
587
+ treatment: declared.treatment,
588
+ });
589
+ }
590
+ if (declared.format && !VALUE_KINDS.has(node.kind)) {
591
+ conflict(`is a ${node.kind}, which renders no value to format`, { format: declared.format.kind });
592
+ }
593
+ if (declared.format && node.kind === 'field-display') {
594
+ const type = index.fieldTypes.get(node.fieldId);
595
+ if (!formatFits(declared.format, type)) {
596
+ conflict(`formats ${node.fieldId} as ${declared.format.kind}, which its declared type is not`, {
597
+ format: declared.format.kind,
598
+ fieldId: node.fieldId,
599
+ });
600
+ }
601
+ }
602
+ if (declared.control && node.kind !== 'input') {
603
+ conflict(`is a ${node.kind}, which is not edited by a control`, { control: declared.control });
604
+ }
605
+ if (typeof declared.headingLevel === 'number' && node.kind !== 'text') {
606
+ conflict(`is a ${node.kind}, which cannot be a heading`, { headingLevel: declared.headingLevel });
607
+ }
608
+ }
434
609
  function checkDestructive(bag, node, view, actions) {
435
610
  if (node.kind !== 'button') {
436
611
  return;
@@ -516,21 +691,52 @@ function checkPrimaryActions(bag, index, resolved) {
516
691
  }
517
692
  }
518
693
  }
519
- /** A view that has section headings but no title of its own has no top of the outline. */
694
+ /**
695
+ * The document outline of each view, checked on **resolved heading levels** rather than on
696
+ * rendered markup.
697
+ *
698
+ * Only the primary render path is walked: an empty template and a false branch are
699
+ * alternatives to the outline, not part of it, so including them would report headings
700
+ * that are never on screen together.
701
+ */
520
702
  function checkHeadingStructure(bag, index, resolved) {
521
703
  for (const node of index.nodes.values()) {
522
704
  if (!isUINode(node) || node.kind !== 'view') {
523
705
  continue;
524
706
  }
525
- const roles = descendants(index, node.id)
526
- .filter((child) => child.kind === 'text')
527
- .map((child) => resolved[child.id]?.textRole);
528
- if (roles.includes('heading') && !roles.includes('title') && !roles.includes('display')) {
707
+ const levels = [];
708
+ for (const child of primaryDescendants(index, node.id)) {
709
+ const level = resolved[child.id]?.headingLevel;
710
+ if (typeof level === 'number') {
711
+ levels.push(level);
712
+ }
713
+ }
714
+ if (levels.length === 0) {
715
+ // A view with no headings at all has no outline to be wrong about.
716
+ continue;
717
+ }
718
+ const report = (message, details) => {
529
719
  bag.warnings.push({
530
720
  code: VALIDATION_CODES.invalidHeadingStructure,
531
- message: `View ${node.name ?? node.id} has section headings but no title above them`,
721
+ message: `View ${node.name ?? node.id} ${message}`,
532
722
  nodeId: node.id,
723
+ details: { levels: [...levels], ...details },
533
724
  });
725
+ };
726
+ const primary = levels.filter((level) => level === 1).length;
727
+ if (primary === 0) {
728
+ report('has headings but no level-1 heading', { primaryHeadings: 0 });
729
+ }
730
+ else if (primary > 1) {
731
+ report(`has ${primary} level-1 headings`, { primaryHeadings: primary });
732
+ }
733
+ let previous = 0;
734
+ for (const level of levels) {
735
+ if (previous !== 0 && level > previous + 1) {
736
+ report(`skips from heading level ${previous} to ${level}`, { from: previous, to: level });
737
+ break;
738
+ }
739
+ previous = level;
534
740
  }
535
741
  }
536
742
  }
package/dist/validate.js CHANGED
@@ -493,12 +493,39 @@ function validateUiNode(node, context) {
493
493
  validateExpression(node.source, node.id, context, new Set());
494
494
  requireField(node.fieldId, node.id, context);
495
495
  return;
496
- case 'form':
496
+ case 'form': {
497
497
  validateExpression(node.target, node.id, context, new Set());
498
498
  if (node.submitActionId) {
499
499
  requireKind(node.submitActionId, 'action', node.id, context, VALIDATION_CODES.invalidActionRef);
500
500
  }
501
+ if (node.submitButtonId) {
502
+ const button = context.nodes.get(node.submitButtonId);
503
+ if (button?.kind !== 'button') {
504
+ context.errors.push({
505
+ code: VALIDATION_CODES.invalidUiChild,
506
+ message: `Form ${node.id} names ${node.submitButtonId} as its submit control, which is not a button`,
507
+ nodeId: node.id,
508
+ });
509
+ }
510
+ else {
511
+ if (!uiDescendants(node.id, context).has(node.submitButtonId)) {
512
+ context.errors.push({
513
+ code: VALIDATION_CODES.invalidUiChild,
514
+ message: `Form ${node.id} names ${node.submitButtonId} as its submit control, but that button is not inside the form`,
515
+ nodeId: node.id,
516
+ });
517
+ }
518
+ if (node.submitActionId && button.actionId !== node.submitActionId) {
519
+ context.errors.push({
520
+ code: VALIDATION_CODES.invalidActionRef,
521
+ message: `Form ${node.id} submits ${node.submitActionId} but its submit control invokes ${button.actionId}`,
522
+ nodeId: node.id,
523
+ });
524
+ }
525
+ }
526
+ }
501
527
  return;
528
+ }
502
529
  case 'input':
503
530
  if (!node.binding?.location) {
504
531
  context.errors.push({
@@ -538,9 +565,31 @@ function validateUiNode(node, context) {
538
565
  case 'conditional':
539
566
  validateExpression(node.condition, node.id, context, new Set());
540
567
  return;
568
+ case 'diagnostic':
569
+ requireKind(node.actionId, 'action', node.id, context, VALIDATION_CODES.invalidActionRef);
570
+ return;
541
571
  default:
542
572
  }
543
573
  }
574
+ /** Every UI node beneath this one, for checks that must know what a subtree contains. */
575
+ function uiDescendants(id, context) {
576
+ const found = new Set();
577
+ const visit = (current) => {
578
+ const node = context.nodes.get(current);
579
+ if (!node || !isUINode(node)) {
580
+ return;
581
+ }
582
+ for (const childId of uiChildIds(node)) {
583
+ if (found.has(childId)) {
584
+ continue;
585
+ }
586
+ found.add(childId);
587
+ visit(childId);
588
+ }
589
+ };
590
+ visit(id);
591
+ return found;
592
+ }
544
593
  function validateEdges(context) {
545
594
  for (const edge of context.graph.listEdges()) {
546
595
  if (!EDGE_KINDS.includes(edge.kind)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cynodia/axiom-core",
3
- "version": "0.5.0-alpha.1",
3
+ "version": "0.5.2-alpha.1",
4
4
  "description": "Application Graph, semantic types, locations and validation for Axiom.",
5
5
  "license": "MIT",
6
6
  "author": "AskTech AS",