@graph8/sdk 0.358.0 → 0.362.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 (110) hide show
  1. package/blocks/_foundation/README.md +108 -0
  2. package/blocks/_foundation/block.json +160 -12
  3. package/blocks/_foundation/components/api-error.tsx +62 -18
  4. package/blocks/_foundation/components/empty-state.tsx +39 -7
  5. package/blocks/_foundation/components/screen/bar-list.tsx +71 -0
  6. package/blocks/_foundation/components/screen/detail-panel.tsx +114 -0
  7. package/blocks/_foundation/components/screen/entity-avatar.tsx +56 -0
  8. package/blocks/_foundation/components/screen/form-sheet.tsx +73 -0
  9. package/blocks/_foundation/components/screen/index.ts +21 -0
  10. package/blocks/_foundation/components/screen/key-value-list.tsx +65 -0
  11. package/blocks/_foundation/components/screen/screen-layout.tsx +124 -0
  12. package/blocks/_foundation/components/screen/section-card.tsx +47 -0
  13. package/blocks/_foundation/components/screen/sparkline.tsx +77 -0
  14. package/blocks/_foundation/components/screen/split-view.tsx +125 -0
  15. package/blocks/_foundation/components/screen/stat-strip.tsx +121 -0
  16. package/blocks/_foundation/components/screen/status-pill.tsx +104 -0
  17. package/blocks/_foundation/components/screen/time-series-chart.tsx +114 -0
  18. package/blocks/_foundation/components/screen/toolbar.tsx +144 -0
  19. package/blocks/_foundation/hooks/use-url-state.ts +74 -0
  20. package/blocks/appointments/README.md +21 -1
  21. package/blocks/appointments/block.json +39 -2
  22. package/blocks/appointments/components/event-type-related.tsx +75 -0
  23. package/blocks/appointments/components/meeting-list.tsx +8 -4
  24. package/blocks/appointments/components/meetings-screen.tsx +154 -0
  25. package/blocks/appointments/components/scheduling-screen.tsx +121 -0
  26. package/blocks/appointments/hooks/use-event-types.ts +17 -0
  27. package/blocks/block.types.ts +9 -0
  28. package/blocks/deals/README.md +34 -3
  29. package/blocks/deals/block.json +39 -4
  30. package/blocks/deals/components/deal-detail.tsx +4 -2
  31. package/blocks/deals/components/deal-related.tsx +87 -0
  32. package/blocks/deals/components/deals-screen.tsx +293 -0
  33. package/blocks/deals/components/pipeline-board.tsx +4 -2
  34. package/blocks/deals/hooks/use-default-owner.ts +60 -0
  35. package/blocks/deals/lib/deals-types.ts +14 -0
  36. package/blocks/forms/README.md +12 -0
  37. package/blocks/forms/block.json +9 -1
  38. package/blocks/forms/components/forms-screen.tsx +350 -0
  39. package/blocks/forms/components/submissions-table.tsx +13 -5
  40. package/blocks/lifecycle/README.md +16 -0
  41. package/blocks/lifecycle/block.json +23 -1
  42. package/blocks/lifecycle/components/account-related.tsx +66 -0
  43. package/blocks/lifecycle/components/lifecycle-screen.tsx +147 -0
  44. package/blocks/newsletter/README.md +13 -0
  45. package/blocks/newsletter/block.json +9 -1
  46. package/blocks/newsletter/components/newsletter-screen.tsx +402 -0
  47. package/blocks/newsletter/components/subscriber-table.tsx +12 -6
  48. package/blocks/nurture/README.md +12 -0
  49. package/blocks/nurture/block.json +9 -1
  50. package/blocks/nurture/components/nurture-screen.tsx +279 -0
  51. package/blocks/pages/README.md +12 -0
  52. package/blocks/pages/block.json +9 -1
  53. package/blocks/pages/components/content-library.tsx +37 -7
  54. package/blocks/pages/components/page-list.tsx +4 -2
  55. package/blocks/pages/components/pages-screen.tsx +154 -0
  56. package/blocks/quotes/README.md +17 -0
  57. package/blocks/quotes/block.json +26 -2
  58. package/blocks/quotes/components/quote-list.tsx +7 -3
  59. package/blocks/quotes/components/quote-related.tsx +120 -0
  60. package/blocks/quotes/components/quotes-screen.tsx +169 -0
  61. package/dist/cli.js +197 -37
  62. package/dist/index.browser.js +1 -1
  63. package/dist/index.browser.js.map +1 -1
  64. package/dist/index.browser.mjs +1 -1
  65. package/dist/index.browser.mjs.map +1 -1
  66. package/dist/index.d.mts +93 -7
  67. package/dist/index.d.ts +93 -7
  68. package/dist/index.js +1 -1
  69. package/dist/index.js.map +1 -1
  70. package/dist/index.mjs +1 -1
  71. package/dist/index.mjs.map +1 -1
  72. package/dist/react.browser.js +1 -1
  73. package/dist/react.browser.js.map +1 -1
  74. package/dist/react.browser.mjs +1 -1
  75. package/dist/react.browser.mjs.map +1 -1
  76. package/dist/react.d.mts +90 -4
  77. package/dist/react.d.ts +90 -4
  78. package/dist/react.js +1 -1
  79. package/dist/react.js.map +1 -1
  80. package/dist/react.mjs +1 -1
  81. package/dist/react.mjs.map +1 -1
  82. package/dist/types/blocks/create.d.ts +15 -1
  83. package/dist/types/blocks/doctor.d.ts +2 -1
  84. package/dist/types/blocks/flows.d.ts +20 -0
  85. package/dist/types/blocks/registry.d.ts +15 -0
  86. package/dist/types/core.d.ts +3 -2
  87. package/dist/types/react.d.ts +2 -0
  88. package/dist/types/studio.d.ts +48 -0
  89. package/dist/types/version.d.ts +2 -2
  90. package/docs/api/adCampaigns.d.ts +4 -0
  91. package/docs/api/adCampaigns.md +4 -0
  92. package/docs/api/gtmCampaigns.d.ts +4 -0
  93. package/docs/api/gtmCampaigns.md +2 -0
  94. package/docs/api/gtmContext.d.ts +5 -1
  95. package/docs/api/gtmContext.md +15 -7
  96. package/docs/api/integrations.d.ts +29 -4
  97. package/docs/api/integrations.md +14 -0
  98. package/docs/sdk-clients.md +1 -1
  99. package/package.json +1 -1
  100. package/stacks/stack.schema.json +6 -1
  101. package/styles/styles.css +1 -1
  102. package/templates/next-app/README.md.tmpl +16 -0
  103. package/templates/next-app/app/globals.css.tmpl +105 -0
  104. package/templates/next-app/app/layout.tsx.tmpl +11 -2
  105. package/templates/next-app/components/app-shell.tsx.tmpl +238 -23
  106. package/templates/next-app/components/shell/connection-status.tsx.tmpl +109 -0
  107. package/templates/next-app/components/shell/nav-model.ts.tmpl +65 -0
  108. package/templates/next-app/components/shell/quick-jump.tsx.tmpl +131 -0
  109. package/templates/next-app/components/shell/theme-toggle.tsx.tmpl +40 -0
  110. package/templates/next-app/lib/nav.ts.tmpl +1 -1
