@tangle-network/ui 11.32.1 → 11.33.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 (36) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/index.d.ts +2 -1
  3. package/dist/index.js +3 -2
  4. package/dist/lib/control-presentation.js +2 -1
  5. package/dist/lib/focus.d.ts +5 -1
  6. package/dist/lib/focus.js +6 -1
  7. package/dist/lib/interaction.d.ts +41 -0
  8. package/dist/lib/interaction.js +42 -0
  9. package/dist/primitives/button.js +19 -18
  10. package/dist/primitives/card.d.ts +14 -1
  11. package/dist/primitives/card.js +15 -5
  12. package/dist/primitives/dropdown-menu.js +7 -6
  13. package/dist/primitives/segmented-control.d.ts +5 -1
  14. package/dist/primitives/segmented-control.js +12 -5
  15. package/dist/primitives/select.js +4 -3
  16. package/dist/primitives/tabs.js +4 -3
  17. package/dist/run/run-group.js +3 -2
  18. package/dist/utils.d.ts +2 -1
  19. package/dist/utils.js +2 -1
  20. package/package.json +2 -2
  21. package/src/files/file-artifact-pane.test.tsx +4 -2
  22. package/src/lib/control-presentation.ts +10 -4
  23. package/src/lib/focus.ts +7 -1
  24. package/src/lib/interaction.ts +64 -0
  25. package/src/primitives/button.tsx +29 -10
  26. package/src/primitives/card.tsx +23 -6
  27. package/src/primitives/control-presentation.test.tsx +6 -3
  28. package/src/primitives/date-picker.test.tsx +1 -1
  29. package/src/primitives/dropdown-menu.tsx +15 -10
  30. package/src/primitives/interaction-states.test.tsx +285 -0
  31. package/src/primitives/segmented-control.test.tsx +4 -2
  32. package/src/primitives/segmented-control.tsx +13 -14
  33. package/src/primitives/select.tsx +11 -5
  34. package/src/primitives/tabs.tsx +7 -3
  35. package/src/run/run-group.tsx +3 -2
  36. package/src/utils/index.ts +9 -0
