@tangle-network/ui 11.30.2 → 11.31.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 (37) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/index.d.ts +8 -3
  3. package/dist/index.js +7 -2
  4. package/dist/markdown/code-block.d.ts +11 -4
  5. package/dist/markdown/code-block.js +239 -107
  6. package/dist/markdown/code-meta.d.ts +11 -0
  7. package/dist/markdown/code-meta.js +98 -0
  8. package/dist/markdown/markdown-table.d.ts +8 -0
  9. package/dist/markdown/markdown-table.js +169 -0
  10. package/dist/markdown/markdown.d.ts +17 -4
  11. package/dist/markdown/markdown.js +96 -34
  12. package/dist/markdown.d.ts +5 -3
  13. package/dist/markdown.js +4 -2
  14. package/dist/run/approval-card.d.ts +46 -0
  15. package/dist/run/approval-card.js +162 -0
  16. package/dist/run/run-phase-list.d.ts +18 -0
  17. package/dist/run/run-phase-list.js +56 -0
  18. package/dist/run/task-list.d.ts +33 -0
  19. package/dist/run/task-list.js +138 -0
  20. package/dist/run.d.ts +4 -1
  21. package/dist/run.js +4 -1
  22. package/package.json +3 -4
  23. package/src/markdown/code-block.test.tsx +35 -26
  24. package/src/markdown/code-block.tsx +197 -110
  25. package/src/markdown/code-meta.ts +68 -0
  26. package/src/markdown/index.ts +10 -2
  27. package/src/markdown/markdown-table.test.tsx +63 -0
  28. package/src/markdown/markdown-table.tsx +191 -0
  29. package/src/markdown/markdown.test.tsx +27 -0
  30. package/src/markdown/markdown.tsx +126 -48
  31. package/src/run/approval-card.test.tsx +41 -0
  32. package/src/run/approval-card.tsx +146 -0
  33. package/src/run/index.ts +17 -0
  34. package/src/run/run-phase-list.test.tsx +25 -0
  35. package/src/run/run-phase-list.tsx +56 -0
  36. package/src/run/task-list.test.tsx +35 -0
  37. package/src/run/task-list.tsx +117 -0
