@softize/opus 18.5.0 → 20.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 (151) hide show
  1. package/CHANGELOG.md +275 -0
  2. package/bin/cli.mjs +5 -0
  3. package/bin/lib/check.mjs +198 -10
  4. package/bin/lib/copy.mjs +29 -0
  5. package/bin/lib/mcp.mjs +1 -1
  6. package/docs/adr/0023-surfaces-share-one-anatomy-and-two-layouts.md +298 -0
  7. package/docs/adr/0024-fill-height-propagates-by-context.md +118 -0
  8. package/docs/data-products.md +15 -9
  9. package/docs/protocol.md +3 -4
  10. package/docs/releasing.md +2 -2
  11. package/package.json +2 -2
  12. package/registry/skills/create-opus-action/scripts/scaffold.mjs +2 -1
  13. package/registry/templates/app/package.json +1 -1
  14. package/src/core/runtime.ts +96 -8
  15. package/src/core/types.ts +5 -2
  16. package/src/ui/components/patterns/action-form-card.tsx +11 -1
  17. package/src/ui/components/patterns/content-header.tsx +16 -12
  18. package/src/ui/components/patterns/fill-region.ts +150 -0
  19. package/src/ui/components/patterns/heading-level.ts +16 -0
  20. package/src/ui/components/patterns/list.tsx +103 -73
  21. package/src/ui/components/patterns/page.tsx +20 -4
  22. package/src/ui/components/patterns/pane.tsx +294 -0
  23. package/src/ui/components/patterns/presentation.tsx +81 -54
  24. package/src/ui/components/patterns/sidebar.tsx +13 -72
  25. package/src/ui/components/patterns/split.tsx +51 -38
  26. package/src/ui/components/patterns/surface-anatomy.ts +246 -0
  27. package/src/ui/components/patterns/surface-header.tsx +51 -6
  28. package/src/ui/components/patterns/surface-media.tsx +69 -0
  29. package/src/ui/components/patterns/trigger.tsx +12 -2
  30. package/src/ui/components/primitives/alert.tsx +9 -1
  31. package/src/ui/components/primitives/avatar.tsx +7 -3
  32. package/src/ui/components/primitives/card.tsx +91 -29
  33. package/src/ui/components/primitives/carousel.tsx +34 -5
  34. package/src/ui/components/primitives/checkbox.tsx +11 -3
  35. package/src/ui/components/primitives/command.tsx +14 -5
  36. package/src/ui/components/primitives/copyable.tsx +120 -10
  37. package/src/ui/components/primitives/detail.tsx +10 -13
  38. package/src/ui/components/primitives/dialog.tsx +179 -45
  39. package/src/ui/components/primitives/drawer.tsx +131 -24
  40. package/src/ui/components/primitives/empty.tsx +1 -1
  41. package/src/ui/components/primitives/icon-picker.tsx +6 -2
  42. package/src/ui/components/primitives/icon-tile.tsx +7 -0
  43. package/src/ui/components/primitives/input-group.tsx +9 -2
  44. package/src/ui/components/primitives/input-otp.tsx +1 -1
  45. package/src/ui/components/primitives/kbd.tsx +1 -1
  46. package/src/ui/components/primitives/label.tsx +3 -0
  47. package/src/ui/components/primitives/menu.tsx +5 -0
  48. package/src/ui/components/primitives/metric-card.tsx +98 -39
  49. package/src/ui/components/primitives/popover.tsx +1 -1
  50. package/src/ui/components/primitives/progress.tsx +9 -1
  51. package/src/ui/components/primitives/slider.tsx +62 -11
  52. package/src/ui/components/primitives/truncate.tsx +10 -1
  53. package/src/ui/docs/content/accordion.md +74 -27
  54. package/src/ui/docs/content/action-form-card.md +24 -15
  55. package/src/ui/docs/content/action-form-dialog.md +17 -7
  56. package/src/ui/docs/content/action-form.md +92 -77
  57. package/src/ui/docs/content/action-list-dialog.md +75 -38
  58. package/src/ui/docs/content/action-list.md +134 -52
  59. package/src/ui/docs/content/action-trigger.md +27 -10
  60. package/src/ui/docs/content/action-view.md +14 -14
  61. package/src/ui/docs/content/actions.md +161 -66
  62. package/src/ui/docs/content/ai.md +43 -44
  63. package/src/ui/docs/content/alert.md +83 -29
  64. package/src/ui/docs/content/ask.md +51 -26
  65. package/src/ui/docs/content/aspect-ratio.md +22 -24
  66. package/src/ui/docs/content/audit.md +23 -19
  67. package/src/ui/docs/content/auth.md +38 -19
  68. package/src/ui/docs/content/avatar.md +54 -5
  69. package/src/ui/docs/content/badge.md +29 -9
  70. package/src/ui/docs/content/breadcrumb.md +21 -8
  71. package/src/ui/docs/content/button.md +73 -43
  72. package/src/ui/docs/content/calendar.md +134 -26
  73. package/src/ui/docs/content/card.md +252 -35
  74. package/src/ui/docs/content/carousel.md +37 -6
  75. package/src/ui/docs/content/chat.md +118 -37
  76. package/src/ui/docs/content/checkbox.md +73 -7
  77. package/src/ui/docs/content/cli.md +94 -55
  78. package/src/ui/docs/content/collapsible.md +43 -19
  79. package/src/ui/docs/content/command.md +30 -8
  80. package/src/ui/docs/content/composer.md +23 -8
  81. package/src/ui/docs/content/content.md +37 -7
  82. package/src/ui/docs/content/copyable.md +39 -8
  83. package/src/ui/docs/content/customization.md +55 -46
  84. package/src/ui/docs/content/cycle.md +33 -18
  85. package/src/ui/docs/content/data-products.md +115 -0
  86. package/src/ui/docs/content/data-state.md +20 -6
  87. package/src/ui/docs/content/data.md +20 -19
  88. package/src/ui/docs/content/detail.md +12 -5
  89. package/src/ui/docs/content/dialog.md +219 -28
  90. package/src/ui/docs/content/dictionary-value.md +4 -4
  91. package/src/ui/docs/content/dock.md +54 -19
  92. package/src/ui/docs/content/dot.md +35 -5
  93. package/src/ui/docs/content/drawer.md +193 -22
  94. package/src/ui/docs/content/empty-value.md +20 -6
  95. package/src/ui/docs/content/empty.md +24 -3
  96. package/src/ui/docs/content/events.md +76 -16
  97. package/src/ui/docs/content/executions.md +9 -9
  98. package/src/ui/docs/content/field.md +21 -6
  99. package/src/ui/docs/content/getting-started.md +44 -34
  100. package/src/ui/docs/content/icon-picker.md +19 -13
  101. package/src/ui/docs/content/icon-tile.md +28 -5
  102. package/src/ui/docs/content/input-otp.md +58 -7
  103. package/src/ui/docs/content/input.md +22 -3
  104. package/src/ui/docs/content/item.md +53 -6
  105. package/src/ui/docs/content/kbd.md +5 -1
  106. package/src/ui/docs/content/label.md +13 -4
  107. package/src/ui/docs/content/log.md +5 -5
  108. package/src/ui/docs/content/markdown.md +5 -4
  109. package/src/ui/docs/content/mcp.md +49 -23
  110. package/src/ui/docs/content/menu.md +95 -10
  111. package/src/ui/docs/content/metric-card.md +68 -21
  112. package/src/ui/docs/content/observability.md +10 -9
  113. package/src/ui/docs/content/page.md +125 -11
  114. package/src/ui/docs/content/pagination.md +13 -5
  115. package/src/ui/docs/content/pane.md +300 -0
  116. package/src/ui/docs/content/popover.md +42 -5
  117. package/src/ui/docs/content/presentation.md +91 -47
  118. package/src/ui/docs/content/progress.md +27 -14
  119. package/src/ui/docs/content/queue.md +39 -21
  120. package/src/ui/docs/content/radio-group.md +26 -4
  121. package/src/ui/docs/content/router.md +29 -10
  122. package/src/ui/docs/content/runtime.md +90 -54
  123. package/src/ui/docs/content/scheduler.md +53 -21
  124. package/src/ui/docs/content/scroll-area.md +42 -35
  125. package/src/ui/docs/content/select.md +78 -33
  126. package/src/ui/docs/content/semantic-context.md +21 -2
  127. package/src/ui/docs/content/separator.md +15 -9
  128. package/src/ui/docs/content/sidebar.md +25 -45
  129. package/src/ui/docs/content/skeleton.md +13 -4
  130. package/src/ui/docs/content/slider.md +37 -9
  131. package/src/ui/docs/content/spinner.md +4 -3
  132. package/src/ui/docs/content/split.md +34 -20
  133. package/src/ui/docs/content/storage.md +8 -9
  134. package/src/ui/docs/content/switch.md +10 -7
  135. package/src/ui/docs/content/table.md +46 -8
  136. package/src/ui/docs/content/tabs.md +14 -4
  137. package/src/ui/docs/content/testing.md +76 -31
  138. package/src/ui/docs/content/textarea.md +5 -4
  139. package/src/ui/docs/content/toast.md +48 -7
  140. package/src/ui/docs/content/toggle.md +3 -3
  141. package/src/ui/docs/content/tokens.md +15 -26
  142. package/src/ui/docs/content/tooltip.md +18 -2
  143. package/src/ui/docs/content/truncate.md +36 -6
  144. package/src/ui/docs/content/ui.md +74 -22
  145. package/src/ui/docs/content/upgrading.md +45 -36
  146. package/src/ui/docs/plugin.ts +36 -2
  147. package/src/ui/docs/registry.tsx +14 -2
  148. package/src/ui/drivers/react.tsx +16 -8
  149. package/src/ui/meta.ts +38 -32
  150. package/src/ui/react.tsx +14 -0
  151. package/src/vite/design.ts +16 -4
