@softize/opus 13.1.0 → 15.0.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 (72) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/bin/cli.mjs +2 -0
  3. package/bin/lib/check.mjs +33 -5
  4. package/bin/lib/cli-shared.mjs +30 -1
  5. package/bin/lib/copy.mjs +279 -6
  6. package/bin/lib/db.mjs +2 -0
  7. package/docs/adr/0004-page-content-state-is-composed.md +39 -5
  8. package/docs/adr/0005-structural-surfaces-share-an-explicit-anatomy.md +9 -3
  9. package/docs/adr/0009-page-title-does-not-carry-a-counter.md +57 -0
  10. package/docs/adr/0010-page-header-owns-page-chrome.md +73 -0
  11. package/docs/code-style.md +4 -1
  12. package/docs/data-layer.md +9 -0
  13. package/package.json +1 -1
  14. package/registry/instructions/opus.md +3 -3
  15. package/registry/skills/build-opus-ui/SKILL.md +3 -2
  16. package/registry/skills/build-opus-ui/references/ui-patterns.md +17 -6
  17. package/registry/templates/app/src/App.tsx +11 -6
  18. package/src/core/types.ts +3 -4
  19. package/src/ui/components/patterns/action-list-dialog.tsx +10 -3
  20. package/src/ui/components/patterns/confirm.tsx +2 -31
  21. package/src/ui/components/patterns/content-header.tsx +42 -141
  22. package/src/ui/components/patterns/data-state.tsx +42 -68
  23. package/src/ui/components/patterns/form.tsx +15 -15
  24. package/src/ui/components/patterns/list.tsx +55 -34
  25. package/src/ui/components/patterns/page-state.tsx +81 -51
  26. package/src/ui/components/patterns/page.tsx +228 -97
  27. package/src/ui/components/patterns/state-surface.tsx +262 -0
  28. package/src/ui/components/patterns/surface-header.tsx +204 -0
  29. package/src/ui/components/patterns/trigger.tsx +9 -10
  30. package/src/ui/components/patterns/view.tsx +14 -16
  31. package/src/ui/components/primitives/alert.tsx +1 -33
  32. package/src/ui/components/primitives/avatar.tsx +15 -5
  33. package/src/ui/components/primitives/badge.tsx +2 -43
  34. package/src/ui/components/primitives/button-group.tsx +34 -8
  35. package/src/ui/components/primitives/button.tsx +31 -35
  36. package/src/ui/components/primitives/control.ts +69 -0
  37. package/src/ui/components/primitives/dot.tsx +1 -30
  38. package/src/ui/components/primitives/input-group.tsx +11 -8
  39. package/src/ui/components/primitives/item.tsx +3 -1
  40. package/src/ui/components/primitives/menu.tsx +1 -7
  41. package/src/ui/components/primitives/pagination.tsx +16 -8
  42. package/src/ui/components/primitives/select.tsx +2 -2
  43. package/src/ui/components/primitives/spinner.tsx +13 -16
  44. package/src/ui/components/primitives/switch.tsx +4 -1
  45. package/src/ui/components/primitives/tabs.tsx +5 -3
  46. package/src/ui/components/primitives/toggle.tsx +9 -4
  47. package/src/ui/docs/content/action-form.md +26 -0
  48. package/src/ui/docs/content/action-list-dialog.md +2 -2
  49. package/src/ui/docs/content/action-list.md +35 -2
  50. package/src/ui/docs/content/action-trigger.md +5 -4
  51. package/src/ui/docs/content/action-view.md +3 -2
  52. package/src/ui/docs/content/alert.md +16 -4
  53. package/src/ui/docs/content/avatar.md +7 -3
  54. package/src/ui/docs/content/button.md +48 -17
  55. package/src/ui/docs/content/communication.md +36 -0
  56. package/src/ui/docs/content/content.md +5 -4
  57. package/src/ui/docs/content/data-state.md +17 -13
  58. package/src/ui/docs/content/dialog.md +1 -4
  59. package/src/ui/docs/content/input.md +1 -1
  60. package/src/ui/docs/content/item.md +1 -1
  61. package/src/ui/docs/content/page.md +160 -37
  62. package/src/ui/docs/content/pagination.md +11 -9
  63. package/src/ui/docs/content/semantic-context.md +3 -2
  64. package/src/ui/docs/content/sidebar.md +2 -42
  65. package/src/ui/docs/content/spinner.md +9 -6
  66. package/src/ui/docs/content/switch.md +1 -1
  67. package/src/ui/docs/content/tabs.md +1 -1
  68. package/src/ui/docs/content/toggle.md +1 -1
  69. package/src/ui/drivers/react.tsx +1 -6
  70. package/src/ui/meta.ts +8 -8
  71. package/src/ui/react.tsx +16 -18
  72. package/src/ui/components/patterns/shell-nav.tsx +0 -154
