@softize/opus 23.0.0 → 24.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 (95) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/bin/cli.mjs +2 -0
  3. package/bin/lib/check.mjs +241 -42
  4. package/bin/lib/copy.mjs +16 -34
  5. package/docs/adr/0023-surfaces-share-one-anatomy-and-two-layouts.md +6 -0
  6. package/docs/adr/0039-filter-presentation-is-independent-from-filter-type.md +3 -3
  7. package/docs/adr/0040-tabs-use-a-single-navigation-presentation.md +5 -6
  8. package/docs/adr/0042-toggle-group-shares-the-grouped-control-grammar.md +79 -0
  9. package/docs/adr/0043-neutral-surfaces-and-interactions-use-opaque-role-tokens.md +73 -0
  10. package/docs/adr/0044-neutral-appearance-derives-from-bounded-axes-per-mode.md +133 -0
  11. package/docs/adr/0045-surface-replaces-content-and-card.md +230 -0
  12. package/docs/adr/0046-structural-body-owns-content-rhythm.md +103 -0
  13. package/docs/adr/0047-pane-is-only-a-split-layout-unit.md +71 -0
  14. package/docs/adr/0048-modal-intro-separates-content-from-chrome.md +34 -0
  15. package/package.json +5 -1
  16. package/registry/skills/build-opus-ui/SKILL.md +11 -11
  17. package/registry/skills/build-opus-ui/references/evaluations.md +10 -9
  18. package/registry/skills/build-opus-ui/references/ui-patterns.md +17 -16
  19. package/registry/templates/app/src/App.tsx +24 -12
  20. package/src/ui/appearance.ts +149 -0
  21. package/src/ui/components/patterns/action-form-card.tsx +46 -33
  22. package/src/ui/components/patterns/action-list-dialog.tsx +2 -4
  23. package/src/ui/components/patterns/confirm.tsx +3 -1
  24. package/src/ui/components/patterns/fill-region.ts +8 -7
  25. package/src/ui/components/patterns/form-dialog.tsx +1 -3
  26. package/src/ui/components/patterns/form.tsx +8 -7
  27. package/src/ui/components/patterns/list.tsx +15 -19
  28. package/src/ui/components/patterns/page.tsx +15 -11
  29. package/src/ui/components/patterns/pane.tsx +9 -240
  30. package/src/ui/components/patterns/presentation.tsx +14 -14
  31. package/src/ui/components/patterns/sidebar.tsx +7 -17
  32. package/src/ui/components/patterns/split.tsx +111 -59
  33. package/src/ui/components/patterns/{surface-header.tsx → structural-header.tsx} +50 -19
  34. package/src/ui/components/patterns/structural-media.tsx +26 -0
  35. package/src/ui/components/patterns/surface-anatomy.ts +71 -61
  36. package/src/ui/components/patterns/surface-assistant.tsx +2 -4
  37. package/src/ui/components/patterns/surface.tsx +620 -0
  38. package/src/ui/components/patterns/trigger.tsx +11 -9
  39. package/src/ui/components/primitives/ask.tsx +155 -83
  40. package/src/ui/components/primitives/button.tsx +11 -9
  41. package/src/ui/components/primitives/checkbox.tsx +8 -8
  42. package/src/ui/components/primitives/command.tsx +1 -1
  43. package/src/ui/components/primitives/composer.tsx +47 -45
  44. package/src/ui/components/primitives/detail.tsx +9 -2
  45. package/src/ui/components/primitives/dialog.tsx +60 -26
  46. package/src/ui/components/primitives/drawer.tsx +58 -12
  47. package/src/ui/components/primitives/icon-picker.tsx +90 -73
  48. package/src/ui/components/primitives/input-group.tsx +35 -28
  49. package/src/ui/components/primitives/input-otp.tsx +16 -16
  50. package/src/ui/components/primitives/input.tsx +20 -10
  51. package/src/ui/components/primitives/item.tsx +9 -12
  52. package/src/ui/components/primitives/menu.tsx +44 -65
  53. package/src/ui/components/primitives/metric-card.tsx +75 -59
  54. package/src/ui/components/primitives/radio-group.tsx +9 -9
  55. package/src/ui/components/primitives/select.tsx +344 -209
  56. package/src/ui/components/primitives/switch.tsx +10 -10
  57. package/src/ui/components/primitives/table.tsx +29 -24
  58. package/src/ui/components/primitives/tabs.tsx +4 -2
  59. package/src/ui/components/primitives/textarea.tsx +6 -6
  60. package/src/ui/components/primitives/toggle-group-context.ts +24 -0
  61. package/src/ui/components/primitives/toggle-group.tsx +116 -74
  62. package/src/ui/components/primitives/toggle.tsx +82 -16
  63. package/src/ui/docs/DocBrowser.tsx +77 -69
  64. package/src/ui/docs/content/action-form-card.md +9 -9
  65. package/src/ui/docs/content/action-list.md +76 -70
  66. package/src/ui/docs/content/action-view.md +33 -18
  67. package/src/ui/docs/content/button.md +1 -1
  68. package/src/ui/docs/content/communication.md +1 -1
  69. package/src/ui/docs/content/customization.md +64 -11
  70. package/src/ui/docs/content/detail.md +8 -6
  71. package/src/ui/docs/content/dialog.md +100 -58
  72. package/src/ui/docs/content/drawer.md +63 -19
  73. package/src/ui/docs/content/field.md +14 -15
  74. package/src/ui/docs/content/icon-tile.md +14 -13
  75. package/src/ui/docs/content/page.md +34 -26
  76. package/src/ui/docs/content/pane.md +41 -290
  77. package/src/ui/docs/content/presentation.md +3 -3
  78. package/src/ui/docs/content/separator.md +6 -6
  79. package/src/ui/docs/content/sidebar.md +68 -266
  80. package/src/ui/docs/content/skeleton.md +20 -18
  81. package/src/ui/docs/content/split.md +14 -14
  82. package/src/ui/docs/content/surface.md +513 -0
  83. package/src/ui/docs/content/tabs.md +20 -20
  84. package/src/ui/docs/content/toggle.md +117 -65
  85. package/src/ui/docs/content/tokens.md +124 -40
  86. package/src/ui/docs/registry.tsx +4 -6
  87. package/src/ui/index.ts +30 -27
  88. package/src/ui/meta.ts +15 -27
  89. package/src/ui/react.tsx +40 -43
  90. package/src/ui/theme.css +311 -64
  91. package/src/ui/components/patterns/content-header.tsx +0 -528
  92. package/src/ui/components/patterns/surface-media.tsx +0 -69
  93. package/src/ui/components/primitives/card.tsx +0 -191
  94. package/src/ui/docs/content/card.md +0 -292
  95. package/src/ui/docs/content/content.md +0 -275
