@softize/opus 29.0.0 → 31.0.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +137 -0
  2. package/bin/lib/check.mjs +178 -94
  3. package/bin/lib/copy.mjs +2970 -1801
  4. package/docs/adr/0043-neutral-surfaces-and-interactions-use-opaque-role-tokens.md +52 -0
  5. package/docs/adr/0044-neutral-appearance-derives-from-bounded-axes-per-mode.md +27 -2
  6. package/docs/adr/0045-surface-replaces-content-and-card.md +3 -0
  7. package/docs/adr/0060-surface-composition-separates-chrome-and-intro.md +142 -0
  8. package/docs/adr/0062-listas-contidas-e-texto-flexivel.md +68 -0
  9. package/docs/adr/0063-metadados-das-superficies.md +44 -0
  10. package/docs/adr/0064-action-list-com-itens-e-fontes-externas.md +47 -0
  11. package/docs/adr/0065-presentation-composes-settings-sections.md +295 -0
  12. package/docs/adr/README.md +8 -0
  13. package/package.json +1 -1
  14. package/registry/skills/build-opus-ui/SKILL.md +21 -19
  15. package/registry/skills/build-opus-ui/references/evaluations.md +21 -10
  16. package/registry/skills/build-opus-ui/references/ui-patterns.md +33 -28
  17. package/src/core/presentation.ts +938 -32
  18. package/src/core/types.ts +2 -1
  19. package/src/ui/components/patterns/form.tsx +2 -0
  20. package/src/ui/components/patterns/grouped-list.ts +2 -2
  21. package/src/ui/components/patterns/list.tsx +588 -365
  22. package/src/ui/components/patterns/page-heading-context.tsx +12 -5
  23. package/src/ui/components/patterns/page.tsx +238 -181
  24. package/src/ui/components/patterns/presentation-fields.tsx +246 -0
  25. package/src/ui/components/patterns/presentation-sections.tsx +494 -0
  26. package/src/ui/components/patterns/presentation.tsx +1142 -229
  27. package/src/ui/components/patterns/structural-header.tsx +47 -34
  28. package/src/ui/components/patterns/surface-anatomy.ts +27 -217
  29. package/src/ui/components/patterns/surface.tsx +55 -37
  30. package/src/ui/components/patterns/trigger.tsx +10 -2
  31. package/src/ui/components/primitives/close-button.tsx +11 -3
  32. package/src/ui/components/primitives/command.tsx +2 -8
  33. package/src/ui/components/primitives/detail.tsx +1 -1
  34. package/src/ui/components/primitives/dialog.tsx +59 -89
  35. package/src/ui/components/primitives/drawer.tsx +52 -87
  36. package/src/ui/components/primitives/field.tsx +1 -1
  37. package/src/ui/components/primitives/pagination.tsx +3 -2
  38. package/src/ui/components/primitives/table.tsx +3 -3
  39. package/src/ui/components/primitives/tabs.tsx +13 -2
  40. package/src/ui/docs/content/action-form.md +2 -1
  41. package/src/ui/docs/content/action-list.md +146 -73
  42. package/src/ui/docs/content/action-trigger.md +5 -0
  43. package/src/ui/docs/content/button.md +5 -6
  44. package/src/ui/docs/content/command.md +62 -50
  45. package/src/ui/docs/content/compositions.md +129 -459
  46. package/src/ui/docs/content/dialog.md +72 -101
  47. package/src/ui/docs/content/drawer.md +69 -92
  48. package/src/ui/docs/content/field.md +2 -1
  49. package/src/ui/docs/content/page.md +207 -386
  50. package/src/ui/docs/content/presentation.md +371 -23
  51. package/src/ui/docs/content/separator.md +1 -1
  52. package/src/ui/docs/content/surface.md +94 -116
  53. package/src/ui/docs/content/table.md +7 -0
  54. package/src/ui/docs/content/tokens.md +18 -4
  55. package/src/ui/drivers/context.tsx +11 -4
  56. package/src/ui/drivers/react.tsx +27 -7
  57. package/src/ui/meta.ts +5 -5
  58. package/src/ui/react.tsx +38 -8
  59. package/src/ui/theme.css +44 -33
@@ -29,7 +29,12 @@ export const presentationSurfaceSchema = z.enum(["page", "dialog", "drawer"]);
29
29
  export type PresentationSurface = z.infer<typeof presentationSurfaceSchema>;
30
30
 
31
31
  export const presentationBindingSchema = z.discriminatedUnion("source", [
32
- z.object({ source: z.literal("route"), param: identifierSchema }).strict(),
32
+ z
33
+ .object({
34
+ source: z.literal("route"),
35
+ param: z.string().regex(/^[a-z][a-zA-Z0-9]*$/),
36
+ })
37
+ .strict(),
33
38
  z.object({ source: z.literal("record"), field: z.string().min(1) }).strict(),
34
39
  z.object({ source: z.literal("item"), field: z.string().min(1) }).strict(),
35
40
  z.object({ source: z.literal("selection") }).strict(),
@@ -71,10 +76,139 @@ const presentationTargetSchema = z
71
76
  })
72
77
  .strict();
73
78
 
