@softize/opus 12.7.1 → 12.8.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 (34) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/bin/lib/gen-dicts.mjs +11 -1
  3. package/bin/lib/gen-runner.mjs +8 -2
  4. package/bin/lib/materialize.mjs +5 -2
  5. package/docs/adr/0003-dictionary-presentation-is-declared.md +160 -0
  6. package/package.json +1 -1
  7. package/registry/skills/build-opus-ui/SKILL.md +31 -10
  8. package/registry/skills/build-opus-ui/references/evaluations.md +25 -0
  9. package/registry/skills/build-opus-ui/references/ui-patterns.md +85 -0
  10. package/registry/skills/implement-opus-change/SKILL.md +4 -3
  11. package/registry/skills/model-opus-dictionary/SKILL.md +76 -0
  12. package/registry/skills/model-opus-dictionary/agents/openai.yaml +4 -0
  13. package/registry/skills/model-opus-dictionary/references/evaluations.md +18 -0
  14. package/src/core/dictionary.ts +152 -0
  15. package/src/core/index.ts +18 -0
  16. package/src/core/types.ts +9 -1
  17. package/src/schema/drivers/zod.ts +46 -13
  18. package/src/ui/components/patterns/list.tsx +136 -51
  19. package/src/ui/components/primitives/badge.tsx +3 -0
  20. package/src/ui/components/primitives/detail.tsx +12 -1
  21. package/src/ui/components/primitives/dictionary-value.tsx +141 -0
  22. package/src/ui/components/primitives/empty-value.tsx +50 -0
  23. package/src/ui/components/primitives/pagination.tsx +86 -56
  24. package/src/ui/docs/content/action-list-dialog.md +1 -1
  25. package/src/ui/docs/content/action-list.md +34 -9
  26. package/src/ui/docs/content/badge.md +6 -3
  27. package/src/ui/docs/content/detail.md +19 -2
  28. package/src/ui/docs/content/dictionary-value.md +120 -0
  29. package/src/ui/docs/content/empty-value.md +48 -0
  30. package/src/ui/docs/content/pagination.md +54 -17
  31. package/src/ui/docs/doc-client.tsx +21 -4
  32. package/src/ui/docs/registry.tsx +6 -0
  33. package/src/ui/meta.ts +14 -2
  34. package/src/ui/react.tsx +6 -0
