Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e156973
feat(storybook): snapshot share images and their button placements
tomeredlich Sep 6, 2026
8bfbc09
feat(snapshot): grow text share images and mark the selection in place
tomeredlich Sep 6, 2026
4a84573
feat(snapshot): make the post card the post page, and give it the room
tomeredlich Sep 6, 2026
5232c3d
feat(snapshot): spend the post card's spacing on its copy
tomeredlich Sep 7, 2026
0832f16
feat(snapshot): lead the post card with the TLDR
tomeredlich Sep 7, 2026
b17223d
style(snapshot): set the post TLDR at normal weight
tomeredlich Sep 7, 2026
9e8dc2e
style(snapshot): grow the wide card further over its gradient
tomeredlich Sep 7, 2026
6d9c227
feat(snapshot): let a growing card shrink, and set its copy as body text
tomeredlich Sep 7, 2026
1e7b647
feat(snapshot): strip the post card to the TLDR and its credit
tomeredlich Sep 7, 2026
7e9502f
feat(snapshot): put the headline back on the post card
tomeredlich Sep 7, 2026
b1c910e
style(snapshot): rule the post card between copy and credit
tomeredlich Sep 7, 2026
6f571d2
feat(snapshot): reduce the post card to its TLDR
tomeredlich Sep 7, 2026
416480c
feat(snapshot): credit the post card again
tomeredlich Sep 7, 2026
b389564
feat(snapshot): set the highlight card like the post card
tomeredlich Sep 7, 2026
1234817
feat(snapshot): one treatment for every text share image
tomeredlich Sep 7, 2026
0d6744d
feat(snapshot): label the surface on the happening now card
tomeredlich Sep 7, 2026
6aafb79
feat(snapshot): move every surface label onto the logo row
tomeredlich Sep 7, 2026
7b8656e
style(snapshot): centre the hot take, unrule the invite
tomeredlich Sep 7, 2026
0455265
Merge branch 'main' into snapshot-share-images
tomeredlich Sep 9, 2026
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
13 changes: 13 additions & 0 deletions packages/shared/src/components/icons/Snapshot/filled.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions packages/shared/src/components/icons/Snapshot/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { ReactElement } from 'react';
import React from 'react';
import type { IconProps } from '../../Icon';
import Icon from '../../Icon';
import OutlinedIcon from './outlined.svg';
import FilledIcon from './filled.svg';

export const SnapshotIcon = (props: IconProps): ReactElement => (
<Icon {...props} IconPrimary={OutlinedIcon} IconSecondary={FilledIcon} />
);
11 changes: 11 additions & 0 deletions packages/shared/src/components/icons/Snapshot/outlined.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions packages/shared/src/components/icons/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ export * from './Shortcuts';
export * from './Sidebar';
export * from './Sites';
export * from './Slack';
export * from './Snapshot';
export * from './Sort';
export * from './Source';
export * from './Sparkle';
Expand Down
113 changes: 113 additions & 0 deletions packages/shared/src/components/imageShare/SnapshotButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import type { ReactElement, ReactNode } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
import { Button, ButtonSize, ButtonVariant } from '../buttons/Button';
import { DownloadIcon, ShareIcon } from '../icons';
import { Tooltip } from '../tooltip/Tooltip';
import type {
CaptureShareImageOptions,
CaptureTarget,
} from '../../lib/imageShare/captureShareImage';
import { useSnapshotCapture } from '../../features/snapshot/useSnapshotCapture';

export const SHARE_LABEL = 'Share as image';

export interface SnapshotButtonProps {
/** The designed square card to rasterize. */
card?: ReactNode;
/** Captured instead of `card`, for surfaces with no designed card yet. */
target?: CaptureTarget;
filename?: string;
label?: string;
showLabel?: boolean;
size?: ButtonSize;
variant?: ButtonVariant;
className?: string;
captureOptions?: CaptureShareImageOptions;
onCapture?: (blob: Blob) => void;
}

