@tangle-network/ui 11.8.0 → 11.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +14 -0
  3. package/dist/auth.js +3 -2
  4. package/dist/chat.js +10 -9
  5. package/dist/{chunk-WUQDUBJG.js → chunk-3XXBZL53.js} +7 -1
  6. package/dist/{chunk-MKTSMWVD.js → chunk-42PAH5D2.js} +4 -1
  7. package/dist/{chunk-LDLOJKPC.js → chunk-55WHV7SS.js} +49 -30
  8. package/dist/{chunk-3M7BPSSW.js → chunk-BEBXZPHW.js} +4 -4
  9. package/dist/{chunk-X53RZSX6.js → chunk-C5WF4WMV.js} +12 -6
  10. package/dist/{chunk-NWECTSOH.js → chunk-HMAPIH7L.js} +9 -3
  11. package/dist/{chunk-QGTIBEPX.js → chunk-K5XFRUS4.js} +9 -3
  12. package/dist/{chunk-NKDZ7GZE.js → chunk-KFQGHD73.js} +8 -2
  13. package/dist/{chunk-GYPQXTJU.js → chunk-M3QZK62R.js} +5 -2
  14. package/dist/{chunk-P5F43A65.js → chunk-OL5GYLHV.js} +3 -2
  15. package/dist/{chunk-Q7ZR6LSD.js → chunk-QGFYBNSO.js} +2 -2
  16. package/dist/{chunk-KNYSKNZX.js → chunk-R7B3UWPF.js} +2 -2
  17. package/dist/{chunk-Q56BYXQF.js → chunk-R7SUY4E3.js} +8 -4
  18. package/dist/chunk-WKWBTL2V.js +15 -0
  19. package/dist/{chunk-BWOUSEHM.js → chunk-X7CU776B.js} +1 -1
  20. package/dist/{chunk-OXZRCXJK.js → chunk-YU4E5J2N.js} +3 -3
  21. package/dist/{chunk-XGOWUDDW.js → chunk-YZ6AK25N.js} +20 -9
  22. package/dist/document-editor-pane-QHDOBA43.js +13 -0
  23. package/dist/editor.js +5 -4
  24. package/dist/files.js +5 -4
  25. package/dist/hooks.js +7 -6
  26. package/dist/index.d.ts +2 -1
  27. package/dist/index.js +28 -16
  28. package/dist/markdown.d.ts +4 -1
  29. package/dist/markdown.js +3 -2
  30. package/dist/openui.js +6 -5
  31. package/dist/primitives.d.ts +2 -0
  32. package/dist/primitives.js +6 -5
  33. package/dist/run.js +10 -9
  34. package/dist/sdk-hooks.js +7 -6
  35. package/dist/tool-previews.js +3 -2
  36. package/dist/utils.d.ts +54 -1
  37. package/dist/utils.js +13 -1
  38. package/package.json +3 -3
  39. package/src/auth/auth.tsx +5 -1
  40. package/src/chat/agent-timeline.tsx +6 -1
  41. package/src/chat/chat-container.tsx +2 -1
  42. package/src/editor/document-editor-pane.test.tsx +3 -1
  43. package/src/editor/document-editor-pane.tsx +5 -1
  44. package/src/editor/markdown-document-editor.test.tsx +9 -4
  45. package/src/files/file-artifact-pane.tsx +3 -2
  46. package/src/files/file-preview.tsx +3 -1
  47. package/src/files/file-tabs.tsx +9 -2
  48. package/src/lib/focus.ts +63 -0
  49. package/src/markdown/code-block.tsx +5 -1
  50. package/src/markdown/markdown.test.tsx +26 -2
  51. package/src/markdown/markdown.tsx +5 -2
  52. package/src/primitives/badge.tsx +2 -1
  53. package/src/primitives/button.tsx +2 -1
  54. package/src/primitives/card.tsx +1 -2
  55. package/src/primitives/dialog.tsx +7 -1
  56. package/src/primitives/input.tsx +15 -14
  57. package/src/primitives/segmented-control.tsx +5 -3
  58. package/src/primitives/select.tsx +3 -1
  59. package/src/primitives/switch.tsx +3 -1
  60. package/src/primitives/tabs.tsx +6 -4
  61. package/src/primitives/terminal-display.tsx +6 -8
  62. package/src/primitives/toast.tsx +5 -1
  63. package/src/tool-previews/command-preview.tsx +5 -1
  64. package/src/tool-previews/web-search-preview.tsx +2 -1
  65. package/src/utils/index.ts +7 -0
  66. package/dist/document-editor-pane-A3JURPW5.js +0 -12
  67. /package/dist/{chunk-XGKULLYE.js → chunk-SNLR2AEA.js} +0 -0
