@tangle-network/ui 11.48.0 → 11.50.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 (50) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/chat/agent-timeline.js +4 -3
  3. package/dist/chat/speaker.d.ts +3 -3
  4. package/dist/chat/speaker.js +12 -18
  5. package/dist/index.d.ts +4 -3
  6. package/dist/index.js +4 -3
  7. package/dist/markdown/code-block.js +1 -1
  8. package/dist/presentation.d.ts +12 -0
  9. package/dist/presentation.js +85 -1
  10. package/dist/primitives/metric-strip.d.ts +2 -2
  11. package/dist/primitives/page-shell.d.ts +6 -3
  12. package/dist/primitives/page-shell.js +3 -60
  13. package/dist/primitives/progress.d.ts +8 -0
  14. package/dist/primitives/progress.js +8 -2
  15. package/dist/primitives/settings.d.ts +28 -1
  16. package/dist/primitives/settings.js +78 -29
  17. package/dist/primitives/status-pill.js +11 -2
  18. package/dist/primitives.d.ts +3 -3
  19. package/dist/primitives.js +3 -3
  20. package/dist/run/expanded-tool-detail.js +12 -4
  21. package/dist/run/run-group.js +21 -9
  22. package/dist/run/run-phase-list.js +5 -8
  23. package/dist/run/status-meta.d.ts +25 -0
  24. package/dist/run/status-meta.js +105 -0
  25. package/dist/run.d.ts +2 -1
  26. package/dist/run.js +2 -1
  27. package/package.json +2 -2
  28. package/src/chat/agent-timeline.test.tsx +9 -0
  29. package/src/chat/agent-timeline.tsx +4 -3
  30. package/src/chat/speaker.test.tsx +3 -1
  31. package/src/chat/speaker.tsx +13 -14
  32. package/src/markdown/code-block.tsx +2 -1
  33. package/src/markdown/markdown.stories.tsx +11 -0
  34. package/src/presentation.ts +68 -0
  35. package/src/primitives/index.ts +2 -1
  36. package/src/primitives/metric-strip.tsx +2 -2
  37. package/src/primitives/page-shell.tsx +9 -58
  38. package/src/primitives/progress.stories.tsx +10 -0
  39. package/src/primitives/progress.tsx +13 -2
  40. package/src/primitives/settings.stories.tsx +94 -3
  41. package/src/primitives/settings.tsx +80 -19
  42. package/src/primitives/status-pill.tsx +8 -1
  43. package/src/run/expanded-tool-detail.tsx +4 -10
  44. package/src/run/index.ts +8 -0
  45. package/src/run/run-group.tsx +8 -16
  46. package/src/run/run-phase-list.stories.tsx +19 -0
  47. package/src/run/run-phase-list.test.tsx +9 -1
  48. package/src/run/run-phase-list.tsx +3 -3
  49. package/src/run/status-meta.tsx +92 -0
  50. package/src/styles/presentation.css +3 -3
@@ -2,7 +2,6 @@ import { TRANSCRIPT_TEXT } from "./transcript-text";
2
2
  import { useState, type KeyboardEvent, type ReactNode } from "react";
