@softize/opus 17.2.0 → 18.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 +55 -1
  2. package/bin/lib/check.mjs +212 -45
  3. package/docs/adr/0011-page-shell-coordinates-persistent-page-chrome.md +4 -0
  4. package/docs/adr/0013-presentation-is-a-portable-action-oriented-artifact.md +14 -2
  5. package/docs/adr/0016-list-collection-header-belongs-to-content.md +80 -0
  6. package/package.json +1 -1
  7. package/registry/skills/build-opus-ui/SKILL.md +30 -20
  8. package/registry/skills/build-opus-ui/references/evaluations.md +9 -3
  9. package/registry/skills/build-opus-ui/references/ui-patterns.md +29 -17
  10. package/src/core/presentation.ts +223 -24
  11. package/src/core/runtime.ts +3 -0
  12. package/src/core/types.ts +2 -0
  13. package/src/mcp/index.ts +1 -0
  14. package/src/ui/components/patterns/action-form-card.tsx +8 -1
  15. package/src/ui/components/patterns/confirm.tsx +194 -157
  16. package/src/ui/components/patterns/content-header.tsx +17 -2
  17. package/src/ui/components/patterns/form-dialog.tsx +28 -14
  18. package/src/ui/components/patterns/form.tsx +340 -222
  19. package/src/ui/components/patterns/list.tsx +43 -44
  20. package/src/ui/components/patterns/page-heading-context.tsx +34 -0
  21. package/src/ui/components/patterns/page-state.tsx +2 -0
  22. package/src/ui/components/patterns/page.tsx +164 -50
  23. package/src/ui/components/patterns/presentation.tsx +140 -84
  24. package/src/ui/components/patterns/surface-header.tsx +5 -6
  25. package/src/ui/components/patterns/trigger.tsx +112 -82
  26. package/src/ui/components/primitives/button.tsx +2 -2
  27. package/src/ui/components/primitives/chat.tsx +19 -5
  28. package/src/ui/components/primitives/control.ts +9 -3
  29. package/src/ui/components/primitives/dialog.tsx +16 -9
  30. package/src/ui/components/primitives/drawer.tsx +9 -6
  31. package/src/ui/docs/content/action-form-card.md +9 -8
  32. package/src/ui/docs/content/action-form-dialog.md +11 -12
  33. package/src/ui/docs/content/action-form.md +25 -25
  34. package/src/ui/docs/content/action-list.md +101 -70
  35. package/src/ui/docs/content/chat.md +4 -4
  36. package/src/ui/docs/content/content.md +29 -13
  37. package/src/ui/docs/content/dialog.md +27 -21
  38. package/src/ui/docs/content/drawer.md +8 -6
  39. package/src/ui/docs/content/page.md +43 -50
  40. package/src/ui/docs/content/presentation.md +39 -28
  41. package/src/ui/docs/doc-client.tsx +1 -1
  42. package/src/ui/meta.ts +4 -4
@@ -38,7 +38,7 @@ import {
38
38
  Calendar as CalendarIcon,
39
39
  Eraser,
40
40
  Filter,
41
- RefreshCw,
41
+ RotateCw,
42
42
  Settings2,
43
43
  Table2,
44
44
  X,
@@ -877,27 +877,27 @@ function AdvancedFiltersDialog({
877
877
  ))}
878
878
  </DialogBody>
879
879
  <DialogFooter>
880
- <Button
881
- variant="outline"
882
- size="sm"
883
- onClick={() => {
884
- const cleared = { ...draft };
885
- for (const [name] of specs) delete cleared[name];
886
- onApply(cleared);
887
- onOpenChange(false);
888
- }}
889
- >
890
- Limpar
891
- </Button>
892
- <Button
893
- size="sm"
894
- onClick={() => {
895
- onApply(draft);
896
- onOpenChange(false);
897
- }}
898
- >
899
- Aplicar
900
- </Button>
880
+ <ButtonGroup mode="spaced" distribution="equal">
881
+ <Button
882
+ variant="outline"
883
+ onClick={() => {
884
+ const cleared = { ...draft };
885
+ for (const [name] of specs) delete cleared[name];
886
+ onApply(cleared);
887
+ onOpenChange(false);
888
+ }}
889
+ >
890
+ Limpar
891
+ </Button>
892
+ <Button
893
+ onClick={() => {
894
+ onApply(draft);
895
+ onOpenChange(false);
896
+ }}
897
+ >
898
+ Aplicar
899
+ </Button>
900
+ </ButtonGroup>
901
901
  </DialogFooter>
