@tangle-network/ui 11.16.10 → 11.17.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 +18 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/primitives/badge.d.ts +7 -0
- package/dist/primitives/badge.js +3 -3
- package/dist/primitives/chip.d.ts +38 -0
- package/dist/primitives/chip.js +41 -0
- package/dist/primitives/icon-tile.d.ts +32 -0
- package/dist/primitives/icon-tile.js +49 -0
- package/dist/primitives/tag.d.ts +36 -0
- package/dist/primitives/tag.js +42 -0
- package/dist/primitives/tone.d.ts +40 -0
- package/dist/primitives/tone.js +106 -0
- package/dist/primitives.d.ts +5 -1
- package/dist/primitives.js +5 -1
- package/dist/tool-previews/command-preview.js +56 -19
- package/package.json +2 -2
- package/src/primitives/badge.tsx +17 -4
- package/src/primitives/chip.test.tsx +178 -0
- package/src/primitives/chip.tsx +108 -0
- package/src/primitives/icon-tile.stories.tsx +74 -0
- package/src/primitives/icon-tile.tsx +96 -0
- package/src/primitives/index.ts +12 -0
- package/src/primitives/tag-chip.stories.tsx +226 -0
- package/src/primitives/tag.tsx +94 -0
- package/src/primitives/tone.ts +130 -0
- package/src/tool-previews/command-preview.test.tsx +18 -0
- package/src/tool-previews/command-preview.tsx +75 -34
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { FileText, Image as ImageIcon, Mic, Video } from "lucide-react";
|
|
3
|
+
import { type ReactNode, useState } from "react";
|
|
4
|
+
import { Button } from "./button";
|
|
5
|
+
import { Chip } from "./chip";
|
|
6
|
+
import { EmptyState } from "./empty-state";
|
|
7
|
+
import { IconTile } from "./icon-tile";
|
|
8
|
+
import { Skeleton } from "./skeleton";
|
|
9
|
+
import { StatusPill, type StatusTone } from "./status-pill";
|
|
10
|
+
import { Tag } from "./tag";
|
|
11
|
+
import type { CategoryTone } from "./tone";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Example data for a content board: the shape GTM's publishing board has. All
|
|
15
|
+
* of it is local to the story; no backend is involved.
|
|
16
|
+
*/
|
|
17
|
+
type Kind = "article" | "image" | "video" | "audio";
|
|
18
|
+
type Post = { id: string; title: string; kind: Kind; campaign: string; status: StatusTone; statusLabel: string };
|
|
19
|
+
|
|
20
|
+
const KINDS: Record<Kind, { label: string; tone: CategoryTone; icon: ReactNode }> = {
|
|
21
|
+
article: { label: "Article", tone: "blue", icon: <FileText /> },
|
|
22
|
+
image: { label: "Image", tone: "pink", icon: <ImageIcon /> },
|
|
23
|
+
video: { label: "Video", tone: "orange", icon: <Video /> },
|
|
24
|
+
audio: { label: "Audio", tone: "teal", icon: <Mic /> },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const POSTS: Post[] = [
|
|
28
|
+
{ id: "1", title: "Launch notes for the October release", kind: "article", campaign: "Launch", status: "success", statusLabel: "Published" },
|
|
29
|
+
{ id: "2", title: "Sandbox cold start, before and after", kind: "video", campaign: "Performance", status: "warning", statusLabel: "Scheduled" },
|
|
30
|
+
{ id: "3", title: "Founder interview: why agents need a runtime that can be inspected end to end by the people who pay for it", kind: "audio", campaign: "Brand", status: "running", statusLabel: "Sending" },
|
|
31
|
+
{ id: "4", title: "Pricing page hero", kind: "image", campaign: "Launch", status: "danger", statusLabel: "Failed" },
|
|
32
|
+
{ id: "5", title: "Draft: weekly changelog", kind: "article", campaign: "Changelog", status: "neutral", statusLabel: "Draft" },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/** Renders the same content on a dark and a light island, side by side. */
|
|
36
|
+
function BothThemes({ children }: { children: ReactNode }) {
|
|
37
|
+
return (
|
|
38
|
+
<div className="grid gap-4 p-4 lg:grid-cols-2">
|
|
39
|
+
{(["dark", "light"] as const).map((theme) => (
|
|
40
|
+
<div
|
|
41
|
+
key={theme}
|
|
42
|
+
data-theme={theme}
|
|
43
|
+
className="min-w-0 rounded-xl border border-[var(--border-subtle)] bg-[var(--bg-root)] p-4 text-[var(--text-primary)]"
|
|
44
|
+
>
|
|
45
|
+
{children}
|
|
46
|
+
</div>
|
|
47
|
+
))}
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function ContentBoard({ initialState = "populated" }: { initialState?: "populated" | "loading" }) {
|
|
53
|
+
const [kinds, setKinds] = useState<Set<Kind>>(new Set());
|
|
54
|
+
const [campaigns, setCampaigns] = useState<string[]>(["Launch"]);
|
|
55
|
+
const [loading, setLoading] = useState(initialState === "loading");
|
|
56
|
+
const visible = POSTS.filter(
|
|
57
|
+
(p) => (kinds.size === 0 || kinds.has(p.kind)) && (campaigns.length === 0 || campaigns.includes(p.campaign)),
|
|
58
|
+
);
|
|
59
|
+
const toggleKind = (kind: Kind, on: boolean) =>
|
|
60
|
+
setKinds((prev) => {
|
|
61
|
+
const next = new Set(prev);
|
|
62
|
+
if (on) next.add(kind);
|
|
63
|
+
else next.delete(kind);
|
|
64
|
+
return next;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<section className="flex min-w-0 flex-col gap-3" aria-label="Content board">
|
|
69
|
+
<div role="group" aria-label="Filter by kind" className="flex flex-wrap gap-1.5">
|
|
70
|
+
{(Object.keys(KINDS) as Kind[]).map((kind) => (
|
|
71
|
+
<Chip
|
|
72
|
+
key={kind}
|
|
73
|
+
tone={KINDS[kind].tone}
|
|
74
|
+
icon={KINDS[kind].icon}
|
|
75
|
+
selected={kinds.has(kind)}
|
|
76
|
+
onSelectedChange={(on) => toggleKind(kind, on)}
|
|
77
|
+
>
|
|
78
|
+
{KINDS[kind].label} · {POSTS.filter((p) => p.kind === kind).length}
|
|
79
|
+
</Chip>
|
|
80
|
+
))}
|
|
81
|
+
</div>
|
|
82
|
+
<div className="flex min-h-6 flex-wrap items-center gap-1.5 text-xs text-[var(--text-muted)]">
|
|
83
|
+
<span>Campaigns:</span>
|
|
84
|
+
{campaigns.length === 0 ? <span>All</span> : null}
|
|
85
|
+
{campaigns.map((c) => (
|
|
86
|
+
<Tag key={c} onRemove={() => setCampaigns((prev) => prev.filter((x) => x !== c))}>
|
|
87
|
+
{c}
|
|
88
|
+
</Tag>
|
|
89
|
+
))}
|
|
90
|
+
{campaigns.length === 0 ? (
|
|
91
|
+
<Chip emphasis="outline" onClick={() => setCampaigns(["Launch"])}>
|
|
92
|
+
Add “Launch”
|
|
93
|
+
</Chip>
|
|
94
|
+
) : null}
|
|
95
|
+
<span className="flex-1" />
|
|
96
|
+
<Chip emphasis="outline" selected={loading} onSelectedChange={setLoading}>
|
|
97
|
+
Simulate loading
|
|
98
|
+
</Chip>
|
|
99
|
+
</div>
|
|
100
|
+
<ul className="flex flex-col divide-y divide-[var(--border-subtle)] rounded-lg border border-[var(--border-subtle)] bg-[var(--bg-card)]">
|
|
101
|
+
{loading
|
|
102
|
+
? [0, 1, 2].map((i) => (
|
|
103
|
+
<li key={i} className="flex items-center gap-3 p-3" aria-hidden="true">
|
|
104
|
+
<Skeleton className="size-8 rounded-md" />
|
|
105
|
+
<Skeleton className="h-4 flex-1" />
|
|
106
|
+
<Skeleton className="h-5 w-16 rounded-full" />
|
|
107
|
+
</li>
|
|
108
|
+
))
|
|
109
|
+
: visible.map((post) => (
|
|
110
|
+
<li key={post.id} className="flex min-w-0 items-center gap-3 p-3">
|
|
111
|
+
<IconTile tone={KINDS[post.kind].tone} icon={KINDS[post.kind].icon} />
|
|
112
|
+
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
113
|
+
<span className="truncate text-sm font-medium" title={post.title}>
|
|
114
|
+
{post.title}
|
|
115
|
+
</span>
|
|
116
|
+
<span className="flex min-w-0 flex-wrap gap-1">
|
|
117
|
+
<Tag tone={KINDS[post.kind].tone} emphasis="outline">
|
|
118
|
+
{KINDS[post.kind].label}
|
|
119
|
+
</Tag>
|
|
120
|
+
<Tag>{post.campaign}</Tag>
|
|
121
|
+
</span>
|
|
122
|
+
</div>
|
|
123
|
+
<StatusPill tone={post.status}>{post.statusLabel}</StatusPill>
|
|
124
|
+
</li>
|
|
125
|
+
))}
|
|
126
|
+
{!loading && visible.length === 0 ? (
|
|
127
|
+
<li className="p-3">
|
|
128
|
+
<EmptyState
|
|
129
|
+
title="No posts match these filters"
|
|
130
|
+
description="Clear a kind or a campaign to see more."
|
|
131
|
+
action={
|
|
132
|
+
<Button
|
|
133
|
+
type="button"
|
|
134
|
+
variant="outline"
|
|
135
|
+
onClick={() => {
|
|
136
|
+
setKinds(new Set());
|
|
137
|
+
setCampaigns([]);
|
|
138
|
+
}}
|
|
139
|
+
>
|
|
140
|
+
Clear filters
|
|
141
|
+
</Button>
|
|
142
|
+
}
|
|
143
|
+
/>
|
|
144
|
+
</li>
|
|
145
|
+
) : null}
|
|
146
|
+
</ul>
|
|
147
|
+
</section>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const meta: Meta = {
|
|
152
|
+
title: "Primitives/Tag and Chip",
|
|
153
|
+
parameters: { layout: "fullscreen" },
|
|
154
|
+
};
|
|
155
|
+
export default meta;
|
|
156
|
+
type Story = StoryObj;
|
|
157
|
+
|
|
158
|
+
/** Filter chips (toggle buttons with a check), removable campaign tags, kind tags, status pills. */
|
|
159
|
+
export const ContentBoardFilters: Story = {
|
|
160
|
+
render: () => (
|
|
161
|
+
<BothThemes>
|
|
162
|
+
<ContentBoard />
|
|
163
|
+
</BothThemes>
|
|
164
|
+
),
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
export const Loading: Story = {
|
|
168
|
+
render: () => (
|
|
169
|
+
<BothThemes>
|
|
170
|
+
<ContentBoard initialState="loading" />
|
|
171
|
+
</BothThemes>
|
|
172
|
+
),
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/** Every categorical tone and neutral, at rest, selected and as an outline, beside the status set. */
|
|
176
|
+
export const ToneSet: Story = {
|
|
177
|
+
render: () => {
|
|
178
|
+
const tones = ["neutral", "violet", "orange", "teal", "blue", "pink", "brown", "cyan", "lime"] as const;
|
|
179
|
+
return (
|
|
180
|
+
<BothThemes>
|
|
181
|
+
<div className="flex flex-col gap-3">
|
|
182
|
+
{tones.map((tone) => (
|
|
183
|
+
<div key={tone} className="flex flex-wrap items-center gap-2">
|
|
184
|
+
<Tag tone={tone}>{tone}</Tag>
|
|
185
|
+
<Tag tone={tone} emphasis="outline">
|
|
186
|
+
outline
|
|
187
|
+
</Tag>
|
|
188
|
+
<Chip tone={tone} selected={false}>
|
|
189
|
+
Off
|
|
190
|
+
</Chip>
|
|
191
|
+
<Chip tone={tone} selected>
|
|
192
|
+
On
|
|
193
|
+
</Chip>
|
|
194
|
+
{tone !== "neutral" ? <IconTile tone={tone} name={`${tone} project`} size="sm" /> : null}
|
|
195
|
+
</div>
|
|
196
|
+
))}
|
|
197
|
+
<div className="flex flex-wrap gap-2 pt-2">
|
|
198
|
+
{(["success", "warning", "danger", "info", "running", "neutral"] as const).map((tone) => (
|
|
199
|
+
<StatusPill key={tone} tone={tone}>
|
|
200
|
+
{tone}
|
|
201
|
+
</StatusPill>
|
|
202
|
+
))}
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
</BothThemes>
|
|
206
|
+
);
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/** Long and awkward labels truncate inside a narrow column instead of overflowing. */
|
|
211
|
+
export const WorstCase: Story = {
|
|
212
|
+
render: () => (
|
|
213
|
+
<BothThemes>
|
|
214
|
+
<div className="flex w-full max-w-[220px] flex-col items-start gap-2">
|
|
215
|
+
<Tag onRemove={() => {}}>supercalifragilistic.address+newsletter@extremely-long-domain.example.com</Tag>
|
|
216
|
+
<Tag tone="teal">Research · Q4 enterprise procurement working group (EMEA)</Tag>
|
|
217
|
+
<Chip tone="violet" selected>
|
|
218
|
+
A filter whose label never ends because the account name is long
|
|
219
|
+
</Chip>
|
|
220
|
+
<Tag tone="pink">日本語のラベル</Tag>
|
|
221
|
+
<Tag tone="lime">🚀 Launch</Tag>
|
|
222
|
+
<Chip size="md">1</Chip>
|
|
223
|
+
</div>
|
|
224
|
+
</BothThemes>
|
|
225
|
+
),
|
|
226
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { X } from "lucide-react";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { focusRing } from "../lib/focus";
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
import { TONE_CLASSES, type Tone } from "./tone";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A static label for what something IS: a category, a kind, a named value.
|
|
9
|
+
*
|
|
10
|
+
* Tags are squared-off (`rounded-md`) where `StatusPill` is fully round, so a
|
|
11
|
+
* category and a state never share a silhouette even before colour is read.
|
|
12
|
+
* For a state, use `StatusPill`. For a label the user presses, use `Chip`.
|
|
13
|
+
*
|
|
14
|
+
* `onRemove` adds a native remove button after the label — the removable
|
|
15
|
+
* variant, for a recipient, an applied filter or an attached item. The tag
|
|
16
|
+
* itself stays non-interactive, so the remove button is the only tab stop.
|
|
17
|
+
* Removing a tag unmounts its focused button; move focus to the next tag or
|
|
18
|
+
* the input that adds tags, or keyboard focus falls to the page.
|
|
19
|
+
*/
|
|
20
|
+
export interface TagProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "children"> {
|
|
21
|
+
children: React.ReactNode;
|
|
22
|
+
/** Categorical tone, or `neutral` (the default) for an uncategorised label. */
|
|
23
|
+
tone?: Tone;
|
|
24
|
+
/** `soft` fills the tag; `outline` keeps only the border, for dense rows. */
|
|
25
|
+
emphasis?: "soft" | "outline";
|
|
26
|
+
size?: "sm" | "md";
|
|
27
|
+
/** A leading glyph, drawn in the tone's icon colour. Decorative. */
|
|
28
|
+
icon?: React.ReactNode;
|
|
29
|
+
/** Shows a remove button; called when it is pressed. */
|
|
30
|
+
onRemove?: () => void;
|
|
31
|
+
/**
|
|
32
|
+
* Accessible name of the remove button. Defaults to "Remove <label>" when
|
|
33
|
+
* the label is a string; pass it when the label is not plain text.
|
|
34
|
+
*/
|
|
35
|
+
removeLabel?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const SIZE = {
|
|
39
|
+
sm: "h-6 gap-1 px-2 text-xs [&_svg]:size-3",
|
|
40
|
+
md: "h-7 gap-1.5 px-2.5 text-sm [&_svg]:size-3.5",
|
|
41
|
+
} as const;
|
|
42
|
+
|
|
43
|
+
const Tag = React.forwardRef<HTMLSpanElement, TagProps>(
|
|
44
|
+
(
|
|
45
|
+
{ className, children, tone = "neutral", emphasis = "soft", size = "sm", icon, onRemove, removeLabel, ...props },
|
|
46
|
+
ref,
|
|
47
|
+
) => {
|
|
48
|
+
const classes = TONE_CLASSES[tone];
|
|
49
|
+
const label = removeLabel ?? (typeof children === "string" ? `Remove ${children}` : "Remove");
|
|
50
|
+
return (
|
|
51
|
+
<span
|
|
52
|
+
ref={ref}
|
|
53
|
+
className={cn(
|
|
54
|
+
"inline-flex min-w-0 max-w-full shrink-0 items-center rounded-md border font-medium",
|
|
55
|
+
SIZE[size],
|
|
56
|
+
emphasis === "outline" ? classes.outline : classes.surface,
|
|
57
|
+
onRemove && (size === "sm" ? "pr-0.5" : "pr-1"),
|
|
58
|
+
className,
|
|
59
|
+
)}
|
|
60
|
+
{...props}
|
|
61
|
+
>
|
|
62
|
+
{icon ? (
|
|
63
|
+
<span aria-hidden="true" className={cn("inline-flex shrink-0", classes.icon)}>
|
|
64
|
+
{icon}
|
|
65
|
+
</span>
|
|
66
|
+
) : null}
|
|
67
|
+
{/* A long label truncates here rather than pushing the remove button
|
|
68
|
+
out; the title gives its full text on hover. */}
|
|
69
|
+
<span className="min-w-0 truncate" title={typeof children === "string" ? children : undefined}>
|
|
70
|
+
{children}
|
|
71
|
+
</span>
|
|
72
|
+
{onRemove ? (
|
|
73
|
+
<button
|
|
74
|
+
type="button"
|
|
75
|
+
aria-label={label}
|
|
76
|
+
onClick={onRemove}
|
|
77
|
+
className={cn(
|
|
78
|
+
// The visible target is the glyph's box; the pseudo-element widens
|
|
79
|
+
// the hit area without moving the tag's layout.
|
|
80
|
+
"relative inline-flex shrink-0 items-center justify-center rounded-sm opacity-70 transition-[opacity,background-color] duration-150 ease-out hover:bg-[color-mix(in_srgb,currentColor_14%,transparent)] hover:opacity-100 before:absolute before:-inset-1.5 before:content-['']",
|
|
81
|
+
size === "sm" ? "size-5" : "size-6",
|
|
82
|
+
focusRing,
|
|
83
|
+
)}
|
|
84
|
+
>
|
|
85
|
+
<X aria-hidden="true" />
|
|
86
|
+
</button>
|
|
87
|
+
) : null}
|
|
88
|
+
</span>
|
|
89
|
+
);
|
|
90
|
+
},
|
|
91
|
+
);
|
|
92
|
+
Tag.displayName = "Tag";
|
|
93
|
+
|
|
94
|
+
export { Tag };
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Categorical tones: the colours that tell KINDS of things apart — an entity,
|
|
3
|
+
* a file type, a channel, a capability. A category never reports a state; that
|
|
4
|
+
* is `StatusPill`'s job, on its own success / warning / danger / info / neutral
|
|
5
|
+
* triples. See "Color Roles" in the brand guidelines.
|
|
6
|
+
*
|
|
7
|
+
* Each tone reads one matched family from `@tangle-network/brand`
|
|
8
|
+
* (`--tone-<name>-{bg,bg-hover,bg-selected,border,border-selected,text,icon}`),
|
|
9
|
+
* solved so the text and icon clear their floors on every fill in both themes.
|
|
10
|
+
*
|
|
11
|
+
* Every class below is written out in full: Tailwind and the sandbox-ui style
|
|
12
|
+
* bundle read this file statically, so an interpolated class name would never
|
|
13
|
+
* be generated.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const CATEGORY_TONES = [
|
|
17
|
+
"violet",
|
|
18
|
+
"orange",
|
|
19
|
+
"teal",
|
|
20
|
+
"blue",
|
|
21
|
+
"pink",
|
|
22
|
+
"brown",
|
|
23
|
+
"cyan",
|
|
24
|
+
"lime",
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
export type CategoryTone = (typeof CATEGORY_TONES)[number];
|
|
28
|
+
|
|
29
|
+
/** A categorical tone, or `neutral` for a label that is not a category. */
|
|
30
|
+
export type Tone = CategoryTone | "neutral";
|
|
31
|
+
|
|
32
|
+
export interface ToneClasses {
|
|
33
|
+
/** Resting fill, border and label colour. */
|
|
34
|
+
surface: string;
|
|
35
|
+
/** Border only; the label keeps the tone's text colour. */
|
|
36
|
+
outline: string;
|
|
37
|
+
/** Hover fill for an interactive surface. */
|
|
38
|
+
hover: string;
|
|
39
|
+
/** Fill and border of a selected chip. */
|
|
40
|
+
selected: string;
|
|
41
|
+
/** The glyph colour, for an icon beside a label or inside a tile. */
|
|
42
|
+
icon: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const TONE_CLASSES: Record<Tone, ToneClasses> = {
|
|
46
|
+
violet: {
|
|
47
|
+
surface: "border-[var(--tone-violet-border)] bg-[var(--tone-violet-bg)] text-[var(--tone-violet-text)]",
|
|
48
|
+
outline: "border-[var(--tone-violet-border)] bg-transparent text-[var(--tone-violet-text)]",
|
|
49
|
+
hover: "hover:bg-[var(--tone-violet-bg-hover)]",
|
|
50
|
+
selected: "border-[var(--tone-violet-border-selected)] bg-[var(--tone-violet-bg-selected)]",
|
|
51
|
+
icon: "text-[var(--tone-violet-icon)]",
|
|
52
|
+
},
|
|
53
|
+
orange: {
|
|
54
|
+
surface: "border-[var(--tone-orange-border)] bg-[var(--tone-orange-bg)] text-[var(--tone-orange-text)]",
|
|
55
|
+
outline: "border-[var(--tone-orange-border)] bg-transparent text-[var(--tone-orange-text)]",
|
|
56
|
+
hover: "hover:bg-[var(--tone-orange-bg-hover)]",
|
|
57
|
+
selected: "border-[var(--tone-orange-border-selected)] bg-[var(--tone-orange-bg-selected)]",
|
|
58
|
+
icon: "text-[var(--tone-orange-icon)]",
|
|
59
|
+
},
|
|
60
|
+
teal: {
|
|
61
|
+
surface: "border-[var(--tone-teal-border)] bg-[var(--tone-teal-bg)] text-[var(--tone-teal-text)]",
|
|
62
|
+
outline: "border-[var(--tone-teal-border)] bg-transparent text-[var(--tone-teal-text)]",
|
|
63
|
+
hover: "hover:bg-[var(--tone-teal-bg-hover)]",
|
|
64
|
+
selected: "border-[var(--tone-teal-border-selected)] bg-[var(--tone-teal-bg-selected)]",
|
|
65
|
+
icon: "text-[var(--tone-teal-icon)]",
|
|
66
|
+
},
|
|
67
|
+
blue: {
|
|
68
|
+
surface: "border-[var(--tone-blue-border)] bg-[var(--tone-blue-bg)] text-[var(--tone-blue-text)]",
|
|
69
|
+
outline: "border-[var(--tone-blue-border)] bg-transparent text-[var(--tone-blue-text)]",
|
|
70
|
+
hover: "hover:bg-[var(--tone-blue-bg-hover)]",
|
|
71
|
+
selected: "border-[var(--tone-blue-border-selected)] bg-[var(--tone-blue-bg-selected)]",
|
|
72
|
+
icon: "text-[var(--tone-blue-icon)]",
|
|
73
|
+
},
|
|
74
|
+
pink: {
|
|
75
|
+
surface: "border-[var(--tone-pink-border)] bg-[var(--tone-pink-bg)] text-[var(--tone-pink-text)]",
|
|
76
|
+
outline: "border-[var(--tone-pink-border)] bg-transparent text-[var(--tone-pink-text)]",
|
|
77
|
+
hover: "hover:bg-[var(--tone-pink-bg-hover)]",
|
|
78
|
+
selected: "border-[var(--tone-pink-border-selected)] bg-[var(--tone-pink-bg-selected)]",
|
|
79
|
+
icon: "text-[var(--tone-pink-icon)]",
|
|
80
|
+
},
|
|
81
|
+
brown: {
|
|
82
|
+
surface: "border-[var(--tone-brown-border)] bg-[var(--tone-brown-bg)] text-[var(--tone-brown-text)]",
|
|
83
|
+
outline: "border-[var(--tone-brown-border)] bg-transparent text-[var(--tone-brown-text)]",
|
|
84
|
+
hover: "hover:bg-[var(--tone-brown-bg-hover)]",
|
|
85
|
+
selected: "border-[var(--tone-brown-border-selected)] bg-[var(--tone-brown-bg-selected)]",
|
|
86
|
+
icon: "text-[var(--tone-brown-icon)]",
|
|
87
|
+
},
|
|
88
|
+
cyan: {
|
|
89
|
+
surface: "border-[var(--tone-cyan-border)] bg-[var(--tone-cyan-bg)] text-[var(--tone-cyan-text)]",
|
|
90
|
+
outline: "border-[var(--tone-cyan-border)] bg-transparent text-[var(--tone-cyan-text)]",
|
|
91
|
+
hover: "hover:bg-[var(--tone-cyan-bg-hover)]",
|
|
92
|
+
selected: "border-[var(--tone-cyan-border-selected)] bg-[var(--tone-cyan-bg-selected)]",
|
|
93
|
+
icon: "text-[var(--tone-cyan-icon)]",
|
|
94
|
+
},
|
|
95
|
+
lime: {
|
|
96
|
+
surface: "border-[var(--tone-lime-border)] bg-[var(--tone-lime-bg)] text-[var(--tone-lime-text)]",
|
|
97
|
+
outline: "border-[var(--tone-lime-border)] bg-transparent text-[var(--tone-lime-text)]",
|
|
98
|
+
hover: "hover:bg-[var(--tone-lime-bg-hover)]",
|
|
99
|
+
selected: "border-[var(--tone-lime-border-selected)] bg-[var(--tone-lime-bg-selected)]",
|
|
100
|
+
icon: "text-[var(--tone-lime-icon)]",
|
|
101
|
+
},
|
|
102
|
+
// Neutral is the chrome greys, not a ramp: the nested-well fill, the subtle
|
|
103
|
+
// hairline, muted text. Hover and selected step toward the ink so they stay
|
|
104
|
+
// grey in every named theme; the selected border is the dim text tier, which
|
|
105
|
+
// clears 3:1 on the well in both themes.
|
|
106
|
+
neutral: {
|
|
107
|
+
surface: "border-[var(--surface-neutral-border)] bg-[var(--surface-neutral-bg)] text-[var(--text-secondary)]",
|
|
108
|
+
outline: "border-[var(--border-default)] bg-transparent text-[var(--text-secondary)]",
|
|
109
|
+
hover: "hover:bg-[color-mix(in_srgb,var(--surface-neutral-bg),var(--text-primary)_6%)]",
|
|
110
|
+
selected:
|
|
111
|
+
"border-[var(--text-dim)] bg-[color-mix(in_srgb,var(--surface-neutral-bg),var(--text-primary)_12%)] text-[var(--text-primary)]",
|
|
112
|
+
icon: "text-[var(--text-dim)]",
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A stable categorical tone for a key — an account id, a channel, a project
|
|
118
|
+
* name — for things that need telling apart but have no category of their own.
|
|
119
|
+
* The same key always gets the same tone, in every product and session.
|
|
120
|
+
* Distinct keys can share a tone, so the label or glyph must still name it.
|
|
121
|
+
*/
|
|
122
|
+
export function toneFor(key: string): CategoryTone {
|
|
123
|
+
// FNV-1a: a few lines, no dependency, and well spread over short strings.
|
|
124
|
+
let hash = 0x811c9dc5;
|
|
125
|
+
for (let i = 0; i < key.length; i++) {
|
|
126
|
+
hash ^= key.charCodeAt(i);
|
|
127
|
+
hash = Math.imul(hash, 0x01000193);
|
|
128
|
+
}
|
|
129
|
+
return CATEGORY_TONES[(hash >>> 0) % CATEGORY_TONES.length];
|
|
130
|
+
}
|
|
@@ -80,3 +80,21 @@ describe("CommandPreview", () => {
|
|
|
80
80
|
expect(screen.getByRole("region", { name: "stdout" })).toHaveTextContent("M a");
|
|
81
81
|
});
|
|
82
82
|
});
|
|
83
|
+
|
|
84
|
+
describe("CommandPreview output", () => {
|
|
85
|
+
it("offers the rest of an output its height cap hides", () => {
|
|
86
|
+
const scroll = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollHeight");
|
|
87
|
+
const client = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight");
|
|
88
|
+
Object.defineProperty(HTMLElement.prototype, "scrollHeight", { configurable: true, get: () => 900 });
|
|
89
|
+
Object.defineProperty(HTMLElement.prototype, "clientHeight", { configurable: true, get: () => 320 });
|
|
90
|
+
try {
|
|
91
|
+
render(<CommandPreview part={part({ output: "a\nb\nc" })} defaultExpanded />);
|
|
92
|
+
fireEvent.click(screen.getByRole("button", { name: "Show all stdout (3 lines)" }));
|
|
93
|
+
expect(screen.getByRole("region", { name: "stdout" }).className).not.toContain("max-h-80");
|
|
94
|
+
expect(screen.queryByRole("button", { name: /Show all stdout/ })).toBeNull();
|
|
95
|
+
} finally {
|
|
96
|
+
if (scroll) Object.defineProperty(HTMLElement.prototype, "scrollHeight", scroll);
|
|
97
|
+
if (client) Object.defineProperty(HTMLElement.prototype, "clientHeight", client);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { memo, useId, useState } from "react";
|
|
1
|
+
import { memo, useId, useLayoutEffect, useRef, useState } from "react";
|
|
2
2
|
import { ChevronDown, ChevronRight, Loader2 } from "lucide-react";
|
|
3
3
|
import { focusRing } from "../lib/focus";
|
|
4
4
|
import { cn } from "../lib/utils";
|
|
@@ -205,6 +205,65 @@ function CommandText({ command }: { command: string }) {
|
|
|
205
205
|
);
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
+
/**
|
|
209
|
+
* One output stream, capped at a readable height. When the cap hides lines,
|
|
210
|
+
* a control says so and shows the rest in place: a region that ends flush
|
|
211
|
+
* with its frame gave no sign that more output was there.
|
|
212
|
+
*/
|
|
213
|
+
function OutputRegion({
|
|
214
|
+
label,
|
|
215
|
+
text,
|
|
216
|
+
className,
|
|
217
|
+
}: {
|
|
218
|
+
label: string;
|
|
219
|
+
text: string;
|
|
220
|
+
className?: string;
|
|
221
|
+
}) {
|
|
222
|
+
const ref = useRef<HTMLPreElement>(null);
|
|
223
|
+
const [clipped, setClipped] = useState(false);
|
|
224
|
+
const [full, setFull] = useState(false);
|
|
225
|
+
useLayoutEffect(() => {
|
|
226
|
+
const el = ref.current;
|
|
227
|
+
if (!el || full) return;
|
|
228
|
+
const check = () => setClipped(el.scrollHeight > el.clientHeight + 1);
|
|
229
|
+
check();
|
|
230
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
231
|
+
const observer = new ResizeObserver(check);
|
|
232
|
+
observer.observe(el);
|
|
233
|
+
return () => observer.disconnect();
|
|
234
|
+
}, [full]);
|
|
235
|
+
const lines = text.replace(/\n$/, "").split("\n").length;
|
|
236
|
+
return (
|
|
237
|
+
<div className={className}>
|
|
238
|
+
<pre
|
|
239
|
+
ref={ref}
|
|
240
|
+
tabIndex={0}
|
|
241
|
+
role="region"
|
|
242
|
+
aria-label={label}
|
|
243
|
+
className={cn(
|
|
244
|
+
"overflow-auto whitespace-pre-wrap [overflow-wrap:anywhere] px-3 py-2 leading-relaxed",
|
|
245
|
+
full ? "" : "max-h-80",
|
|
246
|
+
focusRing,
|
|
247
|
+
)}
|
|
248
|
+
>
|
|
249
|
+
{text}
|
|
250
|
+
</pre>
|
|
251
|
+
{clipped && !full ? (
|
|
252
|
+
<button
|
|
253
|
+
type="button"
|
|
254
|
+
onClick={() => setFull(true)}
|
|
255
|
+
className={cn(
|
|
256
|
+
"w-full border-t border-white/10 px-3 py-1.5 text-left font-sans text-[11px] font-medium text-muted-foreground transition-colors hover:text-foreground",
|
|
257
|
+
focusRing,
|
|
258
|
+
)}
|
|
259
|
+
>
|
|
260
|
+
Show all {label} ({lines} lines)
|
|
261
|
+
</button>
|
|
262
|
+
) : null}
|
|
263
|
+
</div>
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
208
267
|
/**
|
|
209
268
|
* One shell command as a terminal block: a `$ command` prompt line with its
|
|
210
269
|
* exit status, and stdout and stderr in separate regions below it.
|
|
@@ -305,43 +364,25 @@ export const CommandPreview = memo(
|
|
|
305
364
|
{expanded && hasBody ? (
|
|
306
365
|
<div id={bodyId} className="border-t border-white/10">
|
|
307
366
|
{output.stdout ? (
|
|
308
|
-
<
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
"max-h-80 overflow-auto whitespace-pre-wrap [overflow-wrap:anywhere] px-3 py-2 leading-relaxed",
|
|
314
|
-
focusRing,
|
|
315
|
-
)}
|
|
316
|
-
>
|
|
317
|
-
{output.stdout}
|
|
318
|
-
</pre>
|
|
367
|
+
<OutputRegion
|
|
368
|
+
label="stdout"
|
|
369
|
+
text={output.stdout}
|
|
370
|
+
className=""
|
|
371
|
+
/>
|
|
319
372
|
) : null}
|
|
320
373
|
{output.stderr ? (
|
|
321
|
-
<
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
"max-h-80 overflow-auto whitespace-pre-wrap [overflow-wrap:anywhere] border-t border-white/10 px-3 py-2 leading-relaxed text-[var(--surface-danger-text)]",
|
|
327
|
-
focusRing,
|
|
328
|
-
)}
|
|
329
|
-
>
|
|
330
|
-
{output.stderr}
|
|
331
|
-
</pre>
|
|
374
|
+
<OutputRegion
|
|
375
|
+
label="stderr"
|
|
376
|
+
text={output.stderr}
|
|
377
|
+
className="border-t border-white/10 text-[var(--surface-danger-text)]"
|
|
378
|
+
/>
|
|
332
379
|
) : null}
|
|
333
380
|
{errorText ? (
|
|
334
|
-
<
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
"max-h-80 overflow-auto whitespace-pre-wrap [overflow-wrap:anywhere] border-t border-white/10 px-3 py-2 leading-relaxed text-[var(--surface-danger-text)]",
|
|
340
|
-
focusRing,
|
|
341
|
-
)}
|
|
342
|
-
>
|
|
343
|
-
{errorText}
|
|
344
|
-
</pre>
|
|
381
|
+
<OutputRegion
|
|
382
|
+
label="error"
|
|
383
|
+
text={errorText}
|
|
384
|
+
className="border-t border-white/10 text-[var(--surface-danger-text)]"
|
|
385
|
+
/>
|
|
345
386
|
) : null}
|
|
346
387
|
</div>
|
|
347
388
|
) : null}
|