diff --git a/packages/app/src/components/calculator/ThroughputCalculatorDisplay.tsx b/packages/app/src/components/calculator/ThroughputCalculatorDisplay.tsx
index fe84bb3b..0e26093a 100644
--- a/packages/app/src/components/calculator/ThroughputCalculatorDisplay.tsx
+++ b/packages/app/src/components/calculator/ThroughputCalculatorDisplay.tsx
@@ -19,9 +19,8 @@ import {
import { ExternalLinkIcon } from '@/components/ui/external-link-icon';
import { Input } from '@/components/ui/input';
import { LabelWithTooltip } from '@/components/ui/label-with-tooltip';
-import { ShareButton } from '@/components/ui/share-button';
+import { ChartShareActions } from '@/components/ui/chart-display-helpers';
import { UnofficialDomainNotice } from '@/components/ui/unofficial-domain-notice';
-import { ShareTwitterButton, ShareLinkedInButton } from '@/components/share-buttons';
import { TooltipProvider } from '@/components/ui/tooltip';
import { MultiSelect } from '@/components/ui/multi-select';
import { SegmentedToggle, type SegmentedToggleOption } from '@/components/ui/segmented-toggle';
@@ -420,13 +419,7 @@ export default function ThroughputCalculatorDisplay() {
across all GPUs. Values are interpolated from real benchmark data.
-
+
{/* Controls — grid layout matching inference chart controls */}
diff --git a/packages/app/src/components/gpu-power/GpuPowerDisplay.tsx b/packages/app/src/components/gpu-power/GpuPowerDisplay.tsx
index 8b71d6ed..29d59e04 100644
--- a/packages/app/src/components/gpu-power/GpuPowerDisplay.tsx
+++ b/packages/app/src/components/gpu-power/GpuPowerDisplay.tsx
@@ -12,9 +12,8 @@ import ChartLegend from '@/components/ui/chart-legend';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { SegmentedToggle, type SegmentedToggleOption } from '@/components/ui/segmented-toggle';
-import { ShareButton } from '@/components/ui/share-button';
+import { ChartShareActions } from '@/components/ui/chart-display-helpers';
import { UnofficialDomainNotice } from '@/components/ui/unofficial-domain-notice';
-import { ShareTwitterButton, ShareLinkedInButton } from '@/components/share-buttons';
import {
Select,
SelectContent,
@@ -259,11 +258,7 @@ export default function GpuMetricsDisplay() {
Re-lock feature gate
-
-
-
-
-
+
diff --git a/packages/app/src/components/gpu-specs/gpu-specs-content.tsx b/packages/app/src/components/gpu-specs/gpu-specs-content.tsx
index a6b0f370..42dd4ea4 100644
--- a/packages/app/src/components/gpu-specs/gpu-specs-content.tsx
+++ b/packages/app/src/components/gpu-specs/gpu-specs-content.tsx
@@ -6,9 +6,8 @@ import { BarChart3, Radar, Table2 } from 'lucide-react';
import { Card } from '@/components/ui/card';
import { SegmentedToggle, type SegmentedToggleOption } from '@/components/ui/segmented-toggle';
-import { ShareButton } from '@/components/ui/share-button';
+import { ChartShareActions } from '@/components/ui/chart-display-helpers';
import { UnofficialDomainNotice } from '@/components/ui/unofficial-domain-notice';
-import { ShareTwitterButton, ShareLinkedInButton } from '@/components/share-buttons';
import {
formatTflops,
getScaleUpDomainMemory,
@@ -321,13 +320,7 @@ export function GpuSpecsContent() {
compute performance, memory bandwidth, and interconnect details.
-
+
diff --git a/packages/app/src/components/submissions/SubmissionsDisplay.tsx b/packages/app/src/components/submissions/SubmissionsDisplay.tsx
index 09656817..738defe7 100644
--- a/packages/app/src/components/submissions/SubmissionsDisplay.tsx
+++ b/packages/app/src/components/submissions/SubmissionsDisplay.tsx
@@ -9,8 +9,7 @@ import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { ChartButtons } from '@/components/ui/chart-buttons';
import { SegmentedToggle, type SegmentedToggleOption } from '@/components/ui/segmented-toggle';
-import { ShareButton } from '@/components/ui/share-button';
-import { ShareTwitterButton, ShareLinkedInButton } from '@/components/share-buttons';
+import { ChartShareActions } from '@/components/ui/chart-display-helpers';
import { exportToCsv } from '@/lib/csv-export';
import { submissionsVolumeToCsv } from '@/lib/csv-export-helpers';
import { useSubmissions } from '@/hooks/api/use-submissions';
@@ -90,11 +89,7 @@ export default function SubmissionsDisplay() {
Re-lock feature gate
-
-
-
-
-
+
diff --git a/packages/app/src/components/ui/chart-display-helpers.test.tsx b/packages/app/src/components/ui/chart-display-helpers.test.tsx
index 76369f70..f78b570a 100644
--- a/packages/app/src/components/ui/chart-display-helpers.test.tsx
+++ b/packages/app/src/components/ui/chart-display-helpers.test.tsx
@@ -34,12 +34,12 @@ afterEach(() => {
});
describe('ChartShareActions', () => {
- it('renders the shared copy-link, X, and LinkedIn buttons with stable test ids', () => {
+ it('renders the share popover trigger', () => {
renderUi();
- expect(container.querySelector('[data-testid="share-button"]')).not.toBeNull();
- expect(container.querySelector('[data-testid="share-twitter"]')).not.toBeNull();
- expect(container.querySelector('[data-testid="share-linkedin"]')).not.toBeNull();
+ const trigger = container.querySelector('[data-testid="share-button"]');
+ expect(trigger).not.toBeNull();
+ expect(trigger?.textContent).toContain('Share');
});
});
diff --git a/packages/app/src/components/ui/chart-display-helpers.tsx b/packages/app/src/components/ui/chart-display-helpers.tsx
index ee8c7f16..724eb152 100644
--- a/packages/app/src/components/ui/chart-display-helpers.tsx
+++ b/packages/app/src/components/ui/chart-display-helpers.tsx
@@ -1,7 +1,6 @@
import Link from 'next/link';
import type { ReactNode } from 'react';
-import { ShareTwitterButton, ShareLinkedInButton } from '@/components/share-buttons';
import { Badge } from '@/components/ui/badge';
import { ExternalLinkIcon } from '@/components/ui/external-link-icon';
import { ShareButton } from '@/components/ui/share-button';
@@ -93,15 +92,7 @@ function getCostValues(selectedYAxisMetric: string) {
}
export function ChartShareActions() {
- return (
-
- );
+ return ;
}
export function MetricAssumptionNotes({
diff --git a/packages/app/src/components/ui/share-button.test.tsx b/packages/app/src/components/ui/share-button.test.tsx
new file mode 100644
index 00000000..f581818b
--- /dev/null
+++ b/packages/app/src/components/ui/share-button.test.tsx
@@ -0,0 +1,62 @@
+// @vitest-environment jsdom
+import React, { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('@/lib/url-state', () => ({
+ buildShareUrl: () => 'https://inferencex.semianalysis.com/?g_model=dsr1#inference',
+}));
+
+vi.mock('@/lib/analytics', () => ({
+ track: vi.fn(),
+}));
+
+import { ShareButton } from '@/components/ui/share-button';
+
+let container: HTMLDivElement;
+let root: Root;
+
+function renderUi(ui: React.ReactNode) {
+ act(() => root.render(ui));
+}
+
+beforeEach(() => {
+ container = document.createElement('div');
+ document.body.append(container);
+ root = createRoot(container);
+});
+
+afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+});
+
+describe('ShareButton', () => {
+ it('renders a closed popover trigger by default', () => {
+ renderUi();
+
+ const trigger = container.querySelector('[data-testid="share-button"]');
+ expect(trigger).not.toBeNull();
+ expect(trigger?.textContent).toContain('Share');
+ // Popover content lives in a portal and is not in the DOM until opened.
+ expect(document.querySelector('[data-testid="share-popover"]')).toBeNull();
+ });
+
+ it('opens the popover with the share URL pre-filled when the trigger is clicked', () => {
+ renderUi();
+
+ const trigger = container.querySelector('[data-testid="share-button"]');
+ expect(trigger).not.toBeNull();
+
+ act(() => trigger?.click());
+
+ const input = document.querySelector('[data-testid="share-url-input"]');
+ expect(input).not.toBeNull();
+ expect(input?.value).toBe('https://inferencex.semianalysis.com/?g_model=dsr1#inference');
+
+ // Copy + social buttons live inside the popover content.
+ expect(document.querySelector('[data-testid="share-copy-button"]')).not.toBeNull();
+ expect(document.querySelector('[data-testid="share-twitter"]')).not.toBeNull();
+ expect(document.querySelector('[data-testid="share-linkedin"]')).not.toBeNull();
+ });
+});
diff --git a/packages/app/src/components/ui/share-button.tsx b/packages/app/src/components/ui/share-button.tsx
index cfd72033..908a8828 100644
--- a/packages/app/src/components/ui/share-button.tsx
+++ b/packages/app/src/components/ui/share-button.tsx
@@ -1,60 +1,123 @@
'use client';
-import { track } from '@/lib/analytics';
-import { Check, Link as LinkIcon } from 'lucide-react';
-import { useCallback, useState } from 'react';
+import { Check, Copy, Share2 } from 'lucide-react';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { ShareLinkedInButton, ShareTwitterButton } from '@/components/share-buttons';
+import { track } from '@/lib/analytics';
import { buildShareUrl } from '@/lib/url-state';
import { Button } from './button';
+import { Popover, PopoverContent, PopoverTrigger } from './popover';
export function ShareButton() {
+ const [open, setOpen] = useState(false);
const [copied, setCopied] = useState(false);
+ const [url, setUrl] = useState('');
+ const inputRef = useRef(null);
+
+ useEffect(() => {
+ if (!open) return;
+ const next = buildShareUrl();
+ setUrl(next);
+ setCopied(false);
+ track('share_popover_opened');
+ // Auto-select the URL so Cmd/Ctrl+C just works.
+ queueMicrotask(() => {
+ inputRef.current?.focus();
+ inputRef.current?.select();
+ });
+ }, [open]);
const handleCopy = useCallback(async () => {
- const url = buildShareUrl();
+ const target = url || buildShareUrl();
track('share_link_copied');
try {
- await navigator.clipboard.writeText(url);
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
+ await navigator.clipboard.writeText(target);
} catch {
- // fallback for older browsers
const textArea = document.createElement('textarea');
- textArea.value = url;
+ textArea.value = target;
document.body.append(textArea);
textArea.select();
document.execCommand('copy');
textArea.remove();
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
}
- // Dispatch action event for post-action star prompt
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
window.dispatchEvent(new CustomEvent('inferencex:action'));
- }, []);
+ }, [url]);
return (
-
+
+ {
+ // Keep focus on the URL input rather than the first focusable child.
+ event.preventDefault();
+ inputRef.current?.focus();
+ inputRef.current?.select();
+ }}
+ >
+
+
+
Share this view
+
+ Anyone with this link will see your current selections and filters.
+
+
+
+ event.currentTarget.select()}
+ className="border-input bg-background h-8 flex-1 min-w-0 rounded-md border px-2 font-mono text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
+ />
+
+ {copied ? (
+ <>
+
+ Copied
+ >
+ ) : (
+ <>
+
+ Copy
+ >
+ )}
+
+
+
+
+
+
);
}
diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts
index 374c66e5..b315f9c0 100644
--- a/packages/app/vitest.config.ts
+++ b/packages/app/vitest.config.ts
@@ -4,7 +4,7 @@ import path from 'path';
export default defineConfig({
test: {
environment: 'node',
- include: ['src/**/*.test.ts'],
+ include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
coverage: {
provider: 'v8',
include: ['src/lib/**/*.ts', 'src/scripts/**/*.ts', 'src/app/api/**/*.ts'],