@@ -0,0 +1,162 @@
1
+ import { cn } from "../lib/utils.js";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { Check, ChevronRight, Circle, FileMinus, FilePen, FilePlus } from "lucide-react";
4
+ //#region src/run/approval-card.tsx
5
+ const CHANGE_ICON = {
6
+ added: FilePlus,
7
+ modified: FilePen,
8
+ deleted: FileMinus
9
+ };
10
+ function count(value, sign) {
11
+ return value === void 0 ? `${sign}–` : `${sign}${value}`;
12
+ }
13
+ /** Files and +/− line counts for a write, with totals when every count is known. */
14
+ function ApprovalDiffSummary({ files, note }) {
15
+ const known = files.every((file) => file.additions !== void 0 && file.deletions !== void 0);
16
+ const additions = files.reduce((sum, file) => sum + (file.additions ?? 0), 0);
17
+ const deletions = files.reduce((sum, file) => sum + (file.deletions ?? 0), 0);
18
+ return /* @__PURE__ */ jsxs("div", {
19
+ className: "rounded-lg border border-border",
20
+ children: [/* @__PURE__ */ jsxs("div", {
21
+ className: "flex flex-wrap items-center justify-between gap-2 border-b border-border px-3 py-2 text-sm",
22
+ children: [/* @__PURE__ */ jsx("span", {
23
+ className: "font-medium text-foreground",
24
+ children: files.length === 1 ? "1 file" : `${files.length} files`
25
+ }), known ? /* @__PURE__ */ jsxs("span", {
26
+ className: "tabular-nums",
27
+ children: [
28
+ /* @__PURE__ */ jsxs("span", {
29
+ className: "text-[var(--surface-success-text)]",
30
+ children: ["+", additions]
31
+ }),
32
+ " ",
33
+ /* @__PURE__ */ jsxs("span", {
34
+ className: "text-[var(--surface-danger-text)]",
35
+ children: ["−", deletions]
36
+ })
37
+ ]
38
+ }) : note && /* @__PURE__ */ jsx("span", {
39
+ className: "text-muted-foreground",
40
+ children: note
41
+ })]
42
+ }), /* @__PURE__ */ jsx("ul", {
43
+ className: "divide-y divide-border/70",
44
+ children: files.map((file) => {
45
+ const Icon = CHANGE_ICON[file.change ?? "modified"];
46
+ return /* @__PURE__ */ jsxs("li", {
47
+ className: "flex items-center justify-between gap-3 px-3 py-2 text-sm",
48
+ children: [/* @__PURE__ */ jsxs("span", {
49
+ className: "flex min-w-0 items-center gap-2",
50
+ children: [/* @__PURE__ */ jsx(Icon, {
51
+ "aria-hidden": true,
52
+ className: "size-4 shrink-0 text-muted-foreground"
53
+ }), /* @__PURE__ */ jsx("span", {
54
+ className: "min-w-0 truncate font-mono text-foreground",
55
+ title: file.path,
56
+ children: file.path
57
+ })]
58
+ }), /* @__PURE__ */ jsxs("span", {
59
+ className: "shrink-0 tabular-nums",
60
+ children: [
61
+ /* @__PURE__ */ jsx("span", {
62
+ className: "text-[var(--surface-success-text)]",
63
+ children: count(file.additions, "+")
64
+ }),
65
+ " ",
66
+ /* @__PURE__ */ jsx("span", {
67
+ className: "text-[var(--surface-danger-text)]",
68
+ children: count(file.deletions, "−")
69
+ })
70
+ ]
71
+ })]
72
+ }, file.path);
73
+ })
74
+ })]
75
+ });
76
+ }
77
+ function PlanMarker({ state }) {
78
+ if (state === "done") return /* @__PURE__ */ jsx(Check, {
79
+ "aria-hidden": true,
80
+ className: "size-4 shrink-0 text-[var(--surface-success-text)]"
81
+ });
82
+ if (state === "current") return /* @__PURE__ */ jsx(ChevronRight, {
83
+ "aria-hidden": true,
84
+ className: "size-4 shrink-0 text-foreground"
85
+ });
86
+ return /* @__PURE__ */ jsx(Circle, {
87
+ "aria-hidden": true,
88
+ className: "size-3.5 shrink-0 text-muted-foreground"
89
+ });
90
+ }
91
+ /**
92
+ * A request for a person to approve one action: what it does in plain words,
93
+ * where, its step in a plan, the files it writes, and the raw request behind
94
+ * a Details toggle. The caller owns the decision and supplies the actions.
95
+ */
96
+ function ApprovalCard({ title, context, step, plan, diff, diffNote, details, status, actions, className, ...props }) {
97
+ return /* @__PURE__ */ jsxs("section", {
98
+ role: "group",
99
+ "aria-label": "Approval request",
100
+ ...props,
101
+ className: cn("space-y-3 rounded-xl border border-border bg-card p-4 shadow-sm", className),
102
+ children: [
103
+ /* @__PURE__ */ jsxs("div", {
104
+ className: "space-y-1",
105
+ children: [(step || context) && /* @__PURE__ */ jsxs("p", {
106
+ className: "flex flex-wrap items-center gap-2 text-sm text-muted-foreground",
107
+ children: [step && /* @__PURE__ */ jsxs("span", {
108
+ className: "rounded-md bg-muted px-2 py-0.5 font-medium text-foreground",
109
+ children: [
110
+ "Step ",
111
+ step.index,
112
+ " of ",
113
+ step.total
114
+ ]
115
+ }), context && /* @__PURE__ */ jsx("span", {
116
+ className: "min-w-0 truncate",
117
+ children: context
118
+ })]
119
+ }), /* @__PURE__ */ jsx("h3", {
120
+ className: "text-base font-semibold leading-6 text-foreground",
121
+ children: title
122
+ })]
123
+ }),
124
+ plan && plan.length > 0 && /* @__PURE__ */ jsx("ol", {
125
+ className: "space-y-1.5",
126
+ "aria-label": "Plan",
127
+ children: plan.map((item, index) => /* @__PURE__ */ jsxs("li", {
128
+ "aria-current": item.state === "current" ? "step" : void 0,
129
+ className: cn("flex items-start gap-2 text-sm leading-6", item.state === "current" ? "font-medium text-foreground" : "text-muted-foreground"),
130
+ children: [/* @__PURE__ */ jsx("span", {
131
+ className: "mt-1",
132
+ children: /* @__PURE__ */ jsx(PlanMarker, { state: item.state })
133
+ }), /* @__PURE__ */ jsx("span", {
134
+ className: "min-w-0",
135
+ children: item.label
136
+ })]
137
+ }, index))
138
+ }),
139
+ diff && diff.length > 0 && /* @__PURE__ */ jsx(ApprovalDiffSummary, {
140
+ files: diff,
141
+ note: diffNote
142
+ }),
143
+ details !== void 0 && /* @__PURE__ */ jsxs("details", {
144
+ className: "group text-sm",
145
+ children: [/* @__PURE__ */ jsx("summary", {
146
+ className: "cursor-pointer select-none font-medium text-muted-foreground hover:text-foreground",
147
+ children: "Details"
148
+ }), /* @__PURE__ */ jsx("div", {
149
+ className: "mt-2",
150
+ children: details
151
+ })]
152
+ }),
153
+ status,
154
+ actions && /* @__PURE__ */ jsx("div", {
155
+ className: "flex flex-wrap gap-2",
156
+ children: actions
157
+ })
158
+ ]
159
+ });
160
+ }
161
+ //#endregion
162
+ export { ApprovalCard, ApprovalDiffSummary };
@@ -0,0 +1,18 @@
1
+ import { ReactNode } from "react";
2
+ //#region src/run/run-phase-list.d.ts
3
+ export interface RunPhase {
4
+ id: string;
5
+ /** The step or phase the work belongs to. */
6
+ title: ReactNode;
7
+ /** One line on what happened, such as "Read 12 files · ran 3 commands". */
8
+ summary?: ReactNode;
9
+ status?: "running" | "done" | "error";
10
+ /** The phase's individual steps, shown when expanded. */
11
+ content: ReactNode;
12
+ }
13
+ /** A turn's tool runs grouped by step, each collapsed to one summarizing line. */
14
+ export declare function RunPhaseList({ phases, className }: {
15
+ phases: readonly RunPhase[];
16
+ className?: string;
17
+ }): import("react").JSX.Element | null;
18
+ //#endregion
@@ -0,0 +1,56 @@
1
+ import { focusRing } from "../lib/focus.js";
2
+ import { cn } from "../lib/utils.js";
3
+ import { useState } from "react";
4
+ import { jsx, jsxs } from "react/jsx-runtime";
5
+ import { ChevronRight, CircleAlert, Loader2 } from "lucide-react";
6
+ //#region src/run/run-phase-list.tsx
7
+ function PhaseRow({ phase }) {
8
+ const [open, setOpen] = useState(false);
9
+ const id = `run-phase-${phase.id}`;
10
+ return /* @__PURE__ */ jsxs("li", { children: [/* @__PURE__ */ jsxs("button", {
11
+ type: "button",
12
+ "aria-expanded": open,
13
+ "aria-controls": id,
14
+ onClick: () => setOpen(!open),
15
+ className: cn("flex w-full items-start gap-2 rounded-md py-1 text-left text-sm transition-colors hover:text-foreground", focusRing),
16
+ children: [
17
+ /* @__PURE__ */ jsx(ChevronRight, {
18
+ "aria-hidden": true,
19
+ className: cn("mt-1 size-4 shrink-0 text-muted-foreground transition-transform", open && "rotate-90")
20
+ }),
21
+ /* @__PURE__ */ jsxs("span", {
22
+ className: "min-w-0 flex-1 leading-6",
23
+ children: [/* @__PURE__ */ jsx("span", {
24
+ className: "font-medium text-foreground",
25
+ children: phase.title
26
+ }), phase.summary && /* @__PURE__ */ jsxs("span", {
27
+ className: "text-muted-foreground",
28
+ children: [" · ", phase.summary]
29
+ })]
30
+ }),
31
+ phase.status === "running" && /* @__PURE__ */ jsx(Loader2, {
32
+ "aria-label": "Running",
33
+ className: "mt-1 size-4 shrink-0 animate-spin text-muted-foreground"
34
+ }),
35
+ phase.status === "error" && /* @__PURE__ */ jsx(CircleAlert, {
36
+ "aria-label": "Had an error",
37
+ className: "mt-1 size-4 shrink-0 text-[var(--surface-danger-text)]"
38
+ })
39
+ ]
40
+ }), open && /* @__PURE__ */ jsx("div", {
41
+ id,
42
+ className: "mb-2 ml-6 mt-1",
43
+ children: phase.content
44
+ })] });
45
+ }
46
+ /** A turn's tool runs grouped by step, each collapsed to one summarizing line. */
47
+ function RunPhaseList({ phases, className }) {
48
+ if (phases.length === 0) return null;
49
+ return /* @__PURE__ */ jsx("ul", {
50
+ className: cn("space-y-0.5", className),
51
+ "aria-label": "Tool runs",
52
+ children: phases.map((phase) => /* @__PURE__ */ jsx(PhaseRow, { phase }, phase.id))
53
+ });
54
+ }
55
+ //#endregion
56
+ export { RunPhaseList };
@@ -0,0 +1,33 @@
1
+ import { ReactNode } from "react";
2
+ //#region src/run/task-list.d.ts
3
+ /** The states an agent's working checklist reports; unknown values read as to-do. */
4
+ export type TaskStatus = "pending" | "in_progress" | "completed" | "cancelled" | "blocked" | "failed";
5
+ export interface TaskListItem {
6
+ id: string;
7
+ title: string;
8
+ status: TaskStatus | (string & {});
9
+ priority?: string;
10
+ description?: string;
11
+ }
12
+ export interface TaskListProps {
13
+ items: readonly TaskListItem[];
14
+ /** Header title; "Tasks" by default. */
15
+ title?: ReactNode;
16
+ /** Trailing header content, such as a run state. */
17
+ meta?: ReactNode;
18
+ className?: string;
19
+ }
20
+ export declare function taskStatus(status: string): TaskStatus;
21
+ /** Priority is worth a label only when the tasks disagree about it. */
22
+ export declare function showsTaskPriority(items: readonly TaskListItem[]): boolean;
23
+ /** "2 of 3 done": cancelled tasks leave the count rather than counting as done. */
24
+ export declare function taskProgress(items: readonly TaskListItem[]): {
25
+ done: number;
26
+ total: number;
27
+ };
28
+ /**
29
+ * An agent's working checklist: a header with progress, each task's state as
30
+ * a toned pill, and priority shown only when the tasks differ in it.
31
+ */
32
+ export declare function TaskList({ items, title, meta, className }: TaskListProps): import("react").JSX.Element;
33
+ //#endregion
@@ -0,0 +1,138 @@
1
+ import { cn } from "../lib/utils.js";
2
+ import { StatusPill } from "../primitives/status-pill.js";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ //#region src/run/task-list.tsx
5
+ const STATUS = {
6
+ pending: {
7
+ tone: "neutral",
8
+ label: "To do"
9
+ },
10
+ in_progress: {
11
+ tone: "running",
12
+ label: "In progress"
13
+ },
14
+ completed: {
15
+ tone: "success",
16
+ label: "Done"
17
+ },
18
+ cancelled: {
19
+ tone: "neutral",
20
+ label: "Cancelled"
21
+ },
22
+ blocked: {
23
+ tone: "warning",
24
+ label: "Blocked"
25
+ },
26
+ failed: {
27
+ tone: "danger",
28
+ label: "Failed"
29
+ }
30
+ };
31
+ const STATUS_ALIASES = {
32
+ todo: "pending",
33
+ open: "pending",
34
+ in_progress: "in_progress",
35
+ "in-progress": "in_progress",
36
+ active: "in_progress",
37
+ running: "in_progress",
38
+ done: "completed",
39
+ complete: "completed",
40
+ completed: "completed",
41
+ success: "completed",
42
+ cancelled: "cancelled",
43
+ canceled: "cancelled",
44
+ skipped: "cancelled",
45
+ blocked: "blocked",
46
+ failed: "failed",
47
+ error: "failed"
48
+ };
49
+ function taskStatus(status) {
50
+ const key = status.trim().toLowerCase();
51
+ return STATUS[key] ? key : STATUS_ALIASES[key] ?? "pending";
52
+ }
53
+ /** Priority is worth a label only when the tasks disagree about it. */
54
+ function showsTaskPriority(items) {
55
+ return new Set(items.map((item) => item.priority?.trim().toLowerCase()).filter(Boolean)).size > 1;
56
+ }
57
+ /** "2 of 3 done": cancelled tasks leave the count rather than counting as done. */
58
+ function taskProgress(items) {
59
+ const counted = items.filter((item) => taskStatus(item.status) !== "cancelled");
60
+ return {
61
+ done: counted.filter((item) => taskStatus(item.status) === "completed").length,
62
+ total: counted.length
63
+ };
64
+ }
65
+ function capitalized(value) {
66
+ return value.charAt(0).toUpperCase() + value.slice(1);
67
+ }
68
+ /**
69
+ * An agent's working checklist: a header with progress, each task's state as
70
+ * a toned pill, and priority shown only when the tasks differ in it.
71
+ */
72
+ function TaskList({ items, title = "Tasks", meta, className }) {
73
+ const showPriority = showsTaskPriority(items);
74
+ const { done, total } = taskProgress(items);
75
+ return /* @__PURE__ */ jsxs("section", {
76
+ "aria-label": typeof title === "string" ? title : "Tasks",
77
+ className: cn("rounded-xl border border-border bg-card p-4 shadow-sm", className),
78
+ children: [
79
+ /* @__PURE__ */ jsxs("header", {
80
+ className: "mb-3 flex flex-wrap items-center justify-between gap-2",
81
+ children: [/* @__PURE__ */ jsxs("div", {
82
+ className: "flex min-w-0 items-baseline gap-2",
83
+ children: [/* @__PURE__ */ jsx("h3", {
84
+ className: "text-base font-semibold text-foreground",
85
+ children: title
86
+ }), total > 0 && /* @__PURE__ */ jsxs("span", {
87
+ className: "text-sm text-muted-foreground",
88
+ children: [
89
+ done,
90
+ " of ",
91
+ total,
92
+ " done"
93
+ ]
94
+ })]
95
+ }), meta]
96
+ }),
97
+ total > 0 && /* @__PURE__ */ jsx("div", {
98
+ className: "mb-3 h-1.5 overflow-hidden rounded-full bg-muted",
99
+ "aria-hidden": "true",
100
+ children: /* @__PURE__ */ jsx("div", {
101
+ className: "h-full rounded-full bg-[var(--surface-success-text)] transition-[width]",
102
+ style: { width: `${Math.round(done / total * 100)}%` }
103
+ })
104
+ }),
105
+ /* @__PURE__ */ jsx("ol", {
106
+ className: "divide-y divide-border/70",
107
+ children: items.map((item, index) => {
108
+ const status = STATUS[taskStatus(item.status)];
109
+ return /* @__PURE__ */ jsxs("li", {
110
+ className: "flex items-start justify-between gap-3 py-2.5 first:pt-0 last:pb-0",
111
+ children: [/* @__PURE__ */ jsxs("div", {
112
+ className: "min-w-0 flex-1",
113
+ children: [/* @__PURE__ */ jsx("p", {
114
+ className: cn("text-sm font-medium leading-6 text-foreground", taskStatus(item.status) === "cancelled" && "text-muted-foreground line-through"),
115
+ children: item.title
116
+ }), item.description && /* @__PURE__ */ jsx("p", {
117
+ className: "mt-0.5 text-sm leading-6 text-muted-foreground",
118
+ children: item.description
119
+ })]
120
+ }), /* @__PURE__ */ jsxs("div", {
121
+ className: "flex shrink-0 items-center gap-2",
122
+ children: [showPriority && item.priority && /* @__PURE__ */ jsx("span", {
123
+ className: "text-sm text-muted-foreground",
124
+ children: capitalized(item.priority.trim())
125
+ }), /* @__PURE__ */ jsx(StatusPill, {
126
+ tone: status.tone,
127
+ size: "md",
128
+ children: status.label
129
+ })]
130
+ })]
131
+ }, `${item.id}-${index}`);
132
+ })
133
+ })
134
+ ]
135
+ });
136
+ }
137
+ //#endregion
138
+ export { TaskList, showsTaskPriority, taskProgress, taskStatus };
package/dist/run.d.ts CHANGED
@@ -6,4 +6,7 @@ import { InlineToolItem, InlineToolItemProps } from "./run/inline-tool-item.js";
6
6
  import { InlineThinkingItem, InlineThinkingItemProps } from "./run/inline-thinking-item.js";
