@cynodia/axiom-core 0.4.0-alpha.1 → 0.5.0-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
@@ -9,6 +9,11 @@ The Application Graph and its semantic model: nodes, fields, structured types,
9
9
  expressions, **locations** (addressable writable positions), edge derivation,
10
10
  validation and type inference.
11
11
 
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.
16
+
12
17
  ## Installation
13
18
 
14
19
  ```bash
@@ -1,4 +1,4 @@
1
- import { expressionFieldIds, walkExpression } from './expressions.js';
1
+ import { constructedFieldIds, expressionFieldIds, walkExpression } from './expressions.js';
2
2
  import { locationExpressions, locationFieldIds, locationRootStateId, locationSelectorFieldIds, } from './location.js';
3
3
  import { isUINode } from './ui.js';
4
4
  /** Ids a `ref` expression mentions anywhere in the tree. */
@@ -42,6 +42,19 @@ export function deriveEdges(nodes) {
42
42
  rootScope.set(node.id, statesOf(node.source, new Map(), states));
43
43
  }
44
44
  }
45
+ // Which states can hold instances of an entity, including nested ones. An entity-scoped
46
+ // constraint reads its fields wherever those instances actually live.
47
+ const entities = new Map(nodes.filter((node) => node.kind === 'entity').map((node) => [node.id, node]));
48
+ const statesByEntity = new Map();
49
+ for (const node of nodes) {
50
+ if (node.kind !== 'state' || node.draft || node.derivation) {
51
+ continue;
52
+ }
53
+ for (const entityId of reachableEntities(node.valueType, entities)) {
54
+ statesByEntity.set(entityId, [...(statesByEntity.get(entityId) ?? []), node.id]);
55
+ }
56
+ }
57
+ const entityScope = (entityId) => new Map([...rootScope, [entityId, statesByEntity.get(entityId) ?? []]]);
45
58
  const link = (from, to, kind, fieldIds = []) => {
46
59
  if (from === to || !known.has(from) || !known.has(to)) {
47
60
  return;
@@ -92,8 +105,14 @@ export function deriveEdges(nodes) {
92
105
  if (node.entityId) {
93
106
  link(node.id, node.entityId, 'constrains', expressionFieldIds(node.expression));
94
107
  }
95
- reads(node.id, node.expression, rootScope);
108
+ reads(node.id, node.expression, node.entityId ? entityScope(node.entityId) : rootScope);
109
+ break;
110
+ case 'transition-constraint': {
111
+ link(node.id, node.entityId, 'constrains', expressionFieldIds(node.expression));
112
+ const holders = statesByEntity.get(node.entityId) ?? [];
113
+ reads(node.id, node.expression, new Map([...rootScope, [node.previousScopeId, holders], [node.proposedScopeId, holders]]));
96
114
  break;
115
+ }
97
116
  case 'route':
98
117
  link(node.id, node.viewId, 'routes-to');
99
118
  break;
@@ -111,7 +130,11 @@ export function deriveEdges(nodes) {
111
130
  },
112
131
  }));
113
132
  }
114
- /** The states an expression ultimately draws its members from. */
133
+ /**
134
+ * The states an expression ultimately draws its members from. Following `field` and calls
135
+ * matters: a collection reached as `coalesce(field(ref(state), lines), [])` still comes
136
+ * from that state, and its members' fields are still reads of it.
137
+ */
115
138
  function statesOf(expression, scope, states) {
116
139
  switch (expression.kind) {
117
140
  case 'ref': {
@@ -125,11 +148,38 @@ function statesOf(expression, scope, states) {
125
148
  case 'find':
126
149
  case 'sort':
127
150
  case 'map':
151
+ case 'flatten':
152
+ return statesOf(expression.source, scope, states);
153
+ case 'conditional':
154
+ return [
155
+ ...new Set([
156
+ ...statesOf(expression.whenTrue, scope, states),
157
+ ...statesOf(expression.whenFalse, scope, states),
158
+ ]),
159
+ ];
160
+ case 'field':
128
161
  return statesOf(expression.source, scope, states);
162
+ case 'call':
163
+ return [...new Set(expression.arguments.flatMap((argument) => statesOf(argument, scope, states)))];
129
164
  default:
130
165
  return [];
131
166
  }
132
167
  }
168
+ /** Entities reachable from a type, following entity fields as well as collections. */
169
+ function reachableEntities(type, entities, seen = new Set()) {
170
+ const found = [];
171
+ for (const entityId of entityIdsIn(type)) {
172
+ if (seen.has(entityId)) {
173
+ continue;
174
+ }
175
+ seen.add(entityId);
176
+ found.push(entityId);
177
+ for (const field of entities.get(entityId)?.fields ?? []) {
178
+ found.push(...reachableEntities(field.valueType, entities, seen));
179
+ }
180
+ }
181
+ return found;
182
+ }
133
183
  function bind(scope, id, targets) {
134
184
  const next = new Map(scope);
135
185
  next.set(id, targets);
@@ -180,7 +230,9 @@ function collectReads(expression, scope, states, found = new Map()) {
180
230
  case 'filter':
181
231
  case 'find':
182
232
  case 'map':
183
- case 'sort': {
233
+ case 'sort':
234
+ case 'every':
235
+ case 'some': {
184
236
  collectReads(expression.source, scope, states, found);
185
237
  const inner = bind(scope, expression.scopeId, statesOf(expression.source, scope, states));
186
238
  const body = expression.kind === 'map'
@@ -191,6 +243,14 @@ function collectReads(expression, scope, states, found = new Map()) {
191
243
  collectReads(body, inner, states, found);
192
244
  return found;
193
245
  }
246
+ case 'flatten':
247
+ collectReads(expression.source, scope, states, found);
248
+ return found;
249
+ case 'conditional':
250
+ collectReads(expression.condition, scope, states, found);
251
+ collectReads(expression.whenTrue, scope, states, found);
252
+ collectReads(expression.whenFalse, scope, states, found);
253
+ return found;
194
254
  default:
195
255
  return found;
196
256
  }
@@ -217,8 +277,9 @@ function linkOperations(actionId, operations, linker, scope) {
217
277
  linker.reads(actionId, operation.value, scope);
218
278
  break;
219
279
  case 'insert':
220
- // Inserting a constructed record writes every field the record declares.
221
- linker.writes(actionId, operation.target, scope, 'writes', expressionFieldIds(operation.value));
280
+ // A constructed record writes the fields it declares. Fields consulted while
281
+ // computing those values are reads, and must not be reported as writes.
282
+ linker.writes(actionId, operation.target, scope, 'writes', constructedFieldIds(operation.value));
222
283
  linker.reads(actionId, operation.value, scope);
223
284
  break;
224
285
  case 'remove':
@@ -48,5 +48,22 @@ export declare const VALIDATION_CODES: {
48
48
  readonly initialValueUnknownField: "INITIAL_VALUE_UNKNOWN_FIELD";
49
49
  readonly initialValueMissingRequiredField: "INITIAL_VALUE_MISSING_REQUIRED_FIELD";
50
50
  readonly initialValueInvalidEntity: "INITIAL_VALUE_INVALID_ENTITY";
51
+ readonly scopeShadowing: "SCOPE_SHADOWING";
52
+ readonly scopeCollidesWithNode: "SCOPE_COLLIDES_WITH_NODE";
53
+ readonly ephemeralStatePersisted: "EPHEMERAL_STATE_PERSISTED";
54
+ readonly unknownPresentationToken: "UNKNOWN_PRESENTATION_TOKEN";
55
+ readonly presentationSemanticConflict: "PRESENTATION_SEMANTIC_CONFLICT";
56
+ readonly multiplePrimaryActions: "MULTIPLE_PRIMARY_ACTIONS";
57
+ readonly formWithoutPrimaryAction: "FORM_WITHOUT_PRIMARY_ACTION";
58
+ readonly destructiveActionPresentedAsSuccess: "DESTRUCTIVE_ACTION_PRESENTED_AS_SUCCESS";
59
+ readonly destructiveActionUnmarked: "DESTRUCTIVE_ACTION_UNMARKED";
60
+ readonly excessiveHorizontalActions: "EXCESSIVE_HORIZONTAL_ACTIONS";
61
+ readonly emptyStateWithoutRecoveryAction: "EMPTY_STATE_WITHOUT_RECOVERY_ACTION";
62
+ readonly rigidHorizontalLayout: "RIGID_HORIZONTAL_LAYOUT";
63
+ readonly conflictingSizing: "CONFLICTING_SIZING";
64
+ readonly interactiveElementMissingLabel: "INTERACTIVE_ELEMENT_MISSING_LABEL";
65
+ readonly formInputMissingLabel: "FORM_INPUT_MISSING_LABEL";
66
+ readonly invalidHeadingStructure: "INVALID_HEADING_STRUCTURE";
67
+ readonly opaquePresentation: "OPAQUE_PRESENTATION";
51
68
  };
52
69
  //# sourceMappingURL=diagnostics.d.ts.map
@@ -31,4 +31,23 @@ export const VALIDATION_CODES = {
31
31
  initialValueUnknownField: 'INITIAL_VALUE_UNKNOWN_FIELD',
32
32
  initialValueMissingRequiredField: 'INITIAL_VALUE_MISSING_REQUIRED_FIELD',
33
33
  initialValueInvalidEntity: 'INITIAL_VALUE_INVALID_ENTITY',
34
+ scopeShadowing: 'SCOPE_SHADOWING',
35
+ scopeCollidesWithNode: 'SCOPE_COLLIDES_WITH_NODE',
36
+ ephemeralStatePersisted: 'EPHEMERAL_STATE_PERSISTED',
37
+ // Presentation and UX. Everything here is a warning except an unknown token, which the
38
+ // renderer genuinely cannot act on.
39
+ unknownPresentationToken: 'UNKNOWN_PRESENTATION_TOKEN',
40
+ presentationSemanticConflict: 'PRESENTATION_SEMANTIC_CONFLICT',
41
+ multiplePrimaryActions: 'MULTIPLE_PRIMARY_ACTIONS',
42
+ formWithoutPrimaryAction: 'FORM_WITHOUT_PRIMARY_ACTION',
43
+ destructiveActionPresentedAsSuccess: 'DESTRUCTIVE_ACTION_PRESENTED_AS_SUCCESS',
44
+ destructiveActionUnmarked: 'DESTRUCTIVE_ACTION_UNMARKED',
45
+ excessiveHorizontalActions: 'EXCESSIVE_HORIZONTAL_ACTIONS',
46
+ emptyStateWithoutRecoveryAction: 'EMPTY_STATE_WITHOUT_RECOVERY_ACTION',
47
+ rigidHorizontalLayout: 'RIGID_HORIZONTAL_LAYOUT',
48
+ conflictingSizing: 'CONFLICTING_SIZING',
49
+ interactiveElementMissingLabel: 'INTERACTIVE_ELEMENT_MISSING_LABEL',
50
+ formInputMissingLabel: 'FORM_INPUT_MISSING_LABEL',
51
+ invalidHeadingStructure: 'INVALID_HEADING_STRUCTURE',
52
+ opaquePresentation: 'OPAQUE_PRESENTATION',
34
53
  };
@@ -5,7 +5,7 @@ import type { LiteralValue } from './nodes.js';
5
5
  * identifier: `ref` resolves an id against the evaluation scope chain (route parameters,
6
6
  * action parameters, iteration scopes, then state), and `field` reads a field by id.
7
7
  */
8
- export type Expression = LiteralExpression | RefExpression | FieldExpression | ObjectExpression | BinaryExpression | UnaryExpression | CallExpression | FilterExpression | FindExpression | MapExpression | SortExpression;
8
+ export type Expression = LiteralExpression | RefExpression | FieldExpression | ObjectExpression | BinaryExpression | UnaryExpression | CallExpression | FilterExpression | FindExpression | MapExpression | SortExpression | EveryExpression | SomeExpression | FlattenExpression | ConditionalExpression;
9
9
  export type ExpressionKind = Expression['kind'];
10
10
  /** Every expression kind the runtime is required to evaluate. */
11
11
  export declare const EXPRESSION_KINDS: readonly ExpressionKind[];
@@ -57,7 +57,7 @@ export interface UnaryExpression {
57
57
  * must be implemented by the runtime: a function that is declared here but unevaluated
58
58
  * would be a construct that typechecks, validates and then does nothing.
59
59
  */
60
- export type BuiltinFunction = 'required' | 'is-empty' | 'length' | 'contains' | 'concat' | 'coalesce' | 'one-of' | 'count' | 'sum' | 'lowercase' | 'to-string' | 'now' | 'uuid';
60
+ export type BuiltinFunction = 'required' | 'is-empty' | 'non-empty' | 'length' | 'contains' | 'concat' | 'coalesce' | 'one-of' | 'count' | 'sum' | 'lowercase' | 'to-string' | 'now' | 'uuid';
61
61
  export declare const BUILTIN_FUNCTIONS: readonly BuiltinFunction[];
62
62
  /** Functions that reduce a collection of numbers to a number. */
63
63
  export declare const AGGREGATE_FUNCTIONS: readonly BuiltinFunction[];
@@ -99,6 +99,32 @@ export interface SortExpression {
99
99
  by: Expression;
100
100
  direction?: 'asc' | 'desc';
101
101
  }
102
+ /** True when every member satisfies the predicate. An empty collection satisfies it. */
103
+ export interface EveryExpression {
104
+ kind: 'every';
105
+ source: Expression;
106
+ scopeId: NodeId;
107
+ predicate: Expression;
108
+ }
109
+ /** True when at least one member satisfies the predicate. An empty collection does not. */
110
+ export interface SomeExpression {
111
+ kind: 'some';
112
+ source: Expression;
113
+ scopeId: NodeId;
114
+ predicate: Expression;
115
+ }
116
+ /** Collapses one level of nesting: Collection<Collection<T>> becomes Collection<T>. */
117
+ export interface FlattenExpression {
118
+ kind: 'flatten';
119
+ source: Expression;
120
+ }
121
+ /** Chooses between two values. Both branches are expressions, never callbacks. */
122
+ export interface ConditionalExpression {
123
+ kind: 'conditional';
124
+ condition: Expression;
125
+ whenTrue: Expression;
126
+ whenFalse: Expression;
127
+ }
102
128
  export declare function literal(value: LiteralValue): LiteralExpression;
103
129
  export declare function ref(targetId: NodeId): RefExpression;
104
130
  export declare function field(source: Expression, id: FieldId): FieldExpression;
@@ -113,8 +139,24 @@ export declare function sort(source: Expression, scopeId: NodeId, by: Expression
113
139
  /** Sums a collection of numbers. An empty collection sums to zero. */
114
140
  export declare function sum(source: Expression): CallExpression;
115
141
  export declare function count(source: Expression): CallExpression;
142
+ /** True when a value exists at all — an empty collection or string still exists. */
143
+ export declare function required(value: Expression): CallExpression;
144
+ /** True when a collection or string has no members. */
145
+ export declare function isEmpty(value: Expression): CallExpression;
146
+ export declare function nonEmpty(value: Expression): CallExpression;
147
+ /** The first value that exists. Only null and undefined are skipped. */
148
+ export declare function coalesce(...values: Expression[]): CallExpression;
149
+ export declare function every(source: Expression, scopeId: NodeId, predicate: Expression): EveryExpression;
150
+ export declare function some(source: Expression, scopeId: NodeId, predicate: Expression): SomeExpression;
151
+ export declare function flatten(source: Expression): FlattenExpression;
152
+ export declare function conditional(condition: Expression, whenTrue: Expression, whenFalse: Expression): ConditionalExpression;
116
153
  /** Visits every sub-expression, parents before children. */
117
154
  export declare function walkExpression(expression: Expression, visit: (node: Expression) => void): void;
155
+ /**
156
+ * Field ids a constructed record assigns. Only the record's own entries count: the
157
+ * expressions that compute those values are reads, not writes.
158
+ */
159
+ export declare function constructedFieldIds(expression: Expression): FieldId[];
118
160
  /** Field ids an expression reads, including nested sources and constructed records. */
119
161
  export declare function expressionFieldIds(expression: Expression): FieldId[];
120
162
  //# sourceMappingURL=expressions.d.ts.map
@@ -11,10 +11,15 @@ export const EXPRESSION_KINDS = [
11
11
  'find',
12
12
  'map',
13
13
  'sort',
14
+ 'every',
15
+ 'some',
16
+ 'flatten',
17
+ 'conditional',
14
18
  ];
15
19
  export const BUILTIN_FUNCTIONS = [
16
20
  'required',
17
21
  'is-empty',
22
+ 'non-empty',
18
23
  'length',
19
24
  'contains',
20
25
  'concat',
@@ -69,6 +74,33 @@ export function sum(source) {
69
74
  export function count(source) {
70
75
  return call('count', source);
71
76
  }
77
+ /** True when a value exists at all — an empty collection or string still exists. */
78
+ export function required(value) {
79
+ return call('required', value);
80
+ }
81
+ /** True when a collection or string has no members. */
82
+ export function isEmpty(value) {
83
+ return call('is-empty', value);
84
+ }
85
+ export function nonEmpty(value) {
86
+ return call('non-empty', value);
87
+ }
88
+ /** The first value that exists. Only null and undefined are skipped. */
89
+ export function coalesce(...values) {
90
+ return call('coalesce', ...values);
91
+ }
92
+ export function every(source, scopeId, predicate) {
93
+ return { kind: 'every', source, scopeId, predicate };
94
+ }
95
+ export function some(source, scopeId, predicate) {
96
+ return { kind: 'some', source, scopeId, predicate };
97
+ }
98
+ export function flatten(source) {
99
+ return { kind: 'flatten', source };
100
+ }
101
+ export function conditional(condition, whenTrue, whenFalse) {
102
+ return { kind: 'conditional', condition, whenTrue, whenFalse };
103
+ }
72
104
  /** Visits every sub-expression, parents before children. */
73
105
  export function walkExpression(expression, visit) {
74
106
  visit(expression);
@@ -106,9 +138,29 @@ export function walkExpression(expression, visit) {
106
138
  walkExpression(expression.source, visit);
107
139
  walkExpression(expression.by, visit);
108
140
  return;
141
+ case 'every':
142
+ case 'some':
143
+ walkExpression(expression.source, visit);
144
+ walkExpression(expression.predicate, visit);
145
+ return;
146
+ case 'flatten':
147
+ walkExpression(expression.source, visit);
148
+ return;
149
+ case 'conditional':
150
+ walkExpression(expression.condition, visit);
151
+ walkExpression(expression.whenTrue, visit);
152
+ walkExpression(expression.whenFalse, visit);
153
+ return;
109
154
  default:
110
155
  }
111
156
  }
157
+ /**
158
+ * Field ids a constructed record assigns. Only the record's own entries count: the
159
+ * expressions that compute those values are reads, not writes.
160
+ */
161
+ export function constructedFieldIds(expression) {
162
+ return expression.kind === 'object' ? expression.entries.map((entry) => entry.fieldId) : [];
163
+ }
112
164
  /** Field ids an expression reads, including nested sources and constructed records. */
113
165
  export function expressionFieldIds(expression) {
114
166
  const found = [];
package/dist/graph.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { EdgeId, FieldId, NodeId } from './ids.js';
2
2
  import type { EdgeKind, FieldDef, GraphEdge } from './nodes.js';
3
3
  import type { AnyNode, ApplicationGraphData, NodeInput, NodeKind, NodeOfKind } from './types.js';
4
+ import type { Theme, ThemeInput } from './theme.js';
4
5
  export interface FieldIndexEntry {
5
6
  entityId: NodeId;
6
7
  field: FieldDef;
@@ -25,6 +26,14 @@ export declare class ApplicationGraph {
25
26
  get id(): string;
26
27
  get name(): string;
27
28
  get version(): string;
29
+ /**
30
+ * The application's visual identity, completed against the default theme. A theme is
31
+ * presentation only: changing it cannot change an action, a constraint or a route.
32
+ */
33
+ get theme(): Theme;
34
+ /** Exactly what the application declared, before defaults were filled in. */
35
+ get declaredTheme(): ThemeInput | undefined;
36
+ setTheme(theme: ThemeInput | undefined): void;
28
37
  addNode<T extends AnyNode>(node: NodeInput<T>): NodeId;
29
38
  getNode<T extends AnyNode = AnyNode>(id: NodeId): T | undefined;
30
39
  hasNode(id: NodeId): boolean;
package/dist/graph.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { deriveEdges } from './derive-edges.js';
2
2
  import { createEdgeId, createNodeId } from './ids.js';
3
+ import { resolveTheme } from './theme.js';
3
4
  function clone(value) {
4
5
  return structuredClone(value);
5
6
  }
@@ -16,7 +17,7 @@ export class ApplicationGraph {
16
17
  /** Bumped by every change, so the derived edge index can never serve stale data. */
17
18
  revision = 0;
18
19
  semanticIndex;
19
- constructor(id, name, version = '0.4.0') {
20
+ constructor(id, name, version = '0.5.0') {
20
21
  this.data = { id, name, version, nodes: {}, edges: {} };
21
22
  }
22
23
  get id() {
@@ -28,6 +29,26 @@ export class ApplicationGraph {
28
29
  get version() {
29
30
  return this.data.version;
30
31
  }
32
+ /**
33
+ * The application's visual identity, completed against the default theme. A theme is
34
+ * presentation only: changing it cannot change an action, a constraint or a route.
35
+ */
36
+ get theme() {
37
+ return resolveTheme(this.data.theme);
38
+ }
39
+ /** Exactly what the application declared, before defaults were filled in. */
40
+ get declaredTheme() {
41
+ return this.data.theme ? structuredClone(this.data.theme) : undefined;
42
+ }
43
+ setTheme(theme) {
44
+ if (theme === undefined) {
45
+ delete this.data.theme;
46
+ }
47
+ else {
48
+ this.data.theme = structuredClone(theme);
49
+ }
50
+ this.revision += 1;
51
+ }
31
52
  addNode(node) {
32
53
  const id = (node.id ?? createNodeId(node.kind));
33
54
  if (this.data.nodes[id]) {
package/dist/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export * from './ids.js';
2
2
  export * from './diagnostics.js';
3
3
  export * from './location.js';
4
+ export * from './presentation.js';
5
+ export * from './theme.js';
4
6
  export * from './type-ref.js';
5
7
  export * from './expressions.js';
6
8
  export * from './nodes.js';
@@ -10,7 +12,9 @@ export * from './graph.js';
10
12
  export * from './infer.js';
11
13
  export * from './context.js';
12
14
  export * from './validate-location.js';
15
+ export * from './resolve-presentation.js';
13
16
  export * from './validate.js';
17
+ export * from './validate-presentation.js';
14
18
  export * from './derive-edges.js';
15
19
  export * from './ir.js';
16
20
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  export * from './ids.js';
2
2
  export * from './diagnostics.js';
3
3
  export * from './location.js';
4
+ export * from './presentation.js';
5
+ export * from './theme.js';
4
6
  export * from './type-ref.js';
5
7
  export * from './expressions.js';
6
8
  export * from './nodes.js';
@@ -10,6 +12,8 @@ export * from './graph.js';
10
12
  export * from './infer.js';
11
13
  export * from './context.js';
12
14
  export * from './validate-location.js';
15
+ export * from './resolve-presentation.js';
13
16
  export * from './validate.js';
17
+ export * from './validate-presentation.js';
14
18
  export * from './derive-edges.js';
15
19
  export * from './ir.js';
package/dist/infer.js CHANGED
@@ -141,6 +141,14 @@ export function inferExpressionType(expression, context, scope) {
141
141
  const item = itemTypeOf(inferExpressionType(expression.source, context, scope));
142
142
  return item ? optionalType(item) : undefined;
143
143
  }
144
+ case 'every':
145
+ case 'some':
146
+ return primitiveType('boolean');
147
+ case 'flatten':
148
+ return itemTypeOf(inferExpressionType(expression.source, context, scope));
149
+ case 'conditional':
150
+ return (inferExpressionType(expression.whenTrue, context, scope) ??
151
+ inferExpressionType(expression.whenFalse, context, scope));
144
152
  case 'map': {
145
153
  const item = itemTypeOf(inferExpressionType(expression.source, context, scope));
146
154
  const projected = inferExpressionType(expression.projection, context, withScope(scope, expression.scopeId, item));
@@ -157,6 +165,8 @@ export function scopeForExpression(expression, context, scope) {
157
165
  case 'find':
158
166
  case 'map':
159
167
  case 'sort':
168
+ case 'every':
169
+ case 'some':
160
170
  return withScope(scope, expression.scopeId, itemTypeOf(inferExpressionType(expression.source, context, scope)));
161
171
  default:
162
172
  return scope ?? new Map();
package/dist/ir.d.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  import type { FieldId, NodeId } from './ids.js';
2
- import type { ActionDef, ConstraintDef, EntityDef, GraphEdge, RouteParameter, StateDef } from './nodes.js';
2
+ import type { ActionDef, ConstraintDef, EntityDef, GraphEdge, RouteParameter, StateDef, TransitionConstraintDef } from './nodes.js';
3
3
  import type { FieldIndexEntry } from './graph.js';
4
4
  import type { UINode } from './ui.js';
5
5
  import type { AnyNode } from './types.js';
6
6
  import type { TypeRef } from './type-ref.js';
7
+ import type { ResolvedPresentation } from './presentation.js';
8
+ import type { Theme } from './theme.js';
7
9
  export interface RouteSegment {
8
10
  kind: 'static' | 'parameter';
9
11
  value: string;
@@ -34,6 +36,8 @@ export interface ApplicationIR {
34
36
  actions: Record<NodeId, ActionDef>;
35
37
  uiNodes: Record<NodeId, UINode>;
36
38
  constraints: ConstraintDef[];
39
+ /** Rules about how state may change, enforced on every governed mutation path. */
40
+ transitionConstraints: TransitionConstraintDef[];
37
41
  routes: CompiledRoute[];
38
42
  edges: GraphEdge[];
39
43
  /**
@@ -46,5 +50,21 @@ export interface ApplicationIR {
46
50
  * to canonical application state from a write to a draft.
47
51
  */
48
52
  locationRoots: Record<NodeId, NodeId>;
53
+ /**
54
+ * Whether the field each input addresses is declared required, resolved here so a
55
+ * renderer can mark it without re-deriving the model.
56
+ */
57
+ locationRequired: Record<NodeId, boolean>;
58
+ /** The application's visual identity, completed against the default theme. */
59
+ theme: Theme;
60
+ /**
61
+ * Presentation with every question already answered, per UI node: renderer defaults,
62
+ * theme, inheritance, semantic inference, node declaration and responsive overrides all
63
+ * resolved. A renderer reads this and needs to know nothing about how it was decided.
64
+ *
65
+ * It is deliberately still semantic — roles, tokens and device classes, not CSS — so a
66
+ * second renderer remains possible.
67
+ */
68
+ presentation: Record<NodeId, ResolvedPresentation>;
49
69
  }
50
70
  //# sourceMappingURL=ir.d.ts.map
package/dist/nodes.d.ts CHANGED
@@ -2,12 +2,7 @@ import type { Expression } from './expressions.js';
2
2
  import type { CollectionItemLocation, Location } from './location.js';
3
3
  import type { EdgeId, FieldId, NodeId } from './ids.js';
4
4
  import type { TypeRef } from './type-ref.js';
5
- /** Minimal, optional presentation hints. Styling is not a 0.2 research objective. */
6
- export interface PresentationHints {
7
- role?: 'primary' | 'secondary' | 'danger';
8
- density?: 'compact' | 'normal';
9
- emphasis?: 'normal' | 'strong';
10
- }
5
+ import type { ConfirmationPresentation } from './presentation.js';
11
6
  export interface NodeBase {
12
7
  id: NodeId;
13
8
  name?: string;
@@ -50,6 +45,16 @@ export interface StateDef extends NodeBase {
50
45
  * definition, so instance validation skips them until an action commits the value.
51
46
  */
52
47
  draft?: boolean;
48
+ /**
49
+ * Marks ephemeral presentation state — which panel is expanded, which tab is selected,
50
+ * whether a dialog is open. It is not canonical domain state: instance validation skips
51
+ * it, and it may not be persisted. Marking it says so in the graph instead of leaving an
52
+ * agent to guess which states are domain facts.
53
+ *
54
+ * It changes what a state *is*, never what is permitted: a write reaching domain state
55
+ * is governed exactly as before.
56
+ */
57
+ ephemeral?: boolean;
53
58
  persistence?: StatePersistence;
54
59
  }
55
60
  export interface ActionParameter {
@@ -62,9 +67,16 @@ export interface FailureMode {
62
67
  code: string;
63
68
  message?: string;
64
69
  }
70
+ /** A condition together with the failure it reports, so the two cannot drift apart. */
71
+ export interface ActionGuard {
72
+ condition: Expression;
73
+ failureMode?: FailureMode;
74
+ }
65
75
  export interface ActionDef extends NodeBase {
66
76
  kind: 'action';
67
77
  parameters?: ActionParameter[];
78
+ /** Preferred over the parallel `preconditions` and `failureModes` arrays. */
79
+ guards?: ActionGuard[];
68
80
  preconditions?: Expression[];
69
81
  operations: Operation[];
70
82
  postconditions?: Expression[];
@@ -73,7 +85,14 @@ export interface ActionDef extends NodeBase {
73
85
  destructive?: boolean;
74
86
  requiresConfirmation?: boolean;
75
87
  confirmationMessage?: string;
88
+ /** What the confirmation says, when a plain message is not enough. */
89
+ confirmation?: ConfirmationPresentation;
76
90
  }
91
+ /**
92
+ * The conditions an action checks, however they were written. `guards` pairs each
93
+ * condition with its failure; the older parallel arrays are matched by position.
94
+ */
95
+ export declare function actionGuards(action: ActionDef): ActionGuard[];
77
96
  export type Operation = SetOperation | InsertOperation | RemoveOperation | ForEachOperation | InvokeOperation | NavigateOperation | NativeOperation;
78
97
  export type OperationKind = Operation['kind'];
79
98
  /** Every operation kind the runtime is required to execute. */
@@ -159,6 +178,29 @@ export interface ConstraintDef extends NodeBase {
159
178
  severity?: 'error' | 'warning';
160
179
  message?: string;
161
180
  }
181
+ /**
182
+ * A rule about how state may change, rather than about what state may be.
183
+ *
184
+ * An ordinary constraint judges the proposed state on its own; a transition constraint
185
+ * sees the instance as it was when the transaction began *and* as the transaction
186
+ * proposes it. That is what lets a rule like "once confirmed, an order may not change"
187
+ * hold no matter which path attempts the write — an action, an input binding, an
188
+ * iteration, or something added later.
189
+ *
190
+ * `previousScopeId` and `proposedScopeId` bind those two instances for the expression.
191
+ * When the instance is being removed, the proposed scope is bound to nothing.
192
+ */
193
+ export interface TransitionConstraintDef extends NodeBase {
194
+ kind: 'transition-constraint';
195
+ /** The entity whose transitions are governed. It must have an identity field. */
196
+ entityId: NodeId;
197
+ previousScopeId: NodeId;
198
+ proposedScopeId: NodeId;
199
+ /** Must hold for every governed transition. */
200
+ expression: Expression;
201
+ severity?: 'error' | 'warning';
202
+ message?: string;
203
+ }
162
204
  export interface RouteParameter {
163
205
  id: NodeId;
164
206
  /** Matches the `:name` placeholder in the route path. */
package/dist/nodes.js CHANGED
@@ -1,3 +1,16 @@
1
+ /**
2
+ * The conditions an action checks, however they were written. `guards` pairs each
3
+ * condition with its failure; the older parallel arrays are matched by position.
4
+ */
5
+ export function actionGuards(action) {
6
+ if (action.guards && action.guards.length > 0) {
7
+ return action.guards;
8
+ }
9
+ return (action.preconditions ?? []).map((condition, index) => ({
10
+ condition,
11
+ ...(action.failureModes?.[index] ? { failureMode: action.failureModes[index] } : {}),
12
+ }));
13
+ }
1
14
  /** Every operation kind the runtime is required to execute. */
2
15
  export const OPERATION_KINDS = [
3
16
  'set',