@softize/opus 12.3.0 → 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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,36 @@ 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
+
10
40
  ## 12.3.0 — 2026-08-23
11
41
 
12
42
  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.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",
@@ -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,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