From 53c050211cc76de8b8d50f63a88c289a73297929 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Mon, 31 Aug 2026 15:19:04 +0300 Subject: [PATCH 01/19] docs(snapshot): document post page share placements in Storybook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Storybook page that draws the post page from its production components — PostSourceInfo, PostActions, PostContentWidget and MobilePostFloatingBar — at desktop, tablet and mobile, showing what ships today and the one snapshot placement we want. Today the post menu opens with 'Share via' and the engagement bar ends with a labeled 'Copy'; below laptop PostHeaderActions is hidden, so on tablet the only share action on the screen is that Copy. The post has a real per-post OG image, so snapshot is limited to the text-selection bar, where a highlighted line has no OG of its own. Carries the Snapshot icon it needs; no production surface changes. Co-Authored-By: Claude Opus 5 --- .../src/components/icons/Snapshot/filled.svg | 13 + .../src/components/icons/Snapshot/index.tsx | 10 + .../components/icons/Snapshot/outlined.svg | 11 + packages/shared/src/components/icons/index.ts | 1 + .../stories/features/snapshot/sharingMap.ts | 179 +++++++++ .../features/snapshot/surfaceChrome.tsx | 268 ++++++++++++++ .../snapshot/surfaces/PostPage.stories.tsx | 342 ++++++++++++++++++ 7 files changed, 824 insertions(+) create mode 100644 packages/shared/src/components/icons/Snapshot/filled.svg create mode 100644 packages/shared/src/components/icons/Snapshot/index.tsx create mode 100644 packages/shared/src/components/icons/Snapshot/outlined.svg create mode 100644 packages/storybook/stories/features/snapshot/sharingMap.ts create mode 100644 packages/storybook/stories/features/snapshot/surfaceChrome.tsx create mode 100644 packages/storybook/stories/features/snapshot/surfaces/PostPage.stories.tsx diff --git a/packages/shared/src/components/icons/Snapshot/filled.svg b/packages/shared/src/components/icons/Snapshot/filled.svg new file mode 100644 index 00000000000..d4cc05f0b56 --- /dev/null +++ b/packages/shared/src/components/icons/Snapshot/filled.svg @@ -0,0 +1,13 @@ + + + Icon/Snapshot/Filled + + + + + + + + + + diff --git a/packages/shared/src/components/icons/Snapshot/index.tsx b/packages/shared/src/components/icons/Snapshot/index.tsx new file mode 100644 index 00000000000..8707b229fad --- /dev/null +++ b/packages/shared/src/components/icons/Snapshot/index.tsx @@ -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 => ( + +); diff --git a/packages/shared/src/components/icons/Snapshot/outlined.svg b/packages/shared/src/components/icons/Snapshot/outlined.svg new file mode 100644 index 00000000000..af265154e03 --- /dev/null +++ b/packages/shared/src/components/icons/Snapshot/outlined.svg @@ -0,0 +1,11 @@ + + + Icon/Snapshot/Outline + + + + + + + + diff --git a/packages/shared/src/components/icons/index.ts b/packages/shared/src/components/icons/index.ts index 52ee9458013..5c1057b1724 100644 --- a/packages/shared/src/components/icons/index.ts +++ b/packages/shared/src/components/icons/index.ts @@ -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'; diff --git a/packages/storybook/stories/features/snapshot/sharingMap.ts b/packages/storybook/stories/features/snapshot/sharingMap.ts new file mode 100644 index 00000000000..63c6c3bada3 --- /dev/null +++ b/packages/storybook/stories/features/snapshot/sharingMap.ts @@ -0,0 +1,179 @@ +/** + * The decision behind every share control, shared by the Sharing map and the + * Visibility variations pages so the two can never drift apart. + */ +export type LeadAction = 'Link' | 'Share to' | 'Snapshot'; + +export type Support = 'core' | 'secondary' | 'none'; + +export interface SharingMapRow { + surface: string; + pr: string; + link: Support; + snapshot: Support; + leads: LeadAction; + why: string; +} + +export const SHARING_MAP: SharingMapRow[] = [ + { + surface: 'Post page & modal', + pr: '6350', + link: 'core', + snapshot: 'none', + leads: 'Link', + why: 'It already has a real OG image, so the link carries the payload', + }, + { + surface: 'Highlighted text', + pr: '6352', + link: 'secondary', + snapshot: 'core', + leads: 'Snapshot', + why: 'The quote is the share; the link is attribution', + }, + { + surface: 'End of conversation', + pr: '6349', + link: 'core', + snapshot: 'secondary', + leads: 'Link', + why: 'The thread keeps moving; a still frame goes stale', + }, + { + surface: 'Post-upvote prompt', + pr: '6351', + link: 'core', + snapshot: 'none', + leads: 'Link', + why: 'Same payload as the post — no second image', + }, + { + surface: 'Briefing / digest', + pr: '6353', + link: 'none', + snapshot: 'core', + leads: 'Snapshot', + why: 'Personalized: a link gives them their briefing, or nothing', + }, + { + surface: 'Profile', + pr: '6354', + link: 'core', + snapshot: 'secondary', + leads: 'Link', + why: 'The point is that they follow you', + }, + { + surface: 'Tags & sources', + pr: '6357', + link: 'core', + snapshot: 'none', + leads: 'Link', + why: 'A live feed; an image of a tag says little', + }, + { + surface: 'Leaderboard — the board', + pr: '6359', + link: 'core', + snapshot: 'secondary', + leads: 'Link', + why: 'It changes weekly', + }, + { + surface: 'Leaderboard — my rank', + pr: '6359', + link: 'secondary', + snapshot: 'core', + leads: 'Snapshot', + why: 'Status content is image-first', + }, + { + surface: 'Happening Now', + pr: '6355', + link: 'secondary', + snapshot: 'core', + leads: 'Snapshot', + why: 'Payload ≈ the whole page, and news travels in chat apps', + }, + { + surface: 'Reading streak', + pr: '6358', + link: 'none', + snapshot: 'core', + leads: 'Snapshot', + why: 'A link to your streak means nothing to anyone else', + }, + { + surface: 'Celebrations & achievements', + pr: '6360', + link: 'none', + snapshot: 'core', + leads: 'Snapshot', + why: 'Pure status', + }, + { + surface: 'DevCard', + pr: '6356', + link: 'secondary', + snapshot: 'none', + leads: 'Share to', + why: 'Already an image — do not wrap an image in an image', + }, + { + surface: 'Reading history', + pr: '6361', + link: 'core', + snapshot: 'none', + leads: 'Link', + why: 'Each row is just a post', + }, + { + surface: 'Copy my feed', + pr: '6362', + link: 'none', + snapshot: 'core', + leads: 'Snapshot', + why: 'No URL anyone else can open', + }, + { + surface: 'Squad directory', + pr: '6363', + link: 'core', + snapshot: 'secondary', + leads: 'Link', + why: 'The point is joining', + }, + { + surface: 'Best-of / discovery', + pr: '6364', + link: 'core', + snapshot: 'secondary', + leads: 'Link', + why: 'Evergreen page worth landing on', + }, + { + surface: 'Hot takes', + pr: '6365', + link: 'secondary', + snapshot: 'core', + leads: 'Snapshot', + why: 'Opinion is quotable and self-contained', + }, + { + surface: 'Invite a friend', + pr: '6366', + link: 'core', + snapshot: 'secondary', + leads: 'Link', + why: 'An image of a referral cannot be clicked', + }, + { + surface: 'Watercooler post', + pr: '—', + link: 'core', + snapshot: 'secondary', + leads: 'Link', + why: 'It is a post', + }, +]; diff --git a/packages/storybook/stories/features/snapshot/surfaceChrome.tsx b/packages/storybook/stories/features/snapshot/surfaceChrome.tsx new file mode 100644 index 00000000000..50ca4caaae6 --- /dev/null +++ b/packages/storybook/stories/features/snapshot/surfaceChrome.tsx @@ -0,0 +1,268 @@ +import React from 'react'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + LinkIcon, + ShareIcon, + SnapshotIcon, +} from '@dailydotdev/shared/src/components/icons'; +import type { LeadAction } from './sharingMap'; + +export const AVATAR = + 'https://res.cloudinary.com/daily-now/image/upload/s--O0TOmw4y--/f_auto/v1715772965/public/noProfile'; + +export const ART = + 'https://media.daily.dev/image/upload/s--_MjhSTze--/q_auto/v1773608417/achievements/cant_spend_it_all'; + +/* ------------------------------------------------------------------ prose */ + +export const H1 = ({ children }: { children: React.ReactNode }) => ( +

{children}