79
+ const presentationConditionSchema = z.discriminatedUnion("op", [
80
+ z
81
+ .object({
82
+ field: z.string().min(1),
83
+ op: z.enum(["eq", "neq"]),
84
+ value: z.union([z.string(), z.number(), z.boolean(), z.null()]),
85
+ })
86
+ .strict(),
87
+ z
88
+ .object({
89
+ field: z.string().min(1),
90
+ op: z.enum(["in", "notIn"]),
91
+ values: z
92
+ .array(z.union([z.string(), z.number(), z.boolean(), z.null()]))
93
+ .min(1),
94
+ })
95
+ .strict(),
96
+ ]);
97
+ export type PresentationCondition = z.infer<typeof presentationConditionSchema>;
98
+ export function matchesPresentationConditions(
99
+ conditions: readonly PresentationCondition[] | undefined,
100
+ item: Record<string, unknown>,
101
+ ): boolean {
102
+ return (conditions ?? []).every((condition) => {
103
+ const value = item[condition.field];
104
+ if (value === undefined) return false;
105
+ if (condition.op === "eq") return value === condition.value;
106
+ if (condition.op === "neq") return value !== condition.value;
107
+ const matches = ("values" in condition ? condition.values : []).some(
108
+ (candidate) => candidate === value,
109
+ );
110
+ return condition.op === "in" ? matches : !matches;
111
+ });
112
+ }
113
+ const presentationRequirementSchema = z.union([
114
+ z.string().min(1),
115
+ z.array(z.string().min(1)).min(1),
116
+ ]);
117
+
118
+ /** Permissões alternativas seguem a semântica de requires dos contratos (qualquer uma). */
119
+ export function meetsPresentationRequirement(
120
+ requires: string | readonly string[] | undefined,
121
+ can?: (permission: string) => boolean,
122
+ ): boolean {
123
+ if (requires === undefined) return true;
124
+ return (typeof requires === "string" ? [requires] : requires).some(
125
+ (permission) => can?.(permission) === true,
126
+ );
127
+ }
128
+
129
+ /** Visibilidade não substitui authorize: requisitos da página e da action são cumulativos. */
130
+ export function canAccessPresentation(
131
+ definition: PresentationDefinition,
132
+ actions: PresentationActionRegistry,
133
+ can?: (permission: string) => boolean,
134
+ ): boolean {
135
+ if (!meetsPresentationRequirement(definition.requires, can)) return false;
136
+ if (definition.source !== undefined) {
137
+ const source = actions[definition.source.action];
138
+ if (!source || !meetsPresentationRequirement(source.requires, can))
139
+ return false;
140
+ }
141
+ if (
142
+ isPresentationComponentBody(definition.body) &&
143
+ definition.body.contract
144
+ ) {
145
+ const contract = actions[definition.body.contract];
146
+ return (
147
+ contract !== undefined &&
148
+ meetsPresentationRequirement(contract.requires, can)
149
+ );
150
+ }
151
+ if (!isPresentationActionBody(definition.body)) return true;
152
+ const action = actions[definition.body.action];
153
+ return (
154
+ action !== undefined && meetsPresentationRequirement(action.requires, can)
155
+ );
156
+ }
157
+
158
+ const presentationOpenSchema = z
159
+ .object({
160
+ presentation: identifierSchema,
161
+ surface: presentationSurfaceSchema.optional(),
162
+ input: z.record(presentationBindingSchema).default({}),
163
+ requires: presentationRequirementSchema.optional(),
164
+ when: z.array(presentationConditionSchema).optional(),
165
+ })
166
+ .strict();
167
+ const presentationColumnSchema = z.discriminatedUnion("kind", [
168
+ z
169
+ .object({
170
+ kind: z.literal("text"),
171
+ prefix: z.string().optional(),
172
+ empty: z.string().optional(),
173
+ icon: identifierSchema.optional(),
174
+ labels: z.record(z.string()).optional(),
175
+ fallback: z.string().optional(),
176
+ })
177
+ .strict(),
178
+ z
179
+ .object({
180
+ kind: z.literal("switch"),
181
+ action: z.string().min(1),
182
+ input: z.record(presentationBindingSchema),
183
+ valueField: z.string().min(1),
184
+ label: z.string().min(1),
185
+ itemLabel: z.string().min(1),
186
+ requires: presentationRequirementSchema.optional(),
187
+ })
188
+ .strict(),
189
+ ]);
190
+ export type PresentationColumn = z.infer<typeof presentationColumnSchema>;
191
+
74
192
  const presentationCommandSchema = z
75
193
  .object({
76
194
  action: z.string().min(1),
77
- placement: z.enum(["header", "footer"]),
195
+ id: identifierSchema.optional(),
196
+ accessibleLabel: z.string().min(1).optional(),
197
+ successMessage: z.string().min(1).optional(),
198
+ requires: presentationRequirementSchema.optional(),
199
+ when: z.array(presentationConditionSchema).optional(),
200
+ confirm: z
201
+ .object({
202
+ title: z.string().min(1),
203
+ body: z.string().min(1).optional(),
204
+ actionLabel: z.string().min(1).optional(),
205
+ })
206
+ .strict()
207
+ .optional(),
208
+ placement: z.enum(["header", "toolbar", "footer", "row", "section"]),
209
+ icon: identifierSchema.optional(),
210
+ itemLabel: z.string().min(1).optional(),
211
+ label: z.string().min(1).optional(),
78
212
  input: z.record(presentationBindingSchema).default({}),
79
213
  target: presentationTargetSchema.optional(),
80
214
  blocking: z.enum(["action", "group", "surface"]).default("action"),
@@ -87,6 +221,7 @@ const presentationFieldSchema = z
87
221
  key: z.string().min(1),
88
222
  label: z.string().min(1),
89
223
  empty: z.string().min(1).optional(),
224
+ format: z.enum(["text", "pre", "html"]).optional(),
90
225
  })
91
226
  .strict();
92
227
 
@@ -97,14 +232,60 @@ const presentationActionBodySchema = z
97
232
  onSuccess: z.array(presentationEffectSchema).default([]),
98
233
  submitLabel: z.string().min(1).optional(),
99
234
  fields: z.array(presentationFieldSchema).min(1).optional(),
100
- open: z
235
+ cells: identifierSchema.optional(),
236
+ rowKey: z.string().min(1).optional(),
237
+ beforeContent: identifierSchema.optional(),
238
+ rowContent: identifierSchema.optional(),
239
+ collection: identifierSchema.optional(),
240
+ toolbarContent: identifierSchema.optional(),
241
+ filterDefaults: z.record(z.string()).optional(),
242
+ columns: z.record(presentationColumnSchema).optional(),
243
+ intro: z.string().min(1).optional(),
244
+ defaultsFromRecord: z.boolean().optional(),
245
+ initialValues: z.record(presentationJsonValueSchema).optional(),
246
+ formFields: z.array(z.string().min(1)).min(1).optional(),
247
+ formGroups: z
248
+ .array(
249
+ z
250
+ .object({
251
+ title: z.string().min(1),
252
+ fields: z.array(z.string().min(1)).min(1),
253
+ })
254
+ .strict(),
255
+ )
256
+ .min(1)
257
+ .optional(),
258
+ fieldComponents: z.record(identifierSchema).optional(),
259
+ lookups: z
260
+ .record(
261
+ z
262
+ .object({
263
+ action: z.string().min(1),
264
+ label: z.string().min(1),
265
+ value: z.string().min(1),
266
+ format: identifierSchema.optional(),
267
+ prepend: z
268
+ .array(
269
+ z
270
+ .object({ value: z.string(), label: z.string().min(1) })
271
+ .strict(),
272
+ )
273
+ .optional(),
274
+ })
275
+ .strict(),
276
+ )
277
+ .optional(),
278
+ inspect: z
101
279
  .object({
102
- presentation: identifierSchema,
103
- surface: presentationSurfaceSchema.optional(),
104
- input: z.record(presentationBindingSchema).default({}),
280
+ component: identifierSchema,
281
+ requires: presentationRequirementSchema.optional(),
282
+ when: z.array(presentationConditionSchema).optional(),
105
283
  })
