@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,152 @@
1
+ /**
2
+ * Apresentação de dicionários — o vocabulário fechado e a tabela de decisão (ADR 0003).
3
+ *
4
+ * Mora no CORE porque é isomórfico: o driver de schema valida o que `t.dict` declara, o
5
+ * manifest projeta a metadata e a UI aplica os defaults lendo a meta que viaja no contrato.
6
+ * Nada aqui depende de React; `DictionaryValue` (ui/react) é só a materialização visual.
7
+ */
8
+
9
+ import { getLogicalType } from './logical-type.ts'
10
+ import type { LogicalTypeMeta } from './types.ts'
11
+
12
+ /** Papel de apresentação declarado no nível do dicionário. */
13
+ export const DICT_PRESENTATIONS = ['classification', 'status', 'stage', 'plain'] as const
14
+ export type DictPresentation = (typeof DICT_PRESENTATIONS)[number]
15
+
16
+ /** Tom explícito de uma entrada — honrado apenas por status e estágios. */
17
+ export const DICT_TONES = ['neutral', 'info', 'success', 'warning', 'danger'] as const
18
+ export type DictTone = (typeof DICT_TONES)[number]
19
+
20
+ /**
21
+ * Meta de cada chave de um dict. `label` obrigatório; resto livre.
22
+ *
23
+ * - `doc` é o entendimento de negócio da chave (o que aquele estado significa, quando se
24
+ * entra/sai) — a fonte rica de consulta rápida; flui pro manifest/Lens e NÃO vira tooltip.
25
+ * - `description` é o texto curto voltado à pessoa: aparece no tooltip do `DictionaryValue`
26
+ * e como apoio de opção. Não repetir o rótulo — descrição igual ao rótulo não renderiza.
27
+ * - `tone` é o tom tonal do badge de status/estágio; classificação ignora.
28
+ * - `icon` é o identificador estável do catálogo de ícones do Opus (`iconPickerIcons`);
29
+ * nome fora do catálogo não renderiza ícone.
30
+ * - `color` permanece metadata livre (lida pela Lens); o renderer não a interpreta.
31
+ */
32
+ export interface DictEntryMeta {
33
+ label: string
34
+ doc?: string
35
+ description?: string
36
+ tone?: DictTone
37
+ icon?: string
38
+ color?: string
39
+ order?: number
40
+ [key: string]: unknown
41
+ }
42
+
43
+ export function isDictPresentation(value: unknown): value is DictPresentation {
44
+ return typeof value === 'string' && (DICT_PRESENTATIONS as readonly string[]).includes(value)
45
+ }
46
+
47
+ export function isDictTone(value: unknown): value is DictTone {
48
+ return typeof value === 'string' && (DICT_TONES as readonly string[]).includes(value)
49
+ }
50
+
51
+ /** Forma normalizada de um dicionário, independente de onde a meta foi lida. */
52
+ export interface DictionaryDescriptor {
53
+ keys: string[]
54
+ entries: Record<string, DictEntryMeta>
55
+ presentation?: DictPresentation
56
+ doc?: string
57
+ }
58
+
59
+ /**
60
+ * Lê a meta de `t.dict` de qualquer portador: a própria `LogicalTypeMeta`, um `DictType`
61
+ * (`{ meta }`) ou o schema com a meta anexada por `attachLogicalType` (o `.zod()` do dict).
62
+ * Retorna `undefined` quando a fonte não é um dicionário reconhecível.
63
+ */
64
+ export function dictionaryDescriptor(source: unknown): DictionaryDescriptor | undefined {
65
+ if (source === null || typeof source !== 'object') return undefined
66
+ const attached = getLogicalType(source)
67
+ const meta = attached !== undefined
68
+ ? attached
69
+ : 'logicalType' in source
70
+ ? (source as LogicalTypeMeta)
71
+ : (source as { meta?: unknown }).meta
72
+ if (meta === null || typeof meta !== 'object') return undefined
73
+ const { logicalType, params } = meta as LogicalTypeMeta
74
+ if (logicalType !== 'dict' || params === undefined) return undefined
75
+ const keys = Array.isArray(params.keys) ? (params.keys as unknown[]).filter((k): k is string => typeof k === 'string') : []
76
+ const entries = params.entries !== null && typeof params.entries === 'object'
77
+ ? (params.entries as Record<string, DictEntryMeta>)
78
+ : {}
79
+ return {
80
+ keys,
81
+ entries,
82
+ ...(isDictPresentation(params.presentation) ? { presentation: params.presentation } : {}),
83
+ ...(typeof params.doc === 'string' ? { doc: params.doc } : {}),
84
+ }
85
+ }
86
+
87
+ /** Resultado da tabela de decisão para UM valor — o que o renderer materializa. */
88
+ export interface DictionaryValuePresentation {
89
+ value: string
90
+ /** Rótulo sempre presente; valor desconhecido devolve o próprio código. */
91
+ label: string
92
+ /** False quando o valor não está no dicionário. */
93
+ known: boolean
94
+ presentation: DictPresentation
95
+ /** `null` = texto; `'outline'` = classificação; tom = badge tonal de status/estágio. */
96
+ badge: 'outline' | DictTone | null
97
+ /** Identificador do catálogo, só quando declarado na entrada. */
98
+ icon: string | null
99
+ /** Só quando `description` existe e acrescenta algo ao rótulo. */
100
+ description: string | null
101
+ }
102
+
103
+ const normalizeText = (value: string): string =>
104
+ value.trim().replace(/\s+/g, ' ').replace(/[.。]+$/u, '').toLocaleLowerCase()
105
+
106
+ /** Descrição que só repete o rótulo não vira tooltip. */
107
+ export function dictionaryDescription(label: string, description: unknown): string | null {
108
+ if (typeof description !== 'string') return null
109
+ const text = description.trim()
110
+ if (text.length === 0) return null
111
+ return normalizeText(text) === normalizeText(label) ? null : text
112
+ }
113
+
114
+ /**
115
+ * Aplica a tabela de decisão da ADR 0003:
116
+ *
117
+ * | papel | forma | variante |
118
+ * | classification | badge | outline (ignora `tone`) |
119
+ * | status / stage | badge | tonal: `tone` ?? neutral |
120
+ * | plain / ausente | texto | — |
121
+ *
122
+ * Ícone só quando declarado; tooltip só quando `description` acrescenta. Valor fora do
123
+ * dicionário vira texto puro com o código — nunca um badge colorido sobre algo desconhecido.
124
+ */
125
+ export function presentDictionaryValue(
126
+ descriptor: DictionaryDescriptor,
127
+ value: string,
128
+ overrides: { presentation?: DictPresentation } = {},
129
+ ): DictionaryValuePresentation {
130
+ const presentation = overrides.presentation ?? descriptor.presentation ?? 'plain'
131
+ const entry = Object.prototype.hasOwnProperty.call(descriptor.entries, value)
132
+ ? descriptor.entries[value]
133
+ : undefined
134
+ if (entry === undefined || typeof entry.label !== 'string') {
135
+ return { value, label: value, known: false, presentation, badge: null, icon: null, description: null }
136
+ }
137
+ const badge: DictionaryValuePresentation['badge'] =
138
+ presentation === 'classification'
139
+ ? 'outline'
140
+ : presentation === 'status' || presentation === 'stage'
141
+ ? (isDictTone(entry.tone) ? entry.tone : 'neutral')
142
+ : null
143
+ return {
144
+ value,
145
+ label: entry.label,
146
+ known: true,
147
+ presentation,
148
+ badge,
149
+ icon: typeof entry.icon === 'string' && entry.icon.length > 0 ? entry.icon : null,
150
+ description: dictionaryDescription(entry.label, entry.description),
151
+ }
152
+ }
package/src/core/index.ts CHANGED
@@ -138,6 +138,24 @@ export { normalizeTraceContext } from './trace.ts'
138
138
  // `@softize/opus/schema` re-exporta os dois pra manter a API de sempre.
