@softize/opus 12.9.0 → 12.10.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 CHANGED
@@ -7,6 +7,23 @@ 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
+ ## 12.10.0 — 2026-09-03
11
+
12
+ `Alert` ganha a variante aditiva `warning` e passa a oferecer realce tonal nativo ao redor do
13
+ ícone. O layout com ícone reserva uma coluna estável; sem ícone, o aviso continua em bloco e não
14
+ cria espaço vazio. `style` permite ajustar as medidas internas quando uma composição exigir.
15
+
16
+ `FieldLabel` adota a mesma hierarquia tipográfica de `DetailField`, com ritmo vertical menor, e o
17
+ estado marcado de uma escolha em cartão deixa de vazar para rótulos comuns com checkbox. Tooltips
18
+ passam a ter largura máxima compartilhada e quebra de linha natural; `ActionForm` apenas consome
19
+ esse padrão, sem decidir a largura do primitivo.
20
+
21
+ `opus copy` passa a inventariar estaticamente `label`, `description` e `doc` declarados em
22
+ `t.dict`, inclusive a documentação do dicionário. Metadados livres não viram copy por inferência;
23
+ declarações dinâmicas continuam falhando no gate em vez de executar código consumidor. Em runtime,
24
+ o dicionário mantém um snapshot imutável das entradas, então alterações posteriores no objeto
25
+ original, em `metaFor` ou na metadata lógica não mudam a copy já declarada.
26
+
10
27
  ## 12.9.0 — 2026-09-03
11
28
 
12
29
  `Chat` aceita `empty`, um nó React para o estado sem mensagens: o app compõe o vazio com os
package/bin/lib/copy.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Inventário semântico de copy dos contratos Opus.
2
+ * Inventário semântico de copy dos contratos e dicionários Opus.
3
3
  *
4
- * O Opus conhece o papel dos textos declarados em defineAction/defineContract;
4
+ * O Opus conhece o papel dos textos declarados em defineAction/defineContract e t.dict;
5
5
  * a @softize/base continua dona da política e da validação editorial. Este módulo
6
6
  * apenas projeta o protocolo JSON v2 sem executar código do consumidor.
7
7
  */