106
284
  .strict()
107
285
  .optional(),
286
+ opens: z.array(presentationOpenSchema).min(1).optional(),
287
+ emptyMessage: z.string().min(1).optional(),
288
+ open: presentationOpenSchema.optional(),
108
289
  })
109
290
  .strict();
110
291
 
@@ -112,14 +293,203 @@ const presentationComponentBodySchema = z
112
293
  .object({
113
294
  /** Boundary estático do conteúdo React fornecido pelo consumidor. */
114
295
  component: identifierSchema,
296
+ contract: z.string().min(1).optional(),
297
+ footer: z.boolean().optional(),
298
+ input: z.record(presentationBindingSchema).optional(),
115
299
  /** Define se o heading principal nasce na casca ou no conteúdo React. */
116
300
  heading: z.enum(["shell", "content"]).default("shell"),
117
301
  })
118
302
  .strict();
119
303
 
304
+ const presentationSectionBase = {
305
+ id: identifierSchema,
306
+ title: z.string().min(1).optional(),
307
+ feedback: identifierSchema.optional(),
308
+ notices: z
309
+ .array(
310
+ z
311
+ .object({
312
+ text: z.string().min(1),
313
+ when: z.array(presentationConditionSchema).optional(),
314
+ })
315
+ .strict(),
316
+ )
317
+ .optional(),
318
+ };
319
+
320
+ export const presentationSectionSchema = z.discriminatedUnion("kind", [
321
+ z
322
+ .object({
323
+ ...presentationSectionBase,
324
+ kind: z.literal("content"),
325
+ layout: z.enum(["flow", "fill"]).optional(),
326
+ content: identifierSchema,
327
+ actions: identifierSchema.optional(),
328
+ })
329
+ .strict(),
330
+ z
331
+ .object({
332
+ ...presentationSectionBase,
333
+ kind: z.literal("items"),
334
+ intro: identifierSchema.optional(),
335
+ actions: identifierSchema.optional(),
336
+ items: z
337
+ .array(
338
+ z
339
+ .object({
340
+ id: identifierSchema,
341
+ title: z.string().min(1),
342
+ description: z.string().min(1).optional(),
343
+ content: identifierSchema.optional(),
344
+ value: z
345
+ .object({
346
+ field: z.string().min(1),
347
+ empty: z.string().optional(),
348
+ suffix: z.string().optional(),
349
+ })
350
+ .strict()
351
+ .optional(),
352
+ labelId: z.string().min(1).optional(),
353
+ placement: z.enum(["body", "actions", "below"]).optional(),
354
+ })
355
+ .strict(),
356
+ )
357
+ .min(1),
358
+ })
359
+ .strict(),
360
+ z
361
+ .object({
362
+ ...presentationSectionBase,
363
+ kind: z.literal("list"),
364
+ source: identifierSchema,
365
+ item: z
366
+ .object({
367
+ title: z.string().min(1),
368
+ description: z.string().min(1).optional(),
369
+ })
370
+ .strict()
371
+ .optional(),
372
+ emptyMessage: z.string().min(1).optional(),
373
+ errorMessage: z.string().min(1).optional(),
374
+ })
375
+ .strict(),
376
+ ]);
377
+ export type PresentationSection = z.infer<typeof presentationSectionSchema>;
378
+ export type PresentationListSection = Extract<
379
+ PresentationSection,
380
+ { kind: "list" }
381
+ >;
382
+
383
+ const presentationSectionsBodySchema = z
384
+ .object({
385
+ sections: z.array(presentationSectionSchema).min(1),
386
+ aside: z
387
+ .object({
388
+ content: identifierSchema,
389
+ label: z.string().min(1),
390
+ width: z.enum(["narrow", "default", "wide"]).optional(),
391
+ })
392
+ .strict()
393
+ .optional(),
394
+ overlays: z.array(identifierSchema).optional(),
395
+ })
396
+ .strict()
397
+ .superRefine((body, context) => {
398
+ const seen = new Set<string>();
399
+ body.sections.forEach((section, index) => {
400
+ if (seen.has(section.id))
401
+ context.addIssue({
402
+ code: "custom",
403
+ message: "Id de seção duplicado.",
404
+ path: ["sections", index, "id"],
405
+ });
406
+ seen.add(section.id);
407
+ if (section.kind === "items") {
408
+ const items = new Set<string>();
409
+ section.items.forEach((item, itemIndex) => {
410
+ if (items.has(item.id))
411
+ context.addIssue({
412
+ code: "custom",
413
+ message: "Id de item duplicado.",
414
+ path: ["sections", index, "items", itemIndex, "id"],
415
+ });
416
+ items.add(item.id);
417
+ });
418
+ }
419
+ });
420
+ });
421
+ export type PresentationSectionsBody = z.infer<
422
+ typeof presentationSectionsBodySchema
423
+ >;
424
+
425
+ const presentationTabsBodySchema = z
426
+ .object({
427
+ tabLabel: z.string().min(1),
428
+ framed: z.boolean().optional(),
429
+ distribution: z.enum(["content", "equal"]).optional(),
430
+ defaultTab: identifierSchema,
431
+ tabs: z
432
+ .array(
433
+ z
434
+ .object({
435
+ id: identifierSchema,
436
+ label: z.string().min(1),
437
+ icon: identifierSchema.optional(),
438
+ content: identifierSchema.optional(),
439
+ presentation: identifierSchema.optional(),
440
+ route: z
441
+ .string()
442
+ .regex(
443
+ /^\/(?:[a-z0-9-]+|:[a-z][a-zA-Z0-9]*)(?:\/(?:[a-z0-9-]+|:[a-z][a-zA-Z0-9]*))*$/,
444
+ )
445
+ .optional(),
446
+ })
447
+ .strict(),
448
+ )
449
+ .min(1),
450
+ })
451
+ .strict()
452
+ .superRefine((body, context) => {
453
+ const seen = new Set<string>();
454
+ const routes = new Set<string>();
455
+ body.tabs.forEach((tab, index) => {
456
+ if ((tab.content === undefined) === (tab.presentation === undefined))
457
+ context.addIssue({
458
+ code: "custom",
459
+ message: "A aba exige content ou presentation, exclusivamente.",
460
+ path: ["tabs", index],
461
+ });
462
+ if (seen.has(tab.id))
463
+ context.addIssue({
464
+ code: "custom",
465
+ message: "Id de aba duplicado.",
466
+ path: ["tabs", index, "id"],
467
+ });
468
+ seen.add(tab.id);
469
+ if (tab.route !== undefined) {
470
+ if (routes.has(tab.route))
471
+ context.addIssue({
472
+ code: "custom",
473
+ message: "Rota de aba duplicada.",
474
+ path: ["tabs", index, "route"],
475
+ });
476
+ routes.add(tab.route);
477
+ }
478
+ });
479
+ if (!seen.has(body.defaultTab))
480
+ context.addIssue({
481
+ code: "custom",
482
+ message: "A aba inicial precisa existir na composição.",
483
+ path: ["defaultTab"],
484
+ });
485
+ });
486
+ export type PresentationTabsBody = z.infer<typeof presentationTabsBodySchema>;
487
+
120
488
  const presentationBodySchema = z.union([
121
489
  presentationActionBodySchema,
122
490
  presentationComponentBodySchema,
491
+ presentationSectionsBodySchema,
492
+ presentationTabsBodySchema,
123
493
  ]);