3
3
  import {
4
4
  AlertTriangle,
5
- CheckCircle2,
6
5
  CircleDot,
7
6
  FileText,
8
7
  Info,
@@ -16,6 +15,7 @@ import { Markdown } from "../markdown/markdown";
16
15
  import { ThinkingIndicator } from "./thinking-indicator";
17
16
  import { type ToolCallData } from "../run/tool-call-feed";
18
17
  import { ToolCallGroup, ToolCallStep } from "../run/tool-call-step";
18
+ import { statusMeta } from "../run/status-meta";
19
19
  import type { ChatAuthor } from "../types/message";
20
20
  import type { ToolPart } from "../types/parts";
21
21
 
@@ -132,7 +132,7 @@ const TONE_STYLES: Record<AgentTimelineTone, { card: string; text: string; suppo
132
132
  card: "border-[var(--surface-success-border)] bg-[var(--surface-success-bg)]",
133
133
  text: "text-[var(--surface-success-text)]",
134
134
  supportingText: "text-[var(--surface-success-text)]",
135
- icon: CheckCircle2,
135
+ icon: statusMeta("done").icon,
136
136
  },
137
137
  warning: {
138
138
  card: "border-[var(--surface-warning-border)] bg-[var(--surface-warning-bg)]",
@@ -144,7 +144,8 @@ const TONE_STYLES: Record<AgentTimelineTone, { card: string; text: string; suppo
144
144
  card: "border-[var(--surface-danger-border)] bg-[var(--surface-danger-bg)]",
145
145
  text: "text-[var(--surface-danger-text)]",
146
146
  supportingText: "text-[var(--surface-danger-text)]",
147
- icon: AlertTriangle,
147
+ // Failed is always the failed glyph; the triangle is for warnings.
148
+ icon: statusMeta("failed").icon,
148
149
  },
149
150
  };
150
151
 
@@ -52,6 +52,7 @@ describe("SpeakerHeader", () => {
52
52
  expect(screen.getByText("@writer")).toBeInTheDocument()
53
53
  expect(screen.getByText("Answering")).toBeInTheDocument()
54
54
  expect(container.querySelector("[data-speaker-state]")?.getAttribute("data-speaker-state")).toBe("answering")
55
+ expect(container.querySelector("[data-run-status]")?.getAttribute("data-run-status")).toBe("running")
55
56
  })
56
57
 
57
58
  it("says nothing more once the responder has ended, even while others answer", () => {
@@ -62,8 +63,9 @@ describe("SpeakerHeader", () => {
62
63
  })
63
64
 
64
65
  it("shows a failed responder as failed while the turn still runs", () => {
65
- render(<SpeakerHeader speaker={{ id: "ap_research", name: "Research Lead", state: "failed" }} live />)
66
+ const { container } = render(<SpeakerHeader speaker={{ id: "ap_research", name: "Research Lead", state: "failed" }} live />)
66
67
  expect(screen.getByText("Failed")).toBeInTheDocument()
68
+ expect(container.querySelector("[data-run-status]")?.getAttribute("data-run-status")).toBe("failed")
67
69
  expect(screen.queryByText("Answering")).not.toBeInTheDocument()
68
70
  })
69
71
 
@@ -1,5 +1,5 @@
1
1
  import type { HTMLAttributes } from "react";
2
- import { StatusPill } from "../primitives/status-pill";
2
+ import { RunStatusIcon } from "../run/status-meta";
3
3
  import { MessageAuthor } from "./message-author";
4
4
 
5
5
  /** Where one responder is in its answer. */
@@ -79,9 +79,9 @@ export interface SpeakerHeaderProps extends Omit<HTMLAttributes<HTMLDivElement>,
79
79
  * gets, then the handle a person types to address the agent, then its state.
80
80
  *
81
81
  * The state comes from the speaker's own end state, else from `live`. An
82
- * answering responder shows the status grammar's open ring with the word, a
83
- * failed one its cross with the word, and a finished one nothing, as a settled
84
- * run row does. `data-speaker-state` carries all three.
82
+ * answering responder shows the running glyph with the word, a failed one the
83
+ * failed glyph with the word, both from `RunStatusIcon`, and a finished one
84
+ * nothing, as a settled run row does. `data-speaker-state` carries all three.
85
85
  */
86
86
  export function SpeakerHeader({ speaker, live = false, ...props }: SpeakerHeaderProps) {
87
87
  const state: SpeakerState = speaker.state ?? (live ? "answering" : "done");
@@ -92,19 +92,18 @@ export function SpeakerHeader({ speaker, live = false, ...props }: SpeakerHeader
92
92
  {...props}
93
93
  >
94
94
  {speaker.handle ? (
95
- <span className="min-w-0 truncate text-muted-foreground text-sm" title={`@${speaker.handle}`}>
95
+ // A person reads the name first. The handle has no base width and grows only into the space
96
+ // the name and state leave, up to its own width, so it gives way before the name loses a pixel.
97
+ <span className="min-w-0 max-w-fit flex-1 truncate text-muted-foreground text-sm" title={`@${speaker.handle}`}>
96
98
  @{speaker.handle}
97
99
  </span>
98
100
  ) : null}
99
- {state === "answering" ? (
100
- <StatusPill tone="running" bare size="md" className="shrink-0 py-0">
101
- Answering
102
- </StatusPill>
103
- ) : state === "failed" ? (
104
- <StatusPill tone="danger" bare size="md" className="shrink-0 py-0">
105
- Failed
106
- </StatusPill>
107
- ) : null}
101
+ {state === "done" ? null : (
102
+ <span className="inline-flex shrink-0 items-center gap-1.5 font-medium text-sm">
103
+ <RunStatusIcon status={state === "failed" ? "failed" : "running"} aria-hidden />
104
+ {state === "failed" ? "Failed" : "Answering"}
105
+ </span>
106
+ )}
108
107
  </MessageAuthor>
109
108
  );
110
109
  }
@@ -169,7 +169,8 @@ export const CopyButton = memo(({ text, label: actionLabel, copiedLabel="Copied"
169
169
  onClick={handleCopy}
170
170
  className={cn(
171
171
  codePresentation.copy,
172
- actionLabel && "gap-2 px-3",
172
+ // The header's type, not the code font the block sets.
173
+ actionLabel && "gap-2 px-3 font-sans text-sm",
173
174
  focusRing,
174
175
  className,
175
176
  )}
@@ -221,6 +221,17 @@ export const CodeBlockOnly: Story = {
221
221
  ),
222
222
  }
223
223
 
224
+ export const CodeBlockLabelledCopy: Story = {
225
+ name: 'CodeBlock — labelled copy action',
226
+ render: () => (
227
+ <div className="w-full max-w-[680px] p-6 rounded-xl bg-card">
228
+ <CodeBlock code={`<script src="https://embed.tangle.tools/agent.js" data-agent="support"></script>`} language="html" label="HTML">
229
+ <CopyButton text={`<script src="https://embed.tangle.tools/agent.js" data-agent="support"></script>`} label="Copy embed code" />
230
+ </CodeBlock>
231
+ </div>
232
+ ),
233
+ }
234
+
224
235
  export const CodeBlockNoLanguage: Story = {
225
236
  name: 'CodeBlock — no language',
226
237
  render: () => (
@@ -110,6 +110,7 @@ export const metricPresentation = {
110
110
  } as const;
111
111
 
112
112
  export const metricLayouts = {
113
+ 2: "grid-cols-1 sm:grid-cols-2 max-sm:[&>div+div]:border-t sm:[&>div:not(:nth-child(2n+1))]:border-l",
113
114
  3: "grid-cols-2 sm:grid-cols-3 max-sm:[&>div:not(:nth-child(2n+1))]:border-l sm:[&>div:not(:nth-child(3n+1))]:border-l",
114
115
  4: "grid-cols-2 sm:grid-cols-4 max-sm:[&>div:not(:nth-child(2n+1))]:border-l sm:[&>div:not(:nth-child(4n+1))]:border-l",
115
116
  5: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 max-sm:[&>div:not(:nth-child(2n+1))]:border-l sm:max-lg:[&>div:not(:nth-child(3n+1))]:border-l lg:[&>div:not(:nth-child(5n+1))]:border-l",
@@ -180,6 +181,73 @@ export function bindDialog(dialog: HTMLDialogElement, trigger: HTMLElement): ()
180
181
  return () => {trigger.removeEventListener("click",show);dialog.removeEventListener("close",close);dialog.removeEventListener("click",click);dialog.removeEventListener("keydown",key);};
181
182
  }
182
183
 
184
+ /**
185
+ * The settings section links of PageShell `measure="settings"`, for any host: PageShell binds its
186
+ * nav with it, and a server-rendered host binds the same markup. On the tab strip the current link
187
+ * (`aria-current="page"`) is centred by scrolling the strip only, never the page. It centres when
188
+ * the current link changes or first becomes reachable, never on scroll or on a change that keeps
189
+ * the same current link, so a user who scrolled to the first or last section stays there; resize
190
+ * and font swaps re-centre only until the user first scrolls the strip. A plain click on a link to
191
+ * another document makes it current at once, while that document loads, and a page restored from
192
+ * the back-forward cache shows the link it left.
193
+ */
194
+ export function bindSettingsNav(nav: HTMLElement): () => void {
195
+ let centredKey: string | null = null;
196
+ let userScrolled = false;
197
+ let left: { from: Element | null; to: Element } | null = null;
198
+ const current = () => nav.querySelector<HTMLElement>('[aria-current="page"]');
199
+ const reveal = (layoutOnly: boolean) => {
200
+ const link = current();
201
+ if (nav.scrollWidth <= nav.clientWidth || !link) return;
202
+ const key = link.getAttribute("href") ?? link.textContent ?? "";
203
+ if (key !== centredKey) userScrolled = false;
204
+ else if (!layoutOnly || userScrolled) return;
205
+ centredKey = key;
206
+ const strip = nav.getBoundingClientRect(), box = link.getBoundingClientRect();
207
+ nav.scrollLeft += box.left + box.width / 2 - (strip.left + strip.width / 2);
208
+ };
209
+ const mark = (from: Element | null, to: Element | null) => { from?.removeAttribute("aria-current"); to?.setAttribute("aria-current", "page"); };
210
+ // On window, so it runs after the page's own handlers: a client router that took the click
211
+ // (preventDefault) sets the current link itself.
212
+ const click = (event: MouseEvent) => {
213
+ if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
214
+ const link = event.target instanceof Element ? event.target.closest("a[href]") : null;
215
+ if (!(link instanceof HTMLAnchorElement) || !nav.contains(link) || (link.target && link.target !== "_self") || link.hasAttribute("download")) return;
216
+ if (link.origin !== location.origin || link.pathname + link.search === location.pathname + location.search) return;
217
+ const from = current();
218
+ if (from === link) return;
219
+ left = { from, to: link };
220
+ mark(from, link);
221
+ };
222
+ const restore = (event: PageTransitionEvent) => {
223
+ if (!event.persisted || !left) return;
224
+ mark(left.to, left.from);
225
+ left = null;
226
+ };
227
+ const onUser = () => { userScrolled = true; };
228
+ const userEvents = ["pointerdown", "touchstart", "wheel", "keydown"] as const;
229
+ for (const type of userEvents) nav.addEventListener(type, onUser, { passive: true });
230
+ window.addEventListener("click", click);
231
+ window.addEventListener("pageshow", restore);
232
+ let live = true;
233
+ reveal(false);
234
+ // A web font swap widens the tabs without resizing the strip.
235
+ void document.fonts?.ready.then(() => { if (live) reveal(true); });
236
+ const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => reveal(true));
237
+ resize?.observe(nav);
238
+ // Links a host replaces (sections loaded later, a new aria-current) can change the current link.
239
+ const links = new MutationObserver(() => reveal(false));
240
+ links.observe(nav, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ["aria-current"] });
241
+ return () => {
242
+ live = false;
243
+ resize?.disconnect();
244
+ links.disconnect();
245
+ for (const type of userEvents) nav.removeEventListener(type, onUser);
246
+ window.removeEventListener("click", click);
247
+ window.removeEventListener("pageshow", restore);
248
+ };
249
+ }
250
+
183
251
  export const labelVariants = cva(
184
252
  "font-medium text-[length:var(--font-size-label,0.875rem)] leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
185
253
  );
@@ -203,13 +203,14 @@ export type { PageShellProps } from "./page-shell";
203
203
  export { Field, fieldMessageId } from "./field";
204
204
  export type { FieldProps } from "./field";
205
205
 
206
- export { SettingsList, SettingsListItem, SettingsRow, SettingsSection } from "./settings";
206
+ export { SettingsList, SettingsListItem, SettingsNote, SettingsRow, SettingsSection } from "./settings";
207
207
  export type {
208
208
  SettingsListAction,
209
209
  SettingsListColumns,
210
210
  SettingsListItemProps,
211
211
  SettingsListMenuItem,
212
212
  SettingsListProps,
213
+ SettingsNoteProps,
213
214
  SettingsRowLayout,
214
215
  SettingsRowProps,
215
216
  SettingsSectionProps,
@@ -8,8 +8,8 @@ export interface MetricStripProps
8
8
  extends React.HTMLAttributes<HTMLDListElement> {
9
9
  children: React.ReactNode;
10
10
  /** Desktop columns. Default retains the existing two / sm:four layout.
11
- * Five and six use three columns between sm and lg. Never inferred from data. */
12
- columns?: 3 | 4 | 5 | 6;
11
+ * Two stack below sm; five and six use three columns between sm and lg. Never inferred from data. */
12
+ columns?: 2 | 3 | 4 | 5 | 6;
13
13
  }
14
14
 
15
15
  const MetricStrip = React.forwardRef<HTMLDListElement, MetricStripProps>(
@@ -3,6 +3,7 @@
3
3
  import * as React from "react";
4
4
  import type { ReactNode } from "react";
5
5
  import { cn } from "../lib/utils";
6
+ import { bindSettingsNav } from "../presentation";
6
7
 
7
8
  export interface PageShellProps {
8
9
  children?: ReactNode;
@@ -17,9 +18,12 @@ export interface PageShellProps {
17
18
  * column. `settings` is a 48rem column of cards whose navigation is a sticky
18
19
  * 13rem column of rows when its frame is at least 60rem wide, and a sticky
19
20
  * 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.
21
+ * (`aria-current="page"`) is centred by scrolling the strip only
22
+ * ({@link bindSettingsNav}). Put links (or a list of links) in `navigation`;
23
+ * an `svg` in a link shows only in the column, and an element with
24
+ * `data-count` sits at its end. A host whose page scrolls under its own
25
+ * sticky header sets `--tangle-settings-sticky-top` on the frame or an
26
+ * ancestor, such as `var(--shell-header-height)`, and the nav sticks below it.
23
27
  */
24
28
  measure?: "reading" | "settings";
25
29
  navigationLabel?: string;
@@ -27,60 +31,6 @@ export interface PageShellProps {
27
31
  className?: string;
28
32
  }
29
33
 
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
- * It centres when the current link changes (or first becomes reachable), never
34
- * on scroll, arrow taps or re-renders that keep the same current link, so a
35
- * user who scrolls to the first or last section stays there. Resize and font
36
- * swaps re-centre only until the user first scrolls the strip.
37
- */
38
- function useCurrentLinkInView(ref: React.RefObject<HTMLElement | null>, enabled: boolean) {
39
- React.useEffect(() => {
40
- const nav = ref.current;
41
- if (!enabled || !nav) return;
42
- let centredKey: string | null = null;
43
- let userScrolled = false;
44
- const reveal = (layoutOnly: boolean) => {
45
- if (nav.scrollWidth <= nav.clientWidth) return;
46
- const current = nav.querySelector<HTMLElement>('[aria-current="page"]');
47
- if (!current) return;
48
- const key = current.getAttribute("href") ?? current.textContent ?? "";
49
- if (key !== centredKey) userScrolled = false;
50
- else if (!layoutOnly || userScrolled) return;
51
- centredKey = key;
52
- const strip = nav.getBoundingClientRect();
53
- const link = current.getBoundingClientRect();
54
- nav.scrollLeft += link.left + link.width / 2 - (strip.left + strip.width / 2);
55
- };
56
- const onUser = () => { userScrolled = true; };
57
- nav.addEventListener("pointerdown", onUser, { passive: true });
58
- nav.addEventListener("touchstart", onUser, { passive: true });
59
- nav.addEventListener("wheel", onUser, { passive: true });
60
- nav.addEventListener("keydown", onUser);
61
- let live = true;
62
- reveal(false);
63
- // A web font swap widens the tabs without resizing the strip, so the
64
- // resize observer alone would leave the current tab where it was.
65
- void document.fonts?.ready.then(() => { if (live) reveal(true); });
66
- const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => reveal(true));
67
- resize?.observe(nav);
68
- // Links that a caller replaces (sections loaded later, a new aria-current)
69
- // can change the current link; the same current link is left where it is.
70
- const links = new MutationObserver(() => reveal(false));
71
- links.observe(nav, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ["aria-current"] });
72
- return () => {
73
- live = false;
74
- resize?.disconnect();
75
- links.disconnect();
76
- nav.removeEventListener("pointerdown", onUser);
77
- nav.removeEventListener("touchstart", onUser);
78
- nav.removeEventListener("wheel", onUser);
79
- nav.removeEventListener("keydown", onUser);
80
- };
81
- }, [ref, enabled]);
82
- }
83
-
84
34
  /**
85
35
  * Ported from sandbox-ui/src/primitives/page-shell.tsx. An optional bounded
86
36
  * width, gutter and vertical rhythm, not an application shell or a card grid.
@@ -93,7 +43,8 @@ function useCurrentLinkInView(ref: React.RefObject<HTMLElement | null>, enabled:
93
43
  export function PageShell({ children, className, navigation, article, outline, header, measure = "reading", navigationLabel = "Sections", outlineLabel = "On this page" }: PageShellProps) {
94
44
  const navRef = React.useRef<HTMLElement>(null);
95
45
  const settings = measure === "settings";
96
- useCurrentLinkInView(navRef, settings && Boolean(navigation));
46
+ const hasNavigation = Boolean(navigation);
47
+ React.useEffect(() => (settings && hasNavigation && navRef.current ? bindSettingsNav(navRef.current) : undefined), [settings, hasNavigation]);
97
48
  if (navigation || article || outline || header || settings) {
98
49
  const shell = <div className={cn("tangle-reading-shell", !settings && className)} data-measure={settings ? "settings" : undefined}>
99
50
  {header && <div className="tangle-reading-header">{header}</div>}
@@ -38,6 +38,16 @@ export const SandboxWithValue: Story = {
38
38
  ),
39
39
  }
40
40
 
41
+ export const Tones: Story = {
42
+ render: () => (
43
+ <div className="flex w-80 flex-col gap-4">
44
+ {(['neutral', 'success', 'warning', 'danger'] as const).map((tone) => (
45
+ <Progress key={tone} value={tone === 'danger' ? 100 : 70} tone={tone} aria-label={`${tone} reading`} />
46
+ ))}
47
+ </div>
48
+ ),
49
+ }
50
+
41
51
  export const Empty: Story = {
42
52
  render: () => <Progress value={0} className="w-80" />,
43
53
  }
@@ -1,16 +1,27 @@
1
1
  import * as ProgressPrimitive from "@radix-ui/react-progress";
2
2
  import * as React from "react";
3
3
  import { cn } from "../lib/utils";
4
+ import type { StatusTone } from "./status-pill";
5
+
6
+ // A status ink is also its fill (Brand tokens), so a toned bar matches the state text beside it.
7
+ const TONE_FILL = {
8
+ neutral: "bg-[var(--surface-neutral-text)]",
9
+ success: "bg-[var(--surface-success-text)]",
10
+ warning: "bg-[var(--surface-warning-text)]",
11
+ danger: "bg-[var(--surface-danger-text)]",
12
+ } satisfies Partial<Record<StatusTone, string>>;
4
13
 
5
14
  const Progress = React.forwardRef<
6
15
  React.ElementRef<typeof ProgressPrimitive.Root>,
7
16
  React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> & {
8
17
  variant?: "default" | "sandbox";
18
+ /** A state the reading is in, such as `danger` at a limit; it replaces the variant's fill. */
19
+ tone?: keyof typeof TONE_FILL;
9
20
  showValue?: boolean;
10
21
  }
11
22
  >(
12
23
  (
13
- { className, value, variant = "default", showValue = false, ...props },
24
+ { className, value, variant = "default", tone, showValue = false, ...props },
14
25
  ref,
15
26
  ) => {
16
27
  const indicatorVariants = {
@@ -31,7 +42,7 @@ const Progress = React.forwardRef<
31
42
  <ProgressPrimitive.Indicator
32
43
  className={cn(
33
44
  "h-full w-full flex-1 transition-all duration-300 ease-out",
34
- indicatorVariants[variant],
45
+ tone ? TONE_FILL[tone] : indicatorVariants[variant],
35
46
  )}
36
47
  style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
37
48
  />
@@ -1,17 +1,20 @@
1
1
  import { Bell, Bot, Check, CreditCard, Inbox, Loader2, Settings, Users, Workflow } from "lucide-react";
2
- import { useEffect, useState } from "react";
2
+ import { useEffect, useRef, useState } from "react";
3
3
  import type { Meta, StoryObj } from "@storybook/react";
4
4
  import { Avatar, AvatarFallback } from "./avatar";
5
5
  import { Button } from "./button";
6
6
  import { Card } from "./card";
7
7
  import { Field, fieldMessageId } from "./field";
8
8
  import { Input, Textarea } from "./input";
9
+ import { Metric, MetricStrip } from "./metric-strip";
9
10
  import { PageHeader } from "./page-header";
10
11
  import { PageShell } from "./page-shell";
11
12
  import { SegmentedControl } from "./segmented-control";
12
13
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select";
13
- import { SettingsList, SettingsListItem, SettingsRow, SettingsSection } from "./settings";
14
+ import { SettingsList, SettingsListItem, SettingsNote, SettingsRow, SettingsSection } from "./settings";
15
+ import type { StatusTone } from "./status-pill";
14
16
  import { Switch } from "./switch";
17
+ import { bindSettingsNav } from "../presentation";
15
18
 
16
19
  /** Example data only: no routing, persistence or fetching. Every state is drawn at once so one screenshot shows them all. */
17
20
 
@@ -35,6 +38,8 @@ function Saved() {
35
38
 
36
39
  interface Automation { name: string; meta: string; state: string; verb: string; ownerOnly?: boolean; error?: string }
37
40
 
41
+ const AUTOMATION_TONE: Record<string, StatusTone> = { Active: "success", Paused: "neutral", "Couldn't start": "danger" };
42
+
38
43
  const automations: Automation[] = [
39
44
  { name: "Weekly competitor scan", meta: "Mondays at 09:00", state: "Active", verb: "Pause" },
40
45
  { name: "Inbound lead triage", meta: "When a signed webhook arrives", state: "Paused", verb: "Resume", ownerOnly: true },
@@ -152,7 +157,8 @@ function SettingsPage({ sectionsLoadLater = false, withoutNavigation = false, cl
152
157
  key={job.name}
153
158
  title={<a href={`#${job.name}`}>{job.name}</a>}
154
159
  meta={job.meta}
155
- status={job.error ? <span className="font-medium text-[var(--surface-danger-text)]">{job.state}</span> : job.state}
160
+ status={job.state}
161
+ tone={AUTOMATION_TONE[job.state]}
156
162
  error={job.error}
157
163
  action={{
158
164
  label: job.verb,
@@ -277,6 +283,85 @@ function SettingsValuesExample() {
277
283
  );
278
284
  }
279
285
 
286
+ const webhooks: Array<{ url: string; events: string[]; state: string; tone: StatusTone }> = [
287
+ { url: "https://hooks.fieldnote-analytics.example/intelligence/v1/trace-events", events: ["trace.completed", "eval.regressed", "budget.exceeded"], state: "Active", tone: "success" },
288
+ { url: "https://ops.fieldnote.example/alerts/webhooks/intelligence_regressions", events: ["eval.regressed"], state: "Inactive", tone: "neutral" },
289
+ { url: "https://n8n.fieldnote.example/webhook/7f3c2a91-5d4e-4b8a-9c61-2e0f8d7a4b13", events: ["budget.exceeded", "alert.triggered"], state: "Failing", tone: "danger" },
290
+ ];
291
+
292
+ const alertRules: Array<{ name: string; rule: string; severity: string; tone: StatusTone }> = [
293
+ { name: "Error rate", rule: "Above 5% for 10 minutes", severity: "Critical", tone: "danger" },
294
+ { name: "p95 latency", rule: "Above 4 s for 15 minutes", severity: "Warning", tone: "warning" },
295
+ { name: "Daily spend", rule: "Above $40 in a day", severity: "Notice", tone: "neutral" },
296
+ ];
297
+
298
+ /** Notes (figure, empty, loading, notice, refusal), toned list states, and long URLs and event names in narrow columns. */
299
+ function NotesAndTonesExample() {
300
+ return (
301
+ <div className="mx-auto max-w-3xl space-y-6 p-6 max-sm:p-4">
302
+ <SettingsSection id="webhooks" title="Webhooks" description="Signed POSTs when traces, evals or budgets change.">
303
+ <SettingsNote>
304
+ <MetricStrip columns={2} aria-label="Last 7 days">
305
+ <Metric label="Deliveries" value="1,284" />
306
+ <Metric label="Failed" value="3" />
307
+ </MetricStrip>
308
+ </SettingsNote>
309
+ <SettingsList aria-label="Webhook endpoints" columns={{ status: "7rem", action: "8rem", menu: "36px" }}>
310
+ {webhooks.map((hook) => (
311
+ <SettingsListItem
312
+ key={hook.url}
313
+ title={<a href={`#${hook.state}`}>{hook.url}</a>}
314
+ meta={hook.events.join(", ")}
315
+ status={hook.state}
316
+ tone={hook.tone}
317
+ action={{ label: "Send test", onSelect: () => undefined }}
318
+ menu={[{ label: "Delete…", tone: "danger", onSelect: () => undefined }]}
319
+ />
320
+ ))}
321
+ </SettingsList>
322
+ <SettingsNote tone="warning" role="status">One endpoint failed its last three deliveries. It pauses after five.</SettingsNote>
323
+ </SettingsSection>
324
+ <SettingsSection id="alert-rules" title="Alert rules">
325
+ <SettingsList aria-label="Alert rules" columns={{ status: "7rem", menu: "36px" }}>
326
+ {alertRules.map((rule) => (
327
+ <SettingsListItem key={rule.name} title={rule.name} meta={rule.rule} status={rule.severity} tone={rule.tone} menu={[{ label: "Delete…", tone: "danger", onSelect: () => undefined }]} />
328
+ ))}
329
+ </SettingsList>
330
+ </SettingsSection>
331
+ <SettingsSection id="saved-versions" title="Saved versions">
332
+ <SettingsNote tone="muted">No saved versions yet.</SettingsNote>
333
+ <SettingsNote tone="muted" role="status"><span className="inline-flex items-center gap-1.5"><Loader2 aria-hidden="true" className="size-4 animate-spin motion-reduce:animate-none" />Loading saved versions…</span></SettingsNote>
334
+ <SettingsNote tone="danger" role="alert">Couldn't load saved versions. The workspace didn't answer; nothing changed.</SettingsNote>
335
+ </SettingsSection>
336
+ </div>
337
+ );
338
+ }
339
+
340
+ const HOST_SECTIONS = [["account", "Account"], ["keys", "API keys"], ["usage", "Usage"], ["billing", "Billing"], ["webhooks", "Webhooks"], ["team", "Team"]] as const;
341
+
342
+ /** What a server-rendered host such as tangle-router writes: PageShell's settings markup, with no React inside it. */
343
+ const HOST_MARKUP = `<div class="tangle-settings-frame" style="--tangle-settings-sticky-top: var(--shell-header-height)">
344
+ <div class="tangle-reading-shell" data-measure="settings">
345
+ <div class="tangle-reading-header"><h1 class="tangle-heading" data-variant="section">Settings</h1></div>
346
+ <nav aria-label="Settings sections" class="tangle-reading-nav"><ul role="list">${HOST_SECTIONS.map(([id, label]) => `<li><a href="/settings-doc/${id}"${id === "usage" ? ' aria-current="page"' : ""}>${label}</a></li>`).join("")}</ul></nav>
347
+ <article class="tangle-reading-article">${["Usage", "Limits", "Sources"].map((name) => `<section role="region" aria-label="${name}" data-slot="card" class="rounded-[var(--radius-lg)] border border-border bg-card p-5" style="min-height: 28rem"><h2 class="font-semibold">${name}</h2></section>`).join("")}</article>
348
+ </div></div>`;
349
+
350
+ /** A host whose document scrolls under its own sticky header, with server markup bound by bindSettingsNav. */
351
+ function ServerRenderedHostExample() {
352
+ const root = useRef<HTMLDivElement>(null);
353
+ useEffect(() => {
354
+ const nav = root.current?.querySelector<HTMLElement>("nav");
355
+ return nav ? bindSettingsNav(nav) : undefined;
356
+ }, []);
357
+ return (
358
+ <>
359
+ <header className="sticky top-0 z-10 flex h-[var(--shell-header-height)] items-center border-b border-border bg-background px-6 font-medium text-sm">Host header</header>
360
+ <div ref={root} dangerouslySetInnerHTML={{ __html: HOST_MARKUP }} />
361
+ </>
362
+ );
363
+ }
364
+
280
365
  const meta = {
281
366
  title: "Primitives/Settings",
282
367
  component: SettingsSection,
@@ -302,3 +387,9 @@ export const Fields: Story = { render: () => <FieldsExample /> };
302
387
 
303
388
  /** Values preserve ReactNode content and coexist with controls; only value rows stack in a narrow compact layout. */
304
389
  export const Values: Story = { render: () => <SettingsValuesExample /> };
390
+
391
+ /** Every line that is not a row, list states in their tone, and long URLs and event names wrapping whole at 390. */
392
+ export const NotesAndTones: Story = { render: () => <NotesAndTonesExample /> };
393
+
394
+ /** tangle-router's case: server-rendered settings markup under a sticky host header, bound with bindSettingsNav. */
395
+ export const ServerRenderedHost: Story = { render: () => <ServerRenderedHostExample /> };