@softize/opus 13.1.0 → 14.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 (59) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/bin/lib/copy.mjs +276 -6
  3. package/docs/code-style.md +4 -1
  4. package/package.json +1 -1
  5. package/registry/instructions/opus.md +3 -3
  6. package/registry/templates/app/src/App.tsx +11 -6
  7. package/src/core/types.ts +3 -4
  8. package/src/ui/components/patterns/action-list-dialog.tsx +10 -3
  9. package/src/ui/components/patterns/confirm.tsx +2 -31
  10. package/src/ui/components/patterns/content-header.tsx +42 -141
  11. package/src/ui/components/patterns/data-state.tsx +42 -68
  12. package/src/ui/components/patterns/form.tsx +15 -15
  13. package/src/ui/components/patterns/list.tsx +14 -8
  14. package/src/ui/components/patterns/page-state.tsx +39 -51
  15. package/src/ui/components/patterns/page.tsx +18 -53
  16. package/src/ui/components/patterns/state-surface.tsx +148 -0
  17. package/src/ui/components/patterns/surface-header.tsx +119 -0
  18. package/src/ui/components/patterns/trigger.tsx +7 -9
  19. package/src/ui/components/patterns/view.tsx +14 -16
  20. package/src/ui/components/primitives/alert.tsx +1 -33
  21. package/src/ui/components/primitives/avatar.tsx +15 -5
  22. package/src/ui/components/primitives/badge.tsx +2 -43
  23. package/src/ui/components/primitives/button.tsx +31 -35
  24. package/src/ui/components/primitives/control.ts +60 -0
  25. package/src/ui/components/primitives/dot.tsx +1 -30
  26. package/src/ui/components/primitives/input-group.tsx +11 -8
  27. package/src/ui/components/primitives/item.tsx +3 -1
  28. package/src/ui/components/primitives/menu.tsx +1 -7
  29. package/src/ui/components/primitives/pagination.tsx +16 -8
  30. package/src/ui/components/primitives/select.tsx +2 -2
  31. package/src/ui/components/primitives/spinner.tsx +13 -16
  32. package/src/ui/components/primitives/switch.tsx +4 -1
  33. package/src/ui/components/primitives/tabs.tsx +5 -3
  34. package/src/ui/components/primitives/toggle.tsx +9 -4
  35. package/src/ui/docs/content/action-form.md +26 -0
  36. package/src/ui/docs/content/action-list-dialog.md +2 -2
  37. package/src/ui/docs/content/action-list.md +3 -1
  38. package/src/ui/docs/content/action-trigger.md +4 -4
  39. package/src/ui/docs/content/action-view.md +3 -2
  40. package/src/ui/docs/content/avatar.md +7 -3
  41. package/src/ui/docs/content/button.md +30 -14
  42. package/src/ui/docs/content/communication.md +36 -0
  43. package/src/ui/docs/content/content.md +5 -4
  44. package/src/ui/docs/content/data-state.md +17 -13
  45. package/src/ui/docs/content/dialog.md +1 -4
  46. package/src/ui/docs/content/input.md +1 -1
  47. package/src/ui/docs/content/item.md +1 -1
  48. package/src/ui/docs/content/page.md +12 -4
  49. package/src/ui/docs/content/pagination.md +11 -9
  50. package/src/ui/docs/content/semantic-context.md +3 -2
  51. package/src/ui/docs/content/sidebar.md +2 -42
  52. package/src/ui/docs/content/spinner.md +9 -6
  53. package/src/ui/docs/content/switch.md +1 -1
  54. package/src/ui/docs/content/tabs.md +1 -1
  55. package/src/ui/docs/content/toggle.md +1 -1
  56. package/src/ui/drivers/react.tsx +1 -6
  57. package/src/ui/meta.ts +5 -5
  58. package/src/ui/react.tsx +8 -16
  59. package/src/ui/components/patterns/shell-nav.tsx +0 -154
package/CHANGELOG.md CHANGED
@@ -7,6 +7,40 @@ Depois de qualquer bump, rode os gates (`typecheck` · `test` · `opus check` ·
7
7
  `opus copy --check` · `base copy check` · `manifest:check`) — eles apontam o que a
8
8
  mudança cobra do seu código.
9
9
 
