@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.
- package/CHANGELOG.md +64 -1
- package/bin/lib/check.mjs +212 -45
- package/docs/adr/0011-page-shell-coordinates-persistent-page-chrome.md +13 -11
- package/docs/adr/0013-presentation-is-a-portable-action-oriented-artifact.md +14 -2
- 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 +31 -20
- package/registry/skills/build-opus-ui/references/evaluations.md +10 -3
- package/registry/skills/build-opus-ui/references/ui-patterns.md +28 -16
- 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 +195 -83
- package/src/ui/components/patterns/presentation.tsx +140 -86
- package/src/ui/components/patterns/surface-header.tsx +5 -6
- package/src/ui/components/patterns/trigger.tsx +112 -82
- 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/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 +42 -46
- 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,49 @@ function PresentationFrame({
|
|
|
145
152
|
);
|
|
146
153
|
|
|
147
154
|
if (surface === "page") {
|
|
148
|
-
if (
|
|
149
|
-
|
|
150
|
-
<
|
|
151
|
-
<
|
|
152
|
-
{
|
|
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
|
-
<
|
|
161
|
+
<ContentActions>
|
|
157
162
|
<PresentationActions>{headerActions}</PresentationActions>
|
|
158
|
-
</
|
|
163
|
+
</ContentActions>
|
|
159
164
|
)}
|
|
160
|
-
</
|
|
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
|
|
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 ?
|
|
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 ?
|
|
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(
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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(
|
|
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 = (
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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(
|
|
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
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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-
|
|
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>
|