124
494
 
125
495
  const presentationAssistantSchema = z
@@ -132,31 +502,115 @@ const presentationAssistantSchema = z
132
502
  const presentationRouteRecordSchema = z
133
503
  .object({
134
504
  presentation: identifierSchema,
135
- param: identifierSchema,
505
+ fallbackFields: z.array(z.string().min(1)).min(1).optional(),
506
+ param: z.string().regex(/^[a-z][a-zA-Z0-9]*$/),
136
507
  field: z.string().min(1),
137
508
  })
138
509
  .strict();
139
510
 
511
+ const presentationRouteQuerySchema = z
512
+ .object({
513
+ param: z.string().regex(/^[a-z][a-zA-Z0-9]*$/),
514
+ input: z.string().regex(/^[a-z][a-zA-Z0-9]*$/),
515
+ })
516
+ .strict();
140
517
  export const presentationRouteSchema = z
141
518
  .object({
519
+ parent: identifierSchema.optional(),
520
+ params: z.record(z.array(z.string().min(1)).min(1)).optional(),
521
+ query: z
522
+ .union([
523
+ presentationRouteQuerySchema,
524
+ z.array(presentationRouteQuerySchema).min(1),
525
+ ])
526
+ .optional(),
142
527
  path: z
143
528
  .string()
144
529
  .regex(
145
- /^\/(?:[a-z0-9-]+|:[a-z][a-z0-9]*)(?:\/(?:[a-z0-9-]+|:[a-z][a-z0-9]*))*$/,
530
+ /^\/(?:[a-z0-9-]+|:[a-z][a-zA-Z0-9]*)(?:\/(?:[a-z0-9-]+|:[a-z][a-zA-Z0-9]*))*$/,
146
531
  ),
147
532
  surface: presentationSurfaceSchema,
148
533
  record: presentationRouteRecordSchema.optional(),
534
+ alternatives: z
535
+ .array(
536
+ z
537
+ .object({
538
+ when: z.array(presentationConditionSchema).min(1),
539
+ presentation: identifierSchema,
540
+ })
541
+ .strict(),
542
+ )
543
+ .optional(),
149
544
  })
150
545
  .strict();
151
546
  export type PresentationRoute = z.infer<typeof presentationRouteSchema>;
152
547
 
548
+ /** Parâmetros necessários para selecionar esta rota; filtros livres não participam. */
549
+ export function presentationRouteQueries(
550
+ route: PresentationRoute | undefined,
551
+ ): Array<z.infer<typeof presentationRouteQuerySchema>> {
552
+ return route?.query
553
+ ? Array.isArray(route.query)
554
+ ? route.query
555
+ : [route.query]
556
+ : [];
557
+ }
558
+
153
559
  export const presentationSchema = z
154
560
  .object({
155
561
  schemaVersion: z.literal(1),
156
562
  id: identifierSchema,
157
563
  title: z.string().min(1),
564
+ requires: presentationRequirementSchema.optional(),
565
+ width: z.enum(["default", "wide"]).optional(),
158
566
  route: presentationRouteSchema.optional(),
159
567
  body: presentationBodySchema,
568
+ source: z
569
+ .object({
570
+ action: z.string().min(1),
571
+ retainOnError: z.boolean().optional(),
572
+ errorMessage: z.string().min(1).optional(),
573
+ input: z.record(presentationBindingSchema).default({}),
574
+ record: z
575
+ .object({ field: z.string().min(1), param: z.string().min(1) })
576
+ .strict()
577
+ .optional(),
578
+ })
579
+ .strict()
580
+ .optional(),
581
+ externalSource: identifierSchema.optional(),
582
+ links: z
583
+ .array(
584
+ z
585
+ .object({
586
+ id: identifierSchema,
587
+ label: z.string().min(1),
588
+ presentation: identifierSchema,
589
+ icon: identifierSchema.optional(),
590
+ showLabel: z.boolean().optional(),
591
+ placement: z.enum(["header", "section"]),
592
+ input: z.record(presentationBindingSchema).default({}),
593
+ })
594
+ .strict(),
595
+ )
596
+ .default([]),
597
+ header: z
598
+ .object({
599
+ title: identifierSchema.optional(),
600
+ description: identifierSchema.optional(),
601
+ meta: identifierSchema.optional(),
602
+ actions: identifierSchema.optional(),
603
+ })
604
+ .strict()
605
+ .optional(),
606
+ page: z
607
+ .object({
608
+ width: z.enum(["narrow", "default", "wide"]).optional(),
609
+ layout: z.enum(["flow", "fill"]).optional(),
610
+ flush: z.boolean().optional(),
611
+ })
612
+ .strict()
613
+ .optional(),
160
614
  assistant: presentationAssistantSchema.optional(),
161
615
  actions: z.array(presentationCommandSchema).default([]),
162
616
  })
@@ -243,7 +697,11 @@ type DefinedPresentationComponentBody<Body> =
243
697
  type DefinedPresentationBody<Body extends PresentationDefinitionInput["body"]> =
244
698
  Body extends { component: string }
245
699
  ? DefinedPresentationComponentBody<Body>
246
- : DefinedPresentationActionBody<Body>;
700
+ : Body extends { sections: unknown }
701
+ ? PresentationSectionsBody
702
+ : Body extends { tabs: unknown }
703
+ ? PresentationTabsBody
704
+ : DefinedPresentationActionBody<Body>;
247
705
  type DefinedPresentationCommand<Command> = Command extends object
248
706
  ? Omit<Command, "input" | "blocking" | "onSuccess"> &
