@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.
- package/CHANGELOG.md +34 -0
- package/bin/lib/copy.mjs +276 -6
- package/docs/code-style.md +4 -1
- package/package.json +1 -1
- package/registry/instructions/opus.md +3 -3
- package/registry/templates/app/src/App.tsx +11 -6
- package/src/core/types.ts +3 -4
- package/src/ui/components/patterns/action-list-dialog.tsx +10 -3
- package/src/ui/components/patterns/confirm.tsx +2 -31
- package/src/ui/components/patterns/content-header.tsx +42 -141
- package/src/ui/components/patterns/data-state.tsx +42 -68
- package/src/ui/components/patterns/form.tsx +15 -15
- package/src/ui/components/patterns/list.tsx +14 -8
- package/src/ui/components/patterns/page-state.tsx +39 -51
- package/src/ui/components/patterns/page.tsx +18 -53
- package/src/ui/components/patterns/state-surface.tsx +148 -0
- package/src/ui/components/patterns/surface-header.tsx +119 -0
- package/src/ui/components/patterns/trigger.tsx +7 -9
- package/src/ui/components/patterns/view.tsx +14 -16
- package/src/ui/components/primitives/alert.tsx +1 -33
- package/src/ui/components/primitives/avatar.tsx +15 -5
- package/src/ui/components/primitives/badge.tsx +2 -43
- package/src/ui/components/primitives/button.tsx +31 -35
- package/src/ui/components/primitives/control.ts +60 -0
- package/src/ui/components/primitives/dot.tsx +1 -30
- package/src/ui/components/primitives/input-group.tsx +11 -8
- package/src/ui/components/primitives/item.tsx +3 -1
- package/src/ui/components/primitives/menu.tsx +1 -7
- package/src/ui/components/primitives/pagination.tsx +16 -8
- package/src/ui/components/primitives/select.tsx +2 -2
- package/src/ui/components/primitives/spinner.tsx +13 -16
- package/src/ui/components/primitives/switch.tsx +4 -1
- package/src/ui/components/primitives/tabs.tsx +5 -3
- package/src/ui/components/primitives/toggle.tsx +9 -4
- package/src/ui/docs/content/action-form.md +26 -0
- package/src/ui/docs/content/action-list-dialog.md +2 -2
- package/src/ui/docs/content/action-list.md +3 -1
- package/src/ui/docs/content/action-trigger.md +4 -4
- package/src/ui/docs/content/action-view.md +3 -2
- package/src/ui/docs/content/avatar.md +7 -3
- package/src/ui/docs/content/button.md +30 -14
- package/src/ui/docs/content/communication.md +36 -0
- package/src/ui/docs/content/content.md +5 -4
- package/src/ui/docs/content/data-state.md +17 -13
- package/src/ui/docs/content/dialog.md +1 -4
- package/src/ui/docs/content/input.md +1 -1
- package/src/ui/docs/content/item.md +1 -1
- package/src/ui/docs/content/page.md +12 -4
- package/src/ui/docs/content/pagination.md +11 -9
- package/src/ui/docs/content/semantic-context.md +3 -2
- package/src/ui/docs/content/sidebar.md +2 -42
- package/src/ui/docs/content/spinner.md +9 -6
- package/src/ui/docs/content/switch.md +1 -1
- package/src/ui/docs/content/tabs.md +1 -1
- package/src/ui/docs/content/toggle.md +1 -1
- package/src/ui/drivers/react.tsx +1 -6
- package/src/ui/meta.ts +5 -5
- package/src/ui/react.tsx +8 -16
- 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([['
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1689
|
-
const entry = { source: file, line: lineOf(sourceFile,
|
|
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
|
package/docs/code-style.md
CHANGED
|
@@ -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`, `
|
|
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
|
@@ -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`
|
|
21
|
-
semânticas antigas
|
|
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
|
-
{
|
|
23
|
-
|
|
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
|
-
|
|
555
|
-
|
|
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 só 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 `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{...(
|
|
88
|
+
{...(emptyMessage !== undefined ? { emptyMessage } : {})}
|
|
89
|
+
{...(errorMessage !== undefined ? { errorMessage } : {})}
|
|
90
|
+
{...(retryLabel !== undefined ? { retryLabel } : {})}
|
|
84
91
|
{...(empty !== undefined ? { empty } : {})}
|
|
85
92
|
{...(loading !== undefined ? { loading } : {})}
|
|
86
93
|
>
|