From 039f0f7f9b2f3d7b6d9dd02cf961a045dd640157 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Wed, 2 Sep 2026 17:47:01 +0300 Subject: [PATCH 1/3] docs(snapshot): document hot take and reading history share placements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds one Storybook page — Features / Snapshot / Surfaces / Hot takes & history — recording where a share control belongs on the two hot-take frames (the swipe modal, the profile list) and on reading history. Each surface is drawn at desktop, tablet and mobile, with what ships today beside the placement the page argues for. No production surface changes. The controls are inert: the page compares placement, not behaviour. SnapshotIcon comes with it because the page draws it and it is not on main yet. Mockup-to-eng-pass: 1 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 + .../features/snapshot/surfaceChrome.tsx | 217 ++++++++++ .../surfaces/HotTakesAndHistory.stories.tsx | 392 ++++++++++++++++++ 6 files changed, 644 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/surfaceChrome.tsx create mode 100644 packages/storybook/stories/features/snapshot/surfaces/HotTakesAndHistory.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/surfaceChrome.tsx b/packages/storybook/stories/features/snapshot/surfaceChrome.tsx new file mode 100644 index 00000000000..b6787bff0a6 --- /dev/null +++ b/packages/storybook/stories/features/snapshot/surfaceChrome.tsx @@ -0,0 +1,217 @@ +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'; + +type LeadAction = 'Link' | 'Snapshot'; + +export const AVATAR = + 'https://res.cloudinary.com/daily-now/image/upload/s--O0TOmw4y--/f_auto/v1715772965/public/noProfile'; + +/* ------------------------------------------------------------------ prose */ + +const H1 = ({ children }: { children: React.ReactNode }) => ( +

{children}

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

{children}

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

+ {children} +

