@softize/opus 15.2.2 → 16.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 (37) hide show
  1. package/CHANGELOG.md +51 -18
  2. package/bin/lib/check.mjs +98 -15
  3. package/bin/lib/copy.mjs +811 -314
  4. package/bin/lib/gen-manifest.mjs +24 -23
  5. package/bin/lib/gen-runner.mjs +188 -148
  6. package/docs/adr/0010-page-header-owns-page-chrome.md +3 -4
  7. package/docs/adr/0011-page-shell-coordinates-persistent-page-chrome.md +5 -3
  8. package/docs/adr/0012-modal-header-only-names-the-surface.md +45 -0
  9. package/docs/adr/0013-presentation-is-a-portable-action-oriented-artifact.md +92 -0
  10. package/docs/code-style.md +24 -19
  11. package/package.json +5 -1
  12. package/registry/skills/build-opus-ui/references/ui-patterns.md +43 -26
  13. package/src/core/presentation.ts +512 -0
  14. package/src/presentation/index.ts +1 -0
  15. package/src/ui/components/patterns/action-list-dialog.tsx +26 -9
  16. package/src/ui/components/patterns/confirm.tsx +34 -29
  17. package/src/ui/components/patterns/form-dialog.tsx +20 -8
  18. package/src/ui/components/patterns/list.tsx +26 -9
  19. package/src/ui/components/patterns/page.tsx +99 -139
  20. package/src/ui/components/patterns/presentation.tsx +316 -0
  21. package/src/ui/components/patterns/sidebar.tsx +3 -3
  22. package/src/ui/components/patterns/trigger.tsx +38 -17
  23. package/src/ui/components/primitives/button-group.tsx +53 -43
  24. package/src/ui/components/primitives/command.tsx +30 -72
  25. package/src/ui/components/primitives/dialog.tsx +23 -89
  26. package/src/ui/components/primitives/drawer.tsx +8 -34
  27. package/src/ui/docs/content/action-form-dialog.md +12 -10
  28. package/src/ui/docs/content/action-list-dialog.md +22 -17
  29. package/src/ui/docs/content/button.md +52 -35
  30. package/src/ui/docs/content/communication.md +26 -26
  31. package/src/ui/docs/content/dialog.md +173 -154
  32. package/src/ui/docs/content/drawer.md +12 -11
  33. package/src/ui/docs/content/page.md +72 -91
  34. package/src/ui/docs/content/presentation.md +158 -0
  35. package/src/ui/docs/registry.tsx +6 -0
  36. package/src/ui/meta.ts +8 -2
  37. package/src/ui/react.tsx +10 -3
@@ -22,24 +22,13 @@ import { createPortal } from "react-dom";
22
22
  import { ArrowLeft } from "lucide-react";
23
23
  import { cn } from "../../lib/cn.ts";
24
24
  import { Button } from "../primitives/button.tsx";
25
- import {
26
- Tooltip,
27
- TooltipContent,
28
- TooltipProvider,
29
- TooltipTrigger,
30
- } from "../primitives/tooltip.tsx";
31
25
  import { SurfaceHeader, surfaceHeaderClasses } from "./surface-header.tsx";
32
26
  import { PageStatePresenceProvider } from "./page-state.tsx";
33
27
 
34
- export type PageHeaderVariant = "default" | "bar";
35
-
36
- interface PageContextValue {
37
- headerVariant: PageHeaderVariant;
38
- containerClassName?: string;
39
- }
28
+ interface PageContextValue {}
40
29
 
41
30
  const PageContext = createContext<PageContextValue | null>(null);
42
- type PageHeadingRegion = PageHeaderVariant | "intro";
31
+ type PageHeadingRegion = "header" | "intro";
43
32
 
44
33
  const PageHeaderContext = createContext<PageHeadingRegion | null>(null);
45
34
  const PageIntegralStateContext = createContext(false);