7
7
  import { ExpandedToolDetail, ExpandedToolDetailProps } from "./run/expanded-tool-detail.js";
8
8
  import { LiveDuration } from "./run/run-item-primitives.js";
9
- export { ExpandedToolDetail, type ExpandedToolDetailProps, type FeedSegment, InlineThinkingItem, type InlineThinkingItemProps, InlineToolItem, type InlineToolItemProps, LiveDuration, RunGroup, type RunGroupProps, RunRowShell, type RunRowShellProps, type RunRowStatus, RunRowStatusDot, type ToolCallData, ToolCallFeed, type ToolCallFeedProps, type ToolCallStatus, type ToolCallType, parseToolEvent };
9
+ import { TaskList, TaskListItem, TaskListProps, TaskStatus, showsTaskPriority, taskProgress, taskStatus } from "./run/task-list.js";
10
+ import { ApprovalCard, ApprovalCardProps, ApprovalDiffFile, ApprovalDiffSummary, ApprovalPlanStep } from "./run/approval-card.js";
11
+ import { RunPhase, RunPhaseList } from "./run/run-phase-list.js";
12
+ export { ApprovalCard, type ApprovalCardProps, type ApprovalDiffFile, ApprovalDiffSummary, type ApprovalPlanStep, ExpandedToolDetail, type ExpandedToolDetailProps, type FeedSegment, InlineThinkingItem, type InlineThinkingItemProps, InlineToolItem, type InlineToolItemProps, LiveDuration, RunGroup, type RunGroupProps, type RunPhase, RunPhaseList, RunRowShell, type RunRowShellProps, type RunRowStatus, RunRowStatusDot, TaskList, type TaskListItem, type TaskListProps, type TaskStatus, type ToolCallData, ToolCallFeed, type ToolCallFeedProps, type ToolCallStatus, type ToolCallType, parseToolEvent, showsTaskPriority, taskProgress, taskStatus };
package/dist/run.js CHANGED
@@ -5,4 +5,7 @@ import { InlineThinkingItem } from "./run/inline-thinking-item.js";
5
5
  import { RunGroup } from "./run/run-group.js";
