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: {...@@ -1374,7 +1374,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1374 if (!showSnoozeButton) setSnoozeMenuOpen(false);1374 if (!showSnoozeButton) setSnoozeMenuOpen(false);
1375 }, [showSnoozeButton]);1375 }, [showSnoozeButton]);
1376 const handlePrClick = useCallback(1376 const handlePrClick = useCallback(
1377 (event: ReactMouseEvent<HTMLAnchorElement>) => {1377 (event: ReactMouseEvent<HTMLElement>) => {
1378 const url = pr?.url ?? currentLinkedPr?.url;1378 const url = pr?.url ?? currentLinkedPr?.url;
1379 if (!url) return;1379 if (!url) return;
1380 const openedInRightPanel = openPrLink(1380 const openedInRightPanel = openPrLink(
apps/web/src/components/ThreadStatusIndicators.tsx+27-25
...@@ -16,7 +16,7 @@ import {...@@ -16,7 +16,7 @@ import {
16} from "@t3tools/shared/threadPullRequests";16} from "@t3tools/shared/threadPullRequests";
17import { FolderGit2Icon, TerminalIcon } from "lucide-react";17import { FolderGit2Icon, TerminalIcon } from "lucide-react";
18import { useMemo, type MouseEvent } from "react";18import { useMemo, type MouseEvent } from "react";
19import { buttonVariants, InlineButton } from "./ui/button";19import { Button, InlineButton } from "./ui/button";
20import { cn } from "../lib/utils";20import { cn } from "../lib/utils";
21import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments";21import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments";
22import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";22import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
...@@ -207,50 +207,52 @@ export function ThreadPullRequestBadgeControl({...@@ -207,50 +207,52 @@ export function ThreadPullRequestBadgeControl({
207 url?: string | undefined;207 url?: string | undefined;
208 status: PrStatusIndicator | null;208 status: PrStatusIndicator | null;
209 onOpenStack: () => void;209 onOpenStack: () => void;
210 onOpenPullRequest: (event: MouseEvent<HTMLAnchorElement>) => void;210 onOpenPullRequest: (event: MouseEvent<HTMLElement>) => void;
211}) {211}) {
212 const presentation = resolveThreadPullRequestBadgePresentation({ badge, number, url, status });212 const presentation = resolveThreadPullRequestBadgePresentation({ badge, number, url, status });
213 if (presentation === null) return null;213 if (presentation === null) return null;
214 const isStack = badge?.kind === "stack";214 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 );
224 const content = (215 const content = (
225 <>216 <>
226 <presentation.Icon aria-hidden className="size-3 shrink-0" />217 <presentation.Icon aria-hidden className="size-3 shrink-0" />
227 {presentation.text}218 {presentation.text}
228 </>219 </>
229 );220 );
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 );
230 return (235 return (
231 <Tooltip>236 <Tooltip>
232 <TooltipTrigger237 <TooltipTrigger
233 render={238 render={
234 isStack ? (239 variant === "ghost" ? (
235 <InlineButton240 <Button
236 className={className}241 render={element}
242 variant="ghost"
243 size="xs"
244 className={presentation.toneClassName}
237 aria-label={presentation.label}245 aria-label={presentation.label}
238 onPointerDown={(event) => event.stopPropagation()}246 onPointerDown={(event) => event.stopPropagation()}
239 onClick={(event) => {247 {...linkProps}
240 event.preventDefault();
241 event.stopPropagation();
242 onOpenStack();
243 }}
244 />248 />
245 ) : (249 ) : (
246 <a250 <InlineButton
247 href={url}251 render={element}
248 target="_blank"252 className={presentation.toneClassName}
249 rel="noopener noreferrer"
250 className={className}
251 aria-label={presentation.label}253 aria-label={presentation.label}
252 onPointerDown={(event) => event.stopPropagation()}254 onPointerDown={(event) => event.stopPropagation()}
253 onClick={onOpenPullRequest}255 {...linkProps}
254 />256 />
255 )257 )
256 }258 }
apps/web/src/components/pullRequest/PullRequestGhosts.tsx+1-4
...@@ -169,10 +169,7 @@ export function PullRequestDetailGhost({...@@ -169,10 +169,7 @@ export function PullRequestDetailGhost({
169 <span className="min-w-0 truncate font-medium">{seed.repository}</span>169 <span className="min-w-0 truncate font-medium">{seed.repository}</span>
170 <InlineButton170 <InlineButton
171 onClick={() => void readLocalApi()?.shell.openExternal(seed.url)}171 onClick={() => void readLocalApi()?.shell.openExternal(seed.url)}
172 className={cn(172 className={statePresentation?.toneClassName}
173 "font-medium underline-offset-2 hover:underline",
174 statePresentation?.toneClassName,
175 )}
176 aria-label={`Open pull request #${seed.number} on host`}173 aria-label={`Open pull request #${seed.number} on host`}
177 >174 >
178 #{seed.number}175 #{seed.number}
apps/web/src/components/settings/ExpandableText.tsx+2-1
...@@ -37,7 +37,8 @@ export function ExpandableText({...@@ -37,7 +37,8 @@ export function ExpandableText({
37 <InlineButton37 <InlineButton
38 aria-expanded={expanded}38 aria-expanded={expanded}
39 aria-controls={textId}39 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"
41 onClick={() => setExpanded((value) => !value)}42 onClick={() => setExpanded((value) => !value)}
42 >43 >
43 {expanded ? "Show less" : expandLabel}44 {expanded ? "Show less" : expandLabel}
apps/web/src/components/settings/ProcessSignalActions.tsx+2-2
...@@ -19,7 +19,7 @@ export function ProcessSignalActions({...@@ -19,7 +19,7 @@ export function ProcessSignalActions({
19 <InlineButton19 <InlineButton
20 disabled={disabled}20 disabled={disabled}
21 aria-label="Send SIGINT"21 aria-label="Send SIGINT"
22 className="text-[11px] font-medium text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"22 tone="muted"
23 onClick={() => onSignal("SIGINT")}23 onClick={() => onSignal("SIGINT")}
24 >24 >
25 INT25 INT
...@@ -34,7 +34,7 @@ export function ProcessSignalActions({...@@ -34,7 +34,7 @@ export function ProcessSignalActions({
34 <InlineButton34 <InlineButton
35 disabled={disabled}35 disabled={disabled}
36 aria-label="Send SIGKILL"36 aria-label="Send SIGKILL"
37 className="text-[11px] font-medium text-destructive underline-offset-2 hover:underline"37 tone="destructive"
38 onClick={() => onSignal("SIGKILL")}38 onClick={() => onSignal("SIGKILL")}
39 >39 >
40 KILL40 KILL
apps/web/src/components/settings/SettingInheritance.tsx+2-8
...@@ -286,10 +286,7 @@ export function SettingInheritance({...@@ -286,10 +286,7 @@ export function SettingInheritance({
286 <div className="flex items-center justify-between gap-3 px-2 text-xs text-muted-foreground">286 <div className="flex items-center justify-between gap-3 px-2 text-xs text-muted-foreground">
287 <span>Overridden by</span>287 <span>Overridden by</span>
288 {onClearOverrides ? (288 {onClearOverrides ? (
289 <InlineButton289 <InlineButton onClick={() => onClearOverrides(overriding)}>
290 className="font-medium text-foreground underline-offset-2 hover:underline"
291 onClick={() => onClearOverrides(overriding)}
292 >
293 Reset {overriding.length === 1 ? "it" : "all"}290 Reset {overriding.length === 1 ? "it" : "all"}
294 </InlineButton>291 </InlineButton>
295 ) : null}292 ) : null}
...@@ -300,10 +297,7 @@ export function SettingInheritance({...@@ -300,10 +297,7 @@ export function SettingInheritance({
300 key={project.projectId}297 key={project.projectId}
301 className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 px-2 py-1"298 className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 px-2 py-1"
302 >299 >
303 <InlineButton300 <InlineButton className="min-w-0 justify-start" onClick={project.open}>
304 className="min-w-0 justify-start text-left text-foreground underline-offset-2 hover:underline"
305 onClick={project.open}
306 >
307 <span className="truncate">{project.label}</span>301 <span className="truncate">{project.label}</span>
308 </InlineButton>302 </InlineButton>
309 <span className="max-w-32 truncate text-muted-foreground tabular-nums">303 <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() {...@@ -17,11 +17,7 @@ export function SidebarThreadUndoNotice() {
17 <Alert role="status" variant="sidebar">17 <Alert role="status" variant="sidebar">
18 <AlertDescription>18 <AlertDescription>
19 {notice.action} {notice.count} thread{notice.count === 1 ? "" : "s"},{" "}19 {notice.action} {notice.count} thread{notice.count === 1 ? "" : "s"},{" "}
20 <InlineButton20 <InlineButton onClick={undoLatestThreadAction}>
21 underline
22 onClick={undoLatestThreadAction}
23 className="hover:text-sidebar-foreground"
24 >
25 {shortcut ? `${shortcut} to undo` : "Undo"}21 {shortcut ? `${shortcut} to undo` : "Undo"}
26 </InlineButton>22 </InlineButton>
27 </AlertDescription>23 </AlertDescription>
apps/web/src/components/ui/button.tsx+28-15
...@@ -97,22 +97,35 @@ function Button({ className, variant, size, render, ...props }: ButtonProps) {...@@ -97,22 +97,35 @@ function Button({ className, variant, size, render, ...props }: ButtonProps) {
9797
98export { Button, buttonVariants };98export { 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. */
101export function InlineButton({115export function InlineButton({
102 className,116 className,
103 underline = false,117 tone,
118 render,
104 ...props119 ...props
105}: React.ComponentProps<"button"> & { underline?: boolean }) {120}: useRender.ComponentProps<"button"> & VariantProps<typeof inlineButtonVariants>) {
106 return (121 const defaultProps = {
107 <button122 className: cn(inlineButtonVariants({ tone }), className),
108 type="button"123 "data-slot": "inline-button",
109 data-slot="inline-button"124 type: render ? undefined : ("button" as const),
110 className={cn(125 };
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",126 return useRender({
112 underline && "border-b border-transparent hover:border-current",127 defaultTagName: "button",
113 className,128 props: mergeProps<"button">(defaultProps, props),
114 )}129 render,
115 {...props}130 });
116 />
117 );
118}131}
scripts/lint-restyle-ceiling.ts+1-1
...@@ -8,7 +8,7 @@ import * as NodeURL from "node:url";...@@ -8,7 +8,7 @@ import * as NodeURL from "node:url";
8// components/ui exports are migrated to variants (see vite.config.ts). This gate keeps the8// components/ui exports are migrated to variants (see vite.config.ts). This gate keeps the
9// count from growing: CI fails when findings exceed the ceiling. Lower the ceiling when you9// count from growing: CI fails when findings exceed the ceiling. Lower the ceiling when you
10// migrate a file, and delete this script when the rule becomes an error.10// migrate a file, and delete this script when the rule becomes an error.
11export const RESTYLE_CEILING = 865;11export const RESTYLE_CEILING = 826;
1212
13const RULE = "shadcn(no-restyle)";13const RULE = "shadcn(no-restyle)";
1414