@softize/opus 27.0.0 → 28.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 +73 -0
  2. package/bin/lib/check.mjs +139 -30
  3. package/docs/adr/0015-action-size-follows-interaction-density.md +2 -0
  4. package/docs/adr/0017-ui-assumes-a-fixed-desktop-layout.md +14 -8
  5. package/docs/adr/0029-page-header-is-the-single-page-introduction.md +3 -1
  6. package/docs/adr/0057-doc-browser-permite-busca-no-chrome-do-consumidor.md +39 -5
  7. package/docs/adr/0060-surface-composition-separates-chrome-and-intro.md +161 -0
  8. package/docs/adr/README.md +2 -0
  9. package/package.json +1 -1
  10. package/registry/skills/build-opus-ui/SKILL.md +11 -11
  11. package/registry/skills/build-opus-ui/references/evaluations.md +10 -0
  12. package/registry/skills/build-opus-ui/references/ui-patterns.md +17 -15
  13. package/src/ui/components/patterns/list.tsx +62 -53
  14. package/src/ui/components/patterns/page.tsx +146 -38
  15. package/src/ui/components/patterns/presentation.tsx +44 -59
  16. package/src/ui/components/patterns/structural-header.tsx +42 -6
  17. package/src/ui/components/patterns/surface-anatomy.ts +52 -22
  18. package/src/ui/components/patterns/surface.tsx +2 -3
  19. package/src/ui/components/primitives/control.ts +2 -2
  20. package/src/ui/components/primitives/dialog.tsx +7 -2
  21. package/src/ui/components/primitives/drawer.tsx +7 -2
  22. package/src/ui/components/primitives/icon-picker.tsx +271 -34
  23. package/src/ui/components/primitives/pagination.tsx +5 -4
  24. package/src/ui/components/primitives/tabs.tsx +15 -2
  25. package/src/ui/components/primitives/toggle.tsx +1 -1
  26. package/src/ui/docs/DocBrowser.tsx +122 -12
  27. package/src/ui/docs/content/action-list.md +11 -2
  28. package/src/ui/docs/content/button.md +7 -7
  29. package/src/ui/docs/content/compositions.md +35 -23
  30. package/src/ui/docs/content/dialog.md +18 -6
  31. package/src/ui/docs/content/documentation-portal.md +40 -1
  32. package/src/ui/docs/content/drawer.md +17 -5
  33. package/src/ui/docs/content/icon-picker.md +37 -19
  34. package/src/ui/docs/content/input.md +1 -1
  35. package/src/ui/docs/content/page.md +169 -69
  36. package/src/ui/docs/content/pagination.md +7 -8
  37. package/src/ui/docs/content/presentation.md +4 -3
  38. package/src/ui/docs/content/surface.md +8 -5
  39. package/src/ui/docs/content/tabs.md +12 -4
  40. package/src/ui/docs/types.ts +2 -0
  41. package/src/ui/meta.ts +1 -1
  42. package/src/ui/react.tsx +9 -1
