@tangle-network/ui 11.16.11 → 11.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/index.d.ts +14 -1
  3. package/dist/index.js +14 -1
  4. package/dist/primitives/alert-dialog.d.ts +34 -0
  5. package/dist/primitives/alert-dialog.js +68 -0
  6. package/dist/primitives/badge.d.ts +7 -0
  7. package/dist/primitives/badge.js +3 -3
  8. package/dist/primitives/checkbox.d.ts +12 -0
  9. package/dist/primitives/checkbox.js +34 -0
  10. package/dist/primitives/chip.d.ts +38 -0
  11. package/dist/primitives/chip.js +41 -0
  12. package/dist/primitives/collapsible.d.ts +12 -0
  13. package/dist/primitives/collapsible.js +13 -0
  14. package/dist/primitives/context-menu.d.ts +36 -0
  15. package/dist/primitives/context-menu.js +86 -0
  16. package/dist/primitives/icon-tile.d.ts +32 -0
  17. package/dist/primitives/icon-tile.js +49 -0
  18. package/dist/primitives/popover.d.ts +15 -0
  19. package/dist/primitives/popover.js +25 -0
  20. package/dist/primitives/separator.d.ts +11 -0
  21. package/dist/primitives/separator.js +21 -0
  22. package/dist/primitives/sheet.d.ts +28 -0
  23. package/dist/primitives/sheet.js +70 -0
  24. package/dist/primitives/slider.d.ts +14 -0
  25. package/dist/primitives/slider.js +37 -0
  26. package/dist/primitives/tag.d.ts +36 -0
  27. package/dist/primitives/tag.js +42 -0
  28. package/dist/primitives/theme-toggle.js +8 -0
  29. package/dist/primitives/tone.d.ts +40 -0
  30. package/dist/primitives/tone.js +106 -0
  31. package/dist/primitives/tooltip.d.ts +16 -0
  32. package/dist/primitives/tooltip.js +32 -0
  33. package/dist/primitives.d.ts +14 -1
  34. package/dist/primitives.js +14 -1
  35. package/package.json +9 -2
  36. package/src/primitives/alert-dialog.tsx +139 -0
  37. package/src/primitives/badge.tsx +17 -4
  38. package/src/primitives/checkbox-separator-sheet-tooltip.stories.tsx +92 -0
  39. package/src/primitives/checkbox-separator-sheet-tooltip.test.tsx +107 -0
  40. package/src/primitives/checkbox.tsx +42 -0
  41. package/src/primitives/chip.test.tsx +178 -0
  42. package/src/primitives/chip.tsx +108 -0
  43. package/src/primitives/collapsible.tsx +16 -0
  44. package/src/primitives/context-menu.tsx +168 -0
  45. package/src/primitives/icon-tile.stories.tsx +74 -0
  46. package/src/primitives/icon-tile.tsx +96 -0
  47. package/src/primitives/index.ts +69 -0
  48. package/src/primitives/popover-collapsible-alert-dialog-context-menu-slider.stories.tsx +124 -0
  49. package/src/primitives/popover-collapsible-alert-dialog-context-menu-slider.test.tsx +165 -0
  50. package/src/primitives/popover.tsx +45 -0
  51. package/src/primitives/separator.tsx +30 -0
  52. package/src/primitives/sheet.tsx +134 -0
  53. package/src/primitives/slider.tsx +71 -0
  54. package/src/primitives/tag-chip.stories.tsx +226 -0
  55. package/src/primitives/tag.tsx +94 -0
  56. package/src/primitives/theme-toggle.test.tsx +2 -1
  57. package/src/primitives/theme-toggle.tsx +2 -0
  58. package/src/primitives/tone.ts +130 -0
  59. package/src/primitives/tooltip.tsx +54 -0
  60. package/src/stories/theme-showcase.stories.tsx +2 -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 };
@@ -101,7 +101,8 @@ describe("root mode preference", () => {
101
101
  });
