@softize/opus 21.0.1 → 22.1.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 (62) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/bin/cli.mjs +9 -1
  3. package/bin/lib/check.mjs +37 -29
  4. package/docs/adr/0004-page-content-state-is-composed.md +3 -2
  5. package/docs/adr/0005-structural-surfaces-share-an-explicit-anatomy.md +3 -0
  6. package/docs/adr/0006-semantic-context-precedes-visual-variant.md +23 -14
  7. package/docs/adr/0011-page-shell-coordinates-persistent-page-chrome.md +5 -0
  8. package/docs/adr/0013-presentation-is-a-portable-action-oriented-artifact.md +7 -4
  9. package/docs/adr/0016-list-collection-header-belongs-to-content.md +13 -8
  10. package/docs/adr/0023-surfaces-share-one-anatomy-and-two-layouts.md +27 -26
  11. package/docs/adr/0025-content-frame-treatment-does-not-change-semantics.md +1 -0
  12. package/docs/adr/0029-page-header-is-the-single-page-introduction.md +71 -0
  13. package/docs/adr/0030-framed-content-can-use-sectioned-anatomy.md +89 -0
  14. package/docs/adr/0031-detail-dividers-own-the-tabular-label-treatment.md +61 -0
  15. package/docs/adr/0032-tab-baseline-respects-the-container-inset.md +59 -0
  16. package/docs/adr/0033-drawer-uses-an-inset-structural-frame.md +72 -0
  17. package/docs/adr/0034-tabs-can-own-a-frame-without-owning-a-surface.md +60 -0
  18. package/docs/adr/0035-button-group-separates-frame-relationship-and-distribution.md +63 -0
  19. package/docs/adr/0036-tabs-declares-trigger-distribution.md +58 -0
  20. package/docs/adr/0037-design-data-lives-outside-the-contract.md +117 -0
  21. package/docs/radius-scale.md +5 -2
  22. package/package.json +5 -1
  23. package/registry/skills/build-opus-ui/SKILL.md +9 -6
  24. package/registry/skills/build-opus-ui/references/evaluations.md +5 -2
  25. package/registry/skills/build-opus-ui/references/ui-patterns.md +14 -16
  26. package/registry/skills/create-opus-action/references/contract-and-binding.md +5 -3
  27. package/registry/skills/create-opus-action/scripts/scaffold.mjs +0 -4
  28. package/registry/skills/test-opus-action/SKILL.md +2 -2
  29. package/registry/skills/test-opus-action/references/harness.md +2 -2
  30. package/registry/templates/app/src/App.tsx +1 -1
  31. package/registry/templates/app/src/domains/tasks/actions/list.test.ts +2 -19
  32. package/registry/templates/app/src/domains/tasks/actions/list.ts +3 -5
  33. package/registry/templates/app/vite.config.ts +3 -2
  34. package/src/core/domain.ts +2 -2
  35. package/src/core/types.ts +6 -20
  36. package/src/design/index.ts +179 -0
  37. package/src/testing/fake.ts +2 -2
  38. package/src/testing/index.ts +7 -3
  39. package/src/ui/components/patterns/content-header.tsx +51 -13
  40. package/src/ui/components/patterns/list.tsx +51 -33
  41. package/src/ui/components/patterns/page.tsx +139 -251
  42. package/src/ui/components/patterns/presentation.tsx +2 -6
  43. package/src/ui/components/primitives/button-group.tsx +80 -8
  44. package/src/ui/components/primitives/button.tsx +85 -60
  45. package/src/ui/components/primitives/detail.tsx +63 -57
  46. package/src/ui/components/primitives/dialog.tsx +8 -11
  47. package/src/ui/components/primitives/drawer.tsx +16 -12
  48. package/src/ui/components/primitives/tabs.tsx +123 -54
  49. package/src/ui/docs/content/actions.md +49 -43
  50. package/src/ui/docs/content/button.md +55 -29
  51. package/src/ui/docs/content/content.md +61 -36
  52. package/src/ui/docs/content/detail.md +28 -19
  53. package/src/ui/docs/content/drawer.md +53 -47
  54. package/src/ui/docs/content/input.md +51 -35
  55. package/src/ui/docs/content/page.md +84 -95
  56. package/src/ui/docs/content/pane.md +83 -63
  57. package/src/ui/docs/content/presentation.md +20 -20
  58. package/src/ui/docs/content/tabs.md +107 -36
  59. package/src/ui/docs/content/testing.md +9 -25
  60. package/src/ui/meta.ts +5 -5
  61. package/src/ui/react.tsx +0 -1
  62. package/src/vite/design.ts +133 -23
