Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 52 additions & 8 deletions web/app/[locale]/components/download-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,27 @@ import { ctaButtonStyle } from "./cta-styles";
import { PlatformIcon } from "./platform-icons";
import { WaitlistDialog } from "./waitlist-dialog";

// Optional per-instance padding override in px, used only by the /debug tuner
// to fine-tune both sizes live. When absent (every production call site), the
// Tailwind classes below are the single source of truth and rendering is
// unchanged. downloadRight = gap LEFT of divider; caretLeft = gap RIGHT of it.
export type PadOverride = {
downloadLeft?: number;
downloadRight?: number;
caretLeft?: number;
caretRight?: number;
};

export function DownloadButton({
size = "default",
location = "hero",
className,
padOverride,
}: {
size?: "default" | "sm";
location?: string;
className?: string;
padOverride?: PadOverride;
}) {
const t = useTranslations("common");
const tp = useTranslations("platforms");
Expand All @@ -48,6 +61,14 @@ export function DownloadButton({
const onConfirmationPage = pathname === DOWNLOAD_CONFIRMATION_PATH;
const macHref = onConfirmationPage ? DOWNLOAD_URL : DOWNLOAD_CONFIRMATION_HREF;

// Inline padding overrides win over the Tailwind classes; undefined in prod.
const downloadStyle = padOverride
? { paddingLeft: padOverride.downloadLeft, paddingRight: padOverride.downloadRight }
: undefined;
const caretStyle = padOverride
? { paddingLeft: padOverride.caretLeft, paddingRight: padOverride.caretRight }
: undefined;

// The split button is one pill with two zones (Mac download + platform caret)
// that tint independently on hover. `overflow-hidden` clips the hover tint to
// the rounded corners; the divider and caret are kept barely-there so the
Expand All @@ -57,13 +78,18 @@ export function DownloadButton({
// under `dark:` to keep the split affordance equally quiet.
// Slightly more breathing room after the label than the caret zone's
// padding, so the divider sits a touch closer to the caret than to "Mac".
const downloadZone = `flex items-center transition-colors hover:bg-background/[0.04] dark:hover:bg-background/[0.03] ${
// The hover tint is applied without a CSS transition on purpose: animating
// background-color/opacity here promotes the sub-pixel-positioned zone into
// its own WebKit compositing layer, which snaps to the device-pixel grid and
// makes the label/caret visibly jump on hover (Safari only, worst at the
// small size). Instant tint avoids the promotion, so nothing shifts.
const downloadZone = `flex items-center hover:bg-background/[0.04] dark:hover:bg-background/[0.03] ${
isSmall
? "gap-2 pl-4 pr-2.5 py-1.5 text-xs"
: "gap-2.5 pl-5 pr-3 py-2.5 text-[15px]"
? "gap-2 pl-4 pr-[7px] py-1.5 text-xs"
: "gap-2.5 pl-5 pr-2.5 py-2.5 text-[15px]"
}`;
const caretZone = `group flex items-center justify-center transition-colors hover:bg-background/[0.04] dark:hover:bg-background/[0.03] data-[popup-open]:bg-background/[0.04] dark:data-[popup-open]:bg-background/[0.03] ${
isSmall ? "px-2" : "px-2.5"
const caretZone = `group flex items-center justify-center hover:bg-background/[0.04] dark:hover:bg-background/[0.03] data-[popup-open]:bg-background/[0.04] dark:data-[popup-open]:bg-background/[0.03] ${
isSmall ? "pl-[5px] pr-2" : "pl-[7px] pr-2"
}`;

const captureMac = () =>
Expand Down Expand Up @@ -100,7 +126,7 @@ export function DownloadButton({
strokeWidth="2.25"
strokeLinecap="round"
strokeLinejoin="round"
className="opacity-40 transition-opacity group-hover:opacity-70 group-data-[popup-open]:opacity-70 dark:opacity-30 dark:group-hover:opacity-55 dark:group-data-[popup-open]:opacity-55"
className="opacity-40 group-hover:opacity-70 group-data-[popup-open]:opacity-70 dark:opacity-30 dark:group-hover:opacity-55 dark:group-data-[popup-open]:opacity-55"
aria-hidden="true"
>
<path d="m6 9 6 6 6-6" />
Expand All @@ -110,18 +136,35 @@ export function DownloadButton({
return (
<>
<div
// No CSS transition on the hover tint and no forced GPU layer (e.g.
// `translateZ(0)`) on this pill: either one makes WebKit composite the
// sub-pixel-positioned zones and snap them to the device-pixel grid on
// hover, which reads as the label/caret jumping (Safari only, worst at
// the small size). Keeping the hover repaint on the main thread matches
// Chrome and stays stable; overflow-hidden still clips the tint since
// the zones never get their own layer.
className={`inline-flex items-stretch overflow-hidden whitespace-nowrap rounded-full bg-foreground font-medium ${
className ?? ""
}`}
style={ctaButtonStyle}
>
{onConfirmationPage ? (
<a href={macHref} onClick={captureMac} className={downloadZone}>
<a
href={macHref}
onClick={captureMac}
className={downloadZone}
style={downloadStyle}
>
{macIcon}
{t("downloadForMac")}
</a>
) : (
<Link href={macHref} onClick={captureMac} className={downloadZone}>
<Link
href={macHref}
onClick={captureMac}
className={downloadZone}
style={downloadStyle}
>
{macIcon}
{t("downloadForMac")}
</Link>
Expand All @@ -132,6 +175,7 @@ export function DownloadButton({
<Menu.Root>
<Menu.Trigger
className={caretZone}
style={caretStyle}
aria-label={t("otherPlatforms")}
>
{caretIcon}
Expand Down
195 changes: 195 additions & 0 deletions web/app/[locale]/debug/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
"use client";

// Live tuner for the real DownloadButton. Renders both sizes at once and drives
// each one's padding via the component's `padOverride` prop, so the divider
// spacing can be dialed in per size and copied back into download-button.tsx.
// Diagnostic toggles (re-add transition, drop translateZ, magnify) exist to
// pin down the Safari hover jump. Reach it at /debug. Not linked from anywhere.

import { useState } from "react";
import { DownloadButton } from "../components/download-button";

type Pad = {
downloadLeft: number;
downloadRight: number;
caretLeft: number;
caretRight: number;
};
type Size = "default" | "sm";

const INITIAL: Record<Size, Pad> = {
default: { downloadLeft: 20, downloadRight: 10, caretLeft: 7, caretRight: 8 },
sm: { downloadLeft: 16, downloadRight: 7, caretLeft: 5, caretRight: 8 },
};

const FIELDS: { key: keyof Pad; label: string; accent: string }[] = [
{ key: "downloadRight", label: "Left of divider (download pr)", accent: "#3b82f6" },
{ key: "caretLeft", label: "Right of divider (caret pl)", accent: "#f97316" },
{ key: "downloadLeft", label: "Outer left (before icon)", accent: "#9ca3af" },
{ key: "caretRight", label: "Outer right (after caret)", accent: "#9ca3af" },
];

// px -> Tailwind spacing token (v4: 1 unit = 4px; .5 steps ok; else arbitrary)
function tw(prefix: string, px: number) {
if (px % 4 === 0) return `${prefix}-${px / 4}`;
if (px % 2 === 0) return `${prefix}-${px / 4}`; // e.g. 10 -> 2.5, 6 -> 1.5
return `${prefix}-[${px}px]`;
}

export default function DownloadButtonDebug() {
const [pad, setPad] = useState<Record<Size, Pad>>(() => structuredClone(INITIAL));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const [dark, setDark] = useState(false);
const [zoom, setZoom] = useState(3);
const [reTransition, setReTransition] = useState(false);
const [forceTranslateZ, setForceTranslateZ] = useState(false);
const [copied, setCopied] = useState(false);

const set = (size: Size, key: keyof Pad, v: number) =>
setPad((p) => ({ ...p, [size]: { ...p[size], [key]: v } }));

const classesFor = (size: Size) => {
const p = pad[size];
return {
download: `${tw("pl", p.downloadLeft)} ${tw("pr", p.downloadRight)}`,
caret:
p.caretLeft === p.caretRight
? tw("px", p.caretLeft)
: `${tw("pl", p.caretLeft)} ${tw("pr", p.caretRight)}`,
};
};

const snippet = () => {
const d = classesFor("default");
const s = classesFor("sm");
return [
"// download zone padding",
`default download: ${d.download} caret: ${d.caret}`,
`sm download: ${s.download} caret: ${s.caret}`,
"",
"// raw px",
`default: ${JSON.stringify(pad.default)}`,
`sm: ${JSON.stringify(pad.sm)}`,
].join("\n");
};

const copy = async () => {
await navigator.clipboard.writeText(snippet());
setCopied(true);
window.setTimeout(() => setCopied(false), 1200);
};

const wrapClass = [
reTransition ? "dbg-retrans" : "",
forceTranslateZ ? "dbg-tz" : "",
]
.filter(Boolean)
.join(" ");

return (
<div className="mx-auto max-w-6xl p-8">
{/* Diagnostic CSS scoped to the previews */}
<style>{`
.dbg-retrans a, .dbg-retrans button { transition: background-color .15s ease !important; }
.dbg-retrans svg { transition: opacity .15s ease !important; }
.dbg-tz > div:first-child { transform: translateZ(0) !important; }
`}</style>

<h1 className="text-lg font-semibold">DownloadButton debug</h1>
<p className="mb-6 text-sm text-muted">
Both sizes render the real component; sliders drive its{" "}
<code className="rounded bg-code-bg px-1">padOverride</code>. Hover each
button to check the jump. Copy classes into{" "}
<code className="rounded bg-code-bg px-1">
web/app/[locale]/components/download-button.tsx
</code>
.
</p>

{/* toolbar */}
<div className="mb-6 flex flex-wrap items-center gap-5 text-sm">
<label className="flex items-center gap-2">
<input type="checkbox" checked={dark} onChange={(e) => setDark(e.target.checked)} />
dark bg
</label>
<label className="flex items-center gap-2">
zoom
<input type="range" min={1} max={8} step={0.5} value={zoom}
onChange={(e) => setZoom(Number(e.target.value))} />
<span className="w-8 tabular-nums">{zoom}×</span>
</label>
<label className="flex items-center gap-2 text-blue-600">
<input type="checkbox" checked={reTransition}
onChange={(e) => setReTransition(e.target.checked)} />
re-add hover transition (diagnostic: should reintroduce the jump)
</label>
<label className="flex items-center gap-2 text-blue-600">
<input type="checkbox" checked={forceTranslateZ}
onChange={(e) => setForceTranslateZ(e.target.checked)} />
force translateZ on pill (diagnostic: should reintroduce the jump)
</label>
</div>

{/* previews + sliders, one column per size */}
<div className="grid gap-8 md:grid-cols-2">
{(["default", "sm"] as const).map((size) => (
<div key={size} className="rounded-xl border border-border p-4">
<div className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted">
{size}
</div>

<div
className={dark ? "dark" : undefined}
style={{
background: dark ? "#0a0a0a" : "#fafafa",
borderRadius: 12,
padding: "48px 24px",
overflow: "auto",
display: "flex",
justifyContent: "center",
}}
>
<div className={wrapClass}
style={{ transform: `scale(${zoom})`, transformOrigin: "center" }}>
<DownloadButton size={size} location="debug" padOverride={pad[size]} />
</div>
</div>

<div className="mt-4 space-y-3">
{FIELDS.map((f) => (
<div key={f.key}>
<div className="mb-1 flex items-center justify-between text-sm">
<span className="flex items-center gap-2 font-medium">
<span className="inline-block h-2 w-2 rounded-sm"
style={{ background: f.accent }} />
{f.label}
</span>
<input type="number" min={0} max={40} value={pad[size][f.key]}
onChange={(e) => set(size, f.key, Number(e.target.value))}
className="w-14 rounded border border-border bg-transparent px-2 py-0.5 text-right text-sm" />
</div>
<input type="range" min={0} max={40} step={1} value={pad[size][f.key]}
onChange={(e) => set(size, f.key, Number(e.target.value))}
className="w-full" />
</div>
))}
<div className="pt-1 font-mono text-xs text-muted">
download {classesFor(size).download} · caret {classesFor(size).caret}
</div>
</div>
</div>
))}
</div>

{/* output */}
<div className="mt-8">
<button onClick={copy}
className="mb-2 rounded-md bg-foreground px-4 py-2 text-sm font-medium text-background">
{copied ? "Copied ✓" : "Copy classes + px (both sizes)"}
</button>
<pre className="overflow-auto rounded-lg border border-border bg-code-bg p-3 font-mono text-xs">
{snippet()}
</pre>
</div>
</div>
);
}
Loading