package/src/auth/auth.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import type * as React from "react";
4
+ import { focusRing } from "../lib/focus";
4
5
  import { cn } from "../lib/utils";
5
6
  import { Avatar, AvatarFallback, AvatarImage } from "../primitives/avatar";
6
7
  import { Button, type ButtonProps } from "../primitives/button";
@@ -120,7 +121,10 @@ export function UserMenu({
120
121
  <DropdownMenuTrigger asChild>
121
122
  <button
122
123
  type="button"
123
- className="flex items-center gap-2 rounded-lg p-1.5 transition-colors hover:bg-accent focus:outline-none focus:ring-2 focus:ring-ring"
124
+ className={cn(
125
+ "flex items-center gap-2 rounded-lg p-1.5 transition-colors hover:bg-accent",
126
+ focusRing,
127
+ )}
124
128
  >
125
129
  <Avatar className="h-8 w-8">
126
130
  <AvatarImage src={undefined} alt={user.name ?? user.email} />
@@ -6,6 +6,7 @@ import {
6
6
  FileText,
7
7
  Info,
8
8
  } from "lucide-react";
9
+ import { focusRing } from "../lib/focus";
9
10
  import { cn } from "../lib/utils";
10
11
  import { type MessageRole } from "./chat-message";
11
12
  import { UserMessage } from "./user-message";
@@ -235,7 +236,11 @@ function ArtifactCard({ item }: { item: AgentTimelineArtifactItem }) {
235
236
  item.onClick?.();
236
237
  }
237
238
  }}
238
- className="block w-full text-left transition-transform hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
239
+ // Rounded like the card inside it, so the focus ring follows its corners.
240
+ className={cn(
241
+ "block w-full rounded-[var(--radius-lg)] text-left transition-transform hover:-translate-y-0.5",
242
+ focusRing,
243
+ )}
239
244
  >
240
245
  {content}
241
246
  </div>
@@ -5,6 +5,7 @@ import {
5
5
  useRef,
6
6
  } from "react";
7
7
  import { ArrowDown } from "lucide-react";
8
+ import { focusRing } from "../lib/focus";
8
9
  import { cn } from "../lib/utils";
9
10
  import type { SessionMessage } from "../types/message";
10
11
  import type { SessionPart, TextPart, ToolPart } from "../types/parts";
@@ -459,7 +460,7 @@ export const ChatContainer = memo(
459
460
  "flex items-center gap-1.5 px-3 py-1.5 rounded-full",
460
461
  "border border-border bg-card shadow-[0_6px_16px_rgba(15,23,42,0.08)]",
461
462
  "text-xs text-foreground transition-colors hover:bg-accent",
462
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60",
463
+ focusRing,
463
464
  )}
464
465
  >
465
466
  <ArrowDown className="w-3 h-3" />
@@ -32,6 +32,8 @@ async function paneWithoutPeers(message: string) {
32
32
  return { DocumentEditorPane, loaders };
33
33
  }
34
34
 
