From 90965cb4c4e6e0c840b87c63d390e2af2fe03f6d Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 8 Oct 2026 15:48:11 +0300 Subject: [PATCH 1/5] =?UTF-8?q?feat(cards):=20card=5Fsave=5Fon=5Fhover=20e?= =?UTF-8?q?xperiment=20=E2=80=94=20bookmark=20in=20the=20card=20header,=20?= =?UTF-8?q?bigger=20bar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behind `card_save_on_hover` (default off, logged-in users only). Treatment, grid cards only (article, share, video, post, poll, collection, X post): - The bookmark moves to the card header, right before ⋯. Like Read post and ⋯ it is hidden at rest on desktop and shown on hover; it is always visible on touch. - The bar is the existing v2 bar at `compact` density: today's order (upvote, comment, downvote, copy link, impressions) at 32px with 20px icons, in the same 36px row, so cards keep their height. - `ActionButtonsV2` gains `density` and `bookmarkInHeader`; no new bar. List and signal cards are not part of the experiment and stay at control. Wide featured cards have no header slot and keep the bookmark in the bar. Run the experiment in a GrowthBook namespace exclusive with `engagement_bar_v2`. Also fixes four pre-existing strict-mode errors in SocialTwitterGrid that the changed-files guard surfaces. Co-Authored-By: Claude Opus 5.5 --- .../cards/Freeform/FreeformGrid.tsx | 12 + .../components/cards/article/ArticleGrid.tsx | 12 + .../cards/collection/CollectionCardHeader.tsx | 6 +- .../cards/collection/CollectionGrid.tsx | 16 +- .../components/cards/common/ActionButtons.tsx | 29 ++- .../cards/common/ActionButtons.v2.tsx | 55 +++-- .../cards/common/CardHeaderBookmark.tsx | 51 +++++ .../cards/common/PostCardHeader.tsx | 4 + .../cards/common/SquadPostCardHeader.tsx | 11 +- .../cards/common/saveOnHover.spec.tsx | 198 ++++++++++++++++ .../src/components/cards/poll/PollGrid.tsx | 12 + .../src/components/cards/share/ShareGrid.tsx | 12 + .../cards/socialTwitter/SocialTwitterGrid.tsx | 18 +- .../src/hooks/cards/useCardSaveOnHover.ts | 17 ++ packages/shared/src/lib/featureManagement.ts | 5 + .../experiments/CardSaveOnHover.stories.tsx | 211 ++++++++++++++++++ 16 files changed, 640 insertions(+), 29 deletions(-) create mode 100644 packages/shared/src/components/cards/common/CardHeaderBookmark.tsx create mode 100644 packages/shared/src/components/cards/common/saveOnHover.spec.tsx create mode 100644 packages/shared/src/hooks/cards/useCardSaveOnHover.ts create mode 100644 packages/storybook/stories/experiments/CardSaveOnHover.stories.tsx diff --git a/packages/shared/src/components/cards/Freeform/FreeformGrid.tsx b/packages/shared/src/components/cards/Freeform/FreeformGrid.tsx index fdcbe2cc6b6..c6cf3a03f12 100644 --- a/packages/shared/src/components/cards/Freeform/FreeformGrid.tsx +++ b/packages/shared/src/components/cards/Freeform/FreeformGrid.tsx @@ -19,6 +19,8 @@ import { ClickbaitShield } from '../common/ClickbaitShield'; import PostTags from '../common/PostTags'; import { useSmartTitle } from '../../../hooks/post/useSmartTitle'; import { useHiddenFeedbackPanel } from '../../../hooks/post/useHiddenFeedbackPanel'; +import { useCardSaveOnHover } from '../../../hooks/cards/useCardSaveOnHover'; +import { CardHeaderBookmark } from '../common/CardHeaderBookmark'; export const FreeformGrid = forwardRef(function SharePostCard( { @@ -42,6 +44,7 @@ export const FreeformGrid = forwardRef(function SharePostCard( const containerRef = useRef(); const image = usePostImage(post); const { title } = useSmartTitle(post); + const saveOnHover = useCardSaveOnHover(); const { isHidden, content: hiddenPanel } = useHiddenFeedbackPanel(post); if (isHidden) { @@ -79,6 +82,14 @@ export const FreeformGrid = forwardRef(function SharePostCard( + ) : undefined + } /> {title} @@ -111,6 +122,7 @@ export const FreeformGrid = forwardRef(function SharePostCard( onCommentClick={onCommentClick} onCopyLinkClick={onCopyLinkClick} onBookmarkClick={onBookmarkClick} + bookmarkInHeader={saveOnHover} className="mt-auto" onDownvoteClick={onDownvoteClick} /> diff --git a/packages/shared/src/components/cards/article/ArticleGrid.tsx b/packages/shared/src/components/cards/article/ArticleGrid.tsx index 4abb8fcd9c2..b0b9dd1ec7f 100644 --- a/packages/shared/src/components/cards/article/ArticleGrid.tsx +++ b/packages/shared/src/components/cards/article/ArticleGrid.tsx @@ -26,6 +26,8 @@ import ActionButtons from '../common/ActionButtons'; import { FeedbackGrid } from './feedback/FeedbackGrid'; import { ClickbaitShield } from '../common/ClickbaitShield'; import { useSmartTitle } from '../../../hooks/post/useSmartTitle'; +import { useCardSaveOnHover } from '../../../hooks/cards/useCardSaveOnHover'; +import { CardHeaderBookmark } from '../common/CardHeaderBookmark'; export const ArticleGrid = forwardRef(function ArticleGrid( { @@ -48,6 +50,7 @@ export const ArticleGrid = forwardRef(function ArticleGrid( ): ReactElement { const { className, style } = domProps; const { isHidden, content: hiddenPanel } = useHiddenFeedbackPanel(post); + const saveOnHover = useCardSaveOnHover(); const { data } = useBlockPostPanel(post); const onPostCardClick = () => onPostClick(post); const onPostCardAuxClick = () => onPostAuxClick(post); @@ -130,6 +133,14 @@ export const ArticleGrid = forwardRef(function ArticleGrid( postLink={post.permalink} onReadArticleClick={onReadArticleClick} showFeedback={showFeedback} + bookmark={ + saveOnHover ? ( + + ) : undefined + } /> {title} @@ -168,6 +179,7 @@ export const ArticleGrid = forwardRef(function ArticleGrid( onCommentClick={onCommentClick} onCopyLinkClick={onCopyLinkClick} onBookmarkClick={onBookmarkClick} + bookmarkInHeader={saveOnHover} onDownvoteClick={onDownvoteClick} /> )} diff --git a/packages/shared/src/components/cards/collection/CollectionCardHeader.tsx b/packages/shared/src/components/cards/collection/CollectionCardHeader.tsx index 2338b7ff68b..5f8fb3e988a 100644 --- a/packages/shared/src/components/cards/collection/CollectionCardHeader.tsx +++ b/packages/shared/src/components/cards/collection/CollectionCardHeader.tsx @@ -1,4 +1,4 @@ -import type { ReactElement } from 'react'; +import type { ReactElement, ReactNode } from 'react'; import React from 'react'; import classNames from 'classnames'; import { CollectionSourceStack } from '../../post/collection/CollectionSourceStack'; @@ -12,10 +12,13 @@ import type { Post } from '../../../graphql/posts'; interface CollectionCardHeaderProps { post: Post; + /** `card_save_on_hover`: the bookmark, right before ⋯. */ + bookmark?: ReactNode; } export const CollectionCardHeader = ({ post, + bookmark, }: CollectionCardHeaderProps): ReactElement => { const { collectionSources: sources, @@ -43,6 +46,7 @@ export const CollectionCardHeader = ({ totalSources={totalSources ?? 0} />
+ {bookmark} onPostClick?.(post); const onPostCardAuxClick = () => onPostAuxClick?.(post); const { isHidden, content: hiddenPanel } = useHiddenFeedbackPanel(post); + const saveOnHover = useCardSaveOnHover(); if (isHidden) { return ( @@ -94,7 +97,17 @@ export const CollectionGrid = forwardRef(function CollectionCard( onPostCardAuxClick={onPostCardAuxClick} /> - + + ) : undefined + } + /> diff --git a/packages/shared/src/components/cards/common/ActionButtons.tsx b/packages/shared/src/components/cards/common/ActionButtons.tsx index e642f8ce963..530b31c1971 100644 --- a/packages/shared/src/components/cards/common/ActionButtons.tsx +++ b/packages/shared/src/components/cards/common/ActionButtons.tsx @@ -29,6 +29,7 @@ import { useBrandSponsorship } from '../../../hooks/useBrandSponsorship'; import { usePostImpressions } from '../../../hooks/post/usePostImpressions'; import { useEngagementBarV2 } from '../../../hooks/useEngagementBarV2'; import ActionButtonsV2 from './ActionButtons.v2'; +import { useCardSaveOnHover } from '../../../hooks/cards/useCardSaveOnHover'; import { getPostPath } from '../../../lib/links'; export type ActionButtonsVariant = 'grid' | 'list' | 'signal'; @@ -44,6 +45,12 @@ export interface ActionButtonsProps { /** Controls sizing and behavior. Grid = smaller icons, List = larger icons with link navigation */ variant?: ActionButtonsVariant; showDownvoteAction?: boolean; + /** + * `card_save_on_hover`: the card renders the bookmark in its header, so the + * bar leaves it out. Only grid cards that do so pass it; it is ignored when + * the flag is off. + */ + bookmarkInHeader?: boolean; } const variantConfig = { @@ -320,12 +327,28 @@ const ActionButtonsV1 = ({ return buttons; }; -const ActionButtons = (props: ActionButtonsProps): ReactElement => { +const ActionButtons = (props: ActionButtonsProps): ReactElement | null => { + const saveOnHover = useCardSaveOnHover(); const useV2 = useEngagementBarV2(); + const { variant = 'grid', bookmarkInHeader, ...rest } = props; + // `card_save_on_hover` only changes grid cards: the v2 bar at 32px, without + // the bookmark when the card shows it in its header. List and signal cards + // stay at control. Run it in a GrowthBook namespace exclusive with + // `engagement_bar_v2`; for a user in both, this one wins on grid cards. + if (saveOnHover && variant === 'grid') { + return ( + + ); + } if (useV2) { - return ; + return ; } - return ; + return ; }; export default ActionButtons; diff --git a/packages/shared/src/components/cards/common/ActionButtons.v2.tsx b/packages/shared/src/components/cards/common/ActionButtons.v2.tsx index 30d8e9d3f30..ad335429cde 100644 --- a/packages/shared/src/components/cards/common/ActionButtons.v2.tsx +++ b/packages/shared/src/components/cards/common/ActionButtons.v2.tsx @@ -3,6 +3,7 @@ import React, { useMemo } from 'react'; import classNames from 'classnames'; import type { Post } from '../../../graphql/posts'; import { CardAction } from '../../buttons/CardAction'; +import type { CardActionDensity } from '../../buttons/CardAction'; import { CardActionBar } from '../../buttons/CardActionBar'; import { AnalyticsIcon, @@ -35,9 +36,20 @@ export interface ActionButtonsProps { onDownvoteClick?: (post: Post) => unknown; variant?: ActionButtonsVariant; showDownvoteAction?: boolean; + /** + * Button size. `tight` (24px) is the feed default; `card_save_on_hover` + * renders grid cards at `compact` (32px). + */ + density?: CardActionDensity; + /** The card renders the bookmark in its header, so the bar leaves it out. */ + bookmarkInHeader?: boolean; } -const FEED_CARD_DENSITY = 'tight'; +// `py-1.5` holds the grid row at 36px around 24px buttons; `py-0.5` does the +// same around 32px ones, so cards keep their height at either size. +const gridContainerClassName: Partial> = { + compact: 'px-2 py-0.5', +}; const variantConfig = { grid: { @@ -69,8 +81,13 @@ const ActionButtons = ({ onDownvoteClick, variant = 'grid', showDownvoteAction = true, + density = 'tight', + bookmarkInHeader = false, }: ActionButtonsProps): ReactElement | null => { const config = variantConfig[variant]; + const containerClassName = + (variant === 'grid' && gridContainerClassName[density]) || + config.containerClassName; const isFeedPreview = useFeedPreviewMode(); const { getUpvoteAnimation } = useBrandSponsorship(); @@ -123,7 +140,7 @@ const ActionButtons = ({ id={`post-${post.id}-comment-btn`} href={getPostPath(post)} pressed={post.commented} - density={FEED_CARD_DENSITY} + density={density} icon={} iconPressed={} label="Comment" @@ -137,7 +154,7 @@ const ActionButtons = ({ } iconPressed={} label="Comments" @@ -153,7 +170,7 @@ const ActionButtons = ({
@@ -164,7 +181,7 @@ const ActionButtons = ({ > } iconPressed={} @@ -196,23 +213,25 @@ const ActionButtons = ({ /> )} - + {!bookmarkInHeader && ( + + )} } label="Copy link" onClick={onCopyLink} @@ -229,7 +248,7 @@ const ActionButtons = ({ > } label="Impressions" count={impressions} diff --git a/packages/shared/src/components/cards/common/CardHeaderBookmark.tsx b/packages/shared/src/components/cards/common/CardHeaderBookmark.tsx new file mode 100644 index 00000000000..65e4bae8cf6 --- /dev/null +++ b/packages/shared/src/components/cards/common/CardHeaderBookmark.tsx @@ -0,0 +1,51 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import type { Post } from '../../../graphql/posts'; +import { BookmarkButton } from '../../buttons/BookmarkButton.v2'; +import { useCardActions } from '../../../hooks/cards/useCardActions'; +import { useFeedPreviewMode } from '../../../hooks/useFeedPreviewMode'; +import { visibleOnGroupHover } from './common'; + +interface CardHeaderBookmarkProps { + post: Post; + onBookmarkClick?: (post: Post) => unknown; + className?: string; +} + +/** + * `card_save_on_hover`: the card's bookmark, in the header right before ⋯. + * Hidden at rest on desktop and shown while the card is hovered, like Read + * post and ⋯. Hidden elements are not in the tab order, so for keyboard users + * it appears once focus is already inside the card (the card's link or a bar + * button) and is reachable from there; Tab cannot land on it from outside. + * Always visible on touch screens. + */ +export const CardHeaderBookmark = ({ + post, + onBookmarkClick, + className, +}: CardHeaderBookmarkProps): ReactElement | null => { + const isFeedPreview = useFeedPreviewMode(); + const { onToggleBookmark } = useCardActions({ post, onBookmarkClick }); + + if (isFeedPreview) { + return null; + } + + return ( + + ); +}; diff --git a/packages/shared/src/components/cards/common/PostCardHeader.tsx b/packages/shared/src/components/cards/common/PostCardHeader.tsx index 8cae1f2fb18..7cb97bb4591 100644 --- a/packages/shared/src/components/cards/common/PostCardHeader.tsx +++ b/packages/shared/src/components/cards/common/PostCardHeader.tsx @@ -37,6 +37,8 @@ interface CardHeaderProps { flagProps?: FlagProps; showFeedback?: boolean; primaryAction?: ReactNode; + /** `card_save_on_hover`: the bookmark, right before ⋯. */ + bookmark?: ReactNode; } const Container = getGroupedHoverContainer('span'); @@ -51,6 +53,7 @@ export const PostCardHeader = ({ openNewTab, showFeedback, primaryAction, + bookmark, }: CardHeaderProps): ReactElement => { const isFeedPreview = useFeedPreviewMode(); const isSharedPostDeleted = post.sharedPost?.id === DeletedPostId; @@ -127,6 +130,7 @@ export const PostCardHeader = ({ icon={readPostIcon} /> ))} + {bookmark} )} diff --git a/packages/shared/src/components/cards/common/SquadPostCardHeader.tsx b/packages/shared/src/components/cards/common/SquadPostCardHeader.tsx index 22311d93668..d7606c42439 100644 --- a/packages/shared/src/components/cards/common/SquadPostCardHeader.tsx +++ b/packages/shared/src/components/cards/common/SquadPostCardHeader.tsx @@ -1,4 +1,4 @@ -import type { ReactElement } from 'react'; +import type { ReactElement, ReactNode } from 'react'; import React from 'react'; import classNames from 'classnames'; import type { Post } from '../../../graphql/posts'; @@ -12,10 +12,16 @@ import { AuthorSourceStack } from './AuthorSourceStack'; // enableSourceHeader is kept for API compatibility with the shared card props; // the source now always renders in the stack (when it isn't a user source). -type SquadPostCardHeaderProps = { post: Post; enableSourceHeader?: boolean }; +type SquadPostCardHeaderProps = { + post: Post; + enableSourceHeader?: boolean; + /** `card_save_on_hover`: the bookmark, right before ⋯. */ + bookmark?: ReactNode; +}; export const SquadPostCardHeader = ({ post, + bookmark, }: SquadPostCardHeaderProps): ReactElement => { const { author, source, bookmarked } = post; const { highlightBookmarkedPost } = useBookmarkProvider({ @@ -34,6 +40,7 @@ export const SquadPostCardHeader = ({
+ {bookmark} ({ + useRouter: () => ({ pathname: '/', query: {} }), +})); + +jest.mock('../../../hooks/cards/useCardSaveOnHover', () => ({ + useCardSaveOnHover: jest.fn(), +})); + +jest.mock('../../../hooks/post/usePostImpressions', () => ({ + usePostImpressions: jest.fn(), +})); + +const setFlag = (on: boolean) => + jest.mocked(useCardSaveOnHover).mockReturnValue(on); + +beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(usePostImpressions).mockReturnValue({ + showImpressions: true, + impressions: 1000, + canViewAnalytics: false, + onImpressionsClick: jest.fn(), + }); +}); + +const wrap = (children: React.ReactNode) => ( + {children} +); + +/** The bar's buttons, in order, by accessible name. */ +const barLabels = (container: HTMLElement): string[] => { + const upvote = container.querySelector('[id$="-upvote-btn"]'); + const bar = upvote?.closest('.justify-between'); + return Array.from(bar?.querySelectorAll('button, a[href]') ?? []) + .map((el) => el.getAttribute('aria-label')) + .filter((label): label is string => !!label); +}; + +describe('ActionButtons with card_save_on_hover', () => { + const renderBar = ( + variant: ActionButtonsVariant, + bookmarkInHeader?: boolean, + ) => + render( + wrap( + , + ), + ); + + it('keeps today’s order without bookmark when the card shows it in its header', () => { + setFlag(true); + const { container } = renderBar('grid', true); + + expect(barLabels(container)).toEqual([ + 'Upvote', + 'Comments', + 'Downvote', + 'Copy link', + 'Impressions', + ]); + }); + + it('keeps the bookmark in the bar for a grid card that has no header slot', () => { + setFlag(true); + const { container } = renderBar('grid'); + + expect(barLabels(container)).toEqual([ + 'Upvote', + 'Comments', + 'Downvote', + 'Bookmark', + 'Copy link', + 'Impressions', + ]); + }); + + it.each(['list', 'signal'])( + 'leaves %s cards at control, bookmark in the bar', + (variant) => { + setFlag(true); + const { container } = renderBar(variant); + + // Control is today's v1 bar: 24px buttons, the bookmark included. + expect(barLabels(container)).toContain('Bookmark'); + expect( + document.querySelector(`#post-${post.id}-upvote-btn`), + ).not.toHaveClass('h-8'); + }, + ); + + it('renders grid actions at 32px', () => { + setFlag(true); + renderBar('grid', true); + + expect(screen.getByRole('button', { name: 'Upvote' })).toHaveClass('h-8'); + }); + + it('leaves today’s bar untouched when the flag is off', () => { + setFlag(false); + renderBar('grid', true); + + expect( + document.querySelector(`#post-${post.id}-upvote-btn`), + ).not.toHaveClass('h-8'); + expect(screen.getByRole('button', { name: 'Bookmark' })).toBeVisible(); + }); +}); + +const props = (cardPost: Post): PostCardProps => ({ + post: cardPost, + onPostClick: jest.fn(), + onPostAuxClick: jest.fn(), + onUpvoteClick: jest.fn(), + onCommentClick: jest.fn(), + onBookmarkClick: jest.fn(), + onShare: jest.fn(), + onCopyLinkClick: jest.fn(), + onReadArticleClick: jest.fn(), +}); + +const gridCards: [string, ComponentType, Post][] = [ + ['article', ArticleGrid, post], + ['share', ShareGrid, sharePost], + [ + 'freeform', + FreeformGrid, + { ...post, type: PostType.Freeform, contentHtml: '

Post

' }, + ], + ['poll', PollGrid, pollPost], + [ + 'collection', + CollectionGrid, + { ...post, type: PostType.Collection, collectionSources: [] }, + ], + [ + 'social', + SocialTwitterGrid, + { ...sharePost, type: PostType.SocialTwitter, sharedPost: undefined }, + ], +]; + +describe.each(gridCards)('%s grid card', (_, Card, cardPost) => { + it('moves the bookmark to the header when the flag is on', () => { + setFlag(true); + const cardProps = props(cardPost); + const { container } = render(wrap()); + + const bookmarks = screen.getAllByRole('button', { name: 'Bookmark' }); + expect(bookmarks).toHaveLength(1); + expect(barLabels(container)).not.toContain('Bookmark'); + + // Hidden at rest on desktop, like Read post and ⋯. + expect(bookmarks[0]).toHaveClass('laptop:mouse:invisible'); + + fireEvent.click(bookmarks[0]); + expect(cardProps.onBookmarkClick).toHaveBeenCalledWith( + expect.objectContaining({ id: cardPost.id }), + ); + }); + + it('keeps the bookmark in the bar when the flag is off', () => { + setFlag(false); + render(wrap()); + + // Located by id: today's bar names the upvote button by its count. + const bar = document + .getElementById(`post-${cardPost.id}-upvote-btn`) + ?.closest('.justify-between') as HTMLElement; + expect( + within(bar).getByRole('button', { name: 'Bookmark' }), + ).toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/components/cards/poll/PollGrid.tsx b/packages/shared/src/components/cards/poll/PollGrid.tsx index fcdb7d603ea..f2d398267a7 100644 --- a/packages/shared/src/components/cards/poll/PollGrid.tsx +++ b/packages/shared/src/components/cards/poll/PollGrid.tsx @@ -17,6 +17,8 @@ import CardOverlay from '../common/CardOverlay'; import { useSmartTitle } from '../../../hooks/post/useSmartTitle'; import { usePollVote } from '../../../hooks/post/usePollVote'; import { isSourceSquadOrMachine } from '../../../graphql/sources'; +import { useCardSaveOnHover } from '../../../hooks/cards/useCardSaveOnHover'; +import { CardHeaderBookmark } from '../common/CardHeaderBookmark'; const PollGrid = forwardRef(function PollCard( { @@ -35,6 +37,7 @@ const PollGrid = forwardRef(function PollCard( const { user } = useAuthContext(); const { handleVote, shouldAnimateResults } = usePollVote({ post }); const { title } = useSmartTitle(post); + const saveOnHover = useCardSaveOnHover(); const { pinnedAt, trending, pollOptions, endsAt, numPollVotes, source } = post; @@ -61,6 +64,14 @@ const PollGrid = forwardRef(function PollCard( + ) : undefined + } /> {title} @@ -91,6 +102,7 @@ const PollGrid = forwardRef(function PollCard( onCommentClick={onCommentClick} onCopyLinkClick={onCopyLinkClick} onBookmarkClick={onBookmarkClick} + bookmarkInHeader={saveOnHover} onDownvoteClick={onDownvoteClick} /> diff --git a/packages/shared/src/components/cards/share/ShareGrid.tsx b/packages/shared/src/components/cards/share/ShareGrid.tsx index 5318356cb73..5a9b94dafeb 100644 --- a/packages/shared/src/components/cards/share/ShareGrid.tsx +++ b/packages/shared/src/components/cards/share/ShareGrid.tsx @@ -29,6 +29,8 @@ import { useFeature } from '../../GrowthBookProvider'; import { sharedPostPreviewFeature } from '../../../lib/featureManagement'; import { SharedPostPreview } from './SharedPostPreview'; import { useHiddenFeedbackPanel } from '../../../hooks/post/useHiddenFeedbackPanel'; +import { useCardSaveOnHover } from '../../../hooks/cards/useCardSaveOnHover'; +import { CardHeaderBookmark } from '../common/CardHeaderBookmark'; const EmptyStateContainer = classed( 'div', @@ -57,6 +59,7 @@ export const ShareGrid = forwardRef(function ShareGrid( const onPostCardAuxClick = () => onPostAuxClick(post); const containerRef = useRef(); const { title } = useSmartTitle(post); + const saveOnHover = useCardSaveOnHover(); const { sharedPost } = post; const isDeleted = sharedPost?.id === DeletedPostId; const { private: sharedPostPrivate, source: sharedPostSource } = @@ -192,6 +195,14 @@ export const ShareGrid = forwardRef(function ShareGrid( source={post.source} postLink={sharedPost?.permalink} onReadArticleClick={onReadArticleClick} + bookmark={ + saveOnHover ? ( + + ) : undefined + } /> {(!isSharedTweet || post.title) && {title}} @@ -219,6 +230,7 @@ export const ShareGrid = forwardRef(function ShareGrid( onCommentClick={onCommentClick} onCopyLinkClick={onCopyLinkClick} onBookmarkClick={onBookmarkClick} + bookmarkInHeader={saveOnHover} onDownvoteClick={onDownvoteClick} /> diff --git a/packages/shared/src/components/cards/socialTwitter/SocialTwitterGrid.tsx b/packages/shared/src/components/cards/socialTwitter/SocialTwitterGrid.tsx index 8fd589b002d..169c4f291ab 100644 --- a/packages/shared/src/components/cards/socialTwitter/SocialTwitterGrid.tsx +++ b/packages/shared/src/components/cards/socialTwitter/SocialTwitterGrid.tsx @@ -35,6 +35,8 @@ import { useSocialTwitterCardData, } from './socialTwitterHelpers'; import { EmbeddedTweetPreview } from './EmbeddedTweetPreview'; +import { useCardSaveOnHover } from '../../../hooks/cards/useCardSaveOnHover'; +import { CardHeaderBookmark } from '../common/CardHeaderBookmark'; const HeaderActions = getGroupedHoverContainer('span'); @@ -54,6 +56,7 @@ export const SocialTwitterGrid = forwardRef(function SocialTwitterGrid( ref: Ref, ): ReactElement { const isFeedPreview = useFeedPreviewMode(); + const saveOnHover = useCardSaveOnHover(); const isUserSource = isSourceUserSource(post.source); const rawTitle = post.title || post.sharedPost?.title; const { normalizedContent, hasDailyDevMarkdown, socialTextDirectionProps } = @@ -84,8 +87,8 @@ export const SocialTwitterGrid = forwardRef(function SocialTwitterGrid( ); - const onPostCardClick = () => onPostClick(post); - const onPostCardAuxClick = () => onPostAuxClick(post); + const onPostCardClick = () => onPostClick?.(post); + const onPostCardAuxClick = () => onPostAuxClick?.(post); return ( )} + {saveOnHover && ( + + )}
@@ -175,6 +184,7 @@ export const SocialTwitterGrid = forwardRef(function SocialTwitterGrid( { + const { isAuthReady, user } = useAuthContext(); + const { value } = useConditionalFeature({ + feature: featureCardSaveOnHover, + shouldEvaluate: isAuthReady && !!user, + }); + return value; +}; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index d1134a27bfc..862b644a2bc 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -262,6 +262,11 @@ export const featureLayoutV2 = new Feature('layout_v2_2', false); export const featureEngagementBarV2 = new Feature('engagement_bar_v2', false); +// Experiment: on grid cards the bookmark moves to the card header, shown on +// hover beside ⋯, and the bar keeps today's order at 32px with 20px icons. +// List cards keep the bookmark in the bar. Logged-in users only. +export const featureCardSaveOnHover = new Feature('card_save_on_hover', false); + export const featureHeroCards = new Feature('hero_cards', { enabled: false, minSpacing: 10, diff --git a/packages/storybook/stories/experiments/CardSaveOnHover.stories.tsx b/packages/storybook/stories/experiments/CardSaveOnHover.stories.tsx new file mode 100644 index 00000000000..38b0daf8148 --- /dev/null +++ b/packages/storybook/stories/experiments/CardSaveOnHover.stories.tsx @@ -0,0 +1,211 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import { fn } from 'storybook/test'; +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import { PostType } from '@dailydotdev/shared/src/graphql/posts'; +import { featureCardSaveOnHover } from '@dailydotdev/shared/src/lib/featureManagement'; +import { PostTypeToGridCard } from '@dailydotdev/shared/src/components/cards/common/gridCards'; +import { PostTypeToListCard } from '@dailydotdev/shared/src/components/cards/common/listCards'; +import { ArticleGrid } from '@dailydotdev/shared/src/components/cards/article/ArticleGrid'; +import { ArticleList } from '@dailydotdev/shared/src/components/cards/article/ArticleList'; +import post, { sharePost } from '@dailydotdev/shared/__tests__/fixture/post'; +import { pollPost } from '@dailydotdev/shared/__tests__/fixture/pollPost'; +import ExtensionProviders from '../extension/_providers'; +import { FeatureOverrides } from '../../mock/GrowthBookProvider'; + +/** + * `card_save_on_hover`, control vs treatment, on the production cards with + * the shared test fixtures. Treatment: grid cards show the bookmark in the + * header on hover (between Read post and ⋯) and the bar keeps today's order + * at 32px. List cards are not part of the experiment and stay at control. + */ + +const article: Post = { + ...post, + numUpvotes: 63, + numComments: 8, + analytics: { impressions: 197000 }, +} as Post; +const video: Post = { + ...article, + id: 'story-video', + type: PostType.VideoYouTube, + title: 'Shipping a design system in a week', +} as Post; +const freeform: Post = { + ...article, + id: 'story-freeform', + type: PostType.Freeform, + title: 'What I learned rewriting our feed in a weekend', + contentHtml: '

Notes from the rewrite.

', +} as Post; +const collection: Post = { + ...article, + id: 'story-collection', + type: PostType.Collection, + title: 'React Server Components, explained', + collectionSources: [post.source], + numCollectionSources: 3, +} as Post; + +const CARDS: { label: string; post: Post }[] = [ + { label: 'Article', post: article }, + { label: 'Share', post: sharePost }, + { label: 'Post', post: freeform }, + { label: 'Video', post: video }, + { label: 'Poll', post: pollPost }, + { label: 'Collection', post: collection }, +]; + +const handlers = { + onPostClick: fn(), + onPostAuxClick: fn(), + onUpvoteClick: fn(), + onDownvoteClick: fn(), + onCommentClick: fn(), + onBookmarkClick: fn(), + onCopyLinkClick: fn(), + onShare: fn(), + onReadArticleClick: fn(), +}; + +const Flag = ({ on, children }: { on: boolean; children: ReactNode }) => ( + + {children} + +); + +/** Shows the hover state without a pointer: the header actions appear. */ +const forceHoverCss = ` +@media (pointer: fine) { + .force-hover .laptop\\:mouse\\:group-hover\\:visible { visibility: visible; } +}`; + +const Column = ({ + title, + note, + children, +}: { + title: string; + note?: string; + children: ReactNode; +}) => ( +
+
+

{title}

+ {note &&

{note}

} +
+ {children} +
+); + +interface GridArgs { + width: number; +} + +const GridCards = ({ width }: GridArgs): ReactElement => ( +
+ +
+ {CARDS.map(({ label, post }) => { + const Card = PostTypeToGridCard[post.type] ?? ArticleGrid; + return ( +
+

+ {label} +

+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+
+
+ ); + })} +
+
+); + +interface ListArgs { + show: 'both' | 'control' | 'treatment'; +} + +const ListCards = ({ show }: ListArgs): ReactElement => ( +
+
+ {[false, true] + .filter((on) => show === 'both' || (show === 'treatment') === on) + .map((on) => ( + + +
+ {CARDS.map(({ label, post }) => { + const Card = PostTypeToListCard[post.type] ?? ArticleList; + return ; + })} +
+
+
+ ))} +
+
+); + +const meta: Meta = { + title: 'Experiments/Card save on hover', + parameters: { layout: 'fullscreen' }, + globals: { theme: 'dark' }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; + +export const Grid: StoryObj = { + name: 'Grid cards', + args: { width: 292 }, + argTypes: { + width: { control: { type: 'range', min: 272, max: 420, step: 4 } }, + }, + render: (args) => , +}; + +export const List: StoryObj = { + name: 'List cards (phone and tablet)', + args: { show: 'both' }, + argTypes: { + show: { + control: 'inline-radio', + options: ['both', 'control', 'treatment'], + }, + }, + render: (args) => , +}; From 4da11e45a610fc21bdda3d809849ea679ecd4aba Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Fri, 9 Oct 2026 09:23:03 +0300 Subject: [PATCH 2/5] feat(shared): card_save_on_hover moves the list-card bookmark to the header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit List cards now match grid cards: the bookmark leaves the bottom bar and sits in the header right before ⋯ (hover-only on desktop, always visible on touch). The list bar keeps today's 24px buttons, so row heights are unchanged. Signal cards have no header ⋯ and keep the bar bookmark. Co-Authored-By: Claude Opus 5.5 --- .../cards/Freeform/FreeformList.tsx | 12 ++++ .../components/cards/article/ArticleList.tsx | 12 ++++ .../cards/collection/CollectionList.tsx | 12 ++++ .../components/cards/common/ActionButtons.tsx | 63 ++++++++++------- .../cards/common/list/PostCardHeader.tsx | 4 ++ .../cards/common/saveOnHover.spec.tsx | 70 +++++++++++++------ .../src/components/cards/poll/PollList.tsx | 12 ++++ .../src/components/cards/share/ShareList.tsx | 9 +++ .../cards/socialTwitter/SocialTwitterList.tsx | 12 ++++ .../experiments/CardSaveOnHover.stories.tsx | 9 +-- 10 files changed, 164 insertions(+), 51 deletions(-) diff --git a/packages/shared/src/components/cards/Freeform/FreeformList.tsx b/packages/shared/src/components/cards/Freeform/FreeformList.tsx index f72d2e85312..8b140d13504 100644 --- a/packages/shared/src/components/cards/Freeform/FreeformList.tsx +++ b/packages/shared/src/components/cards/Freeform/FreeformList.tsx @@ -30,6 +30,8 @@ import { sanitizeMessage } from '../../../features/onboarding/shared'; import { isSourceUserSource } from '../../../graphql/sources'; import { useHiddenFeedbackPanel } from '../../../hooks/post/useHiddenFeedbackPanel'; import { getPostPath } from '../../../lib/links'; +import { useCardSaveOnHover } from '../../../hooks/cards/useCardSaveOnHover'; +import { CardHeaderBookmark } from '../common/CardHeaderBookmark'; export const FreeformList = forwardRef(function SharePostCard( { @@ -49,6 +51,7 @@ export const FreeformList = forwardRef(function SharePostCard( ref: Ref, ): ReactElement { const { interaction } = usePostActions({ post }); + const saveOnHover = useCardSaveOnHover(); const { pinnedAt, type: postType } = post; const isMobile = useViewSize(ViewSize.MobileL); const onPostCardClick = (event: React.MouseEvent) => @@ -75,6 +78,7 @@ export const FreeformList = forwardRef(function SharePostCard( onCommentClick={onCommentClick} onCopyLinkClick={onCopyLinkClick} onBookmarkClick={onBookmarkClick} + bookmarkInHeader={saveOnHover} className={classNames( 'mt-2 justify-between', !!image && 'laptop:mt-auto', @@ -151,6 +155,14 @@ export const FreeformList = forwardRef(function SharePostCard( > + ) : undefined + } post={post} metadata={metadata} isSourceNamed={enableSourceHeader} diff --git a/packages/shared/src/components/cards/article/ArticleList.tsx b/packages/shared/src/components/cards/article/ArticleList.tsx index 10c9081c274..c831761320d 100644 --- a/packages/shared/src/components/cards/article/ArticleList.tsx +++ b/packages/shared/src/components/cards/article/ArticleList.tsx @@ -31,6 +31,8 @@ import { useSmartTitle } from '../../../hooks/post/useSmartTitle'; import { isSourceUserSource } from '../../../graphql/sources'; import { useHiddenFeedbackPanel } from '../../../hooks/post/useHiddenFeedbackPanel'; import { getPostPath } from '../../../lib/links'; +import { useCardSaveOnHover } from '../../../hooks/cards/useCardSaveOnHover'; +import { CardHeaderBookmark } from '../common/CardHeaderBookmark'; export const ArticleList = forwardRef(function ArticleList( { @@ -64,6 +66,7 @@ export const ArticleList = forwardRef(function ArticleList( const onPostCardClick = (event: React.MouseEvent) => onPostClick?.(post, event); const isMobile = useViewSize(ViewSize.MobileL); + const saveOnHover = useCardSaveOnHover(); const isStacked = isMobile || isNarrow; const { showFeedback } = usePostFeedback({ post }); const { isHidden, content: hiddenPanel } = useHiddenFeedbackPanel(post); @@ -81,6 +84,7 @@ export const ArticleList = forwardRef(function ArticleList( onCommentClick={onCommentClick} onCopyLinkClick={onCopyLinkClick} onBookmarkClick={onBookmarkClick} + bookmarkInHeader={saveOnHover} variant="list" /> @@ -160,6 +164,14 @@ export const ArticleList = forwardRef(function ArticleList( <> + ) : undefined + } post={post} openNewTab={openNewTab} postLink={post.permalink} diff --git a/packages/shared/src/components/cards/collection/CollectionList.tsx b/packages/shared/src/components/cards/collection/CollectionList.tsx index 72f65c0f5bc..1cf1c1ed73c 100644 --- a/packages/shared/src/components/cards/collection/CollectionList.tsx +++ b/packages/shared/src/components/cards/collection/CollectionList.tsx @@ -22,6 +22,8 @@ import { isPostUpdated } from '../../../graphql/posts'; import { TimeFormatType } from '../../../lib/dateFormat'; import { useHiddenFeedbackPanel } from '../../../hooks/post/useHiddenFeedbackPanel'; import { getPostPath } from '../../../lib/links'; +import { useCardSaveOnHover } from '../../../hooks/cards/useCardSaveOnHover'; +import { CardHeaderBookmark } from '../common/CardHeaderBookmark'; export const CollectionList = forwardRef(function CollectionCard( { @@ -40,6 +42,7 @@ export const CollectionList = forwardRef(function CollectionCard( ref: Ref, ) { const isMobile = useViewSize(ViewSize.MobileL); + const saveOnHover = useCardSaveOnHover(); const image = usePostImage(post); const { title } = useTruncatedSummary(post?.title ?? ''); const wasUpdated = isPostUpdated(post); @@ -54,6 +57,7 @@ export const CollectionList = forwardRef(function CollectionCard( onCommentClick={onCommentClick} onCopyLinkClick={onCopyLinkClick} onBookmarkClick={onBookmarkClick} + bookmarkInHeader={saveOnHover} className="mt-2 justify-between tablet:mt-0" variant="list" /> @@ -101,6 +105,14 @@ export const CollectionList = forwardRef(function CollectionCard( > + ) : undefined + } post={post} metadata={{ createdAt: wasUpdated ? post.updatedAt : post.createdAt, diff --git a/packages/shared/src/components/cards/common/ActionButtons.tsx b/packages/shared/src/components/cards/common/ActionButtons.tsx index 530b31c1971..120db8a236c 100644 --- a/packages/shared/src/components/cards/common/ActionButtons.tsx +++ b/packages/shared/src/components/cards/common/ActionButtons.tsx @@ -47,8 +47,8 @@ export interface ActionButtonsProps { showDownvoteAction?: boolean; /** * `card_save_on_hover`: the card renders the bookmark in its header, so the - * bar leaves it out. Only grid cards that do so pass it; it is ignored when - * the flag is off. + * bar leaves it out. Only cards that do so pass it; it is ignored when the + * flag is off. */ bookmarkInHeader?: boolean; } @@ -89,6 +89,7 @@ const ActionButtonsV1 = ({ onDownvoteClick, variant = 'grid', showDownvoteAction = true, + bookmarkInHeader = false, }: ActionButtonsProps): ReactElement | null => { const config = variantConfig[variant]; const isFeedPreview = useFeedPreviewMode(); @@ -247,23 +248,25 @@ const ActionButtonsV1 = ({ /> )} - + {!bookmarkInHeader && ( + + )} { const saveOnHover = useCardSaveOnHover(); const useV2 = useEngagementBarV2(); const { variant = 'grid', bookmarkInHeader, ...rest } = props; - // `card_save_on_hover` only changes grid cards: the v2 bar at 32px, without - // the bookmark when the card shows it in its header. List and signal cards - // stay at control. Run it in a GrowthBook namespace exclusive with - // `engagement_bar_v2`; for a user in both, this one wins on grid cards. + // `card_save_on_hover`: grid cards get the v2 bar at 32px; every card that + // shows the bookmark in its header leaves it out of the bar. List cards + // otherwise keep today's bar, so their rows keep their height. Run it in a + // GrowthBook namespace exclusive with `engagement_bar_v2`; for a user in + // both, this one wins on grid cards. + const inHeader = saveOnHover && !!bookmarkInHeader; if (saveOnHover && variant === 'grid') { return ( { ); } if (useV2) { - return ; + return ( + + ); } - return ; + return ( + + ); }; export default ActionButtons; diff --git a/packages/shared/src/components/cards/common/list/PostCardHeader.tsx b/packages/shared/src/components/cards/common/list/PostCardHeader.tsx index 83ba90e1b5d..f3b419534a3 100644 --- a/packages/shared/src/components/cards/common/list/PostCardHeader.tsx +++ b/packages/shared/src/components/cards/common/list/PostCardHeader.tsx @@ -29,6 +29,8 @@ interface CardHeaderProps { primaryAction?: ReactNode; /** The top label is the source's name, so its seal is there already. */ isSourceNamed?: boolean; + /** `card_save_on_hover`: the bookmark, right before ⋯. */ + bookmark?: ReactNode; metadata?: { topLabel?: PostMetadataProps['topLabel']; bottomLabel?: PostMetadataProps['bottomLabel']; @@ -54,6 +56,7 @@ export const PostCardHeader = ({ primaryAction, isSourceNamed = false, metadata, + bookmark, }: CardHeaderProps): ReactElement => { const isFeedPreview = useFeedPreviewMode(); const postButtonText = useReadPostButtonText(post); @@ -130,6 +133,7 @@ export const PostCardHeader = ({ openNewTab={openNewTab} /> ))} + {bookmark} )} diff --git a/packages/shared/src/components/cards/common/saveOnHover.spec.tsx b/packages/shared/src/components/cards/common/saveOnHover.spec.tsx index 8e7580de441..b21e275d31a 100644 --- a/packages/shared/src/components/cards/common/saveOnHover.spec.tsx +++ b/packages/shared/src/components/cards/common/saveOnHover.spec.tsx @@ -16,6 +16,12 @@ import { FreeformGrid } from '../Freeform/FreeformGrid'; import PollGrid from '../poll/PollGrid'; import { CollectionGrid } from '../collection/CollectionGrid'; import { SocialTwitterGrid } from '../socialTwitter/SocialTwitterGrid'; +import { ArticleList } from '../article/ArticleList'; +import { ShareList } from '../share/ShareList'; +import { FreeformList } from '../Freeform/FreeformList'; +import { PollList } from '../poll/PollList'; +import { CollectionList } from '../collection/CollectionList'; +import { SocialTwitterList } from '../socialTwitter/SocialTwitterList'; import { useCardSaveOnHover } from '../../../hooks/cards/useCardSaveOnHover'; import { usePostImpressions } from '../../../hooks/post/usePostImpressions'; @@ -100,12 +106,12 @@ describe('ActionButtons with card_save_on_hover', () => { }); it.each(['list', 'signal'])( - 'leaves %s cards at control, bookmark in the bar', + 'keeps today’s 24px bar on %s cards', (variant) => { setFlag(true); const { container } = renderBar(variant); - // Control is today's v1 bar: 24px buttons, the bookmark included. + // Without a header slot the bookmark stays in today's bar. expect(barLabels(container)).toContain('Bookmark'); expect( document.querySelector(`#post-${post.id}-upvote-btn`), @@ -113,6 +119,16 @@ describe('ActionButtons with card_save_on_hover', () => { }, ); + it('leaves the bookmark out of a list bar when the card shows it in its header', () => { + setFlag(true); + const { container } = renderBar('list', true); + + expect(barLabels(container)).not.toContain('Bookmark'); + expect( + document.querySelector(`#post-${post.id}-upvote-btn`), + ).not.toHaveClass('h-8'); + }); + it('renders grid actions at 32px', () => { setFlag(true); renderBar('grid', true); @@ -143,28 +159,38 @@ const props = (cardPost: Post): PostCardProps => ({ onReadArticleClick: jest.fn(), }); -const gridCards: [string, ComponentType, Post][] = [ - ['article', ArticleGrid, post], - ['share', ShareGrid, sharePost], - [ - 'freeform', - FreeformGrid, - { ...post, type: PostType.Freeform, contentHtml: '

Post

' }, - ], - ['poll', PollGrid, pollPost], - [ - 'collection', - CollectionGrid, - { ...post, type: PostType.Collection, collectionSources: [] }, - ], - [ - 'social', - SocialTwitterGrid, - { ...sharePost, type: PostType.SocialTwitter, sharedPost: undefined }, - ], +const freeformPost = { + ...post, + type: PostType.Freeform, + contentHtml: '

Post

', +} as Post; +const collectionPost = { + ...post, + type: PostType.Collection, + collectionSources: [], +} as Post; +const socialPost = { + ...sharePost, + type: PostType.SocialTwitter, + sharedPost: undefined, +} as Post; + +const cards: [string, ComponentType, Post][] = [ + ['article grid', ArticleGrid, post], + ['share grid', ShareGrid, sharePost], + ['freeform grid', FreeformGrid, freeformPost], + ['poll grid', PollGrid, pollPost], + ['collection grid', CollectionGrid, collectionPost], + ['social grid', SocialTwitterGrid, socialPost], + ['article list', ArticleList, post], + ['share list', ShareList, sharePost], + ['freeform list', FreeformList, freeformPost], + ['poll list', PollList, pollPost], + ['collection list', CollectionList, collectionPost], + ['social list', SocialTwitterList, socialPost], ]; -describe.each(gridCards)('%s grid card', (_, Card, cardPost) => { +describe.each(cards)('%s card', (_, Card, cardPost) => { it('moves the bookmark to the header when the flag is on', () => { setFlag(true); const cardProps = props(cardPost); diff --git a/packages/shared/src/components/cards/poll/PollList.tsx b/packages/shared/src/components/cards/poll/PollList.tsx index 4ffd45856b1..a181524f8c7 100644 --- a/packages/shared/src/components/cards/poll/PollList.tsx +++ b/packages/shared/src/components/cards/poll/PollList.tsx @@ -23,6 +23,8 @@ import PollOptions from './PollOptions'; import PostMetadata from '../common/PostMetadata'; import { usePollVote } from '../../../hooks/post/usePollVote'; import { getPostPath } from '../../../lib/links'; +import { useCardSaveOnHover } from '../../../hooks/cards/useCardSaveOnHover'; +import { CardHeaderBookmark } from '../common/CardHeaderBookmark'; export const PollList = forwardRef(function PollList( { @@ -42,6 +44,7 @@ export const PollList = forwardRef(function PollList( const { className, style } = domProps; const { type, pinnedAt, trending } = post; const { user } = useAuthContext(); + const saveOnHover = useCardSaveOnHover(); const { handleVote, shouldAnimateResults } = usePollVote({ post }); const onPostCardClick = (event: React.MouseEvent) => @@ -62,6 +65,7 @@ export const PollList = forwardRef(function PollList( onCommentClick={onCommentClick} onCopyLinkClick={onCopyLinkClick} onBookmarkClick={onBookmarkClick} + bookmarkInHeader={saveOnHover} variant="list" /> @@ -107,6 +111,14 @@ export const PollList = forwardRef(function PollList( > + ) : undefined + } post={{ ...post, createdAt: undefined }} openNewTab={openNewTab} postLink={post.permalink} diff --git a/packages/shared/src/components/cards/share/ShareList.tsx b/packages/shared/src/components/cards/share/ShareList.tsx index 860cfe31276..29c002dc62f 100644 --- a/packages/shared/src/components/cards/share/ShareList.tsx +++ b/packages/shared/src/components/cards/share/ShareList.tsx @@ -30,6 +30,8 @@ import { sharedPostPreviewFeature } from '../../../lib/featureManagement'; import { SharedPostPreview } from './SharedPostPreview'; import { useHiddenFeedbackPanel } from '../../../hooks/post/useHiddenFeedbackPanel'; import { getPostPath } from '../../../lib/links'; +import { useCardSaveOnHover } from '../../../hooks/cards/useCardSaveOnHover'; +import { CardHeaderBookmark } from '../common/CardHeaderBookmark'; export const ShareList = forwardRef(function ShareList( { @@ -52,6 +54,7 @@ export const ShareList = forwardRef(function ShareList( ): ReactElement { const { pinnedAt, trending, type } = post; const isMobile = useViewSize(ViewSize.MobileL); + const saveOnHover = useCardSaveOnHover(); const onPostCardClick = (event: React.MouseEvent) => onPostClick?.(post, event); const containerRef = useRef(null); @@ -78,6 +81,7 @@ export const ShareList = forwardRef(function ShareList( onCommentClick={onCommentClick} onCopyLinkClick={onCopyLinkClick} onBookmarkClick={onBookmarkClick} + bookmarkInHeader={saveOnHover} variant="list" /> @@ -151,6 +155,11 @@ export const ShareList = forwardRef(function ShareList( bookmarked={post.bookmarked} > + ) : undefined + } post={{ ...post, type: sharedPost?.type || post.type, diff --git a/packages/shared/src/components/cards/socialTwitter/SocialTwitterList.tsx b/packages/shared/src/components/cards/socialTwitter/SocialTwitterList.tsx index e11760f87ea..79ad54bc1c5 100644 --- a/packages/shared/src/components/cards/socialTwitter/SocialTwitterList.tsx +++ b/packages/shared/src/components/cards/socialTwitter/SocialTwitterList.tsx @@ -31,6 +31,8 @@ import { } from './socialTwitterHelpers'; import { EmbeddedTweetPreview } from './EmbeddedTweetPreview'; import { getPostPath } from '../../../lib/links'; +import { useCardSaveOnHover } from '../../../hooks/cards/useCardSaveOnHover'; +import { CardHeaderBookmark } from '../common/CardHeaderBookmark'; export const SocialTwitterList = forwardRef(function SocialTwitterList( { @@ -49,6 +51,7 @@ export const SocialTwitterList = forwardRef(function SocialTwitterList( ): ReactElement { const { pinnedAt, trending, type: postType } = post; const isMobile = useViewSize(ViewSize.MobileL); + const saveOnHover = useCardSaveOnHover(); const onPostCardClick = (event: React.MouseEvent) => onPostClick?.(post, event); const containerRef = useRef(null); @@ -87,6 +90,7 @@ export const SocialTwitterList = forwardRef(function SocialTwitterList( onCommentClick={onCommentClick} onCopyLinkClick={onCopyLinkClick} onBookmarkClick={onBookmarkClick} + bookmarkInHeader={saveOnHover} variant="list" /> @@ -137,6 +141,14 @@ export const SocialTwitterList = forwardRef(function SocialTwitterList( > + ) : undefined + } post={post} metadata={{ ...metadata, diff --git a/packages/storybook/stories/experiments/CardSaveOnHover.stories.tsx b/packages/storybook/stories/experiments/CardSaveOnHover.stories.tsx index 38b0daf8148..82286883c92 100644 --- a/packages/storybook/stories/experiments/CardSaveOnHover.stories.tsx +++ b/packages/storybook/stories/experiments/CardSaveOnHover.stories.tsx @@ -16,9 +16,10 @@ import { FeatureOverrides } from '../../mock/GrowthBookProvider'; /** * `card_save_on_hover`, control vs treatment, on the production cards with - * the shared test fixtures. Treatment: grid cards show the bookmark in the - * header on hover (between Read post and ⋯) and the bar keeps today's order - * at 32px. List cards are not part of the experiment and stay at control. + * the shared test fixtures. Treatment: the bookmark moves to the card header, + * right before ⋯, on grid and list cards alike. Grid bars keep today's order + * at 32px; list bars keep today's 24px buttons. On desktop the header bookmark + * shows on hover; on phones and tablets it is always visible. */ const article: Post = { @@ -158,7 +159,7 @@ const ListCards = ({ show }: ListArgs): ReactElement => (
From a925954286e3f2f877c0d8f77d973ed91572b85d Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Fri, 9 Oct 2026 09:36:24 +0300 Subject: [PATCH 3/5] Revert "feat(shared): card_save_on_hover moves the list-card bookmark to the header" List cards go back to control: the bookmark stays in the bottom bar. Co-Authored-By: Claude Opus 5.5 --- .../cards/Freeform/FreeformList.tsx | 12 ---- .../components/cards/article/ArticleList.tsx | 12 ---- .../cards/collection/CollectionList.tsx | 12 ---- .../components/cards/common/ActionButtons.tsx | 63 +++++++---------- .../cards/common/list/PostCardHeader.tsx | 4 -- .../cards/common/saveOnHover.spec.tsx | 70 ++++++------------- .../src/components/cards/poll/PollList.tsx | 12 ---- .../src/components/cards/share/ShareList.tsx | 9 --- .../cards/socialTwitter/SocialTwitterList.tsx | 12 ---- .../experiments/CardSaveOnHover.stories.tsx | 9 ++- 10 files changed, 51 insertions(+), 164 deletions(-) diff --git a/packages/shared/src/components/cards/Freeform/FreeformList.tsx b/packages/shared/src/components/cards/Freeform/FreeformList.tsx index 8b140d13504..f72d2e85312 100644 --- a/packages/shared/src/components/cards/Freeform/FreeformList.tsx +++ b/packages/shared/src/components/cards/Freeform/FreeformList.tsx @@ -30,8 +30,6 @@ import { sanitizeMessage } from '../../../features/onboarding/shared'; import { isSourceUserSource } from '../../../graphql/sources'; import { useHiddenFeedbackPanel } from '../../../hooks/post/useHiddenFeedbackPanel'; import { getPostPath } from '../../../lib/links'; -import { useCardSaveOnHover } from '../../../hooks/cards/useCardSaveOnHover'; -import { CardHeaderBookmark } from '../common/CardHeaderBookmark'; export const FreeformList = forwardRef(function SharePostCard( { @@ -51,7 +49,6 @@ export const FreeformList = forwardRef(function SharePostCard( ref: Ref, ): ReactElement { const { interaction } = usePostActions({ post }); - const saveOnHover = useCardSaveOnHover(); const { pinnedAt, type: postType } = post; const isMobile = useViewSize(ViewSize.MobileL); const onPostCardClick = (event: React.MouseEvent) => @@ -78,7 +75,6 @@ export const FreeformList = forwardRef(function SharePostCard( onCommentClick={onCommentClick} onCopyLinkClick={onCopyLinkClick} onBookmarkClick={onBookmarkClick} - bookmarkInHeader={saveOnHover} className={classNames( 'mt-2 justify-between', !!image && 'laptop:mt-auto', @@ -155,14 +151,6 @@ export const FreeformList = forwardRef(function SharePostCard( > - ) : undefined - } post={post} metadata={metadata} isSourceNamed={enableSourceHeader} diff --git a/packages/shared/src/components/cards/article/ArticleList.tsx b/packages/shared/src/components/cards/article/ArticleList.tsx index c831761320d..10c9081c274 100644 --- a/packages/shared/src/components/cards/article/ArticleList.tsx +++ b/packages/shared/src/components/cards/article/ArticleList.tsx @@ -31,8 +31,6 @@ import { useSmartTitle } from '../../../hooks/post/useSmartTitle'; import { isSourceUserSource } from '../../../graphql/sources'; import { useHiddenFeedbackPanel } from '../../../hooks/post/useHiddenFeedbackPanel'; import { getPostPath } from '../../../lib/links'; -import { useCardSaveOnHover } from '../../../hooks/cards/useCardSaveOnHover'; -import { CardHeaderBookmark } from '../common/CardHeaderBookmark'; export const ArticleList = forwardRef(function ArticleList( { @@ -66,7 +64,6 @@ export const ArticleList = forwardRef(function ArticleList( const onPostCardClick = (event: React.MouseEvent) => onPostClick?.(post, event); const isMobile = useViewSize(ViewSize.MobileL); - const saveOnHover = useCardSaveOnHover(); const isStacked = isMobile || isNarrow; const { showFeedback } = usePostFeedback({ post }); const { isHidden, content: hiddenPanel } = useHiddenFeedbackPanel(post); @@ -84,7 +81,6 @@ export const ArticleList = forwardRef(function ArticleList( onCommentClick={onCommentClick} onCopyLinkClick={onCopyLinkClick} onBookmarkClick={onBookmarkClick} - bookmarkInHeader={saveOnHover} variant="list" /> @@ -164,14 +160,6 @@ export const ArticleList = forwardRef(function ArticleList( <> - ) : undefined - } post={post} openNewTab={openNewTab} postLink={post.permalink} diff --git a/packages/shared/src/components/cards/collection/CollectionList.tsx b/packages/shared/src/components/cards/collection/CollectionList.tsx index 1cf1c1ed73c..72f65c0f5bc 100644 --- a/packages/shared/src/components/cards/collection/CollectionList.tsx +++ b/packages/shared/src/components/cards/collection/CollectionList.tsx @@ -22,8 +22,6 @@ import { isPostUpdated } from '../../../graphql/posts'; import { TimeFormatType } from '../../../lib/dateFormat'; import { useHiddenFeedbackPanel } from '../../../hooks/post/useHiddenFeedbackPanel'; import { getPostPath } from '../../../lib/links'; -import { useCardSaveOnHover } from '../../../hooks/cards/useCardSaveOnHover'; -import { CardHeaderBookmark } from '../common/CardHeaderBookmark'; export const CollectionList = forwardRef(function CollectionCard( { @@ -42,7 +40,6 @@ export const CollectionList = forwardRef(function CollectionCard( ref: Ref, ) { const isMobile = useViewSize(ViewSize.MobileL); - const saveOnHover = useCardSaveOnHover(); const image = usePostImage(post); const { title } = useTruncatedSummary(post?.title ?? ''); const wasUpdated = isPostUpdated(post); @@ -57,7 +54,6 @@ export const CollectionList = forwardRef(function CollectionCard( onCommentClick={onCommentClick} onCopyLinkClick={onCopyLinkClick} onBookmarkClick={onBookmarkClick} - bookmarkInHeader={saveOnHover} className="mt-2 justify-between tablet:mt-0" variant="list" /> @@ -105,14 +101,6 @@ export const CollectionList = forwardRef(function CollectionCard( > - ) : undefined - } post={post} metadata={{ createdAt: wasUpdated ? post.updatedAt : post.createdAt, diff --git a/packages/shared/src/components/cards/common/ActionButtons.tsx b/packages/shared/src/components/cards/common/ActionButtons.tsx index 120db8a236c..530b31c1971 100644 --- a/packages/shared/src/components/cards/common/ActionButtons.tsx +++ b/packages/shared/src/components/cards/common/ActionButtons.tsx @@ -47,8 +47,8 @@ export interface ActionButtonsProps { showDownvoteAction?: boolean; /** * `card_save_on_hover`: the card renders the bookmark in its header, so the - * bar leaves it out. Only cards that do so pass it; it is ignored when the - * flag is off. + * bar leaves it out. Only grid cards that do so pass it; it is ignored when + * the flag is off. */ bookmarkInHeader?: boolean; } @@ -89,7 +89,6 @@ const ActionButtonsV1 = ({ onDownvoteClick, variant = 'grid', showDownvoteAction = true, - bookmarkInHeader = false, }: ActionButtonsProps): ReactElement | null => { const config = variantConfig[variant]; const isFeedPreview = useFeedPreviewMode(); @@ -248,25 +247,23 @@ const ActionButtonsV1 = ({ /> )} - {!bookmarkInHeader && ( - - )} + { const saveOnHover = useCardSaveOnHover(); const useV2 = useEngagementBarV2(); const { variant = 'grid', bookmarkInHeader, ...rest } = props; - // `card_save_on_hover`: grid cards get the v2 bar at 32px; every card that - // shows the bookmark in its header leaves it out of the bar. List cards - // otherwise keep today's bar, so their rows keep their height. Run it in a - // GrowthBook namespace exclusive with `engagement_bar_v2`; for a user in - // both, this one wins on grid cards. - const inHeader = saveOnHover && !!bookmarkInHeader; + // `card_save_on_hover` only changes grid cards: the v2 bar at 32px, without + // the bookmark when the card shows it in its header. List and signal cards + // stay at control. Run it in a GrowthBook namespace exclusive with + // `engagement_bar_v2`; for a user in both, this one wins on grid cards. if (saveOnHover && variant === 'grid') { return ( { ); } if (useV2) { - return ( - - ); + return ; } - return ( - - ); + return ; }; export default ActionButtons; diff --git a/packages/shared/src/components/cards/common/list/PostCardHeader.tsx b/packages/shared/src/components/cards/common/list/PostCardHeader.tsx index f3b419534a3..83ba90e1b5d 100644 --- a/packages/shared/src/components/cards/common/list/PostCardHeader.tsx +++ b/packages/shared/src/components/cards/common/list/PostCardHeader.tsx @@ -29,8 +29,6 @@ interface CardHeaderProps { primaryAction?: ReactNode; /** The top label is the source's name, so its seal is there already. */ isSourceNamed?: boolean; - /** `card_save_on_hover`: the bookmark, right before ⋯. */ - bookmark?: ReactNode; metadata?: { topLabel?: PostMetadataProps['topLabel']; bottomLabel?: PostMetadataProps['bottomLabel']; @@ -56,7 +54,6 @@ export const PostCardHeader = ({ primaryAction, isSourceNamed = false, metadata, - bookmark, }: CardHeaderProps): ReactElement => { const isFeedPreview = useFeedPreviewMode(); const postButtonText = useReadPostButtonText(post); @@ -133,7 +130,6 @@ export const PostCardHeader = ({ openNewTab={openNewTab} /> ))} - {bookmark} )} diff --git a/packages/shared/src/components/cards/common/saveOnHover.spec.tsx b/packages/shared/src/components/cards/common/saveOnHover.spec.tsx index b21e275d31a..8e7580de441 100644 --- a/packages/shared/src/components/cards/common/saveOnHover.spec.tsx +++ b/packages/shared/src/components/cards/common/saveOnHover.spec.tsx @@ -16,12 +16,6 @@ import { FreeformGrid } from '../Freeform/FreeformGrid'; import PollGrid from '../poll/PollGrid'; import { CollectionGrid } from '../collection/CollectionGrid'; import { SocialTwitterGrid } from '../socialTwitter/SocialTwitterGrid'; -import { ArticleList } from '../article/ArticleList'; -import { ShareList } from '../share/ShareList'; -import { FreeformList } from '../Freeform/FreeformList'; -import { PollList } from '../poll/PollList'; -import { CollectionList } from '../collection/CollectionList'; -import { SocialTwitterList } from '../socialTwitter/SocialTwitterList'; import { useCardSaveOnHover } from '../../../hooks/cards/useCardSaveOnHover'; import { usePostImpressions } from '../../../hooks/post/usePostImpressions'; @@ -106,12 +100,12 @@ describe('ActionButtons with card_save_on_hover', () => { }); it.each(['list', 'signal'])( - 'keeps today’s 24px bar on %s cards', + 'leaves %s cards at control, bookmark in the bar', (variant) => { setFlag(true); const { container } = renderBar(variant); - // Without a header slot the bookmark stays in today's bar. + // Control is today's v1 bar: 24px buttons, the bookmark included. expect(barLabels(container)).toContain('Bookmark'); expect( document.querySelector(`#post-${post.id}-upvote-btn`), @@ -119,16 +113,6 @@ describe('ActionButtons with card_save_on_hover', () => { }, ); - it('leaves the bookmark out of a list bar when the card shows it in its header', () => { - setFlag(true); - const { container } = renderBar('list', true); - - expect(barLabels(container)).not.toContain('Bookmark'); - expect( - document.querySelector(`#post-${post.id}-upvote-btn`), - ).not.toHaveClass('h-8'); - }); - it('renders grid actions at 32px', () => { setFlag(true); renderBar('grid', true); @@ -159,38 +143,28 @@ const props = (cardPost: Post): PostCardProps => ({ onReadArticleClick: jest.fn(), }); -const freeformPost = { - ...post, - type: PostType.Freeform, - contentHtml: '

Post

', -} as Post; -const collectionPost = { - ...post, - type: PostType.Collection, - collectionSources: [], -} as Post; -const socialPost = { - ...sharePost, - type: PostType.SocialTwitter, - sharedPost: undefined, -} as Post; - -const cards: [string, ComponentType, Post][] = [ - ['article grid', ArticleGrid, post], - ['share grid', ShareGrid, sharePost], - ['freeform grid', FreeformGrid, freeformPost], - ['poll grid', PollGrid, pollPost], - ['collection grid', CollectionGrid, collectionPost], - ['social grid', SocialTwitterGrid, socialPost], - ['article list', ArticleList, post], - ['share list', ShareList, sharePost], - ['freeform list', FreeformList, freeformPost], - ['poll list', PollList, pollPost], - ['collection list', CollectionList, collectionPost], - ['social list', SocialTwitterList, socialPost], +const gridCards: [string, ComponentType, Post][] = [ + ['article', ArticleGrid, post], + ['share', ShareGrid, sharePost], + [ + 'freeform', + FreeformGrid, + { ...post, type: PostType.Freeform, contentHtml: '

Post

' }, + ], + ['poll', PollGrid, pollPost], + [ + 'collection', + CollectionGrid, + { ...post, type: PostType.Collection, collectionSources: [] }, + ], + [ + 'social', + SocialTwitterGrid, + { ...sharePost, type: PostType.SocialTwitter, sharedPost: undefined }, + ], ]; -describe.each(cards)('%s card', (_, Card, cardPost) => { +describe.each(gridCards)('%s grid card', (_, Card, cardPost) => { it('moves the bookmark to the header when the flag is on', () => { setFlag(true); const cardProps = props(cardPost); diff --git a/packages/shared/src/components/cards/poll/PollList.tsx b/packages/shared/src/components/cards/poll/PollList.tsx index a181524f8c7..4ffd45856b1 100644 --- a/packages/shared/src/components/cards/poll/PollList.tsx +++ b/packages/shared/src/components/cards/poll/PollList.tsx @@ -23,8 +23,6 @@ import PollOptions from './PollOptions'; import PostMetadata from '../common/PostMetadata'; import { usePollVote } from '../../../hooks/post/usePollVote'; import { getPostPath } from '../../../lib/links'; -import { useCardSaveOnHover } from '../../../hooks/cards/useCardSaveOnHover'; -import { CardHeaderBookmark } from '../common/CardHeaderBookmark'; export const PollList = forwardRef(function PollList( { @@ -44,7 +42,6 @@ export const PollList = forwardRef(function PollList( const { className, style } = domProps; const { type, pinnedAt, trending } = post; const { user } = useAuthContext(); - const saveOnHover = useCardSaveOnHover(); const { handleVote, shouldAnimateResults } = usePollVote({ post }); const onPostCardClick = (event: React.MouseEvent) => @@ -65,7 +62,6 @@ export const PollList = forwardRef(function PollList( onCommentClick={onCommentClick} onCopyLinkClick={onCopyLinkClick} onBookmarkClick={onBookmarkClick} - bookmarkInHeader={saveOnHover} variant="list" /> @@ -111,14 +107,6 @@ export const PollList = forwardRef(function PollList( > - ) : undefined - } post={{ ...post, createdAt: undefined }} openNewTab={openNewTab} postLink={post.permalink} diff --git a/packages/shared/src/components/cards/share/ShareList.tsx b/packages/shared/src/components/cards/share/ShareList.tsx index 29c002dc62f..860cfe31276 100644 --- a/packages/shared/src/components/cards/share/ShareList.tsx +++ b/packages/shared/src/components/cards/share/ShareList.tsx @@ -30,8 +30,6 @@ import { sharedPostPreviewFeature } from '../../../lib/featureManagement'; import { SharedPostPreview } from './SharedPostPreview'; import { useHiddenFeedbackPanel } from '../../../hooks/post/useHiddenFeedbackPanel'; import { getPostPath } from '../../../lib/links'; -import { useCardSaveOnHover } from '../../../hooks/cards/useCardSaveOnHover'; -import { CardHeaderBookmark } from '../common/CardHeaderBookmark'; export const ShareList = forwardRef(function ShareList( { @@ -54,7 +52,6 @@ export const ShareList = forwardRef(function ShareList( ): ReactElement { const { pinnedAt, trending, type } = post; const isMobile = useViewSize(ViewSize.MobileL); - const saveOnHover = useCardSaveOnHover(); const onPostCardClick = (event: React.MouseEvent) => onPostClick?.(post, event); const containerRef = useRef(null); @@ -81,7 +78,6 @@ export const ShareList = forwardRef(function ShareList( onCommentClick={onCommentClick} onCopyLinkClick={onCopyLinkClick} onBookmarkClick={onBookmarkClick} - bookmarkInHeader={saveOnHover} variant="list" /> @@ -155,11 +151,6 @@ export const ShareList = forwardRef(function ShareList( bookmarked={post.bookmarked} > - ) : undefined - } post={{ ...post, type: sharedPost?.type || post.type, diff --git a/packages/shared/src/components/cards/socialTwitter/SocialTwitterList.tsx b/packages/shared/src/components/cards/socialTwitter/SocialTwitterList.tsx index 79ad54bc1c5..e11760f87ea 100644 --- a/packages/shared/src/components/cards/socialTwitter/SocialTwitterList.tsx +++ b/packages/shared/src/components/cards/socialTwitter/SocialTwitterList.tsx @@ -31,8 +31,6 @@ import { } from './socialTwitterHelpers'; import { EmbeddedTweetPreview } from './EmbeddedTweetPreview'; import { getPostPath } from '../../../lib/links'; -import { useCardSaveOnHover } from '../../../hooks/cards/useCardSaveOnHover'; -import { CardHeaderBookmark } from '../common/CardHeaderBookmark'; export const SocialTwitterList = forwardRef(function SocialTwitterList( { @@ -51,7 +49,6 @@ export const SocialTwitterList = forwardRef(function SocialTwitterList( ): ReactElement { const { pinnedAt, trending, type: postType } = post; const isMobile = useViewSize(ViewSize.MobileL); - const saveOnHover = useCardSaveOnHover(); const onPostCardClick = (event: React.MouseEvent) => onPostClick?.(post, event); const containerRef = useRef(null); @@ -90,7 +87,6 @@ export const SocialTwitterList = forwardRef(function SocialTwitterList( onCommentClick={onCommentClick} onCopyLinkClick={onCopyLinkClick} onBookmarkClick={onBookmarkClick} - bookmarkInHeader={saveOnHover} variant="list" /> @@ -141,14 +137,6 @@ export const SocialTwitterList = forwardRef(function SocialTwitterList( > - ) : undefined - } post={post} metadata={{ ...metadata, diff --git a/packages/storybook/stories/experiments/CardSaveOnHover.stories.tsx b/packages/storybook/stories/experiments/CardSaveOnHover.stories.tsx index 82286883c92..38b0daf8148 100644 --- a/packages/storybook/stories/experiments/CardSaveOnHover.stories.tsx +++ b/packages/storybook/stories/experiments/CardSaveOnHover.stories.tsx @@ -16,10 +16,9 @@ import { FeatureOverrides } from '../../mock/GrowthBookProvider'; /** * `card_save_on_hover`, control vs treatment, on the production cards with - * the shared test fixtures. Treatment: the bookmark moves to the card header, - * right before ⋯, on grid and list cards alike. Grid bars keep today's order - * at 32px; list bars keep today's 24px buttons. On desktop the header bookmark - * shows on hover; on phones and tablets it is always visible. + * the shared test fixtures. Treatment: grid cards show the bookmark in the + * header on hover (between Read post and ⋯) and the bar keeps today's order + * at 32px. List cards are not part of the experiment and stay at control. */ const article: Post = { @@ -159,7 +158,7 @@ const ListCards = ({ show }: ListArgs): ReactElement => (
From c45afd7a8c8cdf92274d868d346d899247000efa Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Fri, 9 Oct 2026 10:48:15 +0300 Subject: [PATCH 4/5] fix(cards): card_save_on_hover enrolls grid renders only, keeps control's bar - Only grid bars evaluate the flag (`useCardSaveOnHover({ shouldEvaluate })`), so list and signal renders never enroll users who can't see a change. - The treatment keeps the bar control renders (v1, or v2 for engagement_bar_v2 users) at 32px instead of swapping in the v2 bar, so the test measures the bookmark move and the size only. No namespace needed. - A saved post keeps its filled bookmark visible at rest in the header. Co-Authored-By: Claude Opus 5.5 --- .../components/cards/common/ActionButtons.tsx | 93 ++++++++++++------- .../cards/common/CardHeaderBookmark.tsx | 9 +- .../cards/common/saveOnHover.spec.tsx | 53 ++++++++++- .../hooks/cards/useCardSaveOnHover.spec.ts | 57 ++++++++++++ .../src/hooks/cards/useCardSaveOnHover.ts | 14 ++- .../experiments/CardSaveOnHover.stories.tsx | 13 ++- 6 files changed, 197 insertions(+), 42 deletions(-) create mode 100644 packages/shared/src/hooks/cards/useCardSaveOnHover.spec.ts diff --git a/packages/shared/src/components/cards/common/ActionButtons.tsx b/packages/shared/src/components/cards/common/ActionButtons.tsx index 530b31c1971..09433f68675 100644 --- a/packages/shared/src/components/cards/common/ActionButtons.tsx +++ b/packages/shared/src/components/cards/common/ActionButtons.tsx @@ -11,6 +11,8 @@ import { DownvoteIcon, } from '../../icons'; import { ButtonColor, ButtonVariant } from '../../buttons/Button'; +import { ButtonSize } from '../../buttons/common'; +import { IconSize } from '../../Icon'; import { useFeedPreviewMode } from '../../../hooks'; import { UpvoteButtonIcon } from './UpvoteButtonIcon'; import { BookmarkButton } from '../../buttons'; @@ -79,6 +81,14 @@ const variantConfig = { }, } as const; +interface ActionButtonsV1Props extends ActionButtonsProps { + /** + * `card_save_on_hover` treatment on grid cards: today's bar at 32px with + * 20px icons, in the same 36px row. + */ + large?: boolean; +} + const ActionButtonsV1 = ({ post, onUpvoteClick, @@ -89,10 +99,13 @@ const ActionButtonsV1 = ({ onDownvoteClick, variant = 'grid', showDownvoteAction = true, -}: ActionButtonsProps): ReactElement | null => { + bookmarkInHeader = false, + large = false, +}: ActionButtonsV1Props): ReactElement | null => { const config = variantConfig[variant]; const isFeedPreview = useFeedPreviewMode(); - const { buttonSize, iconSize } = config; + const buttonSize = large ? ButtonSize.Small : config.buttonSize; + const iconSize = large ? IconSize.XSmall : config.iconSize; const { getUpvoteAnimation } = useBrandSponsorship(); const { @@ -193,7 +206,8 @@ const ActionButtonsV1 = ({
@@ -247,23 +261,25 @@ const ActionButtonsV1 = ({ /> )} - + {!bookmarkInHeader && ( + + )} { - const saveOnHover = useCardSaveOnHover(); - const useV2 = useEngagementBarV2(); const { variant = 'grid', bookmarkInHeader, ...rest } = props; - // `card_save_on_hover` only changes grid cards: the v2 bar at 32px, without - // the bookmark when the card shows it in its header. List and signal cards - // stay at control. Run it in a GrowthBook namespace exclusive with - // `engagement_bar_v2`; for a user in both, this one wins on grid cards. - if (saveOnHover && variant === 'grid') { + // `card_save_on_hover` only changes grid cards, so only they evaluate it: + // list and signal renders must not enroll users who never see a change. + const saveOnHover = useCardSaveOnHover({ + shouldEvaluate: variant === 'grid', + }); + const useV2 = useEngagementBarV2(); + // The treatment keeps the bar control renders (v1, or v2 for + // `engagement_bar_v2` users) and only changes its size and the bookmark's + // place, so the test measures those two things and not a bar swap. + const treatment = saveOnHover && variant === 'grid'; + const inHeader = treatment && !!bookmarkInHeader; + if (useV2) { return ( ); } - if (useV2) { - return ; - } - return ; + return ( + + ); }; export default ActionButtons; diff --git a/packages/shared/src/components/cards/common/CardHeaderBookmark.tsx b/packages/shared/src/components/cards/common/CardHeaderBookmark.tsx index 65e4bae8cf6..37a715afc24 100644 --- a/packages/shared/src/components/cards/common/CardHeaderBookmark.tsx +++ b/packages/shared/src/components/cards/common/CardHeaderBookmark.tsx @@ -19,7 +19,8 @@ interface CardHeaderBookmarkProps { * post and ⋯. Hidden elements are not in the tab order, so for keyboard users * it appears once focus is already inside the card (the card's link or a bar * button) and is reachable from there; Tab cannot land on it from outside. - * Always visible on touch screens. + * Always visible on touch screens, and always visible once the post is saved, + * so the feed keeps showing what you bookmarked, as control's bar does. */ export const CardHeaderBookmark = ({ post, @@ -42,8 +43,10 @@ export const CardHeaderBookmark = ({ onClick={onToggleBookmark} buttonClassName={classNames( 'pointer-events-auto', - visibleOnGroupHover, - 'laptop:mouse:group-focus-within:visible', + // `visible` also overrides PostCardHeader's hidden-at-rest container. + post.bookmarked + ? 'visible' + : [visibleOnGroupHover, 'laptop:mouse:group-focus-within:visible'], className, )} /> diff --git a/packages/shared/src/components/cards/common/saveOnHover.spec.tsx b/packages/shared/src/components/cards/common/saveOnHover.spec.tsx index 8e7580de441..300d90eff68 100644 --- a/packages/shared/src/components/cards/common/saveOnHover.spec.tsx +++ b/packages/shared/src/components/cards/common/saveOnHover.spec.tsx @@ -18,6 +18,7 @@ import { CollectionGrid } from '../collection/CollectionGrid'; import { SocialTwitterGrid } from '../socialTwitter/SocialTwitterGrid'; import { useCardSaveOnHover } from '../../../hooks/cards/useCardSaveOnHover'; import { usePostImpressions } from '../../../hooks/post/usePostImpressions'; +import { useEngagementBarV2 } from '../../../hooks/useEngagementBarV2'; jest.mock('next/router', () => ({ useRouter: () => ({ pathname: '/', query: {} }), @@ -31,11 +32,21 @@ jest.mock('../../../hooks/post/usePostImpressions', () => ({ usePostImpressions: jest.fn(), })); +jest.mock('../../../hooks/useEngagementBarV2', () => ({ + useEngagementBarV2: jest.fn(), +})); + +// Like the real hook: an unevaluated flag reads as control. const setFlag = (on: boolean) => - jest.mocked(useCardSaveOnHover).mockReturnValue(on); + jest + .mocked(useCardSaveOnHover) + .mockImplementation( + ({ shouldEvaluate = true } = {}) => on && shouldEvaluate, + ); beforeEach(() => { jest.clearAllMocks(); + jest.mocked(useEngagementBarV2).mockReturnValue(false); jest.mocked(usePostImpressions).mockReturnValue({ showImpressions: true, impressions: 1000, @@ -105,6 +116,11 @@ describe('ActionButtons with card_save_on_hover', () => { setFlag(true); const { container } = renderBar(variant); + // Not evaluated, so these renders never enroll anyone. + expect(jest.mocked(useCardSaveOnHover)).toHaveBeenCalledWith({ + shouldEvaluate: false, + }); + // Control is today's v1 bar: 24px buttons, the bookmark included. expect(barLabels(container)).toContain('Bookmark'); expect( @@ -117,9 +133,35 @@ describe('ActionButtons with card_save_on_hover', () => { setFlag(true); renderBar('grid', true); + expect(jest.mocked(useCardSaveOnHover)).toHaveBeenCalledWith({ + shouldEvaluate: true, + }); expect(screen.getByRole('button', { name: 'Upvote' })).toHaveClass('h-8'); }); + it('keeps the bar control renders, only bigger', () => { + setFlag(true); + renderBar('grid', true); + + // Today's v1 bar, not the v2 CardAction bar. + expect(document.querySelector(`#post-${post.id}-upvote-btn`)).toHaveClass( + 'btn-tertiary-avocado', + ); + expect(document.querySelector('.card-action-content')).toBeNull(); + }); + + it('keeps the v2 bar for engagement_bar_v2 users, at 32px', () => { + jest.mocked(useEngagementBarV2).mockReturnValue(true); + setFlag(true); + const { container } = renderBar('grid', true); + + expect(document.querySelector('.card-action-content')).not.toBeNull(); + expect(document.querySelector(`#post-${post.id}-upvote-btn`)).toHaveClass( + 'h-8', + ); + expect(barLabels(container)).not.toContain('Bookmark'); + }); + it('leaves today’s bar untouched when the flag is off', () => { setFlag(false); renderBar('grid', true); @@ -183,6 +225,15 @@ describe.each(gridCards)('%s grid card', (_, Card, cardPost) => { ); }); + it('keeps a saved post’s bookmark visible at rest', () => { + setFlag(true); + render(wrap()); + + const bookmark = screen.getByRole('button', { name: 'Remove bookmark' }); + expect(bookmark).toHaveClass('visible'); + expect(bookmark).not.toHaveClass('laptop:mouse:invisible'); + }); + it('keeps the bookmark in the bar when the flag is off', () => { setFlag(false); render(wrap()); diff --git a/packages/shared/src/hooks/cards/useCardSaveOnHover.spec.ts b/packages/shared/src/hooks/cards/useCardSaveOnHover.spec.ts new file mode 100644 index 00000000000..dd41985a09c --- /dev/null +++ b/packages/shared/src/hooks/cards/useCardSaveOnHover.spec.ts @@ -0,0 +1,57 @@ +import { renderHook } from '@testing-library/react'; +import { useCardSaveOnHover } from './useCardSaveOnHover'; +import { useConditionalFeature } from '../useConditionalFeature'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { featureCardSaveOnHover } from '../../lib/featureManagement'; + +jest.mock('../useConditionalFeature', () => ({ + useConditionalFeature: jest.fn(), +})); + +jest.mock('../../contexts/AuthContext', () => ({ + useAuthContext: jest.fn(), +})); + +const setUser = (loggedIn: boolean) => + jest.mocked(useAuthContext).mockReturnValue({ + isAuthReady: true, + user: loggedIn ? { id: 'u1' } : undefined, + } as unknown as ReturnType); + +const evaluated = () => + jest.mocked(useConditionalFeature).mock.calls.at(-1)?.[0].shouldEvaluate; + +beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(useConditionalFeature).mockReturnValue({ + value: true, + isLoading: false, + }); +}); + +describe('useCardSaveOnHover', () => { + it('evaluates the flag for a logged-in user', () => { + setUser(true); + const { result } = renderHook(() => useCardSaveOnHover()); + + expect(evaluated()).toBe(true); + expect(jest.mocked(useConditionalFeature)).toHaveBeenCalledWith( + expect.objectContaining({ feature: featureCardSaveOnHover }), + ); + expect(result.current).toBe(true); + }); + + it('never evaluates for anonymous visitors', () => { + setUser(false); + renderHook(() => useCardSaveOnHover()); + + expect(evaluated()).toBe(false); + }); + + it('does not evaluate where the caller says it cannot matter', () => { + setUser(true); + renderHook(() => useCardSaveOnHover({ shouldEvaluate: false })); + + expect(evaluated()).toBe(false); + }); +}); diff --git a/packages/shared/src/hooks/cards/useCardSaveOnHover.ts b/packages/shared/src/hooks/cards/useCardSaveOnHover.ts index ee9eb2932e1..25ed39e5a2c 100644 --- a/packages/shared/src/hooks/cards/useCardSaveOnHover.ts +++ b/packages/shared/src/hooks/cards/useCardSaveOnHover.ts @@ -2,16 +2,26 @@ import { useAuthContext } from '../../contexts/AuthContext'; import { useConditionalFeature } from '../useConditionalFeature'; import { featureCardSaveOnHover } from '../../lib/featureManagement'; +interface UseCardSaveOnHoverProps { + /** + * Evaluate only where the treatment changes something (grid cards), so + * users who never see a difference are not enrolled. + */ + shouldEvaluate?: boolean; +} + /** * `card_save_on_hover`: grid cards show the bookmark in their header on * hover, and the action bar keeps today's order at 32px. Evaluated for * logged-in users only, so anonymous visitors are never enrolled. */ -export const useCardSaveOnHover = (): boolean => { +export const useCardSaveOnHover = ({ + shouldEvaluate = true, +}: UseCardSaveOnHoverProps = {}): boolean => { const { isAuthReady, user } = useAuthContext(); const { value } = useConditionalFeature({ feature: featureCardSaveOnHover, - shouldEvaluate: isAuthReady && !!user, + shouldEvaluate: isAuthReady && !!user && shouldEvaluate, }); return value; }; diff --git a/packages/storybook/stories/experiments/CardSaveOnHover.stories.tsx b/packages/storybook/stories/experiments/CardSaveOnHover.stories.tsx index 38b0daf8148..5e0364c0e61 100644 --- a/packages/storybook/stories/experiments/CardSaveOnHover.stories.tsx +++ b/packages/storybook/stories/experiments/CardSaveOnHover.stories.tsx @@ -17,8 +17,9 @@ import { FeatureOverrides } from '../../mock/GrowthBookProvider'; /** * `card_save_on_hover`, control vs treatment, on the production cards with * the shared test fixtures. Treatment: grid cards show the bookmark in the - * header on hover (between Read post and ⋯) and the bar keeps today's order - * at 32px. List cards are not part of the experiment and stay at control. + * header on hover (between Read post and ⋯; a saved post keeps it visible) and + * the bar keeps today's bar and order at 32px. List cards are not part of the + * experiment and stay at control. */ const article: Post = { @@ -49,8 +50,16 @@ const collection: Post = { numCollectionSources: 3, } as Post; +const saved: Post = { + ...article, + id: 'story-saved', + title: 'A post you already saved', + bookmarked: true, +} as Post; + const CARDS: { label: string; post: Post }[] = [ { label: 'Article', post: article }, + { label: 'Saved article', post: saved }, { label: 'Share', post: sharePost }, { label: 'Post', post: freeform }, { label: 'Video', post: video }, From ac7a049861aeb2585b1a139112679168e1129e50 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Fri, 9 Oct 2026 17:59:11 +0300 Subject: [PATCH 5/5] fix(cards): card_save_on_hover header bookmark stays hover-only when saved Product call: the header is empty at rest on desktop whether or not the post is saved, so the saved-state exception is removed. Co-Authored-By: Claude Opus 5.5 --- .../src/components/cards/common/CardHeaderBookmark.tsx | 10 ++++------ .../src/components/cards/common/saveOnHover.spec.tsx | 5 ++--- .../stories/experiments/CardSaveOnHover.stories.tsx | 2 +- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/shared/src/components/cards/common/CardHeaderBookmark.tsx b/packages/shared/src/components/cards/common/CardHeaderBookmark.tsx index 37a715afc24..ea80a0299b8 100644 --- a/packages/shared/src/components/cards/common/CardHeaderBookmark.tsx +++ b/packages/shared/src/components/cards/common/CardHeaderBookmark.tsx @@ -19,8 +19,8 @@ interface CardHeaderBookmarkProps { * post and ⋯. Hidden elements are not in the tab order, so for keyboard users * it appears once focus is already inside the card (the card's link or a bar * button) and is reachable from there; Tab cannot land on it from outside. - * Always visible on touch screens, and always visible once the post is saved, - * so the feed keeps showing what you bookmarked, as control's bar does. + * Always visible on touch screens. Saved or not, it stays hover-only on + * desktop so the header is empty at rest (product call). */ export const CardHeaderBookmark = ({ post, @@ -43,10 +43,8 @@ export const CardHeaderBookmark = ({ onClick={onToggleBookmark} buttonClassName={classNames( 'pointer-events-auto', - // `visible` also overrides PostCardHeader's hidden-at-rest container. - post.bookmarked - ? 'visible' - : [visibleOnGroupHover, 'laptop:mouse:group-focus-within:visible'], + visibleOnGroupHover, + 'laptop:mouse:group-focus-within:visible', className, )} /> diff --git a/packages/shared/src/components/cards/common/saveOnHover.spec.tsx b/packages/shared/src/components/cards/common/saveOnHover.spec.tsx index 300d90eff68..8fca4739895 100644 --- a/packages/shared/src/components/cards/common/saveOnHover.spec.tsx +++ b/packages/shared/src/components/cards/common/saveOnHover.spec.tsx @@ -225,13 +225,12 @@ describe.each(gridCards)('%s grid card', (_, Card, cardPost) => { ); }); - it('keeps a saved post’s bookmark visible at rest', () => { + it('keeps a saved post’s bookmark hover-only too', () => { setFlag(true); render(wrap()); const bookmark = screen.getByRole('button', { name: 'Remove bookmark' }); - expect(bookmark).toHaveClass('visible'); - expect(bookmark).not.toHaveClass('laptop:mouse:invisible'); + expect(bookmark).toHaveClass('laptop:mouse:invisible'); }); it('keeps the bookmark in the bar when the flag is off', () => { diff --git a/packages/storybook/stories/experiments/CardSaveOnHover.stories.tsx b/packages/storybook/stories/experiments/CardSaveOnHover.stories.tsx index 5e0364c0e61..d1ed349aeb8 100644 --- a/packages/storybook/stories/experiments/CardSaveOnHover.stories.tsx +++ b/packages/storybook/stories/experiments/CardSaveOnHover.stories.tsx @@ -17,7 +17,7 @@ import { FeatureOverrides } from '../../mock/GrowthBookProvider'; /** * `card_save_on_hover`, control vs treatment, on the production cards with * the shared test fixtures. Treatment: grid cards show the bookmark in the - * header on hover (between Read post and ⋯; a saved post keeps it visible) and + * header on hover (between Read post and ⋯, saved or not) and * the bar keeps today's bar and order at 32px. List cards are not part of the * experiment and stay at control. */