@@ -61,6 +61,7 @@ const COPY_ROLES = new Set([
61
61
  const AUDIT_REFERENCE = /^(?:https?:\/\/\S+|[a-z][a-z0-9-]*:\S+|(?:[A-Za-z0-9._-]+\/)+[A-Za-z0-9._#/-]+|[A-Za-z0-9._-]+\.(?:md|json|ya?ml)(?:#[^\s]+)?)$/u
62
62
  const CONTRACT_MODULES = new Set(['@softize/opus', '@softize/opus/core'])
63
63
  const CONTRACT_FACTORIES = new Set(['defineAction', 'defineContract'])
64
+ const SCHEMA_ZOD_MODULES = new Set(['@softize/opus/schema/zod'])
64
65
  const ACTION_BINDING_KEYS = new Set(['authorize', 'background', 'emits', 'handler', 'idempotency', 'loads'])
65
66
 
66
67
  // Somente exports Opus cujo papel textual é estável por contrato do componente.
@@ -397,6 +398,18 @@ function knownContractFactory(call, checker) {
397
398
  return name !== null && CONTRACT_FACTORIES.has(name)
398
399
  }
399
400
 
401
+ function knownDictionaryFactory(call, checker) {
402
+ const expression = resolveBinding(call.expression, checker)
403
+ if (!ts.isPropertyAccessExpression(expression) && !ts.isElementAccessExpression(expression)) return false
404
+ const member = ts.isPropertyAccessExpression(expression)
405
+ ? expression.name.text
406
+ : expression.argumentExpression === undefined
407
+ ? null
408
+ : staticText(expression.argumentExpression, checker)?.text ?? null
409
+ return member === 'dict' &&
410
+ importedMemberName(expression.expression, checker, (module) => SCHEMA_ZOD_MODULES.has(module)) === 't'
411
+ }
412
+
400
413
  function knownBindAction(call, checker) {
401
414
  return importedMemberName(call.expression, checker, (module) => CONTRACT_MODULES.has(module)) === 'bindAction'
402
415
  }
@@ -512,7 +525,10 @@ function stableExpressionUse(node, checker, mode, stack) {
512
525
  ts.isSpreadAssignment(parent) || ts.isSpreadElement(parent) || ts.isArrayLiteralExpression(parent)
513
526
  ) return consumerMode === 'aggregate' && stableComposition(carrier, checker, stack)
514
527
  if (ts.isCallExpression(parent) && parent.arguments.some((argument) => argument === carrier)) {
515
- if (consumerMode === 'aggregate') return knownContractFactory(parent, checker) && stableFactoryResult(parent, checker, stack)
528
+ if (consumerMode === 'aggregate') {
529
+ if (knownDictionaryFactory(parent, checker)) return true
530
+ return knownContractFactory(parent, checker) && stableFactoryResult(parent, checker, stack)
531
+ }
516
532
  if (
517
533
  consumerMode === 'factory-result' && parent.arguments[0] === carrier &&
518
534
  knownBindAction(parent, checker) && safeBindActionBinding(parent, checker)
@@ -865,14 +881,15 @@ function diagnostic(file, sourceFile, node, field, category = 'content') {
865
881
  /**
866
882
  * Extrai as superfícies humanas de um source isolado.
867
883
  *
868
- * O retorno distingue arquivo sem contrato de contrato sem copy. Essa diferença é
869
- * necessária para hashear todo contrato e detectar a adição posterior de um texto.
884
+ * O retorno distingue arquivo sem superfície Opus de contrato sem copy. Essa diferença é
885
+ * necessária para hashear todo contrato ou dicionário e detectar a adição posterior de texto.
870
886
  */
871
887
  export function extractCopyFromSource(file, sourceText) {
872
888
  const { sourceFile, checker } = parseSource(file, sourceText)
873
889
  const entries = []
874
890
  const diagnostics = []
875
891
  let hasContract = false
892
+ let hasDictionary = false
876
893
  const hasUiImport = sourceFile.statements.some(
877
894
  (statement) =>
878
895
  ts.isImportDeclaration(statement) &&
@@ -999,6 +1016,42 @@ export function extractCopyFromSource(file, sourceText) {
999
1016
  nestedObject(object, 'errors', (error, prefix) => addProperty(error, 'description', 'error', prefix))
1000
1017
  }
1001
1018
 
1019
+ function extractDictionary(call) {
1020
+ const entriesNode = call.arguments[0]
1021
+ const entries = resolveBinding(entriesNode, checker)
1022
+ if (!ts.isObjectLiteralExpression(entries)) {
1023
+ diagnostics.push(diagnostic(file, sourceFile, entriesNode, 't.dict entries', 'structure'))
1024
+ return
1025
+ }
1026
+
1027
+ const listed = properties(entries, sourceFile, checker)
1028
+ for (const opaque of listed.opaque) {
1029
+ diagnostics.push(diagnostic(file, sourceFile, opaque, 't.dict entries', 'structure'))
1030
+ }
1031
+ for (const [key, entry] of listed.items) {
1032
+ const valueNode = propertyValue(entry)
1033
+ const value = resolveBinding(valueNode, checker)
1034
+ if (!ts.isObjectLiteralExpression(value)) {
1035
+ diagnostics.push(diagnostic(file, sourceFile, valueNode, `t.dict.${key}`, 'structure'))
1036
+ continue
1037
+ }
1038
+ addProperty(value, 'label', 'label', `t.dict.${key}.`)
1039
+ addProperty(value, 'description', 'description', `t.dict.${key}.`)
1040
+ addProperty(value, 'doc', 'description', `t.dict.${key}.`)
1041
+ }
1042
+
1043
+ if (call.arguments.length < 2) return
1044
+ const optionsNode = call.arguments[1]
1045
+ const options = resolveBinding(optionsNode, checker)
1046
+ if (ts.isIdentifier(options) && options.text === 'undefined') {
1047
+ const symbol = checker.getSymbolAtLocation(options)
1048
+ const shadowed = symbol?.declarations?.some((declaration) => declaration.getSourceFile() === sourceFile) ?? false
1049
+ if (!shadowed) return
1050
+ }
1051
+ if (ts.isObjectLiteralExpression(options)) addProperty(options, 'doc', 'description', 't.dict.options.')
1052
+ else diagnostics.push(diagnostic(file, sourceFile, optionsNode, 't.dict options', 'structure'))
1053
+ }
1054
+
1002
1055
  function importedMember(node, modulePredicate) {
1003
1056
  return importedMemberName(node, checker, modulePredicate)
1004
1057
  }
@@ -1790,12 +1843,16 @@ export function extractCopyFromSource(file, sourceText) {
1790
1843
  else diagnostics.push(diagnostic(file, sourceFile, node.arguments[0], `${factory}(...)`, 'structure'))
1791
1844
  }
1792
1845
  }
1846
+ if (ts.isCallExpression(node) && knownDictionaryFactory(node, checker) && node.arguments.length > 0) {
1847
+ hasDictionary = true
1848
+ extractDictionary(node)
1849
+ }
1793
1850
  if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) extractJsx(node)
1794
1851
  ts.forEachChild(node, visit)
1795
1852
  }
1796
1853
  visit(sourceFile)
1797
1854
 
1798
- return { hasContract, hasCopySurface: hasContract || hasUiImport, entries, diagnostics }
1855
+ return { hasContract, hasCopySurface: hasContract || hasDictionary || hasUiImport, entries, diagnostics }
1799
1856
  }
1800
1857
 
1801
1858
  function globRegex(pattern) {
@@ -92,16 +92,20 @@ Base instalada é dona do catálogo, dos kinds aceitos e do fundamento de cada r
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
+ | `t.dict` — `label`, `description`, `doc` das entradas e `doc` do dicionário | `label`, `description` |
95
96
 
96
97
  ## Cobertura e significado do gate verde
97
98
 
98
- O extrator cobre duas superfícies, sem heurística de nome:
99
+ O extrator cobre três superfícies, sem heurística de nome:
99
100
 
100
101
  1. propriedades estáticas de `defineAction` e `defineContract` descritas acima, somente
101
102
  quando a factory resolve por símbolo a um import de `@softize/opus` ou
102
103
  `@softize/opus/core`; alias, namespace e destructuring `const` de namespace funcionam,
103
104
  homônimo local não é contrato;
104
- 2. texto estático em filhos e props de uma allowlist de componentes importados diretamente
105
+ 2. `label`, `description` e `doc` estáticos de `t.dict` importado de
106
+ `@softize/opus/schema/zod`; metadata livre não é classificada por nome, declarações dinâmicas
107
+ reprovam em vez de serem executadas e o dicionário mantém um snapshot imutável das entradas;
108
+ 3. texto estático em filhos e props de uma allowlist de componentes importados diretamente
105
109
  de `@softize/opus/ui*` — por exemplo, `Button`, `DialogTitle`, `DialogDescription`,
106
110
  `FieldLabel`, `TabsTrigger`, `Page`, `Input` e `DataState`. Alias e namespace de import
107
111
  continuam rastreáveis. `uppercase`, variantes como `sm:hover:uppercase`, modificadores
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "12.9.0",
3
+ "version": "12.10.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",
@@ -680,7 +680,7 @@ export interface DictType<K extends string, M extends DictEntryMeta> {
680
680
  */
681
681
  labelFor(key: K, locale?: string): string
682
682
  /** Meta completa da chave (label + extras). */
683
- metaFor(key: K): M
683
+ metaFor(key: K): Readonly<M>
684
684
  /** Lista pronta pra `<Select options={...} />`: `{ value, ...meta }`. */
685
685
  options(): DictOption<K, M>[]
686
686
  /** True se `key` está no dict. */
@@ -707,7 +707,7 @@ const dict = <const M extends Record<string, DictEntryMeta>>(
707
707
  opts?: DictOpts,
708
708
  ): DictType<Extract<keyof M, string>, M[keyof M]> => {
709
709
  type K = Extract<keyof M, string>
710
- const keys = Object.keys(entries) as K[]
710
+ const keys = Object.freeze(Object.keys(entries)) as readonly K[]
711
711
  if (keys.length === 0) {
712
712
  throw new Error('t.dict precisa de pelo menos uma entrada')
713
713
  }
@@ -729,16 +729,22 @@ const dict = <const M extends Record<string, DictEntryMeta>>(
729
729
  )
730
730
  }
731
731
  }
732
+ // O dicionário é declarativo: mantém um snapshot próprio e imutável para que `metaFor`,
733
+ // a metadata do schema e mutações posteriores no objeto de entrada não alterem a copy
734
+ // ou a apresentação que foram inventariadas no código-fonte.
735
+ const stableEntries = Object.freeze(
736
+ Object.fromEntries(keys.map((key) => [key, Object.freeze({ ...entries[key] })])),
737
+ ) as unknown as Readonly<M>
732
738
  const zodSchema = z.enum(keys as [K, ...K[]])
733
- const meta: LogicalTypeMeta = {
739
+ const meta: LogicalTypeMeta = Object.freeze({
734
740
  logicalType: 'dict',
735
- params: {
741
+ params: Object.freeze({
736
742
  keys,
737
- entries,
743
+ entries: stableEntries,
738
744
  ...(opts?.doc !== undefined ? { doc: opts.doc } : {}),
739
745
  ...(opts?.presentation !== undefined ? { presentation: opts.presentation } : {}),
740
- },
741
- }
746
+ }),
747
+ })
742
748
  attachLogicalType(zodSchema as unknown as object, meta)
743
749
 
744
750
  return {
@@ -749,16 +755,16 @@ const dict = <const M extends Record<string, DictEntryMeta>>(
749
755
  keys: () => [...keys],
750
756
  labelFor: (key, _locale) => {
751
757
  // _locale reservado pra i18n futuro; no v1 sempre retorna o label cru.
752
- return (entries[key] as M[keyof M]).label
758
+ return (stableEntries[key] as M[keyof M]).label
753
759
  },
754
- metaFor: (key) => entries[key] as M[keyof M],
760
+ metaFor: (key) => stableEntries[key] as M[keyof M],
755
761
  options: () =>
756
762
  keys.map((k) => ({
757
- ...(entries[k] as M[keyof M]),
763
+ ...(stableEntries[k] as M[keyof M]),
758
764
  value: k,
759
765
  })) as DictOption<K, M[keyof M]>[],
760
766
  has: (key: string): key is K =>
761
- Object.prototype.hasOwnProperty.call(entries, key),
767
+ Object.prototype.hasOwnProperty.call(stableEntries, key),
762
768
  }
763
769
  }
764
770
 
@@ -197,7 +197,7 @@ function LabelHelp({ help }: { help: string | undefined }): React.ReactElement |
197
197
  </TooltipTrigger>
198
198
  {/* max-w-sm: frase de help típica (~50 caracteres) cabe numa linha; o
199
199
  text-balance do primitivo só divide o que realmente transborda. */}
200
- <TooltipContent className="max-w-sm">{help}</TooltipContent>
200
+ <TooltipContent>{help}</TooltipContent>
201
201
  </Tooltip>
202
202
  </TooltipProvider>
203
203
  )
@@ -3,25 +3,38 @@ import { cva, type VariantProps } from 'class-variance-authority'
3
3
  import { cn } from '../../lib/cn.ts'
4
4
 
5
5
  /**
6
- * Variantes do alert (cva). COM ícone o layout é grid (1ª coluna pro `>svg`, 2ª pro
7
- * conteúdo, que se ancora em `col-start-2`); sem ícone é bloco comum.
8
- * `destructive` e `success` mantêm superfície tonalizada + borda (divergência da
9
- * casa — o shadcn rebaixou destructive pra bg-card, nós preservamos o realce).
6
+ * Variantes do alert (cva). COM ícone o componente abre uma coluna de realce tonal;
7
+ * sem ícone é bloco comum. `destructive`, `success` e `warning` mantêm superfície
8
+ * tonalizada + borda (divergência da casa — o shadcn rebaixou destructive pra bg-card,
9
+ * nós preservamos o realce).
10
10
  */
11
11
  export const alertVariants = cva(
12
- // O grid (coluna do ícone + coluna do conteúdo) existe QUANDO ícone. Era fixo, com
13
- // a 1ª coluna em `0` — e aí qualquer nó de texto solto virava item anônimo NESSA coluna
14
- // de largura zero, saindo uma palavra por linha. Sem ícone, bloco comum: o `col-start-2`
15
- // dos slots é inócuo fora de grid.
16
- "relative w-full rounded-lg border px-4 py-3 text-sm has-[>svg]:grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:items-start has-[>svg]:gap-x-3 has-[>svg]:gap-y-0.5 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
12
+ 'relative w-full rounded-lg border px-4 py-3 text-sm has-[>svg]:grid has-[>svg]:grid-cols-[1rem_minmax(0,1fr)] has-[>svg]:items-start has-[>svg]:gap-x-3 has-[>svg]:gap-y-1 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
17
13
  {
18
14
  variants: {
19
15
  variant: {
20
16
  default: 'bg-card text-card-foreground',
21
17
  destructive:
22
- 'border-destructive/50 bg-destructive/5 text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current',
18
+ 'border-destructive/50 bg-destructive/5 text-destructive *:data-[slot=alert-description]:text-destructive/90',
23
19
  success:
24
- 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 *:data-[slot=alert-description]:text-emerald-700/90 dark:*:data-[slot=alert-description]:text-emerald-400/90 [&>svg]:text-current',
20
+ 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 *:data-[slot=alert-description]:text-emerald-700/90 dark:*:data-[slot=alert-description]:text-emerald-400/90',
21
+ warning:
22
+ 'border-amber-500/40 bg-amber-500/10 text-foreground *:data-[slot=alert-description]:text-muted-foreground',
23
+ },
24
+ },
25
+ defaultVariants: { variant: 'default' },
26
+ },
27
+ )
28
+
29
+ const alertIconVariants = cva(
30
+ 'flex size-9 shrink-0 items-center justify-center rounded-md [&_svg]:size-5',
31
+ {
32
+ variants: {
33
+ variant: {
34
+ default: 'bg-muted text-muted-foreground',
35
+ destructive: 'bg-destructive/10 text-destructive',
36
+ success: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300',
37
+ warning: 'bg-amber-500/15 text-amber-700 dark:text-amber-300',
25
38
  },
26
39
  },
27
40
  defaultVariants: { variant: 'default' },
@@ -49,9 +62,11 @@ export function Alert({
49
62
  description,
50
63
  icon,
51
64
  children,
65
+ style,
52
66
  ...props
53
67
  }: AlertProps): React.ReactElement {
54
68
  const short = title !== undefined || description !== undefined
69
+ const hasIcon = icon !== undefined && icon !== null
55
70
  // Texto CRU como filho (`<Alert>Sincronizado.</Alert>`) vira descrição: no layout com
56
71
  // ícone ele cairia na coluna do ícone, espremido. Assim o atalho de sempre continua
57
72
  // valendo e cai no slot certo.
@@ -61,16 +76,31 @@ export function Alert({
61
76
  <div
62
77
  data-slot="alert"
63
78
  role="alert"
64
- className={cn(alertVariants({ variant }), className)}
79
+ className={cn(alertVariants({ variant }), hasIcon && 'grid items-start', className)}
80
+ style={hasIcon ? {
81
+ gridTemplateColumns: '2.25rem minmax(0, 1fr)',
82
+ columnGap: '0.75rem',
83
+ rowGap: '0.25rem',
84
+ ...style,
85
+ } : style}
65
86
  {...props}
66
87
  >
67
- {icon}
88
+ {hasIcon && (
89
+ <span
90
+ data-slot="alert-icon"
91
+ aria-hidden="true"
92
+ className={alertIconVariants({ variant })}
93
+ style={{ gridRow: '1 / span 2' }}
94
+ >
95
+ {icon}
96
+ </span>
97
+ )}
68
98
  {short ? (
69
99
  <>
70
100
  {title !== undefined && <AlertTitle>{title}</AlertTitle>}
71
101
  {description !== undefined && <AlertDescription>{description}</AlertDescription>}
72
102
  {/* A ação (children DEPOIS da frase) precisa da coluna 2: sem `col-start-2`
73
- ela cai na coluna do ícone (~1rem) e é espremida pra esquerda. O
103
+ ela cai na coluna do ícone e é espremida pra esquerda. O
74
104
  `col-start-2` é inócuo sem o grid (forma curta sem ícone). */}
75
105
  {children !== undefined && children !== null && (
76
106
  <div data-slot="alert-actions" className="col-start-2 mt-1">
@@ -57,14 +57,14 @@ const fieldVariants = cva(
57
57
  {
58
58
  variants: {
59
59
  orientation: {
60
- vertical: ["flex-col gap-2 [&>*]:w-full [&>.sr-only]:w-auto"],
60
+ vertical: ["flex-col gap-1.5 [&>*]:w-full [&>.sr-only]:w-auto"],
61
61
  horizontal: [
62
62
  "flex-row items-center gap-3",
63
63
  "[&>[data-slot=field-label]]:flex-auto",
64
64
  "has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
65
65
  ],
66
66
  responsive: [
67
- "flex-col gap-2 @md/field-group:flex-row @md/field-group:items-center @md/field-group:gap-3 [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto",
67
+ "flex-col gap-1.5 @md/field-group:flex-row @md/field-group:items-center @md/field-group:gap-3 [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto",
68
68
  "@md/field-group:[&>[data-slot=field-label]]:flex-auto",
69
69
  "@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
70
70
  ],
@@ -113,9 +113,9 @@ function FieldLabel({
113
113
  <Label
114
114
  data-slot="field-label"
115
115
  className={cn(
116
- "group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
116
+ "group/field-label peer/field-label flex w-fit gap-2 text-xs leading-snug font-medium text-muted-foreground group-data-[invalid=true]/field:text-destructive group-data-[disabled=true]/field:opacity-50",
117
117
  "has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
118
- "has-data-[state=checked]:border-primary has-data-[state=checked]:bg-primary/5 dark:has-data-[state=checked]:bg-primary/10",
118
+ "has-[>[data-slot=field]]:has-data-[state=checked]:border-primary has-[>[data-slot=field]]:has-data-[state=checked]:bg-primary/5 dark:has-[>[data-slot=field]]:has-data-[state=checked]:bg-primary/10",
119
119
  className
120
120
  )}
121
121
  {...props}
@@ -40,7 +40,7 @@ function TooltipContent({
40
40
  data-slot="tooltip-content"
41
41
  sideOffset={sideOffset}
42
42
  className={cn(
43
- "z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
43
+ "z-50 w-fit max-w-sm origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-pretty text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
44
44
  className
45
45
  )}
46
46
  {...props}
@@ -16,6 +16,12 @@ Quase todo alert é ícone + título + uma frase — então isso é UMA linha: `
16
16
  title="Skill publicada"
17
17
  description="Os agentes do workspace já enxergam a nova versão."
18
18
  />
19
+ <Alert
20
+ variant="warning"
21
+ icon={<CircleAlert />}
22
+ title="Revisão necessária"
23
+ description="Confira os dados antes de continuar."
24
+ />
19
25
  ```
20
26
 
21
27
  ## Só a frase
@@ -29,7 +35,7 @@ Título é opcional — o aviso de uma linha dispensa. `destructive` e `success`
29
35
 
30
36
  ## Ícone é opcional
31
37
 
32
- Sem `icon` o alert é bloco comum; com ele, vira grid de duas colunas e o conteúdo se alinha ao lado. Texto solto como filho também vale (`<Alert>Sincronizado.</Alert>`) — cai no slot de descrição sozinho.
38
+ Sem `icon` o alert é bloco comum; com ele, vira grid de duas colunas e o conteúdo se alinha ao lado. O ícone da prop recebe o slot `alert-icon` e um realce tonal coerente com a variante. A composição legada com um SVG direto continua abrindo a coluna do ícone. Texto solto como filho também vale (`<Alert>Sincronizado.</Alert>`) — cai no slot de descrição sozinho.
33
39
 
34
40
  ```tsx preview col
35
41
  <Alert title="Sem provider próprio" description="As conversas usam o padrão do sistema." />
@@ -64,6 +70,7 @@ Os dois modos convivem: com `title`/`description` preenchidos, `children` entra
64
70
  |---|---|---|---|
65
71
  | `title` | `React.ReactNode` | | Título do alert (a forma curta). Não é o atributo `title` do HTML — esse é tooltip nativo, banido na casa, e o componente não o aceita. |
66
72
  | `description` | `React.ReactNode` | | A frase. Sozinha, dispensa título. |
67
- | `icon` | `React.ReactNode` | | Ícone à esquerda; é ele que liga o layout de duas colunas. Decorativo — quem nomeia é o título. |
68
- | `variant` | `'default' \| 'destructive' \| 'success'` | `'default'` | O tom da mensagem. |
73
+ | `icon` | `React.ReactNode` | | Ícone à esquerda; liga o layout de duas colunas e recebe realce tonal no slot `alert-icon`. Decorativo — quem nomeia é o título. |
74
+ | `variant` | `'default' \| 'destructive' \| 'success' \| 'warning'` | `'default'` | O tom da mensagem e do realce do ícone. |
69
75
  | `children` | `React.ReactNode` | | Composição (`AlertTitle`/`AlertDescription`), texto cru, ou — junto da forma curta — o que vem depois da frase. |
76
+ | `style` | `React.CSSProperties` | | Ajuste excepcional das medidas internas; com `icon`, pode sobrescrever as colunas e os espaçamentos padrão. |
@@ -1,6 +1,6 @@
1
1
  ## Campo vertical
2
2
 
3
- A composição base: FieldLabel (htmlFor↔id), o controle e FieldDescription como ajuda — empilhados, com o respiro do Field.
3
+ A composição base: FieldLabel (htmlFor↔id), o controle e FieldDescription como ajuda — empilhados, com o respiro do Field. O rótulo usa a mesma hierarquia compacta de `DetailField`; quando o campo é inválido, muda explicitamente para o tom destrutivo.
4
4
 
5
5
  ```tsx preview col md
6
6
  <Field>
@@ -41,10 +41,24 @@ O TooltipProvider embrulha o app uma vez (delay compartilhado); cada Tooltip dis
41
41
  </TooltipProvider>
42
42
  ```
43
43
 
44
+ ## Texto longo
45
+
46
+ O conteúdo tem largura máxima compartilhada e distribui as palavras naturalmente. O consumidor
47
+ pode reduzir ou ampliar esse limite com `className` quando a composição exigir, sem repetir o
48
+ default nos formulários.
49
+
50
+ ```tsx preview
51
+ <Tooltip>
52
+ <TooltipTrigger asChild><Button variant="ghost">Como funciona</Button></TooltipTrigger>
53
+ <TooltipContent>Registre a evidência usada para que esta decisão possa ser consultada depois.</TooltipContent>
54
+ </Tooltip>
55
+ ```
56
+
44
57
  ## Props
45
58
 
46
59
  | Prop | Tipo | Default | Descrição |
47
60
  |---|---|---|---|
48
61
  | `TooltipProvider.delayDuration` | `number` | `0` | Atraso (ms) até aparecer — compartilhado por todos os tooltips do app. |
49
62
  | `TooltipContent.side` | `'top' \| 'right' \| 'bottom' \| 'left'` | `'top'` | Lado preferido; inverte sozinho sem espaço (Radix). |
50
- | `TooltipContent.sideOffset` | `number` | `4` | Distância (px) entre gatilho e dica. |
63
+ | `TooltipContent.sideOffset` | `number` | `0` | Distância (px) entre gatilho e dica. |
64
+ | `TooltipContent.className` | `string` | `max-w-sm text-pretty` | Permite substituir o limite e a distribuição de linha quando necessário. |
package/src/ui/meta.ts CHANGED
@@ -21,7 +21,7 @@ export const componentMeta = {
21
21
  name: 'alert',
22
22
  ancestry: 'opus',
23
23
  whenToUse:
24
- 'Aviso INLINE no fluxo da página (o erro do login, o "sincronizado" da lista) — `role="alert"`, então o leitor de tela anuncia sozinho. Forma CURTA (o caso comum): `<Alert title description icon variant />` numa linha; composição (`AlertTitle`/`AlertDescription`) só quando o conteúdo é rico (parágrafos, link, ação). `variant` default/destructive/success. Pra INTERROMPER cobrando decisão, use `confirm()`; pra recado passageiro, `toast`.',
24
+ 'Aviso INLINE no fluxo da página (o erro do login, o "sincronizado" da lista) — `role="alert"`, então o leitor de tela anuncia sozinho. Forma CURTA (o caso comum): `<Alert title description icon variant />` numa linha; composição (`AlertTitle`/`AlertDescription`) só quando o conteúdo é rico (parágrafos, link, ação). `variant` default/destructive/success/warning; quando informado, o ícone recebe realce tonal nativo. Pra INTERROMPER cobrando decisão, use `confirm()`; pra recado passageiro, `toast`.',
25
25
  },
26
26
  'badge': {
27
27
  name: 'badge',