@softize/opus 18.5.0 → 19.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 (32) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/docs/adr/0023-surfaces-share-one-anatomy-and-two-layouts.md +293 -0
  3. package/package.json +1 -1
  4. package/src/ui/components/patterns/content-header.tsx +1 -9
  5. package/src/ui/components/patterns/heading-level.ts +16 -0
  6. package/src/ui/components/patterns/list.tsx +34 -15
  7. package/src/ui/components/patterns/pane.tsx +294 -0
  8. package/src/ui/components/patterns/presentation.tsx +29 -17
  9. package/src/ui/components/patterns/sidebar.tsx +13 -72
  10. package/src/ui/components/patterns/split.tsx +8 -35
  11. package/src/ui/components/patterns/surface-anatomy.ts +219 -0
  12. package/src/ui/components/patterns/surface-header.tsx +51 -6
  13. package/src/ui/components/patterns/surface-media.tsx +69 -0
  14. package/src/ui/components/primitives/card.tsx +91 -29
  15. package/src/ui/components/primitives/command.tsx +12 -4
  16. package/src/ui/components/primitives/detail.tsx +10 -13
  17. package/src/ui/components/primitives/dialog.tsx +138 -35
  18. package/src/ui/components/primitives/drawer.tsx +122 -23
  19. package/src/ui/components/primitives/metric-card.tsx +98 -39
  20. package/src/ui/docs/content/card.md +221 -30
  21. package/src/ui/docs/content/content.md +6 -0
  22. package/src/ui/docs/content/dialog.md +115 -7
  23. package/src/ui/docs/content/drawer.md +136 -9
  24. package/src/ui/docs/content/metric-card.md +57 -18
  25. package/src/ui/docs/content/page.md +8 -0
  26. package/src/ui/docs/content/pane.md +250 -0
  27. package/src/ui/docs/content/sidebar.md +22 -43
  28. package/src/ui/docs/content/skeleton.md +7 -3
  29. package/src/ui/docs/content/split.md +16 -21
  30. package/src/ui/docs/registry.tsx +8 -2
  31. package/src/ui/meta.ts +8 -2
  32. package/src/ui/react.tsx +14 -0
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Pane — a superfície encaixável de um Split: a coluna ou faixa que hospeda navegação,
3
+ * lista ou conteúdo, com regiões fixas no topo e no rodapé e um corpo que rola.
4
+ *
5
+ * Até a ADR 0023 esta família vivia partida: a raiz em `split.tsx`, os slots em
6
+ * `sidebar.tsx`, sem validação entre eles e com padding assimétrico (header sem recuo,
7
+ * footer com `p-2`). Agora raiz e slots moram aqui e derivam a anatomia da mesma tabela
8
+ * das demais famílias.
9
+ *
10
+ * <Pane>
11
+ * <PaneHeader><PaneMedia>…</PaneMedia><PaneTitle>Conversas</PaneTitle><PaneActions>…</PaneActions></PaneHeader>
12
+ * <PaneBody>…</PaneBody>
13
+ * <PaneFooter>…</PaneFooter>
14
+ * </Pane>
15
+ *
16
+ * `inset` continua existindo para o pane que NÃO usa slots — a caixa simples que recebe
17
+ * conteúdo direto. Quando há slots, o recuo é de cada região, e o inset do pane vira
18
+ * `none` sozinho: o divisor precisa alcançar a borda.
19
+ */
20
+ import {
21
+ Children,
22
+ Fragment,
23
+ isValidElement,
24
+ type CSSProperties,
25
+ type ReactElement,
26
+ type ReactNode,
27
+ } from "react";
28
+
29
+ import { cn } from "../../lib/cn.ts";
30
+ import { headingTags, type HeadingLevel } from "./heading-level.ts";
31
+ import {
32
+ SurfaceLayoutProvider,
33
+ surfaceActionsClasses,
34
+ surfaceDescriptionClasses,
35
+ surfaceHeaderRowClasses,
36
+ surfaceRegionClasses,
37
+ surfaceTitleClasses,
38
+ useSurfaceLayout,
39
+ type SurfaceLayout,
40
+ } from "./surface-anatomy.ts";
41
+ import { SurfaceHeader } from "./surface-header.tsx";
42
+ import { SurfaceMedia, type SurfaceMediaProps } from "./surface-media.tsx";
43
+
44
+ export type PaneSize =
45
+ | number
46
+ | `${number}%`
47
+ | `${number}rem`
48
+ | `${number}em`
49
+ | `${number}vh`
50
+ | `${number}vw`
51
+ | `${number}px`;
52
+
53
+ // Números continuam percentuais por compatibilidade com Split/Pane. Strings preservam
54
+ // a unidade explícita para layouts que precisam de um limite físico, como `28rem`.
55
+ export function paneSizeToCss(value: PaneSize): string {
56
+ return typeof value === "number" ? `${value}%` : value;
57
+ }
58
+
59
+ export interface PaneProps {
60
+ /** Identidade estável do painel dentro do grupo. */
61
+ id?: string;
62
+ /** Tamanho inicial. Números representam porcentagens; strings aceitam %, rem, em, vh, vw e px. */
63
+ initialSize?: PaneSize;
64
+ /** Tamanho mínimo quando o split é redimensionável. */
65
+ minSize?: PaneSize;
66
+ /** Tamanho máximo quando o split é redimensionável. */
67
+ maxSize?: PaneSize;
68
+ /** Ocupa o espaço remanescente no layout simples. */
69
+ grow?: boolean;
70
+ /**
71
+ * Respiro interno do pane sem slots. Com header, body ou footer como filhos DIRETOS o
72
+ * default passa a `none`, porque aí o recuo é de cada região. Quando os slots vêm
73
+ * dentro de outro componente — a `Sidebar`, por exemplo —, o pane não os enxerga:
74
+ * declare `inset="none"` você mesmo.
75
+ */
76
+ inset?: "none" | "sm" | "md" | "lg";
77
+ /** Anatomia das regiões: `sectioned` (default) ou `continuous` — ADR 0023. */
78
+ layout?: SurfaceLayout;
79
+ className?: string;
80
+ children: ReactNode;
81
+ }
82
+
83
+ const insets = { none: "", sm: "p-2", md: "p-4", lg: "p-6" } as const;
84
+
85
+ /** Expande fragments de primeiro nível antes de procurar os slots. */
86
+ function flatten(children: ReactNode): ReactNode[] {
87
+ return Children.toArray(children).flatMap((node) =>
88
+ isValidElement(node) && node.type === Fragment
89
+ ? Children.toArray((node.props as { children?: ReactNode }).children)
90
+ : [node],
91
+ );
92
+ }
93
+
94
+ function hasStructuralSlots(children: ReactNode): boolean {
95
+ return flatten(children).some(
96
+ (node) =>
97
+ isValidElement(node) &&
98
+ (node.type === PaneHeader ||
99
+ node.type === PaneBody ||
100
+ node.type === PaneContent ||
101
+ node.type === PaneFooter),
102
+ );
103
+ }
104
+
105
+ /** Um conteúdo encaixável. Sua posição é dada pela ordem dentro de <Split>. */
106
+ export function Pane({
107
+ id,
108
+ initialSize,
109
+ grow = false,
110
+ inset,
111
+ layout = "sectioned",
112
+ className,
113
+ children,
114
+ }: PaneProps): ReactElement {
115
+ const style: CSSProperties | undefined =
116
+ initialSize === undefined
117
+ ? undefined
118
+ : { flexBasis: paneSizeToCss(initialSize) };
119
+ const structural = hasStructuralSlots(children);
120
+ const resolvedInset = inset ?? (structural ? "none" : "md");
121
+ return (
122
+ <SurfaceLayoutProvider value={layout}>
123
+ <div
124
+ id={id === undefined ? undefined : String(id)}
125
+ data-slot="pane"
126
+ data-inset={resolvedInset}
127
+ data-layout={layout}
128
+ className={cn(
129
+ "min-h-0 min-w-0 shrink-0",
130
+ grow && "flex-1",
131
+ // Com slots o pane é uma coluna: header fixo, corpo que rola, footer fixo.
132
+ structural && "flex flex-col",
133
+ insets[resolvedInset],
134
+ className,
135
+ )}
136
+ style={style}
137
+ >
138
+ {children}
139
+ </div>
140
+ </SurfaceLayoutProvider>
141
+ );
142
+ }
143
+
144
+ /** Faixa fixa no topo de um pane — marca, contexto ou ações locais. */
145
+ export function PaneHeader({
146
+ className,
147
+ children,
148
+ ...props
149
+ }: React.ComponentProps<"div">): ReactElement {
150
+ const layout = useSurfaceLayout("pane");
151
+ return (
152
+ <SurfaceHeader
153
+ name="PaneHeader"
154
+ slot="pane"
155
+ slots={{
156
+ media: PaneMedia,
157
+ title: PaneTitle,
158
+ description: PaneDescription,
159
+ actions: PaneActions,
160
+ }}
161
+ leadingPlacement="inline"
162
+ titleRequired={false}
163
+ // O pane é a superfície genérica: além dos slots, o header hospeda marca, busca ou
164
+ // chrome que não são título nem ação (ADR 0023).
165
+ allowFreeContent
166
+ rowClassName={surfaceHeaderRowClasses("pane")}
167
+ className={cn(surfaceRegionClasses("pane", layout, "header"), className)}
168
+ {...props}
169
+ >
170
+ {children}
171
+ </SurfaceHeader>
172
+ );
173
+ }
174
+
175
+ /** Moldura de ícone ou imagem que abre a linha do header. */
176
+ export function PaneMedia(props: SurfaceMediaProps): ReactElement {
177
+ return <SurfaceMedia slot="pane-media" size="xs" {...props} />;
178
+ }
179
+
180
+ export function PaneTitle({
181
+ className,
182
+ level = 2,
183
+ ...props
184
+ }: React.ComponentProps<"h2"> & {
185
+ /** Hierarquia do documento (`h1`–`h6`). O tamanho vem do layout, nunca do nível. */
186
+ level?: HeadingLevel;
187
+ }): ReactElement {
188
+ const layout = useSurfaceLayout("pane");
189
+ const Comp = headingTags[level];
190
+ return (
191
+ <Comp
192
+ data-slot="pane-title"
193
+ className={cn(
194
+ "min-w-0 truncate",
195
+ surfaceTitleClasses("pane", layout),
196
+ className,
197
+ )}
198
+ {...props}
199
+ />
200
+ );
201
+ }
202
+
203
+ export function PaneDescription({
204
+ className,
205
+ ...props
206
+ }: React.ComponentProps<"p">): ReactElement {
207
+ return (
208
+ <p
209
+ data-slot="pane-description"
210
+ className={cn("truncate", surfaceDescriptionClasses("pane"), className)}
211
+ {...props}
212
+ />
213
+ );
214
+ }
215
+
216
+ /** Ações do header — a família posiciona no extremo oposto ao título. */
217
+ export function PaneActions({
218
+ className,
219
+ ...props
220
+ }: React.ComponentProps<"div">): ReactElement {
221
+ return (
222
+ <div
223
+ data-slot="pane-actions"
224
+ className={cn(surfaceActionsClasses, className)}
225
+ {...props}
226
+ />
227
+ );
228
+ }
229
+
230
+ /** Região rolável que ocupa o espaço restante de um pane. */
231
+ export function PaneBody({
232
+ className,
233
+ children,
234
+ ...props
235
+ }: React.ComponentProps<"div">): ReactElement {
236
+ const layout = useSurfaceLayout("pane");
237
+ return (
238
+ <div
239
+ data-slot="pane-body"
240
+ className={cn(
241
+ "min-h-0 flex-1 overflow-y-auto",
242
+ surfaceRegionClasses("pane", layout, "body"),
243
+ className,
244
+ )}
245
+ {...props}
246
+ >
247
+ {children}
248
+ </div>
249
+ );
250
+ }
251
+
252
+ /** @deprecated Use PaneBody. Alias mantido para migração; sai numa próxima versão major. */
253
+ export function PaneContent({
254
+ className,
255
+ children,
256
+ ...props
257
+ }: React.ComponentProps<"div">): ReactElement {
258
+ const layout = useSurfaceLayout("pane");
259
+ return (
260
+ <div
261
+ data-slot="pane-content"
262
+ className={cn(
263
+ "min-h-0 flex-1 overflow-y-auto",
264
+ surfaceRegionClasses("pane", layout, "body"),
265
+ className,
266
+ )}
267
+ {...props}
268
+ >
269
+ {children}
270
+ </div>
271
+ );
272
+ }
273
+
274
+ /** Faixa fixa no rodapé de um pane para ações persistentes. */
275
+ export function PaneFooter({
276
+ className,
277
+ children,
278
+ ...props
279
+ }: React.ComponentProps<"div">): ReactElement {
280
+ const layout = useSurfaceLayout("pane");
281
+ return (
282
+ <div
283
+ data-slot="pane-footer"
284
+ className={cn(
285
+ "shrink-0",
286
+ surfaceRegionClasses("pane", layout, "footer"),
287
+ className,
288
+ )}
289
+ {...props}
290
+ >
291
+ {children}
292
+ </div>
293
+ );
294
+ }
@@ -42,18 +42,22 @@ import { Copyable } from "../primitives/copyable.tsx";
42
42
  import { DetailField, DetailGroup } from "../primitives/detail.tsx";