@@ -0,0 +1,114 @@
1
+ "use client";
2
+
3
+ /**
4
+ * DetailPanel: the selected record. A sticky header (avatar, title, subtitle, status badges, actions,
5
+ * close), then either underline tabs (Overview, Activity, Notes...) or plain children. Lives inside
6
+ * SplitView's detail slot, or on its own as a record page.
7
+ *
8
+ * SDK operations: none.
9
+ */
10
+ import { useEffect, useState, type ReactNode } from "react";
11
+ import { Maximize2, Minimize2, X } from "lucide-react";
12
+ import { Button, Tabs, TabsContent, TabsList, TabsTrigger, cn } from "@graph8/sdk/ui";
13
+
14
+ export interface DetailTab {
15
+ id: string;
16
+ label: ReactNode;
17
+ content: ReactNode;
18
+ count?: number;
19
+ }
20
+
21
+ export interface DetailPanelProps {
22
+ title: ReactNode;
23
+ subtitle?: ReactNode;
24
+ /** Usually <EntityAvatar size="lg" />. */
25
+ avatar?: ReactNode;
26
+ /** StatusPills or small facts beside the title. */
27
+ badges?: ReactNode;
28
+ /** Buttons or a menu in the header (edit, enroll, delete). */
29
+ actions?: ReactNode;
30
+ tabs?: DetailTab[];
31
+ /** Controlled active tab; otherwise the first tab (or `defaultTab`). */
32
+ activeTab?: string;
33
+ defaultTab?: string;
34
+ onTabChange?: (id: string) => void;
35
+ onClose?: () => void;
36
+ /** Shows an "Open full page" button; wire it to `useUrlState("view")` and SplitView `expanded`. */
37
+ onExpand?: () => void;
38
+ /** Shows a "Back to split view" button instead of the expand button (full-page mode). */
39
+ onCollapse?: () => void;
40
+ children?: ReactNode;
41
+ /** Shown under the header on every tab (a short fact row, an alert). */
42
+ summary?: ReactNode;
43
+ className?: string;
44
+ bodyClassName?: string;
45
+ }
46
+
47
+ export const DETAIL_TAB_LIST_CLASS = "h-auto w-full justify-start gap-0 rounded-none border-b border-border bg-transparent p-0";
48
+ export const DETAIL_TAB_TRIGGER_CLASS =
49
+ "relative -mb-px h-9 rounded-none border-b-2 border-transparent bg-transparent px-3 text-sm font-normal text-text-color-muted shadow-none transition-colors data-[state=active]:border-foreground data-[state=active]:bg-transparent data-[state=active]:font-medium data-[state=active]:text-foreground data-[state=active]:shadow-none hover:text-foreground";
50
+
51
+ export function DetailPanel({ title, subtitle, avatar, badges, actions, tabs, activeTab, defaultTab, onTabChange, onClose, onExpand, onCollapse, children, summary, className, bodyClassName }: DetailPanelProps) {
52
+ const first = tabs?.[0]?.id;
53
+ const [internal, setInternal] = useState(activeTab ?? defaultTab ?? first);
54
+ useEffect(() => {
55
+ if (activeTab != null) setInternal(activeTab);
56
+ }, [activeTab]);
57
+ const current = activeTab ?? internal ?? first;
58
+ const change = (id: string) => {
59
+ if (activeTab == null) setInternal(id);
60
+ onTabChange?.(id);
61
+ };
62
+
63
+ return (
64
+ <article className={cn("flex h-full min-h-0 min-w-0 flex-col bg-background", className)} data-testid="detail-panel">
65
+ <header className="sticky top-0 z-10 shrink-0 border-b border-border bg-background/95 px-5 pb-3 pt-4 backdrop-blur supports-[backdrop-filter]:bg-background/80">
66
+ <div className="flex items-start gap-3">
67
+ {avatar ? <div className="shrink-0">{avatar}</div> : null}
68
+ <div className="min-w-0 flex-1">
69
+ <h2 className="truncate text-base font-medium leading-6 text-foreground">{title}</h2>
70
+ {subtitle ? <p className="truncate text-sm text-text-color-muted">{subtitle}</p> : null}
71
+ {badges ? <div className="mt-1.5 flex flex-wrap items-center gap-1.5">{badges}</div> : null}
72
+ </div>
73
+ <div className="flex shrink-0 items-center gap-1">
74
+ {actions}
75
+ {onCollapse ? (
76
+ <Button type="button" variant="ghost" size="icon" className="size-8 text-text-color-muted hover:text-foreground" aria-label="Back to split view" onClick={onCollapse}>
77
+ <Minimize2 className="size-4" strokeWidth={1.5} aria-hidden />
78
+ </Button>
79
+ ) : onExpand ? (
80
+ <Button type="button" variant="ghost" size="icon" className="size-8 text-text-color-muted hover:text-foreground" aria-label="Open full page" onClick={onExpand}>
81
+ <Maximize2 className="size-4" strokeWidth={1.5} aria-hidden />
82
+ </Button>
83
+ ) : null}
84
+ {onClose ? (
85
+ <Button type="button" variant="ghost" size="icon" className="size-8 text-text-color-muted hover:text-foreground" aria-label="Close details" onClick={onClose}>
86
+ <X className="size-4" strokeWidth={1.5} aria-hidden />
87
+ </Button>
88
+ ) : null}
89
+ </div>
90
+ </div>
91
+ {summary ? <div className="mt-3">{summary}</div> : null}
92
+ </header>
93
+ {tabs && tabs.length > 0 ? (
94
+ <Tabs value={current} onValueChange={change} className="flex min-h-0 flex-1 flex-col">
95
+ <TabsList className={cn(DETAIL_TAB_LIST_CLASS, "shrink-0 overflow-x-auto px-3")} aria-label="Record views">
96
+ {tabs.map((t) => (
97
+ <TabsTrigger key={t.id} value={t.id} className={DETAIL_TAB_TRIGGER_CLASS}>
98
+ {t.label}
99
+ {t.count != null ? <span className="ml-1.5 rounded-full bg-muted px-1.5 text-[11px] tabular-nums text-text-color-muted">{t.count}</span> : null}
100
+ </TabsTrigger>
101
+ ))}
102
+ </TabsList>
103
+ {tabs.map((t) => (
104
+ <TabsContent key={t.id} value={t.id} className={cn("mt-0 min-h-0 flex-1 overflow-y-auto px-5 py-4", bodyClassName)}>
105
+ {t.content}
106
+ </TabsContent>
107
+ ))}
108
+ </Tabs>
109
+ ) : (
110
+ <div className={cn("min-h-0 flex-1 overflow-y-auto px-5 py-4", bodyClassName)}>{children}</div>
111
+ )}
112
+ </article>
113
+ );
114
+ }
@@ -0,0 +1,56 @@
1
+ "use client";
2
+
3
+ /**
4
+ * EntityAvatar: the face of a person or the mark of a company in rows, headers and detail panels.
5
+ * Falls back to initials (person) or the first letter (company); companies are rounded squares,
6
+ * people are circles, so the two read differently at a glance.
7
+ *
8
+ * SDK operations: none.
9
+ */
10
+ import { Building, UserRound } from "lucide-react";
11
+ import { Avatar, AvatarFallback, AvatarImage, cn } from "@graph8/sdk/ui";
12
+
13
+ export interface EntityAvatarProps {
14
+ name: string;
15
+ imageUrl?: string | null;
16
+ kind?: "person" | "company";
17
+ size?: "sm" | "md" | "lg";
18
+ className?: string;
19
+ }
20
+
21
+ const SIZE: Record<NonNullable<EntityAvatarProps["size"]>, { box: string; text: string; icon: string }> = {
22
+ sm: { box: "size-6", text: "text-[10px]", icon: "size-3" },
23
+ md: { box: "size-8", text: "text-xs", icon: "size-4" },
24
+ lg: { box: "size-11", text: "text-sm", icon: "size-5" },
25
+ };
26
+
27
+ /** "Ada Lovelace" -> "AL"; "graph8" -> "G"; "" -> "". */
28
+ export function initialsFor(name: string, kind: "person" | "company" = "person"): string {
29
+ const words = String(name ?? "")
30
+ .trim()
31
+ .split(/\s+/)
32
+ .filter(Boolean);
33
+ if (!words.length) return "";
34
+ if (kind === "company") return words[0].charAt(0).toUpperCase();
35
+ const first = words[0].charAt(0);
36
+ const last = words.length > 1 ? words[words.length - 1].charAt(0) : "";
37
+ return `${first}${last}`.toUpperCase();
38
+ }
39
+
40
+ export function EntityAvatar({ name, imageUrl, kind = "person", size = "md", className }: EntityAvatarProps) {
41
+ const s = SIZE[size] ?? SIZE.md;
42
+ const initials = initialsFor(name, kind);
43
+ const shape = kind === "company" ? "rounded-md" : "rounded-full";
44
+ const Icon = kind === "company" ? Building : UserRound;
45
+ return (
46
+ <Avatar data-kind={kind} className={cn(s.box, shape, "shrink-0 border border-border", className)}>
47
+ {imageUrl ? <AvatarImage src={imageUrl} alt="" className={cn("object-cover", shape)} /> : null}
48
+ <AvatarFallback
49
+ aria-label={name || (kind === "company" ? "Company" : "Person")}
50
+ className={cn(shape, "bg-muted font-medium uppercase tracking-wide text-foreground", s.text)}
51
+ >
52
+ {initials || <Icon className={cn(s.icon, "text-text-color-muted")} strokeWidth={1.5} aria-hidden />}
53
+ </AvatarFallback>
54
+ </Avatar>
55
+ );
56
+ }
@@ -0,0 +1,73 @@
1
+ "use client";
2
+
3
+ /**
4
+ * FormSheet: the create/edit side sheet. Header (title, description), a scrolling body for the form
5
+ * fields, and a sticky footer for Cancel and the one primary action. Built on the UI kit's Sheet
6
+ * (Radix Dialog in a portal), so ConfirmSpendDialog can stack on top of it.
7
+ *
8
+ * SDK operations: none.
9
+ */
10
+ import type { FormEvent, ReactNode } from "react";
11
+ import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, cn } from "@graph8/sdk/ui";
12
+
13
+ export interface FormSheetProps {
14
+ open: boolean;
15
+ onOpenChange: (open: boolean) => void;
16
+ title: ReactNode;
17
+ description?: ReactNode;
18
+ children: ReactNode;
19
+ /** Buttons: Cancel first, the primary action last. */
20
+ footer?: ReactNode;
21
+ /** Wrap the body in a <form>; Enter in a field submits. */
22
+ onSubmit?: (event: FormEvent<HTMLFormElement>) => void;
23
+ width?: "md" | "lg" | "xl";
24
+ side?: "right" | "left";
25
+ /** Keep the sheet open on outside clicks (unsaved work). Default true. */
26
+ blockOutsideClick?: boolean;
27
+ className?: string;
28
+ bodyClassName?: string;
29
+ }
30
+
31
+ const WIDTH: Record<NonNullable<FormSheetProps["width"]>, string> = {
32
+ md: "sm:max-w-md",
33
+ lg: "sm:max-w-lg",
34
+ xl: "sm:max-w-2xl",
35
+ };
36
+
37
+ export function FormSheet({ open, onOpenChange, title, description, children, footer, onSubmit, width = "md", side = "right", blockOutsideClick = true, className, bodyClassName }: FormSheetProps) {
38
+ const bodyClass = "flex min-h-0 flex-1 flex-col";
39
+ const body = (
40
+ <>
41
+ <div className={cn("min-h-0 flex-1 space-y-4 overflow-y-auto px-5 py-4", bodyClassName)}>{children}</div>
42
+ {footer ? <div className="flex shrink-0 items-center justify-end gap-2 border-t border-border bg-background px-5 py-3">{footer}</div> : null}
43
+ </>
44
+ );
45
+ return (
46
+ <Sheet open={open} onOpenChange={onOpenChange}>
47
+ <SheetContent
48
+ side={side}
49
+ data-testid="form-sheet"
50
+ className={cn("flex w-full flex-col gap-0 p-0 sm:w-full", WIDTH[width] ?? WIDTH.md, className)}
51
+ onInteractOutside={(e) => (blockOutsideClick ? e.preventDefault() : undefined)}
52
+ >
53
+ <SheetHeader className="shrink-0 gap-1 border-b border-border px-5 py-4 pr-12 text-left">
54
+ <SheetTitle className="text-base font-medium leading-6 text-foreground">{title}</SheetTitle>
55
+ {description ? <SheetDescription className="text-sm text-text-color-muted">{description}</SheetDescription> : <SheetDescription className="sr-only">Form</SheetDescription>}
56
+ </SheetHeader>
57
+ {onSubmit ? (
58
+ <form
59
+ onSubmit={(e: FormEvent<HTMLFormElement>) => {
60
+ e.preventDefault();
61
+ onSubmit(e);
62
+ }}
63
+ className={bodyClass}
64
+ >
65
+ {body}
66
+ </form>
67
+ ) : (
68
+ <div className={bodyClass}>{body}</div>
69
+ )}
70
+ </SheetContent>
71
+ </Sheet>
72
+ );
73
+ }
@@ -0,0 +1,21 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The screen kit in one import: `import { ScreenLayout, SplitView, ... } from "@/g8/components/screen"`.
5
+ * Each part also has its own file (`@/g8/components/screen/split-view`) for smaller bundles.
6
+ *
7
+ * SDK operations: none.
8
+ */
9
+ export * from "./bar-list";
10
+ export * from "./detail-panel";
11
+ export * from "./entity-avatar";
12
+ export * from "./form-sheet";
13
+ export * from "./key-value-list";
14
+ export * from "./screen-layout";
15
+ export * from "./section-card";
16
+ export * from "./sparkline";
17
+ export * from "./split-view";
18
+ export * from "./stat-strip";
19
+ export * from "./status-pill";
20
+ export * from "./time-series-chart";
21
+ export * from "./toolbar";
@@ -0,0 +1,65 @@
1
+ "use client";
2
+
3
+ /**
4
+ * KeyValueList: labelled facts about one record (email, owner, stage, created), one or two columns.
5
+ * A missing value is a faint dash, never "N/A" or "Unknown"; a real zero reads "0".
6
+ *
7
+ * SDK operations: none.
8
+ */
9
+ import type { ReactNode } from "react";
10
+ import { cn } from "@graph8/sdk/ui";
11
+
12
+ export interface KeyValueItem {
13
+ label: string;
14
+ value: ReactNode;
15
+ /** Span both columns (long text, addresses). */
16
+ wide?: boolean;
17
+ /** Render the value in a monospaced font (ids, domains). */
18
+ mono?: boolean;
19
+ }
20
+
21
+ export interface KeyValueListProps {
22
+ items: KeyValueItem[];
23
+ columns?: 1 | 2;
24
+ /** Put labels beside values instead of above them (dense, one column). */
25
+ inline?: boolean;
26
+ className?: string;
27
+ }
28
+
29
+ export function isEmptyValue(value: ReactNode): boolean {
30
+ return value == null || value === "" || value === false;
31
+ }
32
+
33
+ /** The faint dash the kit shows for an unknown value. */
34
+ export function EmptyValue({ className }: { className?: string }) {
35
+ return (
36
+ <span aria-label="No value" className={cn("text-border select-none", className)} data-empty="true">
37
+ -
38
+ </span>
39
+ );
40
+ }
41
+
42
+ export function KeyValueList({ items, columns = 1, inline, className }: KeyValueListProps) {
43
+ return (
44
+ <dl
45
+ className={cn(
46
+ "grid gap-x-6",
47
+ inline ? "gap-y-2" : "gap-y-3",
48
+ columns === 2 && !inline ? "sm:grid-cols-2" : "grid-cols-1",
49
+ className,
50
+ )}
51
+ >
52
+ {items.map((item, i) => (
53
+ <div
54
+ key={`${item.label}-${i}`}
55
+ className={cn("min-w-0", inline ? "grid grid-cols-[minmax(6rem,9rem)_1fr] items-baseline gap-3" : "", item.wide && columns === 2 && "sm:col-span-2")}
56
+ >
57
+ <dt className={cn("truncate text-xs text-text-color-muted", inline ? "" : "mb-0.5")}>{item.label}</dt>
58
+ <dd className={cn("min-w-0 break-words text-sm text-foreground", item.mono && "font-mono text-[13px]")}>
59
+ {isEmptyValue(item.value) ? <EmptyValue /> : item.value}
60
+ </dd>
61
+ </div>
62
+ ))}
63
+ </dl>
64
+ );
65
+ }
@@ -0,0 +1,124 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ScreenLayout: the frame of a finished screen. A sticky header (icon, title, description, actions,
5
+ * optional underline tabs), then stats, then the toolbar, then the content filling the rest. Use one
6
+ * per route; the app shell provides the sidebar and the workspace header around it.
7
+ *
8
+ * SDK operations: none.
9
+ */
10
+ import { useRef, type KeyboardEvent, type ReactNode } from "react";
11
+ import { cn } from "@graph8/sdk/ui";
12
+ import { StatStrip, type StatItem } from "./stat-strip";
13
+
14
+ export interface ScreenTab {
15
+ id: string;
16
+ label: ReactNode;
17
+ count?: number;
18
+ disabled?: boolean;
19
+ }
20
+
21
+ export interface ScreenLayoutProps {
22
+ title: ReactNode;
23
+ description?: ReactNode;
24
+ /** A lucide icon element (size 16 to 20) shown in an ink well before the title. */
25
+ icon?: ReactNode;
26
+ /** Right-aligned header actions; the one primary action goes last. */
27
+ actions?: ReactNode;
28
+ tabs?: ScreenTab[];
29
+ activeTab?: string;
30
+ onTabChange?: (id: string) => void;
31
+ stats?: StatItem[];
32
+ statsLoading?: boolean;
33
+ toolbar?: ReactNode;
34
+ children: ReactNode;
35
+ /** Breadcrumb or context line above the title ("Contacts / Lists"). */
36
+ eyebrow?: ReactNode;
37
+ /** Let the content scroll inside the frame (lists with their own scroll) instead of the page. */
38
+ fill?: boolean;
39
+ /** Horizontal padding scale. "none" when the content draws its own edges (tables, kanban). */
40
+ padding?: "default" | "none";
41
+ className?: string;
42
+ contentClassName?: string;
43
+ }
44
+
45
+ export function ScreenTabs({ tabs, activeTab, onTabChange, className }: { tabs: ScreenTab[]; activeTab?: string; onTabChange?: (id: string) => void; className?: string }) {
46
+ const refs = useRef<Array<HTMLButtonElement | null>>([]);
47
+ const current = activeTab ?? tabs[0]?.id;
48
+ const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
49
+ const enabled = tabs.map((t, i) => (t.disabled ? -1 : i)).filter((i) => i >= 0);
50
+ const at = enabled.indexOf(tabs.findIndex((t) => t.id === current));
51
+ let next = -1;
52
+ if (e.key === "ArrowRight") next = enabled[(at + 1) % enabled.length];
53
+ else if (e.key === "ArrowLeft") next = enabled[(at - 1 + enabled.length) % enabled.length];
54
+ else if (e.key === "Home") next = enabled[0];
55
+ else if (e.key === "End") next = enabled[enabled.length - 1];
56
+ if (next < 0 || next === undefined) return;
57
+ e.preventDefault();
58
+ refs.current[next]?.focus();
59
+ onTabChange?.(tabs[next].id);
60
+ };
61
+ return (
62
+ <div role="tablist" aria-label="Views" onKeyDown={onKeyDown} className={cn("-mb-px flex items-end gap-0 overflow-x-auto", className)}>
63
+ {tabs.map((t, i) => {
64
+ const active = t.id === current;
65
+ return (
66
+ <button
67
+ key={t.id}
68
+ ref={(el) => { refs.current[i] = el; }}
69
+ type="button"
70
+ role="tab"
71
+ aria-selected={active}
72
+ aria-disabled={t.disabled || undefined}
73
+ disabled={t.disabled}
74
+ tabIndex={active ? 0 : -1}
75
+ onClick={() => (!t.disabled ? onTabChange?.(t.id) : undefined)}
76
+ className={cn(
77
+ "relative inline-flex h-9 shrink-0 items-center gap-1.5 whitespace-nowrap border-b-2 px-3 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
78
+ active ? "border-foreground font-medium text-foreground" : "border-transparent text-text-color-muted hover:text-foreground",
79
+ t.disabled && "opacity-50",
80
+ )}
81
+ >
82
+ {t.label}
83
+ {t.count != null ? (
84
+ <span className={cn("rounded-full px-1.5 text-[11px] leading-4 tabular-nums", active ? "bg-foreground/10 text-foreground" : "bg-muted text-text-color-muted")}>{t.count}</span>
85
+ ) : null}
86
+ </button>
87
+ );
88
+ })}
89
+ </div>
90
+ );
91
+ }
92
+
93
+ export function ScreenLayout({ title, description, icon, actions, tabs, activeTab, onTabChange, stats, statsLoading, toolbar, children, eyebrow, fill, padding = "default", className, contentClassName }: ScreenLayoutProps) {
94
+ const px = padding === "none" ? "" : "px-4 sm:px-6";
95
+ return (
96
+ <section className={cn("flex w-full min-w-0 flex-col", fill ? "h-full min-h-0" : "min-h-full", className)} data-testid="screen-layout">
97
+ <header className={cn("sticky top-0 z-20 shrink-0 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80", padding === "none" ? "px-4 sm:px-6" : px)}>
98
+ <div className={cn("flex items-start justify-between gap-4 pt-4", tabs?.length ? "pb-2" : "pb-3")}>
99
+ <div className="flex min-w-0 items-center gap-3">
100
+ {icon ? (
101
+ <span className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-border bg-muted text-foreground [&_svg]:size-[18px]" aria-hidden>
102
+ {icon}
103
+ </span>
104
+ ) : null}
105
+ <div className="min-w-0">
106
+ {eyebrow ? <div className="mb-0.5 truncate text-xs text-text-color-muted">{eyebrow}</div> : null}
107
+ <h1 className="truncate text-lg font-medium leading-6 tracking-tight text-foreground">{title}</h1>
108
+ {description ? <p className="mt-0.5 truncate text-sm text-text-color-muted">{description}</p> : null}
109
+ </div>
110
+ </div>
111
+ {actions ? <div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div> : null}
112
+ </div>
113
+ {tabs && tabs.length > 0 ? <ScreenTabs tabs={tabs} activeTab={activeTab} onTabChange={onTabChange} /> : null}
114
+ </header>
115
+ {stats || statsLoading ? (
116
+ <div className={cn("shrink-0 py-4", padding === "none" ? "px-4 sm:px-6" : px)}>
117
+ <StatStrip items={stats ?? []} loading={statsLoading} />
118
+ </div>
119
+ ) : null}
120
+ {toolbar ? <div className={cn("shrink-0 pb-3", padding === "none" ? "px-4 sm:px-6" : px, stats || statsLoading ? "" : "pt-4")}>{toolbar}</div> : null}
121
+ <div className={cn("min-h-0 min-w-0 flex-1", fill ? "overflow-hidden" : "", px, toolbar || stats || statsLoading ? "pb-6" : "py-4 pb-6", contentClassName)}>{children}</div>
122
+ </section>
123
+ );
124
+ }
@@ -0,0 +1,47 @@
1
+ "use client";
2
+
3
+ /**
4
+ * SectionCard: a flat, hairlined section with a title row (title, description, actions) and a body.
5
+ * Use it to group facts, lists or a form inside a screen or a detail panel. No shadow: cards in
6
+ * graph8 are flat; only floating layers cast shadows.
7
+ *
8
+ * SDK operations: none.
9
+ */
10
+ import type { ReactNode } from "react";
11
+ import { cn } from "@graph8/sdk/ui";
12
+
13
+ export interface SectionCardProps {
14
+ title: ReactNode;
15
+ description?: ReactNode;
16
+ /** Buttons or a menu, right-aligned in the title row. */
17
+ actions?: ReactNode;
18
+ /** Leading icon beside the title (14 to 16px). */
19
+ icon?: ReactNode;
20
+ children: ReactNode;
21
+ /** Remove the body padding (tables, lists that draw their own rows). */
22
+ flush?: boolean;
23
+ /** Optional footer row (pager, totals, a secondary action). */
24
+ footer?: ReactNode;
25
+ className?: string;
26
+ bodyClassName?: string;
27
+ id?: string;
28
+ }
29
+
30
+ export function SectionCard({ title, description, actions, icon, children, flush, footer, className, bodyClassName, id }: SectionCardProps) {
31
+ return (
32
+ <section id={id} className={cn("flex min-w-0 flex-col overflow-hidden rounded-lg border border-border bg-card text-card-foreground", className)}>
33
+ <header className="flex items-start justify-between gap-3 border-b border-border px-4 py-2.5">
34
+ <div className="flex min-w-0 items-start gap-2">
35
+ {icon ? <span className="mt-0.5 shrink-0 text-text-color-muted [&_svg]:size-4">{icon}</span> : null}
36
+ <div className="min-w-0">
37
+ <h3 className="truncate text-sm font-medium leading-5 text-foreground">{title}</h3>
38
+ {description ? <p className="mt-0.5 text-xs leading-4 text-text-color-muted">{description}</p> : null}
39
+ </div>
40
+ </div>
41
+ {actions ? <div className="flex shrink-0 items-center gap-1.5">{actions}</div> : null}
42
+ </header>
43
+ <div className={cn("min-w-0", flush ? "" : "p-4", bodyClassName)}>{children}</div>
44
+ {footer ? <footer className="border-t border-border px-4 py-2 text-xs text-text-color-muted">{footer}</footer> : null}
45
+ </section>
46
+ );
47
+ }
@@ -0,0 +1,77 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Sparkline: a tiny inline trend (ink line at 38%, area at 6%) for stat tiles and table cells.
5
+ * Pure SVG, no chart library. Scales to its box; pass `className` for the size.
6
+ *
7
+ * SDK operations: none.
8
+ */
9
+ import { useId } from "react";
10
+ import { cn } from "@graph8/sdk/ui";
11
+ import { TONE_CLASSES, type Tone } from "./status-pill";
12
+
13
+ export interface SparklineProps {
14
+ data: number[];
15
+ /** Colour of the line. Default is ink; use a tone only when the direction means good or bad. */
16
+ tone?: Tone;
17
+ /** Fill the area under the line (ink at 6%). */
18
+ area?: boolean;
19
+ /** Mark the last point. */
20
+ endDot?: boolean;
21
+ className?: string;
22
+ /** Accessible summary ("Replies, last 14 days"). */
23
+ label?: string;
24
+ }
25
+
26
+ const TONE_TEXT: Record<Tone, string> = {
27
+ neutral: "text-foreground/40",
28
+ info: TONE_CLASSES.info.text,
29
+ positive: TONE_CLASSES.positive.text,
30
+ warning: TONE_CLASSES.warning.text,
31
+ negative: TONE_CLASSES.negative.text,
32
+ };
33
+
34
+ /** Points scaled into a 100x32 box; a flat series draws a mid line. */
35
+ export function sparklinePoints(data: number[], width = 100, height = 32, pad = 2): Array<[number, number]> {
36
+ const values = data.filter((v) => Number.isFinite(v));
37
+ if (values.length === 0) return [];
38
+ if (values.length === 1) values.push(values[0]);
39
+ const min = Math.min(...values);
40
+ const max = Math.max(...values);
41
+ const span = max - min || 1;
42
+ const stepX = (width - pad * 2) / (values.length - 1);
43
+ return values.map((v, i) => [pad + i * stepX, height - pad - ((v - min) / span) * (height - pad * 2)]);
44
+ }
45
+
46
+ export function Sparkline({ data, tone = "neutral", area = true, endDot = true, className, label }: SparklineProps) {
47
+ const id = useId();
48
+ const pts = sparklinePoints(data);
49
+ if (pts.length === 0) return <span aria-hidden className={cn("inline-block h-8 w-20", className)} />;
50
+ const line = pts.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(" ");
51
+ const first = pts[0];
52
+ const last = pts[pts.length - 1];
53
+ const areaPath = `M${first[0].toFixed(1)},32 L${line.replace(/ /g, " L")} L${last[0].toFixed(1)},32 Z`;
54
+ return (
55
+ <svg
56
+ viewBox="0 0 100 32"
57
+ preserveAspectRatio="none"
58
+ role={label ? "img" : undefined}
59
+ aria-label={label}
60
+ aria-hidden={label ? undefined : true}
61
+ className={cn("h-8 w-20 overflow-visible", TONE_TEXT[tone] ?? TONE_TEXT.neutral, className)}
62
+ data-testid="sparkline"
63
+ >
64
+ {area ? <path d={areaPath} fill="currentColor" fillOpacity={0.12} stroke="none" /> : null}
65
+ <polyline
66
+ points={line}
67
+ fill="none"
68
+ stroke="currentColor"
69
+ strokeWidth={1.5}
70
+ strokeLinejoin="round"
71
+ strokeLinecap="round"
72
+ vectorEffect="non-scaling-stroke"
73
+ />
74
+ {endDot ? <circle id={id} cx={last[0]} cy={last[1]} r={2} fill="currentColor" vectorEffect="non-scaling-stroke" /> : null}
75
+ </svg>
76
+ );
77
+ }