@softize/opus 17.1.0 → 18.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 (42) hide show
  1. package/CHANGELOG.md +64 -1
  2. package/bin/lib/check.mjs +212 -45
  3. package/docs/adr/0011-page-shell-coordinates-persistent-page-chrome.md +13 -11
  4. package/docs/adr/0013-presentation-is-a-portable-action-oriented-artifact.md +14 -2
  5. package/docs/adr/0016-list-collection-header-belongs-to-content.md +80 -0
  6. package/package.json +1 -1
  7. package/registry/skills/build-opus-ui/SKILL.md +31 -20
  8. package/registry/skills/build-opus-ui/references/evaluations.md +10 -3
  9. package/registry/skills/build-opus-ui/references/ui-patterns.md +28 -16
  10. package/src/core/presentation.ts +223 -24
  11. package/src/core/runtime.ts +3 -0
  12. package/src/core/types.ts +2 -0
  13. package/src/mcp/index.ts +1 -0
  14. package/src/ui/components/patterns/action-form-card.tsx +8 -1
  15. package/src/ui/components/patterns/confirm.tsx +194 -157
  16. package/src/ui/components/patterns/content-header.tsx +17 -2
  17. package/src/ui/components/patterns/form-dialog.tsx +28 -14
  18. package/src/ui/components/patterns/form.tsx +340 -222
  19. package/src/ui/components/patterns/list.tsx +43 -44
  20. package/src/ui/components/patterns/page-heading-context.tsx +34 -0
  21. package/src/ui/components/patterns/page-state.tsx +2 -0
  22. package/src/ui/components/patterns/page.tsx +195 -83
  23. package/src/ui/components/patterns/presentation.tsx +140 -86
  24. package/src/ui/components/patterns/surface-header.tsx +5 -6
  25. package/src/ui/components/patterns/trigger.tsx +112 -82
  26. package/src/ui/components/primitives/button.tsx +2 -2
  27. package/src/ui/components/primitives/chat.tsx +19 -5
  28. package/src/ui/components/primitives/control.ts +9 -3
  29. package/src/ui/components/primitives/dialog.tsx +16 -9
  30. package/src/ui/components/primitives/drawer.tsx +9 -6
  31. package/src/ui/docs/content/action-form-card.md +9 -8
  32. package/src/ui/docs/content/action-form-dialog.md +11 -12
  33. package/src/ui/docs/content/action-form.md +25 -25
  34. package/src/ui/docs/content/action-list.md +101 -70
  35. package/src/ui/docs/content/chat.md +4 -4
  36. package/src/ui/docs/content/content.md +29 -13
  37. package/src/ui/docs/content/dialog.md +27 -21
  38. package/src/ui/docs/content/drawer.md +8 -6
  39. package/src/ui/docs/content/page.md +42 -46
  40. package/src/ui/docs/content/presentation.md +39 -28
  41. package/src/ui/docs/doc-client.tsx +1 -1
  42. package/src/ui/meta.ts +4 -4
@@ -68,6 +68,13 @@ import { ActionForm } from "./form.tsx";
68
68
  import { ActionList, type ActionListState } from "./list.tsx";
69
69
  import { ActionTrigger } from "./trigger.tsx";
70
70
  import { ActionView } from "./view.tsx";
71
+ import {
72
+ Content,
73
+ ContentActions,
74
+ ContentBody,
75
+ ContentHeader,
76
+ ContentTitle,
77
+ } from "./content-header.tsx";
71
78
 
