@tangle-network/ui 11.30.1 → 11.31.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 (41) 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 +168 -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/primitives/toolbar.d.ts +10 -10
  15. package/dist/primitives/toolbar.js +2 -2
  16. package/dist/run/approval-card.d.ts +46 -0
  17. package/dist/run/approval-card.js +162 -0
  18. package/dist/run/run-phase-list.d.ts +18 -0
  19. package/dist/run/run-phase-list.js +56 -0
  20. package/dist/run/task-list.d.ts +33 -0
  21. package/dist/run/task-list.js +138 -0
  22. package/dist/run.d.ts +4 -1
  23. package/dist/run.js +4 -1
  24. package/package.json +3 -4
  25. package/src/markdown/code-block.test.tsx +35 -26
  26. package/src/markdown/code-block.tsx +197 -110
  27. package/src/markdown/code-meta.ts +68 -0
  28. package/src/markdown/index.ts +10 -2
  29. package/src/markdown/markdown-table.test.tsx +61 -0
  30. package/src/markdown/markdown-table.tsx +191 -0
  31. package/src/markdown/markdown.test.tsx +27 -0
  32. package/src/markdown/markdown.tsx +126 -48
  33. package/src/primitives/toolbar.test.tsx +9 -10
  34. package/src/primitives/toolbar.tsx +12 -12
  35. package/src/run/approval-card.test.tsx +41 -0
  36. package/src/run/approval-card.tsx +146 -0
  37. package/src/run/index.ts +17 -0
  38. package/src/run/run-phase-list.test.tsx +25 -0
  39. package/src/run/run-phase-list.tsx +56 -0
  40. package/src/run/task-list.test.tsx +35 -0
  41. package/src/run/task-list.tsx +117 -0
