@tangle-network/ui 11.49.0 → 11.50.2

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 +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 +3 -3
  6. package/dist/index.js +3 -3
  7. package/dist/lib/code-presentation.js +1 -1
  8. package/dist/markdown/code-block.js +3 -3
  9. package/dist/presentation.d.ts +12 -0
  10. package/dist/presentation.js +85 -1
  11. package/dist/primitives/metric-strip.d.ts +2 -2
  12. package/dist/primitives/page-shell.d.ts +6 -3
  13. package/dist/primitives/page-shell.js +3 -60
  14. package/dist/primitives/progress.d.ts +8 -0
  15. package/dist/primitives/progress.js +8 -2
  16. package/dist/primitives/settings.d.ts +28 -1
  17. package/dist/primitives/settings.js +78 -29
  18. package/dist/primitives/status-pill.js +11 -2
  19. package/dist/primitives.d.ts +3 -3
  20. package/dist/primitives.js +3 -3
  21. package/package.json +2 -2
  22. package/src/chat/agent-timeline.test.tsx +9 -0
  23. package/src/chat/agent-timeline.tsx +4 -3
  24. package/src/chat/speaker.test.tsx +3 -1
  25. package/src/chat/speaker.tsx +13 -14
  26. package/src/lib/code-presentation.ts +1 -1
  27. package/src/markdown/code-block.test.tsx +0 -6
  28. package/src/markdown/code-block.tsx +4 -3
  29. package/src/markdown/markdown.stories.tsx +20 -0
  30. package/src/presentation.ts +68 -0
  31. package/src/primitives/index.ts +2 -1
  32. package/src/primitives/metric-strip.tsx +2 -2
  33. package/src/primitives/page-shell.tsx +9 -58
  34. package/src/primitives/progress.stories.tsx +10 -0
  35. package/src/primitives/progress.tsx +13 -2
  36. package/src/primitives/settings.stories.tsx +94 -3
  37. package/src/primitives/settings.tsx +80 -19
  38. package/src/primitives/status-pill.tsx +8 -1
  39. package/src/styles/presentation.css +3 -3
@@ -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 /> };
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { CircleAlert, Ellipsis, Lock } from "lucide-react";
3
+ import { CircleAlert, Ellipsis, Lock, TriangleAlert, type LucideIcon } from "lucide-react";
4
4
  import * as React from "react";
5
5
  import { cn } from "../lib/utils";
6
6
  import { Button } from "./button";
@@ -9,6 +9,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
9
9
  import { fieldMessageId } from "./field";
10
10
  import { InfoTip } from "./info-tip";
11
11
  import { Label } from "./label";
12
+ import { StatusGlyph, type StatusTone } from "./status-pill";
12
13
 
13
14
  /*
14
15
  * Settings rows and lists answer to their section card's width, not the
@@ -21,25 +22,46 @@ import { Label } from "./label";
21
22
  */
22
23
 
23
24
  const SCROLL_OFFSET = "scroll-mt-[calc(var(--shell-header-height)_+_1rem)]";
25
+ const GUTTER = "px-5 py-4 @max-[34rem]/settings-section:px-4";
24
26
  const DIVIDED = "not-first:border-t not-first:border-[color:var(--border-subtle)]";
25
27
  const ERROR_TEXT = "text-[color:var(--surface-danger-text)]";
26
28
 