+); + +/* ---------------------------------------------------------------- controls */ + +const ICONS: Record = { + Link: , + Snapshot: , +}; + +const LABELS: Record = { + Link: 'Copy link', + Snapshot: 'Snapshot', +}; + +/** + * Inert on purpose: the page compares where a control sits inside a real + * screen, not what it does when pressed. + */ +export const Control = ({ + action, + className, + label, + size = ButtonSize.Small, + variant = ButtonVariant.Tertiary, +}: { + action: LeadAction; + className?: string; + label?: boolean; + size?: ButtonSize; + variant?: ButtonVariant; +}) => ( + +); + +/* ---------------------------------------------------------- page furniture */ + +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} + + ); + })} +
+); + +export type DeviceName = 'Desktop' | 'Tablet' | 'Mobile'; + +/** A control that only works at one of these widths is not a recommendation. */ +const DEVICES: Record = { + 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, +}: { + name: DeviceName; + children: React.ReactNode; +}) => ( +
+ + {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, + children, +}: { + step: string; + headline: string; + note: string; + children: React.ReactNode; +}) => ( + // Full width so a device rail can scroll across the whole canvas. +
+
+ + {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} +
+); diff --git a/packages/storybook/stories/features/snapshot/surfaces/HotTakesAndHistory.stories.tsx b/packages/storybook/stories/features/snapshot/surfaces/HotTakesAndHistory.stories.tsx new file mode 100644 index 00000000000..c44892c61cd --- /dev/null +++ b/packages/storybook/stories/features/snapshot/surfaces/HotTakesAndHistory.stories.tsx @@ -0,0 +1,392 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + DownvoteIcon, + HotIcon, + MenuIcon, + MiniCloseIcon, + ReputationIcon, + UpvoteIcon, +} from '@dailydotdev/shared/src/components/icons'; +import type { DeviceName } from '../surfaceChrome'; +import { + AVATAR, + Category, + Control, + Device, + OverflowMenu, + Rail, + SurfacePage, + Variant, +} from '../surfaceChrome'; + +/** What ships today, against the placement this page argues for. */ +type Placement = 'today' | 'chosen'; + +type ScreenProps = { device: DeviceName; placement: Placement }; + +const DEVICE_ORDER: DeviceName[] = ['Desktop', 'Tablet', 'Mobile']; + +const Rails = ({ + screen: Screen, + placement, +}: { + screen: React.ComponentType; + placement: Placement; +}) => ( + + {DEVICE_ORDER.map((device) => ( + + ))} + +); + +/* --------------------------------------------------------- the swipe modal */ + +const REACTIONS = [ + { glyph: '❄️', label: 'Cold take - downvote', className: '!size-14' }, + { glyph: '😐', label: 'Skip hot take', className: '!size-12' }, + { glyph: '🔥', label: 'Hot take - upvote', className: '!size-14' }, +]; + +const HotTakeModalScreen = ({ device, placement }: ScreenProps) => ( + +
+
+ + Hot Takes + +
+ +
+
+
+
+ 😐 +
+ + Most developers have a talent for turning simple problems into + overengineered nightmares. + + + “Simplicity is prerequisite for reliability” - Edsger + W. Dijkstra + +
+ + + + 587 + + + +
+
+ +
+ +
+ + + James Davis + + + @jamesdavis7 + + + + + 11.4K + +
+
+
+
+ +
+ {REACTIONS.map(({ glyph, label, className }) => ( +
+ +
+ +
+
+
+); + +/* -------------------------------------------------------- the profile list */ + +const TAKES = [ + { + emoji: '🔥', + title: 'Microservices were a mistake for most teams', + subtitle: 'Distributed systems are a tax, not a feature', + upvotes: 128, + }, + { + emoji: '🧊', + title: 'Code review is mostly theatre', + subtitle: 'Two approvals, forty seconds of reading', + upvotes: 64, + }, +]; + +const HotTakeRow = ({ + take, + placement, +}: { + take: (typeof TAKES)[number]; + placement: Placement; +}) => ( +
+
+ {take.emoji} +
+
+ + {take.title} + + {take.subtitle} +
+
+ {placement === 'chosen' && ( + + )} + +
+
+); + +const HotTakeListScreen = ({ device, placement }: ScreenProps) => ( + +
+ Hot takes + {TAKES.map((take) => ( + + ))} +
+
+); + +/* -------------------------------------------------------- reading history */ + +const HISTORY = [ + 'Why iconic tech brands lost their dominance', + 'The case against microservices', + 'Postgres is all you need, again', +]; + +const HistoryRow = ({ + title, + device, + placement, + menuOpen, +}: { + title: string; + device: DeviceName; + placement: Placement; + menuOpen?: boolean; +}) => ( +
+
+
+ +
+
+

+ {title} +

+ + 4 min read · 128 upvotes + +
+
+ {device === 'Desktop' && ( + <> +
+
+
+); + +const HistoryScreen = ({ device, placement }: ScreenProps) => ( + +
+ + Reading history + + {HISTORY.map((title, index) => ( + + ))} +
+
+); + +/* -------------------------------------------------------------------- page */ + +const HotTakesAndHistory = () => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + +const meta: Meta = { + title: 'Features/Snapshot/Surfaces/Hot takes & history', + component: HotTakesAndHistory, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +export const Variations: StoryObj = {}; From 0a096852f63efc0c569f9e69dcb25c2cf37efe0f Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Thu, 3 Sep 2026 12:50:38 +0300 Subject: [PATCH 2/3] docs(snapshot): cut the page to the placements it argues for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each of the three surfaces was drawn twice, before and after. A review page for a change should show the change, so the before halves go: the modal's Float snapshot, the profile list with no share route, and the history row's ⋯ menu. The placement prop that switched between them, and the menu furniture only the history row needed, go with them. Co-Authored-By: Claude Opus 5 --- .../features/snapshot/surfaceChrome.tsx | 36 ------ .../surfaces/HotTakesAndHistory.stories.tsx | 120 +++++------------- 2 files changed, 31 insertions(+), 125 deletions(-) diff --git a/packages/storybook/stories/features/snapshot/surfaceChrome.tsx b/packages/storybook/stories/features/snapshot/surfaceChrome.tsx index b6787bff0a6..efb1f8eb33e 100644 --- a/packages/storybook/stories/features/snapshot/surfaceChrome.tsx +++ b/packages/storybook/stories/features/snapshot/surfaceChrome.tsx @@ -6,7 +6,6 @@ import { } from '@dailydotdev/shared/src/components/buttons/Button'; import { LinkIcon, - ShareIcon, SnapshotIcon, } from '@dailydotdev/shared/src/components/icons'; @@ -73,41 +72,6 @@ export const Control = ({ /* ---------------------------------------------------------- page furniture */ -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} - - ); - })} -
-); - export type DeviceName = 'Desktop' | 'Tablet' | 'Mobile'; /** A control that only works at one of these widths is not a recommendation. */ diff --git a/packages/storybook/stories/features/snapshot/surfaces/HotTakesAndHistory.stories.tsx b/packages/storybook/stories/features/snapshot/surfaces/HotTakesAndHistory.stories.tsx index c44892c61cd..abaa2f31895 100644 --- a/packages/storybook/stories/features/snapshot/surfaces/HotTakesAndHistory.stories.tsx +++ b/packages/storybook/stories/features/snapshot/surfaces/HotTakesAndHistory.stories.tsx @@ -19,29 +19,23 @@ import { Category, Control, Device, - OverflowMenu, Rail, SurfacePage, Variant, } from '../surfaceChrome'; -/** What ships today, against the placement this page argues for. */ -type Placement = 'today' | 'chosen'; - -type ScreenProps = { device: DeviceName; placement: Placement }; +type ScreenProps = { device: DeviceName }; const DEVICE_ORDER: DeviceName[] = ['Desktop', 'Tablet', 'Mobile']; const Rails = ({ screen: Screen, - placement, }: { screen: React.ComponentType; - placement: Placement; }) => ( {DEVICE_ORDER.map((device) => ( - + ))} ); @@ -54,7 +48,7 @@ const REACTIONS = [ { glyph: '🔥', label: 'Hot take - upvote', className: '!size-14' }, ]; -const HotTakeModalScreen = ({ device, placement }: ScreenProps) => ( +const HotTakeModalScreen = ({ device }: ScreenProps) => (
@@ -92,12 +86,8 @@ const HotTakeModalScreen = ({ device, placement }: ScreenProps) => (
@@ -173,13 +163,7 @@ const TAKES = [ }, ]; -const HotTakeRow = ({ - take, - placement, -}: { - take: (typeof TAKES)[number]; - placement: Placement; -}) => ( +const HotTakeRow = ({ take }: { take: (typeof TAKES)[number] }) => (
{take.emoji} @@ -191,9 +175,7 @@ const HotTakeRow = ({ {take.subtitle}
- {placement === 'chosen' && ( - - )} +
); -const HotTakeListScreen = ({ device, placement }: ScreenProps) => ( +const HotTakeListScreen = ({ device }: ScreenProps) => (
Hot takes {TAKES.map((take) => ( - + ))}
@@ -227,13 +209,9 @@ const HISTORY = [ const HistoryRow = ({ title, device, - placement, - menuOpen, }: { title: string; device: DeviceName; - placement: Placement; - menuOpen?: boolean; }) => (
@@ -269,40 +247,25 @@ const HistoryRow = ({ /> )} - {placement === 'chosen' && } -
-
+ +
); -const HistoryScreen = ({ device, placement }: ScreenProps) => ( +const HistoryScreen = ({ device }: ScreenProps) => (
Reading history - {HISTORY.map((title, index) => ( - + {HISTORY.map((title) => ( + ))}
@@ -319,63 +282,42 @@ const HotTakesAndHistory = () => ( - - - - + - - - - + - - - - + From 802cad4cf36de9581f93129b9b31af347975acd6 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Thu, 3 Sep 2026 16:15:55 +0300 Subject: [PATCH 3/3] feat(history): copy a post link from the reading history row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reading-history row is a pointer back to a post, so copying its link is the share that fits it. The control sits before the ⋯ menu, icon-only because the row already drops its vote buttons below laptop, and always visible rather than hover-gated so it survives touch. CopyStateIcon confirms on the button itself: the copy glyph and a green check share one grid cell, so nothing beside them moves during the swap. Gated on showCopyLink, which only the history list passes, so the reading-history modal keeps the DOM it has. ReadingHistoryList came into the strict-typecheck guard's scope with this change, which surfaced an untyped reduce accumulator and an unguarded Date built from an optional field. Both are typed now, with no change to what runs. Co-Authored-By: Claude Opus 5 --- .../history/ReadingHistory.spec.tsx | 22 ++++++- .../components/history/ReadingHistoryList.tsx | 58 ++++++++++--------- .../src/components/post/PostItemCard.tsx | 18 ++++++ .../src/components/share/CopyStateIcon.tsx | 46 +++++++++++++++ 4 files changed, 117 insertions(+), 27 deletions(-) create mode 100644 packages/shared/src/components/share/CopyStateIcon.tsx diff --git a/packages/shared/src/components/history/ReadingHistory.spec.tsx b/packages/shared/src/components/history/ReadingHistory.spec.tsx index d25c8bee114..de835c2cf3d 100644 --- a/packages/shared/src/components/history/ReadingHistory.spec.tsx +++ b/packages/shared/src/components/history/ReadingHistory.spec.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { subDays } from 'date-fns'; import type { RenderResult } from '@testing-library/react'; -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import nock from 'nock'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { PostItemCardProps } from '../post/PostItemCard'; @@ -199,6 +199,26 @@ describe('PostItemCard component', () => { ); }); + it('should copy the post link and confirm on the button itself', async () => { + const writeText = jest.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + + renderCard({ showCopyLink: true }); + + fireEvent.click(await screen.findByLabelText('Copy link')); + + await waitFor(() => + expect(writeText).toHaveBeenCalledWith(post.commentsPermalink), + ); + await screen.findByLabelText('Link copied'); + }); + + it('should not render the copy link button by default', async () => { + renderCard(); + await screen.findByText(postTitle); + expect(screen.queryByLabelText('Copy link')).not.toBeInTheDocument(); + }); + it('should call onHide on close button clicked', async () => { renderCard({ onHide }); const button = (await screen.findAllByRole('button'))[0]; diff --git a/packages/shared/src/components/history/ReadingHistoryList.tsx b/packages/shared/src/components/history/ReadingHistoryList.tsx index cf17c826d9b..befe9b2b9f2 100644 --- a/packages/shared/src/components/history/ReadingHistoryList.tsx +++ b/packages/shared/src/components/history/ReadingHistoryList.tsx @@ -23,37 +23,43 @@ export default function ReadHistoryList({ let currentDate: Date; return data?.pages.map((page, pageIndex) => - page.readHistory.edges.reduce((dom, { node: history }, edgeIndex) => { - const { timestamp } = history; - const date = new Date(timestamp); + page.readHistory.edges.reduce( + (dom, { node: history }, edgeIndex) => { + const { timestamp } = history; + // Optional only because PostItem is shared with surfaces that carry + // no timestamp; every reading-history edge has one. + const date = new Date(timestamp as Date); + + if (!currentDate || !isDateOnlyEqual(currentDate, date)) { + currentDate = date; + dom.push( + , + ); + } + + const indexes = { page: pageIndex, edge: edgeIndex }; - if (!currentDate || !isDateOnlyEqual(currentDate, date)) { - currentDate = date; dom.push( - onHide({ ...params, ...indexes })} + showVoteActions + showCopyLink + logOrigin={Origin.History} />, ); - } - - const indexes = { page: pageIndex, edge: edgeIndex }; - - dom.push( - onHide({ ...params, ...indexes })} - showVoteActions - logOrigin={Origin.History} - />, - ); - return dom; - }, []), + return dom; + }, + [], + ), ); // @NOTE see https://dailydotdev.atlassian.net/l/cp/dK9h1zoM // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/packages/shared/src/components/post/PostItemCard.tsx b/packages/shared/src/components/post/PostItemCard.tsx index 4a49c05a0ed..ac148a74857 100644 --- a/packages/shared/src/components/post/PostItemCard.tsx +++ b/packages/shared/src/components/post/PostItemCard.tsx @@ -24,6 +24,8 @@ import { isSourceUserSource } from '../../graphql/sources'; import { ReadingHistoryOptionsMenu } from '../history/ReadingHistoryOptionsMenu'; import type { QueryIndexes } from '../../hooks/useReadingHistory'; +import { useCopyPostLink } from '../../hooks/useCopyPostLink'; +import { CopyStateIcon } from '../share/CopyStateIcon'; export interface PostItemCardProps { className?: string; @@ -32,6 +34,7 @@ export interface PostItemCardProps { clickable?: boolean; onHide?: (params: HidePostItemCardProps) => Promise; showVoteActions?: boolean; + showCopyLink?: boolean; logOrigin?: Origin; indexes?: QueryIndexes; } @@ -48,6 +51,7 @@ export default function PostItemCard({ onHide, className, showVoteActions = false, + showCopyLink = false, logOrigin = Origin.Feed, indexes, }: PostItemCardProps): ReactElement { @@ -66,6 +70,7 @@ export default function PostItemCard({ const isUserSource = isSourceUserSource(source); const { toggleUpvote, toggleDownvote } = useReadHistoryVotePost(); + const [copying, copyLink] = useCopyPostLink(post.commentsPermalink); const classes = classNames( 'relative flex w-full flex-row py-3 pl-9 pr-5', @@ -185,6 +190,19 @@ export default function PostItemCard({ onClick={onHideClick} /> )} + {showButtons && showCopyLink && ( +