From 53c050211cc76de8b8d50f63a88c289a73297929 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Mon, 31 Aug 2026 15:19:04 +0300 Subject: [PATCH 01/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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 11:29:20 +0300 Subject: [PATCH 16/40] fix(share): copy the tracked link everywhere, as the rest of the page does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit against production: every copy on a post resolves a short link carrying the share campaign at press time — usePostContent and DiscussionShareRow both do `getShortUrl(commentsPermalink, SharePost)` in the handler. Three of mine put the raw permalink on the clipboard instead, so a paste from them was attributed to nobody and logged nothing. - The selection bar's copy link now shortens, tracks and logs SharePost, matching DiscussionShareRow line for line. - Copy summary embeds the tracked link rather than the permalink. - Both snapshots carry the tracked link as the text half of the clipboard. The link is resolved as a promise handed to ClipboardItem rather than awaited first: awaiting ends the task that handled the gesture, and Safari refuses a clipboard write after that. The capture and the shortener resolve in parallel. Testing: shared 382 suites / 2681 tests, webapp 81 / 644, package lint clean. New spec covers a link that is still resolving when the write starts. Co-Authored-By: Claude Opus 5 --- .../components/imageShare/SnapshotButton.tsx | 14 ++++++-- .../snapshot/CopySummaryButton.spec.tsx | 2 ++ .../features/snapshot/CopySummaryButton.tsx | 16 +++++++-- .../features/snapshot/PollSnapshotButton.tsx | 12 +++++-- .../snapshot/SelectionSnapshotBar.tsx | 35 +++++++++++++++---- .../src/lib/imageShare/copyShareImage.spec.ts | 17 +++++++-- .../src/lib/imageShare/copyShareImage.ts | 9 +++-- 7 files changed, 87 insertions(+), 18 deletions(-) diff --git a/packages/shared/src/components/imageShare/SnapshotButton.tsx b/packages/shared/src/components/imageShare/SnapshotButton.tsx index fc92cfd8b2e..5dc15db9d0d 100644 --- a/packages/shared/src/components/imageShare/SnapshotButton.tsx +++ b/packages/shared/src/components/imageShare/SnapshotButton.tsx @@ -24,8 +24,12 @@ const SHUTTER_SWEEP_MS = 380; export interface SnapshotButtonProps { target: CaptureTarget; - /** Copied as text beside the image, so a paste carries both halves. */ - link?: string; + /** + * Copied as text beside the image, so a paste carries both halves. A getter + * rather than a string: the tracked short link is fetched when pressed, the + * way every other copy on the page fetches it. + */ + link?: string | (() => Promise | string); filename?: string; label?: string; showLabel?: boolean; @@ -85,7 +89,11 @@ export function SnapshotButton({ // 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, link)) { + // Called, not awaited: the capture and the link resolve in parallel + // and the clipboard write stays inside the gesture. + const resolvedLink = typeof link === 'function' ? link() : link; + + if (await copyShareImage(capture, resolvedLink)) { displayToast(link ? 'Image and link copied' : 'Image copied', { variant: ToastType.Success, }); diff --git a/packages/shared/src/features/snapshot/CopySummaryButton.spec.tsx b/packages/shared/src/features/snapshot/CopySummaryButton.spec.tsx index 7e5caa9d157..a49b2d8bf33 100644 --- a/packages/shared/src/features/snapshot/CopySummaryButton.spec.tsx +++ b/packages/shared/src/features/snapshot/CopySummaryButton.spec.tsx @@ -31,6 +31,8 @@ describe('CopySummaryButton', () => { fireEvent.click(screen.getByLabelText('Copy summary')); await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1)); + // The link is the tracked short one, resolved at press time; the test + // shortener is a passthrough, so it comes back as the link given. expect(writeText).toHaveBeenCalledWith( [ 'Why iconic tech brands lost their dominance', diff --git a/packages/shared/src/features/snapshot/CopySummaryButton.tsx b/packages/shared/src/features/snapshot/CopySummaryButton.tsx index dc5cd9a8e0d..596091c0f0c 100644 --- a/packages/shared/src/features/snapshot/CopySummaryButton.tsx +++ b/packages/shared/src/features/snapshot/CopySummaryButton.tsx @@ -9,6 +9,8 @@ import { import { CopyIcon } from '../../components/icons'; import { Tooltip } from '../../components/tooltip/Tooltip'; import { useCopyText } from '../../hooks/useCopy'; +import { useGetShortUrl } from '../../hooks'; +import { ReferralCampaignKey } from '../../lib/referral'; import { ToastType, useToastNotification, @@ -31,20 +33,28 @@ export function CopySummaryButton({ link: string; className?: string; }): ReactElement { - const [, copy] = useCopyText([title, summary, link].join('\n\n')); + const [, copy] = useCopyText(); + const { getShortUrl } = useGetShortUrl(); 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' }); + // The tracked short link, like every other copy on the page — a raw + // permalink pasted into a thread is attributed to nobody. + const shortLink = await getShortUrl(link, ReferralCampaignKey.SharePost); + + await copy({ + textToCopy: [title, summary, shortLink].join('\n\n'), + message: '✅ Copied summary', + }); } catch { displayToast('❌ Your browser blocked the clipboard', { variant: ToastType.Error, }); } - }, [copy, displayToast]); + }, [copy, displayToast, getShortUrl, link, summary, title]); return ( diff --git a/packages/shared/src/features/snapshot/PollSnapshotButton.tsx b/packages/shared/src/features/snapshot/PollSnapshotButton.tsx index 1becd64fb90..b449e973809 100644 --- a/packages/shared/src/features/snapshot/PollSnapshotButton.tsx +++ b/packages/shared/src/features/snapshot/PollSnapshotButton.tsx @@ -1,10 +1,12 @@ import type { ReactElement } from 'react'; -import React, { useRef } from 'react'; +import React, { useCallback, useRef } from 'react'; import type { ButtonSize, ButtonVariant, } from '../../components/buttons/common'; import { SnapshotButton } from '../../components/imageShare/SnapshotButton'; +import { useGetShortUrl } from '../../hooks'; +import { ReferralCampaignKey } from '../../lib/referral'; import type { Post } from '../../graphql/posts'; import { PollSnapshotCard } from './PollSnapshotCard'; import { pollSnapshotFromPost } from './pollSnapshot'; @@ -35,8 +37,14 @@ export function PollSnapshotButton({ variant?: ButtonVariant; }): ReactElement | null { const cardRef = useRef(null); + const { getShortUrl } = useGetShortUrl(); const snapshot = pollSnapshotFromPost(post); + const getTrackedLink = useCallback( + () => getShortUrl(post.commentsPermalink, ReferralCampaignKey.SharePost), + [getShortUrl, post.commentsPermalink], + ); + if (!snapshot) { return null; } @@ -46,7 +54,7 @@ export function PollSnapshotButton({ (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 [, copyLink] = useCopyPostLink(); const [, copyText] = useCopyText(quote?.text); + const { getShortUrl } = useGetShortUrl(); + const { logEvent } = useLogContext(); + + // The same link every other copy on the page produces: shortened, and + // carrying the share campaign so the visit is attributed. + const getTrackedLink = useCallback( + () => getShortUrl(post.commentsPermalink, ReferralCampaignKey.SharePost), + [getShortUrl, post.commentsPermalink], + ); + + const onCopyLink = useCallback(async () => { + logEvent( + postLogEvent(LogEvent.SharePost, post, { + extra: { provider: ShareProvider.CopyLink, origin: Origin.PostContent }, + }), + ); + copyLink({ link: await getTrackedLink() }); + }, [copyLink, getTrackedLink, logEvent, post]); useEffect(() => { if (selection) { @@ -89,7 +112,7 @@ export function SelectionSnapshotBar({ @@ -97,7 +120,7 @@ export function SelectionSnapshotBar({
      ); -/** - * 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 item, whatever this surface actually calls it. */ - highlight?: string; - className?: string; -}) => ( -
      - {items.map((item) => { - const isShare = item === highlight; - - return ( - - {isShare && } - {item} - - ); - })} -
      -); - -/** 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'; /** diff --git a/packages/storybook/stories/features/snapshot/surfaces/PostPage.stories.tsx b/packages/storybook/stories/features/snapshot/surfaces/PostPage.stories.tsx index ba5505678e0..9ecb7a2680c 100644 --- a/packages/storybook/stories/features/snapshot/surfaces/PostPage.stories.tsx +++ b/packages/storybook/stories/features/snapshot/surfaces/PostPage.stories.tsx @@ -14,7 +14,6 @@ import { LinkIcon, MedalBadgeIcon, MenuIcon, - MiniCloseIcon, OpenLinkIcon, ShareIcon, TwitterIcon, @@ -27,25 +26,14 @@ import type { DeviceName } from '../surfaceChrome'; import { AVATAR, Category, - OverflowMenu, - POST_MENU, Control, Device, Rail, - ShareSheet, SurfacePage, Variant, } from '../surfaceChrome'; -type Spot = - | 'plain' - | 'today' - | 'today-sheet' - | 'summary' - | 'selection' - | 'strip' - | 'endband' - | 'upvote'; +type Spot = 'summary' | 'selection' | 'strip' | 'endband' | 'upvote'; const TITLE = 'Why iconic tech brands lost their dominance'; @@ -86,7 +74,7 @@ const SnapshotResult = () => ( /* ------------------------------------------------------------ the real page */ /** Desktop only: the header cluster is `hidden laptop:flex` in production. */ -const HeaderActions = ({ menu }: { menu?: boolean }) => ( +const HeaderActions = () => (
      ); /** Below laptop the same cluster lives in a sticky bar with the back arrow. */ -const MobileTopBar = ({ menu }: { menu?: boolean }) => ( +const MobileTopBar = () => (
      ); -const SourceRow = ({ - device, - menu, -}: { - device: DeviceName; - menu?: boolean; -}) => ( +const SourceRow = ({ device }: { device: DeviceName }) => (
      XDA Developers · Follow - {!isCompact(device) && } + {!isCompact(device) && }
      ); @@ -396,17 +373,16 @@ const PostView = ({ spot: Spot; }) => { const compact = isCompact(device); - const showsMenu = spot === 'today'; return ( - {compact && } + {compact && }
      - +

      {compact && } - {spot === 'today-sheet' && } ); }; @@ -471,240 +446,9 @@ 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 ; - } - - 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 - - - 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. -

      -
      - ); - } - - return ( -

      - {SUMMARY_LEAD} - {trailing} -

      - ); -}; - -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'; +type PollSpot = 'result' | 'postvote'; const PollResults = () => (
      @@ -821,7 +565,7 @@ const AllPolls = ({ spot }: { spot: PollSpot }) => ( ); -/* ---------------------------------------------- the modal and the sticky nav */ +/* --------------------------------------------------------- the sticky nav */ /** PostNavigation with `inlineActions`: Read post loses its label. */ const NavActions = ({ close }: { close?: boolean }) => ( @@ -838,24 +582,10 @@ const NavActions = ({ close }: { close?: boolean }) => ( size={ButtonSize.Small} variant={ButtonVariant.Tertiary} /> - {close && ( -
      ); -const NavBar = ({ - close, - share, -}: { - close?: boolean; - share?: boolean; -}) => ( +const NavBar = () => (
      ); -const ModalOrNavView = ({ - device, - kind, - share, -}: { - device: DeviceName; - kind: 'modal' | 'nav'; - share?: boolean; -}) => ( +const StickyNavView = ({ device }: { device: DeviceName }) => ( - {kind === 'nav' && ( -
      - scrolled past the header -
      - )} - +
      + scrolled past the header +
      +
      @@ -919,12 +636,6 @@ const ModalOrNavView = ({

      - {kind === 'modal' && ( -
      - - -
      - )}
      ); @@ -937,37 +648,6 @@ const PostPage = () => ( map="Sharing map: lead with Copy link (#6350). People want to read the article, not look at a picture of it. Snapshot appears exactly once on this page — on selected text, where the quote is the share and the link is only attribution." title="Post page & modal" > - - - - - - - - - - - - - - - ( @@ -1004,130 +684,59 @@ const PostPage = () => ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + From 31b84ffeb3356a2252abb3f5127d413ed8df1021 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Thu, 3 Sep 2026 16:05:46 +0300 Subject: [PATCH 19/40] feat(share): confirm every copy with the arrow, not just the split button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The split control already answers a copy with the avocado arrow spinning in. The copy buttons around it — the selection bar's link and text, copy summary, and the sticky nav's link — answered with nothing, so the same gesture landed differently depending on which control the reader reached for. CopyStateIcon now takes the glyph it rests on. Left at its default it is the copy sheets, as the split button has it; the two link controls rest on the link, so a row of icons still reads at a glance. The confirmation itself is unchanged, and the 1s window comes from useCopy, which already tracked it. Testing: shared 383 suites / 2684 tests, webapp 81 / 644, package lint clean. New spec covers the resting state, the landed state and a custom idle glyph. Co-Authored-By: Claude Opus 5 --- .../src/components/post/PostNavigation.tsx | 5 +- .../components/share/CopyStateIcon.spec.tsx | 50 +++++++++++++++++++ .../src/components/share/CopyStateIcon.tsx | 13 +++-- .../features/snapshot/CopySummaryButton.tsx | 6 +-- .../snapshot/SelectionSnapshotBar.tsx | 9 ++-- 5 files changed, 71 insertions(+), 12 deletions(-) create mode 100644 packages/shared/src/components/share/CopyStateIcon.spec.tsx diff --git a/packages/shared/src/components/post/PostNavigation.tsx b/packages/shared/src/components/post/PostNavigation.tsx index e74727e9241..09411274da2 100644 --- a/packages/shared/src/components/post/PostNavigation.tsx +++ b/packages/shared/src/components/post/PostNavigation.tsx @@ -3,6 +3,7 @@ import React from 'react'; import classNames from 'classnames'; import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; import { ArrowIcon, MiniCloseIcon as CloseIcon, LinkIcon } from '../icons'; +import { CopyStateIcon } from '../share/CopyStateIcon'; import { PostHeaderActions } from './PostHeaderActions'; import { PostPosition } from '../../hooks/usePostModalNavigation'; import type { PostNavigationProps } from './common'; @@ -36,7 +37,7 @@ function PostNavigation({ feature: featurePostNavCopyLink, shouldEvaluate: isFixedNavigation, }); - const [, copyLink] = useCopyPostLink(post?.commentsPermalink); + const [linkCopied, copyLink] = useCopyPostLink(post?.commentsPermalink); return (
      )} - +
      + + {isParagraphCopyEnabled && ( + + )} +
      diff --git a/packages/shared/src/components/post/focus/PostFocusCard.tsx b/packages/shared/src/components/post/focus/PostFocusCard.tsx index 3175253ad98..a5542748c9e 100644 --- a/packages/shared/src/components/post/focus/PostFocusCard.tsx +++ b/packages/shared/src/components/post/focus/PostFocusCard.tsx @@ -29,6 +29,7 @@ import Markdown from '../../Markdown'; import { ContentEmbeds } from '../../contentEmbeds/ContentEmbeds'; import { LazyImage } from '../../LazyImage'; import { CopySummaryButton } from '../../../features/snapshot/CopySummaryButton'; +import { ParagraphCopyButtons } from '../../../features/snapshot/ParagraphCopyButtons'; import { SelectionSnapshotBar } from '../../../features/snapshot/SelectionSnapshotBar'; import { useSharePlacement } from '../../../features/snapshot/useSharePlacement'; import { @@ -251,6 +252,12 @@ const PostFocusCardRaw = ({ feature: featurePostCopySummary, shouldEvaluate: !!article.summary, }); + // A markdown body has no summary to trail, so the copy sits per paragraph. + const bodyRef = useRef(null); + const isParagraphCopyEnabled = useSharePlacement({ + feature: featurePostCopySummary, + shouldEvaluate: !!article.contentHtml, + }); // Shared into a squad → "Shared via {squad}"; shared to a profile → just // "Shared post" (we don't repeat the author's name). const sharedVia = @@ -598,10 +605,13 @@ const PostFocusCardRaw = ({ )} {article.contentHtml ? ( - <> +
      + {isParagraphCopyEnabled && ( + + )} - +
      ) : ( article.summary && (isVideoType ? ( diff --git a/packages/shared/src/features/snapshot/ParagraphCopyButtons.spec.tsx b/packages/shared/src/features/snapshot/ParagraphCopyButtons.spec.tsx new file mode 100644 index 00000000000..12ecfed540f --- /dev/null +++ b/packages/shared/src/features/snapshot/ParagraphCopyButtons.spec.tsx @@ -0,0 +1,54 @@ +import type { ReactElement } from 'react'; +import React, { useRef } from 'react'; +import { QueryClient } from '@tanstack/react-query'; +import { render, screen, waitFor } from '@testing-library/react'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; +import { ParagraphCopyButtons } from './ParagraphCopyButtons'; + +const LONG = + 'A collection body runs to several paragraphs, and each one of them is a claim somebody might want to lift out on its own.'; + +const Harness = ({ html }: { html: string }): ReactElement => { + const containerRef = useRef(null); + + return ( +
      + {/* Stands in for Markdown, which writes sanitized HTML into the DOM. */} +
      + +
      + ); +}; + +const renderBody = (html: string) => + render( + + + , + ); + +describe('ParagraphCopyButtons', () => { + it('ends every paragraph of the body with a copy control', async () => { + renderBody(`

      ${LONG}

      ${LONG} And a second one.

      `); + + await waitFor(() => + expect(screen.getAllByLabelText('Copy paragraph')).toHaveLength(2), + ); + }); + + it('puts the control inside the paragraph it copies', async () => { + renderBody(`

      ${LONG}

      `); + + const copy = await screen.findByLabelText('Copy paragraph'); + // Inside the

      , so it trails the last line instead of sitting under it. + expect(copy.closest('p')).not.toBeNull(); + }); + + it('leaves a caption alone — copying one line helps nobody', async () => { + renderBody('

      Figure 1.

      '); + + await waitFor(() => + expect(screen.queryByLabelText('Copy paragraph')).not.toBeInTheDocument(), + ); + }); +}); diff --git a/packages/shared/src/features/snapshot/ParagraphCopyButtons.tsx b/packages/shared/src/features/snapshot/ParagraphCopyButtons.tsx new file mode 100644 index 00000000000..32d4089b324 --- /dev/null +++ b/packages/shared/src/features/snapshot/ParagraphCopyButtons.tsx @@ -0,0 +1,123 @@ +import type { ReactElement, RefObject } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../components/buttons/Button'; +import { CopyStateIcon } from '../../components/share/CopyStateIcon'; +import { Tooltip } from '../../components/tooltip/Tooltip'; +import { useCopyText } from '../../hooks/useCopy'; + +/** A one-line paragraph is a caption or a stub; copying it helps nobody. */ +const MIN_LENGTH = 80; + +const SLOT_ATTRIBUTE = 'data-paragraph-copy'; + +const ParagraphCopy = ({ text }: { text: string }): ReactElement => { + const [copied, copy] = useCopyText(text); + + return ( + +
      + )} ); }; diff --git a/packages/shared/src/components/post/brief/BriefPostHeaderActions.spec.tsx b/packages/shared/src/components/post/brief/BriefPostHeaderActions.spec.tsx new file mode 100644 index 00000000000..d010a936630 --- /dev/null +++ b/packages/shared/src/components/post/brief/BriefPostHeaderActions.spec.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { BriefPostHeaderActions } from './BriefPostHeaderActions'; +import type { Post } from '../../../graphql/posts'; +import { Origin } from '../../../lib/log'; + +const mockCopyLink = jest.fn(); +const mockOpenSharePost = jest.fn(); + +let mockAtEveryWidth = false; + +jest.mock('../../../hooks/useSharePost', () => ({ + useSharePost: () => ({ + copyLink: mockCopyLink, + openSharePost: mockOpenSharePost, + }), +})); + +jest.mock('../../../features/snapshot/useSharePlacement', () => ({ + useSharePlacement: () => mockAtEveryWidth, +})); + +const post = { id: 'brief-1', slug: 'brief-1' } as Post; + +const renderComponent = () => + render( + + + , + ); + +describe('BriefPostHeaderActions', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockAtEveryWidth = false; + }); + + it('keeps the cluster desktop-only while the placement is off', () => { + renderComponent(); + + expect( + screen.getByRole('button', { name: 'Copy link' }).closest('div'), + ).toHaveClass('hidden', 'laptop:flex'); + expect( + screen.queryByRole('button', { name: 'Share briefing' }), + ).not.toBeInTheDocument(); + }); + + it('shows copy link and share at every width when the placement is on', () => { + mockAtEveryWidth = true; + renderComponent(); + + const cluster = screen + .getByRole('button', { name: 'Copy link' }) + .closest('div'); + + expect(cluster).not.toHaveClass('hidden'); + expect( + screen.getByRole('button', { name: 'Share briefing' }), + ).toBeInTheDocument(); + }); + + it('draws every control in the cluster at the same weight', () => { + mockAtEveryWidth = true; + renderComponent(); + + const controls = [ + screen.getByRole('button', { name: 'Copy link' }), + screen.getByRole('button', { name: 'Share briefing' }), + screen.getByRole('link'), + ]; + + // The settings gear shipped variant-less, so it read brighter than the + // share controls that now sit beside it. + controls.forEach((control) => expect(control).toHaveClass('btn-tertiary')); + }); + + it('copies the brief link and opens the share modal', () => { + mockAtEveryWidth = true; + renderComponent(); + + fireEvent.click(screen.getByRole('button', { name: 'Copy link' })); + fireEvent.click(screen.getByRole('button', { name: 'Share briefing' })); + + expect(mockCopyLink).toHaveBeenCalledWith({ post }); + expect(mockOpenSharePost).toHaveBeenCalledWith({ post }); + }); +}); diff --git a/packages/shared/src/components/post/brief/BriefPostHeaderActions.tsx b/packages/shared/src/components/post/brief/BriefPostHeaderActions.tsx index d1cb33406be..c612681644a 100644 --- a/packages/shared/src/components/post/brief/BriefPostHeaderActions.tsx +++ b/packages/shared/src/components/post/brief/BriefPostHeaderActions.tsx @@ -4,11 +4,14 @@ import classNames from 'classnames'; import classed from '../../../lib/classed'; import type { PostHeaderActionsProps } from '../common'; import Link from '../../utilities/Link'; -import { Button, ButtonSize } from '../../buttons/Button'; +import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; import { settingsUrl } from '../../../lib/constants'; -import { LinkIcon, SettingsIcon } from '../../icons'; +import { LinkIcon, SettingsIcon, ShareIcon } from '../../icons'; +import { Tooltip } from '../../tooltip/Tooltip'; import { useSharePost } from '../../../hooks/useSharePost'; import type { Origin } from '../../../lib/log'; +import { featureBriefingShareControls } from '../../../lib/featureManagement'; +import { useSharePlacement } from '../../../features/snapshot/useSharePlacement'; const Container = classed('div', 'flex flex-row items-center'); @@ -26,20 +29,51 @@ export const BriefPostHeaderActions = ({ origin: Origin; showShareButton?: boolean; }): ReactElement => { - const { copyLink } = useSharePost(origin); + const { copyLink, openSharePost } = useSharePost(origin); + const atEveryWidth = useSharePlacement({ + feature: featureBriefingShareControls, + shouldEvaluate: showShareButton, + }); return ( -
      +
      {showShareButton && ( + <> + +
      diff --git a/packages/shared/src/features/briefing/components/BriefPostHeader.tsx b/packages/shared/src/features/briefing/components/BriefPostHeader.tsx index 40afc3cd964..073fc38c3fe 100644 --- a/packages/shared/src/features/briefing/components/BriefPostHeader.tsx +++ b/packages/shared/src/features/briefing/components/BriefPostHeader.tsx @@ -26,12 +26,16 @@ export const BriefPostHeader = ({
      {kicker} - {children &&
      {children}
      } + {/* The actions used to be desktop-only, so nothing had to survive a + narrow row: the kicker gives way to them rather than pushing. */} + {children &&
      {children}
      }
      {heading} diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 1e66e348900..a127889eb2e 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -38,6 +38,14 @@ export const featurePostCopySummary = new Feature('post_copy_summary', false); export const featurePostSharePrompts = new Feature('post_share_prompts', false); export const featurePollSnapshot = new Feature('poll_snapshot', false); export const featurePostCopyLink = new Feature('post_copy_link', false); +// Experiment: share controls on the two briefing surfaces — copy link at every +// width on the briefing (today the whole cluster is `hidden laptop:block`), a +// copy button per row on /briefing, and a share band at the end of the read. +// Default MUST stay `false` — see the rule below. +export const featureBriefingShareControls = new Feature( + 'briefing_share_controls', + false, +); // Experiment: community takes — an LLM-generated digest of what the developer // community on HN/Lobsters thinks about a post. Control hides the surface, // treatment shows it. Enrollment is conditional on the post actually having a From b5067906f022446b3adc0275ee931779a21c0a94 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Wed, 2 Sep 2026 18:01:38 +0300 Subject: [PATCH 27/40] feat(briefing): share a highlight, a bullet, or the Must know section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three controls inside the brief itself: - The post page's highlight bar (#6556), scoped to a body container so a selection in the header or the preferences card never raises it. - A copy icon per bullet and paragraph, carrying the text and the brief link, after #6350's copy-summary icon. Each one is labelled with what it copies, since a brief carries a column of them. - A Snapshot on the Must know heading that captures its bullets as one card, with the claim before the colon as each line. None of these can be declared in JSX: the body is a single ``, so briefBodyBlocks reads the blocks back out of the rendered DOM — which is also the most faithful source, being what the reader is looking at — and the controls are portalled into them. Hosts are marked and swept before each run, because Markdown re-renders on its own for hover cards and the image modal. The closing band is #6369's ShareBand with the briefing's copy, so it cannot drift from the end-of-conversation band it was asked to match. All of it sits behind `briefing_share_controls`, default false. Mockup-to-eng-pass: 1 Co-Authored-By: Claude Opus 5 --- .../post/brief/BriefPostContent.tsx | 30 +++- .../features/briefing/briefBodyBlocks.spec.ts | 101 +++++++++++ .../src/features/briefing/briefBodyBlocks.ts | 93 +++++++++++ .../components/BriefBlockCopyButton.tsx | 64 +++++++ .../BriefBodyShareControls.spec.tsx | 110 ++++++++++++ .../components/BriefBodyShareControls.tsx | 158 ++++++++++++++++++ .../components/BriefShareBand.spec.tsx | 45 +++++ .../briefing/components/BriefShareBand.tsx | 59 +++++++ .../features/snapshot/ListSnapshotCard.tsx | 126 ++++++++++++++ 9 files changed, 783 insertions(+), 3 deletions(-) create mode 100644 packages/shared/src/features/briefing/briefBodyBlocks.spec.ts create mode 100644 packages/shared/src/features/briefing/briefBodyBlocks.ts create mode 100644 packages/shared/src/features/briefing/components/BriefBlockCopyButton.tsx create mode 100644 packages/shared/src/features/briefing/components/BriefBodyShareControls.spec.tsx create mode 100644 packages/shared/src/features/briefing/components/BriefBodyShareControls.tsx create mode 100644 packages/shared/src/features/briefing/components/BriefShareBand.spec.tsx create mode 100644 packages/shared/src/features/briefing/components/BriefShareBand.tsx create mode 100644 packages/shared/src/features/snapshot/ListSnapshotCard.tsx diff --git a/packages/shared/src/components/post/brief/BriefPostContent.tsx b/packages/shared/src/components/post/brief/BriefPostContent.tsx index ad37fd2cbff..ac92e71bfa6 100644 --- a/packages/shared/src/components/post/brief/BriefPostContent.tsx +++ b/packages/shared/src/components/post/brief/BriefPostContent.tsx @@ -1,6 +1,6 @@ import classNames from 'classnames'; import type { ReactElement } from 'react'; -import React, { useMemo, useEffect, useState } from 'react'; +import React, { useMemo, useEffect, useRef, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useRouter } from 'next/router'; import { @@ -46,7 +46,10 @@ import { ButtonVariant, } from '../../buttons/Button'; import { LogEvent, TargetId } from '../../../lib/log'; -import { featurePlusCtaCopy } from '../../../lib/featureManagement'; +import { + featureBriefingShareControls, + featurePlusCtaCopy, +} from '../../../lib/featureManagement'; import { LottieAnimation } from '../../LottieAnimation'; import { briefFeatureList, PlusList } from '../../plus/PlusList'; import { HourDropdown } from '../../fields/HourDropdown'; @@ -65,6 +68,10 @@ import { getFirstName } from '../../../lib/user'; import Link from '../../utilities/Link'; import { ActionType } from '../../../graphql/actions'; import { BriefUpgradeAlert } from '../../../features/briefing/components/BriefUpgradeAlert'; +import { BriefShareBand } from '../../../features/briefing/components/BriefShareBand'; +import { BriefBodyShareControls } from '../../../features/briefing/components/BriefBodyShareControls'; +import { SelectionSnapshotBar } from '../../../features/snapshot/SelectionSnapshotBar'; +import { useSharePlacement } from '../../../features/snapshot/useSharePlacement'; import type { BriefPostHeaderProps } from '../../../features/briefing/components/BriefPostHeader'; import { BriefPostHeader } from '../../../features/briefing/components/BriefPostHeader'; import type { NotificationChannel } from '../../../hooks/notifications/useNotificationSettings'; @@ -135,6 +142,12 @@ const BriefPostContentRaw = ({ unsubscribePersonalizedDigest, } = usePersonalizedDigest(); const [digestTimeIndex, setDigestTimeIndex] = useState(8); + const briefBodyRef = useRef(null); + // The post page's highlight bar, on the briefing's own flag: one switch + // turns every control on this surface on or off together. + const isSelectionShareEnabled = useSharePlacement({ + feature: featureBriefingShareControls, + }); const briefDigest = getPersonalizedDigest(UserPersonalizedDigestType.Brief); @@ -390,7 +403,18 @@ const BriefPostContentRaw = ({
      - +
      + +
      + {isSelectionShareEnabled && ( + + )} + + {isNotPlus && (
      Must know

      +
        +
      • AI agents are taking over your dev tools: The shift is accelerating.
      • +
      • Postgres keeps eating the specialists: One engine, every workload.
      • +
      +

      Worth a look

      +

      A paragraph under the second heading.

      +
        +
      • A bullet under the second heading.
      • +
      +`; + +const render = (html = BODY) => { + const container = document.createElement('div'); + container.innerHTML = html; + document.body.appendChild(container); + + return container; +}; + +describe('getBriefBlocks', () => { + it('returns every bullet and paragraph in the body', () => { + const blocks = getBriefBlocks(render()); + + expect(blocks).toHaveLength(4); + expect(blocks[0].text).toContain( + 'AI agents are taking over your dev tools', + ); + expect(blocks[2].text).toBe('A paragraph under the second heading.'); + }); + + it('skips a paragraph that only wraps a list item', () => { + const blocks = getBriefBlocks( + render('
      • Wrapped bullet

      '), + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0].node.tagName).toBe('LI'); + }); + + it('drops empty blocks', () => { + expect(getBriefBlocks(render('

      Real

      '))).toHaveLength( + 1, + ); + }); +}); + +describe('getBriefSection', () => { + it('collects only the bullets under the named heading', () => { + const section = getBriefSection(render(), 'Must know'); + + expect(section?.heading.tagName).toBe('H2'); + expect(section?.blocks).toHaveLength(2); + expect(section?.blocks[1].text).toContain('Postgres keeps eating'); + }); + + it('stops at the next heading', () => { + const section = getBriefSection(render(), 'Worth a look'); + + expect(section?.blocks.map((block) => block.text)).toEqual([ + 'A paragraph under the second heading.', + 'A bullet under the second heading.', + ]); + }); + + it('matches the heading regardless of case', () => { + expect(getBriefSection(render(), 'must KNOW')?.blocks).toHaveLength(2); + }); + + it('returns null when the brief has no such section', () => { + expect(getBriefSection(render(), 'Deep dive')).toBeNull(); + }); +}); + +describe('splitBriefBullet', () => { + it('splits the claim from the evidence', () => { + expect(splitBriefBullet('The claim: the evidence')).toEqual({ + lead: 'The claim', + rest: 'the evidence', + }); + }); + + it('keeps a bullet with no lead whole', () => { + expect(splitBriefBullet('One sentence with no colon')).toEqual({ + lead: 'One sentence with no colon', + }); + }); + + it('keeps a bullet whole when the colon is far too late to be a lead', () => { + const value = `${'a'.repeat(130)}: trailing`; + + expect(splitBriefBullet(value)).toEqual({ lead: value }); + }); +}); diff --git a/packages/shared/src/features/briefing/briefBodyBlocks.ts b/packages/shared/src/features/briefing/briefBodyBlocks.ts new file mode 100644 index 00000000000..86c07fe5d6d --- /dev/null +++ b/packages/shared/src/features/briefing/briefBodyBlocks.ts @@ -0,0 +1,93 @@ +/** + * BriefPostContent renders the body through `` + * — one blob, no per-item nodes — so a control per bullet has nothing to hang + * off in JSX. These read the blocks back out of the rendered DOM, which is also + * the most faithful source: what the reader is actually looking at. + */ + +export interface BriefBlock { + node: HTMLElement; + text: string; +} + +export interface BriefSection { + heading: HTMLElement; + blocks: BriefBlock[]; +} + +const BLOCK_SELECTOR = 'li, p'; +const HEADING_SELECTOR = 'h1, h2, h3'; + +/* textContent, not innerText: innerText needs layout, which jsdom has none of, + and the collapsed whitespace is what a paste wants anyway. */ +const text = (node: HTMLElement) => + (node.textContent ?? '').replace(/\s+/g, ' ').trim(); + +/** Every copyable block, skipping paragraphs that only wrap a list item. */ +export function getBriefBlocks(container: HTMLElement): BriefBlock[] { + return Array.from(container.querySelectorAll(BLOCK_SELECTOR)) + .filter((node) => !(node.tagName === 'P' && node.closest('li'))) + .map((node) => ({ node, text: text(node) })) + .filter((block) => block.text.length > 0); +} + +/** + * The section a heading opens, up to the next heading of any level. Matching is + * on the heading's own text because the backend sends no ids or classes. + */ +export function getBriefSection( + container: HTMLElement, + headingText: string, +): BriefSection | null { + const heading = Array.from( + container.querySelectorAll(HEADING_SELECTOR), + ).find( + (node) => text(node).toLowerCase() === headingText.toLowerCase().trim(), + ); + + if (!heading) { + return null; + } + + const blocks: BriefBlock[] = []; + let sibling = heading.nextElementSibling; + + while (sibling && !sibling.matches(HEADING_SELECTOR)) { + if (sibling instanceof HTMLElement) { + const nested = sibling.querySelectorAll('li'); + const nodes = nested.length ? Array.from(nested) : [sibling]; + + nodes.forEach((node) => { + const value = text(node); + + if (value) { + blocks.push({ node, text: value }); + } + }); + } + + sibling = sibling.nextElementSibling; + } + + return { heading, blocks }; +} + +/** + * Bullets read `the claim: the evidence`. The claim alone is + * what fits a card, so the lead is split off and the rest becomes its subtitle. + */ +export function splitBriefBullet(value: string): { + lead: string; + rest?: string; +} { + const separator = value.indexOf(':'); + + if (separator < 1 || separator > 120) { + return { lead: value }; + } + + return { + lead: value.slice(0, separator).trim(), + rest: value.slice(separator + 1).trim() || undefined, + }; +} diff --git a/packages/shared/src/features/briefing/components/BriefBlockCopyButton.tsx b/packages/shared/src/features/briefing/components/BriefBlockCopyButton.tsx new file mode 100644 index 00000000000..be566c5ef80 --- /dev/null +++ b/packages/shared/src/features/briefing/components/BriefBlockCopyButton.tsx @@ -0,0 +1,64 @@ +import type { ReactElement } from 'react'; +import React, { useCallback } from 'react'; +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 { truncateAtWordBoundary } from '../../../lib/strings'; +import { + ToastType, + useToastNotification, +} from '../../../hooks/useToastNotification'; + +/** Enough of the block to tell two buttons apart, not the whole paragraph. */ +const LABEL_LENGTH = 60; + +/** + * #6350's copy-summary icon, per bullet: the text plus the brief link, so a + * paste carries the claim and where it came from. + */ +export function BriefBlockCopyButton({ + text, + link, +}: { + text: string; + link: string; +}): ReactElement { + const [, copy] = useCopyText([text, link].join('\n\n')); + const { displayToast } = useToastNotification(); + // Every bullet carries one of these, so a label that only said "Copy" would + // read as a wall of identical buttons on a screen reader. + const label = `Copy: ${truncateAtWordBoundary(text, LABEL_LENGTH)}`; + + // 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' }); + } catch { + displayToast('❌ Your browser blocked the clipboard', { + variant: ToastType.Error, + }); + } + }, [copy, displayToast]); + + return ( + +
      ); From 7e319dae14c2dd3405a05859361a5d634145db41 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Sun, 6 Sep 2026 15:38:42 +0300 Subject: [PATCH 30/40] fix(briefing): draw the Must know card the way the library draws it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card's `meta` line is sized for a short label — "Frontend · 3m read" in #6544's own examples — and is not clamped. It was being fed the sentence behind each claim, which wrapped for as many lines as it needed and pushed the rest of the card out of frame. Items carry the claim alone now, and the subtitle carries what a brief actually knows about itself: how many posts and sources it read. ListSnapshotCard goes back to #6544's file byte for byte. The `subtitle` and `footer` props read as dead here, but they belong to the shared card, and the examples use both. Co-Authored-By: Claude Opus 5 --- .../features/briefing/briefBodyBlocks.spec.ts | 15 +++++------ .../src/features/briefing/briefBodyBlocks.ts | 16 ++++------- .../BriefBodyShareControls.spec.tsx | 11 ++++++++ .../components/BriefBodyShareControls.tsx | 15 +++++++---- .../features/snapshot/ListSnapshotCard.tsx | 27 ++++++++++++++++++- 5 files changed, 58 insertions(+), 26 deletions(-) diff --git a/packages/shared/src/features/briefing/briefBodyBlocks.spec.ts b/packages/shared/src/features/briefing/briefBodyBlocks.spec.ts index 0c5018b8fb5..fd62acebf30 100644 --- a/packages/shared/src/features/briefing/briefBodyBlocks.spec.ts +++ b/packages/shared/src/features/briefing/briefBodyBlocks.spec.ts @@ -80,22 +80,19 @@ describe('getBriefSection', () => { }); describe('splitBriefBullet', () => { - it('splits the claim from the evidence', () => { - expect(splitBriefBullet('The claim: the evidence')).toEqual({ - lead: 'The claim', - rest: 'the evidence', - }); + it('keeps the claim and drops the evidence', () => { + expect(splitBriefBullet('The claim: the evidence')).toBe('The claim'); }); it('keeps a bullet with no lead whole', () => { - expect(splitBriefBullet('One sentence with no colon')).toEqual({ - lead: 'One sentence with no colon', - }); + expect(splitBriefBullet('One sentence with no colon')).toBe( + 'One sentence with no colon', + ); }); it('keeps a bullet whole when the colon is far too late to be a lead', () => { const value = `${'a'.repeat(130)}: trailing`; - expect(splitBriefBullet(value)).toEqual({ lead: value }); + expect(splitBriefBullet(value)).toBe(value); }); }); diff --git a/packages/shared/src/features/briefing/briefBodyBlocks.ts b/packages/shared/src/features/briefing/briefBodyBlocks.ts index bc3b7dd66a7..1abcba36f02 100644 --- a/packages/shared/src/features/briefing/briefBodyBlocks.ts +++ b/packages/shared/src/features/briefing/briefBodyBlocks.ts @@ -73,21 +73,15 @@ export function getBriefSection( } /** - * Bullets read `the claim: the evidence`. The claim alone is - * what fits a card, so the lead is split off and the rest becomes its subtitle. + * Bullets read `the claim: the evidence`. Only the claim fits + * a card line, so the evidence is dropped. */ -export function splitBriefBullet(value: string): { - lead: string; - rest?: string; -} { +export function splitBriefBullet(value: string): string { const separator = value.indexOf(':'); if (separator < 1 || separator > 120) { - return { lead: value }; + return value; } - return { - lead: value.slice(0, separator).trim(), - rest: value.slice(separator + 1).trim() || undefined, - }; + return value.slice(0, separator).trim(); } diff --git a/packages/shared/src/features/briefing/components/BriefBodyShareControls.spec.tsx b/packages/shared/src/features/briefing/components/BriefBodyShareControls.spec.tsx index 3bfcfd10c87..782c47540dc 100644 --- a/packages/shared/src/features/briefing/components/BriefBodyShareControls.spec.tsx +++ b/packages/shared/src/features/briefing/components/BriefBodyShareControls.spec.tsx @@ -107,4 +107,15 @@ describe('BriefBodyShareControls', () => { screen.getAllByText('A paragraph under the second heading.'), ).toHaveLength(1); }); + + it('puts the claim on the card without the sentence behind it', () => { + mockIsEnabled = true; + renderComponent(); + + // In the body it trails a , so it is not an element of its own. + // On the card it would be one — and the card's line is unclamped and sized + // for a short label, so a whole sentence there overflows it. + expect(screen.queryAllByText('The shift is accelerating.')).toHaveLength(0); + expect(screen.getAllByText('Agents are eating dev tools')).toHaveLength(2); + }); }); diff --git a/packages/shared/src/features/briefing/components/BriefBodyShareControls.tsx b/packages/shared/src/features/briefing/components/BriefBodyShareControls.tsx index 6ba7acd0733..b696d73c107 100644 --- a/packages/shared/src/features/briefing/components/BriefBodyShareControls.tsx +++ b/packages/shared/src/features/briefing/components/BriefBodyShareControls.tsx @@ -49,6 +49,10 @@ export function BriefBodyShareControls({ contentHtml?: string; }): ReactElement | null { const cardRef = useRef(null); + const posts = post.flags?.posts; + const sources = post.flags?.sources; + const subtitle = + posts && sources ? `${posts} posts from ${sources} sources` : undefined; const [mounts, setMounts] = useState({ copy: [] }); const isEnabled = useSharePlacement({ feature: featureBriefingShareControls, @@ -87,11 +91,11 @@ export function BriefBodyShareControls({ })); const section = getBriefSection(container, SNAPSHOT_SECTION); - const items = section?.blocks.slice(0, 5).map((block) => { - const { lead, rest } = splitBriefBullet(block.text); - - return { title: lead, meta: rest }; - }); + // The claim alone. `meta` renders unclamped, and it is sized for a label + // like "Frontend · 3m read", which a brief's bullets do not carry. + const items = section?.blocks + .slice(0, 5) + .map((block) => ({ title: splitBriefBullet(block.text) })); setMounts({ copy, @@ -148,6 +152,7 @@ export function BriefBodyShareControls({ eyebrow={SNAPSHOT_SECTION} items={mounts.snapshot.items} seed={post.id} + subtitle={subtitle} title={post.title ?? SNAPSHOT_SECTION} /> diff --git a/packages/shared/src/features/snapshot/ListSnapshotCard.tsx b/packages/shared/src/features/snapshot/ListSnapshotCard.tsx index bea63a33bc3..07b3536b8f9 100644 --- a/packages/shared/src/features/snapshot/ListSnapshotCard.tsx +++ b/packages/shared/src/features/snapshot/ListSnapshotCard.tsx @@ -4,6 +4,7 @@ import colors from '../../styles/colors'; import { SnapshotFrame } from './SnapshotFrame'; const MUTED = colors.salt['90']; +const DIVIDER = colors.pepper['10']; export interface SnapshotListItem { title: string; @@ -13,7 +14,9 @@ export interface SnapshotListItem { export interface ListSnapshotCardProps { eyebrow: string; title: string; + subtitle?: string; items: SnapshotListItem[]; + footer?: string; seed?: string; } @@ -22,7 +25,7 @@ export interface ListSnapshotCardProps { * archive, a feed digest — which are the same object with a different label. */ function ListSnapshotCardComponent( - { eyebrow, title, items, seed }: ListSnapshotCardProps, + { eyebrow, title, subtitle, items, footer, seed }: ListSnapshotCardProps, ref: React.Ref, ): ReactElement { const visible = items.slice(0, 5); @@ -55,6 +58,14 @@ function ListSnapshotCardComponent( > {title} + {subtitle && ( + + {subtitle} + + )}
        {visible.map((item, index) => ( @@ -93,6 +104,20 @@ function ListSnapshotCardComponent( ))}
      + + {footer && ( + + {footer} + + )} ); From f5ab917d41d643d2394fce1d8c017146931deb57 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Tue, 8 Sep 2026 12:32:45 +0300 Subject: [PATCH 31/40] feat(briefing): take #6544's current frame for the Must know card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The eyebrow moves off the copy and onto the logo row, where it reads as a sibling of the mark rather than a headline for the list under it, and the title takes the space it left. SnapshotFrame, SnapshotEyebrow, the gradient module and ListSnapshotCard are #6544's files byte for byte, so the card cannot drift from the library it belongs to. The frame's new props are additive and the gutter maths resolves to the old 780/58 values, so the cards already drawing through it — the quote card, the poll card — render exactly as before. Co-Authored-By: Claude Opus 5 --- .../features/snapshot/ListSnapshotCard.tsx | 20 ++--- .../src/features/snapshot/SnapshotEyebrow.tsx | 39 ++++++++++ .../src/features/snapshot/SnapshotFrame.tsx | 78 ++++++++++++++++--- .../src/features/snapshot/snapshotGradient.ts | 5 ++ 4 files changed, 120 insertions(+), 22 deletions(-) create mode 100644 packages/shared/src/features/snapshot/SnapshotEyebrow.tsx diff --git a/packages/shared/src/features/snapshot/ListSnapshotCard.tsx b/packages/shared/src/features/snapshot/ListSnapshotCard.tsx index 07b3536b8f9..c4657373aec 100644 --- a/packages/shared/src/features/snapshot/ListSnapshotCard.tsx +++ b/packages/shared/src/features/snapshot/ListSnapshotCard.tsx @@ -1,6 +1,7 @@ import type { ReactElement } from 'react'; import React, { forwardRef } from 'react'; import colors from '../../styles/colors'; +import { SnapshotEyebrow } from './SnapshotEyebrow'; import { SnapshotFrame } from './SnapshotFrame'; const MUTED = colors.salt['90']; @@ -31,21 +32,14 @@ function ListSnapshotCardComponent( const visible = items.slice(0, 5); return ( - + } + ref={ref} + seed={seed ?? title} + >
      - - {eyebrow} - -

      + {label} + + ); +} diff --git a/packages/shared/src/features/snapshot/SnapshotFrame.tsx b/packages/shared/src/features/snapshot/SnapshotFrame.tsx index 05c5a2c5589..298e40f3f9a 100644 --- a/packages/shared/src/features/snapshot/SnapshotFrame.tsx +++ b/packages/shared/src/features/snapshot/SnapshotFrame.tsx @@ -1,15 +1,28 @@ import type { ReactElement, ReactNode } from 'react'; import React, { forwardRef } from 'react'; +import classNames from 'classnames'; import LogoIcon from '../../svg/LogoIcon'; import LogoText from '../../svg/LogoText'; -import { getSnapshotGradient, SNAPSHOT_SIZE } from './snapshotGradient'; +import { + getSnapshotGradient, + SNAPSHOT_MAX_HEIGHT, + SNAPSHOT_SIZE, +} from './snapshotGradient'; export const SNAPSHOT_CARD_SIZE = 780; +/** + * A page-shaped card: the gradient stays as a border rather than a stage, so + * the copy gets the room instead. Surfaces where the text *is* the payload use + * it — a wide margin around a cramped article is space spent on nothing. + */ +export const SNAPSHOT_CARD_WIDE = 1008; /** Canvas minus the logo row and the gaps either side of the card. */ export const SNAPSHOT_CARD_MAX = SNAPSHOT_SIZE - 150; const CARD_RADIUS = 48; const CARD_EDGE = 2; +const CARD_PADDING = 58; +const CARD_PADDING_WIDE = 32; /** * The App Store device frame: a lit hairline that is brightest along the top @@ -33,8 +46,24 @@ interface SnapshotFrameProps { logoPlacement?: SnapshotLogoPlacement; /** A glyph bled across the card body at low opacity, behind the content. */ watermark?: string; + /** + * Sits on the logo row, far right — for a surface label that belongs with + * the mark rather than with the copy. + */ + logoAside?: ReactNode; /** Drop the card shell and stand the children straight on the gradient. */ bare?: boolean; + /** + * Let the height follow the content instead of holding 1:1. Text surfaces + * use it so the image can carry more than a screenshot would; it still + * starts at the square and stops at SNAPSHOT_MAX_HEIGHT. + */ + grow?: boolean; + /** + * Widen the card to SNAPSHOT_CARD_WIDE and tighten its padding, for surfaces + * whose copy needs the room more than the frame needs the margin. + */ + wide?: boolean; children: ReactNode; } @@ -42,12 +71,17 @@ function SnapshotFrameComponent( { seed, watermark, + logoAside, bare, + grow, + wide, logoPlacement = 'inline', children, }: SnapshotFrameProps, ref: React.Ref, ): ReactElement { + const cardWidth = wide ? SNAPSHOT_CARD_WIDE : SNAPSHOT_CARD_SIZE; + const gutter = (SNAPSHOT_SIZE - cardWidth) / 2; const isOverlaid = logoPlacement !== 'inline'; const overlayStyle = { position: 'absolute' as const, @@ -65,19 +99,38 @@ function SnapshotFrameComponent(

      ); + const logoRow = logoAside ? ( +
      + {logo} + {logoAside} +
      + ) : ( + logo + ); + return (
      {/* Standing alone on the gradient, the collectible has no card to sit in: the mark leads above it, or floats over its artwork. */} - {bare && !isOverlaid && logo} + {bare && !isOverlaid && logoRow} {bare ? (
      @@ -87,8 +140,10 @@ function SnapshotFrameComponent( ) : (
      - {!isOverlaid && logo} + {!isOverlaid && logoRow} {children}
      diff --git a/packages/shared/src/features/snapshot/snapshotGradient.ts b/packages/shared/src/features/snapshot/snapshotGradient.ts index 03059ea599c..e6384db61c3 100644 --- a/packages/shared/src/features/snapshot/snapshotGradient.ts +++ b/packages/shared/src/features/snapshot/snapshotGradient.ts @@ -1,4 +1,9 @@ export const SNAPSHOT_SIZE = 1080; +/** + * 9:16 — the tallest frame every share destination still shows whole. Text + * surfaces grow into it instead of clamping their copy to the square. + */ +export const SNAPSHOT_MAX_HEIGHT = 1920; /* eslint-disable no-bitwise -- an FNV hash and a mulberry32 PRNG are defined in terms of integer bit operations; expressing them any other way would From 46e56779cc3f9a2d8fdae9c738843cedb713576e Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Tue, 8 Sep 2026 12:39:19 +0300 Subject: [PATCH 32/40] feat(snapshot): take #6544's card design for the saved image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quote card was a size ladder inside a fixed square: short highlights set at 72px, long ones stepped down to 40 and the frame padded whatever was left with gradient. #6544 replaced that with one copy size in a frame that grows with the passage, so a shared image no longer changes character with its length, and pulls the attribution back to just the source. Carries over SnapshotFrame's grow/wide modes, SnapshotCredit, and the measured capture options — a growing card cannot be captured at an assumed 1080 square without letterboxing a long passage or padding a short one. The poll card keeps its square, since bar charts need it, and picks up the same credit block. Co-Authored-By: Claude Opus 5 --- .../components/imageShare/SnapshotButton.tsx | 13 ++- .../snapshot/HighlightTextSnapshotCard.tsx | 99 +++++-------------- .../features/snapshot/PollSnapshotCard.tsx | 10 +- .../snapshot/SelectionSnapshotBar.tsx | 13 +-- .../src/features/snapshot/SnapshotCredit.tsx | 47 +++++++++ .../src/features/snapshot/SnapshotFrame.tsx | 78 +++++++++++++-- .../src/features/snapshot/snapshotCapture.ts | 22 +++++ .../src/features/snapshot/snapshotGradient.ts | 5 + .../src/features/snapshot/snapshotText.ts | 14 +++ .../snapshot/surfaces/PostPage.stories.tsx | 17 ++-- packages/webapp/pages/dev/snapshot.tsx | 42 +++----- 11 files changed, 217 insertions(+), 143 deletions(-) create mode 100644 packages/shared/src/features/snapshot/SnapshotCredit.tsx create mode 100644 packages/shared/src/features/snapshot/snapshotCapture.ts diff --git a/packages/shared/src/components/imageShare/SnapshotButton.tsx b/packages/shared/src/components/imageShare/SnapshotButton.tsx index 5dc15db9d0d..41fc5fa282f 100644 --- a/packages/shared/src/components/imageShare/SnapshotButton.tsx +++ b/packages/shared/src/components/imageShare/SnapshotButton.tsx @@ -36,7 +36,11 @@ export interface SnapshotButtonProps { size?: ButtonSize; variant?: ButtonVariant; className?: string; - captureOptions?: CaptureShareImageOptions; + /** + * A getter rather than a value for cards whose frame grows with its copy: + * the height can only be measured once the card is mounted. + */ + captureOptions?: CaptureShareImageOptions | (() => CaptureShareImageOptions); onCapture?: (blob: Blob) => void; } @@ -80,7 +84,12 @@ export function SnapshotButton({ setIsCapturing(true); try { - const capture = captureShareImage(target, captureOptions); + const capture = captureShareImage( + target, + typeof captureOptions === 'function' + ? captureOptions() + : captureOptions, + ); if (onCapture) { onCapture(await capture); diff --git a/packages/shared/src/features/snapshot/HighlightTextSnapshotCard.tsx b/packages/shared/src/features/snapshot/HighlightTextSnapshotCard.tsx index a6887f0b94b..32296539bf1 100644 --- a/packages/shared/src/features/snapshot/HighlightTextSnapshotCard.tsx +++ b/packages/shared/src/features/snapshot/HighlightTextSnapshotCard.tsx @@ -1,103 +1,52 @@ import type { ReactElement } from 'react'; import React, { forwardRef } from 'react'; -import colors from '../../styles/colors'; +import { SnapshotCredit } from './SnapshotCredit'; 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; -}; +import { + SNAPSHOT_COPY_SIZE, + SNAPSHOT_PASSAGE_LIMIT, + truncateAtWord, +} from './snapshotText'; export interface HighlightTextSnapshotCardProps { + /** What the reader marked, and the whole subject of the card. */ text: string; source?: { name: string; image?: string }; - postTitle?: string; - domain?: string; seed?: string; } +/** + * The reader's selection, set like the post card's TLDR: same copy scale, + * same credit. Nothing around the selection is carried — what was marked is + * what gets sent, so the card needs no highlight of its own. The source is + * named, not linked: a URL is unreadable at a glance and unclickable in an + * image. + */ function HighlightTextSnapshotCardComponent( - { text, source, postTitle, domain, seed }: HighlightTextSnapshotCardProps, + { text, source, seed }: HighlightTextSnapshotCardProps, ref: React.Ref, ): ReactElement { - const quote = truncateAtWord(text); - const attribution = [postTitle, domain].filter(Boolean).join(' · '); + const quote = truncateAtWord(text, SNAPSHOT_PASSAGE_LIMIT); return ( - +
      - - “ -

      {quote}

      -
      - {source && ( -
      - {source.image && ( - - )} - - {source.name} - -
      - )} - {attribution && ( - - {attribution} - - )} -
      + {source?.name && ( + + )}
      ); diff --git a/packages/shared/src/features/snapshot/PollSnapshotCard.tsx b/packages/shared/src/features/snapshot/PollSnapshotCard.tsx index b0acec3fb7e..4b5d5a5015c 100644 --- a/packages/shared/src/features/snapshot/PollSnapshotCard.tsx +++ b/packages/shared/src/features/snapshot/PollSnapshotCard.tsx @@ -1,6 +1,7 @@ import type { ReactElement } from 'react'; import React, { forwardRef } from 'react'; import colors from '../../styles/colors'; +import { SnapshotCredit } from './SnapshotCredit'; import { SnapshotFrame } from './SnapshotFrame'; const MUTED = colors.salt['90']; @@ -116,14 +117,7 @@ function PollSnapshotCardComponent( ))}
    - {source && ( - - {source.name} - - )} + {source && } ); diff --git a/packages/shared/src/features/snapshot/SelectionSnapshotBar.tsx b/packages/shared/src/features/snapshot/SelectionSnapshotBar.tsx index e9663d5e301..a56dbaae66c 100644 --- a/packages/shared/src/features/snapshot/SelectionSnapshotBar.tsx +++ b/packages/shared/src/features/snapshot/SelectionSnapshotBar.tsx @@ -20,7 +20,7 @@ import { ReferralCampaignKey } from '../../lib/referral'; import { ShareProvider } from '../../lib/share'; import type { Post } from '../../graphql/posts'; import { HighlightTextSnapshotCard } from './HighlightTextSnapshotCard'; -import { SNAPSHOT_SIZE } from './snapshotGradient'; +import { getSnapshotCaptureOptions } from './snapshotCapture'; import type { TextSelection } from './useTextSelection'; import { useTextSelection } from './useTextSelection'; @@ -29,13 +29,6 @@ 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; @@ -111,7 +104,7 @@ export function SelectionSnapshotBar({ {/* Snapshot leads, labelled and solid: it is the reason the bar exists, and the two copies beside it are the familiar fallbacks. */} getSnapshotCaptureOptions(cardRef.current)} filename={`daily-quote-${post.id}`} link={getTrackedLink} target={cardRef} @@ -147,8 +140,6 @@ export function SelectionSnapshotBar({ > + {image && ( + + )} + + {name} + + + ); +} diff --git a/packages/shared/src/features/snapshot/SnapshotFrame.tsx b/packages/shared/src/features/snapshot/SnapshotFrame.tsx index 05c5a2c5589..298e40f3f9a 100644 --- a/packages/shared/src/features/snapshot/SnapshotFrame.tsx +++ b/packages/shared/src/features/snapshot/SnapshotFrame.tsx @@ -1,15 +1,28 @@ import type { ReactElement, ReactNode } from 'react'; import React, { forwardRef } from 'react'; +import classNames from 'classnames'; import LogoIcon from '../../svg/LogoIcon'; import LogoText from '../../svg/LogoText'; -import { getSnapshotGradient, SNAPSHOT_SIZE } from './snapshotGradient'; +import { + getSnapshotGradient, + SNAPSHOT_MAX_HEIGHT, + SNAPSHOT_SIZE, +} from './snapshotGradient'; export const SNAPSHOT_CARD_SIZE = 780; +/** + * A page-shaped card: the gradient stays as a border rather than a stage, so + * the copy gets the room instead. Surfaces where the text *is* the payload use + * it — a wide margin around a cramped article is space spent on nothing. + */ +export const SNAPSHOT_CARD_WIDE = 1008; /** Canvas minus the logo row and the gaps either side of the card. */ export const SNAPSHOT_CARD_MAX = SNAPSHOT_SIZE - 150; const CARD_RADIUS = 48; const CARD_EDGE = 2; +const CARD_PADDING = 58; +const CARD_PADDING_WIDE = 32; /** * The App Store device frame: a lit hairline that is brightest along the top @@ -33,8 +46,24 @@ interface SnapshotFrameProps { logoPlacement?: SnapshotLogoPlacement; /** A glyph bled across the card body at low opacity, behind the content. */ watermark?: string; + /** + * Sits on the logo row, far right — for a surface label that belongs with + * the mark rather than with the copy. + */ + logoAside?: ReactNode; /** Drop the card shell and stand the children straight on the gradient. */ bare?: boolean; + /** + * Let the height follow the content instead of holding 1:1. Text surfaces + * use it so the image can carry more than a screenshot would; it still + * starts at the square and stops at SNAPSHOT_MAX_HEIGHT. + */ + grow?: boolean; + /** + * Widen the card to SNAPSHOT_CARD_WIDE and tighten its padding, for surfaces + * whose copy needs the room more than the frame needs the margin. + */ + wide?: boolean; children: ReactNode; } @@ -42,12 +71,17 @@ function SnapshotFrameComponent( { seed, watermark, + logoAside, bare, + grow, + wide, logoPlacement = 'inline', children, }: SnapshotFrameProps, ref: React.Ref, ): ReactElement { + const cardWidth = wide ? SNAPSHOT_CARD_WIDE : SNAPSHOT_CARD_SIZE; + const gutter = (SNAPSHOT_SIZE - cardWidth) / 2; const isOverlaid = logoPlacement !== 'inline'; const overlayStyle = { position: 'absolute' as const, @@ -65,19 +99,38 @@ function SnapshotFrameComponent( ); + const logoRow = logoAside ? ( +
    + {logo} + {logoAside} +
    + ) : ( + logo + ); + return (
    {/* Standing alone on the gradient, the collectible has no card to sit in: the mark leads above it, or floats over its artwork. */} - {bare && !isOverlaid && logo} + {bare && !isOverlaid && logoRow} {bare ? (
    @@ -87,8 +140,10 @@ function SnapshotFrameComponent( ) : (
    - {!isOverlaid && logo} + {!isOverlaid && logoRow} {children}
    diff --git a/packages/shared/src/features/snapshot/snapshotCapture.ts b/packages/shared/src/features/snapshot/snapshotCapture.ts new file mode 100644 index 00000000000..ebcfe1a4473 --- /dev/null +++ b/packages/shared/src/features/snapshot/snapshotCapture.ts @@ -0,0 +1,22 @@ +import type { CaptureShareImageOptions } from '../../lib/imageShare/captureShareImage'; +import { SNAPSHOT_MAX_HEIGHT, SNAPSHOT_SIZE } from './snapshotGradient'; + +/** + * A designed card is 1080 wide and carries its own logo, so the capture only + * has to match its height. Growing cards are measured rather than assumed, in + * both directions: assuming the square would letterbox a long passage down to + * a screenshot's worth of text, and pad a short one out with dead gradient. + * An unmeasurable element falls back to the square. + */ +export function getSnapshotCaptureOptions( + element?: HTMLElement | null, +): CaptureShareImageOptions { + const measured = Math.round(element?.getBoundingClientRect().height ?? 0); + + return { + width: SNAPSHOT_SIZE, + height: measured ? Math.min(SNAPSHOT_MAX_HEIGHT, measured) : SNAPSHOT_SIZE, + padding: 0, + branded: false, + }; +} diff --git a/packages/shared/src/features/snapshot/snapshotGradient.ts b/packages/shared/src/features/snapshot/snapshotGradient.ts index 03059ea599c..e6384db61c3 100644 --- a/packages/shared/src/features/snapshot/snapshotGradient.ts +++ b/packages/shared/src/features/snapshot/snapshotGradient.ts @@ -1,4 +1,9 @@ export const SNAPSHOT_SIZE = 1080; +/** + * 9:16 — the tallest frame every share destination still shows whole. Text + * surfaces grow into it instead of clamping their copy to the square. + */ +export const SNAPSHOT_MAX_HEIGHT = 1920; /* eslint-disable no-bitwise -- an FNV hash and a mulberry32 PRNG are defined in terms of integer bit operations; expressing them any other way would diff --git a/packages/shared/src/features/snapshot/snapshotText.ts b/packages/shared/src/features/snapshot/snapshotText.ts index dcc0561a55a..ff239d7dcd6 100644 --- a/packages/shared/src/features/snapshot/snapshotText.ts +++ b/packages/shared/src/features/snapshot/snapshotText.ts @@ -5,6 +5,20 @@ */ export const SNAPSHOT_TEXT_LIMIT = 280; +/** + * The frame grows to fit, so the ceiling on a shared passage is about + * legibility at 1080 wide rather than about the square. + */ +export const SNAPSHOT_PASSAGE_LIMIT = 900; + +/** + * One size for the copy on the post and highlight cards, not a scale. Those + * frames grow to fit now, so type no longer has to shrink to reach the bottom + * of a fixed square — and a shared image that changes size with its length + * reads as two different cards. + */ +export const SNAPSHOT_COPY_SIZE = 38; + export function truncateAtWord( text: string, limit = SNAPSHOT_TEXT_LIMIT, diff --git a/packages/storybook/stories/features/snapshot/surfaces/PostPage.stories.tsx b/packages/storybook/stories/features/snapshot/surfaces/PostPage.stories.tsx index 9ecb7a2680c..98f927257a8 100644 --- a/packages/storybook/stories/features/snapshot/surfaces/PostPage.stories.tsx +++ b/packages/storybook/stories/features/snapshot/surfaces/PostPage.stories.tsx @@ -46,23 +46,18 @@ const SUMMARY_QUOTE = 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. */ + so it is drawn full-size and zoomed rather than made responsive. */ const PREVIEW_SIZE = 360; const SnapshotResult = () => (
    -
    + {/* zoom, not transform: the frame grows with the passage, so the preview + box has to take its height from the card instead of assuming a square. */} +
    ( diff --git a/packages/webapp/pages/dev/snapshot.tsx b/packages/webapp/pages/dev/snapshot.tsx index 75efa8f5b5c..fdbf8798537 100644 --- a/packages/webapp/pages/dev/snapshot.tsx +++ b/packages/webapp/pages/dev/snapshot.tsx @@ -23,6 +23,7 @@ import { CopySummaryButton } from '@dailydotdev/shared/src/features/snapshot/Cop 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 { getSnapshotCaptureOptions } from '@dailydotdev/shared/src/features/snapshot/snapshotCapture'; import { captureShareImage } from '@dailydotdev/shared/src/lib/imageShare/captureShareImage'; import { useCopyText } from '@dailydotdev/shared/src/hooks/useCopy'; import { @@ -127,22 +128,15 @@ const POLL_POST = { } as unknown as Post; const QUOTE_TIERS = [ - { label: '<=70 chars . 72px', text: 'They optimised the product they had.' }, - { label: '<=140 chars . 60px', text: QUOTE }, - { label: '<=240 chars . 48px', text: `${SUMMARY_LEAD}${QUOTE}` }, + { label: 'one line', text: 'They optimised the product they had.' }, + { label: 'a sentence', text: QUOTE }, + { label: 'a passage . the frame grows', text: `${SUMMARY_LEAD}${QUOTE}` }, { - label: '>280 chars . truncated at the last word', - text: `${SUMMARY_LEAD}${QUOTE} ${SUMMARY_LEAD}${QUOTE}`, + label: 'past 900 chars . cut at the last word', + text: `${SUMMARY_LEAD}${QUOTE} `.repeat(6), }, ]; -const CAPTURE_OPTIONS = { - width: SNAPSHOT_SIZE, - height: SNAPSHOT_SIZE, - padding: 0, - branded: false, -}; - const useIsAllowedHost = () => { const [allowed, setAllowed] = useState(true); @@ -360,16 +354,11 @@ const ScaledCard = ({
    -
    - {children} -
    + {/* zoom, not transform: the card's height follows its copy now, and a + scaled box has to shrink with it rather than crop or pad it. */} +
    {children}
    ); @@ -392,7 +381,10 @@ const CaptureOutput = ({ setError(null); try { - const blob = await captureShareImage(target, CAPTURE_OPTIONS); + const blob = await captureShareImage( + target, + getSnapshotCaptureOptions(target.current), + ); setImage({ url: URL.createObjectURL(blob), size: blob.size }); } catch (e) { setError(String(e)); @@ -476,7 +468,7 @@ const TheTwoAdditions = ({
    @@ -484,8 +476,6 @@ const TheTwoAdditions = ({ {QUOTE_TIERS.map((tier) => ( { > Date: Tue, 8 Sep 2026 13:11:13 +0300 Subject: [PATCH 33/40] fix(snapshot): do not credit the API's placeholder source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A link the backend could not attribute is given a real source record named "unknown", handle "unknown", over a squad placeholder avatar. In the post header that passes as chrome; printed as the sole credit on an image someone sends out it reads as a bug — and the credit became the only attribution when the card dropped the domain line. Credit the domain instead, which is the one thing an unattributed link still carries, and nobody at all when there is not even that. Co-Authored-By: Claude Opus 5 --- .../snapshot/SelectionSnapshotBar.tsx | 7 ++--- .../features/snapshot/pollSnapshot.spec.ts | 26 +++++++++++++++++++ .../src/features/snapshot/pollSnapshot.ts | 3 ++- .../src/features/snapshot/snapshotSource.ts | 25 ++++++++++++++++++ 4 files changed, 55 insertions(+), 6 deletions(-) create mode 100644 packages/shared/src/features/snapshot/snapshotSource.ts diff --git a/packages/shared/src/features/snapshot/SelectionSnapshotBar.tsx b/packages/shared/src/features/snapshot/SelectionSnapshotBar.tsx index a56dbaae66c..ed7513b39c8 100644 --- a/packages/shared/src/features/snapshot/SelectionSnapshotBar.tsx +++ b/packages/shared/src/features/snapshot/SelectionSnapshotBar.tsx @@ -21,6 +21,7 @@ import { ShareProvider } from '../../lib/share'; import type { Post } from '../../graphql/posts'; import { HighlightTextSnapshotCard } from './HighlightTextSnapshotCard'; import { getSnapshotCaptureOptions } from './snapshotCapture'; +import { snapshotSource } from './snapshotSource'; import type { TextSelection } from './useTextSelection'; import { useTextSelection } from './useTextSelection'; @@ -141,11 +142,7 @@ export function SelectionSnapshotBar({
    diff --git a/packages/shared/src/features/snapshot/pollSnapshot.spec.ts b/packages/shared/src/features/snapshot/pollSnapshot.spec.ts index e448d2df237..cdca0e72cb1 100644 --- a/packages/shared/src/features/snapshot/pollSnapshot.spec.ts +++ b/packages/shared/src/features/snapshot/pollSnapshot.spec.ts @@ -1,5 +1,6 @@ import type { Post } from '../../graphql/posts'; import { pollSnapshotFromPost } from './pollSnapshot'; +import { snapshotSource } from './snapshotSource'; const poll = { id: 'poll-1', @@ -74,3 +75,28 @@ describe('pollSnapshotFromPost', () => { ).toBeNull(); }); }); + +describe('snapshotSource', () => { + it('falls back to the domain when the API could not attribute the link', () => { + expect( + snapshotSource({ + source: { name: 'unknown', handle: 'unknown' }, + domain: 'xda-developers.com', + } as unknown as Post), + ).toEqual({ name: 'xda-developers.com' }); + }); + + it('credits nobody rather than a placeholder', () => { + expect( + snapshotSource({ source: { name: 'unknown' } } as unknown as Post), + ).toBeUndefined(); + }); + + it('credits a real source with its avatar', () => { + expect( + snapshotSource({ + source: { name: 'Trends', image: 'trends.png' }, + } as unknown as Post), + ).toEqual({ name: 'Trends', image: 'trends.png' }); + }); +}); diff --git a/packages/shared/src/features/snapshot/pollSnapshot.ts b/packages/shared/src/features/snapshot/pollSnapshot.ts index 1d0f3dde856..9dfe6191f75 100644 --- a/packages/shared/src/features/snapshot/pollSnapshot.ts +++ b/packages/shared/src/features/snapshot/pollSnapshot.ts @@ -3,6 +3,7 @@ import type { Post } from '../../graphql/posts'; import { postDateFormat } from '../../lib/dateFormat'; import { largeNumberFormat } from '../../lib'; import type { PollSnapshotCardProps } from './PollSnapshotCard'; +import { snapshotSource } from './snapshotSource'; /** * The card renders what it is handed rather than recomputing anything, so the @@ -44,7 +45,7 @@ export function pollSnapshotFromPost(post: Post): PollSnapshotCardProps | null { text: option.text, share: Math.round(((option.numVotes ?? 0) / total) * 100), })), - source: post.source ? { name: post.source.name } : undefined, + source: snapshotSource(post), seed: post.id, }; } diff --git a/packages/shared/src/features/snapshot/snapshotSource.ts b/packages/shared/src/features/snapshot/snapshotSource.ts new file mode 100644 index 00000000000..5987db7c34b --- /dev/null +++ b/packages/shared/src/features/snapshot/snapshotSource.ts @@ -0,0 +1,25 @@ +import type { Post } from '../../graphql/posts'; + +/** + * The API's catch-all source for a link it could not attribute: handle and + * name are both the literal string "unknown", over a squad placeholder + * avatar. In the product's own header that placeholder is small and passes as + * chrome; printed as the sole credit on an image someone sends out it reads + * as a bug. + */ +const UNKNOWN_SOURCE = 'unknown'; + +/** Who to credit on a share image, or nobody rather than a placeholder. */ +export function snapshotSource( + post: Pick, +): { name: string; image?: string } | undefined { + const { source, domain } = post; + + if (!source?.name || source.name === UNKNOWN_SOURCE) { + // The domain is what the reader would recognise anyway, and it is the one + // fact an unattributed link still carries. + return domain ? { name: domain } : undefined; + } + + return { name: source.name, image: source.image }; +} From e78bb7e8ef5a897a9d3ce3602213d208c171ecba Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:35:04 +0300 Subject: [PATCH 34/40] fix(briefing): gate the list and header controls with useConditionalFeature useSharePlacement went with #6556's cleanup: besides reading the flag it forced every placement on for *.preview.app.daily.dev. The list rows and the briefing header now read briefing_share_controls through useConditionalFeature, the header only when it is asked to show the share button, so the digest page never evaluates it. The flag default stays false. The share arrow and link glyphs import from their own icon files rather than the icons barrel. Tests: drop the two that pinned class names (the text column's flex classes and the btn-tertiary weight) and fold the copy and share presses on a row into one test. --- .../components/brief/BriefListItem.spec.tsx | 39 +++++-------------- .../src/components/brief/BriefListItem.tsx | 8 ++-- .../brief/BriefPostHeaderActions.spec.tsx | 21 ++-------- .../post/brief/BriefPostHeaderActions.tsx | 7 ++-- packages/shared/src/lib/featureManagement.ts | 6 +-- 5 files changed, 24 insertions(+), 57 deletions(-) diff --git a/packages/shared/src/components/brief/BriefListItem.spec.tsx b/packages/shared/src/components/brief/BriefListItem.spec.tsx index 34082474069..8d8361e5ac5 100644 --- a/packages/shared/src/components/brief/BriefListItem.spec.tsx +++ b/packages/shared/src/components/brief/BriefListItem.spec.tsx @@ -32,8 +32,11 @@ jest.mock('../../hooks/useSharePost', () => ({ }), })); -jest.mock('../../features/snapshot/useSharePlacement', () => ({ - useSharePlacement: () => mockWithShareControls, +jest.mock('../../hooks/useConditionalFeature', () => ({ + useConditionalFeature: () => ({ + value: mockWithShareControls, + isLoading: false, + }), })); const post = { @@ -107,7 +110,7 @@ describe('BriefListItem', () => { expect(mockLogEvent).toHaveBeenCalledTimes(1); }); - it('renders no share controls while the placement is off', () => { + it('renders no share controls while the flag is off', () => { renderComponent(); expect( @@ -118,41 +121,17 @@ describe('BriefListItem', () => { ).not.toBeInTheDocument(); }); - it('keeps the share controls inside the card', () => { - mockWithShareControls = true; - renderComponent(); - - const button = screen.getByRole('button', { name: 'Copy link' }); - const article = button.closest('article'); - const column = article?.querySelector('div.flex.flex-col'); - - // `w-full` on the text column pushes the control past the card border. - expect(column).not.toHaveClass('w-full'); - expect(column).toHaveClass('min-w-0', 'flex-1'); - expect(article).toContainElement(button); - }); - - it('copies the brief link without opening the brief', () => { + it('copies and shares the brief link without opening the brief', () => { mockWithShareControls = true; const onClick = jest.fn(); renderComponent(onClick); fireEvent.click(screen.getByRole('button', { name: 'Copy link' })); - - expect(mockCopyLink).toHaveBeenCalledWith({ post }); - expect(onClick).not.toHaveBeenCalled(); - expect(mockOnPostClick).not.toHaveBeenCalled(); - }); - - it('opens the share surface without opening the brief', () => { - mockWithShareControls = true; - const onClick = jest.fn(); - renderComponent(onClick); - fireEvent.click(screen.getByRole('button', { name: 'Share briefing' })); + expect(mockCopyLink).toHaveBeenCalledWith({ post }); expect(mockOpenSharePost).toHaveBeenCalledWith({ post }); - expect(mockCopyLink).not.toHaveBeenCalled(); expect(onClick).not.toHaveBeenCalled(); + expect(mockOnPostClick).not.toHaveBeenCalled(); }); }); diff --git a/packages/shared/src/components/brief/BriefListItem.tsx b/packages/shared/src/components/brief/BriefListItem.tsx index 01fce4ea676..c0a245c0138 100644 --- a/packages/shared/src/components/brief/BriefListItem.tsx +++ b/packages/shared/src/components/brief/BriefListItem.tsx @@ -10,7 +10,9 @@ import { import type { PillProps } from '../Pill'; import { Pill } from '../Pill'; import { IconSize } from '../Icon'; -import { BriefGradientIcon, LinkIcon, LockIcon, ShareIcon } from '../icons'; +import { BriefGradientIcon, LockIcon } from '../icons'; +import { LinkIcon } from '../icons/Link'; +import { ShareIcon } from '../icons/Share'; import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; import { Tooltip } from '../tooltip/Tooltip'; import type { Origin, TargetId } from '../../lib/log'; @@ -27,7 +29,7 @@ import { usePlusSubscription } from '../../hooks/usePlusSubscription'; import { useSharePost } from '../../hooks/useSharePost'; import { CopyStateIcon } from '../share/CopyStateIcon'; import { featureBriefingShareControls } from '../../lib/featureManagement'; -import { useSharePlacement } from '../../features/snapshot/useSharePlacement'; +import { useConditionalFeature } from '../../hooks/useConditionalFeature'; export type BriefListItemProps = { className?: string; @@ -62,7 +64,7 @@ export const BriefListItem = ({ const { logEvent } = useLogContext(); const onPostClick = useOnPostClick({ origin }); const { copyLink, isCopying, openSharePost } = useSharePost(origin); - const withShareControls = useSharePlacement({ + const { value: withShareControls } = useConditionalFeature({ feature: featureBriefingShareControls, }); diff --git a/packages/shared/src/components/post/brief/BriefPostHeaderActions.spec.tsx b/packages/shared/src/components/post/brief/BriefPostHeaderActions.spec.tsx index bcc75bedee8..4365d75ce06 100644 --- a/packages/shared/src/components/post/brief/BriefPostHeaderActions.spec.tsx +++ b/packages/shared/src/components/post/brief/BriefPostHeaderActions.spec.tsx @@ -17,8 +17,8 @@ jest.mock('../../../hooks/useSharePost', () => ({ }), })); -jest.mock('../../../features/snapshot/useSharePlacement', () => ({ - useSharePlacement: () => mockAtEveryWidth, +jest.mock('../../../hooks/useConditionalFeature', () => ({ + useConditionalFeature: () => ({ value: mockAtEveryWidth, isLoading: false }), })); const post = { id: 'brief-1', slug: 'brief-1' } as Post; @@ -41,7 +41,7 @@ describe('BriefPostHeaderActions', () => { mockAtEveryWidth = false; }); - it('keeps the cluster desktop-only while the placement is off', () => { + it('keeps the cluster desktop-only while the flag is off', () => { renderComponent(); expect( @@ -52,7 +52,7 @@ describe('BriefPostHeaderActions', () => { ).not.toBeInTheDocument(); }); - it('shows copy link and share at every width when the placement is on', () => { + it('shows copy link and share at every width when the flag is on', () => { mockAtEveryWidth = true; renderComponent(); @@ -66,19 +66,6 @@ describe('BriefPostHeaderActions', () => { ).toBeInTheDocument(); }); - it('draws every control in the cluster at the same weight', () => { - mockAtEveryWidth = true; - renderComponent(); - - const controls = [ - screen.getByRole('button', { name: 'Copy link' }), - screen.getByRole('button', { name: 'Share briefing' }), - screen.getByRole('link'), - ]; - - controls.forEach((control) => expect(control).toHaveClass('btn-tertiary')); - }); - it('copies the brief link and opens the share modal', () => { mockAtEveryWidth = true; renderComponent(); diff --git a/packages/shared/src/components/post/brief/BriefPostHeaderActions.tsx b/packages/shared/src/components/post/brief/BriefPostHeaderActions.tsx index 9f2897ca05d..6da44b63e42 100644 --- a/packages/shared/src/components/post/brief/BriefPostHeaderActions.tsx +++ b/packages/shared/src/components/post/brief/BriefPostHeaderActions.tsx @@ -6,13 +6,14 @@ import type { PostHeaderActionsProps } from '../common'; import Link from '../../utilities/Link'; import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; import { settingsUrl } from '../../../lib/constants'; -import { LinkIcon, SettingsIcon, ShareIcon } from '../../icons'; +import { LinkIcon, SettingsIcon } from '../../icons'; +import { ShareIcon } from '../../icons/Share'; import { Tooltip } from '../../tooltip/Tooltip'; import { useSharePost } from '../../../hooks/useSharePost'; import { CopyStateIcon } from '../../share/CopyStateIcon'; import type { Origin } from '../../../lib/log'; import { featureBriefingShareControls } from '../../../lib/featureManagement'; -import { useSharePlacement } from '../../../features/snapshot/useSharePlacement'; +import { useConditionalFeature } from '../../../hooks/useConditionalFeature'; const Container = classed('div', 'flex flex-row items-center'); @@ -31,7 +32,7 @@ export const BriefPostHeaderActions = ({ showShareButton?: boolean; }): ReactElement => { const { copyLink, isCopying, openSharePost } = useSharePost(origin); - const atEveryWidth = useSharePlacement({ + const { value: atEveryWidth } = useConditionalFeature({ feature: featureBriefingShareControls, shouldEvaluate: showShareButton, }); diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 5a041ab0bc5..36e335f17cc 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -30,10 +30,8 @@ export const featurePostPageHighlights = new Feature( false, ); export const featurePostRedesign = new Feature('post_redesign', false); -// Experiment: share controls on the two briefing surfaces — copy link at every -// width on the briefing (today the whole cluster is `hidden laptop:block`), a -// copy button per row on /briefing, and a share band at the end of the read. -// Default MUST stay `false` — see the rule below. +// Experiment: share controls on the briefing list and the briefing itself. +// Default MUST stay `false`: it is the control. export const featureBriefingShareControls = new Feature( 'briefing_share_controls', false, From 4e424187fe1fad00ca88575de9f8c6255cfc29ce Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:35:12 +0300 Subject: [PATCH 35/40] fix(briefing): rebuild the in-body controls on main's snapshot placements The briefing body carried its own portal machinery, BriefBodyShareControls, which main's ParagraphSnapshotButtons already does, and it had three bugs of its own: - It looked for the blocks once, in an effect keyed on contentHtml. Markdown sanitizes the body a render after mounting, so with the flag already on at first render the effect found an empty body and never ran again; only the preview-host override flipping on a render late hid this. The Must know button now watches the body with a MutationObserver, like ParagraphSnapshotButtons, and a test renders the body late. - The Must know card was mounted with the page, one 1080px card per briefing view. It is armed with useArmedCard now. - None of the in-body controls logged anything. The per-block copy wrote the untracked permalink and wrapped useCopyText in a try/catch that could never fire, since useCopyText reports a blocked clipboard itself. The per-block copy of text and link becomes a snapshot of the block, as #6556 did for post paragraphs: ParagraphSnapshotButtons takes a selector (bullets, and paragraphs that are not the body of one) and an origin, and BriefBlockCopyButton and getBriefBlocks go. The card list also no longer repeats a claim that appears twice, which would have collided as a React key in ListSnapshotCard. Every placement logs SharePost under its own origin: SelectionSnapshotBar takes an origin prop (brief text selection), then brief paragraph, brief must know, and end of briefing for the closing band, which no longer shared the header's host origin. The flag is evaluated once in BriefPostContent, which renders the selection bar, block snapshots, Must know snapshot and band together, so the components themselves no longer read it. The band says "Share this briefing": a recipient of the link reads it too, and it is not theirs. --- .../post/brief/BriefPostContent.tsx | 38 ++-- .../features/briefing/briefBodyBlocks.spec.ts | 38 ++-- .../src/features/briefing/briefBodyBlocks.ts | 49 ++---- .../components/BriefBlockCopyButton.tsx | 61 ------- .../BriefBodyShareControls.spec.tsx | 121 ------------- .../components/BriefBodyShareControls.tsx | 162 ------------------ .../BriefMustKnowSnapshotButton.spec.tsx | 134 +++++++++++++++ .../BriefMustKnowSnapshotButton.tsx | 133 ++++++++++++++ .../components/BriefShareBand.spec.tsx | 78 +++++---- .../briefing/components/BriefShareBand.tsx | 36 +--- .../snapshot/ParagraphSnapshotButtons.tsx | 16 +- .../snapshot/SelectionSnapshotBar.tsx | 13 +- packages/shared/src/lib/log.ts | 4 + 13 files changed, 398 insertions(+), 485 deletions(-) delete mode 100644 packages/shared/src/features/briefing/components/BriefBlockCopyButton.tsx delete mode 100644 packages/shared/src/features/briefing/components/BriefBodyShareControls.spec.tsx delete mode 100644 packages/shared/src/features/briefing/components/BriefBodyShareControls.tsx create mode 100644 packages/shared/src/features/briefing/components/BriefMustKnowSnapshotButton.spec.tsx create mode 100644 packages/shared/src/features/briefing/components/BriefMustKnowSnapshotButton.tsx diff --git a/packages/shared/src/components/post/brief/BriefPostContent.tsx b/packages/shared/src/components/post/brief/BriefPostContent.tsx index ac92e71bfa6..f46a8b0241f 100644 --- a/packages/shared/src/components/post/brief/BriefPostContent.tsx +++ b/packages/shared/src/components/post/brief/BriefPostContent.tsx @@ -45,7 +45,7 @@ import { ButtonSize, ButtonVariant, } from '../../buttons/Button'; -import { LogEvent, TargetId } from '../../../lib/log'; +import { LogEvent, Origin, TargetId } from '../../../lib/log'; import { featureBriefingShareControls, featurePlusCtaCopy, @@ -69,9 +69,10 @@ import Link from '../../utilities/Link'; import { ActionType } from '../../../graphql/actions'; import { BriefUpgradeAlert } from '../../../features/briefing/components/BriefUpgradeAlert'; import { BriefShareBand } from '../../../features/briefing/components/BriefShareBand'; -import { BriefBodyShareControls } from '../../../features/briefing/components/BriefBodyShareControls'; +import { BriefMustKnowSnapshotButton } from '../../../features/briefing/components/BriefMustKnowSnapshotButton'; +import { BRIEF_BLOCK_SELECTOR } from '../../../features/briefing/briefBodyBlocks'; import { SelectionSnapshotBar } from '../../../features/snapshot/SelectionSnapshotBar'; -import { useSharePlacement } from '../../../features/snapshot/useSharePlacement'; +import { ParagraphSnapshotButtons } from '../../../features/snapshot/ParagraphSnapshotButtons'; import type { BriefPostHeaderProps } from '../../../features/briefing/components/BriefPostHeader'; import { BriefPostHeader } from '../../../features/briefing/components/BriefPostHeader'; import type { NotificationChannel } from '../../../hooks/notifications/useNotificationSettings'; @@ -143,9 +144,7 @@ const BriefPostContentRaw = ({ } = usePersonalizedDigest(); const [digestTimeIndex, setDigestTimeIndex] = useState(8); const briefBodyRef = useRef(null); - // The post page's highlight bar, on the briefing's own flag: one switch - // turns every control on this surface on or off together. - const isSelectionShareEnabled = useSharePlacement({ + const { value: isShareEnabled } = useConditionalFeature({ feature: featureBriefingShareControls, }); @@ -406,15 +405,26 @@ const BriefPostContentRaw = ({
    - {isSelectionShareEnabled && ( - + {isShareEnabled && ( + <> + + + + + )} - - {isNotPlus && (
    { return container; }; -describe('getBriefBlocks', () => { - it('returns every bullet and paragraph in the body', () => { - const blocks = getBriefBlocks(render()); +describe('BRIEF_BLOCK_SELECTOR', () => { + it('matches every bullet and paragraph in the body', () => { + const blocks = render().querySelectorAll(BRIEF_BLOCK_SELECTOR); - expect(blocks).toHaveLength(4); - expect(blocks[0].text).toContain( - 'AI agents are taking over your dev tools', - ); - expect(blocks[2].text).toBe('A paragraph under the second heading.'); + expect(Array.from(blocks, (block) => block.tagName)).toEqual([ + 'LI', + 'LI', + 'P', + 'LI', + ]); }); it('skips a paragraph that only wraps a list item', () => { - const blocks = getBriefBlocks( - render('
    • Wrapped bullet

    '), - ); + const blocks = render( + '
    • Wrapped bullet

    ', + ).querySelectorAll(BRIEF_BLOCK_SELECTOR); - expect(blocks).toHaveLength(1); - expect(blocks[0].node.tagName).toBe('LI'); - }); - - it('drops empty blocks', () => { - expect(getBriefBlocks(render('

    Real

    '))).toHaveLength( - 1, - ); + expect(Array.from(blocks, (block) => block.tagName)).toEqual(['LI']); }); }); @@ -58,13 +52,13 @@ describe('getBriefSection', () => { expect(section?.heading.tagName).toBe('H2'); expect(section?.blocks).toHaveLength(2); - expect(section?.blocks[1].text).toContain('Postgres keeps eating'); + expect(section?.blocks[1]).toContain('Postgres keeps eating'); }); it('stops at the next heading', () => { const section = getBriefSection(render(), 'Worth a look'); - expect(section?.blocks.map((block) => block.text)).toEqual([ + expect(section?.blocks).toEqual([ 'A paragraph under the second heading.', 'A bullet under the second heading.', ]); diff --git a/packages/shared/src/features/briefing/briefBodyBlocks.ts b/packages/shared/src/features/briefing/briefBodyBlocks.ts index 1abcba36f02..db01c6e447e 100644 --- a/packages/shared/src/features/briefing/briefBodyBlocks.ts +++ b/packages/shared/src/features/briefing/briefBodyBlocks.ts @@ -1,36 +1,25 @@ /** - * BriefPostContent renders the body through `` - * — one blob, no per-item nodes — so a control per bullet has nothing to hang - * off in JSX. These read the blocks back out of the rendered DOM, which is also - * the most faithful source: what the reader is actually looking at. + * BriefPostContent renders the body as one `` + * blob, with no per-item nodes, so the share controls read its blocks back out + * of the rendered DOM: what the reader is actually looking at. */ -export interface BriefBlock { - node: HTMLElement; - text: string; -} +/** A bullet, or a paragraph that is not the body of one. */ +export const BRIEF_BLOCK_SELECTOR = 'li, :not(li) > p'; export interface BriefSection { heading: HTMLElement; - blocks: BriefBlock[]; + /** The text of every bullet, or of every block when it has none. */ + blocks: string[]; } -const BLOCK_SELECTOR = 'li, p'; const HEADING_SELECTOR = 'h1, h2, h3'; /* textContent, not innerText: innerText needs layout, which jsdom has none of, - and the collapsed whitespace is what a paste wants anyway. */ -const text = (node: HTMLElement) => + and the collapsed whitespace is what a card wants anyway. */ +const text = (node: Element) => (node.textContent ?? '').replace(/\s+/g, ' ').trim(); -/** Skips paragraphs that only wrap a list item, which would copy twice. */ -export function getBriefBlocks(container: HTMLElement): BriefBlock[] { - return Array.from(container.querySelectorAll(BLOCK_SELECTOR)) - .filter((node) => !(node.tagName === 'P' && node.closest('li'))) - .map((node) => ({ node, text: text(node) })) - .filter((block) => block.text.length > 0); -} - /** * The section a heading opens, up to the next heading of any level. Matching is * on the heading's own text because the backend sends no ids or classes. @@ -49,22 +38,20 @@ export function getBriefSection( return null; } - const blocks: BriefBlock[] = []; + const blocks: string[] = []; let sibling = heading.nextElementSibling; while (sibling && !sibling.matches(HEADING_SELECTOR)) { - if (sibling instanceof HTMLElement) { - const nested = sibling.querySelectorAll('li'); - const nodes = nested.length ? Array.from(nested) : [sibling]; + const nested = sibling.querySelectorAll('li'); + const nodes = nested.length ? Array.from(nested) : [sibling]; - nodes.forEach((node) => { - const value = text(node); + nodes.forEach((node) => { + const value = text(node); - if (value) { - blocks.push({ node, text: value }); - } - }); - } + if (value) { + blocks.push(value); + } + }); sibling = sibling.nextElementSibling; } diff --git a/packages/shared/src/features/briefing/components/BriefBlockCopyButton.tsx b/packages/shared/src/features/briefing/components/BriefBlockCopyButton.tsx deleted file mode 100644 index 86c06361d29..00000000000 --- a/packages/shared/src/features/briefing/components/BriefBlockCopyButton.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import type { ReactElement } from 'react'; -import React, { useCallback } from 'react'; -import { - Button, - ButtonSize, - ButtonVariant, -} from '../../../components/buttons/Button'; -import { Tooltip } from '../../../components/tooltip/Tooltip'; -import { CopyStateIcon } from '../../../components/share/CopyStateIcon'; -import { useCopyText } from '../../../hooks/useCopy'; -import { truncateAtWordBoundary } from '../../../lib/strings'; -import { - ToastType, - useToastNotification, -} from '../../../hooks/useToastNotification'; - -/** Enough of the block to tell two buttons apart, not the whole paragraph. */ -const LABEL_LENGTH = 60; - -/** Copies the block and the brief link, so a paste carries both. */ -export function BriefBlockCopyButton({ - text, - link, -}: { - text: string; - link: string; -}): ReactElement { - const [copied, copy] = useCopyText([text, link].join('\n\n')); - const { displayToast } = useToastNotification(); - // Every bullet carries one of these, so a label that only said "Copy" would - // read as a wall of identical buttons on a screen reader. - const label = `Copy: ${truncateAtWordBoundary(text, LABEL_LENGTH)}`; - - // 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' }); - } catch { - displayToast('❌ Your browser blocked the clipboard', { - variant: ToastType.Error, - }); - } - }, [copy, displayToast]); - - return ( - -
    - ); -}; - -const BriefingScreen = ({ - device, - spot, -}: { - device: DeviceName; - spot: Spot; -}) => ( - -
    - {/* BriefUpgradeAlert — non-Plus only. */} -
    - Upgrade to Plus for a briefing every morning -
    - -
    -
    - - Your Monday briefing - - -
    -

    - Tomer presidential briefing -

    - - - Save 12m of reading - - - 34 posts analyzed - - -
    - -
    - - - 5m read - -
    -
    - {[0, 1, 2, 3, 4, 5].map((i) => ( - - ))} -
    - - 12 Sources - -
    -
    - -
    - {BODY.map(([heading, body]) => ( -
    - - {heading} - - {body} -
    - ))} -
    - - {spot === 'closing' && ( -
    -
    - - Share your briefing - - - Short briefing by @tomer - -
    - -
    - )} -
    -
    -); - -const AllDevices = ({ spot }: { spot: Spot }) => ( - - - - - -); - -/* ------------------------------------------------------- the /briefing list */ - -const BRIEFS = [ - { title: 'Your Monday briefing', pill: 'Just in', read: false, mins: 5 }, - { title: 'Your Sunday briefing', read: true, mins: 4 }, - { title: 'Your Saturday briefing', read: true, mins: 6 }, -]; - -/** - * BriefShareControls from #6353: copy link on the left, then the arrow that - * opens the social surface. One glyph per meaning — the arrow is never a - * one-tap copy. Rendered after the full-bleed CardLink with an explicit - * z-index, or the overlay swallows the clicks. - */ -const RowControls = () => ( -
    -
    -); - -const BriefRow = ({ - brief, - device, -}: { - brief: (typeof BRIEFS)[number]; - device: DeviceName; -}) => ( -
    - {/* BriefGradientIcon — `hidden mobileXL:flex`. */} - {device !== 'Mobile' && ( - - )} -
    -
    - - {brief.title} - - {brief.pill && ( - - {brief.pill} - - )} -
    - - {brief.mins}m read time - {' • '} - Based on 34 posts from 12 sources - -
    - -
    -); - -const BriefListScreen = ({ device }: { device: DeviceName }) => ( - -
    -
    -

    - Presidential briefings -

    - -
    - -
    - Upgrade to Plus for a briefing every morning -
    - -
    - {BRIEFS.map((brief) => ( - - ))} -
    - - - 2025 - -
    -
    -); - -const AllLists = () => ( - - - - - -); - -const Briefing = () => ( - - - - - - - - - - - - - - - - - - - -); - -const meta: Meta = { - title: 'Features/Snapshot/Surfaces/Briefing', - component: Briefing, - parameters: { layout: 'fullscreen' }, -}; - -export default meta; - -export const Variations: StoryObj = {}; diff --git a/packages/storybook/stories/features/snapshot/surfaces/Overview.stories.tsx b/packages/storybook/stories/features/snapshot/surfaces/Overview.stories.tsx index f6ed7e190c9..0acba82cb8c 100644 --- a/packages/storybook/stories/features/snapshot/surfaces/Overview.stories.tsx +++ b/packages/storybook/stories/features/snapshot/surfaces/Overview.stories.tsx @@ -76,8 +76,8 @@ const PAGES: React.ReactNode[][] = [ ], [ 'Briefing', - '#6353', - 'Whole briefing versus per item, plus a closing band at the end of the read', + '#6563', + 'Shipped behind briefing_share_controls: the live briefing is the reference, so it has no mockup here', ], [ 'Profile', From 9127aebbdadb11d96357286fff9d952886822fed Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:50:44 +0300 Subject: [PATCH 37/40] fix(briefing): keep contextMenuId off the header actions' DOM node BriefPostHeaderActions spread every prop it did not name onto its container, so the contextMenuId both callers pass landed on a div and React warned about an unknown DOM attribute. It is dropped the way PostHeaderActions already drops it. --- .../shared/src/components/post/brief/BriefPostHeaderActions.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/shared/src/components/post/brief/BriefPostHeaderActions.tsx b/packages/shared/src/components/post/brief/BriefPostHeaderActions.tsx index 6da44b63e42..9582257b903 100644 --- a/packages/shared/src/components/post/brief/BriefPostHeaderActions.tsx +++ b/packages/shared/src/components/post/brief/BriefPostHeaderActions.tsx @@ -26,6 +26,7 @@ export const BriefPostHeaderActions = ({ isFixedNavigation, origin, showShareButton = false, + contextMenuId: _contextMenuId, ...props }: PostHeaderActionsProps & { origin: Origin; From 1eebef21a468d4aa6e7360b6c48b4337402ac48f Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:08:16 +0300 Subject: [PATCH 38/40] feat(briefing): ship the share controls without a flag The share controls on the briefing list, header and body are permanent now, so briefing_share_controls is gone along with the flag-off paths and the tests that only covered them. The header's Share button shows at every width. The copy link and the settings gear stay laptop-only: below laptop the post page's sticky header and the modal's navigation strip already carry a copy link and the menu with Share via and Settings, so repeating them in the briefing header would put two copy links a few rows apart on phones. The digest page, which has no share button, keeps its laptop-only gear as before. --- .../components/brief/BriefListItem.spec.tsx | 22 ------- .../src/components/brief/BriefListItem.tsx | 64 ++++++++----------- .../post/brief/BriefPostContent.tsx | 44 +++++-------- .../brief/BriefPostHeaderActions.spec.tsx | 42 +++++------- .../post/brief/BriefPostHeaderActions.tsx | 37 ++++------- packages/shared/src/lib/featureManagement.ts | 6 -- .../snapshot/surfaces/Overview.stories.tsx | 2 +- 7 files changed, 74 insertions(+), 143 deletions(-) diff --git a/packages/shared/src/components/brief/BriefListItem.spec.tsx b/packages/shared/src/components/brief/BriefListItem.spec.tsx index 8d8361e5ac5..c01f7b8dde8 100644 --- a/packages/shared/src/components/brief/BriefListItem.spec.tsx +++ b/packages/shared/src/components/brief/BriefListItem.spec.tsx @@ -10,8 +10,6 @@ const mockLogEvent = jest.fn(); const mockCopyLink = jest.fn(); const mockOpenSharePost = jest.fn(); -let mockWithShareControls = false; - jest.mock('../../hooks/useOnPostClick', () => ({ __esModule: true, default: () => mockOnPostClick, @@ -32,13 +30,6 @@ jest.mock('../../hooks/useSharePost', () => ({ }), })); -jest.mock('../../hooks/useConditionalFeature', () => ({ - useConditionalFeature: () => ({ - value: mockWithShareControls, - isLoading: false, - }), -})); - const post = { id: 'brief-1', slug: 'brief-1', @@ -63,7 +54,6 @@ const renderComponent = (onClick = jest.fn()) => describe('BriefListItem', () => { beforeEach(() => { jest.clearAllMocks(); - mockWithShareControls = false; }); it('delegates regular clicks to the parent handler and tracks the click', () => { @@ -110,19 +100,7 @@ describe('BriefListItem', () => { expect(mockLogEvent).toHaveBeenCalledTimes(1); }); - it('renders no share controls while the flag is off', () => { - renderComponent(); - - expect( - screen.queryByRole('button', { name: 'Copy link' }), - ).not.toBeInTheDocument(); - expect( - screen.queryByRole('button', { name: 'Share briefing' }), - ).not.toBeInTheDocument(); - }); - it('copies and shares the brief link without opening the brief', () => { - mockWithShareControls = true; const onClick = jest.fn(); renderComponent(onClick); diff --git a/packages/shared/src/components/brief/BriefListItem.tsx b/packages/shared/src/components/brief/BriefListItem.tsx index c0a245c0138..c3b8d8cfd68 100644 --- a/packages/shared/src/components/brief/BriefListItem.tsx +++ b/packages/shared/src/components/brief/BriefListItem.tsx @@ -28,8 +28,6 @@ import { useLogContext } from '../../contexts/LogContext'; import { usePlusSubscription } from '../../hooks/usePlusSubscription'; import { useSharePost } from '../../hooks/useSharePost'; import { CopyStateIcon } from '../share/CopyStateIcon'; -import { featureBriefingShareControls } from '../../lib/featureManagement'; -import { useConditionalFeature } from '../../hooks/useConditionalFeature'; export type BriefListItemProps = { className?: string; @@ -64,9 +62,6 @@ export const BriefListItem = ({ const { logEvent } = useLogContext(); const onPostClick = useOnPostClick({ origin }); const { copyLink, isCopying, openSharePost } = useSharePost(origin); - const { value: withShareControls } = useConditionalFeature({ - feature: featureBriefingShareControls, - }); const trackBriefClick = () => { onPostClick({ post }); @@ -98,14 +93,9 @@ export const BriefListItem = ({
    -
    + {/* `w-full` would claim the whole card and push the controls past its + border. */} +
    {title} @@ -170,30 +160,28 @@ export const BriefListItem = ({ onAuxClick={(event) => event.button === 1 && trackBriefClick()} /> - {withShareControls && ( - // After the CardLink and above it: the overlay covers the whole row, - // so anything rendered before it never receives the click. -
    - -
    - )} + {/* After the CardLink and above it: the overlay covers the whole row, + so anything rendered before it never receives the click. */} +
    + +
    ); }; diff --git a/packages/shared/src/components/post/brief/BriefPostContent.tsx b/packages/shared/src/components/post/brief/BriefPostContent.tsx index f46a8b0241f..26a91037b1d 100644 --- a/packages/shared/src/components/post/brief/BriefPostContent.tsx +++ b/packages/shared/src/components/post/brief/BriefPostContent.tsx @@ -46,10 +46,7 @@ import { ButtonVariant, } from '../../buttons/Button'; import { LogEvent, Origin, TargetId } from '../../../lib/log'; -import { - featureBriefingShareControls, - featurePlusCtaCopy, -} from '../../../lib/featureManagement'; +import { featurePlusCtaCopy } from '../../../lib/featureManagement'; import { LottieAnimation } from '../../LottieAnimation'; import { briefFeatureList, PlusList } from '../../plus/PlusList'; import { HourDropdown } from '../../fields/HourDropdown'; @@ -144,9 +141,6 @@ const BriefPostContentRaw = ({ } = usePersonalizedDigest(); const [digestTimeIndex, setDigestTimeIndex] = useState(8); const briefBodyRef = useRef(null); - const { value: isShareEnabled } = useConditionalFeature({ - feature: featureBriefingShareControls, - }); const briefDigest = getPersonalizedDigest(UserPersonalizedDigestType.Brief); @@ -405,26 +399,22 @@ const BriefPostContentRaw = ({
    - {isShareEnabled && ( - <> - - - - - - )} + + + + {isNotPlus && (
    ({ useSharePost: () => ({ copyLink: mockCopyLink, @@ -17,17 +15,13 @@ jest.mock('../../../hooks/useSharePost', () => ({ }), })); -jest.mock('../../../hooks/useConditionalFeature', () => ({ - useConditionalFeature: () => ({ value: mockAtEveryWidth, isLoading: false }), -})); - const post = { id: 'brief-1', slug: 'brief-1' } as Post; -const renderComponent = () => +const renderComponent = (showShareButton = true) => render( describe('BriefPostHeaderActions', () => { beforeEach(() => { jest.clearAllMocks(); - mockAtEveryWidth = false; }); - it('keeps the cluster desktop-only while the flag is off', () => { + it('shows share at every width and leaves the copy link to laptop', () => { renderComponent(); expect( - screen.getByRole('button', { name: 'Copy link' }).closest('div'), - ).toHaveClass('hidden', 'laptop:flex'); - expect( - screen.queryByRole('button', { name: 'Share briefing' }), - ).not.toBeInTheDocument(); + screen.getByRole('button', { name: 'Share briefing' }), + ).not.toHaveClass('hidden'); + expect(screen.getByRole('button', { name: 'Copy link' })).toHaveClass( + 'hidden', + 'laptop:flex', + ); }); - it('shows copy link and share at every width when the flag is on', () => { - mockAtEveryWidth = true; - renderComponent(); - - const cluster = screen - .getByRole('button', { name: 'Copy link' }) - .closest('div'); + it('renders no share controls where the share button is off', () => { + renderComponent(false); - expect(cluster).not.toHaveClass('hidden'); expect( - screen.getByRole('button', { name: 'Share briefing' }), - ).toBeInTheDocument(); + screen.queryByRole('button', { name: 'Copy link' }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Share briefing' }), + ).not.toBeInTheDocument(); }); it('copies the brief link and opens the share modal', () => { - mockAtEveryWidth = true; renderComponent(); fireEvent.click(screen.getByRole('button', { name: 'Copy link' })); diff --git a/packages/shared/src/components/post/brief/BriefPostHeaderActions.tsx b/packages/shared/src/components/post/brief/BriefPostHeaderActions.tsx index 9582257b903..e53d36e70f7 100644 --- a/packages/shared/src/components/post/brief/BriefPostHeaderActions.tsx +++ b/packages/shared/src/components/post/brief/BriefPostHeaderActions.tsx @@ -12,8 +12,6 @@ import { Tooltip } from '../../tooltip/Tooltip'; import { useSharePost } from '../../../hooks/useSharePost'; import { CopyStateIcon } from '../../share/CopyStateIcon'; import type { Origin } from '../../../lib/log'; -import { featureBriefingShareControls } from '../../../lib/featureManagement'; -import { useConditionalFeature } from '../../../hooks/useConditionalFeature'; const Container = classed('div', 'flex flex-row items-center'); @@ -33,45 +31,38 @@ export const BriefPostHeaderActions = ({ showShareButton?: boolean; }): ReactElement => { const { copyLink, isCopying, openSharePost } = useSharePost(origin); - const { value: atEveryWidth } = useConditionalFeature({ - feature: featureBriefingShareControls, - shouldEvaluate: showShareButton, - }); return ( -
    + {/* Below laptop the page's own header already has a copy link and a menu + with Settings, so only Share joins it there. */} +
    {showShareButton && ( <>
    ); }; -const renderBody = (html: string) => +const renderBody = (html: string, options?: Options) => render( - + , ); @@ -87,4 +108,57 @@ describe('ParagraphSnapshotButtons', () => { expect(screen.getAllByText(LONG, { exact: true })).toHaveLength(2); expect(screen.getAllByText(second, { exact: true })).toHaveLength(1); }); + + it('captures a link in the paragraph as part of its text', async () => { + renderBody( + `

    ${LONG} Read more

    `, + ); + + fireEvent.pointerEnter(await screen.findByLabelText('Snapshot')); + + expect(screen.getByText(`${LONG} Read more`)).toBeInTheDocument(); + }); + + describe('on a brief', () => { + const renderBrief = () => + renderBody(briefContentHtml, { + ariaLabel: getBriefBlockLabel, + omit: BRIEF_SOURCE_LINK_SELECTOR, + selector: BRIEF_BLOCK_SELECTOR, + }); + + it('leaves the link to the sources out of the bullet', async () => { + renderBrief(); + + const [, bullet] = await screen.findAllByRole('button', { + name: /^Snapshot: /, + }); + fireEvent.pointerEnter(bullet); + + const passage = + 'US intelligence labels Chinese AI distillation a national security threat: A joint advisory accuses six Chinese firms of systematic distillation campaigns against U.S. frontier models. U.S. labs are now being advised to serve subtly degraded responses to suspected distillers to protect their model weights.'; + expect(screen.getByText(passage)).toBeInTheDocument(); + expect( + screen.queryByText(`${passage} Read more`), + ).not.toBeInTheDocument(); + }); + + it('names every button after its block', async () => { + renderBrief(); + + const buttons = await screen.findAllByRole('button', { + name: /^Snapshot: /, + }); + + // The TLDR, three Must know bullets and one Good to know bullet. + expect(buttons).toHaveLength(5); + expect(buttons[1]).toHaveAccessibleName( + 'Snapshot: US intelligence labels Chinese AI distillation a national…', + ); + expect( + new Set(buttons.map((button) => button.getAttribute('aria-label'))) + .size, + ).toBe(5); + }); + }); }); diff --git a/packages/shared/src/features/snapshot/ParagraphSnapshotButtons.tsx b/packages/shared/src/features/snapshot/ParagraphSnapshotButtons.tsx index e3a3be66ea1..decf79d62bf 100644 --- a/packages/shared/src/features/snapshot/ParagraphSnapshotButtons.tsx +++ b/packages/shared/src/features/snapshot/ParagraphSnapshotButtons.tsx @@ -16,13 +16,17 @@ const SLOT_ATTRIBUTE = 'data-paragraph-snapshot'; * renders — into the text the button captures, and into the signature that * decides whether the body has changed. */ -const proseOf = (paragraph: HTMLElement): string => { +const proseOf = (paragraph: HTMLElement, omit?: string): string => { const clone = paragraph.cloneNode(true) as HTMLElement; clone .querySelectorAll(`[${SLOT_ATTRIBUTE}]`) .forEach((slot) => slot.remove()); + if (omit) { + clone.querySelectorAll(omit).forEach((node) => node.remove()); + } + return (clone.textContent ?? '').trim(); }; @@ -40,14 +44,20 @@ export function ParagraphSnapshotButtons({ containerRef, post, selector = 'p', + omit, origin = Origin.PostParagraph, + ariaLabel, }: { containerRef: RefObject; post: Post; /** Which blocks of the body get a control. */ selector?: string; + /** What a block renders beyond its passage, like a trailing link. */ + omit?: string; /** Which surface the body is on, for the snapshot's share event. */ origin?: Origin; + /** Names each control after its passage, where the shared label repeats. */ + ariaLabel?: (passage: string) => string; }): ReactElement | null { const [slots, setSlots] = useState<{ node: HTMLElement; text: string }[]>([]); // The observer fires on the spans this appends, so a signature guards the @@ -63,8 +73,10 @@ export function ParagraphSnapshotButtons({ const paragraphs = Array.from( container.querySelectorAll(selector), - ).filter((paragraph) => proseOf(paragraph).length >= MIN_LENGTH); - const nextSignature = paragraphs.map(proseOf).join(' '); + ).filter((paragraph) => proseOf(paragraph, omit).length >= MIN_LENGTH); + const nextSignature = paragraphs + .map((paragraph) => proseOf(paragraph, omit)) + .join(' '); if (nextSignature === signature.current) { return; @@ -73,7 +85,7 @@ export function ParagraphSnapshotButtons({ signature.current = nextSignature; setSlots( paragraphs.map((paragraph) => { - const text = proseOf(paragraph); + const text = proseOf(paragraph, omit); const existing = paragraph.querySelector( `[${SLOT_ATTRIBUTE}]`, ); @@ -89,7 +101,7 @@ export function ParagraphSnapshotButtons({ return { node: slot, text }; }), ); - }, [containerRef, selector]); + }, [containerRef, omit, selector]); useEffect(() => { const container = containerRef.current; @@ -117,6 +129,7 @@ export function ParagraphSnapshotButtons({ {slots.map(({ node, text }) => createPortal( (null); const { isArmed, armProps } = useArmedCard(); @@ -59,6 +61,7 @@ export function TextSnapshotButton({ wrapping is cheaper than widening its props for one caller. */} getSnapshotCaptureOptions(cardRef.current)} className={className} filename={filename} From d671f8af621b55526af2caf07d0acf28356b0d4c Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:47:40 +0300 Subject: [PATCH 40/40] fix(briefing): name a bullet's snapshot after its claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the preview the block names ran through the colon that ends a bullet's claim, so several read "Snapshot: Shopify acquires Tailwind Labs to anchor the CSS framework:…". The name now takes the claim, as the Must know card does, and a cut drops the punctuation it would end on before the ellipsis. --- .../features/briefing/briefBodyBlocks.spec.ts | 16 +++++++++++----- .../src/features/briefing/briefBodyBlocks.ts | 12 +++++++----- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/shared/src/features/briefing/briefBodyBlocks.spec.ts b/packages/shared/src/features/briefing/briefBodyBlocks.spec.ts index 5222801e119..a74e6a7b47d 100644 --- a/packages/shared/src/features/briefing/briefBodyBlocks.spec.ts +++ b/packages/shared/src/features/briefing/briefBodyBlocks.spec.ts @@ -92,18 +92,24 @@ describe('getBriefSection', () => { }); describe('getBriefBlockLabel', () => { - it('names the button after the opening of its block', () => { + it('names a bullet after its claim', () => { expect( getBriefBlockLabel( - 'US intelligence labels Chinese AI distillation a national security threat: A joint advisory', + 'Shopify acquires Tailwind Labs to anchor the CSS framework: Tailwind CSS creator Adam Wathan announced', ), ).toBe( - 'Snapshot: US intelligence labels Chinese AI distillation a national…', + 'Snapshot: Shopify acquires Tailwind Labs to anchor the CSS framework', ); }); - it('keeps a short block whole', () => { - expect(getBriefBlockLabel('Short block')).toBe('Snapshot: Short block'); + it('cuts a long opening at a word, without the punctuation it ends on', () => { + expect( + getBriefBlockLabel( + 'The U.S. government now officially accuses Chinese AI labs, of industrial-scale model distillation.', + ), + ).toBe( + 'Snapshot: The U.S. government now officially accuses Chinese AI labs…', + ); }); }); diff --git a/packages/shared/src/features/briefing/briefBodyBlocks.ts b/packages/shared/src/features/briefing/briefBodyBlocks.ts index 620a0b64bfa..116a91a84cf 100644 --- a/packages/shared/src/features/briefing/briefBodyBlocks.ts +++ b/packages/shared/src/features/briefing/briefBodyBlocks.ts @@ -99,12 +99,14 @@ export function splitBriefBullet(value: string): string { /** * Every block has its own snapshot, so "Snapshot" alone repeats a dozen times - * down the brief. The opening of the block tells a screen reader which one - * each button captures. + * down the brief. The bullet's claim, or the opening of a paragraph, tells a + * screen reader which one each button captures. */ export function getBriefBlockLabel(passage: string): string { - return `${SNAPSHOT_LABEL}: ${truncateAtWord( - passage.replace(/\s+/g, ' '), + const excerpt = truncateAtWord( + splitBriefBullet(passage.replace(/\s+/g, ' ')), LABEL_EXCERPT_LENGTH, - )}`; + ).replace(/[\s,.:;]+…$/, '…'); + + return `${SNAPSHOT_LABEL}: ${excerpt}`; }