@tangle-network/ui 11.2.4 → 11.4.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.
@@ -0,0 +1,57 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { PageHeader } from "./page-header";
4
+
5
+ describe("PageHeader", () => {
6
+ it("renders the title as the page's only h1", () => {
7
+ render(<PageHeader title="Workflows" />);
8
+ const headings = screen.getAllByRole("heading", { level: 1 });
9
+ expect(headings).toHaveLength(1);
10
+ expect(headings[0]).toHaveTextContent("Workflows");
11
+ });
12
+
13
+ it("steps the title down to h2 for a nested surface", () => {
14
+ render(<PageHeader level={2} title="Skills" />);
15
+ expect(screen.queryByRole("heading", { level: 1 })).toBeNull();
16
+ expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent(
17
+ "Skills",
18
+ );
19
+ });
20
+
21
+ it("puts the id on the heading so a region can point aria-labelledby at it", () => {
22
+ render(<PageHeader titleId="keys-title" title="API keys" />);
23
+ expect(screen.getByRole("heading", { level: 1 })).toHaveAttribute(
24
+ "id",
25
+ "keys-title",
26
+ );
27
+ });
28
+
29
+ // The description is prose ABOUT the page, so it must not land in the
30
+ // heading — a paragraph rendered where a heading goes reads as body copy to a
31
+ // sighted reader and as the page's accessible name to a screen reader.
32
+ it("keeps the description out of the heading", () => {
33
+ render(<PageHeader title="Billing" description="Spend and invoices." />);
34
+ const heading = screen.getByRole("heading", { level: 1 });
35
+ expect(heading).toHaveTextContent("Billing");
36
+ expect(heading).not.toHaveTextContent("Spend and invoices.");
37
+ expect(screen.getByText("Spend and invoices.")).toBeInTheDocument();
38
+ });
39
+
40
+ it("renders the actions and meta slots when given", () => {
41
+ render(
42
+ <PageHeader
43
+ title="Teams"
44
+ actions={<button type="button">Invite</button>}
45
+ meta={<span>4 members</span>}
46
+ />,
47
+ );
48
+ expect(screen.getByRole("button", { name: "Invite" })).toBeInTheDocument();
49
+ expect(screen.getByText("4 members")).toBeInTheDocument();
50
+ });
51
+
52
+ it("omits the optional slots entirely when not given", () => {
53
+ const { container } = render(<PageHeader title="Alerts" />);
54
+ expect(container.querySelector("p")).toBeNull();
55
+ expect(screen.queryByRole("button")).toBeNull();
56
+ });
57
+ });
@@ -0,0 +1,86 @@
1
+ import * as React from "react";
2
+ import { cn } from "../lib/utils";
3
+
4
+ /**
5
+ * The one masthead every console page opens with.
6
+ *
7
+ * A page identifies itself in three separate registers, and each gets its own
8
+ * slot here so they cannot compete for the same one:
9
+ *
10
+ * - `title` — what the page IS. The `<h1>`, and the only `<h1>`.
11
+ * - `description` — one line on what it is for. Prose belongs here, never in
12
+ * the title slot: a paragraph rendered where a heading goes reads as body
13
+ * copy to a sighted reader and as nothing at all to a screen reader, which
14
+ * is how a page ends up with no accessible name.
15
+ * - `actions` — what the reader can DO from here, right-aligned and wrapping
16
+ * under the title on a narrow viewport rather than squeezing it.
17
+ *
18
+ * `meta` is the fourth register and the one most often missing: the small facts
19
+ * that qualify everything below (a count, a scope, a window). They sit on their
20
+ * own line under the title so a reader can tell the difference between "this
21
+ * page has nothing in it" and "this filter matches nothing".
22
+ *
23
+ * `titleId` is exposed so a page can point `aria-labelledby` at the heading
24
+ * from a region further down without minting a second copy of the string.
25
+ */
26
+ export interface PageHeaderProps
27
+ extends Omit<React.HTMLAttributes<HTMLElement>, "title"> {
28
+ title: React.ReactNode;
29
+ description?: React.ReactNode;
30
+ actions?: React.ReactNode;
31
+ meta?: React.ReactNode;
32
+ titleId?: string;
33
+ /** Renders the title one step down for a nested/tab surface. */
34
+ level?: 1 | 2;
35
+ }
36
+
37
+ const PageHeader = React.forwardRef<HTMLElement, PageHeaderProps>(
38
+ (
39
+ {
40
+ className,
41
+ title,
42
+ description,
43
+ actions,
44
+ meta,
45
+ titleId,
46
+ level = 1,
47
+ ...props
48
+ },
49
+ ref,
50
+ ) => {
51
+ const Heading = level === 1 ? "h1" : "h2";
52
+ return (
53
+ <header ref={ref} className={cn("mb-6", className)} {...props}>
54
+ <div className="flex flex-wrap items-start justify-between gap-x-6 gap-y-3">
55
+ <div className="min-w-0">
56
+ <Heading
57
+ id={titleId}
58
+ className={cn(
59
+ "text-balance font-semibold tracking-tight",
60
+ level === 1 ? "text-2xl" : "text-lg",
61
+ )}
62
+ >
63
+ {title}
64
+ </Heading>
65
+ {description && (
66
+ <p className="mt-1 max-w-prose text-muted-foreground text-sm">
67
+ {description}
68
+ </p>
69
+ )}
70
+ </div>
71
+ {actions && (
72
+ <div className="flex flex-wrap items-center gap-2">{actions}</div>
73
+ )}
74
+ </div>
75
+ {meta && (
76
+ <div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 text-[var(--text-dim)] text-xs">
77
+ {meta}
78
+ </div>
79
+ )}
80
+ </header>
81
+ );
82
+ },
83
+ );
84
+ PageHeader.displayName = "PageHeader";
85
+
86
+ export { PageHeader };
@@ -0,0 +1,104 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { StatusPill, type StatusTone } from "./status-pill";
4
+
5
+ const TONES: StatusTone[] = [
6
+ "success",
7
+ "warning",
8
+ "danger",
9
+ "info",
10
+ "running",
11
+ "neutral",
12
+ ];
13
+
14
+ describe("StatusPill", () => {
15
+ it("states the status as text, not colour alone", () => {
16
+ render(<StatusPill tone="danger">Failed</StatusPill>);
17
+ expect(screen.getByText("Failed")).toBeInTheDocument();
18
+ });
19
+
20
+ // The glyph is the channel that survives greyscale and colour blindness, so
21
+ // every tone must carry one and it must be hidden from the accessibility tree
22
+ // — the label beside it already says the same thing.
23
+ it.each(TONES)("gives %s a decorative glyph", (tone) => {
24
+ const { container } = render(<StatusPill tone={tone}>Status</StatusPill>);
25
+ const glyph = container.querySelector("svg");
26
+ expect(glyph).not.toBeNull();
27
+ expect(glyph).toHaveAttribute("aria-hidden", "true");
28
+ });
29
+
30
+ it.each(TONES)("brings %s's own paired background", (tone) => {
31
+ const { container } = render(<StatusPill tone={tone}>Status</StatusPill>);
32
+ const pill = container.firstElementChild as HTMLElement;
33
+ expect(pill.className).toContain(`bg-[var(--surface-`);
34
+ expect(pill.className).toContain("border");
35
+ });
36
+
37
+ // A tone colour is solved against its own background. `bare` drops that
38
+ // background, so the tone may ride on the glyph only — lifting it onto the
39
+ // label would put it on whatever plane the caller happens to be on.
40
+ it.each(TONES)("keeps %s's tone off the label in bare mode", (tone) => {
41
+ const { container } = render(
42
+ <StatusPill bare tone={tone}>
43
+ Status
44
+ </StatusPill>,
45
+ );
46
+ const pill = container.firstElementChild as HTMLElement;
47
+ expect(pill.className).toContain("bg-transparent");
48
+ expect(pill.className).not.toContain("bg-[var(--surface-");
49
+
50
+ const glyphWrapper = container.querySelector("svg")
51
+ ?.parentElement as HTMLElement;
52
+ expect(glyphWrapper.className).toContain("text-[var(--surface-");
53
+ });
54
+
55
+ // Guards the reason TONE_TEXT is declared rather than recovered by searching
56
+ // TONE_SURFACE for a `text-` class: a miss there yields undefined and removes
57
+ // the tone from the only channel bare mode has left.
58
+ it.each(TONES)("resolves a real text class for %s in bare mode", (tone) => {
59
+ const { container } = render(
60
+ <StatusPill bare tone={tone}>
61
+ Status
62
+ </StatusPill>,
63
+ );
64
+ const glyphWrapper = container.querySelector("svg")
65
+ ?.parentElement as HTMLElement;
66
+ expect(glyphWrapper.className).not.toContain("undefined");
67
+ expect(glyphWrapper.className).toMatch(/text-\[var\(--surface-[a-z]+-text\)\]/);
68
+ });
69
+
70
+ // `px-2` from the size and `px-0` from bare are the same utility group, so
71
+ // which one lands is decided by the class merge rather than by reading order.
72
+ // Asserting the resolved class list keeps that a guarantee: a bare pill has to
73
+ // sit flush against the text beside it, and silently keeping the size padding
74
+ // would space it like a chip that lost only its background.
75
+ it.each(["sm", "md"] as const)("drops the %s size padding in bare mode", (size) => {
76
+ const { container } = render(
77
+ <StatusPill bare size={size} tone="info">
78
+ Queued
79
+ </StatusPill>,
80
+ );
81
+ const cls = (container.firstElementChild as HTMLElement).className;
82
+ expect(cls).toContain("px-0");
83
+ expect(cls).not.toMatch(/(^| )px-2(\.5)?( |$)/);
84
+ });
85
+
86
+ it("gives no two tones the same silhouette", () => {
87
+ const shapes = TONES.map((tone) => {
88
+ const { container } = render(<StatusPill tone={tone}>S</StatusPill>);
89
+ return container.querySelector("svg")?.innerHTML ?? "";
90
+ });
91
+ expect(new Set(shapes).size).toBe(shapes.length);
92
+ });
93
+
94
+ it("takes a larger size", () => {
95
+ const { container } = render(
96
+ <StatusPill size="md" tone="info">
97
+ Queued
98
+ </StatusPill>,
99
+ );
100
+ expect((container.firstElementChild as HTMLElement).className).toContain(
101
+ "text-sm",
102
+ );
103
+ });
104
+ });
@@ -0,0 +1,193 @@
1
+ import * as React from "react";
2
+ import { cn } from "../lib/utils";
3
+
4
+ /**
5
+ * A run/resource state, told three ways at once.
6
+ *
7
+ * Status is the one place a console cannot afford to speak in colour alone: a
8
+ * red dot and a green dot are the same dot to roughly one man in twelve, and
9
+ * identical in a greyscale print or a screenshot pasted into a ticket. So every
10
+ * pill carries all three channels — a GLYPH whose silhouette differs per tone,
11
+ * the tone's COLOUR, and the state's own LABEL as text.
12
+ *
13
+ * The glyphs are chosen to survive at 8px and to differ in outline rather than
14
+ * in fill: a ring reads as "still open", a solid disc as "settled", a slashed
15
+ * disc as "stopped". Two states never share one silhouette.
16
+ *
17
+ * Each tone draws its fill, border and text from ONE matched token triple.
18
+ * That pairing is the whole point: a status colour is solved against its own
19
+ * background, and nothing guarantees it against an arbitrary one. A component
20
+ * that always brings its own background cannot be placed onto a plane that
21
+ * breaks it.
22
+ */
23
+ export type StatusTone =
24
+ | "success"
25
+ | "warning"
26
+ | "danger"
27
+ | "info"
28
+ | "neutral"
29
+ | "running";
30
+
31
+ export interface StatusPillProps
32
+ extends Omit<React.HTMLAttributes<HTMLSpanElement>, "children"> {
33
+ tone: StatusTone;
34
+ children: React.ReactNode;
35
+ /**
36
+ * Drops the fill and border, leaving a toned GLYPH beside a label in the
37
+ * inherited body colour.
38
+ *
39
+ * The label deliberately does not keep the tone. Every status text token
40
+ * clears the 4.5:1 body floor on the page canvas — a gate holds them there —
41
+ * but in light the margin is thin (4.51:1 for warning, 4.54:1 for success),
42
+ * and the canvas is only one of the planes a caller can put a pill on. The
43
+ * glyph keeps the tone instead because a glyph is non-text content against a
44
+ * 3:1 floor, so the tone still reads with room to spare on any plane.
45
+ *
46
+ * For a control that supplies its own surface (a chip, a selected row).
47
+ */
48
+ bare?: boolean;
49
+ size?: "sm" | "md";
50
+ }
51
+
52
+ const TONE_SURFACE: Record<StatusTone, string> = {
53
+ success:
54
+ "bg-[var(--surface-success-bg)] text-[var(--surface-success-text)] border-[var(--surface-success-border)]",
55
+ warning:
56
+ "bg-[var(--surface-warning-bg)] text-[var(--surface-warning-text)] border-[var(--surface-warning-border)]",
57
+ danger:
58
+ "bg-[var(--surface-danger-bg)] text-[var(--surface-danger-text)] border-[var(--surface-danger-border)]",
59
+ info: "bg-[var(--surface-info-bg)] text-[var(--surface-info-text)] border-[var(--surface-info-border)]",
60
+ // `running` shares the info triple on purpose. `--status-running` exists but
61
+ // is the green dot on a sandbox — a machine's power state, not a run's
62
+ // outcome — and it ships as one colour with no paired background or text
63
+ // tier, which is the pairing this component is built to guarantee. Green also
64
+ // already means SUCCEEDED here, so a green "running" pill would say a run had
65
+ // finished well while it was still going.
66
+ running:
67
+ "bg-[var(--surface-info-bg)] text-[var(--surface-info-text)] border-[var(--surface-info-border)]",
68
+ neutral:
69
+ "bg-[var(--surface-neutral-bg)] text-[var(--surface-neutral-text)] border-[var(--surface-neutral-border)]",
70
+ };
71
+
72
+ /**
73
+ * The text tier alone, for `bare`. Declared rather than recovered from
74
+ * `TONE_SURFACE` by string search: picking the `text-` class out of that string
75
+ * silently yields `undefined` the moment the triple gains a second `text-`
76
+ * utility, and an undefined class removes the tone from the one channel `bare`
77
+ * has left — the glyph — with nothing to signal that it happened.
78
+ */
79
+ const TONE_TEXT: Record<StatusTone, string> = {
80
+ success: "text-[var(--surface-success-text)]",
81
+ warning: "text-[var(--surface-warning-text)]",
82
+ danger: "text-[var(--surface-danger-text)]",
83
+ info: "text-[var(--surface-info-text)]",
84
+ running: "text-[var(--surface-info-text)]",
85
+ neutral: "text-[var(--surface-neutral-text)]",
86
+ };
87
+
88
+ /**
89
+ * One glyph per silhouette. `currentColor` throughout so the mark inherits the
90
+ * tone's text colour and can never drift from the label beside it.
91
+ */
92
+ function ToneGlyph({ tone }: { tone: StatusTone }) {
93
+ // `aria-hidden` is written on every `<svg>` rather than carried in this
94
+ // spread: the label beside the glyph already names the state, so a title here
95
+ // would announce it twice — and a lint rule that reads JSX statically cannot
96
+ // see the attribute through a spread, so it reports each mark as an unlabelled
97
+ // image.
98
+ const common = {
99
+ viewBox: "0 0 8 8",
100
+ className: "size-2 shrink-0",
101
+ };
102
+ switch (tone) {
103
+ // Settled and good: a solid disc.
104
+ case "success":
105
+ return (
106
+ <svg {...common} aria-hidden="true" fill="currentColor">
107
+ <circle cx="4" cy="4" r="3.25" />
108
+ </svg>
109
+ );
110
+ // Settled and bad: a disc with a bar through it, so it differs from
111
+ // success in OUTLINE and not only in hue.
112
+ case "danger":
113
+ return (
114
+ <svg
115
+ {...common}
116
+ aria-hidden="true"
117
+ fill="none"
118
+ stroke="currentColor"
119
+ strokeWidth="1.5"
120
+ >
121
+ <circle cx="4" cy="4" r="3.1" fill="currentColor" opacity="0.35" />
122
+ <path d="M1.9 6.1 6.1 1.9" strokeLinecap="round" />
123
+ </svg>
124
+ );
125
+ // Needs a person: a triangle.
126
+ case "warning":
127
+ return (
128
+ <svg {...common} aria-hidden="true" fill="currentColor">
129
+ <path d="M4 0.6 7.7 7.1H0.3z" />
130
+ </svg>
131
+ );
132
+ // Still moving: an open ring, visibly hollow at 8px.
133
+ case "running":
134
+ return (
135
+ <svg
136
+ {...common}
137
+ aria-hidden="true"
138
+ fill="none"
139
+ stroke="currentColor"
140
+ strokeWidth="1.75"
141
+ >
142
+ <circle cx="4" cy="4" r="2.9" />
143
+ </svg>
144
+ );
145
+ // Informational: a square, the only right-angled mark in the set.
146
+ case "info":
147
+ return (
148
+ <svg {...common} aria-hidden="true" fill="currentColor">
149
+ <rect x="0.9" y="0.9" width="6.2" height="6.2" rx="1.2" />
150
+ </svg>
151
+ );
152
+ // Nothing has happened: a dash. No enclosed area at all.
153
+ default:
154
+ return (
155
+ <svg
156
+ {...common}
157
+ aria-hidden="true"
158
+ fill="none"
159
+ stroke="currentColor"
160
+ strokeWidth="1.75"
161
+ >
162
+ <path d="M1 4h6" strokeLinecap="round" />
163
+ </svg>
164
+ );
165
+ }
166
+ }
167
+
168
+ const StatusPill = React.forwardRef<HTMLSpanElement, StatusPillProps>(
169
+ ({ className, tone, children, bare = false, size = "sm", ...props }, ref) => (
170
+ <span
171
+ ref={ref}
172
+ className={cn(
173
+ "inline-flex items-center gap-1.5 whitespace-nowrap rounded-full font-medium",
174
+ size === "sm" ? "px-2 py-0.5 text-xs" : "px-2.5 py-1 text-sm",
175
+ bare
176
+ ? "border border-transparent bg-transparent px-0"
177
+ : cn("border", TONE_SURFACE[tone]),
178
+ className,
179
+ )}
180
+ {...props}
181
+ >
182
+ {/* In `bare` mode the tone rides on the glyph alone, so it is scoped to
183
+ the wrapper the glyph sits in rather than applied to the whole pill. */}
184
+ <span className={cn("inline-flex", bare && TONE_TEXT[tone])}>
185
+ <ToneGlyph tone={tone} />
186
+ </span>
187
+ {children}
188
+ </span>
189
+ ),
190
+ );
191
+ StatusPill.displayName = "StatusPill";
192
+
193
+ export { StatusPill };
@@ -0,0 +1,89 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { FilterField, Toolbar } from "./toolbar";
4
+
5
+ describe("Toolbar", () => {
6
+ it("renders each slot it is given", () => {
7
+ render(
8
+ <Toolbar
9
+ actions={<button type="button">Export</button>}
10
+ filters={<span>Product</span>}
11
+ search={<input aria-label="Search" type="search" />}
12
+ />,
13
+ );
14
+ expect(screen.getByLabelText("Search")).toBeInTheDocument();
15
+ expect(screen.getByText("Product")).toBeInTheDocument();
16
+ expect(screen.getByRole("button", { name: "Export" })).toBeInTheDocument();
17
+ });
18
+
19
+ it("omits a slot's wrapper entirely when it is not given", () => {
20
+ const { container } = render(
21
+ <Toolbar search={<input aria-label="Search" type="search" />} />,
22
+ );
23
+ const row = container.firstElementChild as HTMLElement;
24
+ expect(row.children).toHaveLength(1);
25
+ });
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", () => {
32
+ const { container } = render(
33
+ <Toolbar
34
+ actions={<button type="button">Export</button>}
35
+ filters={<span>Product</span>}
36
+ search={<input aria-label="Search" type="search" />}
37
+ />,
38
+ );
39
+ const [searchSlot, filterSlot, actionsSlot] = Array.from(
40
+ (container.firstElementChild as HTMLElement).children,
41
+ ) 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");
47
+ expect(actionsSlot.className).toContain("shrink-0");
48
+ expect(actionsSlot.className).not.toContain("flex-1");
49
+ });
50
+
51
+ it("scrolls the filter row instead of wrapping it", () => {
52
+ const { container } = render(<Toolbar filters={<span>Product</span>} />);
53
+ const filterSlot = (container.firstElementChild as HTMLElement)
54
+ .children[0] as HTMLElement;
55
+ expect(filterSlot.className).toContain("overflow-x-auto");
56
+ expect(filterSlot.className).toContain("min-w-0");
57
+ });
58
+
59
+ // A free child would render as a bare flex item with none of the slots'
60
+ // guards, so it would size off its content and push the row into overflow.
61
+ it("renders nothing for a stray child", () => {
62
+ const { container } = render(
63
+ // @ts-expect-error children is deliberately not part of the API
64
+ <Toolbar>
65
+ <span>stray</span>
66
+ </Toolbar>,
67
+ );
68
+ expect(screen.queryByText("stray")).toBeNull();
69
+ expect((container.firstElementChild as HTMLElement).children).toHaveLength(
70
+ 0,
71
+ );
72
+ });
73
+ });
74
+
75
+ describe("FilterField", () => {
76
+ it("labels the control visibly rather than by placeholder", () => {
77
+ render(
78
+ <FilterField htmlFor="product" label="Product">
79
+ <select id="product">
80
+ <option>All products</option>
81
+ </select>
82
+ </FilterField>,
83
+ );
84
+ expect(screen.getByText("Product")).toBeInTheDocument();
85
+ expect(screen.getByLabelText("Product")).toBe(
86
+ screen.getByRole("combobox"),
87
+ );
88
+ });
89
+ });
@@ -0,0 +1,105 @@
1
+ import * as React from "react";
2
+ import { cn } from "../lib/utils";
3
+
4
+ /**
5
+ * The control row above a table or list: search, filters, then the view switch.
6
+ *
7
+ * It exists because the alternative keeps producing the same two failures. A
8
+ * bare flex row gives every control its natural width, so a search field sits
9
+ * at 1120px on one line and three selects stack full-width beneath it — three
10
+ * enormous empty bars where a compact bar belongs. And each control brought its
11
+ * own visual language, so one row carried a native `<select>`, a custom input
12
+ * and a segmented control side by side.
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.
17
+ *
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.
24
+ *
25
+ * On a narrow viewport the row becomes a column and the filters scroll
26
+ * horizontally as a group rather than wrapping into a tall stack that pushes
27
+ * the table itself below the fold.
28
+ */
29
+ /**
30
+ * Slots only — `children` is omitted deliberately. A free child would render as
31
+ * a bare flex item with none of the `min-w-0` / `shrink-0` guards the named
32
+ * slots carry, so it would size itself off its content and push the row into
33
+ * the overflow this layout exists to prevent.
34
+ */
35
+ export interface ToolbarProps
36
+ extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
37
+ /** The one control that expands to fill the row. */
38
+ search?: React.ReactNode;
39
+ /** Filters, in reading order. Kept on one scrollable line when space runs out. */
40
+ filters?: React.ReactNode;
41
+ /** View switches and exports, pinned to the end. */
42
+ actions?: React.ReactNode;
43
+ }
44
+
45
+ const Toolbar = React.forwardRef<HTMLDivElement, ToolbarProps>(
46
+ ({ className, search, filters, actions, ...props }, ref) => (
47
+ <div
48
+ ref={ref}
49
+ className={cn(
50
+ "mb-4 flex flex-col gap-3 lg:flex-row lg:items-center",
51
+ className,
52
+ )}
53
+ {...props}
54
+ >
55
+ {search && <div className="min-w-0 lg:max-w-sm lg:flex-1">{search}</div>}
56
+ {filters && (
57
+ // `py-1` leaves room for a focus ring above and below; horizontally the
58
+ // scroll container clips it, which is the trade a single-line filter row
59
+ // makes. No negative margin — pulling the row wider than its parent is
60
+ // what made every page report 4px of horizontal overflow.
61
+ <div className="flex min-w-0 items-center gap-2 overflow-x-auto py-1 lg:flex-1">
62
+ {filters}
63
+ </div>
64
+ )}
65
+ {actions && (
66
+ <div className="flex shrink-0 items-center gap-2 lg:ml-auto">
67
+ {actions}
68
+ </div>
69
+ )}
70
+ </div>
71
+ ),
72
+ );
73
+ Toolbar.displayName = "Toolbar";
74
+
75
+ /**
76
+ * A labelled filter control. The label is visible, not a placeholder: a select
77
+ * whose current value IS its label ("All products") tells the reader what is
78
+ * selected but never what the control governs, so a row of three reads as three
79
+ * unrelated words.
80
+ */
81
+ export interface FilterFieldProps extends React.HTMLAttributes<HTMLDivElement> {
82
+ label: string;
83
+ htmlFor?: string;
84
+ }
85
+
86
+ const FilterField = React.forwardRef<HTMLDivElement, FilterFieldProps>(
87
+ ({ className, label, htmlFor, children, ...props }, ref) => (
88
+ <div
89
+ ref={ref}
90
+ className={cn("flex shrink-0 items-center gap-2", className)}
91
+ {...props}
92
+ >
93
+ <label
94
+ htmlFor={htmlFor}
95
+ className="whitespace-nowrap text-muted-foreground text-xs"
96
+ >
97
+ {label}
98
+ </label>
99
+ {children}
100
+ </div>
101
+ ),
102
+ );
103
+ FilterField.displayName = "FilterField";
104
+
105
+ export { FilterField, Toolbar };
@@ -1,3 +1,15 @@
1
+ /**
2
+ * The client half of agent-app's reversible redaction.
3
+ *
4
+ * `agent-app/redact` builds a document of masked spans with each original kept
5
+ * encrypted, and reveals one span at a time through `revealSpan`, where the
6
+ * authorization and the audit record happen. This module renders that document
7
+ * and calls back for a reveal.
8
+ *
9
+ * The two segment types are deliberately not identical: agent-app's carries the
10
+ * `cipher` for a masked span, and this one does not. The ciphertext stays on the
11
+ * server, so the type a browser holds cannot name it.
12
+ */
1
13
  export {
2
14
  RedactedDocument,
3
15
  type RedactedDocumentProps,
@@ -32,7 +32,10 @@ export function RunRowStatusDot({ status }: { status: RunRowStatus }) {
32
32
  export interface RunRowShellProps {
33
33
  /** Semantic lead glyph — always visible; never replaced by status. */
34
34
  icon: ReactNode;
35
- title: string;
35
+ /** Row title. A string in the common case; a node when the title carries its
36
+ * own treatment (e.g. an active-state shimmer sweep) — it renders inline in
37
+ * the shell's title slot either way. */
38
+ title: ReactNode;
36
39
  /** Secondary inline text (tool path/command, or a reasoning preview). */
37
40
  description?: string;
38
41
  /** Render the description in mono (tools) vs prose (reasoning). */