@tangle-network/ui 11.39.0 → 11.40.1

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 (39) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/index.d.ts +4 -2
  3. package/dist/index.js +3 -1
  4. package/dist/lib/control-presentation.d.ts +1 -1
  5. package/dist/lib/control-presentation.js +9 -7
  6. package/dist/presentation.d.ts +1 -1
  7. package/dist/presentation.js +9 -8
  8. package/dist/primitives/avatar.d.ts +9 -1
  9. package/dist/primitives/avatar.js +6 -2
  10. package/dist/primitives/button.d.ts +2 -1
  11. package/dist/primitives/field.d.ts +27 -0
  12. package/dist/primitives/field.js +54 -0
  13. package/dist/primitives/info-tip.js +50 -0
  14. package/dist/primitives/page-shell.d.ts +19 -3
  15. package/dist/primitives/page-shell.js +80 -22
  16. package/dist/primitives/segmented-control.js +1 -1
  17. package/dist/primitives/settings.d.ts +125 -0
  18. package/dist/primitives/settings.js +316 -0
  19. package/dist/primitives/switch.js +1 -1
  20. package/dist/primitives/upload-progress.js +11 -6
  21. package/dist/primitives.d.ts +4 -2
  22. package/dist/primitives.js +3 -1
  23. package/package.json +2 -2
  24. package/src/lib/control-presentation.ts +9 -7
  25. package/src/presentation.ts +10 -8
  26. package/src/primitives/avatar.tsx +10 -5
  27. package/src/primitives/button.tsx +2 -1
  28. package/src/primitives/control-presentation.stories.tsx +8 -1
  29. package/src/primitives/field.tsx +63 -0
  30. package/src/primitives/index.ts +16 -0
  31. package/src/primitives/info-tip.tsx +40 -0
  32. package/src/primitives/page-shell.tsx +70 -8
  33. package/src/primitives/settings.stories.tsx +267 -0
  34. package/src/primitives/settings.tsx +404 -0
  35. package/src/primitives/switch.tsx +3 -0
  36. package/src/primitives/upload-progress.stories.tsx +36 -1
  37. package/src/primitives/upload-progress.tsx +13 -8
  38. package/src/styles/compat.css +5 -5
  39. package/src/styles/presentation.css +43 -2
@@ -2,14 +2,19 @@ import * as AvatarPrimitive from "@radix-ui/react-avatar";
2
2
  import * as React from "react";
3
3
  import { cn } from "../lib/utils";
4
4
 
