@tangle-network/ui 11.16.11 → 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 +12 -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/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
|
@@ -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
|
+
}
|