diff --git a/packages/shared/src/components/Feed.spec.tsx b/packages/shared/src/components/Feed.spec.tsx index 515c80bc929..eda7782eb2c 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,40 @@ describe('Feed ad cadence with highlight cards', () => { expect(order.slice(2).every((t) => t === 'postItem')).toBe(true); }); + // The hero above the feed is already showing an ad, so the grid stands its + // first one down: two placements become one. The survivor keeps its index + // because the dropped ad no longer occupies a cell against the cadence, so + // the next slot comes due one post later and lands back where it was. + 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..523bb3c8cb0 100644 --- a/packages/shared/src/components/FeedItemComponent.tsx +++ b/packages/shared/src/components/FeedItemComponent.tsx @@ -14,21 +14,19 @@ 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 type { FeaturedWideColSpan } from './cards/common/featuredWide'; import { PostTypeToWideCard } from './cards/common/wideCards'; +import { PostTypeToListCard } from './cards/common/listCards'; import { ShareGrid } from './cards/share/ShareGrid'; -import { ShareList } from './cards/share/ShareList'; import { CollectionGrid } from './cards/collection'; import type { UseBookmarkPost } from '../hooks/useBookmarkPost'; import { AdActions } from '../lib/ads'; @@ -51,9 +49,7 @@ import { 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'; @@ -135,20 +131,6 @@ const PostTypeToTagCard: Record> = { [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 +158,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; diff --git a/packages/shared/src/components/MainFeedLayout.tsx b/packages/shared/src/components/MainFeedLayout.tsx index ea0560d5708..a1ec55aaf21 100644 --- a/packages/shared/src/components/MainFeedLayout.tsx +++ b/packages/shared/src/components/MainFeedLayout.tsx @@ -63,13 +63,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, @@ -77,6 +78,9 @@ import { upvotedFeedVersion, } from '../lib/featureManagement'; import type { FeedContainerProps } from './feeds'; +import { FeedHero } from './feeds/hero/FeedHero'; +import { useFeedHeroAd } from './feeds/hero/useFeedHeroAd'; +import { useFeedHeroPreview } from './feeds/hero/useFeedHeroPreview'; import { getFeedName } from '../lib/feed'; import CommentFeed from './CommentFeed'; import { COMMENT_FEED_QUERY } from '../graphql/comments'; @@ -372,6 +376,19 @@ export default function MainFeedLayout({ [showExploreChips, exploreCategories, feeds, isV2], ); + const isMainFeedPage = + feedName === SharedFeedPage.MyFeed || feedName === SharedFeedPage.Popular; + const { value: isFeedHeroFlagOn } = useConditionalFeature({ + feature: featureFeedHero, + shouldEvaluate: isMainFeedPage, + }); + const isFeedHeroPreview = useFeedHeroPreview(); + const isFeedHeroEnabled = + isMainFeedPage && (isFeedHeroFlagOn || isFeedHeroPreview); + // Read here as well as in the hero so the grid below can stand its own first + // ad down while the hero is showing one. Same query key, so one request. + const { isVisible: isHeroAdVisible } = useFeedHeroAd(isFeedHeroEnabled); + const { isSearchPageLaptop } = useSearchResultsLayout(); const config = useMemo(() => { @@ -763,6 +780,38 @@ 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 without the same inset the hero runs wider than the cards + // on both sides and sits flush to the top edge. The bottom is what is left of + // the grid's 32px row gap once the grid's own top inset is counted, so the + // hero stands the same distance off the first row as the rows do off each + // other. + const isV2Grid = isV2 && !shouldUseListFeedLayout; + const heroClassName = classNames( + 'w-full', + isV2Grid + ? `${v2FeedSideInsetClass} mb-8 tablet:mb-6 tablet:pt-2 laptop:mb-2 laptop:pt-6` + : '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 && @@ -837,18 +886,10 @@ export default function MainFeedLayout({ - {chipsNode} - - ) : undefined - } + topContent={topContent} + disableHighlightCards={isFeedHeroEnabled} + skipFirstAd={isHeroAdVisible} + deferWideCards={isFeedHeroEnabled} className={classNames( shouldUseListFeedLayout && !isFinder && 'laptop:px-6', )} diff --git a/packages/shared/src/components/cards/Freeform/FreeformFeaturedWideGridCard.tsx b/packages/shared/src/components/cards/Freeform/FreeformFeaturedWideGridCard.tsx index de22714156d..627bc15828f 100644 --- a/packages/shared/src/components/cards/Freeform/FreeformFeaturedWideGridCard.tsx +++ b/packages/shared/src/components/cards/Freeform/FreeformFeaturedWideGridCard.tsx @@ -11,7 +11,15 @@ 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, + HERO_DESCRIPTION_CLASS_NAME, + HERO_TEXT_FIT_CLASS_NAME, + HERO_TITLE_CLASS_NAME, + INNER_GRID_COLS, + TEXT_COL_SPAN, + TITLE_CLASS_NAME, +} from '../common/featuredWide'; import { FeaturedWideCardShell } from '../common/FeaturedWideCardShell'; import { FeaturedWideImageColumn } from '../common/FeaturedWideImageColumn'; import { FeaturedWideActions } from '../common/FeaturedWideActions'; @@ -34,6 +42,7 @@ export const FreeformFeaturedWideGridCard = forwardRef( eagerLoadImage = false, enableSourceHeader = false, wideColSpan = 2, + hero, }: FeaturedWideCardProps, ref: Ref, ): ReactElement { @@ -64,13 +73,25 @@ export const FreeformFeaturedWideGridCard = forwardRef( image || overlay ? INNER_GRID_COLS[wideColSpan] : 'grid-cols-1', )} > -
- +
+ -