43
43
  import {
44
44
  Dialog,
45
+ DialogActions,
45
46
  DialogBody,
46
47
  DialogContent,
47
48
  DialogFooter,
48
49
  DialogHeader,
50
+ DialogNavigation,
49
51
  DialogTitle,
50
52
  } from "../primitives/dialog.tsx";
51
53
  import {
52
54
  Drawer,
55
+ DrawerActions,
53
56
  DrawerBody,
54
57
  DrawerContent,
55
58
  DrawerFooter,
56
59
  DrawerHeader,
60
+ DrawerNavigation,
57
61
  DrawerTitle,
58
62
  } from "../primitives/drawer.tsx";
59
63
  import {
@@ -253,6 +257,11 @@ function PresentationFrame({
253
257
  )}
254
258
  </PageHeader>
255
259
  )}
260
+ {contentOwnsHeading ? null : (
261
+ <PageIntro>
262
+ <PageTitle>{title}</PageTitle>
263
+ </PageIntro>
264
+ )}
256
265
  <PageBody className="min-h-0 flex-1 overflow-hidden">
257
266
  {withAssistant(
258
267
  <div
@@ -261,11 +270,6 @@ function PresentationFrame({
261
270
  layout === "fill" ? "overflow-y-hidden" : "overflow-y-auto",
262
271
  )}
263
272
  >
264
- {contentOwnsHeading ? null : (
265
- <PageIntro>
266
- <PageTitle>{title}</PageTitle>
267
- </PageIntro>
268
- )}
269
273
  {body}
270
274
  </div>,
271
275
  )}
