@softize/opus 12.2.0 → 12.3.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 (38) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/docs/elevation-scale.md +2 -0
  3. package/docs/radius-scale.md +1 -0
  4. package/docs/relative-unit-scale.md +55 -0
  5. package/package.json +1 -1
  6. package/registry/skills/build-opus-ui/SKILL.md +8 -4
  7. package/registry/skills/build-opus-ui/references/ui-patterns.md +2 -0
  8. package/src/ui/components/patterns/page.tsx +17 -10
  9. package/src/ui/components/patterns/sidebar.tsx +1 -1
  10. package/src/ui/components/primitives/accordion.tsx +1 -1
  11. package/src/ui/components/primitives/button.tsx +4 -4
  12. package/src/ui/components/primitives/calendar.tsx +2 -2
  13. package/src/ui/components/primitives/chat.tsx +1 -1
  14. package/src/ui/components/primitives/checkbox.tsx +1 -1
  15. package/src/ui/components/primitives/command.tsx +1 -1
  16. package/src/ui/components/primitives/dialog.tsx +1 -1
  17. package/src/ui/components/primitives/input-group.tsx +4 -4
  18. package/src/ui/components/primitives/input-otp.tsx +1 -1
  19. package/src/ui/components/primitives/input.tsx +4 -5
  20. package/src/ui/components/primitives/item.tsx +1 -1
  21. package/src/ui/components/primitives/radio-group.tsx +1 -1
  22. package/src/ui/components/primitives/scroll-area.tsx +1 -1
  23. package/src/ui/components/primitives/select.tsx +2 -2
  24. package/src/ui/components/primitives/switch.tsx +1 -1
  25. package/src/ui/components/primitives/table.tsx +2 -2
  26. package/src/ui/components/primitives/tabs.tsx +3 -3
  27. package/src/ui/components/primitives/textarea.tsx +1 -1
  28. package/src/ui/components/primitives/toggle.tsx +1 -1
  29. package/src/ui/components/primitives/tooltip.tsx +1 -1
  30. package/src/ui/docs/DocBrowser.tsx +1 -1
  31. package/src/ui/docs/content/button.md +2 -2
  32. package/src/ui/docs/content/customization.md +5 -0
  33. package/src/ui/docs/content/page.md +7 -2
  34. package/src/ui/docs/content/tabs.md +1 -1
  35. package/src/ui/docs/content/tokens.md +9 -9
  36. package/src/ui/docs/doc.tsx +1 -1
  37. package/src/ui/meta.ts +2 -2
  38. package/src/ui/theme.css +15 -24
package/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ 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.3.0 — 2026-08-23
11
+
12
+ O Opus deixa de definir `font-size` em `html`. A fonte raiz volta a pertencer ao navegador e à
13
+ aplicação, enquanto texto, espaçamento, arredondamento, foco, elevação e limites dimensionais da
14
+ UI usam uma escala relativa em `rem`. Assim, preferências de acessibilidade e identidades próprias
15
+ conseguem ajustar a densidade do documento sem disputar com o tema da biblioteca.
16
+
17
+ A mudança é visual e não exige migração de API. Sem uma regra do consumidor, `1rem` segue a
18
+ configuração do navegador. Aplicações que desejarem conservar a proporção anterior podem definir
19
+ `html { font-size: 93.75%; }` no próprio CSS. `px` permanece apenas em hairlines e compensações
20
+ geométricas inventariadas por teste.
21
+
22
+ ## 12.2.1 — 2026-08-23
23
+
24
+ O cabeçalho de `Page` passa a alinhar `actions` à base do bloco formado por título e descrição.
25
+ Assim, os botões permanecem junto ao fim do contexto quando a página apresenta uma linha
26
+ explicativa. Múltiplas ações conservam o espaçamento entre si. A API pública não muda.
27
+
10
28
  ## 12.2.0 — 2026-08-23
11
29
 
12
30
  A escala de arredondamento ganha uma base mais presente sem transformar todo componente em uma
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "12.2.0",
3
+ "version": "12.3.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",
@@ -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.
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Padroniza o que toda página repete: <main> + container de largura CHEIA (o caso
5
5
  * comum do back-office; quem quiser estreito passa `className="max-w-5xl"`) + header
6
- * (título, descrição e a ação à direita — em geral o botão de criar). O conteúdo é
6
+ * (título, descrição e ações à direita, alinhadas ao fim desse bloco — em geral o botão
7
+ * de criar). O conteúdo é
7
8
  * children, sem nada imposto. Presentacional (sem contrato) de propósito: uma página
8
9
  * agrega N fontes; o que é action-driven mora dentro (ActionList, listas query-backed).
9
10
  */