@@ -413,9 +413,35 @@ export class Runtime {
413
413
  })
414
414
  }
415
415
  this.actions.set(action.name, action)
416
+ if (this.config.dev) this.warnIgnoredExecutionFields(action)
416
417
  }
417
418
  }
418
419
 
420
+ /**
421
+ * `emits` e `background` pertencem ao binding de qualquer kind pelo tipo, mas o runtime e o
422
+ * manifest só os aplicam em `simple` e `form`. Restringir o tipo quebraria bindings
423
+ * existentes; o aviso em desenvolvimento evita que a declaração pareça ter efeito.
424
+ */
425
+ private warnIgnoredExecutionFields(action: ActionDef): void {
426
+ if (action.kind !== 'list' && action.kind !== 'view') return
427
+ const declared = action as { emits?: unknown; background?: unknown }
428
+ const fields = (['emits', 'background'] as const).filter((field) => declared[field] !== undefined)
429
+ if (fields.length === 0) return
430
+ const list = fields.map((field) => `\`${field}\``).join(' e ')
431
+ const effects = fields.map((field) =>
432
+ field === 'background'
433
+ ? 'Sem fila, a action executa na própria requisição.'
434
+ : 'Os eventos declarados não entram no manifest nem na conferência do que o handler emite.',
435
+ )
436
+ this.log.warn(
437
+ `A action "${action.name}" é do kind "${action.kind}" e declara ${list}, que o runtime só ` +
438
+ `aplica em actions simple e form. ${effects.join(' ')} Remova ${list} da declaração da ` +
439
+ 'action, no `bindAction` ou no `defineAction`; se a operação precisa de fila ou de ' +
440
+ 'eventos declarados, use uma action simple ou form.',
441
+ { code: 'runtime.action_field_ignored', action: action.name, kind: action.kind, fields },
442
+ )
443
+ }
444
+
419
445
  private registerReactions(reactions: Array<ReactionDef<any>>): void {
420
446
  for (const reaction of reactions) {
421
447
  if (this.reactions.has(reaction.name)) {
@@ -455,6 +481,12 @@ export class Runtime {
455
481
  })
456
482
  }
457
483
 
484
+ // Antes de inicializar adapters: é uma checagem do registro, e falhar aqui não deixa
485
+ // conexão aberta para trás. Sem scheduler, a checagem não roda porque nenhum schedule
486
+ // executaria: os adapters inicializam e o start falha depois, com
487
+ // `runtime.eventbus_required` se também faltar event bus, senão `runtime.scheduler_required`.
488
+ if (this.scheduler !== undefined) this.assertScheduledActionsExist()
489
+
458
490
  for (const adapter of this.allAdapters()) {
459
491
  if (adapter.init !== undefined) await adapter.init(this)
460
492
  if (adapter.dispose !== undefined) {
@@ -1203,6 +1235,35 @@ export class Runtime {
1203
1235
  }
1204
1236
  }
1205
1237
 
1238
+ /**
1239
+ * Um schedule com nome de action errado dispararia em silêncio para sempre. Schedules com
1240
+ * `enabled: false` ficam de fora: não são registrados e podem apontar para uma action que
1241
+ * ainda não existe neste ambiente.
1242
+ */
1243
+ private assertScheduledActionsExist(): void {
1244
+ const missing = [...this.schedules.values()].filter(
1245
+ (schedule) => schedule.enabled !== false && !this.actions.has(schedule.action),
1246
+ )
1247
+ if (missing.length === 0) return
1248
+ const list = missing
1249
+ .map((schedule) => `"${schedule.name}" → "${schedule.action}"`)
1250
+ .join(', ')
1251
+ throw error({
1252
+ code: 'runtime.schedule_action_not_found',
1253
+ category: 'internal',
1254
+ message:
1255
+ `O runtime não iniciou porque ${
1256
+ missing.length === 1
1257
+ ? 'um schedule aponta para uma action não registrada'
1258
+ : `${missing.length} schedules apontam para actions não registradas`
1259
+ }: ${list}. Corrija o nome em \`action\` ou registre a action no mesmo runtime; para ` +
1260
+ 'manter o schedule sem executar, declare `enabled: false`.',
1261
+ meta: {
1262
+ schedules: missing.map((schedule) => ({ schedule: schedule.name, action: schedule.action })),
1263
+ },
1264
+ })
1265
+ }
1266
+
1206
1267
  private registerSchedule(schedule: ScheduleDef): void {
1207
1268
  if (schedule.enabled === false) {
1208
1269
  this.log.info('schedule disabled', { schedule: schedule.name })
@@ -1221,12 +1282,45 @@ export class Runtime {
1221
1282
  typeof schedule.input === 'function'
1222
1283
  ? await (schedule.input as () => unknown | Promise<unknown>)()
1223
1284
  : (schedule.input ?? {})
1224
- await this.execute(schedule.action, input, {
1285
+ const result = await this.execute(schedule.action, input, {
1225
1286
  user: null,
1226
1287
  tenantId: null,
1227
1288
  can: () => false,
1228
1289
  provenance: { kind: 'schedule', scheduleName: schedule.name },
1229
1290
  })
1291
+ if (result.ok) return
1292
+ // Ninguém espera o resultado de um disparo: sem este registro, a falha só existiria no
1293
+ // audit da action. Action inexistente não chega aqui, porque o start() recusa o schedule.
1294
+ this.log.error(
1295
+ `O schedule "${schedule.name}" não concluiu a action "${schedule.action}" (${result.error.code}). ` +
1296
+ 'O agendamento continua ativo e o próximo disparo executa a action de novo. Confira a ' +
1297
+ 'mensagem e o audit da action; `auth.unauthenticated` indica que a action agendada precisa ' +
1298
+ 'declarar `public: true`.',
1299
+ {
1300
+ schedule: schedule.name,
1301
+ action: schedule.action,
1302
+ code: result.error.code,
1303
+ message: result.error.message,
1304
+ actionId: result.meta.actionId,
1305
+ ...(result.error.cause !== undefined ? { cause: result.error.cause } : {}),
1306
+ },
1307
+ )
1308
+ }
1309
+
1310
+ /**
1311
+ * `ctx.emit` de reaction não publica: encadear reactions por evento ainda não é suportado.
1312
+ * Fora de produção, o descarte vira aviso para que o evento não pareça ter sido entregue.
1313
+ */
1314
+ private buildReactionEmit(reaction: ReactionDef<any>, log: Logger): ReactionContext['emit'] {
1315
+ return async (event) => {
1316
+ if (!this.config.dev) return
1317
+ log.warn(
1318
+ `A reaction "${reaction.name}" chamou ctx.emit('${event}'), mas reactions não publicam ` +
1319
+ 'eventos: o evento foi descartado e nenhuma reaction ou integração o recebe. Para ' +
1320
+ 'anunciar esse fato, emita-o no handler da action que o produz e declare-o em `emits`.',
1321
+ { code: 'runtime.reaction_emit_ignored', reaction: reaction.name, event },
1322
+ )
1323
+ }
1230
1324
  }
1231
1325
 
1232
1326
  private async runReaction(
@@ -1260,7 +1354,7 @@ export class Runtime {
1260
1354
  tenantId: event.tenant ?? null,
1261
1355
  db: this.data?.contextExtension().db,
1262
1356
  log: reactionLog,
1263
- emit: noopEmit,
1357
+ emit: this.buildReactionEmit(reaction, reactionLog),
1264
1358
  storage: this.storage ?? null,
1265
1359
  cache: this.cache ?? null,
1266
1360
  // Reação = contexto de sistema; o agente só alcança actions públicas (can nega por
@@ -1411,12 +1505,6 @@ function severityForError(err: any): 'info' | 'warning' | 'error' {
1411
1505
  return 'error'
1412
1506
  }
1413
1507
 
1414
- /**
1415
- * Placeholder pra `ctx.emit` em reactions. Reaction chain (reaction emite
1416
- * eventos novos que disparam outras reactions) entra em v1+.
1417
- */
1418
- const noopEmit: ActionContext['emit'] = async () => undefined
1419
-
1420
1508
  function generateId(): string {
1421
1509
  if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
1422
1510
  return crypto.randomUUID()
package/src/core/types.ts CHANGED
@@ -255,8 +255,10 @@ 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`. Disponível em `ActionContext.emit` e
259
- * `ReactionContext.emit`. Plugada pelo `EventBusAdapter`.
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`.
260
262
  */
261
263
  export type EmitFn = <T = unknown>(
262
264
  event: string,
@@ -293,6 +295,7 @@ export interface ReactionContext {
293
295
  tenantId: string | null
294
296
  db: unknown
295
297
  log: Logger
298
+ /** Descarta o evento sem publicá-lo: reactions ainda não encadeiam outras. Ver `EmitFn`. */
296
299
  emit: EmitFn
297
300
  storage: StorageAdapter | null
298
301
  cache: CacheAdapter | null
@@ -13,6 +13,7 @@ import {
13
13
  CardTitle,
14
14
  } from "../primitives/card.tsx";
15
15
  import { ActionForm, type ActionFormProps } from "./form.tsx";
16
+ import type { HeadingLevel } from "./heading-level.ts";
16
17
  import { ButtonGroup } from "../primitives/button-group.tsx";
17
18
 
18
19
  export interface ActionFormCardProps<
@@ -21,6 +22,12 @@ export interface ActionFormCardProps<
21
22
  > extends Omit<ActionFormProps<TInput, TData>, "body" | "footer"> {
22
23
  /** Título do header do card. Sem ele, o card começa direto no conteúdo. */
23
24
  title?: string;
25
+ /**
26
+ * Nível semântico do título (`h1`–`h6`), repassado ao `CardTitle` como `level`. Sem
27
+ * ele vale o `h3` do Card. Muda só a hierarquia do documento; a escala visual continua
28
+ * a do Card.
29
+ */
30
+ titleLevel?: HeadingLevel;
24
31
  /** Subtítulo opcional, abaixo do título. */
25
32
  description?: string;
26
33
  /** Classes do Card (a superfície). `className` vai pro <form>. */
@@ -29,6 +36,7 @@ export interface ActionFormCardProps<
29
36
 
30
37
  export function ActionFormCard<TInput extends Record<string, unknown>, TData>({
31
38
  title,
39
+ titleLevel,
32
40
  description,
33
41
  cardClassName,
34
42
  ...rest
@@ -37,7 +45,9 @@ export function ActionFormCard<TInput extends Record<string, unknown>, TData>({
37
45
  <Card className={cardClassName}>
38
46
  {(title !== undefined || description !== undefined) && (
39
47
  <CardHeader>
40
- {title !== undefined && <CardTitle>{title}</CardTitle>}
48
+ {title !== undefined && (
49
+ <CardTitle level={titleLevel}>{title}</CardTitle>
50
+ )}
41
51
  {description !== undefined && (
42
52
  <CardDescription>{description}</CardDescription>
43
53
  )}
@@ -10,6 +10,8 @@ import {
10
10
  type ReactNode,
11
11
  } from "react";
12
12
  import { cn } from "../../lib/cn.ts";
13
+ import { fillRegionClassName, useFillRegion } from "./fill-region.ts";
14
+ import { headingTags } from "./heading-level.ts";
13
15
  import { usePageHeadingPresence } from "./page-heading-context.tsx";
14
16
  import {
15
17
  SurfaceHeader,
@@ -121,6 +123,7 @@ export function Content({
121
123
  ...props
122
124
  }: ContentProps): ReactElement {
123
125
  const titleId = useId();
126
+ const fill = useFillRegion();
124
127
  const context = {
125
128
  element: as,
126
129
  frameVariant,
@@ -184,6 +187,7 @@ export function Content({
184
187
  data-as="fieldset"
185
188
  data-framed={framed}
186
189
  data-frame-variant={frameVariant}
190
+ {...fill.attributes}
187
191
  aria-labelledby={ariaLabelledBy}
188
192
  className={cn(
189
193
  !framed && "space-y-3",
@@ -193,11 +197,12 @@ export function Content({
193
197
  framed &&
194
198
  frameVariant === "legend" &&
195
199
  "space-y-3 rounded-lg border border-border px-4 pb-4",
200
+ fill.filling && fillRegionClassName,
196
201
  className,
197
202
  )}
198
203
  {...(props as ComponentProps<"fieldset">)}
199
204
  >
200
- {anatomy}
205
+ {fill.provide(anatomy)}
201
206
  </fieldset>
202
207
  ) : (
203
208
  <section
@@ -205,15 +210,17 @@ export function Content({
205
210
  data-as="section"
206
211
  data-framed={framed}
207
212
  data-frame-variant={frameVariant}
213
+ {...fill.attributes}
208
214
  aria-labelledby={ariaLabelledBy}
209
215
  className={cn(
210
216
  "space-y-3",
211
217
  framed && "rounded-lg border border-border p-4",
218
+ fill.filling && fillRegionClassName,
212
219
  className,
213
220
  )}
214
221
  {...(props as ComponentProps<"section">)}
215
222
  >
216
- {anatomy}
223
+ {fill.provide(anatomy)}
217
224
  </section>
218
225
  )}
219
226
  </ContentContext.Provider>
@@ -291,15 +298,6 @@ export function ContentHeader({
291
298
  );
292
299
  }
293
300
 
294
- const headingTags = {
295
- 1: "h1",
296
- 2: "h2",
297
- 3: "h3",
298
- 4: "h4",
299
- 5: "h5",
300
- 6: "h6",
301
- } as const;
302
-
303
301
  export function ContentTitle({
304
302
  className,
305
303
  id,
@@ -387,6 +385,7 @@ export function ContentActions({
387
385
 
388
386
  export function ContentBody({
389
387
  className,
388
+ children,
390
389
  ...props
391
390
  }: HTMLAttributes<HTMLDivElement>): ReactElement {
392
391
  const content = requiredContext(
@@ -394,18 +393,23 @@ export function ContentBody({
394
393
  "ContentBody",
395
394
  "Content",
396
395
  );
396
+ const fill = useFillRegion();
397
397
  return (
398
398
  <div
399
399
  data-slot="content-body"
400
+ {...fill.attributes}
400
401
  className={cn(
401
402
  content.element === "fieldset" && "space-y-3",
402
403
  content.element === "fieldset" &&
403
404
  content.framed &&
404
405
  content.frameVariant === "default" &&
405
406
  "clear-left pt-3",
407
+ fill.filling && fillRegionClassName,
406
408
  className,
407
409
  )}
408
410
  {...props}
409
- />
411
+ >
412
+ {fill.provide(children)}
413
+ </div>
410
414
  );
411
415
  }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Propagação da ocupação de altura (interno) — ADR 0024.
3
+ *
4
+ * `Page layout="fill"` limita a página à altura do shell; a `Presentation` em página herda
5
+ * esse limite pela `Page` que ela monta. Para uma região interna ocupar a folga e rolar
6
+ * sozinha, cada elemento entre a raiz limitada e essa região precisa repassar a altura como
7
+ * coluna flexível. Este módulo faz essa cadeia se formar sem classes no consumidor:
8
+ *
9
+ * - a raiz (`FillRoot`) publica o contexto;
10
+ * - os elos canônicos (`PageBody`, `Content`, `ContentBody`, o corpo da `Presentation`)
11
+ * leem o contexto com `useFillRegion` e o republicam para os filhos;
12
+ * - a folha (`ActionList`) usa `useFillLeaf`: assume `fill` por padrão quando o elemento
13
+ * pai é um elo e se registra na cadeia;
14
+ * - um elo só vira coluna flexível quando uma folha se registrou abaixo dele. Irmãos sem
15
+ * folha mantêm a altura do próprio conteúdo, e uma página `fill` sem lista não muda;
16
+ * - superfícies com anatomia própria (Card, Dialog, Drawer, Pane) cortam a cadeia com
17
+ * `FillBoundary`: a altura delas é decisão delas, inclusive quando um portal as renderiza
18
+ * dentro de uma página `fill`.
19
+ *
20
+ * O registro acontece em `useLayoutEffect`, então as classes chegam antes da primeira pintura.
21
+ */
22
+
23
+ import {
24
+ createContext,
25
+ createElement,
26
+ useContext,
27
+ useLayoutEffect,
28
+ useMemo,
29
+ useRef,
30
+ useState,
31
+ type ReactElement,
32
+ type ReactNode,
33
+ type RefObject,
34
+ } from "react";
35
+
36
+ export type FillLayout = "flow" | "fill";
37
+
38
+ interface FillChain {
39
+ /** Anuncia uma folha abaixo deste elo; devolve a retirada do anúncio. */
40
+ register(): () => void;
41
+ }
42
+
43
+ const FillChainContext = createContext<FillChain | null>(null);
44
+
45
+ /** A raiz já tem altura limitada: não há o que propagar acima dela. */
46
+ const rootChain: FillChain = { register: () => () => {} };
47
+
48
+ /** Classes de um elo que repassa a altura à folha registrada abaixo dele. */
49
+ export const fillRegionClassName = "flex min-h-0 flex-1 flex-col";
50
+
51
+ /**
52
+ * Atributo que marca um elo da cadeia no DOM: `"true"` quando repassa a altura, `"false"`
53
+ * quando está na cadeia sem folha abaixo. A folha só assume `fill` implícito quando o
54
+ * elemento pai o carrega — um contêiner próprio no meio não repassa a altura.
55
+ */
56
+ export const fillRegionAttribute = "data-fill";
57
+
58
+ /** Publica a cadeia quando `active`; caso contrário a interrompe. */
59
+ export function FillRoot({
60
+ active,
61
+ children,
62
+ }: {
63
+ active: boolean;
64
+ children?: ReactNode;
65
+ }): ReactElement {
66
+ return createElement(
67
+ FillChainContext.Provider,
68
+ { value: active ? rootChain : null },
69
+ children,
70
+ );
71
+ }
72
+
73
+ /** Interrompe a cadeia: o conteúdo abaixo decide a própria altura. */
74
+ export function FillBoundary({
75
+ children,
76
+ }: {
77
+ children?: ReactNode;
78
+ }): ReactElement {
79
+ return createElement(FillChainContext.Provider, { value: null }, children);
80
+ }
81
+
82
+ export interface FillRegion {
83
+ /** O elo está em uma cadeia `fill`, com ou sem folha abaixo. */
84
+ inChain: boolean;
85
+ /** Uma folha se registrou abaixo: o elo deve repassar a altura. */
86
+ filling: boolean;
87
+ /** Atributos do elemento do elo. */
88
+ attributes: { "data-fill"?: "true" | "false" };
89
+ /** Republica a cadeia para os filhos do elo. */
90
+ provide(children: ReactNode): ReactElement;
91
+ }
92
+
93
+ /** Elo da cadeia: lê o contexto, conta as folhas abaixo e o republica. */
94
+ export function useFillRegion(): FillRegion {
95
+ const parent = useContext(FillChainContext);
96
+ const [leaves, setLeaves] = useState(0);
97
+ const chain = useMemo<FillChain | null>(
98
+ () =>
99
+ parent === null
100
+ ? null
101
+ : {
102
+ register() {
103
+ setLeaves((count) => count + 1);
104
+ const releaseParent = parent.register();
105
+ let registered = true;
106
+ return () => {
107
+ if (!registered) return;
108
+ registered = false;
109
+ setLeaves((count) => Math.max(0, count - 1));
110
+ releaseParent();
111
+ };
112
+ },
113
+ },
114
+ [parent],
115
+ );
116
+ const inChain = parent !== null;
117
+ const filling = inChain && leaves > 0;
118
+ return {
119
+ inChain,
120
+ filling,
121
+ attributes: inChain ? { "data-fill": filling ? "true" : "false" } : {},
122
+ provide: (children) =>
123
+ createElement(FillChainContext.Provider, { value: chain }, children),
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Folha da cadeia. Sem `layout` explícito, assume `fill` quando o elemento pai é um elo;
129
+ * com `layout="fill"` explícito, registra-se mesmo sob um contêiner próprio, que passa a
130
+ * ser responsabilidade de quem o escreveu. `layout="flow"` nunca se registra.
131
+ */
132
+ export function useFillLeaf<TElement extends HTMLElement>(
133
+ layout: FillLayout | undefined,
134
+ ): { layout: FillLayout; ref: RefObject<TElement | null> } {
135
+ const chain = useContext(FillChainContext);
136
+ const ref = useRef<TElement | null>(null);
137
+ const [linked, setLinked] = useState(false);
138
+ useLayoutEffect(() => {
139
+ setLinked(
140
+ chain !== null &&
141
+ ref.current?.parentElement?.hasAttribute(fillRegionAttribute) === true,
142
+ );
143
+ }, [chain]);
144
+ const resolved = layout ?? (linked ? "fill" : "flow");
145
+ useLayoutEffect(() => {
146
+ if (chain === null || resolved !== "fill") return undefined;
147
+ return chain.register();
148
+ }, [chain, resolved]);
149
+ return { layout: resolved, ref };
150
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Nível de heading (interno): hierarquia do documento, separada do tamanho visual.
3
+ *
4
+ * A ADR 0023 fixa a regra: `h1`–`h6` expressam hierarquia; a escala do título vem da
5
+ * família e do layout. As duas decisões nunca se misturam num mesmo valor.
6
+ */
7
+ export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
8
+
9
+ export const headingTags = {
10
+ 1: "h1",
11
+ 2: "h2",
12
+ 3: "h3",
13
+ 4: "h4",
14
+ 5: "h5",
15
+ 6: "h6",
16
+ } as const satisfies Record<HeadingLevel, `h${HeadingLevel}`>;