@cynodia/axiom-ui 0.7.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/LICENSE +21 -0
- package/README.md +83 -0
- package/dist/catalog.d.ts +32 -0
- package/dist/catalog.js +29 -0
- package/dist/example/app.d.ts +6 -0
- package/dist/example/app.js +365 -0
- package/dist/example/domain.d.ts +71 -0
- package/dist/example/domain.js +469 -0
- package/dist/example/index.d.ts +3 -0
- package/dist/example/index.js +2 -0
- package/dist/expand.d.ts +119 -0
- package/dist/expand.js +227 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +11 -0
- package/dist/inference.d.ts +55 -0
- package/dist/inference.js +113 -0
- package/dist/pattern.d.ts +192 -0
- package/dist/pattern.js +39 -0
- package/dist/patterns/action-bar.d.ts +24 -0
- package/dist/patterns/action-bar.js +94 -0
- package/dist/patterns/entity-form.d.ts +93 -0
- package/dist/patterns/entity-form.js +340 -0
- package/dist/patterns/entity-list.d.ts +45 -0
- package/dist/patterns/entity-list.js +236 -0
- package/dist/patterns/metric-grid.d.ts +31 -0
- package/dist/patterns/metric-grid.js +107 -0
- package/dist/patterns/page.d.ts +28 -0
- package/dist/patterns/page.js +104 -0
- package/dist/queries.d.ts +60 -0
- package/dist/queries.js +53 -0
- package/dist/toolkit.d.ts +8 -0
- package/dist/toolkit.js +19 -0
- package/docs/OWNERSHIP.md +75 -0
- package/docs/PATTERN_AUTHORING.md +71 -0
- package/docs/PATTERN_CATALOG.json +504 -0
- package/docs/PROVENANCE.md +55 -0
- package/docs/TOOLKIT_AGENT_REFERENCE.md +205 -0
- package/package.json +47 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { field, ref } from '@cynodia/axiom-core';
|
|
2
|
+
import { definePattern } from '../pattern.js';
|
|
3
|
+
import { actionOf, defaultListFields, entityOf, fieldOf, formatFor, isCollection, labelFor, memberEntityId, roleForAction, stateOf, } from '../inference.js';
|
|
4
|
+
/** The expression that names the current row inside a `repeat`: the repeat node's own id. */
|
|
5
|
+
export function rowRef(instance) {
|
|
6
|
+
return ref(`ui_${instance}_rows`.replace(/[^a-zA-Z0-9_]/g, '_'));
|
|
7
|
+
}
|
|
8
|
+
export function rowField(instance, fieldId) {
|
|
9
|
+
return field(rowRef(instance), fieldId);
|
|
10
|
+
}
|
|
11
|
+
export const entityList = definePattern({
|
|
12
|
+
name: 'entity-list',
|
|
13
|
+
version: '0.7.0',
|
|
14
|
+
purpose: 'Lists the instances of a collection, one row per member, with optional per-row actions.',
|
|
15
|
+
inputs: {
|
|
16
|
+
source: { kind: 'state', required: true, purpose: 'The collection state to list.' },
|
|
17
|
+
fields: {
|
|
18
|
+
kind: 'field-list',
|
|
19
|
+
required: false,
|
|
20
|
+
purpose: 'Which fields to display, in order.',
|
|
21
|
+
inferredWhenAbsent: 'Every field of the member entity except its identity field.',
|
|
22
|
+
},
|
|
23
|
+
formats: {
|
|
24
|
+
kind: 'nodes',
|
|
25
|
+
required: false,
|
|
26
|
+
purpose: 'Value format per field, where the declared type cannot imply one.',
|
|
27
|
+
inferredWhenAbsent: 'number, boolean, date and datetime from the field’s type; currency and percentage are never guessed.',
|
|
28
|
+
},
|
|
29
|
+
rowActions: { kind: 'action-list', required: false, purpose: 'Actions offered on each row.' },
|
|
30
|
+
rowArguments: { kind: 'nodes', required: false, purpose: 'Arguments per row action, evaluated in the row scope.' },
|
|
31
|
+
emptyMessage: {
|
|
32
|
+
kind: 'text',
|
|
33
|
+
required: false,
|
|
34
|
+
purpose: 'What the empty state says.',
|
|
35
|
+
inferredWhenAbsent: 'An empty state is still generated, with a generic caption.',
|
|
36
|
+
},
|
|
37
|
+
emptyAction: {
|
|
38
|
+
kind: 'action',
|
|
39
|
+
required: false,
|
|
40
|
+
purpose: 'An action offered from the empty state, so it says what to do about it.',
|
|
41
|
+
inferredWhenAbsent: 'The empty state is a caption only, and validateGraph warns that it offers no recovery.',
|
|
42
|
+
},
|
|
43
|
+
emptyActionArguments: { kind: 'nodes', required: false, purpose: 'Arguments for the empty-state action.' },
|
|
44
|
+
rowExtra: { kind: 'slot', required: false, purpose: 'Extra semantic content inside each row.' },
|
|
45
|
+
},
|
|
46
|
+
slots: ['rowExtra'],
|
|
47
|
+
produces: ['container', 'repeat', 'field-display', 'button', 'text'],
|
|
48
|
+
expansion: [
|
|
49
|
+
{ part: 'root', kind: 'container', role: 'wraps the list' },
|
|
50
|
+
{ part: 'rows', kind: 'repeat', role: 'the repeat; ref this id to address the current row' },
|
|
51
|
+
{ part: 'row', kind: 'container', role: 'one row, horizontal when wide and stacked when compact' },
|
|
52
|
+
{ part: 'cell', kind: 'field-display', role: 'one per field, in order' },
|
|
53
|
+
{ part: 'row-actions', kind: 'container', role: 'action-group at the end of the row' },
|
|
54
|
+
{ part: 'row-action', kind: 'button', role: 'one per row action' },
|
|
55
|
+
{ part: 'empty-state', kind: 'container', role: 'shown when the collection is empty' },
|
|
56
|
+
{ part: 'empty-caption', kind: 'text', role: 'what the empty state says' },
|
|
57
|
+
{ part: 'empty-action', kind: 'button', role: 'the recovery action, when one is given' },
|
|
58
|
+
],
|
|
59
|
+
check(declaration, { graph, instance }) {
|
|
60
|
+
const findings = [];
|
|
61
|
+
const state = stateOf(graph, declaration.source);
|
|
62
|
+
if (!state) {
|
|
63
|
+
return [
|
|
64
|
+
{
|
|
65
|
+
code: 'SOURCE_NOT_A_STATE',
|
|
66
|
+
message: `${String(declaration.source)} is not a state in this graph.`,
|
|
67
|
+
severity: 'error',
|
|
68
|
+
path: `${instance}.source`,
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
}
|
|
72
|
+
if (!isCollection(state.valueType)) {
|
|
73
|
+
return [
|
|
74
|
+
{
|
|
75
|
+
code: 'SOURCE_NOT_A_COLLECTION',
|
|
76
|
+
message: `${state.name ?? String(declaration.source)} is not a collection, so it has no rows to list.`,
|
|
77
|
+
severity: 'error',
|
|
78
|
+
path: `${instance}.source`,
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
const entityId = memberEntityId(state.valueType);
|
|
83
|
+
const entity = entityId ? entityOf(graph, entityId) : undefined;
|
|
84
|
+
if (!entity) {
|
|
85
|
+
return [
|
|
86
|
+
{
|
|
87
|
+
code: 'SOURCE_NOT_ENTITIES',
|
|
88
|
+
message: `${state.name ?? String(declaration.source)} holds primitives, not entity instances, so it has no fields to show.`,
|
|
89
|
+
severity: 'error',
|
|
90
|
+
path: `${instance}.source`,
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
}
|
|
94
|
+
(declaration.fields ?? []).forEach((fieldId, index) => {
|
|
95
|
+
if (!fieldOf(entity, fieldId)) {
|
|
96
|
+
findings.push({
|
|
97
|
+
code: 'FIELD_NOT_ON_ENTITY',
|
|
98
|
+
message: `${String(fieldId)} is not a field of ${entity.name ?? String(entity.id)}.`,
|
|
99
|
+
severity: 'error',
|
|
100
|
+
path: `${instance}.fields[${index}]`,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
if (!entity.identityFieldId && (declaration.rowActions ?? []).length > 0) {
|
|
105
|
+
findings.push({
|
|
106
|
+
code: 'NO_IDENTITY_FIELD',
|
|
107
|
+
message: `${entity.name ?? String(entity.id)} declares no identityFieldId, so a row action cannot address the row it belongs to.`,
|
|
108
|
+
severity: 'warning',
|
|
109
|
+
path: `${instance}.rowActions`,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return findings;
|
|
113
|
+
},
|
|
114
|
+
expand(declaration, context) {
|
|
115
|
+
const state = stateOf(context.graph, declaration.source);
|
|
116
|
+
if (!state) {
|
|
117
|
+
throw new Error(`entity-list ${declaration.instance}: source disappeared between check and expansion`);
|
|
118
|
+
}
|
|
119
|
+
const entity = entityOf(context.graph, memberEntityId(state.valueType));
|
|
120
|
+
if (!entity) {
|
|
121
|
+
throw new Error(`entity-list ${declaration.instance}: member entity disappeared between check and expansion`);
|
|
122
|
+
}
|
|
123
|
+
const fields = declaration.fields ?? defaultListFields(entity);
|
|
124
|
+
if (!declaration.fields) {
|
|
125
|
+
context.explain(`fields inferred from ${entity.name ?? String(entity.id)}: every field except the identity field ${String(entity.identityFieldId)}`);
|
|
126
|
+
}
|
|
127
|
+
// The repeat binds the current member to its own id, so a cell reads
|
|
128
|
+
// `field(ref(<repeat id>), <field id>)`. The id is derived from the instance, which is
|
|
129
|
+
// what lets `rowRef(instance)` be written by a caller before expansion has happened.
|
|
130
|
+
const rowsId = context.id('rows');
|
|
131
|
+
const cells = fields.map((fieldId, index) => {
|
|
132
|
+
const definition = fieldOf(entity, fieldId);
|
|
133
|
+
const format = declaration.formats?.[String(fieldId)] ?? (definition ? formatFor(definition.valueType) : undefined);
|
|
134
|
+
if (!declaration.formats?.[String(fieldId)] && format) {
|
|
135
|
+
context.explain(`${String(fieldId)} formatted as ${format.kind}, from its declared type`);
|
|
136
|
+
}
|
|
137
|
+
const label = definition ? labelFor(definition) : undefined;
|
|
138
|
+
return context.add({
|
|
139
|
+
id: context.id('cell', index),
|
|
140
|
+
kind: 'field-display',
|
|
141
|
+
source: ref(rowsId),
|
|
142
|
+
fieldId,
|
|
143
|
+
...(label ? { label } : {}),
|
|
144
|
+
presentation: { ...(format ? { format } : {}) },
|
|
145
|
+
}, 'cell');
|
|
146
|
+
});
|
|
147
|
+
const rowChildren = [...cells, ...context.slot('rowExtra')];
|
|
148
|
+
const rowActions = (declaration.rowActions ?? []).map((actionId, index) => {
|
|
149
|
+
const action = actionOf(context.graph, actionId);
|
|
150
|
+
const uxRole = action ? roleForAction(action, index === 0 && !action.destructive) : 'secondary-action';
|
|
151
|
+
if (action?.destructive) {
|
|
152
|
+
context.explain(`${String(actionId)} placed in row actions as destructive because the action declares it`);
|
|
153
|
+
}
|
|
154
|
+
return context.add({
|
|
155
|
+
id: context.id('row_action', index),
|
|
156
|
+
kind: 'button',
|
|
157
|
+
label: action?.name ?? String(actionId),
|
|
158
|
+
actionId,
|
|
159
|
+
...(declaration.rowArguments?.[String(actionId)]
|
|
160
|
+
? { arguments: declaration.rowArguments[String(actionId)] }
|
|
161
|
+
: {}),
|
|
162
|
+
presentation: { uxRole },
|
|
163
|
+
}, 'row-action');
|
|
164
|
+
});
|
|
165
|
+
if (rowActions.length > 0) {
|
|
166
|
+
rowChildren.push(context.add({
|
|
167
|
+
id: context.id('row_actions'),
|
|
168
|
+
kind: 'container',
|
|
169
|
+
children: rowActions,
|
|
170
|
+
presentation: { uxRole: 'action-group', layout: { kind: 'horizontal', gap: 'small', align: 'center' } },
|
|
171
|
+
}, 'row-actions'));
|
|
172
|
+
}
|
|
173
|
+
const row = context.add({
|
|
174
|
+
id: context.id('row'),
|
|
175
|
+
kind: 'container',
|
|
176
|
+
children: rowChildren,
|
|
177
|
+
presentation: {
|
|
178
|
+
surface: 'base',
|
|
179
|
+
padding: { horizontal: 'medium', vertical: 'small' },
|
|
180
|
+
// Wide: a row reads across, table-like. Compact: it stacks into a record. Both are
|
|
181
|
+
// stated as intent; neither names a width.
|
|
182
|
+
layout: { kind: 'horizontal', gap: 'medium', align: 'center', justify: 'between' },
|
|
183
|
+
responsive: { compact: { layout: { kind: 'vertical', gap: 'xsmall' }, } },
|
|
184
|
+
},
|
|
185
|
+
}, 'row');
|
|
186
|
+
const emptyCaption = context.add({
|
|
187
|
+
id: context.id('empty_caption'),
|
|
188
|
+
kind: 'text',
|
|
189
|
+
value: declaration.emptyMessage ?? `No ${entity.name ?? 'records'} yet.`,
|
|
190
|
+
presentation: { textRole: 'body', headingLevel: 'none', emphasis: 'subtle' },
|
|
191
|
+
}, 'empty-caption');
|
|
192
|
+
const emptyChildren = [emptyCaption];
|
|
193
|
+
if (declaration.emptyAction !== undefined) {
|
|
194
|
+
const recovery = actionOf(context.graph, declaration.emptyAction);
|
|
195
|
+
emptyChildren.push(context.add({
|
|
196
|
+
id: context.id('empty_action'),
|
|
197
|
+
kind: 'button',
|
|
198
|
+
label: recovery?.name ?? String(declaration.emptyAction),
|
|
199
|
+
actionId: declaration.emptyAction,
|
|
200
|
+
...(declaration.emptyActionArguments ? { arguments: declaration.emptyActionArguments } : {}),
|
|
201
|
+
presentation: { uxRole: 'primary-action' },
|
|
202
|
+
}, 'empty-action'));
|
|
203
|
+
context.explain('the empty state offers a recovery action, so it says what to do and not only that there is nothing');
|
|
204
|
+
}
|
|
205
|
+
// The empty state is a container even when it holds only a caption, so adding a recovery
|
|
206
|
+
// action later changes a declaration rather than the shape of the generated tree.
|
|
207
|
+
const empty = context.add({
|
|
208
|
+
id: context.id('empty'),
|
|
209
|
+
kind: 'container',
|
|
210
|
+
children: emptyChildren,
|
|
211
|
+
presentation: {
|
|
212
|
+
uxRole: 'empty-state',
|
|
213
|
+
padding: 'medium',
|
|
214
|
+
layout: { kind: 'vertical', gap: 'small', align: 'start' },
|
|
215
|
+
},
|
|
216
|
+
}, 'empty-state');
|
|
217
|
+
context.explain('an empty state is always generated: a collection may be empty and a blank region explains nothing');
|
|
218
|
+
const repeat = context.add({
|
|
219
|
+
id: rowsId,
|
|
220
|
+
kind: 'repeat',
|
|
221
|
+
// A collection state is never null in a validated graph, but a derived one can be;
|
|
222
|
+
// the coalesce is what keeps a legitimately absent collection from failing the render.
|
|
223
|
+
source: ref(declaration.source),
|
|
224
|
+
templateId: row,
|
|
225
|
+
emptyTemplateId: empty,
|
|
226
|
+
presentation: { layout: { kind: 'vertical', gap: 'xsmall' } },
|
|
227
|
+
}, 'rows');
|
|
228
|
+
return context.add({
|
|
229
|
+
id: context.id('root'),
|
|
230
|
+
kind: 'container',
|
|
231
|
+
name: state.name,
|
|
232
|
+
children: [repeat],
|
|
233
|
+
presentation: { layout: { kind: 'vertical', gap: 'small' } },
|
|
234
|
+
}, 'root');
|
|
235
|
+
},
|
|
236
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Expression, ValueFormat } from '@cynodia/axiom-core';
|
|
2
|
+
import type { Emphasis } from '@cynodia/axiom-core';
|
|
3
|
+
import type { PatternText } from '../pattern.js';
|
|
4
|
+
/**
|
|
5
|
+
* Prominent summary values — the row of figures at the top of a dashboard.
|
|
6
|
+
*
|
|
7
|
+
* The caller supplies a label, a value expression and optionally an emphasis. Everything
|
|
8
|
+
* structural is the pattern's: the grid that reflows by available width rather than by
|
|
9
|
+
* breakpoint, the caption-above-figure pairing, the fact that a metric's label is a label and
|
|
10
|
+
* not a heading, and that the figure is drawn at display scale while staying outside the
|
|
11
|
+
* document outline.
|
|
12
|
+
*/
|
|
13
|
+
export interface MetricDeclaration {
|
|
14
|
+
/**
|
|
15
|
+
* The caption. Absent, and where `value` is a plain reference to a named state, the
|
|
16
|
+
* state's own `name` — because a measure the graph has already named does not need
|
|
17
|
+
* naming twice, and prose duplicated is prose that can disagree.
|
|
18
|
+
*/
|
|
19
|
+
label?: PatternText;
|
|
20
|
+
value: Expression;
|
|
21
|
+
format?: ValueFormat;
|
|
22
|
+
emphasis?: Emphasis;
|
|
23
|
+
description?: PatternText;
|
|
24
|
+
}
|
|
25
|
+
export interface MetricGridDeclaration {
|
|
26
|
+
pattern: 'metric-grid';
|
|
27
|
+
instance: string;
|
|
28
|
+
metrics: MetricDeclaration[];
|
|
29
|
+
}
|
|
30
|
+
export declare const metricGrid: import("../pattern.js").PatternDefinition<MetricGridDeclaration>;
|
|
31
|
+
//# sourceMappingURL=metric-grid.d.ts.map
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { definePattern, nameOf } from '../pattern.js';
|
|
2
|
+
import { stateOf } from '../inference.js';
|
|
3
|
+
/**
|
|
4
|
+
* The label a metric can be given without the author writing one.
|
|
5
|
+
*
|
|
6
|
+
* Only a plain `ref` to a state qualifies: the state has a name, and that name is the
|
|
7
|
+
* measure. Nothing is guessed from an id or from the shape of a computation — a label
|
|
8
|
+
* invented from an identifier is a guess presented as a fact.
|
|
9
|
+
*/
|
|
10
|
+
function inferredLabel(graph, metric) {
|
|
11
|
+
return metric.value.kind === 'ref' ? stateOf(graph, metric.value.targetId)?.name : undefined;
|
|
12
|
+
}
|
|
13
|
+
export const metricGrid = definePattern({
|
|
14
|
+
name: 'metric-grid',
|
|
15
|
+
version: '0.7.0',
|
|
16
|
+
purpose: 'A reflowing grid of prominent summary values.',
|
|
17
|
+
inputs: {
|
|
18
|
+
metrics: {
|
|
19
|
+
kind: 'nodes',
|
|
20
|
+
required: true,
|
|
21
|
+
purpose: 'Each metric: a value expression, and optionally a label, format, emphasis and description.',
|
|
22
|
+
inferredWhenAbsent: 'A metric label is inferred from the name of the state its value refers to; a label is required only where the value is not a plain state reference.',
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
slots: [],
|
|
26
|
+
produces: ['container', 'text'],
|
|
27
|
+
expansion: [
|
|
28
|
+
{ part: 'root', kind: 'container', role: 'adaptive grid of metrics' },
|
|
29
|
+
{ part: 'metric', kind: 'container', role: 'one raised cell per metric' },
|
|
30
|
+
{ part: 'metric-label', kind: 'text', role: 'the caption, a label and not a heading' },
|
|
31
|
+
{ part: 'metric-value', kind: 'text', role: 'the figure, display scale, outside the outline' },
|
|
32
|
+
{ part: 'metric-description', kind: 'text', role: 'optional caption under the figure' },
|
|
33
|
+
],
|
|
34
|
+
check(declaration, { graph, instance }) {
|
|
35
|
+
const findings = [];
|
|
36
|
+
declaration.metrics.forEach((metric, index) => {
|
|
37
|
+
if (metric.label === undefined && inferredLabel(graph, metric) === undefined) {
|
|
38
|
+
findings.push({
|
|
39
|
+
code: 'METRIC_LABEL_REQUIRED',
|
|
40
|
+
message: 'A metric needs a label, and one can only be inferred when its value is a reference to a named state.',
|
|
41
|
+
severity: 'error',
|
|
42
|
+
path: `${instance}.metrics[${index}].label`,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
return findings;
|
|
47
|
+
},
|
|
48
|
+
expand(declaration, context) {
|
|
49
|
+
const cells = declaration.metrics.map((metric, index) => {
|
|
50
|
+
const caption = metric.label ?? inferredLabel(context.graph, metric);
|
|
51
|
+
if (metric.label === undefined) {
|
|
52
|
+
context.explain(`metric ${index} labelled "${String(caption)}" from the name of the state it reads`);
|
|
53
|
+
}
|
|
54
|
+
const label = context.add({
|
|
55
|
+
id: context.id('label', index),
|
|
56
|
+
kind: 'text',
|
|
57
|
+
value: caption,
|
|
58
|
+
// A metric's caption is a label, never a heading: a dashboard of eight figures
|
|
59
|
+
// would otherwise put eight headings into the document outline.
|
|
60
|
+
presentation: { textRole: 'label', headingLevel: 'none', emphasis: 'subtle' },
|
|
61
|
+
}, 'metric-label');
|
|
62
|
+
const value = context.add({
|
|
63
|
+
id: context.id('value', index),
|
|
64
|
+
kind: 'text',
|
|
65
|
+
value: metric.value,
|
|
66
|
+
presentation: {
|
|
67
|
+
textRole: 'display',
|
|
68
|
+
headingLevel: 'none',
|
|
69
|
+
...(metric.emphasis ? { emphasis: metric.emphasis } : {}),
|
|
70
|
+
...(metric.format ? { format: metric.format } : {}),
|
|
71
|
+
},
|
|
72
|
+
}, 'metric-value');
|
|
73
|
+
const children = [label, value];
|
|
74
|
+
if (metric.description !== undefined) {
|
|
75
|
+
children.push(context.add({
|
|
76
|
+
id: context.id('description', index),
|
|
77
|
+
kind: 'text',
|
|
78
|
+
value: metric.description,
|
|
79
|
+
presentation: { textRole: 'caption', headingLevel: 'none', emphasis: 'subtle' },
|
|
80
|
+
}, 'metric-description'));
|
|
81
|
+
}
|
|
82
|
+
return context.add({
|
|
83
|
+
id: context.id('metric', index),
|
|
84
|
+
kind: 'container',
|
|
85
|
+
...(nameOf(caption) ? { name: nameOf(caption) } : {}),
|
|
86
|
+
children,
|
|
87
|
+
presentation: {
|
|
88
|
+
surface: 'raised',
|
|
89
|
+
padding: 'medium',
|
|
90
|
+
layout: { kind: 'vertical', gap: 'xsmall' },
|
|
91
|
+
},
|
|
92
|
+
}, 'metric');
|
|
93
|
+
});
|
|
94
|
+
context.explain(`${cells.length} metrics laid out as an adaptive grid: as many columns of at least medium width as fit`);
|
|
95
|
+
return context.add({
|
|
96
|
+
id: context.id('root'),
|
|
97
|
+
kind: 'container',
|
|
98
|
+
children: cells,
|
|
99
|
+
presentation: {
|
|
100
|
+
layout: { kind: 'grid', gap: 'medium', columns: { mode: 'adaptive', minimum: 'narrow' } },
|
|
101
|
+
// Stacking on a compact device is responsive *intent*; what width triggers it is
|
|
102
|
+
// the renderer's business and appears nowhere in the graph.
|
|
103
|
+
responsive: { compact: { layout: { kind: 'vertical', gap: 'small' } } },
|
|
104
|
+
},
|
|
105
|
+
}, 'root');
|
|
106
|
+
},
|
|
107
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { PatternText } from '../pattern.js';
|
|
2
|
+
/**
|
|
3
|
+
* A conventional application page: a header region carrying the title, description and the
|
|
4
|
+
* page-level actions, then a content region.
|
|
5
|
+
*
|
|
6
|
+
* What the caller does not write: the header container, the content container, the heading
|
|
7
|
+
* level, the landmark roles, the spacing between regions, or the decision that the page
|
|
8
|
+
* title is the document's level-1 heading. Those are the same on every page of every
|
|
9
|
+
* application, which is exactly what makes them the pattern's business rather than the
|
|
10
|
+
* author's.
|
|
11
|
+
*/
|
|
12
|
+
export interface PageDeclaration {
|
|
13
|
+
pattern: 'page';
|
|
14
|
+
instance: string;
|
|
15
|
+
/**
|
|
16
|
+
* The page heading. `string | Expression`, so a detail page can be titled by the record it
|
|
17
|
+
* is about — `field(find(products, …), name)` — rather than by a caption that is the same
|
|
18
|
+
* on every product.
|
|
19
|
+
*/
|
|
20
|
+
title: PatternText;
|
|
21
|
+
description?: PatternText;
|
|
22
|
+
/** Placed in the header, to the trailing side of the title. */
|
|
23
|
+
actions?: unknown;
|
|
24
|
+
/** The page body, in order. */
|
|
25
|
+
content?: unknown;
|
|
26
|
+
}
|
|
27
|
+
export declare const page: import("../pattern.js").PatternDefinition<PageDeclaration>;
|
|
28
|
+
//# sourceMappingURL=page.d.ts.map
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { definePattern, nameOf } from '../pattern.js';
|
|
2
|
+
export const page = definePattern({
|
|
3
|
+
name: 'page',
|
|
4
|
+
version: '0.7.0',
|
|
5
|
+
purpose: 'A titled application page with a header region, page-level actions and a content region.',
|
|
6
|
+
inputs: {
|
|
7
|
+
title: {
|
|
8
|
+
kind: 'text',
|
|
9
|
+
required: true,
|
|
10
|
+
purpose: 'The page heading, and the document’s level-1 heading. A string, or an expression for a title that names the record the page is about.',
|
|
11
|
+
},
|
|
12
|
+
description: { kind: 'text', required: false, purpose: 'A caption under the title. A string or an expression.' },
|
|
13
|
+
actions: {
|
|
14
|
+
kind: 'slot',
|
|
15
|
+
required: false,
|
|
16
|
+
purpose: 'Page-level controls, placed in the header.',
|
|
17
|
+
inferredWhenAbsent: 'No action group is generated.',
|
|
18
|
+
},
|
|
19
|
+
content: { kind: 'slot', required: false, purpose: 'The page body, in the order given.' },
|
|
20
|
+
},
|
|
21
|
+
slots: ['actions', 'content'],
|
|
22
|
+
produces: ['container', 'text'],
|
|
23
|
+
expansion: [
|
|
24
|
+
{ part: 'root', kind: 'container', role: 'the page, padded and vertical' },
|
|
25
|
+
{ part: 'header', kind: 'container', role: 'header-region landmark' },
|
|
26
|
+
{ part: 'title-block', kind: 'container', role: 'title and description' },
|
|
27
|
+
{ part: 'title', kind: 'text', role: 'the h1' },
|
|
28
|
+
{ part: 'description', kind: 'text', role: 'caption under the title' },
|
|
29
|
+
{ part: 'actions', kind: 'container', role: 'action-group beside the title, from the actions slot' },
|
|
30
|
+
{ part: 'content', kind: 'container', role: 'content-region landmark, holding the content slot' },
|
|
31
|
+
],
|
|
32
|
+
expand(declaration, context) {
|
|
33
|
+
const children = [];
|
|
34
|
+
const heading = context.add({
|
|
35
|
+
id: context.id('title'),
|
|
36
|
+
kind: 'text',
|
|
37
|
+
// Whatever the author gave: a caption, or an expression over the record on screen.
|
|
38
|
+
value: declaration.title,
|
|
39
|
+
// `title` is the type scale; level 1 is the document outline. They are separate
|
|
40
|
+
// decisions and the pattern makes both, because a page has exactly one of each.
|
|
41
|
+
presentation: { textRole: 'title', headingLevel: 1 },
|
|
42
|
+
}, 'title');
|
|
43
|
+
const headerChildren = [heading];
|
|
44
|
+
context.explain('page title rendered at text role "title" and heading level 1');
|
|
45
|
+
if (declaration.description !== undefined) {
|
|
46
|
+
headerChildren.push(context.add({
|
|
47
|
+
id: context.id('description'),
|
|
48
|
+
kind: 'text',
|
|
49
|
+
value: declaration.description,
|
|
50
|
+
presentation: { textRole: 'caption', headingLevel: 'none', emphasis: 'subtle' },
|
|
51
|
+
}, 'description'));
|
|
52
|
+
}
|
|
53
|
+
// The title and description are one block so the action group sits beside the pair
|
|
54
|
+
// rather than beside the title alone.
|
|
55
|
+
const titleBlock = context.add({
|
|
56
|
+
id: context.id('title_block'),
|
|
57
|
+
kind: 'container',
|
|
58
|
+
children: headerChildren,
|
|
59
|
+
presentation: { layout: { kind: 'vertical', gap: 'xsmall' } },
|
|
60
|
+
}, 'title-block');
|
|
61
|
+
const actions = context.slot('actions');
|
|
62
|
+
const headerBlocks = [titleBlock];
|
|
63
|
+
if (actions.length > 0) {
|
|
64
|
+
headerBlocks.push(context.add({
|
|
65
|
+
id: context.id('actions'),
|
|
66
|
+
kind: 'container',
|
|
67
|
+
children: actions,
|
|
68
|
+
presentation: { uxRole: 'action-group', layout: { kind: 'horizontal', gap: 'small', align: 'center' } },
|
|
69
|
+
}, 'actions'));
|
|
70
|
+
context.explain('page actions grouped in an action-group beside the title');
|
|
71
|
+
}
|
|
72
|
+
children.push(context.add({
|
|
73
|
+
id: context.id('header'),
|
|
74
|
+
kind: 'container',
|
|
75
|
+
children: headerBlocks,
|
|
76
|
+
presentation: {
|
|
77
|
+
uxRole: 'header-region',
|
|
78
|
+
layout: { kind: 'horizontal', justify: 'between', align: 'center', gap: 'medium', wrap: true },
|
|
79
|
+
// A header that cannot fit its actions beside the title stacks them rather than
|
|
80
|
+
// truncating either. Intent, not a breakpoint.
|
|
81
|
+
responsive: { compact: { layout: { kind: 'vertical', gap: 'small' }, } },
|
|
82
|
+
},
|
|
83
|
+
}, 'header'));
|
|
84
|
+
const content = context.slot('content');
|
|
85
|
+
if (content.length > 0) {
|
|
86
|
+
children.push(context.add({
|
|
87
|
+
id: context.id('content'),
|
|
88
|
+
kind: 'container',
|
|
89
|
+
children: content,
|
|
90
|
+
presentation: { uxRole: 'content-region', layout: { kind: 'vertical', gap: 'large' } },
|
|
91
|
+
}, 'content'));
|
|
92
|
+
}
|
|
93
|
+
if (typeof declaration.title !== 'string') {
|
|
94
|
+
context.explain('page title is an expression, so it follows the record the page is about');
|
|
95
|
+
}
|
|
96
|
+
return context.add({
|
|
97
|
+
id: context.id('root'),
|
|
98
|
+
kind: 'container',
|
|
99
|
+
...(nameOf(declaration.title) ? { name: nameOf(declaration.title) } : {}),
|
|
100
|
+
children,
|
|
101
|
+
presentation: { layout: { kind: 'vertical', gap: 'large' }, padding: 'large' },
|
|
102
|
+
}, 'root');
|
|
103
|
+
},
|
|
104
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { ApplicationGraph, NodeId } from '@cynodia/axiom-core';
|
|
2
|
+
import type { PatternDeclaration, ToolkitProvenance } from './pattern.js';
|
|
3
|
+
import type { PatternExpansion, Toolkit } from './expand.js';
|
|
4
|
+
/**
|
|
5
|
+
* Toolkit-aware queries, deliberately **not** part of `AgentAPI`.
|
|
6
|
+
*
|
|
7
|
+
* They live here for two reasons. The dependency direction forbids the other arrangement —
|
|
8
|
+
* `agent-api` knows only core, and a toolkit that core knew about would stop being a toolkit.
|
|
9
|
+
* And the separation is the point: canonical `AgentAPI` must answer every question about an
|
|
10
|
+
* expanded application without knowing a toolkit exists, or the abstraction is opaque. These
|
|
11
|
+
* are additive convenience, and an agent that never finds them loses nothing it needs.
|
|
12
|
+
*
|
|
13
|
+
* Everything here reads the graph. Only `getPatternExpansion` and `getPatternDeclaration`
|
|
14
|
+
* need the toolkit instance, because a declaration is not stored in the graph — which is
|
|
15
|
+
* itself the answer to "is the expanded graph enough": for semantics yes, for authoring
|
|
16
|
+
* history no.
|
|
17
|
+
*/
|
|
18
|
+
export interface ToolkitQueries {
|
|
19
|
+
/** Every pattern instance in the graph, from provenance alone. */
|
|
20
|
+
getPatternInstances(): Array<{
|
|
21
|
+
instance: string;
|
|
22
|
+
pattern: string;
|
|
23
|
+
nodeIds: NodeId[];
|
|
24
|
+
}>;
|
|
25
|
+
getPatternForNode(nodeId: NodeId): ToolkitProvenance | undefined;
|
|
26
|
+
getNodesForInstance(instance: string): NodeId[];
|
|
27
|
+
getInstancesOfPattern(pattern: string): string[];
|
|
28
|
+
/** Requires the toolkit that expanded it: the record is not in the graph. */
|
|
29
|
+
getPatternExpansion(instance: string): PatternExpansion | undefined;
|
|
30
|
+
getPatternDeclaration(instance: string): PatternDeclaration | undefined;
|
|
31
|
+
/** Why the pattern chose what it chose, if the expansion is still in memory. */
|
|
32
|
+
explainInstance(instance: string): string[];
|
|
33
|
+
}
|
|
34
|
+
export declare function createToolkitQueries(graph: ApplicationGraph, toolkit?: Toolkit): ToolkitQueries;
|
|
35
|
+
/**
|
|
36
|
+
* Rewrites a canonical validation finding so it points at the declaration that caused it.
|
|
37
|
+
*
|
|
38
|
+
* Toolkit `check` catches what it can before expansion. Anything it cannot — a rule that only
|
|
39
|
+
* holds over the assembled graph — surfaces from `validateGraph` against a node the author
|
|
40
|
+
* never wrote. Provenance is what turns `ui_product_list_row_action_0 has no argument for
|
|
41
|
+
* param_product` into `product_list ▸ entity-list ▸ row-action`, which is the difference
|
|
42
|
+
* between a diagnostic an agent can act on and one it has to reverse-engineer.
|
|
43
|
+
*/
|
|
44
|
+
export interface MappedIssue {
|
|
45
|
+
code: string;
|
|
46
|
+
message: string;
|
|
47
|
+
nodeId?: NodeId;
|
|
48
|
+
/** `product_list.row-action`, when the node came from a pattern. */
|
|
49
|
+
declarationPath?: string;
|
|
50
|
+
pattern?: string;
|
|
51
|
+
instance?: string;
|
|
52
|
+
/** The full chain, outermost first, for a node from a nested pattern. */
|
|
53
|
+
ancestry?: string[];
|
|
54
|
+
}
|
|
55
|
+
export declare function mapIssuesToDeclarations(graph: ApplicationGraph, issues: readonly {
|
|
56
|
+
code: string;
|
|
57
|
+
message: string;
|
|
58
|
+
nodeId?: NodeId;
|
|
59
|
+
}[]): MappedIssue[];
|
|
60
|
+
//# sourceMappingURL=queries.d.ts.map
|
package/dist/queries.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { provenanceOf } from './pattern.js';
|
|
2
|
+
import { instancesOfPattern, nodesOfInstance } from './expand.js';
|
|
3
|
+
export function createToolkitQueries(graph, toolkit) {
|
|
4
|
+
return {
|
|
5
|
+
getPatternInstances() {
|
|
6
|
+
const byInstance = new Map();
|
|
7
|
+
for (const node of graph.listNodes()) {
|
|
8
|
+
const provenance = provenanceOf(node);
|
|
9
|
+
if (!provenance) {
|
|
10
|
+
continue;
|
|
11
|
+
}
|
|
12
|
+
const existing = byInstance.get(provenance.instance) ?? {
|
|
13
|
+
instance: provenance.instance,
|
|
14
|
+
pattern: provenance.pattern,
|
|
15
|
+
nodeIds: [],
|
|
16
|
+
};
|
|
17
|
+
existing.nodeIds.push(node.id);
|
|
18
|
+
byInstance.set(provenance.instance, existing);
|
|
19
|
+
}
|
|
20
|
+
return [...byInstance.values()];
|
|
21
|
+
},
|
|
22
|
+
getPatternForNode: (nodeId) => {
|
|
23
|
+
const node = graph.getNode(nodeId);
|
|
24
|
+
return node ? provenanceOf(node) : undefined;
|
|
25
|
+
},
|
|
26
|
+
getNodesForInstance: (instance) => nodesOfInstance(graph, instance),
|
|
27
|
+
getInstancesOfPattern: (pattern) => instancesOfPattern(graph, pattern),
|
|
28
|
+
getPatternExpansion: (instance) => toolkit?.inspect(graph, instance),
|
|
29
|
+
getPatternDeclaration: (instance) => toolkit?.inspect(graph, instance)?.declaration,
|
|
30
|
+
explainInstance: (instance) => toolkit?.inspect(graph, instance)?.explanations ?? [],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export function mapIssuesToDeclarations(graph, issues) {
|
|
34
|
+
return issues.map((issue) => {
|
|
35
|
+
if (!issue.nodeId) {
|
|
36
|
+
return { code: issue.code, message: issue.message };
|
|
37
|
+
}
|
|
38
|
+
const node = graph.getNode(issue.nodeId);
|
|
39
|
+
const provenance = node ? provenanceOf(node) : undefined;
|
|
40
|
+
if (!provenance) {
|
|
41
|
+
return { code: issue.code, message: issue.message, nodeId: issue.nodeId };
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
code: issue.code,
|
|
45
|
+
message: issue.message,
|
|
46
|
+
nodeId: issue.nodeId,
|
|
47
|
+
declarationPath: `${provenance.instance}.${provenance.part}`,
|
|
48
|
+
pattern: provenance.pattern,
|
|
49
|
+
instance: provenance.instance,
|
|
50
|
+
...(provenance.ancestry ? { ancestry: provenance.ancestry } : {}),
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The prototype toolkit: five patterns, deliberately.
|
|
3
|
+
*
|
|
4
|
+
* Adding a sixth because it would be convenient is how a pattern layer becomes a component
|
|
5
|
+
* catalogue, which is the outcome this research exists to avoid.
|
|
6
|
+
*/
|
|
7
|
+
export declare const axiomUi: import("./expand.js").Toolkit;
|
|
8
|
+
//# sourceMappingURL=toolkit.d.ts.map
|
package/dist/toolkit.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createToolkit } from './expand.js';
|
|
2
|
+
import { page } from './patterns/page.js';
|
|
3
|
+
import { metricGrid } from './patterns/metric-grid.js';
|
|
4
|
+
import { entityList } from './patterns/entity-list.js';
|
|
5
|
+
import { entityForm } from './patterns/entity-form.js';
|
|
6
|
+
import { actionBar } from './patterns/action-bar.js';
|
|
7
|
+
/**
|
|
8
|
+
* The prototype toolkit: five patterns, deliberately.
|
|
9
|
+
*
|
|
10
|
+
* Adding a sixth because it would be convenient is how a pattern layer becomes a component
|
|
11
|
+
* catalogue, which is the outcome this research exists to avoid.
|
|
12
|
+
*/
|
|
13
|
+
export const axiomUi = createToolkit([
|
|
14
|
+
page,
|
|
15
|
+
metricGrid,
|
|
16
|
+
entityList,
|
|
17
|
+
entityForm,
|
|
18
|
+
actionBar,
|
|
19
|
+
]);
|