@@ -17,7 +18,7 @@ export interface PageProps {
17
18
  count?: number
18
19
  /** Linha de contexto sob o título. */
19
20
  description?: ReactNode
20
- /** Ação à direita do header em geral o botão de criar. */
21
+ /** Ações à direita do header, alinhadas ao fim do bloco de contexto. */
21
22
  actions?: ReactNode
22
23
  /** Classes do container. Default: largura cheia; `max-w-5xl` estreita e centra. */
23
24
  className?: string
@@ -28,16 +29,22 @@ export function Page({ title, count, description, actions, className, children }
28
29
  return (
29
30
  <main data-slot="page" className="min-w-0 flex-1">
30
31
  <div className={cn('mx-auto px-8 py-8', className)}>
31
- {/* As ações alinham com o TÍTULO; a descrição vem abaixo, em linha própria. */}
32
+ {/* As ações acompanham o fim do contexto, mesmo quando uma descrição sob o título. */}
32
33
  <div data-slot="page-header" className="mb-6">
33
- <div className={cn(actions !== undefined && 'flex items-center justify-between gap-4')}>
34
- <h1 className="flex items-baseline gap-2 text-2xl font-semibold tracking-tight">
35
- {title}
36
- {count !== undefined && <span className="font-mono text-base text-muted-foreground/60">{count}</span>}
37
- </h1>
38
- {actions}
34
+ <div className={cn(actions !== undefined && 'flex items-end justify-between gap-4')}>
35
+ <div data-slot="page-heading" className="min-w-0">
36
+ <h1 className="flex items-baseline gap-2 text-2xl font-semibold tracking-tight">
37
+ {title}
38
+ {count !== undefined && <span className="font-mono text-base text-muted-foreground/60">{count}</span>}
39
+ </h1>
40
+ {description !== undefined && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
41
+ </div>
42
+ {actions !== undefined && (
43
+ <div data-slot="page-actions" className="flex shrink-0 items-center gap-4">
44
+ {actions}
45
+ </div>
46
+ )}
39
47
  </div>
40
- {description !== undefined && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
41
48
  </div>
42
49
  {children}
43
50
  </div>
@@ -104,7 +104,7 @@ export function SidebarNav({ groups, activeId, onSelect, navLabel = 'Navegação
104
104
  {!collapsed && titled(group.label) && <div className="px-2.5 pb-1 text-xs font-medium text-muted-foreground/70">{group.label}</div>}
105
105
  {group.items?.map((item) => renderItem(item))}
106
106
  {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>}
107
+ {!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
108
  {subgroup.items.map((item) => renderItem(item, titled(subgroup.label)))}
109
109
  </div>)}
110
110
  </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}
@@ -81,7 +81,7 @@ export function DialogHeader({ className, ...props }: React.ComponentProps<'div'
81
81
  // Fixo (shrink-0), com divisor embaixo (border-b). p-5: padding igual nos quatro
82
82
  // lados, casando com o body. O :has tira o divisor quando o header é seguido
83
83
  // DIRETO pelo footer (confirm sem body) — senão o border-b dele encostaria no
84
- // border-t do footer (2px); o divisor é o do footer.
84
+ // A compensação coincide com a soma das hairlines; o divisor fica no footer.
85
85
  className={cn(
86
86
  'flex shrink-0 flex-col gap-2 border-b p-5 text-center sm:text-left',
87
87
  '[&:has(+[data-slot=dialog-footer])]:border-b-0',
@@ -24,7 +24,7 @@ function InputGroup({ className, shape = 'default', ...props }: React.ComponentP
24
24
  "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
25
25
 
26
26
  // Focus state.
27
- "has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50",
27
+ "has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-[0.1875rem] has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50",
28
28
 
29
29
  // Error state.
30
30
  "has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
@@ -37,7 +37,7 @@ function InputGroup({ className, shape = 'default', ...props }: React.ComponentP
37
37
  }
38
38
 
39
39
  const inputGroupAddonVariants = cva(
40
- "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
40
+ "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-0.3125rem)] [&>svg:not([class*='size-'])]:size-4",
41
41
  {
42
42
  variants: {
43
43
  align: {
@@ -84,10 +84,10 @@ const inputGroupButtonVariants = cva(
84
84
  {
85
85
  variants: {
86
86
  size: {
87
- xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
87
+ xs: "h-6 gap-1 rounded-[calc(var(--radius)-0.3125rem)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
88
88
  sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5",
89
89
  "icon-xs":
90
- "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
90
+ "size-6 rounded-[calc(var(--radius)-0.3125rem)] p-0 has-[>svg]:p-0",
91
91
  "icon-sm": "size-8 p-0 has-[>svg]:p-0",
92
92
  },
93
93
  },
@@ -49,7 +49,7 @@ function InputOTPSlot({
49
49
  data-slot="input-otp-slot"
50
50
  data-active={isActive}
51
51
  className={cn(
52
- "relative flex h-9 w-9 items-center justify-center border-y border-r border-input text-sm transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md aria-invalid:border-destructive data-[active=true]:z-10 data-[active=true]:border-ring data-[active=true]:ring-[3px] data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:border-destructive data-[active=true]:aria-invalid:ring-destructive/20 dark:bg-input/30 dark:data-[active=true]:aria-invalid:ring-destructive/40",
52
+ "relative flex h-9 w-9 items-center justify-center border-y border-r border-input text-sm transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md aria-invalid:border-destructive data-[active=true]:z-10 data-[active=true]:border-ring data-[active=true]:ring-[0.1875rem] data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:border-destructive data-[active=true]:aria-invalid:ring-destructive/20 dark:bg-input/30 dark:data-[active=true]:aria-invalid:ring-destructive/40",
53
53
  className
54
54
  )}
55
55
  {...props}
@@ -14,7 +14,7 @@ export interface InputProps extends React.ComponentProps<"input"> {
14
14
 
15
15
  // O <input> CRU (sem adorno): ELE é o campo — borda, foco, ref e className moram nele.
16
16
  const bareInput =
17
- "h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40"
17
+ "h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30 focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40"
18
18
 
19
19
  function Input({ className, type, icon, trailing, ...props }: InputProps) {
20
20
  // Sem adorno: o <input> cru de sempre — retrocompatível (refs, forms, InputGroupInput).
@@ -24,7 +24,7 @@ function Input({ className, type, icon, trailing, ...props }: InputProps) {
24
24
  // Adornado: o MESMO modelo flex do InputGroup/Select. A borda e o anel moram no WRAPPER
25
25
  // (disparados pelo `:focus-visible` do input interno — foco de teclado); o <input> fica
26
26
  // SEM borda e CRESCE (flex-1), e os adornos são IRMÃOS ao lado. Sem conta de pl-9/pr-9: o
27
- // `px-3` + `gap` posicionam sozinhos (o trailing fica a 12px da borda, como no Select). O
27
+ // `px-3` + `gap` posicionam sozinhos (o trailing fica a 0.75rem da borda, como no Select). O
28
28
  // `className` estiliza o CAMPO (largura, raio, fundo, fonte), como no Select — e a fonte/
29
29
  // cor cascateiam pro <input> (um `text-xs`/`font-mono` aqui alcança o texto digitado).
30
30
  return (
@@ -32,7 +32,7 @@ function Input({ className, type, icon, trailing, ...props }: InputProps) {
32
32
  data-slot="input-wrapper"
33
33
  className={cn(
34
34
  "flex h-9 w-full min-w-0 items-center gap-1.5 rounded-md border border-input bg-transparent px-3 text-base transition-[color,box-shadow] outline-none md:text-sm dark:bg-input/30",
35
- "has-[input:focus-visible]:border-ring has-[input:focus-visible]:ring-[3px] has-[input:focus-visible]:ring-ring/50",
35
+ "has-[input:focus-visible]:border-ring has-[input:focus-visible]:ring-[0.1875rem] has-[input:focus-visible]:ring-ring/50",
36
36
  "has-[input[aria-invalid=true]]:border-destructive has-[input[aria-invalid=true]]:ring-destructive/20 dark:has-[input[aria-invalid=true]]:ring-destructive/40",
37
37
  "has-[input:disabled]:pointer-events-none has-[input:disabled]:opacity-50",
38
38
  className,
@@ -59,8 +59,7 @@ function Input({ className, type, icon, trailing, ...props }: InputProps) {
59
59
  {trailing !== undefined && (
60
60
  // Interativo (AÇÃO, não decoração): sem pointer-events-none. O tom muted (ação
61
61
  // secundária) mora no slot — o botão ghost herda, sem sprinklar no call site. Se o
62
- // trailing é BOTÃO, puxa ~6px pra o inset direito casar com o vertical (botão
63
- // quadrado no canto, não flutuando a 12px), como o InputGroup.
62
+ // trailing é botão: aproxima 0.375rem para casar o inset horizontal com o vertical.
64
63
  <span data-slot="input-trailing" className="flex shrink-0 items-center text-muted-foreground has-[button]:-mr-1.5">
65
64
  {trailing}
66
65
  </span>
@@ -31,7 +31,7 @@ function ItemSeparator({
31
31
  }
32
32
 
33
33
  const itemVariants = cva(
34
- "group/item flex flex-wrap items-center rounded-md border border-transparent text-sm transition-colors duration-100 outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-accent/50",
34
+ "group/item flex flex-wrap items-center rounded-md border border-transparent text-sm transition-colors duration-100 outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-accent/50",
35
35
  {
36
36
  variants: {
37
37
  variant: {
@@ -25,7 +25,7 @@ function RadioGroupItem({
25
25
  <RadioGroupPrimitive.Item
26
26
  data-slot="radio-group-item"
27
27
  className={cn(
28
- "aspect-square size-4 shrink-0 rounded-full border border-input text-primary transition-[color,box-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 dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
28
+ "aspect-square size-4 shrink-0 rounded-full border border-input text-primary transition-[color,box-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 dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
29
29
  className
30
30
  )}
31
31
  {...props}
@@ -16,7 +16,7 @@ function ScrollArea({
16
16
  >
17
17
  <ScrollAreaPrimitive.Viewport
18
18
  data-slot="scroll-area-viewport"
19
- className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
19
+ className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 focus-visible:outline-1"
20
20
  >
21
21
  {children}
22
22
  </ScrollAreaPrimitive.Viewport>
@@ -154,7 +154,7 @@ function NativeSelect({
154
154
  onChange={(e) => onChange(e.target.value)}
155
155
  className={cn(
156
156
  "h-9 w-full min-w-0 appearance-none rounded-md border border-input bg-transparent px-3 py-2 pr-9 text-sm transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground disabled:pointer-events-none disabled:cursor-not-allowed data-[size=sm]:h-8 data-[size=sm]:py-1 data-[shape=pill]:rounded-full dark:bg-input/30 dark:hover:bg-input/50",
157
- 'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
157
+ 'focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50',
158
158
  'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
159
159
  icon ? 'pl-9' : '',
160
160
  className,
@@ -468,7 +468,7 @@ function CustomSelect(props: Exclude<SelectProps, SelectNativeProps>): React.Rea
468
468
  // Ação no fim do campo é SECUNDÁRIA: o tom muted mora no slot (o botão
469
469
  // ghost herda), pra não sprinklar `text-muted-foreground` em cada call
470
470
  // site. O hover acende via o próprio `hover:text-accent-foreground` do ghost.
471
- // Botão trailing na BORDA (buscável, sem chevron): puxa ~6px pra o inset
471
+ // Botão trailing na borda: aproxima 0.375rem para equilibrar o inset.
472
472
  // direito casar com o vertical (botão quadrado no canto), como o InputGroup.
473
473
  // Com chevron, NÃO puxa — senão cola no chevron (o `gap-1.5` do control).
474
474
  className={cn('flex shrink-0 items-center text-muted-foreground', canSearch && 'has-[button]:-mr-1.5')}
@@ -15,7 +15,7 @@ function Switch({
15
15
  data-slot="switch"
16
16
  data-size={size}
17
17
  className={cn(
18
- "peer group/switch inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
18
+ "peer group/switch inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
19
19
  className
20
20
  )}
21
21
  {...props}
@@ -68,7 +68,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
68
68
  <th
69
69
  data-slot="table-head"
70
70
  className={cn(
71
- "h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
71
+ "h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[0.125rem]",
72
72
  className
73
73
  )}
74
74
  {...props}
@@ -81,7 +81,7 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
81
81
  <td
82
82
  data-slot="table-cell"
83
83
  className={cn(
84
- "p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
84
+ "p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[0.125rem]",
85
85
  className
86
86
  )}
87
87
  {...props}
@@ -5,7 +5,7 @@ import { Tabs as TabsPrimitive } from "radix-ui"
5
5
  import { cn } from '../../lib/cn.ts'
6
6
 
7
7
  // Divergência da casa (ejetado): o Tabs ganhou `size` — Button e Select têm `sm`, o Tabs
8
- // não tinha, e uma fileira densa (o header do Maestro) ficava com o segmento 4px mais alto
8
+ // não tinha, e uma fileira densa ficava com o segmento 0.25rem mais alto
9
9
  // que os irmãos. O tamanho flui pra TabsList via contexto (a altura é dela).
10
10
  type TabsSize = "default" | "sm"
11
11
  const TabsSizeContext = React.createContext<TabsSize>("default")
@@ -37,7 +37,7 @@ const tabsListVariants = cva(
37
37
  // A altura horizontal entra como classe simples no componente (abaixo), para que um
38
38
  // `className="h-full"` do consumidor consiga substituí-la via tailwind-merge. Seletores
39
39
  // condicionais por data-size têm especificidade maior e tornavam a altura inextensível.
40
- "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none data-[variant=line]:p-0",
40
+ "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[0.1875rem] text-muted-foreground group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none data-[variant=line]:p-0",
41
41
  {
42
42
  variants: {
43
43
  variant: {
@@ -78,7 +78,7 @@ function TabsTrigger({
78
78
  <TabsPrimitive.Trigger
79
79
  data-slot="tabs-trigger"
80
80
  className={cn(
81
- "relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
81
+ "relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
82
82
  "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
83
83
  "data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground",
84
84
  "after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-1px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
7
7
  <textarea
8
8
  data-slot="textarea"
9
9
  className={cn(
10
- "flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base transition-[color,box-shadow] outline-none placeholder:text-muted-foreground 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 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
10
+ "flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base transition-[color,box-shadow] outline-none placeholder:text-muted-foreground 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 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
11
11
  className
12
12
  )}
13
13
  {...props}
@@ -5,7 +5,7 @@ import { Toggle as TogglePrimitive } from "radix-ui"
5
5
  import { cn } from '../../lib/cn.ts'
6
6
 
7
7
  const toggleVariants = cva(
8
- "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
8
+ "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
9
9
  {
10
10
  variants: {
11
11
  variant: {
@@ -46,7 +46,7 @@ function TooltipContent({
46
46
  {...props}
47
47
  >
48
48
  {children}
49
- <TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
49
+ <TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[0.125rem] bg-foreground fill-foreground" />
50
50
  </TooltipPrimitive.Content>
51
51
  </TooltipPrimitive.Portal>
52
52
  )
@@ -86,7 +86,7 @@ export function DocBrowser({
86
86
  items: g.pages.map((p) => ({
87
87
  id: p.slug,
88
88
  label: p.title,
89
- badge: p.badge === undefined ? undefined : <span className="rounded border border-border/60 px-1 font-mono text-[10px] leading-tight text-muted-foreground/60">{p.badge}</span>,
89
+ badge: p.badge === undefined ? undefined : <span className="rounded border border-border/60 px-1 font-mono text-[0.625rem] leading-tight text-muted-foreground/60">{p.badge}</span>,
90
90
  })),
91
91
  })),
92
92
  }))
@@ -14,7 +14,7 @@ quando destrói de verdade.
14
14
 
15
15
  ## Tamanhos
16
16
 
17
- size icon exige aria-label (não tem texto pra ler). Os só-ícone vêm em três: `icon` (36px), `icon-sm` (32px, pra fileira densa como o header) e `icon-xs` (24px, pra ação DENTRO de um campo o `trailing` do Input/Select).
17
+ O tamanho icon exige `aria-label`, porque não texto visível. Os botões só-ícone vêm em três tamanhos: `icon` (2.25rem), `icon-sm` (2rem, para uma fileira densa como o cabeçalho) e `icon-xs` (1.5rem, para uma ação dentro de um campo, como o `trailing` de Input ou Select).
18
18
 
19
19
  ```tsx preview
20
20
  <Button size="sm">Pequeno</Button>
@@ -54,7 +54,7 @@ buttonVariants serve pro caso sem filho único.
54
54
  | Prop | Tipo | Default | Descrição |
55
55
  |---|---|---|---|
56
56
  | `variant` | `'default' \| 'secondary' \| 'outline' \| 'ghost' \| 'destructive' \| 'link'` | `'default'` | A intenção da ação — define cor e peso visual. |
57
- | `size` | `'default' \| 'sm' \| 'lg' \| 'icon' \| 'icon-sm' \| 'icon-xs'` | `'default'` | O tamanho. Os icon* são quadrados (36/32/24px), pra botão só de ícone (com aria-label). |
57
+ | `size` | `'default' \| 'sm' \| 'lg' \| 'icon' \| 'icon-sm' \| 'icon-xs'` | `'default'` | O tamanho. Os icon* são quadrados (2.25/2/1.5rem) para botões só de ícone, com `aria-label`. |
58
58
  | `asChild` | `boolean` | `false` | Renderiza como o filho (Radix Slot) em vez de `<button>` — pra âncoras e afins. |
59
59
  | `busy` | `boolean` | `false` | Ação em andamento (depois do clique): mostra Spinner + desabilita. Não é "carregando" de conteúdo (que é Spinner/Skeleton num nível de página). |
60
60
  | `icon` | `React.ElementType` | | Ícone à esquerda (ex.: icon={Plus}). No busy é trocado pelo Spinner — não soma. |
@@ -28,6 +28,11 @@ divergência declarada), pra valer pra casa toda.
28
28
  O Opus deriva de `rounded-xs` a `rounded-2xl` dessa base. Assim, a marca muda a presença dos
29
29
  cantos sem substituir as utilities escolhidas pelos componentes nem criar tokens por papel.
30
30
 
31
+ O tema não define `font-size` em `html`: a fonte raiz pertence ao navegador e à aplicação.
32
+ Quando o produto precisa de outra densidade, declare essa escolha no CSS do app. Por exemplo,
33
+ `html { font-size: 93.75%; }` conserva a proporção que uma raiz de 15 teria sobre a base usual
34
+ de 16, sem transformar esse valor em uma regra da biblioteca.
35
+
31
36
  ## 2 · className em tudo
32
37
 
33
38
  > Todo componente termina em `cn(base, className)` com tailwind-merge: o utilitário do consumidor
@@ -1,6 +1,10 @@
1
1
  ## Esqueleto de página
2
2
 
3
- O que toda página do back-office repete, num pattern só: `<main>` + container de largura cheia (o caso comum; `className="max-w-5xl"` estreita e centra) + header com título, descrição e a ação à direita. O conteúdo é children, sem nada imposto. Presentacional de propósito — uma página agrega N fontes; o que é action-driven (listas, forms) mora dentro.
3
+ Use `Page` para manter título, contexto, ações e conteúdo no mesmo ritmo visual nas telas do
4
+ back-office. As ações ficam à direita e acompanham a base do conjunto formado pelo título e pela
5
+ descrição. O componente ocupa a largura disponível; passe `className="max-w-5xl"` quando a tela
6
+ pedir conteúdo mais estreito e centralizado. A área abaixo do cabeçalho permanece livre para
7
+ tabelas, cards ou outras composições.
4
8
 
5
9
  ```tsx preview col
6
10
  render(
@@ -8,6 +12,7 @@ render(
8
12
  <Page
9
13
  title="Workspaces"
10
14
  count={3}
15
+ description="Ambientes compartilhados pela equipe."
11
16
  actions={
12
17
  <Button>
13
18
  <Plus /> Novo workspace
@@ -29,6 +34,6 @@ render(
29
34
  | `title` | `string` | | O h1 da página. |
30
35
  | `count` | `number` | | Total de itens ao lado do título (mono, esmaecido). |
31
36
  | `description` | `ReactNode` | | Linha de contexto sob o título (ex.: "N no total · X ativos"). |
32
- | `actions` | `ReactNode` | | Ação à direita do header em geral o botão de criar. |
37
+ | `actions` | `ReactNode` | | Ações à direita do cabeçalho, alinhadas à base do título e da descrição. |
33
38
  | `className` | `string` | largura cheia | Classes do container (`max-w-5xl` estreita e centra). |
34
39
  | `children` | `ReactNode` | | O conteúdo — espaçamento e diagramação são seus. |
@@ -69,7 +69,7 @@ orientation=vertical no Tabs: a lista vira coluna e o traço da variante line mi
69
69
 
70
70
  ## Densidade (size)
71
71
 
72
- `size="sm"` no `Tabs` baixa a lista de h-9 (36px) pra h-8 (32px) o par do `sm` que Button e Select têm, pra uma fileira densa (toolbar, header) onde o segmento não pode ficar mais alto que os irmãos.
72
+ `size="sm"` no `Tabs` reduz a lista de `h-9` (2.25rem) para `h-8` (2rem). É o par do `sm` de Button e Select para uma fileira densa, como uma toolbar ou um cabeçalho, em que o segmento não deve ficar mais alto que os elementos vizinhos.
73
73
 
74
74
  ```tsx preview col
75
75
  <Tabs defaultValue="preview" size="sm">
@@ -101,11 +101,11 @@ render(
101
101
  )
102
102
  ```
103
103
 
104
- ## Densidade
104
+ ## Escala relativa
105
105
 
106
- > A base é 15px: o `html` da casa fixa o rem, e TODA a escala (texto, spacing, radius) deriva
107
- > dele a UI densifica ~6% vs o default do browser (16px). Nada de font-size solto em px nos
108
- > componentes; quem quiser outra densidade sobrescreve o `html` no app.
106
+ > O Opus não define a fonte raiz. Cada `rem` acompanha o documento, portanto o navegador e a
107
+ > aplicação continuam responsáveis pela densidade. Uma aplicação pode ajustar `font-size` em
108
+ > `html`; texto, espaçamento, radius, foco e elevação acompanham a mesma decisão.
109
109
 
110
110
  ## Radius
111
111
 
@@ -118,12 +118,12 @@ render(
118
118
  render(
119
119
  <div className="flex flex-wrap gap-4">
120
120
  {[
121
- ['rounded-xs', 'radius − 6px'],
122
- ['rounded-sm', 'radius − 4px'],
123
- ['rounded-md', 'radius − 2px'],
121
+ ['rounded-xs', 'radius − 0.375rem'],
122
+ ['rounded-sm', 'radius − 0.25rem'],
123
+ ['rounded-md', 'radius − 0.125rem'],
124
124
  ['rounded-lg', 'radius (0.75rem)'],
125
- ['rounded-xl', 'radius + 4px'],
126
- ['rounded-2xl', 'radius + 8px'],
125
+ ['rounded-xl', 'radius + 0.25rem'],
126
+ ['rounded-2xl', 'radius + 0.5rem'],
127
127
  ].map(([cls, calc]) => (
128
128
  <div key={cls} className="flex items-center gap-3">
129
129
  <div className={'h-12 w-12 border-2 border-foreground/30 bg-muted ' + cls} />
@@ -30,7 +30,7 @@ function InlineMd({ text }: { text: string }): React.ReactElement {
30
30
  {parts.map((part, i) =>
31
31
  i % 2 === 1 ? (
32
32
  // eslint-disable-next-line react/no-array-index-key
33
- <code key={i} className="rounded-[calc(var(--radius)-4px)] bg-muted px-1.5 py-0.5 font-mono text-[0.85em]">
33
+ <code key={i} className="rounded-[calc(var(--radius)-0.25rem)] bg-muted px-1.5 py-0.5 font-mono text-[0.85em]">
34
34
  {part}
35
35
  </code>
36
36
  ) : (
package/src/ui/meta.ts CHANGED
@@ -33,7 +33,7 @@ export const componentMeta = {
33
33
  name: 'button',
34
34
  ancestry: 'shadcn',
35
35
  whenToUse:
36
- 'Ação clicável. `variant` define intenção (default/secondary/outline/ghost/destructive/link), `size` define altura (default/sm/lg · icon/icon-sm/icon-xs pros só-ícone, 36/32/24px) e `shape="pill"` troca somente a geometria, sem alterar cor ou hierarquia. `busy` mostra spinner e desabilita. `asChild` renderiza como outro elemento.',
36
+ 'Ação clicável. `variant` define intenção (default/secondary/outline/ghost/destructive/link), `size` define altura (default/sm/lg · icon/icon-sm/icon-xs para os casos só-ícone, 2.25/2/1.5rem) e `shape="pill"` troca somente a geometria, sem alterar cor ou hierarquia. `busy` mostra spinner e desabilita. `asChild` renderiza como outro elemento.',
37
37
  },
38
38
  'card': {
39
39
  name: 'card',
@@ -116,7 +116,7 @@ export const componentMeta = {
116
116
  name: 'select',
117
117
  ancestry: 'opus',
118
118
  whenToUse:
119
- 'O seletor ÚNICO da casa: toda escolha em lista. Data-driven (`options` = { value, label, hint?, content?, group? }), nunca JSX de item. Modos por prop: `searchable`, `multiple`, `onSearch` e `native`. `variant="default"` é campo de formulário; `outline` abraça o conteúdo com borda; `ghost` abraça o conteúdo sem borda. `size` segue a régua inline (default 36px/sm 32px) e `shape="pill"` troca somente a geometria. Também aceita `icon`, `trailing`, `triggerLabel` e `clearable`.',
119
+ 'Seletor para escolhas em lista. Recebe `options` no formato { value, label, hint?, content?, group? }, sem JSX por item. Os modos são definidos por `searchable`, `multiple`, `onSearch` e `native`. `variant="default"` funciona como campo de formulário; `outline` envolve o conteúdo com borda; `ghost` o envolve sem borda. `size` segue a régua inline (default 2.25rem/sm 2rem), e `shape="pill"` altera somente a geometria. Também aceita `icon`, `trailing`, `triggerLabel` e `clearable`.',
120
120
  },
121
121
  'separator': {
122
122
  name: 'separator',
package/src/ui/theme.css CHANGED
@@ -49,7 +49,7 @@
49
49
  --destructive-foreground: hsl(0 0% 98%);
50
50
  --border: hsl(0 0% 92.5%);
51
51
  --input: hsl(0 0% 92.5%);
52
- /* Ring de foco LEVE (pedido da casa): aro sutil, não um halo. Com ring-[3px] + /50,
52
+ /* Ring de foco LEVE (pedido da casa): aro sutil, não um halo. Com ring-[0.1875rem] + /50,
53
53
  ~86% encosta na borda (89.8%) — um sussurro de foco. */
54
54
  --ring: hsl(0 0% 86%);
55
55
  --radius: 0.75rem;
@@ -134,23 +134,23 @@
134
134
  --color-input: var(--input);
135
135
  --color-ring: var(--ring);
136
136
 
137
- --radius-xs: calc(var(--radius) - 6px);
138
- --radius-sm: calc(var(--radius) - 4px);
139
- --radius-md: calc(var(--radius) - 2px);
137
+ --radius-xs: calc(var(--radius) - 0.375rem);
138
+ --radius-sm: calc(var(--radius) - 0.25rem);
139
+ --radius-md: calc(var(--radius) - 0.125rem);
140
140
  --radius-lg: var(--radius);
141
- --radius-xl: calc(var(--radius) + 4px);
142
- --radius-2xl: calc(var(--radius) + 8px);
141
+ --radius-xl: calc(var(--radius) + 0.25rem);
142
+ --radius-2xl: calc(var(--radius) + 0.5rem);
143
143
 
144
144
  /* Escala Tailwind, com receita direta para o compilador preservar modifiers `/opacity`.
145
145
  `light-dark()` mantém o peso adequado em cada tema sem uma variável intermediária. */
146
- --shadow-2xs: 0 1px 2px -1px light-dark(rgb(15 15 15 / 0.03), rgb(0 0 0 / 0.12));
147
- --shadow-xs: 0 1px 2px -1px light-dark(rgb(15 15 15 / 0.025), rgb(0 0 0 / 0.12)), 0 2px 4px -2px light-dark(rgb(15 15 15 / 0.035), rgb(0 0 0 / 0.18));
148
- --shadow-sm: 0 2px 4px -2px light-dark(rgb(15 15 15 / 0.025), rgb(0 0 0 / 0.16)), 0 4px 8px -4px light-dark(rgb(15 15 15 / 0.04), rgb(0 0 0 / 0.28));
149
- --shadow: 0 2px 6px -2px light-dark(rgb(15 15 15 / 0.03), rgb(0 0 0 / 0.16)), 0 6px 14px -6px light-dark(rgb(15 15 15 / 0.05), rgb(0 0 0 / 0.29));
150
- --shadow-md: 0 4px 8px -4px light-dark(rgb(15 15 15 / 0.03), rgb(0 0 0 / 0.16)), 0 10px 28px -8px light-dark(rgb(15 15 15 / 0.065), rgb(0 0 0 / 0.3));
151
- --shadow-lg: 0 6px 16px -6px light-dark(rgb(15 15 15 / 0.03), rgb(0 0 0 / 0.18)), 0 18px 48px -12px light-dark(rgb(15 15 15 / 0.07), rgb(0 0 0 / 0.3));
152
- --shadow-xl: 0 8px 24px -10px light-dark(rgb(15 15 15 / 0.04), rgb(0 0 0 / 0.2)), 0 24px 64px -18px light-dark(rgb(15 15 15 / 0.08), rgb(0 0 0 / 0.34));
153
- --shadow-2xl: 0 12px 32px -14px light-dark(rgb(15 15 15 / 0.05), rgb(0 0 0 / 0.22)), 0 32px 88px -24px light-dark(rgb(15 15 15 / 0.1), rgb(0 0 0 / 0.38));
146
+ --shadow-2xs: 0 0.0625rem 0.125rem -0.0625rem light-dark(rgb(15 15 15 / 0.03), rgb(0 0 0 / 0.12));
147
+ --shadow-xs: 0 0.0625rem 0.125rem -0.0625rem light-dark(rgb(15 15 15 / 0.025), rgb(0 0 0 / 0.12)), 0 0.125rem 0.25rem -0.125rem light-dark(rgb(15 15 15 / 0.035), rgb(0 0 0 / 0.18));
148
+ --shadow-sm: 0 0.125rem 0.25rem -0.125rem light-dark(rgb(15 15 15 / 0.025), rgb(0 0 0 / 0.16)), 0 0.25rem 0.5rem -0.25rem light-dark(rgb(15 15 15 / 0.04), rgb(0 0 0 / 0.28));
149
+ --shadow: 0 0.125rem 0.375rem -0.125rem light-dark(rgb(15 15 15 / 0.03), rgb(0 0 0 / 0.16)), 0 0.375rem 0.875rem -0.375rem light-dark(rgb(15 15 15 / 0.05), rgb(0 0 0 / 0.29));
150
+ --shadow-md: 0 0.25rem 0.5rem -0.25rem light-dark(rgb(15 15 15 / 0.03), rgb(0 0 0 / 0.16)), 0 0.625rem 1.75rem -0.5rem light-dark(rgb(15 15 15 / 0.065), rgb(0 0 0 / 0.3));
151
+ --shadow-lg: 0 0.375rem 1rem -0.375rem light-dark(rgb(15 15 15 / 0.03), rgb(0 0 0 / 0.18)), 0 1.125rem 3rem -0.75rem light-dark(rgb(15 15 15 / 0.07), rgb(0 0 0 / 0.3));
152
+ --shadow-xl: 0 0.5rem 1.5rem -0.625rem light-dark(rgb(15 15 15 / 0.04), rgb(0 0 0 / 0.2)), 0 1.5rem 4rem -1.125rem light-dark(rgb(15 15 15 / 0.08), rgb(0 0 0 / 0.34));
153
+ --shadow-2xl: 0 0.75rem 2rem -0.875rem light-dark(rgb(15 15 15 / 0.05), rgb(0 0 0 / 0.22)), 0 2rem 5.5rem -1.5rem light-dark(rgb(15 15 15 / 0.1), rgb(0 0 0 / 0.38));
154
154
  }
155
155
 
156
156
  /* Base canônica do consumidor — vem DAQUI, não do entry de cada app. Tailwind v4
@@ -160,15 +160,6 @@
160
160
  * {
161
161
  border-color: var(--border);
162
162
  }
163
- /* Base 15px: o rem da casa — TODA a escala rem (texto, spacing, radius) deriva daqui,
164
- então a UI densifica ~6% vs o default do browser (16px). Histórico (conferido no git):
165
- até a 2.31.4 o pacote NÃO fixava rem (valia o 16px do browser); a 2.31.5 introduziu
166
- 14px; a 2.35.0 assenta em 15px — valor INÉDITO, não é revert. O 15px veio da prática:
167
- a Empresa X escolheu 15px por conta própria em DOIS apps, e a exceção reincidente
168
- virou default. Quem quiser mais denso sobrescreve o `html` no próprio entry. */
169
- html {
170
- font-size: 15px;
171
- }
172
163
  /* Canvas da casa: quase-branco vem do BODY — o shell é transparente e as
173
164
  superfícies (cards, miolo flush) pintam bg-background por cima. Tinte de 25%
174
165
  de muted: separa a superfície do canvas sem virar cinza; segue o tema. */
@@ -241,7 +232,7 @@
241
232
  * regra custom vaza pra dentro do not-prose (o CodeBlock ganhava uma SEGUNDA
242
233
  * borda no <pre>, colada na do contêiner — a "borda duplicada"). */
243
234
  .prose :where(code):not(:where(pre code, [class~='not-prose'], [class~='not-prose'] *)) {
244
- border-radius: calc(var(--radius) - 4px);
235
+ border-radius: calc(var(--radius) - 0.25rem);
245
236
  background: var(--muted);
246
237
  padding: 0.125rem 0.375rem;
247
238
  font-weight: 400;