@@ -0,0 +1,285 @@
1
+ import * as React from "react";
2
+ import { cleanup, render, screen } from "@testing-library/react";
3
+ import { compile } from "tailwindcss";
4
+ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
5
+ import { focusFieldWithin } from "../lib/focus";
6
+ import {
7
+ ixControl,
8
+ ixControlAfter,
9
+ ixControlSoft,
10
+ ixField,
11
+ ixRow,
12
+ ixSelected,
13
+ ixTabUnderline,
14
+ } from "../lib/interaction";
15
+ import { Button } from "./button";
16
+ import { Card, CardDescription } from "./card";
17
+ import {
18
+ DropdownMenu,
19
+ DropdownMenuCheckboxItem,
20
+ DropdownMenuContent,
21
+ DropdownMenuItem,
22
+ DropdownMenuRadioGroup,
23
+ DropdownMenuRadioItem,
24
+ DropdownMenuSub,
25
+ DropdownMenuSubTrigger,
26
+ DropdownMenuTrigger,
27
+ } from "./dropdown-menu";
28
+ import { Input, Textarea } from "./input";
29
+ import { SegmentedControl } from "./segmented-control";
30
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select";
31
+ import { Tabs, TabsList, TabsTrigger } from "./tabs";
32
+
33
+ /**
34
+ * Every primitive draws its interaction states from one helper in
35
+ * lib/interaction.ts (ROOT-SPEC section b). This is the cheap static guard:
36
+ * each primitive's rendered className carries every class of its helper, so a
37
+ * later edit or a tailwind-merge conflict cannot silently drop a state. The
38
+ * second suite compiles each helper with Tailwind to prove the classes emit the
39
+ * declarations and selectors the states need.
40
+ */
41
+ afterEach(cleanup);
42
+ beforeAll(() => {
43
+ // jsdom lacks the layout APIs Radix Select and Menu call while opening.
44
+ Element.prototype.scrollIntoView ??= vi.fn();
45
+ Element.prototype.hasPointerCapture ??= vi.fn(() => false);
46
+ Element.prototype.releasePointerCapture ??= vi.fn();
47
+ });
48
+
49
+ const tokens = (helper: string) => helper.split(/\s+/).filter(Boolean);
50
+ const classes = (el: Element) => new Set(el.className.split(/\s+/));
51
+
52
+ /** Every class of every helper is on the element. */
53
+ function expectHelpers(el: Element, ...helpers: string[]) {
54
+ const have = classes(el);
55
+ for (const helper of helpers) {
56
+ for (const token of tokens(helper)) expect(have, `${el.tagName} is missing ${token}`).toContain(token);
57
+ }
58
+ }
59
+
60
+ /** The glow only strong controls get. */
61
+ const HOVER_GLOW = "hover:shadow-[var(--ix-hover)]";
62
+ /** focusRing, which every control keeps exactly. */
63
+ const FOCUS_RING = [
64
+ "focus-visible:outline-1",
65
+ "focus-visible:outline-[var(--focus-border)]",
66
+ "focus-visible:ring-4",
67
+ "focus-visible:ring-[var(--focus-halo)]",
68
+ ];
69
+ /** focusField's `:focus` part, which every field keeps exactly. */
70
+ const FOCUS_FIELD = ["focus:outline-hidden", "focus:border-[var(--focus-border)]", "focus:ring-3", "focus:ring-[var(--focus-halo)]"];
71
+
72
+ describe("each primitive renders its interaction helper", () => {
73
+ it.each(["default", "destructive", "secondary", "outline", "sandbox"] as const)(
74
+ "Button %s takes ixControl and keeps the focus ring",
75
+ (variant) => {
76
+ render(<Button variant={variant}>Go</Button>);
77
+ const button = screen.getByRole("button");
78
+ expectHelpers(button, ixControl);
79
+ for (const token of FOCUS_RING) expect(classes(button)).toContain(token);
80
+ },
81
+ );
82
+
83
+ it.each(["ghost", "link", "bare"] as const)("Button %s takes ixControlSoft and never the hover glow", (variant) => {
84
+ render(<Button variant={variant}>Go</Button>);
85
+ const button = screen.getByRole("button");
86
+ expectHelpers(button, ixControlSoft);
87
+ expect(classes(button)).not.toContain(HOVER_GLOW);
88
+ expect(classes(button)).not.toContain("hover:bg-muted");
89
+ for (const token of FOCUS_RING) expect(classes(button)).toContain(token);
90
+ });
91
+
92
+ it.each(["outline", "ghost", "bare"] as const)("a %s toggle Button (aria-pressed) adds ixControl and ixSelected", (variant) => {
93
+ render(<Button variant={variant} aria-pressed>Bold</Button>);
94
+ expectHelpers(screen.getByRole("button"), ixControl, ixSelected);
95
+ });
96
+
97
+ it("Input and Textarea take ixField with focusField's focus border and halo", () => {
98
+ render(<><Input aria-label="Name" /><Textarea aria-label="Notes" /></>);
99
+ for (const field of [screen.getByLabelText("Name"), screen.getByLabelText("Notes")]) {
100
+ expectHelpers(field, ixField);
101
+ for (const token of FOCUS_FIELD) expect(classes(field)).toContain(token);
102
+ expect(classes(field)).not.toContain("border-border");
103
+ expect(classes(field)).not.toContain("hover:border-[var(--border-strong)]");
104
+ }
105
+ });
106
+
107
+ it("focusFieldWithin is a field: ixField plus the has-focus ring, without the neutral hover", () => {
108
+ expect(tokens(focusFieldWithin)).toEqual(expect.arrayContaining(tokens(ixField)));
109
+ expect(focusFieldWithin).toContain("has-[");
110
+ expect(focusFieldWithin).toContain(":focus]:ring-[var(--focus-halo)]");
111
+ expect(tokens(focusFieldWithin)).not.toContain("hover:border-[var(--border-strong)]");
112
+ expect(tokens(focusFieldWithin)).not.toContain("border-border");
113
+ });
114
+
115
+ it("SelectTrigger is a field that keeps its well and an after-click ring while open", () => {
116
+ render(<Select><SelectTrigger aria-label="Status"><SelectValue /></SelectTrigger></Select>);
117
+ const trigger = screen.getByRole("combobox");
118
+ expectHelpers(trigger, ixField, ixControlAfter);
119
+ for (const token of FOCUS_FIELD) expect(classes(trigger)).toContain(token);
120
+ expect(classes(trigger)).toContain("data-[state=open]:shadow-[var(--ix-after)]");
121
+ expect(classes(trigger)).toContain("data-[state=open]:border-[var(--ix-hover-border)]");
122
+ // The open state must not repaint the well.
123
+ expect([...classes(trigger)].filter((c) => c.startsWith("data-[state=open]:bg-"))).toEqual([]);
124
+ });
125
+
126
+ it("SelectContent paints the popover level and SelectItem takes ixRow", async () => {
127
+ render(
128
+ <Select open value="a">
129
+ <SelectTrigger aria-label="Status"><SelectValue /></SelectTrigger>
130
+ <SelectContent><SelectItem value="a">Active</SelectItem><SelectItem value="b">Paused</SelectItem></SelectContent>
131
+ </Select>,
132
+ );
133
+ const listbox = await screen.findByRole("listbox");
134
+ const content = listbox.closest("[data-radix-select-viewport]")?.parentElement ?? listbox;
135
+ expect(classes(content)).toContain("bg-popover");
136
+ expect(classes(content)).toContain("shadow-[var(--edge-highlight),var(--shadow-card)]");
137
+ expect(classes(content)).not.toContain("bg-card");
138
+ for (const option of screen.getAllByRole("option")) {
139
+ expectHelpers(option, ixRow);
140
+ expect(classes(option)).not.toContain("focus:bg-[var(--accent-surface-strong)]");
141
+ }
142
+ });
143
+
144
+ it("DropdownMenu content paints the popover level and every item kind takes ixRow", async () => {
145
+ render(
146
+ <DropdownMenu open>
147
+ <DropdownMenuTrigger>Menu</DropdownMenuTrigger>
148
+ <DropdownMenuContent>
149
+ <DropdownMenuItem>Rename</DropdownMenuItem>
150
+ <DropdownMenuCheckboxItem checked>Pinned</DropdownMenuCheckboxItem>
151
+ <DropdownMenuRadioGroup value="a"><DropdownMenuRadioItem value="a">Newest</DropdownMenuRadioItem></DropdownMenuRadioGroup>
152
+ <DropdownMenuSub><DropdownMenuSubTrigger>Move to</DropdownMenuSubTrigger></DropdownMenuSub>
153
+ </DropdownMenuContent>
154
+ </DropdownMenu>,
155
+ );
156
+ const menu = await screen.findByRole("menu");
157
+ expect(classes(menu)).toContain("bg-popover");
158
+ expect(classes(menu)).toContain("shadow-[var(--edge-highlight),var(--shadow-card)]");
159
+ expect(classes(menu)).not.toContain("bg-card");
160
+ const items = [
161
+ screen.getByRole("menuitem", { name: "Rename" }),
162
+ screen.getByRole("menuitemcheckbox", { name: "Pinned" }),
163
+ screen.getByRole("menuitemradio", { name: "Newest" }),
164
+ screen.getByRole("menuitem", { name: "Move to" }),
165
+ ];
166
+ for (const item of items) {
167
+ expectHelpers(item, ixRow);
168
+ expect(classes(item)).not.toContain("focus:bg-muted/50");
169
+ }
170
+ });
171
+
172
+ it("Card is a ladder level with a shadow; hover and interactive cards take ixControl", () => {
173
+ render(
174
+ <>
175
+ <Card data-testid="plain" />
176
+ <Card data-testid="hover" hover />
177
+ <Card data-testid="interactive" interactive />
178
+ <CardDescription>Guests, staff and vendors message your business on WhatsApp.</CardDescription>
179
+ </>,
180
+ );
181
+ const plain = screen.getByTestId("plain");
182
+ expect(plain).toHaveAttribute("data-slot", "card");
183
+ expect(classes(plain)).toContain("shadow-[var(--edge-highlight),var(--shadow-card)]");
184
+ expect(classes(plain)).not.toContain(HOVER_GLOW);
185
+ for (const id of ["hover", "interactive"]) {
186
+ const card = screen.getByTestId(id);
187
+ expectHelpers(card, ixControl);
188
+ expect(card).not.toHaveAttribute("interactive");
189
+ expect(classes(card)).not.toContain("hover:border-[var(--border-strong)]");
190
+ }
191
+ expect(classes(screen.getByText(/Guests, staff/))).toContain("line-clamp-2");
192
+ });
193
+
194
+ it("SegmentedControl row: a well track, soft items, the selected state on aria-checked", () => {
195
+ const options = [{ value: "a", label: "A–Z" }, { value: "b", label: "Recent" }];
196
+ render(<SegmentedControl aria-label="Sort" value="a" onValueChange={() => {}} options={options} />);
197
+ const track = screen.getByRole("radiogroup");
198
+ expect(classes(track)).toContain("bg-[var(--well)]");
199
+ expect(classes(track)).toContain("border-[var(--field-border)]");
200
+ expect(classes(track)).not.toContain("bg-card");
201
+ for (const radio of screen.getAllByRole("radio")) {
202
+ expectHelpers(radio, ixControlSoft, ixSelected);
203
+ for (const token of FOCUS_RING) expect(classes(radio)).toContain(token);
204
+ expect(classes(radio)).not.toContain("bg-[var(--accent-surface)]");
205
+ }
206
+ });
207
+
208
+ it("SegmentedControl tabs: the underline helper and a transparent border slot", () => {
209
+ const options = [{ value: "a", label: "Team" }, { value: "b", label: "Roles" }];
210
+ render(<SegmentedControl aria-label="Section" variant="tabs" value="a" onValueChange={() => {}} options={options} />);
211
+ for (const radio of screen.getAllByRole("radio")) {
212
+ expectHelpers(radio, ixTabUnderline);
213
+ expect(classes(radio)).toContain("border-transparent");
214
+ expect(classes(radio)).not.toContain("border-[var(--accent-text)]");
215
+ }
216
+ });
217
+
218
+ it.each([
219
+ ["default", [ixControlSoft, ixSelected]],
220
+ ["pills", [ixControlSoft, ixSelected]],
221
+ ["underline", [ixTabUnderline]],
222
+ ] as const)("TabsTrigger %s takes its helper", (variant, helpers) => {
223
+ render(
224
+ <Tabs defaultValue="a">
225
+ <TabsList variant={variant}>
226
+ <TabsTrigger variant={variant} value="a">Overview</TabsTrigger>
227
+ <TabsTrigger variant={variant} value="b">Activity</TabsTrigger>
228
+ </TabsList>
229
+ </Tabs>,
230
+ );
231
+ for (const tab of screen.getAllByRole("tab")) {
232
+ expectHelpers(tab, ...helpers);
233
+ for (const token of FOCUS_RING) expect(classes(tab)).toContain(token);
234
+ expect([...classes(tab)]).not.toEqual(expect.arrayContaining(["data-[state=active]:bg-background"]));
235
+ expect(classes(tab)).not.toContain("data-[state=active]:border-primary");
236
+ }
237
+ });
238
+ });
239
+
240
+ describe("Tailwind compiles every interaction class to the state it names", () => {
241
+ /** A fresh compiler per class, so each class's CSS is read on its own. */
242
+ async function cssFor(candidate: string) {
243
+ const compiler = await compile("@tailwind utilities;");
244
+ return compiler.build([candidate]);
245
+ }
246
+
247
+ const all = [...new Set([ixControl, ixControlAfter, ixControlSoft, ixField, ixSelected, ixTabUnderline, ixRow].flatMap(tokens))];
248
+
249
+ it.each(all)("%s emits a rule", async (candidate) => {
250
+ expect(await cssFor(candidate)).toMatch(/\{[^}]*:[^}]*\}/);
251
+ });
252
+
253
+ it("composes the hover and press glows with the focus ring instead of replacing it", async () => {
254
+ for (const [candidate, value] of [
255
+ ["hover:shadow-[var(--ix-hover)]", "var(--ix-hover)"],
256
+ ["active:shadow-[var(--ix-press)]", "var(--ix-press)"],
257
+ ]) {
258
+ const css = await cssFor(candidate);
259
+ expect(css).toContain(`--tw-shadow: ${value}`);
260
+ expect(css).toContain("var(--tw-ring-shadow), var(--tw-shadow)");
261
+ }
262
+ });
263
+
264
+ it("keys the after-click ring on mouse focus only", async () => {
265
+ const css = await cssFor("focus:not-focus-visible:shadow-[var(--ix-after)]");
266
+ expect(css).toContain(":focus:not(:focus-visible)");
267
+ expect(css).toContain("--tw-shadow: var(--ix-after)");
268
+ });
269
+
270
+ it("reads the field fill and border from the surface context", async () => {
271
+ expect(await cssFor("bg-[var(--field-surface,var(--well))]")).toContain("background-color: var(--field-surface,var(--well))");
272
+ expect(await cssFor("border-[var(--field-border)]")).toContain("border-color: var(--field-border)");
273
+ expect(await cssFor("hover:border-[var(--ix-hover-border)]")).toContain("border-color: var(--ix-hover-border)");
274
+ });
275
+
276
+ it("draws selection on aria-pressed, aria-checked, aria-current, data-state on and active", async () => {
277
+ const css = await cssFor("aria-[current=page]:shadow-[inset_0_0_0_1px_var(--ix-sel-ring)]");
278
+ expect(css).toContain('[aria-current="page"]');
279
+ expect(css).toContain("inset 0 0 0 1px");
280
+ expect(await cssFor("aria-pressed:bg-[var(--ix-sel-bg)]")).toContain('[aria-pressed="true"]');
281
+ expect(await cssFor("aria-checked:bg-[var(--ix-sel-bg)]")).toContain('[aria-checked="true"]');
282
+ expect(await cssFor("data-[state=active]:bg-[var(--ix-sel-bg)]")).toContain('[data-state="active"]');
283
+ expect(await cssFor("data-[highlighted]:bg-[var(--ix-sel-bg)]")).toContain("[data-highlighted]");
284
+ });
285
+ });
@@ -271,7 +271,9 @@ describe("SegmentedControl", () => {
271
271
  const radiogroup = screen.getByRole("radiogroup")
272
272
  expect(radiogroup.className).toContain("flex-wrap")
273
273
  expect(radiogroup.className).toContain("rounded-lg")
274
- expect(radiogroup.className).toContain("bg-card")
274
+ // The row track is a field well, so it steps off whatever plane holds it.
275
+ expect(radiogroup.className).toContain("bg-[var(--well)]")
276
+ expect(radiogroup.className).toContain("border-[var(--field-border)]")
275
277
  })