@@ -49,6 +38,11 @@ const pageBarClassName =
49
38
  "flex h-12 shrink-0 items-center border-b border-border bg-background text-foreground";
50
39
  const pageBarContentClassName = "w-full py-2";
51
40
 
41
+ /** Uso interno de patterns que precisam adaptar a Page ao shell sem duplicar sua anatomia. */
42
+ export function useInsidePageShell(): boolean {
43
+ return useContext(PageShellContext);
44
+ }
45
+
52
46
  function PageShellNavigationSlot({
53
47
  className,
54
48
  ...props
@@ -102,7 +96,9 @@ export function PageShell({
102
96
  headerClassName,
103
97
  ...props
104
98
  }: PageShellProps): ReactElement {
105
- const [actionsTarget, setActionsTarget] = useState<HTMLDivElement | null>(null);
99
+ const [actionsTarget, setActionsTarget] = useState<HTMLDivElement | null>(
100
+ null,
101
+ );
106
102
 
107
103
  return (
108
104
  <div
@@ -124,11 +120,7 @@ export function PageShell({
124
120
  }}
125
121
  leadingPlacement="inline"
126
122
  titleRequired={false}
127
- contentClassName={cn(
128
- pageBarContentClassName,
129
- "px-3",
130
- headerClassName,
131
- )}
123
+ contentClassName={cn(pageBarContentClassName, "px-3", headerClassName)}
132
124
  className={pageBarClassName}
133
125
  data-variant="bar"
134
126
  >
@@ -211,6 +203,9 @@ export function Page({
211
203
  const bodies = nodes.filter(
212
204
  (node) => isValidElement(node) && node.type === PageBody,
213
205
  );
206
+ const footers = nodes.filter(
207
+ (node) => isValidElement(node) && node.type === PageFooter,
208
+ );
214
209
  const intros = nodes.filter(
215
210
  (node) => isValidElement(node) && node.type === PageIntro,
216
211
  );
@@ -240,58 +235,52 @@ export function Page({
240
235
  intros.length === 1 &&
241
236
  headers.length === 0 &&
242
237
  bodies.length === 1 &&
243
- intros.length + bodies.length === nodes.length;
238
+ footers.length <= 1 &&
239
+ intros.length + bodies.length + footers.length === nodes.length;
244
240
  const validStandaloneComposition =
245
241
  !insideShell &&
246
242
  headers.length === 1 &&
247
243
  intros.length === 0 &&
248
244
  bodies.length === 1 &&
249
- headers.length + bodies.length === nodes.length;
245
+ footers.length <= 1 &&
246
+ headers.length + bodies.length + footers.length === nodes.length;
250
247
  if (!validIntroComposition && !validStandaloneComposition) {
251
248
  throw new Error(
252
249
  insideShell
253
- ? "Page explícito dentro de PageShell exige exatamente um PageIntro e um PageBody como filhos diretos."
254
- : "Page explícito exige exatamente um PageHeader ou PageIntro e um PageBody como filhos diretos.",
250
+ ? "Page explícito dentro de PageShell exige um PageIntro, um PageBody e no máximo um PageFooter como filhos diretos."
251
+ : "Page explícito exige um PageHeader ou PageIntro, um PageBody e no máximo um PageFooter como filhos diretos.",
255
252
  );
256
253
  }
257
254
  }
258
- const headerVariant: PageHeaderVariant = shorthand
259
- ? "default"
260
- : ((headers[0] as ReactElement<PageHeaderProps> | undefined)?.props
261
- .variant ?? "default");
262
- const pageContext: PageContextValue = {
263
- headerVariant,
264
- containerClassName: className,
265
- };
266
-
267
- const content = shorthand && insideShell ? (
268
- <>
269
- <PageIntro>
270
- <PageTitle>{title}</PageTitle>
271
- {description !== undefined && (
272
- <PageDescription>{description}</PageDescription>
273
- )}
274
- {actions !== undefined && <PageActions>{actions}</PageActions>}
275
- </PageIntro>
276
- <PageBody>{children}</PageBody>
277
- </>
278
- ) : shorthand ? (
279
- <>
280
- <PageHeader>
281
- <PageTitle>{title}</PageTitle>
282
- {description !== undefined && (
283
- <PageDescription>{description}</PageDescription>
284
- )}
285
- {actions !== undefined && <PageActions>{actions}</PageActions>}
286
- </PageHeader>
287
- <PageBody>{children}</PageBody>
288
- </>
289
- ) : (
290
- children
291
- );
255
+ const content =
256
+ shorthand && insideShell ? (
257
+ <>
258
+ <PageIntro>
259
+ <PageTitle>{title}</PageTitle>
260
+ {description !== undefined && (
261
+ <PageDescription>{description}</PageDescription>
262
+ )}
263
+ {actions !== undefined && <PageActions>{actions}</PageActions>}
264
+ </PageIntro>
265
+ <PageBody>{children}</PageBody>
266
+ </>
267
+ ) : shorthand ? (
268
+ <>
269
+ <PageHeader>
270
+ <PageTitle>{title}</PageTitle>
271
+ {description !== undefined && (
272
+ <PageDescription>{description}</PageDescription>
273
+ )}
274
+ {actions !== undefined && <PageActions>{actions}</PageActions>}
275
+ </PageHeader>
276
+ <PageBody>{children}</PageBody>
277
+ </>
278
+ ) : (
279
+ children
280
+ );
292
281
 
293
282
  return (
294
- <PageContext.Provider value={pageContext}>
283
+ <PageContext.Provider value={{}}>
295
284
  <PageIntegralStateContext.Provider value={integralState}>
296
285
  <PageStatePresenceProvider register={registerIntegralState}>
297
286
  <main
@@ -299,28 +288,20 @@ export function Page({
299
288
  className={cn(
300
289
  "min-w-0 flex-1",
301
290
  insideShell && "min-h-full",
302
- (headerVariant === "bar" || integralState) && "flex flex-col",
303
- // A barra contém a rolagem (`min-h-0`); o estado integral ocupa a altura
304
- // disponível (`min-h-full`). Emitir os dois juntos deixava o resultado por
305
- // conta da ordem do CSS gerado — no estado integral, quem manda é ele.
306
- integralState ? "min-h-full" : headerVariant === "bar" && "min-h-0",
291
+ integralState && "flex min-h-full flex-col",
307
292
  )}
308
293
  {...props}
309
294
  >
310
- {headerVariant === "bar" ? (
311
- content
312
- ) : (
313
- <div
314
- className={cn(
315
- "mx-auto max-w-7xl space-y-6 px-8 py-8",
316
- integralState &&
317
- "flex min-h-full w-full flex-1 flex-col space-y-0",
318
- className,
319
- )}
320
- >
321
- {content}
322
- </div>
323
- )}
295
+ <div
296
+ className={cn(
297
+ "mx-auto max-w-7xl space-y-6 px-8 py-8",
298
+ integralState &&
299
+ "flex min-h-full w-full flex-1 flex-col space-y-0",
300
+ className,
301
+ )}
302
+ >
303
+ {content}
304
+ </div>
324
305
  </main>
325
306
  </PageStatePresenceProvider>
326
307
  </PageIntegralStateContext.Provider>
@@ -356,10 +337,12 @@ export function PageIntro({
356
337
  name="PageIntro"
357
338
  slot="page-intro"
358
339
  slots={{
340
+ leading: [PageBack, PageNavigation],
359
341
  title: PageTitle,
360
342
  description: PageDescription,
361
343
  actions: PageActions,
362
344
  }}
345
+ leadingPlacement="above"
363
346
  className={className}
364
347
  {...props}
365
348
  >
@@ -370,13 +353,9 @@ export function PageIntro({
370
353
  }
371
354
 
372
355
  /** A anatomia é a de `SurfaceHeader`, a mesma de `ContentHeader`; só os slots mudam de nome. */
373
- export interface PageHeaderProps extends HTMLAttributes<HTMLDivElement> {
374
- /** `default` fica no container; `bar` cria uma faixa compacta no topo da Page. */
375
- variant?: PageHeaderVariant;
376
- }
356
+ export interface PageHeaderProps extends HTMLAttributes<HTMLDivElement> {}
377
357
 
378
358
  export function PageHeader({
379
- variant = "default",
380
359
  className,
381
360
  children,
382
361
  ...props
@@ -386,7 +365,7 @@ export function PageHeader({
386
365
  requireParent(page !== null, "PageHeader", "Page");
387
366
  if (integralState) return <></>;
388
367
  return (
389
- <PageHeaderContext.Provider value={variant}>
368
+ <PageHeaderContext.Provider value="header">
390
369
  <SurfaceHeader
391
370
  name="PageHeader"
392
371
  slot="page"
@@ -396,20 +375,8 @@ export function PageHeader({
396
375
  description: PageDescription,
397
376
  actions: PageActions,
398
377
  }}
399
- leadingPlacement={variant === "bar" ? "inline" : "above"}
400
- contentClassName={
401
- variant === "bar"
402
- ? cn(
403
- pageBarContentClassName,
404
- "mx-auto max-w-7xl px-8",
405
- page?.containerClassName,
406
- )
407
- : undefined
408
- }
409
- className={cn(
410
- variant === "bar" && pageBarClassName,
411
- className,
412
- )}
378
+ leadingPlacement="inline"
379
+ className={className}
413
380
  {...props}
414
381
  >
415
382
  {children}
@@ -428,11 +395,9 @@ export function PageTitle({
428
395
  <h1
429
396
  data-slot="page-title"
430
397
  className={cn(
431
- variant === "bar"
432
- ? "truncate text-sm font-semibold"
433
- : variant === "intro"
434
- ? "text-3xl font-semibold tracking-tight"
435
- : surfaceHeaderClasses.page.title,
398
+ variant === "intro"
399
+ ? "text-3xl font-semibold tracking-tight"
400
+ : surfaceHeaderClasses.page.title,
436
401
  className,
437
402
  )}
438
403
  {...props}
@@ -445,20 +410,11 @@ export function PageDescription({
445
410
  ...props
446
411
  }: HTMLAttributes<HTMLParagraphElement>): ReactElement {
447
412
  const variant = useContext(PageHeaderContext);
448
- requireParent(
449
- variant !== null,
450
- "PageDescription",
451
- "PageHeader ou PageIntro",
452
- );
413
+ requireParent(variant !== null, "PageDescription", "PageHeader ou PageIntro");
453
414
  return (
454
415
  <p
455
416
  data-slot="page-description"
456
- className={cn(
457
- variant === "bar"
458
- ? "mt-0.5 truncate text-xs text-muted-foreground"
459
- : surfaceHeaderClasses.page.description,
460
- className,
461
- )}
417
+ className={cn(surfaceHeaderClasses.page.description, className)}
462
418
  {...props}
463
419
  />
464
420
  );
@@ -476,7 +432,7 @@ export function PageActions({
476
432
  <div
477
433
  data-slot="page-actions"
478
434
  className={cn(
479
- variant === "bar" || insideShell
435
+ insideShell || variant === "header"
480
436
  ? "flex shrink-0 items-center gap-2"
481
437
  : surfaceHeaderClasses.page.actions,
482
438
  className,
@@ -488,12 +444,14 @@ export function PageActions({
488
444
  return target === null ? actions : createPortal(actions, target);
489
445
  }
490
446
 
491
- export interface PageBackProps extends Omit<ComponentProps<"a">, "children" | "href"> {
492
- /** Nome do destino pai. Na barra, também alimenta tooltip e nome acessível. */
447
+ export interface PageBackProps extends Omit<
448
+ ComponentProps<"a">,
449
+ "children" | "href"
450
+ > {
451
+ /** Nome visível do destino pai e base do nome acessível. */
493
452
  children: ReactNode;
494
- /** Destino explícito. Obrigatório: sem `href` o elemento não é tabulável, não expõe
495
- * `role="link"` e o navegador descarta o nome acessível — na barra, onde não há texto
496
- * visível, o retorno simplesmente desapareceria para tecnologia assistiva. */
453
+ /** Destino explícito. Obrigatório: sem `href` o elemento não é tabulável nem expõe
454
+ * `role="link"`. */
497
455
  href: string;
498
456
  }
499
457
 
@@ -505,7 +463,7 @@ export function PageNavigation({
505
463
  requireParent(
506
464
  useContext(PageHeaderContext) !== null,
507
465
  "PageNavigation",
508
- "PageHeader",
466
+ "PageHeader ou PageIntro",
509
467
  );
510
468
  return (
511
469
  <div
@@ -523,34 +481,24 @@ export function PageBack({
523
481
  ...props
524
482
  }: PageBackProps): ReactElement {
525
483
  const variant = useContext(PageHeaderContext);
526
- requireParent(variant !== null, "PageBack", "PageHeader");
484
+ requireParent(variant !== null, "PageBack", "PageHeader ou PageIntro");
527
485
  const destination =
528
486
  typeof children === "string" ? children : "a página anterior";
529
487
  const accessibleLabel = ariaLabel ?? `Voltar para ${destination}`;
530
- const link = (
531
- <Button asChild variant="ghost" size={variant === "bar" ? "icon-sm" : "sm"}>
488
+ return (
489
+ <Button asChild variant="ghost" size="icon-sm">
532
490
  <a
533
491
  data-slot="page-back"
534
492
  aria-label={accessibleLabel}
493
+ title={accessibleLabel}
535
494
  className={className}
536
495
  {...props}
537
496
  >
538
497
  <ArrowLeft aria-hidden="true" />
539
- {variant === "default" && <span>{children}</span>}
498
+ <span className="sr-only">{children}</span>
540
499
  </a>
541
500
  </Button>
542
501
  );
543
-
544
- return variant === "bar" ? (
545
- <TooltipProvider delayDuration={200}>
546
- <Tooltip>
547
- <TooltipTrigger asChild>{link}</TooltipTrigger>
548
- <TooltipContent>{accessibleLabel}</TooltipContent>
549
- </Tooltip>
550
- </TooltipProvider>
551
- ) : (
552
- link
553
- );
554
502
  }
555
503
 
556
504
  export function PageBody({
@@ -563,11 +511,23 @@ export function PageBody({
563
511
  return (
564
512
  <div
565
513
  data-slot="page-body"
514
+ className={cn(integralState && "flex min-h-0 flex-1 flex-col", className)}
515
+ {...props}
516
+ />
517
+ );
518
+ }
519
+
520
+ export function PageFooter({
521
+ className,
522
+ ...props
523
+ }: HTMLAttributes<HTMLDivElement>): ReactElement {
524
+ const page = useContext(PageContext);
525
+ requireParent(page !== null, "PageFooter", "Page");
526
+ return (
527
+ <div
528
+ data-slot="page-footer"
566
529
  className={cn(
567
- page?.headerVariant === "bar" &&
568
- "mx-auto w-full max-w-7xl flex-1 px-8 py-8",
569
- page?.headerVariant === "bar" && page.containerClassName,
570
- integralState && "flex min-h-0 flex-1 flex-col",
530
+ "mt-auto flex shrink-0 items-center justify-end border-t bg-muted/30 px-8 py-4",
571
531
  className,
572
532
  )}
573
533
  {...props}