@tangle-network/ui 11.38.2 → 11.40.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.
@@ -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 /> };