276
278
 
277
279
  it("applies tabs variant classes with nowrap", () => {
@@ -287,7 +289,7 @@ describe("SegmentedControl", () => {
287
289
  expect(radiogroup.className).toContain("flex-nowrap")
288
290
  expect(radiogroup.className).toContain("overflow-x-auto")
289
291
  expect(radiogroup.className).toContain("border-b")
290
- expect(radiogroup.className).not.toContain("bg-card")
292
+ expect(radiogroup.className).not.toContain("bg-[var(--well)]")
291
293
  })
292
294
 
293
295
  it("passes aria-label to the radiogroup", () => {
@@ -5,6 +5,7 @@ declare const process: { env: { NODE_ENV?: string } } | undefined
5
5
  import * as React from "react"
6
6
  import { touchTarget } from "../lib/control-presentation"
7
7
  import { focusRing, focusRingInset } from "../lib/focus"
8
+ import { ixControlSoft, ixSelected, ixTabUnderline } from "../lib/interaction"
8
9
  import { cn } from "../lib/utils"
9
10
 
10
11
  /**
@@ -19,7 +20,11 @@ import { cn } from "../lib/utils"
19
20
  * console warning fires when both are omitted.
20
21
  *
21
22
  * Design rules baked into the default variant:
22
- * - Only the SELECTED segment shows a surface colour + accent text.
23
+ * - The row track is a field well (`--well`, `--field-border`), so the
24
+ * control reads as one inset group on whatever plane it sits on.
25
+ * - Only the SELECTED segment shows a surface colour + accent text: the
26
+ * shared selected state (`ixSelected`: tint plus an inset ring). A tab
27
+ * shows it as an underline instead (`ixTabUnderline`).
23
28
  * - Unselected segments have NO background — they're plain-text labels
24
29
  * that darken on hover. This keeps the selected segment as the
25
30
  * single visual anchor instead of the whole group competing with
@@ -160,7 +165,7 @@ export function SegmentedControl<T extends string = string>({
160
165
  className={cn(
161
166
  "flex gap-1",
162
167
  variant === "row" &&
163
- "flex-wrap items-center rounded-lg border border-border bg-card p-1",
168
+ "flex-wrap items-center rounded-lg border border-[var(--field-border)] bg-[var(--well)] p-1",
164
169
  variant === "tabs" &&
165
170
  ["relative flex-nowrap items-end border-b border-border pb-0 overflow-x-auto", tabEdgeFade],
166
171
  className,
@@ -186,19 +191,13 @@ export function SegmentedControl<T extends string = string>({
186
191
  }}
187
192
  className={cn(
188
193
  "relative inline-flex items-center gap-2 whitespace-nowrap text-sm font-medium transition-[color,background-color,border-color,box-shadow]",
189
- variant === "row" && ["rounded-md px-3 py-1.5", focusRing],
194
+ variant === "row" && ["rounded-md border border-transparent px-3 py-1.5", focusRing, ixControlSoft, ixSelected],
190
195
  // The strip scrolls horizontally, which would clip an outer ring.
191
- variant === "tabs" && ["shrink-0 rounded-none border-b-2 -mb-px px-4 py-2", touchTarget, focusRingInset],
192
- // Active styling is the ONLY styled state — unselected
193
- // segments stay transparent on purpose so they don't
194
- // compete visually with the selection.
195
- active
196
- ? variant === "row"
197
- ? "border border-transparent bg-[var(--accent-surface)] text-[var(--accent-text)]"
198
- : "border-[var(--accent-text)] text-[var(--accent-text)]"
199
- : variant === "row"
200
- ? "border border-transparent text-muted-foreground hover:text-foreground"
201
- : "border-transparent text-muted-foreground hover:text-foreground",
196
+ // The border-b-2 slot stays transparent: the underline is ixTabUnderline's inset shadow.
197
+ variant === "tabs" && ["shrink-0 rounded-none border-b-2 border-transparent -mb-px px-4 py-2", touchTarget, focusRingInset, ixTabUnderline],
198
+ // Selection (aria-checked) is the only filled state; unselected
199
+ // segments stay transparent so they don't compete with it.
200
+ active ? "text-[var(--accent-text)]" : "text-muted-foreground hover:text-foreground",
202
201
  )}
203
202
  >
204
203
  <span>{option.label}</span>
@@ -4,6 +4,7 @@ import * as SelectPrimitive from "@radix-ui/react-select";
4
4
  import { Check, ChevronDown, ChevronUp } from "lucide-react";
5
5
  import * as React from "react";
6
6
  import { controlSizes, fieldPresentation, fieldSizes, fieldTouchText } from "../lib/control-presentation";
7
+ import { ixControlAfter, ixRow } from "../lib/interaction";
7
8
  import { cn } from "../lib/utils";
8
9
 
9
10
  const Select = SelectPrimitive.Root;
@@ -22,14 +23,18 @@ const SelectTrigger = React.forwardRef<
22
23
  <SelectPrimitive.Trigger
23
24
  ref={ref}
24
25
  className={cn(
25
- "flex w-full items-center justify-between gap-2 whitespace-nowrap border py-1 shadow-sm placeholder:text-[var(--text-dim)] disabled:cursor-not-allowed disabled:opacity-50",
26
+ "flex w-full items-center justify-between gap-2 whitespace-nowrap border py-1 placeholder:text-[var(--text-dim)] disabled:cursor-not-allowed disabled:opacity-50",
27
+ // A field (ixField, via fieldPresentation) that keeps a soft ring after a
28
+ // mouse click, like every control.
26
29
  fieldPresentation,
30
+ ixControlAfter,
27
31
  fieldTouchText,
28
32
  // A long value ends in an ellipsis before the chevron: line-clamp cannot
29
33
  // ellipsize a nowrap line, so it clipped mid-glyph under the icon.
30
34
  "data-[placeholder]:text-[var(--text-dim)] [&>span]:min-w-0 [&>span]:truncate [&>span]:text-left",
31
- // The designed open state: accent border and surface while the menu is up.
32
- "data-[state=open]:border-primary data-[state=open]:bg-[var(--accent-surface-strong)] [&[data-state=open]>svg]:rotate-180 [&>svg]:transition-transform",
35
+ // Open: the after-click ring stays while the menu is up, and the field
36
+ // keeps its well fill so it still reads as the field it is.
37
+ "data-[state=open]:border-[var(--ix-hover-border)] data-[state=open]:shadow-[var(--ix-after)] [&[data-state=open]>svg]:rotate-180 [&>svg]:transition-transform",
33
38
  size === "compact" || size === "touch" ? cn(fieldSizes.md, controlSizes[size]) : fieldSizes[size === "default" ? "md" : size],
34
39
  className,
35
40
  )}
@@ -86,7 +91,7 @@ const SelectContent = React.forwardRef<
86
91
  <SelectPrimitive.Content
87
92
  ref={ref}
88
93
  className={cn(
89
- "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-lg border border-border bg-card text-foreground shadow-[var(--shadow-dropdown)] data-[state=closed]:animate-out data-[state=open]:animate-in",
94
+ "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-lg border border-border bg-popover text-popover-foreground shadow-[var(--edge-highlight),var(--shadow-card)] data-[state=closed]:animate-out data-[state=open]:animate-in",
90
95
  position === "popper" &&
91
96
  "data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=bottom]:translate-y-1 data-[side=top]:-translate-y-1",
92
97
  className,
@@ -129,7 +134,8 @@ const SelectItem = React.forwardRef<
129
134
  <SelectPrimitive.Item
130
135
  ref={ref}
131
136
  className={cn(
132
- "relative flex w-full cursor-default select-none items-center rounded-md py-1.5 pr-8 pl-2 text-sm outline-none transition-colors focus:bg-[var(--accent-surface-strong)] focus:text-foreground data-[state=checked]:bg-[var(--accent-surface-strong)] data-[state=checked]:font-medium data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
137
+ "relative flex w-full cursor-default select-none items-center rounded-md py-1.5 pr-8 pl-2 text-sm outline-none transition-[color,background-color,box-shadow] focus:text-foreground data-[state=checked]:font-medium data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
138
+ ixRow,
133
139
  className,
134
140
  )}
135
141
  {...props}
@@ -1,6 +1,7 @@
1
1
  import * as TabsPrimitive from "@radix-ui/react-tabs";
2
2
  import * as React from "react";
3
3
  import { focusRing } from "../lib/focus";
4
+ import { ixControlSoft, ixSelected, ixTabUnderline } from "../lib/interaction";
4
5
  import { cn } from "../lib/utils";
5
6
 
6
7
  const Tabs = TabsPrimitive.Root;
@@ -34,13 +35,16 @@ const TabsTrigger = React.forwardRef<
34
35
  variant?: "default" | "pills" | "underline";
35
36
  }
36
37
  >(({ className, variant = "default", ...props }, ref) => {
38
+ // Selection is the shared selected state (lib/interaction.ts): a tint and an
39
+ // inset ring for default and pills, a 2px underline for underline, whose
40
+ // border-b-2 slot stays transparent.
37
41
  const variants = {
38
42
  default:
39
- `inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-all ${focusRing} disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm`,
43
+ `inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-all ${focusRing} disabled:pointer-events-none disabled:opacity-50 ${ixControlSoft} ${ixSelected}`,
40
44
  pills:
41
- `inline-flex items-center justify-center whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium transition-all ${focusRing} disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=inactive]:text-muted-foreground data-[state=inactive]:hover:bg-muted`,
45
+ `inline-flex items-center justify-center whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium transition-all ${focusRing} disabled:pointer-events-none disabled:opacity-50 data-[state=inactive]:text-muted-foreground ${ixControlSoft} ${ixSelected}`,
42
46
  underline:
43
- `inline-flex items-center justify-center whitespace-nowrap rounded-t-md pb-3 text-sm font-medium transition-all ${focusRing} disabled:pointer-events-none disabled:opacity-50 border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:text-foreground data-[state=inactive]:text-muted-foreground`,
47
+ `inline-flex items-center justify-center whitespace-nowrap rounded-t-md pb-3 text-sm font-medium transition-all ${focusRing} disabled:pointer-events-none disabled:opacity-50 border-b-2 border-transparent data-[state=inactive]:text-muted-foreground ${ixTabUnderline}`,
44
48
  };
45
49
 
46
50
  return (
@@ -64,6 +64,7 @@ import {
64
64
  type OpenUIAction,
65
65
  type OpenUIComponentNode,
66
66
  } from "../openui/openui-artifact-renderer";
67
+ import { TRANSCRIPT_TEXT } from "../chat/transcript-text";
67
68
 
68
69
  // ---------------------------------------------------------------------------
69
70
  // Default branding
@@ -427,7 +428,7 @@ export const RunGroup = memo(
427
428
  if (part.type === "text" && !part.synthetic && part.text.trim()) {
428
429
  return (
429
430
  <div key={key} className="px-0.5">
430
- <Markdown className="tangle-prose text-[15px] leading-7 text-[var(--text-primary)]">
431
+ <Markdown className={`tangle-prose ${TRANSCRIPT_TEXT} text-[var(--text-primary)]`}>
431
432
  {part.text}
432
433
  </Markdown>
433
434
  </div>
@@ -570,7 +571,7 @@ export const RunGroup = memo(
570
571
  <SyntheticText text={part.text} />
571
572
  ) : (
572
573
  <div className="px-1 py-0.5">
573
- <Markdown className="tangle-prose text-[15px] leading-7">{part.text}</Markdown>
574
+ <Markdown className={`tangle-prose ${TRANSCRIPT_TEXT}`}>{part.text}</Markdown>
574
575
  </div>
575
576
  );
576
577
  }
@@ -15,3 +15,12 @@ export {
15
15
  focusRing,
16
16
  focusRingInset,
17
17
  } from '../lib/focus';
18
+ export {
19
+ ixControl,
20
+ ixControlAfter,
21
+ ixControlSoft,
22
+ ixField,
23
+ ixRow,
24
+ ixSelected,
25
+ ixTabUnderline,
26
+ } from '../lib/interaction';