@@ -0,0 +1,141 @@
1
+ import * as React from 'react'
2
+ import type { LucideIcon } from 'lucide-react'
3
+
4
+ import {
5
+ dictionaryDescriptor,
6
+ presentDictionaryValue,
7
+ type DictionaryDescriptor,
8
+ type DictPresentation,
9
+ type DictTone,
10
+ } from '../../../core/index.ts'
11
+ import { cn } from '../../lib/cn.ts'
12
+ import { Badge, type BadgeProps } from './badge.tsx'
13
+ import { iconPickerIcons } from './icon-picker.tsx'
14
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip.tsx'
15
+
16
+ /**
17
+ * Fonte aceita pelo renderer: o `DictType` de `t.dict` (tem `meta`), a própria
18
+ * `LogicalTypeMeta` lida do schema, ou um descritor já normalizado.
19
+ */
20
+ export type DictionaryValueSource =
21
+ | { meta: unknown }
22
+ | { logicalType: string; params?: Record<string, unknown> }
23
+ | DictionaryDescriptor
24
+
25
+ export interface DictionaryValueProps extends Omit<React.ComponentProps<'span'>, 'children'> {
26
+ /** O dicionário (`t.dict`) ou a meta lida do schema do contrato. */
27
+ dict: DictionaryValueSource
28
+ /** O código a apresentar. Vazio renderiza `fallback`. */
29
+ value: string | null | undefined
30
+ /** Sobrepõe o papel declarado no dicionário SÓ nesta ocorrência. */
31
+ presentation?: DictPresentation
32
+ /** Sobrepõe a variante do badge (override explícito). Valor fora do dicionário segue texto. */
33
+ variant?: BadgeProps['variant']
34
+ /** `false` esconde o ícone declarado; um nó substitui o ícone do catálogo. */
35
+ icon?: boolean | React.ReactNode
36
+ /** Catálogo de ícones nome → componente. Default: `iconPickerIcons`. */
37
+ icons?: Record<string, LucideIcon>
38
+ /** `false` suprime o tooltip mesmo com `description` na entrada. */
39
+ tooltip?: boolean
40
+ /** Conteúdo quando `value` é vazio. Default: nada. */
41
+ fallback?: React.ReactNode
42
+ }
43
+
44
+ const toneVariant: Record<DictTone, NonNullable<BadgeProps['variant']>> = {
45
+ neutral: 'secondary',
46
+ info: 'info',
47
+ success: 'success',
48
+ warning: 'warning',
49
+ danger: 'danger',
50
+ }
51
+
52
+ function isDescriptor(source: DictionaryValueSource): source is DictionaryDescriptor {
53
+ return 'entries' in source && 'keys' in source
54
+ }
55
+
56
+ /**
57
+ * Renderiza um valor de dicionário com a apresentação que o DICIONÁRIO declarou (ADR 0003):
58
+ * classificação → Badge `outline`; status/estágio → Badge tonal pelo `tone` da entrada
59
+ * (`neutral` sem tom); `plain` ou sem papel → texto. Ícone só quando a entrada declara `icon`
60
+ * do catálogo; tooltip só quando `description` acrescenta ao rótulo. O rótulo é sempre texto —
61
+ * cor e ícone nunca comunicam sozinhos. Valor fora do dicionário mostra o código como texto.
62
+ */
63
+ export function DictionaryValue({
64
+ dict,
65
+ value,
66
+ presentation,
67
+ variant,
68
+ icon = true,
69
+ icons = iconPickerIcons,
70
+ tooltip = true,
71
+ fallback = null,
72
+ className,
73
+ ...props
74
+ }: DictionaryValueProps): React.ReactElement | null {
75
+ if (value === null || value === undefined || value === '') return <>{fallback}</>
76
+ const descriptor = isDescriptor(dict) ? dict : dictionaryDescriptor(dict)
77
+ const resolved = presentDictionaryValue(
78
+ descriptor ?? { keys: [], entries: {} },
79
+ value,
80
+ presentation !== undefined ? { presentation } : {},
81
+ )
82
+
83
+ const IconComponent = resolved.icon !== null ? icons[resolved.icon] : undefined
84
+ const iconNode =
85
+ icon === false
86
+ ? null
87
+ : icon === true
88
+ ? IconComponent !== undefined
89
+ ? <IconComponent aria-hidden="true" />
90
+ : null
91
+ : icon
92
+ const description = tooltip ? resolved.description : null
93
+
94
+ const dataProps = {
95
+ 'data-dictionary-presentation': resolved.presentation,
96
+ 'data-dictionary-value': resolved.value,
97
+ ...(resolved.known ? {} : { 'data-dictionary-unknown': true }),
98
+ ...(description !== null ? { tabIndex: 0 } : {}),
99
+ }
100
+
101
+ // Forma: texto quando o papel não pede badge e ninguém sobrepôs a variante. Valor fora do
102
+ // dicionário é sempre texto — override não promove código desconhecido a badge.
103
+ const badgeVariant: BadgeProps['variant'] = !resolved.known
104
+ ? undefined
105
+ : variant ??
106
+ (resolved.badge === null ? undefined : resolved.badge === 'outline' ? 'outline' : toneVariant[resolved.badge])
107
+
108
+ const content =
109
+ badgeVariant === undefined ? (
110
+ <span
111
+ data-slot="dictionary-value"
112
+ className={cn('inline-flex items-center gap-1 [&>svg]:size-3.5 [&>svg]:shrink-0', className)}
113
+ {...dataProps}
114
+ {...props}
115
+ >
116
+ {iconNode}
117
+ {resolved.label}
118
+ </span>
119
+ ) : (
120
+ <Badge
121
+ data-slot="dictionary-value"
122
+ variant={badgeVariant}
123
+ className={className}
124
+ {...dataProps}
125
+ {...props}
126
+ >
127
+ {iconNode}
128
+ {resolved.label}
129
+ </Badge>
130
+ )
131
+
132
+ if (description === null) return content
133
+ return (
134
+ <TooltipProvider delayDuration={300}>
135
+ <Tooltip>
136
+ <TooltipTrigger asChild>{content}</TooltipTrigger>
137
+ <TooltipContent className="max-w-sm">{description}</TooltipContent>
138
+ </Tooltip>
139
+ </TooltipProvider>
140
+ )
141
+ }
@@ -0,0 +1,50 @@
1
+ import * as React from 'react'
2
+
3
+ import { cn } from '../../lib/cn.ts'
4
+
5
+ /**
6
+ * Ausência de valor: `null`, `undefined`, string vazia ou só com espaços. `0`, `false` e
7
+ * coleções vazias são valores legítimos e nunca contam como ausência — quem apresenta
8
+ * booleanos e listas decide a própria forma.
9
+ */
10
+ export function isAbsentValue(value: unknown): boolean {
11
+ return value === null || value === undefined || (typeof value === 'string' && value.trim().length === 0)
12
+ }
13
+
14
+ export interface EmptyValueProps extends Omit<React.ComponentProps<'span'>, 'children'> {
15
+ /** O que a ausência significa neste domínio. Default: “Não informado”. */
16
+ label?: string
17
+ /** Célula compacta: mostra o travessão e reserva o `label` à leitura assistiva. */
18
+ compact?: boolean
19
+ }
20
+
21
+ /**
22
+ * Representação padrão de valor ausente. Em texto corrido (DetailField) o rótulo aparece;
23
+ * em célula compacta (`compact`, o default das colunas de ActionList) aparece o travessão
24
+ * e o rótulo vai para o leitor de tela. Um domínio troca o rótulo por `label`
25
+ * (“Nunca enviado”, “Sem vencimento”, “Não se aplica”) em vez de repetir a condicional.
26
+ */
27
+ export function EmptyValue({
28
+ label = 'Não informado',
29
+ compact = false,
30
+ className,
31
+ ...props
32
+ }: EmptyValueProps): React.ReactElement {
33
+ return (
34
+ <span
35
+ data-slot="empty-value"
36
+ data-compact={compact || undefined}
37
+ className={cn('text-muted-foreground', className)}
38
+ {...props}
39
+ >
40
+ {compact ? (
41
+ <>
42
+ <span aria-hidden="true">—</span>
43
+ <span className="sr-only">{label}</span>
44
+ </>
45
+ ) : (
46
+ label
47
+ )}
48
+ </span>
49
+ )
50
+ }
@@ -1,117 +1,147 @@
1
- import * as React from "react"
2
- import {
3
- ChevronLeftIcon,
4
- ChevronRightIcon,
5
- MoreHorizontalIcon,
6
- } from "lucide-react"
1
+ import * as React from 'react'
2
+ import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from 'lucide-react'
7
3
 