139
139
  export { attachLogicalType, getLogicalType } from './logical-type.ts'
140
140
 
141
+ // — Dicionários (apresentação declarada — ADR 0003) ————————————————————————————
142
+ export {
143
+ DICT_PRESENTATIONS,
144
+ DICT_TONES,
145
+ isDictPresentation,
146
+ isDictTone,
147
+ dictionaryDescriptor,
148
+ dictionaryDescription,
149
+ presentDictionaryValue,
150
+ } from './dictionary.ts'
151
+ export type {
152
+ DictPresentation,
153
+ DictTone,
154
+ DictEntryMeta,
155
+ DictionaryDescriptor,
156
+ DictionaryValuePresentation,
157
+ } from './dictionary.ts'
158
+
141
159
  // — Action ————————————————————————————————————————————————————————————————————
142
160
  export {
143
161
  defineAction,
package/src/core/types.ts CHANGED
@@ -626,8 +626,13 @@ export interface ListColumnSpec {
626
626
  key: string
627
627
  label: I18nRef
628
628
  /** Render padrão: 'text' (default) · 'number' (alinha à direita) · 'date' (Intl) ·
629
- * 'badge' (chip com o valor; dict/cores via célula custom por enquanto). */
629
+ * 'badge' (chip `outline` com o valor legado; coluna de dicionário com `presentation`
630
+ * declarado usa `DictionaryValue` e dispensa este tipo). */
630
631
  type?: 'text' | 'number' | 'date' | 'badge'
632
+ /** Referência do dicionário registrado em `TbdlibProvider dicts` que esta coluna mostra,
633
+ * quando o schema de saída não carrega a meta de `t.dict` (ex.: campo `z.string()`).
634
+ * Coluna cujo campo de saída É um `t.dict().zod()` resolve sozinha, sem esta chave. */
635
+ dictionary?: string
631
636
  /** Header clicável (asc ↔ desc). Convenção: a UI escreve `sort: '<key>:<dir>'` no
632
637
  * input; o handler implementa o orderBy. */
633
638
  sortable?: boolean
@@ -637,6 +642,9 @@ export interface ListColumnSpec {
637
642
  hidden?: boolean
638
643
  /** Formato quando `type: 'date'` (Intl.DateTimeFormatOptions). */
639
644
  dateFormat?: Intl.DateTimeFormatOptions
645
+ /** O que a ausência do valor significa nesta coluna (“Nunca enviado”, “Sem vencimento”).
646
+ * Sem isto, a célula mostra o travessão e “Não informado” à leitura assistiva. */
647
+ empty?: I18nRef
640
648
  }
641
649
 
642
650
  export interface SortSpec {
@@ -28,6 +28,14 @@
28
28
 
29
29
  import { z, type ZodTypeAny, type ZodSchema } from 'zod'
30
30
  import { attachLogicalType, type LogicalTypeMeta } from '../index.ts'
31
+ import {
32
+ DICT_PRESENTATIONS,
33
+ DICT_TONES,
34
+ isDictPresentation,
35
+ isDictTone,
36
+ type DictEntryMeta,
37
+ type DictPresentation,
38
+ } from '../../core/dictionary.ts'
31
39
  import {
32
40
  formatDatetime,
33
41
  formatDate,
@@ -646,18 +654,11 @@ function isLogicalType(value: unknown): value is LogicalType<unknown> {
646
654
  // =============================================================================
647
655
 
648
656
  /**
649
- * Meta de cada chave de um dict. `label` obrigatório; resto livre. `doc` é o
650
- * entendimento de negócio da chave (o que aquele estado significa, quando se
651
- * entra/sai) a fonte rica de consulta rápida; flui pro manifest/lente.
657
+ * Meta de cada chave de um dict o tipo mora no core (`@softize/opus/core`, ADR 0003) e é
658
+ * reexportado aqui pra manter a API: `label` obrigatório, `doc` (negócio, manifest/Lens),
659
+ * `description` (tooltip), `tone` (badge tonal de status/estágio), `icon` (catálogo).
652
660
  */
653
- export interface DictEntryMeta {
654
- label: string
655
- doc?: string
656
- color?: string
657
- icon?: string
658
- order?: number
659
- [key: string]: unknown
660
- }
661
+ export type { DictEntryMeta }
661
662
 
662
663
  /**
663
664
  * Option pronta pra UI: combina key + meta da entrada.
@@ -686,11 +687,19 @@ export interface DictType<K extends string, M extends DictEntryMeta> {
686
687
  has(key: string): key is K
687
688
  /** Doc de negócio do dicionário inteiro (o que esse vocabulário representa). */
688
689
  doc?: string
690
+ /** Papel de apresentação declarado (ADR 0003); ausente = texto (`plain`). */
691
+ presentation?: DictPresentation
689
692
  }
690
693
 
691
- /** Opções do dict: `doc` = o entendimento do vocabulário como um todo. */
694
+ /**
695
+ * Opções do dict: `doc` = o entendimento do vocabulário como um todo; `presentation` = o
696
+ * papel de apresentação (`classification` · `status` · `stage` · `plain`) que o
697
+ * `DictionaryValue` e as colunas de `ActionList` honram. Sem `presentation`, o valor
698
+ * renderiza como texto — a apresentação é declarada, nunca inferida.
699
+ */
692
700
  export interface DictOpts {
693
701
  doc?: string
702
+ presentation?: DictPresentation
694
703
  }
695
704
 
696
705
  const dict = <const M extends Record<string, DictEntryMeta>>(
@@ -702,10 +711,33 @@ const dict = <const M extends Record<string, DictEntryMeta>>(
702
711
  if (keys.length === 0) {
703
712
  throw new Error('t.dict precisa de pelo menos uma entrada')
704
713
  }
714
+ if (opts?.presentation !== undefined && !isDictPresentation(opts.presentation)) {
715
+ throw new Error(
716
+ `t.dict: presentation "${String(opts.presentation)}" inválida; use ${DICT_PRESENTATIONS.join(' | ')}`,
717
+ )
718
+ }
719
+ const tonal = opts?.presentation === 'status' || opts?.presentation === 'stage'
720
+ for (const key of keys) {
721
+ const tone = (entries[key] as DictEntryMeta).tone
722
+ if (tone === undefined) continue
723
+ if (!isDictTone(tone)) {
724
+ throw new Error(`t.dict: tone "${String(tone)}" inválido em "${key}"; use ${DICT_TONES.join(' | ')}`)
725
+ }
726
+ if (!tonal) {
727
+ throw new Error(
728
+ `t.dict: tone em "${key}" exige presentation "status" ou "stage" (classificação usa outline; plain é texto)`,
729
+ )
730
+ }
731
+ }
705
732
  const zodSchema = z.enum(keys as [K, ...K[]])
706
733
  const meta: LogicalTypeMeta = {
707
734
  logicalType: 'dict',
708
- params: { keys, entries, ...(opts?.doc !== undefined ? { doc: opts.doc } : {}) },
735
+ params: {
736
+ keys,
737
+ entries,
738
+ ...(opts?.doc !== undefined ? { doc: opts.doc } : {}),
739
+ ...(opts?.presentation !== undefined ? { presentation: opts.presentation } : {}),
740
+ },
709
741
  }
710
742
  attachLogicalType(zodSchema as unknown as object, meta)
711
743
 
@@ -713,6 +745,7 @@ const dict = <const M extends Record<string, DictEntryMeta>>(
713
745
  zod: () => zodSchema,
714
746
  meta,
715
747
  ...(opts?.doc !== undefined ? { doc: opts.doc } : {}),
748
+ ...(opts?.presentation !== undefined ? { presentation: opts.presentation } : {}),
716
749
  keys: () => [...keys],
717
750
  labelFor: (key, _locale) => {
718
751
  // _locale reservado pra i18n futuro; no v1 sempre retorna o label cru.
@@ -20,7 +20,7 @@
20
20
  */
21
21
 
22
22
  import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'
23
- import { ArrowDown, ArrowUp, ArrowUpDown, Calendar as CalendarIcon, ChevronLeft, ChevronRight, Eraser, RefreshCw, Settings2, SlidersHorizontal, Table2, X } from 'lucide-react'
23
+ import { ArrowDown, ArrowUp, ArrowUpDown, Calendar as CalendarIcon, Eraser, RefreshCw, Settings2, SlidersHorizontal, Table2, X } from 'lucide-react'
24
24
  import type {
25
25
  ActionDef,
26
26
  FilterSpec,
@@ -30,9 +30,22 @@ import type {
30
30
  ListColumnSpec,
31
31
  SortSpec,
32
32
  } from '../../../core/index.ts'
33
+ import { dictionaryDescriptor, getLogicalType, type DictionaryDescriptor } from '../../../core/index.ts'
33
34
  import { useDicts, useListAction, useLookupAction, type DictLike } from '../../drivers/react.tsx'
34
35
  import { cn } from '../../lib/cn.ts'
36
+ import { objectSchemaShape } from '../../lib/object-schema.ts'
35
37
  import { Badge } from '../primitives/badge.tsx'
38
+ import { DictionaryValue } from '../primitives/dictionary-value.tsx'
39
+ import { EmptyValue, isAbsentValue } from '../primitives/empty-value.tsx'
40
+ import {
41
+ Pagination,
42
+ PaginationContent,
43
+ PaginationEllipsis,
44
+ PaginationItem,
45
+ PaginationLink,
46
+ PaginationNext,
47
+ PaginationPrevious,
48
+ } from '../primitives/pagination.tsx'
36
49
  import { Button } from '../primitives/button.tsx'
37
50
  import { Calendar } from '../primitives/calendar.tsx'
38
51
  import { Checkbox } from '../primitives/checkbox.tsx'
@@ -158,6 +171,9 @@ export function presetRange(preset: string, now = new Date()): { from: string; t
158
171
  export interface ListActionLike {
159
172
  name: string
160
173
  kind: 'list'
174
+ /** Schema de saída (item). Coluna cujo campo carrega a meta de `t.dict` renderiza
175
+ * `DictionaryValue` sem configuração — ver `columnDictionary`. */
176
+ output?: unknown
161
177
  columns?: ListColumnSpec[] | undefined
162
178
  filters?: Record<string, FilterSpec> | undefined
163
179
  sort?: { fields: string[]; default?: SortSpec[] } | undefined
@@ -270,6 +286,42 @@ function optionsFor(name: string, spec: FilterSpec, runtime?: Record<string, Sel
270
286
  return []
271
287
  }
272
288
 
289
+ /** Desce wrappers (optional/nullable/default) até o schema do campo. Estrutural, sem
290
+ * `instanceof`, pra funcionar com as representações v3 e v4 do Zod. */
291
+ function innerSchema(schema: object): object {
292
+ let current = schema
293
+ for (;;) {
294
+ const inner = (current as { _def?: { innerType?: unknown } })._def?.innerType
295
+ if (inner === null || typeof inner !== 'object') return current
296
+ current = inner
297
+ }
298
+ }
299
+
300
+ /** Dicionário que uma coluna mostra (ADR 0003). Precedência: `dictionary` explícito no
301
+ * contrato (resolvido pelos dicts do provider) > meta de `t.dict` no campo do schema de
302
+ * saída (zero-config) > nada (a célula segue como texto). */
303
+ function columnDictionary(
304
+ column: ListColumnSpec,
305
+ outputShape: Record<string, unknown> | undefined,
306
+ dicts: Record<string, DictLike>,
307
+ ): DictionaryDescriptor | undefined {
308
+ if (column.dictionary !== undefined) {
309
+ const dict = dicts[column.dictionary]
310
+ if (dict === undefined) return undefined
311
+ const fromMeta = dictionaryDescriptor(dict)
312
+ if (fromMeta !== undefined) return fromMeta
313
+ // Dict-like custom (só `options()`): rótulos sem papel de apresentação.
314
+ const options = dict.options()
315
+ return {
316
+ keys: options.map((o) => o.value),
317
+ entries: Object.fromEntries(options.map((o) => [o.value, { ...o, label: o.label }])),
318
+ }
319
+ }
320
+ const field = outputShape?.[column.key]
321
+ if (field === null || typeof field !== 'object') return undefined
322
+ return dictionaryDescriptor(getLogicalType(innerSchema(field)))
323
+ }
324
+
273
325
  /** Opções de `options: { kind: 'dictionary', ref }` via dicts do provider. */
274
326
  function dictOptionsFor(spec: FilterSpec, dicts: Record<string, DictLike>): SelectOption[] {
275
327
  if (spec.options?.kind !== 'dictionary') return []
@@ -319,7 +371,9 @@ function FilterField({
319
371
  options: SelectOption[]
320
372
  /** Todos os valores de filtro vigentes — habilita `depends` e alimenta o lookup. */
321
373
  allValues: Record<string, unknown>
322
- /** Largura do contexto: a toolbar usa os defaults compactos; o modal manda w-full. */
374
+ /** Largura do contexto: inline, cada tipo tem largura fixa e previsível (select `w-40`,
375
+ * lookup e múltiplo `w-52`, texto `w-40`); nunca cresce pelo conteúdo. O modal manda
376
+ * `w-full`, que vence pelo merge de classes. */
323
377
  className?: string
324
378
  }): React.ReactElement {
325
379
  const label = text(spec.label, name)
@@ -344,7 +398,7 @@ function FilterField({
344
398
  onChange={(v) => onChange(v)}
345
399
  options={effOptions}
346
400
  placeholder={placeholder}
347
- className={cn('min-w-44', className)}
401
+ className={cn('w-52', className)}
348
402
  disabled={!enabled}
349
403
  {...(lookup.onSearch !== undefined ? { onSearch: lookup.onSearch, loading: lookup.loading } : {})}
350
404
  />
@@ -362,7 +416,7 @@ function FilterField({
362
416
  onChange={(v) => onChange(v)}
363
417
  options={effOptions}
364
418
  placeholder={placeholder}
365
- className={cn('min-w-44', className)}
419
+ className={cn('w-52', className)}
366
420
  disabled={!enabled}
367
421
  {...(lookup.onSearch !== undefined ? { onSearch: lookup.onSearch, loading: lookup.loading } : {})}
368
422
  />
@@ -380,7 +434,7 @@ function FilterField({
380
434
  onChange={(v) => onChange(v === ALL ? '' : v)}
381
435
  options={[{ value: ALL, label: text(spec.placeholder, 'Todos') }, ...base]}
382
436
  disabled={!enabled}
383
- className={cn('min-w-36', className)}
437
+ className={cn('w-40', className)}
384
438
  />
385
439
  )
386
440
  }
@@ -497,7 +551,15 @@ function Labeled({
497
551
  }): React.ReactElement {
498
552
  return (
499
553
  <div data-slot={slot} className="flex shrink-0 flex-col gap-1">
500
- <span className="px-0.5 text-xs font-medium text-muted-foreground">{label}</span>
554
+ {/* w-0 + min-w-full: a label acompanha a largura do controle e nunca a dita —
555
+ um rótulo longo trunca em vez de alargar o filtro. */}
556
+ <span
557
+ data-slot="action-filter-label"
558
+ title={label}
559
+ className="block w-0 min-w-full truncate px-0.5 text-xs font-medium text-muted-foreground"
560
+ >
561
+ {label}
562
+ </span>
501
563
  {children}
502
564
  </div>
503
565
  )
@@ -993,11 +1055,16 @@ export function ActionList<TInput, TItem>({
993
1055
  // — Colunas: prop explícita > derivadas do contrato (+ células custom). O column
994
1056
  // picker (state.columns) escolhe o subconjunto visível; null = o default (!hidden).
995
1057
  const visibleKeys = state.columns
1058
+ const dicts = useDicts()
996
1059
  const derived: Array<ActionListColumn<TItem> & { spec?: ListColumnSpec }> = useMemo(() => {
997
1060
  if (columns !== undefined) return columns
1061
+ const outputShape = objectSchemaShape(action.output)
998
1062
  return (action.columns ?? [])
999
1063
  .filter((c) => (visibleKeys !== null ? visibleKeys.includes(c.key) : c.hidden !== true))
1000
- .map((c) => ({
1064
+ .map((c) => {
1065
+ // Resolvido uma vez por coluna: não depende do item.
1066
+ const dictionary = cells?.[c.key] === undefined ? columnDictionary(c, outputShape, dicts) : undefined
1067
+ return {
1001
1068
  key: c.key,
1002
1069
  header: text(c.label, c.key),
1003
1070
  sortable: c.sortable === true,
@@ -1007,7 +1074,24 @@ export function ActionList<TInput, TItem>({
1007
1074
  cells?.[c.key] ??
1008
1075
  ((item: TItem): ReactNode => {
1009
1076
  const v = (item as Record<string, unknown>)[c.key]
1010
- if (v === null || v === undefined || v === '') return null
1077
+ // Ausência (null · undefined · '' · espaços) tem representação padrão; 0 e
1078
+ // false são valores. O contrato troca o significado por `empty`.
1079
+ if (isAbsentValue(v)) {
1080
+ const label = text(c.empty, '')
1081
+ return <EmptyValue compact {...(label !== '' ? { label } : {})} />
1082
+ }
1083
+ if (dictionary !== undefined && typeof v === 'string') {
1084
+ // Sem `presentation` declarado o valor é texto (rótulo); `type: 'badge'`
1085
+ // legado mantém o chip outline, agora com o rótulo em vez do código.
1086
+ const legacyBadge = c.type === 'badge' && dictionary.presentation === undefined
1087
+ return (
1088
+ <DictionaryValue
1089
+ dict={dictionary}
1090
+ value={v}
1091
+ {...(legacyBadge ? { variant: 'outline' as const } : {})}
1092
+ />
1093
+ )
1094
+ }
1011
1095
  if (c.type === 'date') {
1012
1096
  // Date-only ('YYYY-MM-DD') parseia LOCAL — new Date() iria pra meia-noite
1013
1097
  // UTC e deslocaria um dia no fuso; ISO completo segue no caminho normal.
@@ -1020,8 +1104,9 @@ export function ActionList<TInput, TItem>({
1020
1104
  if (c.type === 'badge') return <Badge variant="outline">{String(v)}</Badge>
1021
1105
  return typeof v === 'object' ? null : String(v)
1022
1106
  }),
1023
- }))
1024
- }, [columns, action.columns, cells, visibleKeys])
1107
+ }
1108
+ })
1109
+ }, [columns, action.columns, action.output, cells, visibleKeys, dicts])
1025
1110
 
1026
1111
  // — Column picker: só faz sentido na tabela derivada do contrato —
1027
1112
  const pickerColumns = columns === undefined ? (action.columns ?? []) : []
@@ -1330,47 +1415,47 @@ export function ActionList<TInput, TItem>({
1330
1415
  Página {state.page} de {pages}
1331
1416
  </span>
1332
1417
  {pages > 1 && (
1333
- <div className="flex items-center gap-0.5">
1334
- <Button
1335
- size="sm"
1336
- variant="ghost"
1337
- className="size-7 p-0"
1338
- aria-label="Página anterior"
1339
- disabled={state.page <= 1}
1340
- onClick={() => goTo(state.page - 1)}
1341
- >
1342
- <ChevronLeft className="size-3.5" />
1343
- </Button>
1344
- {pageWindow(state.page, pages).map((n, i) =>
1345
- n === '…' ? (
1346
- // eslint-disable-next-line react/no-array-index-key
1347
- <span key={`e${i}`} className="px-1">
1348
-
1349
- </span>
1350
- ) : (
1351
- <Button
1352
- key={n}
1353
- size="sm"
1354
- variant={n === state.page ? 'secondary' : 'ghost'}
1355
- className="size-7 p-0"
1356
- aria-current={n === state.page ? 'page' : undefined}
1357
- onClick={() => goTo(n)}
1358
- >
1359
- {n}
1360
- </Button>
1361
- ),
1362
- )}
1363
- <Button
1364
- size="sm"
1365
- variant="ghost"
1366
- className="size-7 p-0"
1367
- aria-label="Próxima página"
1368
- disabled={state.page >= pages}
1369
- onClick={() => goTo(state.page + 1)}
1370
- >
1371
- <ChevronRight className="size-3.5" />
1372
- </Button>
1373
- </div>
1418
+ // A primitiva pública na escala densa do rodapé: setas quadradas `size-7`,
1419
+ // números `h-7 min-w-7` que crescem com os dígitos (5726 e 5727 não se colam).
1420
+ <Pagination className="mx-0 w-auto">
1421
+ <PaginationContent className="gap-0.5">
1422
+ <PaginationItem>
1423
+ <PaginationPrevious
1424
+ iconOnly
1425
+ className="size-7"
1426
+ iconClassName="size-3.5"
1427
+ disabled={state.page <= 1}
1428
+ onClick={() => goTo(state.page - 1)}
1429
+ />
1430
+ </PaginationItem>
1431
+ {pageWindow(state.page, pages).map((n, i) =>
1432
+ n === '…' ? (
1433
+ // eslint-disable-next-line react/no-array-index-key
1434
+ <PaginationItem key={`e${i}`}>
1435
+ <PaginationEllipsis className="size-7" />
1436
+ </PaginationItem>
1437
+ ) : (
1438
+ <PaginationItem key={n}>
1439
+ <PaginationLink
1440
+ page={n}
1441
+ isActive={n === state.page}
1442
+ className="h-7 min-w-7 text-xs"
1443
+ onClick={() => goTo(n)}
1444
+ />
1445
+ </PaginationItem>
1446
+ ),
1447
+ )}
1448
+ <PaginationItem>
1449
+ <PaginationNext
1450
+ iconOnly
1451
+ className="size-7"
1452
+ iconClassName="size-3.5"
1453
+ disabled={state.page >= pages}
1454
+ onClick={() => goTo(state.page + 1)}
1455
+ />
1456
+ </PaginationItem>
1457
+ </PaginationContent>
1458
+ </Pagination>
1374
1459
  )}
1375
1460
  {/* Com 1 página o pager some; o placeholder segura a coluna do meio. */}
1376
1461
  {pages <= 1 && <span />}
@@ -18,6 +18,9 @@ export const badgeVariants = cva(
18
18
  success: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400',
19
19
  warning: 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400',
20
20
  info: 'border-blue-500/30 bg-blue-500/10 text-blue-700 dark:text-blue-400',
21
+ // `danger` é o tom tonal da família (status/estágio em `DictionaryValue`);
22
+ // `destructive` continua sólido, reservado a alerta/ação.
23
+ danger: 'border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-400',
21
24
  },
22
25
  },
23
26
  defaultVariants: { variant: 'default' },
@@ -1,6 +1,7 @@
1
1
  import * as React from 'react'
2
2
 
3
3
  import { cn } from '../../lib/cn.ts'
4
+ import { EmptyValue, isAbsentValue } from './empty-value.tsx'
4
5
 
5
6
  export type DetailGroupVariant = 'plain' | 'framed'
6
7
  export type DetailGroupOrientation = 'vertical' | 'horizontal'
@@ -60,9 +61,13 @@ export interface DetailFieldProps extends Omit<
60
61
  'children'
61
62
  > {
62
63
  label: React.ReactNode
64
+ /** `null`, `undefined`, string vazia ou só com espaços renderizam a ausência
65
+ * (“Não informado” ou `empty`); `0` e `false` seguem como valores. */
63
66
  value: React.ReactNode
64
67
  /** Ícone decorativo que identifica o campo antes do par chave/valor. */
65
68
  icon?: React.ReactNode
69
+ /** O que a ausência significa neste campo: um rótulo (“Nunca enviado”) ou um nó próprio. */
70
+ empty?: React.ReactNode
66
71
  }
67
72
 
68
73
  function DetailField({
@@ -70,8 +75,14 @@ function DetailField({
70
75
  label,
71
76
  value,
72
77
  icon,
78
+ empty,
73
79
  ...props
74
80
  }: DetailFieldProps): React.ReactElement {
81
+ const content = !isAbsentValue(value)
82
+ ? value
83
+ : empty === undefined || typeof empty === 'string'
84
+ ? <EmptyValue {...(empty !== undefined ? { label: empty } : {})} />
85
+ : empty
75
86
  return (
76
87
  <div
77
88
  data-slot="detail-field"
@@ -104,7 +115,7 @@ function DetailField({
104
115
  data-slot="detail-field-value"
105
116
  className="min-w-0 text-sm leading-snug font-normal"
106
117
  >
107
- {value}
118
+ {content}
108
119
  </dd>
109
120
  </div>
110
121
  )