From cbcb0d45946581c9954051dd6519e80b83cb4173 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 04:29:29 +0000 Subject: [PATCH] feat(share): redesign share button as popover with consolidated actions Promotes the chart-header share affordance from a small outlined icon-button (easy to miss) to a filled brand-color trigger that opens a popover containing: a one-line explanation, the current share URL auto-selected for Cmd/Ctrl+C, a dedicated Copy button with feedback, and the X / LinkedIn share buttons consolidated inside (previously hidden on mobile behind the sm: breakpoint). Adds share_popover_opened analytics event so popover discoverability can be measured separately from copy / social conversion. Refactors the four direct ShareButton + social-button consumers to use the shared ChartShareActions wrapper. Wires .test.tsx files into vitest's include pattern so the existing chart-display-helpers.test.tsx and the new share-button.test.tsx run in CI (the coverage exclude already listed *.test.tsx, but the include pattern was matching only .test.ts). Closes #268 Co-authored-by: functionstackx --- .../ThroughputCalculatorDisplay.tsx | 11 +- .../components/gpu-power/GpuPowerDisplay.tsx | 9 +- .../gpu-specs/gpu-specs-content.tsx | 11 +- .../submissions/SubmissionsDisplay.tsx | 9 +- .../ui/chart-display-helpers.test.tsx | 8 +- .../components/ui/chart-display-helpers.tsx | 11 +- .../src/components/ui/share-button.test.tsx | 62 +++++++++ .../app/src/components/ui/share-button.tsx | 127 +++++++++++++----- packages/app/vitest.config.ts | 2 +- 9 files changed, 171 insertions(+), 79 deletions(-) create mode 100644 packages/app/src/components/ui/share-button.test.tsx 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" + /> + +
+
+ Or share on +
+ + +
+
+
+
+ ); } 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'],