10
+ ## 14.0.0 — 2026-09-09
11
+
12
+ Estados vazio, carregando e erro passam a uma composição só. `DataState` compõe `Empty` (moldura
13
+ sólida, `bare` dentro de tabela) e o mesmo `Alert` de contexto `danger` com botão de recuperação
14
+ que `PageState` já usava; o `Spinner` vira decorativo e o contêiner carrega o único `role="status"`.
15
+ As props de estado têm um nome só em `DataState`, `PageState`, `ActionList` e `ActionListDialog`:
16
+ `emptyMessage`, `errorMessage`, `retryLabel`; `PageState` ganha `onRetry` e `ActionView` ganha
17
+ `emptyMessage` como atalho. `ContentHeader` e `PageHeader` renderizam a mesma anatomia
18
+ (`surface-header.tsx`), e `Content` passa a usar `count`, como `Page`.
19
+
20
+ A escala de `size` é uma só (`primitives/control.ts`): `xs`, `sm`, `default`, `lg` (1.5, 2, 2.25
21
+ e 2.5rem) e `icon-xs`, `icon-sm`, `icon`, `icon-lg` (1.5, 1.75, 2.25 e 2.5rem), consumida por
22
+ Button, InputGroupButton, Toggle, Select, Tabs, Switch, Item, Avatar, Spinner, Pagination e
23
+ ActionTrigger. O degrau de 1.75rem que os consumidores forçavam por classe agora é `icon-sm`.
24
+ `Button.icon` recebe um nó (`icon={<Plus />}`), como todos os outros componentes. `LabelHelp` é
25
+ exportado pelo barrel e documentado em `form.md`.
26
+
27
+ O extrator de copy alcança o que antes escapava do inventário: chamadas imperativas de diálogo
28
+ (`dialog.confirm`, `alert`, `prompt`, `choose` — título, descrição e rótulos das ações),
29
+ `TooltipContent`, rótulos em arrays literais locais percorridos por `map`, o `help` de
30
+ `LabelHelp` e elementos JSX com genérico (`<ActionList<A, B>>`). Os campos de estado seguem os
31
+ nomes novos (`emptyMessage`, `errorMessage`, `retryLabel`).
32
+
33
+ **Breaking:** renomeie `emptyText`, `errorText` e `retryText` para `emptyMessage`,
34
+ `errorMessage` e `retryLabel` em `DataState`, `PageState` e `ActionListDialog`
35
+ (`Select.emptyText` não muda). Troque `Content.meta` por `count`; `ContentHeader` só existe
36
+ dentro de `Content`. `icon-sm` passa a medir 1.75rem e `Avatar` segue a escala (`sm` antigo é
37
+ `xs`; `default` cresce para 2.25rem); classes `size-7` forçadas viram `size="icon-sm"`.
38
+ `Button.icon` deixa de aceitar componente: `icon={Trash}` vira `icon={<Trash />}`. Removidos:
39
+ `TbdlibProvider` (use `OpusProvider`), `ShellNav*` (use `SidebarNav`), `confirm()` e `ConfirmHost`
40
+ (use `dialog.confirm` e `DialogHost`), `FieldSpec.hint` (use `help`) e as variantes semânticas
41
+ legadas de Button, Badge, Alert, Dot e MenuItem — declare `context` e `variant`. Testes que
42
+ liam o markup do vazio de `DataState` passam a olhar `[data-slot=empty]`.
43
+
10
44
  ## 13.1.0 — 2026-09-08
11
45
 
12
46
  Erros deixam de falar inglês e de vazar detalhe interno. `normalizeError` devolve a microcopy fixa
package/bin/lib/copy.mjs CHANGED
@@ -107,8 +107,16 @@ const JSX_CHILD_ROLES = new Map([
107
107
  ['TableCaption', 'description'],
108
108
  ['TableHead', 'heading'],
109
109
  ['TabsTrigger', 'tab'],
110
+ // A Base não tem papel `tooltip`. O tooltip nomeia um controle icon-only, logo é um
111
+ // fragmento (sem ponto final), não uma frase; `label` é o papel que reflete isso.
112
+ ['TooltipContent', 'label'],
110
113
  ])
111
114
 
115
+ // Componentes cuja copy entra pelas próprias props e que, como filhos, não acrescentam
116
+ // texto ao pai: `<FieldLabel>Nome<LabelHelp help="…" /></FieldLabel>` inventaria `Nome`
117
+ // como label e a ajuda como helper-text, sem tornar o filho opaco.
118
+ const SELF_CONTAINED_CHILD_COMPONENTS = new Set(['LabelHelp'])
119
+
112
120
  const OPTIONAL_CHILD_COMPONENTS = new Set(['ActionForm', 'ActionFormCard', 'ActionFormDialog'])
