@softize/opus 20.0.0 → 21.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 (38) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/bin/lib/gen-manifest.mjs +1 -0
  3. package/bin/lib/gen-runner.mjs +3 -0
  4. package/bin/lib/introspect.mjs +5 -4
  5. package/docs/adr/0020-icon-tiles-are-explicitly-composed.md +8 -0
  6. package/docs/adr/0021-content-carries-structure-and-semantic-element.md +7 -0
  7. package/docs/adr/0023-surfaces-share-one-anatomy-and-two-layouts.md +14 -1
  8. package/docs/adr/0025-content-frame-treatment-does-not-change-semantics.md +134 -0
  9. package/docs/adr/0026-reactions-apply-declared-delivery-policy.md +123 -0
  10. package/docs/adr/0027-reactions-publish-declared-events-with-bounded-chains.md +96 -0
  11. package/docs/adr/0028-schedules-run-actions-as-the-system-by-action-opt-in.md +127 -0
  12. package/docs/protocol.md +60 -24
  13. package/package.json +1 -1
  14. package/src/core/audit.ts +2 -1
  15. package/src/core/index.ts +1 -1
  16. package/src/core/reactions.ts +6 -2
  17. package/src/core/runtime.ts +598 -70
  18. package/src/core/schedules.ts +3 -2
  19. package/src/core/types.ts +52 -7
  20. package/src/testing/index.ts +25 -14
  21. package/src/ui/components/patterns/content-header.tsx +94 -15
  22. package/src/ui/components/patterns/list.tsx +2 -14
  23. package/src/ui/components/patterns/surface-header.tsx +58 -25
  24. package/src/ui/components/primitives/card.tsx +1 -1
  25. package/src/ui/components/primitives/dialog.tsx +1 -1
  26. package/src/ui/components/primitives/drawer.tsx +1 -1
  27. package/src/ui/docs/content/action-list.md +7 -6
  28. package/src/ui/docs/content/actions.md +2 -1
  29. package/src/ui/docs/content/audit.md +10 -0
  30. package/src/ui/docs/content/auth.md +5 -1
  31. package/src/ui/docs/content/content.md +88 -20
  32. package/src/ui/docs/content/events.md +109 -11
  33. package/src/ui/docs/content/icon-tile.md +11 -11
  34. package/src/ui/docs/content/runtime.md +11 -2
  35. package/src/ui/docs/content/scheduler.md +53 -12
  36. package/src/ui/docs/content/testing.md +14 -2
  37. package/src/ui/meta.ts +1 -1
  38. package/src/ui/react.tsx +1 -0
@@ -11,8 +11,9 @@
11
11
  * - Schedule = tempo dispara
12
12
  *
13
13
  * Runtime registra no SchedulerAdapter no `start()`. Quando o tempo
14
- * configurado bate, o scheduler chama `runtime.execute(action, input, ctx)`
15
- * com `ctx.provenance = { kind: 'schedule', scheduleName }`.
14
+ * configurado bate, o runtime executa a action como o sistema, com
15
+ * `ctx.provenance = { kind: 'schedule', scheduleName }`; a action precisa
16
+ * declarar `automatable: true` (ou `public: true`). Ver ADR 0028.
16
17
  *
17
18
  * Ver §16 do protocolo.
18
19
  */