902
902
  </DialogContent>
903
903
  </Dialog>
@@ -1083,7 +1083,7 @@ export function ActionFilterBar({
1083
1083
  onClick={() => void onRefresh()}
1084
1084
  disabled={refreshing}
1085
1085
  >
1086
- <RefreshCw
1086
+ <RotateCw
1087
1087
  className={cn(
1088
1088
  "h-3.5 w-3.5",
1089
1089
  refreshing && "animate-spin",
@@ -1845,27 +1845,26 @@ export function ActionList<TInput, TItem>({
1845
1845
  </DialogBody>
1846
1846
  )}
1847
1847
  <DialogFooter>
1848
- <DialogClose initialFocus asChild>
1849
- <Button variant="ghost" size="sm">
1850
- Cancelar
1851
- </Button>
1852
- </DialogClose>
1853
- <DialogClose asChild>
1854
- <Button
1855
- size="sm"
1856
- context={
1857
- confirming.destructive === true ? "danger" : "primary"
1858
- }
1859
- variant="solid"
1860
- onClick={() => {
1861
- const a = confirming;
1862
- setConfirming(null);
1863
- void runBatch(a);
1864
- }}
1865
- >
1866
- {confirming.label}
1867
- </Button>
1868
- </DialogClose>
1848
+ <ButtonGroup mode="spaced" distribution="equal">
1849
+ <DialogClose initialFocus asChild>
1850
+ <Button variant="outline">Cancelar</Button>
1851
+ </DialogClose>
1852
+ <DialogClose asChild>
1853
+ <Button
1854
+ context={
1855
+ confirming.destructive === true ? "danger" : "primary"
1856
+ }
1857
+ variant="solid"
1858
+ onClick={() => {
1859
+ const a = confirming;
1860
+ setConfirming(null);
1861
+ void runBatch(a);
1862
+ }}
1863
+ >
1864
+ {confirming.label}
1865
+ </Button>
1866
+ </DialogClose>
1867
+ </ButtonGroup>
1869
1868
  </DialogFooter>
1870
1869
  </DialogContent>
1871
1870
  </Dialog>
@@ -0,0 +1,34 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useLayoutEffect,
5
+ type ReactElement,
6
+ type ReactNode,
7
+ } from "react";
8
+
9
+ const PageHeadingPresenceContext = createContext<
10
+ (() => () => void) | null
11
+ >(null);
12
+
13
+ export function PageHeadingPresenceProvider({
14
+ register,
15
+ children,
16
+ }: {
17
+ register: () => () => void;
18
+ children: ReactNode;
19
+ }): ReactElement {
20
+ return (
21
+ <PageHeadingPresenceContext.Provider value={register}>
22
+ {children}
23
+ </PageHeadingPresenceContext.Provider>
24
+ );
25
+ }
26
+
27
+ /** Integração interna para headings que podem nascer dentro de boundaries intermediários. */
28
+ export function usePageHeadingPresence(active = true): void {
29
+ const register = useContext(PageHeadingPresenceContext);
30
+ useLayoutEffect(() => {
31
+ if (!active || register === null) return;
32
+ return register();
33
+ }, [active, register]);
34
+ }
@@ -14,6 +14,7 @@ import {
14
14
  type ReactNode,
15
15
  } from "react";
16
16
  import { cn } from "../../lib/cn.ts";
17
+ import { usePageHeadingPresence } from "./page-heading-context.tsx";
17
18
  import {
18
19
  DEFAULT_RETRY_LABEL,
19
20
  EmptySurface,
@@ -77,6 +78,7 @@ export function PageState({
77
78
  className,
78
79
  }: PageStateProps): React.ReactElement {
79
80
  const registerPresence = useContext(PageStatePresenceContext);
81
+ usePageHeadingPresence(status !== "ready");
80
82
  useLayoutEffect(() => {
81
83
  if (status === "ready" || registerPresence === null) return;
82
84
  return registerPresence();
@@ -11,6 +11,7 @@ import {
11
11
  isValidElement,
12
12
  useCallback,
13
13
  useContext,
14
+ useLayoutEffect,
14
15
  useRef,
15
16
  useState,
16
17
  type HTMLAttributes,
@@ -22,8 +23,13 @@ import { createPortal } from "react-dom";
22
23
  import { ArrowLeft } from "lucide-react";
23
24
  import { cn } from "../../lib/cn.ts";
24
25
  import { Button } from "../primitives/button.tsx";
26
+ import { Content } from "./content-header.tsx";
27
+ import {
28
+ PageHeadingPresenceProvider,
29
+ usePageHeadingPresence,
30
+ } from "./page-heading-context.tsx";
25
31
  import { SurfaceHeader, surfaceHeaderClasses } from "./surface-header.tsx";
26
- import { PageStatePresenceProvider } from "./page-state.tsx";
32
+ import { PageState, PageStatePresenceProvider } from "./page-state.tsx";
27
33
 
28
34
  interface PageContextValue {}
29
35
 
@@ -36,8 +42,8 @@ const PageActionsTargetContext = createContext<HTMLElement | null>(null);
36
42
  const PageNavigationTargetContext = createContext<HTMLElement | null>(null);
37
43
  const PageShellContext = createContext(false);
38
44
  const pageBarClassName =
39
- "flex h-12 shrink-0 items-center border-b border-border bg-background text-foreground";
40
- const pageBarContentClassName = "w-full py-2";
45
+ "flex h-[3.75rem] shrink-0 items-center border-b border-border bg-background text-foreground";
46
+ const pageBarContentClassName = "w-full p-3";
41
47
 
42
48
  /** Uso interno de patterns que precisam adaptar a Page ao shell sem duplicar sua anatomia. */
43
49
  export function useInsidePageShell(): boolean {
@@ -213,6 +219,59 @@ interface PageComposedProps extends PageBaseProps {
213
219
 
214
220
  export type PageProps = PageShorthandProps | PageComposedProps;
215
221
 
222
+ function countDirectChildrenOfType(
223
+ node: ReactElement,
224
+ type: typeof PageTitle,
225
+ ): number {
226
+ return Children.toArray(
227
+ (node.props as { children?: ReactNode }).children,
228
+ ).filter((child) => isValidElement(child) && child.type === type).length;
229
+ }
230
+
231
+ function countPageBodyHeadings(node: ReactNode): {
232
+ content: number;
233
+ state: number;
234
+ } {
235
+ return Children.toArray(node).reduce(
236
+ (total, child) => {
237
+ if (!isValidElement(child)) return total;
238
+ const props = child.props as {
239
+ children?: ReactNode;
240
+ level?: number;
241
+ status?: string;
242
+ };
243
+ if (child.type === PageState && props.status !== "ready") {
244
+ return {
245
+ content: total.content,
246
+ state: total.state + 1,
247
+ };
248
+ }
249
+ const nested = countPageBodyHeadings(props.children);
250
+ return {
251
+ content:
252
+ total.content +
253
+ (child.type === Content && props.level === 1 ? 1 : 0) +
254
+ nested.content,
255
+ state:
256
+ total.state + nested.state,
257
+ };
258
+ },
259
+ { content: 0, state: 0 },
260
+ );
261
+ }
262
+
263
+ function hasOpaquePageBodyContent(node: ReactNode): boolean {
264
+ return Children.toArray(node).some((child) => {
265
+ if (!isValidElement(child)) return false;
266
+ if (typeof child.type !== "string") {
267
+ return child.type !== Content && child.type !== PageState;
268
+ }
269
+ return hasOpaquePageBodyContent(
270
+ (child.props as { children?: ReactNode }).children,
271
+ );
272
+ });
273
+ }
274
+
216
275
  export function Page({
217
276
  title,
218
277
  actions,
@@ -236,7 +295,9 @@ export function Page({
236
295
  (node) => isValidElement(node) && node.type === PageIntro,
237
296
  );
238
297
  const activeStateCount = useRef(0);
298
+ const headingCount = useRef(0);
239
299
  const [integralState, setIntegralState] = useState(false);
300
+ const [, setHeadingRevision] = useState(0);
240
301
  const registerIntegralState = useCallback(() => {
241
302
  activeStateCount.current += 1;
242
303
  setIntegralState(true);
@@ -248,6 +309,17 @@ export function Page({
248
309
  setIntegralState(activeStateCount.current > 0);
249
310
  };
250
311
  }, []);
312
+ const registerHeading = useCallback(() => {
313
+ headingCount.current += 1;
314
+ setHeadingRevision((revision) => revision + 1);
315
+ let registered = true;
316
+ return () => {
317
+ if (!registered) return;
318
+ registered = false;
319
+ headingCount.current = Math.max(0, headingCount.current - 1);
320
+ setHeadingRevision((revision) => revision + 1);
321
+ };
322
+ }, []);
251
323
  if (
252
324
  shorthand &&
253
325
  nodes.some(
@@ -263,46 +335,78 @@ export function Page({
263
335
  );
264
336
  }
265
337
  if (!shorthand) {
266
- const validIntroComposition =
267
- !insideShell &&
268
- intros.length === 1 &&
269
- headers.length === 0 &&
270
- bodies.length === 1 &&
271
- footers.length <= 1 &&
272
- intros.length + bodies.length + footers.length === nodes.length;
273
- const validStandaloneComposition =
274
- !insideShell &&
275
- headers.length === 1 &&
276
- intros.length === 0 &&
277
- bodies.length === 1 &&
278
- footers.length <= 1 &&
279
- headers.length + bodies.length + footers.length === nodes.length;
280
- const validShellComposition =
281
- insideShell &&
282
- headers.length === 0 &&
338
+ const validComposition =
339
+ headers.length <= 1 &&
340
+ intros.length <= 1 &&
283
341
  bodies.length === 1 &&
284
- intros.length === 1 &&
285
342
  footers.length <= 1 &&
286
343
  headers.length + intros.length + bodies.length + footers.length ===
287
344
  nodes.length;
288
- if (
289
- !validIntroComposition &&
290
- !validStandaloneComposition &&
291
- !validShellComposition
292
- ) {
345
+ if (!validComposition) {
293
346
  throw new Error(
294
- insideShell
295
- ? "Page explícito dentro de PageShell exige um PageIntro, um PageBody e aceita no máximo um PageFooter como filhos diretos."
296
- : "Page explícito exige um PageHeader ou PageIntro, um PageBody e no máximo um PageFooter como filhos diretos.",
347
+ "Page explícito exige um PageBody e aceita no máximo um PageHeader, um PageIntro e um PageFooter como filhos diretos.",
348
+ );
349
+ }
350
+ const bodyHeadingSources = bodies.reduce<{
351
+ content: number;
352
+ state: number;
353
+ }>(
354
+ (total, body) => {
355
+ const count = countPageBodyHeadings(
356
+ (body as ReactElement<{ children?: ReactNode }>).props.children,
357
+ );
358
+ return {
359
+ content: total.content + count.content,
360
+ state: total.state + count.state,
361
+ };
362
+ },
363
+ { content: 0, state: 0 },
364
+ );
365
+ const structuralHeadingSources =
366
+ headers.reduce<number>(
367
+ (total, header) =>
368
+ total + countDirectChildrenOfType(header as ReactElement, PageTitle),
369
+ 0,
370
+ ) +
371
+ intros.reduce<number>(
372
+ (total, intro) =>
373
+ total + countDirectChildrenOfType(intro as ReactElement, PageTitle),
374
+ 0,
375
+ ) + bodyHeadingSources.content;
376
+ const headingSources =
377
+ bodyHeadingSources.state > 0
378
+ ? bodyHeadingSources.state + bodyHeadingSources.content
379
+ : structuralHeadingSources;
380
+ const hasOpaqueBodyContent = bodies.some((body) =>
381
+ hasOpaquePageBodyContent(
382
+ (body as ReactElement<{ children?: ReactNode }>).props.children,
383
+ ),
384
+ );
385
+ if (headingSources > 1 || (headingSources === 0 && !hasOpaqueBodyContent)) {
386
+ throw new Error(
387
+ "Page explícito exige exatamente um heading principal em PageHeader, PageIntro, Content level={1} ou PageState ativo dentro de PageBody.",
297
388
  );
298
389
  }
299
390
  }
391
+ useLayoutEffect(() => {
392
+ const hasActiveIntegralState = activeStateCount.current > 0;
393
+ if (integralState !== hasActiveIntegralState) return;
394
+ if (headingCount.current !== 1) {
395
+ throw new Error(
396
+ "Page explícito exige exatamente um heading principal em PageHeader, PageIntro, Content level={1} ou PageState ativo dentro de PageBody.",
397
+ );
398
+ }
399
+ });
300
400
  const content =
301
401
  shorthand && insideShell ? (
302
402
  <>
403
+ {actions !== undefined && (
404
+ <PageHeader>
405
+ <PageActions>{actions}</PageActions>
406
+ </PageHeader>
407
+ )}
303
408
  <PageIntro>
304
409
  <PageTitle>{title}</PageTitle>
305
- {actions !== undefined && <PageActions>{actions}</PageActions>}
306
410
  </PageIntro>
307
411
  <PageBody>{children}</PageBody>
308
412
  </>
@@ -322,26 +426,28 @@ export function Page({
322
426
  <PageContext.Provider value={{}}>
323
427
  <PageIntegralStateContext.Provider value={integralState}>
324
428
  <PageStatePresenceProvider register={registerIntegralState}>
325
- <main
326
- data-slot="page"
327
- className={cn(
328
- "min-w-0 flex-1",
329
- insideShell && "min-h-full",
330
- integralState && "flex min-h-full flex-col",
331
- )}
332
- {...props}
333
- >
334
- <div
429
+ <PageHeadingPresenceProvider register={registerHeading}>
430
+ <main
431
+ data-slot="page"
335
432
  className={cn(
336
- "mx-auto max-w-7xl space-y-6 px-8 py-8",
337
- integralState &&
338
- "flex min-h-full w-full flex-1 flex-col space-y-0",
339
- className,
433
+ "min-w-0 flex-1",
434
+ insideShell && "min-h-full",
435
+ integralState && "flex min-h-full flex-col",
340
436
  )}
437
+ {...props}
341
438
  >
342
- {content}
343
- </div>
344
- </main>
439
+ <div
440
+ className={cn(
441
+ "mx-auto max-w-7xl space-y-6 px-8 py-8",
442
+ integralState &&
443
+ "flex min-h-full w-full flex-1 flex-col space-y-0",
444
+ className,
445
+ )}
446
+ >
447
+ {content}
448
+ </div>
449
+ </main>
450
+ </PageHeadingPresenceProvider>
345
451
  </PageStatePresenceProvider>
346
452
  </PageIntegralStateContext.Provider>
347
453
  </PageContext.Provider>
@@ -394,7 +500,10 @@ export function PageIntro({
394
500
  );
395
501
  }
396
502
 
397
- /** A anatomia é a de `SurfaceHeader`, a mesma de `ContentHeader`; só os slots mudam de nome. */
503
+ /**
504
+ * Chrome da Page: navegação e ações persistentes. Fora de PageShell também pode
505
+ * carregar o título; dentro dele, os slots são projetados na barra do shell.
506
+ */
398
507
  export interface PageHeaderProps extends HTMLAttributes<HTMLDivElement> {}
399
508
 
400
509
  export function PageHeader({
@@ -425,7 +534,11 @@ export function PageHeader({
425
534
  actions: PageActions,
426
535
  }}
427
536
  leadingPlacement="inline"
428
- className={className}
537
+ titleRequired={false}
538
+ className={cn(
539
+ "flex h-[3.75rem] items-center border-b p-3 [&>*]:w-full",
540
+ className,
541
+ )}
429
542
  {...props}
430
543
  >
431
544
  {children}
@@ -440,6 +553,7 @@ export function PageTitle({
440
553
  }: HTMLAttributes<HTMLHeadingElement>): ReactElement {
441
554
  const variant = useContext(PageHeaderContext);
442
555
  requireParent(variant !== null, "PageTitle", "PageHeader ou PageIntro");
556
+ usePageHeadingPresence();
443
557
  return (
444
558
  <h1
445
559
  data-slot="page-title"
@@ -570,7 +684,7 @@ export function PageFooter({
570
684
  <div
571
685
  data-slot="page-footer"
572
686
  className={cn(
573
- "mt-auto flex shrink-0 items-center justify-end border-t bg-muted/30 px-8 py-4",
687
+ "mt-auto flex h-[3.75rem] shrink-0 items-center justify-end border-t bg-muted/30 p-3",
574
688
  className,
575
689
  )}
576
690
  {...props}