@@ -8,18 +8,6 @@ import { ChevronRight } from "lucide-react";
8
8
  import { useOverflowing } from "../../lib/overflow.ts";
9
9
  import { cn } from "../../lib/cn.ts";
10
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";
23
11
  import { focusRing } from "../primitives/control.ts";
24
12
  import {
25
13
  Tooltip,
@@ -127,8 +115,8 @@ export function SidebarItem({
127
115
  "outline-none",
128
116
  focusRing,
129
117
  active
130
- ? "bg-muted font-medium text-foreground"
131
- : "text-foreground/80 hover:bg-muted/60",
118
+ ? "bg-interaction-selected font-medium text-foreground"
119
+ : "text-foreground/80 hover:bg-interaction-hover",
132
120
  )}
133
121
  >
134
122
  <span
@@ -159,8 +147,10 @@ export function SidebarItem({
159
147
  data-slot="sidebar-item"
160
148
  className={cn(
161
149
  "group/sidebar-item relative flex items-center rounded-md transition-colors",
162
- disabled ? "text-muted-foreground/50 opacity-40" : "hover:bg-muted/60",
163
- active && "bg-muted",
150
+ disabled
151
+ ? "text-muted-foreground/50 opacity-40"
152
+ : "hover:bg-interaction-hover",
153
+ active && "bg-interaction-selected",
164
154
  dropPosition === "inside" &&
165
155
  "bg-primary/5 ring-2 ring-inset ring-primary/60",
166
156
  className,
@@ -333,7 +323,7 @@ export function SidebarTreeGroup({
333
323
  );
334
324
  }
335
325
 
336
- /** Navegação controlada para Sidebar. Header/footer continuam slots explícitos do pai. */
326
+ /** Navegação controlada para Sidebar. Chrome adicional pertence à composição hospedeira. */
337
327
  export function SidebarNav({
338
328
  groups,
339
329
  activeId,
@@ -1,33 +1,43 @@
1
- import { Children, cloneElement, Fragment, isValidElement, type ReactElement, type ReactNode } from 'react'
2
- import { cn } from '../../lib/cn.ts'
3
- import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '../primitives/resizable.tsx'
4
- import { Pane, paneSizeToCss, type PaneProps, type PaneSize } from './pane.tsx'
1
+ import {
2
+ Children,
3
+ cloneElement,
4
+ Fragment,
5
+ isValidElement,
6
+ type ReactElement,
7
+ type ReactNode,
8
+ } from "react";
9
+ import { cn } from "../../lib/cn.ts";
10
+ import {
11
+ ResizableHandle,
12
+ ResizablePanel,
13
+ ResizablePanelGroup,
14
+ } from "../primitives/resizable.tsx";
15
+ import { Pane, paneSizeToCss, type PaneProps, type PaneSize } from "./pane.tsx";
5
16
 
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 }
17
+ // Pane é a unidade espacial de Split; sua apresentação pertence ao filho (ADR 0047).
18
+ export { Pane } from "./pane.tsx";
19
+ export type { PaneProps, PaneSize };
10
20
 
11
- export type SplitLayout = Record<string, number>
21
+ export type SplitLayout = Record<string, number>;
12
22
  export interface SplitLayoutChange {
13
- isUserInteraction: boolean
23
+ isUserInteraction: boolean;
14
24
  }
15
25
 
16
26
  export interface SplitProps {
17
27
  /** Identidade estável do grupo redimensionável. */
18
- id?: string
19
- direction?: 'horizontal' | 'vertical'
28
+ id?: string;
29
+ direction?: "horizontal" | "vertical";
20
30
  /** Quando ligado, cada fronteira ganha um separador acessível e arrastável. */
21
- resizable?: boolean
22
- handle?: boolean
31
+ resizable?: boolean;
32
+ handle?: boolean;
23
33
  /** Layout percentual restaurado, indexado pelos ids dos panes. */
24
- defaultLayout?: SplitLayout
34
+ defaultLayout?: SplitLayout;
25
35
  /** Chamado ao concluir uma mudança de layout; pode persistir o resultado em localStorage. */
26
- onLayoutChanged?: (layout: SplitLayout, meta: SplitLayoutChange) => void
27
- className?: string
36
+ onLayoutChanged?: (layout: SplitLayout, meta: SplitLayoutChange) => void;
37
+ className?: string;
28
38
  /** Identificador semântico opcional para testes e composição de patterns. */
29
- 'data-slot'?: string
30
- children: ReactNode
39
+ "data-slot"?: string;
40
+ children: ReactNode;
31
41
  }
32
42
 
33
43
  /**
@@ -35,27 +45,36 @@ export interface SplitProps {
35
45
  * `Children.toArray` numera as chaves por nível, então o filho de um fragment herda a
36
46
  * chave do fragment como prefixo — sem isso, `.0` de dentro colidiria com `.0` de fora.
37
47
  */
38
- function flattenFragments(children: ReactNode, prefix = ''): ReactNode[] {
48
+ function flattenFragments(children: ReactNode, prefix = ""): ReactNode[] {
39
49
  return Children.toArray(children).flatMap((node): ReactNode[] => {
40
50
  if (isValidElement(node) && node.type === Fragment)
41
- return flattenFragments((node.props as { children?: ReactNode }).children, `${prefix}${String(node.key)}/`)
42
- if (prefix !== '' && isValidElement(node)) return [cloneElement(node, { key: `${prefix}${String(node.key)}` })]
43
- return [node]
44
- })
51
+ return flattenFragments(
52
+ (node.props as { children?: ReactNode }).children,
53
+ `${prefix}${String(node.key)}/`,
54
+ );
55
+ if (prefix !== "" && isValidElement(node))
56
+ return [cloneElement(node, { key: `${prefix}${String(node.key)}` })];
57
+ return [node];
58
+ });
45
59
  }
46
60
 
47
61
  /** Nome legível do filho recusado, para a mensagem apontar o nó certo. */
48
62
  function describeChild(node: ReactNode): string {
49
- if (typeof node === 'string' || typeof node === 'number' || typeof node === 'bigint') {
50
- const text = String(node).trim()
51
- return `o texto "${text.length > 40 ? `${text.slice(0, 40)}…` : text}"`
63
+ if (
64
+ typeof node === "string" ||
65
+ typeof node === "number" ||
66
+ typeof node === "bigint"
67
+ ) {
68
+ const text = String(node).trim();
69
+ return `o texto "${text.length > 40 ? `${text.slice(0, 40)}…` : text}"`;
52
70
  }
53
71
  if (isValidElement(node)) {
54
- const type = node.type as string | { displayName?: string; name?: string }
55
- const name = typeof type === 'string' ? type : (type.displayName ?? type.name ?? '')
56
- return name === '' ? 'um componente sem nome' : `<${name}>`
72
+ const type = node.type as string | { displayName?: string; name?: string };
73
+ const name =
74
+ typeof type === "string" ? type : (type.displayName ?? type.name ?? "");
75
+ return name === "" ? "um componente sem nome" : `<${name}>`;
57
76
  }
58
- return 'um nó que não é elemento React'
77
+ return "um nó que não é elemento React";
59
78
  }
60
79
 
61
80
  /**
@@ -65,27 +84,27 @@ function describeChild(node: ReactNode): string {
65
84
  */
66
85
  function panesOf(children: ReactNode): ReactElement<PaneProps>[] {
67
86
  return flattenFragments(children).flatMap((child) => {
68
- if (isValidElement<PaneProps>(child) && child.type === Pane) return [child]
87
+ if (isValidElement<PaneProps>(child) && child.type === Pane) return [child];
69
88
  // Espaço em branco (um `{' '}` da formatação) não é conteúdo: nunca ocupou lugar.
70
- if (typeof child === 'string' && child.trim() === '') return []
89
+ if (typeof child === "string" && child.trim() === "") return [];
71
90
  throw new Error(
72
91
  `Split recebeu ${describeChild(child)} como filho, mas só distribui <Pane>: esse conteúdo ficaria fora do layout. ` +
73
- 'Envolva-o em um <Pane>. Se ele vem de um componente que devolve um <Pane>, escreva o <Pane> dentro do Split e ' +
74
- 'passe o componente como filho dele: o Split lê o tamanho de cada pane antes de renderizá-lo.',
75
- )
76
- })
92
+ "Envolva-o em um <Pane>. Se ele vem de um componente que devolve um <Pane>, escreva o <Pane> dentro do Split e " +
93
+ "passe o componente como filho dele: o Split lê o tamanho de cada pane antes de renderizá-lo.",
94
+ );
95
+ });
77
96
  }
78
97
 
79
98
  // Números continuam percentuais por compatibilidade com Split/Pane. Strings preservam
80
99
  // a unidade explícita para layouts que precisam de um limite físico, como `28rem`.
81
100
  function resizableSize(value: PaneSize | undefined): string | undefined {
82
- return value === undefined ? undefined : paneSizeToCss(value)
101
+ return value === undefined ? undefined : paneSizeToCss(value);
83
102
  }
84
103
 
85
104
  function percentage(value: PaneSize | undefined): number | undefined {
86
- if (typeof value === 'number') return value
87
- if (value?.endsWith('%')) return Number.parseFloat(value)
88
- return undefined
105
+ if (typeof value === "number") return value;
106
+ if (value?.endsWith("%")) return Number.parseFloat(value);
107
+ return undefined;
89
108
  }
90
109
 
91
110
  /**
@@ -95,31 +114,47 @@ function percentage(value: PaneSize | undefined): number | undefined {
95
114
  */
96
115
  export function Split({
97
116
  id,
98
- direction = 'horizontal',
117
+ direction = "horizontal",
99
118
  resizable = false,
100
119
  handle = false,
101
120
  defaultLayout,
102
121
  onLayoutChanged,
103
122
  className,
104
- 'data-slot': dataSlot = 'split',
123
+ "data-slot": dataSlot = "split",
105
124
  children,
106
125
  }: SplitProps): React.ReactElement {
107
- const panes = panesOf(children)
108
- const vertical = direction === 'vertical'
126
+ const panes = panesOf(children);
127
+ const vertical = direction === "vertical";
109
128
 
110
129
  if (!resizable) {
111
130
  return (
112
- <div id={id === undefined ? undefined : String(id)} data-slot={dataSlot} data-direction={direction} className={cn('flex min-h-0 min-w-0 flex-1', vertical && 'flex-col', className)}>
131
+ <div
132
+ id={id === undefined ? undefined : String(id)}
133
+ data-slot={dataSlot}
134
+ data-direction={direction}
135
+ className={cn(
136
+ "flex min-h-0 min-w-0 flex-1",
137
+ vertical && "flex-col",
138
+ className,
139
+ )}
140
+ >
113
141
  {panes}
114
142
  </div>
115
- )
143
+ );
116
144
  }
117
145
 
118
- const fixed = panes.reduce((sum, pane) => sum + (percentage(pane.props.initialSize) ?? 0), 0)
119
- const hasAbsoluteSize = panes.some((pane) => typeof pane.props.initialSize === 'string' && !pane.props.initialSize.endsWith('%'))
120
- const growing = panes.filter((pane) => pane.props.grow)
121
- const rest = Math.max(0, 100 - fixed)
122
- const fallback = panes.length === 0 ? 100 : 100 / panes.length
146
+ const fixed = panes.reduce(
147
+ (sum, pane) => sum + (percentage(pane.props.initialSize) ?? 0),
148
+ 0,
149
+ );
150
+ const hasAbsoluteSize = panes.some(
151
+ (pane) =>
152
+ typeof pane.props.initialSize === "string" &&
153
+ !pane.props.initialSize.endsWith("%"),
154
+ );
155
+ const growing = panes.filter((pane) => pane.props.grow);
156
+ const rest = Math.max(0, 100 - fixed);
157
+ const fallback = panes.length === 0 ? 100 : 100 / panes.length;
123
158
 
124
159
  return (
125
160
  <ResizablePanelGroup
@@ -128,11 +163,16 @@ export function Split({
128
163
  defaultLayout={defaultLayout}
129
164
  onLayoutChanged={onLayoutChanged}
130
165
  data-slot={dataSlot}
131
- className={cn('min-h-0 min-w-0 flex-1', className)}
166
+ className={cn("min-h-0 min-w-0 flex-1", className)}
132
167
  >
133
168
  {panes.flatMap((pane, index) => {
134
- const size = pane.props.initialSize
135
- ?? (hasAbsoluteSize ? undefined : pane.props.grow ? rest / Math.max(1, growing.length) : fallback)
169
+ const size =
170
+ pane.props.initialSize ??
171
+ (hasAbsoluteSize
172
+ ? undefined
173
+ : pane.props.grow
174
+ ? rest / Math.max(1, growing.length)
175
+ : fallback);
136
176
  const panel = (
137
177
  <ResizablePanel
138
178
  key={pane.key ?? index}
@@ -141,11 +181,23 @@ export function Split({
141
181
  minSize={resizableSize(pane.props.minSize)}
142
182
  maxSize={resizableSize(pane.props.maxSize)}
143
183
  >
144
- <Pane {...pane.props} id={undefined} className={cn('h-full w-full', pane.props.className)} />
184
+ <Pane
185
+ {...pane.props}
186
+ id={undefined}
187
+ className={cn("h-full w-full", pane.props.className)}
188
+ />
145
189
  </ResizablePanel>
146
- )
147
- return index === 0 ? [panel] : [<ResizableHandle key={`handle-${pane.key ?? index}`} withHandle={handle} />, panel]
190
+ );
191
+ return index === 0
192
+ ? [panel]
193
+ : [
194
+ <ResizableHandle
195
+ key={`handle-${pane.key ?? index}`}
196
+ withHandle={handle}
197
+ />,
198
+ panel,
199
+ ];
148
200
  })}
149
201
  </ResizablePanelGroup>
150
- )
202
+ );
151
203
  }
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Cabeçalho de superfície (interno): a anatomia que Page e Content compartilham.
2
+ * Cabeçalho estrutural (interno): a anatomia que Page, Surface, Dialog, Drawer e Pane
3
+ * compartilham. O nome público `SurfaceHeader` pertence à família Surface (ADR 0045).
3
4
  *
4
5
  * Um título, metadata opcional e uma região de ações que vai para o extremo oposto
5
6
  * em larguras amplas e empilha abaixo do contexto nas estreitas. Cada família escolhe quais slots
@@ -17,11 +18,11 @@ import {
17
18
  } from "react";
18
19
  import { cn } from "../../lib/cn.ts";
19
20
 
20
- export type SurfaceHeaderVariant = "page" | "section";
21
+ export type StructuralHeaderVariant = "page" | "section";
21
22
 
22
- /** As classes por slot, por hierarquia visual — PageTitle e ContentTitle(page) são o mesmo. */
23
- export const surfaceHeaderClasses: Record<
24
- SurfaceHeaderVariant,
23
+ /** As classes por slot, por hierarquia visual — PageTitle e SurfaceTitle no nível 1 são o mesmo. */
24
+ export const structuralHeaderClasses: Record<
25
+ StructuralHeaderVariant,
25
26
  { title: string; description: string; count: string; actions: string }
26
27
  > = {
27
28
  page: {
@@ -38,7 +39,7 @@ export const surfaceHeaderClasses: Record<
38
39
  },
39
40
  };
40
41
 
41
- interface SurfaceHeaderSlots {
42
+ interface StructuralHeaderSlots {
42
43
  leading?: ElementType | readonly ElementType[];
43
44
  /** Ícone ou imagem que abre a linha, antes do heading (ADR 0023). */
44
45
  media?: ElementType;
@@ -48,15 +49,15 @@ interface SurfaceHeaderSlots {
48
49
  actions?: ElementType;
49
50
  }
50
51
 
51
- export interface SurfaceHeaderProps extends HTMLAttributes<HTMLDivElement> {
52
+ export interface StructuralHeaderProps extends HTMLAttributes<HTMLDivElement> {
52
53
  /** Elemento estrutural; `legend` preserva a mesma anatomia com wrappers phrasing. */
53
54
  as?: "div" | "legend";
54
- /** Nome do componente público, para as mensagens de erro (`PageHeader`, `ContentHeader`). */
55
+ /** Nome do componente público, para as mensagens de erro (`PageHeader`, `SurfaceHeader`). */
55
56
  name: string;
56
57
  /** Prefixo dos `data-slot` (`page` → `page-header`, `page-heading`). */
57
58
  slot: string;
58
59
  /** Os componentes de slot que este cabeçalho reconhece. */
59
- slots: SurfaceHeaderSlots;
60
+ slots: StructuralHeaderSlots;
60
61
  /** Posição de um slot introdutório opcional, antes da linha ou dentro dela. */
61
62
  leadingPlacement?: "above" | "inline";
62
63
  /** Mantém a mídia na linha geral ou a agrupa com o heading. */
@@ -72,7 +73,7 @@ export interface SurfaceHeaderProps extends HTMLAttributes<HTMLDivElement> {
72
73
  /**
73
74
  * Aceita conteúdo livre além dos slots. Vale para a superfície genérica — um pane
74
75
  * hospeda marca, busca ou chrome que não são título nem ação. As famílias que sempre
75
- * NOMEIAM algo (Card, Dialog, Drawer, Page, Content) continuam estritas.
76
+ * NOMEIAM algo (Surface, Dialog, Drawer, Page) continuam estritas.
76
77
  */
77
78
  allowFreeContent?: boolean;
78
79
  /**
@@ -91,7 +92,7 @@ function flatten(children: ReactNode): ReactNode[] {
91
92
  );
92
93
  }
93
94
 
94
- export function SurfaceHeader({
95
+ export function StructuralHeader({
95
96
  as: Root = "div",
96
97
  name,
97
98
  slot,
@@ -107,7 +108,7 @@ export function SurfaceHeader({
107
108
  className,
108
109
  children,
109
110
  ...props
110
- }: SurfaceHeaderProps): ReactElement {
111
+ }: StructuralHeaderProps): ReactElement {
111
112
  const Wrapper = Root === "legend" ? "span" : "div";
112
113
  const nodes = flatten(children);
113
114
  const of = (type: ElementType) =>
@@ -124,8 +125,7 @@ export function SurfaceHeader({
124
125
  const counts = slots.count === undefined ? [] : of(slots.count);
125
126
  const descriptions =
126
127
  slots.description === undefined ? [] : of(slots.description);
127
- const actionSlots =
128
- slots.actions === undefined ? [] : of(slots.actions);
128
+ const actionSlots = slots.actions === undefined ? [] : of(slots.actions);
129
129
  const slotTypes = [
130
130
  ...leadingTypes,
131
131
  slots.media,
@@ -137,7 +137,9 @@ export function SurfaceHeader({
137
137
  const free = allowFreeContent
138
138
  ? nodes.filter(
139
139
  (node) =>
140
- !(isValidElement(node) && slotTypes.includes(node.type as ElementType)),
140
+ !(
141
+ isValidElement(node) && slotTypes.includes(node.type as ElementType)
142
+ ),
141
143
  )
142
144
  : [];
143
145
  const recognized =
@@ -242,16 +244,26 @@ export function SurfaceHeader({
242
244
  )}
243
245
  {mediaPlacement === "row" && medias}
244
246
  {heading}
247
+ {!heading && leading.length === 0 && (
248
+ <Wrapper
249
+ data-slot={`${slot}-header-spacer`}
250
+ className="min-w-0 flex-1"
251
+ aria-hidden="true"
252
+ />
253
+ )}
245
254
  {actionSlots}
246
255
  {trailing}
247
256
  </>
248
257
  );
249
258
  const layout =
250
259
  leadingPlacement === "inline" ? (
251
- <Wrapper className={cn("flex min-w-0 items-center gap-3", rowClassName)}>
260
+ <Wrapper
261
+ data-slot={`${slot}-header-row`}
262
+ className={cn("flex min-w-0 items-center gap-3", rowClassName)}
263
+ >
252
264
  {row}
253
265
  </Wrapper>
254
- ) : leading.length > 0 ? (
266
+ ) : leading.length > 0 && heading ? (
255
267
  <>
256
268
  <Wrapper data-slot={`${slot}-navigation`} className="mb-4">
257
269
  {leading}
@@ -267,6 +279,19 @@ export function SurfaceHeader({
267
279
  {actionSlots}
268
280
  </Wrapper>
269
281
  </>
282
+ ) : leading.length > 0 ? (
283
+ <Wrapper
284
+ data-slot={`${slot}-header-row`}
285
+ className="flex min-w-0 items-center gap-4"
286
+ >
287
+ <Wrapper
288
+ data-slot={`${slot}-navigation`}
289
+ className="min-w-0 flex-1"
290
+ >
291
+ {leading}
292
+ </Wrapper>
293
+ {actionSlots}
294
+ </Wrapper>
270
295
  ) : (
271
296
  <>
272
297
  {heading}
@@ -282,7 +307,10 @@ export function SurfaceHeader({
282
307
  leadingPlacement !== "inline" &&
283
308
  leading.length === 0 &&
284
309
  actionSlots.length > 0 &&
285
- "flex flex-row items-end justify-between gap-4",
310
+ cn(
311
+ "flex flex-row items-end gap-4",
312
+ heading ? "justify-between" : "justify-end",
313
+ ),
286
314
  className,
287
315
  )}
288
316
  {...props}
@@ -290,7 +318,10 @@ export function SurfaceHeader({
290
318
  {contentClassName === undefined ? (
291
319
  layout
292
320
  ) : (
293
- <Wrapper data-slot={`${slot}-header-content`} className={contentClassName}>
321
+ <Wrapper
322
+ data-slot={`${slot}-header-content`}
323
+ className={contentClassName}
324
+ >
294
325
  {layout}
295
326
  </Wrapper>
296
327
  )}
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Slot de mídia estrutural (interno): abre a linha do header de Dialog, Drawer e Pane
3
+ * sem decidir a apresentação do conteúdo. A moldura, quando necessária, é composta
4
+ * explicitamente com `IconTile` pelo consumidor.
5
+ */
6
+ import type { ReactElement } from "react";
7
+ import { cn } from "../../lib/cn.ts";
8
+
9
+ export interface StructuralMediaProps extends React.ComponentProps<"div"> {}
10
+
11
+ export function StructuralMedia({
12
+ slot,
13
+ className,
14
+ ...props
15
+ }: StructuralMediaProps & { slot: string }): ReactElement {
16
+ return (
17
+ <div
18
+ data-slot={slot}
19
+ className={cn(
20
+ "flex shrink-0 items-center justify-center [&>svg:not([class*='size-'])]:size-4",
21
+ className,
22
+ )}
23
+ {...props}
24
+ />
25
+ );
26
+ }