8
4
  import { cn } from '../../lib/cn.ts'
9
- import { buttonVariants, type Button } from './button.tsx'
5
+ import { buttonVariants, type ButtonProps } from './button.tsx'
10
6
 
11
- function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
7
+ function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
12
8
  return (
13
9
  <nav
14
10
  role="navigation"
15
- aria-label="pagination"
11
+ aria-label="Paginação"
16
12
  data-slot="pagination"
17
- className={cn("mx-auto flex w-full justify-center", className)}
13
+ className={cn('mx-auto flex w-full justify-center', className)}
18
14
  {...props}
19
15
  />
20
16
  )
21
17
  }
22
18
 
23
- function PaginationContent({
24
- className,
25
- ...props
26
- }: React.ComponentProps<"ul">) {
19
+ function PaginationContent({ className, ...props }: React.ComponentProps<'ul'>) {
27
20
  return (
28
21
  <ul
29
22
  data-slot="pagination-content"
30
- className={cn("flex flex-row items-center gap-1", className)}
23
+ className={cn('flex flex-row items-center gap-1', className)}
31
24
  {...props}
32
25
  />
33
26
  )
34
27
  }
35
28
 
36
- function PaginationItem({ ...props }: React.ComponentProps<"li">) {
29
+ function PaginationItem({ ...props }: React.ComponentProps<'li'>) {
37
30
  return <li data-slot="pagination-item" {...props} />
38
31
  }
39
32
 
40
- type PaginationLinkProps = {
33
+ export interface PaginationLinkProps extends Omit<React.ComponentProps<'a'>, 'children'> {
34
+ /** Página atual: vira `outline` e ganha `aria-current="page"`. */
41
35
  isActive?: boolean
42
- } & Pick<React.ComponentProps<typeof Button>, "size"> &
43
- React.ComponentProps<"a">
36
+ /** Escala do Button. `default` (números: altura fixa, largura mínima quadrada que cresce
37
+ * com os dígitos) ou uma escala `icon*` (setas quadradas, sem padding horizontal). */
38
+ size?: ButtonProps['size']
39
+ /** Número da página: dá o nome acessível (“Página N”) e o conteúdo, quando `children` falta. */
40
+ page?: number
41
+ /** Sem `href` o link é um `<button>`; `disabled` vale só nesse modo. */
42
+ disabled?: boolean
43
+ children?: React.ReactNode
44
+ }
44
45
 
45
- function PaginationLink({
46
- className,
47
- isActive,
48
- size = "icon",
49
- ...props
50
- }: PaginationLinkProps) {
46
+ /**
47
+ * Um link de página. Altura fixa, largura mínima quadrada e largura que cresce com o
48
+ * número (`min-w-9 w-auto px-2`, dígitos tabulares): 5726 e 5727 nunca se sobrepõem. Com
49
+ * `href` é `<a>`; sem `href` é `<button type="button">`, que preserva foco e teclado no
50
+ * modo controlado. Ajuste a escala por `className` (ex.: `h-7 min-w-7 text-xs`).
51
+ */
52
+ function PaginationLink({ className, isActive, page, disabled, href, size = 'default', children, ...props }: PaginationLinkProps) {
53
+ const square = typeof size === 'string' && size.startsWith('icon')
54
+ const shared = {
55
+ 'aria-current': isActive ? ('page' as const) : undefined,
56
+ 'aria-label': props['aria-label'] ?? (page !== undefined ? `Página ${page}` : undefined),
57
+ 'data-slot': 'pagination-link',
58
+ 'data-active': isActive,
59
+ className: cn(
60
+ buttonVariants({ variant: isActive ? 'outline' : 'ghost', size }),
61
+ // Número: a largura mínima é quadrada e o conteúdo manda no resto. Escala `icon*`
62
+ // fica quadrada de verdade (sem `has-[>svg]:px-*` do `default`).
63
+ !square && 'h-9 w-auto min-w-9 px-2 tabular-nums',
64
+ className,
65
+ ),
66
+ }
67
+ const content = children ?? page
68
+ if (href !== undefined) {
69
+ return (
70
+ <a href={href} {...props} {...shared}>
71
+ {content}
72
+ </a>
73
+ )
74
+ }
75
+ const { onClick, ...rest } = props as React.ComponentProps<'button'>
51
76
  return (
52
- <a
53
- aria-current={isActive ? "page" : undefined}
54
- data-slot="pagination-link"
55
- data-active={isActive}
56
- className={cn(
57
- buttonVariants({
58
- variant: isActive ? "outline" : "ghost",
59
- size,
60
- }),
61
- className
62
- )}
63
- {...props}
64
- />
77
+ <button type="button" disabled={disabled} onClick={onClick} {...rest} {...shared}>
78
+ {content}
79
+ </button>
65
80
  )
66
81
  }
67
82
 
83
+ export interface PaginationControlProps extends Omit<PaginationLinkProps, 'page' | 'isActive'> {
84
+ /** Nome acessível e texto visível. Default: “Página anterior” / “Próxima página”. */
85
+ label?: string
86
+ /** Só a seta, quadrada (escala `icon`, `size-9`); o `label` continua como nome acessível. */
87
+ iconOnly?: boolean
88
+ /** Classe do ícone (ex.: `size-3.5` na escala densa). Vai no próprio svg, porque o
89
+ * fallback `[&_svg:not([class*='size-'])]:size-4` do Button vence qualquer seletor de fora. */
90
+ iconClassName?: string
91
+ }
92
+
68
93
  function PaginationPrevious({
69
94
  className,
95
+ label = 'Página anterior',
96
+ iconOnly = false,
97
+ iconClassName,
98
+ size,
70
99
  ...props
71
- }: React.ComponentProps<typeof PaginationLink>) {
100
+ }: PaginationControlProps) {
72
101
  return (
73
102
  <PaginationLink
74
- aria-label="Go to previous page"
75
- size="default"
76
- className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
103
+ aria-label={label}
104
+ size={size ?? (iconOnly ? 'icon' : 'default')}
105
+ className={cn(!iconOnly && 'gap-1 px-2.5 sm:pl-2.5', className)}
77
106
  {...props}
78
107
  >
79
- <ChevronLeftIcon />
80
- <span className="hidden sm:block">Previous</span>
108
+ <ChevronLeftIcon className={iconClassName} />
109
+ {!iconOnly && <span className="hidden sm:block">{label}</span>}
81
110
  </PaginationLink>
82
111
  )
83
112
  }
84
113
 
85
114
  function PaginationNext({
86
115
  className,
116
+ label = 'Próxima página',
117
+ iconOnly = false,
118
+ iconClassName,
119
+ size,
87
120
  ...props
88
- }: React.ComponentProps<typeof PaginationLink>) {
121
+ }: PaginationControlProps) {
89
122
  return (
90
123
  <PaginationLink
91
- aria-label="Go to next page"
92
- size="default"
93
- className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
124
+ aria-label={label}
125
+ size={size ?? (iconOnly ? 'icon' : 'default')}
126
+ className={cn(!iconOnly && 'gap-1 px-2.5 sm:pr-2.5', className)}
94
127
  {...props}
95
128
  >
96
- <span className="hidden sm:block">Next</span>
97
- <ChevronRightIcon />
129
+ {!iconOnly && <span className="hidden sm:block">{label}</span>}
130
+ <ChevronRightIcon className={iconClassName} />
98
131
  </PaginationLink>
99
132
  )
100
133
  }
101
134
 
102
- function PaginationEllipsis({
103
- className,
104
- ...props
105
- }: React.ComponentProps<"span">) {
135
+ /** Decorativa: `aria-hidden`; os números ao redor já dizem que há páginas no vão. */
136
+ function PaginationEllipsis({ className, ...props }: React.ComponentProps<'span'>) {
106
137
  return (
107
138
  <span
108
139
  aria-hidden
109
140
  data-slot="pagination-ellipsis"
110
- className={cn("flex size-9 items-center justify-center", className)}
141
+ className={cn('flex size-9 items-center justify-center', className)}
111
142
  {...props}
112
143
  >
113
144
  <MoreHorizontalIcon className="size-4" />
114
- <span className="sr-only">More pages</span>
115
145
  </span>
116
146
  )
117
147
  }
@@ -27,7 +27,7 @@ render(
27
27
  {workspaces.map((w) => (
28
28
  <div key={w.id} className="flex items-center gap-2 rounded-md border border-border p-3">
29
29
  <span className="text-sm font-semibold">{w.name}</span>
30
- <Badge variant={w.status === 'active' ? 'success' : 'warning'}>{w.status}</Badge>
30
+ <DictionaryValue dict={docWorkspaceStatus} value={w.status} />
31
31
  <span className="ml-auto text-xs text-muted-foreground">{w.agents} agentes</span>
32
32
  </div>
33
33
  ))}
@@ -28,16 +28,37 @@ superfícies que não são listagens, como relatórios. O estado é controlado p
28
28
  />
29
29
  ```
30
30
 
31
- Busca e filtros são renderizados somente quando declarados. Períodos incluem os presets do
31
+ Selects inline têm largura fixa e previsível (`w-40`; lookup e múltiplo `w-52`): nem a label nem
32
+ o valor selecionado alargam o controle — o rótulo trunca e a opção inteira continua na lista. No
33
+ modal de filtros avançados, o select ocupa a largura toda. Busca e filtros são renderizados
34
+ somente quando declarados. Períodos incluem os presets do
32
35
  contrato e o intervalo personalizado no mesmo calendário usado pela `ActionList`.
33
36
 
37
+ ## Colunas de dicionário
38
+
39
+ Coluna cujo campo no schema de saída é um `t.dict().zod()` renderiza `DictionaryValue` com a
40
+ apresentação que o dicionário declarou (`presentation`: classificação em badge `outline`, status
41
+ e estágio em badge tonal, `plain` ou ausente como texto). Nada a configurar na tela. Quando o
42
+ campo de saída é uma string comum, a coluna nomeia o dicionário registrado no provider:
43
+ `{ key: 'source', label: 'Fonte', dictionary: 'customerSource' }`. Dimensões independentes, como
44
+ tipo e estágio, ficam em colunas distintas — a leitura de comparação depende disso. `type:
45
+ 'badge'` continua sendo o chip `outline` legado; com dicionário, ele mostra o rótulo em vez do
46
+ código.
47
+
48
+ ## Valor ausente
49
+
50
+ Célula escalar com `null`, `undefined`, string vazia ou só espaços mostra o travessão e “Não
51
+ informado” à leitura assistiva, sem condicional na tela. `0` e `false` são valores; array e objeto
52
+ sem `cells` continuam sem representação padrão, porque a forma de uma coleção é de quem a
53
+ apresenta. A coluna do contrato
54
+ troca o significado com `empty`: `{ key: 'sentAt', label: 'Último envio', empty: 'Nunca enviado' }`.
55
+ Renderer customizado reutiliza `EmptyValue`.
56
+
34
57
  ## Células custom (cells)
35
58
 
36
- As colunas do contrato descrevem DADOS; apresentação especial entra por cima com `cells` (chave = key da coluna). É onde vivem chips coloridos por dict, links e a coluna de ações.
59
+ As colunas do contrato descrevem DADOS; apresentação especial entra por cima com `cells` (chave = key da coluna). É onde vivem links, composições e a coluna de ações; valor de dicionário já resolve sozinho pela coluna, sem `cells`.
37
60
 
38
61
  ```tsx preview col
39
- const STATUS_VARIANT = { active: 'success', onboarding: 'warning', paused: 'outline' }
40
-
41
62
  render(
42
63
  <DocBrowserActionProvider>
43
64
  <div className="w-full">
@@ -45,7 +66,11 @@ render(
45
66
  action={docWorkspaceList}
46
67
  input={{}}
47
68
  cells={{
48
- status: (w) => <Badge variant={STATUS_VARIANT[w.status]}>{w.status}</Badge>,
69
+ name: (w) => (
70
+ <a href="#" onClick={(e) => e.preventDefault()} className="font-medium underline-offset-4 hover:underline">
71
+ {w.name}
72
+ </a>
73
+ ),
49
74
  }}
50
75
  />
51
76
  </div>
@@ -68,7 +93,7 @@ periods: [
68
93
 
69
94
  ## Paginação e loading
70
95
 
71
- O pattern manda `limit` (= `pageSize`, default 50) e `page` no input; o handler implementa o OFFSET e devolve `total` no Paginated. O rodapé (Página X de Y · páginas numeradas com reticências · "N itens no total") aparece sempre que há total; mudar filtro/busca/sort/período volta pra página 1 (a página vive na URL: `?page=2`). No primeiro carregamento, `DataState` centraliza o spinner; no refetch com a lista já na tela, o corpo esmaece (`aria-busy`) até os dados chegarem.
96
+ O pattern manda `limit` (= `pageSize`, default 50) e `page` no input; o handler implementa o OFFSET e devolve `total` no Paginated. O rodapé (Página X de Y · páginas numeradas com reticências · "N itens no total") compõe a primitiva `Pagination` na escala densa — números com largura mínima que cresce com os dígitos, setas quadradas, nome acessível por página — e aparece sempre que há total; mudar filtro/busca/sort/período volta pra página 1 (a página vive na URL: `?page=2`). No primeiro carregamento, `DataState` centraliza o spinner; no refetch com a lista já na tela, o corpo esmaece (`aria-busy`) até os dados chegarem.
72
97
 
73
98
  ## Multi-seleção com can (batch)
74
99
 
@@ -106,7 +131,7 @@ render(
106
131
  <div key={w.id} className="rounded-lg border border-border p-3">
107
132
  <div className="flex items-center gap-2">
108
133
  <span className="text-sm font-semibold">{w.name}</span>
109
- <Badge variant={w.status === 'active' ? 'success' : 'warning'}>{w.status}</Badge>
134
+ <DictionaryValue dict={docWorkspaceStatus} value={w.status} />
110
135
  </div>
111
136
  <p className="mt-1 text-xs text-muted-foreground">{w.agents} agentes.</p>
112
137
  </div>
@@ -171,7 +196,7 @@ render(
171
196
  <div key={w.id} className="rounded-lg border border-border p-3">
172
197
  <div className="flex items-center gap-2">
173
198
  <span className="text-sm font-semibold">{w.name}</span>
174
- <Badge variant={w.status === 'active' ? 'success' : 'warning'}>{w.status}</Badge>
199
+ <DictionaryValue dict={docWorkspaceStatus} value={w.status} />
175
200
  </div>
176
201
  <p className="mt-1 text-xs text-muted-foreground">{w.agents} agentes.</p>
177
202
  </div>
@@ -188,7 +213,7 @@ render(
188
213
 
189
214
  | Chave | O que declara |
190
215
  |---|---|
191
- | `columns` | `{ key, label, type?, sortable?, fit?, hidden?, dateFormat? }` — a tabela. `hidden` fica fora (base do futuro column picker). |
216
+ | `columns` | `{ key, label, type?, sortable?, fit?, hidden?, dateFormat?, dictionary?, empty? }` — a tabela. `hidden` fica fora (base do futuro column picker); `dictionary` nomeia o dicionário do provider; `empty` dá o significado da ausência. |
192
217
  | `filters` | `{ [nome]: { label, type, options?, multiple?, advanced?, depends?, … } }` — a toolbar. `advanced` vai pro modal; `depends` desabilita/cascateia; options `kind: 'lookup'` busca numa action. Valor aplicado entra no input com o MESMO nome. |
193
218
  | `text` | `{ fields }` — liga a busca; convenção: param `q` no input. Com período/filtros no contrato ela fica à direita; sendo a ÚNICA forma de recorte, abre a linha. |
194
219
  | `sort` | `{ fields, default }` — ordenação inicial; header ordenável escreve `sort: 'chave:dir'`. |
@@ -12,13 +12,16 @@ Button.
12
12
 
13
13
  ## Tons de status da casa
14
14
 
15
- success/warning/info (fill tingido + borda translúcida) — convenção da casa pra estado semântico,
16
- igual ao KindBadge do Maestro. Não-interativos.
15
+ success/warning/info/danger (fill tingido + borda translúcida) — convenção da casa pra estado
16
+ semântico, igual ao KindBadge do Maestro. Não-interativos. `danger` é o tom tonal da família;
17
+ `destructive` continua sólido, pra alerta e ação. Valor de dicionário com papel declarado usa
18
+ `DictionaryValue`, que escolhe o tom pela metadata em vez de repetir esta escolha em cada tela.
17
19
 
18
20
  ```tsx preview
19
21
  <Badge variant="success">Ativa</Badge>
20
22
  <Badge variant="warning">Aguardando revisor</Badge>
21
23
  <Badge variant="info">Em sessão</Badge>
24
+ <Badge variant="danger">Bloqueada</Badge>
22
25
  ```
23
26
 
24
27
  ## Com ícone
@@ -44,5 +47,5 @@ variantes.
44
47
 
45
48
  | Prop | Tipo | Default | Descrição |
46
49
  |---|---|---|---|
47
- | `variant` | `'default' \| 'secondary' \| 'destructive' \| 'outline' \| 'success' \| 'warning' \| 'info'` | `'default'` | Intenção (default/secondary/destructive/outline) ou tom de status da casa (success/warning/info). |
50
+ | `variant` | `'default' \| 'secondary' \| 'destructive' \| 'outline' \| 'success' \| 'warning' \| 'info' \| 'danger'` | `'default'` | Intenção (default/secondary/destructive/outline) ou tom de status da casa (success/warning/info/danger). |
48
51
  | `asChild` | `boolean` | `false` | Renderiza como o filho (Radix Slot) em vez de `<span>` — ex.: um `<a>` com cara de badge. |
@@ -6,8 +6,13 @@ para entrada e validação; use DetailField quando a pessoa apenas consulta um v
6
6
  <DetailGroup columns={2}>
7
7
  <DetailField label="Cliente" value="João Silva" />
8
8
  <DetailField
9
- label="Tipo"
10
- value={<Badge variant="secondary">Prospect</Badge>}
9
+ label="Estágio"
10
+ value={
11
+ <DictionaryValue
12
+ dict={{ keys: ['prospect', 'customer'], entries: { prospect: { label: 'Prospect' }, customer: { label: 'Cliente', tone: 'success' } }, presentation: 'stage' }}
13
+ value="prospect"
14
+ />
15
+ }
11
16
  />
12
17
  <DetailField
13
18
  className="sm:col-span-2"
@@ -18,6 +23,18 @@ para entrada e validação; use DetailField quando a pessoa apenas consulta um v
18
23
  </DetailGroup>
19
24
  ```
20
25
 
26
+ ## Valor ausente
27
+
28
+ `null`, `undefined`, string vazia ou só com espaços mostram “Não informado”; `empty` troca o
29
+ significado no domínio. `0` e `false` seguem como valores. Ver `EmptyValue`.
30
+
31
+ ```tsx preview col
32
+ <DetailGroup columns={2}>
33
+ <DetailField label="Telefone" value={null} />
34
+ <DetailField label="Último envio" value="" empty="Nunca enviado" />
35
+ </DetailGroup>
36
+ ```
37
+
21
38
  ## Moldura e divisórias internas
22
39
 
23
40
  `variant="framed"` adiciona a superfície e a borda externa. `dividers` desenha apenas as