@softize/opus 12.3.0 → 12.5.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,51 @@ 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.5.0 — 2026-08-24
11
+
12
+ Cache vira porta do protocolo. `CacheAdapter` chega aos handlers como `ctx.cache`, com a superfície
13
+ mínima que um handler usa — `get`, `set`, `delete` — e cresce por reincidência, como a do storage.
14
+ O **miss é o `null`**: quem observa a execução distingue acerto de ausência pelo retorno, sem o
15
+ adapter reportar métrica por fora, e erro de infraestrutura continua sendo exceção — cache
16
+ indisponível não deve virar "não estava em cache".
17
+
18
+ `cache/memory` é o driver de processo, para desenvolvimento e para quem roda numa instância só.
19
+ Expira preguiçosamente (a entrada vencida some quando alguém a procura, sem timer permanente),
20
+ tem teto de entradas para não crescer sem limite dentro de um servidor de longa duração, e expõe
21
+ `sweep()` e `size()` para quem quiser forçar a limpeza ou observar o tamanho.
22
+
23
+ Nada é obrigatório: sem adapter montado, `ctx.cache` é `null` e o handler decide o que fazer.
24
+
25
+ ## 12.4.0 — 2026-08-24
26
+
27
+ Superfícies de trabalho — canvas, editor, preview — ganham os dois lugares que faltavam. `Dock`
28
+ ancora as ferramentas à própria superfície, com `DockGroup` separando intenções e `DockAction`
29
+ distinguindo modo de execução; a barra é uma `toolbar` de verdade, com setas e Home/End entre as
30
+ ações. `SurfaceStatus` recebe o que a superfície diz sobre si — salvamento, versão, execução
31
+ percorrida — num canto estável, fora da barra: estado não é ação, e uma toolbar que carrega texto
32
+ vivo deixa de ser navegável como toolbar.
33
+
34
+ O truncamento passa a ter uma segunda forma. `Truncate` aceita `fade`, e a utilitária
35
+ `truncate-fade` esmaece o fim da linha no lugar das reticências. O hook `useOverflowing`, extraído
36
+ do próprio `Truncate`, fica exposto para quem precisa do sinal de corte sem o tooltip — é o que
37
+ mantém a máscara fora do texto que cabe inteiro.
38
+
39
+ `SidebarItem` vira a linha das árvores de navegação: `actions`, `onToggle`/`expanded` e
40
+ `dropPosition` são irmãos do destino, nunca botões aninhados, e o rótulo cortado esmaece até a
41
+ borda. Ele e o item do `ShellNav` também ganham foco visível com o anel do `Button` — antes a
42
+ navegação por teclado caía no anel do navegador, que destoa do tema.
43
+
44
+ Entram ainda `Dot`, para estado compacto onde o contexto já explica o significado, e
45
+ `Table variant="framed"`, que traz para o componente a moldura de datagrid antes montada à mão em
46
+ cada consumidor. `Select` passa a aplicar `className` na raiz do controle em todos os modos.
47
+
48
+ O `opus copy` reconhece `Dock.label`, `DockAction.label` e `DockAction.hint`: componente com texto
49
+ em prop precisa entrar no mapa de papéis, senão a copy sai do inventário em silêncio quando uma
50
+ superfície migra de `aria-label` para prop.
51
+
52
+ Tudo é aditivo e não exige migração. Consumidores que montavam a moldura da tabela à mão podem
53
+ trocar o wrapper por `variant="framed"` quando quiserem.
54
+
10
55
  ## 12.3.0 — 2026-08-23
11
56
 
12
57
  O Opus deixa de definir `font-size` em `html`. A fonte raiz volta a pertencer ao navegador e à
package/bin/cli.mjs CHANGED
@@ -509,8 +509,8 @@ Fundação de UI (só apps web — preset Tailwind + tema + flags do Opus):
509
509
  tema/tsconfig/dep — avisa o que falta plugar (não edita seus arquivos: sem clobber)
510
510
 
511
511
  Este comando materializa somente os artefatos Opus. O template encadeia
512
- \`opus setup && base setup\`: a Base 2.0 é obrigatória quando o gate universal de copy
513
- está habilitado; Maestro continua opcional.
512
+ \`opus setup && base setup\`: a versão da Base registrada em base.json é obrigatória quando
513
+ o gate universal de copy está habilitado; Maestro continua opcional.
514
514
  `)
515
515
  return
516
516
  }
package/bin/lib/copy.mjs CHANGED
@@ -130,6 +130,10 @@ const JSX_PROP_ROLES = new Map([
130
130
  ['Alert', new Map([['title', 'title'], ['description', 'message']])],
131
131
  ['CommandInput', new Map([['placeholder', 'placeholder']])],
132
132
  ['DataState', new Map([['emptyText', 'empty-state'], ['errorText', 'error']])],
133
+ // A Dock nomeia a barra e cada ação por prop. Sem estas linhas, a copy sairia do inventário
134
+ // exatamente quando uma superfície migra de <Button aria-label> para <DockAction label>.
135
+ ['Dock', new Map([['label', 'label']])],
136
+ ['DockAction', new Map([['label', 'label'], ['hint', 'description']])],
133
137
  ['Input', new Map([['placeholder', 'placeholder']])],
134
138
  ['InputGroupInput', new Map([['placeholder', 'placeholder']])],
135
139
  ['InputGroupTextarea', new Map([['placeholder', 'placeholder']])],
package/docs/releasing.md CHANGED
@@ -53,7 +53,7 @@ pra testar publicação seria a guarda atrapalhando quem está experimentando.
53
53
 
54
54
  **Gate de qualidade**: depois do bump e da materialização, mas antes de publicar, roda
55
55
  `pnpm typecheck` + `pnpm test` + `pnpm copy:check` + `base copy check` e **aborta a release
56
- se qualquer um falhar**. O Opus declara `@softize/base ^2.0.0` em `dependencies`, pois usa
56
+ se qualquer um falhar**. O Opus declara `@softize/base ^2.1.0` em `dependencies`, pois usa
57
57
  suas APIs públicas de filesystem em runtime, e materializa os artefatos Base no próprio repo;
58
58
  a release não baixa uma política ad hoc. Como o
59
59
  Opus **ship source** (`.ts`, sem build), essa é a última barreira antes do tarball — sem ela,
@@ -63,7 +63,7 @@ um `tsc` vermelho, inventário desatualizado ou violação da política vaza pro
63
63
  **Smoke do esqueleto**: depois do bump e antes do publish, o `release.sh` gera um app com
64
64
  `opus create`, instala o **tarball exato** que vai ser publicado e roda os gates dele
65
65
  (typecheck · test · `opus check` · `opus copy --check` · `base copy check` · manifest ·
66
- build). O template exige `@softize/base ^2.0.0`; publique a Base compatível antes do Opus.
66
+ build). O template exige `@softize/base ^2.1.0`; publique a Base compatível antes do Opus.
67
67
  O `minimumReleaseAgeExclude` do template inclui os dois pacotes, e o smoke executa os
68
68
  fragmentos de pre-push para provar que o layout pnpm instalado resolve ambos os CLIs.
69
69
  É o que pega o que typecheck+test não
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "12.3.0",
3
+ "version": "12.5.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",
@@ -122,6 +122,14 @@
122
122
  "./storage": "./src/storage/index.ts",
123
123
  "./storage/fs": "./src/storage/drivers/fs.ts",
124
124
  "./storage/s3": "./src/storage/drivers/s3.ts",
125
+ "./cache": {
126
+ "types": "./src/cache/index.ts",
127
+ "default": "./src/cache/index.ts"
128
+ },
129
+ "./cache/memory": {
130
+ "types": "./src/cache/drivers/memory.ts",
131
+ "default": "./src/cache/drivers/memory.ts"
132
+ },
125
133
  "./ai": "./src/ai/index.ts",
126
134
  "./ai/anthropic": "./src/ai/drivers/anthropic.ts",
127
135
  "./mcp": "./src/mcp/index.ts",
@@ -201,7 +209,6 @@
201
209
  },
202
210
  "dependencies": {
203
211
  "@modelcontextprotocol/sdk": "^1.29.0",
204
- "@softize/base": "^2.0.0",
205
212
  "@radix-ui/react-checkbox": "^1.1.3",
206
213
  "@radix-ui/react-dialog": "^1.1.4",
207
214
  "@radix-ui/react-dropdown-menu": "^2.1.4",
@@ -211,6 +218,7 @@
211
218
  "@radix-ui/react-slot": "^1.1.1",
212
219
  "@radix-ui/react-tabs": "^1.1.2",
213
220
  "@radix-ui/react-tooltip": "^1.1.6",
221
+ "@softize/base": "^2.1.0",
214
222
  "@tailwindcss/typography": "^0.5.20",
215
223
  "@types/markdown-it": "^14.1.2",
216
224
  "class-variance-authority": "^0.7.1",
@@ -234,11 +242,11 @@
234
242
  "zod-to-json-schema": "^3.23.0"
235
243
  },