@@ -334,14 +338,18 @@ function PresentationFrame({
334
338
  >
335
339
  <div className="flex h-full min-h-0 flex-col">
336
340
  <DialogHeader>
337
- {navigation}
338
- <DialogTitle className="truncate">{title}</DialogTitle>
341
+ {navigation === undefined ? null : (
342
+ <DialogNavigation>{navigation}</DialogNavigation>
343
+ )}
344
+ <DialogTitle>{title}</DialogTitle>
339
345
  {headerActions === undefined &&
340
346
  assistantAction === undefined ? null : (
341
- <PresentationActions>
342
- {headerActions}
343
- {assistantAction}
344
- </PresentationActions>
347
+ <DialogActions>
348
+ <PresentationActions>
349
+ {headerActions}
350
+ {assistantAction}
351
+ </PresentationActions>
352
+ </DialogActions>
345
353
  )}
346
354
  </DialogHeader>
347
355
  {withAssistant(<DialogBody>{body}</DialogBody>)}
@@ -380,14 +388,18 @@ function PresentationFrame({
380
388
  >
381
389
  <div className="flex h-full min-h-0 flex-col">
382
390
  <DrawerHeader>
383
- {navigation}
384
- <DrawerTitle className="truncate">{title}</DrawerTitle>
391
+ {navigation === undefined ? null : (
392
+ <DrawerNavigation>{navigation}</DrawerNavigation>
393
+ )}
394
+ <DrawerTitle>{title}</DrawerTitle>
385
395
  {headerActions === undefined &&
386
396
  assistantAction === undefined ? null : (
387
- <PresentationActions>
388
- {headerActions}
389
- {assistantAction}
390
- </PresentationActions>
397
+ <DrawerActions>
398
+ <PresentationActions>
399
+ {headerActions}
400
+ {assistantAction}
401
+ </PresentationActions>
402
+ </DrawerActions>
391
403
  )}
392
404
  </DrawerHeader>
393
405
  {withAssistant(<DrawerBody>{body}</DrawerBody>)}
@@ -7,6 +7,19 @@ import {
7
7
  import { ChevronRight } from "lucide-react";
8
8
  import { useOverflowing } from "../../lib/overflow.ts";
9
9
  import { cn } from "../../lib/cn.ts";
10
+
11
+ // Os slots de Pane moram em `pane.tsx` desde a ADR 0023. A Sidebar continua sendo uma
12
+ // das superfícies que os compõe, então reexporta o que antes definia.
13
+ export {
14
+ PaneActions,
15
+ PaneBody,
16
+ PaneContent,
17
+ PaneDescription,
18
+ PaneFooter,
19
+ PaneHeader,
20
+ PaneMedia,
21
+ PaneTitle,
22
+ } from "./pane.tsx";
10
23
  import { focusRing } from "../primitives/control.ts";
11
24
  import {
12
25
  Tooltip,
@@ -58,78 +71,6 @@ export function Sidebar({
58
71
  );
59
72
  }
60
73
 
61
- /** Faixa fixa no topo de um pane — marca, contexto ou ações locais. */
62
- export function PaneHeader({
63
- className,
64
- children,
65
- }: {
66
- className?: string;
67
- children: ReactNode;
68
- }): React.ReactElement {
69
- return (
70
- <div
71
- data-slot="pane-header"
72
- className={cn("shrink-0 border-b border-border", className)}
73
- >
74
- {children}
75
- </div>
76
- );
77
- }
78
-
79
- /** Região rolável que ocupa o espaço restante de um pane. */
80
- export function PaneBody({
81
- className,
82
- children,
83
- }: {
84
- className?: string;
85
- children: ReactNode;
86
- }): React.ReactElement {
87
- return (
88
- <div
89
- data-slot="pane-body"
90
- className={cn("min-h-0 flex-1 overflow-y-auto", className)}
91
- >
92
- {children}
93
- </div>
94
- );
95
- }
96
-
97
- /** @deprecated Use PaneBody. Alias mantido para migração; sai numa próxima versão major. */
98
- export function PaneContent({
99
- className,
100
- children,
101
- }: {
102
- className?: string;
103
- children: ReactNode;
104
- }): React.ReactElement {
105
- return (
106
- <div
107
- data-slot="pane-content"
108
- className={cn("min-h-0 flex-1 overflow-y-auto", className)}
109
- >
110
- {children}
111
- </div>
112
- );
113
- }
114
-
115
- /** Faixa fixa no rodapé de um pane para ações persistentes. */
116
- export function PaneFooter({
117
- className,
118
- children,
119
- }: {
120
- className?: string;
121
- children: ReactNode;
122
- }): React.ReactElement {
123
- return (
124
- <div
125
- data-slot="pane-footer"
126
- className={cn("shrink-0 border-t border-border p-2", className)}
127
- >
128
- {children}
129
- </div>
130
- );
131
- }
132
-
133
74
  export interface SidebarItemProps {
134
75
  label: string;
135
76
  icon?: ReactNode;
@@ -1,41 +1,18 @@
1
- import { Children, isValidElement, type CSSProperties, type ReactElement, type ReactNode } from 'react'
1
+ import { Children, isValidElement, type ReactElement, type ReactNode } from 'react'
2
2
  import { cn } from '../../lib/cn.ts'
3
3
  import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '../primitives/resizable.tsx'
4
+ import { Pane, paneSizeToCss, type PaneProps, type PaneSize } from './pane.tsx'
5
+
6
+ // A família Pane mora em `pane.tsx` desde a ADR 0023; o Split continua sendo sua casa
7
+ // pública, então reexporta o que já era importado daqui.
8
+ export { Pane, PaneActions, PaneBody, PaneContent, PaneDescription, PaneFooter, PaneHeader, PaneMedia, PaneTitle } from './pane.tsx'
9
+ export type { PaneProps, PaneSize }
4
10
 
5
- export type PaneSize = number | `${number}%` | `${number}rem` | `${number}em` | `${number}vh` | `${number}vw` | `${number}px`
6
11
  export type SplitLayout = Record<string, number>
7
12
  export interface SplitLayoutChange {
8
13
  isUserInteraction: boolean
9
14
  }
10
15
 
11
- export interface PaneProps {
12
- /** Identidade estável usada pelo layout redimensionável e por layouts persistidos. */
13
- id?: string
14
- /** Tamanho inicial. Números representam porcentagens; strings aceitam %, rem, em, vh, vw e px. */
15
- initialSize?: PaneSize
16
- /** Tamanho mínimo quando o split é redimensionável. */
17
- minSize?: PaneSize
18
- /** Tamanho máximo quando o split é redimensionável. */
19
- maxSize?: PaneSize
20
- /** Ocupa o espaço remanescente no layout simples. */
21
- grow?: boolean
22
- /** Respiro interno padrão. Use `none` para chrome, navegação ou conteúdo com inset próprio. */
23
- inset?: 'none' | 'sm' | 'md' | 'lg'
24
- className?: string
25
- children: ReactNode
26
- }
27
-
28
- /** Um conteúdo encaixável. Sua posição é dada pela ordem dentro de <Split>. */
29
- export function Pane({ id, initialSize, grow = false, inset = 'md', className, children }: PaneProps): React.ReactElement {
30
- const style: CSSProperties | undefined = initialSize === undefined ? undefined : { flexBasis: sizeToCss(initialSize) }
31
- const insets = { none: '', sm: 'p-2', md: 'p-4', lg: 'p-6' } as const
32
- return (
33
- <div id={id === undefined ? undefined : String(id)} data-slot="pane" data-inset={inset} className={cn('min-h-0 min-w-0 shrink-0', grow && 'flex-1', insets[inset], className)} style={style}>
34
- {children}
35
- </div>
36
- )
37
- }
38
-
39
16
  export interface SplitProps {
40
17
  /** Identidade estável do grupo redimensionável. */
41
18
  id?: string
@@ -61,12 +38,8 @@ function panesOf(children: ReactNode): ReactElement<PaneProps>[] {
61
38
 
62
39
  // Números continuam percentuais por compatibilidade com Split/Pane. Strings preservam
63
40
  // a unidade explícita para layouts que precisam de um limite físico, como `28rem`.
64
- function sizeToCss(value: PaneSize): string {
65
- return typeof value === 'number' ? `${value}%` : value
66
- }
67
-
68
41
  function resizableSize(value: PaneSize | undefined): string | undefined {
69
- return value === undefined ? undefined : sizeToCss(value)
42
+ return value === undefined ? undefined : paneSizeToCss(value)
70
43
  }
71
44
 
72
45
  function percentage(value: PaneSize | undefined): number | undefined {