@@ -110,7 +110,9 @@ export function StructuralHeader({
110
110
  ...props
111
111
  }: StructuralHeaderProps): ReactElement {
112
112
  const Wrapper = Root === "legend" ? "span" : "div";
113
- const nodes = flatten(children);
113
+ const nodes = flatten(children).filter(
114
+ (node) => typeof node !== "string" || node.trim() !== "",
115
+ );
114
116
  const of = (type: ElementType) =>
115
117
  nodes.filter((node) => isValidElement(node) && node.type === type);
116
118
  const leadingTypes: readonly ElementType[] =
@@ -305,6 +307,7 @@ export function StructuralHeader({
305
307
  className={cn(
306
308
  actionSlots.length > 0 &&
307
309
  "flex flex-row items-end justify-between gap-4",
310
+ rowClassName,
308
311
  )}
309
312
  >
310
313
  {heading}
@@ -314,16 +317,24 @@ export function StructuralHeader({
314
317
  ) : leading.length > 0 ? (
315
318
  <Wrapper
316
319
  data-slot={`${slot}-header-row`}
317
- className="flex min-w-0 items-center gap-4"
320
+ className={cn("flex min-w-0 items-center gap-4", rowClassName)}
318
321
  >
319
- <Wrapper
320
- data-slot={`${slot}-navigation`}
321
- className="min-w-0 flex-1"
322
- >
322
+ <Wrapper data-slot={`${slot}-navigation`} className="min-w-0 flex-1">
323
323
  {leading}
324
324
  </Wrapper>
325
325
  {actionSlots}
326
326
  </Wrapper>
327
+ ) : rowClassName !== undefined ? (
328
+ <Wrapper
329
+ data-slot={`${slot}-header-row`}
330
+ className={cn(
331
+ rowClassName,
332
+ heading ? "justify-between" : "justify-end",
333
+ )}
334
+ >
335
+ {heading}
336
+ {actionSlots}
337
+ </Wrapper>
327
338
  ) : (
328
339
  <>
329
340
  {heading}
@@ -360,3 +371,28 @@ export function StructuralHeader({
360
371
  </Root>
361
372
  );
362
373
  }
374
+
375
+ /** A intro visível abre o corpo; wrappers opacos são verificados quando materializam seus slots. */
376
+ export function validateSurfaceIntro(
377
+ children: ReactNode,
378
+ intro: ElementType,
379
+ name: string,
380
+ ): void {
381
+ const flatten = (nodes: ReactNode): ReactNode[] =>
382
+ Children.toArray(nodes).flatMap((node) =>
383
+ isValidElement(node) && node.type === Fragment
384
+ ? flatten((node.props as { children?: ReactNode }).children)
385
+ : [node],
386
+ );
387
+ const nodes = flatten(children).filter(
388
+ (node) => typeof node !== "string" || node.trim() !== "",
389
+ );
390
+ const indexes = nodes.flatMap((node, index) =>
391
+ isValidElement(node) && node.type === intro ? [index] : [],
392
+ );
393
+ if (indexes.length > 1 || (indexes.length === 1 && indexes[0] !== 0)) {
394
+ throw new Error(
395
+ `${name} aceita uma única intro como primeiro bloco do corpo.`,
396
+ );
397
+ }
398
+ }
@@ -27,7 +27,7 @@ import {
27
27
  } from "react";
28
28
  import { FillBoundary } from "./fill-region.ts";
29
29
 
30
- export type SurfaceFamily = "surface" | "dialog" | "drawer";
30
+ export type SurfaceFamily = "surface" | "dialog" | "drawer" | "page";
31
31
  export type SurfaceLayout = "continuous" | "sectioned";
32
32
  export type SurfaceRegion = "header" | "body" | "footer";
33
33
 
@@ -64,18 +64,22 @@ interface FamilyAnatomy {
64
64
  description: string;
65
65
  /** Espaço entre mídia, título e ações na linha do header. */
66
66
  headerGap: string;
67
+ /** Altura mínima da linha, equivalente ao controle padrão da família. */
68
+ headerMinHeight: string;
67
69
  /** Faixa do rodapé no layout seccionado, quando a família declara uma. */
68
70
  footerBand: string;
69
71
  }
70
72
 
71
73
  /**
72
74
  * O recuo seccionado de Dialog e Drawer: 1rem, a densidade das superfícies de operação.
73
- * A Surface usa o mesmo recuo lateral, para conviver com um drawer sem degrau visível, e
74
- * faixas de header e footer mais baixas.
75
+ * O header é comum à família; corpos e rodapés conservam o ritmo de cada superfície.
75
76
  */
77
+ const structuralHeaderPadding = "p-4";
78
+ const structuralHeaderMinHeight = "min-h-8";
79
+
76
80
  const sectionedRegions: RegionAnatomy = {
77
- header: "p-4",
78
- body: "px-4 py-6",
81
+ header: structuralHeaderPadding,
82
+ body: "p-4",
79
83
  footer: "p-4",
80
84
  };
81
85
 
@@ -83,25 +87,47 @@ const sectionedRegions: RegionAnatomy = {
83
87
  * Ritmo entre blocos diretos do conteúdo; relações internas pertencem a um wrapper filho.
84
88
  * Os blocos não encolhem: o body é quem rola quando a família tem altura limitada.
85
89
  */
86
- const structuralBodyRhythm = "flex flex-col gap-6 *:shrink-0";
90
+ export const structuralBodyRhythm = "flex flex-col gap-4 *:shrink-0";
91
+ export const structuralRegionSpacing = "space-y-4";
92
+ export const structuralRegionGap = "gap-4";
87
93
 
88
94
  export const surfaceAnatomy: Record<SurfaceFamily, FamilyAnatomy> = {
95
+ page: {
96
+ regions: {
97
+ continuous: {
98
+ header: structuralHeaderPadding,
99
+ body: "px-8 pt-0 pb-0 [[data-slot=page-body]:first-child>&]:pt-4",
100
+ footer: "px-8 py-4",
101
+ },
102
+ sectioned: {
103
+ header: sectionedRegions.header,
104
+ body: "px-8 pt-4 pb-0 [[data-slot=page-body]:has(+[data-slot=page-footer])>&]:pb-4",
105
+ footer: "px-8 py-4",
106
+ },
107
+ },
108
+ title: {
109
+ continuous: "text-2xl font-semibold",
110
+ sectioned: "text-2xl font-semibold",
111
+ },
112
+ description: "text-sm text-muted-foreground",
113
+ headerGap: "gap-4",
114
+ headerMinHeight: structuralHeaderMinHeight,
115
+ footerBand: "bg-muted/30",
116
+ },
89
117
  // Medidas da Surface delimitada (com moldura ou elevação). Sem delimitação, as regiões
90
118
  // não recebem recuo: a superfície flui alinhada ao hospedeiro (ADR 0045).
91
119
  surface: {
92
120
  regions: {
93
- // O espaço entre regiões soma o ritmo estrutural. Quando o header fica sozinho, ele
94
- // reassume o respiro compacto da faixa; quando o body abre ou fecha a superfície,
95
- // recebe o respiro inteiro do conteúdo.
121
+ // No contínuo, a região anterior fornece o intervalo; a primeira abre o recuo.
96
122
  continuous: {
97
- header: "px-4 pt-4 pb-6 [&:last-child]:pb-4",
98
- body: "px-4 pt-0 pb-6 [&:first-child]:pt-6",
123
+ header: structuralHeaderPadding,
124
+ body: "px-4 pt-0 pb-4 [&:first-child]:pt-4",
99
125
  footer: "px-4 pt-0 pb-4 [&:first-child]:pt-4",
100
126
  },
101
127
  sectioned: {
102
- header: "px-4 py-3",
103
- body: "px-4 py-6",
104
- footer: "px-4 py-3",
128
+ header: structuralHeaderPadding,
129
+ body: "p-4",
130
+ footer: "p-4",
105
131
  },
106
132
  },
107
133
  // Uma escala só: o nível 1 troca a apresentação inteira (ADR 0038), nunca o layout.
@@ -111,14 +137,15 @@ export const surfaceAnatomy: Record<SurfaceFamily, FamilyAnatomy> = {
111
137
  },
112
138
  description: "mt-0.5 text-xs text-muted-foreground",
113
139
  headerGap: "gap-2",
140
+ headerMinHeight: structuralHeaderMinHeight,
114
141
  footerBand: "",
115
142
  },
116
143
  dialog: {
117
144
  regions: {
118
145
  continuous: {
119
- header: "p-4 pb-3 [&:last-child]:pb-4",
120
- body: "px-4 py-3 [&:last-child]:pb-6 [&:first-child]:pt-6",
121
- footer: "p-4 pt-3",
146
+ header: structuralHeaderPadding,
147
+ body: "px-4 pt-0 pb-4 [&:first-child]:pt-4",
148
+ footer: "px-4 pt-0 pb-4 [&:first-child]:pt-4",
122
149
  },
123
150
  sectioned: sectionedRegions,
124
151
  },
@@ -128,14 +155,15 @@ export const surfaceAnatomy: Record<SurfaceFamily, FamilyAnatomy> = {
128
155
  },
129
156
  description: "text-sm text-muted-foreground",
130
157
  headerGap: "gap-4",
158
+ headerMinHeight: structuralHeaderMinHeight,
131
159
  footerBand: "bg-muted/20 dark:bg-input/20",
132
160
  },
133
161
  drawer: {
134
162
  regions: {
135
163
  continuous: {
136
- header: "p-4 pb-3 [&:last-child]:pb-4",
137
- body: "px-4 py-3 [&:last-child]:pb-6 [&:first-child]:pt-6",
138
- footer: "p-4 pt-3",
164
+ header: structuralHeaderPadding,
165
+ body: "px-4 pt-0 pb-4 [&:first-child]:pt-4",
166
+ footer: "px-4 pt-0 pb-4 [&:first-child]:pt-4",
139
167
  },
140
168
  sectioned: sectionedRegions,
141
169
  },
@@ -145,6 +173,7 @@ export const surfaceAnatomy: Record<SurfaceFamily, FamilyAnatomy> = {
145
173
  },
146
174
  description: "text-sm text-muted-foreground",
147
175
  headerGap: "gap-4",
176
+ headerMinHeight: structuralHeaderMinHeight,
148
177
  footerBand: "bg-muted/20 dark:bg-input/20",
149
178
  },
150
179
  };
@@ -190,7 +219,7 @@ export function surfaceDescriptionClasses(family: SurfaceFamily): string {
190
219
  * `flex-1` mantém o heading como região elástica e as ações no extremo oposto.
191
220
  */
192
221
  export function surfaceHeaderRowClasses(family: SurfaceFamily): string {
193
- return `flex min-w-0 flex-1 items-center ${surfaceAnatomy[family].headerGap}`;
222
+ return `flex min-w-0 flex-1 items-center ${surfaceAnatomy[family].headerGap} ${surfaceAnatomy[family].headerMinHeight}`;
194
223
  }
195
224
 
196
225
  /** Ações do header e do footer: fora do fluxo de compressão, na escala densa. */
@@ -207,7 +236,7 @@ export const surfaceActionsClasses = "flex shrink-0 items-center gap-2";
207
236
  */
208
237
  export const surfaceLegendClasses = {
209
238
  notch: "ml-2 w-auto px-2",
210
- afterNotch: "pt-3",
239
+ afterNotch: "pt-4",
211
240
  band: "float-left w-full",
212
241
  afterBand: "clear-left",
213
242
  } as const;
@@ -241,6 +270,7 @@ export function SurfaceLayoutProvider({
241
270
  }
242
271
 
243
272
  const familyDefaultLayout: Record<SurfaceFamily, SurfaceLayout> = {
273
+ page: "sectioned",
244
274
  surface: "continuous",
245
275
  dialog: "sectioned",
246
276
  drawer: "sectioned",
@@ -49,6 +49,7 @@ import {
49
49
  surfaceHeaderRowClasses,
50
50
  surfaceLegendClasses,
51
51
  surfaceRegionClasses,
52
+ structuralRegionSpacing,
52
53
  surfaceTitleClasses,
53
54
  type SurfaceLayout,
54
55
  } from "./surface-anatomy.ts";
@@ -343,9 +344,7 @@ export function Surface({
343
344
  framed && "has-[>[data-flush=true]]:overflow-hidden",
344
345
  raised &&
345
346
  "bg-card text-card-foreground [--surface-background:var(--card)]",
346
- structured &&
347
- !bounded &&
348
- (variant === "page" ? "space-y-6" : "space-y-3"),
347
+ structured && !bounded && structuralRegionSpacing,
349
348
  link && fill.filling && fillRegionClassName,
350
349
  className,
351
350
  )}
@@ -7,7 +7,7 @@ export type ControlShape = 'default' | 'pill'
7
7
  * Cada componente aceita o subconjunto coerente com seu papel, nunca um nome com outra medida.
8
8
  *
9
9
  * - Altura de linha (`xs` · `sm` · `default` · `lg`): 1.5 · 2 · 2.25 · 2.5rem.
10
- * - Quadrado só-ícone (`icon-xs` · `icon-sm` · `icon` · `icon-lg`): 1.5 · 1.75 · 2.25 · 2.5rem.
10
+ * - Quadrado só-ícone (`icon-xs` · `icon-sm` · `icon` · `icon-lg`): 1.5 · 2 · 2.25 · 2.5rem.
11
11
  * `icon-xs` é o degrau da ação que mora dentro de uma linha densa. `icon-sm` atende
12
12
  * composições compactas que ainda precisam de mais presença.
13
13
  * - Glifo (o svg dentro do controle daquele tamanho): 0.875 · 0.875 · 1 · 1.25rem. O quadrado
@@ -27,7 +27,7 @@ export const controlHeight: Record<ControlSize, string> = {
27
27
 
28
28
  export const controlSquare: Record<ControlIconSize, string> = {
29
29
  'icon-xs': 'size-6',
30
- 'icon-sm': 'size-7',
30
+ 'icon-sm': 'size-8',
31
31
  icon: 'size-9',
32
32
  'icon-lg': 'size-10',
33
33
  }
@@ -14,7 +14,11 @@ import {
14
14
  } from "../patterns/surface-anatomy.ts";
15
15
  import { FillBoundary } from "../patterns/fill-region.ts";
16
16
  import { headingTags, type HeadingLevel } from "../patterns/heading-level.ts";
17
- import { StructuralHeader } from "../patterns/structural-header.tsx";
17
+ import {
18
+ StructuralHeader,
19
+ structuralHeaderClasses,
20
+ validateSurfaceIntro,
21
+ } from "../patterns/structural-header.tsx";
18
22
  import {
19
23
  StructuralMedia,
20
24
  type StructuralMediaProps,
@@ -698,6 +702,7 @@ export function DialogBody({
698
702
  }: React.ComponentProps<"div"> & { flush?: boolean }) {
699
703
  const { mode } = useDialogContext();
700
704
  const layout = useSurfaceLayout("dialog");
705
+ validateSurfaceIntro(props.children, DialogIntro, "DialogBody");
701
706
  return (
702
707
  <div
703
708
  data-slot="dialog-body"
@@ -761,7 +766,7 @@ export function DialogTitle({
761
766
  ? "min-w-0 whitespace-normal"
762
767
  : "min-w-0 truncate",
763
768
  intro
764
- ? "text-xl font-semibold leading-tight tracking-tight"
769
+ ? structuralHeaderClasses.page.title
765
770
  : surfaceTitleClasses("dialog", layout),
766
771
  className,
767
772
  );
@@ -13,7 +13,11 @@ import {
13
13
  type SurfaceLayout,
14
14
  } from "../patterns/surface-anatomy.ts";
15
15
  import { headingTags, type HeadingLevel } from "../patterns/heading-level.ts";
16
- import { StructuralHeader } from "../patterns/structural-header.tsx";
16
+ import {
17
+ StructuralHeader,
18
+ structuralHeaderClasses,
19
+ validateSurfaceIntro,
20
+ } from "../patterns/structural-header.tsx";
17
21
  import {
18
22
  StructuralMedia,
19
23
  type StructuralMediaProps,
@@ -361,6 +365,7 @@ function DrawerBody({
361
365
  ...props
362
366
  }: React.ComponentProps<"div"> & { flush?: boolean }) {
363
367
  const layout = useSurfaceLayout("drawer");
368
+ validateSurfaceIntro(props.children, DrawerIntro, "DrawerBody");
364
369
  return (
365
370
  <div
366
371
  data-slot="drawer-body"
@@ -408,7 +413,7 @@ function DrawerTitle({
408
413
  const classes = cn(
409
414
  intro ? "min-w-0 whitespace-normal" : "min-w-0 truncate",
410
415
  intro
411
- ? "text-xl font-semibold leading-tight tracking-tight"
416
+ ? structuralHeaderClasses.page.title
412
417
  : surfaceTitleClasses("drawer", layout),
413
418
  className,
414
419
  );