+); + +export const H2 = ({ children }: { children: React.ReactNode }) => ( +

{children}

+); + +export const P = ({ children }: { children: React.ReactNode }) => ( +

{children}

+); + +export const Note = ({ children }: { children: React.ReactNode }) => ( +

+ {children} +

+); + +/* ---------------------------------------------------------------- controls */ + +export const ICONS: Record = { + Link: , + 'Share to': , + Snapshot: , +}; + +export const LABELS: Record = { + Link: 'Copy link', + 'Share to': 'Share', + Snapshot: 'Snapshot', +}; + +/** + * Inert on purpose: this page compares where a control sits inside a real + * screen. The working buttons and live capture are on Button placements. + */ +export const Control = ({ + action, + label, + text, + size = ButtonSize.Small, + variant = ButtonVariant.Tertiary, +}: { + action: LeadAction; + label?: boolean; + /** Overrides the default label where the surface needs shorter copy. */ + text?: string; + size?: ButtonSize; + variant?: ButtonVariant; +}) => ( + +); + +/* ---------------------------------------------------------- page furniture */ + +/** The frame every surface is drawn inside, so variants compare like for like. */ +export const Screen = ({ + children, + width = 'w-[26rem]', + className, +}: { + children: React.ReactNode; + width?: string; + className?: string; +}) => ( +
+ {children} +
+); + +/** + * The real context menu, not an illustration of one. Every surface below + * passes its production item list — today the share entry is "Share via", + * which opens the share modal; no surface offers Copy link from a menu. + */ +export const OverflowMenu = ({ + items, + highlight, + className, +}: { + items: string[]; + /** The share entry, drawn with its icon and picked out from the rest. + * Omitted where the menu has no share action at all — several do not. */ + highlight?: string; + className?: string; +}) => ( +
+ {items.map((item) => + item === highlight ? ( + + + {item} + + ) : ( + + {item} + + ), + )} +
+); + +/** The production post/feed context menu, in order. */ +export const POST_MENU = [ + 'Share via', + 'Hide', + 'Report', + 'Downvote', + 'Read it later', +]; + +export const Variant = ({ + step, + headline, + note, + wide, + children, +}: { + step: string; + headline: string; + note: string; + /** Full width, for a variation drawn at several breakpoints. */ + wide?: boolean; + children: React.ReactNode; +}) => ( +
+
+ + {step} + + + {headline} + + {note} +
+ {children} +
+); + +export const Category = ({ + title, + covers, + verdict, + children, +}: { + title: string; + covers: string; + verdict: string; + children: React.ReactNode; +}) => ( +
+
+

{title}

+ {covers} +

+ {verdict} +

+
+
{children}
+
+); + +/** Every category page opens with the same header, so they read as a set. */ +export const SurfacePage = ({ + title, + intro, + map, + children, +}: { + title: string; + intro: string; + map: string; + children: React.ReactNode; +}) => ( +
+
+

{title}

+

{intro}

+ {map} +
+ {children} +
+); + +/** + * Breakpoints matter more than usual here: PostHeaderActions is + * `hidden laptop:flex`, so the control that leads on desktop is simply not + * on the page below 1020px. Every recommendation is drawn at all three. + */ +export const DEVICES = { + desktop: { label: 'Desktop · 1280', width: 'w-[40rem]' }, + tablet: { label: 'Tablet · 768', width: 'w-[30rem]' }, + mobile: { label: 'Mobile · 375', width: 'w-[23.4rem]' }, +} as const; + +export type Device = keyof typeof DEVICES; + +export const DeviceRail = ({ + children, +}: { + children: React.ReactNode; +}) => ( +
{children}
+); + +export const DeviceFrame = ({ + device, + note, + children, +}: { + device: Device; + note?: string; + children: React.ReactNode; +}) => ( +
+
+ + {DEVICES[device].label} + + {note && ( + {note} + )} +
+ {children} +
+); diff --git a/packages/storybook/stories/features/snapshot/surfaces/PostPage.stories.tsx b/packages/storybook/stories/features/snapshot/surfaces/PostPage.stories.tsx new file mode 100644 index 00000000000..d0dc24be0d7 --- /dev/null +++ b/packages/storybook/stories/features/snapshot/surfaces/PostPage.stories.tsx @@ -0,0 +1,342 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + BookmarkIcon, + CopyIcon, + DiscussIcon, + DownvoteIcon, + LinkIcon, + MenuIcon, + OpenLinkIcon, + UpvoteIcon, +} from '@dailydotdev/shared/src/components/icons'; +import type { Device } from '../surfaceChrome'; +import { + AVATAR, + Category, + Control, + DeviceFrame, + DeviceRail, + DEVICES, + Note, + OverflowMenu, + POST_MENU, + Screen, + SurfacePage, + Variant, +} from '../surfaceChrome'; + +type Spot = 'today' | 'selection'; + +const TITLE = 'Why iconic tech brands lost their dominance'; + +const SUMMARY = + 'Nokia, BlackBerry and Kodak all led their categories and all missed the same turn: they optimised the product they had instead of the one their customers were moving to.'; + +const TAGS = ['tech', 'business', 'startups']; + +const isCompact = (device: Device) => device !== 'desktop'; + +/* Source row. PostHeaderActions is `hidden laptop:flex`, so Read post and the + ⋯ menu only exist on desktop; below that the Follow link takes their place. */ +const SourceRow = ({ + device, + menu, +}: { + device: Device; + menu?: boolean; +}) => ( +
+ + + XDA Developers + + {isCompact(device) ? ( + <> + · + Follow + + ) : ( +
+ +
+ )} + {menu && } +
+); + +const Tags = () => ( +
    + {TAGS.map((tag) => ( +
  • + #{tag} +
  • + ))} +
+); + +/* The production engagement bar: a bordered pill, labels on everything but + the votes, and Copy — not Share — as the last action. */ +const ActionBar = ({ device }: { device: Device }) => { + const labels = !isCompact(device); + + return ( +
+
+ + + +
+
+ ); +}; + +const SelectionBar = () => ( +
+
+); + +const MobileFloatingBar = () => ( +
+ + +
+); + +const PostScreen = ({ device, spot }: { device: Device; spot: Spot }) => { + const compact = isCompact(device); + + return ( + +
+ + +

+ {TITLE} +

+ +

+ The pattern repeats across decades.{' '} + + {SUMMARY} + +

+ {spot === 'selection' && } + + + + + Aug 12, 2026 · 4 min read + + +
+ + + 128 Upvotes · 24 Comments + + + + +
+ +
+ + Bobby Iliev + + + The org chart point is the whole article, honestly. + +
+
+ +
+ + {compact && } + + ); +}; + +const Rail = ({ spot }: { spot: Spot }) => ( + + + + + + + + + + + +); + +const PostPage = () => ( + + + + + + + + + + + + + + +); + +const meta: Meta = { + title: 'Features/Snapshot/Surfaces/Post page', + component: PostPage, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +export const Variations: StoryObj = {}; From 41115224a1f4a4aef3beddbfc1ce046cc54c4fbd Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Mon, 31 Aug 2026 15:29:36 +0300 Subject: [PATCH 02/19] docs(snapshot): show the exported image on the post page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page argued for one snapshot placement — the text-selection bar — without showing what pressing it produces, so the trade-off against Copy link had to be taken on trust. The selection category now ends with the 1080x1080 card the button exports, drawn from the real HighlightTextSnapshotCard (#6544) at a third of its size and seeded with the highlighted line on the page above it. Carries the four card files that page needs from #6544 — the frame, the seeded gradient, the quote truncation and the highlight card itself — plus the .snapshot-copy utility they rely on. No production surface renders them yet. Co-Authored-By: Claude Opus 5 --- .../snapshot/HighlightTextSnapshotCard.tsx | 108 ++++++++++++++ .../src/features/snapshot/SnapshotFrame.tsx | 136 ++++++++++++++++++ .../src/features/snapshot/snapshotGradient.ts | 71 +++++++++ .../src/features/snapshot/snapshotText.ts | 26 ++++ packages/shared/src/styles/utilities.css | 9 ++ .../snapshot/surfaces/PostPage.stories.tsx | 36 +++++ 6 files changed, 386 insertions(+) create mode 100644 packages/shared/src/features/snapshot/HighlightTextSnapshotCard.tsx create mode 100644 packages/shared/src/features/snapshot/SnapshotFrame.tsx create mode 100644 packages/shared/src/features/snapshot/snapshotGradient.ts create mode 100644 packages/shared/src/features/snapshot/snapshotText.ts diff --git a/packages/shared/src/features/snapshot/HighlightTextSnapshotCard.tsx b/packages/shared/src/features/snapshot/HighlightTextSnapshotCard.tsx new file mode 100644 index 00000000000..a6887f0b94b --- /dev/null +++ b/packages/shared/src/features/snapshot/HighlightTextSnapshotCard.tsx @@ -0,0 +1,108 @@ +import type { ReactElement } from 'react'; +import React, { forwardRef } from 'react'; +import colors from '../../styles/colors'; +import { SnapshotFrame } from './SnapshotFrame'; +import { truncateAtWord } from './snapshotText'; + +const MUTED = colors.salt['90']; +const DIVIDER = colors.pepper['10']; + +/** + * The quote is the whole image, so it takes as much size as it can carry: + * short highlights get set large, longer ones step down rather than clip. + */ +const quoteFontSize = (length: number): number => { + if (length <= 70) { + return 72; + } + + if (length <= 140) { + return 60; + } + + if (length <= 240) { + return 48; + } + + return 40; +}; + +export interface HighlightTextSnapshotCardProps { + text: string; + source?: { name: string; image?: string }; + postTitle?: string; + domain?: string; + seed?: string; +} + +function HighlightTextSnapshotCardComponent( + { text, source, postTitle, domain, seed }: HighlightTextSnapshotCardProps, + ref: React.Ref, +): ReactElement { + const quote = truncateAtWord(text); + const attribution = [postTitle, domain].filter(Boolean).join(' · '); + + return ( + +
+
+ + “ + +

+ {quote} +

+
+ +
+ {source && ( +
+ {source.image && ( + + )} + + {source.name} + +
+ )} + {attribution && ( + + {attribution} + + )} +
+
+
+ ); +} + +export const HighlightTextSnapshotCard = forwardRef( + HighlightTextSnapshotCardComponent, +); diff --git a/packages/shared/src/features/snapshot/SnapshotFrame.tsx b/packages/shared/src/features/snapshot/SnapshotFrame.tsx new file mode 100644 index 00000000000..05c5a2c5589 --- /dev/null +++ b/packages/shared/src/features/snapshot/SnapshotFrame.tsx @@ -0,0 +1,136 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { forwardRef } from 'react'; +import LogoIcon from '../../svg/LogoIcon'; +import LogoText from '../../svg/LogoText'; +import { getSnapshotGradient, SNAPSHOT_SIZE } from './snapshotGradient'; + +export const SNAPSHOT_CARD_SIZE = 780; +/** 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; + +/** + * The App Store device frame: a lit hairline that is brightest along the top + * edge and fades out by the middle, over a body darker than the ground. + */ +const CARD_EDGE_GRADIENT = + 'linear-gradient(170deg, rgba(214, 196, 255, 0.92) 0%, rgba(158, 126, 236, 0.5) 12%, rgba(104, 82, 168, 0.16) 38%, rgba(255, 255, 255, 0.05) 72%, rgba(180, 156, 255, 0.14) 100%)'; +const CARD_BODY = '#0B0812'; +const CARD_GLOW = + '0 0 120px rgba(126, 82, 214, 0.38), 0 48px 96px rgba(4, 2, 9, 0.62)'; + +export type SnapshotLogoPlacement = 'inline' | 'top-left' | 'top-right'; + +interface SnapshotFrameProps { + seed: string; + /** + * 'inline' leads the content with the mark. The overlay placements float it + * over whatever fills the card instead, for cards whose own artwork reaches + * the top edge. + */ + logoPlacement?: SnapshotLogoPlacement; + /** A glyph bled across the card body at low opacity, behind the content. */ + watermark?: string; + /** Drop the card shell and stand the children straight on the gradient. */ + bare?: boolean; + children: ReactNode; +} + +function SnapshotFrameComponent( + { + seed, + watermark, + bare, + logoPlacement = 'inline', + children, + }: SnapshotFrameProps, + ref: React.Ref, +): ReactElement { + const isOverlaid = logoPlacement !== 'inline'; + const overlayStyle = { + position: 'absolute' as const, + top: 30, + ...(logoPlacement === 'top-right' ? { right: 30 } : { left: 30 }), + zIndex: 4, + }; + const 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 && logo} + {children} +
+ ) : ( +
+
+ {watermark && ( + + {watermark} + + )} + {isOverlaid && logo} +
+ {!isOverlaid && logo} + {children} +
+
+
+ )} +
+ ); +} + +export const SnapshotFrame = forwardRef(SnapshotFrameComponent); diff --git a/packages/shared/src/features/snapshot/snapshotGradient.ts b/packages/shared/src/features/snapshot/snapshotGradient.ts new file mode 100644 index 00000000000..03059ea599c --- /dev/null +++ b/packages/shared/src/features/snapshot/snapshotGradient.ts @@ -0,0 +1,71 @@ +export const SNAPSHOT_SIZE = 1080; + +/* 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 + change the numbers they produce. */ +const hashSeed = (seed: string): number => { + let hash = 2166136261; + + for (let i = 0; i < seed.length; i += 1) { + hash ^= seed.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + + return hash >>> 0; +}; + +const createRandom = (seed: string): (() => number) => { + let state = hashSeed(seed) || 1; + + return () => { + state += 0x6d2b79f5; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +}; +/* eslint-enable no-bitwise */ + +/** + * Sampled from the App Store screenshots: a near-black violet ground with one + * large halo behind the subject and a quieter wash along the bottom. + */ +const BASE = 'linear-gradient(178deg, #150C26 0%, #0B0713 52%, #08060F 100%)'; + +const HALOS = [ + { r: 128, g: 82, b: 214 }, + { r: 151, g: 78, b: 224 }, + { r: 106, g: 78, b: 220 }, + { r: 177, g: 75, b: 215 }, +]; + +const rgba = ( + { r, g, b }: { r: number; g: number; b: number }, + alpha: number, +): string => `rgba(${r}, ${g}, ${b}, ${alpha})`; + +export function getSnapshotGradient(seed: string): string { + const random = createRandom(seed); + const halo = HALOS[Math.floor(random() * HALOS.length)]; + const accent = HALOS[Math.floor(random() * HALOS.length)]; + + const haloX = Math.round(38 + random() * 24); + const haloY = Math.round(2 + random() * 12); + const haloAlpha = 0.5 + random() * 0.18; + + const washX = Math.round(12 + random() * 76); + const washAlpha = 0.16 + random() * 0.12; + + return [ + `radial-gradient(72% 48% at ${haloX}% ${haloY}%, ${rgba( + halo, + haloAlpha, + )} 0%, ${rgba(halo, 0)} 68%)`, + `radial-gradient(58% 34% at ${washX}% 104%, ${rgba( + accent, + washAlpha, + )} 0%, ${rgba(accent, 0)} 72%)`, + BASE, + ].join(', '); +} diff --git a/packages/shared/src/features/snapshot/snapshotText.ts b/packages/shared/src/features/snapshot/snapshotText.ts new file mode 100644 index 00000000000..dcc0561a55a --- /dev/null +++ b/packages/shared/src/features/snapshot/snapshotText.ts @@ -0,0 +1,26 @@ +/** + * A shared quote reads as one thought, and 280 characters still sets legibly + * inside the square. Longer selections are cut rather than refused: the reader + * gets the opening of what was marked, and the link carries the rest. + */ +export const SNAPSHOT_TEXT_LIMIT = 280; + +export function truncateAtWord( + text: string, + limit = SNAPSHOT_TEXT_LIMIT, +): string { + const trimmed = text.trim(); + + if (trimmed.length <= limit) { + return trimmed; + } + + const cut = trimmed.slice(0, limit); + const lastSpace = cut.lastIndexOf(' '); + + // A single unbroken run longer than the limit has no word to fall back to. + return `${(lastSpace > limit * 0.6 + ? cut.slice(0, lastSpace) + : cut + ).trimEnd()}…`; +} diff --git a/packages/shared/src/styles/utilities.css b/packages/shared/src/styles/utilities.css index 81b7d7362dd..06db629a060 100644 --- a/packages/shared/src/styles/utilities.css +++ b/packages/shared/src/styles/utilities.css @@ -1298,3 +1298,12 @@ img.agent-media-ring { panel, hanging off the right edge. These re-run the card's own mobile rules against the container instead, at the same 500px the card switches on, so a panel dragged wide gets the side-by-side layout back. */ + +/* Snapshot copy is rasterized once and never reflows, so it can afford the + expensive wrapping: balance evens the line lengths and removes the orphan + word, and anywhere keeps long URLs or type names inside the card. */ +.snapshot-copy { + text-wrap: balance; + overflow-wrap: anywhere; + hyphens: none; +} diff --git a/packages/storybook/stories/features/snapshot/surfaces/PostPage.stories.tsx b/packages/storybook/stories/features/snapshot/surfaces/PostPage.stories.tsx index d0dc24be0d7..33ddcc96031 100644 --- a/packages/storybook/stories/features/snapshot/surfaces/PostPage.stories.tsx +++ b/packages/storybook/stories/features/snapshot/surfaces/PostPage.stories.tsx @@ -15,6 +15,8 @@ import { OpenLinkIcon, UpvoteIcon, } from '@dailydotdev/shared/src/components/icons'; +import { HighlightTextSnapshotCard } from '@dailydotdev/shared/src/features/snapshot/HighlightTextSnapshotCard'; +import { SNAPSHOT_SIZE } from '@dailydotdev/shared/src/features/snapshot/snapshotGradient'; import type { Device } from '../surfaceChrome'; import { AVATAR, @@ -211,6 +213,32 @@ const MobileFloatingBar = () => (
); +/* The card from #6544 at a third of its size: it is built for a 1080px canvas, + so it is drawn full-size and scaled rather than made responsive. */ +const PREVIEW_SIZE = 360; + +const SnapshotResult = () => ( +
+
+ +
+
+); + const PostScreen = ({ device, spot }: { device: Device; spot: Spot }) => { const compact = isCompact(device); @@ -326,6 +354,14 @@ const PostPage = () => ( > + + + + From 1a03b1f7f5f577bb66c8a45920b033b075b0f427 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Mon, 31 Aug 2026 17:00:42 +0300 Subject: [PATCH 03/19] feat(snapshot): put the selection snapshot on the post page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #6556 argued the case for one snapshot placement and drew what it exports; this makes it real. Selecting a line of a post now raises a floating bar with copy link, copy text and Snapshot, and Snapshot hands back the 1080x1080 PNG of the quote — clipboard first, download where the clipboard refuses images. Behind `snapshot_selection_share`, which defaults to off, and only on the post page: the decision on the modal was to leave it alone. `useConditionalFeature` evaluates on post pages only, so nothing else logs an exposure. The capture pipeline and the button come from #6544 (snapdom, captureShareImage, SnapshotButton). New here: `useTextSelection`, which reports a selection only while it lives inside the post body, and the bar itself. The selection commits on the trailing edge of the drag rather than behind a drag flag — a pointerup lost to a release outside the window would otherwise strand the toolbar. /dev/snapshot carries the review surface: the live bar, the card at every quote length, and the real capture output. Unflagged there, so it can be reviewed without GrowthBook, and blocked on the canonical production hosts. Testing: shared 376 suites / 2652 tests, webapp 81 / 644, extension 6 / 52. Drag-selected a quote in a browser and captured from the bar: clipboard on a real press, download plus an "Image saved" toast on a scripted one. Co-Authored-By: Claude Opus 5 --- packages/shared/package.json | 1 + .../components/imageShare/SnapshotButton.tsx | 126 ++++++++ .../src/components/post/PostContent.spec.tsx | 71 ++++- .../src/components/post/PostContent.tsx | 15 +- .../snapshot/SelectionSnapshotBar.spec.tsx | 86 +++++ .../snapshot/SelectionSnapshotBar.tsx | 134 ++++++++ .../src/features/snapshot/shutterSound.ts | 23 ++ .../src/features/snapshot/useTextSelection.ts | 117 +++++++ packages/shared/src/lib/featureManagement.ts | 4 + .../src/lib/imageShare/captureShareImage.ts | 207 ++++++++++++ .../src/lib/imageShare/copyShareImage.ts | 19 ++ .../src/lib/imageShare/downloadShareImage.ts | 10 + packages/shared/src/styles/utilities.css | 42 +++ packages/webapp/pages/dev/snapshot.tsx | 301 ++++++++++++++++++ packages/webapp/public/sounds/shutter.mp3 | Bin 0 -> 45824 bytes pnpm-lock.yaml | 8 + 16 files changed, 1161 insertions(+), 3 deletions(-) create mode 100644 packages/shared/src/components/imageShare/SnapshotButton.tsx create mode 100644 packages/shared/src/features/snapshot/SelectionSnapshotBar.spec.tsx create mode 100644 packages/shared/src/features/snapshot/SelectionSnapshotBar.tsx create mode 100644 packages/shared/src/features/snapshot/shutterSound.ts create mode 100644 packages/shared/src/features/snapshot/useTextSelection.ts create mode 100644 packages/shared/src/lib/imageShare/captureShareImage.ts create mode 100644 packages/shared/src/lib/imageShare/copyShareImage.ts create mode 100644 packages/shared/src/lib/imageShare/downloadShareImage.ts create mode 100644 packages/webapp/pages/dev/snapshot.tsx create mode 100644 packages/webapp/public/sounds/shutter.mp3 diff --git a/packages/shared/package.json b/packages/shared/package.json index 1cab59ec9b7..e60ee3a478c 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -125,6 +125,7 @@ "@tiptap/extension-placeholder": "^3.22.5", "@tiptap/react": "^3.22.5", "@tiptap/starter-kit": "^3.22.5", + "@zumer/snapdom": "^2.23.1", "border-beam": "1.3.0", "check-password-strength": "^2.0.10", "cmdk": "^1.0.0", diff --git a/packages/shared/src/components/imageShare/SnapshotButton.tsx b/packages/shared/src/components/imageShare/SnapshotButton.tsx new file mode 100644 index 00000000000..f4edfccb178 --- /dev/null +++ b/packages/shared/src/components/imageShare/SnapshotButton.tsx @@ -0,0 +1,126 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { SnapshotIcon } from '../icons'; +import { Tooltip } from '../tooltip/Tooltip'; +import { + ToastType, + useToastNotification, +} from '../../hooks/useToastNotification'; +import type { + CaptureShareImageOptions, + CaptureTarget, +} from '../../lib/imageShare/captureShareImage'; +import { captureShareImage } from '../../lib/imageShare/captureShareImage'; +import { downloadShareImage } from '../../lib/imageShare/downloadShareImage'; +import { copyShareImage } from '../../lib/imageShare/copyShareImage'; +import { playShutterSound } from '../../features/snapshot/shutterSound'; + +export const SNAPSHOT_LABEL = 'Snapshot'; + +/** Matches the snapshot-shutter-sweep animation in utilities.css. */ +const SHUTTER_SWEEP_MS = 380; + +export interface SnapshotButtonProps { + target: CaptureTarget; + filename?: string; + label?: string; + showLabel?: boolean; + size?: ButtonSize; + variant?: ButtonVariant; + className?: string; + captureOptions?: CaptureShareImageOptions; + onCapture?: (blob: Blob) => void; +} + +export function SnapshotButton({ + target, + filename = 'daily-snapshot', + label = SNAPSHOT_LABEL, + showLabel = true, + captureOptions, + onCapture, + size = ButtonSize.Small, + variant = ButtonVariant.Tertiary, + className, +}: SnapshotButtonProps): ReactElement { + const { displayToast } = useToastNotification(); + const [isCapturing, setIsCapturing] = useState(false); + const [isFlashing, setIsFlashing] = useState(false); + const flashTimeout = useRef>(); + + useEffect( + () => () => { + if (flashTimeout.current) { + clearTimeout(flashTimeout.current); + } + }, + [], + ); + + const onSnapshot = useCallback( + async (event: React.MouseEvent) => { + // Every placement sits inside a clickable card, row or link. + event.preventDefault(); + event.stopPropagation(); + playShutterSound(); + setIsFlashing(true); + flashTimeout.current = setTimeout( + () => setIsFlashing(false), + SHUTTER_SWEEP_MS, + ); + setIsCapturing(true); + + try { + const capture = captureShareImage(target, captureOptions); + + if (onCapture) { + onCapture(await capture); + return; + } + + // Pasting beats a file in Downloads for every target we share to, so + // the clipboard leads and the download is the fallback. + if (await copyShareImage(capture)) { + displayToast('Image copied', { variant: ToastType.Success }); + return; + } + + downloadShareImage(await capture, filename); + displayToast('Image saved', { variant: ToastType.Success }); + } catch { + displayToast('Could not create the snapshot, please try again', { + variant: ToastType.Error, + }); + } finally { + setIsCapturing(false); + } + }, + [captureOptions, displayToast, filename, onCapture, target], + ); + + return ( + + + + ); +} diff --git a/packages/shared/src/components/post/PostContent.spec.tsx b/packages/shared/src/components/post/PostContent.spec.tsx index 370b236b1d3..61333f3ce5b 100644 --- a/packages/shared/src/components/post/PostContent.spec.tsx +++ b/packages/shared/src/components/post/PostContent.spec.tsx @@ -1,11 +1,14 @@ import React from 'react'; import { QueryClient } from '@tanstack/react-query'; import { GrowthBook } from '@growthbook/growthbook-react'; -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { TestBootProvider } from '../../../__tests__/helpers/boot'; import { postWithCommunitySentiment } from '../../../__tests__/fixture/post'; import { Origin } from '../../lib/log'; -import { featureCommunitySentiment } from '../../lib/featureManagement'; +import { + featureCommunitySentiment, + featureSnapshotSelectionShare, +} from '../../lib/featureManagement'; import { PostContentRaw } from './PostContent'; const renderContent = (gb?: GrowthBook) => @@ -19,6 +22,42 @@ const renderContent = (gb?: GrowthBook) => , ); +const QUOTE = + 'They optimised the product they had instead of the one their customers were moving to.'; + +const renderPostPage = (gb?: GrowthBook) => + render( + + + , + ); + +const selectTheSummary = () => { + const node = screen.getByTestId('tldr-container').firstChild as Node; + const range = document.createRange(); + range.setStart(node, 0); + range.setEnd(node, node.textContent?.length ?? 0); + + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + // The reader letting go of the drag is what commits the quote. + fireEvent.pointerUp(document); +}; + +const snapshotFlagOn = () => { + const gb = new GrowthBook(); + gb.setFeatures({ + [featureSnapshotSelectionShare.id]: { defaultValue: true }, + }); + + return gb; +}; + describe('PostContent community sentiment', () => { it('renders in the classic post modal when the flag is enabled', () => { const gb = new GrowthBook(); @@ -44,3 +83,31 @@ describe('PostContent community sentiment', () => { ).not.toBeInTheDocument(); }); }); + +describe('PostContent selection snapshot', () => { + beforeAll(() => { + // jsdom has no layout, and the bar refuses a selection it cannot place. + Range.prototype.getBoundingClientRect = () => + ({ top: 400, bottom: 440, left: 100, width: 300 } as DOMRect); + }); + + it('offers a snapshot of a quote on the post page when the flag is enabled', () => { + renderPostPage(snapshotFlagOn()); + + selectTheSummary(); + + expect( + screen.getByRole('toolbar', { name: 'Share selected text' }), + ).toBeInTheDocument(); + }); + + it('stays out of the way when the flag is disabled', () => { + renderPostPage(); + + selectTheSummary(); + + expect( + screen.queryByRole('toolbar', { name: 'Share selected text' }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/components/post/PostContent.tsx b/packages/shared/src/components/post/PostContent.tsx index 269107b846a..fc98f1fcc8e 100644 --- a/packages/shared/src/components/post/PostContent.tsx +++ b/packages/shared/src/components/post/PostContent.tsx @@ -1,6 +1,6 @@ import classNames from 'classnames'; import type { ComponentProps, ReactElement } from 'react'; -import React from 'react'; +import React, { useRef } from 'react'; import dynamic from 'next/dynamic'; import type { Post } from '../../graphql/posts'; import { isVideoPost } from '../../graphql/posts'; @@ -22,6 +22,7 @@ import { useConditionalFeature } from '../../hooks/useConditionalFeature'; import { feature, featureCommunitySentiment, + featureSnapshotSelectionShare, } from '../../lib/featureManagement'; import { isDevelopment } from '../../lib/constants'; import { LazyImage } from '../LazyImage'; @@ -31,6 +32,7 @@ import { PostClickbaitShield } from './common/PostClickbaitShield'; import { useSmartTitle } from '../../hooks/post/useSmartTitle'; import { PostTagList } from './tags/PostTagList'; import PostSourceInfo from './PostSourceInfo'; +import { SelectionSnapshotBar } from '../../features/snapshot/SelectionSnapshotBar'; import { useReaderInstallPromptGate } from '../../hooks/useReaderInstallPromptGate'; import { CommunitySentiment, @@ -97,6 +99,7 @@ export function PostContentRaw({ commentAds, }: PostContentRawProps): ReactElement { const { subject } = useToastNotification(); + const postContainerRef = useRef(null); const engagementActions = usePostContent({ origin, post, @@ -130,6 +133,12 @@ export function PostContentRaw({ }); const showCommunitySentiment = !!communitySentimentData && (communitySentimentEnabled || isDevelopment); + // Only the post page: in the modal the quote competes with the close and + // navigation controls, and the decision was to keep snapshot off it. + const { value: isSelectionSnapshotEnabled } = useConditionalFeature({ + feature: featureSnapshotSelectionShare, + shouldEvaluate: isPostPage, + }); const hasNavigation = !!onPreviousPost || !!onNextPost; const isVideoType = isVideoPost(post); const hasToc = (post.toc?.length ?? 0) > 0; @@ -164,6 +173,7 @@ export function PostContentRaw({ const postMainColumn = ( + {isSelectionSnapshotEnabled && ( + + )} {contentLeading} { + // jsdom has no layout, and the bar refuses a selection it cannot place. + Range.prototype.getBoundingClientRect = () => + ({ top: 400, bottom: 440, left: 100, width: 300 } as DOMRect); +}); + +const Harness = (): ReactElement => { + const containerRef = useRef(null); + + return ( +
+ +

{QUOTE}

+

Comments live out here.

+
+ ); +}; + +const renderBar = () => + render( + + + , + ); + +const select = (testId: string, length?: number) => { + const node = screen.getByTestId(testId).firstChild as Node; + const range = document.createRange(); + range.setStart(node, 0); + range.setEnd(node, length ?? node.textContent?.length ?? 0); + + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + // The reader letting go of the drag is what commits the quote. + fireEvent.pointerUp(document); +}; + +const toolbar = () => + screen.queryByRole('toolbar', { name: 'Share selected text' }); + +describe('SelectionSnapshotBar', () => { + it('offers a snapshot of a quote selected in the post body', () => { + renderBar(); + + select('body'); + + expect(toolbar()).toBeInTheDocument(); + expect(screen.getByLabelText('Snapshot')).toBeInTheDocument(); + }); + + it('ignores a selection too short to be a quote', () => { + renderBar(); + + select('body', 6); + + expect(toolbar()).not.toBeInTheDocument(); + }); + + it('leaves selections outside the post body alone', () => { + renderBar(); + + const outside = document.createElement('p'); + outside.textContent = QUOTE; + document.body.appendChild(outside); + + const range = document.createRange(); + range.selectNodeContents(outside); + window.getSelection()?.removeAllRanges(); + window.getSelection()?.addRange(range); + document.dispatchEvent(new Event('selectionchange')); + + expect(toolbar()).not.toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/features/snapshot/SelectionSnapshotBar.tsx b/packages/shared/src/features/snapshot/SelectionSnapshotBar.tsx new file mode 100644 index 00000000000..0a75606e256 --- /dev/null +++ b/packages/shared/src/features/snapshot/SelectionSnapshotBar.tsx @@ -0,0 +1,134 @@ +import type { ReactElement, RefObject } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../components/buttons/Button'; +import { CopyIcon, LinkIcon } from '../../components/icons'; +import { SnapshotButton } from '../../components/imageShare/SnapshotButton'; +import { Tooltip } from '../../components/tooltip/Tooltip'; +import { useCopyText } from '../../hooks/useCopy'; +import type { Post } from '../../graphql/posts'; +import { HighlightTextSnapshotCard } from './HighlightTextSnapshotCard'; +import { SNAPSHOT_SIZE } from './snapshotGradient'; +import type { TextSelection } from './useTextSelection'; +import { useTextSelection } from './useTextSelection'; + +const BAR_HEIGHT = 44; +const GAP = 8; +/** Keeps the bar off the viewport edges when the quote runs to the margin. */ +const EDGE = 96; + +const CAPTURE_OPTIONS = { + width: SNAPSHOT_SIZE, + height: SNAPSHOT_SIZE, + padding: 0, + branded: false, +}; + +const position = (selection: TextSelection) => { + const above = selection.top - BAR_HEIGHT - GAP; + const center = selection.left + selection.width / 2; + + return { + // Below the quote when it starts at the top of the viewport, where there + // is no room above it. + top: above < GAP ? selection.bottom + GAP : above, + left: Math.min( + Math.max(center, EDGE), + globalThis.innerWidth ? globalThis.innerWidth - EDGE : center, + ), + }; +}; + +export function SelectionSnapshotBar({ + post, + containerRef, +}: { + post: Post; + containerRef: RefObject; +}): ReactElement | null { + const barRef = useRef(null); + const cardRef = useRef(null); + const selection = useTextSelection(containerRef, true, barRef); + // The card outlives the bar: pressing Snapshot collapses the selection in + // some browsers, and the capture still has to find the quote mounted. + const [quote, setQuote] = useState(null); + // useCopyText, not useCopyLink: the link variant reaches for the shortener, + // which needs an authenticated user, and the bar has to work signed out. + const [, copyLink] = useCopyText(post.commentsPermalink); + const [, copyText] = useCopyText(quote?.text); + + useEffect(() => { + if (selection) { + setQuote(selection); + } + }, [selection]); + + if (!quote || typeof document === 'undefined') { + return null; + } + + return createPortal( + <> + {selection && ( +
+ +
+ )} + + {/* The card the capture reads from, off-screen at its full 1080px. */} +
+ +
+ , + document.body, + ); +} diff --git a/packages/shared/src/features/snapshot/shutterSound.ts b/packages/shared/src/features/snapshot/shutterSound.ts new file mode 100644 index 00000000000..ac00c91412d --- /dev/null +++ b/packages/shared/src/features/snapshot/shutterSound.ts @@ -0,0 +1,23 @@ +import { fromCDN } from '../../lib/links'; + +let shutter: HTMLAudioElement | null = null; + +/** + * One shared element rather than one per press: rewinding an existing clip is + * instant, while a fresh Audio has to fetch and decode before it plays. + */ +export function playShutterSound(): void { + if (typeof window === 'undefined') { + return; + } + + if (!shutter) { + shutter = new Audio(fromCDN('/sounds/shutter.mp3')); + shutter.volume = 0.45; + } + + shutter.currentTime = 0; + // Autoplay policy rejects until the page has been interacted with, and the + // capture must not fail because the sound did. + shutter.play().catch(() => {}); +} diff --git a/packages/shared/src/features/snapshot/useTextSelection.ts b/packages/shared/src/features/snapshot/useTextSelection.ts new file mode 100644 index 00000000000..73b5abb82ac --- /dev/null +++ b/packages/shared/src/features/snapshot/useTextSelection.ts @@ -0,0 +1,117 @@ +import type { RefObject } from 'react'; +import { useCallback, useEffect, useState } from 'react'; + +export interface TextSelection { + text: string; + /** Viewport coordinates, so a fixed toolbar can use them unchanged. */ + top: number; + bottom: number; + left: number; + width: number; +} + +/** Under this a selection is a stray double-click, not a quote worth sharing. */ +export const MIN_SELECTION_LENGTH = 24; + +/** How long the selection has to hold still before the toolbar commits to it. */ +const SETTLE_MS = 150; + +const read = (container: HTMLElement | null): TextSelection | null => { + const selection = globalThis.getSelection?.(); + + if (!container || !selection || selection.isCollapsed) { + return null; + } + + const text = selection.toString().trim(); + + if (text.length < MIN_SELECTION_LENGTH || selection.rangeCount === 0) { + return null; + } + + const range = selection.getRangeAt(0); + + if (!container.contains(range.commonAncestorContainer)) { + return null; + } + + const rect = range.getBoundingClientRect(); + + if (!rect.width && !rect.height) { + return null; + } + + return { + text, + top: rect.top, + bottom: rect.bottom, + left: rect.left, + width: rect.width, + }; +}; + +/** + * The current selection, but only while it lives inside `containerRef` — a + * quote from the post body, never from the comments or the nav around it. + */ +export function useTextSelection( + containerRef: RefObject, + enabled: boolean, + /** Pointer presses inside this element leave the selection alone, so the + toolbar built on top of it can be clicked. */ + ignoreRef?: RefObject, +): TextSelection | null { + const [selection, setSelection] = useState(null); + + const sync = useCallback( + () => setSelection(read(containerRef.current)), + [containerRef], + ); + + useEffect(() => { + if (!enabled) { + setSelection(null); + return undefined; + } + + let settle: ReturnType; + + // The range grows on every mouse move and a toolbar that chases it is + // unusable, so the trailing edge of the drag is the one that counts. A + // timer rather than a drag flag: a pointerup can be lost to a pointer + // released outside the window, and a flag left raised would strand the + // toolbar for the rest of the page's life. + const onSelectionChange = () => { + clearTimeout(settle); + settle = setTimeout(sync, SETTLE_MS); + }; + const onPointerDown = (event: PointerEvent) => { + if (ignoreRef?.current?.contains(event.target as Node)) { + return; + } + + setSelection(null); + }; + const onPointerUp = () => { + clearTimeout(settle); + sync(); + }; + + document.addEventListener('selectionchange', onSelectionChange); + document.addEventListener('pointerdown', onPointerDown); + document.addEventListener('pointerup', onPointerUp); + globalThis.addEventListener('scroll', sync, { passive: true }); + globalThis.addEventListener('resize', sync); + + return () => { + clearTimeout(settle); + document.removeEventListener('selectionchange', onSelectionChange); + document.removeEventListener('pointerdown', onPointerDown); + document.removeEventListener('pointerup', onPointerUp); + globalThis.removeEventListener('scroll', sync); + globalThis.removeEventListener('resize', sync); + }; + }, [enabled, ignoreRef, sync]); + + return selection; +} diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index a5d0107efda..eec00f80236 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -30,6 +30,10 @@ export const featurePostPageHighlights = new Feature( false, ); export const featurePostRedesign = new Feature('post_redesign', false); +export const featureSnapshotSelectionShare = new Feature( + 'snapshot_selection_share', + 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 diff --git a/packages/shared/src/lib/imageShare/captureShareImage.ts b/packages/shared/src/lib/imageShare/captureShareImage.ts new file mode 100644 index 00000000000..0365ec1b77e --- /dev/null +++ b/packages/shared/src/lib/imageShare/captureShareImage.ts @@ -0,0 +1,207 @@ +import type { RefObject } from 'react'; +import { createElement } from 'react'; +import type { SnapdomOptions } from '@zumer/snapdom'; +import LogoIcon from '../../svg/LogoIcon'; +import LogoText from '../../svg/LogoText'; + +export const SHARE_IMAGE_WIDTH = 1200; +export const SHARE_IMAGE_HEIGHT = 630; + +const LOGO_BAR_HEIGHT = 72; +const LOGO_BAR_BORDER = 2; +const LOGO_HEIGHT = 26; +const LOGO_GAP = 8; +const LOGO_ICON_RATIO = 35 / 20; +const LOGO_TEXT_RATIO = 77 / 20; + +export type CaptureTarget = HTMLElement | RefObject; + +export interface CaptureShareImageOptions extends SnapdomOptions { + width?: number; + height?: number; + padding?: number; + frameBackgroundColor?: string; + branded?: boolean; +} + +const TRANSPARENT = 'rgba(0, 0, 0, 0)'; +const CAPTURE_TIMEOUT_MS = 15000; + +// A cross-origin image without CORS headers leaves snapdom's inliner pending +// forever, which would otherwise spin the trigger button indefinitely. +const withTimeout = (promise: Promise): Promise => + Promise.race([ + promise, + new Promise((_, reject) => { + setTimeout( + () => reject(new Error('captureShareImage: capture timed out')), + CAPTURE_TIMEOUT_MS, + ); + }), + ]); + +const resolveFrameBackground = (): string => { + const rootStyle = getComputedStyle(document.documentElement); + const rootBackground = rootStyle.backgroundColor; + + if (rootBackground && rootBackground !== TRANSPARENT) { + return rootBackground; + } + + const themeBackground = rootStyle + .getPropertyValue('--theme-background-default') + .trim(); + + if (themeBackground) { + return themeBackground; + } + + return getComputedStyle(document.body).backgroundColor; +}; + +const svgToImage = async (markup: string): Promise => { + const image = new Image(); + image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(markup)}`; + await image.decode(); + + return image; +}; + +const drawLogoBar = async ( + context: CanvasRenderingContext2D, + canvasWidth: number, + canvasHeight: number, +): Promise => { + const { renderToStaticMarkup } = await import('react-dom/server'); + const rootStyle = getComputedStyle(document.documentElement); + const themeColor = rootStyle.getPropertyValue('--theme-text-primary').trim(); + const color = themeColor || getComputedStyle(document.body).color; + const barBackground = rootStyle + .getPropertyValue('--theme-background-default') + .trim(); + const barBorder = rootStyle + .getPropertyValue('--theme-border-subtlest-tertiary') + .trim(); + + const barTop = canvasHeight - LOGO_BAR_HEIGHT; + + if (barBackground) { + context.fillStyle = barBackground; + context.fillRect(0, barTop, canvasWidth, LOGO_BAR_HEIGHT); + } + + if (barBorder) { + context.fillStyle = barBorder; + context.fillRect(0, barTop, canvasWidth, LOGO_BAR_BORDER); + } + + const toSizedMarkup = (markup: string, width: number): string => + markup + .replace(' { + const element = target instanceof HTMLElement ? target : target.current; + + if (!element) { + throw new Error('captureShareImage: target element is not mounted'); + } + + const { + width = SHARE_IMAGE_WIDTH, + height = SHARE_IMAGE_HEIGHT, + padding = 48, + frameBackgroundColor, + branded = true, + ...snapOptions + } = options; + const barHeight = branded ? LOGO_BAR_HEIGHT : 0; + const contentWidth = width - padding * 2; + const contentHeight = height - padding * 2 - barHeight; + + const rect = element.getBoundingClientRect(); + + if (!rect.width || !rect.height) { + throw new Error('captureShareImage: target element has no size'); + } + + const fitScale = Math.min( + contentWidth / rect.width, + contentHeight / rect.height, + ); + const captureScale = Math.max(1, fitScale); + + const { snapdom } = await import('@zumer/snapdom'); + const result = await withTimeout( + snapdom(element, { + embedFonts: true, + scale: captureScale, + ...snapOptions, + }), + ); + const source = await result.toCanvas(); + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext('2d'); + + if (!context) { + throw new Error('captureShareImage: canvas 2d context unavailable'); + } + + context.fillStyle = frameBackgroundColor ?? resolveFrameBackground(); + context.fillRect(0, 0, canvas.width, canvas.height); + + const drawScale = Math.min( + contentWidth / source.width, + contentHeight / source.height, + ); + const drawWidth = source.width * drawScale; + const drawHeight = source.height * drawScale; + + context.imageSmoothingQuality = 'high'; + context.drawImage( + source, + (canvas.width - drawWidth) / 2, + padding + (contentHeight - drawHeight) / 2, + drawWidth, + drawHeight, + ); + + if (branded) { + await drawLogoBar(context, width, height); + } + + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob) { + resolve(blob); + } else { + reject(new Error('captureShareImage: failed to encode PNG')); + } + }, 'image/png'); + }); +} diff --git a/packages/shared/src/lib/imageShare/copyShareImage.ts b/packages/shared/src/lib/imageShare/copyShareImage.ts new file mode 100644 index 00000000000..a712696ceef --- /dev/null +++ b/packages/shared/src/lib/imageShare/copyShareImage.ts @@ -0,0 +1,19 @@ +/** + * Puts the PNG on the clipboard so it can be pasted straight into a chat or a + * composer. Safari only honours a clipboard write inside the task that handled + * the gesture, so the blob is handed over as a promise rather than awaited + * first — `ClipboardItem` resolves it without losing the gesture. + */ +export async function copyShareImage(blob: Promise): Promise { + if (typeof ClipboardItem === 'undefined' || !navigator.clipboard?.write) { + return false; + } + + try { + await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]); + + return true; + } catch { + return false; + } +} diff --git a/packages/shared/src/lib/imageShare/downloadShareImage.ts b/packages/shared/src/lib/imageShare/downloadShareImage.ts new file mode 100644 index 00000000000..e4d411d267d --- /dev/null +++ b/packages/shared/src/lib/imageShare/downloadShareImage.ts @@ -0,0 +1,10 @@ +export function downloadShareImage(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `${filename}.png`; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); +} diff --git a/packages/shared/src/styles/utilities.css b/packages/shared/src/styles/utilities.css index 06db629a060..9ed02e1b782 100644 --- a/packages/shared/src/styles/utilities.css +++ b/packages/shared/src/styles/utilities.css @@ -1299,6 +1299,48 @@ img.agent-media-ring { against the container instead, at the same 500px the card switches on, so a panel dragged wide gets the side-by-side layout back. */ +/* Shutter feedback on the snapshot button: a highlight crossing the face once, + left to right, so the press reads as a capture rather than a submit. */ +@keyframes snapshot-shutter-sweep { + 0% { + opacity: 0; + transform: translateX(-120%) skewX(-18deg); + } + + 22% { + opacity: 1; + } + + 100% { + opacity: 0; + transform: translateX(220%) skewX(-18deg); + } +} + +.snapshot-shutter-sweep::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 60%; + pointer-events: none; + background: linear-gradient( + 90deg, + transparent 0%, + rgba(255, 255, 255, 0.85) 50%, + transparent 100% + ); + animation: snapshot-shutter-sweep 380ms cubic-bezier(0.22, 1, 0.36, 1); +} + +@media (prefers-reduced-motion: reduce) { + .snapshot-shutter-sweep::after { + animation: none; + opacity: 0; + } +} + /* Snapshot copy is rasterized once and never reflows, so it can afford the expensive wrapping: balance evens the line lengths and removes the orphan word, and anywhere keeps long URLs or type names inside the card. */ diff --git a/packages/webapp/pages/dev/snapshot.tsx b/packages/webapp/pages/dev/snapshot.tsx new file mode 100644 index 00000000000..a47efeda107 --- /dev/null +++ b/packages/webapp/pages/dev/snapshot.tsx @@ -0,0 +1,301 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { NextSeo } from 'next-seo'; +import Toast from '@dailydotdev/shared/src/components/notifications/Toast'; +import { + Button, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { HighlightTextSnapshotCard } from '@dailydotdev/shared/src/features/snapshot/HighlightTextSnapshotCard'; +import { SelectionSnapshotBar } from '@dailydotdev/shared/src/features/snapshot/SelectionSnapshotBar'; +import { SNAPSHOT_SIZE } from '@dailydotdev/shared/src/features/snapshot/snapshotGradient'; +import { captureShareImage } from '@dailydotdev/shared/src/lib/imageShare/captureShareImage'; +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; + +/** + * /dev/snapshot — internal review surface for the post-page selection + * snapshot: the floating bar, the 1080×1080 card it exports, and the real + * capture output. + * + * The bar here is mounted unflagged so it can be reviewed without GrowthBook; + * on the post page itself it sits behind `snapshot_selection_share`. Carries + * `noindex`/`nofollow`; reachable on preview + local but blocked on the + * canonical production hosts. + */ + +const SOURCE = { + name: 'XDA Developers', + image: + 'https://res.cloudinary.com/daily-now/image/upload/s--O0TOmw4y--/f_auto/v1715772965/public/noProfile', +}; + +const POST = { + id: 'dev-snapshot-post', + title: 'Why iconic tech brands lost their dominance', + domain: 'xda-developers.com', + commentsPermalink: 'https://app.daily.dev/posts/dev-snapshot-post', + source: { id: 'xda', ...SOURCE }, +} as Post; + +const QUOTE = + 'Nokia, BlackBerry and Kodak all led their categories and all missed the same turn: they optimised the product they had instead of the one their customers were moving to.'; + +/** One per size tier in HighlightTextSnapshotCard, so all four are reviewed. */ +const TIERS = [ + { label: '≤70 chars · 72px', text: 'They optimised the product they had.' }, + { + label: '≤140 chars · 60px', + text: 'Every incumbent optimised the product it already had instead of the one its customers were moving to.', + }, + { label: '≤240 chars · 48px', text: QUOTE }, + { + label: '>280 chars · truncated at the last word', + text: `${QUOTE} ${QUOTE}`, + }, +]; + +const useIsAllowedHost = () => { + const [allowed, setAllowed] = useState(true); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + const { hostname } = window.location; + // Block the canonical production hosts only; allow localhost and the + // *.preview.app.daily.dev preview deployments so reviewers can open it. + setAllowed(hostname !== 'app.daily.dev' && hostname !== 'www.daily.dev'); + }, []); + + return allowed; +}; + +const Section = ({ + title, + caption, + children, +}: { + title: string; + caption: string; + children: ReactNode; +}) => ( +
+
+

{title}

+

{caption}

+
+ {children} + +); + +const PREVIEW_SIZE = 300; + +const ScaledCard = ({ + label, + text, +}: { + label: string; + text: string; +}): ReactElement => ( +
+
+ {label} +
+
+
+ +
+
+
+); + +const CaptureOutput = (): ReactElement => { + const cardRef = useRef(null); + const [image, setImage] = useState<{ url: string; size: number } | null>( + null, + ); + const [isCapturing, setIsCapturing] = useState(false); + const [error, setError] = useState(null); + + const onCapture = useCallback(async () => { + setIsCapturing(true); + setError(null); + + try { + const blob = await captureShareImage(cardRef, { + width: SNAPSHOT_SIZE, + height: SNAPSHOT_SIZE, + padding: 0, + branded: false, + }); + setImage({ url: URL.createObjectURL(blob), size: blob.size }); + } catch (e) { + setError(String(e)); + } finally { + setIsCapturing(false); + } + }, []); + + return ( +
+
+ + {image && ( + + {SNAPSHOT_SIZE}×{SNAPSHOT_SIZE} · {Math.round(image.size / 1024)} KB + + )} + {error && ( + {error} + )} +
+ + {image ? ( + The captured snapshot + ) : ( +
+ Not captured yet +
+ )} + + {/* The full-size card the capture reads from. */} +
+ +
+
+ ); +}; + +const LiveBar = (): ReactElement => { + const containerRef = useRef(null); + + return ( +
+ + + {SOURCE.name} · Aug 12, 2026 · 4 min read + +

+ {POST.title} +

+

+ The pattern repeats across decades. {QUOTE} +

+

+ The org chart is the part nobody shares: every one of them had a team + whose budget depended on the old product continuing to sell, and that + team wrote the roadmap. +

+
+ ); +}; + +const SnapshotDevPage = (): ReactElement => { + const allowed = useIsAllowedHost(); + + if (!allowed) { + return ( +
+

+ The snapshot review page is not available on production. +

+
+ ); + } + + return ( + <> + + +
+
+
+

+ Post page selection snapshot +

+

+ A highlighted line has no OG image of its own, so the quote is the + share and the link is only attribution. This is the one snapshot + placement on the post page — everywhere else the post's own + OG image already carries the payload. +

+

+ On the real post page this sits behind the{' '} + snapshot_selection_share flag, which defaults to off. + Here it is always on so it can be reviewed without GrowthBook. +

+
+ +
+ +
+ +
+
+ {TIERS.map((tier) => ( + + ))} +
+
+ +
+ +
+
+
+ + ); +}; + +SnapshotDevPage.getLayout = (page: ReactNode): ReactNode => page; + +export default SnapshotDevPage; diff --git a/packages/webapp/public/sounds/shutter.mp3 b/packages/webapp/public/sounds/shutter.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..f49b95f152c6d13f7a411f01abb94bab8b734be3 GIT binary patch literal 45824 zcmeI&Yfw{X8o=?B1Ofs=i#RjWV=XJbF^m-ov%JCpQbWN_v@^UOKF z_dn;;@%_k=Mtt~j2;Sutr2ea`{^dGworAr$d8+@rSpEBnyT2DZ?jYOy6ZVee!_jeK zwtB#coUMz07*7y7|MqMB#YRF1Mi&30^fd98)Rn@P8iFEPUmIhbK;6k!FQ8CujNV-M zkDpm%X&HTGyqnJ5C<(*BGE*4FpyZ562ufG!d8_aRIpc1zuG|%wlM7;=8=0&X#jpma zI7y#=^=?vr=pZ@#T3#}|er8+A{Q6l=#nAN4Sx!<(>h5oX3jW?luGu#DU2ku1-(r=e zOMj~&$t*RR-Wsss2`yc6EP)*md#p6-ZW}{M%9k+p?j6^qpBSg<*5qdp`WcLLy*?#l z=AGT>-%fVwQC?5#tbb~-y?U_Ic>Z>_iJZ?gyTmY0NFzI^+$y$Sp$qP1F0&=&2?CkG zD(BAFuhVm>0;`kl<$ci;T#xc*&)iX|wSK|Ct2xV;Pq{%(w8qWp|6D~^wd8%jNvg<| zh*wkc;;G?Z1|{k!b8MvDw0ufQFrE2~V&MJrl9;U5t&{?DN~foX;cJL!1CgxG;)TP$z5~(#u))g{>Jy& zyv(F|#)+vn{x736a$fwH{M3Wh^MEc&t6xRXZT2#on-~oQb z*U7ir2VVR*5<0QwNV7;3s(y?EMMXDLL?YR5GGS#uxtOf^onQ5}qajTwDiobN>l?Q* z@_bpOmzS4+Xry$1pu)E%QravOM!HuynZ%Yp+iH9!FwAc0@(p^C9rx|&?E^XzgNS5m zJC$&7HaSsYd@=6u9_ADGDyqbMi)F*3665shy5wTc!i#pE51G2kDJ4PSirPaOEqq9J zZa(Y94rce|K2l{QBIgwIGBrNA{jWXYKMD#JYYx4Mm!&>7+9Pw$;dMKfH~4*bW-Y5H zsMRUYQ{j9qsQi0nd5vs?pY?KT)A{bus`ja?REB7yc-DcSd}LK)+j#FzO>^$WHbGbZkk*~o5hrGHL z*NW21J0C@q3T<^=STW9&TDOiV)}rvvO|OO@=~K2PwOuWF>&-fp%2FNw>P29Y^uV`9 zKC5q14$FEY?56(wtM@(5x8@a=3Ddobx0jFYZ%y-Y zlw9@-=$nxqdQmO(|2X~Pk$rQD@c^aNY^5Z_ylz1yL32`m*6?70>RrC;Nt0C~S!*@< zt+(D&@9jm4a@%>gO&xf>w=!3(sO>9^5eFsYvaMsqDG&YarrVAbgpIu~znk6H!>eo? zp6@+(wEjbKO4HRjZyI8oo~Jve*pBFJ(XpneUuhNU_nP|M#*?Vufc^PN-4k<#$9@v$ z_h&6hOw9T?jrV!u#JI12A{ir|55BM~uZ8U4J!o!TS4Y(A)9l$-mo=t^R+fo(3&`27 z-71e8T^8iE;6l#u`(K57=gYqM@XB?xcX6?Is)3(Bm%xYQHklD7Qv?yGZcLvb7OFe@ z-{kNHf_K}W>%6q!X(2f+I7H2Q1V~P^&PxmK7LwC~L)5HCfaEmmytLqMAvrBLM9q2x zNKUiPOAGE6lGB1i)T~E<b<6^g9A;2hhZ2C~P(pAWvj8NA z85Gu`1Ryz-5M0MB0Lftng>@(aNDd_g*D(t~a+pD39ZCQ`mqQ7`b-V)blEdo>+mHf~ z9FhnI@Crb3cs*eoQUH=e62Smo0Z0z7Cu~CsKypYT7{Dt4$>H^cZAbw~4oL(9cm*Ii zyq>TPDFDeKiC_S)03?Uk6Sg4*AUPxv4B!=jH^cZAbw~4oL(9cm*Ii Zyq>TPDFDeKiC_S)03?Uk6Sg4*{tKU|N9h0n literal 0 HcmV?d00001 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b1d536563b8..f7542270858 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -447,6 +447,9 @@ importers: '@tiptap/starter-kit': specifier: ^3.22.5 version: 3.22.5 + '@zumer/snapdom': + specifier: ^2.23.1 + version: 2.24.7 border-beam: specifier: 1.3.0 version: 1.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -4851,6 +4854,9 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + '@zumer/snapdom@2.24.7': + resolution: {integrity: sha512-RKWP/NoxU5zXO1O1QaIerdb8HYLDrQPSU9G9UMk9XDSevsXwIydDYFnJukrp0BqrckQT+Hbspxx2WERXIF2i9w==} + abab@2.0.6: resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} deprecated: Use your platform's native atob() and btoa() methods instead @@ -14279,6 +14285,8 @@ snapshots: '@xtuc/long@4.2.2': {} + '@zumer/snapdom@2.24.7': {} + abab@2.0.6: {} accepts@1.3.8: From 0d3b11f7c2a991adc059ea975894342798991183 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Mon, 31 Aug 2026 17:07:43 +0300 Subject: [PATCH 04/19] docs(snapshot): restore the full post page page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The version on this branch was a trimmed copy: two categories out of six. The page on #6544 carries the whole post-page argument, and the missing four are the load-bearing ones — the share strip, end-of-thread band and post-upvote prompt; the audit showing seven post types breaking seven different ways; the poll recommendation, which is the one type where snapshot beats a link outright; and the modal and sticky nav the audit had missed. Brings surfaceChrome along, since the page is drawn with its ShareSheet and its three real viewport widths rather than the older device rail, and keeps the exported-image variant next to the selection bar it belongs to. Co-Authored-By: Claude Opus 5 --- .../features/snapshot/surfaceChrome.tsx | 187 +-- .../snapshot/surfaces/PostPage.stories.tsx | 1208 ++++++++++++++--- 2 files changed, 1095 insertions(+), 300 deletions(-) diff --git a/packages/storybook/stories/features/snapshot/surfaceChrome.tsx b/packages/storybook/stories/features/snapshot/surfaceChrome.tsx index 50ca4caaae6..8ca9e03b6c6 100644 --- a/packages/storybook/stories/features/snapshot/surfaceChrome.tsx +++ b/packages/storybook/stories/features/snapshot/surfaceChrome.tsx @@ -58,14 +58,11 @@ export const LABELS: Record = { export const Control = ({ action, label, - text, size = ButtonSize.Small, variant = ButtonVariant.Tertiary, }: { action: LeadAction; label?: boolean; - /** Overrides the default label where the surface needs shorter copy. */ - text?: string; size?: ButtonSize; variant?: ButtonVariant; }) => ( @@ -75,7 +72,7 @@ export const Control = ({ size={size} variant={variant} > - {label ? text ?? LABELS[action] : undefined} + {label ? LABELS[action] : undefined} ); @@ -101,71 +98,144 @@ export const Screen = ({ ); /** - * The real context menu, not an illustration of one. Every surface below - * passes its production item list — today the share entry is "Share via", - * which opens the share modal; no surface offers Copy link from a menu. + * The real post menu, in the real order — PostOptionButton.tsx. There is no + * "Copy link" item here or on any other surface; the menu leads with "Share + * via", which opens the share sheet. */ +export const POST_MENU = [ + 'Share via', + 'Hide', + 'Report', + 'Read it later', + 'Translate', + 'Follow XDA Developers', +]; + export const OverflowMenu = ({ items, highlight, className, }: { items: string[]; - /** The share entry, drawn with its icon and picked out from the rest. - * Omitted where the menu has no share action at all — several do not. */ + /** The share item, whatever this surface actually calls it. */ highlight?: string; className?: string; }) => (
- {items.map((item) => - item === highlight ? ( - - - {item} - - ) : ( + {items.map((item) => { + const isShare = item === highlight; + + return ( + {isShare && } {item} - ), - )} + ); + })}
); -/** The production post/feed context menu, in order. */ -export const POST_MENU = [ - 'Share via', - 'Hide', - 'Report', - 'Downvote', - 'Read it later', -]; +/** The production share modal: copy link, then the named targets. */ +export const ShareSheet = () => ( +
+
+ Share +
+ {[ + ['Copy link', 'bg-accent-cabbage-default'], + ['X', 'bg-text-primary'], + ['WhatsApp', 'bg-accent-avocado-default'], + ['Facebook', 'bg-accent-bun-default'], + ['Reddit', 'bg-accent-ketchup-default'], + ['LinkedIn', 'bg-accent-blueCheese-default'], + ['Telegram', 'bg-accent-water-default'], + ['Email', 'bg-accent-burger-default'], + ].map(([label, tone]) => ( +
+ + + {label} + +
+ ))} +
+
+
+); + +export type DeviceName = 'Desktop' | 'Tablet' | 'Mobile'; + +/** + * Breakpoints matter more than usual here. PostSourceInfo renders the whole + * header cluster as `hidden laptop:flex`, so the ⋯ menu that carries sharing + * on desktop is simply not in the article header below 1020px — it moves to a + * sticky back-bar, and a floating action bar appears at the bottom. A + * recommendation that only works on one of the three is not a recommendation. + */ +export const DEVICES: Record< + DeviceName, + { width: number; viewport: string } +> = { + Desktop: { width: 680, viewport: '1020px and up' }, + Tablet: { width: 560, viewport: '768px' }, + Mobile: { width: 375, viewport: '375px' }, +}; + +/** A surface drawn at one real viewport width, so density is comparable. */ +export const Device = ({ + name, + children, + height, +}: { + name: DeviceName; + children: React.ReactNode; + /** Mobile surfaces pin a floating bar, so the frame needs a known height. */ + height?: number; +}) => ( +
+ + {name} · {DEVICES[name].viewport} + +
+ {children} +
+
+); + +/** Devices sit in a scroller rather than wrapping, so widths stay honest. */ +export const Rail = ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+); export const Variant = ({ step, headline, note, - wide, children, }: { step: string; headline: string; note: string; - /** Full width, for a variation drawn at several breakpoints. */ - wide?: boolean; children: React.ReactNode; }) => ( -
+ // Full width so a device rail can scroll across the whole canvas. +
{step} @@ -198,7 +268,7 @@ export const Category = ({ {verdict}

-
{children}
+
{children}
); @@ -223,46 +293,3 @@ export const SurfacePage = ({ {children}
); - -/** - * Breakpoints matter more than usual here: PostHeaderActions is - * `hidden laptop:flex`, so the control that leads on desktop is simply not - * on the page below 1020px. Every recommendation is drawn at all three. - */ -export const DEVICES = { - desktop: { label: 'Desktop · 1280', width: 'w-[40rem]' }, - tablet: { label: 'Tablet · 768', width: 'w-[30rem]' }, - mobile: { label: 'Mobile · 375', width: 'w-[23.4rem]' }, -} as const; - -export type Device = keyof typeof DEVICES; - -export const DeviceRail = ({ - children, -}: { - children: React.ReactNode; -}) => ( -
{children}
-); - -export const DeviceFrame = ({ - device, - note, - children, -}: { - device: Device; - note?: string; - children: React.ReactNode; -}) => ( -
-
- - {DEVICES[device].label} - - {note && ( - {note} - )} -
- {children} -
-); diff --git a/packages/storybook/stories/features/snapshot/surfaces/PostPage.stories.tsx b/packages/storybook/stories/features/snapshot/surfaces/PostPage.stories.tsx index 33ddcc96031..ba5505678e0 100644 --- a/packages/storybook/stories/features/snapshot/surfaces/PostPage.stories.tsx +++ b/packages/storybook/stories/features/snapshot/surfaces/PostPage.stories.tsx @@ -6,364 +6,1132 @@ import { ButtonVariant, } from '@dailydotdev/shared/src/components/buttons/Button'; import { + ArrowIcon, BookmarkIcon, CopyIcon, DiscussIcon, DownvoteIcon, LinkIcon, + MedalBadgeIcon, MenuIcon, + MiniCloseIcon, OpenLinkIcon, + ShareIcon, + TwitterIcon, UpvoteIcon, + WhatsappIcon, } from '@dailydotdev/shared/src/components/icons'; import { HighlightTextSnapshotCard } from '@dailydotdev/shared/src/features/snapshot/HighlightTextSnapshotCard'; import { SNAPSHOT_SIZE } from '@dailydotdev/shared/src/features/snapshot/snapshotGradient'; -import type { Device } from '../surfaceChrome'; +import type { DeviceName } from '../surfaceChrome'; import { AVATAR, Category, - Control, - DeviceFrame, - DeviceRail, - DEVICES, - Note, OverflowMenu, POST_MENU, - Screen, + Control, + Device, + Rail, + ShareSheet, SurfacePage, Variant, } from '../surfaceChrome'; -type Spot = 'today' | 'selection'; +type Spot = + | 'plain' + | 'today' + | 'today-sheet' + | 'summary' + | 'selection' + | 'strip' + | 'endband' + | 'upvote'; const TITLE = 'Why iconic tech brands lost their dominance'; -const SUMMARY = - 'Nokia, BlackBerry and Kodak all led their categories and all missed the same turn: they optimised the product they had instead of the one their customers were moving to.'; +const SUMMARY_LEAD = + 'Nokia, BlackBerry and Kodak all led their categories and all missed the same turn. '; + +const SUMMARY_QUOTE = + 'Every one of them optimised the product they had instead of the one their customers were moving to.'; + +const isCompact = (device: DeviceName) => device !== 'Desktop'; + +/* The card from #6544 at a third of its size: it is built for a 1080px canvas, + so it is drawn full-size and scaled rather than made responsive. */ +const PREVIEW_SIZE = 360; -const TAGS = ['tech', 'business', 'startups']; +const SnapshotResult = () => ( +
+
+ +
+
+); -const isCompact = (device: Device) => device !== 'desktop'; +/* ------------------------------------------------------------ the real page */ + +/** Desktop only: the header cluster is `hidden laptop:flex` in production. */ +const HeaderActions = ({ menu }: { menu?: boolean }) => ( +
+ +
+); + +/** Below laptop the same cluster lives in a sticky bar with the back arrow. */ +const MobileTopBar = ({ menu }: { menu?: boolean }) => ( +
+
+); -/* Source row. PostHeaderActions is `hidden laptop:flex`, so Read post and the - ⋯ menu only exist on desktop; below that the Follow link takes their place. */ const SourceRow = ({ device, menu, }: { - device: Device; + device: DeviceName; menu?: boolean; }) => ( -
- - - XDA Developers - - {isCompact(device) ? ( - <> - · - Follow - - ) : ( -
- -
- )} - {menu && } +
+ XDA Developers + · + Follow + {!isCompact(device) && } +
+); + +const Summary = ({ + device, + highlighted, + inlineTrailing, + trailing, +}: { + device: DeviceName; + highlighted?: boolean; + /** Runs in at the end of the last line, inside the paragraph. */ + inlineTrailing?: React.ReactNode; + trailing?: React.ReactNode; +}) => ( +
+

+ {SUMMARY_LEAD} + + {SUMMARY_QUOTE} + + {inlineTrailing} +

+ {trailing}
); const Tags = () => ( -
    - {TAGS.map((tag) => ( -
  • + {['#tech-industry', '#brands', '#strategy'].map((tag) => ( + - #{tag} -
  • + {tag} + ))} -
+
); -/* The production engagement bar: a bordered pill, labels on everything but - the votes, and Copy — not Share — as the last action. */ -const ActionBar = ({ device }: { device: Device }) => { - const labels = !isCompact(device); - - return ( -
-
- - - -
-
- ); -}; +const Metadata = () => ( + + Aug 31, 2026 · 4 min read · From xda-developers.com + +); -const SelectionBar = () => ( -
( + +); + +/** PostActions.v2: a bordered bar, labels visible from Comment rightwards. */ +const InlineActionBar = ({ device }: { device: DeviceName }) => ( +
+ } label="Upvote" /> + } label="Downvote" /> + } + label="Comment" + labelVisible={!isCompact(device)} /> -
); -const MobileFloatingBar = () => ( -
+/** MobilePostFloatingBar.v2: pinned, icons only, copy link last. */ +const FloatingBar = () => ( +
+ } label="Downvote" /> + } label="Bookmark" /> + } label="Copy link" /> +
+); + +/** + * #6350's 'Copy summary': one tap puts the headline, the TLDR and the article + * link on the clipboard. Icon only, XSmall, inline, and quieter than the body + * copy it trails — it runs in at the end of the summary's last line, so it has + * to sit below the text in the reading order without breaking the paragraph. + */ +const CopySummary = () => ( +
); -/* The card from #6544 at a third of its size: it is built for a 1080px canvas, - so it is drawn full-size and scaled rather than made responsive. */ -const PREVIEW_SIZE = 360; +const CommentBar = () => ( +
+ +
+ Share your thoughts +
+
+); -const SnapshotResult = () => ( +/** + * DiscussionShareRow, as it ships: copy, X, WhatsApp, up to four squad + * avatars, then the modal. Only two squads show below tablet. + */ +const ShareStrip = ({ device }: { device: DeviceName }) => ( +
+ Share this post +
+ } label="Copy link" /> + } label="Share on X" /> + } label="Share on WhatsApp" /> + {Array.from( + { length: device === 'Mobile' ? 2 : 4 }, + (_, index) => index, + ).map((squad) => ( +
+
+); + +const Comments = ({ device }: { device: DeviceName }) => ( +
+ 24 comments +
+ +
+ + Bobby Iliev + + + The org chart point is the whole article, honestly. + +
+
+
+); + +const Band = ({ + title, + body, + children, + accent, +}: { + title: string; + body: string; + children: React.ReactNode; + accent?: boolean; +}) => (
-
- +
+ {title} + {body}
+ {children}
); -const PostScreen = ({ device, spot }: { device: Device; spot: Spot }) => { +const PostView = ({ + device, + spot, +}: { + device: DeviceName; + spot: Spot; +}) => { const compact = isCompact(device); + const showsMenu = spot === 'today'; return ( - -
- + + {compact && } + +
+

{TITLE}

-

- The pattern repeats across decades.{' '} - - {SUMMARY} - -

- {spot === 'selection' && } + : undefined} + trailing={spot === 'selection' ? : undefined} + /> + - - Aug 12, 2026 · 4 min read - + {!compact && ( + + )} + + {spot === 'upvote' && ( + + + + )} -
+ {spot === 'strip' && ( +
+ + +
+ )} + + - - 128 Upvotes · 24 Comments + {spot === 'endband' && ( + + + + )} +
+ + {compact && } + {spot === 'today-sheet' && } + + ); +}; + +const AllDevices = ({ spot }: { spot: Spot }) => ( + + + + + +); + + +/* -------------------------------------------------------------- post types */ + +type PostKind = + | 'article' + | 'video' + | 'freeform' + | 'poll' + | 'collection' + | 'shared' + | 'twitter'; + +const TYPE_TITLE: Record = { + article: 'Why iconic tech brands lost their dominance', + video: 'I rebuilt my homelab around one mini PC', + freeform: 'What we learned shipping a design system in six weeks', + poll: 'Which do you reach for first in a new service?', + collection: 'Everything announced at the Postgres 19 launch', + shared: 'Worth reading if you touch CI at all', + twitter: 'The org chart is the product roadmap', +}; + +/** Article and video carry a summary. Nothing else does. */ +const HAS_SUMMARY: PostKind[] = ['article', 'video', 'collection']; + +const PollBody = () => ( +
+ {[ + ['Postgres', 62], + ['Redis', 21], + ['SQLite', 11], + ['Something else', 6], + ].map(([label, share]) => ( +
+ + + {label} + {share}% +
+ ))} + + 1,284 votes · 2 days left + +
+ + + Why did you vote this way? + + +
+
+); - +const TypeBody = ({ + kind, + trailing, +}: { + kind: PostKind; + trailing?: React.ReactNode; +}) => { + if (kind === 'poll') { + return ; + } -
- -
- - Bobby Iliev + if (kind === 'video') { + return ( +
+ YouTube player +
+ ); + } + + if (kind === 'twitter') { + return ( +
+
+ + + @bobbyiliev · on X + +
+ + Every reorg is a bet about what the product will need in a year. + +
+ ); + } + + if (kind === 'shared') { + return ( +
+ + Worth reading if you touch CI at all. + +
+
+
+ + Why iconic tech brands lost their dominance - - The org chart point is the whole article, honestly. + + xda-developers.com
+
+ ); + } + if (kind === 'collection') { + return ( +
+

+ {SUMMARY_LEAD} + {trailing} +

+
+
+ {[0, 1, 2, 3].map((i) => ( + + ))} +
+ + 12 sources · updated 2h ago + +
+
+ ); + } + + if (kind === 'freeform') { + return ( +
+

+ We gave ourselves six weeks and one rule: no component ships without a + consumer. Here is what broke. +

+

1. Tokens before components

+

+ Naming the colours took longer than building anything that used them. +

+ ); + } - {compact && } - + return ( +

+ {SUMMARY_LEAD} + {trailing} +

); }; -const Rail = ({ spot }: { spot: Spot }) => ( - - - - - - - - - - - +const TypeView = ({ + kind, + gap, +}: { + kind: PostKind; + /** What sharing gets wrong on this type. */ + gap: string; +}) => ( +
+
+
+
+ + {kind === 'freeform' || kind === 'poll' || kind === 'shared' + ? 'Frontend Fans' + : 'XDA Developers'} + + {kind !== 'shared' && ( + <> + · + Follow + + )} +
+ {kind !== 'shared' && kind !== 'poll' && kind !== 'freeform' && ( + + )} +
+
+ +

+ {TYPE_TITLE[kind]} +

+ + : undefined} + /> + +
+ } label="Upvote" /> + } label="Downvote" /> + } label="Comment" labelVisible /> + } label="Bookmark" labelVisible /> + } label="Copy" labelVisible /> +
+
+
+ {gap} +
+); + + +/* --------------------------------------------------------- the poll variant */ + +type PollSpot = 'today' | 'result' | 'postvote'; + +const PollResults = () => ( +
+ {[ + ['Postgres', 62], + ['Redis', 21], + ['SQLite', 11], + ['Something else', 6], + ].map(([label, share]) => ( +
+ + + {label} + {share}% + +
+ ))} +
+); + +const PollView = ({ + device, + spot, +}: { + device: DeviceName; + spot: PollSpot; +}) => ( + +
+
+ Frontend Fans + · + Follow +
+ +

+ Which do you reach for first in a new service? +

+ + + +
+ + 1,284 votes · 2 days left + + {spot === 'result' && } +
+ +
+ + + Why did you vote this way? + +
+ {spot === 'postvote' && ( + + )} + +
+
+ +
+ } label="Upvote" /> + } label="Downvote" /> + } + label="Comment" + labelVisible={!isCompact(device)} + /> + } + label="Bookmark" + labelVisible={!isCompact(device)} + /> + } + label="Copy" + labelVisible={!isCompact(device)} + /> +
+
+
+); + +const AllPolls = ({ spot }: { spot: PollSpot }) => ( + + + + + +); + +/* ---------------------------------------------- the modal and the sticky nav */ + +/** PostNavigation with `inlineActions`: Read post loses its label. */ +const NavActions = ({ close }: { close?: boolean }) => ( +
+
+); + +const NavBar = ({ + close, + share, +}: { + close?: boolean; + share?: boolean; +}) => ( +
+
+); + +const ModalOrNavView = ({ + device, + kind, + share, +}: { + device: DeviceName; + kind: 'modal' | 'nav'; + share?: boolean; +}) => ( + + {kind === 'nav' && ( +
+ scrolled past the header +
+ )} + + +
+
+ XDA Developers + · + Follow + {/* `navigation.actions: ml-auto tablet:hidden` — gone from tablet up. */} + {device === 'Mobile' && } +
+ +

+ {TITLE} +

+

+ {SUMMARY_LEAD} + +

+ + {kind === 'modal' && ( +
+ + +
+ )} +
+
); +/* -------------------------------------------------------------------- page */ + const PostPage = () => ( - + + + + + + + + + + - + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); From c8a0befd5bb36c211647815d023a6f9b70b18ddc Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Mon, 31 Aug 2026 17:28:21 +0300 Subject: [PATCH 05/19] feat(snapshot): make every post page placement testable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Storybook page argues for nine placements; only the selection bar could actually be pressed. /dev/snapshot now carries all of them with working controls rather than pictures of them: copy summary, the selection bar, the share strip, the end-of-thread band, the post-upvote prompt, both poll snapshots and the sticky nav copy-link. Every copy button copies for real and every Snapshot rasterizes the card it would share. Two new payloads this needed: - PollSnapshotCard — the poll recommendation had no card. The winner is drawn in the accent and the rest stay quiet, so the answer reads before the bars. - CopySummaryButton (#6350) — headline, TLDR and link as three paragraphs, so a paste into a thread is a usable message rather than a bare URL. Both copy call sites now catch a rejected clipboard write. `navigator.clipboard` refuses outright when the document is not focused, and the shared hook awaits it without a catch, so the press produced no toast, no fallback and an unhandled rejection. It now says the clipboard was blocked. Only the selection bar is wired to the real post page; the rest are placement mocks with real controls, which is what the review needs before any of them is committed to a surface. Testing: shared 377 suites / 2654 tests, webapp 81 / 644. Pressed every control in a browser — real clicks copy (OS clipboard changes), blocked clipboard shows the error toast, both poll Snapshots and both capture buttons return 1080² PNGs (quote 423 KB, poll 378 KB). Co-Authored-By: Claude Opus 5 --- .../snapshot/CopySummaryButton.spec.tsx | 54 ++ .../features/snapshot/CopySummaryButton.tsx | 67 ++ .../features/snapshot/PollSnapshotCard.tsx | 123 ++++ packages/webapp/pages/dev/snapshot.tsx | 678 ++++++++++++++---- 4 files changed, 802 insertions(+), 120 deletions(-) create mode 100644 packages/shared/src/features/snapshot/CopySummaryButton.spec.tsx create mode 100644 packages/shared/src/features/snapshot/CopySummaryButton.tsx create mode 100644 packages/shared/src/features/snapshot/PollSnapshotCard.tsx diff --git a/packages/shared/src/features/snapshot/CopySummaryButton.spec.tsx b/packages/shared/src/features/snapshot/CopySummaryButton.spec.tsx new file mode 100644 index 00000000000..7e5caa9d157 --- /dev/null +++ b/packages/shared/src/features/snapshot/CopySummaryButton.spec.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { QueryClient } from '@tanstack/react-query'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; +import Toast from '../../components/notifications/Toast'; +import { CopySummaryButton } from './CopySummaryButton'; + +const writeText = jest.fn().mockResolvedValue(undefined); + +beforeEach(() => { + writeText.mockClear(); + Object.assign(navigator, { clipboard: { writeText } }); +}); + +const renderButton = () => + render( + + + + , + ); + +describe('CopySummaryButton', () => { + it('copies the headline, the summary and the link as three paragraphs', async () => { + renderButton(); + + fireEvent.click(screen.getByLabelText('Copy summary')); + + await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1)); + expect(writeText).toHaveBeenCalledWith( + [ + 'Why iconic tech brands lost their dominance', + 'They optimised the product they had.', + 'https://app.daily.dev/posts/p1', + ].join('\n\n'), + ); + }); + + it('says so when the browser blocks the clipboard', async () => { + writeText.mockRejectedValueOnce(new Error('NotAllowedError')); + + renderButton(); + + fireEvent.click(screen.getByLabelText('Copy summary')); + + expect( + await screen.findByText('❌ Your browser blocked the clipboard'), + ).toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/features/snapshot/CopySummaryButton.tsx b/packages/shared/src/features/snapshot/CopySummaryButton.tsx new file mode 100644 index 00000000000..dc5cd9a8e0d --- /dev/null +++ b/packages/shared/src/features/snapshot/CopySummaryButton.tsx @@ -0,0 +1,67 @@ +import type { ReactElement } from 'react'; +import React, { useCallback } from 'react'; +import classNames from 'classnames'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../components/buttons/Button'; +import { CopyIcon } from '../../components/icons'; +import { Tooltip } from '../../components/tooltip/Tooltip'; +import { useCopyText } from '../../hooks/useCopy'; +import { + ToastType, + useToastNotification, +} from '../../hooks/useToastNotification'; + +/** + * #6350's Copy summary. One press puts the headline, the TLDR and the link on + * the clipboard together, so pasting into a thread gives a usable message + * rather than a bare URL — the payload is text because the point is that it + * can be pasted and edited, not looked at. + */ +export function CopySummaryButton({ + title, + summary, + link, + className, +}: { + title: string; + summary: string; + link: string; + className?: string; +}): ReactElement { + const [, copy] = useCopyText([title, summary, link].join('\n\n')); + const { displayToast } = useToastNotification(); + + // The clipboard rejects outright when the document is not focused, and a + // press that reports nothing at all reads as a dead button. + const onCopy = useCallback(async () => { + try { + await copy({ message: '✅ Copied summary' }); + } catch { + displayToast('❌ Your browser blocked the clipboard', { + variant: ToastType.Error, + }); + } + }, [copy, displayToast]); + + return ( + + + ); +}; + +/* --------------------------------------------------------------- the mocks */ + +const ArticleBody = ({ trailing }: { trailing?: ReactNode }) => ( +
+
+ + {SOURCE.name} + } label="Options" /> +
+

