@softize/opus 17.2.0 → 18.0.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.
- package/CHANGELOG.md +62 -1
- package/bin/lib/check.mjs +212 -45
- package/docs/adr/0010-page-header-owns-page-chrome.md +2 -2
- package/docs/adr/0011-page-shell-coordinates-persistent-page-chrome.md +4 -0
- package/docs/adr/0013-presentation-is-a-portable-action-oriented-artifact.md +14 -2
- package/docs/adr/0015-action-size-follows-interaction-density.md +1 -1
- package/docs/adr/0016-list-collection-header-belongs-to-content.md +80 -0
- package/package.json +1 -1
- package/registry/skills/build-opus-ui/SKILL.md +30 -20
- package/registry/skills/build-opus-ui/references/evaluations.md +9 -3
- package/registry/skills/build-opus-ui/references/ui-patterns.md +30 -18
- package/src/core/presentation.ts +223 -24
- package/src/core/runtime.ts +3 -0
- package/src/core/types.ts +2 -0
- package/src/mcp/index.ts +1 -0
- package/src/ui/components/patterns/action-form-card.tsx +8 -1
- package/src/ui/components/patterns/confirm.tsx +194 -157
- package/src/ui/components/patterns/content-header.tsx +17 -2
- package/src/ui/components/patterns/form-dialog.tsx +28 -14
- package/src/ui/components/patterns/form.tsx +340 -222
- package/src/ui/components/patterns/list.tsx +43 -44
- package/src/ui/components/patterns/page-heading-context.tsx +34 -0
- package/src/ui/components/patterns/page-state.tsx +2 -0
- package/src/ui/components/patterns/page.tsx +165 -51
- package/src/ui/components/patterns/presentation.tsx +140 -84
- package/src/ui/components/patterns/surface-header.tsx +5 -6
- package/src/ui/components/patterns/trigger.tsx +113 -83
- package/src/ui/components/primitives/button.tsx +2 -2
- package/src/ui/components/primitives/chat.tsx +19 -5
- package/src/ui/components/primitives/control.ts +9 -3
- package/src/ui/components/primitives/dialog.tsx +16 -9
- package/src/ui/components/primitives/drawer.tsx +9 -6
- package/src/ui/docs/content/action-form-card.md +9 -8
- package/src/ui/docs/content/action-form-dialog.md +11 -12
- package/src/ui/docs/content/action-form.md +25 -25
- package/src/ui/docs/content/action-list.md +101 -70
- package/src/ui/docs/content/action-trigger.md +2 -2
- package/src/ui/docs/content/button.md +1 -1
- package/src/ui/docs/content/chat.md +4 -4
- package/src/ui/docs/content/content.md +29 -13
- package/src/ui/docs/content/dialog.md +27 -21
- package/src/ui/docs/content/drawer.md +8 -6
- package/src/ui/docs/content/page.md +43 -50
- package/src/ui/docs/content/presentation.md +39 -28
- package/src/ui/docs/doc-client.tsx +1 -1
- 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,51 @@ function PresentationFrame({
|
|
|
145
152
|
);
|
|
146
153
|
|
|
147
154
|
if (surface === "page") {
|
|
155
|
+
if (listBody) {
|
|
156
|
+
const content = (
|
|
157
|
+
<Content level={1} variant="page">
|
|
158
|
+
<ContentHeader>
|
|
159
|
+
<ContentTitle>{title}</ContentTitle>
|
|
160
|
+
{headerActions === undefined ? null : (
|
|
161
|
+
<ContentActions>
|
|
162
|
+
<PresentationActions>{headerActions}</PresentationActions>
|
|
163
|
+
</ContentActions>
|
|
164
|
+
)}
|
|
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
|
+
}
|
|
148
183
|
if (insidePageShell) {
|
|
149
184
|
return (
|
|
150
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
|
+
)}
|
|
151
198
|
<PageIntro>
|
|
152
|
-
{navigation === undefined ? null : (
|
|
153
|
-
<PageNavigation>{navigation}</PageNavigation>
|
|
154
|
-
)}
|
|
155
199
|
<PageTitle>{title}</PageTitle>
|
|
156
|
-
{headerActions === undefined ? null : (
|
|
157
|
-
<PageActions>
|
|
158
|
-
<PresentationActions>{headerActions}</PresentationActions>
|
|
159
|
-
</PageActions>
|
|
160
|
-
)}
|
|
161
200
|
</PageIntro>
|
|
162
201
|
<PageBody className="min-h-0 flex-1 overflow-y-auto">{body}</PageBody>
|
|
163
202
|
{hasFooter ? <PageFooter>{renderFooterActions()}</PageFooter> : null}
|
|
@@ -171,7 +210,7 @@ function PresentationFrame({
|
|
|
171
210
|
className,
|
|
172
211
|
)}
|
|
173
212
|
>
|
|
174
|
-
<PageHeader
|
|
213
|
+
<PageHeader>
|
|
175
214
|
{navigation === undefined ? null : (
|
|
176
215
|
<PageNavigation>{navigation}</PageNavigation>
|
|
177
216
|
)}
|
|
@@ -218,7 +257,9 @@ function PresentationFrame({
|
|
|
218
257
|
)}
|
|
219
258
|
</DialogHeader>
|
|
220
259
|
<DialogBody>{body}</DialogBody>
|
|
221
|
-
{hasFooter ?
|
|
260
|
+
{hasFooter ? (
|
|
261
|
+
<DialogFooter>{renderFooterActions(true)}</DialogFooter>
|
|
262
|
+
) : null}
|
|
222
263
|
</DialogContent>
|
|
223
264
|
</Dialog>
|
|
224
265
|
);
|
|
@@ -251,7 +292,9 @@ function PresentationFrame({
|
|
|
251
292
|
)}
|
|
252
293
|
</DrawerHeader>
|
|
253
294
|
<DrawerBody>{body}</DrawerBody>
|
|
254
|
-
{hasFooter ?
|
|
295
|
+
{hasFooter ? (
|
|
296
|
+
<DrawerFooter>{renderFooterActions(true)}</DrawerFooter>
|
|
297
|
+
) : null}
|
|
255
298
|
</DrawerContent>
|
|
256
299
|
</Drawer>
|
|
257
300
|
);
|
|
@@ -368,18 +411,25 @@ export function Presentation({
|
|
|
368
411
|
effects: PresentationDefinition["body"]["onSuccess"],
|
|
369
412
|
result?: unknown,
|
|
370
413
|
): void => {
|
|
371
|
-
const next = applyPresentationEffects(
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
+
);
|
|
377
427
|
for (const action of next.refresh) onRefresh?.(action);
|
|
378
428
|
if (next.invocation !== invocation || next.exit !== undefined) {
|
|
379
429
|
onInvocationChange?.(next.invocation);
|
|
380
430
|
}
|
|
381
431
|
},
|
|
382
|
-
[context, invocation, onInvocationChange, onRefresh],
|
|
432
|
+
[context, definitions, invocation, onInvocationChange, onRefresh],
|
|
383
433
|
);
|
|
384
434
|
|
|
385
435
|
const closeSurface = useCallback((): void => {
|
|
@@ -417,14 +467,16 @@ export function Presentation({
|
|
|
417
467
|
bindings: PresentationCommand["input"],
|
|
418
468
|
targetContext: PresentationBindingContext = context,
|
|
419
469
|
): void => {
|
|
420
|
-
const target = definitions.find(
|
|
470
|
+
const target = definitions.find(
|
|
471
|
+
(candidate) => candidate.id === presentationId,
|
|
472
|
+
);
|
|
421
473
|
if (target === undefined) {
|
|
422
474
|
throw new Error(`Presentation “${presentationId}” não registrada.`);
|
|
423
475
|
}
|
|
424
476
|
onInvocationChange?.(
|
|
425
477
|
openPresentation(invocation, {
|
|
426
478
|
presentationId: target.id,
|
|
427
|
-
surface: surface ?? invocation.surface,
|
|
479
|
+
surface: surface ?? target.route?.surface ?? invocation.surface,
|
|
428
480
|
input: resolvePresentationBindings(bindings, targetContext) as Record<
|
|
429
481
|
string,
|
|
430
482
|
PresentationJsonValue
|
|
@@ -433,7 +485,10 @@ export function Presentation({
|
|
|
433
485
|
);
|
|
434
486
|
};
|
|
435
487
|
|
|
436
|
-
const renderCommand = (
|
|
488
|
+
const renderCommand = (
|
|
489
|
+
command: PresentationCommand,
|
|
490
|
+
size: ButtonSize = "default",
|
|
491
|
+
): ReactElement => {
|
|
437
492
|
const action = actions[command.action];
|
|
438
493
|
if (action === undefined) {
|
|
439
494
|
throw new Error(`Action “${command.action}” não registrada.`);
|
|
@@ -445,7 +500,6 @@ export function Presentation({
|
|
|
445
500
|
// Header e footer são regiões de decisão da superfície, não toolbars operacionais.
|
|
446
501
|
// Seus comandos textuais usam a linha padrão; listas e grids continuam donos da
|
|
447
502
|
// densidade compacta de suas próprias ações.
|
|
448
|
-
const size: ButtonSize = "default";
|
|
449
503
|
if (action.kind === "simple") {
|
|
450
504
|
return (
|
|
451
505
|
<PresentationCommandTrigger
|
|
@@ -465,6 +519,7 @@ export function Presentation({
|
|
|
465
519
|
`A action “${command.action}” precisa declarar uma Presentation de destino.`,
|
|
466
520
|
);
|
|
467
521
|
}
|
|
522
|
+
const label = actionLabel(action);
|
|
468
523
|
return (
|
|
469
524
|
<Button
|
|
470
525
|
key={`${command.placement}:${command.action}`}
|
|
@@ -478,7 +533,7 @@ export function Presentation({
|
|
|
478
533
|
)
|
|
479
534
|
}
|
|
480
535
|
>
|
|
481
|
-
{
|
|
536
|
+
{label}
|
|
482
537
|
</Button>
|
|
483
538
|
);
|
|
484
539
|
};
|
|
@@ -498,11 +553,8 @@ export function Presentation({
|
|
|
498
553
|
action={bodyAction as FormContract<Record<string, unknown>, unknown>}
|
|
499
554
|
defaultValues={bodyInput}
|
|
500
555
|
submitLabel={definition.body.submitLabel}
|
|
501
|
-
onCancel={
|
|
502
|
-
|
|
503
|
-
? undefined
|
|
504
|
-
: closeSurface
|
|
505
|
-
}
|
|
556
|
+
onCancel={invocation.surface === "page" ? undefined : closeSurface}
|
|
557
|
+
cancelVariant="outline"
|
|
506
558
|
onSuccess={(data) => applyEffects(definition.body.onSuccess, data)}
|
|
507
559
|
disabled={hasSurfaceBlock}
|
|
508
560
|
onLoadingChange={setBodyLoading}
|
|
@@ -514,76 +566,79 @@ export function Presentation({
|
|
|
514
566
|
}
|
|
515
567
|
if (bodyAction.kind === "list") {
|
|
516
568
|
return (
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
+
/>
|
|
537
591
|
);
|
|
538
592
|
}
|
|
539
593
|
if (bodyAction.kind === "view") {
|
|
540
594
|
const fields = definition.body.fields ?? [];
|
|
541
595
|
return (
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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>
|
|
559
613
|
);
|
|
560
614
|
}
|
|
561
|
-
throw new Error(
|
|
615
|
+
throw new Error(
|
|
616
|
+
`A action simple “${bodyAction.name}” não pode ocupar o body.`,
|
|
617
|
+
);
|
|
562
618
|
};
|
|
563
619
|
|
|
564
620
|
const headerActions = definition.actions
|
|
565
621
|
.filter((command) => command.placement === "header")
|
|
566
|
-
.map(renderCommand);
|
|
622
|
+
.map((command) => renderCommand(command));
|
|
567
623
|
const footerActions = definition.actions
|
|
568
624
|
.filter((command) => command.placement === "footer")
|
|
569
|
-
.map(renderCommand);
|
|
625
|
+
.map((command) => renderCommand(command));
|
|
570
626
|
const parentSurface = invocation.stack.at(-1)?.surface;
|
|
571
627
|
const canGoBack =
|
|
572
628
|
parentSurface !== undefined &&
|
|
573
629
|
(invocation.surface === "page" || parentSurface !== "page");
|
|
574
|
-
const navigation =
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
);
|
|
630
|
+
const navigation = !canGoBack ? undefined : (
|
|
631
|
+
<Button
|
|
632
|
+
size="icon"
|
|
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
|
+
);
|
|
587
642
|
|
|
588
643
|
return (
|
|
589
644
|
<PresentationFrame
|
|
@@ -604,6 +659,7 @@ export function Presentation({
|
|
|
604
659
|
bodyClassName={bodyClassNameProp}
|
|
605
660
|
hasBodyFooter={bodyAction.kind === "form"}
|
|
606
661
|
blocked={surfaceBlocked}
|
|
662
|
+
listBody={bodyAction.kind === "list"}
|
|
607
663
|
>
|
|
608
664
|
{renderBody}
|
|
609
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-
|
|
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) =>
|
|
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>
|