From 97071452df5ea2f67dd1aea766912496751d1854 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 8 Sep 2026 23:07:08 +0300 Subject: [PATCH 1/5] refactor(cards): give the wide card a hero variant and share the type maps Groundwork for the feed hero section. The featured-wide cards gain a `hero` prop: the cover fills its column instead of being letterboxed, the copy sizes itself from container queries on the card's own width rather than the viewport's, and the summary is clamped to the whole lines the fixed card height actually leaves, so the action row keeps its place under a headline of any length. `PostTypeToTagCard` and `PostTypeToTagList` move out of FeedItemComponent into gridCards.ts and listCards.ts so the hero can reach the same mapping rather than keeping a second copy of it. The feed gains three settings a surface above it can use to make room: `disableHighlightCards`, `skipFirstAd` and `deferWideCards`. The first ad is shifted rather than dropped, so the creative moves to the next slot instead of being fetched and discarded. Co-Authored-By: Claude Opus 5 --- packages/shared/src/components/Feed.spec.tsx | 93 +++++++++++++++++++ packages/shared/src/components/Feed.tsx | 12 +++ .../src/components/FeedItemComponent.tsx | 45 +-------- .../Freeform/FreeformFeaturedWideGridCard.tsx | 50 ++++++++-- .../ArticleFeaturedWideGridCard.spec.tsx | 31 ++++++- .../article/ArticleFeaturedWideGridCard.tsx | 52 +++++++++-- .../CollectionFeaturedWideGridCard.tsx | 50 ++++++++-- .../src/components/cards/common/Card.tsx | 11 +++ .../cards/common/FeaturedWideImageColumn.tsx | 20 +++- .../common/FeaturedWideTextContainer.tsx | 28 ++++-- .../components/cards/common/featuredWide.ts | 84 +++++++++++++++++ .../src/components/cards/common/gridCards.ts | 23 +++++ .../src/components/cards/common/listCards.ts | 23 +++++ .../src/components/cards/highlight/common.tsx | 70 ++++++++++---- .../cards/share/ShareFeaturedWideGridCard.tsx | 54 +++++++++-- .../src/components/feeds/FeedContainer.tsx | 10 +- packages/shared/src/hooks/useFeed.ts | 38 +++++++- .../shared/src/hooks/useFittedLineClamp.ts | 79 ++++++++++++++++ .../src/lib/feedHighlightColSpan.spec.ts | 34 +++++++ .../shared/src/lib/feedHighlightColSpan.ts | 11 +++ 20 files changed, 715 insertions(+), 103 deletions(-) create mode 100644 packages/shared/src/components/cards/common/gridCards.ts create mode 100644 packages/shared/src/components/cards/common/listCards.ts create mode 100644 packages/shared/src/hooks/useFittedLineClamp.ts diff --git a/packages/shared/src/components/Feed.spec.tsx b/packages/shared/src/components/Feed.spec.tsx index 515c80bc929..de6fbd6dba4 100644 --- a/packages/shared/src/components/Feed.spec.tsx +++ b/packages/shared/src/components/Feed.spec.tsx @@ -556,6 +556,64 @@ describe('Feed logged in', () => { ).toEqual(['postItem', 'postItem', 'highlightItem', 'postItem']); }); + it('should drop feedV2 highlights when the surface shows them itself', async () => { + renderComponent( + [ + { + request: { + query: FEED_V2_QUERY, + variables, + }, + result: { + data: { + page: { + pageInfo: defaultFeedPage.pageInfo, + edges: [ + { + node: { + __typename: 'FeedPostItem', + post: defaultFeedPage.edges[0].node, + feedMeta: defaultFeedPage.edges[0].node.feedMeta ?? null, + }, + }, + { + node: { + __typename: 'FeedHighlightsItem', + feedMeta: null, + highlights: [ + { + id: 'highlight-1', + channel: 'agents', + headline: 'The first highlight', + highlightedAt: '2026-04-05T09:00:00.000Z', + post: { + id: defaultFeedPage.edges[0].node.id, + commentsPermalink: + defaultFeedPage.edges[0].node.commentsPermalink, + }, + }, + ], + }, + }, + ], + }, + }, + }, + }, + ], + defaultUser, + SharedFeedPage.MyFeed, + FEED_V2_QUERY, + { disableHighlightCards: true }, + ); + + await waitForNock(); + + expect(await screen.findByTestId('postItem')).toBeInTheDocument(); + expect(screen.queryByTestId('highlightItem')).not.toBeInTheDocument(); + expect(screen.queryByText('Happening Now')).not.toBeInTheDocument(); + }); + it('should send upvote mutation', async () => { let mutationCalled = false; renderComponent([ @@ -1889,6 +1947,7 @@ interface HighlightLayoutRenderParams { briefBannerPage?: number; staticAd?: { ad: Ad; index: number }; disableAds?: boolean; + skipFirstAd?: boolean; user?: LoggedUser; isHorizontal?: boolean; feedName?: AllFeedPages; @@ -1906,6 +1965,7 @@ const renderWithHighlightLayout = ({ briefBannerPage, staticAd, disableAds, + skipFirstAd, user = defaultUser, isHorizontal, feedName = SharedFeedPage.MyFeed, @@ -2012,6 +2072,7 @@ const renderWithHighlightLayout = ({ variables={variables} staticAd={staticAd} disableAds={disableAds} + skipFirstAd={skipFirstAd} isHorizontal={isHorizontal} /> @@ -2096,6 +2157,38 @@ describe('Feed ad cadence with highlight cards', () => { expect(order.slice(2).every((t) => t === 'postItem')).toBe(true); }); + // The survivor keeps its index: the dropped ad no longer occupies a cell + // against the cadence, so the next slot comes due one post later. + it('drops the first ad slot when the surface shows one above the feed', async () => { + const posts = Array.from({ length: 20 }, (_, i) => buildPost(`p${i}`)); + + renderWithHighlightLayout({ + posts, + highlightEnabled: false, + skipFirstAd: true, + }); + + const order = await getFeedItemTestIds(); + const adIndices = order + .map((type, index) => (type === 'adItem' ? index : -1)) + .filter((index) => index >= 0); + + expect(adIndices).toEqual([12]); + }); + + it('keeps both ad slots when nothing is shown above the feed', async () => { + const posts = Array.from({ length: 20 }, (_, i) => buildPost(`p${i}`)); + + renderWithHighlightLayout({ posts, highlightEnabled: false }); + + const order = await getFeedItemTestIds(2); + const adIndices = order + .map((type, index) => (type === 'adItem' ? index : -1)) + .filter((index) => index >= 0); + + expect(adIndices).toEqual([4, 12]); + }); + // Same fixture, flag off: layout disabled → wide card collapses to 1 cell // (every item contributes 1 to visualCellsSoFar). Ad falls at the original // 4th position. diff --git a/packages/shared/src/components/Feed.tsx b/packages/shared/src/components/Feed.tsx index c4fe7262a42..e3c42ca11f6 100644 --- a/packages/shared/src/components/Feed.tsx +++ b/packages/shared/src/components/Feed.tsx @@ -102,6 +102,12 @@ export interface FeedProps showSearch?: boolean; actionButtons?: ReactNode; disableAds?: boolean; + /** The surface shows the highlights itself, so keep them out of the grid. */ + disableHighlightCards?: boolean; + /** The surface shows an ad above the feed, so drop the grid's first one. */ + skipFirstAd?: boolean; + /** The surface leads with a featured card, so keep wide ones out of row one. */ + deferWideCards?: boolean; staticAd?: { ad: Ad; index: number }; disableAdRefresh?: boolean; allowFetchMore?: boolean; @@ -211,6 +217,9 @@ export default function Feed({ shortcuts, actionButtons, disableAds, + disableHighlightCards, + skipFirstAd, + deferWideCards, staticAd, disableAdRefresh = false, allowFetchMore, @@ -376,6 +385,9 @@ export default function Feed({ excludePinnedPosts, settings: { disableAds, + disableHighlightCards, + skipFirstAd, + deferWideCards, staticAd, adPostLength: isSquadFeed ? 2 : undefined, feedName, diff --git a/packages/shared/src/components/FeedItemComponent.tsx b/packages/shared/src/components/FeedItemComponent.tsx index 95d1321a2e8..c1f1b4bf243 100644 --- a/packages/shared/src/components/FeedItemComponent.tsx +++ b/packages/shared/src/components/FeedItemComponent.tsx @@ -14,22 +14,18 @@ import { LogEvent, Origin, TargetType } from '../lib/log'; import type { SearchLogExtra } from '../lib/searchLog'; import type { UseVotePost } from '../hooks'; import { useFeedLayout } from '../hooks'; -import { CollectionList } from './cards/collection/CollectionList'; import { FeedItemType } from './cards/common/common'; import { AdGrid } from './cards/ad/AdGrid'; import { AdList } from './cards/ad/AdList'; import { SignalAdList } from './cards/ad/SignalAdList'; import type { AdCardProps } from './cards/ad/common/common'; -import { FreeformGrid } from './cards/Freeform/FreeformGrid'; -import { FreeformList } from './cards/Freeform/FreeformList'; import type { PostClick } from '../lib/click'; import { ArticleList } from './cards/article/ArticleList'; import { ArticleGrid } from './cards/article/ArticleGrid'; +import { PostTypeToGridCard } from './cards/common/gridCards'; import type { FeaturedWideColSpan } from './cards/common/featuredWide'; import { PostTypeToWideCard } from './cards/common/wideCards'; -import { ShareGrid } from './cards/share/ShareGrid'; -import { ShareList } from './cards/share/ShareList'; -import { CollectionGrid } from './cards/collection'; +import { PostTypeToListCard } from './cards/common/listCards'; import type { UseBookmarkPost } from '../hooks/useBookmarkPost'; import { AdActions } from '../lib/ads'; import { useFeedCardContext } from '../features/posts/FeedCardContext'; @@ -38,7 +34,6 @@ import { AdMeasurement } from './cards/ad/common/AdMeasurement'; import { AdViewability } from './cards/ad/common/AdViewability'; import type { ViewabilityData } from '../features/monetization/viewability'; import { viewabilityLogExtra } from '../features/monetization/viewability'; -import { BriefCard } from './cards/brief/BriefCard/BriefCard'; import { ActivePostContextProvider } from '../contexts/ActivePostContext'; import { LogExtraContextProvider } from '../contexts/LogExtraContext'; import { SquadAdList } from './cards/ad/squad/SquadAdList'; @@ -50,10 +45,6 @@ import { } from '../lib/engagementAds'; import { useEngagementAdsContext } from '../contexts/EngagementAdsContext'; import { useLogContext } from '../contexts/LogContext'; -import PollGrid from './cards/poll/PollGrid'; -import { PollList } from './cards/poll/PollList'; -import { SocialTwitterGrid } from './cards/socialTwitter/SocialTwitterGrid'; -import { SocialTwitterList } from './cards/socialTwitter/SocialTwitterList'; import { SignalList } from './cards/common/list/SignalList'; import { OtherFeedPage } from '../lib/query'; import { isSourceSquadOrMachine } from '../graphql/sources'; @@ -121,34 +112,6 @@ export function getFeedItemKey(item: FeedItem, index: number): string { } } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const PostTypeToTagCard: Record> = { - [PostType.Article]: ArticleGrid, - [PostType.Share]: ShareGrid, - [PostType.Welcome]: FreeformGrid, - [PostType.Freeform]: FreeformGrid, - [PostType.VideoYouTube]: ArticleGrid, - [PostType.Collection]: CollectionGrid, - [PostType.Brief]: BriefCard, - [PostType.Poll]: PollGrid, - [PostType.SocialTwitter]: SocialTwitterGrid, - [PostType.Digest]: ArticleGrid, -}; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const PostTypeToTagList: Record> = { - [PostType.Article]: ArticleList, - [PostType.Share]: ShareList, - [PostType.Welcome]: FreeformList, - [PostType.Freeform]: FreeformList, - [PostType.VideoYouTube]: ArticleList, - [PostType.Collection]: CollectionList, - [PostType.Brief]: BriefCard, - [PostType.Poll]: PollList, - [PostType.SocialTwitter]: SocialTwitterList, - [PostType.Digest]: ArticleList, -}; - const getPostTypeForCard = (post?: Post): PostType => { if (!post) { return PostType.Article; @@ -176,7 +139,7 @@ const getTags = ({ }: GetTagsProps) => { const useListCards = isListFeedLayout || shouldUseListMode; const isSignalFeed = feedName === OtherFeedPage.AgentsVibes; - const listPostTag = isSignalFeed ? SignalList : PostTypeToTagList[postType]; + const listPostTag = isSignalFeed ? SignalList : PostTypeToListCard[postType]; const listPlaceholderTag = isSignalFeed ? SignalPlaceholderList : PlaceholderList; @@ -185,7 +148,7 @@ const getTags = ({ return { PostTag: useListCards ? listPostTag ?? ArticleList - : PostTypeToTagCard[postType] ?? ArticleGrid, + : PostTypeToGridCard[postType] ?? ArticleGrid, AdTag: useListCards ? listAdTag : AdGrid, SquadAdTag: useListCards ? SquadAdList : SquadAdGrid, PlaceholderTag: useListCards ? listPlaceholderTag : PlaceholderGrid, diff --git a/packages/shared/src/components/cards/Freeform/FreeformFeaturedWideGridCard.tsx b/packages/shared/src/components/cards/Freeform/FreeformFeaturedWideGridCard.tsx index de22714156d..207d31a8528 100644 --- a/packages/shared/src/components/cards/Freeform/FreeformFeaturedWideGridCard.tsx +++ b/packages/shared/src/components/cards/Freeform/FreeformFeaturedWideGridCard.tsx @@ -11,11 +11,21 @@ import { useCardCover } from '../../../hooks/feed/useCardCover'; import { stripHtmlTags } from '../../../lib/strings'; import { HighlightChip } from '../common/HighlightChip'; import type { FeaturedWideCardProps } from '../common/featuredWide'; -import { INNER_GRID_COLS } from '../common/featuredWide'; +import { + DESCRIPTION_CLASS_NAME, + featuredWideGridClass, + featuredWideTextColClass, + HERO_DESCRIPTION_CLASS_NAME, + HERO_DESCRIPTION_MAX_LINES, + HERO_TEXT_FIT_CLASS_NAME, + HERO_TITLE_CLASS_NAME, + TITLE_CLASS_NAME, +} from '../common/featuredWide'; import { FeaturedWideCardShell } from '../common/FeaturedWideCardShell'; import { FeaturedWideImageColumn } from '../common/FeaturedWideImageColumn'; import { FeaturedWideActions } from '../common/FeaturedWideActions'; import { FeaturedWideTextContainer } from '../common/FeaturedWideTextContainer'; +import { useFittedLineClamp } from '../../../hooks/useFittedLineClamp'; export const FreeformFeaturedWideGridCard = forwardRef( function FreeformFeaturedWideGridCard( @@ -34,6 +44,7 @@ export const FreeformFeaturedWideGridCard = forwardRef( eagerLoadImage = false, enableSourceHeader = false, wideColSpan = 2, + hero, }: FeaturedWideCardProps, ref: Ref, ): ReactElement { @@ -42,6 +53,8 @@ export const FreeformFeaturedWideGridCard = forwardRef( const image = usePostImage(post); const significance = post.hero?.significance; const { overlay } = useCardCover({ post, onShare }); + const hasMedia = !!image || !!overlay; + const textFit = useFittedLineClamp(HERO_DESCRIPTION_MAX_LINES); const description = useMemo( () => stripHtmlTags(post.contentHtml ?? '').trim(), [post.contentHtml], @@ -61,16 +74,29 @@ export const FreeformFeaturedWideGridCard = forwardRef(
-
- +
+ -

+

{title}

@@ -87,7 +113,16 @@ export const FreeformFeaturedWideGridCard = forwardRef( className="mt-1" /> {!!description && ( -

+

{description}

)} @@ -101,11 +136,12 @@ export const FreeformFeaturedWideGridCard = forwardRef( onDownvoteClick={onDownvoteClick} />
- {(!!image || !!overlay) && ( + {hasMedia && ( diff --git a/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.spec.tsx b/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.spec.tsx index 25b111db9b6..27f5fc284b5 100644 --- a/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.spec.tsx +++ b/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.spec.tsx @@ -7,6 +7,7 @@ import type { NextRouter } from 'next/router'; import { useRouter } from 'next/router'; import post from '../../../../__tests__/fixture/post'; import type { PostCardProps } from '../common/common'; +import type { FeaturedWideColSpan } from '../common/featuredWide'; import type { Post } from '../../../graphql/posts'; import type { PostHero, PostHeroSignificance } from '../../../graphql/types'; import { TestBootProvider } from '../../../../__tests__/helpers/boot'; @@ -59,7 +60,9 @@ const makeHero = (significance: PostHeroSignificance | null): PostHero | null => : null; const renderComponent = ( - props: Partial = {}, + props: Partial< + PostCardProps & { wideColSpan?: FeaturedWideColSpan; hero?: boolean } + > = {}, ): RenderResult => { // HighlightChip short-circuits when the experiment flag is off; the // chip-label tests need it on, so override the GrowthBook value here. @@ -107,3 +110,29 @@ it('renders no chip when post has no highlight', () => { expect(screen.queryByText('Major')).not.toBeInTheDocument(); expect(screen.queryByText('Notable')).not.toBeInTheDocument(); }); + +describe('hero sizing', () => { + // The shared fixture has no summary, and the summary is the element under + // test here. + const summarised: Post = { ...post, summary: 'What the post is about.' }; + const summaryOf = (): HTMLElement => + screen.getByText(summarised.summary as string); + + it('lets the summary give way so the action row keeps its place', () => { + renderComponent({ post: summarised, hero: true }); + + const summary = summaryOf(); + // Every element is `flex-shrink: 0` by default in base.css, so both have to + // opt back in or the actions are pushed out of the fixed-height card. + expect(summary).toHaveClass('shrink'); + expect(summary.parentElement).toHaveClass('shrink', 'min-h-0'); + }); + + it('leaves the in-feed card unable to shrink its summary', () => { + renderComponent({ post: summarised, wideColSpan: 2 }); + + const summary = summaryOf(); + expect(summary).not.toHaveClass('shrink'); + expect(summary.parentElement).not.toHaveClass('shrink'); + }); +}); diff --git a/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx b/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx index 5e2b309e09c..faeb4f69fc6 100644 --- a/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx +++ b/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx @@ -15,11 +15,21 @@ import { useCardCover } from '../../../hooks/feed/useCardCover'; import { stripHtmlTags } from '../../../lib/strings'; import { HighlightChip } from '../common/HighlightChip'; import type { FeaturedWideCardProps } from '../common/featuredWide'; -import { INNER_GRID_COLS } from '../common/featuredWide'; +import { + DESCRIPTION_CLASS_NAME, + featuredWideGridClass, + featuredWideTextColClass, + HERO_DESCRIPTION_CLASS_NAME, + HERO_DESCRIPTION_MAX_LINES, + HERO_TEXT_FIT_CLASS_NAME, + HERO_TITLE_CLASS_NAME, + TITLE_CLASS_NAME, +} from '../common/featuredWide'; import { FeaturedWideCardShell } from '../common/FeaturedWideCardShell'; import { FeaturedWideImageColumn } from '../common/FeaturedWideImageColumn'; import { FeaturedWideActions } from '../common/FeaturedWideActions'; import { FeaturedWideTextContainer } from '../common/FeaturedWideTextContainer'; +import { useFittedLineClamp } from '../../../hooks/useFittedLineClamp'; export const ArticleFeaturedWideGridCard = forwardRef( function ArticleFeaturedWideGridCard( @@ -39,6 +49,7 @@ export const ArticleFeaturedWideGridCard = forwardRef( domProps = {}, eagerLoadImage = false, wideColSpan = 2, + hero, }: FeaturedWideCardProps, ref: Ref, ): ReactElement { @@ -48,6 +59,10 @@ export const ArticleFeaturedWideGridCard = forwardRef( const isVideoType = isVideoPost(post); const image = usePostImage(post); const { overlay } = useCardCover({ post, onShare }); + const hasMedia = !!image || !!overlay; + // The headline keeps every line it needs; the summary takes what is left + // of the hero's fixed height, in whole lines. + const textFit = useFittedLineClamp(HERO_DESCRIPTION_MAX_LINES); const significance = post.hero?.significance; const isTweetPost = post.type === PostType.SocialTwitter || @@ -97,7 +112,10 @@ export const ArticleFeaturedWideGridCard = forwardRef( const standardContent = ( <> - + -

+

{title}

@@ -122,7 +145,16 @@ export const ArticleFeaturedWideGridCard = forwardRef( className="mt-1" /> {!!description && ( -

+

{description}

)} @@ -152,17 +184,23 @@ export const ArticleFeaturedWideGridCard = forwardRef(
-
+
{showFeedback ? feedbackContent : standardContent}
- {(!!image || !!overlay) && ( + {hasMedia && ( , ): ReactElement { @@ -43,6 +54,8 @@ export const CollectionFeaturedWideGridCard = forwardRef( const significance = post.hero?.significance; const wasUpdated = isPostUpdated(post); const { overlay } = useCardCover({ post, onShare }); + const hasMedia = !!image || !!overlay; + const textFit = useFittedLineClamp(HERO_DESCRIPTION_MAX_LINES); return ( -
- +
+ -

+

{title}

@@ -85,7 +111,16 @@ export const CollectionFeaturedWideGridCard = forwardRef( className="mt-1" /> {!!post.summary && ( -

+

{post.summary}

)} @@ -99,11 +134,12 @@ export const CollectionFeaturedWideGridCard = forwardRef( onDownvoteClick={onDownvoteClick} />
- {(!!image || !!overlay) && ( + {hasMedia && ( diff --git a/packages/shared/src/components/cards/common/Card.tsx b/packages/shared/src/components/cards/common/Card.tsx index c3ccaf13ada..89d3e91bc8b 100644 --- a/packages/shared/src/components/cards/common/Card.tsx +++ b/packages/shared/src/components/cards/common/Card.tsx @@ -54,6 +54,17 @@ const cardClassess = export const Card = classed('article', styles.card, cardClassess); +/** + * A card without its chrome, for surfaces that sit on the page background + * rather than in the feed grid. Keeps the module class, which is what routes + * pointer events past the card body to the links inside it. + */ +export const FlatCard = classed( + 'article', + styles.card, + 'relative flex flex-col', +); + export const ClickableCard = classed('article', cardClassess); export const ChecklistCardComponent = classed( diff --git a/packages/shared/src/components/cards/common/FeaturedWideImageColumn.tsx b/packages/shared/src/components/cards/common/FeaturedWideImageColumn.tsx index 4dbe756565a..f6a5d021be7 100644 --- a/packages/shared/src/components/cards/common/FeaturedWideImageColumn.tsx +++ b/packages/shared/src/components/cards/common/FeaturedWideImageColumn.tsx @@ -5,12 +5,19 @@ import { HIGH_PRIORITY_IMAGE_PROPS, Image, ImageType } from '../../image/Image'; import { PlayIcon } from '../../icons'; import { IconSize } from '../../Icon'; import type { FeaturedWideColSpan } from './featuredWide'; -import { IMAGE_COL_SPAN } from './featuredWide'; +import { featuredWideImageColClass } from './featuredWide'; export type FeaturedWideImageColumnProps = { image?: string; alt: string; wideColSpan: FeaturedWideColSpan; + /** + * The hero's treatment: container-query spans, and the cover cropped to fill + * its column inset with its own corners, the way the feed cards treat a + * cover. Off keeps the letterboxed image over the blurred backdrop the + * in-feed wide cards use. + */ + hero?: boolean; overlay?: ReactNode; isVideoType?: boolean; eagerLoadImage?: boolean; @@ -23,14 +30,16 @@ export const FeaturedWideImageColumn = ({ overlay, isVideoType, eagerLoadImage, + hero, }: FeaturedWideImageColumnProps): ReactElement => (
- {!!image && ( + {!!image && !hero && (
{children}
; +export const FeaturedWideTextContainer = forwardRef( + function FeaturedWideTextContainer( + { + className, + children, + }: { + className?: string; + children: ReactNode; + }, + ref: Ref, + ): ReactElement { + return ( +
+ {children} +
+ ); + }, +); diff --git a/packages/shared/src/components/cards/common/featuredWide.ts b/packages/shared/src/components/cards/common/featuredWide.ts index 17fb8506fc0..267241a69f6 100644 --- a/packages/shared/src/components/cards/common/featuredWide.ts +++ b/packages/shared/src/components/cards/common/featuredWide.ts @@ -4,8 +4,58 @@ export type FeaturedWideColSpan = 2 | 3 | 4; export type FeaturedWideCardProps = PostCardProps & { wideColSpan?: FeaturedWideColSpan; + /** + * The standalone hero treatment: the cover is cropped to fill its column + * instead of being letterboxed, and the text trades headline size for lines + * because it runs in a third of the card's width. The in-feed wide cards + * share a row with normal cards and keep the original sizes. + */ + hero?: boolean; }; +export const TITLE_CLASS_NAME = 'line-clamp-4 typo-title1'; +export const HERO_TITLE_CLASS_NAME = 'line-clamp-5 typo-title2'; +export const DESCRIPTION_CLASS_NAME = 'line-clamp-3'; + +/** + * The hero's card height is fixed, so a headline that runs to five lines would + * otherwise push the action row out through the bottom edge. `base.css` resets + * every element to `flex-shrink: 0`, so this block and the summary opt back in: + * the summary is the only shrinkable child, which makes it the one that gives + * way while the headline above it keeps every line. `grow` so the block's floor + * is the action row rather than wherever its own copy happens to end, which is + * what `useFittedLineClamp` measures down to. + */ +export const HERO_TEXT_FIT_CLASS_NAME = 'min-h-0 shrink grow overflow-hidden'; + +/** + * Six is the ceiling, not the count — the measured fit replaces it whenever the + * headline above leaves room for fewer. Kept as a class so the first paint, + * before anything has been measured, is already close. + */ +export const HERO_DESCRIPTION_CLASS_NAME = 'line-clamp-6 min-h-0 shrink'; +export const HERO_DESCRIPTION_MAX_LINES = 6; + +/** + * The hero card sizes itself against its own width rather than the viewport's: + * the section is capped at the feed grid's width, which follows the reader's + * card-count setting, so a wide monitor showing three cards gives this card + * half the room a five-card feed does. + * + * Narrow, the cover sits under the copy; from 40rem it takes a column beside + * it; from 52rem the copy keeps two of five for the 40/60 split. Needs + * `@container/wide` on an ancestor — the carousel slide provides it, which is + * why in-feed wide cards keep their prop-driven spans. + */ +const HERO_INNER_GRID_CLASS_NAME = + 'grid-cols-1 grid-rows-[minmax(0,1fr)_10rem] @[40rem]/wide:grid-cols-2 @[40rem]/wide:grid-rows-[minmax(0,1fr)] @[52rem]/wide:grid-cols-5'; + +const HERO_TEXT_COL_CLASS_NAME = + 'col-span-1 row-start-1 @[52rem]/wide:col-span-2'; + +const HERO_IMAGE_COL_CLASS_NAME = + 'col-span-1 row-start-2 @[40rem]/wide:row-start-1 @[52rem]/wide:col-span-3'; + export const INNER_GRID_COLS: Record = { 2: 'grid-cols-2', 3: 'grid-cols-3', @@ -17,3 +67,37 @@ export const IMAGE_COL_SPAN: Record = { 3: 'col-span-2', 4: 'col-span-3', }; + +type FeaturedWideLayout = { + hasMedia: boolean; + wideColSpan: FeaturedWideColSpan; + hero?: boolean; +}; + +export const featuredWideGridClass = ({ + hasMedia, + wideColSpan, + hero, +}: FeaturedWideLayout): string => { + if (!hasMedia) { + return 'grid-cols-1'; + } + + return hero ? HERO_INNER_GRID_CLASS_NAME : INNER_GRID_COLS[wideColSpan]; +}; + +/** + * Only the hero states a span: with a single image column beside it the text + * takes one column by default, which is what every other span comes to. + */ +export const featuredWideTextColClass = ({ + hasMedia, + hero, +}: Pick): string | undefined => + hasMedia && hero ? HERO_TEXT_COL_CLASS_NAME : undefined; + +export const featuredWideImageColClass = ({ + wideColSpan, + hero, +}: Pick): string => + hero ? HERO_IMAGE_COL_CLASS_NAME : IMAGE_COL_SPAN[wideColSpan]; diff --git a/packages/shared/src/components/cards/common/gridCards.ts b/packages/shared/src/components/cards/common/gridCards.ts new file mode 100644 index 00000000000..4edffccc43a --- /dev/null +++ b/packages/shared/src/components/cards/common/gridCards.ts @@ -0,0 +1,23 @@ +import type React from 'react'; +import { PostType } from '../../../graphql/posts'; +import { ArticleGrid } from '../article/ArticleGrid'; +import { ShareGrid } from '../share/ShareGrid'; +import { FreeformGrid } from '../Freeform/FreeformGrid'; +import { CollectionGrid } from '../collection/CollectionGrid'; +import PollGrid from '../poll/PollGrid'; +import { SocialTwitterGrid } from '../socialTwitter/SocialTwitterGrid'; +import { BriefCard } from '../brief/BriefCard/BriefCard'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const PostTypeToGridCard: Record> = { + [PostType.Article]: ArticleGrid, + [PostType.Share]: ShareGrid, + [PostType.Welcome]: FreeformGrid, + [PostType.Freeform]: FreeformGrid, + [PostType.VideoYouTube]: ArticleGrid, + [PostType.Collection]: CollectionGrid, + [PostType.Brief]: BriefCard, + [PostType.Poll]: PollGrid, + [PostType.SocialTwitter]: SocialTwitterGrid, + [PostType.Digest]: ArticleGrid, +}; diff --git a/packages/shared/src/components/cards/common/listCards.ts b/packages/shared/src/components/cards/common/listCards.ts new file mode 100644 index 00000000000..cd686fdc88a --- /dev/null +++ b/packages/shared/src/components/cards/common/listCards.ts @@ -0,0 +1,23 @@ +import type React from 'react'; +import { PostType } from '../../../graphql/posts'; +import { ArticleList } from '../article/ArticleList'; +import { ShareList } from '../share/ShareList'; +import { FreeformList } from '../Freeform/FreeformList'; +import { CollectionList } from '../collection/CollectionList'; +import { PollList } from '../poll/PollList'; +import { SocialTwitterList } from '../socialTwitter/SocialTwitterList'; +import { BriefCard } from '../brief/BriefCard/BriefCard'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const PostTypeToListCard: Record> = { + [PostType.Article]: ArticleList, + [PostType.Share]: ShareList, + [PostType.Welcome]: FreeformList, + [PostType.Freeform]: FreeformList, + [PostType.VideoYouTube]: ArticleList, + [PostType.Collection]: CollectionList, + [PostType.Brief]: BriefCard, + [PostType.Poll]: PollList, + [PostType.SocialTwitter]: SocialTwitterList, + [PostType.Digest]: ArticleList, +}; diff --git a/packages/shared/src/components/cards/highlight/common.tsx b/packages/shared/src/components/cards/highlight/common.tsx index e7c0806c6f2..f1fcced6d4b 100644 --- a/packages/shared/src/components/cards/highlight/common.tsx +++ b/packages/shared/src/components/cards/highlight/common.tsx @@ -27,10 +27,12 @@ const getHighlightUrl = (highlight: PostHighlight): string => export const ReadAllHighlightsFooter = ({ highlightId, onClick, + compact, className, }: { highlightId?: string; onClick?: () => void; + compact?: boolean; className?: string; }): ReactElement => { const href = getHighlightsUrl(highlightId); @@ -39,7 +41,10 @@ export const ReadAllHighlightsFooter = ({ onClick?.()} > @@ -65,26 +70,37 @@ const HighlightRow = ({ highlight, index, onHighlightClick, + compact, }: { highlight: PostHighlight; index: number; onHighlightClick?: (highlight: PostHighlight, position: number) => void; + compact?: boolean; }): ReactElement => { return ( onHighlightClick?.(highlight, index + 1)} > {highlight.headline} + {/* In the headline's own text flow rather than on a line of its own, + so it trails the last word and wraps with it. */} + + · + + - ); @@ -95,16 +111,34 @@ export const HighlightCardContent = ({ onHighlightClick, onReadAllClick, variant, -}: HighlightCardProps & { variant: 'grid' | 'list' }): ReactElement => { - const headerClassName = - variant === 'list' - ? 'flex items-center pb-4' - : 'flex items-center px-4 py-4'; - const contentClassName = - variant === 'list' - ? 'flex flex-col gap-2' - : 'no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-y-auto px-2.5 pb-1 pt-0'; - const footerClassName = variant === 'list' ? 'pt-1.5' : 'px-1 pb-1'; + compact, +}: HighlightCardProps & { + variant: 'grid' | 'list'; + /** Flush against its container, for a surface without card chrome. */ + compact?: boolean; +}): ReactElement => { + const isFlushGrid = variant === 'grid' && compact; + const headerClassName = classNames( + 'flex items-center', + variant === 'list' && 'pb-4', + variant === 'grid' && (isFlushGrid ? 'px-4 pb-2' : 'px-4 py-4'), + ); + const contentClassName = classNames( + variant === 'list' && 'flex flex-col gap-2', + variant === 'grid' && + 'no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-y-auto pt-0', + variant === 'grid' && !isFlushGrid && 'px-2.5 pb-1', + // Only from `laptop`, where the column has a fixed height and the list + // actually scrolls: the fade stops the pinned footer slicing a row flat. + isFlushGrid && + 'laptop:[mask-image:linear-gradient(to_bottom,black_calc(100%-1.25rem),transparent)]', + ); + const footerClassName = classNames( + variant === 'list' && 'pt-1.5', + // The bottom inset matches the ad card's padding, so the two columns finish + // on one line rather than 12px apart. + variant === 'grid' && (isFlushGrid ? 'px-4 pb-3 pt-2' : 'px-1 pb-1'), + ); const firstHighlight = highlights[0]; return ( @@ -127,12 +161,14 @@ export const HighlightCardContent = ({ highlight={highlight} index={index} onHighlightClick={onHighlightClick} + compact={compact} /> ))}
diff --git a/packages/shared/src/components/cards/share/ShareFeaturedWideGridCard.tsx b/packages/shared/src/components/cards/share/ShareFeaturedWideGridCard.tsx index 333f0733141..a7c8ae91bd1 100644 --- a/packages/shared/src/components/cards/share/ShareFeaturedWideGridCard.tsx +++ b/packages/shared/src/components/cards/share/ShareFeaturedWideGridCard.tsx @@ -16,11 +16,21 @@ import { DeletedPostId } from '../../../lib/constants'; import { stripHtmlTags } from '../../../lib/strings'; import { HighlightChip } from '../common/HighlightChip'; import type { FeaturedWideCardProps } from '../common/featuredWide'; -import { INNER_GRID_COLS } from '../common/featuredWide'; +import { + DESCRIPTION_CLASS_NAME, + featuredWideGridClass, + featuredWideTextColClass, + HERO_DESCRIPTION_CLASS_NAME, + HERO_DESCRIPTION_MAX_LINES, + HERO_TEXT_FIT_CLASS_NAME, + HERO_TITLE_CLASS_NAME, + TITLE_CLASS_NAME, +} from '../common/featuredWide'; import { FeaturedWideCardShell } from '../common/FeaturedWideCardShell'; import { FeaturedWideImageColumn } from '../common/FeaturedWideImageColumn'; import { FeaturedWideActions } from '../common/FeaturedWideActions'; import { FeaturedWideTextContainer } from '../common/FeaturedWideTextContainer'; +import { useFittedLineClamp } from '../../../hooks/useFittedLineClamp'; export const ShareFeaturedWideGridCard = forwardRef( function ShareFeaturedWideGridCard( @@ -40,6 +50,7 @@ export const ShareFeaturedWideGridCard = forwardRef( domProps = {}, eagerLoadImage = false, wideColSpan = 2, + hero, }: FeaturedWideCardProps, ref: Ref, ): ReactElement { @@ -58,6 +69,8 @@ export const ShareFeaturedWideGridCard = forwardRef( ? stripHtmlTags(sharedPost?.contentHtml ?? post.contentHtml ?? '').trim() : ''; const { overlay } = useCardCover({ post, onShare }); + const hasMedia = !!image || !!overlay; + const textFit = useFittedLineClamp(HERO_DESCRIPTION_MAX_LINES); return ( -
- +
+ {(!isSharedTweet || post.title) && ( -

+

{title}

)} @@ -122,7 +148,20 @@ export const ShareFeaturedWideGridCard = forwardRef( ) : ( <> {!!sharedSummary && ( -

+

{sharedSummary}

)} @@ -143,11 +182,12 @@ export const ShareFeaturedWideGridCard = forwardRef( onDownvoteClick={onDownvoteClick} />
- {(!!image || !!overlay) && ( + {hasMedia && ( ( const adRepeat = adTemplate?.adRepeat ?? pageSize + 1; const adJitter = adTemplate?.adJitter ?? 0; - const adPage = getAdSlotIndex({ + const slot = getAdSlotIndex({ index, adStart, adRepeat, @@ -579,7 +597,15 @@ export default function useFeed( seed: adJitterSeedRef.current ?? '', }); - if (adPage === undefined) { + if (slot === undefined) { + return undefined; + } + + // Shifted rather than skipped, so the creative the first slot would have + // shown moves down to the second instead of being fetched and discarded. + const adPage = settings?.skipFirstAd ? slot - 1 : slot; + + if (adPage < 0) { return undefined; } @@ -618,6 +644,7 @@ export default function useFeed( adTemplate?.adJitter, adsUpdatedAt, pageSize, + settings?.skipFirstAd, ], ); @@ -662,6 +689,7 @@ export default function useFeed( startIndex: heroCardsConfig.startIndex, widenableTypes, firstSlotOffset: effectiveFirstSlotOffset, + minWideCardRow: settings?.deferWideCards ? 1 : 0, }); const staticAd = settings?.staticAd; @@ -707,7 +735,7 @@ export default function useFeed( } if (node.itemType === 'highlight') { - if (!node.highlights.length) { + if (!node.highlights.length || settings?.disableHighlightCards) { return; } pushAndAdvance({ @@ -760,6 +788,7 @@ export default function useFeed( feedQuery.dataUpdatedAt, placeholdersPerPage, getAd, + settings?.disableHighlightCards, settings?.staticAd, heroCardsConfig, virtualizedNumCards, @@ -770,6 +799,7 @@ export default function useFeed( widenableTypes, excludePinnedPosts, effectiveFirstSlotOffset, + settings?.deferWideCards, ]); const placements = useMemo( @@ -785,6 +815,7 @@ export default function useFeed( fullRowInsertionBeforeIndex, cadence, firstSlotOffset: effectiveFirstSlotOffset, + minWideCardRow: settings?.deferWideCards ? 1 : 0, }), [ items, @@ -796,6 +827,7 @@ export default function useFeed( cadence, widenableTypes, effectiveFirstSlotOffset, + settings?.deferWideCards, ], ); diff --git a/packages/shared/src/hooks/useFittedLineClamp.ts b/packages/shared/src/hooks/useFittedLineClamp.ts new file mode 100644 index 00000000000..2b67ae380a0 --- /dev/null +++ b/packages/shared/src/hooks/useFittedLineClamp.ts @@ -0,0 +1,79 @@ +import { useCallback, useEffect, useState } from 'react'; + +interface FittedLineClamp { + /** The box the text has to fit inside. */ + containerRef: (node: HTMLElement | null) => void; + /** The text itself, which must be the last thing in that box. */ + textRef: (node: HTMLElement | null) => void; + /** Whole lines that fit, never more than `maxLines`. */ + lines: number; +} + +/** + * How many whole lines of a block still fit the room left under everything + * above it. + * + * A fixed line count cannot do this: the text starts wherever the copy above it + * happens to end, and clamping to a count that no longer fits lets the box clip + * the overflow — a row of glyphs sliced through the middle rather than a + * paragraph that stops. `-webkit-line-clamp` takes a number, not a height, and + * the free space is only known once the flex column above has been laid out, so + * it is measured here and handed back as that number. + * + * Reducing the count only makes the text shorter, and the text is the last + * child, so where it starts does not move and the measurement cannot chase + * itself. + */ +export const useFittedLineClamp = (maxLines: number): FittedLineClamp => { + // Nodes as state rather than refs so the effect re-runs when they attach: + // the card renders no summary at all until the post arrives. + const [container, setContainer] = useState(null); + const [text, setText] = useState(null); + const [lines, setLines] = useState(maxLines); + + useEffect(() => { + if (!container || !text || typeof ResizeObserver === 'undefined') { + return undefined; + } + + const measure = () => { + const style = getComputedStyle(text); + const lineHeight = parseFloat(style.lineHeight); + + // `normal` gives no number to divide by. Leaving the count alone keeps + // the CSS clamp, which is the behaviour without this hook at all. + if (!lineHeight) { + return; + } + + const bottom = + container.getBoundingClientRect().bottom - + parseFloat(getComputedStyle(container).paddingBottom || '0'); + const available = bottom - text.getBoundingClientRect().top; + + setLines( + Math.max(0, Math.min(maxLines, Math.floor(available / lineHeight))), + ); + }; + + // The container's own height moves the floor; the text's moves the ceiling, + // and the copy above it can rewrap without either box changing size. + const observer = new ResizeObserver(measure); + + observer.observe(container); + observer.observe(text); + measure(); + + return () => observer.disconnect(); + }, [container, text, maxLines]); + + return { + containerRef: useCallback((node: HTMLElement | null) => { + setContainer(node); + }, []), + textRef: useCallback((node: HTMLElement | null) => { + setText(node); + }, []), + lines, + }; +}; diff --git a/packages/shared/src/lib/feedHighlightColSpan.spec.ts b/packages/shared/src/lib/feedHighlightColSpan.spec.ts index 79e8b554d7e..97bf90c4ae7 100644 --- a/packages/shared/src/lib/feedHighlightColSpan.spec.ts +++ b/packages/shared/src/lib/feedHighlightColSpan.spec.ts @@ -422,6 +422,7 @@ describe('computePlacements', () => { minSpacing: 10, startIndex: 0, widenableTypes: ALL_WIDENABLE, + minWideCardRow: 0, }; const colSpans = (items: FeedItem[], o = opts) => @@ -514,6 +515,39 @@ describe('computePlacements', () => { ]); }); + describe('minWideCardRow', () => { + // `startIndex` counts items, so at four columns item 0 is still in the + // opening row. A surface that leads with its own featured card needs the + // floor stated in rows. + it('keeps wide cards out of the rows below the floor', () => { + const items = Array.from({ length: 6 }, () => + makePostItem(makePost({ significance: 'breaking' })), + ); + + expect(colSpans(items, { ...opts, minSpacing: 0 })).toEqual([ + 4, 4, 4, 4, 4, 4, + ]); + expect( + colSpans(items, { ...opts, minSpacing: 0, minWideCardRow: 1 }), + ).toEqual([1, 1, 1, 1, 4, 4]); + }); + + it('measures the floor in rows, not items', () => { + const items = Array.from({ length: 6 }, (_, index) => + makePostItem(makePost(index === 5 ? { significance: 'breaking' } : {})), + ); + // Five single-column cards fill row 0 and spill into row 1, so the sixth + // is past the floor and widens — to the 3 columns left in its row, since + // the fit-to-row clamp still applies. + const placements = computePlacements(items, { + ...opts, + minWideCardRow: 1, + }); + + expect(placements[5]).toEqual({ colSpan: 3, row: 1, column: 1 }); + }); + }); + it('caps wide cards to one per ten items', () => { const items = [ makePostItem(makePost({ significance: 'notable' })), diff --git a/packages/shared/src/lib/feedHighlightColSpan.ts b/packages/shared/src/lib/feedHighlightColSpan.ts index e43040e06fb..6991c363fae 100644 --- a/packages/shared/src/lib/feedHighlightColSpan.ts +++ b/packages/shared/src/lib/feedHighlightColSpan.ts @@ -59,6 +59,13 @@ export interface PlacementBuilderOptions { startIndex: number; widenableTypes: ReadonlySet; firstSlotOffset?: number; + /** + * First grid row a wide card may occupy. `startIndex` gates on the item + * index, which at five columns still lets one land in the opening row, so a + * surface that already leads with a full-size card above the grid (the feed + * hero) needs the floor stated in rows instead. + */ + minWideCardRow?: number; } /** @@ -167,6 +174,7 @@ export const createPlacementBuilder = ({ startIndex, widenableTypes, firstSlotOffset = 0, + minWideCardRow = 0, }: PlacementBuilderOptions): PlacementBuilder => { const layoutEnabled = isEnabled && !isMobile && !isList && numCards > 1; const safeNumCards = Math.max(numCards, 1); @@ -212,6 +220,9 @@ export const createPlacementBuilder = ({ if (itemIdx < startIndex) { return 1; } + if (row < minWideCardRow) { + return 1; + } if (itemIdx - lastLargeIndex < minSpacing) { return 1; } From dbb152022937d20d04e3f402c61b259a1df740b9 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 8 Sep 2026 23:07:15 +0300 Subject: [PATCH 2/5] feat(feed): add the feed hero section behind a flag A hero above My Feed and Popular: a carousel of the current headlines, the Happening Now list beside it, and an ad in a column of its own. The section lays out on the feed grid's own column count rather than on viewport thresholds of its own, so it reflows when the feed does and its column edges land on the grid's. The rail earns a second column at six, the ad earns one at four, and the featured card takes what is left. A feed rendering as a list stacks instead: the lead story as a list card with the headlines under it. While the hero seats an ad the grid stands its own first placement down, and the highlights card is dropped from the grid since the hero already shows those headlines. Headline clicks report the same events the in-feed highlights card does. Flag `feed_hero`, default off. Mockup-to-eng-pass: 1 Co-Authored-By: Claude Opus 5 --- .../shared/src/components/MainFeedLayout.tsx | 71 ++- .../src/components/feeds/hero/FeedHero.tsx | 206 ++++++++ .../components/feeds/hero/FeedHeroAdCard.tsx | 126 +++++ .../feeds/hero/FeedHeroCarousel.spec.tsx | 114 ++++ .../feeds/hero/FeedHeroCarousel.tsx | 219 ++++++++ .../components/feeds/hero/FeedHeroSection.tsx | 123 +++++ .../feeds/hero/feedHeroShape.spec.ts | 61 +++ .../components/feeds/hero/feedHeroShape.ts | 73 +++ .../components/feeds/hero/useFeedHeroAd.ts | 57 ++ packages/shared/src/lib/featureManagement.ts | 4 + packages/shared/src/styles/utilities.css | 64 +++ .../features/feed/FeedHero.stories.tsx | 478 +++++++++++++++++ .../stories/features/feed/feedHero.mocks.tsx | 487 ++++++++++++++++++ 13 files changed, 2070 insertions(+), 13 deletions(-) create mode 100644 packages/shared/src/components/feeds/hero/FeedHero.tsx create mode 100644 packages/shared/src/components/feeds/hero/FeedHeroAdCard.tsx create mode 100644 packages/shared/src/components/feeds/hero/FeedHeroCarousel.spec.tsx create mode 100644 packages/shared/src/components/feeds/hero/FeedHeroCarousel.tsx create mode 100644 packages/shared/src/components/feeds/hero/FeedHeroSection.tsx create mode 100644 packages/shared/src/components/feeds/hero/feedHeroShape.spec.ts create mode 100644 packages/shared/src/components/feeds/hero/feedHeroShape.ts create mode 100644 packages/shared/src/components/feeds/hero/useFeedHeroAd.ts create mode 100644 packages/storybook/stories/features/feed/FeedHero.stories.tsx create mode 100644 packages/storybook/stories/features/feed/feedHero.mocks.tsx diff --git a/packages/shared/src/components/MainFeedLayout.tsx b/packages/shared/src/components/MainFeedLayout.tsx index dc555d19bf4..d92565293fd 100644 --- a/packages/shared/src/components/MainFeedLayout.tsx +++ b/packages/shared/src/components/MainFeedLayout.tsx @@ -64,13 +64,14 @@ import { useViewSize, ViewSize, } from '../hooks'; -import { feedNameToHeading } from './feeds/FeedContainer'; +import { feedNameToHeading, v2FeedSideInsetClass } from './feeds/FeedContainer'; import { pageHeaderClassName } from './layout/PageHeader'; import { customFeedVersion, discussedFeedVersion, feature, featureFeedChips, + featureFeedHero, FeedChipsVariant, followingFeedVersion, latestFeedVersion, @@ -78,6 +79,7 @@ import { upvotedFeedVersion, } from '../lib/featureManagement'; import type { FeedContainerProps } from './feeds'; +import { FeedHero } from './feeds/hero/FeedHero'; import { getFeedName } from '../lib/feed'; import CommentFeed from './CommentFeed'; import { COMMENT_FEED_QUERY } from '../graphql/comments'; @@ -385,6 +387,18 @@ export default function MainFeedLayout({ [showExploreChips, exploreCategories, feeds, isV2], ); + const isMainFeedPage = + feedName === SharedFeedPage.MyFeed || feedName === SharedFeedPage.Popular; + const { value: isFeedHeroEnabled } = useConditionalFeature({ + feature: featureFeedHero, + shouldEvaluate: isMainFeedPage, + }); + // The hero reports back rather than being asked: it only has a placement once + // its own column exists and an ad has come back for it. While it is showing + // one, the grid stands its first ad down so the reader does not meet two + // before the first post. + const [isHeroAdVisible, setIsHeroAdVisible] = useState(false); + const { isSearchPageLaptop } = useSearchResultsLayout(); const config = useMemo(() => { @@ -791,6 +805,45 @@ export default function MainFeedLayout({ } return ''; }, [customFeedsData, feedName, router.query.slugOrId]); + const chipsTopContent = + (isExploreTag || shouldUseListFeedLayout) && chipsNode ? ( +
+ {chipsNode} +
+ ) : undefined; + // The v2 grid is inset inside the floating card and the hero is its sibling, + // not its child, so it has to repeat both the inset and the card border rules + // or it runs wider and brighter than every card under it. No bottom margin + // from `tablet` up, where the grid already opens with that same inset; mobile + // keeps one as the only separator the two have there. + const isV2Grid = isV2 && !shouldUseListFeedLayout; + const heroClassName = classNames( + 'w-full tablet:pt-6', + isV2Grid + ? classNames( + v2FeedSideInsetClass, + 'mb-8 tablet:mb-0', + '[&_article:hover]:!border-border-subtlest-tertiary [&_article]:!border-border-subtlest-quaternary', + ) + : 'mb-8', + ); + // Left undefined when the hero is off so `Feed` keeps its own top slot for + // the reading reminder. + const topContent = isFeedHeroEnabled ? ( + <> + + {chipsTopContent} + + ) : ( + chipsTopContent + ); + const v2ActionButtons = feedProps?.actionButtons; const showFeedV2PageHeader = isV2 && @@ -861,18 +914,10 @@ export default function MainFeedLayout({ - {chipsNode} -
- ) : undefined - } + topContent={topContent} + disableHighlightCards={isFeedHeroEnabled} + skipFirstAd={isHeroAdVisible} + deferWideCards={isFeedHeroEnabled} className={classNames(!isFinder && feedGutter)} /> ) diff --git a/packages/shared/src/components/feeds/hero/FeedHero.tsx b/packages/shared/src/components/feeds/hero/FeedHero.tsx new file mode 100644 index 00000000000..72e50202c44 --- /dev/null +++ b/packages/shared/src/components/feeds/hero/FeedHero.tsx @@ -0,0 +1,206 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useEffect, useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import type { Post } from '../../../graphql/posts'; +import type { Connection } from '../../../graphql/common'; +import { gqlClient } from '../../../graphql/common'; +import { + FEED_BY_IDS_QUERY, + supportedTypesForPrivateSources, +} from '../../../graphql/feed'; +import type { PostHighlight } from '../../../graphql/highlights'; +import { majorHeadlinesQueryOptions } from '../../../graphql/highlights'; +import type { ViewabilityData } from '../../../features/monetization/viewability'; +import { viewabilityLogExtra } from '../../../features/monetization/viewability'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { useLogContext } from '../../../contexts/LogContext'; +import { useVotePost } from '../../../hooks'; +import { useBookmarkPost } from '../../../hooks/useBookmarkPost'; +import { useCopyLink } from '../../../hooks/useCopy'; +import { ImpressionStatus } from '../../../hooks/feed/useLogImpression'; +import { + adLogEvent, + feedHighlightsLogEvent, + usePostLogEvent, +} from '../../../lib/feed'; +import { AdActions } from '../../../lib/ads'; +import { LogEvent, Origin } from '../../../lib/log'; +import { generateQueryKey, RequestKey, StaleTime } from '../../../lib/query'; +import { FeedHeroSection } from './FeedHeroSection'; +import { useFeedHeroAd } from './useFeedHeroAd'; + +const HIGHLIGHT_COUNT = 6; +const FEATURED_POST_COUNT = 4; + +/** + * The carousel and the Happening Now list are the same headlines: the top few + * get their full post fetched for a card, the rest stay as rows. + */ +export const FeedHero = ({ + feedName, + className, + onAdVisibleChange, +}: { + /** For the headline click events, which the in-feed card also reports. */ + feedName: string; + className?: string; + /** + * Told whether the hero ended up showing a placement, so the feed below can + * stand its own first ad down. Only the hero knows: it turns on the column + * the ad needs, and the ad still has to come back before there is one to + * stand down for. + */ + onAdVisibleChange?: (isVisible: boolean) => void; +}): ReactElement | null => { + const { user, tokenRefreshed } = useAuthContext(); + const { logEvent } = useLogContext(); + const postLogEvent = usePostLogEvent(); + const { toggleUpvote, toggleDownvote } = useVotePost(); + const { toggleBookmark } = useBookmarkPost(); + const [, copyLink] = useCopyLink(); + + const { ad, placement, shape } = useFeedHeroAd(true); + + const { data: headlines } = useQuery({ + ...majorHeadlinesQueryOptions({ first: HIGHLIGHT_COUNT }), + enabled: tokenRefreshed, + }); + const highlights = useMemo( + () => headlines?.majorHeadlines?.edges?.map(({ node }) => node) ?? [], + [headlines], + ); + + const postIds = useMemo( + () => highlights.slice(0, FEATURED_POST_COUNT).map(({ post }) => post.id), + [highlights], + ); + + const { data: featured } = useQuery({ + queryKey: generateQueryKey(RequestKey.FeedByIds, user, 'hero', ...postIds), + queryFn: () => + gqlClient.request<{ page: Connection }>(FEED_BY_IDS_QUERY, { + first: postIds.length, + postIds, + loggedIn: !!user, + supportedTypes: supportedTypesForPrivateSources, + }), + enabled: tokenRefreshed && postIds.length > 0, + staleTime: StaleTime.Default, + }); + + // `feedByIds` answers in its own order, so re-key by id to keep the carousel + // in the same order as the headlines beside it. + const posts = useMemo(() => { + const byId = new Map( + featured?.page?.edges?.map(({ node }) => [node.id, node]) ?? [], + ); + + return postIds.map((id) => byId.get(id)).filter(Boolean) as Post[]; + }, [featured, postIds]); + + // The hero renders nothing without posts, so it is showing no ad either. + const adPlacement = posts.length > 0 ? placement : 'none'; + const isAdShown = adPlacement !== 'none'; + + // Stacked, the section leads with one story as a list card and the headline + // list carries the rest, so the lead is dropped from the list rather than + // appearing twice a few pixels apart. + const railHighlights = + shape.layout === 'stacked' ? highlights.slice(1) : highlights; + + const onAdAction = useCallback( + (action: AdActions, extra?: Record) => { + if (!ad) { + return; + } + + logEvent( + adLogEvent(action, ad, { extra: { origin: 'feed hero', ...extra } }), + ); + }, + [ad, logEvent], + ); + + const logHighlightsClick = useCallback( + (action: string, clickedHighlight?: PostHighlight, position?: number) => + logEvent( + feedHighlightsLogEvent(LogEvent.Click, { + feedName, + action, + position, + count: railHighlights.length, + clickedHighlight, + highlightIds: railHighlights.map(({ id }) => id), + origin: Origin.Feed, + }), + ), + [feedName, logEvent, railHighlights], + ); + + useEffect(() => { + onAdVisibleChange?.(isAdShown); + }, [isAdShown, onAdVisibleChange]); + + useEffect(() => { + // Gated on `isAdShown`, not just on the ad existing: logging here while the + // hero renders nothing would mark the cached ad LOGGED and swallow the + // impression for the render that actually puts it on screen. + if (!ad || !isAdShown || ad.impressionStatus === ImpressionStatus.LOGGED) { + return; + } + + onAdAction(AdActions.Impression); + ad.impressionStatus = ImpressionStatus.LOGGED; + }, [ad, isAdShown, onAdAction]); + + const cardProps = useMemo( + () => ({ + onPostClick: (post: Post) => + logEvent( + postLogEvent(LogEvent.Click, post, { + extra: { origin: Origin.Feed }, + }), + ), + onUpvoteClick: (post: Post, origin = Origin.Feed) => + toggleUpvote({ payload: post, origin }), + onDownvoteClick: (post: Post, origin = Origin.Feed) => + toggleDownvote({ payload: post, origin }), + onBookmarkClick: (post: Post, origin = Origin.Feed) => + toggleBookmark({ post, origin }), + onCopyLinkClick: (_: React.MouseEvent, post: Post) => + copyLink({ link: post.commentsPermalink }), + }), + [ + copyLink, + logEvent, + postLogEvent, + toggleBookmark, + toggleDownvote, + toggleUpvote, + ], + ); + + if (!posts.length) { + return null; + } + + return ( + + logHighlightsClick('highlight_click', highlight, position) + } + onReadAllClick={() => logHighlightsClick('read_all_click')} + onAdLinkClick={() => onAdAction(AdActions.Click)} + onAdViewable={(_, data: ViewabilityData) => + onAdAction(AdActions.Viewable, viewabilityLogExtra(data)) + } + /> + ); +}; diff --git a/packages/shared/src/components/feeds/hero/FeedHeroAdCard.tsx b/packages/shared/src/components/feeds/hero/FeedHeroAdCard.tsx new file mode 100644 index 00000000000..cb42bf52685 --- /dev/null +++ b/packages/shared/src/components/feeds/hero/FeedHeroAdCard.tsx @@ -0,0 +1,126 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import type { Ad } from '../../../graphql/posts'; +import type { ViewabilityData } from '../../../features/monetization/viewability'; +import { FlatCard } from '../../cards/common/Card'; +import AdLink from '../../cards/ad/common/AdLink'; +import AdAttribution from '../../cards/ad/common/AdAttribution'; +import { AdFavicon } from '../../cards/ad/common/AdFavicon'; +import { AdImage } from '../../cards/ad/common/AdImage'; +import { AdPixel } from '../../cards/ad/common/AdPixel'; +import { AdMeasurement } from '../../cards/ad/common/AdMeasurement'; +import { AdViewability } from '../../cards/ad/common/AdViewability'; +import { RemoveAd } from '../../cards/ad/common/RemoveAd'; +import { AdvertiseLink } from '../../cards/ad/common/AdvertiseLink'; +import PostTags from '../../cards/common/PostTags'; +import { ButtonSize, ButtonVariant } from '../../buttons/common'; +import { Image } from '../../image/Image'; +import classed from '../../../lib/classed'; +import { useAdLabel } from '../../../features/monetization/useAdLabel'; +import { usePlusSubscription } from '../../../hooks/usePlusSubscription'; +import { TargetId } from '../../../lib/log'; + +// `CardImage`'s own height and treatment, so the creative comes out the shape +// the reader has already seen four of on the row below. +const AdCover = classed(Image, 'h-40 w-full rounded-12 object-cover'); + +interface FeedHeroAdCardProps { + ad: Ad; + onLinkClick?: (ad: Ad) => unknown; + onViewable?: (ad: Ad, data: ViewabilityData) => void; + className?: string; +} + +/** + * The rail's ad, built to read as another big article rather than a compact + * widget. It follows the featured card beside it in both order and scale: the + * advertiser mark, the headline, its tags, the disclosure where the card puts + * its date, then the cover at the grid card's fixed 160px. + * + * The two controls sit below the card rather than inside it, the way "Read all" + * sits below the headline list, so the hover highlight covers what the card + * links to and stops short of the buttons that go somewhere else. + */ +export const FeedHeroAdCard = ({ + ad, + onLinkClick, + onViewable, + className, +}: FeedHeroAdCardProps): ReactElement => { + const { isPlus } = usePlusSubscription(); + const { showAdvertiseLink } = useAdLabel(); + const matchingTags = ad.matchingTags ?? []; + + return ( +
+ + + + {/* `typo-title3` is `CardTitle`'s size — what every post in the grid + gives its headline. */} + + {ad.description} + + {/* `CardSpace`'s job: the copy takes the column's slack so the cover + below keeps its fixed height. */} +
+ {matchingTags.length > 0 && ( + + )} + + {!!ad.image && ( + + )} + {/* Out of flow so the column's spacing doesn't reserve a row for it. */} +
+ +
+ + onViewable?.(ad, data)} /> + + {/* Outside the card, so hovering them does not light the creative up as + though it were the link. The creative's own call to action is left out + too: a third button wraps this row onto two lines in a 270px column, + and the card is already the click target. + + The negative margins cancel the 12px `ButtonSize.Small` pads with, so + what lines up with the cover and the copy above is the label rather + than the button's box. */} +
+ {showAdvertiseLink && ( + + )} + {!isPlus && ( + + )} +
+
+ ); +}; diff --git a/packages/shared/src/components/feeds/hero/FeedHeroCarousel.spec.tsx b/packages/shared/src/components/feeds/hero/FeedHeroCarousel.spec.tsx new file mode 100644 index 00000000000..5971e6d2780 --- /dev/null +++ b/packages/shared/src/components/feeds/hero/FeedHeroCarousel.spec.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import type { RenderResult } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import type { NextRouter } from 'next/router'; +import { useRouter } from 'next/router'; +import basePost from '../../../../__tests__/fixture/post'; +import { TestBootProvider } from '../../../../__tests__/helpers/boot'; +import type { Post } from '../../../graphql/posts'; +import { FeedHeroCarousel } from './FeedHeroCarousel'; +import type { FeedHeroLayout } from './feedHeroShape'; + +jest.mock('next/router', () => ({ + useRouter: jest.fn(), +})); + +beforeEach(() => { + jest.clearAllMocks(); + jest + .mocked(useRouter) + .mockImplementation(() => ({ pathname: '/' } as unknown as NextRouter)); +}); + +const titles = ['First hero post', 'Second hero post', 'Third hero post']; + +const posts: Post[] = titles.map((title, index) => ({ + ...basePost, + id: `hero-${index}`, + title, +})); + +const renderComponent = ( + carouselPosts: Post[] = posts, + layout: FeedHeroLayout = 'stacked', +): RenderResult => + render( + + + , + ); + +const getTitle = (title: string) => + screen.getByRole('heading', { name: title }); + +describe('FeedHeroCarousel stacked', () => { + it('leads with the first post at full width', () => { + renderComponent(); + + expect(getTitle(titles[0])).toBeInTheDocument(); + }); + + it('leaves the rest of the posts to the headline list', () => { + renderComponent(); + + expect( + screen.queryByRole('heading', { name: titles[1] }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('heading', { name: titles[2] }), + ).not.toBeInTheDocument(); + }); + + it('has nothing to page, so no indicators, arrows or autoplay', () => { + renderComponent(); + + expect( + screen.queryByRole('button', { name: /^Show featured post/ }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /^Next:/ }), + ).not.toBeInTheDocument(); + expect(screen.queryByTestId('carouselProgress')).not.toBeInTheDocument(); + }); + + it('renders nothing without posts', () => { + const { container } = renderComponent([]); + + expect(container).toBeEmptyDOMElement(); + }); +}); + +describe('FeedHeroCarousel wide', () => { + it('pages through every post once the section has columns', () => { + renderComponent(posts, 'wide'); + + expect( + screen.getByRole('button', { name: `Next: ${titles[1]}` }), + ).toBeInTheDocument(); + expect( + screen.getAllByRole('button', { name: /^Show featured post/ }), + ).toHaveLength(titles.length); + }); +}); + +describe('FeedHeroCarousel split', () => { + it('pages through every post', () => { + renderComponent(posts, 'split'); + + expect( + screen.getAllByRole('button', { name: /^Show featured post/ }), + ).toHaveLength(titles.length); + }); + + // Half a section is about a feed column wide, so the featured post takes the + // card the feed itself would give it rather than the wide card. + it('uses the standard card, not the wide one', () => { + renderComponent(posts, 'split'); + + expect(getTitle(titles[0])).toBeInTheDocument(); + expect( + screen.getByRole('region', { name: 'Featured posts' }), + ).toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/components/feeds/hero/FeedHeroCarousel.tsx b/packages/shared/src/components/feeds/hero/FeedHeroCarousel.tsx new file mode 100644 index 00000000000..e21851084e8 --- /dev/null +++ b/packages/shared/src/components/feeds/hero/FeedHeroCarousel.tsx @@ -0,0 +1,219 @@ +import type { CSSProperties, ReactElement } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import type { Post } from '../../../graphql/posts'; +import type { FeaturedWideCardProps } from '../../cards/common/featuredWide'; +import { PostTypeToWideCard } from '../../cards/common/wideCards'; +import { ArticleFeaturedWideGridCard } from '../../cards/article/ArticleFeaturedWideGridCard'; +import { PostTypeToGridCard } from '../../cards/common/gridCards'; +import { PostTypeToListCard } from '../../cards/common/listCards'; +import { ArticleList } from '../../cards/article/ArticleList'; +import { ArticleGrid } from '../../cards/article/ArticleGrid'; +import type { FeedHeroLayout } from './feedHeroShape'; +import { Button } from '../../buttons/Button'; +import { ButtonSize, ButtonVariant } from '../../buttons/common'; +import { Tooltip } from '../../tooltip/Tooltip'; +import { ArrowIcon } from '../../icons'; + +export type FeedHeroCarouselProps = Omit & { + posts: Post[]; + autoplayMs?: number; + /** + * The section's shape. Passed in rather than measured here so the card and + * the layout around it come from one number: read apart, a viewport + * breakpoint and a container query disagreed, and a list card stretched down + * a 30rem column was the result. + */ + layout?: FeedHeroLayout; + className?: string; +}; + +const wrapIndex = (index: number, total: number): number => + (index + total) % total; + +export const FeedHeroCarousel = ({ + posts, + autoplayMs = 6000, + layout = 'stacked', + className, + ...cardProps +}: FeedHeroCarouselProps): ReactElement | null => { + const [slide, setSlide] = useState<{ index: number; from: number | null }>({ + index: 0, + from: null, + }); + const [isManualChange, setIsManualChange] = useState(false); + if (!posts.length) { + return null; + } + + // One column: the lead story as a list card, with the headline list under it, + // so the section is the same kind of thing as the rows beneath it. There is + // no paging here — a slide has to stop short of the edge for the next one to + // peek, and at these widths that left every card too narrow to read. The + // stories it would have paged through are the headline list's first rows. + if (layout === 'stacked') { + const [lead] = posts; + const LeadCard = PostTypeToListCard[lead.type] ?? ArticleList; + + return ( +
+ +
+ ); + } + + const total = posts.length; + const active = wrapIndex(slide.index, total); + + const moveTo = (position: number) => { + if (wrapIndex(position, total) === active) { + return; + } + setSlide({ index: position, from: active }); + }; + + const goTo = (position: number) => { + setIsManualChange(true); + moveTo(position); + }; + + const post = posts[active]; + const outgoing = slide.from === null ? null : posts[slide.from]; + // Half a section is about the width of a feed column, so the featured post + // takes the card the feed itself would give it. The wide card's own layout + // has nothing left to trade at that size and its copy clips mid-sentence. + const isWide = layout === 'wide'; + const cardFor = (item: Post) => { + if (isWide) { + return PostTypeToWideCard[item.type] ?? ArticleFeaturedWideGridCard; + } + + return PostTypeToGridCard[item.type] ?? ArticleGrid; + }; + const Card = cardFor(post); + // `hero` is the wide card's own prop; the standard card has no use for it. + const wideProps = isWide ? { hero: true } : {}; + const previous = posts[wrapIndex(active - 1, total)]; + const next = posts[wrapIndex(active + 1, total)]; + + // The slide being replaced stays mounted on top of the new one until its + // fade finishes, so the two cross over instead of the card popping. + let outgoingSlide: ReactElement | null = null; + if (outgoing) { + const OutgoingCard = cardFor(outgoing); + outgoingSlide = ( +
{ + if (event.target !== event.currentTarget) { + return; + } + setSlide((current) => ({ ...current, from: null })); + }} + > + +
+ ); + } + + return ( +
+
+ {outgoingSlide} +
+ +
+
+ {total > 1 && ( +
+
+ {posts.map((item, position) => ( + + ))} +
+
+ +
+
+ )} +
+ ); +}; diff --git a/packages/shared/src/components/feeds/hero/FeedHeroSection.tsx b/packages/shared/src/components/feeds/hero/FeedHeroSection.tsx new file mode 100644 index 00000000000..2f040cd687e --- /dev/null +++ b/packages/shared/src/components/feeds/hero/FeedHeroSection.tsx @@ -0,0 +1,123 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import type { Ad, Post } from '../../../graphql/posts'; +import type { PostHighlight } from '../../../graphql/highlights'; +import type { ViewabilityData } from '../../../features/monetization/viewability'; +import type { FeaturedWideCardProps } from '../../cards/common/featuredWide'; +import { HighlightCardContent } from '../../cards/highlight/common'; +import { FeedHeroAdCard } from './FeedHeroAdCard'; +import { FeedHeroCarousel } from './FeedHeroCarousel'; +import type { FeedHeroAdPlacement, FeedHeroShape } from './feedHeroShape'; +import { feedHeroShape } from './feedHeroShape'; + +/** + * Written out rather than built, because Tailwind only generates the classes it + * can see. Six is the grid's own ceiling — `FeedContext`'s widest setting. + */ +const gridColsClass: Partial> = { + 2: 'grid-cols-2', + 3: 'grid-cols-3', + 4: 'grid-cols-4', + 5: 'grid-cols-5', + 6: 'grid-cols-6', +}; + +const colSpanClass: Partial> = { + 1: 'col-span-1', + 2: 'col-span-2', + 3: 'col-span-3', + 4: 'col-span-4', +}; + +interface FeedHeroSectionProps { + posts: Post[]; + highlights: PostHighlight[]; + ad?: Ad; + /** + * Where the section is putting the ad. Decided by the caller from the same + * shape passed in here, so the column and what goes in it cannot disagree. + */ + adPlacement?: FeedHeroAdPlacement; + /** The section's row, measured against the feed grid's column count. */ + shape?: FeedHeroShape; + cardProps?: Omit; + onAdLinkClick?: (ad: Ad) => unknown; + onAdViewable?: (ad: Ad, data: ViewabilityData) => void; + onHighlightClick?: (highlight: PostHighlight, position: number) => void; + onReadAllClick?: () => void; + className?: string; +} + +export function FeedHeroSection({ + posts, + highlights, + ad, + adPlacement = 'none', + shape = feedHeroShape(1), + cardProps, + onAdLinkClick, + onAdViewable, + onHighlightClick, + onReadAllClick, + className, +}: FeedHeroSectionProps): ReactElement { + const adProps = { onLinkClick: onAdLinkClick, onViewable: onAdViewable }; + const { columns, featuredSpan, railSpan, layout } = shape; + // One column stacks: no grid to hold columns apart, and no row height, since + // the list card and the headline list each take what they need. + const isStacked = layout === 'stacked'; + + return ( +
+
+ + + {!!ad && adPlacement === 'column' && ( + + )} +
+
+ ); +} diff --git a/packages/shared/src/components/feeds/hero/feedHeroShape.spec.ts b/packages/shared/src/components/feeds/hero/feedHeroShape.spec.ts new file mode 100644 index 00000000000..ef83a9ebed1 --- /dev/null +++ b/packages/shared/src/components/feeds/hero/feedHeroShape.spec.ts @@ -0,0 +1,61 @@ +import { feedHeroShape } from './feedHeroShape'; + +describe('feedHeroShape', () => { + // The row has to come out exactly as wide as the grid under it, or the + // section's edges land next to the feed's instead of on them. One column is + // not in this: the section stacks there rather than laying out on a grid. + it.each([2, 3, 4, 5, 6])('fills all %i columns', (columns) => { + const { featuredSpan, railSpan, adSpan } = feedHeroShape(columns); + + expect(featuredSpan + railSpan + adSpan).toBe(columns); + }); + + it('stacks the phone, where there is one column to share', () => { + expect(feedHeroShape(1)).toMatchObject({ + layout: 'stacked', + adPlacement: 'none', + }); + }); + + // Whatever the count says: a grid hero over a list feed reads as two + // different things stacked on each other. + it.each([2, 3, 4, 5, 6])( + 'stacks a %i-column feed that renders as a list', + (columns) => { + expect(feedHeroShape(columns, true)).toMatchObject({ + columns: 1, + layout: 'stacked', + adPlacement: 'none', + }); + }, + ); + + it('gives the rail a column of its own from two columns up', () => { + expect(feedHeroShape(2)).toMatchObject({ featuredSpan: 1, railSpan: 1 }); + }); + + // One column is a feed card, which is what the standard card is drawn for; + // the wide card only earns its shape back across two. + it('takes the standard card at one column and the wide card beyond it', () => { + expect(feedHeroShape(2).layout).toBe('split'); + expect(feedHeroShape(3).layout).toBe('wide'); + }); + + // Below four columns the feed keeps the placement and shows it in its own + // slot rather than the hero squeezing a different-shaped one in. + it('spares the ad a column only once there are four', () => { + expect(feedHeroShape(3)).toMatchObject({ adSpan: 0, adPlacement: 'none' }); + expect(feedHeroShape(4)).toMatchObject({ + adSpan: 1, + adPlacement: 'column', + }); + }); + + // The featured card takes the slack as the grid widens, up to the point + // where the headline list has earned a second column too. + it('widens the featured card, then the rail', () => { + expect(feedHeroShape(4)).toMatchObject({ featuredSpan: 2, railSpan: 1 }); + expect(feedHeroShape(5)).toMatchObject({ featuredSpan: 3, railSpan: 1 }); + expect(feedHeroShape(6)).toMatchObject({ featuredSpan: 3, railSpan: 2 }); + }); +}); diff --git a/packages/shared/src/components/feeds/hero/feedHeroShape.ts b/packages/shared/src/components/feeds/hero/feedHeroShape.ts new file mode 100644 index 00000000000..557d102b0ff --- /dev/null +++ b/packages/shared/src/components/feeds/hero/feedHeroShape.ts @@ -0,0 +1,73 @@ +/** + * Where the section puts the placement. `column` is the full card in a column + * of its own; `none` hands it back to the feed, which places it exactly as it + * does with no hero at all. + */ +export type FeedHeroAdPlacement = 'none' | 'column'; + +/** + * Which card the featured post takes. + * + * `stacked` is one column: the lead story as a list card with the headline list + * under it, which is what the feed's own list view gets. `split` is one feed + * column wide, which takes the platform's standard card — the wide card's own + * layout has nothing left to trade at that size and its copy clips mid- + * sentence. `wide` is two columns or more, where the wide card earns its shape + * back. + */ +export type FeedHeroLayout = 'stacked' | 'split' | 'wide'; + +export type FeedHeroShape = { + /** Columns in the hero's row — the feed grid's own count. */ + columns: number; + featuredSpan: number; + railSpan: number; + /** 0 when the ad has no column of its own. */ + adSpan: number; + layout: FeedHeroLayout; + adPlacement: FeedHeroAdPlacement; +}; + +/** + * The hero's row, laid out on the feed grid's columns instead of on thresholds + * of its own, so the section reflows when the feed does and its column edges + * land on the grid's rather than beside them. + * + * The rail holds a single column until there are enough to spare it a second, + * and the ad earns one from four columns up — below that the feed keeps the + * placement rather than the hero squeezing it in. The featured card takes + * whatever is left, which is what makes the row come out exactly as wide as the + * grid beneath it however many columns that is. + * + * A feed rendering as a list takes the one-column shape whatever its column + * count says, so the section reads as the same kind of thing as the rows under + * it. + */ +export const feedHeroShape = ( + columns: number, + isList = false, +): FeedHeroShape => { + if (isList || columns <= 1) { + return { + columns: 1, + featuredSpan: 1, + railSpan: 1, + adSpan: 0, + layout: 'stacked', + adPlacement: 'none', + }; + } + + const adSpan = columns >= 4 ? 1 : 0; + const railSpan = columns >= 6 ? 2 : 1; + const featuredSpan = columns - railSpan - adSpan; + + return { + columns, + featuredSpan, + railSpan, + adSpan, + layout: featuredSpan > 1 ? 'wide' : 'split', + adPlacement: adSpan > 0 ? 'column' : 'none', + }; +}; diff --git a/packages/shared/src/components/feeds/hero/useFeedHeroAd.ts b/packages/shared/src/components/feeds/hero/useFeedHeroAd.ts new file mode 100644 index 00000000000..a4a311ad611 --- /dev/null +++ b/packages/shared/src/components/feeds/hero/useFeedHeroAd.ts @@ -0,0 +1,57 @@ +import { useContext } from 'react'; +import type { Ad } from '../../../graphql/posts'; +import { useAdQuery } from '../../../features/monetization/useAdQuery'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import FeedContext from '../../../contexts/FeedContext'; +import { useFeedLayout } from '../../../hooks/useFeedLayout'; +import { usePlusSubscription } from '../../../hooks/usePlusSubscription'; +import { AdPlacement } from '../../../lib/ads'; +import { generateQueryKey, RequestKey, StaleTime } from '../../../lib/query'; +import type { FeedHeroAdPlacement, FeedHeroShape } from './feedHeroShape'; +import { feedHeroShape } from './feedHeroShape'; + +export type FeedHeroAdSlot = { + ad?: Ad; + placement: FeedHeroAdPlacement; + /** + * The section's row. The carousel needs it too — it picks the featured card + * from the same number of columns the card is given, so the two cannot end + * up disagreeing about how much room there is. + */ + shape: FeedHeroShape; +}; + +/** + * The hero's ad. The feed underneath drops its own first placement whenever the + * hero has somewhere to put one, so the reader never meets two before the first + * post — and gets it back the moment the hero does not. + * + * Whether there is somewhere comes from the grid's column count, which is there + * on the first render, rather than from measuring the section: a load that + * paints straight at its final size never fires a resize to measure on. + */ +export const useFeedHeroAd = (enabled: boolean): FeedHeroAdSlot => { + const { user, tokenRefreshed } = useAuthContext(); + const { isPlus } = usePlusSubscription(); + const { numCards } = useContext(FeedContext); + // The same call the feed makes, so the two agree on what a list is: below + // laptop, and on laptop up whenever the reader has list mode on. + const { shouldUseListFeedLayout } = useFeedLayout(); + // `.eco` regardless of the reader's spaciness, because that is the count the + // grid itself renders with — see `FeedContainer`. + const shape = feedHeroShape(numCards.eco, shouldUseListFeedLayout); + + const { data: ad } = useAdQuery({ + placement: AdPlacement.Feed, + queryKey: generateQueryKey(RequestKey.Ads, user, 'feed-hero'), + enabled: + enabled && tokenRefreshed && !isPlus && shape.adPlacement !== 'none', + staleTime: StaleTime.OneHour, + }); + + return { + ad: ad ?? undefined, + placement: ad ? shape.adPlacement : 'none', + shape, + }; +}; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index a8446e8c602..0ba00a8ef80 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -271,6 +271,10 @@ export const featureHeroCards = new Feature('hero_cards', { }, }); +// Experiment: a hero section above the feed — a carousel of the current +// headlines, with the Happening Now list and a direct ad placement beside it. +export const featureFeedHero = new Feature('feed_hero', false); + // Experiment: skip layout/paint for off-screen feed cards via CSS // `content-visibility: auto` to keep long feeds responsive. export const featureFeedContentVisibility = new Feature( diff --git a/packages/shared/src/styles/utilities.css b/packages/shared/src/styles/utilities.css index 269f0e41dd3..d6eda7dab28 100644 --- a/packages/shared/src/styles/utilities.css +++ b/packages/shared/src/styles/utilities.css @@ -340,6 +340,70 @@ } } +@keyframes feed-hero-slide-in { + from { + opacity: 0; + transform: scale(0.985); + } + + to { + opacity: 1; + transform: none; + } +} + +@keyframes feed-hero-slide-out { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} + +.feed-hero-slide-in { + animation: feed-hero-slide-in 420ms cubic-bezier(0.16, 1, 0.3, 1) both; +} + +.feed-hero-slide-out { + animation: feed-hero-slide-out 320ms ease-out both; +} + +/* Near-zero rather than `none`: the outgoing slide is unmounted on its own + `animationend`, which never arrives if the animation is removed outright. */ +@media (prefers-reduced-motion: reduce) { + .feed-hero-slide-in, + .feed-hero-slide-out { + animation-duration: 1ms; + } +} + +@keyframes feed-hero-carousel-progress { + from { + transform: scaleX(0); + } + + to { + transform: scaleX(1); + } +} + +/* The slide advances on this animation's `animationend`, so pausing it also + pauses the rotation and reduced motion stops the carousel altogether. */ +.feed-hero-carousel-progress { + transform-origin: left center; + animation: feed-hero-carousel-progress + var(--feed-hero-carousel-duration, 6s) linear forwards; +} + +@media (prefers-reduced-motion: reduce) { + .feed-hero-carousel-progress { + animation: none; + transform: scaleX(1); + } +} + .feed-highlights-new-item-border-bottom { border-style: solid; border-width: 0 0 0.0625rem; diff --git a/packages/storybook/stories/features/feed/FeedHero.stories.tsx b/packages/storybook/stories/features/feed/FeedHero.stories.tsx new file mode 100644 index 00000000000..2a5fb600762 --- /dev/null +++ b/packages/storybook/stories/features/feed/FeedHero.stories.tsx @@ -0,0 +1,478 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import { fn } from 'storybook/test'; +import { ArticleGrid } from '@dailydotdev/shared/src/components/cards/article/ArticleGrid'; +import { ExploreChipsBar } from '@dailydotdev/shared/src/components/feeds/ExploreChipsBar'; +import { FeedHeroAdCard } from '@dailydotdev/shared/src/components/feeds/hero/FeedHeroAdCard'; +import { FeedHeroCarousel } from '@dailydotdev/shared/src/components/feeds/hero/FeedHeroCarousel'; +import { FeedHeroSection } from '@dailydotdev/shared/src/components/feeds/hero/FeedHeroSection'; +import { feedHeroShape } from '@dailydotdev/shared/src/components/feeds/hero/feedHeroShape'; +import { + adWithLongCopy, + adWithoutAdvertiser, + adWithoutCta, + adWithoutImage, + adWithoutTags, + cardHandlers, + exploreCategories, + feedPosts, + FeedHeroProviders, + heroAd, + heroPosts, + highlights, + longTitleHeroPost, + mixedTypeHeroPosts, + noImageHeroPost, + readHeroPost, +} from './feedHero.mocks'; + +const Page = ({ children }: { children: ReactNode }): ReactElement => ( + +
+ {/* A four-card feed, the narrowest one the ad column appears in. */} +
+ {children} +
+
+
+); + +const Case = ({ + title, + note, + width, + children, +}: { + title: string; + note?: string; + width?: string; + children: ReactNode; +}): ReactElement => ( +
+

{title}

+ {!!note &&

{note}

} +
+ {children} +
+
+); + +const FeedGrid = (): ReactElement => ( +
+ {feedPosts.map((post) => ( + + ))} +
+); + +const meta: Meta = { + title: 'Features/Feed/Hero', + parameters: { + layout: 'fullscreen', + }, +}; + +export default meta; + +type Story = StoryObj; + +export const FullLayout: Story = { + name: 'Hero + all posts', + render: () => ( + + +
+ + +
+
+ ), +}; + +export const HeroOnly: Story = { + name: 'Hero section', + render: () => ( + + + + ), +}; + +// The hero lays out on the feed grid's own columns, so its stage is a column +// count rather than a width — the same count the grid under it is using, which +// `FeedContext` derives from the viewport, the sidebar's state and the reader's +// card-count setting together. The widths here are only what that many columns +// come to at a typical window; the shape does not depend on them. +const FEED_STAGES = [ + { + columns: 2, + px: 720, + note: 'the featured post takes one column — the feed card at its own size — and the feed below keeps the ad', + }, + { + columns: 3, + px: 980, + note: 'the featured card takes two columns and turns wide; a third for the ad would leave every column too narrow, so the feed keeps it', + }, + { + columns: 4, + px: 1250, + note: 'the ad earns a column, at the 270px the card was drawn for', + }, + { + columns: 5, + px: 1560, + note: 'the slack goes to the featured card', + }, + { + columns: 6, + px: 1880, + note: 'wide enough that the headline list earns a second column too', + }, +]; + +export const WideFeed: Story = { + name: 'Hero at each stage', + render: () => ( + +
+ {FEED_STAGES.map(({ columns, px, note }) => { + const shape = feedHeroShape(columns); + + return ( +
+

+ {columns} columns · about {px}px of feed +

+

{note}

+
+ +
+
+ ); + })} +
+
+ ), +}; + +// Each breakpoint gets its own iframe so Tailwind's media queries resolve +// against a real viewport width, not a resized container. +const BREAKPOINTS = [ + { label: 'Mobile', width: 390, height: 900 }, + { label: 'Tablet', width: 768, height: 900 }, + { label: 'Laptop', width: 1024, height: 760 }, + { label: 'Desktop', width: 1440, height: 760 }, +]; + +export const Responsive: Story = { + name: 'Responsive breakpoints', + render: (args, { globals }) => ( +
+

+ The four-column shape rendered at each breakpoint, so what these show is + the section holding that shape as the window narrows — not which shape a + window picks. That comes from the feed grid's column count, which + no story can set from a viewport: see “Hero at each stage” + for the stages themselves, and “Hero in list view” for the + one-column list shape. +

+
+ {BREAKPOINTS.map(({ label, width, height }) => ( +
+ + {label} · {width}px + +