@softize/opus 27.0.0 → 28.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 (42) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/bin/lib/check.mjs +139 -30
  3. package/docs/adr/0015-action-size-follows-interaction-density.md +2 -0
  4. package/docs/adr/0017-ui-assumes-a-fixed-desktop-layout.md +14 -8
  5. package/docs/adr/0029-page-header-is-the-single-page-introduction.md +3 -1
  6. package/docs/adr/0057-doc-browser-permite-busca-no-chrome-do-consumidor.md +39 -5
  7. package/docs/adr/0060-surface-composition-separates-chrome-and-intro.md +161 -0
  8. package/docs/adr/README.md +2 -0
  9. package/package.json +1 -1
  10. package/registry/skills/build-opus-ui/SKILL.md +11 -11
  11. package/registry/skills/build-opus-ui/references/evaluations.md +10 -0
  12. package/registry/skills/build-opus-ui/references/ui-patterns.md +17 -15
  13. package/src/ui/components/patterns/list.tsx +62 -53
  14. package/src/ui/components/patterns/page.tsx +146 -38
  15. package/src/ui/components/patterns/presentation.tsx +44 -59
  16. package/src/ui/components/patterns/structural-header.tsx +42 -6
  17. package/src/ui/components/patterns/surface-anatomy.ts +52 -22
  18. package/src/ui/components/patterns/surface.tsx +2 -3
  19. package/src/ui/components/primitives/control.ts +2 -2
  20. package/src/ui/components/primitives/dialog.tsx +7 -2
  21. package/src/ui/components/primitives/drawer.tsx +7 -2
  22. package/src/ui/components/primitives/icon-picker.tsx +271 -34
  23. package/src/ui/components/primitives/pagination.tsx +5 -4
  24. package/src/ui/components/primitives/tabs.tsx +15 -2
  25. package/src/ui/components/primitives/toggle.tsx +1 -1
  26. package/src/ui/docs/DocBrowser.tsx +122 -12
  27. package/src/ui/docs/content/action-list.md +11 -2
  28. package/src/ui/docs/content/button.md +7 -7
  29. package/src/ui/docs/content/compositions.md +35 -23
  30. package/src/ui/docs/content/dialog.md +18 -6
  31. package/src/ui/docs/content/documentation-portal.md +40 -1
  32. package/src/ui/docs/content/drawer.md +17 -5
  33. package/src/ui/docs/content/icon-picker.md +37 -19
  34. package/src/ui/docs/content/input.md +1 -1
  35. package/src/ui/docs/content/page.md +169 -69
  36. package/src/ui/docs/content/pagination.md +7 -8
  37. package/src/ui/docs/content/presentation.md +4 -3
  38. package/src/ui/docs/content/surface.md +8 -5
  39. package/src/ui/docs/content/tabs.md +12 -4
  40. package/src/ui/docs/types.ts +2 -0
  41. package/src/ui/meta.ts +1 -1
  42. package/src/ui/react.tsx +9 -1
