@auto-engineer/narrative 1.150.0 → 1.153.0

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/package.json CHANGED
@@ -26,9 +26,9 @@
26
26
  "typescript": "^5.9.2",
27
27
  "zod": "^3.22.4",
28
28
  "zod-to-json-schema": "^3.22.3",
29
- "@auto-engineer/file-store": "1.150.0",
30
- "@auto-engineer/id": "1.150.0",
31
- "@auto-engineer/message-bus": "1.150.0"
29
+ "@auto-engineer/file-store": "1.153.0",
30
+ "@auto-engineer/id": "1.153.0",
31
+ "@auto-engineer/message-bus": "1.153.0"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@types/node": "^20.0.0",
@@ -38,7 +38,7 @@
38
38
  "publishConfig": {
39
39
  "access": "public"
40
40
  },
41
- "version": "1.150.0",
41
+ "version": "1.153.0",
42
42
  "scripts": {
43
43
  "build": "tsx scripts/build.ts",
44
44
  "test": "vitest run --reporter=dot",
@@ -1,6 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
- import { command, react } from './fluent-builder';
3
- import { clearCurrentScene, startScene } from './narrative-context';
2
+ import { command, experience, query, react } from './fluent-builder';
3
+ import { clearCurrentScene, getCurrentScene, startScene } from './narrative-context';
4
4
  import { createIntegration } from './types';
5
5
 
6
6
  // Test integrations
@@ -34,3 +34,49 @@ describe('via method', () => {
34
34
  expect(slice).toBeDefined();
35
35
  });
36
36
  });
37
+
38
+ describe('ui method', () => {
39
+ beforeEach(() => {
40
+ startScene('test-flow');
41
+ });
42
+
43
+ afterEach(() => {
44
+ clearCurrentScene();
45
+ });
46
+
47
+ it('sets client.ui on a command moment', () => {
48
+ const uiBlock = { layoutId: 'centered-narrow', surface: 'route' as const };
49
+ command('Submit Order').ui(uiBlock);
50
+
51
+ const scene = getCurrentScene();
52
+ const moment = scene!.moments[0];
53
+ expect(moment.type).toBe('command');
54
+ if (moment.type === 'command') {
55
+ expect(moment.client.ui).toEqual(uiBlock);
56
+ }
57
+ });
58
+
59
+ it('sets client.ui on a query moment', () => {
60
+ const uiBlock = { layoutId: 'two-column', surface: 'route' as const };
61
+ query('View Items').ui(uiBlock);
62
+
63
+ const scene = getCurrentScene();
64
+ const moment = scene!.moments[0];
65
+ expect(moment.type).toBe('query');
66
+ if (moment.type === 'query') {
67
+ expect(moment.client.ui).toEqual(uiBlock);
68
+ }
69
+ });
70
+
71
+ it('sets client.ui on an experience moment', () => {
72
+ const uiBlock = { layoutId: 'full-width', surface: 'ephemeral' as const };
73
+ experience('Welcome Tour').ui(uiBlock);
74
+
75
+ const scene = getCurrentScene();
76
+ const moment = scene!.moments[0];
77
+ expect(moment.type).toBe('experience');
78
+ if (moment.type === 'experience') {
79
+ expect(moment.client.ui).toEqual(uiBlock);
80
+ }
81
+ });
82
+ });
@@ -1,6 +1,6 @@
1
1
  import createDebug from 'debug';
2
2
  import { type ASTNode, print } from 'graphql';
