@softize/opus 12.2.1 → 12.4.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 (53) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/bin/cli.mjs +2 -2
  3. package/bin/lib/copy.mjs +4 -0
  4. package/docs/elevation-scale.md +2 -0
  5. package/docs/radius-scale.md +1 -0
  6. package/docs/relative-unit-scale.md +55 -0
  7. package/docs/releasing.md +2 -2
  8. package/package.json +3 -3
  9. package/registry/skills/build-opus-ui/SKILL.md +8 -4
  10. package/registry/skills/build-opus-ui/references/ui-patterns.md +2 -0
  11. package/registry/templates/app/package.json +1 -1
  12. package/src/ui/components/patterns/dock.tsx +182 -0
  13. package/src/ui/components/patterns/shell-nav.tsx +1 -0
  14. package/src/ui/components/patterns/sidebar.tsx +56 -13
  15. package/src/ui/components/primitives/accordion.tsx +1 -1
  16. package/src/ui/components/primitives/button.tsx +4 -4
  17. package/src/ui/components/primitives/calendar.tsx +2 -2
  18. package/src/ui/components/primitives/chat.tsx +1 -1
  19. package/src/ui/components/primitives/checkbox.tsx +1 -1
  20. package/src/ui/components/primitives/command.tsx +1 -1
  21. package/src/ui/components/primitives/dialog.tsx +1 -1
  22. package/src/ui/components/primitives/dot.tsx +39 -0
  23. package/src/ui/components/primitives/input-group.tsx +4 -4
  24. package/src/ui/components/primitives/input-otp.tsx +1 -1
  25. package/src/ui/components/primitives/input.tsx +4 -5
  26. package/src/ui/components/primitives/item.tsx +1 -1
  27. package/src/ui/components/primitives/radio-group.tsx +1 -1
  28. package/src/ui/components/primitives/scroll-area.tsx +1 -1
  29. package/src/ui/components/primitives/select.tsx +7 -4
  30. package/src/ui/components/primitives/switch.tsx +1 -1
  31. package/src/ui/components/primitives/table.tsx +14 -4
  32. package/src/ui/components/primitives/tabs.tsx +3 -3
  33. package/src/ui/components/primitives/textarea.tsx +1 -1
  34. package/src/ui/components/primitives/toggle.tsx +1 -1
  35. package/src/ui/components/primitives/tooltip.tsx +1 -1
  36. package/src/ui/components/primitives/truncate.tsx +9 -20
  37. package/src/ui/docs/DocBrowser.tsx +1 -1
  38. package/src/ui/docs/content/button.md +2 -2
  39. package/src/ui/docs/content/customization.md +5 -0
  40. package/src/ui/docs/content/dock.md +69 -0
  41. package/src/ui/docs/content/dot.md +17 -0
  42. package/src/ui/docs/content/select.md +1 -0
  43. package/src/ui/docs/content/sidebar.md +21 -0
  44. package/src/ui/docs/content/table.md +25 -24
  45. package/src/ui/docs/content/tabs.md +1 -1
  46. package/src/ui/docs/content/tokens.md +9 -9
  47. package/src/ui/docs/content/truncate.md +26 -0
  48. package/src/ui/docs/doc.tsx +1 -1
  49. package/src/ui/docs/registry.tsx +4 -0
  50. package/src/ui/lib/overflow.ts +34 -0
  51. package/src/ui/meta.ts +16 -4
  52. package/src/ui/react.tsx +12 -1
  53. package/src/ui/theme.css +27 -24