@@ -14,10 +14,8 @@ import { IconTile, type IconTileProps } from "../primitives/icon-tile.tsx";
14
14
  import { fillRegionClassName, useFillRegion } from "./fill-region.ts";
15
15
  import { headingTags } from "./heading-level.ts";
16
16
  import { usePageHeadingPresence } from "./page-heading-context.tsx";
17
- import {
18
- SurfaceMedia,
19
- type SurfaceMediaProps,
20
- } from "./surface-media.tsx";
17
+ import type { SurfaceLayout } from "./surface-anatomy.ts";
18
+ import { SurfaceMedia, type SurfaceMediaProps } from "./surface-media.tsx";
21
19
  import {
22
20
  SurfaceHeader,
23
21
  surfaceHeaderClasses,
@@ -33,6 +31,7 @@ interface ContentContextValue {
33
31
  element: ContentElement;
34
32
  frameVariant: ContentFrameVariant;
35
33
  framed: boolean;
34
+ layout: SurfaceLayout;
36
35
  level: ContentHeaderLevel;
37
36
  titleId: string;
38
37
  variant: ContentHeaderVariant;
@@ -54,6 +53,8 @@ function requiredContext<T>(
54
53
  interface ContentSharedProps {
55
54
  /** Aplica a moldura estrutural da região sem transferi-la ao conteúdo interno. */
56
55
  framed?: boolean;
56
+ /** Anatomia interna: contínua (default) ou com header separado do corpo. */
57
+ layout?: SurfaceLayout;
57
58
  /** Hierarquia visual: `section` (default) ou `page`, a mesma de PageHeader. */
58
59
  variant?: ContentHeaderVariant;
59
60
  }
@@ -129,6 +130,7 @@ export function Content({
129
130
  level = 2,
130
131
  variant = "section",
131
132
  framed = false,
133
+ layout = "continuous",
132
134
  frameVariant = "default",
133
135
  children,
134
136
  className,
@@ -140,6 +142,7 @@ export function Content({
140
142
  element: as,
141
143
  frameVariant,
142
144
  framed,
145
+ layout,
143
146
  level: level as ContentHeaderLevel,
144
147
  titleId,
145
148
  variant,
@@ -147,6 +150,15 @@ export function Content({
147
150
  const shorthand = title !== undefined;
148
151
  const nodes = Children.toArray(children);
149
152
 
153
+ if (layout === "sectioned" && !framed) {
154
+ throw new Error('Content layout="sectioned" exige framed.');
155
+ }
156
+ if (layout === "sectioned" && frameVariant === "legend") {
157
+ throw new Error(
158
+ 'Content não combina layout="sectioned" com frameVariant="legend".',
159
+ );
160
+ }
161
+
150
162
  if (
151
163
  shorthand &&
152
164
  nodes.some((node) => isValidElement(node) && node.type === ContentHeader)
@@ -200,16 +212,22 @@ export function Content({
200
212
  data-as="fieldset"
201
213
  data-framed={framed}
202
214
  data-frame-variant={frameVariant}
215
+ data-layout={layout}
203
216
  {...fill.attributes}
204
217
  aria-labelledby={ariaLabelledBy}
205
218
  className={cn(
206
- !framed && "space-y-3",
219
+ !framed && layout === "continuous" && "space-y-3",
207
220
  framed &&
221
+ layout === "continuous" &&
208
222
  frameVariant === "default" &&
209
- "rounded-lg border border-border p-4",
223
+ "rounded-xl border border-border p-4",
210
224
  framed &&
225
+ layout === "continuous" &&
211
226
  frameVariant === "legend" &&
212
- "space-y-3 rounded-lg border border-border px-4 pb-4",
227
+ "space-y-3 rounded-xl border border-border px-4 pb-4",
228
+ framed &&
229
+ layout === "sectioned" &&
230
+ "overflow-hidden rounded-xl border border-border",
213
231
  fill.filling && fillRegionClassName,
214
232
  className,
215
233
  )}
@@ -223,16 +241,22 @@ export function Content({
223
241
  data-as="section"
224
242
  data-framed={framed}
225
243
  data-frame-variant={frameVariant}
244
+ data-layout={layout}
226
245
  {...fill.attributes}
227
246
  aria-labelledby={ariaLabelledBy}
228
247
  className={cn(
229
- "space-y-3",
248
+ layout === "continuous" && "space-y-3",
230
249
  framed &&
250
+ layout === "continuous" &&
231
251
  frameVariant === "default" &&
232
- "rounded-lg border border-border p-4",
252
+ "rounded-xl border border-border p-4",
233
253
  framed &&
254
+ layout === "continuous" &&
234
255
  frameVariant === "legend" &&
235
- "rounded-lg border border-border px-4 pb-4",
256
+ "rounded-xl border border-border px-4 pb-4",
257
+ framed &&
258
+ layout === "sectioned" &&
259
+ "overflow-hidden rounded-xl border border-border",
236
260
  fill.filling && fillRegionClassName,
237
261
  className,
238
262
  )}
@@ -273,11 +297,16 @@ export function ContentHeader({
273
297
  invalidMessage="ContentHeader de fieldset aceita somente ContentMedia e ContentTitle; contador, descrição e ações ficam fora da legenda."
274
298
  className={cn(
275
299
  content.framed &&
300
+ content.layout === "continuous" &&
276
301
  content.frameVariant === "default" &&
277
302
  "float-left w-full",
278
303
  content.framed &&
304
+ content.layout === "continuous" &&
279
305
  content.frameVariant === "legend" &&
280
306
  "-ml-2 w-auto px-2",
307
+ content.framed &&
308
+ content.layout === "sectioned" &&
309
+ "float-left w-full border-b border-border p-3",
281
310
  className,
282
311
  )}
283
312
  {...props}
@@ -302,16 +331,22 @@ export function ContentHeader({
302
331
  mediaPlacement="heading"
303
332
  className={cn(
304
333
  content.framed &&
334
+ content.layout === "continuous" &&
305
335
  content.frameVariant === "legend" &&
306
336
  "flex items-center [&>[data-slot=content-actions]]:-mr-2 [&>[data-slot=content-actions]]:bg-[var(--surface-background,var(--background))] [&>[data-slot=content-actions]]:px-2 [&>[data-slot=content-heading]]:-ml-2 [&>[data-slot=content-heading]]:w-fit [&>[data-slot=content-heading]]:max-w-full [&>[data-slot=content-heading]]:bg-[var(--surface-background,var(--background))] [&>[data-slot=content-heading]]:px-2",
307
337
  content.framed &&
338
+ content.layout === "continuous" &&
308
339
  content.frameVariant === "legend" &&
309
340
  content.variant === "section" &&
310
341
  "-mt-4 min-h-8",
311
342
  content.framed &&
343
+ content.layout === "continuous" &&
312
344
  content.frameVariant === "legend" &&
313
345
  content.variant === "page" &&
314
346
  "-mt-5 min-h-10",
347
+ content.framed &&
348
+ content.layout === "sectioned" &&
349
+ "border-b border-border p-3",
315
350
  content.variant === "page" &&
316
351
  "flex-row items-center justify-start gap-3",
317
352
  className,
@@ -370,9 +405,7 @@ export function ContentMedia({
370
405
  <SurfaceMedia
371
406
  slot="content-media"
372
407
  {...props}
373
- aria-hidden={
374
- context.element === "fieldset" ? true : props["aria-hidden"]
375
- }
408
+ aria-hidden={context.element === "fieldset" ? true : props["aria-hidden"]}
376
409
  />
377
410
  );
378
411
  }
@@ -481,8 +514,13 @@ export function ContentBody({
481
514
  content.element === "fieldset" && "space-y-3",
482
515
  content.element === "fieldset" &&
483
516
  content.framed &&
517
+ content.layout === "continuous" &&
484
518
  content.frameVariant === "default" &&
485
519
  "clear-left pt-3",
520
+ content.element === "fieldset" &&
521
+ content.framed &&
522
+ content.layout === "sectioned" &&
523
+ "clear-left",
486
524
  fill.filling && fillRegionClassName,
487
525
  className,
488
526
  )}
@@ -993,26 +993,35 @@ function PeriodControl({
993
993
  variant={embedded ? "ghost" : "outline"}
994
994
  data-slot="action-list-period"
995
995
  className={cn(
996
- "max-w-full gap-1.5 overflow-hidden bg-muted/30 px-3 hover:bg-muted/30 hover:text-foreground dark:bg-input/30 dark:hover:bg-input/30",
997
- clearable && "pr-10",
996
+ "relative max-w-full gap-1.5 overflow-hidden bg-muted/30 px-3 hover:bg-muted/30 hover:text-foreground dark:bg-input/30 dark:hover:bg-input/30",
997
+ // O X e o chevron ocupam posições explícitas na ponta do controle. O
998
+ // padding reserva os dois sem depender da largura do texto selecionado.
999
+ clearable && "pr-16",
998
1000
  embedded &&
999
1001
  "gap-1 rounded-none border-0 bg-transparent pl-0 pr-0 font-normal shadow-none hover:bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-transparent dark:hover:bg-transparent",
1000
1002
  )}
1001
1003
  >
1002
- {embedded ? null : <CalendarIcon className="h-3.5 w-3.5" />}
1003
- <span className="min-w-0 truncate">{label}</span>
1004
- {embedded ? (
1005
- <span className="shrink-0 opacity-50">
1006
- <ChevronDown className="size-4" aria-hidden="true" />
1004
+ {embedded ? null : (
1005
+ <span className="shrink-0">
1006
+ <CalendarIcon className="h-3.5 w-3.5" />
1007
1007
  </span>
1008
- ) : null}
1008
+ )}
1009
+ <span className="min-w-0 truncate">{label}</span>
1010
+ <span
1011
+ className={cn(
1012
+ "shrink-0 opacity-50",
1013
+ clearable && "pointer-events-none absolute right-8",
1014
+ )}
1015
+ >
1016
+ <ChevronDown className="size-4" aria-hidden="true" />
1017
+ </span>
1009
1018
  </Button>
1010
1019
  </PopoverTrigger>
1011
1020
  {clearable ? (
1012
1021
  <InputGroupButton
1013
1022
  size="icon-xs"
1014
1023
  aria-label="Limpar período"
1015
- className="absolute top-1/2 right-1.5 -translate-y-1/2 text-muted-foreground hover:text-foreground"
1024
+ className="absolute top-1/2 right-2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
1016
1025
  onClick={() => {
1017
1026
  setOpen(false);
1018
1027
  setShowCalendar(false);
@@ -1422,7 +1431,7 @@ export function ActionFilterBar({
1422
1431
  <div
1423
1432
  data-slot="action-filter-period-group"
1424
1433
  className={cn(
1425
- "flex min-h-9 w-fit min-w-0 max-w-full items-center rounded-md border border-input bg-muted/30 text-foreground transition-[color,box-shadow] focus-within:border-ring focus-within:ring-[0.1875rem] focus-within:ring-ring/50 dark:bg-input/30",
1434
+ "flex min-h-9 w-fit min-w-0 max-w-full items-center rounded-md border border-input bg-muted/30 text-foreground transition-[color,box-shadow] focus-within:border-ring focus-within:ring-[0.1875rem] focus-within:ring-ring/50 has-[[data-state=open]]:border-ring has-[[data-state=open]]:ring-[0.1875rem] has-[[data-state=open]]:ring-ring/50 dark:bg-input/30",
1426
1435
  !periodGroupClearable && "pr-3",
1427
1436
  )}
1428
1437
  >
@@ -1430,6 +1439,31 @@ export function ActionFilterBar({
1430
1439
  className="ml-3 size-3.5 shrink-0 text-muted-foreground"
1431
1440
  aria-hidden="true"
1432
1441
  />
1442
+ <div className="pl-2">
1443
+ <PeriodControl
1444
+ periods={periods}
1445
+ period={activePeriod}
1446
+ from={state.from}
1447
+ to={state.to}
1448
+ clearable={false}
1449
+ embedded
1450
+ onChange={(next) =>
1451
+ onStateChange({
1452
+ ...state,
1453
+ ...next,
1454
+ period:
1455
+ next.period === defaultPeriod
1456
+ ? null
1457
+ : next.period,
1458
+ })
1459
+ }
1460
+ />
1461
+ </div>
1462
+ <Separator
1463
+ data-slot="action-filter-period-separator"
1464
+ orientation="vertical"
1465
+ className="mx-2 self-center bg-input data-[orientation=vertical]:h-4"
1466
+ />
1433
1467
  <FilterField
1434
1468
  name={name}
1435
1469
  spec={spec}
@@ -1439,28 +1473,7 @@ export function ActionFilterBar({
1439
1473
  options={optionsFor(name, spec, filterOptions)}
1440
1474
  defaultValue={filterDefaults?.[name]}
1441
1475
  embedded
1442
- className="w-fit min-w-0 max-w-80 [&_[data-slot=select-control]]:gap-1 [&_[data-slot=select-control]]:rounded-none [&_[data-slot=select-control]]:bg-transparent [&_[data-slot=select-control]]:pl-2 [&_[data-slot=select-control]]:pr-0 [&_[data-slot=select-control]]:font-normal [&_[data-slot=select-control]]:text-foreground [&_[data-slot=select-control]]:shadow-none [&_[data-slot=select-control]:hover]:bg-transparent [&_[data-slot=select-control]:focus-within]:ring-0 [&_[data-slot=select-control]:focus-within]:ring-offset-0"
1443
- />
1444
- <Separator
1445
- data-slot="action-filter-period-separator"
1446
- orientation="vertical"
1447
- className="ml-1.5 mr-2.5 h-4 shrink-0"
1448
- />
1449
- <PeriodControl
1450
- periods={periods}
1451
- period={activePeriod}
1452
- from={state.from}
1453
- to={state.to}
1454
- clearable={false}
1455
- embedded
1456
- onChange={(next) =>
1457
- onStateChange({
1458
- ...state,
1459
- ...next,
1460
- period:
1461
- next.period === defaultPeriod ? null : next.period,
1462
- })
1463
- }
1476
+ className="w-fit min-w-0 max-w-80 [&_[data-slot=select-control]]:gap-1 [&_[data-slot=select-control]]:rounded-none [&_[data-slot=select-control]]:bg-transparent [&_[data-slot=select-control]]:pl-0 [&_[data-slot=select-control]]:pr-0 [&_[data-slot=select-control]]:font-normal [&_[data-slot=select-control]]:text-foreground [&_[data-slot=select-control]]:shadow-none [&_[data-slot=select-control]:hover]:bg-transparent [&_[data-slot=select-control]:focus-within]:ring-0 [&_[data-slot=select-control]:focus-within]:ring-offset-0"
1464
1477
  />
1465
1478
  {periodGroupClearable ? (
1466
1479
  <InputGroupButton
@@ -2381,7 +2394,12 @@ export function ActionList<TInput, TItem>({
2381
2394
  linhas: a dos resultados absorve a altura que sobra, a do rodapé se mede
2382
2395
  pelo conteúdo. Em `flow` a primeira linha também é `1fr`, mas o bloco não
2383
2396
  tem altura própria e ela vira a altura do conteúdo. */}
2384
- <div className={cn("grid min-h-0 grid-rows-[1fr_auto]", layout === "fill" && "flex-1")}>
2397
+ <div
2398
+ className={cn(
2399
+ "grid min-h-0 grid-rows-[1fr_auto]",
2400
+ layout === "fill" && "flex-1",
2401
+ )}
2402
+ >
2385
2403
  {/* A rolagem mora aqui, nos dois eixos: é este elemento que o `sticky` do
2386
2404
  cabeçalho enxerga. Um contêiner de rolagem entre ele e a tabela roubaria
2387
2405
  essa referência e o cabeçalho voltaria a subir junto com as linhas.