236
244
  "peerDependencies": {
237
- "@opentelemetry/api": "^1.9.0",
238
245
  "@anthropic-ai/sdk": ">=0.35.0",
239
246
  "@aws-sdk/client-s3": "^3.0.0",
240
247
  "@aws-sdk/s3-request-presigner": "^3.0.0",
241
248
  "@hookform/resolvers": "^3.0.0",
249
+ "@opentelemetry/api": "^1.9.0",
242
250
  "@tanstack/react-query": "^5.0.0",
243
251
  "bullmq": "^5.0.0",
244
252
  "fastify": "^5.0.0",
@@ -30,7 +30,7 @@
30
30
  "zod": "^3.24.0"
31
31
  },
32
32
  "devDependencies": {
33
- "@softize/base": "^2.0.0",
33
+ "@softize/base": "^2.1.0",
34
34
  "@tailwindcss/vite": "^4.1.0",
35
35
  "@types/node": "^22.0.0",
36
36
  "@types/react": "^19.0.0",
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Driver de cache em memória do processo.
3
+ *
4
+ * Serve desenvolvimento e o caso de um único processo. Não compartilha nada entre
5
+ * instâncias — quem precisa disso usa um driver distribuído, e a troca é só a montagem.
6
+ *
7
+ * A expiração é preguiçosa: a entrada vencida some quando alguém a procura. Sem varredura
8
+ * de fundo, um cache de processo não justifica um timer permanente; `sweep()` existe para
9
+ * quem quiser forçar a limpeza.
10
+ */
11
+ import type { CacheAdapter, CacheSetOptions } from '../../core/index.ts'
12
+
13
+ interface Entry {
14
+ value: unknown
15
+ /** Epoch em milissegundos; `null` = não expira. */
16
+ expiresAt: number | null
17
+ }
18
+
19
+ export interface MemoryCacheOptions {
20
+ /** Expiração padrão quando a chamada não passa `ttlSeconds`. Ausente = não expira. */
21
+ defaultTtlSeconds?: number
22
+ /** Teto de entradas. Passando dele, a mais antiga sai — um cache de processo não deve
23
+ * crescer sem limite dentro de um servidor de longa duração. */
24
+ maxEntries?: number
25
+ }
26
+
27
+ export interface MemoryCache extends CacheAdapter {
28
+ /** Remove as entradas vencidas agora. */
29
+ sweep(): number
30
+ /** Quantas entradas o cache guarda neste momento. */
31
+ size(): number
32
+ }
33
+
34
+ export function memoryCache(options: MemoryCacheOptions = {}): MemoryCache {
35
+ const entries = new Map<string, Entry>()
36
+ const maxEntries = options.maxEntries ?? 10_000
37
+
38
+ const expired = (entry: Entry, now: number): boolean => entry.expiresAt !== null && entry.expiresAt <= now
39
+
40
+ return {
41
+ name: 'memory',
42
+ kind: 'cache',
43
+ get<T>(key: string): Promise<T | null> {
44
+ const entry = entries.get(key)
45
+ if (entry === undefined) return Promise.resolve(null)
46
+ if (expired(entry, Date.now())) {
47
+ entries.delete(key)
48
+ return Promise.resolve(null)
49
+ }
50
+ return Promise.resolve(entry.value as T)
51
+ },
52
+ set<T>(key: string, value: T, opts: CacheSetOptions = {}): Promise<void> {
53
+ const ttl = opts.ttlSeconds ?? options.defaultTtlSeconds
54
+ // Reinserir move a chave para o fim da ordem do Map, que é o que faz o descarte
55
+ // abaixo tirar a MAIS ANTIGA, e não a que acabou de ser escrita.
56
+ entries.delete(key)
57
+ entries.set(key, { value, expiresAt: ttl === undefined ? null : Date.now() + ttl * 1000 })
58
+ if (entries.size > maxEntries) {
59
+ const oldest = entries.keys().next()
60
+ if (!oldest.done) entries.delete(oldest.value)
61
+ }
62
+ return Promise.resolve()
63
+ },
64
+ delete(key: string): Promise<void> {
65
+ entries.delete(key)
66
+ return Promise.resolve()
67
+ },
68
+ sweep(): number {
69
+ const now = Date.now()
70
+ let removed = 0
71
+ for (const [key, entry] of entries) {
72
+ if (expired(entry, now)) {
73
+ entries.delete(key)
74
+ removed += 1
75
+ }
76
+ }
77
+ return removed
78
+ },
79
+ size: () => entries.size,
80
+ healthCheck: () => Promise.resolve({ ok: true as const, details: { entries: entries.size } }),
81
+ }
82
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @softize/opus/cache — helpers compartilhados dos drivers de cache.
3
+ *
4
+ * EXPERIMENTAL. O contrato (`CacheAdapter`) vive no core; o driver de memória fica em
5
+ * `cache/memory`. A superfície é a mínima que um handler usa — guardar, ler, remover — e
6
+ * cresce por reincidência, não por especulação.
7
+ */
8
+
9
+ export type { CacheAdapter, CacheSetOptions } from '../core/index.ts'
10
+
11
+ /** Monta uma chave estável a partir de partes. Evita a concatenação à mão que produz
12
+ * chaves parecidas e colidentes entre domínios. */
13
+ export function cacheKey(...parts: (string | number)[]): string {
14
+ return parts.map((part) => String(part)).join(':')
15
+ }
package/src/core/index.ts CHANGED
@@ -99,6 +99,8 @@ export type {
99
99
  SchedulerAdapter,
100
100
  ClientAdapter,
101
101
  StorageAdapter,
102
+ CacheAdapter,
103
+ CacheSetOptions,
102
104
  StoragePutOptions,
103
105
  StorageObject,
104
106
  AiAdapter,
@@ -61,6 +61,7 @@ import type {
61
61
  SchedulerAdapter,
62
62
  ServerAdapter,
63
63
  StorageAdapter,
64
+ CacheAdapter,
64
65
  AiAdapter,
65
66
  AiRunOptions,
66
67
  BoundAiRunOptions,
@@ -97,6 +98,8 @@ export interface RuntimeSetup {
97
98
  client?: ClientAdapter
98
99
  /** EXPERIMENTAL — storage de objetos (arquivos); chega nos handlers via `ctx.storage`. */
99
100
  storage?: StorageAdapter
101
+ /** EXPERIMENTAL — cache de leitura; chega nos handlers via `ctx.cache`. */
102
+ cache?: CacheAdapter
100
103
  /** EXPERIMENTAL — IA generativa (complete/extract); chega nos handlers via `ctx.ai`. */
101
104
  ai?: AiAdapter
102
105
 
@@ -243,6 +246,7 @@ export class Runtime {
243
246
  private readonly scheduler: SchedulerAdapter | undefined
244
247
  private readonly client: ClientAdapter | undefined
245
248
  private readonly storage: StorageAdapter | undefined
249
+ private readonly cache: CacheAdapter | undefined
246
250
  private readonly ai: AiAdapter | undefined
247
251
  /** Cache das tools derivadas das actions `ai:enabled` (registry é estático pós-start). */
248
252
  private aiToolsCache: AiTool[] | null = null
@@ -293,6 +297,7 @@ export class Runtime {
293
297
  this.scheduler = setup.scheduler
294
298
  this.client = setup.client
295
299
  this.storage = setup.storage
300
+ this.cache = setup.cache
296
301
  this.ai = setup.ai
297
302
  }
298
303
 
@@ -764,6 +769,7 @@ export class Runtime {
764
769
  if (this.scheduler !== undefined) yield this.scheduler
765
770
  if (this.client !== undefined) yield this.client
766
771
  if (this.storage !== undefined) yield this.storage
772
+ if (this.cache !== undefined) yield this.cache
767
773
  if (this.ai !== undefined) yield this.ai
768
774
  }
769
775
 
@@ -860,6 +866,7 @@ export class Runtime {
860
866
  log,
861
867
  emit: this.buildEmit(action, actionId, base, trace),
862
868
  storage: this.storage ?? null,
869
+ cache: this.cache ?? null,
863
870
  ai: this.bindAi({ ...base, ...(trace !== undefined ? { trace } : {}) }),
864
871
  provenance,
865
872
  ...(trace !== undefined ? { trace } : {}),
@@ -1192,6 +1199,7 @@ export class Runtime {
1192
1199
  log: reactionLog,
1193
1200
  emit: noopEmit,
1194
1201
  storage: this.storage ?? null,
1202
+ cache: this.cache ?? null,
1195
1203
  // Reação = contexto de sistema; o agente só alcança actions públicas (can nega por
1196
1204
  // padrão — nada de LLM disparado por evento chamando action gated sem gate explícito).
1197
1205
  ai: this.bindAi({
package/src/core/types.ts CHANGED
@@ -277,6 +277,7 @@ export interface ActionContext {
277
277
  log: Logger
278
278
  emit: EmitFn
279
279
  storage: StorageAdapter | null
280
+ cache: CacheAdapter | null
280
281
  ai: BoundAi | null
281
282
  provenance: Provenance
282
283
  trace?: TraceContext
@@ -294,6 +295,7 @@ export interface ReactionContext {
294
295
  log: Logger
295
296
  emit: EmitFn
296
297
  storage: StorageAdapter | null
298
+ cache: CacheAdapter | null
297
299
  ai: BoundAi | null
298
300
  provenance: Provenance
299
301
  trace?: TraceContext
@@ -941,6 +943,7 @@ export type AdapterKind =
941
943
  | 'scheduler'
942
944
  | 'client'
943
945
  | 'storage'
946
+ | 'cache'
944
947
  | 'ai'
945
948
  | 'ui'
946
949
  | 'schema'
@@ -1167,6 +1170,28 @@ export interface StorageAdapter extends Adapter {
1167
1170
  url(key: string, opts?: { expiresInSeconds?: number }): Promise<string>
1168
1171
  }
1169
1172
 
1173
+ /**
1174
+ * EXPERIMENTAL — cache de leitura. A superfície é a mínima que um handler usa e cresce
1175
+ * por reincidência, como a do storage.
1176
+ *
1177
+ * O MISS é o `null`: quem observa a execução distingue acerto de ausência pelo retorno,
1178
+ * sem o adapter precisar reportar métrica por fora. Erro de infraestrutura é outra coisa
1179
+ * e continua sendo exceção — cache indisponível não deve virar "não estava em cache".
1180
+ */
1181
+ export interface CacheAdapter extends Adapter {
1182
+ kind: 'cache'
1183
+ /** Valor guardado, ou `null` quando a chave não está presente (ou expirou). */
1184
+ get<T>(key: string): Promise<T | null>
1185
+ /** Guarda o valor. Sem `ttlSeconds`, a expiração é a que o driver definir. */
1186
+ set<T>(key: string, value: T, opts?: CacheSetOptions): Promise<void>
1187
+ /** Remove. Idempotente: remover o que não existe não é erro. */
1188
+ delete(key: string): Promise<void>
1189
+ }
1190
+
1191
+ export interface CacheSetOptions {
1192
+ ttlSeconds?: number
1193
+ }
1194
+
1170
1195
  /** Opções por chamada do `AiAdapter` — tudo tem default do driver. */
1171
1196
  export interface AiCompleteOptions {
1172
1197
  /** System prompt da chamada. */
@@ -43,6 +43,7 @@ import type {
43
43
  Paginated,
44
44
  Schema,
45
45
  StorageAdapter,
46
+ CacheAdapter,
46
47
  StorageObject,
47
48
  StoragePutOptions,
48
49
  User,
@@ -72,6 +73,7 @@ export interface TestContextOptions {
72
73
  can?: ActionContext['can']
73
74
  db?: unknown
74
75
  storage?: StorageAdapter | null
76
+ cache?: CacheAdapter | null
75
77
  ai?: AiAdapter | null
76
78
  meta?: Record<string, unknown>
77
79
  trace?: TraceContext
@@ -133,6 +135,7 @@ export function testContext(options: TestContextOptions = {}): TestContext {
133
135
  return Promise.resolve()
134
136
  },
135
137
  storage: options.storage ?? null,
138
+ cache: options.cache ?? null,
136
139
  ai: options.ai ? toBoundAi(options.ai) : null,
137
140
  provenance: { kind: 'system', source: 'test' },
138
141
  ...(options.trace !== undefined ? { trace: options.trace } : {}),
@@ -0,0 +1,182 @@
1
+ /**
2
+ * <Dock /> — a barra de ferramentas que flutua sobre uma superfície de trabalho.
3
+ *
4
+ * Canvas, editor e preview têm o mesmo problema: as ferramentas não cabem num cabeçalho
5
+ * sem empilhar mais uma faixa de chrome sobre a trilha que a aplicação já desenha. A Dock
6
+ * resolve isso ancorando as ações à própria superfície, agrupadas e sempre visíveis.
7
+ *
8
+ * O componente é dono da moldura, dos separadores entre grupos, da forma dos botões e da
9
+ * semântica de toolbar (`role="toolbar"` com navegação por setas). QUAIS ferramentas
10
+ * existem e o que cada uma faz continua sendo da aplicação.
11
+ *
12
+ * Requer `<TooltipProvider>` na raiz do app — cada ação nomeia-se por tooltip.
13
+ */
14
+
15
+ import * as React from 'react'
16
+ import { cn } from '../../lib/cn.ts'
17
+ import { Button } from '../primitives/button.tsx'
18
+ import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip.tsx'
19
+
20
+ const POSITION = {
21
+ bottom: 'bottom-3 left-1/2 -translate-x-1/2',
22
+ 'bottom-left': 'bottom-3 left-3',
23
+ 'bottom-right': 'bottom-3 right-3',
24
+ } as const
25
+
26
+ export interface DockProps {
27
+ /** Aresta da superfície onde a barra se ancora. */
28
+ position?: keyof typeof POSITION
29
+ /** Nome acessível da barra; é o que a tecnologia assistiva anuncia ao entrar nela. */
30
+ label: string
31
+ className?: string
32
+ children: React.ReactNode
33
+ }
34
+
35
+ /** Barra flutuante ancorada à superfície. O contêiner precisa ser `relative`. */
36
+ export function Dock({ position = 'bottom', label, className, children }: DockProps): React.ReactElement {
37
+ const ref = React.useRef<HTMLDivElement>(null)
38
+
39
+ // Navegação de toolbar: as setas andam entre as ações, e Home/End vão às pontas. Sem isso
40
+ // uma barra com dez ícones cobra dez Tabs de quem navega por teclado.
41
+ const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
42
+ const keys = ['ArrowRight', 'ArrowLeft', 'Home', 'End']
43
+ if (!keys.includes(event.key) || ref.current === null) return
44
+ const items = Array.from(ref.current.querySelectorAll<HTMLButtonElement>('button:not([disabled])'))
45
+ if (items.length === 0) return
46
+ const current = items.indexOf(document.activeElement as HTMLButtonElement)
47
+ if (current === -1) return
48
+ event.preventDefault()
49
+ const next =
50
+ event.key === 'Home'
51
+ ? 0
52
+ : event.key === 'End'
53
+ ? items.length - 1
54
+ : event.key === 'ArrowRight'
55
+ ? (current + 1) % items.length
56
+ : (current - 1 + items.length) % items.length
57
+ items[next]?.focus()
58
+ }
59
+
60
+ return (
61
+ <div
62
+ ref={ref}
63
+ data-slot="dock"
64
+ role="toolbar"
65
+ aria-label={label}
66
+ aria-orientation="horizontal"
67
+ onKeyDown={onKeyDown}
68
+ className={cn(
69
+ 'absolute z-10 flex max-w-[calc(100%-1.5rem)] flex-row items-center gap-1.5 overflow-x-auto',
70
+ 'rounded-2xl border border-border bg-background/95 p-1.5 shadow-lg backdrop-blur',
71
+ POSITION[position],
72
+ className,
73
+ )}
74
+ >
75
+ {children}
76
+ </div>
77
+ )
78
+ }
79
+
80
+ /** Grupo de ações afins. A divisória entre grupos é do componente, não do consumidor. */
81
+ export function DockGroup({ className, children }: { className?: string; children: React.ReactNode }): React.ReactElement {
82
+ return (
83
+ <div
84
+ data-slot="dock-group"
85
+ className={cn(
86
+ 'flex shrink-0 flex-row items-center gap-1 border-l border-border pl-1.5',
87
+ 'first:border-l-0 first:pl-0',
88
+ className,
89
+ )}
90
+ >
91
+ {children}
92
+ </div>
93
+ )
94
+ }
95
+
96
+ export interface DockActionProps {
97
+ icon: React.ReactNode
98
+ /** Nome da ação: vai para o tooltip e para o nome acessível. */
99
+ label: string
100
+ /** Presente quando a ação liga um modo; ausente quando ela apenas executa. */
101
+ pressed?: boolean
102
+ disabled?: boolean
103
+ /** Conteúdo do tooltip quando ele precisa dizer mais que o rótulo. */
104
+ hint?: React.ReactNode
105
+ className?: string
106
+ onClick: () => void
107
+ }
108
+
109
+ /** Ação de ícone da Dock. Com `pressed`, comunica modo ligado em vez de execução. */
110
+ export function DockAction({ icon, label, pressed, disabled = false, hint, className, onClick }: DockActionProps): React.ReactElement {
111
+ return (
112
+ <Tooltip>
113
+ <TooltipTrigger asChild>
114
+ <Button
115
+ data-slot="dock-action"
116
+ size="icon"
117
+ variant={pressed === true ? 'default' : 'ghost'}
118
+ aria-label={label}
119
+ aria-pressed={pressed}
120
+ disabled={disabled}
121
+ onClick={onClick}
122
+ className={cn('size-9 shrink-0 rounded-xl', className)}
123
+ >
124
+ {icon}
125
+ </Button>
126
+ </TooltipTrigger>
127
+ <TooltipContent>{hint ?? label}</TooltipContent>
128
+ </Tooltip>
129
+ )
130
+ }
131
+
132
+ const STATUS_POSITION = {
133
+ 'top-right': 'top-3 right-3',
134
+ 'top-left': 'top-3 left-3',
135
+ } as const
136
+
137
+ export interface SurfaceStatusProps {
138
+ /** Canto da superfície onde o estado se ancora. */
139
+ position?: keyof typeof STATUS_POSITION
140
+ /** Ações do recurso aberto — em geral o mesmo menu que a linha dele tem na navegação. */
141
+ actions?: React.ReactNode
142
+ className?: string
143
+ children?: React.ReactNode
144
+ }
145
+
146
+ /**
147
+ * O que a superfície diz sobre SI — salvamento, versão, execução percorrida — flutuando no canto,
148
+ * separado das ferramentas. Fica fora da Dock de propósito: estado não é ação, e uma toolbar que
149
+ * carrega texto vivo deixa de ser navegável como toolbar.
150
+ *
151
+ * A região é `role="status"` com `aria-live="polite"`: a mudança é anunciada sem roubar o foco.
152
+ * Mensagem de sucesso é transitória por natureza; falha permanece até o estado mudar. Quem decide
153
+ * isso é a aplicação, que conhece o ciclo — o componente só reserva o lugar.
154
+ */
155
+ export function SurfaceStatus({ position = 'top-right', actions, className, children }: SurfaceStatusProps): React.ReactElement {
156
+ const hasActions = actions !== undefined && actions !== null
157
+ return (
158
+ <div
159
+ data-slot="surface-status"
160
+ className={cn(
161
+ 'pointer-events-none absolute z-10 flex max-w-[calc(100%-1.5rem)] flex-row items-center gap-2',
162
+ 'rounded-full border border-border bg-background/95 py-1.5 pl-3 text-xs text-muted-foreground shadow-sm backdrop-blur',
163
+ hasActions ? 'pr-1' : 'pr-3',
164
+ STATUS_POSITION[position],
165
+ className,
166
+ )}
167
+ >
168
+ {/* Só o texto é região viva: anunciar o menu a cada mudança de estado seria ruído. */}
169
+ <span role="status" aria-live="polite" className="flex min-w-0 flex-row items-center gap-2">
170
+ {children}
171
+ </span>
172
+ {hasActions && (
173
+ <span
174
+ data-slot="surface-status-actions"
175
+ className="pointer-events-auto flex shrink-0 items-center [&_button]:size-6 [&_button]:rounded-full [&_svg]:size-3.5"
176
+ >
177
+ {actions}
178
+ </span>
179
+ )}
180
+ </div>
181
+ )
182
+ }
@@ -74,6 +74,7 @@ function renderItem(item: ShellNavItem, activeId: string | undefined, onSelect:
74
74
  className={cn(
75
75
  'flex w-full items-center gap-2.5 rounded-md py-1.5 text-left text-sm transition-colors',
76
76
  'disabled:pointer-events-none disabled:opacity-40',
77
+ 'outline-none focus-visible:ring-2 focus-visible:ring-ring/50',
77
78
  railed ? 'justify-center px-0' : 'px-2.5',
78
79
  active ? 'bg-muted font-medium text-foreground' : 'text-foreground/80 hover:bg-muted/60',
79
80
  )}
@@ -1,4 +1,6 @@
1
- import { createContext, useContext, type ReactNode } from 'react'
1
+ import { createContext, useContext, type HTMLAttributes, type ReactNode } from 'react'
2
+ import { ChevronRight } from 'lucide-react'
3
+ import { useOverflowing } from '../../lib/overflow.ts'
2
4
  import { cn } from '../../lib/cn.ts'
3
5
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../primitives/tooltip.tsx'
4
6
 
@@ -50,22 +52,58 @@ export interface SidebarItemProps {
50
52
  badge?: ReactNode
51
53
  active?: boolean
52
54
  disabled?: boolean
55
+ /** Controles irmãos do alvo de navegação (ex.: menu de ações). */
56
+ actions?: ReactNode
57
+ /** Presente quando a linha desdobra filhos; o controle fica fora do botão principal. */
58
+ onToggle?: () => void
59
+ expanded?: boolean
60
+ /** Posição indicada durante drag-and-drop; bordas inserem, centro recebe dentro. */
61
+ dropPosition?: 'before' | 'inside' | 'after'
62
+ /** Props de drag-and-drop aplicadas à linha, sem acoplar a um driver. */
63
+ dragProps?: HTMLAttributes<HTMLDivElement>
64
+ /** Complemento do tooltip no rail recolhido. */
65
+ tooltipHint?: ReactNode
53
66
  className?: string
54
67
  onClick: () => void
55
68
  }
56
69
 
57
- /** Item de navegação que se adapta ao colapso da Sidebar em que está. */
58
- export function SidebarItem({ label, icon, badge, active = false, disabled = false, className, onClick }: SidebarItemProps): React.ReactElement {
70
+ /** Linha única de navegação: o alvo principal e seus controles são irmãos, nunca botões aninhados. */
71
+ export function SidebarItem({ label, icon, badge, active = false, disabled = false, actions, onToggle, expanded = false, dropPosition, dragProps, tooltipHint, className, onClick }: SidebarItemProps): React.ReactElement {
59
72
  const collapsed = useSidebarCollapsed()
60
- const button = (
61
- <button type="button" disabled={disabled} aria-current={active ? 'page' : undefined} onClick={onClick} className={cn('flex items-center gap-2.5 rounded-md transition-colors', 'disabled:pointer-events-none disabled:opacity-40', collapsed ? 'mx-auto size-9 justify-center p-0' : 'w-full px-2.5 py-1.5 text-left text-sm', active ? 'bg-muted font-medium text-foreground' : 'text-foreground/80 hover:bg-muted/60', className)}>
62
- {icon !== undefined && <span className="flex shrink-0">{icon}</span>}
63
- {!collapsed && <span className="min-w-0 flex-1 truncate">{label}</span>}
64
- {!collapsed && badge !== undefined && <span className="shrink-0">{badge}</span>}
65
- </button>
73
+ const hasActions = actions !== undefined && actions !== null
74
+ // O rótulo cortado esmaece em vez de terminar em reticências; a medição do transbordo
75
+ // mantém a máscara fora do rótulo que cabe inteiro na linha.
76
+ const { ref: labelRef, overflowing: labelCut } = useOverflowing<HTMLSpanElement>(label)
77
+ if (collapsed) {
78
+ const button = (
79
+ <button type="button" disabled={disabled} aria-current={active ? 'page' : undefined} onClick={onClick} className={cn('mx-auto grid size-9 place-items-center rounded-md transition-colors disabled:pointer-events-none disabled:opacity-40', 'outline-none focus-visible:ring-2 focus-visible:ring-ring/50', active ? 'bg-muted font-medium text-foreground' : 'text-foreground/80 hover:bg-muted/60', className)}>
80
+ {icon}
81
+ </button>
82
+ )
83
+ return <div {...dragProps}><Tooltip><TooltipTrigger asChild>{button}</TooltipTrigger><TooltipContent side="right">{label}{tooltipHint}</TooltipContent></Tooltip></div>
84
+ }
85
+
86
+ return (
87
+ <div data-slot="sidebar-item" className={cn('group/sidebar-item relative flex items-center rounded-md transition-colors', disabled ? 'text-muted-foreground/50 opacity-40' : 'hover:bg-muted/60', active && 'bg-muted', dropPosition === 'inside' && 'bg-primary/5 ring-2 ring-inset ring-primary/60', className)} {...dragProps}>
88
+ {(dropPosition === 'before' || dropPosition === 'after') && <span data-slot="sidebar-item-drop-indicator" className={cn('pointer-events-none absolute inset-x-1 z-20 h-0.5 rounded-full bg-primary before:absolute before:-left-1 before:top-1/2 before:size-2 before:-translate-y-1/2 before:rounded-full before:border-2 before:border-primary before:bg-background', dropPosition === 'before' ? '-top-px' : '-bottom-px')} />}
89
+ {icon !== undefined && (
90
+ <span data-slot="sidebar-item-leading" className="pointer-events-none absolute left-2.5 top-1/2 z-10 grid size-4 -translate-y-1/2 place-items-center">
91
+ <span className={cn('absolute inset-0 flex items-center justify-center transition-opacity', onToggle !== undefined && 'group-hover/sidebar-item:opacity-0 group-has-[[data-slot=sidebar-item-toggle]:focus-visible]/sidebar-item:opacity-0')}>{icon}</span>
92
+ {onToggle !== undefined && (
93
+ <button type="button" data-slot="sidebar-item-toggle" onClick={onToggle} aria-label={expanded ? `Fechar ${label}` : `Abrir ${label}`} aria-expanded={expanded} className="pointer-events-none absolute inset-0 grid place-items-center rounded-sm text-muted-foreground opacity-0 transition-[color,opacity] outline-none hover:text-foreground group-hover/sidebar-item:pointer-events-auto group-hover/sidebar-item:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring/50">
94
+ <ChevronRight className={cn('size-3 transition-transform', expanded && 'rotate-90')} />
95
+ </button>
96
+ )}
97
+ </span>
98
+ )}
99
+ <button type="button" disabled={disabled} aria-current={active ? 'page' : undefined} onClick={onClick} className={cn('flex min-w-0 flex-1 items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm disabled:pointer-events-none', 'outline-none focus-visible:ring-2 focus-visible:ring-ring/50', disabled ? 'text-muted-foreground/50' : active ? 'font-medium text-foreground' : 'text-foreground/80')}>
100
+ {icon !== undefined && <span data-slot="sidebar-item-leading-space" aria-hidden className="size-4 shrink-0" />}
101
+ <span ref={labelRef} data-slot="sidebar-item-label" className={cn('min-w-0 flex-1 overflow-hidden whitespace-nowrap group-has-[[data-slot=dot]]/sidebar-item:mr-[1.1875rem]', labelCut && 'truncate-fade')}>{label}</span>
102
+ {badge !== undefined && <span data-slot="sidebar-item-badge" className={cn('shrink-0 transition-opacity has-[[data-slot=dot]]:absolute has-[[data-slot=dot]]:right-1 has-[[data-slot=dot]]:top-1/2 has-[[data-slot=dot]]:z-10 has-[[data-slot=dot]]:grid has-[[data-slot=dot]]:size-6 has-[[data-slot=dot]]:-translate-y-1/2 has-[[data-slot=dot]]:place-items-center', hasActions && 'group-hover/sidebar-item:has-[[data-slot=dot]]:opacity-0')}>{badge}</span>}
103
+ </button>
104
+ {hasActions && <div data-slot="sidebar-item-actions" className="pointer-events-none absolute right-1 top-1/2 z-10 flex -translate-y-1/2 items-center opacity-0 transition-opacity group-hover/sidebar-item:pointer-events-auto group-hover/sidebar-item:opacity-100 has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:opacity-100 has-[[data-state=open]]:pointer-events-auto has-[[data-state=open]]:opacity-100 [&_button]:grid [&_button]:size-6 [&_button]:place-items-center [&_button]:p-0 [&_svg]:size-3.5">{actions}</div>}
105
+ </div>
66
106
  )
67
- if (!collapsed) return button
68
- return <Tooltip><TooltipTrigger asChild>{button}</TooltipTrigger><TooltipContent side="right">{label}</TooltipContent></Tooltip>
69
107
  }
70
108
 
71
109
  export interface SidebarNavGroup {
@@ -86,6 +124,11 @@ const subFilled = (subgroup: SidebarNavSubgroup): boolean => subgroup.items.leng
86
124
  const groupFilled = (group: SidebarNavGroup): boolean =>
87
125
  (group.items?.length ?? 0) > 0 || (group.subgroups?.some(subFilled) ?? false)
88
126
 
127
+ /** Rótulo discreto de um grupo de navegação, compartilhado por listas e árvores. */
128
+ export function SidebarGroupLabel({ className, children }: { className?: string; children: ReactNode }): React.ReactElement {
129
+ return <div data-slot="sidebar-group-label" className={cn('px-2.5 pb-1 text-sm font-medium text-muted-foreground/70', className)}>{children}</div>
130
+ }
131
+
89
132
  /** Navegação controlada para Sidebar. Header/footer continuam slots explícitos do pai. */
90
133
  export function SidebarNav({ groups, activeId, onSelect, navLabel = 'Navegação', className }: { groups: SidebarNavGroup[]; activeId?: string; onSelect: (id: string) => void; navLabel?: string; className?: string }): React.ReactElement {
91
134
  const collapsed = useSidebarCollapsed()
@@ -101,7 +144,7 @@ export function SidebarNav({ groups, activeId, onSelect, navLabel = 'Navegação
101
144
  const body = (
102
145
  <nav aria-label={navLabel} data-slot="sidebar-nav" className={cn('space-y-4 p-2', className)}>
103
146
  {groups.map((group, gi) => !groupFilled(group) ? null : <div key={gi} className="space-y-0.5">
104
- {!collapsed && titled(group.label) && <div className="px-2.5 pb-1 text-xs font-medium text-muted-foreground/70">{group.label}</div>}
147
+ {!collapsed && titled(group.label) && <SidebarGroupLabel>{group.label}</SidebarGroupLabel>}
105
148
  {group.items?.map((item) => renderItem(item))}
106
149
  {group.subgroups?.map((subgroup, si) => !subFilled(subgroup) ? null : <div key={si} className="space-y-0.5">
107
150
  {!collapsed && titled(subgroup.label) && <div className="pb-0.5 pl-4 pr-2.5 pt-1.5 text-[0.6875rem] font-medium text-muted-foreground/50">{subgroup.label}</div>}
@@ -0,0 +1,39 @@
1
+ import * as React from 'react'
2
+ import { cva, type VariantProps } from 'class-variance-authority'
3
+ import { cn } from '../../lib/cn.ts'
4
+
5
+ export const dotVariants = cva('inline-block size-1.5 shrink-0 rounded-full', {
6
+ variants: {
7
+ variant: {
8
+ default: 'bg-primary',
9
+ secondary: 'bg-muted-foreground/45',
10
+ destructive: 'bg-destructive',
11
+ outline: 'border border-border bg-transparent',
12
+ success: 'bg-emerald-500',
13
+ warning: 'bg-amber-500',
14
+ info: 'bg-blue-500',
15
+ },
16
+ },
17
+ defaultVariants: {
18
+ variant: 'default',
19
+ },
20
+ })
21
+
22
+ export interface DotProps extends React.ComponentProps<'span'>, VariantProps<typeof dotVariants> {
23
+ /** Nome acessível quando a cor comunica estado; sem label, o ponto é decorativo. */
24
+ label?: string
25
+ }
26
+
27
+ /** Indicador compacto de estado; use Badge quando o texto precisar permanecer visível. */
28
+ export function Dot({ className, variant, label, ...props }: DotProps): React.ReactElement {
29
+ return (
30
+ <span
31
+ data-slot="dot"
32
+ role={label === undefined ? undefined : 'img'}
33
+ aria-label={label}
34
+ aria-hidden={label === undefined ? true : undefined}
35
+ className={cn(dotVariants({ variant }), className)}
36
+ {...props}
37
+ />
38
+ )
39
+ }
@@ -52,6 +52,7 @@ interface SelectBaseProps {
52
52
  /** Texto do campo vazio. */
53
53
  placeholder?: string
54
54
  disabled?: boolean
55
+ /** Classes da raiz do controle, incluindo campo, ícones e ações. */
55
56
  className?: string
56
57
  id?: string
57
58
  /** Ícone LEADING dentro do campo — identifica o filtro sem jogar o ícone ao lado. */
@@ -133,7 +134,10 @@ function NativeSelect({
133
134
  }: SelectNativeProps): React.ReactElement {
134
135
  const groups = byGroup(options)
135
136
  return (
136
- <div className="group/select relative w-full min-w-0 has-[select:disabled]:opacity-50" data-slot="select-wrapper">
137
+ <div
138
+ className={cn('group/select relative w-full min-w-0 has-[select:disabled]:opacity-50', className)}
139
+ data-slot="select-wrapper"
140
+ >
137
141
  {icon ? (
138
142
  <span
139
143
  className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground select-none [&_svg]:size-4"
@@ -157,7 +161,6 @@ function NativeSelect({
157
161
  'focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50',
158
162
  'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
159
163
  icon ? 'pl-9' : '',
160
- className,
161
164
  )}
162
165
  >
163
166
  {placeholder !== undefined && (
@@ -2,11 +2,21 @@ import * as React from "react"
2
2
 
3
3
  import { cn } from '../../lib/cn.ts'
4
4
 
5
- function Table({ className, ...props }: React.ComponentProps<"table">) {
5
+ export interface TableProps extends React.ComponentProps<"table"> {
6
+ /** `framed` aplica a moldura canônica de datagrid no contêiner da tabela. */
7
+ variant?: 'plain' | 'framed'
8
+ }
9
+
10
+ function Table({ className, variant = 'plain', ...props }: TableProps) {
6
11
  return (
7
12
  <div
8
13
  data-slot="table-container"
9
- className="relative w-full overflow-x-auto"
14
+ data-variant={variant}
15
+ className={cn(
16
+ "relative w-full overflow-x-auto",
17
+ variant === 'framed' &&
18
+ 'rounded-lg border border-border [&_[data-slot=table-header]]:bg-muted/20',
19
+ )}
10
20
  >
11
21
  <table
12
22
  data-slot="table"
@@ -1,10 +1,13 @@
1
1
  import * as React from 'react'
2
2
  import { Tooltip, TooltipContent, TooltipTrigger } from './tooltip.tsx'
3
+ import { useOverflowing } from '../../lib/overflow.ts'
3
4
  import { cn } from '../../lib/cn.ts'
4
5
 
5
6
  export interface TruncateProps extends React.ComponentProps<'span'> {
6
7
  /** Conteúdo do tooltip quando transborda. Default: os próprios children. */
7
8
  tooltip?: React.ReactNode
9
+ /** Sinaliza o corte esmaecendo o fim da linha, no lugar das reticências. */
10
+ fade?: boolean
8
11
  }
9
12
 
10
13
  /**
@@ -12,29 +15,15 @@ export interface TruncateProps extends React.ComponentProps<'span'> {
12
15
  * `scrollWidth > clientWidth`, re-medido em resize). Substitui a composição
13
16
  * `block truncate` + `title` sempre presente — title em texto que não corta é ruído.
14
17
  * Requer `<TooltipProvider>` na raiz do app (o esqueleto do `opus create` já monta).
18
+ * Com `fade`, a mesma medição decide quando esmaecer o fim da linha em vez de cortar
19
+ * em reticências — sem transbordo, nada é aplicado.
15
20
  */
16
- export function Truncate({ tooltip, children, className, ...props }: TruncateProps): React.ReactElement {
17
- const ref = React.useRef<HTMLSpanElement>(null)
18
- const [overflowing, setOverflowing] = React.useState(false)
19
-
20
- const measure = React.useCallback(() => {
21
- const el = ref.current
22
- if (el === null) return
23
- setOverflowing(el.scrollWidth > el.clientWidth)
24
- }, [])
25
-
26
- React.useLayoutEffect(() => {
27
- measure()
28
- const el = ref.current
29
- if (el === null || typeof ResizeObserver === 'undefined') return
30
- const ro = new ResizeObserver(measure)
31
- ro.observe(el)
32
- return () => ro.disconnect()
33
- // children na dependência: texto novo re-mede (o RO só vê mudança de CAIXA).
34
- }, [measure, children])
21
+ export function Truncate({ tooltip, fade = false, children, className, ...props }: TruncateProps): React.ReactElement {
22
+ const { ref, overflowing } = useOverflowing<HTMLSpanElement>(children)
23
+ const cut = fade ? (overflowing ? 'truncate-fade' : 'overflow-hidden whitespace-nowrap') : 'truncate'
35
24
 
36
25
  const span = (
37
- <span ref={ref} data-slot="truncate" className={cn('block truncate', className)} {...props}>
26
+ <span ref={ref} data-slot="truncate" className={cn('block', cut, className)} {...props}>
38
27
  {children}
39
28
  </span>
40
29
  )
@@ -0,0 +1,69 @@
1
+ # Dock
2
+
3
+ Ferramentas ancoradas à superfície de trabalho. Um canvas ou um editor raramente comporta mais
4
+ uma faixa de chrome no topo: a trilha da aplicação já ocupa esse papel, e um segundo cabeçalho
5
+ empilha duas faixas com a mesma função. A Dock coloca as ações sobre a própria superfície,
6
+ agrupadas e sempre alcançáveis.
7
+
8
+ ```tsx preview
9
+ <div className="relative h-40 w-full rounded-lg border border-border bg-muted/20">
10
+ <Dock label="Ferramentas do fluxo">
11
+ <DockGroup>
12
+ <DockAction icon={<MousePointer2 />} label="Selecionar e mover" pressed onClick={() => {}} />
13
+ </DockGroup>
14
+ <DockGroup>
15
+ <DockAction icon={<Plus />} label="Adicionar etapa" onClick={() => {}} />
16
+ <DockAction icon={<GitBranch />} label="Adicionar condição" onClick={() => {}} />
17
+ </DockGroup>
18
+ <DockGroup>
19
+ <DockAction icon={<Sparkles />} label="Revisar com IA" onClick={() => {}} />
20
+ </DockGroup>
21
+ </Dock>
22
+ <SurfaceStatus>Salvo</SurfaceStatus>
23
+ </div>
24
+ ```
25
+
26
+ O contêiner precisa ser `relative` — a Dock se ancora nele, não na janela. `position` escolhe a
27
+ aresta: `bottom` (centro), `bottom-left` e `bottom-right`. Uma superfície pode ter mais de uma
28
+ barra quando os papéis são distintos, como ferramentas ao centro e controles de viewport à
29
+ esquerda.
30
+
31
+ ## Grupos e divisórias
32
+
33
+ A divisória entre grupos pertence ao componente: o consumidor declara `DockGroup` e a linha
34
+ aparece entre grupos consecutivos, nunca antes do primeiro. Agrupe por intenção — modo, criação,
35
+ IA, publicação — em vez de espalhar ícones numa fileira única.
36
+
37
+ ## Estado da superfície
38
+
39
+ Estado não é ferramenta, e por isso não mora na barra: `SurfaceStatus` flutua num canto da mesma
40
+ superfície — `top-right` por padrão — e recebe salvamento, versão publicada, execução percorrida.
41
+ A região é `role="status"` com `aria-live="polite"`, então a mudança é anunciada sem roubar o foco.
42
+ Separar os dois preserva a barra como toolbar navegável e dá ao estado um lugar estável, que não
43
+ muda de posição conforme o número de ferramentas.
44
+
45
+ Mensagem de sucesso é transitória por natureza — some depois de confirmar — enquanto falha
46
+ permanece até o estado mudar. Essa decisão é da aplicação, que conhece o ciclo; o componente
47
+ apenas reserva o lugar.
48
+
49
+ `actions` recebe o que o recurso aberto permite fazer — em geral o mesmo menu que a linha dele
50
+ tem na navegação. Assim a superfície deixa de ser o único lugar onde renomear ou excluir não
51
+ alcançam o item que está na tela. Apenas o texto é região viva: anunciar o menu a cada mudança de
52
+ estado seria ruído para quem usa leitor de tela.
53
+
54
+ ## Modo e execução
55
+
56
+ `DockAction` sem `pressed` executa uma ação. Com `pressed`, comunica um modo ligado — o botão
57
+ assume o preenchimento e expõe `aria-pressed`. Use `hint` quando o tooltip precisar dizer mais que
58
+ o rótulo, que também é o nome acessível.
59
+
60
+ ## Teclado
61
+
62
+ A barra é uma `toolbar`: as setas andam entre as ações e Home/End vão às pontas. Cada ação nomeia-se
63
+ por tooltip, então `<TooltipProvider>` precisa existir na raiz do app — o esqueleto do `opus create`
64
+ já monta.
65
+
66
+ | Prop | Tipo | Default | O que faz |
67
+ | --- | --- | --- | --- |
68
+ | `position` | `'bottom' \| 'bottom-left' \| 'bottom-right'` | `'bottom'` | Aresta do contêiner onde a barra se ancora. |
69
+ | `label` | `string` | | Nome acessível da barra. |
@@ -0,0 +1,17 @@
1
+ # Dot
2
+
3
+ Indicador visual compacto para estados que já têm contexto. O tamanho permanece fixo; `variant`
4
+ seleciona somente a intenção semântica. Quando a cor carrega significado, `label` fornece o nome
5
+ acessível. Sem `label`, o ponto é decorativo.
6
+
7
+ ```tsx preview
8
+ <div className="flex items-center gap-4">
9
+ <Dot variant="success" label="Ativo" />
10
+ <Dot variant="warning" label="Atenção" />
11
+ <Dot variant="destructive" label="Falhou" />
12
+ <Dot variant="info" label="Publicado" />
13
+ <Dot variant="secondary" aria-hidden />
14
+ </div>
15
+ ```
16
+
17
+ Use `Badge` quando o estado precisar permanecer legível sem depender do contexto ao redor.
@@ -340,3 +340,4 @@ render(
340
340
  | `size` | `'default' \| 'sm'` | `'default'` | Altura: default (h-9, a do Input e do Button) ou sm (h-8) pra toolbar densa. |
341
341
  | `disabled` | `boolean` | `false` | Esmaece e trava o controle. |
342
342
  | `id` | `string` | | Vai pro campo — pra parear com o `htmlFor` do Label. |
343
+ | `className` | `string` | | Classes da raiz do controle, incluindo campo, ícones e ações, em todos os modos. |
@@ -29,6 +29,27 @@ render(
29
29
 
30
30
  `collapsed` pertence à própria `Sidebar`; `SidebarItem`, `SidebarNav` e `ShellNav` adaptam-se automaticamente para botões `size-9` centralizados, ícones e tooltips. `PaneHeader`, `PaneContent` e `PaneFooter` são os slots do pane: a aplicação mantém a identidade e ações que lhe pertencem sem atribuí-las artificialmente à sidebar.
31
31
 
32
+ `SidebarItem` também é a linha de árvores de navegação. `actions` e o controle formado por
33
+ `onToggle`/`expanded` são irmãos do botão principal, portanto menus e chevrons não criam
34
+ controles interativos aninhados. No modo recolhido, a linha conserva somente o destino com
35
+ ícone e tooltip. No modo aberto, o chevron ocupa o lugar do ícone da pasta durante o hover da própria linha ou
36
+ foco, e as ações ficam sobrepostas à extremidade direita: controles invisíveis não reduzem o
37
+ espaço disponível para o rótulo. O foco comum no destino não mantém o chevron aberto;
38
+ somente o foco visível no próprio controle o revela para navegação por teclado.
39
+ As ações aparecem no hover da própria linha, no foco visível do próprio controle ou enquanto o menu está
40
+ aberto; focar o destino da linha não revela as reticências.
41
+ O rótulo que não cabe na linha desaparece num gradiente até a borda, em vez de terminar em
42
+ reticências; a linha mede o próprio transbordo, então o rótulo que cabe inteiro fica intacto.
43
+ O foco visível usa o mesmo anel do `Button` (`ring-2 ring-ring/50`), na linha aberta, no botão do
44
+ rail recolhido e no chevron — sem ele a navegação por teclado cairia no anel padrão do navegador,
45
+ que destoa do tema.
46
+ Durante drag-and-drop, `dropPosition="before"` e `"after"` desenham uma linha sobreposta ao
47
+ limite do item; `"inside"` realça a superfície da pasta. O indicador nunca reserva espaço.
48
+
49
+ Árvores montadas por composição usam `SidebarGroupLabel` para os mesmos rótulos discretos
50
+ que o `SidebarNav` desenha automaticamente. O heading permanece `text-sm`; hierarquia vem
51
+ do peso médio e da cor atenuada, não de reduzir legibilidade.
52
+
32
53
  ```tsx
33
54
  <Sidebar collapsed={collapsed}>
34
55
  <PaneHeader><MySidebarHeader onToggle={() => setCollapsed(!collapsed)} /></PaneHeader>
@@ -38,32 +38,33 @@ A Table vem SEM borda externa — só as divisórias de linha (a última o Table
38
38
 
39
39
  ## Moldura (o datagrid da casa)
40
40
 
41
- A borda externa NÃO é do componente: é um wrapper `overflow-hidden rounded-lg border border-border` por fora o estilo padrão de datagrid do back-office. O `overflow-hidden` recorta os cantos e contém o scroll horizontal; a última linha já vem sem divisória, então o quadro fecha limpo. O ActionList aplica a moldura sozinho na tabela derivada; em tabela montada à mão, é você quem embrulha.
41
+ A variante `framed` aplica no próprio contêiner a borda externa, os cantos arredondados, o
42
+ scroll horizontal contido e o fundo discreto do cabeçalho. A última linha já vem sem divisória,
43
+ então o quadro fecha limpo. O ActionList continua sendo o pattern indicado para coleções
44
+ pesquisáveis derivadas de actions `kind: 'list'`.
42
45
 
43
46
  ```tsx preview col
44
- <div className="w-full overflow-hidden rounded-lg border border-border">
45
- <Table>
46
- <TableHeader>
47
- <TableRow>
48
- <TableHead>Sessão</TableHead>
49
- <TableHead>Agente</TableHead>
50
- <TableHead className="text-right">Duração</TableHead>
51
- </TableRow>
52
- </TableHeader>
53
- <TableBody>
54
- <TableRow>
55
- <TableCell className="font-medium">Importar pedidos da transportadora</TableCell>
56
- <TableCell>developer</TableCell>
57
- <TableCell className="text-right">42 min</TableCell>
58
- </TableRow>
59
- <TableRow>
60
- <TableCell className="font-medium">Revisar contrato de rastreio</TableCell>
61
- <TableCell>reviewer</TableCell>
62
- <TableCell className="text-right">18 min</TableCell>
63
- </TableRow>
64
- </TableBody>
65
- </Table>
66
- </div>
47
+ <Table variant="framed">
48
+ <TableHeader>
49
+ <TableRow>
50
+ <TableHead>Sessão</TableHead>
51
+ <TableHead>Agente</TableHead>
52
+ <TableHead className="text-right">Duração</TableHead>
53
+ </TableRow>
54
+ </TableHeader>
55
+ <TableBody>
56
+ <TableRow>
57
+ <TableCell className="font-medium">Importar pedidos da transportadora</TableCell>
58
+ <TableCell>developer</TableCell>
59
+ <TableCell className="text-right">42 min</TableCell>
60
+ </TableRow>
61
+ <TableRow>
62
+ <TableCell className="font-medium">Revisar contrato de rastreio</TableCell>
63
+ <TableCell>reviewer</TableCell>
64
+ <TableCell className="text-right">18 min</TableCell>
65
+ </TableRow>
66
+ </TableBody>
67
+ </Table>
67
68
  ```
68
69
 
69
70
  ## Com rodapé (TableFooter)
@@ -20,6 +20,32 @@ O mesmo componente, com espaço de sobra: sem tooltip, sem atributo, só o texto
20
20
  </div>
21
21
  ```
22
22
 
23
+ ## Corte por esmaecimento
24
+
25
+ `fade` troca as reticências por um gradiente: o texto segue até a borda da caixa e desaparece
26
+ ali. A medição é a mesma, então o esmaecimento e a dica aparecem juntos, só quando há texto
27
+ escondido.
28
+
29
+ ```tsx preview
30
+ <div className="w-48 rounded-md border p-2">
31
+ <Truncate fade>relatorio-consolidado-vendas-pos-vendas-2026-julho-final-v3.xlsx</Truncate>
32
+ </div>
33
+ ```
34
+
35
+ Uma composição que precisa do esmaecimento sem o tooltip aplica a utilitária `truncate-fade`,
36
+ que cobre o último `1.5rem` da caixa, e decide quando ligá-la com o hook `useOverflowing` — a
37
+ mesma medição que este componente usa. A máscara pressupõe corte real: em texto que cabe
38
+ inteiro, ela esmaeceria o fim de uma palavra visível. É assim que o rótulo do `SidebarItem`
39
+ funciona.
40
+
41
+ ```tsx
42
+ const { ref, overflowing } = useOverflowing<HTMLSpanElement>(label)
43
+
44
+ <span ref={ref} className={cn('overflow-hidden whitespace-nowrap', overflowing && 'truncate-fade')}>
45
+ {label}
46
+ </span>
47
+ ```
48
+
23
49
  ## Conteúdo da dica
24
50
 
25
51
  `tooltip` sobrepõe o conteúdo mostrado no hover (default: os próprios children) — útil
@@ -50,6 +50,8 @@ import confirmMd from './content/confirm.md?raw'
50
50
  import aspectRatioMd from './content/aspect-ratio.md?raw'
51
51
  import avatarMd from './content/avatar.md?raw'
52
52
  import badgeMd from './content/badge.md?raw'
53
+ import dotMd from './content/dot.md?raw'
54
+ import dockMd from './content/dock.md?raw'
53
55
  import breadcrumbMd from './content/breadcrumb.md?raw'
54
56
  import buttonMd from './content/button.md?raw'
55
57
  import buttonGroupMd from './content/button-group.md?raw'
@@ -357,6 +359,8 @@ export const UI_SECTIONS: DocSection[] = [
357
359
  pages: [
358
360
  { slug: 'avatar', title: 'Avatar', render: comp('Avatar', 'avatar', avatarMd) },
359
361
  { slug: 'badge', title: 'Badge', render: comp('Badge', 'badge', badgeMd) },
362
+ { slug: 'dot', title: 'Dot', render: comp('Dot', 'dot', dotMd) },
363
+ { slug: 'dock', title: 'Dock', render: comp('Dock', 'dock', dockMd) },
360
364
  { slug: 'carousel', title: 'Carousel', render: comp('Carousel', 'carousel', carouselMd) },
361
365
  { slug: 'copyable', title: 'Copyable', render: comp('Copyable', 'copyable', copyableMd) },
362
366
  { slug: 'item', title: 'Item', render: comp('Item', 'item', itemMd) },
@@ -0,0 +1,34 @@
1
+ import * as React from 'react'
2
+
3
+ /**
4
+ * Diz se uma linha única de texto não cabe na própria caixa (`scrollWidth > clientWidth`).
5
+ * A medida é refeita quando a caixa muda de tamanho e quando o conteúdo passado em `content`
6
+ * muda — o ResizeObserver enxerga a CAIXA, não o texto que entra nela.
7
+ *
8
+ * É o que separa o corte real do corte suposto: quem consome liga o sinal de truncamento
9
+ * (reticências, esmaecimento, tooltip) só quando existe texto escondido.
10
+ */
11
+ export function useOverflowing<T extends HTMLElement>(content?: React.ReactNode): {
12
+ ref: React.RefObject<T | null>
13
+ overflowing: boolean
14
+ } {
15
+ const ref = React.useRef<T>(null)
16
+ const [overflowing, setOverflowing] = React.useState(false)
17
+
18
+ const measure = React.useCallback(() => {
19
+ const el = ref.current
20
+ if (el === null) return
21
+ setOverflowing(el.scrollWidth > el.clientWidth)
22
+ }, [])
23
+
24
+ React.useLayoutEffect(() => {
25
+ measure()
26
+ const el = ref.current
27
+ if (el === null || typeof ResizeObserver === 'undefined') return
28
+ const ro = new ResizeObserver(measure)
29
+ ro.observe(el)
30
+ return () => ro.disconnect()
31
+ }, [measure, content])
32
+
33
+ return { ref, overflowing }
34
+ }
package/src/ui/meta.ts CHANGED
@@ -29,6 +29,18 @@ export const componentMeta = {
29
29
  whenToUse:
30
30
  'Rótulo curto de status/categoria. `variant` pra intenção (default/secondary/destructive/outline) ou tom de status (success/warning/info, fill tingido). Não-interativo — pra clique, use Button ou `asChild` num <a>.',
31
31
  },
32
+ 'dot': {
33
+ name: 'dot',
34
+ ancestry: 'opus',
35
+ whenToUse:
36
+ 'Sinal compacto de estado quando o contexto ou o nome acessível já explica o significado. `variant` segue a intenção semântica (default/secondary/destructive/outline/success/warning/info). Passe `label` quando a cor comunicar informação; sem label, o ponto é decorativo. Para texto visível, use Badge.',
37
+ },
38
+ 'dock': {
39
+ name: 'dock',
40
+ ancestry: 'opus',
41
+ whenToUse:
42
+ 'Barra de ferramentas ancorada a uma superfície de trabalho — canvas, editor, preview —, quando um cabeçalho empilharia mais uma faixa de chrome sobre a trilha. Compõe Dock > DockGroup > DockAction; o estado da superfície (salvamento, versão) fica no SurfaceStatus, que flutua num canto e não pertence à barra. `position` escolhe a aresta; a divisória entre grupos é do componente. Requer TooltipProvider na raiz. Para ações de uma PÁGINA, use as `actions` do Page; para um conjunto de toggles exclusivos, ToggleGroup.',
43
+ },
32
44
  'button': {
33
45
  name: 'button',
34
46
  ancestry: 'shadcn',
@@ -137,7 +149,7 @@ export const componentMeta = {
137
149
  name: 'table',
138
150
  ancestry: 'shadcn',
139
151
  whenToUse:
140
- 'Tabela de dados. Compõe Table > (TableHeader > TableRow > TableHead, TableBody > TableRow > TableCell). Vem SEM borda externa o datagrid da casa é a Table num wrapper "overflow-hidden rounded-lg border border-border". Pra listagem tabular — pro pattern de search use ActionList (que já monta a tabela emoldurada do spec).',
152
+ 'Tabela de dados. Compõe Table > (TableHeader > TableRow > TableHead, TableBody > TableRow > TableCell). `variant="plain"` vem sem borda externa; `variant="framed"` aplica a moldura canônica, recorta o scroll e destaca o cabeçalho. Pra listagem tabular — pro pattern de search use ActionList.',
141
153
  },
142
154
  'tabs': {
143
155
  name: 'tabs',
@@ -167,7 +179,7 @@ export const componentMeta = {
167
179
  name: 'truncate',
168
180
  ancestry: 'opus',
169
181
  whenToUse:
170
- 'Texto truncado com tooltip SÓ quando transborda (medição do overflow, re-medida em resize) — substitui a composição `block truncate` + `title` sempre presente, que mostra dica até em texto que não corta. `tooltip` sobrepõe o conteúdo da dica (default: os children). Requer TooltipProvider na raiz. Pra célula de tabela, nome de arquivo, URL — qualquer linha única que pode estourar.',
182
+ 'Texto truncado com tooltip SÓ quando transborda (medição do overflow, re-medida em resize) — substitui a composição `block truncate` + `title` sempre presente, que mostra dica até em texto que não corta. `tooltip` sobrepõe o conteúdo da dica (default: os children); `fade` troca as reticências por um esmaecimento até a borda, ligado pela mesma medição. Requer TooltipProvider na raiz. Pra célula de tabela, nome de arquivo, URL — qualquer linha única que pode estourar.',
171
183
  },
172
184
  'accordion': {
173
185
  name: 'accordion',
package/src/ui/react.tsx CHANGED
@@ -135,6 +135,7 @@ export {
135
135
  TableCell,
136
136
  TableCaption,
137
137
  } from './components/primitives/table.tsx'
138
+ export type { TableProps } from './components/primitives/table.tsx'
138
139
 
139
140
  // UM seletor: `native` (menu do SO), `searchable` (busca), `multiple` (chips) e
140
141
  // `variant="ghost"` (barra de composer) são PROPS, não componentes. Ver select.tsx.
@@ -172,6 +173,10 @@ export { ToggleGroup, ToggleGroupItem } from './components/primitives/toggle-gro
172
173
  export { Truncate } from './components/primitives/truncate.tsx'
173
174
  export type { TruncateProps } from './components/primitives/truncate.tsx'
174
175
 
176
+ // A medição de transbordo por trás do Truncate, exposta para composições que precisam
177
+ // do sinal de corte sem o tooltip — por exemplo, esmaecer um rótulo com `truncate-fade`.
178
+ export { useOverflowing } from './lib/overflow.ts'
179
+
175
180
  // Patterns (renderizam actions a partir do spec)
176
181
  export { ActionForm, ActionFormField, useActionFormContext } from './components/patterns/form.tsx'
177
182
  export type { ActionFormProps, ActionFormFieldProps, ActionFormContextValue } from './components/patterns/form.tsx'
@@ -209,8 +214,12 @@ export type { PageProps } from './components/patterns/page.tsx'
209
214
  export { Split, Pane } from './components/patterns/split.tsx'
210
215
  export type { SplitProps, PaneProps, PaneSize, SplitLayout, SplitLayoutChange } from './components/patterns/split.tsx'
211
216
 
217
+ // A barra de ferramentas que flutua sobre uma superfície de trabalho (canvas, editor, preview).
218
+ export { Dock, DockGroup, DockAction, SurfaceStatus } from './components/patterns/dock.tsx'
219
+ export type { DockProps, DockActionProps, SurfaceStatusProps } from './components/patterns/dock.tsx'
220
+
212
221
  // Barra lateral composicional: header/conteúdo/footer e navegação, sem possuir o layout.
213
- export { PaneHeader, PaneContent, PaneFooter, Sidebar, SidebarItem, SidebarNav } from './components/patterns/sidebar.tsx'
222
+ export { PaneHeader, PaneContent, PaneFooter, Sidebar, SidebarItem, SidebarGroupLabel, SidebarNav } from './components/patterns/sidebar.tsx'
214
223
  export type { SidebarProps, SidebarItemProps, SidebarNavGroup, SidebarNavSubgroup, SidebarNavItem } from './components/patterns/sidebar.tsx'
215
224
 
216
225
  // O menu como primitivo pivotável: recolhe quando composto dentro de Sidebar.
@@ -221,3 +230,5 @@ export type { ShellNavProps, ShellNavGroup, ShellNavItem, ShellNavHeadingProps }
221
230
  // reativa da URL + navigate — sem tabela de rotas. Ver src/ui/router.ts.
222
231
  export { navigate, usePathname, useSegments, useSearchParams } from './router.ts'
223
232
  export type { NavigateOptions } from './router.ts'
233
+ export { Dot, dotVariants } from './components/primitives/dot.tsx'
234
+ export type { DotProps } from './components/primitives/dot.tsx'
package/src/ui/theme.css CHANGED
@@ -168,6 +168,18 @@
168
168
  }
169
169
  }
170
170
 
171
+ /* Truncamento por esmaecimento: a linha continua em direção à borda e desaparece num
172
+ * gradiente, em vez de terminar em reticências. A máscara ocupa o último 1.5rem da caixa,
173
+ * então só deve ser aplicada quando o texto REALMENTE corta — em texto que cabe, ela
174
+ * esmaeceria o fim de uma palavra inteira. `Truncate fade` e o rótulo do `SidebarItem`
175
+ * medem o transbordo antes de ligá-la. */
176
+ @utility truncate-fade {
177
+ overflow: hidden;
178
+ white-space: nowrap;
179
+ text-overflow: clip;
180
+ mask-image: linear-gradient(to right, #000 calc(100% - 1.5rem), transparent);
181
+ }
182
+
171
183
  /* Scrollbar nativa da casa: todo scroller do documento compartilha o mesmo acabamento,
172
184
  * inclusive conteúdo renderizado por portal. Isso elimina a variação do macOS entre raiz,
173
185
  * elementos aninhados, mouse e trackpad sem trocar o mecanismo nativo nem adicionar trabalho