diff --git a/packages/shared/src/components/brief/BriefListItem.spec.tsx b/packages/shared/src/components/brief/BriefListItem.spec.tsx
index c4e7e582b15..34082474069 100644
--- a/packages/shared/src/components/brief/BriefListItem.spec.tsx
+++ b/packages/shared/src/components/brief/BriefListItem.spec.tsx
@@ -1,11 +1,16 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BriefListItem } from './BriefListItem';
import type { Post } from '../../graphql/posts';
import { LogEvent, Origin, TargetId } from '../../lib/log';
const mockOnPostClick = jest.fn();
const mockLogEvent = jest.fn();
+const mockCopyLink = jest.fn();
+const mockOpenSharePost = jest.fn();
+
+let mockWithShareControls = false;
jest.mock('../../hooks/useOnPostClick', () => ({
__esModule: true,
@@ -20,6 +25,17 @@ jest.mock('../../hooks/usePlusSubscription', () => ({
usePlusSubscription: () => ({ isPlus: true }),
}));
+jest.mock('../../hooks/useSharePost', () => ({
+ useSharePost: () => ({
+ copyLink: mockCopyLink,
+ openSharePost: mockOpenSharePost,
+ }),
+}));
+
+jest.mock('../../features/snapshot/useSharePlacement', () => ({
+ useSharePlacement: () => mockWithShareControls,
+}));
+
const post = {
id: 'brief-1',
slug: 'brief-1',
@@ -30,18 +46,21 @@ const post = {
const renderComponent = (onClick = jest.fn()) =>
render(
- ,
+
+
+ ,
);
describe('BriefListItem', () => {
beforeEach(() => {
jest.clearAllMocks();
+ mockWithShareControls = false;
});
it('delegates regular clicks to the parent handler and tracks the click', () => {
@@ -87,4 +106,53 @@ describe('BriefListItem', () => {
expect(mockOnPostClick).toHaveBeenCalledWith({ post });
expect(mockLogEvent).toHaveBeenCalledTimes(1);
});
+
+ it('renders no share controls while the placement is off', () => {
+ renderComponent();
+
+ expect(
+ screen.queryByRole('button', { name: 'Copy link' }),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: 'Share briefing' }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('keeps the share controls inside the card', () => {
+ mockWithShareControls = true;
+ renderComponent();
+
+ const button = screen.getByRole('button', { name: 'Copy link' });
+ const article = button.closest('article');
+ const column = article?.querySelector('div.flex.flex-col');
+
+ // `w-full` on the text column pushes the control past the card border.
+ expect(column).not.toHaveClass('w-full');
+ expect(column).toHaveClass('min-w-0', 'flex-1');
+ expect(article).toContainElement(button);
+ });
+
+ it('copies the brief link without opening the brief', () => {
+ mockWithShareControls = true;
+ const onClick = jest.fn();
+ renderComponent(onClick);
+
+ fireEvent.click(screen.getByRole('button', { name: 'Copy link' }));
+
+ expect(mockCopyLink).toHaveBeenCalledWith({ post });
+ expect(onClick).not.toHaveBeenCalled();
+ expect(mockOnPostClick).not.toHaveBeenCalled();
+ });
+
+ it('opens the share surface without opening the brief', () => {
+ mockWithShareControls = true;
+ const onClick = jest.fn();
+ renderComponent(onClick);
+
+ fireEvent.click(screen.getByRole('button', { name: 'Share briefing' }));
+
+ expect(mockOpenSharePost).toHaveBeenCalledWith({ post });
+ expect(mockCopyLink).not.toHaveBeenCalled();
+ expect(onClick).not.toHaveBeenCalled();
+ });
});
diff --git a/packages/shared/src/components/brief/BriefListItem.tsx b/packages/shared/src/components/brief/BriefListItem.tsx
index 6782d7e6922..01fce4ea676 100644
--- a/packages/shared/src/components/brief/BriefListItem.tsx
+++ b/packages/shared/src/components/brief/BriefListItem.tsx
@@ -10,7 +10,9 @@ import {
import type { PillProps } from '../Pill';
import { Pill } from '../Pill';
import { IconSize } from '../Icon';
-import { BriefGradientIcon, LockIcon } from '../icons';
+import { BriefGradientIcon, LinkIcon, LockIcon, ShareIcon } from '../icons';
+import { Button, ButtonSize, ButtonVariant } from '../buttons/Button';
+import { Tooltip } from '../tooltip/Tooltip';
import type { Origin, TargetId } from '../../lib/log';
import { LogEvent } from '../../lib/log';
import useOnPostClick from '../../hooks/useOnPostClick';
@@ -22,6 +24,10 @@ import { anchorDefaultRel } from '../../lib/strings';
import Link from '../utilities/Link';
import { useLogContext } from '../../contexts/LogContext';
import { usePlusSubscription } from '../../hooks/usePlusSubscription';
+import { useSharePost } from '../../hooks/useSharePost';
+import { CopyStateIcon } from '../share/CopyStateIcon';
+import { featureBriefingShareControls } from '../../lib/featureManagement';
+import { useSharePlacement } from '../../features/snapshot/useSharePlacement';
export type BriefListItemProps = {
className?: string;
@@ -55,6 +61,10 @@ export const BriefListItem = ({
const { isPlus } = usePlusSubscription();
const { logEvent } = useLogContext();
const onPostClick = useOnPostClick({ origin });
+ const { copyLink, isCopying, openSharePost } = useSharePost(origin);
+ const withShareControls = useSharePlacement({
+ feature: featureBriefingShareControls,
+ });
const trackBriefClick = () => {
onPostClick({ post });
@@ -86,14 +96,22 @@ export const BriefListItem = ({
-
-
+
+
{title}
@@ -150,6 +168,30 @@ export const BriefListItem = ({
onAuxClick={(event) => event.button === 1 && trackBriefClick()}
/>
+ {withShareControls && (
+ // After the CardLink and above it: the overlay covers the whole row,
+ // so anything rendered before it never receives the click.
+
+
+ );
+}
+
+export const ListSnapshotCard = forwardRef(ListSnapshotCardComponent);
diff --git a/packages/shared/src/features/snapshot/SnapshotEyebrow.tsx b/packages/shared/src/features/snapshot/SnapshotEyebrow.tsx
new file mode 100644
index 00000000000..64797e2650b
--- /dev/null
+++ b/packages/shared/src/features/snapshot/SnapshotEyebrow.tsx
@@ -0,0 +1,39 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import colors from '../../styles/colors';
+
+export interface SnapshotEyebrowProps {
+ label: string;
+ /** Paints the label with the surface's own wordmark gradient. */
+ gradient?: string;
+}
+
+/**
+ * Which part of the product the card came from. It rides the logo row rather
+ * than the copy: it is a sibling of the mark, not a headline for the text
+ * under it.
+ */
+export function SnapshotEyebrow({
+ label,
+ gradient,
+}: SnapshotEyebrowProps): ReactElement {
+ return (
+
+ {label}
+
+ );
+}
diff --git a/packages/shared/src/features/snapshot/SnapshotFrame.tsx b/packages/shared/src/features/snapshot/SnapshotFrame.tsx
index 05c5a2c5589..298e40f3f9a 100644
--- a/packages/shared/src/features/snapshot/SnapshotFrame.tsx
+++ b/packages/shared/src/features/snapshot/SnapshotFrame.tsx
@@ -1,15 +1,28 @@
import type { ReactElement, ReactNode } from 'react';
import React, { forwardRef } from 'react';
+import classNames from 'classnames';
import LogoIcon from '../../svg/LogoIcon';
import LogoText from '../../svg/LogoText';
-import { getSnapshotGradient, SNAPSHOT_SIZE } from './snapshotGradient';
+import {
+ getSnapshotGradient,
+ SNAPSHOT_MAX_HEIGHT,
+ SNAPSHOT_SIZE,
+} from './snapshotGradient';
export const SNAPSHOT_CARD_SIZE = 780;
+/**
+ * A page-shaped card: the gradient stays as a border rather than a stage, so
+ * the copy gets the room instead. Surfaces where the text *is* the payload use
+ * it — a wide margin around a cramped article is space spent on nothing.
+ */
+export const SNAPSHOT_CARD_WIDE = 1008;
/** Canvas minus the logo row and the gaps either side of the card. */
export const SNAPSHOT_CARD_MAX = SNAPSHOT_SIZE - 150;
const CARD_RADIUS = 48;
const CARD_EDGE = 2;
+const CARD_PADDING = 58;
+const CARD_PADDING_WIDE = 32;
/**
* The App Store device frame: a lit hairline that is brightest along the top
@@ -33,8 +46,24 @@ interface SnapshotFrameProps {
logoPlacement?: SnapshotLogoPlacement;
/** A glyph bled across the card body at low opacity, behind the content. */
watermark?: string;
+ /**
+ * Sits on the logo row, far right — for a surface label that belongs with
+ * the mark rather than with the copy.
+ */
+ logoAside?: ReactNode;
/** Drop the card shell and stand the children straight on the gradient. */
bare?: boolean;
+ /**
+ * Let the height follow the content instead of holding 1:1. Text surfaces
+ * use it so the image can carry more than a screenshot would; it still
+ * starts at the square and stops at SNAPSHOT_MAX_HEIGHT.
+ */
+ grow?: boolean;
+ /**
+ * Widen the card to SNAPSHOT_CARD_WIDE and tighten its padding, for surfaces
+ * whose copy needs the room more than the frame needs the margin.
+ */
+ wide?: boolean;
children: ReactNode;
}
@@ -42,12 +71,17 @@ function SnapshotFrameComponent(
{
seed,
watermark,
+ logoAside,
bare,
+ grow,
+ wide,
logoPlacement = 'inline',
children,
}: SnapshotFrameProps,
ref: React.Ref,
): ReactElement {
+ const cardWidth = wide ? SNAPSHOT_CARD_WIDE : SNAPSHOT_CARD_SIZE;
+ const gutter = (SNAPSHOT_SIZE - cardWidth) / 2;
const isOverlaid = logoPlacement !== 'inline';
const overlayStyle = {
position: 'absolute' as const,
@@ -65,19 +99,38 @@ function SnapshotFrameComponent(
);
+ const logoRow = logoAside ? (
+
+ {logo}
+ {logoAside}
+
+ ) : (
+ logo
+ );
+
return (
{/* Standing alone on the gradient, the collectible has no card to sit
in: the mark leads above it, or floats over its artwork. */}
- {bare && !isOverlaid && logo}
+ {bare && !isOverlaid && logoRow}
{bare ? (
@@ -87,8 +140,10 @@ function SnapshotFrameComponent(
) : (
diff --git a/packages/shared/src/features/snapshot/snapshotGradient.ts b/packages/shared/src/features/snapshot/snapshotGradient.ts
index 03059ea599c..e6384db61c3 100644
--- a/packages/shared/src/features/snapshot/snapshotGradient.ts
+++ b/packages/shared/src/features/snapshot/snapshotGradient.ts
@@ -1,4 +1,9 @@
export const SNAPSHOT_SIZE = 1080;
+/**
+ * 9:16 — the tallest frame every share destination still shows whole. Text
+ * surfaces grow into it instead of clamping their copy to the square.
+ */
+export const SNAPSHOT_MAX_HEIGHT = 1920;
/* eslint-disable no-bitwise -- an FNV hash and a mulberry32 PRNG are defined
in terms of integer bit operations; expressing them any other way would
diff --git a/packages/shared/src/hooks/useSharePost.ts b/packages/shared/src/hooks/useSharePost.ts
index ffe886bb53c..fb173d929a3 100644
--- a/packages/shared/src/hooks/useSharePost.ts
+++ b/packages/shared/src/hooks/useSharePost.ts
@@ -17,12 +17,14 @@ type FuncProps = Omit;
interface UseSharePost {
openSharePost: (props: FuncProps) => void;
copyLink: (props: FuncProps) => void;
+ /** True for the second after a copy, so a control can confirm on itself. */
+ isCopying: boolean;
openNativeSharePost?: (post: Post) => void;
}
export function useSharePost(origin: Origin): UseSharePost {
const { logEvent } = useLogContext();
- const [, copyLink] = useCopyPostLink();
+ const [isCopying, copyLink] = useCopyPostLink();
const { getShortUrl, getTrackedUrl } = useGetShortUrl();
const { openModal } = useLazyModal();
const postLogEvent = usePostLogEvent();
@@ -81,6 +83,7 @@ export function useSharePost(origin: Origin): UseSharePost {
return {
openSharePost,
copyLink: copyLinkShare,
+ isCopying,
openNativeSharePost,
};
}
diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts
index 1e66e348900..a127889eb2e 100644
--- a/packages/shared/src/lib/featureManagement.ts
+++ b/packages/shared/src/lib/featureManagement.ts
@@ -38,6 +38,14 @@ export const featurePostCopySummary = new Feature('post_copy_summary', false);
export const featurePostSharePrompts = new Feature('post_share_prompts', false);
export const featurePollSnapshot = new Feature('poll_snapshot', false);
export const featurePostCopyLink = new Feature('post_copy_link', false);
+// Experiment: share controls on the two briefing surfaces — copy link at every
+// width on the briefing (today the whole cluster is `hidden laptop:block`), a
+// copy button per row on /briefing, and a share band at the end of the read.
+// Default MUST stay `false` — see the rule below.
+export const featureBriefingShareControls = new Feature(
+ 'briefing_share_controls',
+ false,
+);
// Experiment: community takes — an LLM-generated digest of what the developer
// community on HN/Lobsters thinks about a post. Control hides the surface,
// treatment shows it. Enrollment is conditional on the post actually having a