35
+ // The pane is imported on a fresh module graph, which re-transforms its whole
36
+ // dependency tree: seconds on a busy runner, past the 5s default.
35
37
  describe("DocumentEditorPane without the editor peers", () => {
36
38
  it("renders the preview, because only the edit tab needs the peers", async () => {
37
39
  const { DocumentEditorPane, loaders } = await paneWithoutPeers(
@@ -54,5 +56,5 @@ describe("DocumentEditorPane without the editor peers", () => {
54
56
  expect(loaders.loadDocumentEditorPeers).not.toHaveBeenCalled();
55
57
  expect(loaders.loadCollaborationPeers).not.toHaveBeenCalled();
56
58
  expect(loaders.loadEditorProviderPeers).not.toHaveBeenCalled();
57
- });
59
+ }, 30_000);
58
60
  });
@@ -4,6 +4,7 @@ import { type ReactNode, useEffect, useMemo, useState } from "react";
4
4
  import { PencilLine, Save, Users, Wifi, WifiOff } from "lucide-react";
5
5
  import { Markdown } from "../markdown/markdown";
6
6
  import { Tabs, TabsContent, TabsList, TabsTrigger } from "../primitives/tabs";
7
+ import { focusRing } from "../lib/focus";
7
8
  import { cn } from "../lib/utils";
8
9
  import { ArtifactPane, type ArtifactPaneProps } from "../primitives/artifact-pane";
9
10
  import {
@@ -276,7 +277,10 @@ export function DocumentEditorPane({
276
277
  type="button"
277
278
  onClick={() => void onSave(draft)}
278
279
  disabled={saving || !isDirty}
279
- className="inline-flex items-center gap-2 rounded-[var(--radius-full)] border border-border bg-card px-3 py-1.5 text-xs font-semibold text-foreground transition-colors hover:border-primary/40 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
280
+ className={cn(
281
+ "inline-flex items-center gap-2 rounded-[var(--radius-full)] border border-border bg-card px-3 py-1.5 text-xs font-semibold text-foreground transition-colors hover:border-[var(--border-strong)] hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50",
282
+ focusRing,
283
+ )}
280
284
  >
281
285
  <Save className="h-3.5 w-3.5" />
282
286
  {saving ? "Saving..." : saveLabel}
@@ -29,6 +29,11 @@ async function freshEditor(load: () => Promise<EditorPeers.DocumentEditorPeers>)
29
29
  return (await import("./markdown-document-editor")).MarkdownDocumentEditor;
30
30
  }
31
31
 
32
+ // The first render imports tiptap for real. A cold import on a busy runner
33
+ // takes several seconds, well past waitFor's 1s and a test's 5s defaults.
34
+ const PEERS_LOADED = { timeout: 15_000 };
35
+ const LOADS_PEERS = 30_000;
36
+
32
37
  afterEach(() => {
33
38
  vi.doUnmock("./editor-peers");
34
39
  vi.resetModules();
@@ -42,7 +47,7 @@ describe("MarkdownDocumentEditor", () => {
42
47
 
43
48
  await waitFor(() => {
44
49
  expect(container.querySelector(".ProseMirror")).not.toBeNull();
45
- });
50
+ }, PEERS_LOADED);
46
51
 
47
52
  const surface = container.querySelector(".ProseMirror");
48
53
  expect(surface?.querySelector("h1")?.textContent).toBe("Title");
@@ -50,7 +55,7 @@ describe("MarkdownDocumentEditor", () => {
50
55
  // StarterKit arrives through `peers.starterKit.default`. A broken hand-off
51
56
  // leaves the document without the heading node that StarterKit configures.
52
57
  expect(screen.getByTitle(/^Bold/)).toBeInTheDocument();
53
- });
58
+ }, LOADS_PEERS);
54
59
 
55
60
  it("holds a placeholder while the peers are still loading", async () => {
56
61
  let releasePeers: (() => void) | null = null;
@@ -85,7 +90,7 @@ describe("MarkdownDocumentEditor", () => {
85
90
 
86
91
  await waitFor(() => {
87
92
  expect(container.querySelector(".ProseMirror")).not.toBeNull();
88
- });
93
+ }, PEERS_LOADED);
89
94
 
90
95
  // The editor reports once when it parses the initial content. What must
91
96
  // not grow is the count across a value-prop write: pushing that back
@@ -98,7 +103,7 @@ describe("MarkdownDocumentEditor", () => {
98
103
  expect(container.querySelector(".ProseMirror h1")?.textContent).toBe("Changed");
99
104
  });
100
105
  expect(onChange).toHaveBeenCalledTimes(callsAfterMount);
101
- });
106
+ }, LOADS_PEERS);
102
107
 
103
108
  it("fails with the install list when the peers cannot be loaded", async () => {
104
109
  const missing = "install @tiptap/react and @tiptap/starter-kit";
@@ -5,6 +5,7 @@ import {
5
5
  type DocumentEditorMode,
6
6
  type DocumentEditorPaneCollaborationConfig,
7
7
  } from "../editor/document-editor-pane";
8
+ import { focusRing } from "../lib/focus";
8
9
  import { ArtifactPane, type ArtifactPaneProps } from "../primitives/artifact-pane";
9
10
  import { detectFileFormat } from "./file-format";
10
11
  import { FilePreview, type FilePreviewProps } from "./file-preview";
@@ -99,7 +100,7 @@ export function FileArtifactPane({
99
100
  type="button"
100
101
  aria-label={`Download ${filename}`}
101
102
  onClick={onDownload}
102
- className="rounded-[var(--radius-sm)] p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
103
+ className={`rounded-[var(--radius-sm)] p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground ${focusRing}`}
103
104
  >
104
105
  <Download className="h-4 w-4" />
105
106
  </button>
@@ -109,7 +110,7 @@ export function FileArtifactPane({
109
110
  type="button"
110
111
  aria-label={`Close ${filename}`}
111
112
  onClick={onClose}
112
- className="rounded-[var(--radius-sm)] p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
113
+ className={`rounded-[var(--radius-sm)] p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground ${focusRing}`}
113
114
  >
114
115
  <X className="h-4 w-4" />
115
116
  </button>
@@ -22,6 +22,7 @@ import {
22
22
  X,
23
23
  type LucideIcon,
24
24
  } from "lucide-react";
25
+ import { focusRingInset } from "../lib/focus";
25
26
  import { cn } from "../lib/utils";
26
27
  import { Button } from "../primitives/button";
27
28
  import { Markdown } from "../markdown/markdown";
@@ -219,7 +220,8 @@ function ImagePreview({ src, filename }: { src: string; filename: string }) {
219
220
  aria-pressed={naturalSize}
220
221
  aria-label={naturalSize ? "Fit image to pane" : "Show image at natural size"}
221
222
  className={cn(
222
- "flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary/60",
223
+ "flex items-center justify-center",
224
+ focusRingInset,
223
225
  naturalSize ? "m-auto cursor-zoom-out" : "h-full w-full cursor-zoom-in p-4",
224
226
  )}
225
227
  >
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import { X, FileText, FileCode, FileSpreadsheet } from "lucide-react";
6
+ import { focusRing, focusRingInset } from "../lib/focus";
6
7
  import { cn } from "../lib/utils";
7
8
 
8
9
  export interface FileTabData {
@@ -49,7 +50,10 @@ export function FileTabs({ tabs, activeId, onSelect, onClose, className }: FileT
49
50
  <button
50
51
  type="button"
51
52
  onClick={() => onSelect(tab.id)}
52
- className="flex min-w-0 items-center gap-1.5 px-3 py-1.5 text-xs transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary/60"
53
+ className={cn(
54
+ "flex min-w-0 items-center gap-1.5 px-3 py-1.5 text-xs transition-colors hover:text-foreground",
55
+ focusRingInset,
56
+ )}
53
57
  >
54
58
  <Icon className="h-3 w-3 shrink-0" />
55
59
  <span className="max-w-[120px] truncate">{tab.name}</span>
@@ -59,7 +63,10 @@ export function FileTabs({ tabs, activeId, onSelect, onClose, className }: FileT
59
63
  type="button"
60
64
  aria-label={`Close ${tab.name}`}
61
65
  onClick={() => onClose(tab.id)}
62
- className="mr-1 rounded p-0.5 opacity-0 transition-opacity hover:bg-accent focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 group-hover:opacity-100"
66
+ className={cn(
67
+ "mr-1 rounded p-0.5 opacity-0 transition-opacity hover:bg-accent focus-visible:opacity-100 group-hover:opacity-100",
68
+ focusRing,
69
+ )}
63
70
  >
64
71
  <X className="h-2.5 w-2.5" />
65
72
  </button>
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The focus treatment every control shares, in two shapes over one palette
3
+ * (`--focus-border`, `--focus-halo` and `--border-strong` from
4
+ * `@tangle-network/brand`'s tokens.css).
5
+ *
6
+ * A FIELD — input, textarea, select trigger, composer — shows focus on its own
7
+ * border: the hairline shifts to `--focus-border` and a 3px `--focus-halo` ring
8
+ * spreads outside it. It keys off `:focus`, because a caret in a field needs the
9
+ * cue whether the field was clicked or tabbed into.
10
+ *
11
+ * A CONTROL — button, tab, switch, link, card — has no border to shift, so it
12
+ * draws a 1px `--focus-border` outline with the same 3px halo beyond it. It keys
13
+ * off `:focus-visible`, so a mouse click leaves no ring.
14
+ *
15
+ * The halo is a Tailwind ring, which renders as box-shadow: it follows
16
+ * border-radius and never moves layout, and a consumer removes it with
17
+ * `focus:ring-0` / `focus-visible:ring-0`, which tailwind-merge resolves against
18
+ * these classes. The control's line is an outline so that forced-colors mode,
19
+ * which drops box-shadows, still paints it. A field has its border for that, so
20
+ * it hides the outline with `outline-hidden`, which stays transparent-but-present
21
+ * in forced-colors mode.
22
+ */
23
+
24
+ /**
25
+ * Resting hairline, neutral hover, and `:focus` border plus halo for a text
26
+ * field. The hover is a plain `hover:` class so a consumer's `hover:border-*`
27
+ * replaces it through tailwind-merge; the focus border outranks it because
28
+ * Tailwind emits `focus:` after `hover:`.
29
+ */
30
+ export const focusField =
31
+ "border-border transition-[border-color,box-shadow] duration-150 ease-out hover:border-[var(--border-strong)] focus:outline-hidden focus:border-[var(--focus-border)] focus:ring-3 focus:ring-[var(--focus-halo)]";
32
+
33
+ /**
34
+ * The field treatment for a container that wraps a borderless field, such as a
35
+ * composer around a textarea and its buttons. It lights while a FIELD inside
36
+ * has focus: a text input, textarea, native select or contenteditable editor. A
37
+ * focused button, checkbox or button-type input inside the container shows only
38
+ * its own ring, so a click on the send button does not read as field focus and
39
+ * a tab to it does not stack two indicators. `:focus-within` cannot tell the
40
+ * two apart, so the rule keys off `:has()`; a consumer's `focus-within:*` class
41
+ * therefore does not replace it. To style that container's focus differently,
42
+ * compose your own classes instead of this helper. The field selector is
43
+ * written out in full because Tailwind reads class names statically.
44
+ */
45
+ export const focusFieldWithin =
46
+ "border-border transition-[border-color,box-shadow] duration-150 ease-out hover:border-[var(--border-strong)] has-[:is(textarea,select,[contenteditable]:not([contenteditable=false]),input:not([type=button],[type=submit],[type=reset],[type=image],[type=checkbox],[type=radio],[type=range],[type=color],[type=file])):focus]:border-[var(--focus-border)] has-[:is(textarea,select,[contenteditable]:not([contenteditable=false]),input:not([type=button],[type=submit],[type=reset],[type=image],[type=checkbox],[type=radio],[type=range],[type=color],[type=file])):focus]:ring-3 has-[:is(textarea,select,[contenteditable]:not([contenteditable=false]),input:not([type=button],[type=submit],[type=reset],[type=image],[type=checkbox],[type=radio],[type=range],[type=color],[type=file])):focus]:ring-[var(--focus-halo)]";
47
+
48
+ /** Error state for a field: a danger hairline and the same focus shape in danger. Compose after `focusField`. */
49
+ export const focusFieldInvalid =
50
+ "border-[var(--surface-danger-border)] hover:border-[var(--surface-danger-border)] focus:border-[var(--focus-border-danger)] focus:ring-[var(--focus-halo-danger)]";
51
+
52
+ const focusRingBase =
53
+ "focus-visible:outline-1 focus-visible:outline-[var(--focus-border)] focus-visible:ring-4 focus-visible:ring-[var(--focus-halo)]";
54
+
55
+ /** Keyboard-only focus for a control: a 1px focus line with the halo beyond it. */
56
+ export const focusRing = `${focusRingBase} focus-visible:outline-offset-0`;
57
+
58
+ /**
59
+ * `focusRing` drawn inside the box, for a control flush against a scroll
60
+ * container or its neighbours (a tab strip, a list row), where an outer ring
61
+ * would be clipped or would overlap them.
62
+ */
63
+ export const focusRingInset = `${focusRingBase} focus-visible:-outline-offset-1 focus-visible:ring-inset`;
@@ -9,6 +9,7 @@ import {
9
9
  } from "react";
10
10
  import SyntaxHighlighter from "react-syntax-highlighter";
11
11
  import { Check, Copy } from "lucide-react";
12
+ import { focusRing } from "../lib/focus";
12
13
  import { cn } from "../lib/utils";
13
14
 
14
15
  // Theme-aware syntax highlighting — reads CSS custom properties at render time.
@@ -191,7 +192,10 @@ export const CopyButton = memo(({ text }: { text: string }) => {
191
192
  return (
192
193
  <button
193
194
  onClick={handleCopy}
194
- className="flex items-center justify-center w-6 h-6 rounded-md bg-muted border border-border hover:border-primary/20 transition-colors"
195
+ className={cn(
196
+ "flex items-center justify-center w-6 h-6 rounded-md bg-muted border border-border hover:border-[var(--border-strong)] transition-colors",
197
+ focusRing,
198
+ )}
195
199
  title="Copy to clipboard"
196
200
  >
197
201
  {copied ? (
@@ -1,5 +1,5 @@
1
- import { describe, expect, it } from "vitest";
2
- import { render } from "@testing-library/react";
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
3
  import { Markdown } from "./markdown";
4
4
 
5
5
  describe("Markdown", () => {
@@ -20,4 +20,28 @@ describe("Markdown", () => {
20
20
  expect(inlineCode).toHaveClass("bg-card");
21
21
  expect(inlineCode).toHaveClass("text-[var(--code-keyword)]");
22
22
  });
23
+
24
+ it("passes parsed URLs through the typed transform hook", () => {
25
+ const urlTransform = vi.fn((url: string, key: string) => (
26
+ key === "href" && url === "system/brief.md"
27
+ ? "/app/ws-1/vault?file=system%2Fbrief.md"
28
+ : url
29
+ ));
30
+
31
+ render(
32
+ <Markdown urlTransform={urlTransform}>
33
+ {'[Brief](system/brief.md)'}
34
+ </Markdown>,
35
+ );
36
+
37
+ expect(screen.getByRole("link", { name: "Brief" })).toHaveAttribute(
38
+ "href",
39
+ "/app/ws-1/vault?file=system%2Fbrief.md",
40
+ );
41
+ expect(urlTransform).toHaveBeenCalledWith(
42
+ "system/brief.md",
43
+ "href",
44
+ expect.objectContaining({ tagName: "a" }),
45
+ );
46
+ });
23
47
  });
@@ -1,5 +1,5 @@
1
1
  import { memo } from "react";
2
- import ReactMarkdown from "react-markdown";
2
+ import ReactMarkdown, { type UrlTransform } from "react-markdown";
3
3
  import remarkGfm from "remark-gfm";
4
4
  import rehypeSanitize from "rehype-sanitize";
5
5
  import { CodeBlock, CopyButton } from "./code-block";
@@ -8,13 +8,15 @@ import { cn } from "../lib/utils";
8
8
  export interface MarkdownProps {
9
9
  children: string;
10
10
  className?: string;
11
+ /** Transform parsed link and image URLs before sanitisation. */
12
+ urlTransform?: UrlTransform;
11
13
  }
12
14
 
13
15
  /**
14
16
  * Renders Markdown content with GFM support, XSS sanitisation, and
15
17
  * custom code block rendering via our CodeBlock component.
16
18
  */
17
- export const Markdown = memo(({ children, className }: MarkdownProps) => {
19
+ export const Markdown = memo(({ children, className, urlTransform }: MarkdownProps) => {
18
20
  return (
19
21
  <div
20
22
  className={cn("tangle-prose max-w-none text-sm", className)}
@@ -22,6 +24,7 @@ export const Markdown = memo(({ children, className }: MarkdownProps) => {
22
24
  <ReactMarkdown
23
25
  remarkPlugins={[remarkGfm]}
24
26
  rehypePlugins={[rehypeSanitize]}
27
+ urlTransform={urlTransform}
25
28
  components={{
26
29
  pre({ children: preChildren }) {
27
30
  return <>{preChildren}</>;
@@ -1,9 +1,10 @@
1
1
  import { cva, type VariantProps } from "class-variance-authority";
2
2
  import type * as React from "react";
3
+ import { focusRing } from "../lib/focus";
3
4
  import { cn } from "../lib/utils";
4
5
 
5
6
  const badgeVariants = cva(
6
- "inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 font-medium text-xs transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
7
+ `inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 font-medium text-xs transition-colors ${focusRing}`,
7
8
  {
8
9
  variants: {
9
10
  variant: {
@@ -1,10 +1,11 @@
1
1
  import { Slot } from "@radix-ui/react-slot";
2
2
  import { cva, type VariantProps } from "class-variance-authority";
3
3
  import * as React from "react";
4
+ import { focusRing } from "../lib/focus";
4
5
  import { cn } from "../lib/utils";
5
6
 
6
7
  const buttonVariants = cva(
7
- "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg font-medium text-sm transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
8
+ `inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg font-medium text-sm transition-all ${focusRing} disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0`,
8
9
  {
9
10
  variants: {
10
11
  variant: {
@@ -22,8 +22,7 @@ const Card = React.forwardRef<
22
22
  "rounded-[var(--radius-lg)] border text-card-foreground transition-[border-color,box-shadow]",
23
23
  "duration-[var(--transition-default)]",
24
24
  variants[variant],
25
- hover &&
26
- "cursor-pointer hover:border-primary/30",
25
+ hover && "cursor-pointer hover:border-[var(--border-strong)]",
27
26
  className,
28
27
  )}
29
28
  {...props}
@@ -1,6 +1,7 @@
1
1
  import * as DialogPrimitive from "@radix-ui/react-dialog";
2
2
  import { X } from "lucide-react";
3
3
  import * as React from "react";
4
+ import { focusRing } from "../lib/focus";
4
5
  import { cn } from "../lib/utils";
5
6
 
6
7
  const Dialog = DialogPrimitive.Root;
@@ -58,7 +59,12 @@ const DialogContent = React.forwardRef<
58
59
  {...props}
59
60
  >
60
61
  {children}
61
- <DialogPrimitive.Close className="absolute top-4 right-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
62
+ <DialogPrimitive.Close
63
+ className={cn(
64
+ "absolute top-4 right-4 rounded-sm opacity-70 transition-[opacity,box-shadow] hover:opacity-100 focus-visible:opacity-100 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground",
65
+ focusRing,
66
+ )}
67
+ >
62
68
  <X className="h-4 w-4" />
63
69
  <span className="sr-only">Close</span>
64
70
  </DialogPrimitive.Close>
@@ -1,16 +1,21 @@
1
1
  import * as React from "react";
2
+ import { focusField, focusFieldInvalid } from "../lib/focus";
2
3
  import { cn } from "../lib/utils";
3
4
 
4
5
  import { cva, type VariantProps } from "class-variance-authority";
5
6
 
6
7
  const inputVariants = cva(
7
- "flex w-full rounded-lg border bg-card px-4 py-2 text-sm transition-all duration-200 placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 file:border-0 file:bg-transparent file:font-medium file:text-sm",
8
+ cn(
9
+ "flex w-full rounded-lg border bg-card px-4 py-2 text-sm placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 file:border-0 file:bg-transparent file:font-medium file:text-sm",
10
+ focusField,
11
+ ),
8
12
  {
9
13
  variants: {
14
+ // `default` and `sandbox` render the same field; both names stay valid.
10
15
  variant: {
11
- default: "border-input focus:ring-ring",
12
- sandbox: "border-border focus:border-[var(--border-accent-hover)] focus:ring-[var(--border-accent)]",
13
- error: "border-[var(--surface-danger-border)] focus:ring-[var(--surface-danger-border)]",
16
+ default: "",
17
+ sandbox: "",
18
+ error: focusFieldInvalid,
14
19
  },
15
20
  size: {
16
21
  default: "h-11",
@@ -75,6 +80,7 @@ Input.displayName = "Input";
75
80
 
76
81
  export interface TextareaProps
77
82
  extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
83
+ /** Both values render the same field. */
78
84
  variant?: "default" | "sandbox";
79
85
  label?: string;
80
86
  error?: string;
@@ -83,26 +89,21 @@ export interface TextareaProps
83
89
 
84
90
  const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
85
91
  (
86
- { className, variant = "default", label, error, hint, id, ...props },
92
+ // `variant` is taken out so it never reaches the DOM element.
93
+ { className, variant: _variant, label, error, hint, id, ...props },
87
94
  ref,
88
95
  ) => {
89
96
  const textareaId = id ?? label?.toLowerCase().replace(/\s+/g, "-");
90
97
 
91
- const variants = {
92
- default: "border-input focus:ring-ring",
93
- sandbox:
94
- "border-border focus:border-[var(--border-accent-hover)] focus:ring-[var(--border-accent)]",
95
- };
96
-
97
98
  const textarea = (
98
99
  <textarea
99
100
  id={textareaId}
100
101
  className={cn(
101
- "flex min-h-[120px] w-full resize-y rounded-lg border bg-card px-4 py-3 text-sm transition-all duration-200",
102
+ "flex min-h-[120px] w-full resize-y rounded-lg border bg-card px-4 py-3 text-sm",
102
103
  "placeholder:text-muted-foreground",
103
- "focus:outline-none focus:ring-2 focus:ring-offset-0",
104
104
  "disabled:cursor-not-allowed disabled:opacity-50",
105
- error ? "border-[var(--surface-danger-border)] focus:ring-[var(--surface-danger-border)]" : variants[variant],
105
+ focusField,
106
+ error && focusFieldInvalid,
106
107
  className,
107
108
  )}
108
109
  ref={ref}
@@ -3,6 +3,7 @@
3
3
  declare const process: { env: { NODE_ENV?: string } } | undefined
4
4
 
5
5
  import * as React from "react"
6
+ import { focusRing, focusRingInset } from "../lib/focus"
6
7
  import { cn } from "../lib/utils"
7
8
 
8
9
  /**
@@ -120,9 +121,10 @@ export function SegmentedControl<T extends string = string>({
120
121
  if (!active) onValueChange(option.value)
121
122
  }}
122
123
  className={cn(
123
- "relative inline-flex items-center gap-2 whitespace-nowrap text-sm transition-colors",
124
- variant === "row" && "rounded-md px-3 py-1.5",
125
- variant === "tabs" && "rounded-none border-b-2 -mb-px px-4 py-2",
124
+ "relative inline-flex items-center gap-2 whitespace-nowrap text-sm transition-[color,background-color,border-color,box-shadow]",
125
+ variant === "row" && ["rounded-md px-3 py-1.5", focusRing],
126
+ // The strip scrolls horizontally, which would clip an outer ring.
127
+ variant === "tabs" && ["rounded-none border-b-2 -mb-px px-4 py-2", focusRingInset],
126
128
  // Active styling is the ONLY styled state — unselected
127
129
  // segments stay transparent on purpose so they don't
128
130
  // compete visually with the selection.
@@ -3,6 +3,7 @@
3
3
  import * as SelectPrimitive from "@radix-ui/react-select";
4
4
  import { Check, ChevronDown, ChevronUp } from "lucide-react";
5
5
  import * as React from "react";
6
+ import { focusField } from "../lib/focus";
6
7
  import { cn } from "../lib/utils";
7
8
 
8
9
  const Select = SelectPrimitive.Root;
@@ -18,7 +19,8 @@ const SelectTrigger = React.forwardRef<
18
19
  <SelectPrimitive.Trigger
19
20
  ref={ref}
20
21
  className={cn(
21
- "flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-border bg-card px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
22
+ "flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border bg-card px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
23
+ focusField,
22
24
  className,
23
25
  )}
24
26
  {...props}
@@ -2,6 +2,7 @@
2
2
 
3
3
  import * as SwitchPrimitives from "@radix-ui/react-switch";
4
4
  import * as React from "react";
5
+ import { focusRing } from "../lib/focus";
5
6
  import { cn } from "../lib/utils";
6
7
 
7
8
  const Switch = React.forwardRef<
@@ -10,7 +11,8 @@ const Switch = React.forwardRef<
10
11
  >(({ className, ...props }, ref) => (
11
12
  <SwitchPrimitives.Root
12
13
  className={cn(
13
- "peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
14
+ "peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-[background-color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
15
+ focusRing,
14
16
  className,
15
17
  )}
16
18
  {...props}
@@ -1,5 +1,6 @@
1
1
  import * as TabsPrimitive from "@radix-ui/react-tabs";
2
2
  import * as React from "react";
3
+ import { focusRing } from "../lib/focus";
3
4
  import { cn } from "../lib/utils";
4
5
 
5
6
  const Tabs = TabsPrimitive.Root;
@@ -35,11 +36,11 @@ const TabsTrigger = React.forwardRef<
35
36
  >(({ className, variant = "default", ...props }, ref) => {
36
37
  const variants = {
37
38
  default:
38
- "inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
39
+ `inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-all ${focusRing} disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm`,
39
40
  pills:
40
- "inline-flex items-center justify-center whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=inactive]:text-muted-foreground data-[state=inactive]:hover:bg-muted",
41
+ `inline-flex items-center justify-center whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium transition-all ${focusRing} disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=inactive]:text-muted-foreground data-[state=inactive]:hover:bg-muted`,
41
42
  underline:
42
- "inline-flex items-center justify-center whitespace-nowrap pb-3 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:text-foreground data-[state=inactive]:text-muted-foreground",
43
+ `inline-flex items-center justify-center whitespace-nowrap rounded-t-md pb-3 text-sm font-medium transition-all ${focusRing} disabled:pointer-events-none disabled:opacity-50 border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:text-foreground data-[state=inactive]:text-muted-foreground`,
43
44
  };
44
45
 
45
46
  return (
@@ -59,7 +60,8 @@ const TabsContent = React.forwardRef<
59
60
  <TabsPrimitive.Content
60
61
  ref={ref}
61
62
  className={cn(
62
- "mt-4 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
63
+ "mt-4",
64
+ focusRing,
63
65
  "data-[state=active]:fade-in-0 data-[state=active]:slide-in-from-bottom-2 data-[state=active]:animate-in",
64
66
  className,
65
67
  )}
@@ -1,5 +1,6 @@
1
1
  import * as React from "react";
2
2
  import { useEffect, useRef } from "react";
3
+ import { focusFieldWithin } from "../lib/focus";
3
4
  import { cn } from "../lib/utils";
4
5
 
5
6
  export interface TerminalDisplayProps extends React.HTMLAttributes<HTMLDivElement> {
@@ -142,11 +143,13 @@ TerminalCursor.displayName = "TerminalCursor";
142
143
  export interface TerminalInputProps
143
144
  extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "onSubmit"> {
144
145
  onSubmit?: (value: string) => void;
146
+ /** Both values render the same field. */
145
147
  variant?: "default" | "sandbox";
146
148
  }
147
149
 
148
150
  const TerminalInput = React.forwardRef<HTMLInputElement, TerminalInputProps>(
149
- ({ className, onSubmit, variant = "default", ...props }, ref) => {
151
+ // `variant` is taken out so it never reaches the DOM element.
152
+ ({ className, onSubmit, variant: _variant, ...props }, ref) => {
150
153
  const [value, setValue] = React.useState("");
151
154
 
152
155
  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
@@ -156,16 +159,11 @@ const TerminalInput = React.forwardRef<HTMLInputElement, TerminalInputProps>(
156
159
  }
157
160
  };
158
161
 
159
- const variants = {
160
- default: "border-border focus-within:border-border",
161
- sandbox: "border-border focus-within:border-[var(--border-accent-hover)]",
162
- };
163
-
164
162
  return (
165
163
  <div
166
164
  className={cn(
167
- "flex items-center rounded-lg border bg-background px-4 py-2.5 font-mono text-sm transition-colors",
168
- variants[variant],
165
+ "flex items-center rounded-lg border bg-background px-4 py-2.5 font-mono text-sm",
166
+ focusFieldWithin,
169
167
  className,
170
168
  )}
171
169
  >