authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-22 12:22:16-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-22 12:22:16-07:00
log4c9fbf1f647c83179af1c2acd78eded04ef07fe8
treef71d7ba480e2521fd09e53e4944d2db8af0db2b0
parent18aa7036732c0fc2e2ab79e54d5d8f59a160843d
signature Signed by PGP key B5690EEEBB952194

refactor(web): InlineButton underlines on hover and takes a tone (#13034)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

9 files changed, 65 insertions(+), 62 deletions(-)

apps/web/src/components/Sidebar.tsx+1-1
......@@ -1374,7 +1374,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
13741374 if (!showSnoozeButton) setSnoozeMenuOpen(false);
13751375 }, [showSnoozeButton]);
13761376 const handlePrClick = useCallback(
1377 (event: ReactMouseEvent<HTMLAnchorElement>) => {
1377 (event: ReactMouseEvent<HTMLElement>) => {
13781378 const url = pr?.url ?? currentLinkedPr?.url;
13791379 if (!url) return;
13801380 const openedInRightPanel = openPrLink(
apps/web/src/components/ThreadStatusIndicators.tsx+27-25
......@@ -16,7 +16,7 @@ import {
1616} from "@t3tools/shared/threadPullRequests";
1717import { FolderGit2Icon, TerminalIcon } from "lucide-react";
1818import { useMemo, type MouseEvent } from "react";
19import { buttonVariants, InlineButton } from "./ui/button";
19import { Button, InlineButton } from "./ui/button";
2020import { cn } from "../lib/utils";
2121import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments";
2222import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
......@@ -207,50 +207,52 @@ export function ThreadPullRequestBadgeControl({
207207 url?: string | undefined;
208208 status: PrStatusIndicator | null;
209209 onOpenStack: () => void;
210 onOpenPullRequest: (event: MouseEvent<HTMLAnchorElement>) => void;
210 onOpenPullRequest: (event: MouseEvent<HTMLElement>) => void;
211211}) {
212212 const presentation = resolveThreadPullRequestBadgePresentation({ badge, number, url, status });
213213 if (presentation === null) return null;
214214 const isStack = badge?.kind === "stack";
215 const className = cn(
216 variant === "ghost"
217 ? buttonVariants({ variant: "ghost", size: "xs" })
218 : "inline-flex shrink-0 cursor-pointer items-center gap-0.5 whitespace-nowrap border-b border-transparent hover:border-current focus-visible:outline-2 focus-visible:outline-ring",
219 "text-xs tabular-nums",
220 variant === "ghost" &&
221 "font-normal text-xs! active:scale-100 [--control-icon-color:currentColor]",
222 presentation.toneClassName,
223 );
224215 const content = (
225216 <>
226217 <presentation.Icon aria-hidden className="size-3 shrink-0" />
227218 {presentation.text}
228219 </>
229220 );
221 const linkProps = isStack
222 ? {
223 onClick: (event: MouseEvent<HTMLElement>) => {
224 event.preventDefault();
225 event.stopPropagation();
226 onOpenStack();
227 },
228 }
229 : { onClick: onOpenPullRequest };
230 const element = isStack ? (
231 <button type="button" />
232 ) : (
233 <a href={url} target="_blank" rel="noopener noreferrer" />
234 );
230235 return (
231236 <Tooltip>
232237 <TooltipTrigger
233238 render={
234 isStack ? (
235 <InlineButton
236 className={className}
239 variant === "ghost" ? (
240 <Button
241 render={element}
242 variant="ghost"
243 size="xs"
244 className={presentation.toneClassName}
237245 aria-label={presentation.label}
238246 onPointerDown={(event) => event.stopPropagation()}
239 onClick={(event) => {
240 event.preventDefault();
241 event.stopPropagation();
242 onOpenStack();
243 }}
247 {...linkProps}
244248 />
245249 ) : (
246 <a
247 href={url}
248 target="_blank"
249 rel="noopener noreferrer"
250 className={className}
250 <InlineButton
251 render={element}
252 className={presentation.toneClassName}
251253 aria-label={presentation.label}
252254 onPointerDown={(event) => event.stopPropagation()}
253 onClick={onOpenPullRequest}
255 {...linkProps}
254256 />
255257 )
256258 }
apps/web/src/components/pullRequest/PullRequestGhosts.tsx+1-4
......@@ -169,10 +169,7 @@ export function PullRequestDetailGhost({
169169 <span className="min-w-0 truncate font-medium">{seed.repository}</span>
170170 <InlineButton
171171 onClick={() => void readLocalApi()?.shell.openExternal(seed.url)}
172 className={cn(
173 "font-medium underline-offset-2 hover:underline",
174 statePresentation?.toneClassName,
175 )}
172 className={statePresentation?.toneClassName}
176173 aria-label={`Open pull request #${seed.number} on host`}
177174 >
178175 #{seed.number}
apps/web/src/components/settings/ExpandableText.tsx+2-1
......@@ -37,7 +37,8 @@ export function ExpandableText({
3737 <InlineButton
3838 aria-expanded={expanded}
3939 aria-controls={textId}
40 className="mt-1 text-[11px] font-medium text-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
40 tone="muted"
41 className="mt-1"
4142 onClick={() => setExpanded((value) => !value)}
4243 >
4344 {expanded ? "Show less" : expandLabel}
apps/web/src/components/settings/ProcessSignalActions.tsx+2-2
......@@ -19,7 +19,7 @@ export function ProcessSignalActions({
1919 <InlineButton
2020 disabled={disabled}
2121 aria-label="Send SIGINT"
22 className="text-[11px] font-medium text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
22 tone="muted"
2323 onClick={() => onSignal("SIGINT")}
2424 >
2525 INT
......@@ -34,7 +34,7 @@ export function ProcessSignalActions({
3434 <InlineButton
3535 disabled={disabled}
3636 aria-label="Send SIGKILL"
37 className="text-[11px] font-medium text-destructive underline-offset-2 hover:underline"
37 tone="destructive"
3838 onClick={() => onSignal("SIGKILL")}
3939 >
4040 KILL
apps/web/src/components/settings/SettingInheritance.tsx+2-8
......@@ -286,10 +286,7 @@ export function SettingInheritance({
286286 <div className="flex items-center justify-between gap-3 px-2 text-xs text-muted-foreground">
287287 <span>Overridden by</span>
288288 {onClearOverrides ? (
289 <InlineButton
290 className="font-medium text-foreground underline-offset-2 hover:underline"
291 onClick={() => onClearOverrides(overriding)}
292 >
289 <InlineButton onClick={() => onClearOverrides(overriding)}>
293290 Reset {overriding.length === 1 ? "it" : "all"}
294291 </InlineButton>
295292 ) : null}
......@@ -300,10 +297,7 @@ export function SettingInheritance({
300297 key={project.projectId}
301298 className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 px-2 py-1"
302299 >
303 <InlineButton
304 className="min-w-0 justify-start text-left text-foreground underline-offset-2 hover:underline"
305 onClick={project.open}
306 >
300 <InlineButton className="min-w-0 justify-start" onClick={project.open}>
307301 <span className="truncate">{project.label}</span>
308302 </InlineButton>
309303 <span className="max-w-32 truncate text-muted-foreground tabular-nums">
apps/web/src/components/sidebar/SidebarThreadUndoNotice.tsx+1-5
......@@ -17,11 +17,7 @@ export function SidebarThreadUndoNotice() {
1717 <Alert role="status" variant="sidebar">
1818 <AlertDescription>
1919 {notice.action} {notice.count} thread{notice.count === 1 ? "" : "s"},{" "}
20 <InlineButton
21 underline
22 onClick={undoLatestThreadAction}
23 className="hover:text-sidebar-foreground"
24 >
20 <InlineButton onClick={undoLatestThreadAction}>
2521 {shortcut ? `${shortcut} to undo` : "Undo"}
2622 </InlineButton>
2723 </AlertDescription>
apps/web/src/components/ui/button.tsx+28-15
......@@ -97,22 +97,35 @@ function Button({ className, variant, size, render, ...props }: ButtonProps) {
9797
9898export { Button, buttonVariants };
9999
100/** An inline action that keeps the geometry of surrounding text or a graph node. */
100const inlineButtonVariants = cva(
101 "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",
102 {
103 defaultVariants: { tone: "default" },
104 variants: {
105 tone: {
106 default: "text-foreground",
107 muted: "text-muted-foreground hover:text-foreground",
108 destructive: "text-destructive/80 hover:text-destructive",
109 },
110 },
111 },
112);
113
114/** An inline text action that keeps the size of the surrounding text and underlines on hover. */
101115export function InlineButton({
102116 className,
103 underline = false,
117 tone,
118 render,
104119 ...props
105}: React.ComponentProps<"button"> & { underline?: boolean }) {
106 return (
107 <button
108 type="button"
109 data-slot="inline-button"
110 className={cn(
111 "inline-flex shrink-0 cursor-pointer items-center gap-0.5 whitespace-nowrap focus-visible:outline-2 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-64",
112 underline && "border-b border-transparent hover:border-current",
113 className,
114 )}
115 {...props}
116 />
117 );
120}: useRender.ComponentProps<"button"> & VariantProps<typeof inlineButtonVariants>) {
121 const defaultProps = {
122 className: cn(inlineButtonVariants({ tone }), className),
123 "data-slot": "inline-button",
124 type: render ? undefined : ("button" as const),
125 };
126 return useRender({
127 defaultTagName: "button",
128 props: mergeProps<"button">(defaultProps, props),
129 render,
130 });
118131}
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 = 865;
11export const RESTYLE_CEILING = 826;
1212
1313const RULE = "shadcn(no-restyle)";
1414