@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.
@@ -0,0 +1,75 @@
1
+ # Ownership
2
+
3
+ Who decides what a generated node contains, after expansion.
4
+
5
+ Leaving this implicit makes "may I edit this node?" unanswerable, so every expansion records
6
+ an answer.
7
+
8
+ ## Two modes
9
+
10
+ | | `declaration` (default) | `graph` |
11
+ | --- | --- | --- |
12
+ | Source of truth | the pattern declaration | the expanded graph |
13
+ | The graph is | a build artifact | the application |
14
+ | Re-expansion | authoritative, overwrites | a mistake |
15
+ | Editing a generated node | **drift** — reported, then lost on the next build | legitimate |
16
+ | The declaration afterwards | still required | history |
17
+ | Toolkit dependency | build-time | none after materializing |
18
+
19
+ ```ts
20
+ axiomUi.expand(graph, declaration); // declaration-owned
21
+ axiomUi.expand(graph, declaration, { ownership: 'graph' }); // graph-owned from the start
22
+ materializePattern(graph, axiomUi.inspect(graph, 'products')!); // hand over later
23
+ ```
24
+
25
+ ## Declaration-owned
26
+
27
+ The default, because a toolkit is an authoring layer. The declaration is what you maintain;
28
+ the graph is what the build produces from it.
29
+
30
+ Editing a generated node here is not forbidden — nothing can forbid it — but it is **drift**,
31
+ and the next expansion silently discards it. So drift is detected rather than ignored:
32
+
33
+ ```ts
34
+ detectDrift(graph, axiomUi.inspect(graph, 'product_list')!);
35
+ // [{ code: 'TOOLKIT_EXPANSION_DRIFT', nodeId: 'ui_product_list_row',
36
+ // property: 'children', expected: [...], actual: [...],
37
+ // message: 'ui_product_list_row.children differs from what entity-list generated' }]
38
+ ```
39
+
40
+ It names the node and the property, not merely that something changed, because "your edit will
41
+ be lost" is only actionable if it says which edit.
42
+
43
+ ### Resolving drift
44
+
45
+ A `TOOLKIT_EXPANSION_DRIFT` finding has exactly **two** correct resolutions, and the property it
46
+ names tells you which fits:
47
+
48
+ 1. **Change the declaration** so expansion produces what you edited. Right whenever a pattern
49
+ input, an option or a slot can express the change — which is most of the time, and it keeps
50
+ the declaration the single source of truth. Re-expand and `detectDrift` returns `[]`.
51
+ 2. **Materialize the instance**, taking ownership of the generated nodes. Right when the change
52
+ is something no input can express. After this the edit is legitimate, the declaration is
53
+ history, and the next build will not overwrite it.
54
+
55
+ Reverting the edit is a third outcome but not a resolution: it discards the intent that caused
56
+ the drift. What is never correct is leaving it — under `declaration` ownership the next
57
+ expansion discards the edit without asking.
58
+
59
+ ## Graph-owned
60
+
61
+ For an application that no longer wants a toolkit dependency, or one that has outgrown a
62
+ pattern. `materializePattern` keeps every generated node exactly as it is and re-marks it, so
63
+ nothing treats the declaration as authoritative again.
64
+
65
+ There is no un-materialize. Recovering a declaration from an expanded graph is a different
66
+ problem and is not attempted.
67
+
68
+ ## Which to choose
69
+
70
+ Start with `declaration`. Materialize a specific instance when you need to diverge from what
71
+ the pattern generates in a way no slot or option covers — that is a deliberate, recorded
72
+ decision, and it is better than an edit that quietly disappears.
73
+
74
+ A materialized application is an ordinary Axiom application with no toolkit involvement: it
75
+ validates, compiles, executes and renders with the package uninstalled.
@@ -0,0 +1,71 @@
1
+ # Authoring a pattern
2
+
3
+ For a third party defining patterns outside `@cynodia/axiom-ui`. Requires **no changes to
4
+ Axiom core, the runtime or any renderer**.
5
+
6
+ ```ts
7
+ import { createToolkit, definePattern } from '@cynodia/axiom-ui';
8
+
9
+ const inventoryStatusPanel = definePattern<{
10
+ pattern: 'inventory-status-panel';
11
+ instance: string;
12
+ threshold: number;
13
+ }>({
14
+ name: 'inventory-status-panel',
15
+ version: '1.0.0',
16
+ purpose: 'A domain-specific panel reporting stock health.',
17
+ inputs: { threshold: { kind: 'token', required: true, purpose: 'Below this, stock counts as low.' } },
18
+ slots: [],
19
+ produces: ['container', 'text'],
20
+ expansion: [
21
+ { part: 'root', kind: 'container', role: 'the panel' },
22
+ { part: 'caption', kind: 'text', role: 'heading' },
23
+ ],
24
+ check(declaration, { graph, instance }) {
25
+ return declaration.threshold > 0
26
+ ? []
27
+ : [{ code: 'INVALID_THRESHOLD', message: 'A threshold must be positive.', severity: 'error', path: `${instance}.threshold` }];
28
+ },
29
+ expand(declaration, context) {
30
+ const caption = context.add({ id: context.id('caption'), kind: 'text', value: 'Stock health' }, 'caption');
31
+ context.explain(`low stock counted below ${declaration.threshold}`);
32
+ return context.add({ id: context.id('root'), kind: 'container', children: [caption] }, 'root');
33
+ },
34
+ });
35
+
36
+ export const hospitality = createToolkit([inventoryStatusPanel]);
37
+ ```
38
+
39
+ ## The two halves
40
+
41
+ `inputs`, `slots`, `produces`, `purpose` and `version` are **data**, and the catalogue serves
42
+ them to agents. `check` and `expand` are authoring-time TypeScript and never reach a graph, an
43
+ IR or a runtime.
44
+
45
+ That split is the whole portability story: the part an agent must discover is data; the part
46
+ that only runs during a build is code. A pattern *definition* is not portable across
47
+ languages. A pattern *declaration*, and everything it expands into, is.
48
+
49
+ ## Rules
50
+
51
+ - **MUST** create every node through `context.add(node, part)`. It stamps provenance, records the node for drift detection and keeps ids deterministic. A `graph.addNode` inside `expand` is invisible to all three.
52
+ - **MUST** derive ids with `context.id(part, index?)`. Never a counter, never a random value: expansion must be deterministic, and the same declaration must always produce the same graph.
53
+ - **MUST NOT** create state, actions, constraints or transition constraints. A pattern composes existing behaviour. Business semantics belong to the application.
54
+ - **MUST NOT** emit a colour, a length, a breakpoint, a CSS property or a renderer name. Emit roles, tokens and responsive intent; the theme decides how they look.
55
+ - **MUST NOT** accept a callback as an input. No `renderRow(item) => …`. Take a slot instead.
56
+ - **SHOULD** implement `check` for anything expansion assumes, so a mistake points at the declaration rather than at a generated node.
57
+ - **SHOULD** call `context.explain(...)` whenever the pattern decides something the author did not write. This is what an agent reads back.
58
+ - **SHOULD** declare `inferredWhenAbsent` on every optional input the pattern works out for itself. It is how an agent knows what it may leave out.
59
+
60
+ ## Versioning
61
+
62
+ `version` is recorded in each generated node's provenance. It is what makes a stored expansion
63
+ reproducible and an upgrade diffable:
64
+
65
+ ```ts
66
+ diffPatternExpansion(expansion, nextToolkit, scratchGraph);
67
+ // { added: [...], removed: [...], changed: [{ nodeId, property, from, to }] }
68
+ ```
69
+
70
+ Change it whenever expansion output changes. An application should learn that an upgrade
71
+ reshapes it from a diff, not from a rendering.
@@ -0,0 +1,504 @@
1
+ {
2
+ "catalog": "axiom.ui.catalog.v1",
3
+ "toolkit": "@cynodia/axiom-ui",
4
+ "version": "0.7.0",
5
+ "release": "0.7.0-alpha.1",
6
+ "description": "Semantic UI patterns for Axiom. A pattern is not a component: it expands, at authoring time, into ordinary Axiom UI nodes and has no runtime existence. Declare the UX concept; the expansion is explicit, inspectable application semantics.",
7
+ "ownership": {
8
+ "default": "declaration",
9
+ "values": {
10
+ "declaration": "The pattern declaration is the source of truth. Re-expansion is authoritative and editing a generated node is drift.",
11
+ "graph": "The expanded graph is the source of truth. The declaration is history and edits are legitimate."
12
+ }
13
+ },
14
+ "reading": {
15
+ "inputs": "Every declaration key, its kind, whether it is required, and what the pattern does with it.",
16
+ "inferred": "What the pattern works out for itself when an input is absent, and from what.",
17
+ "expansion": "The generated tree, part by part: the canonical node kind each part is, and where it sits. Describing only inputs was not enough — an agent composing against a generated tree needs its shape.",
18
+ "generatedIdFormat": "How to address a generated node before expansion has happened.",
19
+ "slots": "Where caller-supplied semantic content is placed. Slot content is node ids or nested declarations, never markup."
20
+ },
21
+ "patterns": [
22
+ {
23
+ "name": "action-bar",
24
+ "purpose": "A row of action controls whose emphasis is inferred from the actions themselves.",
25
+ "required": [
26
+ "actions"
27
+ ],
28
+ "optional": [
29
+ "primary",
30
+ "labels",
31
+ "arguments",
32
+ "alignment"
33
+ ],
34
+ "inputs": {
35
+ "actions": {
36
+ "kind": "action-list",
37
+ "required": true,
38
+ "purpose": "The actions to expose, in order."
39
+ },
40
+ "primary": {
41
+ "kind": "action",
42
+ "required": false,
43
+ "purpose": "Which action is the primary one.",
44
+ "inferredWhenAbsent": "The first action that is not destructive."
45
+ },
46
+ "labels": {
47
+ "kind": "text",
48
+ "required": false,
49
+ "purpose": "Override a control’s label.",
50
+ "inferredWhenAbsent": "The action’s own name."
51
+ },
52
+ "arguments": {
53
+ "kind": "nodes",
54
+ "required": false,
55
+ "purpose": "Arguments per action, keyed by action parameter id."
56
+ },
57
+ "alignment": {
58
+ "kind": "token",
59
+ "required": false,
60
+ "purpose": "Where the group sits along its axis.",
61
+ "inferredWhenAbsent": "start"
62
+ }
63
+ },
64
+ "slots": [],
65
+ "produces": [
66
+ "container",
67
+ "button"
68
+ ],
69
+ "expansion": [
70
+ {
71
+ "part": "root",
72
+ "kind": "container",
73
+ "role": "the action-group"
74
+ },
75
+ {
76
+ "part": "button",
77
+ "kind": "button",
78
+ "role": "one per action, emphasis from the action"
79
+ }
80
+ ],
81
+ "generatedIdFormat": "ui_<instance>_<part>, or ui_<instance>_<part>_<index> for a repeated part",
82
+ "inferred": {
83
+ "primary": "The first action that is not destructive.",
84
+ "labels": "The action’s own name.",
85
+ "alignment": "start"
86
+ }
87
+ },
88
+ {
89
+ "name": "entity-form",
90
+ "purpose": "A form that creates a record in a draft state or edits an existing one addressed by expression, with labels, controls, required markers and a submit action inferred from the graph.",
91
+ "required": [
92
+ "submit"
93
+ ],
94
+ "optional": [
95
+ "draft",
96
+ "target",
97
+ "options",
98
+ "mode",
99
+ "fields",
100
+ "submitArguments",
101
+ "submitLabel",
102
+ "title",
103
+ "titleLevel",
104
+ "description",
105
+ "secondaryActions"
106
+ ],
107
+ "inputs": {
108
+ "draft": {
109
+ "kind": "state",
110
+ "required": false,
111
+ "purpose": "Create mode: the draft state the controls write into. Its entity supplies every field’s label, control and required status.",
112
+ "inferredWhenAbsent": "Required unless `target` is given; exactly one of the two is."
113
+ },
114
+ "target": {
115
+ "kind": "nodes",
116
+ "required": false,
117
+ "purpose": "Edit mode: { state, identity } addressing an existing collection member — identity is an expression, usually a route parameter.",
118
+ "inferredWhenAbsent": "Required unless `draft` is given; exactly one of the two is."
119
+ },
120
+ "options": {
121
+ "kind": "nodes",
122
+ "required": false,
123
+ "purpose": "A canonical InputOptionsSource per field id, for a field whose value identifies another record.",
124
+ "inferredWhenAbsent": "A field with no options source uses the control its declared type implies."
125
+ },
126
+ "submit": {
127
+ "kind": "action",
128
+ "required": true,
129
+ "purpose": "The action the form submits."
130
+ },
131
+ "mode": {
132
+ "kind": "token",
133
+ "required": false,
134
+ "purpose": "\"create\" offers the identity field; \"edit\" omits it, because an identity is not editable.",
135
+ "inferredWhenAbsent": "edit when `target` is given, create when `draft` is. Declaring it is only needed to edit through a draft state."
136
+ },
137
+ "fields": {
138
+ "kind": "field-list",
139
+ "required": false,
140
+ "purpose": "Which fields to edit, in order.",
141
+ "inferredWhenAbsent": "Every field of the entity, in declaration order, identity included — omitting a required field would produce a form that can never submit."
142
+ },
143
+ "submitArguments": {
144
+ "kind": "nodes",
145
+ "required": false,
146
+ "purpose": "Arguments for the submit action."
147
+ },
148
+ "submitLabel": {
149
+ "kind": "text",
150
+ "required": false,
151
+ "purpose": "The submit control’s label.",
152
+ "inferredWhenAbsent": "The action’s own name."
153
+ },
154
+ "title": {
155
+ "kind": "text",
156
+ "required": false,
157
+ "purpose": "A heading above the form."
158
+ },
159
+ "titleLevel": {
160
+ "kind": "token",
161
+ "required": false,
162
+ "purpose": "The title’s document-outline level.",
163
+ "inferredWhenAbsent": "2 — the level below a page title, so a page and its form do not skip a level."
164
+ },
165
+ "description": {
166
+ "kind": "text",
167
+ "required": false,
168
+ "purpose": "A caption under the heading."
169
+ },
170
+ "secondaryActions": {
171
+ "kind": "slot",
172
+ "required": false,
173
+ "purpose": "Controls placed beside the submit button."
174
+ }
175
+ },
176
+ "slots": [
177
+ "secondaryActions"
178
+ ],
179
+ "produces": [
180
+ "form",
181
+ "input",
182
+ "button",
183
+ "container",
184
+ "text",
185
+ "diagnostic"
186
+ ],
187
+ "expansion": [
188
+ {
189
+ "part": "root",
190
+ "kind": "form",
191
+ "role": "the form; submitButtonId is the generated submit, and target reads the record being edited"
192
+ },
193
+ {
194
+ "part": "title",
195
+ "kind": "text",
196
+ "role": "optional heading, level 2"
197
+ },
198
+ {
199
+ "part": "description",
200
+ "kind": "text",
201
+ "role": "optional caption"
202
+ },
203
+ {
204
+ "part": "input",
205
+ "kind": "input",
206
+ "role": "one per field, in entity declaration order; bound to the draft field or to the addressed member’s field"
207
+ },
208
+ {
209
+ "part": "diagnostic",
210
+ "kind": "diagnostic",
211
+ "role": "where a refusal of the submit action appears"
212
+ },
213
+ {
214
+ "part": "actions",
215
+ "kind": "container",
216
+ "role": "action-group holding submit and the secondaryActions slot"
217
+ },
218
+ {
219
+ "part": "submit",
220
+ "kind": "button",
221
+ "role": "the primary action"
222
+ }
223
+ ],
224
+ "generatedIdFormat": "ui_<instance>_<part>, or ui_<instance>_<part>_<index> for a repeated part",
225
+ "inferred": {
226
+ "draft": "Required unless `target` is given; exactly one of the two is.",
227
+ "target": "Required unless `draft` is given; exactly one of the two is.",
228
+ "options": "A field with no options source uses the control its declared type implies.",
229
+ "mode": "edit when `target` is given, create when `draft` is. Declaring it is only needed to edit through a draft state.",
230
+ "fields": "Every field of the entity, in declaration order, identity included — omitting a required field would produce a form that can never submit.",
231
+ "submitLabel": "The action’s own name.",
232
+ "titleLevel": "2 — the level below a page title, so a page and its form do not skip a level."
233
+ }
234
+ },
235
+ {
236
+ "name": "entity-list",
237
+ "purpose": "Lists the instances of a collection, one row per member, with optional per-row actions.",
238
+ "required": [
239
+ "source"
240
+ ],
241
+ "optional": [
242
+ "fields",
243
+ "formats",
244
+ "rowActions",
245
+ "rowArguments",
246
+ "emptyMessage",
247
+ "emptyAction",
248
+ "emptyActionArguments",
249
+ "rowExtra"
250
+ ],
251
+ "inputs": {
252
+ "source": {
253
+ "kind": "state",
254
+ "required": true,
255
+ "purpose": "The collection state to list."
256
+ },
257
+ "fields": {
258
+ "kind": "field-list",
259
+ "required": false,
260
+ "purpose": "Which fields to display, in order.",
261
+ "inferredWhenAbsent": "Every field of the member entity except its identity field."
262
+ },
263
+ "formats": {
264
+ "kind": "nodes",
265
+ "required": false,
266
+ "purpose": "Value format per field, where the declared type cannot imply one.",
267
+ "inferredWhenAbsent": "number, boolean, date and datetime from the field’s type; currency and percentage are never guessed."
268
+ },
269
+ "rowActions": {
270
+ "kind": "action-list",
271
+ "required": false,
272
+ "purpose": "Actions offered on each row."
273
+ },
274
+ "rowArguments": {
275
+ "kind": "nodes",
276
+ "required": false,
277
+ "purpose": "Arguments per row action, evaluated in the row scope."
278
+ },
279
+ "emptyMessage": {
280
+ "kind": "text",
281
+ "required": false,
282
+ "purpose": "What the empty state says.",
283
+ "inferredWhenAbsent": "An empty state is still generated, with a generic caption."
284
+ },
285
+ "emptyAction": {
286
+ "kind": "action",
287
+ "required": false,
288
+ "purpose": "An action offered from the empty state, so it says what to do about it.",
289
+ "inferredWhenAbsent": "The empty state is a caption only, and validateGraph warns that it offers no recovery."
290
+ },
291
+ "emptyActionArguments": {
292
+ "kind": "nodes",
293
+ "required": false,
294
+ "purpose": "Arguments for the empty-state action."
295
+ },
296
+ "rowExtra": {
297
+ "kind": "slot",
298
+ "required": false,
299
+ "purpose": "Extra semantic content inside each row."
300
+ }
301
+ },
302
+ "slots": [
303
+ "rowExtra"
304
+ ],
305
+ "produces": [
306
+ "container",
307
+ "repeat",
308
+ "field-display",
309
+ "button",
310
+ "text"
311
+ ],
312
+ "expansion": [
313
+ {
314
+ "part": "root",
315
+ "kind": "container",
316
+ "role": "wraps the list"
317
+ },
318
+ {
319
+ "part": "rows",
320
+ "kind": "repeat",
321
+ "role": "the repeat; ref this id to address the current row"
322
+ },
323
+ {
324
+ "part": "row",
325
+ "kind": "container",
326
+ "role": "one row, horizontal when wide and stacked when compact"
327
+ },
328
+ {
329
+ "part": "cell",
330
+ "kind": "field-display",
331
+ "role": "one per field, in order"
332
+ },
333
+ {
334
+ "part": "row-actions",
335
+ "kind": "container",
336
+ "role": "action-group at the end of the row"
337
+ },
338
+ {
339
+ "part": "row-action",
340
+ "kind": "button",
341
+ "role": "one per row action"
342
+ },
343
+ {
344
+ "part": "empty-state",
345
+ "kind": "container",
346
+ "role": "shown when the collection is empty"
347
+ },
348
+ {
349
+ "part": "empty-caption",
350
+ "kind": "text",
351
+ "role": "what the empty state says"
352
+ },
353
+ {
354
+ "part": "empty-action",
355
+ "kind": "button",
356
+ "role": "the recovery action, when one is given"
357
+ }
358
+ ],
359
+ "generatedIdFormat": "ui_<instance>_<part>, or ui_<instance>_<part>_<index> for a repeated part",
360
+ "inferred": {
361
+ "fields": "Every field of the member entity except its identity field.",
362
+ "formats": "number, boolean, date and datetime from the field’s type; currency and percentage are never guessed.",
363
+ "emptyMessage": "An empty state is still generated, with a generic caption.",
364
+ "emptyAction": "The empty state is a caption only, and validateGraph warns that it offers no recovery."
365
+ }
366
+ },
367
+ {
368
+ "name": "metric-grid",
369
+ "purpose": "A reflowing grid of prominent summary values.",
370
+ "required": [
371
+ "metrics"
372
+ ],
373
+ "optional": [],
374
+ "inputs": {
375
+ "metrics": {
376
+ "kind": "nodes",
377
+ "required": true,
378
+ "purpose": "Each metric: a value expression, and optionally a label, format, emphasis and description.",
379
+ "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."
380
+ }
381
+ },
382
+ "slots": [],
383
+ "produces": [
384
+ "container",
385
+ "text"
386
+ ],
387
+ "expansion": [
388
+ {
389
+ "part": "root",
390
+ "kind": "container",
391
+ "role": "adaptive grid of metrics"
392
+ },
393
+ {
394
+ "part": "metric",
395
+ "kind": "container",
396
+ "role": "one raised cell per metric"
397
+ },
398
+ {
399
+ "part": "metric-label",
400
+ "kind": "text",
401
+ "role": "the caption, a label and not a heading"
402
+ },
403
+ {
404
+ "part": "metric-value",
405
+ "kind": "text",
406
+ "role": "the figure, display scale, outside the outline"
407
+ },
408
+ {
409
+ "part": "metric-description",
410
+ "kind": "text",
411
+ "role": "optional caption under the figure"
412
+ }
413
+ ],
414
+ "generatedIdFormat": "ui_<instance>_<part>, or ui_<instance>_<part>_<index> for a repeated part",
415
+ "inferred": {
416
+ "metrics": "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."
417
+ }
418
+ },
419
+ {
420
+ "name": "page",
421
+ "purpose": "A titled application page with a header region, page-level actions and a content region.",
422
+ "required": [
423
+ "title"
424
+ ],
425
+ "optional": [
426
+ "description",
427
+ "actions",
428
+ "content"
429
+ ],
430
+ "inputs": {
431
+ "title": {
432
+ "kind": "text",
433
+ "required": true,
434
+ "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."
435
+ },
436
+ "description": {
437
+ "kind": "text",
438
+ "required": false,
439
+ "purpose": "A caption under the title. A string or an expression."
440
+ },
441
+ "actions": {
442
+ "kind": "slot",
443
+ "required": false,
444
+ "purpose": "Page-level controls, placed in the header.",
445
+ "inferredWhenAbsent": "No action group is generated."
446
+ },
447
+ "content": {
448
+ "kind": "slot",
449
+ "required": false,
450
+ "purpose": "The page body, in the order given."
451
+ }
452
+ },
453
+ "slots": [
454
+ "actions",
455
+ "content"
456
+ ],
457
+ "produces": [
458
+ "container",
459
+ "text"
460
+ ],
461
+ "expansion": [
462
+ {
463
+ "part": "root",
464
+ "kind": "container",
465
+ "role": "the page, padded and vertical"
466
+ },
467
+ {
468
+ "part": "header",
469
+ "kind": "container",
470
+ "role": "header-region landmark"
471
+ },
472
+ {
473
+ "part": "title-block",
474
+ "kind": "container",
475
+ "role": "title and description"
476
+ },
477
+ {
478
+ "part": "title",
479
+ "kind": "text",
480
+ "role": "the h1"
481
+ },
482
+ {
483
+ "part": "description",
484
+ "kind": "text",
485
+ "role": "caption under the title"
486
+ },
487
+ {
488
+ "part": "actions",
489
+ "kind": "container",
490
+ "role": "action-group beside the title, from the actions slot"
491
+ },
492
+ {
493
+ "part": "content",
494
+ "kind": "container",
495
+ "role": "content-region landmark, holding the content slot"
496
+ }
497
+ ],
498
+ "generatedIdFormat": "ui_<instance>_<part>, or ui_<instance>_<part>_<index> for a repeated part",
499
+ "inferred": {
500
+ "actions": "No action group is generated."
501
+ }
502
+ }
503
+ ]
504
+ }