authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-22 21:33:00-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-22 21:33:00-07:00
log29931dd3a9a4996a5282f59564a64e6be496d5c6
tree119f238532a8068f809f3e278bf0983e95acc58e
parentaca3c87cdbfb59ea5ed52fe2df82df508d57eb31
signature Signed by PGP key B5690EEEBB952194

hatch/variant functions (#13191)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

14 files changed, 129 insertions(+), 97 deletions(-)

apps/web/src/components/CustomSnoozeDialog.tsx+16-30
......@@ -1,5 +1,4 @@
11import { useEffect, useId, useState } from "react";
2import { Tabs } from "@base-ui/react/tabs";
32import { create } from "zustand";
43import {
54 localSnoozeDate,
......@@ -14,7 +13,7 @@ import { weekStartsOn } from "../timestampFormat";
1413import { Popover, PopoverTrigger, PopoverPopup } from "./ui/popover";
1514import { Input } from "./ui/input";
1615import { Label } from "./ui/label";
17import { toggleVariants } from "./ui/toggle";
16import { Toggle, ToggleGroup } from "./ui/toggle-group";
1817import { Select, SelectTrigger, SelectValue, SelectPopup, SelectItem } from "./ui/select";
1918import {
2019 NumberField,
......@@ -95,34 +94,21 @@ function CustomSnoozeDialog() {
9594 <DialogDescription>Choose when snoozed threads return to your inbox.</DialogDescription>
9695 </DialogHeader>
9796 <DialogPanel className="text-base sm:text-sm">
98 <Tabs.Root
99 value={mode}
100 onValueChange={(value) => {
101 if (value === "date" || value === "duration") setMode(value);
102 setError(null);
103 }}
104 className="flex flex-col gap-4"
105 >
106 <Tabs.List
97 <div className="flex flex-col gap-4">
98 <ToggleGroup
10799 aria-label="Schedule type"
108 className="flex gap-0.5 rounded-lg bg-input/40 p-0.5"
100 className="w-full *:flex-1"
101 value={[mode]}
102 onValueChange={(next) => {
103 const value = next[0];
104 if (value === "date" || value === "duration") setMode(value);
105 setError(null);
106 }}
109107 >
110 {(["date", "duration"] as const).map((value) => (
111 <Tabs.Tab
112 key={value}
113 value={value}
114 data-pressed={mode === value ? "" : undefined}
115 className={toggleVariants({
116 variant: "segmented",
117 size: "sm",
118 className: "flex-1",
119 })}
120 >
121 {value === "date" ? "Date and time" : "Duration"}
122 </Tabs.Tab>
123 ))}
124 </Tabs.List>
125 <Tabs.Panel value={mode} className="flex flex-col gap-4">
108 <Toggle value="date">Date and time</Toggle>
109 <Toggle value="duration">Duration</Toggle>
110 </ToggleGroup>
111 <div className="flex flex-col gap-4">
126112 {mode === "date" ? (
127113 <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
128114 <div className="flex min-w-0 flex-col gap-1.5">
......@@ -226,8 +212,8 @@ function CustomSnoozeDialog() {
226212 </Label>
227213 </div>
228214 )}
229 </Tabs.Panel>
230 </Tabs.Root>
215 </div>
216 </div>
231217 {error && (
232218 <p role="alert" className="text-destructive">
233219 {error}
apps/web/src/components/GitActionsControl.tsx+8-19
......@@ -48,7 +48,7 @@ import {
4848} from "~/components/Icons";
4949import { RadioGroup } from "~/components/ui/radio-group";
5050import { Spinner } from "~/components/ui/spinner";
51import { toggleVariants } from "~/components/ui/toggle";
51import { Toggle, ToggleGroup } from "~/components/ui/toggle-group";
5252import { cn } from "~/lib/utils";
5353import {
5454 buildGitActionProgressStages,
......@@ -815,10 +815,10 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
815815 >
816816 Protocol
817817 </span>
818 <RadioGroup
819 className="w-fit flex-row gap-0.5 rounded-lg bg-input/40 p-0.5"
820 value={publishProtocol}
821 onValueChange={(protocol) => {
818 <ToggleGroup
819 value={[publishProtocol]}
820 onValueChange={(next) => {
821 const protocol = next[0];
822822 if (protocol === "ssh" || protocol === "https") {
823823 setPublishProtocol(protocol);
824824 }
......@@ -826,20 +826,9 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
826826 aria-labelledby="publish-protocol-label"
827827 disabled={publishRepositoryAction.isPending}
828828 >
829 {(["ssh", "https"] as const).map((protocol) => (
830 <RadioPrimitive.Root
831 key={protocol}
832 value={protocol}
833 data-pressed={publishProtocol === protocol ? "" : undefined}
834 className={toggleVariants({
835 variant: "segmented",
836 size: "segmented",
837 })}
838 >
839 {protocol.toUpperCase()}
840 </RadioPrimitive.Root>
841 ))}
842 </RadioGroup>
829 <Toggle value="ssh">SSH</Toggle>
830 <Toggle value="https">HTTPS</Toggle>
831 </ToggleGroup>
843832 </div>
844833 </div>
845834 ) : null}
apps/web/src/components/chat/ComposerBanner.tsx+10-10
......@@ -4,7 +4,7 @@ import { ChevronDownIcon, XIcon } from "lucide-react";
44import type { ComponentProps } from "react";
55
66import { cn } from "~/lib/utils";
7import { Button, buttonVariants } from "../ui/button";
7import { Button } from "../ui/button";
88import { ScrollArea } from "../ui/scroll-area";
99
1010export type ComposerBannerVariant = "default" | "error" | "info" | "success" | "warning";
......@@ -323,18 +323,18 @@ function Dot({ className, ...props }: ComponentProps<"span">) {
323323 );
324324}
325325
326function ToggleIcon({ expanded, className }: { expanded: boolean; className?: string }) {
326// Decorative: the row itself is the control, so this only matches Dismiss's box.
327function ToggleIcon({ expanded }: { expanded: boolean }) {
327328 return (
328 <span
329 aria-hidden
330 className={cn(
331 buttonVariants({ size: "icon-xs", variant: "ghost" }),
332 "pointer-events-none",
333 className,
334 )}
329 <Button
330 render={<span aria-hidden />}
331 size="icon-xs"
332 variant="ghost"
333 tabIndex={-1}
334 className="pointer-events-none"
335335 >
336336 <ChevronDownIcon className={cn("size-3.5", !expanded && "rotate-180")} />
337 </span>
337 </Button>
338338 );
339339}
340340
apps/web/src/components/chat/ProviderModelPicker.tsx+2-3
......@@ -5,9 +5,8 @@ import {
55 type ResolvedKeybindingsConfig,
66} from "@t3tools/contracts";
77import { memo, useEffect, useMemo, useState } from "react";
8import type { VariantProps } from "class-variance-authority";
98import { Badge } from "../ui/badge";
10import { buttonVariants } from "../ui/button";
9import type { ButtonVariant } from "../ui/button";
1110import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
1211import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
1312import { cn } from "~/lib/utils";
......@@ -49,7 +48,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
4948 disabled?: boolean;
5049 terminalOpen?: boolean;
5150 open?: boolean;
52 triggerVariant?: VariantProps<typeof buttonVariants>["variant"];
51 triggerVariant?: ButtonVariant;
5352 triggerClassName?: string;
5453 /** Aggregate settings can show a neutral value without claiming one provider is selected. */
5554 triggerLabel?: string;
apps/web/src/components/chat/TraitsPicker.tsx+2-3
......@@ -16,9 +16,8 @@ import {
1616 normalizeModelSlug,
1717} from "@t3tools/shared/model";
1818import { memo, useCallback } from "react";
19import type { VariantProps } from "class-variance-authority";
2019import { GaugeIcon, ZapIcon } from "lucide-react";
21import { buttonVariants } from "../ui/button";
20import type { ButtonVariant } from "../ui/button";
2221import {
2322 Menu,
2423 MenuGroup,
......@@ -277,7 +276,7 @@ export interface TraitsMenuContentProps {
277276 modelOptions?: ProviderOptions | null | undefined;
278277 allowPromptInjectedEffort?: boolean;
279278 planModeEnabled: boolean;
280 triggerVariant?: VariantProps<typeof buttonVariants>["variant"];
279 triggerVariant?: ButtonVariant;
281280 triggerClassName?: string;
282281 isComposerOwned?: boolean;
283282}
apps/web/src/components/chat/composerProviderState.tsx+2-3
......@@ -13,10 +13,9 @@ import {
1313 isClaudeUltrathinkPrompt,
1414 normalizeModelSlug,
1515} from "@t3tools/shared/model";
16import type { VariantProps } from "class-variance-authority";
1716import type { ReactNode } from "react";
1817
19import type { buttonVariants } from "../ui/button";
18import type { ButtonVariant } from "../ui/button";
2019import type { DraftId } from "../../composerDraftStore";
2120import { getProviderModelCapabilities } from "../../providerModels";
2221import type { ComposerControlSize } from "./ComposerControl";
......@@ -55,7 +54,7 @@ type TraitsRenderInput = {
5554 planModeEnabled: boolean;
5655 size?: ComposerControlSize;
5756 hidden?: boolean;
58 triggerVariant?: VariantProps<typeof buttonVariants>["variant"];
57 triggerVariant?: ButtonVariant;
5958 triggerClassName?: string;
6059 isComposerOwned?: boolean;
6160};
apps/web/src/components/settings/FontFamilyPicker.tsx+4-7
......@@ -1,5 +1,5 @@
11import { LegendList, type LegendListRef } from "@legendapp/list/react";
2import { CheckIcon, ChevronDownIcon } from "lucide-react";
2import { CheckIcon } from "lucide-react";
33import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
44import { isMonospaceFamily, queryInstalledFontFamilies } from "../../appearanceFonts";
55import {
......@@ -11,7 +11,7 @@ import {
1111 ComboboxPopup,
1212 ComboboxTrigger,
1313} from "../ui/combobox";
14import { selectTriggerVariants } from "../ui/select";
14import { SelectButton } from "../ui/select";
1515
1616const DEFAULT_FONT_VALUE = "__default__";
1717
......@@ -204,11 +204,8 @@ export function FontFamilyPicker({
204204 void listRef.current?.scrollIndexIntoView?.({ index: eventDetails.index, animated: false });
205205 }}
206206 >
207 <ComboboxTrigger aria-label={ariaLabel} className={selectTriggerVariants({ size: "sm" })}>
208 <span className="min-w-0 truncate">
209 {selectedFamily.length === 0 ? defaultFamily : selectedFamily}
210 </span>
211 <ChevronDownIcon className="-me-1 size-3 opacity-50" />
207 <ComboboxTrigger aria-label={ariaLabel} render={<SelectButton size="sm" />}>
208 {selectedFamily.length === 0 ? defaultFamily : selectedFamily}
212209 </ComboboxTrigger>
213210 <ComboboxPopup align="end" className="flex w-72 flex-col">
214211 <ComboboxSearchInput
apps/web/src/components/settings/SnapShotSettings.tsx+13-17
......@@ -7,11 +7,9 @@ import {
77 type DesktopSnapShotSetupAction,
88 type SnapShotShortcut,
99} from "@t3tools/contracts";
10import { ChevronDownIcon, PlayIcon } from "lucide-react";
10import { PlayIcon } from "lucide-react";
1111import { useCallback, useEffect, useRef, useState } from "react";
1212
13import { cn } from "~/lib/utils";
14
1513import { useClientSettings, useUpdateClientSettings } from "../../hooks/useSettings";
1614import { getDesktopSnapShotBridge } from "../../lib/desktopSnapShot";
1715import {
......@@ -44,7 +42,7 @@ import {
4442import { searchableSetting } from "./settingsSearch";
4543import { Button } from "../ui/button";
4644import { Menu, MenuItem, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../ui/menu";
47import { selectTriggerVariants } from "../ui/select";
45import { SelectButton } from "../ui/select";
4846import { Switch } from "../ui/switch";
4947import { toastManager } from "../ui/toast";
5048import { SnapShotSetupDialog } from "./SnapShotSetupDialog";
......@@ -497,21 +495,19 @@ export function SnapShotSettings() {
497495 <Menu>
498496 <MenuTrigger
499497 aria-label={"Snapshot sound: " + soundLabel}
500 className={cn(selectTriggerVariants({ size: "sm" }), "w-auto min-w-0")}
498 render={<SelectButton size="sm" />}
499 className="w-auto min-w-0"
501500 disabled={!captureAvailable}
502501 >
503 <span className="min-w-0 flex-1 truncate text-left">
504 {soundSelection === "off" ? (
505 "Off"
506 ) : soundSelection === "soft-pop" ? (
507 <>
508 Whoosh <span className="text-muted-foreground">(Default)</span>
509 </>
510 ) : (
511 "Click"
512 )}
513 </span>
514 <ChevronDownIcon className="-me-1 size-3 shrink-0 opacity-50" />
502 {soundSelection === "off" ? (
503 "Off"
504 ) : soundSelection === "soft-pop" ? (
505 <>
506 Whoosh <span className="text-muted-foreground">(Default)</span>
507 </>
508 ) : (
509 "Click"
510 )}
515511 </MenuTrigger>
516512 <MenuPopup align="end">
517513 <MenuRadioGroup
apps/web/src/components/ui/badge.tsx+1
......@@ -60,4 +60,5 @@ function Badge({ className, variant, size, render, ...props }: BadgeProps) {
6060 });
6161}
6262
63// The *Variants function is for components/ui modules only.
6364export { Badge, badgeVariants };
apps/web/src/components/ui/button.tsx+8-3
......@@ -71,9 +71,12 @@ const buttonVariants = cva(
7171 },
7272);
7373
74type ButtonVariant = NonNullable<VariantProps<typeof buttonVariants>["variant"]>;
75type ButtonSize = NonNullable<VariantProps<typeof buttonVariants>["size"]>;
76
7477interface ButtonProps extends useRender.ComponentProps<"button"> {
75 variant?: VariantProps<typeof buttonVariants>["variant"];
76 size?: VariantProps<typeof buttonVariants>["size"];
78 variant?: ButtonVariant;
79 size?: ButtonSize;
7780}
7881
7982function Button({ className, variant, size, render, ...props }: ButtonProps) {
......@@ -95,7 +98,9 @@ function Button({ className, variant, size, render, ...props }: ButtonProps) {
9598 });
9699}
97100
98export { Button, buttonVariants };
101// buttonVariants is exported for other components/ui modules only; app code
102// renders a Button (with `render` for other elements) instead.
103export { Button, buttonVariants, type ButtonSize, type ButtonVariant };
99104
100105const inlineButtonVariants = cva(
101106 "inline-flex shrink-0 cursor-pointer items-center gap-0.5 whitespace-nowrap font-medium underline-offset-2 hover:underline focus-visible:outline-2 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-64",
apps/web/src/components/ui/select.tsx+34-1
......@@ -1,5 +1,7 @@
11"use client";
2import { mergeProps } from "@base-ui/react/merge-props";
23import { Select as SelectPrimitive } from "@base-ui/react/select";
4import { useRender } from "@base-ui/react/use-render";
35import { cva, type VariantProps } from "class-variance-authority";
46import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
57import type * as React from "react";
......@@ -57,6 +59,37 @@ function SelectTrigger({
5759 );
5860}
5961
62/**
63 * The select-field look for a picker that is not a Select, such as a Menu or
64 * Combobox trigger. Render it as that trigger: `<MenuTrigger render={<SelectButton />}>`.
65 */
66function SelectButton({
67 className,
68 size = "default",
69 children,
70 render,
71 ...props
72}: useRender.ComponentProps<"button"> & Pick<VariantProps<typeof selectTriggerVariants>, "size">) {
73 const defaultProps = {
74 className: cn(selectTriggerVariants({ size }), className),
75 "data-slot": "select-trigger",
76 type: render ? undefined : ("button" as const),
77 };
78 return useRender({
79 defaultTagName: "button",
80 props: {
81 ...mergeProps<"button">(defaultProps, props),
82 children: (
83 <>
84 <span className="min-w-0 flex-1 truncate text-left">{children}</span>
85 <ChevronDownIcon aria-hidden className="-me-1 size-3 shrink-0 opacity-50" />
86 </>
87 ),
88 },
89 render,
90 });
91}
92
6093function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
6194 return (
6295 <SelectPrimitive.Value
......@@ -182,7 +215,7 @@ function SelectGroupLabel(props: SelectPrimitive.GroupLabel.Props) {
182215export {
183216 Select,
184217 SelectTrigger,
185 selectTriggerVariants,
218 SelectButton,
186219 SelectValue,
187220 SelectPopup,
188221 SelectPopup as SelectContent,
apps/web/src/components/ui/toggle.tsx+1
......@@ -52,4 +52,5 @@ function Toggle({
5252 );
5353}
5454
55// The *Variants function is for components/ui modules only.
5556export { Toggle, toggleVariants };
scripts/lint-restyle-ceiling.ts+1-1
......@@ -8,7 +8,7 @@ import * as NodeURL from "node:url";
88// components/ui exports are migrated to variants (see vite.config.ts). This gate keeps the
99// count from growing: CI fails when findings exceed the ceiling. Lower the ceiling when you
1010// migrate a file, and delete this script when the rule becomes an error.
11export const RESTYLE_CEILING = 627;
11export const RESTYLE_CEILING = 623;
1212
1313const RULE = "shadcn(no-restyle)";
1414
vite.config.ts+27
......@@ -16,6 +16,20 @@ const RESTRICTED_IMPORT_PATHS = [
1616 },
1717];
1818
19/**
20 * The cva functions behind components/ui exports. They style a foreign element to look
21 * like a Button or Toggle, which bypasses the component's variants; render the component
22 * instead (`render={<Button …/>}`, or `SelectButton` for a picker trigger).
23 */
24const RESTRICTED_UI_VARIANT_PATTERNS = [
25 {
26 group: ["**/components/ui/*", "**/ui/*", "./ui/*"],
27 importNames: ["buttonVariants", "toggleVariants", "badgeVariants", "selectTriggerVariants"],
28 message:
29 "Render the components/ui export instead of borrowing its class recipe (render={<Button …/>}, SelectButton, ToggleGroup).",
30 },
31];
32
1933/** Lucide's pull-request glyphs, which only `pullRequestIcons.tsx` may name. */
2034const RESTRICTED_PULL_REQUEST_GLYPH_IMPORTS = {
2135 name: "lucide-react",
......@@ -160,6 +174,19 @@ export default defineConfig({
160174 files: ["packages/shared/src/hostProcess.ts"],
161175 rules: { "t3code/no-global-process-runtime": "off" },
162176 },
177 {
178 files: ["apps/web/src/**"],
179 excludeFiles: ["apps/web/src/components/ui/**"],
180 rules: {
181 "eslint/no-restricted-imports": [
182 "error",
183 {
184 paths: [...RESTRICTED_IMPORT_PATHS, RESTRICTED_PULL_REQUEST_GLYPH_IMPORTS],
185 patterns: RESTRICTED_UI_VARIANT_PATTERNS,
186 },
187 ],
188 },
189 },
163190 {
164191 // The one module allowed to name lucide's pull-request glyphs; everything else picks
165192 // from its vocabulary. The other import restrictions still apply here.