export function SnapshotButton({
card,
target,
filename = 'daily-share',
label = SHARE_LABEL,
showLabel = true,
captureOptions,
onCapture,
size = ButtonSize.Small,
variant = ButtonVariant.Tertiary,
className,
}: SnapshotButtonProps): ReactElement {
if (!card && !target) {
throw new Error('SnapshotButton needs either a card or a target');
}

// Rendering starts on intent, not on mount: a feed would otherwise carry a
// 1080px card for every item it shows.
const [isPrepared, setIsPrepared] = useState(false);
const isPending = useRef(false);

const { status, canShareFile, canCopyImage, offScreenCard, shareImage } =
useSnapshotCapture({
card,
target,
filename,
captureOptions,
isActive: isPrepared,
onCapture,
});

// A press before the render finished waits for it. The clipboard needs the
// press's own gesture, so this path can only download — hovering first is
// what buys the copy.
useEffect(() => {
if (isPending.current && status === 'ready') {
isPending.current = false;
shareImage();
}
}, [shareImage, status]);

const prepare = useCallback(() => setIsPrepared(true), []);

const onClick = useCallback(
(event: React.MouseEvent) => {
// The trigger sits inside clickable cards, rows and links.
event.preventDefault();
event.stopPropagation();

if (status === 'ready') {
shareImage();
return;
}

isPending.current = true;
setIsPrepared(true);
},
[shareImage, status],
);

const canShare = canShareFile || canCopyImage;

return (
<>
{offScreenCard}
<Tooltip content={label} visible={!showLabel}>
<Button
type="button"
aria-label={label}
className={classNames('shrink-0', className)}
icon={canShare ? <ShareIcon /> : <DownloadIcon />}
loading={isPending.current && status === 'loading'}
onClick={onClick}
onFocus={prepare}
onPointerEnter={prepare}
size={size}
variant={variant}
>
{showLabel ? label : undefined}
</Button>
</Tooltip>
</>
);
}
130 changes: 130 additions & 0 deletions packages/shared/src/features/snapshot/AchievementSnapshotCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import type { ReactElement } from 'react';
import React, { forwardRef } from 'react';
import { AchievementRarityTier } from '../profile/components/achievements/achievementRarity';
import { SnapshotFrame } from './SnapshotFrame';

const CARD_WIDTH = 620;
/** Trading-card proportions (2.5:3.5) rather than a square slab. */
const CARD_HEIGHT = Math.round(CARD_WIDTH * 1.4);
const CARD_RADIUS = 36;

/**
* The Game Center slab treatment: artwork full bleed, a scrim carrying the
* copy, and gold reserved for the sub-1% band so it means something.
*/
const SCRIM =
'linear-gradient(to top, rgba(6,8,11,0.94) 0%, rgba(6,8,11,0.72) 34%, rgba(6,8,11,0.12) 66%, rgba(6,8,11,0) 100%)';

const PILL = {
gold: { background: '#efab27', color: '#08110c' },
plain: { background: 'rgba(8,10,13,0.72)', color: '#FFFFFF' },
};

export interface AchievementSnapshotCardProps {
name: string;
description: string;
image?: string;
rarity: number | null;
tier: AchievementRarityTier | null;
completedAt: string;
seed?: string;
}

function AchievementSnapshotCardComponent(
{
name,
description,
image,
rarity,
tier,
completedAt,
seed,
}: AchievementSnapshotCardProps,
ref: React.Ref<HTMLDivElement>,
): ReactElement {
const isEmerald = tier === AchievementRarityTier.Emerald;
const pill = isEmerald ? PILL.gold : PILL.plain;
const rarityLabel = isEmerald ? '<1%' : `${Math.round(rarity ?? 0)}%`;

return (
<SnapshotFrame bare logoPlacement="top-right" ref={ref} seed={seed ?? name}>
<div
className="relative flex flex-col justify-end overflow-hidden"
style={{
width: CARD_WIDTH,
height: CARD_HEIGHT,
borderRadius: CARD_RADIUS,
background: '#12151C',
boxShadow: '0 48px 96px rgba(4, 2, 9, 0.7)',
}}
>
{image && (
<img
src={image}
alt=""
crossOrigin="anonymous"
className="absolute inset-0 block size-full object-cover"
/>
)}

<span
aria-hidden
className="absolute inset-0"
style={{ background: SCRIM }}
/>

{tier && (
<span
className="absolute font-bold"
style={{
left: 26,
top: 26,
padding: '9px 20px',
borderRadius: 999,
fontSize: 26,
lineHeight: 1,
...pill,
}}
>
{rarityLabel} rare
</span>
)}

<div
className="relative flex flex-col"
style={{ padding: '0 34px 34px' }}
>
<span
className="snapshot-copy font-bold text-white"
style={{ fontSize: 46, lineHeight: 1.2, letterSpacing: '-0.01em' }}
>
{name}
</span>
<span
style={{
marginTop: 8,
color: 'rgba(255,255,255,0.78)',
fontSize: 28,
lineHeight: 1.32,
}}
>
{description}
</span>
<span
style={{
marginTop: 14,
color: 'rgba(255,255,255,0.7)',
fontSize: 26,
}}
>
Completed {completedAt}
</span>
</div>
</div>
</SnapshotFrame>
);
}

export const AchievementSnapshotCard = forwardRef(
AchievementSnapshotCardComponent,
);
Loading
Loading