249
707
  Omit<PresentationCommand, "input" | "blocking" | "onSuccess"> & {
@@ -333,6 +791,18 @@ export function isPresentationComponentBody(
333
791
  return "component" in body;
334
792
  }
335
793
 
794
+ export function isPresentationSectionsBody(
795
+ body: PresentationDefinition["body"],
796
+ ): body is PresentationSectionsBody {
797
+ return "sections" in body;
798
+ }
799
+
800
+ export function isPresentationTabsBody(
801
+ body: PresentationDefinition["body"],
802
+ ): body is PresentationTabsBody {
803
+ return "tabs" in body;
804
+ }
805
+
336
806
  export interface PresentationRouteMatch {
337
807
  definition: PresentationDefinition;
338
808
  input: Record<string, string>;
@@ -359,8 +829,10 @@ function decodeRouteSegment(segment: string): string | null {
359
829
  export function matchPresentationRoute(
360
830
  definitions: readonly PresentationDefinition[],
361
831
  pathname: string,
832
+ search: string | URLSearchParams = "",
362
833
  ): PresentationRouteMatch | null {
363
834
  const actual = routeSegments(pathname);
835
+ const params = new URLSearchParams(search);
364
836
  const matches = definitions.flatMap((definition) => {
365
837
  if (definition.route === undefined) return [];
366
838
  const pattern = routeSegments(definition.route.path);
@@ -375,6 +847,17 @@ export function matchPresentationRoute(
375
847
  input[expected.slice(1)] = decoded;
376
848
  } else if (expected !== received) return [];
377
849
  }
850
+ if (
851
+ Object.entries(definition.route.params ?? {}).some(
852
+ ([key, values]) => !values.includes(input[key]!),
853
+ )
854
+ )
855
+ return [];
856
+ for (const query of presentationRouteQueries(definition.route)) {
857
+ const value = params.get(query.param);
858
+ if (!value) return [];
859
+ input[query.input] = value;
860
+ }
378
861
  return [{ definition, input }];
379
862
  });
380
863
  matches.sort((left, right) => {
@@ -382,7 +865,13 @@ export function matchPresentationRoute(
382
865
  routeSegments(entry.definition.route!.path).filter(
383
866
  (segment) => !segment.startsWith(":"),
384
867
  ).length;
385
- return staticCount(right) - staticCount(left);
868
+ return (
869
+ staticCount(right) - staticCount(left) ||
870
+ presentationRouteQueries(right.definition.route).length -
871
+ presentationRouteQueries(left.definition.route).length ||
872
+ Object.keys(right.definition.route?.params ?? {}).length -
873
+ Object.keys(left.definition.route?.params ?? {}).length
874
+ );
386
875
  });
387
876
  return matches[0] ?? null;
388
877
  }
@@ -399,15 +888,30 @@ export function pathForPresentationInvocation(
399
888
  `Presentation “${invocation.presentationId}” não declara route.`,
400
889
  );
401
890
  }
402
- return definition.route.path.replace(/:([a-z][a-z0-9]*)/g, (_, param) => {
403
- const value = invocation.input[param];
404
- if (typeof value !== "string" || value.length === 0) {
405
- throw new Error(
406
- `A rota de “${invocation.presentationId}” exige o parâmetro “${param}”.`,
407
- );
408
- }
409
- return encodeURIComponent(value);
410
- });
891
+ const path = definition.route.path.replace(
892
+ /:([a-z][a-zA-Z0-9]*)/g,
893
+ (_, param) => {
894
+ const value = invocation.input[param];
895
+ if (typeof value !== "string" || value.length === 0) {
896
+ throw new Error(
897
+ `A rota de “${invocation.presentationId}” exige o parâmetro “${param}”.`,
898
+ );
899
+ }
900
+ const allowed = definition.route!.params?.[param];
901
+ if (allowed && !allowed.includes(value)) {
902
+ throw new Error(`Valor inválido para o parâmetro “${param}” da rota.`);
903
+ }
904
+ return encodeURIComponent(value);
905
+ },
906
+ );
907
+ const search = new URLSearchParams();
908
+ for (const query of presentationRouteQueries(definition.route)) {
909
+ const value = invocation.input[query.input];
910
+ if (typeof value !== "string" || !value)
911
+ throw new Error(`A rota exige o parâmetro “${query.input}”.`);
912
+ search.set(query.param, value);
913
+ }
914
+ return `${path}${search.size ? `?${search}` : ""}`;
411
915
  }
412
916
 
413
917
  export interface PresentationBindingContext {
@@ -580,16 +1084,90 @@ export function validatePresentations(
580
1084
  const parsed = z.array(presentationSchema).parse(input);
581
1085
  const byId = new Map<string, PresentationDefinition>();
582
1086
  const problems: string[] = [];
583
- const routePatterns = new Set<string>();
1087
+ const routePatterns = new Map<string, PresentationDefinition[]>();
584
1088
 
585
1089
  for (const presentation of parsed) {
586
1090
  if (byId.has(presentation.id))
587
1091
  problems.push(`Presentation “${presentation.id}” duplicada.`);
588
1092
  byId.set(presentation.id, presentation);
1093
+ if (presentation.source && presentation.externalSource)
1094
+ problems.push("Declare apenas uma fonte de registro.");
1095
+ if (presentation.source) {
1096
+ const source = actions[presentation.source.action];
1097
+ if (source?.kind !== "view" && source?.kind !== "list")
1098
+ problems.push(
1099
+ `Fonte de registro exige action view ou list: ${presentation.source.action}.`,
1100
+ );
1101
+ else if ((source.kind === "list") !== Boolean(presentation.source.record))
1102
+ problems.push(
1103
+ "Fonte list exige seleção de registro; fonte view não aceita seleção.",
1104
+ );
1105
+ else {
1106
+ validateActionBindings(
1107
+ presentation.source.input,
1108
+ source,
1109
+ `${presentation.id}.source.input`,
1110
+ problems,
1111
+ );
1112
+ if (presentation.source.record) {
1113
+ const { field, param } = presentation.source.record;
1114
+ const output = unwrapObjectSchema(source.output)?.shape;
1115
+ if (!output || !(field in output))
1116
+ problems.push(`Campo de seleção inexistente: ${field}.`);
1117
+ if (
1118
+ presentation.route &&
1119
+ !presentation.route.path.includes(`:${param}`) &&
1120
+ !presentationRouteQueries(presentation.route).some(
1121
+ (query) => query.input === param,
1122
+ )
1123
+ )
1124
+ problems.push(
1125
+ `Parâmetro da fonte não declarado na rota: ${param}.`,
1126
+ );
1127
+ }
1128
+ }
1129
+ }
1130
+ if (isPresentationSectionsBody(presentation.body)) {
1131
+ const output = presentation.source
1132
+ ? unwrapObjectSchema(actions[presentation.source.action]?.output)?.shape
1133
+ : undefined;
1134
+ for (const section of presentation.body.sections) {
1135
+ if (section.kind !== "items") continue;
1136
+ for (const item of section.items) {
1137
+ if (item.content && item.value)
1138
+ problems.push(
1139
+ `Item ${item.id} não pode declarar content e value juntos.`,
1140
+ );
1141
+ if (
1142
+ item.value &&
1143
+ !presentation.externalSource &&
1144
+ (!output || !(item.value.field in output))
1145
+ )
1146
+ problems.push(
1147
+ `Campo de registro inexistente: ${item.value.field}.`,
1148
+ );
1149
+ }
1150
+ }
1151
+ }
589
1152
 
590
- if (isPresentationComponentBody(presentation.body)) {
591
- // O conteúdo interno permanece uma fronteira React opaca para a spec da casca.
592
- } else {
1153
+ if (
1154
+ isPresentationComponentBody(presentation.body) &&
1155
+ presentation.body.contract
1156
+ ) {
1157
+ const contract = actions[presentation.body.contract];
1158
+ if (!contract)
1159
+ problems.push(
1160
+ `Contrato de componente inexistente: ${presentation.body.contract}.`,
1161
+ );
1162
+ else
1163
+ validateActionBindings(
1164
+ presentation.body.input ?? {},
1165
+ contract,
1166
+ `${presentation.id}.body.input`,
1167
+ problems,
1168
+ );
1169
+ }
1170
+ if (isPresentationActionBody(presentation.body)) {
593
1171
  const bodyAction = actions[presentation.body.action];
594
1172
  if (bodyAction === undefined) {
595
1173
  problems.push(
@@ -614,6 +1192,13 @@ export function validatePresentations(
614
1192
  `A Presentation “${presentation.id}” precisa declarar fields para a view.`,
615
1193
  );
616
1194
  }
1195
+ if (
1196
+ bodyAction.kind !== "list" &&
1197
+ (presentation.body.cells !== undefined ||
1198
+ presentation.body.emptyMessage !== undefined)
1199
+ ) {
1200
+ problems.push(`Cells e emptyMessage exigem uma action list no body.`);
1201
+ }
617
1202
  if (
618
1203
  bodyAction.kind !== "view" &&
619
1204
  presentation.body.fields !== undefined
@@ -637,7 +1222,68 @@ export function validatePresentations(
637
1222
  }
638
1223
  }
639
1224
 
1225
+ for (const entry of [...presentation.actions, ...presentation.links]) {
1226
+ if (
1227
+ entry.placement === "section" &&
1228
+ !isPresentationSectionsBody(presentation.body)
1229
+ )
1230
+ problems.push(
1231
+ `O placement section de “${presentation.id}” exige sections no body.`,
1232
+ );
1233
+ }
1234
+ const sectionSlots = [
1235
+ ...presentation.actions
1236
+ .filter((command) => command.placement === "section")
1237
+ .map((command) => command.id ?? command.action),
1238
+ ...presentation.links
1239
+ .filter((link) => link.placement === "section")
1240
+ .map((link) => link.id),
1241
+ ];
1242
+ if (new Set(sectionSlots).size !== sectionSlots.length)
1243
+ problems.push(
1244
+ `Slots de ações e links de seção devem ser únicos em “${presentation.id}”.`,
1245
+ );
640
1246
  for (const command of presentation.actions) {
1247
+ if (command.target !== undefined && command.successMessage !== undefined)
1248
+ problems.push(
1249
+ `SuccessMessage só pode ser declarado em comando de execução direta.`,
1250
+ );
1251
+ if (
1252
+ (command.placement === "toolbar" || command.placement === "row") &&
1253
+ (!isPresentationActionBody(presentation.body) ||
1254
+ actions[presentation.body.action]?.kind !== "list")
1255
+ ) {
1256
+ problems.push(
1257
+ `O placement ${command.placement} de “${presentation.id}” exige uma action list no body.`,
1258
+ );
1259
+ }
1260
+ if (command.placement !== "row" && command.itemLabel !== undefined) {
1261
+ problems.push(`ItemLabel só está disponível para comandos de linha.`);
1262
+ }
1263
+ if (command.placement === "row") {
1264
+ if (command.blocking !== "action")
1265
+ problems.push(`Comandos de linha usam blocking action.`);
1266
+ const list = isPresentationActionBody(presentation.body)
1267
+ ? actions[presentation.body.action]
1268
+ : undefined;
1269
+ const output =
1270
+ list?.kind === "list" ? unwrapObjectSchema(list.output) : null;
1271
+ if (output !== null) {
1272
+ const fields = [
1273
+ command.itemLabel,
1274
+ ...(command.when ?? []).map((condition) => condition.field),
1275
+ ...Object.values(command.input).flatMap((binding) =>
1276
+ binding.source === "item" ? [binding.field] : [],
1277
+ ),
1278
+ ];
1279
+ for (const field of fields) {
1280
+ if (field !== undefined && !Object.hasOwn(output.shape, field))
1281
+ problems.push(
1282
+ `O campo “${field}” do comando de linha não existe na saída de “${list!.name}”.`,
1283
+ );
1284
+ }
1285
+ }
1286
+ }
641
1287
  const action = actions[command.action];
642
1288
  if (action === undefined) {
643
1289
  problems.push(`A action “${command.action}” não está disponível.`);
@@ -648,7 +1294,11 @@ export function validatePresentations(
648
1294
  `${presentation.id}.actions.${command.action}.input`,
649
1295
  problems,
650
1296
  );
651
- if (action.kind === "simple" && command.target !== undefined) {
1297
+ if (
1298
+ action.kind === "simple" &&
1299
+ action.effect !== "read" &&
1300
+ command.target !== undefined
1301
+ ) {
652
1302
  problems.push(
653
1303
  `A action simple “${command.action}” executa diretamente e não aceita target.`,
654
1304
  );
@@ -663,16 +1313,73 @@ export function validatePresentations(
663
1313
 
664
1314
  for (const presentation of parsed) {
665
1315
  if (presentation.route !== undefined) {
666
- const pattern = routePattern(presentation.route.path);
667
- if (routePatterns.has(pattern)) {
1316
+ for (const alternative of presentation.route.alternatives ?? []) {
1317
+ const target = byId.get(alternative.presentation);
1318
+ if (
1319
+ !presentation.route.record ||
1320
+ !target ||
1321
+ target.route?.surface !== presentation.route.surface ||
1322
+ target.route?.alternatives
1323
+ )
1324
+ problems.push(
1325
+ "Alternativa de rota exige registro e destino da mesma superfície, sem alternativas encadeadas.",
1326
+ );
1327
+ }
1328
+ const queries = presentationRouteQueries(presentation.route);
1329
+ const pathParams = routeSegments(presentation.route.path)
1330
+ .filter((part) => part.startsWith(":"))
1331
+ .map((part) => part.slice(1));
1332
+ if (
1333
+ new Set(queries.map((query) => query.param)).size !== queries.length ||
1334
+ new Set([...pathParams, ...queries.map((query) => query.input)])
1335
+ .size !==
1336
+ pathParams.length + queries.length
1337
+ ) {
668
1338
  problems.push(
669
- `A route “${presentation.route.path}” conflita com outra route declarada.`,
1339
+ "Os parâmetros e bindings de busca da rota precisam ser distintos.",
670
1340
  );
671
1341
  }
672
- routePatterns.add(pattern);
1342
+ const pattern = `${routePattern(presentation.route.path)}?${presentationRouteQueries(
1343
+ presentation.route,
1344
+ )
1345
+ .map((query) => query.param)
1346
+ .sort()
1347
+ .join("&")}`;
1348
+ const siblings = routePatterns.get(pattern) ?? [];
1349
+ for (const key of Object.keys(presentation.route.params ?? {}))
1350
+ if (!routeSegments(presentation.route.path).includes(`:${key}`))
1351
+ problems.push(`Restrição de parâmetro inexistente na rota: ${key}.`);
1352
+ for (const sibling of siblings) {
1353
+ const current = presentation.route.params ?? {};
1354
+ const previous = sibling.route!.params ?? {};
1355
+ const constrained = Object.keys(current).length > 0;
1356
+ const siblingConstrained = Object.keys(previous).length > 0;
1357
+ const disjoint = routeSegments(presentation.route.path).some(
1358
+ (segment, index) => {
1359
+ const values = current[segment.slice(1)];
1360
+ const siblingKey = routeSegments(sibling.route!.path)[index]!.slice(
1361
+ 1,
1362
+ );
1363
+ const other = previous[siblingKey];
1364
+ return (
1365
+ values && other && values.every((value) => !other.includes(value))
1366
+ );
1367
+ },
1368
+ );
1369
+ if (constrained === siblingConstrained && !disjoint)
1370
+ problems.push(
1371
+ `A route “${presentation.route.path}” conflita com outra route declarada.`,
1372
+ );
1373
+ }
1374
+ routePatterns.set(pattern, [...siblings, presentation]);
673
1375
  const record = presentation.route.record;
674
1376
  if (record !== undefined) {
675
- if (!presentation.route.path.includes(`:${record.param}`)) {
1377
+ if (
1378
+ !presentation.route.path.includes(`:${record.param}`) &&
1379
+ !presentationRouteQueries(presentation.route).some(
1380
+ (query) => query.input === record.param,
1381
+ )
1382
+ ) {
676
1383
  problems.push(
677
1384
  `A route de “${presentation.id}” não declara o parâmetro “${record.param}” usado por record.`,
678
1385
  );
@@ -703,6 +1410,180 @@ export function validatePresentations(
703
1410
  }
704
1411
  }
705
1412
  }
1413
+ if (
1414
+ presentation.route?.parent !== undefined &&
1415
+ !byId.has(presentation.route.parent)
1416
+ )
1417
+ problems.push(`Pai de rota inexistente: ${presentation.route.parent}.`);
1418
+ if (isPresentationTabsBody(presentation.body)) {
1419
+ for (const tab of presentation.body.tabs) {
1420
+ if (tab.presentation !== undefined) {
1421
+ const target = byId.get(tab.presentation);
1422
+ if (
1423
+ target === undefined ||
1424
+ (!isPresentationActionBody(target.body) &&
1425
+ !isPresentationSectionsBody(target.body))
1426
+ )
1427
+ problems.push(
1428
+ `A aba ${tab.id} precisa referenciar uma Presentation de action ou seções.`,
1429
+ );
1430
+ }
1431
+ }
1432
+ }
1433
+ if (isPresentationActionBody(presentation.body)) {
1434
+ const body = presentation.body;
1435
+ const owner = actions[body.action];
1436
+ const shape = unwrapObjectSchema(owner?.input)?.shape;
1437
+ if (
1438
+ body.rowKey &&
1439
+ !Object.hasOwn(
1440
+ unwrapObjectSchema(owner?.output)?.shape ?? {},
1441
+ body.rowKey,
1442
+ )
1443
+ )
1444
+ problems.push(`Chave de linha inexistente: ${body.rowKey}.`);
1445
+ if (
1446
+ (body.lookups ||
1447
+ body.formFields ||
1448
+ body.formGroups ||
1449
+ body.fieldComponents ||
1450
+ body.defaultsFromRecord ||
1451
+ body.initialValues) &&
1452
+ owner?.kind !== "form"
1453
+ )
1454
+ problems.push("Lookups e campos de formulário exigem form.");
1455
+ if (
1456
+ (body.rowKey ||
1457
+ body.columns ||
1458
+ body.opens ||
1459
+ body.inspect ||
1460
+ body.filterDefaults ||
1461
+ body.beforeContent ||
1462
+ body.rowContent ||
1463
+ body.collection ||
1464
+ body.toolbarContent) &&
1465
+ owner?.kind !== "list"
1466
+ )
1467
+ problems.push("Colunas e destinos de linha exigem list.");
1468
+ for (const field of [
1469
+ ...Object.keys(body.initialValues ?? {}),
1470
+ ...Object.keys(body.lookups ?? {}),
1471
+ ...(body.formFields ?? []),
1472
+ ...(body.formGroups?.flatMap((group) => group.fields) ?? []),
1473
+ ...Object.keys(body.fieldComponents ?? {}),
1474
+ ])
1475
+ if (!shape || !(field in shape))
1476
+ problems.push(`Campo de formulário inexistente: ${field}.`);
1477
+ for (const field of Object.keys(body.fieldComponents ?? {})) {
1478
+ if (body.lookups?.[field])
1479
+ problems.push(
1480
+ `Campo não pode declarar lookup e componente: ${field}.`,
1481
+ );
1482
+ if (body.formFields && !body.formFields.includes(field))
1483
+ problems.push(`Componente fora dos campos do formulário: ${field}.`);
1484
+ }
1485
+ for (const lookup of Object.values(body.lookups ?? {})) {
1486
+ const action = actions[lookup.action];
1487
+ const output = unwrapObjectSchema(action?.output)?.shape;
1488
+ if (
1489
+ action?.kind !== "list" ||
1490
+ !output ||
1491
+ !(lookup.label in output) ||
1492
+ !(lookup.value in output)
1493
+ )
1494
+ problems.push(`Lookup inválido: ${lookup.action}.`);
1495
+ if (action?.kind === "list" && action.paginate !== false)
1496
+ problems.push(
1497
+ `Lookup “${lookup.action}” exige paginate false para apresentar todas as opções.`,
1498
+ );
1499
+ }
1500
+ const output = unwrapObjectSchema(owner?.output)?.shape;
1501
+ if (body.open !== undefined && body.opens !== undefined)
1502
+ problems.push("Declare open ou opens, sem destinos concorrentes.");
1503
+ if (body.inspect && (body.open || body.opens))
1504
+ problems.push("Inspect não pode coexistir com open ou opens.");
1505
+ for (const condition of body.inspect?.when ?? [])
1506
+ if (!output || !(condition.field in output))
1507
+ problems.push(`Campo de inspeção inexistente: ${condition.field}.`);
1508
+ for (const opening of [
1509
+ ...(body.opens ?? []),
1510
+ ...(body.open ? [body.open] : []),
1511
+ ]) {
1512
+ const fields = [
1513
+ ...(opening.when ?? []).map((condition) => condition.field),
1514
+ ...Object.values(opening.input).flatMap((binding) =>
1515
+ binding.source === "item" ? [binding.field] : [],
1516
+ ),
1517
+ ];
1518
+ for (const field of fields)
1519
+ if (!output || !(field in output))
1520
+ problems.push(`Campo de destino de linha inexistente: ${field}.`);
1521
+ const target = byId.get(opening.presentation);
1522
+ if (
1523
+ opening.surface &&
1524
+ target?.route &&
1525
+ opening.surface !== target.route.surface
1526
+ )
1527
+ problems.push(
1528
+ `Superfície incompatível no destino de linha: ${opening.presentation}.`,
1529
+ );
1530
+ }
1531
+ for (const [field, column] of Object.entries(body.columns ?? {})) {
1532
+ if (!output || !(field in output))
1533
+ problems.push(`Coluna inexistente: ${field}.`);
1534
+ if (column.kind === "switch") {
1535
+ const action = actions[column.action];
1536
+ const input = unwrapObjectSchema(action?.input)?.shape;
1537
+ for (const source of [
1538
+ column.itemLabel,
1539
+ ...Object.values(column.input).flatMap((binding) =>
1540
+ binding.source === "item" ? [binding.field] : [],
1541
+ ),
1542
+ ])
1543
+ if (!output || !(source in output))
1544
+ problems.push(`Campo de switch inexistente: ${source}.`);
1545
+ if (output?.[field] && !output[field].safeParse(false).success)
1546
+ problems.push(`Coluna de switch precisa ser booleana: ${field}.`);
1547
+ if (
1548
+ !action ||
1549
+ !["form", "simple"].includes(action.kind) ||
1550
+ !input ||
1551
+ !(column.valueField in input) ||
1552
+ !input[column.valueField].safeParse(false).success
1553
+ )
1554
+ problems.push(`Action de switch inválida: ${column.action}.`);
1555
+ else
1556
+ validateActionBindings(
1557
+ {
1558
+ ...column.input,
1559
+ [column.valueField]: { source: "fixed", value: false },
1560
+ },
1561
+ action,
1562
+ `${presentation.id}.columns.${field}`,
1563
+ problems,
1564
+ );
1565
+ }
1566
+ }
1567
+ for (const open of body.opens ?? []) {
1568
+ const target = byId.get(open.presentation);
1569
+ if (
1570
+ !target ||
1571
+ (!isPresentationActionBody(target.body) &&
1572
+ !isPresentationComponentBody(target.body))
1573
+ )
1574
+ problems.push(`Destino de linha inexistente: ${open.presentation}.`);
1575
+ else if (
1576
+ isPresentationActionBody(target.body) &&
1577
+ actions[target.body.action]
1578
+ )
1579
+ validateActionBindings(
1580
+ open.input,
1581
+ actions[target.body.action]!,
1582
+ `${presentation.id}.opens`,
1583
+ problems,
1584
+ );
1585
+ }
1586
+ }
706
1587
  const open = isPresentationActionBody(presentation.body)
707
1588
  ? presentation.body.open
708
1589
  : undefined;
@@ -805,11 +1686,21 @@ export function validatePresentations(
805
1686
  problems.push(
806
1687
  `A action “${command.action}” abre a Presentation inexistente “${command.target.presentation}”.`,
807
1688
  );
808
- } else if (!isPresentationActionBody(target.body)) {
1689
+ } else if (
1690
+ !isPresentationActionBody(target.body) &&
1691
+ !target.source &&
1692
+ !(
1693
+ isPresentationComponentBody(target.body) &&
1694
+ target.body.contract === command.action
1695
+ )
1696
+ ) {
809
1697
  problems.push(
810
1698
  `A Presentation “${target.id}” usa conteúdo de componente e não pode ser aberta pela action “${command.action}”.`,
811
1699
  );
812
- } else if (target.body.action !== command.action) {
1700
+ } else if (
1701
+ isPresentationActionBody(target.body) &&
1702
+ target.body.action !== command.action
1703
+ ) {
813
1704
  problems.push(
814
1705
  `A Presentation “${target.id}” usa “${target.body.action}”, mas foi aberta por “${command.action}”.`,
815
1706
  );
@@ -826,6 +1717,14 @@ export function validatePresentations(
826
1717
  }
827
1718
 
828
1719
  if (problems.length > 0) throw new Error(problems.join("\n"));
1720
+ for (const presentation of parsed) {
1721
+ for (const link of presentation.links) {
1722
+ if (!byId.get(link.presentation)?.route)
1723
+ throw new Error(
1724
+ `Destino de navegação não registrado: ${link.presentation}.`,
1725
+ );
1726
+ }
1727
+ }
829
1728
  return parsed;
830
1729
  }
831
1730
 
@@ -838,8 +1737,15 @@ function unwrapObjectSchema(schema: unknown): z.ZodObject<any> | null {
838
1737
  !visited.has(current)
839
1738
  ) {
840
1739
  visited.add(current);
841
- if (current instanceof z.ZodObject) return current;
842
1740
  const definition = (current as { _def?: Record<string, unknown> })._def;
1741
+ // Contratos podem vir de outra instância de Zod no loader da CLI.
1742
+ if (
1743
+ current instanceof z.ZodObject ||
1744
+ (definition?.typeName === "ZodObject" &&
1745
+ "shape" in current &&
1746
+ typeof current.shape === "object")
1747
+ )
1748
+ return current as z.ZodObject<any>;
843
1749
  current = definition?.innerType ?? definition?.schema ?? null;
844
1750
  }
845
1751
  return null;