3
- import type { CommandMoment, ExperienceMoment, QueryMoment, ReactMoment } from './index';
3
+ import type { CommandMoment, ExperienceMoment, QueryMoment, ReactMoment, UiBlock } from './index';
4
4
  import {
5
5
  addMoment,
6
6
  endClientBlock,
@@ -38,6 +38,7 @@ export interface FluentCommandMomentBuilder {
38
38
  client(description: string, fn: () => void): FluentCommandMomentBuilder;
39
39
  server(fn: () => void): FluentCommandMomentBuilder;
40
40
  server(description: string, fn: () => void): FluentCommandMomentBuilder;
41
+ ui(spec: UiBlock): FluentCommandMomentBuilder;
41
42
  via(integration: Integration | Integration[]): FluentCommandMomentBuilder;
42
43
  retries(count: number): FluentCommandMomentBuilder;
43
44
  request(mutation: unknown): FluentCommandMomentBuilder;
@@ -48,6 +49,7 @@ export interface FluentQueryMomentBuilder {
48
49
  client(description: string, fn: () => void): FluentQueryMomentBuilder;
49
50
  server(fn: () => void): FluentQueryMomentBuilder;
50
51
  server(description: string, fn: () => void): FluentQueryMomentBuilder;
52
+ ui(spec: UiBlock): FluentQueryMomentBuilder;
51
53
  request(query: unknown): FluentQueryMomentBuilder;
52
54
  }
53
55
 
@@ -61,6 +63,7 @@ export interface FluentReactionMomentBuilder {
61
63
  export interface FluentExperienceMomentBuilder {
62
64
  client(fn: () => void): FluentExperienceMomentBuilder;
63
65
  client(description: string, fn: () => void): FluentExperienceMomentBuilder;
66
+ ui(spec: UiBlock): FluentExperienceMomentBuilder;
64
67
  }
65
68
 
66
69
  class CommandMomentBuilderImpl implements FluentCommandMomentBuilder {
@@ -132,6 +135,12 @@ class CommandMomentBuilderImpl implements FluentCommandMomentBuilder {
132
135
  return this;
133
136
  }
134
137
 
138
+ ui(spec: UiBlock): FluentCommandMomentBuilder {
139
+ debugCommand('Setting client.ui for moment %s', this.moment.name);
140
+ this.moment.client.ui = spec;
141
+ return this;
142
+ }
143
+
135
144
  via(integration: Integration | Integration[]): FluentCommandMomentBuilder {
136
145
  const integrations = Array.isArray(integration) ? integration : [integration];
137
146
  this.moment.via = integrations.map((i) => i.name);
@@ -232,6 +241,12 @@ class QueryMomentBuilderImpl implements FluentQueryMomentBuilder {
232
241
  return this;
233
242
  }
234
243
 
244
+ ui(spec: UiBlock): FluentQueryMomentBuilder {
245
+ debugQuery('Setting client.ui for moment %s', this.moment.name);
246
+ this.moment.client.ui = spec;
247
+ return this;
248
+ }
249
+
235
250
  request(query: unknown): FluentQueryMomentBuilder {
236
251
  debugQuery('Setting request for moment %s', this.moment.name);
237
252
  if (typeof query === 'string') {
@@ -346,6 +361,12 @@ class ExperienceMomentBuilderImpl implements FluentExperienceMomentBuilder {
346
361
 
347
362
  return this;
348
363
  }
364
+
365
+ ui(spec: UiBlock): FluentExperienceMomentBuilder {
366
+ debugExperience('Setting client.ui for moment %s', this.moment.name);
367
+ this.moment.client.ui = spec;
368
+ return this;
369
+ }
349
370
  }
350
371
 
351
372
  export const command = (name: string, id?: string): FluentCommandMomentBuilder => {
@@ -3569,4 +3569,42 @@ scene('All Projection Types', 'ALL-PROJ', () => {
3569
3569
  expect(code).toContain("type FitnessGoalCreated = Event<'FitnessGoalCreated'");
3570
3570
  expect(code).toContain("type FitnessGoalsView = State<'FitnessGoalsView'");
3571
3571
  });
3572
+
3573
+ it('emits .ui() when moment has client.ui', async () => {
3574
+ const model: Model = {
3575
+ variant: 'specs',
3576
+ scenes: [
3577
+ {
3578
+ name: 'Checkout',
3579
+ moments: [
3580
+ {
3581
+ type: 'command',
3582
+ name: 'Submit Order',
3583
+ client: {
3584
+ specs: [],
3585
+ ui: {
3586
+ layoutId: 'centered-narrow',
3587
+ surface: 'route',
3588
+ spec: { root: 'layout-root', elements: {}, state: {} },
3589
+ },
3590
+ },
3591
+ server: { description: '', specs: [] },
3592
+ },
3593
+ ],
3594
+ },
3595
+ ],
3596
+ messages: [],
3597
+ integrations: [],
3598
+ modules: [],
3599
+ narratives: [],
3600
+ };
3601
+
3602
+ const result = await modelToNarrative(model);
3603
+ const code = getCode(result);
3604
+
3605
+ expect(code).toContain('.ui(');
3606
+ expect(code).toContain("layoutId: 'centered-narrow'");
3607
+ expect(code).toContain("surface: 'route'");
3608
+ expect(code).toContain("root: 'layout-root'");
3609
+ });
3572
3610
  });
@@ -57,6 +57,39 @@ describe('CommandMomentSchema', () => {
57
57
  });
58
58
  });
59
59
 
60
+ describe('CommandMomentSchema client.ui', () => {
61
+ it('accepts ui block in client alongside specs', () => {
62
+ const moment = {
63
+ type: 'command' as const,
64
+ name: 'Submit Order',
65
+ client: {
66
+ specs: [],
67
+ ui: {
68
+ layoutId: 'centered-narrow',
69
+ mode: 'as-is',
70
+ regions: { main: [{ id: 'r1', name: 'hero' }] },
71
+ spec: { root: 'layout-root', elements: {}, state: {} },
72
+ surface: 'route',
73
+ },
74
+ },
75
+ server: { description: 'Submits order', specs: [] },
76
+ };
77
+
78
+ const result = CommandMomentSchema.safeParse(moment);
79
+
80
+ expect(result.success).toBe(true);
81
+ if (result.success) {
82
+ expect(result.data.client.ui).toEqual({
83
+ layoutId: 'centered-narrow',
84
+ mode: 'as-is',
85
+ regions: { main: [{ id: 'r1', name: 'hero' }] },
86
+ spec: { root: 'layout-root', elements: {}, state: {} },
87
+ surface: 'route',
88
+ });
89
+ }
90
+ });
91
+ });
92
+
60
93
  describe('SceneRouteSchema', () => {
61
94
  it('should accept valid route types', () => {
62
95
  for (const type of ['dedicated', 'nested', 'no-route'] as const) {
@@ -645,21 +678,18 @@ describe('ComponentDefinitionSchema', () => {
645
678
  });
646
679
  });
647
680
 
648
- describe('DesignSchema ui field', () => {
649
- it('should accept design with ui containing a spec', () => {
681
+ describe('DesignSchema', () => {
682
+ it('does not include ui (ui lives in client.ui now)', () => {
650
683
  const design = {
651
- ui: {
652
- spec: {
653
- root: 'page',
654
- elements: { page: { type: 'Stack', children: [] } },
655
- state: { title: 'Dashboard' },
656
- },
657
- },
684
+ ui: { spec: { root: 'page', elements: {}, state: {} } },
685
+ imageAsset: { url: 'https://example.com/img.png' },
658
686
  };
659
- expect(DesignSchema.parse(design)).toEqual(design);
687
+ const parsed = DesignSchema.parse(design);
688
+ expect(parsed).toEqual({ imageAsset: { url: 'https://example.com/img.png' } });
689
+ expect('ui' in parsed).toBe(false);
660
690
  });
661
691
 
662
- it('should accept design without ui', () => {
692
+ it('accepts design without ui', () => {
663
693
  expect(DesignSchema.parse({})).toEqual({});
664
694
  });
665
695
  });
package/src/schema.ts CHANGED
@@ -262,7 +262,6 @@ export const DesignSchema = z
262
262
  .object({
263
263
  imageAsset: ImageAssetSchema.optional().describe('Primary image asset for this entity'),
264
264
  metadata: z.record(z.unknown()).optional().describe('Flexible design metadata'),
265
- ui: UISchema.optional().describe('UI composition for this entity'),
266
265
  })
267
266
  .describe('Design fields for visual representation');
268
267
 
@@ -351,10 +350,24 @@ export const ClientSpecNodeSchema: z.ZodType<ClientSpecNode> = z.lazy(() =>
351
350
 
352
351
  export const ClientSpecSchema = z.array(ClientSpecNodeSchema).default([]);
353
352
 
353
+ export const UiBlockSchema = z
354
+ .object({
355
+ layoutId: z.string().optional().describe('Layout template identifier'),
356
+ mode: z.string().optional().describe('Rendering mode'),
357
+ regions: z
358
+ .record(z.array(z.object({ id: z.string(), name: z.string(), slots: z.record(z.unknown()).optional() })))
359
+ .optional()
360
+ .describe('Layout regions with placed components'),
361
+ spec: z.record(z.unknown()).optional().describe('UI specification tree'),
362
+ surface: z.enum(['route', 'overlay', 'ephemeral']).optional().describe('Presentation context'),
363
+ })
364
+ .describe('UI composition block for a moment');
365
+
354
366
  const CommandMomentSchema = BaseMomentSchema.extend({
355
367
  type: z.literal('command'),
356
368
  client: z.object({
357
369
  specs: ClientSpecSchema,
370
+ ui: UiBlockSchema.optional().describe('UI composition for this moment'),
358
371
  }),
359
372
  request: z.string().describe('Command request (GraphQL, REST endpoint, or other query format)').optional(),
360
373
  mappings: z.array(MappingEntrySchema).optional().describe('Field mappings between Command/Event/State messages'),
@@ -369,6 +382,7 @@ const QueryMomentSchema = BaseMomentSchema.extend({
369
382
  type: z.literal('query'),
370
383
  client: z.object({
371
384
  specs: ClientSpecSchema,
385
+ ui: UiBlockSchema.optional().describe('UI composition for this moment'),
372
386
  }),
373
387
  request: z.string().describe('Query request (GraphQL, REST endpoint, or other query format)').optional(),
374
388
  mappings: z.array(MappingEntrySchema).optional().describe('Field mappings between Command/Event/State messages'),
@@ -392,6 +406,7 @@ const ExperienceMomentSchema = BaseMomentSchema.extend({
392
406
  type: z.literal('experience'),
393
407
  client: z.object({
394
408
  specs: ClientSpecSchema,
409
+ ui: UiBlockSchema.optional().describe('UI composition for this moment'),
395
410
  }),
396
411
  }).describe('Experience moment for user interactions and UI behavior');
397
412
 
@@ -628,3 +643,4 @@ export type UIElement = z.infer<typeof UIElementSchema>;
628
643
  export type UISpec = z.infer<typeof UISpecSchema>;
629
644
  export type UI = z.infer<typeof UISchema>;
630
645
  export type ComponentDefinition = z.infer<typeof ComponentDefinitionSchema>;
646
+ export type UiBlock = z.infer<typeof UiBlockSchema>;
@@ -424,6 +424,20 @@ function addClientToChain(
424
424
  return chain;
425
425
  }
426
426
 
427
+ function addUiToChain(
428
+ ts: typeof import('typescript'),
429
+ f: tsNS.NodeFactory,
430
+ chain: tsNS.Expression,
431
+ slice: Moment,
432
+ ): tsNS.Expression {
433
+ if ('client' in slice && slice.client?.ui !== undefined) {
434
+ return f.createCallExpression(f.createPropertyAccessExpression(chain, f.createIdentifier('ui')), undefined, [
435
+ jsonToExpr(ts, f, slice.client.ui),
436
+ ]);
437
+ }
438
+ return chain;
439
+ }
440
+
427
441
  function addRequestToChain(
428
442
  f: tsNS.NodeFactory,
429
443
  chain: tsNS.Expression,
@@ -767,6 +781,7 @@ function buildMoment(
767
781
  let chain: tsNS.Expression = f.createCallExpression(f.createIdentifier(sliceCtor), undefined, args);
768
782
 
769
783
  chain = addClientToChain(ts, f, chain, slice);
784
+ chain = addUiToChain(ts, f, chain, slice);
770
785
  chain = addRequestToChain(f, chain, slice);
771
786
  chain = addServerToChain(ts, f, chain, slice, messages);
772
787