@softize/opus 12.2.1 → 12.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/bin/cli.mjs +2 -2
  3. package/bin/lib/copy.mjs +4 -0
  4. package/docs/elevation-scale.md +2 -0
  5. package/docs/radius-scale.md +1 -0
  6. package/docs/relative-unit-scale.md +55 -0
  7. package/docs/releasing.md +2 -2
  8. package/package.json +3 -3
  9. package/registry/skills/build-opus-ui/SKILL.md +8 -4
  10. package/registry/skills/build-opus-ui/references/ui-patterns.md +2 -0
  11. package/registry/templates/app/package.json +1 -1
  12. package/src/ui/components/patterns/dock.tsx +182 -0
  13. package/src/ui/components/patterns/shell-nav.tsx +1 -0
  14. package/src/ui/components/patterns/sidebar.tsx +56 -13
  15. package/src/ui/components/primitives/accordion.tsx +1 -1
  16. package/src/ui/components/primitives/button.tsx +4 -4
  17. package/src/ui/components/primitives/calendar.tsx +2 -2
  18. package/src/ui/components/primitives/chat.tsx +1 -1
  19. package/src/ui/components/primitives/checkbox.tsx +1 -1
  20. package/src/ui/components/primitives/command.tsx +1 -1
  21. package/src/ui/components/primitives/dialog.tsx +1 -1
  22. package/src/ui/components/primitives/dot.tsx +39 -0
  23. package/src/ui/components/primitives/input-group.tsx +4 -4
  24. package/src/ui/components/primitives/input-otp.tsx +1 -1
  25. package/src/ui/components/primitives/input.tsx +4 -5
  26. package/src/ui/components/primitives/item.tsx +1 -1
  27. package/src/ui/components/primitives/radio-group.tsx +1 -1
  28. package/src/ui/components/primitives/scroll-area.tsx +1 -1
  29. package/src/ui/components/primitives/select.tsx +7 -4
  30. package/src/ui/components/primitives/switch.tsx +1 -1
  31. package/src/ui/components/primitives/table.tsx +14 -4
  32. package/src/ui/components/primitives/tabs.tsx +3 -3
  33. package/src/ui/components/primitives/textarea.tsx +1 -1
  34. package/src/ui/components/primitives/toggle.tsx +1 -1
  35. package/src/ui/components/primitives/tooltip.tsx +1 -1
  36. package/src/ui/components/primitives/truncate.tsx +9 -20
  37. package/src/ui/docs/DocBrowser.tsx +1 -1
  38. package/src/ui/docs/content/button.md +2 -2
  39. package/src/ui/docs/content/customization.md +5 -0
  40. package/src/ui/docs/content/dock.md +69 -0
  41. package/src/ui/docs/content/dot.md +17 -0
  42. package/src/ui/docs/content/select.md +1 -0
  43. package/src/ui/docs/content/sidebar.md +21 -0
  44. package/src/ui/docs/content/table.md +25 -24
  45. package/src/ui/docs/content/tabs.md +1 -1
  46. package/src/ui/docs/content/tokens.md +9 -9
  47. package/src/ui/docs/content/truncate.md +26 -0
  48. package/src/ui/docs/doc.tsx +1 -1
  49. package/src/ui/docs/registry.tsx +4 -0
  50. package/src/ui/lib/overflow.ts +34 -0
  51. package/src/ui/meta.ts +16 -4
  52. package/src/ui/react.tsx +12 -1
  53. package/src/ui/theme.css +27 -24
@@ -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',
@@ -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
+ }
@@ -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>
@@ -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"
@@ -154,10 +158,9 @@ function NativeSelect({
154
158
  onChange={(e) => onChange(e.target.value)}
155
159
  className={cn(
156
160
  "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',
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 && (
@@ -468,7 +471,7 @@ function CustomSelect(props: Exclude<SelectProps, SelectNativeProps>): React.Rea
468
471
  // Ação no fim do campo é SECUNDÁRIA: o tom muted mora no slot (o botão
469
472
  // ghost herda), pra não sprinklar `text-muted-foreground` em cada call
470
473
  // 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
474
+ // Botão trailing na borda: aproxima 0.375rem para equilibrar o inset.
472
475
  // direito casar com o vertical (botão quadrado no canto), como o InputGroup.
473
476
  // Com chevron, NÃO puxa — senão cola no chevron (o `gap-1.5` do control).
474
477
  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}
@@ -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"
@@ -68,7 +78,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
68
78
  <th
69
79
  data-slot="table-head"
70
80
  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]",
81
+ "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
82
  className
73
83
  )}
74
84
  {...props}
@@ -81,7 +91,7 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
81
91
  <td
82
92
  data-slot="table-cell"
83
93
  className={cn(
84
- "p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
94
+ "p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[0.125rem]",
85
95
  className
86
96
  )}
87
97
  {...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
  )
@@ -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
  )
@@ -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
@@ -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)
@@ -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">