package/src/core/types.ts CHANGED
@@ -255,10 +255,11 @@ export interface User {
255
255
  export type CanFn = (permission: string, resource?: unknown) => boolean | Promise<boolean>
256
256
 
257
257
  /**
258
- * Função que emite um `DomainEvent`. Em `ActionContext.emit`, publica o evento pelo
259
- * `EventBusAdapter`. Em `ReactionContext.emit`, descarta o evento sem publicá-lo, porque
260
- * reactions ainda não encadeiam outras por evento; com `config.dev` ativo, cada descarte
261
- * registra o aviso `runtime.reaction_emit_ignored`.
258
+ * Função que emite um `DomainEvent` pelo `EventBusAdapter`. Em `ActionContext.emit`, publica
259
+ * qualquer evento; um evento fora do `emits` da action gera aviso com
260
+ * `config.warnUndeclaredEmits`. Em `ReactionContext.emit`, publica somente eventos listados no
261
+ * `emits` da reaction e descarta os demais (aviso `runtime.reaction_emit_ignored`); a cadeia de
262
+ * reactions para em `config.maxReactionDepth`. Ver ADR 0027.
262
263
  */
263
264
  export type EmitFn = <T = unknown>(
264
265
  event: string,
@@ -295,7 +296,7 @@ export interface ReactionContext {
295
296
  tenantId: string | null
296
297
  db: unknown
297
298
  log: Logger
298
- /** Descarta o evento sem publicá-lo: reactions ainda não encadeiam outras. Ver `EmitFn`. */
299
+ /** Publica os eventos declarados em `emits` da reaction; descarta os demais. Ver `EmitFn`. */
299
300
  emit: EmitFn
300
301
  storage: StorageAdapter | null
301
302
  cache: CacheAdapter | null
@@ -303,6 +304,12 @@ export interface ReactionContext {
303
304
  provenance: Provenance
304
305
  trace?: TraceContext
305
306
  meta: Record<string, unknown>
307
+ /**
308
+ * Abortado quando a tentativa excede o `timeout` da reaction. O runtime sempre preenche o
309
+ * campo; ele é opcional para quem monta o contexto à mão em testes. Um handler que ignora o
310
+ * sinal continua executando depois do timeout. Ver ADR 0026.
311
+ */
312
+ signal?: AbortSignal
306
313
  }
307
314
 
308
315
  // =============================================================================
@@ -387,11 +394,26 @@ export interface DomainEvent<T = unknown> {
387
394
  actor: { id: string | null; type?: 'user' | 'system' | 'integration' }
388
395
  tenant?: string | null
389
396
  data: T
397
+ /**
398
+ * A action que iniciou a cadeia. Um evento publicado por reaction herda o `source` do evento
399
+ * que ela processava, e `provenance.sourceActionId` continua batendo com o audit da action.
400
+ */
390
401
  source: {
391
402
  action: string
392
403
  actionId: string
393
404
  correlation?: string
394
405
  }
406
+ /**
407
+ * Presente só em evento publicado por reaction: qual reaction o publicou, a partir de qual
408
+ * evento e a que profundidade da cadeia (1 = primeira reaction depois da action). Evento
409
+ * emitido por action não tem `cause` e está na profundidade 0. Ver ADR 0027.
410
+ */
411
+ cause?: {
412
+ reaction: string
413
+ eventId: string
414
+ eventType: string
415
+ depth: number
416
+ }
395
417
  trace?: TraceContext
396
418
  meta?: Record<string, unknown>
397
419
  }
@@ -434,6 +456,12 @@ export interface JobSpec {
434
456
  requestId?: string
435
457
  trace?: TraceContext
436
458
  originalProvenance?: Provenance
459
+ /**
460
+ * Presente quando o próprio runtime enfileirou o job numa execução do sistema (o disparo de um
461
+ * schedule). O worker só reconstrói a identidade de sistema com esta marca; a provenance não
462
+ * basta, porque vem do chamador. Ver ADR 0028.
463
+ */
464
+ system?: true
437
465
  }
438
466
  config: {
439
467
  queue?: string
@@ -828,6 +856,11 @@ export interface ActionBase<In, Out, ParsedIn = In> {
828
856
 
829
857
  // — Integração —
830
858
  ai?: boolean | AIConfig
859
+ /**
860
+ * Aceita execução pelo sistema, sem usuário, quando um schedule a dispara. Sem este opt-in,
861
+ * a execução pelo sistema de uma action não pública termina em `auth.system_forbidden`. Não
862
+ * abre a action a chamadores anônimos. Ver `isSystemActor` e ADR 0028.
863
+ */
831
864
  automatable?: boolean
832
865
  internal?: boolean
833
866
  successStatus?: number
@@ -950,9 +983,17 @@ export interface ReactionDef<Event = unknown> {
950
983
 
951
984
  handler: ReactionHandlerFn<Event>
952
985
 
986
+ /** Eventos que o handler pode publicar com `ctx.emit`; os demais são descartados (ADR 0027). */
987
+ emits?: string[]
988
+
989
+ // — Política de entrega (ADR 0026) —
990
+ /** Total de execuções do handler, com espera opcional entre elas. Sem `retry`, roda uma vez. */
953
991
  retry?: { attempts: number; backoff?: BackoffSpec }
992
+ /** Prazo de cada tentativa, em ms; ao vencer, aborta `ctx.signal`. */
954
993
  timeout?: number
994
+ /** Chave de idempotência: evento com chave já vista em `config.dedupCacheTtl` é descartado. */
955
995
  dedup?: (event: DomainEvent<Event>) => string
996
+ /** Máximo de tentativas simultâneas desta reaction. Padrão 1. */
956
997
  concurrency?: number
957
998
 
958
999
  authorize?: (
@@ -1419,8 +1460,8 @@ export interface SchedulerAdapter extends Adapter {
1419
1460
  * Registra um schedule e retorna função pra cancelar. Chamado pelo
1420
1461
  * runtime no `start()` pra cada schedule.
1421
1462
  *
1422
- * Quando o tempo bater, o adapter chama `fire()` que internamente
1423
- * invoca runtime.execute() com provenance schedule.
1463
+ * Quando o tempo bater, o adapter chama `fire()`, e o runtime executa a
1464
+ * action como o sistema, com provenance schedule (ADR 0028).
1424
1465
  */
1425
1466
  register(spec: ScheduleDef, fire: () => Promise<void> | void): Unsubscribe
1426
1467
  }
@@ -1453,6 +1494,8 @@ export interface RuntimeConfig {
1453
1494
 
1454
1495
  // — Resiliência —
1455
1496
  dedupCacheTtl?: number
1497
+ /** Chaves concluídas que o dedup guarda por reaction; acima disso, descarta a mais antiga. Padrão 10.000. */
1498
+ dedupCacheMaxEntries?: number
1456
1499
  defaultRetry?: { attempts: number; backoff?: BackoffSpec }
1457
1500
 
1458
1501
  // — i18n —
@@ -1464,4 +1507,6 @@ export interface RuntimeConfig {
1464
1507
  // — Limites —
1465
1508
  maxConcurrentActions?: number
1466
1509
  maxConcurrentReactions?: number
1510
+ /** Saltos de reaction que uma cadeia de eventos pode dar. Padrão 5 (ADR 0027). */
1511
+ maxReactionDepth?: number
1467
1512
  }
@@ -9,10 +9,11 @@
9
9
  * capturado em `emitted`, log capturado em `logged`, `can` configurável,
10
10
  * storage plugável (ex.: `memStorage()`).
11
11
  * • `runAction(action, input)` — o MESMO pipeline do `runtime.execute`, em unidade:
12
- * valida input → gate `public` → `authorize` (string DSL ou closure, semântica
13
- * idêntica, `loaded` incluso) → handler → valida output (pulado em `list`, como
14
- * no runtime). Falha estoura o MESMO `ActionError` tipado do runtime
15
- * (`validation.invalid_input`, `auth.unauthenticated`, `auth.forbidden`…).
12
+ * valida input → gate de autenticação (`public`, usuário ou sistema `automatable`) →
13
+ * `authorize` (string DSL ou closure, semântica idêntica, `loaded` incluso) → handler →
14
+ * valida output (pulado em `list`, como no runtime). Falha estoura o MESMO
15
+ * `ActionError` tipado do runtime (`validation.invalid_input`, `auth.unauthenticated`,
16
+ * `auth.system_forbidden`, `auth.forbidden`…). A opção `schedule` executa como o sistema.
16
17
  * Em `OPUS_MODE=design` roteia pro `mockHandler` como o runtime — contrato puro
17
18
  * (sem handler) é testável em design; fora dele, erro claro (`testing.no_handler`).
18
19
  * • `memStorage()` — StorageAdapter em memória pra testar handler que anexa
@@ -31,7 +32,7 @@ import { normalizeKey } from '../storage/index.ts'
31
32
  import { error } from '../core/errors.ts'
32
33
  // Import direto do runtime (fora do index público): a régua handler×mockHandler
33
34
  // tem UMA fonte — o harness nunca pode divergir do execute().
34
- import { isOpusDesignMode } from '../core/runtime.ts'
35
+ import { assertAuthenticated, isOpusDesignMode, markSystemActor } from '../core/runtime.ts'
35
36
  import type {
36
37
  ActionContext,
37
38
  ActionKind,
@@ -201,6 +202,7 @@ export interface TestableAction<In, Out, ParsedIn = In> {
201
202
  name: string
202
203
  kind?: ActionKind
203
204
  public?: boolean
205
+ automatable?: boolean
204
206
  authorize?: AuthorizeSpec<ParsedIn>
205
207
  input: Schema<In, ParsedIn>
206
208
  output: Schema<Out> | Schema<unknown, Out>
@@ -215,6 +217,7 @@ export interface TestableListAction<In, Item, ParsedIn = In> {
215
217
  name: string
216
218
  kind: 'list'
217
219
  public?: boolean
220
+ automatable?: boolean
218
221
  authorize?: AuthorizeSpec<ParsedIn>
219
222
  input: Schema<In, ParsedIn>
220
223
  output: Schema<Item> | Schema<unknown, Item>
@@ -233,6 +236,12 @@ export interface TestableListAction<In, Item, ParsedIn = In> {
233
236
  export interface RunActionOptions extends TestContextOptions {
234
237
  /** Substitui os loaders (não rodam em unidade): vai pro handler e pro evalCtx do authorize. */
235
238
  loaded?: Record<string, unknown>
239
+ /**
240
+ * Executa como o sistema, no disparo do schedule com este nome: `user` é `null`, `can` nega
241
+ * tudo, a provenance é `schedule` e `isSystemActor(ctx)` responde `true`. Como no runtime, a
242
+ * action precisa ser `automatable` (ou `public`); senão, `auth.system_forbidden`.
243
+ */
244
+ schedule?: string
236
245
  }
237
246
 
238
247
  export interface RunActionResult<Out> extends TestContext {
@@ -261,7 +270,7 @@ async function validate<T>(
261
270
 
262
271
  /**
263
272
  * Roda uma action em UNIDADE com o pipeline do `runtime.execute`: valida input →
264
- * gate `public` → authorize → handler → valida output. Sucesso devolve o output
273
+ * gate de autenticação → authorize → handler → valida output. Sucesso devolve o output
265
274
  * validado + o contexto observável (`emitted`, `logged`); falha estoura o mesmo
266
275
  * `ActionError` do runtime — afirme com `expect(...).rejects`.
267
276
  *
@@ -284,19 +293,21 @@ export async function runAction<In, Out, ParsedIn = In>(
284
293
  input: unknown,
285
294
  options: RunActionOptions = {},
286
295
  ): Promise<RunActionResult<Out>> {
287
- const t = testContext(options)
296
+ const schedule = options.schedule
297
+ const t = testContext(
298
+ schedule === undefined ? options : { ...options, user: null, can: () => false },
299
+ )
288
300
  const { ctx } = t
301
+ if (schedule !== undefined) {
302
+ ctx.provenance = { kind: 'schedule', scheduleName: schedule }
303
+ markSystemActor(ctx)
304
+ }
289
305
  const loaded = options.loaded
290
306
 
291
307
  const parsed = await validate<ParsedIn>(action.input, input, 'input')
292
308
 
293
- if (action.public !== true && ctx.user === null) {
294
- throw error({
295
- code: 'auth.unauthenticated',
296
- category: 'authentication',
297
- message: 'Autenticação necessária',
298
- })
299
- }
309
+ // O mesmo gate do runtime: pública, com usuário ou, pelo sistema, `automatable`.
310
+ assertAuthenticated(action, ctx)
300
311
 
301
312
  if (action.authorize !== undefined) {
302
313
  const spec = action.authorize
@@ -10,9 +10,14 @@ import {
10
10
  type ReactNode,
11
11
  } from "react";
12
12
  import { cn } from "../../lib/cn.ts";
13
+ import { IconTile, type IconTileProps } from "../primitives/icon-tile.tsx";
13
14
  import { fillRegionClassName, useFillRegion } from "./fill-region.ts";
14
15
  import { headingTags } from "./heading-level.ts";
15
16
  import { usePageHeadingPresence } from "./page-heading-context.tsx";
17
+ import {
18
+ SurfaceMedia,
19
+ type SurfaceMediaProps,
20
+ } from "./surface-media.tsx";
16
21
  import {
17
22
  SurfaceHeader,
18
23
  surfaceHeaderClasses,
@@ -59,7 +64,8 @@ interface ContentSectionBaseProps
59
64
  Omit<ComponentProps<"section">, "children" | "title"> {
60
65
  /** Elemento semântico da região. */
61
66
  as?: "section";
62
- frameVariant?: "default";
67
+ /** Tratamento visual da moldura: contínua (default) ou integrada ao header. */
68
+ frameVariant?: ContentFrameVariant;
63
69
  /** Nível semântico do heading, independente de seu destaque visual. */
64
70
  level?: ContentHeaderLevel;
65
71
  }
@@ -78,6 +84,8 @@ interface ContentFieldsetBaseProps
78
84
 
79
85
  interface ContentSectionShorthandProps extends ContentSectionBaseProps {
80
86
  title: ReactNode;
87
+ /** Ícone ou imagem que identifica a região, antes do título. */
88
+ media?: ReactNode;
81
89
  /** Total de itens ao lado do título da seção. A página não carrega contador (ADR 0009). */
82
90
  count?: number;
83
91
  description?: ReactNode;
@@ -87,6 +95,8 @@ interface ContentSectionShorthandProps extends ContentSectionBaseProps {
87
95
 
88
96
  interface ContentFieldsetShorthandProps extends ContentFieldsetBaseProps {
89
97
  title: ReactNode;
98
+ /** Ícone ou imagem decorativa que acompanha o título dentro da legenda. */
99
+ media?: ReactNode;
90
100
  count?: never;
91
101
  description?: never;
92
102
  actions?: never;
@@ -95,6 +105,7 @@ interface ContentFieldsetShorthandProps extends ContentFieldsetBaseProps {
95
105
 
96
106
  interface ContentComposedProps {
97
107
  title?: never;
108
+ media?: never;
98
109
  count?: never;
99
110
  description?: never;
100
111
  actions?: never;
@@ -111,6 +122,7 @@ export type ContentProps =
111
122
  export function Content({
112
123
  as = "section",
113
124
  title,
125
+ media,
114
126
  count,
115
127
  description,
116
128
  actions,
@@ -164,6 +176,7 @@ export function Content({
164
176
  const anatomy = shorthand ? (
165
177
  <>
166
178
  <ContentHeader>
179
+ {hasContentMedia(media) && <ContentMedia>{media}</ContentMedia>}
167
180
  <ContentTitle>{title}</ContentTitle>
168
181
  {count !== undefined && <ContentMeta>{count}</ContentMeta>}
169
182
  {description !== undefined && (
@@ -214,7 +227,12 @@ export function Content({
214
227
  aria-labelledby={ariaLabelledBy}
215
228
  className={cn(
216
229
  "space-y-3",
217
- framed && "rounded-lg border border-border p-4",
230
+ framed &&
231
+ frameVariant === "default" &&
232
+ "rounded-lg border border-border p-4",
233
+ framed &&
234
+ frameVariant === "legend" &&
235
+ "rounded-lg border border-border px-4 pb-4",
218
236
  fill.filling && fillRegionClassName,
219
237
  className,
220
238
  )}
@@ -244,20 +262,15 @@ export function ContentHeader({
244
262
  "Content",
245
263
  );
246
264
  if (content.element === "fieldset") {
247
- const nodes = Children.toArray(children);
248
- if (
249
- nodes.length !== 1 ||
250
- !isValidElement(nodes[0]) ||
251
- nodes[0].type !== ContentTitle
252
- ) {
253
- throw new Error(
254
- "ContentHeader de fieldset exige somente um ContentTitle; contador, descrição e ações ficam fora da legenda.",
255
- );
256
- }
257
265
  return (
258
266
  <ContentHeaderContext.Provider value={content}>
259
- <legend
260
- data-slot="content-header"
267
+ <SurfaceHeader
268
+ as="legend"
269
+ name="ContentHeader"
270
+ slot="content"
271
+ slots={{ media: ContentMedia, title: ContentTitle }}
272
+ mediaPlacement="heading"
273
+ invalidMessage="ContentHeader de fieldset aceita somente ContentMedia e ContentTitle; contador, descrição e ações ficam fora da legenda."
261
274
  className={cn(
262
275
  content.framed &&
263
276
  content.frameVariant === "default" &&
@@ -270,7 +283,7 @@ export function ContentHeader({
270
283
  {...props}
271
284
  >
272
285
  {children}
273
- </legend>
286
+ </SurfaceHeader>
274
287
  </ContentHeaderContext.Provider>
275
288
  );
276
289
  }
@@ -280,12 +293,25 @@ export function ContentHeader({
280
293
  name="ContentHeader"
281
294
  slot="content"
282
295
  slots={{
296
+ media: ContentMedia,
283
297
  title: ContentTitle,
284
298
  count: ContentMeta,
285
299
  description: ContentDescription,
286
300
  actions: ContentActions,
287
301
  }}
302
+ mediaPlacement="heading"
288
303
  className={cn(
304
+ content.framed &&
305
+ content.frameVariant === "legend" &&
306
+ "flex items-center [&>[data-slot=content-actions]]:-mr-2 [&>[data-slot=content-actions]]:bg-[var(--surface-background,var(--background))] [&>[data-slot=content-actions]]:px-2 [&>[data-slot=content-heading]]:-ml-2 [&>[data-slot=content-heading]]:w-fit [&>[data-slot=content-heading]]:max-w-full [&>[data-slot=content-heading]]:bg-[var(--surface-background,var(--background))] [&>[data-slot=content-heading]]:px-2",
307
+ content.framed &&
308
+ content.frameVariant === "legend" &&
309
+ content.variant === "section" &&
310
+ "-mt-4 min-h-8",
311
+ content.framed &&
312
+ content.frameVariant === "legend" &&
313
+ content.variant === "page" &&
314
+ "-mt-5 min-h-10",
289
315
  content.variant === "page" &&
290
316
  "flex-row items-center justify-start gap-3",
291
317
  className,
@@ -298,6 +324,59 @@ export function ContentHeader({
298
324
  );
299
325
  }
300
326
 
327
+ /** `null`, `false` e `undefined` não desenham mídia: uma moldura vazia não identifica nada. */
328
+ function hasContentMedia(media: ReactNode): boolean {
329
+ return media !== undefined && media !== null && media !== false;
330
+ }
331
+
332
+ /**
333
+ * Um `IconTile` recebido como filho não vira uma segunda moldura: o slot assume o lugar dele e
334
+ * herda o que ele declara (`size`, `context`, `variant`, demais props), somando as classes.
335
+ */
336
+ function adoptIconTile(props: SurfaceMediaProps): SurfaceMediaProps {
337
+ const { children, asChild, className } = props;
338
+ if (asChild) return props;
339
+ // `toArray`, não `only`: um array com um único item (de um `map`/`filter`) é válido aqui.
340
+ const nodes = Children.toArray(children);
341
+ if (nodes.length !== 1) return props;
342
+ const child = nodes[0];
343
+ if (!isValidElement<IconTileProps>(child) || child.type !== IconTile) {
344
+ return props;
345
+ }
346
+ // `null` em size/context/variant significa "use o padrão" no IconTile: fica o do slot.
347
+ const declared = Object.fromEntries(
348
+ Object.entries(child.props).filter(([, value]) => value != null),
349
+ ) as SurfaceMediaProps;
350
+ return {
351
+ ...props,
352
+ ...declared,
353
+ className: cn(className, declared.className),
354
+ children: declared.children,
355
+ };
356
+ }
357
+
358
+ /** Moldura de ícone ou imagem que identifica a região nomeada. */
359
+ export function ContentMedia({
360
+ size = "xs",
361
+ ...rest
362
+ }: SurfaceMediaProps): ReactElement {
363
+ const context = requiredContext(
364
+ useContext(ContentHeaderContext),
365
+ "ContentMedia",
366
+ "ContentHeader",
367
+ );
368
+ const props = adoptIconTile({ size, ...rest });
369
+ return (
370
+ <SurfaceMedia
371
+ slot="content-media"
372
+ {...props}
373
+ aria-hidden={
374
+ context.element === "fieldset" ? true : props["aria-hidden"]
375
+ }
376
+ />
377
+ );
378
+ }
379
+
301
380
  export function ContentTitle({
302
381
  className,
303
382
  id,
@@ -1218,20 +1218,8 @@ function AdvancedFiltersDialog({
1218
1218
  frameVariant={
1219
1219
  sectionVariant === "framed" ? "legend" : "default"
1220
1220
  }
1221
- title={
1222
- <span className="flex items-center gap-2">
1223
- {sectionIcons?.[section] !== undefined && (
1224
- <span
1225
- data-slot="content-title-icon"
1226
- aria-hidden="true"
1227
- className="inline-flex shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-4"
1228
- >
1229
- {sectionIcons[section]}
1230
- </span>
1231
- )}
1232
- {section}
1233
- </span>
1234
- }
1221
+ title={section}
1222
+ media={sectionIcons?.[section]}
1235
1223
  >
1236
1224
  <FieldGroup className={cn("grid gap-4", gridColumns)}>
1237
1225
  {entries.map(field)}
@@ -45,10 +45,12 @@ interface SurfaceHeaderSlots {
45
45
  title: ElementType;
46
46
  count?: ElementType;
47
47
  description?: ElementType;
48
- actions: ElementType;
48
+ actions?: ElementType;
49
49
  }
50
50
 
51
51
  export interface SurfaceHeaderProps extends HTMLAttributes<HTMLDivElement> {
52
+ /** Elemento estrutural; `legend` preserva a mesma anatomia com wrappers phrasing. */
53
+ as?: "div" | "legend";
52
54
  /** Nome do componente público, para as mensagens de erro (`PageHeader`, `ContentHeader`). */
53
55
  name: string;
54
56
  /** Prefixo dos `data-slot` (`page` → `page-header`, `page-heading`). */
@@ -57,6 +59,8 @@ export interface SurfaceHeaderProps extends HTMLAttributes<HTMLDivElement> {
57
59
  slots: SurfaceHeaderSlots;
58
60
  /** Posição de um slot introdutório opcional, antes da linha ou dentro dela. */
59
61
  leadingPlacement?: "above" | "inline";
62
+ /** Mantém a mídia na linha geral ou a agrupa com o heading. */
63
+ mediaPlacement?: "row" | "heading";
60
64
  /** Classes de um container interno quando a moldura precisa ocupar toda a largura. */
61
65
  contentClassName?: string;
62
66
  /** Permite uma superfície composta apenas pelas regiões leading/actions. */
@@ -71,6 +75,11 @@ export interface SurfaceHeaderProps extends HTMLAttributes<HTMLDivElement> {
71
75
  * NOMEIAM algo (Card, Dialog, Drawer, Page, Content) continuam estritas.
72
76
  */
73
77
  allowFreeContent?: boolean;
78
+ /**
79
+ * Mensagem própria para uma composição inválida, quando a regra do header não cabe na
80
+ * mensagem genérica (a legenda de um fieldset aceita menos slots que o header comum).
81
+ */
82
+ invalidMessage?: string;
74
83
  }
75
84
 
76
85
  /** Expande fragments de primeiro nível: o shorthand monta os slots dentro de um `<>`. */
@@ -83,19 +92,23 @@ function flatten(children: ReactNode): ReactNode[] {
83
92
  }
84
93
 
85
94
  export function SurfaceHeader({
95
+ as: Root = "div",
86
96
  name,
87
97
  slot,
88
98
  slots,
89
99
  leadingPlacement = "above",
100
+ mediaPlacement = "row",
90
101
  contentClassName,
91
102
  titleRequired = true,
92
103
  rowClassName,
93
104
  trailing,
94
105
  allowFreeContent = false,
106
+ invalidMessage,
95
107
  className,
96
108
  children,
97
109
  ...props
98
110
  }: SurfaceHeaderProps): ReactElement {
111
+ const Wrapper = Root === "legend" ? "span" : "div";
99
112
  const nodes = flatten(children);
100
113
  const of = (type: ElementType) =>
101
114
  nodes.filter((node) => isValidElement(node) && node.type === type);
@@ -111,7 +124,8 @@ export function SurfaceHeader({
111
124
  const counts = slots.count === undefined ? [] : of(slots.count);
112
125
  const descriptions =
113
126
  slots.description === undefined ? [] : of(slots.description);
114
- const actionSlots = of(slots.actions);
127
+ const actionSlots =
128
+ slots.actions === undefined ? [] : of(slots.actions);
115
129
  const slotTypes = [
116
130
  ...leadingTypes,
117
131
  slots.media,
@@ -167,6 +181,7 @@ export function SurfaceHeader({
167
181
  isValidElement(node) ? [node.type as ElementType] : [],
168
182
  ),
169
183
  );
184
+ if (invalidMessage !== undefined) throw new Error(invalidMessage);
170
185
  throw new Error(
171
186
  distinctLeading.size > 1
172
187
  ? `${name} aceita ${[...distinctLeading].map(label).join(" ou ")} na região introdutória, nunca os dois na mesma superfície.`
@@ -174,24 +189,42 @@ export function SurfaceHeader({
174
189
  );
175
190
  }
176
191
 
192
+ const titleLine = titles.length + counts.length > 0 && (
193
+ <Wrapper className="flex items-baseline gap-2">
194
+ {titles}
195
+ {counts}
196
+ </Wrapper>
197
+ );
177
198
  const heading = titles.length +
178
199
  counts.length +
179
200
  descriptions.length +
180
- free.length >
201
+ free.length +
202
+ (mediaPlacement === "heading" ? medias.length : 0) >
181
203
  0 && (
182
- <div
204
+ <Wrapper
183
205
  data-slot={`${slot}-heading`}
184
206
  className={cn("min-w-0", leadingPlacement === "inline" && "flex-1")}
185
207
  >
186
- {titles.length + counts.length > 0 && (
187
- <div className="flex items-baseline gap-2">
188
- {titles}
189
- {counts}
190
- </div>
208
+ {mediaPlacement === "heading" && medias.length > 0 ? (
209
+ <Wrapper
210
+ data-slot={`${slot}-heading-row`}
211
+ className="flex items-center gap-2"
212
+ >
213
+ {medias}
214
+ <Wrapper data-slot={`${slot}-heading-content`} className="min-w-0">
215
+ {titleLine}
216
+ {descriptions}
217
+ {free}
218
+ </Wrapper>
219
+ </Wrapper>
220
+ ) : (
221
+ <>
222
+ {titleLine}
223
+ {descriptions}
224
+ {free}
225
+ </>
191
226
  )}
192
- {descriptions}
193
- {free}
194
- </div>
227
+ </Wrapper>
195
228
  );
196
229
  // A região introdutória carrega o mesmo `data-slot` nas duas apresentações: seletor de teste
197
230
  // ou de CSS que funcione no header padrão precisa funcionar na barra.
@@ -200,14 +233,14 @@ export function SurfaceHeader({
200
233
  {leadingPlacement === "inline" && leading.length > 0 && (
201
234
  // `min-w-0` aqui é o que deixa a trilha do PageNavigation ceder e truncar; sem ele o
202
235
  // wrapper assume o min-content do breadcrumb e a compressão toda cai sobre o título.
203
- <div
236
+ <Wrapper
204
237
  data-slot={`${slot}-navigation`}
205
238
  className={cn("flex min-w-0 items-center", !heading && "flex-1")}
206
239
  >
207
240
  {leading}
208
- </div>
241
+ </Wrapper>
209
242
  )}
210
- {medias}
243
+ {mediaPlacement === "row" && medias}
211
244
  {heading}
212
245
  {actionSlots}
213
246
  {trailing}
@@ -215,15 +248,15 @@ export function SurfaceHeader({
215
248
  );
216
249
  const layout =
217
250
  leadingPlacement === "inline" ? (
218
- <div className={cn("flex min-w-0 items-center gap-3", rowClassName)}>
251
+ <Wrapper className={cn("flex min-w-0 items-center gap-3", rowClassName)}>
219
252
  {row}
220
- </div>
253
+ </Wrapper>
221
254
  ) : leading.length > 0 ? (
222
255
  <>
223
- <div data-slot={`${slot}-navigation`} className="mb-4">
256
+ <Wrapper data-slot={`${slot}-navigation`} className="mb-4">
224
257
  {leading}
225
- </div>
226
- <div
258
+ </Wrapper>
259
+ <Wrapper
227
260
  data-slot={`${slot}-header-row`}
228
261
  className={cn(
229
262
  actionSlots.length > 0 &&
@@ -232,7 +265,7 @@ export function SurfaceHeader({
232
265
  >
233
266
  {heading}
234
267
  {actionSlots}
235
- </div>
268
+ </Wrapper>
236
269
  </>
237
270
  ) : (
238
271
  <>
@@ -242,7 +275,7 @@ export function SurfaceHeader({
242
275
  );
243
276
 
244
277
  return (
245
- <div
278
+ <Root
246
279
  data-slot={`${slot}-header`}
247
280
  className={cn(
248
281
  contentClassName === undefined &&
@@ -257,10 +290,10 @@ export function SurfaceHeader({
257
290
  {contentClassName === undefined ? (
258
291
  layout
259
292
  ) : (
260
- <div data-slot={`${slot}-header-content`} className={contentClassName}>
293
+ <Wrapper data-slot={`${slot}-header-content`} className={contentClassName}>
261
294
  {layout}
262
- </div>
295
+ </Wrapper>
263
296
  )}
264
- </div>
297
+ </Root>
265
298
  );
266
299
  }
@@ -52,7 +52,7 @@ export function Card({
52
52
  data-slot="card"
53
53
  data-layout={layout}
54
54
  className={cn(
55
- "rounded-xl border border-border bg-card text-card-foreground",
55
+ "rounded-xl border border-border bg-card text-card-foreground [--surface-background:var(--card)]",
56
56
  // Seccionado: o divisor encosta na borda, então o raio precisa recortar.
57
57
  layout === "sectioned" && "overflow-hidden",
58
58
  className,
@@ -230,7 +230,7 @@ export function DialogContent({
230
230
  const { mode } = useDialogContext();
231
231
  const showClose = showCloseButton ?? mode !== "alert";
232
232
  const contentClasses =
233
- "fixed left-[50%] top-[50%] z-50 w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] overflow-clip rounded-xl border border-transparent bg-background text-foreground shadow-xl duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 dark:border-border/70";
233
+ "fixed left-[50%] top-[50%] z-50 w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] overflow-clip rounded-xl border border-transparent bg-background text-foreground shadow-xl duration-200 outline-none [--surface-background:var(--background)] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 dark:border-border/70";
234
234
 
235
235
  if (mode === "alert") {
236
236
  return (