package/CHANGELOG.md CHANGED
@@ -7,6 +7,48 @@ 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.4.0 — 2026-08-24
11
+
12
+ Superfícies de trabalho — canvas, editor, preview — ganham os dois lugares que faltavam. `Dock`
13
+ ancora as ferramentas à própria superfície, com `DockGroup` separando intenções e `DockAction`
14
+ distinguindo modo de execução; a barra é uma `toolbar` de verdade, com setas e Home/End entre as
15
+ ações. `SurfaceStatus` recebe o que a superfície diz sobre si — salvamento, versão, execução
16
+ percorrida — num canto estável, fora da barra: estado não é ação, e uma toolbar que carrega texto
17
+ vivo deixa de ser navegável como toolbar.
18
+
19
+ O truncamento passa a ter uma segunda forma. `Truncate` aceita `fade`, e a utilitária
20
+ `truncate-fade` esmaece o fim da linha no lugar das reticências. O hook `useOverflowing`, extraído
21
+ do próprio `Truncate`, fica exposto para quem precisa do sinal de corte sem o tooltip — é o que
22
+ mantém a máscara fora do texto que cabe inteiro.
23
+
24
+ `SidebarItem` vira a linha das árvores de navegação: `actions`, `onToggle`/`expanded` e
25
+ `dropPosition` são irmãos do destino, nunca botões aninhados, e o rótulo cortado esmaece até a
26
+ borda. Ele e o item do `ShellNav` também ganham foco visível com o anel do `Button` — antes a
27
+ navegação por teclado caía no anel do navegador, que destoa do tema.
28
+
29
+ Entram ainda `Dot`, para estado compacto onde o contexto já explica o significado, e
30
+ `Table variant="framed"`, que traz para o componente a moldura de datagrid antes montada à mão em
31
+ cada consumidor. `Select` passa a aplicar `className` na raiz do controle em todos os modos.
32
+
33
+ O `opus copy` reconhece `Dock.label`, `DockAction.label` e `DockAction.hint`: componente com texto
34
+ em prop precisa entrar no mapa de papéis, senão a copy sai do inventário em silêncio quando uma
35
+ superfície migra de `aria-label` para prop.
36
+
37
+ Tudo é aditivo e não exige migração. Consumidores que montavam a moldura da tabela à mão podem
38
+ trocar o wrapper por `variant="framed"` quando quiserem.
39
+
40
+ ## 12.3.0 — 2026-08-23
41
+
42
+ O Opus deixa de definir `font-size` em `html`. A fonte raiz volta a pertencer ao navegador e à
43
+ aplicação, enquanto texto, espaçamento, arredondamento, foco, elevação e limites dimensionais da
44
+ UI usam uma escala relativa em `rem`. Assim, preferências de acessibilidade e identidades próprias
45
+ conseguem ajustar a densidade do documento sem disputar com o tema da biblioteca.
46
+
47
+ A mudança é visual e não exige migração de API. Sem uma regra do consumidor, `1rem` segue a
48
+ configuração do navegador. Aplicações que desejarem conservar a proporção anterior podem definir
49
+ `html { font-size: 93.75%; }` no próprio CSS. `px` permanece apenas em hairlines e compensações
50
+ geométricas inventariadas por teste.
51
+
10
52
  ## 12.2.1 — 2026-08-23
11
53
 
12
54
  O cabeçalho de `Page` passa a alinhar `actions` à base do bloco formado por título e descrição.
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']])],
@@ -20,6 +20,8 @@ uma superfície que precisava apenas de mais presença acabava recebendo um nome
20
20
  - O Opus declara a composição diretamente em `--shadow-*`, mantendo a progressão do
21
21
  Tailwind com camadas mais claras, difusas e spread negativo. Sem variável intermediária,
22
22
  modifiers nativos como `shadow-xl/30` continuam funcionando.
23
+ - Deslocamento, desfoque e spread usam `rem`, portanto a elevação acompanha a escala raiz
24
+ escolhida pelo documento.
23
25
  - Componentes escolhem a intensidade pelo contexto: `sm` para repouso, `md` para
24
26
  flutuantes e `lg` para modais ou destaques fortes. Essa associação é default, não uma
25
27
  restrição semântica.
@@ -18,6 +18,7 @@ voltaria a acoplar forma e tipo de componente, problema removido na versão 10.0
18
18
 
19
19
  - `--radius` passa a `0.75rem`. Aplicações podem sobrescrever essa base para ajustar a identidade
20
20
  inteira sem forkar componentes.
21
+ - Os offsets da escala também usam `rem`, acompanhando a fonte raiz escolhida pelo documento.
21
22
  - `rounded-xs` até `rounded-2xl` formam uma progressão derivada da mesma base. `rounded-full`
22
23
  continua reservado a círculos e variantes explicitamente pill.
23
24
  - Componentes escolhem o degrau pela escala visual. Em geral, detalhes e itens internos usam