+ {TITLE} +

+

+ {SUMMARY_LEAD} + {QUOTE} + {trailing} +

+
+); + +const PollResults = () => ( +
+ {POLL.options.map((option) => ( +
+ + + {option.text} + + + {option.share}% + +
+ ))} +
+); + +/* ---------------------------------------------------- capture + its output */ const ScaledCard = ({ label, - text, + children, + size = 300, }: { label: string; - text: string; -}): ReactElement => ( + children: ReactNode; + size?: number; +}) => (
{label}
- + {children}
); -const CaptureOutput = (): ReactElement => { - const cardRef = useRef(null); +const CaptureOutput = ({ + target, + label, +}: { + target: RefObject; + label: string; +}): ReactElement => { const [image, setImage] = useState<{ url: string; size: number } | null>( null, ); @@ -137,22 +335,17 @@ const CaptureOutput = (): ReactElement => { setError(null); try { - const blob = await captureShareImage(cardRef, { - width: SNAPSHOT_SIZE, - height: SNAPSHOT_SIZE, - padding: 0, - branded: false, - }); + const blob = await captureShareImage(target, CAPTURE_OPTIONS); setImage({ url: URL.createObjectURL(blob), size: blob.size }); } catch (e) { setError(String(e)); } finally { setIsCapturing(false); } - }, []); + }, [target]); return ( -
+
{image && ( - {SNAPSHOT_SIZE}×{SNAPSHOT_SIZE} · {Math.round(image.size / 1024)} KB + {SNAPSHOT_SIZE}x{SNAPSHOT_SIZE} · {Math.round(image.size / 1024)} KB )} {error && ( {error} )}
- - {image ? ( + {image && ( The captured snapshot - ) : ( -
- Not captured yet -
)} - - {/* The full-size card the capture reads from. */} -
- -
); }; -const LiveBar = (): ReactElement => { +/* ------------------------------------------------------------ the sections */ + +const TheTwoAdditions = ({ + quoteRef, +}: { + quoteRef: RefObject; +}): ReactElement => { const containerRef = useRef(null); return ( -
- - - {SOURCE.name} · Aug 12, 2026 · 4 min read - -

- {POST.title} -

-

- The pattern repeats across decades. {QUOTE} -

-

- The org chart is the part nobody shares: every one of them had a team - whose budget depended on the old product continuing to sell, and that - team wrote the roadmap. -

-
+ + + } + /> + + + +
+ + +
+
+ + +
+
+ {QUOTE_TIERS.map((tier) => ( + + + + ))} +
+ +
+
+ ); }; +const ThePromptedMoments = (): ReactElement => ( +
+ +
+
+ +
+ Share your thoughts +
+
+
+ + Share this post + +
+ + } label="Share on X" /> + } + label="Share on WhatsApp" + /> + {[0, 1, 2, 3].map((squad) => ( + + } + label="Share to squad" + /> + ))} + } label="More sharing options" /> +
+
+
+
+ + +
+
+ +
+ + Bobby Iliev + + + The org chart point is the whole article, honestly. + +
+
+ + + +
+
+ + + + + + +
+); + +const ThePoll = ({ + pollRef, +}: { + pollRef: RefObject; +}): ReactElement => ( +
+ +
+

+ {POLL.question} +

+ +
+ + {POLL.votes} · 2 days left + + +
+
+
+ + +
+ + + Why did you vote this way? + +
+ + +
+
+
+ + +
+ + + + +
+
+
+); + +const TheStickyNav = (): ReactElement => ( +
+ +
+ + + {TITLE} + + } label="Upvote" /> + } label="Bookmark" /> + + } label="Options" /> +
+
+
+); + +const ShipsToday = (): ReactElement => ( +
+ +
+ +
+ } label="Upvote" /> + } label="Downvote" /> + } label="Comment" /> + } label="Bookmark" /> + +
+
+
+
+); + +/* ------------------------------------------------------------------- page */ + const SnapshotDevPage = (): ReactElement => { const allowed = useIsAllowedHost(); + const quoteRef = useRef(null); + const pollRef = useRef(null); if (!allowed) { return ( @@ -251,47 +691,45 @@ const SnapshotDevPage = (): ReactElement => {

- Post page selection snapshot + Post page share placements

- A highlighted line has no OG image of its own, so the quote is the - share and the link is only attribution. This is the one snapshot - placement on the post page — everywhere else the post's own - OG image already carries the payload. + Every placement the Storybook post-page page argues for, with + working controls instead of pictures of them. Each copy button + really copies; each Snapshot really rasterizes the card it would + share.

- On the real post page this sits behind the{' '} - snapshot_selection_share flag, which defaults to off. - Here it is always on so it can be reviewed without GrowthBook. + Only the text-selection bar is wired into the real post page, and + there it sits behind snapshot_selection_share, which + defaults to off. Everything else is a placement mock: the controls + are real, the surfaces around them are not.

-
- -
- -
-
- {TIERS.map((tier) => ( - - ))} -
-
- -
- -
+ + + + +
+ + {/* The full-size cards every capture on the page reads from. */} +
+ + +
); }; From b953a1297b44be1ca8bd94879d151f6e7f22a5ec Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Mon, 31 Aug 2026 18:05:07 +0300 Subject: [PATCH 06/19] feat(snapshot): ship the post page share placements behind flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every placement the Storybook page argues for is now production code on the surface it belongs to, each behind its own flag defaulting to off: - post_copy_summary — CopySummaryButton runs into the end of the TLDR in PostContent, evaluated only where a summary exists. - post_share_prompts — DiscussionShareRow under the comment composer in PostEngagements, already shipping in the discussion panel but never on the post page, and EndOfThreadShare after the thread. - poll_snapshot — PollSnapshotButton under the poll bars and on the post-vote prompt in PollPostContent, evaluated only on polls that have votes. - post_nav_copy_link — a copy-link icon in PostNavigation, gated on isFixedNavigation so the inline navigation is untouched. The post-upvote prompt needed nothing: PostContentShare already renders "Should anyone else see this post?" with a copy-link input the moment you upvote. The page proposed a placement production had already built. pollSnapshotFromPost derives each bar from the options' own counts rather than numPollVotes, which counts voters and would leave the bars adding up to something other than the poll. /dev/snapshot now mounts the real components instead of imitations. `/dev/*` short-circuits to a QueryClient-only tree in _app, so the page stands in for the auth and log contexts the share components reach for — signed out, no squads, logging swallowed — and names the flag behind each placement. Testing: shared 379 suites / 2661 tests, webapp 81 / 644, extension 6 / 52. New specs cover the poll payload maths, the band's empty-thread case, the copy payload and both flag states for copy summary. Pressed every control in a browser: both poll snapshots capture, the real share row and band render. Co-Authored-By: Claude Opus 5 --- .../src/components/post/PostContent.spec.tsx | 26 +++ .../src/components/post/PostContent.tsx | 15 ++ .../src/components/post/PostEngagements.tsx | 16 ++ .../src/components/post/PostNavigation.tsx | 23 ++- .../components/post/poll/PollPostContent.tsx | 41 +++- .../snapshot/EndOfThreadShare.spec.tsx | 36 ++++ .../features/snapshot/EndOfThreadShare.tsx | 54 +++++ .../features/snapshot/PollSnapshotButton.tsx | 62 ++++++ .../features/snapshot/pollSnapshot.spec.ts | 55 ++++++ .../src/features/snapshot/pollSnapshot.ts | 40 ++++ packages/shared/src/lib/featureManagement.ts | 4 + packages/webapp/pages/dev/snapshot.tsx | 185 +++++++++++------- 12 files changed, 475 insertions(+), 82 deletions(-) create mode 100644 packages/shared/src/features/snapshot/EndOfThreadShare.spec.tsx create mode 100644 packages/shared/src/features/snapshot/EndOfThreadShare.tsx create mode 100644 packages/shared/src/features/snapshot/PollSnapshotButton.tsx create mode 100644 packages/shared/src/features/snapshot/pollSnapshot.spec.ts create mode 100644 packages/shared/src/features/snapshot/pollSnapshot.ts diff --git a/packages/shared/src/components/post/PostContent.spec.tsx b/packages/shared/src/components/post/PostContent.spec.tsx index 61333f3ce5b..93c295e0e6a 100644 --- a/packages/shared/src/components/post/PostContent.spec.tsx +++ b/packages/shared/src/components/post/PostContent.spec.tsx @@ -7,6 +7,7 @@ import { postWithCommunitySentiment } from '../../../__tests__/fixture/post'; import { Origin } from '../../lib/log'; import { featureCommunitySentiment, + featurePostCopySummary, featureSnapshotSelectionShare, } from '../../lib/featureManagement'; import { PostContentRaw } from './PostContent'; @@ -111,3 +112,28 @@ describe('PostContent selection snapshot', () => { ).not.toBeInTheDocument(); }); }); + +describe('PostContent copy summary', () => { + const withFlag = () => { + const gb = new GrowthBook(); + gb.setFeatures({ [featurePostCopySummary.id]: { defaultValue: true } }); + + return gb; + }; + + it('runs the icon into the end of the TLDR when the flag is enabled', () => { + renderPostPage(withFlag()); + + expect(screen.getByLabelText('Copy summary')).toBeInTheDocument(); + // It has to live inside the paragraph, not under it. + expect(screen.getByTestId('tldr-container')).toContainElement( + screen.getByLabelText('Copy summary'), + ); + }); + + it('stays off the paragraph when the flag is disabled', () => { + renderPostPage(); + + expect(screen.queryByLabelText('Copy summary')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/components/post/PostContent.tsx b/packages/shared/src/components/post/PostContent.tsx index fc98f1fcc8e..79f7b1adffd 100644 --- a/packages/shared/src/components/post/PostContent.tsx +++ b/packages/shared/src/components/post/PostContent.tsx @@ -22,6 +22,7 @@ import { useConditionalFeature } from '../../hooks/useConditionalFeature'; import { feature, featureCommunitySentiment, + featurePostCopySummary, featureSnapshotSelectionShare, } from '../../lib/featureManagement'; import { isDevelopment } from '../../lib/constants'; @@ -33,6 +34,7 @@ import { useSmartTitle } from '../../hooks/post/useSmartTitle'; import { PostTagList } from './tags/PostTagList'; import PostSourceInfo from './PostSourceInfo'; import { SelectionSnapshotBar } from '../../features/snapshot/SelectionSnapshotBar'; +import { CopySummaryButton } from '../../features/snapshot/CopySummaryButton'; import { useReaderInstallPromptGate } from '../../hooks/useReaderInstallPromptGate'; import { CommunitySentiment, @@ -139,6 +141,12 @@ export function PostContentRaw({ feature: featureSnapshotSelectionShare, shouldEvaluate: isPostPage, }); + // Only where there is a summary to copy, so posts without one stay out of + // the experiment entirely. + const { value: isCopySummaryEnabled } = useConditionalFeature({ + feature: featurePostCopySummary, + shouldEvaluate: isPostPage && !!post.summary, + }); const hasNavigation = !!onPreviousPost || !!onNextPost; const isVideoType = isVideoPost(post); const hasToc = (post.toc?.length ?? 0) > 0; @@ -247,6 +255,13 @@ export function PostContentRaw({ data-testid="tldr-container" > {post.summary} + {isCopySummaryEnabled && ( + + )}

))} diff --git a/packages/shared/src/components/post/PostEngagements.tsx b/packages/shared/src/components/post/PostEngagements.tsx index 3aa08de19ad..0f46fc24e4c 100644 --- a/packages/shared/src/components/post/PostEngagements.tsx +++ b/packages/shared/src/components/post/PostEngagements.tsx @@ -33,6 +33,10 @@ import SocialBar from '../cards/socials/SocialBar'; import { PostContentReminder } from './common/PostContentReminder'; import { useSettingsContext } from '../../contexts/SettingsContext'; import { usePostComments } from '../../hooks/comments/usePostComments'; +import { useConditionalFeature } from '../../hooks/useConditionalFeature'; +import { featurePostSharePrompts } from '../../lib/featureManagement'; +import { DiscussionShareRow } from './focus/DiscussionShareRow'; +import { EndOfThreadShare } from '../../features/snapshot/EndOfThreadShare'; const AuthorOnboarding = dynamic( () => import(/* webpackChunkName: "authorOnboarding" */ './AuthorOnboarding'), @@ -90,6 +94,9 @@ function PostEngagements({ false, ); const [linkClicked, setLinkClicked] = useState(false); + const { value: areSharePromptsEnabled } = useConditionalFeature({ + feature: featurePostSharePrompts, + }); const handleLinkClick = () => { setLinkClicked(true); @@ -178,6 +185,12 @@ function PostEngagements({ shouldHandleCommentQuery CommentInputOrModal={CommentInputOrModal} /> + {/* #6348: the densest share affordance we ship, directly under the + composer — the one place on the page where someone has already + decided to engage. */} + {areSharePromptsEnabled && ( + + )} {!isPlus && !hideInternalAd && } + {areSharePromptsEnabled && ( + + )} {authorOnboarding && ( {customActions} {shouldShowUpgrade && } + {isNavCopyLinkEnabled && post && ( + + +
+ {isPollSnapshotEnabled && ( + + )} + +
)}
diff --git a/packages/shared/src/features/snapshot/EndOfThreadShare.spec.tsx b/packages/shared/src/features/snapshot/EndOfThreadShare.spec.tsx new file mode 100644 index 00000000000..a3e66eeffc8 --- /dev/null +++ b/packages/shared/src/features/snapshot/EndOfThreadShare.spec.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { QueryClient } from '@tanstack/react-query'; +import { render, screen } from '@testing-library/react'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; +import { postWithCommunitySentiment as post } from '../../../__tests__/fixture/post'; +import { EndOfThreadShare } from './EndOfThreadShare'; + +const renderBand = (commentsCount: number) => + render( + + + , + ); + +describe('EndOfThreadShare', () => { + it('offers the link at the end of a conversation', () => { + renderBand(24); + + expect(screen.getByText('Enjoyed this discussion?')).toBeInTheDocument(); + expect(screen.getByText('24 comments and counting')).toBeInTheDocument(); + }); + + it('counts one comment in the singular', () => { + renderBand(1); + + expect(screen.getByText('1 comment and counting')).toBeInTheDocument(); + }); + + it('stays away when there is no conversation to pass on', () => { + renderBand(0); + + expect( + screen.queryByText('Enjoyed this discussion?'), + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/features/snapshot/EndOfThreadShare.tsx b/packages/shared/src/features/snapshot/EndOfThreadShare.tsx new file mode 100644 index 00000000000..6ca639e0f88 --- /dev/null +++ b/packages/shared/src/features/snapshot/EndOfThreadShare.tsx @@ -0,0 +1,54 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../components/buttons/Button'; +import { LinkIcon } from '../../components/icons'; +import { useCopyPostLink } from '../../hooks/useCopyPostLink'; +import type { Post } from '../../graphql/posts'; + +/** + * #6349's end-of-conversation band. It sits where reading actually stops, and + * copy link is the whole offer: a still image of a live thread is stale within + * hours, so there is no snapshot here. + */ +export function EndOfThreadShare({ + post, + commentsCount, +}: { + post: Post; + commentsCount: number; +}): ReactElement | null { + const [, copyLink] = useCopyPostLink(post.commentsPermalink); + + // Nothing to be at the end of: an empty thread has no conversation to pass + // on, and the band would just be a second copy-link button. + if (!commentsCount) { + return null; + } + + return ( +
+
+ + Enjoyed this discussion? + + + {commentsCount} {commentsCount === 1 ? 'comment' : 'comments'} and + counting + +
+ +
+ ); +} diff --git a/packages/shared/src/features/snapshot/PollSnapshotButton.tsx b/packages/shared/src/features/snapshot/PollSnapshotButton.tsx new file mode 100644 index 00000000000..c402dfa70fd --- /dev/null +++ b/packages/shared/src/features/snapshot/PollSnapshotButton.tsx @@ -0,0 +1,62 @@ +import type { ReactElement } from 'react'; +import React, { useRef } from 'react'; +import type { + ButtonSize, + ButtonVariant, +} from '../../components/buttons/common'; +import { SnapshotButton } from '../../components/imageShare/SnapshotButton'; +import type { Post } from '../../graphql/posts'; +import { PollSnapshotCard } from './PollSnapshotCard'; +import { pollSnapshotFromPost } from './pollSnapshot'; +import { SNAPSHOT_SIZE } from './snapshotGradient'; + +const CAPTURE_OPTIONS = { + width: SNAPSHOT_SIZE, + height: SNAPSHOT_SIZE, + padding: 0, + branded: false, +}; + +/** + * A poll result is the one post payload an image carries better than the link: + * a bar chart is self-contained, and the URL is worth nothing once voting + * closes. The card is staged off-screen at its full 1080px because the capture + * reads the live DOM — it has to be mounted before the press, not after. + */ +export function PollSnapshotButton({ + post, + showLabel, + size, + variant, +}: { + post: Post; + showLabel?: boolean; + size?: ButtonSize; + variant?: ButtonVariant; +}): ReactElement | null { + const cardRef = useRef(null); + const snapshot = pollSnapshotFromPost(post); + + if (!snapshot) { + return null; + } + + return ( + <> + +
+ +
+ + ); +} diff --git a/packages/shared/src/features/snapshot/pollSnapshot.spec.ts b/packages/shared/src/features/snapshot/pollSnapshot.spec.ts new file mode 100644 index 00000000000..b3a50c4e143 --- /dev/null +++ b/packages/shared/src/features/snapshot/pollSnapshot.spec.ts @@ -0,0 +1,55 @@ +import type { Post } from '../../graphql/posts'; +import { pollSnapshotFromPost } from './pollSnapshot'; + +const poll = { + id: 'poll-1', + title: 'Which do you reach for first in a new service?', + numPollVotes: 1284, + source: { name: 'Frontend Fans' }, + pollOptions: [ + { id: '2', text: 'Redis', order: 1, numVotes: 200 }, + { id: '1', text: 'Postgres', order: 0, numVotes: 700 }, + { id: '3', text: 'SQLite', order: 2, numVotes: 100 }, + ], +} as unknown as Post; + +describe('pollSnapshotFromPost', () => { + it('orders the options by their share of the vote', () => { + expect(pollSnapshotFromPost(poll)?.options).toEqual([ + { text: 'Postgres', share: 70 }, + { text: 'Redis', share: 20 }, + { text: 'SQLite', share: 10 }, + ]); + }); + + it('reads the share from the options rather than the voter count', () => { + // numPollVotes counts voters, so deriving the bars from it would leave + // them adding up to something other than the poll. + const shares = pollSnapshotFromPost(poll)?.options ?? []; + + expect(shares.reduce((sum, option) => sum + option.share, 0)).toBe(100); + }); + + it('carries the vote count and the source as attribution', () => { + const snapshot = pollSnapshotFromPost(poll); + + expect(snapshot?.votes).toBe('1,284 votes'); + expect(snapshot?.source).toEqual({ name: 'Frontend Fans' }); + expect(snapshot?.seed).toBe('poll-1'); + }); + + it('refuses a poll nobody has voted in', () => { + const options = poll.pollOptions?.map((option) => ({ + ...option, + numVotes: 0, + })); + + expect( + pollSnapshotFromPost({ ...poll, pollOptions: options } as Post), + ).toBeNull(); + }); + + it('refuses a post with no options at all', () => { + expect(pollSnapshotFromPost({ ...poll, pollOptions: [] } as Post)).toBeNull(); + }); +}); diff --git a/packages/shared/src/features/snapshot/pollSnapshot.ts b/packages/shared/src/features/snapshot/pollSnapshot.ts new file mode 100644 index 00000000000..7e5e86fcf60 --- /dev/null +++ b/packages/shared/src/features/snapshot/pollSnapshot.ts @@ -0,0 +1,40 @@ +import type { Post } from '../../graphql/posts'; +import type { PollSnapshotCardProps } from './PollSnapshotCard'; + +/** + * The card renders what it is handed rather than recomputing anything, so the + * share of the vote is worked out here — from the options' own counts, not + * from `numPollVotes`, which counts voters rather than option votes and would + * leave the bars adding up to something other than the poll. + */ +export function pollSnapshotFromPost(post: Post): PollSnapshotCardProps | null { + const options = post.pollOptions ?? []; + + if (!options.length) { + return null; + } + + const total = options.reduce( + (sum, option) => sum + (option.numVotes ?? 0), + 0, + ); + + if (!total) { + return null; + } + + const votes = post.numPollVotes ?? total; + + return { + question: post.title ?? '', + options: [...options] + .sort((a, b) => (b.numVotes ?? 0) - (a.numVotes ?? 0)) + .map((option) => ({ + text: option.text, + share: Math.round(((option.numVotes ?? 0) / total) * 100), + })), + votes: `${votes.toLocaleString()} ${votes === 1 ? 'vote' : 'votes'}`, + source: post.source ? { name: post.source.name } : undefined, + seed: post.id, + }; +} diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index eec00f80236..0ea97711991 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -34,6 +34,10 @@ export const featureSnapshotSelectionShare = new Feature( 'snapshot_selection_share', false, ); +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 featurePostNavCopyLink = new Feature('post_nav_copy_link', 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 diff --git a/packages/webapp/pages/dev/snapshot.tsx b/packages/webapp/pages/dev/snapshot.tsx index 6ec99db6c2b..9b56abba6a5 100644 --- a/packages/webapp/pages/dev/snapshot.tsx +++ b/packages/webapp/pages/dev/snapshot.tsx @@ -1,6 +1,7 @@ import type { ReactElement, ReactNode, RefObject } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { NextSeo } from 'next-seo'; +import dynamic from 'next/dynamic'; import Toast from '@dailydotdev/shared/src/components/notifications/Toast'; import { Button, @@ -13,16 +14,13 @@ import { DownvoteIcon, LinkIcon, MenuIcon, - ShareIcon, - TwitterIcon, UpvoteIcon, - WhatsappIcon, } from '@dailydotdev/shared/src/components/icons'; -import { SnapshotButton } from '@dailydotdev/shared/src/components/imageShare/SnapshotButton'; import { HighlightTextSnapshotCard } from '@dailydotdev/shared/src/features/snapshot/HighlightTextSnapshotCard'; import { PollSnapshotCard } from '@dailydotdev/shared/src/features/snapshot/PollSnapshotCard'; import { CopySummaryButton } from '@dailydotdev/shared/src/features/snapshot/CopySummaryButton'; import { SelectionSnapshotBar } from '@dailydotdev/shared/src/features/snapshot/SelectionSnapshotBar'; +import { PollSnapshotButton } from '@dailydotdev/shared/src/features/snapshot/PollSnapshotButton'; import { SNAPSHOT_SIZE } from '@dailydotdev/shared/src/features/snapshot/snapshotGradient'; import { captureShareImage } from '@dailydotdev/shared/src/lib/imageShare/captureShareImage'; import { useCopyText } from '@dailydotdev/shared/src/hooks/useCopy'; @@ -31,6 +29,28 @@ import { useToastNotification, } from '@dailydotdev/shared/src/hooks/useToastNotification'; import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import type { AuthContextData } from '@dailydotdev/shared/src/contexts/AuthContext'; +import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext'; +import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext'; +import type { LogContextData } from '@dailydotdev/shared/src/hooks/log/useLogContextData'; + +/* Both reach for the auth context — squads, and the link shortener — which is + only populated on the client, and this page renders outside the app shell. */ +const DiscussionShareRow = dynamic( + () => + import( + '@dailydotdev/shared/src/components/post/focus/DiscussionShareRow' + ).then((mod) => mod.DiscussionShareRow), + { ssr: false }, +); + +const EndOfThreadShare = dynamic( + () => + import('@dailydotdev/shared/src/features/snapshot/EndOfThreadShare').then( + (mod) => mod.EndOfThreadShare, + ), + { ssr: false }, +); /** * /dev/snapshot — internal review surface for every share placement the @@ -81,6 +101,22 @@ const POLL = { source: { name: 'Frontend Fans' }, }; +/* The real PollSnapshotButton reads a post, so the poll above is expressed as + one — the same numbers, in the shape production hands it. */ +const POLL_POST = { + id: 'dev-snapshot-poll', + title: POLL.question, + numPollVotes: 1284, + commentsPermalink: LINK, + source: { id: 'frontend-fans', name: 'Frontend Fans' }, + pollOptions: [ + { id: '1', text: 'Postgres', order: 0, numVotes: 796 }, + { id: '2', text: 'Redis', order: 1, numVotes: 270 }, + { id: '3', text: 'SQLite', order: 2, numVotes: 141 }, + { id: '4', text: 'Something else', order: 3, numVotes: 77 }, + ], +} as unknown as Post; + const QUOTE_TIERS = [ { label: '<=70 chars . 72px', text: 'They optimised the product they had.' }, { label: '<=140 chars . 60px', text: QUOTE }, @@ -115,6 +151,39 @@ const useIsAllowedHost = () => { return allowed; }; +const LogContext = getLogContextStatic(); + +/** + * `/dev/*` short-circuits to a QueryClient-only tree in _app — no boot, no + * auth — which is what makes these pages load without the API. The production + * share components reach for both, so the review harness stands in for them: + * signed out, no squads, and logging swallowed. + */ +const AUTH_STUB = { + isLoggedIn: false, + isAuthReady: true, + tokenRefreshed: true, + shouldShowLogin: false, + squads: [], + showLogin: () => {}, + closeLogin: () => {}, + logout: async () => {}, + updateUser: async () => {}, + getRedirectUri: () => '', +} as unknown as AuthContextData; + +const LOG_STUB = { + logEvent: () => {}, + logEventStart: () => {}, + logEventEnd: () => {}, +} as unknown as LogContextData; + +const DevProviders = ({ children }: { children: ReactNode }) => ( + + {children} + +); + /* ----------------------------------------------------------- page furniture */ const Section = ({ @@ -464,33 +533,7 @@ const ThePromptedMoments = (): ReactElement => ( Share your thoughts
-
- - Share this post - -
- - } label="Share on X" /> - } - label="Share on WhatsApp" - /> - {[0, 1, 2, 3].map((squad) => ( - - } - label="Share to squad" - /> - ))} - } label="More sharing options" /> -
-
+
@@ -515,16 +558,14 @@ const ThePromptedMoments = (): ReactElement => (
- - - + {POLL.votes} · 2 days left - + @@ -581,11 +617,9 @@ const ThePoll = ({ Why did you vote this way?
- +
+ ); + } + return ( { renderBand(24); expect(screen.getByText('Enjoyed this discussion?')).toBeInTheDocument(); - expect(screen.getByText('24 comments and counting')).toBeInTheDocument(); - }); - - it('counts one comment in the singular', () => { - renderBand(1); - - expect(screen.getByText('1 comment and counting')).toBeInTheDocument(); + expect( + screen.getByText("Send it to someone who'd have opinions."), + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Copy link' })).toBeVisible(); }); it('stays away when there is no conversation to pass on', () => { diff --git a/packages/shared/src/features/snapshot/EndOfThreadShare.tsx b/packages/shared/src/features/snapshot/EndOfThreadShare.tsx index 6ca639e0f88..44943af514e 100644 --- a/packages/shared/src/features/snapshot/EndOfThreadShare.tsx +++ b/packages/shared/src/features/snapshot/EndOfThreadShare.tsx @@ -1,12 +1,17 @@ import type { ReactElement } from 'react'; -import React from 'react'; +import React, { useCallback } from 'react'; import { Button, + ButtonIconPosition, ButtonSize, ButtonVariant, } from '../../components/buttons/Button'; import { LinkIcon } from '../../components/icons'; -import { useCopyPostLink } from '../../hooks/useCopyPostLink'; +import { useCopyText } from '../../hooks/useCopy'; +import { + ToastType, + useToastNotification, +} from '../../hooks/useToastNotification'; import type { Post } from '../../graphql/posts'; /** @@ -21,7 +26,18 @@ export function EndOfThreadShare({ post: Post; commentsCount: number; }): ReactElement | null { - const [, copyLink] = useCopyPostLink(post.commentsPermalink); + const [, copy] = useCopyText(post.commentsPermalink); + const { displayToast } = useToastNotification(); + + const onCopy = useCallback(async () => { + try { + await copy({ message: '✅ Copied link' }); + } catch { + displayToast('❌ Your browser blocked the clipboard', { + variant: ToastType.Error, + }); + } + }, [copy, displayToast]); // Nothing to be at the end of: an empty thread has no conversation to pass // on, and the band would just be a second copy-link button. @@ -30,22 +46,22 @@ export function EndOfThreadShare({ } return ( -
+
- + Enjoyed this discussion? - - {commentsCount} {commentsCount === 1 ? 'comment' : 'comments'} and - counting + + Send it to someone who'd have opinions.
diff --git a/packages/shared/src/features/snapshot/PollSnapshotButton.tsx b/packages/shared/src/features/snapshot/PollSnapshotButton.tsx index c402dfa70fd..1becd64fb90 100644 --- a/packages/shared/src/features/snapshot/PollSnapshotButton.tsx +++ b/packages/shared/src/features/snapshot/PollSnapshotButton.tsx @@ -46,6 +46,7 @@ export function PollSnapshotButton({ + {/* Snapshot leads, labelled and solid: it is the reason the bar + exists, and the two copies beside it are the familiar fallbacks. */} +
)} diff --git a/packages/shared/src/hooks/usePlusSale.spec.tsx b/packages/shared/src/hooks/usePlusSale.spec.tsx index 8f1a61e0cfd..64ee941b978 100644 --- a/packages/shared/src/hooks/usePlusSale.spec.tsx +++ b/packages/shared/src/hooks/usePlusSale.spec.tsx @@ -43,7 +43,9 @@ const runningSale: PlusSaleConfig = { label: '50% off', headline: 'Summer sale: 50% off Plus', description: 'Code SUMMER50 is already applied. Offer ends August 31.', - endDate: '2026-09-01T00:00:00.000Z', + // Relative, not a date: a literal turns the suite red the morning it passes, + // and this one did. + endDate: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), }; // Mirrors the real hook: the committed default (no discount) is returned until diff --git a/packages/shared/src/lib/imageShare/copyShareImage.spec.ts b/packages/shared/src/lib/imageShare/copyShareImage.spec.ts new file mode 100644 index 00000000000..6ee26b27c87 --- /dev/null +++ b/packages/shared/src/lib/imageShare/copyShareImage.spec.ts @@ -0,0 +1,57 @@ +import { copyShareImage } from './copyShareImage'; + +const LINK = 'https://app.daily.dev/posts/p1'; + +class FakeClipboardItem { + public readonly types: string[]; + + constructor(public readonly items: Record>) { + this.types = Object.keys(items); + } +} + +const write = jest.fn(); + +beforeEach(() => { + write.mockReset().mockResolvedValue(undefined); + Object.assign(globalThis, { ClipboardItem: FakeClipboardItem }); + Object.assign(navigator, { clipboard: { write } }); +}); + +const blob = () => Promise.resolve(new Blob(['png'], { type: 'image/png' })); + +describe('copyShareImage', () => { + it('puts the image and the link on the clipboard together', async () => { + await expect(copyShareImage(blob(), LINK)).resolves.toBe(true); + + const [[[item]]] = write.mock.calls; + expect(item.types).toEqual(['image/png', 'text/plain']); + // jsdom's Blob has no text(); its size is the link's byte length. + const text = item.items['text/plain'] as Blob; + expect(text.type).toBe('text/plain'); + expect(text.size).toBe(LINK.length); + }); + + it('keeps the image when a browser refuses two representations', async () => { + write.mockRejectedValueOnce(new Error('NotAllowedError')); + + await expect(copyShareImage(blob(), LINK)).resolves.toBe(true); + + expect(write).toHaveBeenCalledTimes(2); + const [, [[retry]]] = write.mock.calls; + expect(retry.types).toEqual(['image/png']); + }); + + it('copies the image alone when there is no link to carry', async () => { + await expect(copyShareImage(blob())).resolves.toBe(true); + + const [[[item]]] = write.mock.calls; + expect(item.types).toEqual(['image/png']); + }); + + it('reports failure so the caller can fall back to a download', async () => { + write.mockRejectedValue(new Error('NotAllowedError')); + + await expect(copyShareImage(blob(), LINK)).resolves.toBe(false); + }); +}); diff --git a/packages/shared/src/lib/imageShare/copyShareImage.ts b/packages/shared/src/lib/imageShare/copyShareImage.ts index a712696ceef..5f573e94684 100644 --- a/packages/shared/src/lib/imageShare/copyShareImage.ts +++ b/packages/shared/src/lib/imageShare/copyShareImage.ts @@ -1,19 +1,45 @@ /** * Puts the PNG on the clipboard so it can be pasted straight into a chat or a - * composer. Safari only honours a clipboard write inside the task that handled - * the gesture, so the blob is handed over as a promise rather than awaited - * first — `ClipboardItem` resolves it without losing the gesture. + * composer, with the post's link beside it as text: a rich composer takes the + * image, a plain one takes the link, and neither leaves the reader having to + * go back for the other half. + * + * Safari only honours a clipboard write inside the task that handled the + * gesture, so the blob is handed over as a promise rather than awaited first — + * `ClipboardItem` resolves it without losing the gesture. */ -export async function copyShareImage(blob: Promise): Promise { +export async function copyShareImage( + blob: Promise, + link?: string, +): Promise { if (typeof ClipboardItem === 'undefined' || !navigator.clipboard?.write) { return false; } - try { - await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]); + const write = async (item: ClipboardItem): Promise => { + try { + await navigator.clipboard.write([item]); - return true; - } catch { - return false; + return true; + } catch { + return false; + } + }; + + if (link) { + const copied = await write( + new ClipboardItem({ + 'image/png': blob, + 'text/plain': new Blob([link], { type: 'text/plain' }), + }), + ); + + if (copied) { + return true; + } } + + // Not every browser accepts two representations in one item, and the image + // is the half worth keeping when one of them has to go. + return write(new ClipboardItem({ 'image/png': blob })); } diff --git a/packages/webapp/pages/dev/snapshot.tsx b/packages/webapp/pages/dev/snapshot.tsx index 49abe0f2027..66947c89150 100644 --- a/packages/webapp/pages/dev/snapshot.tsx +++ b/packages/webapp/pages/dev/snapshot.tsx @@ -5,6 +5,7 @@ import dynamic from 'next/dynamic'; import Toast from '@dailydotdev/shared/src/components/notifications/Toast'; import { Button, + ButtonIconPosition, ButtonSize, ButtonVariant, } from '@dailydotdev/shared/src/components/buttons/Button'; @@ -245,38 +246,14 @@ const Inert = ({ icon, label }: { icon: ReactElement; label: string }) => ( /> ); -const Band = ({ - title, - body, - accent, - children, -}: { - title: string; - body: string; - accent?: boolean; - children: ReactNode; -}) => ( -
-
- {title} - {body} -
- {children} -
-); - const CopyLink = ({ label, + iconRight, variant = ButtonVariant.Tertiary, size = ButtonSize.Small, }: { label?: boolean; + iconRight?: boolean; variant?: ButtonVariant; size?: ButtonSize; }) => { @@ -298,6 +275,9 @@ const CopyLink = ({
+ } iconPosition={ButtonIconPosition.Right} onClick={onCopy} size={ButtonSize.Small} type="button" - variant={ButtonVariant.Secondary} + variant={ButtonVariant.Primary} > Copy link - + ); } diff --git a/packages/shared/src/components/post/focus/PostDiscussionPanel.tsx b/packages/shared/src/components/post/focus/PostDiscussionPanel.tsx index 7983a5cea79..79a27423037 100644 --- a/packages/shared/src/components/post/focus/PostDiscussionPanel.tsx +++ b/packages/shared/src/components/post/focus/PostDiscussionPanel.tsx @@ -28,7 +28,6 @@ import { usePostComments } from '../../../hooks/comments/usePostComments'; import { DiscussionMetaBar } from './DiscussionMetaBar'; import { DiscussionShareRow } from './DiscussionShareRow'; import { EndOfThreadShare } from '../../../features/snapshot/EndOfThreadShare'; -import { PostContentShare } from '../common/PostContentShare'; import { useSharePlacement } from '../../../features/snapshot/useSharePlacement'; import { featurePostSharePrompts } from '../../../lib/featureManagement'; @@ -177,9 +176,6 @@ export const PostDiscussionPanel = ({ /> - {/* The classic page gets this from PostEngagements; the focus layout - renders its own discussion, so it has to be asked for here. */} - {areSharePromptsEnabled && } {showSortHeader && commentsCount > 0 && ( // A text link (not a button) so it aligns flush-left with the comments // below it; `mb-2` adds breathing room before the first comment. diff --git a/packages/shared/src/components/post/focus/PostFocusCard.tsx b/packages/shared/src/components/post/focus/PostFocusCard.tsx index e9d7c1ad011..f4a276be672 100644 --- a/packages/shared/src/components/post/focus/PostFocusCard.tsx +++ b/packages/shared/src/components/post/focus/PostFocusCard.tsx @@ -61,6 +61,7 @@ import { PostMenuOptions } from '../PostMenuOptions'; import { PostAnsweredQuestions } from '../PostAnsweredQuestions'; import { withPostById } from '../withPostById'; import { FocusCardActionBar } from './FocusCardActionBar'; +import { PostContentShare } from '../common/PostContentShare'; import { PostDiscussionPanel } from './PostDiscussionPanel'; import { CollectionSources } from './CollectionSources'; import { @@ -670,6 +671,10 @@ const PostFocusCardRaw = ({ className="-mt-2" /> + {/* Directly under the upvote that raises it — the classic page gets + this from PostEngagements, in the same place. */} + + {!onClose && }
diff --git a/packages/shared/src/features/snapshot/EndOfThreadShare.tsx b/packages/shared/src/features/snapshot/EndOfThreadShare.tsx index 44943af514e..36b248f7760 100644 --- a/packages/shared/src/features/snapshot/EndOfThreadShare.tsx +++ b/packages/shared/src/features/snapshot/EndOfThreadShare.tsx @@ -1,11 +1,11 @@ import type { ReactElement } from 'react'; import React, { useCallback } from 'react'; +import { ButtonV2 } from '../../components/buttons/ButtonV2'; import { - Button, ButtonIconPosition, ButtonSize, ButtonVariant, -} from '../../components/buttons/Button'; +} from '../../components/buttons/common'; import { LinkIcon } from '../../components/icons'; import { useCopyText } from '../../hooks/useCopy'; import { @@ -55,7 +55,7 @@ export function EndOfThreadShare({ Send it to someone who'd have opinions.
- + ); } diff --git a/packages/shared/src/features/snapshot/PollSnapshotCard.tsx b/packages/shared/src/features/snapshot/PollSnapshotCard.tsx index e0f785fa6b0..b0acec3fb7e 100644 --- a/packages/shared/src/features/snapshot/PollSnapshotCard.tsx +++ b/packages/shared/src/features/snapshot/PollSnapshotCard.tsx @@ -28,19 +28,19 @@ export interface PollSnapshotOption { export interface PollSnapshotCardProps { question: string; options: PollSnapshotOption[]; - votes?: string; + /** Status, vote count and date — the line the poll carries in the product. */ + meta?: string[]; source?: { name: string }; seed?: string; } function PollSnapshotCardComponent( - { question, options, votes, source, seed }: PollSnapshotCardProps, + { question, options, meta, source, seed }: PollSnapshotCardProps, ref: React.Ref, ): ReactElement { // A closed poll is a result, and the result is the winner: it is drawn in // the accent while the rest stay quiet, so the answer reads before the bars. const leader = Math.max(...options.map((option) => option.share)); - const footer = [votes, source?.name].filter(Boolean).join(' · '); return ( @@ -67,7 +67,16 @@ function PollSnapshotCardComponent( {question} -
    + {!!meta?.length && ( + + {meta.join(' · ')} + + )} + +
      {options.slice(0, 4).map((option) => (
    1. - {footer && ( + {source && ( - {footer} + {source.name} )} diff --git a/packages/shared/src/features/snapshot/pollSnapshot.spec.ts b/packages/shared/src/features/snapshot/pollSnapshot.spec.ts index 07e4f936674..e448d2df237 100644 --- a/packages/shared/src/features/snapshot/pollSnapshot.spec.ts +++ b/packages/shared/src/features/snapshot/pollSnapshot.spec.ts @@ -30,14 +30,33 @@ describe('pollSnapshotFromPost', () => { expect(shares.reduce((sum, option) => sum + option.share, 0)).toBe(100); }); - it('carries the vote count and the source as attribution', () => { - const snapshot = pollSnapshotFromPost(poll); + it('carries the same status line the product shows above the options', () => { + // Frozen: postDateFormat says "Now" inside the first minute, and a clock + // read at test time decides which branch this lands on. + jest.useFakeTimers().setSystemTime(new Date('2026-09-01T18:00:00.000Z')); + const snapshot = pollSnapshotFromPost({ + ...poll, + createdAt: '2026-09-01T09:00:00.000Z', + } as Post); + jest.useRealTimers(); - expect(snapshot?.votes).toBe('1,284 votes'); + expect(snapshot?.meta).toEqual(['Voting open', '1.3K votes', 'Today']); expect(snapshot?.source).toEqual({ name: 'Frontend Fans' }); expect(snapshot?.seed).toBe('poll-1'); }); + it('says the voting ended once the poll has closed', () => { + const snapshot = pollSnapshotFromPost({ + ...poll, + endsAt: new Date(Date.now() - 60_000).toISOString(), + } as Post); + + expect(snapshot?.meta?.slice(0, 2)).toEqual([ + 'Voting ended', + '1.3K total votes', + ]); + }); + it('refuses a poll nobody has voted in', () => { const options = poll.pollOptions?.map((option) => ({ ...option, diff --git a/packages/shared/src/features/snapshot/pollSnapshot.ts b/packages/shared/src/features/snapshot/pollSnapshot.ts index 7e5e86fcf60..1d0f3dde856 100644 --- a/packages/shared/src/features/snapshot/pollSnapshot.ts +++ b/packages/shared/src/features/snapshot/pollSnapshot.ts @@ -1,4 +1,7 @@ +import isAfter from 'date-fns/isAfter'; import type { Post } from '../../graphql/posts'; +import { postDateFormat } from '../../lib/dateFormat'; +import { largeNumberFormat } from '../../lib'; import type { PollSnapshotCardProps } from './PollSnapshotCard'; /** @@ -24,16 +27,23 @@ export function pollSnapshotFromPost(post: Post): PollSnapshotCardProps | null { } const votes = post.numPollVotes ?? total; + const hasEnded = !!post.endsAt && isAfter(new Date(), new Date(post.endsAt)); return { question: post.title ?? '', + // The same line the poll carries in the product, in the same order: + // status, then the count, then when it was posted. + meta: [ + hasEnded ? 'Voting ended' : 'Voting open', + `${largeNumberFormat(votes)} ${hasEnded ? 'total votes' : 'votes'}`, + post.createdAt ? postDateFormat(post.createdAt) : undefined, + ].filter(Boolean) as string[], options: [...options] .sort((a, b) => (b.numVotes ?? 0) - (a.numVotes ?? 0)) .map((option) => ({ text: option.text, share: Math.round(((option.numVotes ?? 0) / total) * 100), })), - votes: `${votes.toLocaleString()} ${votes === 1 ? 'vote' : 'votes'}`, source: post.source ? { name: post.source.name } : undefined, seed: post.id, }; diff --git a/packages/webapp/pages/dev/snapshot.tsx b/packages/webapp/pages/dev/snapshot.tsx index 66947c89150..e56532f5772 100644 --- a/packages/webapp/pages/dev/snapshot.tsx +++ b/packages/webapp/pages/dev/snapshot.tsx @@ -548,10 +548,15 @@ const ThePromptedMoments = (): ReactElement => ( step="After upvote · shipping" >
      - - Should anyone else see this post? - - +
      + + Should anyone else see this post? + + + You upvoted it — pass it on. + +
      +
      From 1c513d4d33d50bea59a5a78b7388727d990943d9 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Tue, 1 Sep 2026 10:38:37 +0300 Subject: [PATCH 14/19] feat(share): put both prompts on the split copy-link band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Tsahi's button" is SplitShareButton from #6369 — copy link and a chevron holding the networks, meeting at one hairline — wrapped by ShareBand, which pairs it with a title and a line of copy. #6369 built it for exactly these two surfaces: the band below an active discussion and the prompt after an upvote. Neither PR landed. #6369 is closed and #6378 merged into it, so the components exist on no branch that ships. They are carried here — ShareBand, ShareActions, SplitShareButton, CopyStateIcon and the Origin they log against — and both prompts now render through ShareBand rather than the pair of hand-rolled rows I had built. If that stack revives, these two callers should collapse into its EndOfConversationShare and the copies here should go. The copy is theirs too, so the two bands read as one pair. Testing: shared 382 suites / 2680 tests, webapp 81 / 644, extension 6 / 52, package lint clean for shared and webapp. The ported ShareActions spec runs green here as it does there. Co-Authored-By: Claude Opus 5 --- .../post/common/PostContentShare.tsx | 77 ++----- .../src/components/share/CopyStateIcon.tsx | 47 ++++ .../components/share/ShareActions.spec.tsx | 81 +++++++ .../src/components/share/ShareActions.tsx | 207 ++++++++++++++++++ .../shared/src/components/share/ShareBand.tsx | 81 +++++++ .../src/components/share/SplitShareButton.tsx | 181 +++++++++++++++ .../snapshot/EndOfThreadShare.spec.tsx | 2 +- .../features/snapshot/EndOfThreadShare.tsx | 75 +++---- packages/shared/src/lib/log.ts | 1 + packages/webapp/pages/dev/snapshot.tsx | 26 ++- 10 files changed, 668 insertions(+), 110 deletions(-) create mode 100644 packages/shared/src/components/share/CopyStateIcon.tsx create mode 100644 packages/shared/src/components/share/ShareActions.spec.tsx create mode 100644 packages/shared/src/components/share/ShareActions.tsx create mode 100644 packages/shared/src/components/share/ShareBand.tsx create mode 100644 packages/shared/src/components/share/SplitShareButton.tsx diff --git a/packages/shared/src/components/post/common/PostContentShare.tsx b/packages/shared/src/components/post/common/PostContentShare.tsx index 476292f7004..b1052785fdd 100644 --- a/packages/shared/src/components/post/common/PostContentShare.tsx +++ b/packages/shared/src/components/post/common/PostContentShare.tsx @@ -9,18 +9,7 @@ import { ReferralCampaignKey, useGetShortUrl } from '../../../hooks'; import { PostContentWidget } from './PostContentWidget'; import { useActiveFeedContext } from '../../../contexts'; import { postLogEvent } from '../../../lib/feed'; -import { ButtonV2 } from '../../buttons/ButtonV2'; -import { - ButtonIconPosition, - ButtonSize, - ButtonVariant, -} from '../../buttons/common'; -import { LinkIcon } from '../../icons'; -import { useCopyText } from '../../../hooks/useCopy'; -import { - ToastType, - useToastNotification, -} from '../../../hooks/useToastNotification'; +import { ShareBand } from '../../share/ShareBand'; import { useLogContext } from '../../../contexts/LogContext'; import { useSharePlacement } from '../../../features/snapshot/useSharePlacement'; import { featurePostSharePrompts } from '../../../lib/featureManagement'; @@ -43,33 +32,23 @@ export function PostContentShare({ }); const { logEvent } = useLogContext(); - const { displayToast } = useToastNotification(); const areSharePromptsEnabled = useSharePlacement({ feature: featurePostSharePrompts, }); - const [, copy] = useCopyText(shareLink); - const onCopy = useCallback(async () => { - logEvent( - postLogEvent(LogEvent.SharePost, post, { - extra: { - provider: ShareProvider.CopyLink, - origin: Origin.PostContent, - }, - ...(logOpts && logOpts), - }), - ); - - try { - await copy({ message: '✅ Copied link' }); + const onShare = useCallback( + (provider: ShareProvider) => { + logEvent( + postLogEvent(LogEvent.SharePost, post, { + extra: { provider, origin: Origin.PostContent }, + ...(logOpts && logOpts), + }), + ); // The prompt has done its job; leaving it up nags. onInteract('none'); - } catch { - displayToast('❌ Your browser blocked the clipboard', { - variant: ToastType.Error, - }); - } - }, [copy, displayToast, logEvent, logOpts, onInteract, post]); + }, + [logEvent, logOpts, onInteract, post], + ); if (interaction !== 'upvote' || isLoading) { return null; @@ -77,28 +56,18 @@ export function PostContentShare({ if (areSharePromptsEnabled) { // A prompt, not a form: the link in an input asks to be read before it can - // be used, and there is only one thing to do with it. + // be used, and there is only one thing to do with it. The band and its + // split control are #6369/#6378's, so this and the end-of-thread band read + // as one pair. return ( -
      -
      - - Should anyone else see this post? - - - You upvoted it — pass it on. - -
      - } - iconPosition={ButtonIconPosition.Right} - onClick={onCopy} - size={ButtonSize.Small} - type="button" - variant={ButtonVariant.Primary} - > - Copy link - -
      + ); } diff --git a/packages/shared/src/components/share/CopyStateIcon.tsx b/packages/shared/src/components/share/CopyStateIcon.tsx new file mode 100644 index 00000000000..6c5defa913f --- /dev/null +++ b/packages/shared/src/components/share/CopyStateIcon.tsx @@ -0,0 +1,47 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { CopyIcon, VIcon } from '../icons'; +import type { IconProps } from '../Icon'; + +/** + * easeOutExpo — the curve the design-system dropdown animates on. It + * decelerates into the target with no overshoot, which is what keeps a swap + * from reading as a wobble. + */ +export const EASE_OUT_EXPO = 'ease-[cubic-bezier(0.16,1,0.3,1)]'; + +/** + * A copy is a rare, deliberate moment, so the confirmation earns real motion. + * Both glyphs share one grid cell so the label never shifts mid-swap, and the + * transition collapses to an instant swap under `prefers-reduced-motion`. + */ +export const CopyStateIcon = ({ + copied, + className, + ...props +}: IconProps & { copied: boolean }): ReactElement => { + const layer = classNames( + className, + 'col-start-1 row-start-1 transition-[opacity,transform,filter] duration-200 motion-reduce:transition-none', + EASE_OUT_EXPO, + ); + + return ( + + + + + ); +}; diff --git a/packages/shared/src/components/share/ShareActions.spec.tsx b/packages/shared/src/components/share/ShareActions.spec.tsx new file mode 100644 index 00000000000..5a86a8de95c --- /dev/null +++ b/packages/shared/src/components/share/ShareActions.spec.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import type { RenderResult } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import { ShareActions } from './ShareActions'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; +import { ShareProvider } from '../../lib/share'; +import { useViewSize } from '../../hooks/useViewSize'; + +jest.mock('../../hooks/useViewSize', () => { + const actual = jest.requireActual('../../hooks/useViewSize'); + return { __esModule: true, ...actual, useViewSize: jest.fn() }; +}); + +const useViewSizeMock = useViewSize as jest.Mock; +const writeText = jest.fn().mockResolvedValue(undefined); +const onShare = jest.fn(); +const link = 'https://daily.dev/posts/abc'; +const text = 'Check this out'; + +beforeEach(() => { + jest.clearAllMocks(); + useViewSizeMock.mockReturnValue(true); // default: laptop + Object.assign(navigator, { + clipboard: { writeText }, + }); +}); + +const renderComponent = ( + props: Partial[0]> = {}, +): RenderResult => { + const client = new QueryClient(); + return render( + + + , + ); +}; + +describe('ShareActions inline variant', () => { + it('renders copy link plus a compact set of social networks', () => { + renderComponent({ variant: 'inline' }); + + expect(screen.getByText('Copy link')).toBeInTheDocument(); + expect(screen.getByText('X')).toBeInTheDocument(); + expect(screen.getByText('WhatsApp')).toBeInTheDocument(); + }); + + it('copies the link and reports the CopyLink provider', async () => { + renderComponent({ variant: 'inline' }); + + await act(async () => { + fireEvent.click(screen.getByText('Copy link')); + }); + + await waitFor(() => expect(writeText).toHaveBeenCalledWith(link)); + expect(onShare).toHaveBeenCalledWith(ShareProvider.CopyLink); + }); +}); + +describe('ShareActions icon variant on mobile', () => { + beforeEach(() => useViewSizeMock.mockReturnValue(false)); + + it('copies on a single tap when native share is unavailable', async () => { + renderComponent(); + + const trigger = screen.getByLabelText('Copy link'); + await act(async () => { + fireEvent.click(trigger); + }); + + await waitFor(() => expect(writeText).toHaveBeenCalledWith(link)); + expect(onShare).toHaveBeenCalledWith(ShareProvider.CopyLink); + }); +}); diff --git a/packages/shared/src/components/share/ShareActions.tsx b/packages/shared/src/components/share/ShareActions.tsx new file mode 100644 index 00000000000..a375ea9caf7 --- /dev/null +++ b/packages/shared/src/components/share/ShareActions.tsx @@ -0,0 +1,207 @@ +import type { ReactElement } from 'react'; +import React, { useRef, useState } from 'react'; +import classNames from 'classnames'; +import { Popover, PopoverTrigger } from '@radix-ui/react-popover'; +import { PopoverContent } from '../popover/Popover'; +import { SocialShareList } from '../widgets/SocialShareList'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { CopyIcon } from '../icons'; +import { Tooltip } from '../tooltip/Tooltip'; +import { Typography, TypographyType } from '../typography/Typography'; +import { useViewSize, ViewSize } from '../../hooks/useViewSize'; +import { useShareOrCopyLink } from '../../hooks/useShareOrCopyLink'; +import { shouldUseNativeShare } from '../../lib/func'; +import { ShareProvider } from '../../lib/share'; +import type { ReferralCampaignKey } from '../../lib/referral'; +import { CopyStateIcon } from './CopyStateIcon'; +import { SplitShareButton } from './SplitShareButton'; + +export type ShareActionsVariant = 'icon' | 'inline' | 'split'; + +export interface ShareActionsProps { + link: string; + /** Share text / description used for native share + pre-filled network text. */ + text: string; + cid?: ReferralCampaignKey; + variant?: ShareActionsVariant; + /** Desktop only: reveal the popover on hover as well as click. */ + openOnHover?: boolean; + buttonVariant?: ButtonVariant; + buttonSize?: ButtonSize; + /** Tooltip + accessible label for the icon-only trigger. */ + label?: string; + /** + * Render `triggerText` beside the icon instead of an icon-only trigger. Gated + * so existing icon-only consumers keep their exact DOM. + */ + triggerText?: string; + /** `split` variant only: label for the chevron half that opens the list. */ + dropdownLabel?: string; + emailTitle?: string; + emailSummary?: string; + className?: string; + /** Called for any share/copy so the caller can log with its own origin. */ + onShare?: (provider: ShareProvider) => void; +} + +const HOVER_CLOSE_DELAY = 120; + +export function ShareActions({ + link, + text, + cid, + variant = 'icon', + openOnHover = false, + buttonVariant = ButtonVariant.Tertiary, + buttonSize = ButtonSize.Small, + label = 'Copy link', + triggerText, + dropdownLabel = 'More share options', + emailTitle, + emailSummary, + className, + onShare, +}: ShareActionsProps): ReactElement { + const isLaptop = useViewSize(ViewSize.Laptop); + const [open, setOpen] = useState(false); + const [copying, shareOrCopy] = useShareOrCopyLink({ link, text, cid }); + const closeTimeout = useRef>(); + + const onCopy = () => { + onShare?.(ShareProvider.CopyLink); + shareOrCopy(); + }; + + // `copying` stays true for a second after a copy, which is the whole window + // for the confirmation. The green-check swap is scoped to the split control — + // icon-only triggers keep the existing `secondary` fill so this does not + // restyle every share surface in the app. + const copyIcon = + variant === 'split' ? ( + + ) : ( + + ); + + const list = ( + { + onShare?.(ShareProvider.Native); + shareOrCopy(); + }} + onClickSocial={(provider) => onShare?.(provider)} + /> + ); + + if (variant === 'inline') { + return ( +
      + {list} +
      + ); + } + + // Mobile: a single tap goes straight to the native share sheet (or copy when + // native share is unavailable) — no popover, per sharing UX guidance. + if (!isLaptop) { + return ( + + + + ); + } + + if (variant === 'split') { + return ( + + ); + } + + const cancelClose = () => { + if (closeTimeout.current) { + clearTimeout(closeTimeout.current); + } + }; + const hoverProps = openOnHover + ? { + onMouseEnter: () => { + cancelClose(); + setOpen(true); + }, + onMouseLeave: () => { + closeTimeout.current = setTimeout( + () => setOpen(false), + HOVER_CLOSE_DELAY, + ); + }, + } + : undefined; + + return ( + + + + + + + + + Share + + {list} + + + ); +} diff --git a/packages/shared/src/components/share/ShareBand.tsx b/packages/shared/src/components/share/ShareBand.tsx new file mode 100644 index 00000000000..7ed39dedc4f --- /dev/null +++ b/packages/shared/src/components/share/ShareBand.tsx @@ -0,0 +1,81 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { ShareActions } from './ShareActions'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../typography/Typography'; +import { ButtonSize, ButtonVariant } from '../buttons/common'; +import type { ReferralCampaignKey } from '../../lib/referral'; +import type { ShareProvider } from '../../lib/share'; + +export interface ShareBandProps { + title: string; + description: string; + link: string; + /** Share text / description used for native share + pre-filled network text. */ + text: string; + /** Omit when `link` is already a tracked short URL — passing it double-shortens. */ + cid?: ReferralCampaignKey; + emailTitle?: string; + /** Surface and spacing belong to the host: the two callers sit in different places. */ + className?: string; + onShare: (provider: ShareProvider) => void; +} + +/** + * One line of encouraging copy beside a single split copy-link control, with + * the social networks behind its chevron. + * + * Shared by the two surfaces that prompt a share: `EndOfConversationShare` + * below an active discussion, and `PostContentShare` right after an upvote. + * They differ only in copy, link and placement — everything visual lives here + * so the two cannot drift apart. + */ +export const ShareBand = ({ + title, + description, + link, + text, + cid, + emailTitle, + className, + onShare, +}: ShareBandProps): ReactElement => ( + +); diff --git a/packages/shared/src/components/share/SplitShareButton.tsx b/packages/shared/src/components/share/SplitShareButton.tsx new file mode 100644 index 00000000000..1c06db85088 --- /dev/null +++ b/packages/shared/src/components/share/SplitShareButton.tsx @@ -0,0 +1,181 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { ArrowIcon } from '../icons'; +import { IconSize } from '../Icon'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '../dropdown/DropdownMenu'; +import { Tooltip } from '../tooltip/Tooltip'; +import { CopyStateIcon, EASE_OUT_EXPO } from './CopyStateIcon'; + +export interface SplitShareButtonProps { + /** Tooltip and accessible name for the copy half. */ + label: string; + /** Accessible name for the chevron half. */ + dropdownLabel: string; + /** Visible text on the copy half; falls back to `label`. */ + triggerText?: string; + /** Contents of the dropdown the chevron opens. */ + menu: ReactNode; + /** Drives the copy-glyph confirmation swap. */ + copied: boolean; + onCopy: () => void; + variant?: ButtonVariant; + size?: ButtonSize; + className?: string; +} + +/** + * The halves meet at a single hairline, so the standard side paddings would + * leave a canyon around it. Both sides tighten by one step from the DS value — + * the outer edges keep the standard padding, so the control still reads as one + * button. + */ +const MAIN_INNER_PADDING: Record = { + [ButtonSize.XLarge]: '!pr-5', + [ButtonSize.Large]: '!pr-4', + [ButtonSize.Medium]: '!pr-3', + [ButtonSize.Small]: '!pr-2', + [ButtonSize.XSmall]: '!pr-1.5', +}; + +/** Drops the icon-only square so the chevron hugs the seam symmetrically. */ +const CHEVRON_PADDING: Record = { + [ButtonSize.XLarge]: '!w-auto !px-3', + [ButtonSize.Large]: '!w-auto !px-2.5', + [ButtonSize.Medium]: '!w-auto !px-2', + [ButtonSize.Small]: '!w-auto !px-1.5', + [ButtonSize.XSmall]: '!w-auto !px-1', +}; + +const DIVIDER_BASE = + "relative border-l-0 before:absolute before:left-0 before:w-px before:content-['']"; + +/** + * Variants that paint `--button-default-border-color: transparent` have no + * border for the divider to match, so they draw their own 1px rule. Every other + * variant returns nothing and keeps its real border as the divider. + * + * Alpha is mixed into the colour rather than applied as a separate utility: + * `before:opacity-*` does not survive this project's Tailwind build on + * pseudo-elements and silently renders at full strength. `bg-current` is + * likewise unusable — the theme replaces Tailwind's `colors` wholesale and has + * no `current` key, so it compiles to nothing at all. + */ +const dividerFor = (variant: ButtonVariant): string | false => { + // Sits on a solid fill, where only the label colour is guaranteed to read. + if (variant === ButtonVariant.Primary) { + return classNames( + DIVIDER_BASE, + 'before:inset-y-0 before:bg-[color-mix(in_srgb,var(--button-color,var(--button-default-color)),transparent_80%)]', + ); + } + + // A bare ghost button: a full-height rule would float with nothing to anchor + // it, so it gets a shorter one in the colour `tailwind/buttons.ts` gives the + // Subtle variant's border. + if (variant === ButtonVariant.Tertiary) { + return classNames( + DIVIDER_BASE, + 'before:inset-y-1.5 before:bg-[color-mix(in_srgb,var(--theme-border-subtlest-primary),transparent_70%)]', + ); + } + + return false; +}; + +/** + * Two real buttons that read as one control: the left half runs the primary + * action, the right half drops the standard menu. Geometry matches a standard + * button at every size — only the shared edge deviates. + */ +export const SplitShareButton = ({ + label, + dropdownLabel, + triggerText, + menu, + copied, + onCopy, + variant = ButtonVariant.Tertiary, + size = ButtonSize.Small, + className, +}: SplitShareButtonProps): ReactElement => { + const [open, setOpen] = useState(false); + + return ( +
      + + + + + +
      + ); +}; diff --git a/packages/shared/src/features/snapshot/EndOfThreadShare.spec.tsx b/packages/shared/src/features/snapshot/EndOfThreadShare.spec.tsx index 73222ce1daf..849815c1417 100644 --- a/packages/shared/src/features/snapshot/EndOfThreadShare.spec.tsx +++ b/packages/shared/src/features/snapshot/EndOfThreadShare.spec.tsx @@ -18,7 +18,7 @@ describe('EndOfThreadShare', () => { expect(screen.getByText('Enjoyed this discussion?')).toBeInTheDocument(); expect( - screen.getByText("Send it to someone who'd have opinions."), + screen.getByText('Send it to someone who\u2019d have opinions.'), ).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Copy link' })).toBeVisible(); }); diff --git a/packages/shared/src/features/snapshot/EndOfThreadShare.tsx b/packages/shared/src/features/snapshot/EndOfThreadShare.tsx index 36b248f7760..051a4d17866 100644 --- a/packages/shared/src/features/snapshot/EndOfThreadShare.tsx +++ b/packages/shared/src/features/snapshot/EndOfThreadShare.tsx @@ -1,23 +1,22 @@ import type { ReactElement } from 'react'; import React, { useCallback } from 'react'; -import { ButtonV2 } from '../../components/buttons/ButtonV2'; -import { - ButtonIconPosition, - ButtonSize, - ButtonVariant, -} from '../../components/buttons/common'; -import { LinkIcon } from '../../components/icons'; -import { useCopyText } from '../../hooks/useCopy'; -import { - ToastType, - useToastNotification, -} from '../../hooks/useToastNotification'; +import { ShareBand } from '../../components/share/ShareBand'; +import { useLogContext } from '../../contexts/LogContext'; +import { postLogEvent } from '../../lib/feed'; +import { LogEvent, Origin } from '../../lib/log'; +import { ReferralCampaignKey } from '../../lib/referral'; +import type { ShareProvider } from '../../lib/share'; import type { Post } from '../../graphql/posts'; /** * #6349's end-of-conversation band. It sits where reading actually stops, and - * copy link is the whole offer: a still image of a live thread is stale within + * the link is the whole offer: a still image of a live thread is stale within * hours, so there is no snapshot here. + * + * The band and its split copy-link control come from #6369/#6378, which built + * this surface and the post-upvote prompt as one pair. Neither landed, so the + * components are carried here; if that stack revives, this and PostContentShare + * should collapse into its EndOfConversationShare. */ export function EndOfThreadShare({ post, @@ -26,18 +25,17 @@ export function EndOfThreadShare({ post: Post; commentsCount: number; }): ReactElement | null { - const [, copy] = useCopyText(post.commentsPermalink); - const { displayToast } = useToastNotification(); + const { logEvent } = useLogContext(); - const onCopy = useCallback(async () => { - try { - await copy({ message: '✅ Copied link' }); - } catch { - displayToast('❌ Your browser blocked the clipboard', { - variant: ToastType.Error, - }); - } - }, [copy, displayToast]); + const onShare = useCallback( + (provider: ShareProvider) => + logEvent( + postLogEvent(LogEvent.SharePost, post, { + extra: { provider, origin: Origin.EndOfConversation }, + }), + ), + [logEvent, post], + ); // Nothing to be at the end of: an empty thread has no conversation to pass // on, and the band would just be a second copy-link button. @@ -46,25 +44,14 @@ export function EndOfThreadShare({ } return ( -
      -
      - - Enjoyed this discussion? - - - Send it to someone who'd have opinions. - -
      - } - iconPosition={ButtonIconPosition.Right} - onClick={onCopy} - size={ButtonSize.Small} - type="button" - variant={ButtonVariant.Primary} - > - Copy link - -
      + ); } diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index 01756f6f551..3d658291c7e 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -51,6 +51,7 @@ export enum Origin { HistoryTooltip = 'history tooltip', // search - end PostContent = 'post content', + EndOfConversation = 'end of conversation', History = 'history', FeedbackCard = 'feedback card', FeedCard = 'feed card', diff --git a/packages/webapp/pages/dev/snapshot.tsx b/packages/webapp/pages/dev/snapshot.tsx index e56532f5772..75efa8f5b5c 100644 --- a/packages/webapp/pages/dev/snapshot.tsx +++ b/packages/webapp/pages/dev/snapshot.tsx @@ -45,6 +45,14 @@ const DiscussionShareRow = dynamic( { ssr: false }, ); +const ShareBand = dynamic( + () => + import('@dailydotdev/shared/src/components/share/ShareBand').then( + (mod) => mod.ShareBand, + ), + { ssr: false }, +); + const EndOfThreadShare = dynamic( () => import('@dailydotdev/shared/src/features/snapshot/EndOfThreadShare').then( @@ -547,17 +555,13 @@ const ThePromptedMoments = (): ReactElement => ( note="PostContentShare renders this the moment you upvote a post, with a copy-link input rather than a button. Nothing was built for it: the Storybook page proposed a placement production already had. Snapshot stays out either way — it would be the payload the post's own OG image already carries." step="After upvote · shipping" > -
      -
      - - Should anyone else see this post? - - - You upvoted it — pass it on. - -
      - -
      + {}} + text={TITLE} + title="Should anyone else see this post?" + /> ); From f05db7c24993062ebc4e70379ca038b397b60aff Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Tue, 1 Sep 2026 11:13:39 +0300 Subject: [PATCH 15/19] fix(share): even the air around the prompt, and let it stay after a copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The band sat 40px below the action bar and 16px above the comment box. The focus card's column is already gap-4, so the mt-6 the band carried for the classic layout — where PostContainer has no gap and every child hand-rolls its margins — stacked on top of a gap that was doing the job. Spacing now belongs to the host: the band defaults to my-4 for a container that supplies none, and the focus card passes nothing. Copying no longer dismisses the prompt. It called onInteract('none'), so the band vanished under the cursor — taking the networks behind the chevron with it, right when someone might want a second one. Testing: shared 382 suites / 2680 tests, webapp 81 / 644, package lint clean. Co-Authored-By: Claude Opus 5 --- .../components/post/common/PostContentShare.tsx | 15 +++++++++++---- .../components/post/focus/PostDiscussionPanel.tsx | 6 +++++- .../src/components/post/focus/PostFocusCard.tsx | 5 +++-- .../src/features/snapshot/EndOfThreadShare.tsx | 5 ++++- 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/packages/shared/src/components/post/common/PostContentShare.tsx b/packages/shared/src/components/post/common/PostContentShare.tsx index b1052785fdd..4586024b320 100644 --- a/packages/shared/src/components/post/common/PostContentShare.tsx +++ b/packages/shared/src/components/post/common/PostContentShare.tsx @@ -16,10 +16,17 @@ import { featurePostSharePrompts } from '../../../lib/featureManagement'; interface PostContentShareProps { post: Post; + /** + * Spacing belongs to the host: PostContainer is a flex column with no gap + * and hand-rolls every margin, while the focus card's column already spaces + * its children. A margin that reads as even in one is lopsided in the other. + */ + className?: string; } export function PostContentShare({ post, + className = 'my-4', }: PostContentShareProps): ReactElement | null { const { onInteract, interaction } = usePostActions({ post }); const { logOpts } = useActiveFeedContext(); @@ -38,16 +45,16 @@ export function PostContentShare({ const onShare = useCallback( (provider: ShareProvider) => { + // Deliberately not dismissed: a copy is not always the end of it, and a + // prompt that vanishes under the cursor takes the second network with it. logEvent( postLogEvent(LogEvent.SharePost, post, { extra: { provider, origin: Origin.PostContent }, ...(logOpts && logOpts), }), ); - // The prompt has done its job; leaving it up nags. - onInteract('none'); }, - [logEvent, logOpts, onInteract, post], + [logEvent, logOpts, post], ); if (interaction !== 'upvote' || isLoading) { @@ -61,7 +68,7 @@ export function PostContentShare({ // as one pair. return ( {areSharePromptsEnabled && ( - + )} {showMetaBar && (
      diff --git a/packages/shared/src/components/post/focus/PostFocusCard.tsx b/packages/shared/src/components/post/focus/PostFocusCard.tsx index f4a276be672..3175253ad98 100644 --- a/packages/shared/src/components/post/focus/PostFocusCard.tsx +++ b/packages/shared/src/components/post/focus/PostFocusCard.tsx @@ -672,8 +672,9 @@ const PostFocusCardRaw = ({ /> {/* Directly under the upvote that raises it — the classic page gets - this from PostEngagements, in the same place. */} - + this from PostEngagements, in the same place. No margin: this + column is gap-4, and one of its own would double the air above. */} + {!onClose && } diff --git a/packages/shared/src/features/snapshot/EndOfThreadShare.tsx b/packages/shared/src/features/snapshot/EndOfThreadShare.tsx index 051a4d17866..2a429602c42 100644 --- a/packages/shared/src/features/snapshot/EndOfThreadShare.tsx +++ b/packages/shared/src/features/snapshot/EndOfThreadShare.tsx @@ -21,9 +21,12 @@ import type { Post } from '../../graphql/posts'; export function EndOfThreadShare({ post, commentsCount, + className = 'my-6', }: { post: Post; commentsCount: number; + /** The hosts space their children differently; see PostContentShare. */ + className?: string; }): ReactElement | null { const { logEvent } = useLogContext(); @@ -46,7 +49,7 @@ export function EndOfThreadShare({ return ( Date: Tue, 1 Sep 2026 10:11:18 +0300 Subject: [PATCH 16/19] feat(snapshot): put the Happening Now placements on the real product MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Happening Now can be shared at three levels — the whole page, a topic and a single highlight — and today none of them can. The page also has the shortest shelf life we publish, which is why the image matters more than the link: a URL sends someone to a page that has already moved on. Adds the snapshot cards for both payloads and wires all three levels into /highlights behind their own flags, plus /dev/snapshot-happening-now, which stands the four placements side by side with working controls. Co-Authored-By: Claude Opus 5 --- .../highlights/HighlightItem.spec.tsx | 48 ++ .../components/highlights/HighlightItem.tsx | 102 +++- .../components/highlights/HighlightsPage.tsx | 20 +- .../snapshot/HighlightSnapshotButton.tsx | 78 +++ .../snapshot/HighlightSnapshotCard.tsx | 111 ++++ .../snapshot/HighlightsPageSnapshotButton.tsx | 75 +++ .../snapshot/HighlightsPageSnapshotCard.tsx | 98 +++ packages/shared/src/lib/featureManagement.ts | 14 + .../pages/dev/snapshot-happening-now.tsx | 562 ++++++++++++++++++ 9 files changed, 1078 insertions(+), 30 deletions(-) create mode 100644 packages/shared/src/features/snapshot/HighlightSnapshotButton.tsx create mode 100644 packages/shared/src/features/snapshot/HighlightSnapshotCard.tsx create mode 100644 packages/shared/src/features/snapshot/HighlightsPageSnapshotButton.tsx create mode 100644 packages/shared/src/features/snapshot/HighlightsPageSnapshotCard.tsx create mode 100644 packages/webapp/pages/dev/snapshot-happening-now.tsx diff --git a/packages/shared/src/components/highlights/HighlightItem.spec.tsx b/packages/shared/src/components/highlights/HighlightItem.spec.tsx index 5f1e186989a..76c65834efc 100644 --- a/packages/shared/src/components/highlights/HighlightItem.spec.tsx +++ b/packages/shared/src/components/highlights/HighlightItem.spec.tsx @@ -1,6 +1,14 @@ import React from 'react'; +import { QueryClient } from '@tanstack/react-query'; +import { GrowthBook } from '@growthbook/growthbook-react'; import { render, screen } from '@testing-library/react'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; import type { PostHighlightFeed } from '../../graphql/highlights'; +import type { Feature } from '../../lib/featureManagement'; +import { + featureSnapshotHighlightExpanded, + featureSnapshotHighlightRow, +} from '../../lib/featureManagement'; import { HighlightItem } from './HighlightItem'; const scrollIntoView = jest.fn(); @@ -30,6 +38,17 @@ beforeEach(() => { scrollIntoView.mockClear(); }); +const renderWithFlag = (feature: Feature, defaultExpanded = false) => { + const gb = new GrowthBook(); + gb.setFeatures({ [feature.id]: { defaultValue: true } }); + + return render( + + + , + ); +}; + describe('HighlightItem', () => { it('should expand when the route-driven default changes after mount', () => { const { rerender } = render(); @@ -45,4 +64,33 @@ describe('HighlightItem', () => { ); expect(scrollIntoView).toHaveBeenCalled(); }); + + it('leaves the row without a snapshot while the flag is off', () => { + render(); + + expect( + screen.queryByRole('button', { name: /snapshot/i }), + ).not.toBeInTheDocument(); + }); + + it('puts a snapshot on the row when the row flag is on', () => { + renderWithFlag(featureSnapshotHighlightRow); + + expect(screen.getByRole('button', { name: /snapshot/i })).toBeVisible(); + }); + + it('only offers the expanded snapshot once the highlight is open', () => { + renderWithFlag(featureSnapshotHighlightExpanded); + + expect( + screen.queryByRole('button', { name: /snapshot/i }), + ).not.toBeInTheDocument(); + }); + + it('offers the expanded snapshot beside Read more when open', () => { + renderWithFlag(featureSnapshotHighlightExpanded, true); + + expect(screen.getByRole('button', { name: /snapshot/i })).toBeVisible(); + expect(screen.getByRole('link', { name: /read more/i })).toBeVisible(); + }); }); diff --git a/packages/shared/src/components/highlights/HighlightItem.tsx b/packages/shared/src/components/highlights/HighlightItem.tsx index 4256c618b38..184e7490ea4 100644 --- a/packages/shared/src/components/highlights/HighlightItem.tsx +++ b/packages/shared/src/components/highlights/HighlightItem.tsx @@ -8,6 +8,14 @@ import { ArrowIcon } from '../icons/Arrow'; import { IconSize } from '../Icon'; import Link from '../utilities/Link'; import { RelativeTime } from '../utilities/RelativeTime'; +import { ButtonSize, ButtonVariant } from '../buttons/common'; +import { HighlightSnapshotButton } from '../../features/snapshot/HighlightSnapshotButton'; +import { useSharePlacement } from '../../features/snapshot/useSharePlacement'; +import { + featureSnapshotHighlightExpanded, + featureSnapshotHighlightRow, +} from '../../lib/featureManagement'; +import { formatDate, TimeFormatType } from '../../lib/dateFormat'; interface HighlightItemProps { highlight: PostHighlightFeed; @@ -20,6 +28,13 @@ export const HighlightItem = ({ }: HighlightItemProps): ReactElement => { const [expanded, setExpanded] = useState(defaultExpanded); const ref = useRef(null); + const rowSnapshot = useSharePlacement({ + feature: featureSnapshotHighlightRow, + }); + const expandedSnapshot = useSharePlacement({ + feature: featureSnapshotHighlightExpanded, + shouldEvaluate: expanded, + }); useEffect(() => { if (defaultExpanded) { @@ -52,40 +67,71 @@ export const HighlightItem = ({ return ''; }, [highlight.post]); + const snapshotProps = { + channel: highlight.channel, + headline: highlight.headline, + id: highlight.id, + link: highlight.post.commentsPermalink, + meta: formatDate({ + value: highlight.highlightedAt, + type: TimeFormatType.Post, + }), + tldr, + }; + return (
      -
      - - + + {rowSnapshot && ( + + )} + {expanded && tldr && (

      {tldr}

      - - - Read more - - +
      + + + Read more + + + {expandedSnapshot && ( + + )} +
      )} diff --git a/packages/shared/src/components/highlights/HighlightsPage.tsx b/packages/shared/src/components/highlights/HighlightsPage.tsx index eb11f9e7566..beffdbd3ee9 100644 --- a/packages/shared/src/components/highlights/HighlightsPage.tsx +++ b/packages/shared/src/components/highlights/HighlightsPage.tsx @@ -12,6 +12,10 @@ import { postHighlightsFeedQueryOptions, } from '../../graphql/highlights'; import { Tab, TabContainer } from '../tabs/TabContainer'; +import { ButtonSize, ButtonVariant } from '../buttons/common'; +import { HighlightsPageSnapshotButton } from '../../features/snapshot/HighlightsPageSnapshotButton'; +import { useSharePlacement } from '../../features/snapshot/useSharePlacement'; +import { featureSnapshotHighlightsPage } from '../../lib/featureManagement'; import { DigestCTA } from './DigestCTA'; import { HighlightItem } from './HighlightItem'; @@ -170,13 +174,25 @@ export const HighlightsPage = (): ReactElement => { const activeTab = isAllTab ? ALL_HIGHLIGHTS_LABEL : channelLabel ?? MAJOR_HEADLINES_LABEL; + const pageSnapshot = useSharePlacement({ + feature: featureSnapshotHighlightsPage, + shouldEvaluate: majorHeadlines.length > 0, + }); return (
      -
      -

      +
      +

      Happening Now

      + {pageSnapshot && ( + headline)} + seed="happening-now" + size={ButtonSize.Small} + variant={ButtonVariant.Secondary} + /> + )}
      (null); + + return ( + <> + +
      + +
      + + ); +} diff --git a/packages/shared/src/features/snapshot/HighlightSnapshotCard.tsx b/packages/shared/src/features/snapshot/HighlightSnapshotCard.tsx new file mode 100644 index 00000000000..0bc447c9789 --- /dev/null +++ b/packages/shared/src/features/snapshot/HighlightSnapshotCard.tsx @@ -0,0 +1,111 @@ +import type { ReactElement } from 'react'; +import React, { forwardRef } from 'react'; +import colors from '../../styles/colors'; +import { SnapshotFrame } from './SnapshotFrame'; +import { truncateAtWord } from './snapshotText'; + +const MUTED = colors.salt['90']; +const DIVIDER = colors.pepper['10']; + +const TLDR_LIMIT = 220; + +/** + * The headline is the claim being shared, so it takes the card: short ones are + * set large and longer ones step down rather than push the TLDR off the edge. + */ +const headlineFontSize = (length: number): number => { + if (length <= 50) { + return 64; + } + + if (length <= 90) { + return 54; + } + + if (length <= 140) { + return 44; + } + + return 38; +}; + +export interface HighlightSnapshotCardProps { + headline: string; + tldr?: string; + /** Rendered under the divider, e.g. "2h ago". */ + meta?: string; + channel?: string; + seed?: string; +} + +function HighlightSnapshotCardComponent( + { headline, tldr, meta, channel, seed }: HighlightSnapshotCardProps, + ref: React.Ref, +): ReactElement { + const summary = tldr ? truncateAtWord(tldr, TLDR_LIMIT) : ''; + const footer = [channel, meta].filter(Boolean).join(' · '); + + return ( + +
      +
      + + + Happening now + +
      + +

      + {headline} +

      + + {summary && ( +

      + {summary} +

      + )} + + {footer && ( + + {footer} + + )} +
      +
      + ); +} + +export const HighlightSnapshotCard = forwardRef(HighlightSnapshotCardComponent); diff --git a/packages/shared/src/features/snapshot/HighlightsPageSnapshotButton.tsx b/packages/shared/src/features/snapshot/HighlightsPageSnapshotButton.tsx new file mode 100644 index 00000000000..68e37c19c9a --- /dev/null +++ b/packages/shared/src/features/snapshot/HighlightsPageSnapshotButton.tsx @@ -0,0 +1,75 @@ +import type { ReactElement } from 'react'; +import React, { useRef } from 'react'; +import type { + ButtonSize, + ButtonVariant, +} from '../../components/buttons/common'; +import { SnapshotButton } from '../../components/imageShare/SnapshotButton'; +import { HighlightsPageSnapshotCard } from './HighlightsPageSnapshotCard'; +import { SNAPSHOT_SIZE } from './snapshotGradient'; + +const CAPTURE_OPTIONS = { + width: SNAPSHOT_SIZE, + height: SNAPSHOT_SIZE, + padding: 0, + branded: false, +}; + +export interface HighlightsPageSnapshotButtonProps { + headlines: string[]; + meta?: string; + channel?: string; + link?: string; + seed?: string; + showLabel?: boolean; + size?: ButtonSize; + variant?: ButtonVariant; +} + +/** + * The page-level offer: the top headlines as one card. Weaker than a single + * highlight — five claims at thumbnail size is a wall — but it is the only + * control that can ship without deciding anything about the rows. + */ +export function HighlightsPageSnapshotButton({ + headlines, + meta, + channel, + link, + seed, + showLabel, + size, + variant, +}: HighlightsPageSnapshotButtonProps): ReactElement | null { + const cardRef = useRef(null); + + if (!headlines.length) { + return null; + } + + return ( + <> + +
      + +
      + + ); +} diff --git a/packages/shared/src/features/snapshot/HighlightsPageSnapshotCard.tsx b/packages/shared/src/features/snapshot/HighlightsPageSnapshotCard.tsx new file mode 100644 index 00000000000..f70dbdfc85f --- /dev/null +++ b/packages/shared/src/features/snapshot/HighlightsPageSnapshotCard.tsx @@ -0,0 +1,98 @@ +import type { ReactElement } from 'react'; +import React, { forwardRef } from 'react'; +import colors from '../../styles/colors'; +import { SnapshotFrame } from './SnapshotFrame'; +import { truncateAtWord } from './snapshotText'; + +const MUTED = colors.salt['90']; +const DIVIDER = colors.pepper['10']; + +/** Past five the type has to shrink below what survives a thumbnail. */ +export const HIGHLIGHTS_SNAPSHOT_LIMIT = 5; + +const HEADLINE_LIMIT = 90; + +export interface HighlightsPageSnapshotCardProps { + headlines: string[]; + /** Rendered under the divider, e.g. "Updated 4 minutes ago". */ + meta?: string; + channel?: string; + seed?: string; +} + +function HighlightsPageSnapshotCardComponent( + { headlines, meta, channel, seed }: HighlightsPageSnapshotCardProps, + ref: React.Ref, +): ReactElement { + const shown = headlines.slice(0, HIGHLIGHTS_SNAPSHOT_LIMIT); + const footer = [channel, meta].filter(Boolean).join(' · '); + + return ( + +
      +
      + + + Happening now + +
      + +
        + {shown.map((headline, index) => ( +
      1. + + {index + 1} + + + {truncateAtWord(headline, HEADLINE_LIMIT)} + +
      2. + ))} +
      + + {footer && ( + + {footer} + + )} +
      +
      + ); +} + +export const HighlightsPageSnapshotCard = forwardRef( + HighlightsPageSnapshotCardComponent, +); diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 0ea97711991..1909705a320 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -38,6 +38,20 @@ 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 featurePostNavCopyLink = new Feature('post_nav_copy_link', false); +// Snapshot on Happening Now. Three levels compete for the control and only one +// should ship, so each is its own flag rather than a variant string. +export const featureSnapshotHighlightRow = new Feature( + 'snapshot_highlight_row', + false, +); +export const featureSnapshotHighlightExpanded = new Feature( + 'snapshot_highlight_expanded', + false, +); +export const featureSnapshotHighlightsPage = new Feature( + 'snapshot_highlights_page', + 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 diff --git a/packages/webapp/pages/dev/snapshot-happening-now.tsx b/packages/webapp/pages/dev/snapshot-happening-now.tsx new file mode 100644 index 00000000000..49cfa0ab69a --- /dev/null +++ b/packages/webapp/pages/dev/snapshot-happening-now.tsx @@ -0,0 +1,562 @@ +import type { ReactElement, ReactNode, RefObject } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { NextSeo } from 'next-seo'; +import classNames from 'classnames'; +import Toast from '@dailydotdev/shared/src/components/notifications/Toast'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + ArrowIcon, + LinkIcon, + MenuIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { HighlightSnapshotButton } from '@dailydotdev/shared/src/features/snapshot/HighlightSnapshotButton'; +import { HighlightSnapshotCard } from '@dailydotdev/shared/src/features/snapshot/HighlightSnapshotCard'; +import { HighlightsPageSnapshotButton } from '@dailydotdev/shared/src/features/snapshot/HighlightsPageSnapshotButton'; +import { HighlightsPageSnapshotCard } from '@dailydotdev/shared/src/features/snapshot/HighlightsPageSnapshotCard'; +import { SNAPSHOT_SIZE } from '@dailydotdev/shared/src/features/snapshot/snapshotGradient'; +import { captureShareImage } from '@dailydotdev/shared/src/lib/imageShare/captureShareImage'; +import { useCopyText } from '@dailydotdev/shared/src/hooks/useCopy'; +import { + ToastType, + useToastNotification, +} from '@dailydotdev/shared/src/hooks/useToastNotification'; +import type { AuthContextData } from '@dailydotdev/shared/src/contexts/AuthContext'; +import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext'; +import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext'; +import type { LogContextData } from '@dailydotdev/shared/src/hooks/log/useLogContextData'; + +/** + * /dev/snapshot-happening-now — the four Happening Now placements from the + * Storybook surface page, rebuilt with working controls: every Snapshot below + * really rasterizes the card it would share. + * + * On /highlights itself each level sits behind its own flag — + * `snapshot_highlight_row`, `snapshot_highlight_expanded` and + * `snapshot_highlights_page` — all defaulting to off, and all forced on for + * branch previews. Carries `noindex`/`nofollow` and is blocked on the + * canonical production hosts. + */ + +const LINK = 'https://app.daily.dev/highlights'; + +const HIGHLIGHTS = [ + { + id: 'dev-highlight-openai', + headline: 'OpenAI ships a cheaper model tier', + tldr: 'Priced at a third of the previous tier with the same context window. The cut lands first on the API, with the assistant products following next quarter.', + meta: '2h ago', + }, + { + id: 'dev-highlight-react', + headline: 'React 20 drops the legacy render path', + tldr: 'The codemod covers most applications; class components with legacy context are the exception and will need a manual pass.', + meta: '4h ago', + }, + { + id: 'dev-highlight-postgres', + headline: 'Postgres 19 lands async I/O by default', + tldr: 'Early benchmarks show double-digit gains on write-heavy workloads, with the largest wins on NVMe and the smallest on network storage.', + meta: '6h ago', + }, +]; + +const CHANNEL = 'Headlines'; + +const CAPTURE_OPTIONS = { + width: SNAPSHOT_SIZE, + height: SNAPSHOT_SIZE, + padding: 0, + branded: false, +}; + +const useIsAllowedHost = () => { + const [allowed, setAllowed] = useState(true); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + const { hostname } = window.location; + setAllowed(hostname !== 'app.daily.dev' && hostname !== 'www.daily.dev'); + }, []); + + return allowed; +}; + +const LogContext = getLogContextStatic(); + +/** + * `/dev/*` short-circuits to a QueryClient-only tree in _app — no boot, no + * auth — which is what makes these pages load without the API. + */ +const AUTH_STUB = { + isLoggedIn: false, + isAuthReady: true, + tokenRefreshed: true, + shouldShowLogin: false, + squads: [], + showLogin: () => {}, + closeLogin: () => {}, + logout: async () => {}, + updateUser: async () => {}, + getRedirectUri: () => '', +} as unknown as AuthContextData; + +const LOG_STUB = { + logEvent: () => {}, + logEventStart: () => {}, + logEventEnd: () => {}, +} as unknown as LogContextData; + +const DevProviders = ({ children }: { children: ReactNode }) => ( + + {children} + +); + +/* ----------------------------------------------------------- page furniture */ + +const Placement = ({ + step, + headline, + note, + children, +}: { + step: string; + headline: string; + note: string; + children: ReactNode; +}) => ( +
      +
      + + {step} + + + {headline} + + + {note} + +
      +
      + {children} +
      +
      +); + +const Inert = ({ icon, label }: { icon: ReactElement; label: string }) => ( + + ); +}; + +/* --------------------------------------------------------------- the mocks */ + +type Spot = 'menu' | 'row' | 'expanded' | 'page'; + +const PageHeader = ({ spot }: { spot: Spot }) => ( +
      +

      + Happening Now +

      + {spot === 'page' ? ( + headline)} + link={LINK} + meta="Updated 4 minutes ago" + seed="dev-happening-now" + size={ButtonSize.Small} + variant={ButtonVariant.Secondary} + /> + ) : ( + } label="Options" /> + )} +
      +); + +const HighlightRow = ({ + highlight, + spot, + expanded, +}: { + highlight: (typeof HIGHLIGHTS)[number]; + spot: Spot; + expanded: boolean; +}) => { + const snapshotProps = { + channel: CHANNEL, + headline: highlight.headline, + id: highlight.id, + link: LINK, + meta: highlight.meta, + tldr: highlight.tldr, + }; + + return ( +
      +
      +
      +
      + + {highlight.headline} + + + {highlight.meta} + +
      + +
      + {spot === 'row' && ( + + )} +
      + {expanded && ( +
      +

      {highlight.tldr}

      +
      + + Read more + + + +
      +
      + )} +
      + ); +}; + +const HappeningScreen = ({ spot }: { spot: Spot }) => ( +
      + + {HIGHLIGHTS.map((highlight, index) => ( + + ))} +
      +); + +/* ---------------------------------------------------- capture + its output */ + +const ScaledCard = ({ + label, + children, + size = 260, +}: { + label: string; + children: ReactNode; + size?: number; +}) => ( +
      +
      + {label} +
      +
      +
      + {children} +
      +
      +
      +); + +const CaptureOutput = ({ + target, + label, +}: { + target: RefObject; + label: string; +}): ReactElement => { + const [image, setImage] = useState<{ url: string; size: number } | null>( + null, + ); + const [isCapturing, setIsCapturing] = useState(false); + const [error, setError] = useState(null); + + const onCapture = useCallback(async () => { + setIsCapturing(true); + setError(null); + + try { + const blob = await captureShareImage(target, CAPTURE_OPTIONS); + setImage({ url: URL.createObjectURL(blob), size: blob.size }); + } catch (e) { + setError(String(e)); + } finally { + setIsCapturing(false); + } + }, [target]); + + return ( +
      +
      + + {image && ( + + {SNAPSHOT_SIZE}x{SNAPSHOT_SIZE} · {Math.round(image.size / 1024)} KB + + )} + {error && ( + {error} + )} +
      + {image && ( + The captured snapshot + )} +
      + ); +}; + +/* ------------------------------------------------------------------- page */ + +const HappeningNowSnapshotDevPage = (): ReactElement => { + const allowed = useIsAllowedHost(); + const highlightRef = useRef(null); + const pageRef = useRef(null); + + if (!allowed) { + return ( +
      +

      + The snapshot review page is not available on production. +

      +
      + ); + } + + return ( + <> + + + +
      +
      +
      +

      + Happening now share placements +

      +

      + Three levels can be shared here — the whole page, a topic and a + single highlight — and today none of them can. The page also has + the shortest shelf life in the product, which is exactly why the + image matters: a link sends someone to a page that has already + moved on. +

      +

      + Every Snapshot below is the production component and really + rasterizes its card. On /highlights each level sits + behind its own flag — snapshot_highlight_row,{' '} + snapshot_highlight_expanded and{' '} + snapshot_highlights_page — all defaulting to off. + On a branch preview all three are forced on, so open{' '} + /highlights on this deployment to see them in the + real feed. +

      +
      + +
      +
      +

      + Where the control goes +

      +

      + Snapshot leads at every level — the payload is effectively the + whole page, and news travels through chat apps where an image + renders inline and a link collapses to a grey card. The open + question is per-highlight versus page-level, not which action + wins. +

      +
      + +
      + + + + + + + + + + + + + + + +
      +
      + +
      +
      +

      + What each level exports +

      +

      + The single highlight carries a headline and its TLDR; the page + card carries five headlines and nothing else. This is the + argument for the row placement, drawn rather than asserted. +

      +
      + +
      +
      + + + + +
      + +
      + + headline)} + meta="Updated 4 minutes ago" + seed="dev-happening-now" + /> + + +
      +
      +
      +
      +
      +
      + + {/* The full-size cards every capture on the page reads from. */} +
      + + headline)} + meta="Updated 4 minutes ago" + seed="dev-happening-now" + /> +
      + + ); +}; + +HappeningNowSnapshotDevPage.getLayout = (page: ReactNode): ReactNode => page; + +export default HappeningNowSnapshotDevPage; From 3a0719455cafa2f8e5afbb6e4da4806ad0cd7833 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Tue, 1 Sep 2026 12:55:49 +0300 Subject: [PATCH 17/19] feat(snapshot): leave snapshot to the expanded highlight The row icon and the page-header control both go. The row put a button on every line of a page people scan rather than read, and the page card was a wall of five headlines nobody reads at thumbnail size. What survives is the one placement with an intent signal behind it: the expanded highlight, where there is room for a label and the reader has already said which claim they care about. HighlightItem's header row goes back to its original shape, since the nested button only existed for the row icon. Also lets a preview open its flags on the Vercel host. The daily.dev preview domain is only assigned once a PR is marked ready for review, while Vercel's own host is there from the first push, and nothing in production is served from vercel.app. --- .../highlights/HighlightItem.spec.tsx | 15 +- .../components/highlights/HighlightItem.tsx | 64 +++----- .../components/highlights/HighlightsPage.tsx | 20 +-- .../snapshot/HighlightsPageSnapshotButton.tsx | 75 --------- .../snapshot/HighlightsPageSnapshotCard.tsx | 98 ----------- .../snapshot/useSharePlacement.spec.tsx | 8 + packages/shared/src/lib/constants.ts | 18 +- packages/shared/src/lib/featureManagement.ts | 12 +- .../pages/dev/snapshot-happening-now.tsx | 154 +++++------------- 9 files changed, 92 insertions(+), 372 deletions(-) delete mode 100644 packages/shared/src/features/snapshot/HighlightsPageSnapshotButton.tsx delete mode 100644 packages/shared/src/features/snapshot/HighlightsPageSnapshotCard.tsx diff --git a/packages/shared/src/components/highlights/HighlightItem.spec.tsx b/packages/shared/src/components/highlights/HighlightItem.spec.tsx index 76c65834efc..59021c5141e 100644 --- a/packages/shared/src/components/highlights/HighlightItem.spec.tsx +++ b/packages/shared/src/components/highlights/HighlightItem.spec.tsx @@ -5,10 +5,7 @@ import { render, screen } from '@testing-library/react'; import { TestBootProvider } from '../../../__tests__/helpers/boot'; import type { PostHighlightFeed } from '../../graphql/highlights'; import type { Feature } from '../../lib/featureManagement'; -import { - featureSnapshotHighlightExpanded, - featureSnapshotHighlightRow, -} from '../../lib/featureManagement'; +import { featureSnapshotHighlightExpanded } from '../../lib/featureManagement'; import { HighlightItem } from './HighlightItem'; const scrollIntoView = jest.fn(); @@ -65,20 +62,14 @@ describe('HighlightItem', () => { expect(scrollIntoView).toHaveBeenCalled(); }); - it('leaves the row without a snapshot while the flag is off', () => { - render(); + it('leaves an expanded highlight without a snapshot while the flag is off', () => { + render(); expect( screen.queryByRole('button', { name: /snapshot/i }), ).not.toBeInTheDocument(); }); - it('puts a snapshot on the row when the row flag is on', () => { - renderWithFlag(featureSnapshotHighlightRow); - - expect(screen.getByRole('button', { name: /snapshot/i })).toBeVisible(); - }); - it('only offers the expanded snapshot once the highlight is open', () => { renderWithFlag(featureSnapshotHighlightExpanded); diff --git a/packages/shared/src/components/highlights/HighlightItem.tsx b/packages/shared/src/components/highlights/HighlightItem.tsx index 184e7490ea4..d0c9093d5fb 100644 --- a/packages/shared/src/components/highlights/HighlightItem.tsx +++ b/packages/shared/src/components/highlights/HighlightItem.tsx @@ -11,10 +11,7 @@ import { RelativeTime } from '../utilities/RelativeTime'; import { ButtonSize, ButtonVariant } from '../buttons/common'; import { HighlightSnapshotButton } from '../../features/snapshot/HighlightSnapshotButton'; import { useSharePlacement } from '../../features/snapshot/useSharePlacement'; -import { - featureSnapshotHighlightExpanded, - featureSnapshotHighlightRow, -} from '../../lib/featureManagement'; +import { featureSnapshotHighlightExpanded } from '../../lib/featureManagement'; import { formatDate, TimeFormatType } from '../../lib/dateFormat'; interface HighlightItemProps { @@ -28,9 +25,6 @@ export const HighlightItem = ({ }: HighlightItemProps): ReactElement => { const [expanded, setExpanded] = useState(defaultExpanded); const ref = useRef(null); - const rowSnapshot = useSharePlacement({ - feature: featureSnapshotHighlightRow, - }); const expandedSnapshot = useSharePlacement({ feature: featureSnapshotHighlightExpanded, shouldEvaluate: expanded, @@ -81,40 +75,30 @@ export const HighlightItem = ({ return (
      -
      - - {rowSnapshot && ( - - )} -
      + + + {expanded && tldr && (

      {tldr}

      diff --git a/packages/shared/src/components/highlights/HighlightsPage.tsx b/packages/shared/src/components/highlights/HighlightsPage.tsx index beffdbd3ee9..eb11f9e7566 100644 --- a/packages/shared/src/components/highlights/HighlightsPage.tsx +++ b/packages/shared/src/components/highlights/HighlightsPage.tsx @@ -12,10 +12,6 @@ import { postHighlightsFeedQueryOptions, } from '../../graphql/highlights'; import { Tab, TabContainer } from '../tabs/TabContainer'; -import { ButtonSize, ButtonVariant } from '../buttons/common'; -import { HighlightsPageSnapshotButton } from '../../features/snapshot/HighlightsPageSnapshotButton'; -import { useSharePlacement } from '../../features/snapshot/useSharePlacement'; -import { featureSnapshotHighlightsPage } from '../../lib/featureManagement'; import { DigestCTA } from './DigestCTA'; import { HighlightItem } from './HighlightItem'; @@ -174,25 +170,13 @@ export const HighlightsPage = (): ReactElement => { const activeTab = isAllTab ? ALL_HIGHLIGHTS_LABEL : channelLabel ?? MAJOR_HEADLINES_LABEL; - const pageSnapshot = useSharePlacement({ - feature: featureSnapshotHighlightsPage, - shouldEvaluate: majorHeadlines.length > 0, - }); return (
      -
      -

      +
      +

      Happening Now

      - {pageSnapshot && ( - headline)} - seed="happening-now" - size={ButtonSize.Small} - variant={ButtonVariant.Secondary} - /> - )}
      (null); - - if (!headlines.length) { - return null; - } - - return ( - <> - -
      - -
      - - ); -} diff --git a/packages/shared/src/features/snapshot/HighlightsPageSnapshotCard.tsx b/packages/shared/src/features/snapshot/HighlightsPageSnapshotCard.tsx deleted file mode 100644 index f70dbdfc85f..00000000000 --- a/packages/shared/src/features/snapshot/HighlightsPageSnapshotCard.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import type { ReactElement } from 'react'; -import React, { forwardRef } from 'react'; -import colors from '../../styles/colors'; -import { SnapshotFrame } from './SnapshotFrame'; -import { truncateAtWord } from './snapshotText'; - -const MUTED = colors.salt['90']; -const DIVIDER = colors.pepper['10']; - -/** Past five the type has to shrink below what survives a thumbnail. */ -export const HIGHLIGHTS_SNAPSHOT_LIMIT = 5; - -const HEADLINE_LIMIT = 90; - -export interface HighlightsPageSnapshotCardProps { - headlines: string[]; - /** Rendered under the divider, e.g. "Updated 4 minutes ago". */ - meta?: string; - channel?: string; - seed?: string; -} - -function HighlightsPageSnapshotCardComponent( - { headlines, meta, channel, seed }: HighlightsPageSnapshotCardProps, - ref: React.Ref, -): ReactElement { - const shown = headlines.slice(0, HIGHLIGHTS_SNAPSHOT_LIMIT); - const footer = [channel, meta].filter(Boolean).join(' · '); - - return ( - -
      -
      - - - Happening now - -
      - -
        - {shown.map((headline, index) => ( -
      1. - - {index + 1} - - - {truncateAtWord(headline, HEADLINE_LIMIT)} - -
      2. - ))} -
      - - {footer && ( - - {footer} - - )} -
      -
      - ); -} - -export const HighlightsPageSnapshotCard = forwardRef( - HighlightsPageSnapshotCardComponent, -); diff --git a/packages/shared/src/features/snapshot/useSharePlacement.spec.tsx b/packages/shared/src/features/snapshot/useSharePlacement.spec.tsx index d14007eb028..f5d459243e1 100644 --- a/packages/shared/src/features/snapshot/useSharePlacement.spec.tsx +++ b/packages/shared/src/features/snapshot/useSharePlacement.spec.tsx @@ -63,6 +63,14 @@ describe('useSharePlacement', () => { expect(screen.getByText('on')).toBeInTheDocument(); }); + it('opens itself on the Vercel host, which serves the same deployment', () => { + setHostname('daily-webapp-git-my-branch-dailydotdev.vercel.app'); + + renderProbe(); + + expect(screen.getByText('on')).toBeInTheDocument(); + }); + it('respects a surface that opted out, even on a preview', () => { setHostname('my-branch.preview.app.daily.dev'); diff --git a/packages/shared/src/lib/constants.ts b/packages/shared/src/lib/constants.ts index 3ba0b24fed0..763ff41168f 100644 --- a/packages/shared/src/lib/constants.ts +++ b/packages/shared/src/lib/constants.ts @@ -50,16 +50,22 @@ export const isTesting = export const isGBDevMode = process.env.NEXT_PUBLIC_GB_DEV_MODE === 'true'; /** - * Branch preview deployments, e.g. my-branch.preview.app.daily.dev. They run - * NODE_ENV=production against the production API, so neither `isDevelopment` - * nor GrowthBook's dev tools are available to open a flag for review — the - * host is the only thing that distinguishes them from app.daily.dev. + * Branch preview deployments. They run NODE_ENV=production against the + * production API, so neither `isDevelopment` nor GrowthBook's dev tools are + * available to open a flag for review — the host is the only thing that + * distinguishes them from app.daily.dev. + * + * Two hosts serve the same deployment: the daily.dev domain, which only exists + * once a PR has been marked ready for review, and Vercel's own, which is there + * from the first push. Nothing production is served from vercel.app. */ -export const PREVIEW_HOST_SUFFIX = '.preview.app.daily.dev'; +export const PREVIEW_HOST_SUFFIXES = ['.preview.app.daily.dev', '.vercel.app']; export const isPreviewHost = (): boolean => typeof window !== 'undefined' && - window.location.hostname.endsWith(PREVIEW_HOST_SUFFIX); + PREVIEW_HOST_SUFFIXES.some((suffix) => + window.location.hostname.endsWith(suffix), + ); export const isBrave = (): boolean => { if (typeof window === 'undefined' || !window.Promise) { diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 1909705a320..5a1c7923f36 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -38,20 +38,12 @@ 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 featurePostNavCopyLink = new Feature('post_nav_copy_link', false); -// Snapshot on Happening Now. Three levels compete for the control and only one -// should ship, so each is its own flag rather than a variant string. -export const featureSnapshotHighlightRow = new Feature( - 'snapshot_highlight_row', - false, -); +// Snapshot on Happening Now: the expanded highlight is the only level that +// carries it — expansion is the intent signal, and there is room for a label. export const featureSnapshotHighlightExpanded = new Feature( 'snapshot_highlight_expanded', false, ); -export const featureSnapshotHighlightsPage = new Feature( - 'snapshot_highlights_page', - 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 diff --git a/packages/webapp/pages/dev/snapshot-happening-now.tsx b/packages/webapp/pages/dev/snapshot-happening-now.tsx index 49cfa0ab69a..ab52fac2da9 100644 --- a/packages/webapp/pages/dev/snapshot-happening-now.tsx +++ b/packages/webapp/pages/dev/snapshot-happening-now.tsx @@ -16,8 +16,6 @@ import { import { IconSize } from '@dailydotdev/shared/src/components/Icon'; import { HighlightSnapshotButton } from '@dailydotdev/shared/src/features/snapshot/HighlightSnapshotButton'; import { HighlightSnapshotCard } from '@dailydotdev/shared/src/features/snapshot/HighlightSnapshotCard'; -import { HighlightsPageSnapshotButton } from '@dailydotdev/shared/src/features/snapshot/HighlightsPageSnapshotButton'; -import { HighlightsPageSnapshotCard } from '@dailydotdev/shared/src/features/snapshot/HighlightsPageSnapshotCard'; import { SNAPSHOT_SIZE } from '@dailydotdev/shared/src/features/snapshot/snapshotGradient'; import { captureShareImage } from '@dailydotdev/shared/src/lib/imageShare/captureShareImage'; import { useCopyText } from '@dailydotdev/shared/src/hooks/useCopy'; @@ -31,15 +29,13 @@ import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext import type { LogContextData } from '@dailydotdev/shared/src/hooks/log/useLogContextData'; /** - * /dev/snapshot-happening-now — the four Happening Now placements from the + * /dev/snapshot-happening-now — the Happening Now placements from the * Storybook surface page, rebuilt with working controls: every Snapshot below * really rasterizes the card it would share. * - * On /highlights itself each level sits behind its own flag — - * `snapshot_highlight_row`, `snapshot_highlight_expanded` and - * `snapshot_highlights_page` — all defaulting to off, and all forced on for - * branch previews. Carries `noindex`/`nofollow` and is blocked on the - * canonical production hosts. + * On /highlights itself the placement sits behind `snapshot_highlight_expanded`, + * defaulting to off and forced on for branch previews. Carries + * `noindex`/`nofollow` and is blocked on the canonical production hosts. */ const LINK = 'https://app.daily.dev/highlights'; @@ -193,36 +189,22 @@ const CopyLink = ({ label }: { label?: boolean }) => { /* --------------------------------------------------------------- the mocks */ -type Spot = 'menu' | 'row' | 'expanded' | 'page'; +type Spot = 'menu' | 'expanded'; -const PageHeader = ({ spot }: { spot: Spot }) => ( +const PageHeader = () => (

      Happening Now

      - {spot === 'page' ? ( - headline)} - link={LINK} - meta="Updated 4 minutes ago" - seed="dev-happening-now" - size={ButtonSize.Small} - variant={ButtonVariant.Secondary} - /> - ) : ( - } label="Options" /> - )} + } label="Options" />
      ); const HighlightRow = ({ highlight, - spot, expanded, }: { highlight: (typeof HIGHLIGHTS)[number]; - spot: Spot; expanded: boolean; }) => { const snapshotProps = { @@ -236,32 +218,22 @@ const HighlightRow = ({ return (
      -
      -
      -
      - - {highlight.headline} - - - {highlight.meta} - -
      - +
      +
      + + {highlight.headline} + + + {highlight.meta} +
      - {spot === 'row' && ( - - )} +
      {expanded && (
      @@ -285,13 +257,12 @@ const HighlightRow = ({ const HappeningScreen = ({ spot }: { spot: Spot }) => (
      - + {HIGHLIGHTS.map((highlight, index) => ( ))}
      @@ -392,7 +363,6 @@ const CaptureOutput = ({ const HappeningNowSnapshotDevPage = (): ReactElement => { const allowed = useIsAllowedHost(); const highlightRef = useRef(null); - const pageRef = useRef(null); if (!allowed) { return ( @@ -416,20 +386,18 @@ const HappeningNowSnapshotDevPage = (): ReactElement => { Happening now share placements

      - Three levels can be shared here — the whole page, a topic and a - single highlight — and today none of them can. The page also has - the shortest shelf life in the product, which is exactly why the - image matters: a link sends someone to a page that has already - moved on. + Every highlight here is a self-contained claim with sources + behind it, and today none of them can be lifted out. The page + also has the shortest shelf life in the product, which is + exactly why the image matters: a link sends someone to a page + that has already moved on.

      Every Snapshot below is the production component and really - rasterizes its card. On /highlights each level sits - behind its own flag — snapshot_highlight_row,{' '} - snapshot_highlight_expanded and{' '} - snapshot_highlights_page — all defaulting to off. - On a branch preview all three are forced on, so open{' '} - /highlights on this deployment to see them in the + rasterizes its card. On /highlights the placement + sits behind snapshot_highlight_expanded, defaulting + to off. On a branch preview it is forced on, so open{' '} + /highlights on this deployment to see it in the real feed.

      @@ -440,11 +408,10 @@ const HappeningNowSnapshotDevPage = (): ReactElement => { Where the control goes

      - Snapshot leads at every level — the payload is effectively the - whole page, and news travels through chat apps where an image - renders inline and a link collapses to a grey card. The open - question is per-highlight versus page-level, not which action - wins. + Snapshot leads, not a link: news travels through chat apps + where an image renders inline and a link collapses to a grey + card. The control waits for the expansion rather than sitting + on every row, so a scan of the feed stays a scan.

      @@ -457,41 +424,24 @@ const HappeningNowSnapshotDevPage = (): ReactElement => { - - - - - - - -

      - What each level exports + What a highlight exports

      - The single highlight carries a headline and its TLDR; the page - card carries five headlines and nothing else. This is the - argument for the row placement, drawn rather than asserted. + A headline and its TLDR, set as large as the length allows. + The capture below is the file itself, not a preview of it.

      @@ -511,21 +461,6 @@ const HappeningNowSnapshotDevPage = (): ReactElement => { target={highlightRef} /> - -
      - - headline)} - meta="Updated 4 minutes ago" - seed="dev-happening-now" - /> - - -
      @@ -545,13 +480,6 @@ const HappeningNowSnapshotDevPage = (): ReactElement => { seed={HIGHLIGHTS[0].id} tldr={HIGHLIGHTS[0].tldr} /> - headline)} - meta="Updated 4 minutes ago" - seed="dev-happening-now" - /> ); From c439b21192bced5246dfdda654a5e11bbaa964a8 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Tue, 1 Sep 2026 14:28:03 +0300 Subject: [PATCH 18/19] feat(snapshot): put copy link beside the snapshot on an expanded highlight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The expanded footer offered only Snapshot, while the review page paired it with copy link — the pair the selection bar on the post page already ends on. The two ship together as HighlightShareActions, so the review page renders the production control rather than a copy of it. Keeping the pair in its own component also keeps the copy hook out of HighlightItem: useCopyText reaches for the toast, which needs a QueryClient, and the flag is off for everyone by default. --- .../highlights/HighlightItem.spec.tsx | 1 + .../components/highlights/HighlightItem.tsx | 11 +--- .../snapshot/HighlightShareActions.tsx | 44 ++++++++++++++++ .../pages/dev/snapshot-happening-now.tsx | 50 ++----------------- 4 files changed, 51 insertions(+), 55 deletions(-) create mode 100644 packages/shared/src/features/snapshot/HighlightShareActions.tsx diff --git a/packages/shared/src/components/highlights/HighlightItem.spec.tsx b/packages/shared/src/components/highlights/HighlightItem.spec.tsx index 59021c5141e..ae76240d4b3 100644 --- a/packages/shared/src/components/highlights/HighlightItem.spec.tsx +++ b/packages/shared/src/components/highlights/HighlightItem.spec.tsx @@ -82,6 +82,7 @@ describe('HighlightItem', () => { renderWithFlag(featureSnapshotHighlightExpanded, true); expect(screen.getByRole('button', { name: /snapshot/i })).toBeVisible(); + expect(screen.getByRole('button', { name: /copy link/i })).toBeVisible(); expect(screen.getByRole('link', { name: /read more/i })).toBeVisible(); }); }); diff --git a/packages/shared/src/components/highlights/HighlightItem.tsx b/packages/shared/src/components/highlights/HighlightItem.tsx index d0c9093d5fb..5848a8654b5 100644 --- a/packages/shared/src/components/highlights/HighlightItem.tsx +++ b/packages/shared/src/components/highlights/HighlightItem.tsx @@ -8,8 +8,7 @@ import { ArrowIcon } from '../icons/Arrow'; import { IconSize } from '../Icon'; import Link from '../utilities/Link'; import { RelativeTime } from '../utilities/RelativeTime'; -import { ButtonSize, ButtonVariant } from '../buttons/common'; -import { HighlightSnapshotButton } from '../../features/snapshot/HighlightSnapshotButton'; +import { HighlightShareActions } from '../../features/snapshot/HighlightShareActions'; import { useSharePlacement } from '../../features/snapshot/useSharePlacement'; import { featureSnapshotHighlightExpanded } from '../../lib/featureManagement'; import { formatDate, TimeFormatType } from '../../lib/dateFormat'; @@ -108,13 +107,7 @@ export const HighlightItem = ({ Read more - {expandedSnapshot && ( - - )} + {expandedSnapshot && } )} diff --git a/packages/shared/src/features/snapshot/HighlightShareActions.tsx b/packages/shared/src/features/snapshot/HighlightShareActions.tsx new file mode 100644 index 00000000000..f890fc77590 --- /dev/null +++ b/packages/shared/src/features/snapshot/HighlightShareActions.tsx @@ -0,0 +1,44 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { Button } from '../../components/buttons/Button'; +import { ButtonSize, ButtonVariant } from '../../components/buttons/common'; +import { LinkIcon } from '../../components/icons'; +import { Tooltip } from '../../components/tooltip/Tooltip'; +import { useCopyText } from '../../hooks/useCopy'; +import type { HighlightSnapshotButtonProps } from './HighlightSnapshotButton'; +import { HighlightSnapshotButton } from './HighlightSnapshotButton'; + +/** + * The pair an expanded highlight ends on. Snapshot leads because the page it + * links to has usually moved on by the time anyone opens it; copy link is the + * familiar fallback beside it. + */ +export function HighlightShareActions({ + link, + ...props +}: HighlightSnapshotButtonProps): ReactElement { + // useCopyText, not useCopyLink: the link variant reaches for the shortener, + // which needs an authenticated user, and the page has to work signed out. + const [, copyLink] = useCopyText(link); + + return ( + <> + + - ); -}; - /* --------------------------------------------------------------- the mocks */ type Spot = 'menu' | 'expanded'; @@ -242,12 +205,7 @@ const HighlightRow = ({ Read more - - + )} From 4f7a20726eaaee1ba052b49092a099f22e152954 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Tue, 1 Sep 2026 14:35:05 +0300 Subject: [PATCH 19/19] revert(snapshot): keep the preview host to the daily.dev domain The same deployment is served from vercel.app, but api.daily.dev rejects that origin on CORS: boot and every query fail, hydration dies, and the page renders its SSR output inert. Opening the flags there advertises a placement on a host that cannot reach the data behind it. Leaves the note about why, so the next attempt does not repeat it. --- .../snapshot/useSharePlacement.spec.tsx | 8 -------- packages/shared/src/lib/constants.ts | 19 ++++++++----------- 2 files changed, 8 insertions(+), 19 deletions(-) diff --git a/packages/shared/src/features/snapshot/useSharePlacement.spec.tsx b/packages/shared/src/features/snapshot/useSharePlacement.spec.tsx index f5d459243e1..d14007eb028 100644 --- a/packages/shared/src/features/snapshot/useSharePlacement.spec.tsx +++ b/packages/shared/src/features/snapshot/useSharePlacement.spec.tsx @@ -63,14 +63,6 @@ describe('useSharePlacement', () => { expect(screen.getByText('on')).toBeInTheDocument(); }); - it('opens itself on the Vercel host, which serves the same deployment', () => { - setHostname('daily-webapp-git-my-branch-dailydotdev.vercel.app'); - - renderProbe(); - - expect(screen.getByText('on')).toBeInTheDocument(); - }); - it('respects a surface that opted out, even on a preview', () => { setHostname('my-branch.preview.app.daily.dev'); diff --git a/packages/shared/src/lib/constants.ts b/packages/shared/src/lib/constants.ts index 763ff41168f..4663aa220b3 100644 --- a/packages/shared/src/lib/constants.ts +++ b/packages/shared/src/lib/constants.ts @@ -50,22 +50,19 @@ export const isTesting = export const isGBDevMode = process.env.NEXT_PUBLIC_GB_DEV_MODE === 'true'; /** - * Branch preview deployments. They run NODE_ENV=production against the - * production API, so neither `isDevelopment` nor GrowthBook's dev tools are - * available to open a flag for review — the host is the only thing that - * distinguishes them from app.daily.dev. + * Branch preview deployments, e.g. my-branch.preview.app.daily.dev. They run + * NODE_ENV=production against the production API, so neither `isDevelopment` + * nor GrowthBook's dev tools are available to open a flag for review — the + * host is the only thing that distinguishes them from app.daily.dev. * - * Two hosts serve the same deployment: the daily.dev domain, which only exists - * once a PR has been marked ready for review, and Vercel's own, which is there - * from the first push. Nothing production is served from vercel.app. + * Only this domain: the same deployment is also served from vercel.app, but + * the API rejects that origin on CORS, so the app never boots there. */ -export const PREVIEW_HOST_SUFFIXES = ['.preview.app.daily.dev', '.vercel.app']; +export const PREVIEW_HOST_SUFFIX = '.preview.app.daily.dev'; export const isPreviewHost = (): boolean => typeof window !== 'undefined' && - PREVIEW_HOST_SUFFIXES.some((suffix) => - window.location.hostname.endsWith(suffix), - ); + window.location.hostname.endsWith(PREVIEW_HOST_SUFFIX); export const isBrave = (): boolean => { if (typeof window === 'undefined' || !window.Promise) {