@@ -7,10 +7,10 @@ import {
7
7
  Briefcase,
8
8
  Building2,
9
9
  Calendar,
10
+ Car,
10
11
  ChartBar,
11
12
  ChartLine,
12
13
  ChartPie,
13
- Check,
14
14
  ChevronsUpDown,
15
15
  ClipboardList,
16
16
  Clock,
@@ -71,6 +71,7 @@ export const iconPickerIcons: Record<string, LucideIcon> = {
71
71
  briefcase: Briefcase,
72
72
  building: Building2,
73
73
  calendar: Calendar,
74
+ car: Car,
74
75
  "chart-bar": ChartBar,
75
76
  "chart-line": ChartLine,
76
77
  "chart-pie": ChartPie,
@@ -110,12 +111,111 @@ export const iconPickerIcons: Record<string, LucideIcon> = {
110
111
  zap: Zap,
111
112
  };
112
113
 
114
+ /** Rótulos e termos de busca separados dos identificadores persistidos. */
115
+ export const iconPickerSearch: Record<
116
+ string,
117
+ { label: string; keywords: string[] }
118
+ > = {
119
+ car: { label: "Carro", keywords: ["veículo", "automóvel", "vendas"] },
120
+ activity: { label: "Atividade", keywords: ["pulso", "saúde"] },
121
+ banknote: { label: "Dinheiro", keywords: ["nota", "pagamento", "valor"] },
122
+ bell: { label: "Sino", keywords: ["alerta", "notificação"] },
123
+ box: { label: "Caixa", keywords: ["estoque", "produto"] },
124
+ briefcase: { label: "Maleta", keywords: ["trabalho", "negócio"] },
125
+ building: { label: "Edifício", keywords: ["empresa", "unidade", "prédio"] },
126
+ calendar: { label: "Calendário", keywords: ["agenda", "data", "evento"] },
127
+ "chart-bar": {
128
+ label: "Gráfico de barras",
129
+ keywords: ["relatório", "análise"],
130
+ },
131
+ "chart-line": {
132
+ label: "Gráfico de linhas",
133
+ keywords: ["evolução", "relatório"],
134
+ },
135
+ "chart-pie": {
136
+ label: "Gráfico de pizza",
137
+ keywords: ["percentual", "relatório"],
138
+ },
139
+ "clipboard-list": {
140
+ label: "Prancheta",
141
+ keywords: ["tarefas", "lista", "checklist"],
142
+ },
143
+ clock: { label: "Relógio", keywords: ["hora", "tempo", "prazo"] },
144
+ "credit-card": { label: "Cartão", keywords: ["crédito", "pagamento"] },
145
+ database: { label: "Banco de dados", keywords: ["base", "registros"] },
146
+ "file-text": { label: "Documento", keywords: ["página", "texto", "arquivo"] },
147
+ flag: { label: "Bandeira", keywords: ["marco", "sinalização"] },
148
+ folder: { label: "Pasta", keywords: ["diretório", "arquivos"] },
149
+ gauge: { label: "Medidor", keywords: ["velocidade", "desempenho"] },
150
+ globe: { label: "Globo", keywords: ["mundo", "público", "internet"] },
151
+ heart: { label: "Coração", keywords: ["favorito", "saúde"] },
152
+ layers: { label: "Camadas", keywords: ["coleção", "conjunto"] },
153
+ "layout-grid": { label: "Grade", keywords: ["cartões", "painel"] },
154
+ lightbulb: { label: "Lâmpada", keywords: ["ideia", "sugestão"] },
155
+ mail: {
156
+ label: "Envelope",
157
+ keywords: ["email", "e-mail", "correio", "mensagem"],
158
+ },
159
+ "map-pin": { label: "Localização", keywords: ["endereço", "mapa", "lugar"] },
160
+ "message-circle": {
161
+ label: "Conversa",
162
+ keywords: ["chat", "mensagem", "atendimento"],
163
+ },
164
+ "message-square": { label: "Mensagem", keywords: ["chat", "comentário"] },
165
+ package: { label: "Pacote", keywords: ["entrega", "produto", "encomenda"] },
166
+ phone: { label: "Telefone", keywords: ["ligação", "contato"] },
167
+ rocket: { label: "Foguete", keywords: ["lançamento", "inovação"] },
168
+ settings: { label: "Configurações", keywords: ["ajustes", "engrenagem"] },
169
+ "shopping-cart": { label: "Carrinho", keywords: ["compra", "venda"] },
170
+ star: { label: "Estrela", keywords: ["favorito", "destaque"] },
171
+ store: { label: "Loja", keywords: ["comércio", "unidade"] },
172
+ table: { label: "Tabela", keywords: ["planilha", "catálogo"] },
173
+ target: { label: "Alvo", keywords: ["meta", "objetivo"] },
174
+ "trending-down": { label: "Queda", keywords: ["redução", "tendência"] },
175
+ "trending-up": { label: "Crescimento", keywords: ["aumento", "tendência"] },
176
+ truck: {
177
+ label: "Caminhão",
178
+ keywords: ["transporte", "veículo", "logística"],
179
+ },
180
+ user: { label: "Pessoa", keywords: ["usuário", "cliente"] },
181
+ users: { label: "Pessoas", keywords: ["equipe", "grupo", "clientes"] },
182
+ wallet: { label: "Carteira", keywords: ["saldo", "financeiro"] },
183
+ wrench: { label: "Ferramenta", keywords: ["chave", "manutenção", "serviço"] },
184
+ zap: { label: "Raio", keywords: ["automação", "energia", "fluxo"] },
185
+ };
186
+
187
+ export interface IconCatalogEntry {
188
+ name: string;
189
+ label: string;
190
+ keywords: string[];
191
+ }
192
+ export interface IconCatalog {
193
+ load: () => Promise<IconCatalogEntry[]>;
194
+ render: (name: string, className: string) => React.ReactNode;
195
+ }
196
+ const IconCatalogContext = React.createContext<IconCatalog | null>(null);
197
+ export function IconCatalogProvider({
198
+ catalog,
199
+ children,
200
+ }: {
201
+ catalog: IconCatalog;
202
+ children: React.ReactNode;
203
+ }) {
204
+ return (
205
+ <IconCatalogContext.Provider value={catalog}>
206
+ {children}
207
+ </IconCatalogContext.Provider>
208
+ );
209
+ }
210
+
113
211
  export interface IconPickerProps {
114
212
  /** Nome do ícone selecionado (chave da paleta) — '' = nenhum. */
115
213
  value: string;
116
214
  onChange: (name: string) => void;
117
215
  /** Paleta nome → componente. Default: `iconPickerIcons` (curada). */
118
216
  icons?: Record<string, LucideIcon>;
217
+ /** Rótulos e sinônimos para busca; não alteram o valor persistido. */
218
+ searchTerms?: typeof iconPickerSearch;
119
219
  placeholder?: string;
120
220
  disabled?: boolean;
121
221
  className?: string;
@@ -133,7 +233,8 @@ export interface IconPickerProps {
133
233
  export function IconPicker({
134
234
  value,
135
235
  onChange,
136
- icons = iconPickerIcons,
236
+ icons: providedIcons,
237
+ searchTerms = iconPickerSearch,
137
238
  placeholder = "Selecione um ícone…",
138
239
  disabled,
139
240
  className,
@@ -141,11 +242,80 @@ export function IconPicker({
141
242
  "aria-invalid": ariaInvalid,
142
243
  "aria-describedby": ariaDescribedBy,
143
244
  }: IconPickerProps): React.ReactElement {
245
+ const contextCatalog = React.useContext(IconCatalogContext);
246
+ const catalog = providedIcons ? null : contextCatalog;
247
+ const icons = providedIcons ?? iconPickerIcons;
248
+ const [entries, setEntries] = React.useState<IconCatalogEntry[] | null>(null);
249
+ const [loadError, setLoadError] = React.useState(false);
250
+ const [attempt, setAttempt] = React.useState(0);
251
+ const [query, setQuery] = React.useState("");
252
+ const [limit, setLimit] = React.useState(64);
253
+ const listRef = React.useRef<HTMLDivElement>(null);
144
254
  const [open, setOpen] = React.useState(false);
145
- const Current = value !== "" ? icons[value] : undefined;
255
+ const Current = Object.hasOwn(icons, value) ? icons[value] : undefined;
256
+ const [active, setActive] = React.useState("");
257
+ const loadedCatalog = React.useRef<typeof catalog>(null);
258
+ React.useEffect(() => {
259
+ if (!open || !catalog || loadedCatalog.current === catalog) return;
260
+ setEntries(null);
261
+ let cancelled = false;
262
+ setLoadError(false);
263
+ catalog
264
+ .load()
265
+ .then((items) => {
266
+ if (!cancelled) {
267
+ loadedCatalog.current = catalog;
268
+ setEntries(items);
269
+ }
270
+ })
271
+ .catch(() => {
272
+ if (!cancelled) setLoadError(true);
273
+ });
274
+ return () => {
275
+ cancelled = true;
276
+ };
277
+ }, [open, catalog, attempt]);
278
+ const normalize = (text: string) =>
279
+ text
280
+ .normalize("NFD")
281
+ .replace(/\p{Diacritic}/gu, "")
282
+ .toLocaleLowerCase("pt-BR");
283
+ const available = catalog
284
+ ? loadedCatalog.current === catalog
285
+ ? (entries ?? [])
286
+ : []
287
+ : Object.keys(icons).map((name) => ({
288
+ name,
289
+ label: searchTerms[name]?.label ?? name,
290
+ keywords: searchTerms[name]?.keywords ?? [],
291
+ }));
292
+ const filtered = available.filter((entry) =>
293
+ normalize(query)
294
+ .trim()
295
+ .split(/\s+/)
296
+ .every((term) =>
297
+ normalize(
298
+ [entry.name, entry.label, ...entry.keywords].join(" "),
299
+ ).includes(term),
300
+ ),
301
+ );
302
+ const renderIcon = (name: string, className: string) => {
303
+ if (catalog) return catalog.render(name, className);
304
+ const Icon = Object.hasOwn(icons, name) ? icons[name] : undefined;
305
+ return Icon ? <Icon className={className} aria-hidden="true" /> : null;
306
+ };
146
307
 
147
308
  return (
148
- <Popover open={open} onOpenChange={setOpen}>
309
+ <Popover
310
+ open={open}
311
+ onOpenChange={(next) => {
312
+ setOpen(next);
313
+ if (next) {
314
+ setQuery("");
315
+ setLimit(64);
316
+ }
317
+ }}
318
+ >
149
319
  <PopoverTrigger asChild>
150
320
  <button
151
321
  type="button"
@@ -164,11 +334,13 @@ export function IconPicker({
164
334
  className,
165
335
  )}
166
336
  >
167
- {Current !== undefined ? (
337
+ {(catalog && value) || Current !== undefined ? (
168
338
  <>
169
- <Current className="size-4 shrink-0" />
170
- <span data-slot="icon-picker-value" className="truncate">
171
- {value}
339
+ {renderIcon(value, "size-4 shrink-0")}
340
+ <span data-slot="icon-picker-value" className="sr-only">
341
+ {available.find((entry) => entry.name === value)?.label ??
342
+ searchTerms[value]?.label ??
343
+ "Ícone selecionado"}
172
344
  </span>
173
345
  </>
174
346
  ) : (
@@ -179,33 +351,98 @@ export function IconPicker({
179
351
  </PopoverTrigger>
180
352
  <PopoverContent
181
353
  align="start"
182
- className="pointer-events-auto w-(--radix-popover-trigger-width) p-0"
354
+ className="pointer-events-auto w-96 max-w-[calc(100vw-2rem)] p-0"
355
+ onWheel={(event) => event.stopPropagation()}
356
+ onTouchMove={(event) => event.stopPropagation()}
183
357
  >
184
- <Command>
185
- <CommandInput placeholder="Buscar ícone…" />
186
- <CommandList data-slot="icon-picker-list" className="max-h-60">
187
- <CommandEmpty>Nada encontrado.</CommandEmpty>
188
- <CommandGroup>
189
- {Object.entries(icons).map(([name, Icon]) => (
190
- <CommandItem
191
- key={name}
192
- data-slot="icon-picker-item"
193
- value={name}
194
- onSelect={() => {
195
- onChange(name === value ? "" : name);
196
- setOpen(false);
197
- }}
198
- >
199
- <Icon className="size-4" />
200
- <span className="truncate">{name}</span>
201
- <Check
202
- className={cn(
203
- "ml-auto size-4 shrink-0",
204
- value === name ? "opacity-100" : "opacity-0",
205
- )}
206
- />
207
- </CommandItem>
208
- ))}
358
+ <Command
359
+ shouldFilter={false}
360
+ value={active}
361
+ onValueChange={setActive}
362
+ onKeyDown={(event) => {
363
+ const step =
364
+ event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0;
365
+ if (!step) return;
366
+ event.preventDefault();
367
+ const items = Array.from(
368
+ event.currentTarget.querySelectorAll<HTMLElement>(
369
+ "[cmdk-item]:not([hidden])",
370
+ ),
371
+ );
372
+ const index = items.findIndex(
373
+ (item) => item.dataset.value === active,
374
+ );
375
+ const next =
376
+ items[Math.max(0, Math.min(items.length - 1, index + step))];
377
+ if (next) {
378
+ setActive(next.dataset.value ?? "");
379
+ next.scrollIntoView({ block: "nearest" });
380
+ }
381
+ }}
382
+ >
383
+ <CommandInput
384
+ placeholder="Buscar ícone…"
385
+ value={query}
386
+ onValueChange={(next) => {
387
+ if (listRef.current) listRef.current.scrollTop = 0;
388
+ setQuery(next);
389
+ setLimit(64);
390
+ }}
391
+ />
392
+ {catalog && !entries && !loadError ? (
393
+ <div role="status" className="p-4 text-sm">
394
+ Carregando ícones…
395
+ </div>
396
+ ) : loadError ? (
397
+ <div role="alert" className="p-4 text-sm">
398
+ Não foi possível carregar os ícones.{" "}
399
+ <button
400
+ type="button"
401
+ className="underline"
402
+ onClick={() => setAttempt((value) => value + 1)}
403
+ >
404
+ Tentar novamente
405
+ </button>
406
+ </div>
407
+ ) : null}
408
+ <CommandList
409
+ ref={listRef}
410
+ onScroll={(event) => {
411
+ const list = event.currentTarget;
412
+ if (
413
+ list.scrollHeight - list.scrollTop - list.clientHeight <=
414
+ list.clientHeight / 2
415
+ ) {
416
+ setLimit((count) => Math.min(count + 64, filtered.length));
417
+ }
418
+ }}
419
+ data-slot="icon-picker-list"
420
+ className="max-h-60 overscroll-contain"
421
+ >
422
+ {!loadError && (!catalog || entries) ? (
423
+ <CommandEmpty>Nada encontrado.</CommandEmpty>
424
+ ) : null}
425
+ <CommandGroup className="[&_[cmdk-group-items]]:grid [&_[cmdk-group-items]]:grid-cols-8 [&_[cmdk-group-items]]:gap-1">
426
+ {filtered.slice(0, limit).map(({ name, label }) => {
427
+ return (
428
+ <CommandItem
429
+ key={name}
430
+ data-slot="icon-picker-item"
431
+ value={name}
432
+ aria-label={label}
433
+ data-current={name === value}
434
+ className="h-10 justify-center p-0 data-[current=true]:ring-1 data-[current=true]:ring-inset data-[current=true]:ring-primary"
435
+ onSelect={() => {
436
+ onChange(name === value ? "" : name);
437
+ setOpen(false);
438
+ }}
439
+ >
440
+ <span className="flex h-full w-full items-center justify-center">
441
+ {renderIcon(name, "size-5")}
442
+ </span>
443
+ </CommandItem>
444
+ );
445
+ })}
209
446
  </CommandGroup>
210
447
  </CommandList>
211
448
  </Command>
@@ -3,7 +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
+ import { controlBase, controlSquare, type ControlIconSize } from './control.ts'
7
7
 
8
8
  function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
9
9
  return (
@@ -46,12 +46,13 @@ export interface PaginationLinkProps extends Omit<React.ComponentProps<'a'>, 'ch
46
46
 
47
47
  /**
48
48
  * Um link de página. Altura fixa, largura mínima quadrada e largura que cresce com o
49
- * número (`min-w-9 w-auto px-2`, dígitos tabulares): 5726 e 5727 nunca se sobrepõem. Com
49
+ * número (largura mínima igual à altura do tamanho escolhido, dígitos tabulares): 5726 e 5727 nunca se sobrepõem. Com
50
50
  * `href` é `<a>`; sem `href` é `<button type="button">`, que preserva foco e teclado no
51
- * modo controlado. Ajuste a escala por `className` (ex.: `h-7 min-w-7 text-xs`).
51
+ * modo controlado. Ajuste a escala por `size`; a largura mínima acompanha a altura do degrau.
52
52
  */
53
53
  function PaginationLink({ className, isActive, page, disabled, href, size = 'default', children, ...props }: PaginationLinkProps) {
54
54
  const square = typeof size === 'string' && size.startsWith('icon')
55
+ const minWidth = { xs: 'min-w-6', sm: 'min-w-8', default: 'min-w-9', lg: 'min-w-10' }[controlBase(size)]
55
56
  const shared = {
56
57
  'aria-current': isActive ? ('page' as const) : undefined,
57
58
  'aria-label': props['aria-label'] ?? (page !== undefined ? `Página ${page}` : undefined),
@@ -61,7 +62,7 @@ function PaginationLink({ className, isActive, page, disabled, href, size = 'def
61
62
  buttonVariants({ variant: isActive ? 'outline' : 'ghost', size }),
62
63
  // Número: a largura mínima é quadrada e o conteúdo manda no resto. Escala `icon*`
63
64
  // fica quadrada de verdade (sem `has-[>svg]:px-*` do `default`).
64
- !square && 'h-9 w-auto min-w-9 px-2 tabular-nums',
65
+ !square && cn('w-auto px-2 tabular-nums', minWidth),
65
66
  className,
66
67
  ),
67
68
  }
@@ -2,6 +2,10 @@ import * as React from "react";
2
2
  import { Tabs as TabsPrimitive } from "radix-ui";
3
3
 
4
4
  import { cn } from "../../lib/cn.ts";
5
+ import {
6
+ structuralBodyRhythm,
7
+ structuralRegionGap,
8
+ } from "../patterns/surface-anatomy.ts";
5
9
  import { controlHeight, type ControlSize } from "./control.ts";
6
10
 
7
11
  // Divergência da casa (ejetado): o Tabs ganhou `size` — Button e Select têm `sm`, o Tabs
@@ -45,8 +49,10 @@ function Tabs({
45
49
  data-framed={framed}
46
50
  orientation={orientation}
47
51
  className={cn(
48
- "group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
52
+ "group/tabs flex data-[orientation=horizontal]:flex-col",
53
+ structuralRegionGap,
49
54
  framed && "overflow-hidden rounded-xl border",
55
+ framed && orientation === "vertical" && "p-4",
50
56
  className,
51
57
  )}
52
58
  {...props}
@@ -192,10 +198,17 @@ function TabsPanel({
192
198
  className,
193
199
  ...props
194
200
  }: React.ComponentProps<typeof TabsPrimitive.Content>) {
201
+ const framed = React.useContext(TabsFramedContext);
202
+ const orientation = React.useContext(TabsOrientationContext);
195
203
  return (
196
204
  <TabsPrimitive.Content
197
205
  data-slot="tabs-panel"
198
- className={cn("flex-1 outline-none", className)}
206
+ className={cn(
207
+ "flex-1 outline-none data-[state=inactive]:hidden",
208
+ structuralBodyRhythm,
209
+ framed && orientation === "horizontal" && "px-4 pb-4",
210
+ className,
211
+ )}
199
212
  {...props}
200
213
  />
201
214
  );
@@ -19,7 +19,7 @@ const toggleVariants = cva(
19
19
  variant: {
20
20
  default: "bg-transparent",
21
21
  outline:
22
- "border border-input bg-transparent text-muted-foreground hover:bg-interaction-hover hover:text-foreground active:bg-interaction-pressed data-[state=on]:bg-interaction-selected data-[state=on]:text-foreground",
22
+ "border border-input bg-transparent text-muted-foreground hover:bg-interaction-hover/50 hover:text-foreground active:bg-interaction-pressed data-[state=on]:bg-interaction-selected/50 data-[state=on]:text-foreground",
23
23
  },
24
24
  // Alturas da escala de control.ts (`sm` = o mesmo h-8 de Button e Select).
25
25
  size: {