72
79
  interface PresentationFrameProps {
73
80
  surface: PresentationSurface;
@@ -82,6 +89,7 @@ interface PresentationFrameProps {
82
89
  onOpenChange?: (open: boolean) => void;
83
90
  className?: string;
84
91
  bodyClassName?: string;
92
+ listBody?: boolean;
85
93
  }
86
94
 
87
95
  const bodyClassName =
@@ -115,6 +123,7 @@ function PresentationFrame({
115
123
  onOpenChange,
116
124
  className,
117
125
  bodyClassName: bodyClassNameProp,
126
+ listBody = false,
118
127
  }: PresentationFrameProps): ReactElement {
119
128
  const insidePageShell = useInsidePageShell();
120
129
  const [footerTarget, setFooterTarget] = useState<HTMLElement | null>(null);
@@ -122,9 +131,7 @@ function PresentationFrame({
122
131
  setFooterTarget(node);
123
132
  }, []);
124
133
  const hasFooter = hasBodyFooter || footerActions !== undefined;
125
- const renderFooterActions = (
126
- equal = false,
127
- ): ReactElement => (
134
+ const renderFooterActions = (equal = false): ReactElement => (
128
135
  <PresentationActions equal={equal}>
129
136
  {hasBodyFooter ? (
130
137
  <span
@@ -145,19 +152,49 @@ function PresentationFrame({
145
152
  );
146
153
 
147
154
  if (surface === "page") {
148
- if (insidePageShell) {
149
- return (
150
- <Page className={cn("flex min-h-full flex-col", className)}>
151
- <PageHeader>
152
- {navigation === undefined ? null : (
153
- <PageNavigation>{navigation}</PageNavigation>
154
- )}
155
+ if (listBody) {
156
+ const content = (
157
+ <Content level={1} variant="page">
158
+ <ContentHeader>
159
+ <ContentTitle>{title}</ContentTitle>
155
160
  {headerActions === undefined ? null : (
156
- <PageActions>
161
+ <ContentActions>
157
162
  <PresentationActions>{headerActions}</PresentationActions>
158
- </PageActions>
163
+ </ContentActions>
159
164
  )}
160
- </PageHeader>
165
+ </ContentHeader>
166
+ <ContentBody>{body}</ContentBody>
167
+ </Content>
168
+ );
169
+ return (
170
+ <Page className={cn("flex min-h-full flex-col", className)}>
171
+ {navigation === undefined ? null : (
172
+ <PageHeader>
173
+ <PageNavigation>{navigation}</PageNavigation>
174
+ </PageHeader>
175
+ )}
176
+ <PageBody className="min-h-0 flex-1 overflow-y-auto">
177
+ {content}
178
+ </PageBody>
179
+ {hasFooter ? <PageFooter>{renderFooterActions()}</PageFooter> : null}
180
+ </Page>
181
+ );
182
+ }
183
+ if (insidePageShell) {
184
+ return (
185
+ <Page className={cn("flex min-h-full flex-col", className)}>
186
+ {navigation === undefined && headerActions === undefined ? null : (
187
+ <PageHeader>
188
+ {navigation === undefined ? null : (
189
+ <PageNavigation>{navigation}</PageNavigation>
190
+ )}
191
+ {headerActions === undefined ? null : (
192
+ <PageActions>
193
+ <PresentationActions>{headerActions}</PresentationActions>
194
+ </PageActions>
195
+ )}
196
+ </PageHeader>
197
+ )}
161
198
  <PageIntro>
162
199
  <PageTitle>{title}</PageTitle>
163
200
  </PageIntro>
@@ -173,7 +210,7 @@ function PresentationFrame({
173
210
  className,
174
211
  )}
175
212
  >
176
- <PageHeader className="border-b px-3 py-4">
213
+ <PageHeader>
177
214
  {navigation === undefined ? null : (
178
215
  <PageNavigation>{navigation}</PageNavigation>
179
216
  )}
@@ -220,7 +257,9 @@ function PresentationFrame({
220
257
  )}
221
258
  </DialogHeader>
222
259
  <DialogBody>{body}</DialogBody>
223
- {hasFooter ? <DialogFooter>{renderFooterActions(true)}</DialogFooter> : null}
260
+ {hasFooter ? (
261
+ <DialogFooter>{renderFooterActions(true)}</DialogFooter>
262
+ ) : null}
224
263
  </DialogContent>
225
264
  </Dialog>
226
265
  );
@@ -253,7 +292,9 @@ function PresentationFrame({
253
292
  )}
254
293
  </DrawerHeader>
255
294
  <DrawerBody>{body}</DrawerBody>
256
- {hasFooter ? <DrawerFooter>{renderFooterActions(true)}</DrawerFooter> : null}
295
+ {hasFooter ? (
296
+ <DrawerFooter>{renderFooterActions(true)}</DrawerFooter>
297
+ ) : null}
257
298
  </DrawerContent>
258
299
  </Drawer>
259
300
  );
@@ -370,18 +411,25 @@ export function Presentation({
370
411
  effects: PresentationDefinition["body"]["onSuccess"],
371
412
  result?: unknown,
372
413
  ): void => {
373
- const next = applyPresentationEffects(invocation, effects, {
374
- ...context,
375
- ...(result !== undefined && result !== null && typeof result === "object"
376
- ? { result: result as Record<string, unknown> }
377
- : {}),
378
- });
414
+ const next = applyPresentationEffects(
415
+ invocation,
416
+ effects,
417
+ {
418
+ ...context,
419
+ ...(result !== undefined &&
420
+ result !== null &&
421
+ typeof result === "object"
422
+ ? { result: result as Record<string, unknown> }
423
+ : {}),
424
+ },
425
+ definitions,
426
+ );
379
427
  for (const action of next.refresh) onRefresh?.(action);
380
428
  if (next.invocation !== invocation || next.exit !== undefined) {
381
429
  onInvocationChange?.(next.invocation);
382
430
  }
383
431
  },
384
- [context, invocation, onInvocationChange, onRefresh],
432
+ [context, definitions, invocation, onInvocationChange, onRefresh],
385
433
  );
386
434
 
387
435
  const closeSurface = useCallback((): void => {
@@ -419,14 +467,16 @@ export function Presentation({
419
467
  bindings: PresentationCommand["input"],
420
468
  targetContext: PresentationBindingContext = context,
421
469
  ): void => {
422
- const target = definitions.find((candidate) => candidate.id === presentationId);
470
+ const target = definitions.find(
471
+ (candidate) => candidate.id === presentationId,
472
+ );
423
473
  if (target === undefined) {
424
474
  throw new Error(`Presentation “${presentationId}” não registrada.`);
425
475
  }
426
476
  onInvocationChange?.(
427
477
  openPresentation(invocation, {
428
478
  presentationId: target.id,
429
- surface: surface ?? invocation.surface,
479
+ surface: surface ?? target.route?.surface ?? invocation.surface,
430
480
  input: resolvePresentationBindings(bindings, targetContext) as Record<
431
481
  string,
432
482
  PresentationJsonValue
@@ -435,7 +485,10 @@ export function Presentation({
435
485
  );
436
486
  };
437
487
 
438
- const renderCommand = (command: PresentationCommand): ReactElement => {
488
+ const renderCommand = (
489
+ command: PresentationCommand,
490
+ size: ButtonSize = "default",
491
+ ): ReactElement => {
439
492
  const action = actions[command.action];
440
493
  if (action === undefined) {
441
494
  throw new Error(`Action “${command.action}” não registrada.`);
@@ -447,7 +500,6 @@ export function Presentation({
447
500
  // Header e footer são regiões de decisão da superfície, não toolbars operacionais.
448
501
  // Seus comandos textuais usam a linha padrão; listas e grids continuam donos da
449
502
  // densidade compacta de suas próprias ações.
450
- const size: ButtonSize = "default";
451
503
  if (action.kind === "simple") {
452
504
  return (
453
505
  <PresentationCommandTrigger
@@ -467,6 +519,7 @@ export function Presentation({
467
519
  `A action “${command.action}” precisa declarar uma Presentation de destino.`,
468
520
  );
469
521
  }
522
+ const label = actionLabel(action);
470
523
  return (
471
524
  <Button
472
525
  key={`${command.placement}:${command.action}`}
@@ -480,7 +533,7 @@ export function Presentation({
480
533
  )
481
534
  }
482
535
  >
483
- {actionLabel(action)}
536
+ {label}
484
537
  </Button>
485
538
  );
486
539
  };
@@ -500,11 +553,8 @@ export function Presentation({
500
553
  action={bodyAction as FormContract<Record<string, unknown>, unknown>}
501
554
  defaultValues={bodyInput}
502
555
  submitLabel={definition.body.submitLabel}
503
- onCancel={
504
- invocation.surface === "page"
505
- ? undefined
506
- : closeSurface
507
- }
556
+ onCancel={invocation.surface === "page" ? undefined : closeSurface}
557
+ cancelVariant="outline"
508
558
  onSuccess={(data) => applyEffects(definition.body.onSuccess, data)}
509
559
  disabled={hasSurfaceBlock}
510
560
  onLoadingChange={setBodyLoading}
@@ -516,76 +566,79 @@ export function Presentation({
516
566
  }
517
567
  if (bodyAction.kind === "list") {
518
568
  return (
519
- <ActionList<Record<string, unknown>, Record<string, unknown>>
520
- action={bodyAction as ListContract<
521
- Record<string, unknown>,
522
- Record<string, unknown>
523
- >}
524
- input={bodyInput}
525
- state={listState}
526
- onStateChange={onListStateChange}
527
- {...(definition.body.open === undefined
528
- ? {}
529
- : {
530
- onRowClick: (item: Record<string, unknown>) =>
531
- openTarget(
532
- definition.body.open!.presentation,
533
- definition.body.open!.surface,
534
- definition.body.open!.input,
535
- { ...context, item },
536
- ),
537
- })}
538
- />
569
+ <ActionList<Record<string, unknown>, Record<string, unknown>>
570
+ action={
571
+ bodyAction as ListContract<
572
+ Record<string, unknown>,
573
+ Record<string, unknown>
574
+ >
575
+ }
576
+ input={bodyInput}
577
+ state={listState}
578
+ onStateChange={onListStateChange}
579
+ {...(definition.body.open === undefined
580
+ ? {}
581
+ : {
582
+ onRowClick: (item: Record<string, unknown>) =>
583
+ openTarget(
584
+ definition.body.open!.presentation,
585
+ definition.body.open!.surface,
586
+ definition.body.open!.input,
587
+ { ...context, item },
588
+ ),
589
+ })}
590
+ />
539
591
  );
540
592
  }
541
593
  if (bodyAction.kind === "view") {
542
594
  const fields = definition.body.fields ?? [];
543
595
  return (
544
- <ActionView
545
- action={bodyAction as ViewContract<Record<string, unknown>, unknown>}
546
- input={bodyInput}
547
- >
548
- {(data) => (
549
- <DetailGroup>
550
- {fields.map((field) => (
551
- <DetailField
552
- key={field.key}
553
- label={field.label}
554
- value={readField(data, field.key) as ReactNode}
555
- empty={field.empty}
556
- />
557
- ))}
558
- </DetailGroup>
559
- )}
560
- </ActionView>
596
+ <ActionView
597
+ action={bodyAction as ViewContract<Record<string, unknown>, unknown>}
598
+ input={bodyInput}
599
+ >
600
+ {(data) => (
601
+ <DetailGroup>
602
+ {fields.map((field) => (
603
+ <DetailField
604
+ key={field.key}
605
+ label={field.label}
606
+ value={readField(data, field.key) as ReactNode}
607
+ empty={field.empty}
608
+ />
609
+ ))}
610
+ </DetailGroup>
611
+ )}
612
+ </ActionView>
561
613
  );
562
614
  }
563
- throw new Error(`A action simple “${bodyAction.name}” não pode ocupar o body.`);
615
+ throw new Error(
616
+ `A action simple “${bodyAction.name}” não pode ocupar o body.`,
617
+ );
564
618
  };
565
619
 
566
620
  const headerActions = definition.actions
567
621
  .filter((command) => command.placement === "header")
568
- .map(renderCommand);
622
+ .map((command) => renderCommand(command));
569
623
  const footerActions = definition.actions
570
624
  .filter((command) => command.placement === "footer")
571
- .map(renderCommand);
625
+ .map((command) => renderCommand(command));
572
626
  const parentSurface = invocation.stack.at(-1)?.surface;
573
627
  const canGoBack =
574
628
  parentSurface !== undefined &&
575
629
  (invocation.surface === "page" || parentSurface !== "page");
576
- const navigation =
577
- !canGoBack ? undefined : (
578
- <Button
579
- size="icon-sm"
580
- variant="ghost"
581
- disabled={surfaceBlocked}
582
- aria-label="Voltar"
583
- title="Voltar"
584
- onClick={() => applyEffects([{ effect: "back" }])}
585
- >
586
- <ArrowLeft aria-hidden />
587
- </Button>
588
- );
630
+ const navigation = !canGoBack ? undefined : (
631
+ <Button
632
+ size="icon-sm"
633
+ variant="ghost"
634
+ disabled={surfaceBlocked}
635
+ aria-label="Voltar"
636
+ title="Voltar"
637
+ onClick={() => applyEffects([{ effect: "back" }])}
638
+ >
639
+ <ArrowLeft aria-hidden />
640
+ </Button>
641
+ );
589
642
 
590
643
  return (
591
644
  <PresentationFrame
@@ -606,6 +659,7 @@ export function Presentation({
606
659
  bodyClassName={bodyClassNameProp}
607
660
  hasBodyFooter={bodyAction.kind === "form"}
608
661
  blocked={surfaceBlocked}
662
+ listBody={bodyAction.kind === "list"}
609
663
  >
610
664
  {renderBody}
611
665
  </PresentationFrame>
@@ -25,7 +25,7 @@ export const surfaceHeaderClasses: Record<
25
25
  { title: string; description: string; count: string; actions: string }
26
26
  > = {
27
27
  page: {
28
- title: "text-2xl font-semibold tracking-tight",
28
+ title: "text-3xl font-semibold tracking-tight",
29
29
  description: "mt-1 truncate text-sm text-muted-foreground",
30
30
  count: "font-mono text-base text-muted-foreground/60",
31
31
  actions: "flex w-full shrink-0 items-center gap-4 sm:w-auto",
@@ -129,7 +129,9 @@ export function SurfaceHeader({
129
129
  // dois erros é a quantidade de tipos distintos presentes — dois PageBack são repetição,
130
130
  // não combinação, e merecem a mensagem genérica.
131
131
  const distinctLeading = new Set(
132
- leading.flatMap((node) => (isValidElement(node) ? [node.type as ElementType] : [])),
132
+ leading.flatMap((node) =>
133
+ isValidElement(node) ? [node.type as ElementType] : [],
134
+ ),
133
135
  );
134
136
  throw new Error(
135
137
  distinctLeading.size > 1
@@ -159,10 +161,7 @@ export function SurfaceHeader({
159
161
  // wrapper assume o min-content do breadcrumb e a compressão toda cai sobre o título.
160
162
  <div
161
163
  data-slot={`${slot}-navigation`}
162
- className={cn(
163
- "flex min-w-0 items-center",
164
- !heading && "flex-1",
165
- )}
164
+ className={cn("flex min-w-0 items-center", !heading && "flex-1")}
166
165
  >
167
166
  {leading}
168
167
  </div>