@@ -0,0 +1,191 @@
1
+ import {
2
+ Children,
3
+ createContext,
4
+ isValidElement,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ type ComponentProps,
12
+ } from "react";
13
+ import type { ExtraProps } from "react-markdown";
14
+ import { Check, Copy } from "lucide-react";
15
+ import { focusRing, focusRingInset } from "../lib/focus";
16
+ import { cn } from "../lib/utils";
17
+
18
+ type Element = NonNullable<ExtraProps["node"]>;
19
+ type ElementContent = Element["children"][number];
20
+
21
+ /** A figure: optional sign or currency, digits with separators, an optional unit. */
22
+ const NUMERIC_CELL = /^[(]?[-+−]?\s?[$€£¥₹]?\s?\d[\d,_ ]*(?:\.\d+)?\s?(?:%|[kKmMbB]|x|ms|s|h|pp)?[)]?$/;
23
+
24
+ /** The text of a hast subtree, as a reader sees it. */
25
+ function textOf(node: ElementContent | Element): string {
26
+ if (node.type === "text") return node.value;
27
+ if (node.type !== "element") return "";
28
+ return node.children.map(textOf).join("");
29
+ }
30
+
31
+ function rowsOf(table: Element): { header: string[][]; body: string[][] } {
32
+ const header: string[][] = [];
33
+ const body: string[][] = [];
34
+ const visit = (element: Element, inHead: boolean) => {
35
+ for (const child of element.children) {
36
+ if (child.type !== "element") continue;
37
+ if (child.tagName === "tr") {
38
+ const cells = child.children
39
+ .filter((cell): cell is Element => cell.type === "element" && (cell.tagName === "th" || cell.tagName === "td"))
40
+ .map((cell) => textOf(cell).replace(/\s+/g, " ").trim());
41
+ (inHead ? header : body).push(cells);
42
+ } else {
43
+ visit(child, inHead || child.tagName === "thead");
44
+ }
45
+ }
46
+ };
47
+ visit(table, false);
48
+ return { header, body };
49
+ }
50
+
51
+ /** A column is numeric when every non-empty body cell is a figure. */
52
+ export function numericColumns(body: string[][]): boolean[] {
53
+ const width = Math.max(0, ...body.map((row) => row.length));
54
+ return Array.from({ length: width }, (_, column) => {
55
+ const cells = body.map((row) => row[column] ?? "").filter(Boolean);
56
+ return cells.length > 0 && cells.every((cell) => NUMERIC_CELL.test(cell));
57
+ });
58
+ }
59
+
60
+ function csvCell(value: string): string {
61
+ // A spreadsheet runs a leading = + - @ as a formula unless the cell is a figure.
62
+ const safe = /^[=+\-@\t\r]/.test(value) && !NUMERIC_CELL.test(value) ? `'${value}` : value;
63
+ return /[",\n\r]/.test(safe) || safe !== safe.trim() ? `"${safe.replace(/"/g, '""')}"` : safe;
64
+ }
65
+
66
+ /** RFC 4180 CSV of a rendered Markdown table, header first. */
67
+ export function tableCsv(rows: string[][]): string {
68
+ return rows.map((row) => row.map(csvCell).join(",")).join("\n");
69
+ }
70
+
71
+ const TableColumns = createContext<boolean[]>([]);
72
+ const CellColumn = createContext(-1);
73
+
74
+ function CopyCsvButton({ csv }: { csv: string }) {
75
+ const [state, setState] = useState<"idle" | "copied" | "failed">("idle");
76
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
77
+ useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
78
+ const copy = useCallback(async () => {
79
+ if (timer.current) clearTimeout(timer.current);
80
+ try {
81
+ await navigator.clipboard.writeText(csv);
82
+ setState("copied");
83
+ } catch {
84
+ setState("failed");
85
+ }
86
+ timer.current = setTimeout(() => setState("idle"), 2000);
87
+ }, [csv]);
88
+ return (
89
+ <>
90
+ <button
91
+ type="button"
92
+ onClick={copy}
93
+ className={cn(
94
+ "inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
95
+ focusRing,
96
+ )}
97
+ >
98
+ {state === "copied"
99
+ ? <Check aria-hidden className="size-4 text-[var(--surface-success-text)]" />
100
+ : <Copy aria-hidden className="size-4" />}
101
+ {state === "copied" ? "Copied" : "Copy CSV"}
102
+ </button>
103
+ <span className="sr-only" role="status" aria-live="polite">
104
+ {state === "copied" ? "Table copied as CSV" : state === "failed" ? "Copy failed" : ""}
105
+ </span>
106
+ </>
107
+ );
108
+ }
109
+
110
+ /**
111
+ * A Markdown table as a card: a header row, subtle row lines, figures
112
+ * right-aligned in tabular numerals, a copy-as-CSV control, and horizontal
113
+ * scrolling inside the card when the columns outgrow a phone.
114
+ */
115
+ export function MarkdownTable({ node, className, children, ...props }: ComponentProps<"table"> & ExtraProps) {
116
+ const { csv, numeric, rowCount } = useMemo(() => {
117
+ const { header, body } = node ? rowsOf(node) : { header: [], body: [] };
118
+ return { csv: tableCsv([...header, ...body]), numeric: numericColumns(body), rowCount: body.length };
119
+ }, [node]);
120
+ return (
121
+ <div className="not-prose my-4 overflow-hidden rounded-lg border border-border bg-card">
122
+ <div className="flex items-center justify-between gap-2 border-b border-border px-3 py-1">
123
+ <span className="text-sm text-muted-foreground">{rowCount === 1 ? "1 row" : `${rowCount} rows`}</span>
124
+ <CopyCsvButton csv={csv} />
125
+ </div>
126
+ {/* A keyboard reaches a sideways-scrolling table only when it can take focus. */}
127
+ <div tabIndex={0} role="region" aria-label="Table" className={cn("overflow-x-auto", focusRingInset)}>
128
+ <TableColumns.Provider value={numeric}>
129
+ <table {...props} className={cn("w-full border-collapse text-sm text-foreground", className)}>
130
+ {children}
131
+ </table>
132
+ </TableColumns.Provider>
133
+ </div>
134
+ </div>
135
+ );
136
+ }
137
+
138
+ export function MarkdownTableHead({ node: _node, className, ...props }: ComponentProps<"thead"> & ExtraProps) {
139
+ return <thead {...props} className={cn("bg-muted/50", className)} />;
140
+ }
141
+
142
+ export function MarkdownTableBody({ node: _node, className, ...props }: ComponentProps<"tbody"> & ExtraProps) {
143
+ return <tbody {...props} className={cn("[&>tr:last-child>td]:border-b-0", className)} />;
144
+ }
145
+
146
+ export function MarkdownTableRow({ node: _node, children, ...props }: ComponentProps<"tr"> & ExtraProps) {
147
+ let column = 0;
148
+ return (
149
+ <tr {...props}>
150
+ {Children.map(children, (child) =>
151
+ isValidElement(child)
152
+ ? <CellColumn.Provider value={column++}>{child}</CellColumn.Provider>
153
+ : child,
154
+ )}
155
+ </tr>
156
+ );
157
+ }
158
+
159
+ function useNumericCell(): boolean {
160
+ const numeric = useContext(TableColumns);
161
+ const column = useContext(CellColumn);
162
+ return column >= 0 && numeric[column] === true;
163
+ }
164
+
165
+ export function MarkdownTableHeaderCell({ node: _node, className, ...props }: ComponentProps<"th"> & ExtraProps) {
166
+ const numeric = useNumericCell();
167
+ return (
168
+ <th
169
+ {...props}
170
+ className={cn(
171
+ "whitespace-nowrap border-b border-border px-3 py-2 text-left font-semibold text-foreground",
172
+ numeric && "text-right",
173
+ className,
174
+ )}
175
+ />
176
+ );
177
+ }
178
+
179
+ export function MarkdownTableCell({ node: _node, className, ...props }: ComponentProps<"td"> & ExtraProps) {
180
+ const numeric = useNumericCell();
181
+ return (
182
+ <td
183
+ {...props}
184
+ className={cn(
185
+ "border-b border-border/60 px-3 py-2 align-top",
186
+ numeric ? "whitespace-nowrap text-right tabular-nums" : "min-w-[8rem]",
187
+ className,
188
+ )}
189
+ />
190
+ );
191
+ }
@@ -136,4 +136,31 @@ describe("Markdown", () => {
136
136
  );
137
137
  expect(screen.queryByRole("link")).toBeNull();
138
138
  });
139
+
140
+ it("renders a fenced block's file name and highlights it as that file's language", () => {
141
+ render(<Markdown>{'```ts title="src/lib/ctr.ts"\nexport const x = 1;\n```'}</Markdown>);
142
+ expect(screen.getByText("src/lib/ctr.ts")).toBeInTheDocument();
143
+ expect(screen.getByText("TypeScript")).toBeInTheDocument();
144
+ expect(screen.getByRole("button", { name: "Copy to clipboard" })).toBeInTheDocument();
145
+ });
146
+
147
+ it("renders a single-line fence without a language as a block, not inline code", () => {
148
+ const { container } = render(<Markdown>{"```\nnpm run build\n```\n\nRun `npm test` too."}</Markdown>);
149
+ expect(container.querySelectorAll("pre")).toHaveLength(1);
150
+ expect(container.querySelector("pre")?.textContent).toBe("npm run build");
151
+ expect(container.querySelector("p code")?.textContent).toBe("npm test");
152
+ });
153
+
154
+ it("renders links the host classifies as chips that keep their href", () => {
155
+ render(
156
+ <Markdown linkChip={(href) => href.startsWith("/vault/") ? { kind: "file", label: "REVIEW.md", title: "campaigns/REVIEW.md" } : null}>
157
+ {"See [the review](/vault/REVIEW.md) and [docs](https://example.com)."}
158
+ </Markdown>,
159
+ );
160
+ const chip = screen.getByRole("link", { name: "REVIEW.md" });
161
+ expect(chip.getAttribute("href")).toBe("/vault/REVIEW.md");
162
+ expect(chip.getAttribute("data-link-chip")).toBe("file");
163
+ expect(chip.getAttribute("title")).toBe("campaigns/REVIEW.md");
164
+ expect(screen.getByRole("link", { name: "docs" }).hasAttribute("data-link-chip")).toBe(false);
165
+ });
139
166
  });
@@ -9,11 +9,21 @@ import {
9
9
  } from "react";
10
10
  import ReactMarkdown, { defaultUrlTransform, type ExtraProps, type UrlTransform } from "react-markdown";
11
11
  import remarkGfm from "remark-gfm";
12
- import rehypeSanitize from "rehype-sanitize";
12
+ import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
13
13
  import rehypeSlug from "rehype-slug";
14
14
  import GithubSlugger from "github-slugger";
15
- import { ImageOff } from "lucide-react";
15
+ import { FileImage, FileText, Folder, ImageOff, Package, type LucideIcon } from "lucide-react";
16
16
  import { CodeBlock, CopyButton } from "./code-block";
17
+ import { fenceInfo, remarkCodeMeta } from "./code-meta";
18
+ import {
19
+ MarkdownTable,
20
+ MarkdownTableBody,
21
+ MarkdownTableCell,
22
+ MarkdownTableHead,
23
+ MarkdownTableHeaderCell,
24
+ MarkdownTableRow,
25
+ } from "./markdown-table";
26
+ import { focusRing } from "../lib/focus";
17
27
  import { cn } from "../lib/utils";
18
28
 
19
29
  const SANITIZED_HEADING_ID_PREFIX = "user-content-";
@@ -50,17 +60,101 @@ function transformMarkdownUrl(
50
60
  return safeUrl(transformed);
51
61
  }
52
62
 
63
+ /** How a link to a known object renders: a chip with that object's icon. */
64
+ export interface MarkdownLinkChip {
65
+ kind: "file" | "image" | "asset" | "folder";
66
+ /** Chip text; the link's own text when omitted. */
67
+ label?: string;
68
+ /** Tooltip, such as the full path. */
69
+ title?: string;
70
+ }
71
+
72
+ /** Classifies a link by its transformed href; null keeps an ordinary link. */
73
+ export type MarkdownLinkChipResolver = (href: string) => MarkdownLinkChip | null | undefined;
74
+
75
+ const LINK_CHIP_ICONS: Record<MarkdownLinkChip["kind"], LucideIcon> = {
76
+ file: FileText,
77
+ image: FileImage,
78
+ asset: Package,
79
+ folder: Folder,
80
+ };
81
+
82
+ const LinkChipContext = createContext<MarkdownLinkChipResolver | undefined>(undefined);
83
+
53
84
  // An image already wrapped in a Markdown link keeps that link: anchors cannot nest.
54
85
  const MarkdownLinkContext = createContext(false);
55
86
 
56
- function MarkdownLink({ node: _node, ...props }: ComponentProps<"a"> & ExtraProps) {
87
+ function MarkdownLink({ node: _node, children, className, ...props }: ComponentProps<"a"> & ExtraProps) {
88
+ const resolveChip = useContext(LinkChipContext);
89
+ const chip = typeof props.href === "string" && props.href ? resolveChip?.(props.href) : null;
90
+ if (!chip) {
91
+ return (
92
+ <MarkdownLinkContext.Provider value>
93
+ <a {...props} className={className}>{children}</a>
94
+ </MarkdownLinkContext.Provider>
95
+ );
96
+ }
97
+ const Icon = LINK_CHIP_ICONS[chip.kind];
57
98
  return (
58
99
  <MarkdownLinkContext.Provider value>
59
- <a {...props} />
100
+ <a
101
+ {...props}
102
+ title={chip.title ?? props.title}
103
+ data-link-chip={chip.kind}
104
+ className={cn(
105
+ "not-prose inline-flex max-w-full items-center gap-1.5 rounded-md border border-border bg-muted/60 px-2 py-0.5 align-middle text-[0.9375em] font-medium leading-6 text-foreground no-underline transition-colors hover:border-[var(--border-strong)] hover:bg-muted",
106
+ focusRing,
107
+ className,
108
+ )}
109
+ >
110
+ <Icon aria-hidden className="size-4 shrink-0 text-muted-foreground" />
111
+ <span className="min-w-0 truncate">{chip.label ?? children}</span>
112
+ </a>
60
113
  </MarkdownLinkContext.Provider>
61
114
  );
62
115
  }
63
116
 
117
+ // Fenced code arrives as <pre><code>; inline code is a bare <code>.
118
+ const PreContext = createContext(false);
119
+
120
+ function MarkdownPre({ children }: ComponentProps<"pre"> & ExtraProps) {
121
+ return <PreContext.Provider value>{children}</PreContext.Provider>;
122
+ }
123
+
124
+ function MarkdownCode({ node, className: codeClass, children: codeChildren, ...rest }: ComponentProps<"code"> & ExtraProps) {
125
+ const fenced = useContext(PreContext);
126
+ const code = String(codeChildren).replace(/\n$/, "");
127
+ if (!fenced) {
128
+ const { "data-meta": _meta, ...attributes } = rest as typeof rest & { "data-meta"?: unknown };
129
+ return (
130
+ <code
131
+ className={cn(
132
+ "px-1.5 py-0.5 rounded border border-border bg-muted text-foreground text-[0.85em] font-mono",
133
+ codeClass,
134
+ )}
135
+ {...attributes}
136
+ >
137
+ {codeChildren}
138
+ </code>
139
+ );
140
+ }
141
+ const { language, filename } = fenceInfo(codeClass, node?.properties?.dataMeta);
142
+ return (
143
+ <CodeBlock code={code} language={language} filename={filename} className="my-4">
144
+ <CopyButton text={code} />
145
+ </CodeBlock>
146
+ );
147
+ }
148
+
149
+ // The default schema plus a fence's info string on <code>, which names its file.
150
+ const sanitizeSchema = {
151
+ ...defaultSchema,
152
+ attributes: {
153
+ ...defaultSchema.attributes,
154
+ code: [...(defaultSchema.attributes?.code ?? []), "dataMeta"],
155
+ },
156
+ };
157
+
64
158
  /**
65
159
  * Renders a Markdown image that opens full size in a new tab, or a labelled
66
160
  * placeholder when its URL is unsafe or fails to load, instead of the
@@ -131,58 +225,42 @@ export interface MarkdownProps {
131
225
  className?: string;
132
226
  /** Transform parsed link and image URLs while retaining protocol safety checks. */
133
227
  urlTransform?: UrlTransform;
228
+ /** Render links to known objects (a file, an asset) as chips that keep their href. */
229
+ linkChip?: MarkdownLinkChipResolver;
134
230
  }
135
231
 
136
232
  /**
137
- * Renders Markdown content with GFM support, XSS sanitisation, custom code
138
- * block rendering via our CodeBlock component, and images that open full size
139
- * or show a labelled placeholder when unavailable.
233
+ * Renders Markdown content with GFM support and XSS sanitisation: Shiki code
234
+ * blocks that name their file, tables as cards with right-aligned figures and
235
+ * a CSV copy, images that open full size or show a labelled placeholder, and
236
+ * optional chips for links to known objects.
140
237
  */
141
- export const Markdown = memo(({ children, className, urlTransform }: MarkdownProps) => {
238
+ export const Markdown = memo(({ children, className, urlTransform, linkChip }: MarkdownProps) => {
142
239
  return (
143
240
  <div
144
241
  className={cn("tangle-prose max-w-none text-sm", className)}
145
242
  >
146
- <ReactMarkdown
147
- remarkPlugins={[remarkGfm]}
148
- rehypePlugins={[rehypeSlug, rehypeSanitize]}
149
- urlTransform={(url, key, node) => transformMarkdownUrl(url, key, node, urlTransform)}
150
- components={{
151
- a: MarkdownLink,
152
- img: MarkdownImage,
153
- pre({ children: preChildren }) {
154
- return <>{preChildren}</>;
155
- },
156
- code({ className: codeClass, children: codeChildren, ...rest }) {
157
- const match = /language-(\w+)/.exec(codeClass || "");
158
- const language = match?.[1];
159
- const code = String(codeChildren).replace(/\n$/, "");
160
-
161
- // Inline code (no language fence)
162
- if (!language && !code.includes("\n")) {
163
- return (
164
- <code
165
- className={cn(
166
- "px-1.5 py-0.5 rounded border border-border bg-muted text-foreground text-[0.85em] font-mono",
167
- codeClass,
168
- )}
169
- {...rest}
170
- >
171
- {codeChildren}
172
- </code>
173
- );
174
- }
175
-
176
- return (
177
- <CodeBlock code={code} language={language}>
178
- <CopyButton text={code} />
179
- </CodeBlock>
180
- );
181
- },
182
- }}
183
- >
184
- {children}
185
- </ReactMarkdown>
243
+ <LinkChipContext.Provider value={linkChip}>
244
+ <ReactMarkdown
245
+ remarkPlugins={[remarkGfm, remarkCodeMeta]}
246
+ rehypePlugins={[rehypeSlug, [rehypeSanitize, sanitizeSchema]]}
247
+ urlTransform={(url, key, node) => transformMarkdownUrl(url, key, node, urlTransform)}
248
+ components={{
249
+ a: MarkdownLink,
250
+ img: MarkdownImage,
251
+ pre: MarkdownPre,
252
+ code: MarkdownCode,
253
+ table: MarkdownTable,
254
+ thead: MarkdownTableHead,
255
+ tbody: MarkdownTableBody,
256
+ tr: MarkdownTableRow,
257
+ th: MarkdownTableHeaderCell,
258
+ td: MarkdownTableCell,
259
+ }}
260
+ >
261
+ {children}
262
+ </ReactMarkdown>
263
+ </LinkChipContext.Provider>
186
264
  </div>
187
265
  );
188
266
  });
@@ -24,11 +24,10 @@ describe("Toolbar", () => {
24
24
  expect(row.children).toHaveLength(1);
25
25
  });
26
26
 
27
- // Search and filters both flex, on purpose: giving the filter row its content
28
- // width starves search — a 537px filter row in an 832px toolbar collapses the
29
- // search field to 89px. Sharing bounds the filter row so it scrolls within its
30
- // half and search stays usable. Only `actions` sits at content width.
31
- it("lets search and filters share the row, holding actions at content width", () => {
27
+ // Filters keep their content width while it fits and shrink (scrolling) only
28
+ // once search reaches its 16rem floor; an even split clipped a third filter at
29
+ // 1440px, and no floor left search 89px wide in an 832px toolbar.
30
+ it("keeps filters at content width above a search floor, actions at content width", () => {
32
31
  const { container } = render(
33
32
  <Toolbar
34
33
  actions={<button type="button">Export</button>}
@@ -39,11 +38,11 @@ describe("Toolbar", () => {
39
38
  const [searchSlot, filterSlot, actionsSlot] = Array.from(
40
39
  (container.firstElementChild as HTMLElement).children,
41
40
  ) as HTMLElement[];
42
- expect(searchSlot.className).toContain("lg:flex-1");
43
- expect(searchSlot.className).toContain("min-w-0");
44
- // Both flex, so neither can starve the other.
45
- expect(filterSlot.className).toContain("lg:flex-1");
46
- expect(filterSlot.className).toContain("min-w-0");
41
+ const search = searchSlot.className.split(" ");
42
+ const filters = filterSlot.className.split(" ");
43
+ expect(search).toEqual(expect.arrayContaining(["lg:flex-1", "lg:min-w-64", "lg:max-w-sm", "min-w-0"]));
44
+ expect(filters).toEqual(expect.arrayContaining(["lg:flex-initial", "min-w-0", "lg:overflow-x-auto"]));
45
+ expect(filters).not.toContain("lg:flex-1");
47
46
  expect(actionsSlot.className).toContain("shrink-0");
48
47
  expect(actionsSlot.className).not.toContain("flex-1");
49
48
  });
@@ -11,22 +11,22 @@ import { cn } from "../lib/utils";
11
11
  * own visual language, so one row carried a native `<select>`, a custom input
12
12
  * and a segmented control side by side.
13
13
  *
14
- * So search and filters SHARE the free space and `actions` stays at its content
15
- * width, and the row is the one place those controls are composed, which is what
16
- * keeps them looking like one set.
14
+ * So on one `lg` line the filters keep their content width while it fits,
15
+ * search takes what is left between a 16rem floor and `max-w-sm`, and `actions`
16
+ * stays at its content width. The row is the one place those controls are
17
+ * composed, which is what keeps them looking like one set.
17
18
  *
18
- * Sharing is deliberate, and the alternative is worse. Giving the filter row its
19
- * content width starves search: measured with a 537px filter row in an 832px
20
- * toolbar, the search field collapses to 89px. Letting both flex bounds the
21
- * filter row instead, so it scrolls within its half — 313px each in that same
22
- * case — and search stays usable however many filters a page adds. `max-w-sm`
23
- * then stops search claiming more than it can use when there are few filters.
19
+ * Both earlier layouts failed one side. Filters at content width with no floor
20
+ * starved search: a 537px filter row in an 832px toolbar left it 89px. Splitting
21
+ * the row evenly clipped filters that would have fitted: at 1440px the Assets
22
+ * bar cut its third field at the scroll edge while search sat at 384px. With the
23
+ * floor, filters shrink and scroll only once search is down to 16rem.
24
24
  *
25
25
  * Below `lg` the row becomes a column and the filters wrap onto as many lines
26
26
  * as they need. Scrolling them as one line hid every filter past the first
27
27
  * behind the screen edge on a phone: at 390px the second select started at
28
28
  * 403px and a third at 652px, with nothing to say more were there. From `lg`
29
- * up they share one line and scroll within their half, as above.
29
+ * up they sit on one line as above.
30
30
  */
31
31
  /**
32
32
  * Slots only — `children` is omitted deliberately. A free child would render as
@@ -56,14 +56,14 @@ const Toolbar = React.forwardRef<HTMLDivElement, ToolbarProps>(
56
56
  )}
57
57
  {...props}
58
58
  >
59
- {search && <div className="min-w-0 lg:max-w-sm lg:flex-1">{search}</div>}
59
+ {search && <div className="min-w-0 lg:min-w-64 lg:max-w-sm lg:flex-1">{search}</div>}
60
60
  {filters && (
61
61
  // `py-1` leaves room for a focus ring above and below; on the single
62
62
  // `lg` line the scroll container clips it horizontally, which is the
63
63
  // trade a one-line filter row makes. No negative margin — pulling the
64
64
  // row wider than its parent is what made every page report 4px of
65
65
  // horizontal overflow.
66
- <div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2 py-1 lg:flex-1 lg:flex-nowrap lg:gap-2 lg:overflow-x-auto">
66
+ <div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2 py-1 lg:flex-initial lg:flex-nowrap lg:gap-2 lg:overflow-x-auto">
67
67
  {filters}
68
68
  </div>
69
69
  )}
@@ -0,0 +1,41 @@
1
+ import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { ApprovalCard } from "./approval-card";
4
+
5
+ afterEach(cleanup);
6
+
7
+ describe("ApprovalCard", () => {
8
+ it("shows the plain-language action, its step, the plan, the diff and the raw input behind Details", () => {
9
+ render(
10
+ <ApprovalCard
11
+ title="Create a commit for src/routes/_index.tsx in tangle-network/gtm-agent"
12
+ context="GitHub · tangle-network/gtm-agent"
13
+ step={{ index: 1, total: 4 }}
14
+ plan={[
15
+ { label: "Write the change", state: "current" },
16
+ { label: "Commit it", state: "upcoming" },
17
+ ]}
18
+ diff={[{ path: "src/routes/_index.tsx", change: "modified", additions: 3, deletions: 1 }]}
19
+ details={<pre>{'{"tree":[]}'}</pre>}
20
+ actions={<button type="button">Approve all 4 steps</button>}
21
+ />,
22
+ );
23
+ const card = screen.getByRole("group", { name: "Approval request" });
24
+ expect(within(card).getByRole("heading").textContent).toContain("Create a commit for src/routes/_index.tsx");
25
+ expect(within(card).getByText("Step 1 of 4")).toBeInTheDocument();
26
+ expect(within(card).getByRole("list", { name: "Plan" }).querySelector('[aria-current="step"]')?.textContent).toBe("Write the change");
27
+ expect(within(card).getByText("1 file")).toBeInTheDocument();
28
+ expect(within(card).getAllByText("+3")).toHaveLength(2);
29
+ const details = within(card).getByText("Details").closest("details")!;
30
+ expect(details.open).toBe(false);
31
+ fireEvent.click(within(card).getByText("Details"));
32
+ expect(details.open).toBe(true);
33
+ expect(within(card).getByRole("button", { name: "Approve all 4 steps" })).toBeInTheDocument();
34
+ });
35
+
36
+ it("marks unknown line counts instead of guessing zero", () => {
37
+ render(<ApprovalCard title="Write" diff={[{ path: "a.ts", additions: 2 }]} diffNote="Counting lines…" />);
38
+ expect(screen.getByText("Counting lines…")).toBeInTheDocument();
39
+ expect(screen.getByText("−–")).toBeInTheDocument();
40
+ });
41
+ });