@@ -3,38 +3,44 @@ import { Slot } from '@radix-ui/react-slot'
3
3
 
4
4
  import type { UiContext } from '../../../core/ui-context.ts'
5
5
  import { cn } from '../../lib/cn.ts'
6
- import { focusRing, type ControlShape } from './control.ts'
6
+ import {
7
+ controlBase,
8
+ controlGlyphClass,
9
+ controlHeight,
10
+ controlSquare,
11
+ focusRing,
12
+ type ControlScale,
13
+ type ControlShape,
14
+ } from './control.ts'
7
15
  import { Spinner } from './spinner.tsx'
8
16
 
9
17
  export type ButtonContext = Extract<UiContext, 'neutral' | 'primary' | 'danger'>
10
18
  export type ButtonVariant = 'solid' | 'subtle' | 'outline' | 'ghost' | 'link'
11
- /**
12
- * @deprecated Aliases de migração: `default` → `context="primary"`, `secondary` →
13
- * `context="neutral"`, `destructive` → `context="danger"` (todos com `variant="solid"`).
14
- * Declare `context` + `variant`.
15
- */
16
- export type LegacyButtonVariant = 'default' | 'secondary' | 'destructive'
17
- export type ButtonSize = 'default' | 'sm' | 'lg' | 'icon' | 'icon-sm' | 'icon-xs'
19
+ /** A escala completa de control.ts: alturas de linha e quadrados só-ícone. */
20
+ export type ButtonSize = ControlScale
18
21
 
19
22
  export interface ButtonStyleOptions {
20
23
  /** Sem `context`, `solid`/`subtle` assumem `primary`; `outline`/`ghost`/`link` assumem `neutral`. */
21
24
  context?: ButtonContext | null
22
- /** Tratamento visual. Os valores de `LegacyButtonVariant` são aliases em retirada. */
23
- variant?: ButtonVariant | LegacyButtonVariant | null
25
+ /** Tratamento visual. */
26
+ variant?: ButtonVariant | null
24
27
  size?: ButtonSize | null
25
28
  shape?: ControlShape | null
26
29
  }
27
30
 
28
31
  const baseClasses =
29
- 'inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all disabled:pointer-events-none disabled:opacity-50 active:brightness-90 aria-invalid:border-context-danger aria-invalid:ring-context-danger/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-4'
32
+ 'inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all disabled:pointer-events-none disabled:opacity-50 active:brightness-90 aria-invalid:border-context-danger aria-invalid:ring-context-danger/20 [&_svg]:pointer-events-none [&_svg]:shrink-0'
30
33
 
34
+ /** Padding e ritmo por tamanho; altura e quadrado vêm da escala compartilhada. */
31
35
  const sizeClasses: Record<ButtonSize, string> = {
32
- default: 'h-9 px-4 py-2 has-[>svg]:px-3',
33
- sm: 'h-8 gap-1.5 px-3 has-[>svg]:px-2.5',
34
- lg: 'h-10 px-6 has-[>svg]:px-4',
35
- icon: 'size-9',
36
- 'icon-sm': 'size-8',
37
- 'icon-xs': 'size-6',
36
+ xs: `${controlHeight.xs} gap-1 px-2 text-xs has-[>svg]:px-1.5`,
37
+ sm: `${controlHeight.sm} gap-1.5 px-3 has-[>svg]:px-2.5`,
38
+ default: `${controlHeight.default} px-4 py-2 has-[>svg]:px-3`,
39
+ lg: `${controlHeight.lg} px-6 has-[>svg]:px-4`,
40
+ 'icon-xs': controlSquare['icon-xs'],
41
+ 'icon-sm': controlSquare['icon-sm'],
42
+ icon: controlSquare.icon,
43
+ 'icon-lg': controlSquare['icon-lg'],
38
44
  }
39
45
 