6
6
  import { LiveDuration } from "./run/run-item-primitives.js";
7
7
  import { ToolCallFeed, parseToolEvent } from "./run/tool-call-feed.js";
8
- export { ExpandedToolDetail, InlineThinkingItem, InlineToolItem, LiveDuration, RunGroup, RunRowShell, RunRowStatusDot, ToolCallFeed, parseToolEvent };
8
+ import { TaskList, showsTaskPriority, taskProgress, taskStatus } from "./run/task-list.js";
9
+ import { ApprovalCard, ApprovalDiffSummary } from "./run/approval-card.js";
10
+ import { RunPhaseList } from "./run/run-phase-list.js";
11
+ export { ApprovalCard, ApprovalDiffSummary, ExpandedToolDetail, InlineThinkingItem, InlineToolItem, LiveDuration, RunGroup, RunPhaseList, RunRowShell, RunRowStatusDot, TaskList, ToolCallFeed, parseToolEvent, showsTaskPriority, taskProgress, taskStatus };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/ui",
3
- "version": "11.30.2",
3
+ "version": "11.31.1",
4
4
  "description": "Generic React UI components for Tangle products — primitives, chat, run, files, editor, markdown.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -140,17 +140,17 @@
140
140
  "lucide-react": "^1.48.0",
141
141
  "marked": "^18.0.14",