113
121
  const JSX_ACTION_CONSUMERS = new Set([
114
122
  'ActionForm', 'ActionFormCard', 'ActionFormDialog', 'ActionList', 'ActionListDialog', 'ActionTrigger', 'ActionView',
@@ -125,6 +133,14 @@ const JSX_PROP_ROLES = new Map([
125
133
  ['ActionForm', new Map([['submitLabel', 'button'], ['cancelLabel', 'button']])],
126
134
  ['ActionFormCard', new Map([['title', 'title'], ['description', 'description'], ['submitLabel', 'button'], ['cancelLabel', 'button']])],
127
135
  ['ActionFormDialog', new Map([['title', 'title'], ['description', 'dialog-body'], ['submitLabel', 'button'], ['cancelLabel', 'button']])],
136
+ // Estados unificados (14.0): a mesma tríade de mensagens em ActionList, ActionListDialog,
137
+ // DataState e PageState; ActionView só tem o vazio.
138
+ ['ActionList', new Map([['emptyMessage', 'empty-state'], ['errorMessage', 'error'], ['retryLabel', 'button']])],
139
+ ['ActionListDialog', new Map([
140
+ ['title', 'title'], ['description', 'dialog-body'],
141
+ ['emptyMessage', 'empty-state'], ['errorMessage', 'error'], ['retryLabel', 'button'],
142
+ ])],
143
+ ['ActionView', new Map([['emptyMessage', 'empty-state']])],
128
144
  // `itemLabel` identifica o dado alvo (por exemplo, `customer.name`); não é
129
145
  // microcopy estável e, por isso, não pertence ao inventário editorial.
130
146
  ['ActionTrigger', new Map([['label', 'button']])],
@@ -132,7 +148,7 @@ const JSX_PROP_ROLES = new Map([
132
148
  ['CommandInput', new Map([['placeholder', 'placeholder']])],
133
149
  ['Content', new Map([['title', 'title'], ['description', 'description']])],
134
150
  ['ContentHeader', new Map([['title', 'title'], ['description', 'description']])],
135
- ['DataState', new Map([['emptyText', 'empty-state'], ['errorText', 'error']])],
151
+ ['DataState', new Map([['emptyMessage', 'empty-state'], ['errorMessage', 'error'], ['retryLabel', 'button']])],
136
152
  // A Dock nomeia a barra e cada ação por prop. Sem estas linhas, a copy sairia do inventário
137
153
  // exatamente quando uma superfície migra de <Button aria-label> para <DockAction label>.
138
154
  ['Dock', new Map([['label', 'label']])],
@@ -140,8 +156,14 @@ const JSX_PROP_ROLES = new Map([
140
156
  ['Input', new Map([['placeholder', 'placeholder']])],
141
157
  ['InputGroupInput', new Map([['placeholder', 'placeholder']])],
142
158
  ['InputGroupTextarea', new Map([['placeholder', 'placeholder']])],
159
+ // A ajuda vive num tooltip (portal): não herda transformação nem classe do label.
160
+ ['LabelHelp', new Map([['help', 'helper-text']])],
143
161
  ['MetricCard', new Map([['label', 'label'], ['description', 'description']])],
144
162
  ['Page', new Map([['title', 'title'], ['description', 'description']])],
163
+ ['PageState', new Map([
164
+ ['title', 'title'], ['description', 'description'],
165
+ ['emptyMessage', 'empty-state'], ['errorMessage', 'error'], ['retryLabel', 'button'],
166
+ ])],
145
167
  ['Select', new Map([
146
168
  ['placeholder', 'placeholder'],
147
169
  ['searchPlaceholder', 'placeholder'],
@@ -215,6 +237,36 @@ const JSX_PORTAL_BOUNDARIES = new Set([
215
237
  'PopoverContent', 'TooltipContent',
216
238
  ])
217
239
 
240
+ // Respostas imperativas `dialog.*` da UI Opus. O DialogHost renderiza em portal com classes
241
+ // próprias; nenhuma transformação do chamador alcança o texto.
242
+ const DIALOG_METHOD_ROLES = new Map([
243
+ ['alert', new Map([['title', 'title'], ['description', 'dialog-body'], ['body', 'dialog-body'], ['action', 'button']])],
244
+ ['confirm', new Map([
245
+ ['title', 'title'], ['description', 'dialog-body'], ['body', 'dialog-body'],
246
+ ['action', 'button'], ['cancel', 'button'],
247
+ ])],
248
+ ['prompt', new Map([
249
+ ['title', 'title'], ['description', 'dialog-body'], ['body', 'dialog-body'],
250
+ ['action', 'button'], ['cancel', 'button'], ['placeholder', 'placeholder'],
251
+ ])],
252
+ ['choose', new Map([['title', 'title'], ['description', 'dialog-body'], ['body', 'dialog-body']])],
253
+ ])
254
+
255
+ // Métodos de array que não mutam o receptor. Os que devolvem os próprios elementos (num
256
+ // array novo ou avulsos) exigem que o resultado também seja usado de forma estável; os
257
+ // demais só produzem primitivos, novos objetos ou nada.
258
+ const ARRAY_RETURNING_ARRAY_METHODS = new Set(['concat', 'filter', 'flat', 'slice', 'toReversed', 'toSorted'])
259
+ const ELEMENT_PICKING_ARRAY_METHODS = new Set(['at', 'find', 'findLast'])
260
+ const ELEMENT_ITERATING_ARRAY_METHODS = new Set([
261
+ 'every', 'filter', 'find', 'findIndex', 'findLast', 'findLastIndex', 'flatMap', 'forEach', 'map', 'some',
262
+ ])
263
+ const READ_ONLY_ARRAY_METHODS = new Set([
264
+ ...ARRAY_RETURNING_ARRAY_METHODS, ...ELEMENT_PICKING_ARRAY_METHODS, ...ELEMENT_ITERATING_ARRAY_METHODS,
265
+ 'includes', 'indexOf', 'join', 'lastIndexOf',
266
+ ])
267
+ // Cadeia que preserva os elementos entre o array literal e a iteração que os lê.
268
+ const ELEMENT_PRESERVING_ARRAY_METHODS = new Set(['filter', 'slice', 'toReversed', 'toSorted'])
269
+
218
270
  const digest = (content) => createHash('sha256').update(content).digest('hex')
219
271
  const portable = (value) => value.split(path.sep).join('/')
220
272
 
@@ -421,6 +473,17 @@ function knownBindAction(call, checker) {
421
473
  return importedMemberName(call.expression, checker, (module) => CONTRACT_MODULES.has(module)) === 'bindAction'
422
474
  }
423
475
 
476
+ /** Reconhece `dialog.confirm({...})` e `Opus.dialog.alert({...})` importados da UI Opus. */
477
+ function knownDialogCall(call, checker) {
478
+ const callee = unwrap(call.expression)
479
+ if (
480
+ ts.isPropertyAccessExpression(callee) &&
481
+ DIALOG_METHOD_ROLES.has(callee.name.text) &&
482
+ importedMemberName(callee.expression, checker, uiModule) === 'dialog'
483
+ ) return { method: callee.name.text, field: `dialog.${callee.name.text}()` }
484
+ return null
485
+ }
486
+
424
487
  function safeBindActionBinding(call, checker) {
425
488
  if (call.arguments.length < 2) return false
426
489
  const binding = unwrap(call.arguments[1])
@@ -521,6 +584,16 @@ function stableExpressionUse(node, checker, mode, stack) {
521
584
  const parent = carrier.parent
522
585
 
523
586
  if (assignmentTarget(carrier)) return false
587
+ if (consumerMode === 'aggregate' && ts.isCallExpression(parent) && parent.expression === carrier) {
588
+ const access = unwrap(carrier)
589
+ return (
590
+ ts.isPropertyAccessExpression(access) && arrayValued(access.expression, checker) &&
591
+ readOnlyArrayMethodUse(access.name.text, parent, checker, stack)
592
+ )
593
+ }
594
+ if (ts.isForOfStatement(parent) && parent.expression === carrier) {
595
+ return consumerMode === 'aggregate' && arrayValued(carrier, checker) && readOnlyForOfBinding(parent, checker)
596
+ }
524
597
  if (ts.isVariableDeclaration(parent) && parent.initializer === carrier) {
525
598
  if (!ts.isVariableDeclarationList(parent.parent) || (parent.parent.flags & ts.NodeFlags.Const) === 0) return false
526
599
  const aliases = bindingNames(parent.name, checker)
@@ -534,6 +607,8 @@ function stableExpressionUse(node, checker, mode, stack) {
534
607
  if (ts.isCallExpression(parent) && parent.arguments.some((argument) => argument === carrier)) {
535
608
  if (consumerMode === 'aggregate') {
536
609
  if (knownDictionaryFactory(parent, checker)) return true
610
+ // O DialogHost copia as opções (`{ ...options }`) e só as lê; nada muta o literal.
611
+ if (parent.arguments[0] === carrier && knownDialogCall(parent, checker) !== null) return true
537
612
  return knownContractFactory(parent, checker) && stableFactoryResult(parent, checker, stack)
538
613
  }
539
614
  if (
@@ -573,6 +648,166 @@ function stableReferences(binding, checker, mode, stack) {
573
648
  return stable
574
649
  }
575
650
 
651
+ /**
652
+ * Um parâmetro de callback (ou variável de `for…of`) é somente leitura quando cada uso é
653
+ * `elemento.chave` fora de posição de atribuição. Strings são imutáveis, então uma leitura de
654
+ * propriedade nunca altera o rótulo literal; qualquer outro uso (spread, argumento, retorno,
655
+ * atribuição) poderia mutar ou fazer o objeto escapar e é rejeitado. Destructuring copia os
656
+ * valores de primeiro nível e, por isso, também não alcança o literal.
657
+ */
658
+ function readOnlyElementBinding(name, checker) {
659
+ if (ts.isObjectBindingPattern(name)) return true
660
+ if (!ts.isIdentifier(name)) return false
661
+ const symbol = bindingSymbol(name, checker)
662
+ if (symbol === undefined) return false
663
+ let readOnly = true
664
+ const visit = (node) => {
665
+ if (!readOnly) return
666
+ if (ts.isIdentifier(node) && sameBinding(node, symbol, checker) && !declarationIdentifier(node, symbol)) {
667
+ const carrier = transparentCarrier(node)
668
+ const access = carrier.parent
669
+ readOnly =
670
+ (ts.isPropertyAccessExpression(access) || ts.isElementAccessExpression(access)) &&
671
+ access.expression === carrier && !assignmentTarget(access)
672
+ }
673
+ ts.forEachChild(node, visit)
674
+ }
675
+ visit(name.getSourceFile())
676
+ return readOnly
677
+ }
678
+
679
+ function readOnlyCallback(callback, checker) {
680
+ const value = callback === undefined ? null : unwrap(callback)
681
+ if (value === null || (!ts.isArrowFunction(value) && !ts.isFunctionExpression(value))) return false
682
+ // O terceiro parâmetro expõe o próprio array ao callback; índice e `thisArg` não.
683
+ if (value.parameters.length > 2) return false
684
+ const element = value.parameters[0]
685
+ if (element === undefined) return true
686
+ if (element.dotDotDotToken !== undefined || element.initializer !== undefined) return false
687
+ return readOnlyElementBinding(element.name, checker)
688
+ }
689
+
690
+ function readOnlyArrayMethodUse(method, call, checker, stack) {
691
+ if (!READ_ONLY_ARRAY_METHODS.has(method)) return false
692
+ if (ELEMENT_ITERATING_ARRAY_METHODS.has(method) && !readOnlyCallback(call.arguments[0], checker)) return false
693
+ if (ARRAY_RETURNING_ARRAY_METHODS.has(method) || ELEMENT_PICKING_ARRAY_METHODS.has(method)) {
694
+ return stableExpressionUse(call, checker, 'aggregate', stack)
695
+ }
696
+ return true
697
+ }
698
+
699
+ /**
700
+ * A semântica somente leitura de `map`, `filter`… vale para arrays. Um objeto literal pode
701
+ * declarar um método com o mesmo nome e mutar o que quiser; por isso o receptor precisa ser
702
+ * um `const` com array literal ou o resultado de um método que devolve array sobre ele.
703
+ */
704
+ function arrayValued(node, checker) {
705
+ const value = unwrap(node)
706
+ if (ts.isIdentifier(value)) {
707
+ const binding = constInitializer(value, checker)
708
+ return binding !== null && ts.isArrayLiteralExpression(unwrap(binding.initializer))
709
+ }
710
+ if (ts.isCallExpression(value)) {
711
+ const callee = unwrap(value.expression)
712
+ return ts.isPropertyAccessExpression(callee) && ARRAY_RETURNING_ARRAY_METHODS.has(callee.name.text) &&
713
+ arrayValued(callee.expression, checker)
714
+ }
715
+ return ts.isArrayLiteralExpression(value)
716
+ }
717
+
718
+ function readOnlyForOfBinding(statement, checker) {
719
+ const initializer = statement.initializer
720
+ if (!ts.isVariableDeclarationList(initializer) || initializer.declarations.length !== 1) return false
721
+ return readOnlyElementBinding(initializer.declarations[0].name, checker)
722
+ }
723
+
724
+ /**
725
+ * Localiza o array literal do qual um identificador é elemento: parâmetro de callback de
726
+ * `map`/`forEach`/`filter`… ou variável de `for…of`, possivelmente atrás de `filter`,
727
+ * `slice`, `toSorted` e `toReversed`, que preservam os elementos. Só arrays literais no
728
+ * mesmo arquivo entram; qualquer outra origem devolve null.
729
+ */
730
+ function elementSource(identifier, checker) {
731
+ const symbol = bindingSymbol(identifier, checker)
732
+ const declaration = symbol?.declarations?.find(
733
+ (item) => ts.isParameter(item) || ts.isBindingElement(item) || ts.isVariableDeclaration(item),
734
+ )
735
+ if (declaration === undefined) return null
736
+
737
+ let key = null
738
+ let binding = declaration
739
+ if (ts.isBindingElement(declaration)) {
740
+ if (declaration.dotDotDotToken !== undefined || declaration.initializer !== undefined) return null
741
+ const pattern = declaration.parent
742
+ if (!ts.isObjectBindingPattern(pattern) || (!ts.isParameter(pattern.parent) && !ts.isVariableDeclaration(pattern.parent))) return null
743
+ const keyNode = declaration.propertyName ?? declaration.name
744
+ if (!ts.isIdentifier(keyNode) && !ts.isStringLiteral(keyNode)) return null
745
+ key = keyNode.text
746
+ binding = pattern.parent
747
+ }
748
+
749
+ let receiver
750
+ if (ts.isParameter(binding)) {
751
+ if (binding.dotDotDotToken !== undefined || binding.initializer !== undefined) return null
752
+ const callback = binding.parent
753
+ if ((!ts.isArrowFunction(callback) && !ts.isFunctionExpression(callback)) || callback.parameters[0] !== binding) return null
754
+ const call = transparentCarrier(callback).parent
755
+ if (!ts.isCallExpression(call) || call.arguments[0] !== transparentCarrier(callback)) return null
756
+ const callee = unwrap(call.expression)
757
+ if (!ts.isPropertyAccessExpression(callee) || !ELEMENT_ITERATING_ARRAY_METHODS.has(callee.name.text)) return null
758
+ receiver = callee.expression
759
+ } else if (ts.isVariableDeclaration(binding)) {
760
+ const list = binding.parent
761
+ if (!ts.isVariableDeclarationList(list) || !ts.isForOfStatement(list.parent) || list.parent.initializer !== list) return null
762
+ receiver = list.parent.expression
763
+ } else return null
764
+
765
+ let source = unwrap(receiver)
766
+ while (
767
+ ts.isCallExpression(source) && ts.isPropertyAccessExpression(unwrap(source.expression)) &&
768
+ ELEMENT_PRESERVING_ARRAY_METHODS.has(unwrap(source.expression).name.text)
769
+ ) source = unwrap(unwrap(source.expression).expression)
770
+ const array = resolveBinding(source, checker)
771
+ return ts.isArrayLiteralExpression(array) ? { array, key } : null
772
+ }
773
+
774
+ /**
775
+ * Resolve `elemento.chave` (ou a chave destruturada) para o texto literal de cada elemento
776
+ * do array de origem. Um único elemento opaco ou sem texto estático torna o conjunto todo
777
+ * inextraível: o inventário não pode listar só parte do que a superfície renderiza.
778
+ */
779
+ function elementTexts(node, checker) {
780
+ const value = unwrap(node)
781
+ let identifier
782
+ let key = null
783
+ if (ts.isPropertyAccessExpression(value) && ts.isIdentifier(unwrap(value.expression))) {
784
+ identifier = unwrap(value.expression)
785
+ key = value.name.text
786
+ } else if (ts.isElementAccessExpression(value) && ts.isIdentifier(unwrap(value.expression))) {
787
+ identifier = unwrap(value.expression)
788
+ key = value.argumentExpression === undefined ? null : staticText(value.argumentExpression, checker)?.text ?? null
789
+ } else if (ts.isIdentifier(value)) {
790
+ identifier = value
791
+ } else return null
792
+ const source = elementSource(identifier, checker)
793
+ if (source === null) return null
794
+ if (ts.isIdentifier(value)) key = source.key
795
+ else if (source.key !== null) return null
796
+ if (key === null) return null
797
+
798
+ const listed = objects(source.array, checker)
799
+ if (listed.opaque.length > 0 || listed.values.length === 0) return null
800
+ const texts = []
801
+ for (const element of listed.values) {
802
+ const resolved = property(element, key, element.getSourceFile(), checker)
803
+ if (resolved.opaque !== undefined || resolved.candidate === undefined) return null
804
+ const text = staticText(propertyValue(resolved.candidate), checker)
805
+ if (text === null) return null
806
+ texts.push(text)
807
+ }
808
+ return texts
809
+ }
810
+
576
811
  /**
577
812
  * Objetos e arrays `const` continuam mutáveis. A extração só faz fold quando cada uso
578
813
  * está numa allowlist observável: composição `const` igualmente estável, factory Opus
@@ -982,7 +1217,6 @@ export function extractCopyFromSource(file, sourceText) {
982
1217
  const field = (object, prefix) => {
983
1218
  addProperty(object, 'label', 'label', prefix)
984
1219
  addProperty(object, 'placeholder', 'placeholder', prefix)
985
- addProperty(object, 'hint', 'helper-text', prefix)
986
1220
  addProperty(object, 'help', 'helper-text', prefix)
987
1221
  nestedObject(object, 'options', optionItems, prefix)
988
1222
  }
@@ -1384,6 +1618,11 @@ export function extractCopyFromSource(file, sourceText) {
1384
1618
  ? { kind: 'decorative' }
1385
1619
  : { kind: 'text', ...text, uppercase: inheritedUppercase }
1386
1620
  }
1621
+ const listed = elementTexts(value, checker)
1622
+ if (listed !== null) {
1623
+ const values = listed.map((item) => ({ kind: 'text', ...item, uppercase: inheritedUppercase }))
1624
+ return values.length === 1 ? values[0] : { kind: 'alternatives', values, node: values[0].node }
1625
+ }
1387
1626
  if (ts.isJsxElement(value) || ts.isJsxSelfClosingElement(value) || ts.isJsxFragment(value)) {
1388
1627
  return childNode(value, inheritedUppercase, hiddenAncestor)
1389
1628
  }
@@ -1439,6 +1678,9 @@ export function extractCopyFromSource(file, sourceText) {
1439
1678
  if (ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child)) {
1440
1679
  const opening = ts.isJsxElement(child) ? child.openingElement : child
1441
1680
  const component = opusComponent(opening.tagName)
1681
+ if (component !== null && SELF_CONTAINED_CHILD_COMPONENTS.has(component) && ts.isJsxSelfClosingElement(child)) {
1682
+ return { kind: 'decorative' }
1683
+ }
1442
1684
  const provenElement = transformableElement(opening, component)
1443
1685
  const hidden = attribute(opening.attributes, 'aria-hidden')
1444
1686
  if (hidden.opaque !== undefined) return { kind: 'opaque', node: hidden.opaque }
@@ -1593,7 +1835,10 @@ export function extractCopyFromSource(file, sourceText) {
1593
1835
  if (component === null) return
1594
1836
  let inherited
1595
1837
  const inheritedFor = (className, styleName, portal = false) => {
1596
- if (portal || localTransformOverridesInheritance(opening.attributes, className, styleName)) return false
1838
+ // Um componente que renderiza em portal (TooltipContent) não é descendente visual
1839
+ // dos ancestrais no JSX; a transformação deles não alcança o texto.
1840
+ if (portal || JSX_PORTAL_BOUNDARIES.has(component)) return false
1841
+ if (localTransformOverridesInheritance(opening.attributes, className, styleName)) return false
1597
1842
  inherited ??= ancestorTransform(element)
1598
1843
  return inherited.opaque ? false : inherited.uppercase
1599
1844
  }
@@ -1676,7 +1921,11 @@ export function extractCopyFromSource(file, sourceText) {
1676
1921
  }
1677
1922
  } else {
1678
1923
  const value = raw === null ? null : attributeText(item)
1679
- if (value === null) {
1924
+ // `title={group.label}` dentro de `GROUPS.map(...)`: cada literal do array local
1925
+ // vira um texto na própria linha, em vez de um diagnóstico sobre a prop.
1926
+ const listed = value === null && raw !== null ? elementTexts(raw, checker) : null
1927
+ const texts = value !== null ? [value] : listed ?? []
1928
+ if (texts.length === 0) {
1680
1929
  diagnostics.push(diagnostic(
1681
1930
  file,
1682
1931
  sourceFile,
@@ -1685,8 +1934,8 @@ export function extractCopyFromSource(file, sourceText) {
1685
1934
  raw === null ? 'missing-content' : 'content',
1686
1935
  ))
1687
1936
  }
1688
- else {
1689
- const entry = { source: file, line: lineOf(sourceFile, value.node), role, text: value.text }
1937
+ for (const text of texts) {
1938
+ const entry = { source: file, line: lineOf(sourceFile, text.node), role, text: text.text }
1690
1939
  if (transformed.uppercase) entry.transform = 'uppercase'
1691
1940
  entries.push(entry)
1692
1941
  }
@@ -1838,7 +2087,28 @@ export function extractCopyFromSource(file, sourceText) {
1838
2087
  }
1839
2088
  }
1840
2089
 
2090
+ /**
2091
+ * `dialog.confirm({ title, description, action })` é copy tão editorial quanto a de um
2092
+ * `<ActionFormDialog>`; a chamada só é imperativa. Um template com interpolação
2093
+ * (`description: describe(name)`) cai no diagnóstico `content` normal, declarável em
2094
+ * `copy.dynamic` como `message-template`.
2095
+ */
2096
+ function extractDialog(call, { method, field }) {
2097
+ const raw = call.arguments[0]
2098
+ const options = raw === undefined ? null : resolveBinding(raw, checker)
2099
+ if (options === null || !ts.isObjectLiteralExpression(options)) {
2100
+ diagnostics.push(diagnostic(file, sourceFile, raw ?? call, `${field} options`, 'structure'))
2101
+ return
2102
+ }
2103
+ for (const [name, role] of DIALOG_METHOD_ROLES.get(method)) addVisualObjectProperty(options, name, role, `${field}.`)
2104
+ if (method === 'choose') {
2105
+ nestedObject(options, 'actions', (action, prefix) => addVisualObjectProperty(action, 'label', 'button', prefix), `${field}.`)
2106
+ }
2107
+ }
2108
+
1841
2109
  function visit(node) {
2110
+ const dialogCall = ts.isCallExpression(node) ? knownDialogCall(node, checker) : null
2111
+ if (dialogCall !== null) extractDialog(node, dialogCall)
1842
2112
  const factory = ts.isCallExpression(node) ? contractFactory(node.expression) : null
1843
2113
  if (ts.isCallExpression(node) && factory !== null && node.arguments.length > 0) {
1844
2114
  hasContract = true
@@ -86,13 +86,16 @@ Base instalada é dona do catálogo, dos kinds aceitos e do fundamento de cada r
86
86
  | `messages.success`, `messages.error`, `messages.confirmation` | `success`, `error`, `message` |
87
87
  | `confirm.message` | `dialog-body` |
88
88
  | `fields.*.label`, `filters.*.label` | `label` |
89
- | `placeholder`, `hint`, `help` | `placeholder`, `helper-text` |
89
+ | `placeholder`, `help` | `placeholder`, `helper-text` |
90
90
  | opções estáticas | `menu-item` |
91
91
  | `columns[].label`, `periods[].label` | `heading`, `tab` |
92
92
  | `Select.emptyText`, `Select.searchPlaceholder` | `empty-state`, `placeholder` |
93
93
  | `Select.options[].hint/triggerLabel/group` | `label`, `label`, `heading` |
94
94
  | `ActionTrigger.confirm.*` | papel correspondente do diálogo |
95
95
  | `t.dict` — `label`, `description`, `doc` das entradas e `doc` do dicionário | `label`, `description` |
96
+ | `DataState`/`PageState`/`ActionList`/`ActionListDialog` — `emptyMessage`, `errorMessage`, `retryLabel`; `PageState`/`ActionListDialog` — `title`, `description`; `ActionView.emptyMessage` | `empty-state`, `error`, `button`, `title`, `description` |
97
+ | `dialog.alert/confirm/prompt/choose()` — `title`, `description`, `body`, `action`, `cancel`, `placeholder`, `actions[].label` | papel correspondente do diálogo |
98
+ | `TooltipContent` (children), `LabelHelp.help` | `label`, `helper-text` |
96
99
 
97
100
  ## Cobertura e significado do gate verde
98
101
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "13.1.0",
3
+ "version": "14.0.0",
4
4
  "description": "End-to-end action protocol for TypeScript. Single package with subpath exports (core + adapters).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -17,9 +17,9 @@ artefatos de agentes fica em `base.json`; cada app Opus mantém seu marcador `op
17
17
  - Não duplicar schemas, tipos de transporte, validação ou fetch que o contrato já fornece.
18
18
  - Em UI semântica, declarar primeiro `context` (`neutral`, `primary`, `info`, `success`, `warning`
19
19
  ou `danger`) e usar `variant` somente para o tratamento visual (`solid`, `subtle`, `outline`,
20
- `ghost` ou `link`). Dicionários de status e estágio declaram `context`; `tone` e variantes
21
- semânticas antigas são apenas compatibilidade de migração. `destructive` permanece uma
22
- propriedade comportamental de actions e se projeta visualmente como `danger`.
20
+ `ghost` ou `link`). Dicionários de status e estágio declaram `context`; `tone` é apenas
21
+ compatibilidade de migração e as variantes semânticas antigas não existem mais. `destructive`
22
+ permanece uma propriedade comportamental de actions e se projeta visualmente como `danger`.
23
23
  - Declarar datasets persistentes com `defineSeed` + `bindSeed`, registrá-los em `opus.config.ts`
24
24
  e operá-los por `opus seed`; não criar comandos de seed paralelos nem reset implícito.
25
25
  - Regenerar o inventário com `opus copy` quando mudar copy em contrato ou componente Opus
@@ -1,5 +1,5 @@
1
1
  import type { z } from 'zod'
2
- import { Badge, Card, useListAction } from '@softize/opus/ui/react'
2
+ import { Badge, Card, DataState, useListAction } from '@softize/opus/ui/react'
3
3
  import { Task, taskList } from './domains/tasks/actions/list.ts'
4
4
 
5
5
  type TaskItem = z.infer<typeof Task>
@@ -10,7 +10,7 @@ export function App(): React.ReactElement {
10
10
  // O front consome o CONTRATO via /api — o plugin opusDesign (vite.config.ts)
11
11
  // serve o backend no próprio dev server: dev normal executa o handler real;
12
12
  // `pnpm dev:design` responde com o mockHandler, isolado de qualquer backend.
13
- const { items, isLoading } = useListAction<TaskItem>(taskList)
13
+ const { items, isLoading, error, refetch } = useListAction<TaskItem>(taskList)
14
14
 
15
15
  return (
16
16
  <main className="flex min-h-full items-center justify-center bg-background p-6">
@@ -19,9 +19,14 @@ export function App(): React.ReactElement {
19
19
  <p className="mb-4 text-sm text-muted-foreground">
20
20
  Esqueleto criado pelo opus create. A spec vive nas declarações do domínio.
21
21
  </p>
22
- {isLoading ? (
23
- <p className="text-sm text-muted-foreground">Carregando…</p>
24
- ) : (
22
+ {/* Carregando, erro e vazio saem do DataState; a lista só cuida dos itens. */}
23
+ <DataState
24
+ loading={isLoading}
25
+ error={error ?? null}
26
+ empty={items.length === 0}
27
+ emptyMessage="Nenhuma tarefa ainda."
28
+ onRetry={() => refetch()}
29
+ >
25
30
  <ul className="space-y-2">
26
31
  {items.map((t) => (
27
32
  <li key={t.id} className="flex items-center justify-between gap-2 text-sm">
@@ -30,7 +35,7 @@ export function App(): React.ReactElement {
30
35
  </li>
31
36
  ))}
32
37
  </ul>
33
- )}
38
+ </DataState>
34
39
  </Card>
35
40
  </main>
36
41
  )
package/src/core/types.ts CHANGED
@@ -551,9 +551,8 @@ export type FieldWidget = BuiltInFieldWidget | (string & { readonly __opusCustom
551
551
  export interface FieldSpec {
552
552
  label: I18nRef
553
553
  placeholder?: I18nRef
554
- hint?: I18nRef
555
- /** Ajuda no hover/foco da label (ícone + tooltip). `hint` = texto auxiliar SOB o campo;
556
- * `help` = explicação mais longa, escondida atrás do ícone na label. */
554
+ /** Ajuda junto à label (ícone ⓘ + tooltip): critério, efeito ou limitação que a label não
555
+ * diz. Ajuda que repete a label deve ser omitida. */
557
556
  help?: I18nRef
558
557
 
559
558
  default?: unknown
@@ -629,7 +628,7 @@ export interface ListColumnSpec {
629
628
  * 'badge' (chip `outline` com o valor — legado; coluna de dicionário com `presentation`
630
629
  * declarado usa `DictionaryValue` e dispensa este tipo). */
631
630
  type?: 'text' | 'number' | 'date' | 'badge'
632
- /** Referência do dicionário registrado em `TbdlibProvider dicts` que esta coluna mostra,
631
+ /** Referência do dicionário registrado em `OpusProvider dicts` que esta coluna mostra,
633
632
  * quando o schema de saída não carrega a meta de `t.dict` (ex.: campo `z.string()`).
634
633
  * Coluna cujo campo de saída É um `t.dict().zod()` resolve sozinha, sem esta chave. */
635
634
  dictionary?: string
@@ -37,7 +37,10 @@ export interface ActionListDialogProps<TItem, TInput extends Record<string, unkn
37
37
  note?: ReactNode
38
38
  /** Ação à direita da linha — em geral o botão de criar. */
39
39
  actions?: ReactNode
40
- emptyText?: string
40
+ /** Textos dos estados, repassados ao ActionList (e dele ao DataState). */
41
+ emptyMessage?: string
42
+ errorMessage?: string
43
+ retryLabel?: string
41
44
  /** Sobrepõe o vazio derivado (items.length === 0) — ex.: form inline aberto. */
42
45
  empty?: (items: TItem[]) => boolean
43
46
  /** Carga EXTRA agregada à do fetch (ex.: a query irmã que os children precisam). */
@@ -57,7 +60,9 @@ export function ActionListDialog<TItem, TInput extends Record<string, unknown> =
57
60
  description,
58
61
  note,
59
62
  actions,
60
- emptyText,
63
+ emptyMessage,
64
+ errorMessage,
65
+ retryLabel,
61
66
  empty,
62
67
  loading,
63
68
  className,
@@ -80,7 +85,9 @@ export function ActionListDialog<TItem, TInput extends Record<string, unknown> =
80
85
  <ActionList<TInput, TItem>
81
86
  action={action as unknown as ListAction<TInput, TItem>}
82
87
  input={(input ?? {}) as TInput}
83
- {...(emptyText !== undefined ? { emptyMessage: emptyText } : {})}
88
+ {...(emptyMessage !== undefined ? { emptyMessage } : {})}
89
+ {...(errorMessage !== undefined ? { errorMessage } : {})}
90
+ {...(retryLabel !== undefined ? { retryLabel } : {})}
84
91
  {...(empty !== undefined ? { empty } : {})}
85
92
  {...(loading !== undefined ? { loading } : {})}
86
93
  >