@@ -0,0 +1,55 @@
1
+ # Escala relativa e fonte raiz
2
+
3
+ > **Status: decidido (ago/2026).** O documento define a fonte raiz; o Opus organiza sua
4
+ > escala visual em `rem` e não altera essa escolha.
5
+
6
+ ## Contexto
7
+
8
+ O tema do Opus fixava `font-size: 15px` em `html`. Isso tornava a densidade uniforme, mas
9
+ também fazia uma biblioteca decidir a tipografia raiz de toda a aplicação. A regra alcançava
10
+ conteúdo que não pertencia ao Opus, reduzia a preferência do navegador e obrigava o consumidor
11
+ a desfazer o tema antes de escolher sua própria escala.
12
+
13
+ Parte das medidas visuais ainda usava `px`. Radius, elevação, foco, limites de conteúdo e texto
14
+ compacto deixavam de acompanhar a raiz mesmo quando a aplicação assumia essa responsabilidade.
15
+
16
+ ## Decisão
17
+
18
+ - O tema do Opus não declara `font-size` em `html` nem em `:root`. O navegador e a aplicação
19
+ definem a fonte raiz.
20
+ - Medidas que pertencem à identidade e à escala da interface usam `rem`, diretamente ou pela
21
+ escala do Tailwind, que também é relativa à raiz.
22
+ - Aplicações ajustam a densidade no próprio CSS. Para conservar a proporção que antes resultava
23
+ de uma raiz de 15 sobre a base usual de 16, podem usar `html { font-size: 93.75%; }`.
24
+ - `px` fica restrito a encaixes técnicos que não devem crescer com a tipografia: espessura de
25
+ hairline, compensação exata de uma borda, ajuste óptico preso a esse traço e o raio efetivamente
26
+ infinito do scrollbar nativo. Cada ocorrência integra a allowlist do teste de contrato.
27
+ - Valores históricos no changelog podem continuar em `px`, pois descrevem versões anteriores;
28
+ eles não definem o contrato vigente.
29
+
30
+ ## Consequências
31
+
32
+ - Sem uma regra da aplicação, `1rem` segue a configuração do navegador. A UI pode ficar maior
33
+ para consumidores que recebiam os antigos 15px; essa mudança é visual e intencional.
34
+ - Preferências de acessibilidade e identidades próprias deixam de disputar com um override da
35
+ biblioteca.
36
+ - Alterar a raiz na aplicação escala texto, espaçamento, radius, foco, elevação e limites
37
+ dimensionais do Opus como um conjunto.
38
+ - Hairlines e compensações de borda permanecem nítidas e geometricamente estáveis.
39
+
40
+ ## Alternativas descartadas
41
+
42
+ - **Manter 15px como default:** conserva a densidade anterior, mas mantém a biblioteca como dona
43
+ do documento inteiro.
44
+ - **Declarar `font-size: 100%`:** costuma reproduzir o default, mas ainda é uma opinião redundante
45
+ e pode sobrescrever uma decisão anterior da aplicação.
46
+ - **Converter também hairlines para `rem`:** torna a regra absoluta, mas permite que bordas e
47
+ compensações deixem de coincidir quando a raiz muda.
48
+
49
+ ## Verificação
50
+
51
+ - Um teste de contrato confirma que o tema não define a fonte raiz.
52
+ - O mesmo teste inventaria `px` no código vivo da UI e reprova qualquer ocorrência fora da
53
+ allowlist técnica.
54
+ - Testes de radius e elevação validam as fórmulas em `rem`.
55
+ - A documentação de tokens e a skill `build-opus-ui` orientam a responsabilidade do consumidor.
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.2.1",
3
+ "version": "12.4.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",
@@ -201,7 +201,6 @@
201
201
  },
202
202
  "dependencies": {
203
203
  "@modelcontextprotocol/sdk": "^1.29.0",
204
- "@softize/base": "^2.0.0",
205
204
  "@radix-ui/react-checkbox": "^1.1.3",
206
205
  "@radix-ui/react-dialog": "^1.1.4",
207
206
  "@radix-ui/react-dropdown-menu": "^2.1.4",
@@ -211,6 +210,7 @@
211
210
  "@radix-ui/react-slot": "^1.1.1",
212
211
  "@radix-ui/react-tabs": "^1.1.2",
213
212
  "@radix-ui/react-tooltip": "^1.1.6",
213
+ "@softize/base": "^2.1.0",
214
214
  "@tailwindcss/typography": "^0.5.20",
215
215
  "@types/markdown-it": "^14.1.2",
216
216
  "class-variance-authority": "^0.7.1",
@@ -234,11 +234,11 @@
234
234
  "zod-to-json-schema": "^3.23.0"
235
235
  },