102
102
 
103
103
  it.each([
104
- ["aubergine", "aubergine-light"], ["arena", "arena-light"], ["tangle-dark", "tangle-light"],
104
+ ["aubergine", "aubergine-light"], ["arena", "arena-light"], ["super", "super-light"], ["tangle-dark", "tangle-light"],
105
+ ["agents", "agents-light"],
105
106
  ])("keeps the %s named family in both directions", (name, lightName) => {
106
107
  document.documentElement.dataset.theme = name;
107
108
  render(<ThemeToggle />);
@@ -10,7 +10,9 @@ const systemQuery = "(prefers-color-scheme: dark)";
10
10
  const namedModes = [
11
11
  { dark: "aubergine", light: "aubergine-light" },
12
12
  { dark: "arena", light: "arena-light" },
13
+ { dark: "super", light: "super-light" },
13
14
  { dark: "tangle-dark", light: "tangle-light" },
15
+ { dark: "agents", light: "agents-light" },
14
16
  ] as const;
15
17
 
16
18
  // Client-only preference cache also keeps the control usable when storage is
@@ -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
+ }
@@ -0,0 +1,54 @@
1
+ "use client";
2
+
3
+ import * as TooltipPrimitive from "@radix-ui/react-tooltip";
4
+ import * as React from "react";
5
+ import { cn } from "../lib/utils";
6
+
7
+ /**
8
+ * Wrap an app (or a toolbar) once. The open delay applies to the first tooltip;
9
+ * moving between triggers inside `skipDelayDuration` opens the next one at
10
+ * once, which keeps an icon rail fast to scan.
11
+ */
12
+ const TooltipProvider = ({
13
+ delayDuration = 400,
14
+ skipDelayDuration = 300,
15
+ ...props
16
+ }: React.ComponentProps<typeof TooltipPrimitive.Provider>) => (
17
+ <TooltipPrimitive.Provider
18
+ delayDuration={delayDuration}
19
+ skipDelayDuration={skipDelayDuration}
20
+ {...props}
21
+ />
22
+ );
23
+ TooltipProvider.displayName = "TooltipProvider";
24
+
25
+ const Tooltip = TooltipPrimitive.Root;
26
+
27
+ const TooltipTrigger = TooltipPrimitive.Trigger;
28
+
29
+ /**
30
+ * Inverse-surface label for a control whose name is otherwise only an icon.
31
+ * It is supplementary: the trigger still needs its own accessible name.
32
+ */
33
+ const TooltipContent = React.forwardRef<
34
+ React.ElementRef<typeof TooltipPrimitive.Content>,
35
+ React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
36
+ >(({ className, sideOffset = 6, ...props }, ref) => (
37
+ <TooltipPrimitive.Portal>
38
+ <TooltipPrimitive.Content
39
+ ref={ref}
40
+ sideOffset={sideOffset}
41
+ className={cn(
42
+ "z-50 max-w-xs origin-(--radix-tooltip-content-transform-origin) rounded-md bg-foreground px-2.5 py-1.5 text-background text-xs leading-snug shadow-md",
43
+ "data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95",
44
+ "data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
45
+ "duration-150 ease-out motion-reduce:animate-none",
46
+ className,
47
+ )}
48
+ {...props}
49
+ />
50
+ </TooltipPrimitive.Portal>
51
+ ));
52
+ TooltipContent.displayName = TooltipPrimitive.Content.displayName;
53
+
54
+ export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
@@ -17,6 +17,8 @@ const THEMES: { name: string; theme?: string }[] = [
17
17
  { name: "Aubergine · light", theme: "aubergine-light" },
18
18
  { name: "Experimental green", theme: "arena" },
19
19
  { name: "Experimental green · light", theme: "arena-light" },
20
+ { name: "Super (forest)", theme: "super" },
21
+ { name: "Super · light", theme: "super-light" },
20
22
  ];
21
23
 
22
24
  const NOW = Date.now();