40
46
  const solidClasses: Record<ButtonContext, string> = {
@@ -71,13 +77,6 @@ export function resolveButtonStyle({ context, variant }: ButtonStyleOptions = {}
71
77
  context: ButtonContext
72
78
  variant: ButtonVariant
73
79
  } {
74
- const legacy = variant === 'default' || variant === 'secondary' || variant === 'destructive'
75
- if (context != null && legacy) {
76
- throw new Error('Button não permite combinar context com uma variante semântica legada.')
77
- }
78
- if (variant === 'default') return { context: 'primary', variant: 'solid' }
79
- if (variant === 'secondary') return { context: 'neutral', variant: 'solid' }
80
- if (variant === 'destructive') return { context: 'danger', variant: 'solid' }
81
80
  const resolvedVariant: ButtonVariant = variant ?? 'solid'
82
81
  // Tratamentos de superfície (solid/subtle) pedem destaque; os leves (outline/ghost/link)
83
82
  // são o chrome discreto da casa e só ganham cor quando o contexto é declarado.
@@ -87,8 +86,8 @@ export function resolveButtonStyle({ context, variant }: ButtonStyleOptions = {}
87
86
  }
88
87
 
89
88
  /**
90
- * Classes do Button. `context` seleciona a família semântica e `variant` seleciona
91
- * o tratamento visual. `default`, `secondary` e `destructive` são aliases temporários.
89
+ * Classes do Button. `context` seleciona a família semântica, `variant` o tratamento visual e
90
+ * `size` a medida na escala compartilhada de control.ts.
92
91
  */
93
92
  export function buttonVariants(options: ButtonStyleOptions = {}): string {
94
93
  const resolved = resolveButtonStyle(options)
@@ -107,6 +106,7 @@ export function buttonVariants(options: ButtonStyleOptions = {}): string {
107
106
  focusRing,
108
107
  variantClasses,
109
108
  sizeClasses[options.size ?? 'default'],
109
+ controlGlyphClass(options.size),
110
110
  options.shape === 'pill' && 'rounded-full',
111
111
  )
112
112
  }
@@ -116,8 +116,9 @@ export interface ButtonProps extends React.ComponentProps<'button'>, ButtonStyle
116
116
  asChild?: boolean
117
117
  /** Estado de ação em andamento; mostra Spinner e desabilita o controle. */
118
118
  busy?: boolean
119
- /** Ícone à esquerda. No `busy`, o Spinner ocupa o mesmo espaço. */
120
- icon?: React.ElementType
119
+ /** Ícone à esquerda, como nó (`icon={<Plus />}`); o tamanho do glifo vem do `size`. No
120
+ * `busy`, o Spinner ocupa o mesmo espaço. */
121
+ icon?: React.ReactNode
121
122
  }
122
123
 
123
124
  export function Button({
@@ -128,14 +129,13 @@ export function Button({
128
129
  shape,
129
130
  asChild = false,
130
131
  busy = false,
131
- icon: Icon,
132
+ icon,
132
133
  disabled,
133
134
  children,
134
135
  ...props
135
136
  }: ButtonProps): React.ReactElement {
136
137
  const Comp = asChild ? Slot : 'button'
137
138
  const resolved = resolveButtonStyle({ context, variant })
138
- const iconSize = size === 'sm' ? 'size-3' : 'size-4'
139
139
  return (
140
140
  <Comp
141
141
  data-slot="button"
@@ -149,11 +149,7 @@ export function Button({
149
149
  children
150
150
  ) : (
151
151
  <>
152
- {busy ? (
153
- <Spinner size={size === 'sm' ? 'sm' : 'default'} />
154
- ) : Icon ? (
155
- <Icon className={iconSize} />
156
- ) : null}
152
+ {busy ? <Spinner size={controlBase(size)} /> : icon}
157
153
  {children}
158
154
  </>
159
155
  )}
@@ -1,6 +1,75 @@
1
1
  /** Geometria compartilhada pelos controles inline da casa. */
2
2
  export type ControlShape = 'default' | 'pill'
3
3
 
4
+ /**
5
+ * A escala única de `size` dos controles (14.0.0). Um nome, uma medida — em qualquer
6
+ * componente que aceite `size`, `sm` é a mesma altura de linha e `icon-sm` é o mesmo quadrado.
7
+ * Cada componente aceita o subconjunto coerente com seu papel, nunca um nome com outra medida.
8
+ *
9
+ * - Altura de linha (`xs` · `sm` · `default` · `lg`): 1.5 · 2 · 2.25 · 2.5rem.
10
+ * - Quadrado só-ícone (`icon-xs` · `icon-sm` · `icon` · `icon-lg`): 1.5 · 1.75 · 2.25 · 2.5rem.
11
+ * `icon-xs` é o degrau da ação que mora dentro de uma linha densa. `icon-sm` atende
12
+ * composições compactas que ainda precisam de mais presença.
13
+ * - Glifo (o svg dentro do controle daquele tamanho): 0.875 · 0.875 · 1 · 1.25rem. O Spinner
14
+ * usa esta medida, por isso `busy` substitui o ícone sem mexer na largura do botão.
15
+ */
16
+ export type ControlSize = 'xs' | 'sm' | 'default' | 'lg'
17
+ export type ControlIconSize = 'icon-xs' | 'icon-sm' | 'icon' | 'icon-lg'
18
+ export type ControlScale = ControlSize | ControlIconSize
19
+
20
+ export const controlHeight: Record<ControlSize, string> = {
21
+ xs: 'h-6',
22
+ sm: 'h-8',
23
+ default: 'h-9',
24
+ lg: 'h-10',
25
+ }
26
+
27
+ export const controlSquare: Record<ControlIconSize, string> = {
28
+ 'icon-xs': 'size-6',
29
+ 'icon-sm': 'size-7',
30
+ icon: 'size-9',
31
+ 'icon-lg': 'size-10',
32
+ }
33
+
34
+ export const controlGlyph: Record<ControlSize, string> = {
35
+ xs: 'size-3.5',
36
+ sm: 'size-3.5',
37
+ default: 'size-4',
38
+ lg: 'size-5',
39
+ }
40
+
41
+ // As classes completas precisam permanecer literais para o scanner do Tailwind gerar os seletores.
42
+ // Montar `]:${controlGlyph[...]}` em runtime devolve o nome certo no DOM, mas deixa o CSS ausente.
43
+ const controlGlyphSelector: Record<ControlSize, string> = {
44
+ xs: '[&_svg:not([class*=size-])]:size-3.5',
45
+ sm: '[&_svg:not([class*=size-])]:size-3.5',
46
+ default: '[&_svg:not([class*=size-])]:size-4',
47
+ lg: '[&_svg:not([class*=size-])]:size-5',
48
+ }
49
+
50
+ /** A altura de linha por trás de um tamanho da escala (`icon-sm` → `sm`). */
51
+ export function controlBase(size: ControlScale | null | undefined): ControlSize {
52
+ switch (size) {
53
+ case 'icon-xs':
54
+ return 'xs'
55
+ case 'icon-sm':
56
+ return 'sm'
57
+ case 'icon-lg':
58
+ return 'lg'
59
+ case 'icon':
60
+ case null:
61
+ case undefined:
62
+ return 'default'
63
+ default:
64
+ return size
65
+ }
66
+ }
67
+
68
+ /** Fallback de tamanho do svg descendente, respeitando um `size-*` explícito no próprio ícone. */
69
+ export function controlGlyphClass(size: ControlScale | null | undefined): string {
70
+ return controlGlyphSelector[controlBase(size)]
71
+ }
72
+
4
73
  /**
5
74
  * Anel de foco da casa: fino (0.1875rem), translúcido e encostado na borda — sem offset.
6
75
  * Todo controle focável usa esta constante; espessuras próprias (`ring-2`, `ring-4`) ou
@@ -5,22 +5,9 @@ import { cn } from '../../lib/cn.ts'
5
5
 
6
6
  export type DotContext = UiContext
7
7
  export type DotVariant = 'solid' | 'outline'
8
- /**
9
- * @deprecated Aliases de migração: `default` → `context="primary"`, `secondary` →
10
- * `context="neutral"`, `destructive` → `context="danger"`; `success`, `warning` e `info` → o
11
- * `context` homônimo. Declare `context` + `variant`.
12
- */
13
- export type LegacyDotVariant =
14
- | 'default'
15
- | 'secondary'
16
- | 'destructive'
17
- | 'success'
18
- | 'warning'
19
- | 'info'
20
-
21
8
  interface DotStyleOptions {
22
9
  context?: DotContext | null
23
- variant?: DotVariant | LegacyDotVariant | null
10
+ variant?: DotVariant | null
24
11
  }
25
12
 
26
13
  const solidClasses: Record<DotContext, string> = {
@@ -45,22 +32,6 @@ export function resolveDotStyle({ context, variant }: DotStyleOptions = {}): {
45
32
  context: DotContext
46
33
  variant: DotVariant
47
34
  } {
48
- const legacy =
49
- variant === 'default' ||
50
- variant === 'secondary' ||
51
- variant === 'destructive' ||
52
- variant === 'success' ||
53
- variant === 'warning' ||
54
- variant === 'info'
55
- if (context != null && legacy) {
56
- throw new Error('Dot não permite combinar context com uma variante semântica legada.')
57
- }
58
- if (variant === 'default') return { context: 'primary', variant: 'solid' }
59
- if (variant === 'secondary') return { context: 'neutral', variant: 'solid' }
60
- if (variant === 'destructive') return { context: 'danger', variant: 'solid' }
61
- if (variant === 'success' || variant === 'warning' || variant === 'info') {
62
- return { context: variant, variant: 'solid' }
63
- }
64
35
  return { context: context ?? 'neutral', variant: variant ?? 'solid' }
65
36
  }
66
37
 
@@ -3,7 +3,7 @@ import { cva, type VariantProps } from "class-variance-authority"
3
3
 
4
4
  import { cn } from '../../lib/cn.ts'
5
5
  import type { ControlShape } from './control.ts'
6
- import { Button } from './button.tsx'
6
+ import { Button, type ButtonProps } from './button.tsx'
7
7
  import { Input } from './input.tsx'
8
8
  import { Textarea } from './textarea.tsx'
9
9
 
@@ -79,16 +79,16 @@ function InputGroupAddon({
79
79
  )
80
80
  }
81
81
 
82
+ /** Só o acabamento por tamanho: altura, quadrado e glifo vêm da escala de control.ts via Button. */
82
83
  const inputGroupButtonVariants = cva(
83
84
  "flex items-center gap-2 text-sm shadow-none",
84
85
  {
85
86
  variants: {
86
87
  size: {
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
- sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5",
89
- "icon-xs":
90
- "size-6 rounded-[calc(var(--radius)-0.3125rem)] p-0 has-[>svg]:p-0",
91
- "icon-sm": "size-8 p-0 has-[>svg]:p-0",
88
+ xs: "rounded-[calc(var(--radius)-0.3125rem)] has-[>svg]:px-2",
89
+ sm: "rounded-md",
90
+ "icon-xs": "rounded-[calc(var(--radius)-0.3125rem)] p-0 has-[>svg]:p-0",
91
+ "icon-sm": "p-0 has-[>svg]:p-0",
92
92
  },
93
93
  },
94
94
  defaultVariants: {
@@ -97,19 +97,22 @@ const inputGroupButtonVariants = cva(
97
97
  }
98
98
  )
99
99
 
100
+ /** Subconjunto da escala que cabe dentro de um campo: `xs`, `sm`, `icon-xs` e `icon-sm`. */
101
+ type InputGroupButtonSize = Extract<NonNullable<ButtonProps['size']>, 'xs' | 'sm' | 'icon-xs' | 'icon-sm'>
102
+
100
103
  function InputGroupButton({
101
104
  className,
102
105
  type = "button",
103
106
  variant = "ghost",
104
107
  size = "xs",
105
108
  ...props
106
- }: Omit<React.ComponentProps<typeof Button>, "size"> &
107
- VariantProps<typeof inputGroupButtonVariants>) {
109
+ }: Omit<ButtonProps, 'size'> & { size?: InputGroupButtonSize }) {
108
110
  return (
109
111
  <Button
110
112
  type={type}
111
113
  data-size={size}
112
114
  variant={variant}
115
+ size={size}
113
116
  className={cn(inputGroupButtonVariants({ size }), className)}
114
117
  {...props}
115
118
  />
@@ -3,6 +3,7 @@ import { cva, type VariantProps } from "class-variance-authority";
3
3
  import { Slot } from "radix-ui";
4
4
 
5
5
  import { cn } from "../../lib/cn.ts";
6
+ import type { ControlSize } from "./control.ts";
6
7
  import { Separator } from "./separator.tsx";
7
8
 
8
9
  const itemGroupVariants = cva(
@@ -72,10 +73,11 @@ const itemVariants = cva(
72
73
  outline: "border-border",
73
74
  muted: "bg-muted/50",
74
75
  },
76
+ // Nomes da escala de control.ts; a linha não tem altura fixa, `sm` aperta o respiro.
75
77
  size: {
76
78
  default: "gap-4 p-4",
77
79
  sm: "gap-2.5 px-4 py-3",
78
- },
80
+ } satisfies Partial<Record<ControlSize, string>>,
79
81
  },
80
82
  defaultVariants: { variant: "default", size: "default" },
81
83
  },
@@ -67,18 +67,12 @@ function MenuItem({
67
67
  className,
68
68
  inset,
69
69
  context,
70
- variant,
71
70
  ...props
72
71
  }: React.ComponentProps<typeof MenuPrimitive.Item> & {
73
72
  inset?: boolean
74
73
  context?: Extract<UiContext, 'neutral' | 'danger'>
75
- /** @deprecated Use `context="danger"`. */
76
- variant?: "default" | "destructive"
77
74
  }) {
78
- if (context !== undefined && variant !== undefined) {
79
- throw new Error('MenuItem não permite combinar context com uma variante semântica legada.')
80
- }
81
- const resolvedContext = context ?? (variant === 'destructive' ? 'danger' : 'neutral')
75
+ const resolvedContext = context ?? 'neutral'
82
76
  return (
83
77
  <MenuPrimitive.Item
84
78
  data-slot="menu-item"
@@ -3,6 +3,7 @@ import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from 'lucide-re
3
3
 
4
4
  import { cn } from '../../lib/cn.ts'
5
5
  import { buttonVariants, type ButtonProps } from './button.tsx'
6
+ import { controlSquare, type ControlIconSize } from './control.ts'
6
7
 
7
8
  function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
8
9
  return (
@@ -33,8 +34,8 @@ function PaginationItem({ ...props }: React.ComponentProps<'li'>) {
33
34
  export interface PaginationLinkProps extends Omit<React.ComponentProps<'a'>, 'children'> {
34
35
  /** Página atual: vira `outline` e ganha `aria-current="page"`. */
35
36
  isActive?: boolean
36
- /** Escala do Button. `default` (números: altura fixa, largura mínima quadrada que cresce
37
- * com os dígitos) ou uma escala `icon*` (setas quadradas, sem padding horizontal). */
37
+ /** A escala de control.ts, via Button. `default` (números: altura fixa, largura mínima
38
+ * quadrada que cresce com os dígitos) ou um `icon*` (setas quadradas, sem padding). */
38
39
  size?: ButtonProps['size']
39
40
  /** Número da página: dá o nome acessível (“Página N”) e o conteúdo, quando `children` falta. */
40
41
  page?: number
@@ -83,10 +84,11 @@ function PaginationLink({ className, isActive, page, disabled, href, size = 'def
83
84
  export interface PaginationControlProps extends Omit<PaginationLinkProps, 'page' | 'isActive'> {
84
85
  /** Nome acessível e texto visível. Default: “Página anterior” / “Próxima página”. */
85
86
  label?: string
86
- /** Só a seta, quadrada (escala `icon`, `size-9`); o `label` continua como nome acessível. */
87
+ /** Só a seta, quadrada (`icon`, 2.25rem; `size="icon-sm"` para o rodapé denso); o `label`
88
+ * continua como nome acessível. */
87
89
  iconOnly?: boolean
88
- /** Classe do ícone (ex.: `size-3.5` na escala densa). Vai no próprio svg, porque o
89
- * fallback `[&_svg:not([class*='size-'])]:size-4` do Button vence qualquer seletor de fora. */
90
+ /** Classe do ícone, quando o glifo da escala não servir. Vai no próprio svg, porque o
91
+ * fallback `[&_svg:not([class*=size-])]` do Button vence qualquer seletor de fora. */
90
92
  iconClassName?: string
91
93
  }
92
94
 
@@ -132,16 +134,22 @@ function PaginationNext({
132
134
  )
133
135
  }
134
136
 
137
+ export interface PaginationEllipsisProps extends React.ComponentProps<'span'> {
138
+ /** O quadrado da escala de control.ts — o mesmo das setas ao lado. */
139
+ size?: ControlIconSize
140
+ }
141
+
135
142
  /** Decorativa: `aria-hidden`; os números ao redor já dizem que há páginas no vão. */
136
- function PaginationEllipsis({ className, ...props }: React.ComponentProps<'span'>) {
143
+ function PaginationEllipsis({ className, size = 'icon', ...props }: PaginationEllipsisProps) {
137
144
  return (
138
145
  <span
139
146
  aria-hidden
140
147
  data-slot="pagination-ellipsis"
141
- className={cn('flex size-9 items-center justify-center', className)}
148
+ data-size={size}
149
+ className={cn('flex items-center justify-center', controlSquare[size], className)}
142
150
  {...props}
143
151
  >
144
- <MoreHorizontalIcon className="size-4" />
152
+ <MoreHorizontalIcon className={size === 'icon-xs' || size === 'icon-sm' ? 'size-3.5' : 'size-4'} />
145
153
  </span>
146
154
  )
147
155
  }
@@ -25,7 +25,7 @@ import { Check, ChevronDownIcon, SearchIcon, X } from 'lucide-react'
25
25
  import { Command as CommandPrimitive } from 'cmdk'
26
26
 
27
27
  import { cn } from '../../lib/cn.ts'
28
- import { focusRing, focusRingWithin, type ControlShape } from './control.ts'
28
+ import { focusRing, focusRingWithin, type ControlShape, type ControlSize } from './control.ts'
29
29
  import { CommandEmpty, CommandGroup, CommandItem, CommandList } from './command.tsx'
30
30
  import { Popover, PopoverAnchor, PopoverContent } from './popover.tsx'
31
31
 
@@ -60,7 +60,7 @@ interface SelectBaseProps {
60
60
  'aria-invalid'?: boolean
61
61
  'aria-describedby'?: string
62
62
  /** Altura: `default` (h-9, a do Input/Button) ou `sm` (h-8) pra toolbar densa. */
63
- size?: 'sm' | 'default'
63
+ size?: Extract<ControlSize, 'sm' | 'default'>
64
64
  /** Geometria do controle; `pill` preserva a variante visual escolhida. */
65
65
  shape?: ControlShape
66
66
  'aria-label'?: string
@@ -1,28 +1,25 @@
1
1
  import * as React from 'react'
2
2
  import { Loader2Icon } from 'lucide-react'
3
- import { cva, type VariantProps } from 'class-variance-authority'
4
3
  import { cn } from '../../lib/cn.ts'
4
+ import { controlGlyph, type ControlSize } from './control.ts'
5
5
 
6
- /** Tamanhos do spinner divergência da casa (o shadcn atual só tem `size-4` fixo).
7
- * Mantido aditivo pra cobrir botão (sm) e estados maiores (lg). */
8
- const spinnerVariants = cva('animate-spin', {
9
- variants: {
10
- size: { sm: 'size-3', default: 'size-4', lg: 'size-6' },
11
- },
12
- defaultVariants: { size: 'default' },
13
- })
14
-
15
- export interface SpinnerProps extends React.ComponentProps<'svg'>, VariantProps<typeof spinnerVariants> {}
6
+ export interface SpinnerProps extends React.ComponentProps<'svg'> {
7
+ /** O glifo do controle daquele tamanho na escala de control.ts (`sm` cabe num Button sm). */
8
+ size?: ControlSize | null
9
+ }
16
10
 
17
- /** Indicador de carregamento (Loader2Icon girando) — substitui o `<Loader2Icon className="animate-spin"/>`
18
- * inline repetido. Tamanhos sm/default/lg. Pra placeholder de conteúdo, use Skeleton. */
11
+ /**
12
+ * Indicador de carregamento (Loader2Icon girando) substitui o `<Loader2Icon
13
+ * className="animate-spin"/>` inline repetido. É decorativo: quem nomeia a espera é o contêiner
14
+ * (`role="status"` em DataState, PageState ou no seu) ou o texto ao lado; assim a superfície
15
+ * carrega um status só, sem role duplicado. Pra placeholder de conteúdo, use Skeleton.
16
+ */
19
17
  export function Spinner({ className, size, ...props }: SpinnerProps): React.ReactElement {
20
18
  return (
21
19
  <Loader2Icon
22
20
  data-slot="spinner"
23
- role="status"
24
- aria-label="Carregando"
25
- className={cn(spinnerVariants({ size }), className)}
21
+ aria-hidden="true"
22
+ className={cn('animate-spin', controlGlyph[size ?? 'default'], className)}
26
23
  {...props}
27
24
  />
28
25
  )
@@ -2,13 +2,16 @@ import * as React from "react"
2
2
  import { Switch as SwitchPrimitive } from "radix-ui"
3
3
 
4
4
  import { cn } from '../../lib/cn.ts'
5
+ import type { ControlSize } from './control.ts'
5
6
 
7
+ /** `size` usa os nomes da escala de control.ts; a chave é um trilho (não uma linha), então a
8
+ * medida é a do glifo daquele tamanho: `sm` acompanha o svg de um controle sm (0.875rem). */
6
9
  function Switch({
7
10
  className,
8
11
  size = "default",
9
12
  ...props
10
13
  }: React.ComponentProps<typeof SwitchPrimitive.Root> & {
11
- size?: "sm" | "default"
14
+ size?: Extract<ControlSize, "sm" | "default">
12
15
  }) {
13
16
  return (
14
17
  <SwitchPrimitive.Root
@@ -3,11 +3,13 @@ import { cva, type VariantProps } from "class-variance-authority"
3
3
  import { Tabs as TabsPrimitive } from "radix-ui"
4
4
 
5
5
  import { cn } from '../../lib/cn.ts'
6
+ import { controlHeight, type ControlSize } from './control.ts'
6
7
 
7
8
  // Divergência da casa (ejetado): o Tabs ganhou `size` — Button e Select têm `sm`, o Tabs
8
9
  // não tinha, e uma fileira densa ficava com o segmento 0.25rem mais alto
9
- // que os irmãos. O tamanho flui pra TabsList via contexto (a altura é dela).
10
- type TabsSize = "default" | "sm"
10
+ // que os irmãos. O tamanho flui pra TabsList via contexto (a altura é dela) e a medida é a
11
+ // da escala de control.ts.
12
+ type TabsSize = Extract<ControlSize, "default" | "sm">
11
13
  const TabsSizeContext = React.createContext<TabsSize>("default")
12
14
 
13
15
  function Tabs({
@@ -64,7 +66,7 @@ function TabsList({
64
66
  data-slot="tabs-list"
65
67
  data-variant={variant}
66
68
  data-size={size}
67
- className={cn(tabsListVariants({ variant }), size === "sm" ? "h-8" : "h-9", className)}
69
+ className={cn(tabsListVariants({ variant }), controlHeight[size], className)}
68
70
  {...props}
69
71
  />
70
72
  )
@@ -3,9 +3,13 @@ import { cva, type VariantProps } from "class-variance-authority"
3
3
  import { Toggle as TogglePrimitive } from "radix-ui"
4
4
 
5
5
  import { cn } from '../../lib/cn.ts'
6
+ import { controlHeight, focusRing } from './control.ts'
6
7
 
7
8
  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-[0.1875rem] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-context-danger aria-invalid:ring-context-danger/20 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground dark:aria-invalid:ring-context-danger/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
9
+ [
10
+ "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 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-context-danger aria-invalid:ring-context-danger/20 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground dark:aria-invalid:ring-context-danger/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
11
+ focusRing,
12
+ ],
9
13
  {
10
14
  variants: {
11
15
  variant: {
@@ -13,10 +17,11 @@ const toggleVariants = cva(
13
17
  outline:
14
18
  "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
15
19
  },
20
+ // Alturas da escala de control.ts (`sm` = o mesmo h-8 de Button e Select).
16
21
  size: {
17
- default: "h-9 min-w-9 px-2",
18
- sm: "h-8 min-w-8 px-1.5",
19
- lg: "h-10 min-w-10 px-2.5",
22
+ default: `${controlHeight.default} min-w-9 px-2`,
23
+ sm: `${controlHeight.sm} min-w-8 px-1.5`,
24
+ lg: `${controlHeight.lg} min-w-10 px-2.5`,
20
25
  },
21
26
  shape: {
22
27
  default: '',
@@ -88,6 +88,32 @@ ciclo de vida que o pattern não cobre; validação e execução continuam iguai
88
88
  | `options` | `SelectOption[]` | Opções por id de runtime — sobrepõe o fieldOptions do form e o z.enum. |
89
89
  | `className` | `string` | Classes do invólucro (ex.: `col-span-2` em uma grid). |
90
90
 
91
+ ## Ajuda na label
92
+
93
+ `FieldSpec.help` aparece como um ícone junto à label, com o texto num tooltip. Escreva ali o
94
+ critério, o efeito ou a limitação que a label não diz; ajuda que só repete a label deve ser omitida.
95
+ Não existe texto auxiliar sob o campo: quem precisa de uma frase permanente abaixo do controle usa
96
+ `FieldDescription` na composição própria.
97
+
98
+ `LabelHelp` é esse mesmo ícone, exportado para um formulário sem contrato mostrar a ajuda no mesmo
99
+ lugar:
100
+
101
+ ```tsx preview col md
102
+ <Field>
103
+ <FieldLabel htmlFor="staff" className="items-center gap-1.5">
104
+ <span>Staff</span>
105
+ <LabelHelp help="Tem acesso ao back-office e a todos os workspaces." />
106
+ </FieldLabel>
107
+ <Input id="staff" placeholder="Empresa X" />
108
+ </Field>
109
+ ```
110
+
111
+ ### Propriedades de LabelHelp
112
+
113
+ | Propriedade | Tipo | Descrição |
114
+ |---|---|---|
115
+ | `help` | `string` | O texto do tooltip. Vazio ou ausente, o ícone não renderiza. |
116
+
91
117
  ## Origem das opções de seleção
92
118
 
93
119
  As opções seguem esta precedência: `options` no campo, `fieldOptions` no formulário, `options` no
@@ -23,7 +23,7 @@ render(
23
23
  <Plus /> Workspace
24
24
  </Button>
25
25
  }
26
- emptyText="Nenhum workspace."
26
+ emptyMessage="Nenhum workspace."
27
27
  >
28
28
  {(workspaces) => (
29
29
  <div className="space-y-2">
@@ -69,5 +69,5 @@ corpo do modal.
69
69
  | `actions` | `ReactNode` | | Ação à direita da toolbar — em geral o botão de criar. |
70
70
  | `empty` | `(items) => boolean` | `items.length === 0` | Sobrepõe o vazio derivado. |
71
71
  | `loading` | `boolean` | | Carga extra agregada à do fetch (query irmã). |
72
- | `emptyText / errorText` | `string` | | Textos dos estados (DataState). |
72
+ | `emptyMessage / errorMessage / retryLabel` | `string` | | Textos dos estados; `retryLabel` nomeia a ação somente com ícone e seu tooltip. |
73
73
  | `className` | `string` | `sm:max-w-3xl` | Largura do DialogContent. |