5
- const Avatar = React.forwardRef<
6
- React.ElementRef<typeof AvatarPrimitive.Root>,
7
- React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
8
- >(({ className, ...props }, ref) => (
5
+ const AVATAR_SIZES = { sm: "h-8 w-8", md: "h-10 w-10" } as const;
6
+
7
+ export interface AvatarProps extends React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> {
8
+ /** sm 32px (a list row beside a two-line name), md 40px (default). */
9
+ size?: keyof typeof AVATAR_SIZES;
10
+ }
11
+
12
+ const Avatar = React.forwardRef<React.ElementRef<typeof AvatarPrimitive.Root>, AvatarProps>(({ className, size = "md", ...props }, ref) => (
9
13
  <AvatarPrimitive.Root
10
14
  ref={ref}
11
15
  className={cn(
12
- "relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
16
+ "relative flex shrink-0 overflow-hidden rounded-full",
17
+ AVATAR_SIZES[size],
13
18
  className,
14
19
  )}
15
20
  {...props}
@@ -36,7 +36,8 @@ function buttonClassName(
36
36
 
37
37
  export interface ButtonProps
38
38
  extends React.ButtonHTMLAttributes<HTMLButtonElement> {
39
- variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" | "sandbox" | "bare" | null;
39
+ /** `destructive-outline` is a destructive action at rest, such as "Delete project…", whose confirmation uses `destructive`. */
40
+ variant?: "default" | "destructive" | "destructive-outline" | "outline" | "secondary" | "ghost" | "link" | "sandbox" | "bare" | null;
40
41
  /** sm 32px, default/md 36px (`--control-height`), lg 44px; icon sizes are squares on the same heights. */
41
42
  size?: "default" | "md" | "sm" | "lg" | "xl" | "display" | "icon" | "icon-sm" | "icon-lg" | "compact" | "touch" | null;
42
43
  asChild?: boolean;
@@ -1,6 +1,7 @@
1
1
  import * as React from "react";
2
2
  import type { Meta, StoryObj } from "@storybook/react";
3
3
  import { Button } from "./button";
4
+ import { X } from "lucide-react";
4
5
  import { HelpText } from "./help-text";
5
6
  import { Input, Textarea } from "./input";
6
7
  import { Label } from "./label";
@@ -13,7 +14,7 @@ function Contract() {
13
14
  return (
14
15
  <main className="mx-auto max-w-5xl space-y-6 bg-background p-4 text-foreground" data-presentation-contract>
15
16
  <h1 className="text-xl font-semibold">Field and metric presentation</h1>
16
- {(["sm", "md", "lg", "compact", "touch"] as const).map((size) => (
17
+ {(["default", "sm", "md", "lg", "compact", "touch"] as const).map((size) => (
17
18
  <section key={size} data-size-row={size} className="space-y-2">
18
19
  <h2 className="font-semibold">{size}</h2>
19
20
  <div className="flex flex-wrap items-end gap-3">
@@ -27,10 +28,16 @@ function Contract() {
27
28
  </Select>
28
29
  <Button size={size} onClick={() => setActions((value) => value + 1)}>Save</Button>
29
30
  <Button size={size} variant="outline">Cancel</Button>
31
+ <Button size={size} aria-label={`${size} short action`}>+</Button>
30
32
  </div>
31
33
  <Textarea size={size} aria-label={`${size} notes`} placeholder="Details" />
32
34
  </section>
33
35
  ))}
36
+ <section className="flex flex-wrap gap-3" aria-label="Icon controls">
37
+ {(["icon-sm", "icon", "icon-lg"] as const).map((size) => (
38
+ <Button key={size} size={size} aria-label={`${size} action`} onClick={() => setActions((value) => value + 1)}><X aria-hidden="true" /></Button>
39
+ ))}
40
+ </section>
34
41
  <section className="max-w-sm space-y-1.5" data-form-text>
35
42
  <Label htmlFor="workspace-name">Workspace name</Label>
36
43
  <Input id="workspace-name" aria-describedby="workspace-name-hint" placeholder="Acme" />
@@ -0,0 +1,63 @@
1
+ "use client";
2
+
3
+ import { CircleAlert } from "lucide-react";
4
+ import * as React from "react";
5
+ import { cn } from "../lib/utils";
6
+ import { InfoTip } from "./info-tip";
7
+ import { Label } from "./label";
8
+
9
+ export interface FieldProps {
10
+ label: string;
11
+ /** The control's id; the label targets it. */
12
+ htmlFor: string;
13
+ /** Help that explains what the setting does, shown from an info button. */
14
+ info?: React.ReactNode;
15
+ /** One short sentence the person needs to fill the field in. */
16
+ hint?: React.ReactNode;
17
+ /** A validation error. The control should set `aria-invalid` and reference {@link fieldMessageId}. */
18
+ error?: React.ReactNode;
19
+ /** A non-error status, such as a check that passed. */
20
+ status?: React.ReactNode;
21
+ /** Trailing content in the label row, such as a "Use default" action. */
22
+ aside?: React.ReactNode;
23
+ children: React.ReactNode;
24
+ }
25
+
26
+ /** The id of a field's hint, error, or status line, for the control's `aria-describedby`. */
27
+ export function fieldMessageId(htmlFor: string): string {
28
+ return `${htmlFor}-message`;
29
+ }
30
+
31
+ /**
32
+ * A labelled form field: the label (with an optional info tip and a trailing
33
+ * aside) above the control, and one message line under it. Every line is
34
+ * 14px; an error replaces the status and the hint and is announced.
35
+ */
36
+ export function Field({ label, htmlFor, info, hint, error, status, aside, children }: FieldProps) {
37
+ const message = error ?? status ?? hint;
38
+ return (
39
+ <div className="min-w-0 space-y-2">
40
+ <div className="flex min-h-6 items-center justify-between gap-2">
41
+ <span className="flex min-w-0 items-center gap-1.5">
42
+ <Label htmlFor={htmlFor} className="leading-6 text-foreground">{label}</Label>
43
+ {info && <InfoTip label={label}>{info}</InfoTip>}
44
+ </span>
45
+ {aside}
46
+ </div>
47
+ {children}
48
+ {message && (
49
+ <div
50
+ id={fieldMessageId(htmlFor)}
51
+ role={error ? "alert" : undefined}
52
+ className={cn(
53
+ "text-sm leading-6 [overflow-wrap:anywhere]",
54
+ error ? "flex items-start gap-1.5 text-[var(--surface-danger-text)]" : "text-muted-foreground",
55
+ )}
56
+ >
57
+ {error && <CircleAlert aria-hidden="true" className="mt-1 size-4 shrink-0" />}
58
+ {error ? <span className="min-w-0">{message}</span> : message}
59
+ </div>
60
+ )}
61
+ </div>
62
+ );
63
+ }
@@ -79,6 +79,7 @@ export { Badge, badgeVariants } from "./badge";
79
79
  export type { BadgeProps } from "./badge";
80
80
 
81
81
  export { Avatar, AvatarImage, AvatarFallback } from "./avatar";
82
+ export type { AvatarProps } from "./avatar";
82
83
 
83
84
  export {
84
85
  DropdownMenu,
@@ -199,6 +200,21 @@ export type { HeadingProps, HeadingVariant } from "./heading";
199
200
  export { PageShell } from "./page-shell";
200
201
  export type { PageShellProps } from "./page-shell";
201
202
 
203
+ export { Field, fieldMessageId } from "./field";
204
+ export type { FieldProps } from "./field";
205
+
206
+ export { SettingsList, SettingsListItem, SettingsRow, SettingsSection } from "./settings";
207
+ export type {
208
+ SettingsListAction,
209
+ SettingsListColumns,
210
+ SettingsListItemProps,
211
+ SettingsListMenuItem,
212
+ SettingsListProps,
213
+ SettingsRowLayout,
214
+ SettingsRowProps,
215
+ SettingsSectionProps,
216
+ } from "./settings";
217
+
202
218
  export { PageHeader } from "./page-header";
203
219
  export type { PageHeaderProps } from "./page-header";
204
220
 
@@ -0,0 +1,40 @@
1
+ "use client";
2
+
3
+ import { Info } from "lucide-react";
4
+ import * as React from "react";
5
+ import { Button } from "./button";
6
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip";
7
+
8
+ /**
9
+ * An info button beside a label, for help that explains a setting. It opens
10
+ * on hover and keyboard focus and toggles on tap. The glyph stays 20px; on a
11
+ * coarse pointer a transparent 44px hit area surrounds it. It brings its own
12
+ * provider, so a row or field works in an app that never mounted one.
13
+ */
14
+ export function InfoTip({ label, children }: { label: string; children: React.ReactNode }) {
15
+ const [open, setOpen] = React.useState(false);
16
+ // Radix closes a tooltip on every trigger press and click; a tap or click toggles it instead.
17
+ const openAtPress = React.useRef(false);
18
+ return (
19
+ <TooltipProvider delayDuration={150}>
20
+ <Tooltip open={open} onOpenChange={setOpen}>
21
+ <TooltipTrigger asChild>
22
+ <Button
23
+ type="button"
24
+ variant="bare"
25
+ aria-label={`About ${label}`}
26
+ onPointerDown={() => { openAtPress.current = open; }}
27
+ onClick={(event) => {
28
+ event.preventDefault();
29
+ setOpen(event.detail === 0 ? !open : !openAtPress.current);
30
+ }}
31
+ className="relative inline-flex size-5 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:text-foreground pointer-coarse:before:absolute pointer-coarse:before:-inset-3 pointer-coarse:before:content-['']"
32
+ >
33
+ <Info aria-hidden="true" className="size-4" />
34
+ </Button>
35
+ </TooltipTrigger>
36
+ <TooltipContent side="top" className="max-w-72 text-sm leading-5">{children}</TooltipContent>
37
+ </Tooltip>
38
+ </TooltipProvider>
39
+ );
40
+ }
@@ -1,3 +1,6 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
1
4
  import type { ReactNode } from "react";
2
5
  import { cn } from "../lib/utils";
3
6
 
@@ -7,23 +10,82 @@ export interface PageShellProps {
7
10
  navigation?: ReactNode;
8
11
  article?: ReactNode;
9
12
  outline?: ReactNode;
13
+ /** Spans the reading layout above navigation and article, such as the page's `PageHeader`. */
14
+ header?: ReactNode;
15
+ /**
16
+ * The reading layout's measure. `reading` (default) is the 42rem document
17
+ * column. `settings` is a 48rem column of cards whose navigation is a sticky
18
+ * 13rem column of rows when its frame is at least 60rem wide, and a sticky
19
+ * 56px tab strip under the header below that; the current link
20
+ * (`aria-current="page"`) is centred by scrolling the strip only. Put
21
+ * links (or a list of links) in `navigation`; an `svg` in a link shows only
22
+ * in the column, and an element with `data-count` sits at its end.
23
+ */
24
+ measure?: "reading" | "settings";
10
25
  navigationLabel?: string;
11
26
  outlineLabel?: string;
12
27
  className?: string;
13
28
  }
14
29
 
30
+ /**
31
+ * Centre the current link of a horizontally scrolling nav, so the links on
32
+ * both sides show that the strip scrolls. It moves the strip, never the page.
33
+ */
34
+ function useCurrentLinkInView(ref: React.RefObject<HTMLElement | null>, enabled: boolean) {
35
+ React.useEffect(() => {
36
+ const nav = ref.current;
37
+ if (!enabled || !nav) return;
38
+ const reveal = () => {
39
+ if (nav.scrollWidth <= nav.clientWidth) return;
40
+ const current = nav.querySelector<HTMLElement>('[aria-current="page"]');
41
+ if (!current) return;
42
+ const strip = nav.getBoundingClientRect();
43
+ const link = current.getBoundingClientRect();
44
+ nav.scrollLeft += link.left + link.width / 2 - (strip.left + strip.width / 2);
45
+ };
46
+ let live = true;
47
+ reveal();
48
+ // A web font swap widens the tabs without resizing the strip, so the
49
+ // resize observer alone would leave the current tab where it was.
50
+ void document.fonts?.ready.then(() => { if (live) reveal(); });
51
+ const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(reveal);
52
+ resize?.observe(nav);
53
+ // Links that a caller replaces (sections loaded later, a count that
54
+ // changes) move the current tab as surely as a new aria-current does.
55
+ const links = new MutationObserver(reveal);
56
+ links.observe(nav, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ["aria-current"] });
57
+ return () => {
58
+ live = false;
59
+ resize?.disconnect();
60
+ links.disconnect();
61
+ };
62
+ }, [ref, enabled]);
63
+ }
64
+
15
65
  /**
16
66
  * Ported from sandbox-ui/src/primitives/page-shell.tsx. An optional bounded
17
67
  * width, gutter and vertical rhythm, not an application shell or a card grid.
18
- * The caller owns landmarks, routing, auth, navigation and data state. Use
19
- * className to override the bound/gutters, or omit it for a workbench layout.
68
+ * The caller owns landmarks, routing, auth, navigation and data state.
69
+ * Without slots, className overrides the bound/gutters, or omit it for a
70
+ * workbench layout. With slots it goes on the reading grid. With
71
+ * `measure="settings"` it goes on the frame, the width the layout answers to,
72
+ * so a `max-w-*` there bounds the measure and a narrow frame gets the strip.
20
73
  */
21
- export function PageShell({ children, className, navigation, article, outline, navigationLabel = "Sections", outlineLabel = "On this page" }: PageShellProps) {
22
- if (navigation || article || outline) return <div className={cn("tangle-reading-shell",className)}>
23
- {navigation && <nav aria-label={navigationLabel} className="tangle-reading-nav">{navigation}</nav>}
24
- <article className="tangle-reading-article">{article ?? children}</article>
25
- {outline && <aside aria-label={outlineLabel} className="tangle-reading-outline">{outline}</aside>}
26
- </div>;
74
+ export function PageShell({ children, className, navigation, article, outline, header, measure = "reading", navigationLabel = "Sections", outlineLabel = "On this page" }: PageShellProps) {
75
+ const navRef = React.useRef<HTMLElement>(null);
76
+ const settings = measure === "settings";
77
+ useCurrentLinkInView(navRef, settings && Boolean(navigation));
78
+ if (navigation || article || outline || header || settings) {
79
+ const shell = <div className={cn("tangle-reading-shell", !settings && className)} data-measure={settings ? "settings" : undefined}>
80
+ {header && <div className="tangle-reading-header">{header}</div>}
81
+ {navigation && <nav ref={navRef} aria-label={navigationLabel} className="tangle-reading-nav">{navigation}</nav>}
82
+ <article className="tangle-reading-article">{article ?? children}</article>
83
+ {outline && <aside aria-label={outlineLabel} className="tangle-reading-outline">{outline}</aside>}
84
+ </div>;
85
+ // The frame is the width container the settings layout answers to, so the
86
+ // navigation switches on the content area's width, not the viewport's.
87
+ return settings ? <div className={cn("tangle-settings-frame", className)}>{shell}</div> : shell;
88
+ }
27
89
  return (
28
90
  <div
29
91
  className={cn(
@@ -0,0 +1,267 @@
1
+ import { Bell, Bot, Check, CreditCard, Inbox, Loader2, Settings, Users, Workflow } from "lucide-react";
2
+ import { useEffect, useState } from "react";
3
+ import type { Meta, StoryObj } from "@storybook/react";
4
+ import { Avatar, AvatarFallback } from "./avatar";
5
+ import { Button } from "./button";
6
+ import { Card } from "./card";
7
+ import { Field, fieldMessageId } from "./field";
8
+ import { Input, Textarea } from "./input";
9
+ import { PageHeader } from "./page-header";
10
+ import { PageShell } from "./page-shell";
11
+ import { SegmentedControl } from "./segmented-control";
12
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select";
13
+ import { SettingsList, SettingsListItem, SettingsRow, SettingsSection } from "./settings";
14
+ import { Switch } from "./switch";
15
+
16
+ /** Example data only: no routing, persistence or fetching. Every state is drawn at once so one screenshot shows them all. */
17
+
18
+ const SECTIONS = [
19
+ { id: "general", label: "General", icon: Settings },
20
+ { id: "agent", label: "Agent", icon: Bot },
21
+ { id: "notifications", label: "Notifications", icon: Bell },
22
+ { id: "automations", label: "Automations", icon: Workflow },
23
+ { id: "leads", label: "Inbound leads", icon: Inbox },
24
+ { id: "team", label: "Team", icon: Users, count: 4 },
25
+ { id: "billing", label: "Ad spend", icon: CreditCard },
26
+ ] as const;
27
+
28
+ function Saving() {
29
+ return <><Loader2 aria-hidden="true" className="size-4 animate-spin motion-reduce:animate-none" />Saving…</>;
30
+ }
31
+
32
+ function Saved() {
33
+ return <span className="inline-flex items-center gap-1 text-[var(--surface-success-text)]"><Check aria-hidden="true" className="size-4" />Saved</span>;
34
+ }
35
+
36
+ interface Automation { name: string; meta: string; state: string; verb: string; ownerOnly?: boolean; error?: string }
37
+
38
+ const automations: Automation[] = [
39
+ { name: "Weekly competitor scan", meta: "Mondays at 09:00", state: "Active", verb: "Pause" },
40
+ { name: "Inbound lead triage", meta: "When a signed webhook arrives", state: "Paused", verb: "Resume", ownerOnly: true },
41
+ { name: "Daily brief", meta: "Every day at 08:00 · Europe/Berlin", state: "Couldn't start", verb: "Try again", error: "Platform didn't respond. Nothing ran; try again." },
42
+ ];
43
+
44
+ /** Pause turns an automation off; Resume and Try again turn it back on. */
45
+ function runVerb(job: Automation): Automation {
46
+ return job.state === "Active" ? { ...job, state: "Paused", verb: "Resume" } : { ...job, state: "Active", verb: "Pause", error: undefined };
47
+ }
48
+
49
+ const members = [
50
+ { name: "Priya Raman", email: "priya@fieldnote.example", role: "owner" },
51
+ { name: "Jonas Weber", email: "jonas@fieldnote.example", role: "admin" },
52
+ { name: "Ana Lima", email: "ana.lima.growth-operations-team@fieldnote-analytics.example", role: "editor" },
53
+ ];
54
+
55
+ const roles = [
56
+ { value: "owner", label: "Owner" },
57
+ { value: "admin", label: "Can manage settings and people" },
58
+ { value: "editor", label: "Can edit and chat" },
59
+ { value: "viewer", label: "Can only view" },
60
+ ];
61
+
62
+ interface SettingsPageProps {
63
+ /** Only the first two sections render at first; the rest arrive 300ms later, as role-gated sections do. */
64
+ sectionsLoadLater?: boolean;
65
+ /** Leave out the section navigation. */
66
+ withoutNavigation?: boolean;
67
+ className?: string;
68
+ }
69
+
70
+ function SettingsPage({ sectionsLoadLater = false, withoutNavigation = false, className }: SettingsPageProps) {
71
+ const [current, setCurrent] = useState("team");
72
+ const [sections, setSections] = useState<readonly (typeof SECTIONS)[number][]>(sectionsLoadLater ? SECTIONS.slice(0, 2) : SECTIONS);
73
+ useEffect(() => {
74
+ if (!sectionsLoadLater) return;
75
+ const loaded = setTimeout(() => setSections(SECTIONS), 300);
76
+ return () => clearTimeout(loaded);
77
+ }, [sectionsLoadLater]);
78
+ const [name, setName] = useState("Fieldnote GTM");
79
+ const [digest, setDigest] = useState(true);
80
+ const [approval, setApproval] = useState("each");
81
+ const [jobs, setJobs] = useState(automations);
82
+ const [roleOf, setRoleOf] = useState<Record<string, string>>(Object.fromEntries(members.map((m) => [m.email, m.role])));
83
+
84
+ const navigation = withoutNavigation ? undefined : sections.map((section) => {
85
+ const Icon = section.icon;
86
+ return (
87
+ <a key={section.id} href={`#${section.id}`} aria-current={current === section.id ? "page" : undefined} onClick={() => setCurrent(section.id)}>
88
+ <Icon aria-hidden="true" />
89
+ <span>{section.label}</span>
90
+ {"count" in section && <span data-count="">{section.count}</span>}
91
+ </a>
92
+ );
93
+ });
94
+
95
+ return (
96
+ <PageShell
97
+ measure="settings"
98
+ className={className}
99
+ navigationLabel="Settings sections"
100
+ header={<PageHeader title="Project settings" description="Fieldnote GTM" />}
101
+ navigation={navigation}
102
+ article={<>
103
+ <SettingsSection id="general" title="General">
104
+ <SettingsRow label="Project name" htmlFor="project-name" status={<Saved />}>
105
+ <Input id="project-name" value={name} onChange={(event) => setName(event.target.value)} />
106
+ </SettingsRow>
107
+ <SettingsRow label="Timezone" htmlFor="timezone" description="Moves the digest hour; scheduled posts never move." info="Dates in chats, emails and exports use this zone.">
108
+ <Select defaultValue="berlin">
109
+ <SelectTrigger id="timezone" aria-describedby={fieldMessageId("timezone")}><SelectValue /></SelectTrigger>
110
+ <SelectContent>
111
+ <SelectItem value="berlin">Europe/Berlin (UTC+2)</SelectItem>
112
+ <SelectItem value="new-york">America/New_York (UTC−4)</SelectItem>
113
+ </SelectContent>
114
+ </Select>
115
+ </SettingsRow>
116
+ <SettingsRow label="Business brief" layout="compact">
117
+ <Button type="button" variant="outline">Edit brief</Button>
118
+ </SettingsRow>
119
+ <SettingsRow label="Custom instructions" htmlFor="instructions" layout="stacked" status={<Saving />} description="Applies to new chats and the next automation run.">
120
+ <Textarea id="instructions" defaultValue="Write in plain English. Never promise delivery dates." />
121
+ </SettingsRow>
122
+ </SettingsSection>
123
+
124
+ <SettingsSection id="notifications" title="Notifications" description="Daily digest and approval notices go to the owner.">
125
+ <SettingsRow label="Daily digest" htmlFor="digest" layout="compact">
126
+ <Switch id="digest" checked={digest} onCheckedChange={setDigest} />
127
+ </SettingsRow>
128
+ <SettingsRow label="Approval notices" htmlFor="approval-mode">
129
+ <SegmentedControl
130
+ id="approval-mode"
131
+ aria-label="Approval notices"
132
+ value={approval}
133
+ onValueChange={setApproval}
134
+ options={[{ value: "each", label: "Each request" }, { value: "daily", label: "Once a day" }, { value: "never", label: "Never" }]}
135
+ />
136
+ </SettingsRow>
137
+ <SettingsRow label="Digest hour" htmlFor="digest-hour" error="Couldn't save the digest hour." onRetry={() => undefined}>
138
+ <Input id="digest-hour" defaultValue="08:00" />
139
+ </SettingsRow>
140
+ <SettingsRow label="Texting phone" htmlFor="texting-phone" disabledReason="Only Priya, the owner, can change the texting phone.">
141
+ <Input id="texting-phone" defaultValue="+1 (555) 010-0199" disabled />
142
+ </SettingsRow>
143
+ <SettingsRow label="Send today's digest" layout="compact">
144
+ <Button type="button" variant="outline">Send now</Button>
145
+ </SettingsRow>
146
+ </SettingsSection>
147
+
148
+ <SettingsSection id="automations" title="Automations" info="Automations run on the project's agent and are billed as compute." actions={<Button type="button" variant="outline">New automation</Button>}>
149
+ <SettingsList aria-label="Automations" columns={{ status: "8rem", action: "9rem" }}>
150
+ {jobs.map((job) => (
151
+ <SettingsListItem
152
+ key={job.name}
153
+ title={<a href={`#${job.name}`}>{job.name}</a>}
154
+ meta={job.meta}
155
+ status={job.error ? <span className="font-medium text-[var(--surface-danger-text)]">{job.state}</span> : job.state}
156
+ error={job.error}
157
+ action={{
158
+ label: job.verb,
159
+ onSelect: () => setJobs((all) => all.map((each) => (each.name === job.name ? runVerb(each) : each))),
160
+ disabledReason: job.ownerOnly ? "Only Priya, the owner, can resume it." : undefined,
161
+ }}
162
+ menu={[
163
+ { label: "Duplicate", onSelect: () => undefined },
164
+ job.ownerOnly
165
+ ? { label: "Delete", tone: "danger", onSelect: () => undefined, disabledReason: "Only Priya, the owner, can delete it." }
166
+ : { label: "Delete", tone: "danger", onSelect: () => undefined },
167
+ ]}
168
+ />
169
+ ))}
170
+ </SettingsList>
171
+ </SettingsSection>
172
+
173
+ <SettingsSection id="team" title="Members" actions={<Button type="button">Invite</Button>}>
174
+ <SettingsList aria-label="Members" columns={{ leading: "32px", status: "18rem", menu: "36px" }}>
175
+ {members.map((member) => {
176
+ const roleId = `role-${member.email}`;
177
+ return (
178
+ <SettingsListItem
179
+ key={member.email}
180
+ leading={<Avatar size="sm"><AvatarFallback>{member.name.split(" ").map((part) => part[0]).join("")}</AvatarFallback></Avatar>}
181
+ title={member.name}
182
+ meta={member.email}
183
+ status={member.role === "owner" ? <span className="text-foreground">Owner</span> : (
184
+ <Select value={roleOf[member.email]} onValueChange={(role) => setRoleOf((all) => ({ ...all, [member.email]: role }))}>
185
+ <SelectTrigger id={roleId} aria-label={`Role for ${member.name}`}><SelectValue /></SelectTrigger>
186
+ <SelectContent>
187
+ {roles.filter((role) => role.value !== "owner").map((role) => <SelectItem key={role.value} value={role.value}>{role.label}</SelectItem>)}
188
+ </SelectContent>
189
+ </Select>
190
+ )}
191
+ menu={member.role === "owner" ? undefined : [{ label: "Remove from project", tone: "danger", onSelect: () => undefined }]}
192
+ />
193
+ );
194
+ })}
195
+ </SettingsList>
196
+ </SettingsSection>
197
+
198
+ <SettingsSection
199
+ id="billing"
200
+ title="Ad spend caps"
201
+ description="Ads pause when a cap is reached."
202
+ footer={<>
203
+ <span className="text-muted-foreground">2 unsaved changes</span>
204
+ <span className="ml-auto flex gap-2">
205
+ <Button type="button" variant="ghost">Discard</Button>
206
+ <Button type="button">Save caps</Button>
207
+ </span>
208
+ </>}
209
+ >
210
+ <SettingsRow label="Daily cap" htmlFor="daily-cap"><Input id="daily-cap" defaultValue="$40" /></SettingsRow>
211
+ <SettingsRow label="Monthly cap" htmlFor="monthly-cap"><Input id="monthly-cap" defaultValue="$900" /></SettingsRow>
212
+ </SettingsSection>
213
+
214
+ <SettingsSection id="delete" tone="danger" title="Delete project" description="Deletes every chat, vault file and automation for everyone." actions={<Button type="button" variant="destructive-outline">Delete project…</Button>} />
215
+ </>}
216
+ />
217
+ );
218
+ }
219
+
220
+ function FieldsExample() {
221
+ const [model, setModel] = useState("claude-sonnet");
222
+ return (
223
+ <div className="mx-auto max-w-xl p-6">
224
+ <Card className="space-y-4 p-4">
225
+ <Field label="Display name" htmlFor="display-name" hint="Use 40 characters or fewer.">
226
+ <Input id="display-name" defaultValue="Research agent" aria-describedby={fieldMessageId("display-name")} />
227
+ </Field>
228
+ <Field label="Default model" htmlFor="default-model" info="Replaces the profile's model for new chats." aside={<Button type="button" variant="ghost">Use default</Button>}
229
+ error={model.startsWith("claude") ? "This project's catalog refuses Anthropic models. Pick another." : undefined}>
230
+ <Select value={model} onValueChange={setModel}>
231
+ <SelectTrigger id="default-model" aria-invalid={model.startsWith("claude") || undefined} aria-describedby={fieldMessageId("default-model")}><SelectValue /></SelectTrigger>
232
+ <SelectContent>
233
+ <SelectItem value="claude-sonnet">claude-sonnet</SelectItem>
234
+ <SelectItem value="gpt-6.1-sol">gpt-6.1-sol</SelectItem>
235
+ </SelectContent>
236
+ </Select>
237
+ </Field>
238
+ <Field label="Webhook URL" htmlFor="webhook" status="Reachable: the last check returned 200.">
239
+ <Input id="webhook" defaultValue="https://hooks.fieldnote.example/gtm/leads" aria-describedby={fieldMessageId("webhook")} />
240
+ </Field>
241
+ </Card>
242
+ </div>
243
+ );
244
+ }
245
+
246
+ const meta = {
247
+ title: "Primitives/Settings",
248
+ component: SettingsSection,
249
+ parameters: { layout: "fullscreen" },
250
+ } satisfies Meta<typeof SettingsSection>;
251
+ export default meta;
252
+ type Story = StoryObj<typeof SettingsSection>;
253
+
254
+ /** Every settings atom state on one page in PageShell measure="settings"; resize to see the nav switch at 60rem and rows stack at 34rem. */
255
+ export const Page: Story = { render: () => <SettingsPage /> };
256
+
257
+ /** The sections a role gates load after the page renders; on a phone the strip still centres the current tab when it arrives. */
258
+ export const SectionsLoadLater: Story = { render: () => <SettingsPage sectionsLoadLater /> };
259
+
260
+ /** A settings page with no section navigation is the 48rem column alone at every width. */
261
+ export const WithoutNavigation: Story = { render: () => <SettingsPage withoutNavigation /> };
262
+
263
+ /** className bounds the frame the layout answers to, so a 42rem page in a wide window gets the tab strip. */
264
+ export const Bounded: Story = { render: () => <SettingsPage className="mx-auto max-w-2xl" /> };
265
+
266
+ /** The promoted Field: hint, info tip with an aside action, a live error, and a status. */
267
+ export const Fields: Story = { render: () => <FieldsExample /> };