@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.
- package/CHANGELOG.md +26 -0
- package/dist/{chunk-HSMVDKL3.js → chunk-EJ5NBW7V.js} +275 -0
- package/dist/chunk-HGML45QH.js +104 -0
- package/dist/index.d.ts +3 -43
- package/dist/index.js +16 -98
- package/dist/primitives.d.ts +171 -1
- package/dist/primitives.js +13 -1
- package/dist/redaction.d.ts +42 -0
- package/dist/redaction.js +7 -0
- package/package.json +7 -2
- package/src/primitives/index.ts +12 -0
- package/src/primitives/metric-strip.test.tsx +181 -0
- package/src/primitives/metric-strip.tsx +122 -0
- package/src/primitives/page-header.test.tsx +57 -0
- package/src/primitives/page-header.tsx +86 -0
- package/src/primitives/status-pill.test.tsx +104 -0
- package/src/primitives/status-pill.tsx +193 -0
- package/src/primitives/toolbar.test.tsx +89 -0
- package/src/primitives/toolbar.tsx +105 -0
- package/src/redaction/index.ts +12 -0
- package/src/run/run-row-shell.tsx +4 -1
- /package/src/primitives/{faint-text.test.tsx → faint-text-tokens.test.tsx} +0 -0
|
@@ -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 };
|
package/src/redaction/index.ts
CHANGED
|
@@ -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
|
|
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). */
|
|
File without changes
|