@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 +17 -6
- package/dist/graph.d.ts +9 -3
- package/dist/graph.js +10 -4
- package/dist/infer.js +9 -0
- package/dist/ir.d.ts +9 -0
- package/dist/nodes.d.ts +108 -8
- package/dist/presentation.d.ts +28 -1
- package/dist/presentation.js +15 -0
- package/dist/resolve-presentation.d.ts +2 -1
- package/dist/resolve-presentation.js +49 -6
- package/dist/theme.d.ts +20 -1
- package/dist/theme.js +8 -0
- package/dist/ui.d.ts +95 -4
- package/dist/ui.js +20 -0
- package/dist/validate-location.js +10 -1
- package/dist/validate-presentation.js +215 -9
- package/dist/validate.js +50 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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.
|
|
120
|
-
*
|
|
121
|
-
*
|
|
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
|
-
/**
|
|
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';
|
package/dist/presentation.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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;
|
package/dist/presentation.js
CHANGED
|
@@ -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
|
-
/**
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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}
|
|
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)) {
|