142
142
  "react-markdown": "^10.1.0",
143
- "react-syntax-highlighter": "^16.1.1",
144
143
  "rehype-sanitize": "^6.0.0",
145
144
  "rehype-slug": "^6.0.0",
146
145
  "remark-gfm": "^4.0.1",
146
+ "shiki": "^4.4.3",
147
147
  "tailwind-merge": "^3.7.0",
148
148
  "turndown": "^7.2.4"
149
149
  },
150
150
  "peerDependencies": {
151
151
  "@hocuspocus/provider": ">=2.15.0 <5.0.0",
152
152
  "@nanostores/react": ">=0.8.4 <3.0.0",
153
- "@tangle-network/brand": "^1.19.3",
153
+ "@tangle-network/brand": "^1.19.6",
154
154
  "@tiptap/core": ">=3.20.4 <4.0.0",
155
155
  "@tiptap/extension-collaboration": ">=3.20.4 <4.0.0",
156
156
  "@tiptap/extension-collaboration-caret": ">=3.7.0 <4.0.0",
@@ -209,7 +209,6 @@
209
209
  "@tiptap/starter-kit": "^3.31.3",
210
210
  "@tiptap/y-tiptap": "^3.0.9",
211
211
  "@types/react": "^19.3.0",
212
- "@types/react-syntax-highlighter": "^15.5.13",
213
212
  "@types/turndown": "^5.0.6",
214
213
  "ajv": "8.17.1",
215
214
  "nanostores": "^1.5.4",
@@ -1,6 +1,5 @@
1
1
  import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
2
2
  import { renderToString } from "react-dom/server";
3
- import SyntaxHighlighter from "react-syntax-highlighter";
4
3
  import { afterEach, describe, expect, it, vi } from "vitest";
5
4
  import { CodeBlock, CopyButton } from "./code-block";
6
5
 
@@ -16,12 +15,13 @@ afterEach(() => {
16
15
  describe("CodeBlock scoped semantic colors", () => {
17
16
  it("keeps code readable before the renderer loads, then applies live semantic colors and line numbers", async () => {
18
17
  const { container } = render(<CodeBlock code={'const message = "hello";'} language="javascript" showLineNumbers />);
19
- expect(container.querySelector("pre code")?.textContent).toBe('const message = "hello";');
18
+ // Plain code already carries its line numbers, so highlighting shifts no layout.
19
+ expect(container.querySelector("pre code")?.textContent?.replace(/^1/, "")).toBe('const message = "hello";');
20
20
  // The highlighter loads asynchronously; under a full parallel run it can take longer than waitFor's 1s default.
21
21
  await waitFor(() => expect(container.innerHTML).toContain("var(--syntax-keyword, currentColor)"), { timeout: 10_000 });
22
22
  expect(container.innerHTML).toContain("var(--syntax-string, currentColor)");
23
23
  expect(container.innerHTML).toContain("var(--syntax-comment, currentColor)");
24
- expect(container.querySelector(".react-syntax-highlighter-line-number")).not.toBeNull();
24
+ expect(container.querySelector("[data-line-number]")).not.toBeNull();
25
25
  expect(container.querySelector("pre code")?.textContent?.replace(/^1/, "")).toBe('const message = "hello";');
26
26
  });
27
27
 
@@ -66,32 +66,41 @@ describe("CodeBlock", () => {
66
66
  expect(container.querySelector("code")).not.toBeNull();
67
67
  });
68
68
 
69
- it.each([
70
- ["javascript", "const answer = 42;"],
71
- ["js", "const answer = 42;"],
72
- ["brainfuck", "++[>+++<-]"],
73
- ["not-a-language", "const answer = 42;"],
74
- ])("keeps the default highlighter's tokens for language %s", async (language, code) => {
75
- const baseline = document.createElement("div");
76
- baseline.innerHTML = renderToString(<SyntaxHighlighter language={language}>{code}</SyntaxHighlighter>);
77
- const expected = Array.from(baseline.querySelectorAll("pre code span"), (span) => span.textContent);
78
- expect(expected.length).toBeGreaterThan(0);
69
+ it.each(["javascript", "js", "ts", "tsx"])("highlights %s with Shiki into Brand syntax tokens", async (language) => {
70
+ const code = "const answer = 42; // the answer";
71
+ const { container } = render(<CodeBlock code={code} language={language} />);
72
+ await waitFor(() => expect(container.querySelector("[data-highlighted]")).not.toBeNull(), { timeout: 10_000 });
73
+ const tokens = Array.from(container.querySelectorAll("pre code span span"));
74
+ expect(tokens.length).toBeGreaterThan(3);
75
+ expect(container.innerHTML).toContain("var(--syntax-keyword, currentColor)");
76
+ expect(container.innerHTML).toContain("var(--syntax-comment, currentColor)");
77
+ expect(container.innerHTML).not.toContain("--shiki-");
78
+ expect(container.querySelector("pre code")?.textContent).toBe(code);
79
+ });
79
80
 
81
+ it.each(["brainfuck", "not-a-language", undefined])("keeps %s as readable plain text", (language) => {
82
+ const code = "++[>+++<-]";
80
83
  const { container } = render(<CodeBlock code={code} language={language} />);
81
- await waitFor(() => expect(container.querySelectorAll("pre code span").length).toBe(expected.length));
82
- expect(Array.from(container.querySelectorAll("pre code span"), (span) => span.textContent)).toEqual(expected);
84
+ expect(container.querySelector("[data-highlighted]")).toBeNull();
83
85
  expect(container.querySelector("pre code")?.textContent).toBe(code);
84
86
  });
85
87
 
86
- it("preserves whitespace and line numbering after the renderer loads", async () => {
88
+ it("preserves whitespace and numbers every line", async () => {
87
89
  const code = "\tfirst \n second\n";
88
- const baseline = document.createElement("div");
89
- baseline.innerHTML = renderToString(<SyntaxHighlighter language="text" showLineNumbers>{code}</SyntaxHighlighter>);
90
90
  const { container } = render(<CodeBlock code={code} language="text" showLineNumbers />);
91
- expect(container.querySelector("pre code")?.textContent).toBe(code);
92
- await waitFor(() => expect(container.querySelectorAll(".react-syntax-highlighter-line-number").length)
93
- .toBe(baseline.querySelectorAll(".react-syntax-highlighter-line-number").length));
94
- expect(container.querySelector("pre code")?.textContent).toBe(baseline.querySelector("pre code")?.textContent);
91
+ const numbers = Array.from(container.querySelectorAll("[data-line-number]"), (node) => node.textContent);
92
+ expect(numbers).toEqual(["1", "2", "3"]);
93
+ const text = Array.from(container.querySelectorAll("pre code > span"), (line) =>
94
+ Array.from(line.childNodes).filter((node) => !(node instanceof HTMLElement && node.hasAttribute("data-line-number")))
95
+ .map((node) => node.textContent).join("")).join("");
96
+ expect(text).toBe(code);
97
+ });
98
+
99
+ it("names the file in the header beside the language", () => {
100
+ render(<CodeBlock code={"export {}"} language="ts" filename="src/lib/ctr.ts" />);
101
+ expect(screen.getByText("src/lib/ctr.ts")).toBeInTheDocument();
102
+ expect(screen.getByText("TypeScript")).toBeInTheDocument();
103
+ expect(screen.getByRole("region", { name: "src/lib/ctr.ts code" })).toBeInTheDocument();
95
104
  });
96
105
 
97
106
  it("uses `label` as the header text, overriding `language`", () => {
@@ -102,9 +111,9 @@ describe("CodeBlock", () => {
102
111
  expect(queryByText("typescript")).toBeNull();
103
112
  });
104
113
 
105
- it("falls back to `language` for the header when no `label` is given", () => {
114
+ it("falls back to the language's name for the header when no `label` is given", () => {
106
115
  const { getByText } = render(<CodeBlock code={"x"} language="python" />);
107
- expect(getByText("python")).toBeInTheDocument();
116
+ expect(getByText("Python")).toBeInTheDocument();
108
117
  });
109
118
 
110
119
  it("renders no header when neither label nor language is set", () => {
@@ -161,9 +170,9 @@ describe("CodeBlock", () => {
161
170
  expect(screen.getByRole("button", { name: "Copied" })).toBeTruthy();
162
171
  });
163
172
 
164
- it("renders the header label at the 12px text-xs step", () => {
173
+ it("renders the header label at the 14px text-sm step", () => {
165
174
  const { getByText } = render(<CodeBlock code={"x"} label="YAML" />);
166
- expect(getByText("YAML").className).toContain("text-xs");
175
+ expect(getByText("YAML").className).toContain("text-sm");
167
176
  expect(getByText("YAML").className).not.toContain("calc(");
168
177
  });
169
178