+

{title}

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

+

{description}

)} @@ -106,6 +132,7 @@ export const FreeformFeaturedWideGridCard = forwardRef( image={image} alt={post.title ?? ''} wideColSpan={wideColSpan} + coverImage={hero} overlay={overlay} eagerLoadImage={eagerLoadImage} /> diff --git a/packages/shared/src/components/cards/ad/common/AdFavicon.tsx b/packages/shared/src/components/cards/ad/common/AdFavicon.tsx index 8ef3936c50a..332fa8544c4 100644 --- a/packages/shared/src/components/cards/ad/common/AdFavicon.tsx +++ b/packages/shared/src/components/cards/ad/common/AdFavicon.tsx @@ -10,9 +10,14 @@ import { getAdFaviconImageLink } from './getAdFaviconImageLink'; type AdFaviconProps = { ad: Ad; + size?: ProfileImageSize; className?: string; }; -export const AdFavicon = ({ ad, className }: AdFaviconProps): ReactElement => { +export const AdFavicon = ({ + ad, + size = ProfileImageSize.Medium, + className, +}: AdFaviconProps): ReactElement => { const adImprovementsV3 = useFeature(adImprovementsV3Feature); const imageLink = getAdFaviconImageLink({ ad, @@ -23,7 +28,7 @@ export const AdFavicon = ({ ad, className }: AdFaviconProps): ReactElement => { : null; const renderComponent = ( - props: Partial = {}, + props: Partial< + PostCardProps & { wideColSpan?: 2 | 3 | 4 | 5; 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 +109,30 @@ 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, wideColSpan: 5 }); + + const summary = summaryOf(); + // Every element is `flex-shrink: 0` by default in base.css, so the summary + // and the block holding it have to opt back in or the actions get pushed + // out through the bottom 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..6750d878d3a 100644 --- a/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx +++ b/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx @@ -15,7 +15,15 @@ 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, + HERO_DESCRIPTION_CLASS_NAME, + HERO_TEXT_FIT_CLASS_NAME, + HERO_TITLE_CLASS_NAME, + INNER_GRID_COLS, + TEXT_COL_SPAN, + TITLE_CLASS_NAME, +} from '../common/featuredWide'; import { FeaturedWideCardShell } from '../common/FeaturedWideCardShell'; import { FeaturedWideImageColumn } from '../common/FeaturedWideImageColumn'; import { FeaturedWideActions } from '../common/FeaturedWideActions'; @@ -39,6 +47,7 @@ export const ArticleFeaturedWideGridCard = forwardRef( domProps = {}, eagerLoadImage = false, wideColSpan = 2, + hero, }: FeaturedWideCardProps, ref: Ref, ): ReactElement { @@ -97,7 +106,9 @@ export const ArticleFeaturedWideGridCard = forwardRef( const standardContent = ( <> - + -

+

{title}

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

+

{description}

)} @@ -155,7 +176,12 @@ export const ArticleFeaturedWideGridCard = forwardRef( image || overlay ? INNER_GRID_COLS[wideColSpan] : 'grid-cols-1', )} > -
+
{showFeedback ? feedbackContent : standardContent}
{(!!image || !!overlay) && ( @@ -163,6 +189,7 @@ export const ArticleFeaturedWideGridCard = forwardRef( image={image} alt={post.title ?? ''} wideColSpan={wideColSpan} + coverImage={hero} overlay={overlay} isVideoType={isVideoType} eagerLoadImage={eagerLoadImage} diff --git a/packages/shared/src/components/cards/collection/CollectionFeaturedWideGridCard.tsx b/packages/shared/src/components/cards/collection/CollectionFeaturedWideGridCard.tsx index e75a084a0de..393154dfcf1 100644 --- a/packages/shared/src/components/cards/collection/CollectionFeaturedWideGridCard.tsx +++ b/packages/shared/src/components/cards/collection/CollectionFeaturedWideGridCard.tsx @@ -12,7 +12,15 @@ import { useCardCover } from '../../../hooks/feed/useCardCover'; import { CollectionCardHeader } from './CollectionCardHeader'; import { HighlightChip } from '../common/HighlightChip'; import type { FeaturedWideCardProps } from '../common/featuredWide'; -import { INNER_GRID_COLS } from '../common/featuredWide'; +import { + DESCRIPTION_CLASS_NAME, + HERO_DESCRIPTION_CLASS_NAME, + HERO_TEXT_FIT_CLASS_NAME, + HERO_TITLE_CLASS_NAME, + INNER_GRID_COLS, + TEXT_COL_SPAN, + TITLE_CLASS_NAME, +} from '../common/featuredWide'; import { FeaturedWideCardShell } from '../common/FeaturedWideCardShell'; import { FeaturedWideImageColumn } from '../common/FeaturedWideImageColumn'; import { FeaturedWideActions } from '../common/FeaturedWideActions'; @@ -34,6 +42,7 @@ export const CollectionFeaturedWideGridCard = forwardRef( domProps = {}, eagerLoadImage = false, wideColSpan = 2, + hero, }: FeaturedWideCardProps, ref: Ref, ): ReactElement { @@ -61,10 +70,22 @@ export const CollectionFeaturedWideGridCard = forwardRef( image || overlay ? INNER_GRID_COLS[wideColSpan] : 'grid-cols-1', )} > -
- +
+ -

+

{title}

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

+

{post.summary}

)} @@ -104,6 +130,7 @@ export const CollectionFeaturedWideGridCard = forwardRef( image={image} alt={post.title ?? ''} wideColSpan={wideColSpan} + coverImage={hero} overlay={overlay} eagerLoadImage={eagerLoadImage} /> 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..1fc902c75b7 100644 --- a/packages/shared/src/components/cards/common/FeaturedWideImageColumn.tsx +++ b/packages/shared/src/components/cards/common/FeaturedWideImageColumn.tsx @@ -14,6 +14,12 @@ export type FeaturedWideImageColumnProps = { overlay?: ReactNode; isVideoType?: boolean; eagerLoadImage?: boolean; + /** + * Crop the image to fill its column, inset with its own corners, the way the + * feed cards treat a cover. Off keeps the letterboxed image over a blurred + * backdrop that the in-feed wide cards use. + */ + coverImage?: boolean; }; export const FeaturedWideImageColumn = ({ @@ -23,14 +29,16 @@ export const FeaturedWideImageColumn = ({ overlay, isVideoType, eagerLoadImage, + coverImage, }: FeaturedWideImageColumnProps): ReactElement => (
- {!!image && ( + {!!image && !coverImage && (
{children}
; +}): ReactElement => ( +
{children}
+); diff --git a/packages/shared/src/components/cards/common/featuredWide.ts b/packages/shared/src/components/cards/common/featuredWide.ts index 17fb8506fc0..f3e9be29846 100644 --- a/packages/shared/src/components/cards/common/featuredWide.ts +++ b/packages/shared/src/components/cards/common/featuredWide.ts @@ -1,19 +1,62 @@ import type { PostCardProps } from './common'; -export type FeaturedWideColSpan = 2 | 3 | 4; +export type FeaturedWideColSpan = 2 | 3 | 4 | 5; 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 the text 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. + */ +/** + * The bottom padding matches the fade, so at full height the gradient covers + * only padding and the last line stays solid; once the block is squeezed the + * padding goes first and the line being cut fades out instead of showing a row + * of sliced glyphs. The padding belongs here rather than on the summary because + * `overflow: hidden` clips at the padding edge, which would let a seventh line + * leak out past the clamp. + */ +export const HERO_TEXT_FIT_CLASS_NAME = + 'min-h-0 shrink overflow-hidden pb-5 [mask-image:linear-gradient(to_bottom,black_calc(100%-1.25rem),transparent)]'; +export const HERO_DESCRIPTION_CLASS_NAME = 'line-clamp-6 min-h-0 shrink'; + export const INNER_GRID_COLS: Record = { 2: 'grid-cols-2', 3: 'grid-cols-3', 4: 'grid-cols-4', + 5: 'grid-cols-5', }; export const IMAGE_COL_SPAN: Record = { 2: 'col-span-1', 3: 'col-span-2', 4: 'col-span-3', + 5: 'col-span-3', +}; + +/** + * Every span but 5 leaves the text a single column. 5 exists for the 40/60 + * split, which is two of five — the only ratio here that needs saying. + */ +export const TEXT_COL_SPAN: Record = { + 2: 'col-span-1', + 3: 'col-span-1', + 4: 'col-span-1', + 5: 'col-span-2', }; 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..7d31e5e9f42 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,25 +70,44 @@ 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} @@ -95,16 +119,36 @@ 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 is there to stop the pinned footer slicing a + // row flat. Below that the section stacks, every row fits, and the fade + // would dim the last headline for nothing. + 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 ( @@ -113,7 +157,8 @@ export const HighlightCardContent = ({

Happening Now @@ -127,12 +172,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..19f1cc0f9b4 100644 --- a/packages/shared/src/components/cards/share/ShareFeaturedWideGridCard.tsx +++ b/packages/shared/src/components/cards/share/ShareFeaturedWideGridCard.tsx @@ -16,7 +16,15 @@ 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, + HERO_DESCRIPTION_CLASS_NAME, + HERO_TEXT_FIT_CLASS_NAME, + HERO_TITLE_CLASS_NAME, + INNER_GRID_COLS, + TEXT_COL_SPAN, + TITLE_CLASS_NAME, +} from '../common/featuredWide'; import { FeaturedWideCardShell } from '../common/FeaturedWideCardShell'; import { FeaturedWideImageColumn } from '../common/FeaturedWideImageColumn'; import { FeaturedWideActions } from '../common/FeaturedWideActions'; @@ -40,6 +48,7 @@ export const ShareFeaturedWideGridCard = forwardRef( domProps = {}, eagerLoadImage = false, wideColSpan = 2, + hero, }: FeaturedWideCardProps, ref: Ref, ): ReactElement { @@ -77,8 +86,15 @@ export const ShareFeaturedWideGridCard = forwardRef( image || overlay ? INNER_GRID_COLS[wideColSpan] : 'grid-cols-1', )} > -
- +
+ {(!isSharedTweet || post.title) && ( -

+

{title}

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

+

{sharedSummary}

)} @@ -148,6 +176,7 @@ export const ShareFeaturedWideGridCard = forwardRef( image={image} alt={sharedTitle || post.title || ''} wideColSpan={wideColSpan} + coverImage={hero} overlay={overlay} isVideoType={isVideoType} eagerLoadImage={eagerLoadImage} diff --git a/packages/shared/src/components/feeds/FeedContainer.tsx b/packages/shared/src/components/feeds/FeedContainer.tsx index ec90b7c70b9..64894488fc7 100644 --- a/packages/shared/src/components/feeds/FeedContainer.tsx +++ b/packages/shared/src/components/feeds/FeedContainer.tsx @@ -54,6 +54,14 @@ export interface FeedContainerProps { disableListFrame?: boolean; } +/** + * The v2 grid sits inset inside the floating card. Anything the feed renders in + * its top slot is a sibling of that grid, not a child, so it has to carry the + * same side inset or it runs wider than the cards underneath it. + */ +export const v2FeedSideInsetClass = 'tablet:px-2 laptop:px-6'; +const v2FeedInsetClass = `${v2FeedSideInsetClass} tablet:py-2 laptop:py-6`; + const listGapClass = 'gap-2'; const gridGapClass = 'gap-8'; const feedGapPx = { @@ -373,7 +381,7 @@ export const FeedContainer = ({ // mock. The page-header strip above sets its own // bottom border, so cards sit p-6 inside the floating // card on all four sides. - 'tablet:p-2 laptop:p-6 [&_article:hover]:!border-border-subtlest-tertiary [&_article]:!border-border-subtlest-quaternary', + `${v2FeedInsetClass} [&_article:hover]:!border-border-subtlest-tertiary [&_article]:!border-border-subtlest-quaternary`, // Inner inset for the bordered list frame. With the frame gone // there is nothing to inset from, so the cards run the full // width of the column like the page header above them. 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..2ff6d31ceae --- /dev/null +++ b/packages/shared/src/components/feeds/hero/FeedHero.tsx @@ -0,0 +1,155 @@ +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 { 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, 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 = ({ + className, +}: { + className?: string; +}): ReactElement | null => { + const { user, tokenRefreshed } = useAuthContext(); + const { logEvent } = useLogContext(); + const postLogEvent = usePostLogEvent(); + const { toggleUpvote, toggleDownvote } = useVotePost(); + const { toggleBookmark } = useBookmarkPost(); + const [, copyLink] = useCopyLink(); + + 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]); + + const { ad, isVisible: isAdVisible } = useFeedHeroAd(true); + + const onAdAction = useCallback( + (action: AdActions, extra?: Record) => { + if (!ad) { + return; + } + + logEvent( + adLogEvent(action, ad, { extra: { origin: 'feed hero', ...extra } }), + ); + }, + [ad, logEvent], + ); + + useEffect(() => { + if ( + !ad || + !isAdVisible || + ad.impressionStatus === ImpressionStatus.LOGGED + ) { + return; + } + + onAdAction(AdActions.Impression); + ad.impressionStatus = ImpressionStatus.LOGGED; + }, [ad, isAdVisible, 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 ( + 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..0c21b46e5e5 --- /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'; + +const AdCover = classed(Image, 'h-full w-full 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 on top at the size that card gives its source, the headline + * at the same size, then tags, then the disclosure standing where the card puts + * its date and in that colour. The cover takes whatever height the copy leaves, + * which is what lets the card run the section's height without a gap opening up + * above the controls. + * + * What it keeps from the rail is the spacing: one 16px text edge shared with + * the headline rows beside it. + */ +export const FeedHeroAdCard = ({ + ad, + onLinkClick, + onViewable, + className, +}: FeedHeroAdCardProps): ReactElement => { + const { isPlus } = usePlusSubscription(); + const { showAdvertiseLink } = useAdLabel(); + const matchingTags = ad.matchingTags ?? []; + + return ( + + + {/* Same running order as the featured card beside it: the mark, the + headline, its tags, then the line the card gives its date. */} + + + {ad.description} + + {matchingTags.length > 0 && ( + + )} + + {!!ad.image && ( + // Fixed below `laptop`, where the column runs the full width and + // letting the creative keep its own aspect put a 600px cover on the + // page. From `laptop` it takes the height the copy leaves instead, + // which is what lets the card match the section without a gap opening + // above the controls; the floor stops a long headline collapsing it. + + )} + {/* The feed card's two controls. The creative's own call to action is + left out: a third button wraps the row onto two lines in a 270px + column, and the whole card is already the click target, so the button + was a second route to the same link rather than the only one. + `mt-auto` pins the row when there is no cover to take the slack. */} +
+ {showAdvertiseLink && ( + + )} + {!isPlus && ( + + )} +
+ {/* Out of flow so the column's spacing doesn't reserve a row for it. */} +
+ +
+ + onViewable?.(ad, data)} /> +
+ ); +}; 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..1f136fc93cc --- /dev/null +++ b/packages/shared/src/components/feeds/hero/FeedHeroCarousel.spec.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import type { RenderResult } from '@testing-library/react'; +import { fireEvent, 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'; + +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): RenderResult => + render( + + + , + ); + +const getTitle = (title: string) => + screen.getByRole('heading', { name: title }); + +describe('FeedHeroCarousel', () => { + it('renders the first post and both neighbours as navigation labels', () => { + renderComponent(); + + expect(getTitle(titles[0])).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: `Next: ${titles[1]}` }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: `Previous: ${titles[2]}` }), + ).toBeInTheDocument(); + }); + + it('wraps around when paging past the last post', () => { + renderComponent(); + + fireEvent.click(screen.getByRole('button', { name: `Next: ${titles[1]}` })); + expect(getTitle(titles[1])).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: `Next: ${titles[2]}` })); + fireEvent.click(screen.getByRole('button', { name: `Next: ${titles[0]}` })); + expect(getTitle(titles[0])).toBeInTheDocument(); + }); + + it('jumps to the post picked from the indicators', () => { + renderComponent(); + + fireEvent.click( + screen.getByRole('button', { name: 'Show featured post 3' }), + ); + + expect(getTitle(titles[2])).toBeInTheDocument(); + }); + + it('hides the controls for a single post', () => { + renderComponent([posts[0]]); + + expect(getTitle(titles[0])).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /^Show featured post/ }), + ).not.toBeInTheDocument(); + expect(screen.queryByTestId('carouselProgress')).not.toBeInTheDocument(); + }); + + it('advances once the active indicator finishes filling', () => { + renderComponent(); + + const progress = screen.getByTestId('carouselProgress'); + expect( + screen.getByRole('button', { name: 'Show featured post 1' }), + ).toContainElement(progress); + + fireEvent.animationEnd(progress); + + expect(getTitle(titles[1])).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Show featured post 2' }), + ).toContainElement(screen.getByTestId('carouselProgress')); + }); + + it('keeps the outgoing post mounted until its fade finishes', () => { + renderComponent(); + + fireEvent.click(screen.getByRole('button', { name: `Next: ${titles[1]}` })); + + const leaving = screen.getByTestId('carouselOutgoing'); + expect(leaving).toHaveTextContent(titles[0]); + expect(getTitle(titles[1])).toBeInTheDocument(); + + fireEvent.animationEnd(leaving); + + expect(screen.queryByTestId('carouselOutgoing')).not.toBeInTheDocument(); + }); + + it('only announces a change the reader asked for', () => { + renderComponent(); + + const slide = getTitle(titles[0]).closest('[aria-live]'); + expect(slide).toHaveAttribute('aria-live', 'off'); + + fireEvent.click(screen.getByRole('button', { name: `Next: ${titles[1]}` })); + expect(getTitle(titles[1]).closest('[aria-live]')).toHaveAttribute( + 'aria-live', + 'polite', + ); + }); +}); 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..718658e9854 --- /dev/null +++ b/packages/shared/src/components/feeds/hero/FeedHeroCarousel.tsx @@ -0,0 +1,189 @@ +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 { PostTypeToListCard } from '../../cards/common/listCards'; +import { ArticleFeaturedWideGridCard } from '../../cards/article/ArticleFeaturedWideGridCard'; +import { ArticleList } from '../../cards/article/ArticleList'; +import { useViewSize, ViewSize } from '../../../hooks'; +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; + className?: string; +}; + +const wrapIndex = (index: number, total: number): number => + (index + total) % total; + +export const FeedHeroCarousel = ({ + posts, + autoplayMs = 6000, + className, + wideColSpan, + ...cardProps +}: FeedHeroCarouselProps): ReactElement | null => { + const [slide, setSlide] = useState<{ index: number; from: number | null }>({ + index: 0, + from: null, + }); + const [isManualChange, setIsManualChange] = useState(false); + // Below laptop the feed itself renders list cards, so the featured post does + // too — a two-column wide card leaves the headline about 180px on a phone. + const isLaptop = useViewSize(ViewSize.Laptop); + // The 40/60 split is only readable once the text column can still hold a + // headline; on a 1024px laptop it leaves about 230px, so that falls back to + // an even split. + const isLaptopL = useViewSize(ViewSize.LaptopL); + + if (!posts.length) { + return null; + } + + 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]; + const cardFor = (item: Post) => + isLaptop + ? PostTypeToWideCard[item.type] ?? ArticleFeaturedWideGridCard + : PostTypeToListCard[item.type] ?? ArticleList; + const Card = cardFor(post); + const wideProps = isLaptop + ? { wideColSpan: wideColSpan ?? (isLaptopL ? 5 : 2), 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..ddb6ee39cd1 --- /dev/null +++ b/packages/shared/src/components/feeds/hero/FeedHeroSection.tsx @@ -0,0 +1,81 @@ +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 { useHasFeedHeroAdColumn } from './useFeedHeroAd'; + +interface FeedHeroSectionProps { + posts: Post[]; + highlights: PostHighlight[]; + /** The placement in its own column, beside the headlines. */ + ad?: Ad; + cardProps?: Omit; + onAdLinkClick?: (ad: Ad) => unknown; + onAdViewable?: (ad: Ad, data: ViewabilityData) => void; + onHighlightClick?: (highlight: PostHighlight, position: number) => void; + onReadAllClick?: () => void; + className?: string; +} + +export const FeedHeroSection = ({ + posts, + highlights, + ad, + cardProps, + onAdLinkClick, + onAdViewable, + onHighlightClick, + onReadAllClick, + className, +}: FeedHeroSectionProps): ReactElement => { + // The ad only gets a column where one fits, so the section never lays out a + // fourth track it has nothing to put in. + const hasAdColumn = useHasFeedHeroAdColumn(); + const columnAd = hasAdColumn ? ad : undefined; + + return ( +
+ + + {!!columnAd && ( + + )} +
+ ); +}; diff --git a/packages/shared/src/components/feeds/hero/FeedSectionToolbar.tsx b/packages/shared/src/components/feeds/hero/FeedSectionToolbar.tsx new file mode 100644 index 00000000000..fe19af62272 --- /dev/null +++ b/packages/shared/src/components/feeds/hero/FeedSectionToolbar.tsx @@ -0,0 +1,63 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import Link from '../../utilities/Link'; +import { Tooltip } from '../../tooltip/Tooltip'; +import { Button } from '../../buttons/Button'; +import { ButtonSize, ButtonVariant } from '../../buttons/common'; +import { BookmarkIcon, FilterIcon, SearchIcon } from '../../icons'; + +interface FeedSectionToolbarProps { + title: string; + searchHref?: string; + bookmarksHref?: string; + onFiltersClick?: () => void; + className?: string; +} + +const iconLinkProps = { + tag: 'a', + variant: ButtonVariant.Tertiary, + size: ButtonSize.Medium, +} as const; + +export const FeedSectionToolbar = ({ + title, + searchHref, + bookmarksHref, + onFiltersClick, + className, +}: FeedSectionToolbarProps): ReactElement => ( +
+

{title}

+ {!!searchHref && ( + +
+ +
+
+ )} + {!!onFiltersClick && ( + +
+ + )} +
+); 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..f01c493dc3f --- /dev/null +++ b/packages/shared/src/components/feeds/hero/useFeedHeroAd.ts @@ -0,0 +1,48 @@ +import type { Ad } from '../../../graphql/posts'; +import { useAdQuery } from '../../../features/monetization/useAdQuery'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { usePlusSubscription } from '../../../hooks/usePlusSubscription'; +import { useViewSize, ViewSize } from '../../../hooks'; +import { AdPlacement } from '../../../lib/ads'; +import { generateQueryKey, RequestKey, StaleTime } from '../../../lib/query'; + +export type FeedHeroAdSlot = { + ad?: Ad; + /** Whether the section has anywhere to put it. */ + isVisible: boolean; +}; + +/** + * The placement sits out the laptop range: a fourth column doesn't fit under + * 1360px, where it comes out around 220px — too narrow for a headline at the + * featured card's size. Below `laptop` the section stacks and it returns at the + * end. Shared with the section so the column and the ad behind it can't + * disagree about when it exists. + */ +export const useHasFeedHeroAdColumn = (): boolean => { + const isLaptop = useViewSize(ViewSize.Laptop); + const isLaptopL = useViewSize(ViewSize.LaptopL); + + return !isLaptop || isLaptopL; +}; + +/** + * The hero's ad. Read by the hero itself and by the feed underneath it, which + * drops its own first placement while this one is showing rather than putting + * two ads in front of the reader before the first post. Both callers share the + * query key, so they see one creative from one request. + */ +export const useFeedHeroAd = (enabled: boolean): FeedHeroAdSlot => { + const { user, tokenRefreshed } = useAuthContext(); + const { isPlus } = usePlusSubscription(); + const hasColumn = useHasFeedHeroAdColumn(); + + const { data: ad } = useAdQuery({ + placement: AdPlacement.Feed, + queryKey: generateQueryKey(RequestKey.Ads, user, 'feed-hero'), + enabled: enabled && tokenRefreshed && !isPlus, + staleTime: StaleTime.OneHour, + }); + + return { ad: ad ?? undefined, isVisible: !!ad && hasColumn }; +}; diff --git a/packages/shared/src/components/feeds/hero/useFeedHeroPreview.spec.ts b/packages/shared/src/components/feeds/hero/useFeedHeroPreview.spec.ts new file mode 100644 index 00000000000..24b73cd688e --- /dev/null +++ b/packages/shared/src/components/feeds/hero/useFeedHeroPreview.spec.ts @@ -0,0 +1,38 @@ +import { renderHook } from '@testing-library/react'; +import { useFeedHeroPreview } from './useFeedHeroPreview'; + +const setSearch = (search: string): void => { + window.history.replaceState({}, '', `/${search}`); +}; + +beforeEach(() => { + window.localStorage.clear(); + setSearch(''); +}); + +describe('useFeedHeroPreview', () => { + it('is off without the param', () => { + const { result } = renderHook(() => useFeedHeroPreview()); + + expect(result.current).toBe(false); + }); + + it('turns on with the param and remembers it', () => { + setSearch('?feed_hero=1'); + expect(renderHook(() => useFeedHeroPreview()).result.current).toBe(true); + + setSearch(''); + expect(renderHook(() => useFeedHeroPreview()).result.current).toBe(true); + }); + + it('turns back off with feed_hero=0', () => { + setSearch('?feed_hero=1'); + renderHook(() => useFeedHeroPreview()); + + setSearch('?feed_hero=0'); + expect(renderHook(() => useFeedHeroPreview()).result.current).toBe(false); + + setSearch(''); + expect(renderHook(() => useFeedHeroPreview()).result.current).toBe(false); + }); +}); diff --git a/packages/shared/src/components/feeds/hero/useFeedHeroPreview.ts b/packages/shared/src/components/feeds/hero/useFeedHeroPreview.ts new file mode 100644 index 00000000000..628b981a688 --- /dev/null +++ b/packages/shared/src/components/feeds/hero/useFeedHeroPreview.ts @@ -0,0 +1,47 @@ +import { useEffect, useState } from 'react'; + +const STORAGE_KEY = 'feed_hero_preview'; + +const readStored = (): boolean => { + try { + return globalThis.localStorage?.getItem(STORAGE_KEY) === '1'; + } catch { + return false; + } +}; + +const store = (enabled: boolean): void => { + try { + globalThis.localStorage?.setItem(STORAGE_KEY, enabled ? '1' : '0'); + } catch { + // Private windows and blocked site data: the switch just won't stick. + } +}; + +/** + * Temporary review switch: `?feed_hero=1` turns the hero on for this browser, + * `?feed_hero=0` turns it back off. A preview deploy is a production build, so + * GrowthBook devtools can't force the flag there. Remove this once `feed_hero` + * is configured in GrowthBook — a URL that opts someone into an experiment arm + * would skew the allocation. + */ +export const useFeedHeroPreview = (): boolean => { + const [enabled, setEnabled] = useState(false); + + useEffect(() => { + const param = new URLSearchParams(globalThis.location?.search).get( + 'feed_hero', + ); + + if (param === null) { + setEnabled(readStored()); + return; + } + + const next = param !== '0' && param !== 'false'; + store(next); + setEnabled(next); + }, []); + + return enabled; +}; diff --git a/packages/shared/src/hooks/useFeed.ts b/packages/shared/src/hooks/useFeed.ts index 3176ad002c7..11b72722553 100644 --- a/packages/shared/src/hooks/useFeed.ts +++ b/packages/shared/src/hooks/useFeed.ts @@ -206,6 +206,24 @@ export type FeedReturnType = { type UseFeedSettingParams = { adPostLength?: number; disableAds?: boolean; + /** + * Set when the surface already shows the highlights somewhere else (the feed + * hero), so the grid doesn't repeat them in a card. + */ + disableHighlightCards?: boolean; + /** + * Set when the surface shows an ad of its own above the feed (the feed hero), + * so the reader doesn't meet two of them before the first post. The queue is + * still consumed in order: the slot is dropped, not the creative that would + * have filled it. + */ + skipFirstAd?: boolean; + /** + * Set when the surface already leads with a full-size featured card (the feed + * hero), so the grid doesn't open with a second one directly beneath it. The + * first row stays all single-column cards; wide ones resume below it. + */ + deferWideCards?: boolean; feedName?: string; staticAd?: { ad: Ad; index: number }; /** Set on search feeds so every fetch can be logged as a search execution. */ @@ -571,7 +589,7 @@ export default function useFeed( const adRepeat = adTemplate?.adRepeat ?? pageSize + 1; const adJitter = adTemplate?.adJitter ?? 0; - const adPage = getAdSlotIndex({ + const slot = getAdSlotIndex({ index, adStart, adRepeat, @@ -579,7 +597,16 @@ export default function useFeed( seed: adJitterSeedRef.current ?? '', }); - if (adPage === undefined) { + if (slot === undefined) { + return undefined; + } + + // Shifted rather than offset, so the creative the first slot would have + // shown moves down to the second one instead of being fetched and thrown + // away. + const adPage = settings?.skipFirstAd ? slot - 1 : slot; + + if (adPage < 0) { return undefined; } @@ -618,6 +645,7 @@ export default function useFeed( adTemplate?.adJitter, adsUpdatedAt, pageSize, + settings?.skipFirstAd, ], ); @@ -662,6 +690,7 @@ export default function useFeed( startIndex: heroCardsConfig.startIndex, widenableTypes, firstSlotOffset: effectiveFirstSlotOffset, + minWideCardRow: settings?.deferWideCards ? 1 : 0, }); const staticAd = settings?.staticAd; @@ -707,7 +736,7 @@ export default function useFeed( } if (node.itemType === 'highlight') { - if (!node.highlights.length) { + if (!node.highlights.length || settings?.disableHighlightCards) { return; } pushAndAdvance({ @@ -760,6 +789,7 @@ export default function useFeed( feedQuery.dataUpdatedAt, placeholdersPerPage, getAd, + settings?.disableHighlightCards, settings?.staticAd, heroCardsConfig, virtualizedNumCards, @@ -770,6 +800,7 @@ export default function useFeed( widenableTypes, excludePinnedPosts, effectiveFirstSlotOffset, + settings?.deferWideCards, ]); const placements = useMemo( @@ -785,6 +816,7 @@ export default function useFeed( fullRowInsertionBeforeIndex, cadence, firstSlotOffset: effectiveFirstSlotOffset, + minWideCardRow: settings?.deferWideCards ? 1 : 0, }), [ items, @@ -796,6 +828,7 @@ export default function useFeed( cadence, widenableTypes, effectiveFirstSlotOffset, + settings?.deferWideCards, ], ); diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 589a4301eaf..ff7b4a91ed6 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -282,6 +282,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/lib/feedHighlightColSpan.spec.ts b/packages/shared/src/lib/feedHighlightColSpan.spec.ts index b11943651ba..6813f275efe 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; } diff --git a/packages/shared/src/styles/utilities.css b/packages/shared/src/styles/utilities.css index 8a391c46a3e..e2402708ff5 100644 --- a/packages/shared/src/styles/utilities.css +++ b/packages/shared/src/styles/utilities.css @@ -315,6 +315,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/.storybook/main.ts b/packages/storybook/.storybook/main.ts index 1f6ac08af5c..6a5adbebef1 100644 --- a/packages/storybook/.storybook/main.ts +++ b/packages/storybook/.storybook/main.ts @@ -10,6 +10,7 @@ const config: StorybookConfig = { '../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)', ], addons: [ + 'storybook/viewport', '@storybook/addon-links', '@storybook/addon-themes', '@storybook/addon-designs', diff --git a/packages/storybook/.storybook/preview.tsx b/packages/storybook/.storybook/preview.tsx index 267f120534f..18a7569a1ea 100644 --- a/packages/storybook/.storybook/preview.tsx +++ b/packages/storybook/.storybook/preview.tsx @@ -8,9 +8,57 @@ initialize({ onUnhandledRequest: 'warn', }); +/** + * The product's own breakpoints, so the toolbar's device picker lands exactly + * on the widths the Tailwind config switches at. Each pair brackets a boundary: + * the "-" width is the last one before a breakpoint applies, which is where + * layouts actually break. + */ +const viewports = { + mobile: { name: 'Mobile (360)', styles: { width: '360px', height: '780px' } }, + mobileL: { + name: 'mobileL (420)', + styles: { width: '420px', height: '860px' }, + }, + mobileXL: { + name: 'mobileXL (500)', + styles: { width: '500px', height: '900px' }, + }, + tabletBelow: { + name: 'tablet - (655)', + styles: { width: '655px', height: '900px' }, + }, + tablet: { name: 'tablet (656)', styles: { width: '656px', height: '900px' } }, + laptopBelow: { + name: 'laptop - (1019)', + styles: { width: '1019px', height: '860px' }, + }, + laptop: { + name: 'laptop (1020)', + styles: { width: '1020px', height: '860px' }, + }, + laptopLBelow: { + name: 'laptopL - (1359)', + styles: { width: '1359px', height: '900px' }, + }, + laptopL: { + name: 'laptopL (1360)', + styles: { width: '1360px', height: '900px' }, + }, + laptopXL: { + name: 'laptopXL (1668)', + styles: { width: '1668px', height: '950px' }, + }, + desktop: { + name: 'desktop (1976)', + styles: { width: '1976px', height: '1000px' }, + }, +}; + const preview: Preview = { parameters: { controls: { expanded: true }, + viewport: { options: viewports }, options: { storySort: { order: [ 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..82256ee989c --- /dev/null +++ b/packages/storybook/stories/features/feed/FeedHero.stories.tsx @@ -0,0 +1,378 @@ +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 { FeedSectionToolbar } from '@dailydotdev/shared/src/components/feeds/hero/FeedSectionToolbar'; +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 => ( + +
+
+ {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: () => ( + + + + ), +}; + +export const WideFeed: Story = { + name: 'Hero at a five-card feed', + render: () => ( + +
+ {/* The feed grid caps the hero at 21.25rem per card plus the gaps, so + five cards is the widest it ever runs and the widest its columns + get — about 440px each. */} +
+ +
+
+
+ ), +}; + +// 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 same story rendered at each breakpoint. Laptop and up is the + two-column hero; below that the rail stacks under the carousel. +

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