27
- function ErrorLine({ id, className, children }: { id?: string; className?: string; children: React.ReactNode }) {
29
+ interface LineProps { id?: string; role?: "status" | "alert"; className?: string; children: React.ReactNode }
30
+
31
+ function GlyphLine({ icon: Icon, className, children, ...props }: LineProps & { icon: LucideIcon }) {
28
32
  return (
29
- <div id={id} role="alert" className={cn("flex items-start gap-1.5 text-sm leading-5 [overflow-wrap:anywhere]", ERROR_TEXT, className)}>
30
- <CircleAlert aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
33
+ <div {...props} className={cn("flex items-start gap-1.5 text-sm leading-5 [overflow-wrap:anywhere]", className)}>
34
+ <Icon aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
31
35
  <div className="min-w-0">{children}</div>
32
36
  </div>
33
37
  );
34
38
  }
35
39
 
36
- function LockLine({ id, className, children }: { id?: string; className?: string; children: React.ReactNode }) {
37
- return (
38
- <div id={id} className={cn("flex items-start gap-1.5 text-sm leading-5 text-muted-foreground [overflow-wrap:anywhere]", className)}>
39
- <Lock aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
40
- <div className="min-w-0">{children}</div>
41
- </div>
42
- );
40
+ const ErrorLine = ({ className, ...props }: LineProps) => <GlyphLine icon={CircleAlert} role="alert" className={cn(ERROR_TEXT, className)} {...props} />;
41
+ const LockLine = ({ className, ...props }: LineProps) => <GlyphLine icon={Lock} className={cn("text-muted-foreground", className)} {...props} />;
42
+
43
+ // A long identifier (a URL, an address, an event name) is one unit: it moves to the next line
44
+ // whole, and only one wider than its column breaks, at its own punctuation, where a style guide
45
+ // breaks a URL: after a slash run or `@`, before `. - _ ? # & =`. A mid-word break is the last resort.
46
+ const TOKEN_BREAK = /(?<=[/@])(?=[^/])|(?<=[\p{L}\p{N}])(?=[.\-_?#&=][\p{L}\p{N}])/u;
47
+
48
+ function breakTokens(node: React.ReactNode): React.ReactNode {
49
+ if (typeof node === "string") {
50
+ if (!TOKEN_BREAK.test(node)) return node;
51
+ return node.split(/(\s+)/).map((word, index) => {
52
+ const parts = word.split(TOKEN_BREAK);
53
+ return parts.length < 2 ? word : (
54
+ <span key={index} className="inline-block max-w-full [text-decoration:inherit]">
55
+ {parts.map((part, at) => <React.Fragment key={at}>{at > 0 && <wbr />}{part}</React.Fragment>)}
56
+ </span>
57
+ );
58
+ });
59
+ }
60
+ if (Array.isArray(node)) return React.Children.map(node, breakTokens);
61
+ if (React.isValidElement<{ children?: React.ReactNode }>(node) && node.props.children != null && typeof node.props.children !== "function") {
62
+ return React.cloneElement(node, undefined, breakTokens(node.props.children));
63
+ }
64
+ return node;
43
65
  }
44
66
 
45
67
  export type SettingsRowLayout = "inline" | "compact" | "stacked";
@@ -123,7 +145,8 @@ export function SettingsRow(props: SettingsRowProps) {
123
145
  id={id}
124
146
  data-layout={layout}
125
147
  className={cn(
126
- "grid min-w-0 items-center gap-x-8 gap-y-3 px-5 py-4 @max-[34rem]/settings-section:px-4",
148
+ "grid min-w-0 items-center gap-x-8 gap-y-3",
149
+ GUTTER,
127
150
  SCROLL_OFFSET,
128
151
  DIVIDED,
129
152
  ROW_COLUMNS[hasValue && layout === "compact" ? "inline" : layout],
@@ -150,7 +173,7 @@ export function SettingsRow(props: SettingsRowProps) {
150
173
  </div>
151
174
  {(hasValue || (control != null && control !== false)) && (
152
175
  <div className={cn("flex min-w-0 items-center gap-2", ROW_CONTROL[hasValue && layout === "compact" ? "inline" : layout])}>
153
- {hasValue && <span data-slot="settings-value" className={cn("min-w-0 text-foreground text-sm [overflow-wrap:anywhere] @min-[34rem]/settings-section:text-right", mono && "font-mono")}>{value}</span>}
176
+ {hasValue && <span data-slot="settings-value" className={cn("min-w-0 text-foreground text-sm [overflow-wrap:anywhere] @min-[34rem]/settings-section:text-right", mono && "font-mono")}>{breakTokens(value)}</span>}
154
177
  {control}
155
178
  </div>
156
179
  )}
@@ -194,7 +217,7 @@ export function SettingsSection({
194
217
  data-tone={tone}
195
218
  className={cn("@container/settings-section min-w-0", SCROLL_OFFSET, danger && "border-[color:var(--surface-danger-border)]")}
196
219
  >
197
- <div className="flex items-start justify-between gap-4 px-5 py-4 @max-[34rem]/settings-section:flex-wrap @max-[34rem]/settings-section:px-4">
220
+ <div className={cn("flex items-start justify-between gap-4 @max-[34rem]/settings-section:flex-wrap", GUTTER)}>
198
221
  <div className="min-w-0">
199
222
  <div className="flex items-center gap-1.5">
200
223
  <CardTitle as="h2" id={titleId} className={cn("leading-6 [overflow-wrap:anywhere]", danger && ERROR_TEXT)}>{title}</CardTitle>
@@ -216,6 +239,40 @@ export function SettingsSection({
216
239
  );
217
240
  }
218
241
 
242
+ const NOTE_TONE = {
243
+ default: "text-foreground",
244
+ muted: "text-muted-foreground",
245
+ warning: "text-[color:var(--surface-warning-text)]",
246
+ danger: ERROR_TEXT,
247
+ } as const;
248
+
249
+ const NOTE_GLYPH: Partial<Record<keyof typeof NOTE_TONE, LucideIcon>> = { warning: TriangleAlert, danger: CircleAlert };
250
+
251
+ export interface SettingsNoteProps {
252
+ /**
253
+ * `default` keeps the body ink, for a figure such as a MetricStrip or an inline form.
254
+ * `muted` is an empty list or a loading line. `warning` (a notice) and `danger` (a refusal)
255
+ * lead with a glyph, so the tone is not carried by colour alone.
256
+ */
257
+ tone?: keyof typeof NOTE_TONE;
258
+ /** `status` announces a change politely; `alert` announces a refusal at once. */
259
+ role?: "status" | "alert";
260
+ children?: React.ReactNode;
261
+ }
262
+
263
+ /**
264
+ * A line or block of a {@link SettingsSection} that is not a row: an empty list, a loading line,
265
+ * a notice, a refusal or a figure. It takes the rows' gutters and divider, so it lines up with
266
+ * the rows and lists around it.
267
+ */
268
+ export function SettingsNote({ tone = "default", role, children }: SettingsNoteProps) {
269
+ const className = cn("min-w-0", GUTTER, DIVIDED, NOTE_TONE[tone]);
270
+ const glyph = NOTE_GLYPH[tone];
271
+ return glyph
272
+ ? <GlyphLine icon={glyph} role={role} className={className}>{children}</GlyphLine>
273
+ : <div role={role} className={cn("text-sm leading-5 [overflow-wrap:anywhere]", className)}>{children}</div>;
274
+ }
275
+
219
276
  /** Track widths for a {@link SettingsList}. Every slot its items use needs a column, except status and menu, which have fallbacks. */
220
277
  export interface SettingsListColumns {
221
278
  /** A logo or avatar column, such as `"36px"`. */
@@ -310,6 +367,8 @@ export interface SettingsListItemProps {
310
367
  meta?: React.ReactNode;
311
368
  /** The item's state as plain text, or a control that sets it (a role select). */
312
369
  status?: React.ReactNode;
370
+ /** The state's tone, drawn as a glyph before it in the tone's ink; the state's text keeps its colour. */
371
+ tone?: StatusTone;
313
372
  /** Shown under the meta in danger text and announced. */
314
373
  error?: React.ReactNode;
315
374
  /** The one verb the item needs at rest. It is drawn as an outline button, so it never reads as the state beside it. */
@@ -359,7 +418,7 @@ const NARROW_STATUS = "@max-[34rem]/settings-section:col-start-[main] @max-[34re
359
418
  * that name. Without a status column the state follows the meta, and without
360
419
  * a menu column the ⋯ menu ends the action cell.
361
420
  */
362
- export function SettingsListItem({ leading, title, meta, status, error, action, menu }: SettingsListItemProps) {
421
+ export function SettingsListItem({ leading, title, meta, status, tone, error, action, menu }: SettingsListItemProps) {
363
422
  const columns = React.useContext(SettingsListContext);
364
423
  const titleId = React.useId();
365
424
  const reasonId = `${titleId}-reason`;
@@ -376,6 +435,8 @@ export function SettingsListItem({ leading, title, meta, status, error, action,
376
435
  {action.label}
377
436
  </Button>
378
437
  );
438
+ const state = tone && status != null ? <><StatusGlyph tone={tone} /><span className="min-w-0">{status}</span></> : status;
439
+ const toned = tone && "flex items-center gap-1.5";
379
440
  const actionCell = (actionButton || (menuNode && !columns.menu)) && (
380
441
  <>{actionButton}{!columns.menu && menuNode}</>
381
442
  );
@@ -392,17 +453,17 @@ export function SettingsListItem({ leading, title, meta, status, error, action,
392
453
  id={titleId}
393
454
  className="font-medium text-sm leading-5 text-foreground [overflow-wrap:anywhere] [&_a]:rounded-sm [&_a]:text-foreground [&_a]:underline-offset-4 [&_a:hover]:underline [&_a:focus-visible]:outline-2 [&_a:focus-visible]:outline-offset-2 [&_a:focus-visible]:outline-[var(--focus-border)]"
394
455
  >
395
- {title}
456
+ {breakTokens(title)}
396
457
  </div>
397
- {meta != null && <div className="text-sm leading-5 text-muted-foreground [overflow-wrap:anywhere]">{meta}</div>}
398
- {status != null && !columns.status && <div className="mt-1 text-sm leading-5 text-muted-foreground">{status}</div>}
458
+ {meta != null && <div className="text-sm leading-5 text-muted-foreground [overflow-wrap:anywhere]">{breakTokens(meta)}</div>}
459
+ {status != null && !columns.status && <div className={cn("mt-1 text-sm leading-5 text-muted-foreground", toned)}>{state}</div>}
399
460
  {locked && <LockLine id={reasonId} className="mt-1">{action?.disabledReason}</LockLine>}
400
461
  </div>
401
462
  {error != null && (
402
463
  <ErrorLine className="col-start-[main] row-start-2 mt-1 @max-[34rem]/settings-section:col-end-[-1] @max-[34rem]/settings-section:row-start-3">{error}</ErrorLine>
403
464
  )}
404
465
  {status != null && columns.status && (
405
- <div className={cn("col-start-[status] row-start-1 min-w-0 text-sm leading-5 text-muted-foreground", NARROW_STATUS)}>{status}</div>
466
+ <div className={cn("col-start-[status] row-start-1 min-w-0 text-sm leading-5 text-muted-foreground", toned, NARROW_STATUS)}>{state}</div>
406
467
  )}
407
468
  {actionCell && (
408
469
  <div className="col-start-[action] row-start-1 flex min-w-0 items-center justify-end gap-1">{actionCell}</div>
@@ -170,6 +170,13 @@ function ToneGlyph({ tone }: { tone: StatusTone }) {
170
170
  }
171
171
  }
172
172
 
173
+ const GLYPH = "inline-flex [&>svg]:size-3 [&>svg]:shrink-0";
174
+
175
+ /** The tone's glyph in the tone's ink, before a label that keeps its own colour, as `bare` draws it. */
176
+ export function StatusGlyph({ tone }: { tone: StatusTone }) {
177
+ return <span aria-hidden="true" className={cn(GLYPH, TONE_TEXT[tone])}><ToneGlyph tone={tone} /></span>;
178
+ }
179
+
173
180
  const StatusPill = React.forwardRef<HTMLSpanElement, StatusPillProps>(
174
181
  ({ className, tone, children, icon, bare = false, size = "sm", ...props }, ref) => (
175
182
  <span
@@ -186,7 +193,7 @@ const StatusPill = React.forwardRef<HTMLSpanElement, StatusPillProps>(
186
193
  >
187
194
  {/* In `bare` mode the tone rides on the glyph alone, so it is scoped to
188
195
  the wrapper the glyph sits in rather than applied to the whole pill. */}
189
- <span aria-hidden="true" className={cn("inline-flex [&>svg]:size-3 [&>svg]:shrink-0", bare && TONE_TEXT[tone])}>
196
+ <span aria-hidden="true" className={cn(GLYPH, bare && TONE_TEXT[tone])}>
190
197
  {icon ?? <ToneGlyph tone={tone} />}
191
198
  </span>
192
199
  {children}
@@ -28,7 +28,7 @@
28
28
  .tangle-reading-header { grid-column: 1 / -1; min-width: 0; }
29
29
  /* PageShell measure="settings". The frame is the query container, so the layout follows the content area's width, not the viewport's.
30
30
  Below 60rem the navigation is a sticky tab strip whose track is the divider; from 60rem it is a sticky 13rem column beside a 48rem article.
31
- Without a nav the page is the 48rem column alone. */
31
+ Without a nav the page is the 48rem column alone. A host whose page scrolls under its own sticky header sets --tangle-settings-sticky-top. */
32
32
  .tangle-settings-frame { container: tangle-settings / inline-size; width: 100%; min-width: 0; }
33
33
  .tangle-reading-shell[data-measure="settings"] { --tangle-settings-gutter: 1.5rem; grid-template-columns: minmax(0,1fr); gap: 0; max-width: calc(13rem + 2.5rem + 48rem + 4rem); padding: 1.5rem var(--tangle-settings-gutter) 3rem; }
34
34
  /* Without a nav the shell is the column and its gutters, so the centred shell centres the column and its header. */
@@ -37,7 +37,7 @@
37
37
  .tangle-reading-shell[data-measure="settings"] > .tangle-reading-header > * { margin-bottom: 0; }
38
38
  .tangle-reading-shell[data-measure="settings"] > .tangle-reading-outline { display: none; }
39
39
  .tangle-reading-shell[data-measure="settings"] > .tangle-reading-article { max-width: 48rem; display: flex; flex-direction: column; gap: 1.5rem; }
40
- .tangle-reading-shell[data-measure="settings"] > .tangle-reading-nav { position: sticky; top: 0; z-index: 3; display: flex; align-items: flex-end; gap: 0.25rem; height: var(--shell-header-height); max-height: none; margin: 0 calc(-1 * var(--tangle-settings-gutter)) 1.25rem; padding: 0 var(--tangle-settings-gutter); overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: none; background: hsl(var(--background)); box-shadow: inset 0 -1px 0 hsl(var(--border)); }
40
+ .tangle-reading-shell[data-measure="settings"] > .tangle-reading-nav { position: sticky; top: var(--tangle-settings-sticky-top, 0px); z-index: 3; display: flex; align-items: flex-end; gap: 0.25rem; height: var(--shell-header-height); max-height: none; margin: 0 calc(-1 * var(--tangle-settings-gutter)) 1.25rem; padding: 0 var(--tangle-settings-gutter); overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: none; background: hsl(var(--background)); box-shadow: inset 0 -1px 0 hsl(var(--border)); }
41
41
  .tangle-reading-shell[data-measure="settings"] > .tangle-reading-nav::-webkit-scrollbar { display: none; }
42
42
  .tangle-reading-shell[data-measure="settings"] > .tangle-reading-nav > :is(ul, ol) { display: flex; flex: none; align-items: flex-end; gap: 0.25rem; margin: 0; padding: 0; list-style: none; }
43
43
  .tangle-reading-shell[data-measure="settings"] > .tangle-reading-nav a { flex: none; display: flex; align-items: center; gap: 0.5rem; min-height: var(--control-height-lg, 2.75rem); padding: 0 0.75rem; color: hsl(var(--muted-foreground)); font-size: var(--font-size-label, 0.875rem); font-weight: 500; line-height: 1.25rem; white-space: nowrap; text-decoration: none; transition: color var(--motion-control), background-color var(--motion-control), box-shadow var(--motion-control); }
@@ -55,7 +55,7 @@
55
55
  .tangle-reading-shell[data-measure="settings"] { --tangle-settings-gutter: 2rem; grid-template-columns: minmax(0,48rem); column-gap: 2.5rem; align-items: start; padding: 2rem; }
56
56
  .tangle-reading-shell[data-measure="settings"]:has(> .tangle-reading-nav) { grid-template-columns: 13rem minmax(0,48rem); }
57
57
  .tangle-reading-shell[data-measure="settings"] > .tangle-reading-header { margin-bottom: 1.75rem; }
58
- .tangle-reading-shell[data-measure="settings"] > .tangle-reading-nav { top: 2rem; flex-direction: column; align-items: stretch; gap: 2px; height: auto; margin: 0; padding: 0; overflow: visible; background: none; box-shadow: none; }
58
+ .tangle-reading-shell[data-measure="settings"] > .tangle-reading-nav { top: calc(var(--tangle-settings-sticky-top, 0px) + 2rem); flex-direction: column; align-items: stretch; gap: 2px; height: auto; margin: 0; padding: 0; overflow: visible; background: none; box-shadow: none; }
59
59
  .tangle-reading-shell[data-measure="settings"] > .tangle-reading-nav > :is(ul, ol) { flex-direction: column; align-items: stretch; gap: 2px; }
60
60
  .tangle-reading-shell[data-measure="settings"] > .tangle-reading-nav a { min-height: var(--control-height, 2.25rem); gap: 0.625rem; padding: 0 0.625rem; border-radius: var(--radius-md); }
61
61
  .tangle-reading-shell[data-measure="settings"] > .tangle-reading-nav a > svg { display: block; flex: none; width: 1rem; height: 1rem; }