236
236
  "peerDependencies": {
237
- "@opentelemetry/api": "^1.9.0",
238
237
  "@anthropic-ai/sdk": ">=0.35.0",
239
238
  "@aws-sdk/client-s3": "^3.0.0",
240
239
  "@aws-sdk/s3-request-presigner": "^3.0.0",
241
240
  "@hookform/resolvers": "^3.0.0",
241
+ "@opentelemetry/api": "^1.9.0",
242
242
  "@tanstack/react-query": "^5.0.0",
243
243
  "bullmq": "^5.0.0",
244
244
  "fastify": "^5.0.0",
@@ -19,18 +19,22 @@ existentes, mantendo navegação observável e componentes reutilizáveis sem la
19
19
  o produto precisa de deep link, back/forward ou refresh.
20
20
  4. Manter margem e posicionamento no consumidor; componente reutilizável controla apenas
21
21
  seu interior.
22
- 5. Evoluir um pattern compartilhado apenas quando a recorrência e o contrato estiverem claros.
23
- 6. Tratar tokens de superfície como pares indivisíveis no mesmo fragmento de classes:
22
+ 5. Não definir a fonte raiz em uma biblioteca ou componente. O navegador e a aplicação são
23
+ responsáveis por `font-size` em `html`; medidas escaláveis da UI usam `rem` ou a escala
24
+ relativa do Tailwind. Reservar `px` a hairlines e compensações presas à geometria da borda,
25
+ com justificativa e cobertura explícitas.
26
+ 6. Evoluir um pattern compartilhado apenas quando a recorrência e o contrato estiverem claros.
27
+ 7. Tratar tokens de superfície como pares indivisíveis no mesmo fragmento de classes:
24
28
  `bg-card text-card-foreground` e `bg-popover text-popover-foreground`. Não depender da
25
29
  igualdade atual com `--foreground`, porque o app pode sobrescrever cada par.
26
- 7. Usar diretamente a escala `rounded-*`; não criar radius por nome de componente quando
30
+ 8. Usar diretamente a escala `rounded-*`; não criar radius por nome de componente quando
27
31
  `rounded-xs` a `rounded-2xl` já expressam a forma. Escolher o degrau pela escala visual:
28
32
  detalhes e itens internos tendem a `xs`/`sm`, controles e flutuantes compactos a `md`,
29
33
  molduras estruturais a `lg`, e superfícies principais ou modais a `xl`. Essa associação
30
34
  orienta o default, não cria uma restrição semântica. Em aninhamento, evitar moldura dupla e
31
35
  reduzir o raio interno; em grupos conectados, remover os raios das arestas internas. Tamanho
32
36
  e forma permanecem eixos separados; usar `shape="pill"` quando a pílula for intencional.
33
- 8. Testar estados de loading, vazio, erro, sucesso, permissão e interação relevante.
37
+ 9. Testar estados de loading, vazio, erro, sucesso, permissão e interação relevante.
34
38
 
35
39
  ## Verificação
36
40
 
@@ -5,6 +5,8 @@
5
5
  operação, não a uma tela isolada.
6
6
  - URL representa estado que precisa sobreviver a refresh, deep link ou histórico.
7
7
  - Componentes compartilhados não impõem margem externa; páginas e shells compõem layout.
8
+ - A fonte raiz pertence ao navegador e à aplicação. Medidas escaláveis usam `rem` ou a escala
9
+ relativa do Tailwind; `px` fica restrito a hairlines e compensações ligadas a essas bordas.
8
10
  - Superfície semântica e foreground são um par local (`bg-card text-card-foreground`,
9
11
  `bg-popover text-popover-foreground`); herança do foreground global não substitui o par.
10
12
  - Forma usa a escala `rounded-*`; nome de componente não cria uma segunda escala de radius.
@@ -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,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,10 +144,10 @@ 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
- {!collapsed && titled(subgroup.label) && <div className="pb-0.5 pl-4 pr-2.5 pt-1.5 text-[11px] font-medium text-muted-foreground/50">{subgroup.label}</div>}
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>}
108
151
  {subgroup.items.map((item) => renderItem(item, titled(subgroup.label)))}
