@auto-engineer/narrative 1.150.0 → 1.152.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/id": "1.152.0",
30
+ "@auto-engineer/message-bus": "1.152.0",
31
+ "@auto-engineer/file-store": "1.152.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.152.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) {
package/src/schema.ts CHANGED
@@ -351,10 +351,24 @@ export const ClientSpecNodeSchema: z.ZodType<ClientSpecNode> = z.lazy(() =>
351
351
 
352
352
  export const ClientSpecSchema = z.array(ClientSpecNodeSchema).default([]);
353
353
 
354
+ export const UiBlockSchema = z
355
+ .object({
356
+ layoutId: z.string().optional().describe('Layout template identifier'),
357
+ mode: z.string().optional().describe('Rendering mode'),
358
+ regions: z
359
+ .record(z.array(z.object({ id: z.string(), name: z.string(), slots: z.record(z.unknown()).optional() })))
360
+ .optional()
361
+ .describe('Layout regions with placed components'),
362
+ spec: z.record(z.unknown()).optional().describe('UI specification tree'),
363
+ surface: z.enum(['route', 'overlay', 'ephemeral']).optional().describe('Presentation context'),
364
+ })
365
+ .describe('UI composition block for a moment');
366
+
354
367
  const CommandMomentSchema = BaseMomentSchema.extend({
355
368
  type: z.literal('command'),
356
369
  client: z.object({
357
370
  specs: ClientSpecSchema,
371
+ ui: UiBlockSchema.optional().describe('UI composition for this moment'),
358
372
  }),
359
373
  request: z.string().describe('Command request (GraphQL, REST endpoint, or other query format)').optional(),
360
374
  mappings: z.array(MappingEntrySchema).optional().describe('Field mappings between Command/Event/State messages'),
@@ -369,6 +383,7 @@ const QueryMomentSchema = BaseMomentSchema.extend({
369
383
  type: z.literal('query'),
370
384
  client: z.object({
371
385
  specs: ClientSpecSchema,
386
+ ui: UiBlockSchema.optional().describe('UI composition for this moment'),
372
387
  }),
373
388
  request: z.string().describe('Query request (GraphQL, REST endpoint, or other query format)').optional(),
374
389
  mappings: z.array(MappingEntrySchema).optional().describe('Field mappings between Command/Event/State messages'),
@@ -392,6 +407,7 @@ const ExperienceMomentSchema = BaseMomentSchema.extend({
392
407
  type: z.literal('experience'),
393
408
  client: z.object({
394
409
  specs: ClientSpecSchema,
410
+ ui: UiBlockSchema.optional().describe('UI composition for this moment'),
395
411
  }),
396
412
  }).describe('Experience moment for user interactions and UI behavior');
397
413
 
@@ -628,3 +644,4 @@ export type UIElement = z.infer<typeof UIElementSchema>;
628
644
  export type UISpec = z.infer<typeof UISpecSchema>;
629
645
  export type UI = z.infer<typeof UISchema>;
630
646
  export type ComponentDefinition = z.infer<typeof ComponentDefinitionSchema>;
647
+ 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