109
152
  </div>)}
110
153
  </div>)}
@@ -33,7 +33,7 @@ function AccordionTrigger({
33
33
  <AccordionPrimitive.Trigger
34
34
  data-slot="accordion-trigger"
35
35
  className={cn(
36
- "flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
36
+ "flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
37
37
  className
38
38
  )}
39
39
  {...props}
@@ -31,11 +31,11 @@ export const buttonVariants = cva(
31
31
  sm: 'h-8 gap-1.5 px-3 has-[>svg]:px-2.5',
32
32
  lg: 'h-10 px-6 has-[>svg]:px-4',
33
33
  icon: 'size-9',
34
- // Ícone na escala `sm` (32px) — o par do `sm` pra fileiras densas (toolbar, header)
35
- // onde os irmãos são sm. Sem ele, um botão de ícone ao lado de controles sm ficava
36
- // 4px mais alto ou virava override na mão.
34
+ // Ícone na escala `sm` (2rem) — o par do `sm` para fileiras densas, onde os irmãos
35
+ // também usam `sm`. Sem ele, um botão de ícone ao lado desses controles ficava
36
+ // 0.25rem mais alto ou virava override local.
37
37
  'icon-sm': 'size-8',
38
- // Ícone MENOR (24px) — a ação DENTRO de um campo (o clear/mostrar-senha no
38
+ // Ícone menor (1.5rem) — a ação dentro de um campo (clear/mostrar senha no
39
39
  // `trailing` do Input/Select), que precisa caber folgado num h-9. É o mesmo tamanho
40
40
  // do `icon-xs` do InputGroupButton; sem ele, o exemplo virava `className="size-6"`.
41
41
  'icon-xs': 'size-6',
@@ -72,7 +72,7 @@ function Calendar({
72
72
  defaultClassNames.dropdowns
73
73
  ),
74
74
  dropdown_root: cn(
75
- "relative rounded-md border border-input has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50",
75
+ "relative rounded-md border border-input has-focus:border-ring has-focus:ring-[0.1875rem] has-focus:ring-ring/50",
76
76
  defaultClassNames.dropdown_root
77
77
  ),
78
78
  dropdown: cn(
@@ -206,7 +206,7 @@ function CalendarDayButton({
206
206
  data-range-end={modifiers.range_end}
207
207
  data-range-middle={modifiers.range_middle}
208
208
  className={cn(
209
- "flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-accent-foreground [&>span]:text-xs [&>span]:opacity-70",
209
+ "flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[0.1875rem] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-accent-foreground [&>span]:text-xs [&>span]:opacity-70",
210
210
  defaultClassNames.day,
211
211
  className
212
212
  )}
@@ -136,7 +136,7 @@ const ChatTranscript = React.memo(function ChatTranscript({
136
136
  className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto p-4"
137
137
  >
138
138
  {items.length === 0 && !indicatorVisible && greeting !== undefined && (
139
- <div data-slot="chat-empty" className="m-auto max-w-[280px] text-center text-muted-foreground">
139
+ <div data-slot="chat-empty" className="m-auto max-w-[17.5rem] text-center text-muted-foreground">
140
140
  <div className="mb-2 text-4xl">✦</div>
141
141
  <p className="text-sm leading-relaxed">{greeting}</p>
142
142
  </div>
@@ -12,7 +12,7 @@ function Checkbox({
12
12
  <CheckboxPrimitive.Root
13
13
  data-slot="checkbox"
14
14
  className={cn(
15
- "peer size-4 shrink-0 rounded-[4px] border border-input transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary",
15
+ "peer size-4 shrink-0 rounded-[0.25rem] border border-input transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary",
16
16
  className
17
17
  )}
18
18
  {...props}
@@ -88,7 +88,7 @@ function CommandList({
88
88
  <CommandPrimitive.List
89
89
  data-slot="command-list"
90
90
  className={cn(
91
- "max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
91
+ "max-h-[18.75rem] scroll-py-1 overflow-x-hidden overflow-y-auto",
92
92
  className
93
93
  )}
94
94
  {...props}