From 837aa48a6cc1420cd68543ac94b0b3ec77647da5 Mon Sep 17 00:00:00 2001 From: capJavert Date: Mon, 25 May 2026 23:37:54 +0200 Subject: [PATCH 1/4] feat: post highlight new cards --- packages/shared/src/components/Feed.tsx | 152 ++++++--- .../src/components/FeedItemComponent.tsx | 134 ++++---- .../ArticleFeaturedWideGridCard.spec.tsx | 89 ++++++ .../article/ArticleFeaturedWideGridCard.tsx | 293 ++++++++++++++++++ packages/shared/src/graphql/fragments.ts | 7 + packages/shared/src/graphql/posts.ts | 15 + .../src/lib/feedHighlightColSpan.spec.ts | 172 ++++++++++ .../shared/src/lib/feedHighlightColSpan.ts | 100 ++++++ packages/shared/src/styles/utilities.css | 89 ++++++ 9 files changed, 944 insertions(+), 107 deletions(-) create mode 100644 packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.spec.tsx create mode 100644 packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx create mode 100644 packages/shared/src/lib/feedHighlightColSpan.spec.ts create mode 100644 packages/shared/src/lib/feedHighlightColSpan.ts diff --git a/packages/shared/src/components/Feed.tsx b/packages/shared/src/components/Feed.tsx index 55fffced7b2..91ea945cc74 100644 --- a/packages/shared/src/components/Feed.tsx +++ b/packages/shared/src/components/Feed.tsx @@ -24,6 +24,8 @@ import useFeedInfiniteScroll, { InfiniteScrollScreenOffset, } from '../hooks/feed/useFeedInfiniteScroll'; import FeedItemComponent, { getFeedItemKey } from './FeedItemComponent'; +import { computeColSpans } from '../lib/feedHighlightColSpan'; +import type { FeaturedWideColSpan } from './cards/article/ArticleFeaturedWideGridCard'; import { useLogContext } from '../contexts/LogContext'; import { feedLogExtra, postLogEvent } from '../lib/feed'; import { usePostModalNavigation } from '../hooks/usePostModalNavigation'; @@ -379,6 +381,50 @@ export default function Feed({ firstSlotOffset: Number(showProfileCompletionCard || showBriefCard), }); + const isMobileViewport = !isTabletViewport; + const isListContext = useList || shouldUseListFeedLayout; + const earlyCurrentPageSize = pageSize ?? currentSettings.pageSize; + const earlyShowPromoBanner = !!briefBannerPage; + const earlyShowFirstSlotCard = showProfileCompletionCard || showBriefCard; + const earlyColumnsDiffWithPage = earlyCurrentPageSize % virtualizedNumCards; + const earlyIndexWhenShowingPromoBanner = + earlyCurrentPageSize * Number(briefBannerPage) - + earlyColumnsDiffWithPage * Number(briefBannerPage) - + Number(earlyShowFirstSlotCard); + + const fullRowInsertionBeforeIndex = useMemo(() => { + const set = new Set(); + if (earlyShowPromoBanner) { + set.add(earlyIndexWhenShowingPromoBanner); + } + if (shouldShowInFeedHero) { + set.add(adjustedHeroInsertIndex); + } + return set; + }, [ + earlyShowPromoBanner, + earlyIndexWhenShowingPromoBanner, + shouldShowInFeedHero, + adjustedHeroInsertIndex, + ]); + + const itemColSpans = useMemo( + () => + computeColSpans(items, { + numCards: virtualizedNumCards, + isMobile: isMobileViewport, + isList: isListContext, + fullRowInsertionBeforeIndex, + }), + [ + items, + virtualizedNumCards, + isMobileViewport, + isListContext, + fullRowInsertionBeforeIndex, + ], + ); + useMutationSubscription({ matcher: ({ mutation }) => { const [requestKey] = Array.isArray(mutation.options.mutationKey) @@ -716,45 +762,14 @@ export default function Feed({ }} /> )} - {items.map((item, index) => ( - - {showPromoBanner && index === indexWhenShowingPromoBanner && ( - - )} - {shouldShowInFeedHero && index === adjustedHeroInsertIndex && ( -
- - onEnableHero(NotificationCtaPlacement.InFeedHero) - } - onClose={() => - onDismissHero(NotificationCtaPlacement.InFeedHero) - } - /> -
- )} + {items.map((item, index) => { + const colSpan = itemColSpans[index] ?? 1; + const isWidened = colSpan > 1; + const wideColSpan = + isWidened && (colSpan === 2 || colSpan === 3 || colSpan === 4) + ? (colSpan as FeaturedWideColSpan) + : undefined; + const itemNode = ( ({ onReadArticleClick={onReadArticleClick} virtualizedNumCards={virtualizedNumCards} disableAdRefresh={disableAdRefresh} + wideColSpan={wideColSpan} /> -
- ))} + ); + + return ( + + {showPromoBanner && index === indexWhenShowingPromoBanner && ( + + )} + {shouldShowInFeedHero && + index === adjustedHeroInsertIndex && ( +
+ + onEnableHero(NotificationCtaPlacement.InFeedHero) + } + onClose={() => + onDismissHero(NotificationCtaPlacement.InFeedHero) + } + /> +
+ )} + {isWidened ? ( +
+ {itemNode} +
+ ) : ( + itemNode + )} +
+ ); + })} {!isFetching && !isInitialLoading && !isHorizontal && ( )} diff --git a/packages/shared/src/components/FeedItemComponent.tsx b/packages/shared/src/components/FeedItemComponent.tsx index 594ae9b9e8d..7d818cf488f 100644 --- a/packages/shared/src/components/FeedItemComponent.tsx +++ b/packages/shared/src/components/FeedItemComponent.tsx @@ -28,6 +28,8 @@ 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 { ArticleFeaturedWideGridCard } from './cards/article/ArticleFeaturedWideGridCard'; +import type { FeaturedWideColSpan } from './cards/article/ArticleFeaturedWideGridCard'; import { ShareGrid } from './cards/share/ShareGrid'; import { ShareList } from './cards/share/ShareList'; import { CollectionGrid } from './cards/collection'; @@ -98,6 +100,12 @@ export type FeedItemComponentProps = { ) => unknown; virtualizedNumCards: number; disableAdRefresh?: boolean; + /** + * When set, render the post as a wide featured highlight card spanning + * the given number of grid columns. Only used for article-like post + * types with an active `postHighlight`. + */ + wideColSpan?: FeaturedWideColSpan; } & Pick & Pick; @@ -280,6 +288,7 @@ function FeedItemComponent({ onCommentClick, onReadArticleClick, virtualizedNumCards, + wideColSpan, }: FeedItemComponentProps): ReactElement | null { const { logEvent } = useLogContext(); const inViewRef = useLogImpression( @@ -395,74 +404,67 @@ function FeedItemComponent({ return null; } + const postCardProps = { + enableSourceHeader: + feedName !== 'squad' && isSourceSquadOrMachine(itemPost.source), + ref: inViewRef, + post: { ...itemPost }, + 'data-testid': 'postItem', + onUpvoteClick: (post: Post, origin = Origin.Feed) => { + toggleUpvote({ + payload: post, + origin, + opts: { columns, column, row }, + }); + }, + onDownvoteClick: (post: Post, origin = Origin.Feed) => { + toggleDownvote({ + payload: post, + origin, + opts: { columns, column, row }, + }); + }, + onPostClick: (post: Post, event?: React.MouseEvent) => + onPostClick(post, index, row, column, false, event), + onPostAuxClick: (post: Post) => + onPostClick(post, index, row, column, true), + onReadArticleClick: () => + onReadArticleClick(itemPost, index, row, column), + onShare: (post: Post) => onShare(post, row, column), + onBookmarkClick: (post: Post, origin = Origin.Feed) => { + toggleBookmark({ + post, + origin, + opts: { columns, column, row }, + }); + }, + openNewTab, + enableMenu: !!user, + onMenuClick: (event: React.MouseEvent) => + onMenuClick(event, index, row, column), + onCopyLinkClick: (event: React.MouseEvent, post: Post) => + onCopyLinkClick(event, post, index, row, column), + menuOpened: postMenuIndex === index, + onCommentClick: (post: Post) => + onCommentClick(post, index, row, column, !!boostedBy), + eagerLoadImage: row === 0 && column === 0, + }; + + const isWidenedFeaturedPost = + item.type === FeedItemType.Post && !!wideColSpan && wideColSpan > 1; + return ( - { - toggleUpvote({ - payload: post, - origin, - opts: { - columns, - column, - row, - }, - }); - }} - onDownvoteClick={(post: Post, origin = Origin.Feed) => { - toggleDownvote({ - payload: post, - origin, - opts: { - columns, - column, - row, - }, - }); - }} - onPostClick={(post: Post, event) => - onPostClick(post, index, row, column, false, event) - } - onPostAuxClick={(post: Post) => - onPostClick(post, index, row, column, true) - } - onReadArticleClick={() => - onReadArticleClick(itemPost, index, row, column) - } - onShare={(post: Post) => onShare(post, row, column)} - onBookmarkClick={(post: Post, origin = Origin.Feed) => { - toggleBookmark({ - post, - origin, - opts: { - columns, - column, - row, - }, - }); - }} - openNewTab={openNewTab} - enableMenu={!!user} - onMenuClick={(event: React.MouseEvent) => - onMenuClick(event, index, row, column) - } - onCopyLinkClick={(event: React.MouseEvent, post: Post) => - onCopyLinkClick(event, post, index, row, column) - } - menuOpened={postMenuIndex === index} - onCommentClick={(post: Post) => - onCommentClick(post, index, row, column, !!boostedBy) - } - eagerLoadImage={row === 0 && column === 0} - > - {item.type === FeedItemType.Ad && } - + {isWidenedFeaturedPost ? ( + + ) : ( + + {item.type === FeedItemType.Ad && } + + )} ); } diff --git a/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.spec.tsx b/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.spec.tsx new file mode 100644 index 00000000000..89d3b58720e --- /dev/null +++ b/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.spec.tsx @@ -0,0 +1,89 @@ +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 post from '../../../../__tests__/fixture/post'; +import type { PostCardProps } from '../common/common'; +import type { + Post, + PostHighlight, + PostHighlightSignificance, +} from '../../../graphql/posts'; +import { TestBootProvider } from '../../../../__tests__/helpers/boot'; +import { ArticleFeaturedWideGridCard } from './ArticleFeaturedWideGridCard'; + +jest.mock('next/router', () => ({ + useRouter: jest.fn(), +})); + +beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(useRouter).mockImplementation( + () => + ({ + pathname: '/', + } as unknown as NextRouter), + ); +}); + +const defaultProps: PostCardProps = { + post, + onPostClick: jest.fn(), + onUpvoteClick: jest.fn(), + onCommentClick: jest.fn(), + onBookmarkClick: jest.fn(), + onShare: jest.fn(), + onCopyLinkClick: jest.fn(), + onReadArticleClick: jest.fn(), +}; + +const makeHighlight = ( + significance: PostHighlightSignificance | null, +): PostHighlight | null => + significance + ? { + id: 'h1', + channel: 'vibes', + highlightedAt: '2026-05-25T00:00:00.000Z', + headline: 'A breaking event', + significance, + } + : null; + +const renderComponent = ( + props: Partial = {}, +): RenderResult => + render( + + + , + ); + +const postWith = (significance: PostHighlightSignificance | null): Post => ({ + ...post, + postHighlight: makeHighlight(significance), +}); + +it.each<[PostHighlightSignificance, string]>([ + ['breaking', 'Breaking'], + ['major', 'Major'], + ['notable', 'Notable'], +])('renders the chip label for %s significance', (significance, label) => { + renderComponent({ post: postWith(significance), wideColSpan: 2 }); + expect(screen.getByText(label)).toBeInTheDocument(); +}); + +it('renders no chip for routine significance', () => { + renderComponent({ post: postWith('routine'), wideColSpan: 2 }); + expect(screen.queryByText('Routine')).not.toBeInTheDocument(); + expect(screen.queryByText('Breaking')).not.toBeInTheDocument(); +}); + +it('renders no chip when post has no highlight', () => { + renderComponent({ post: postWith(null), wideColSpan: 2 }); + expect(screen.queryByText('Breaking')).not.toBeInTheDocument(); + expect(screen.queryByText('Major')).not.toBeInTheDocument(); + expect(screen.queryByText('Notable')).not.toBeInTheDocument(); +}); diff --git a/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx b/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx new file mode 100644 index 00000000000..6ca609fb750 --- /dev/null +++ b/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx @@ -0,0 +1,293 @@ +import type { ReactElement, Ref } from 'react'; +import React, { forwardRef, useMemo } from 'react'; +import classNames from 'classnames'; +import type { PostCardProps } from '../common/common'; +import { Container } from '../common/common'; +import FeedItemContainer from '../common/FeedItemContainer'; +import { useBlockPostPanel } from '../../../hooks/post/useBlockPostPanel'; +import { useHiddenFeedbackPanel } from '../../../hooks/post/useHiddenFeedbackPanel'; +import { usePostFeedback } from '../../../hooks'; +import { isVideoPost, PostType } from '../../../graphql/posts'; +import type { PostHighlightSignificance } from '../../../graphql/posts'; +import { PostTagsPanel } from '../../post/block/PostTagsPanel'; +import { + CardSpace, + CardTextContainer, + getPostClassNames, +} from '../common/Card'; +import CardOverlay from '../common/CardOverlay'; +import { Origin } from '../../../lib/log'; +import styles from '../common/Card.module.css'; +import { PostCardHeader } from '../common/PostCardHeader'; +import PostTags from '../common/PostTags'; +import PostMetadata from '../common/PostMetadata'; +import ActionButtons from '../common/ActionButtons'; +import { FeedbackGrid } from './feedback/FeedbackGrid'; +import { ClickbaitShield } from '../common/ClickbaitShield'; +import { useSmartTitle } from '../../../hooks/post/useSmartTitle'; +import { usePostImage } from '../../../hooks/post/usePostImage'; +import { HIGH_PRIORITY_IMAGE_PROPS, Image, ImageType } from '../../image/Image'; +import { stripHtmlTags } from '../../../lib/strings'; + +export type FeaturedWideColSpan = 2 | 3 | 4; + +const INNER_GRID_COLS: Record = { + 2: 'grid-cols-2', + 3: 'grid-cols-3', + 4: 'grid-cols-4', +}; + +const IMAGE_COL_SPAN: Record = { + 2: 'col-span-1', + 3: 'col-span-2', + 4: 'col-span-3', +}; + +const CHIP_LABEL: Partial> = { + breaking: 'Breaking', + major: 'Major', + notable: 'Notable', +}; + +const HighlightChip = ({ + significance, + className, +}: { + significance: PostHighlightSignificance | null | undefined; + className?: string; +}): ReactElement | null => { + if (!significance) { + return null; + } + const label = CHIP_LABEL[significance]; + if (!label) { + return null; + } + return ( + + + + {label} + + + ); +}; + +export const ArticleFeaturedWideGridCard = forwardRef( + function ArticleFeaturedWideGridCard( + { + post, + onPostClick, + onPostAuxClick, + onUpvoteClick, + onDownvoteClick, + onCommentClick, + onBookmarkClick, + onCopyLinkClick, + openNewTab, + children, + onReadArticleClick, + domProps = {}, + eagerLoadImage = false, + wideColSpan = 2, + }: PostCardProps & { wideColSpan?: FeaturedWideColSpan }, + ref: Ref, + ): ReactElement { + const { className, style } = domProps; + const { isHidden, content: hiddenPanel } = useHiddenFeedbackPanel(post); + const { data } = useBlockPostPanel(post); + const onPostCardClick = () => onPostClick?.(post); + const onPostCardAuxClick = () => onPostAuxClick?.(post); + const { pinnedAt, trending } = post; + const { showFeedback } = usePostFeedback({ post }); + const { title } = useSmartTitle(post); + const isVideoType = isVideoPost(post); + const image = usePostImage(post); + const significance = post.postHighlight?.significance ?? null; + const isTweetPost = + post.type === PostType.SocialTwitter || + post.sharedPost?.type === PostType.SocialTwitter; + const shouldUseSharedPostContent = + post.subType === 'repost' && !post.contentHtml?.trim(); + const description = useMemo(() => { + if (isTweetPost) { + const primaryTweetContent = + stripHtmlTags(post.contentHtml ?? '').trim() || + post.title?.trim() || + ''; + const sharedTweetContent = + stripHtmlTags(post.sharedPost?.contentHtml ?? '').trim() || + post.sharedPost?.title?.trim() || + ''; + return shouldUseSharedPostContent + ? sharedTweetContent || primaryTweetContent || '' + : primaryTweetContent || sharedTweetContent || ''; + } + + return post.summary?.trim() || post.sharedPost?.summary?.trim() || ''; + }, [ + isTweetPost, + shouldUseSharedPostContent, + post.contentHtml, + post.title, + post.sharedPost?.title, + post.sharedPost?.contentHtml, + post.sharedPost?.summary, + post.summary, + ]); + + if (isHidden) { + return ( + + {hiddenPanel} + + ); + } + + if (data?.showTagsPanel && (post.tags?.length ?? 0) > 0) { + return ( + + ); + } + + return ( + + + {showFeedback && ( + onUpvoteClick?.(post, Origin.FeedbackCard)} + onDownvoteClick={() => onDownvoteClick?.(post, Origin.FeedbackCard)} + isVideoType={isVideoType} + /> + )} + +
+
+ + +

+ {title} +

+ {!showFeedback && ( + <> +
+ {post.clickbaitTitleDetected && ( + + )} + + +
+ + + )} + {!showFeedback && description ? ( +

+ {description} +

+ ) : null} +
+ {!showFeedback && ( + + + + + )} +
+ {!showFeedback && image ? ( +
+ {post.title} +
+ ) : null} +
+ {children} +
+ ); + }, +); diff --git a/packages/shared/src/graphql/fragments.ts b/packages/shared/src/graphql/fragments.ts index 4d35695915f..bd7417c1a37 100644 --- a/packages/shared/src/graphql/fragments.ts +++ b/packages/shared/src/graphql/fragments.ts @@ -695,6 +695,13 @@ export const FEED_POST_FRAGMENT = gql` name } } + postHighlight { + id + significance + headline + channel + highlightedAt + } } ${FEED_POST_INFO_FRAGMENT} `; diff --git a/packages/shared/src/graphql/posts.ts b/packages/shared/src/graphql/posts.ts index 0943e4ea10e..9b8e8d9ab87 100644 --- a/packages/shared/src/graphql/posts.ts +++ b/packages/shared/src/graphql/posts.ts @@ -236,6 +236,20 @@ export interface PostUserState { pollOption?: { id: string }; } +export type PostHighlightSignificance = + | 'breaking' + | 'major' + | 'notable' + | 'routine'; + +export interface PostHighlight { + id: string; + channel: string; + highlightedAt: string; + headline: string; + significance: PostHighlightSignificance | null; +} + export interface Post { __typename?: string; id: string; @@ -300,6 +314,7 @@ export interface Post { endsAt?: string; liveRoom?: LiveRoomPost | null; analytics?: Partial>; + postHighlight?: PostHighlight | null; } export type RelatedPost = Pick< diff --git a/packages/shared/src/lib/feedHighlightColSpan.spec.ts b/packages/shared/src/lib/feedHighlightColSpan.spec.ts new file mode 100644 index 00000000000..0653365074f --- /dev/null +++ b/packages/shared/src/lib/feedHighlightColSpan.spec.ts @@ -0,0 +1,172 @@ +import type { Ad, Post, PostHighlightSignificance } from '../graphql/posts'; +import { PostType } from '../graphql/posts'; +import type { FeedItem } from '../hooks/useFeed'; +import { FeedItemType } from '../components/cards/common/common'; +import { computeColSpans, requestedColSpan } from './feedHighlightColSpan'; + +const makePost = ( + overrides: Partial & { + significance?: PostHighlightSignificance | null; + } = {}, +): Post => { + const { significance, ...rest } = overrides; + return { + id: rest.id ?? 'p1', + type: rest.type ?? PostType.Article, + image: '', + commentsPermalink: '', + ...(significance !== undefined && { + postHighlight: significance + ? { + id: 'h1', + channel: 'vibes', + highlightedAt: '2026-05-25T00:00:00.000Z', + headline: 'h', + significance, + } + : null, + }), + ...rest, + } as Post; +}; + +const makePostItem = (post: Post): FeedItem => + ({ type: FeedItemType.Post, post } as FeedItem); + +const makeAdItem = (): FeedItem => + ({ + type: FeedItemType.Ad, + ad: { + source: 'a', + company: 'c', + link: '', + description: '', + image: '', + } as Ad, + } as FeedItem); + +describe('requestedColSpan', () => { + it('returns 1 for items without postHighlight', () => { + expect(requestedColSpan(makePostItem(makePost()))).toBe(1); + }); + + it.each<[PostHighlightSignificance, number]>([ + ['breaking', 4], + ['major', 3], + ['notable', 2], + ['routine', 1], + ])('maps %s significance to span %i', (significance, expected) => { + expect(requestedColSpan(makePostItem(makePost({ significance })))).toBe( + expected, + ); + }); + + it('returns 1 for ad items even when their post has a highlight', () => { + expect(requestedColSpan(makeAdItem())).toBe(1); + }); + + it('returns 1 for non-article post types with a highlight', () => { + expect( + requestedColSpan( + makePostItem( + makePost({ type: PostType.Share, significance: 'breaking' }), + ), + ), + ).toBe(1); + }); + + it('allows widening for VideoYouTube posts', () => { + expect( + requestedColSpan( + makePostItem( + makePost({ type: PostType.VideoYouTube, significance: 'major' }), + ), + ), + ).toBe(3); + }); +}); + +describe('computeColSpans', () => { + const opts = { + numCards: 4, + isMobile: false, + isList: false, + }; + + it('returns all 1s when isMobile', () => { + const items = Array.from({ length: 3 }, () => + makePostItem(makePost({ significance: 'breaking' })), + ); + expect(computeColSpans(items, { ...opts, isMobile: true })).toEqual([ + 1, 1, 1, + ]); + }); + + it('returns all 1s when isList', () => { + const items = Array.from({ length: 3 }, () => + makePostItem(makePost({ significance: 'breaking' })), + ); + expect(computeColSpans(items, { ...opts, isList: true })).toEqual([ + 1, 1, 1, + ]); + }); + + it('returns all 1s when numCards <= 1', () => { + const items = [makePostItem(makePost({ significance: 'breaking' }))]; + expect(computeColSpans(items, { ...opts, numCards: 1 })).toEqual([1]); + }); + + it('renders a wide card at requested size at the start of a row', () => { + const items = [ + makePostItem(makePost({ significance: 'major' })), + makePostItem(makePost()), + makePostItem(makePost()), + ]; + expect(computeColSpans(items, opts)).toEqual([3, 1, 1]); + }); + + it('shrinks a wide card that cannot fit the rest of the row', () => { + const items = [ + makePostItem(makePost()), + makePostItem(makePost()), + makePostItem(makePost()), + makePostItem(makePost({ significance: 'breaking' })), + makePostItem(makePost()), + ]; + expect(computeColSpans(items, opts)).toEqual([1, 1, 1, 1, 1]); + }); + + it('handles a chain of wide items', () => { + const items = [ + makePostItem(makePost()), + makePostItem(makePost()), + makePostItem(makePost()), + makePostItem(makePost({ significance: 'major' })), + makePostItem(makePost({ significance: 'breaking' })), + ]; + expect(computeColSpans(items, opts)).toEqual([1, 1, 1, 1, 4]); + }); + + it('clamps to numCards when the requested span exceeds available columns', () => { + const items = [makePostItem(makePost({ significance: 'breaking' }))]; + expect(computeColSpans(items, { ...opts, numCards: 2 })).toEqual([2]); + expect(computeColSpans(items, { ...opts, numCards: 3 })).toEqual([3]); + }); + + it('resets the column tracker at a full-row insertion index', () => { + const items = [ + makePostItem(makePost()), + makePostItem(makePost()), + makePostItem(makePost({ significance: 'major' })), + ]; + const fullRowInsertionBeforeIndex = new Set([2]); + expect( + computeColSpans(items, { ...opts, fullRowInsertionBeforeIndex }), + ).toEqual([1, 1, 3]); + }); + + it('does not widen ads regardless of position', () => { + const items = [makeAdItem()]; + expect(computeColSpans(items, opts)).toEqual([1]); + }); +}); diff --git a/packages/shared/src/lib/feedHighlightColSpan.ts b/packages/shared/src/lib/feedHighlightColSpan.ts new file mode 100644 index 00000000000..c10c6d9dbe9 --- /dev/null +++ b/packages/shared/src/lib/feedHighlightColSpan.ts @@ -0,0 +1,100 @@ +import type { FeedItem } from '../hooks/useFeed'; +import { FeedItemType } from '../components/cards/common/common'; +import { PostType } from '../graphql/posts'; +import type { PostHighlightSignificance } from '../graphql/posts'; + +const SIGNIFICANCE_COL_SPAN: Record = { + breaking: 4, + major: 3, + notable: 2, + routine: 1, +}; + +const WIDENABLE_POST_TYPES = new Set([ + PostType.Article, + PostType.VideoYouTube, +]); + +/** + * Returns the column span a feed item is asking for, before any clamping + * for column count or fit-to-row. + * + * Only Post items with an article-like type and an active `postHighlight` + * request a wide colSpan. Ads, highlight strip items, placeholders, + * marketing items and non-article post types always stay at 1. + */ +export const requestedColSpan = (item: FeedItem): number => { + if (!item || item.type !== FeedItemType.Post) { + return 1; + } + + if (!WIDENABLE_POST_TYPES.has(item.post.type)) { + return 1; + } + + const significance = item.post.postHighlight?.significance; + if (!significance) { + return 1; + } + + return SIGNIFICANCE_COL_SPAN[significance] ?? 1; +}; + +export interface ComputeColSpansOptions { + numCards: number; + isMobile: boolean; + isList: boolean; + /** + * Indices that have a full-row insertion (brief banner, hero, promo) + * rendered BEFORE the item at that index. The column tracker resets to + * column 0 before placing the item, so the item starts a fresh row. + */ + fullRowInsertionBeforeIndex?: ReadonlySet; +} + +/** + * Walks feed items in order and assigns each one a column span. Wide cards + * never bump to a new row — when the requested colSpan exceeds the + * remaining columns in the current row, it shrinks to fit. That keeps the + * grid gap-free without needing `grid-auto-flow: dense`. + * + * Returns an array of colSpans (1..numCards) the same length as `items`. + */ +export const computeColSpans = ( + items: FeedItem[], + { + numCards, + isMobile, + isList, + fullRowInsertionBeforeIndex, + }: ComputeColSpansOptions, +): number[] => { + if (isMobile || isList || numCards <= 1) { + return items.map(() => 1); + } + + const colSpans = new Array(items.length); + let col = 0; + + items.forEach((item, index) => { + if (fullRowInsertionBeforeIndex?.has(index) && col !== 0) { + col = 0; + } + + const requested = requestedColSpan(item); + if (requested === 1) { + colSpans[index] = 1; + col = (col + 1) % numCards; + return; + } + + const clampedToGrid = Math.min(requested, numCards); + const remainingInRow = numCards - col; + const actual = Math.min(clampedToGrid, remainingInRow); + + colSpans[index] = actual; + col = (col + actual) % numCards; + }); + + return colSpans; +}; diff --git a/packages/shared/src/styles/utilities.css b/packages/shared/src/styles/utilities.css index 9b23df6c1ad..fe0085d4e80 100644 --- a/packages/shared/src/styles/utilities.css +++ b/packages/shared/src/styles/utilities.css @@ -808,3 +808,92 @@ .agent-live-radar-sweep { animation: agent-live-radar-sweep 20s linear infinite; } + +@keyframes breaking-news-chip-mesh { + 0% { + background-position: 100% 0%, 80% 100%, 50% 100%, 0% 50%; + } + + 50% { + background-position: 0% 100%, 15% 0%, 50% 0%, 100% 50%; + } + + 100% { + background-position: 100% 0%, 80% 100%, 50% 100%, 0% 50%; + } +} + +@keyframes breaking-news-chip-glow-mesh { + 0% { + background-position: 100% 50%, 50% 0%; + } + + 50% { + background-position: 0% 50%, 50% 100%; + } + + 100% { + background-position: 100% 50%, 50% 0%; + } +} + +.breaking-news-chip-fill { + background-color: var(--theme-accent-ketchup-default); + background-image: radial-gradient( + circle at 85% 25%, + var(--theme-accent-bun-default) 0%, + transparent 52% + ), + radial-gradient( + circle at 70% 90%, + var(--theme-accent-bacon-default) 0%, + transparent 54% + ), + radial-gradient( + circle at 55% 115%, + var(--theme-accent-ketchup-bolder) 0%, + transparent 58% + ), + linear-gradient( + 120deg, + var(--theme-accent-ketchup-default), + var(--theme-accent-bacon-default), + var(--theme-accent-bun-default), + var(--theme-accent-ketchup-bolder) + ); + background-size: 220% 220%, 230% 230%, 260% 260%, 320% 320%; + background-position: 100% 0%, 80% 100%, 50% 100%, 0% 50%; + animation: breaking-news-chip-mesh 4.5s ease-in-out infinite; +} + +.breaking-news-chip-glow { + background-color: var(--theme-accent-ketchup-default); + background-image: radial-gradient( + circle at 75% 50%, + var(--theme-accent-bun-default) 0%, + transparent 62% + ), + radial-gradient( + circle at 50% 50%, + var(--theme-accent-bacon-default) 0%, + transparent 68% + ); + background-size: 180% 180%, 210% 210%; + background-position: 100% 50%, 50% 0%; + animation: breaking-news-chip-glow-mesh 6s ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + .breaking-news-chip-fill, + .breaking-news-chip-glow { + animation: none; + } + + .breaking-news-chip-fill { + background-position: 100% 0%, 80% 100%, 50% 100%, 0% 50%; + } + + .breaking-news-chip-glow { + background-position: 100% 50%, 50% 0%; + } +} From ad59db3ab99fb7b14e2dd646dc8d36deac805e70 Mon Sep 17 00:00:00 2001 From: capJavert Date: Tue, 26 May 2026 09:29:25 +0200 Subject: [PATCH 2/4] feat: add flag --- packages/shared/src/components/Feed.tsx | 11 +++++++++++ packages/shared/src/lib/featureManagement.ts | 5 +++++ packages/shared/src/lib/feedHighlightColSpan.spec.ts | 6 ++++++ packages/shared/src/lib/feedHighlightColSpan.ts | 4 +++- 4 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/components/Feed.tsx b/packages/shared/src/components/Feed.tsx index 91ea945cc74..f841fb56244 100644 --- a/packages/shared/src/components/Feed.tsx +++ b/packages/shared/src/components/Feed.tsx @@ -69,6 +69,7 @@ import { briefCardFeedFeature, briefFeedEntrypointPage, featureFeedAdTemplate, + featurePostHighlightCards, } from '../lib/featureManagement'; import { useNewD1ExperienceFeature } from '../hooks/useNewD1ExperienceFeature'; import type { AwardProps } from '../graphql/njord'; @@ -383,6 +384,14 @@ export default function Feed({ const isMobileViewport = !isTabletViewport; const isListContext = useList || shouldUseListFeedLayout; + const canRenderHighlightCards = + !isMobileViewport && !isListContext && virtualizedNumCards > 1; + const { value: isPostHighlightCardsEnabled } = useConditionalFeature({ + feature: featurePostHighlightCards, + shouldEvaluate: canRenderHighlightCards, + }); + const isHighlightCardLayoutEnabled = + canRenderHighlightCards && isPostHighlightCardsEnabled; const earlyCurrentPageSize = pageSize ?? currentSettings.pageSize; const earlyShowPromoBanner = !!briefBannerPage; const earlyShowFirstSlotCard = showProfileCompletionCard || showBriefCard; @@ -414,6 +423,7 @@ export default function Feed({ numCards: virtualizedNumCards, isMobile: isMobileViewport, isList: isListContext, + isEnabled: isHighlightCardLayoutEnabled, fullRowInsertionBeforeIndex, }), [ @@ -421,6 +431,7 @@ export default function Feed({ virtualizedNumCards, isMobileViewport, isListContext, + isHighlightCardLayoutEnabled, fullRowInsertionBeforeIndex, ], ); diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 7e401a653bf..6bb7491d9bc 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -200,3 +200,8 @@ export const featureCompanionDemoWidget = new Feature( export const featureFeedTagChips = new Feature('feed_tag_chips', false); export const featureEngagementBarV2 = new Feature('engagement_bar_v2', false); + +export const featurePostHighlightCards = new Feature( + 'post_highlight_cards', + false, +); diff --git a/packages/shared/src/lib/feedHighlightColSpan.spec.ts b/packages/shared/src/lib/feedHighlightColSpan.spec.ts index 0653365074f..e411399599f 100644 --- a/packages/shared/src/lib/feedHighlightColSpan.spec.ts +++ b/packages/shared/src/lib/feedHighlightColSpan.spec.ts @@ -91,8 +91,14 @@ describe('computeColSpans', () => { numCards: 4, isMobile: false, isList: false, + isEnabled: true, }; + it('returns all 1s when feature is disabled', () => { + const items = [makePostItem(makePost({ significance: 'breaking' }))]; + expect(computeColSpans(items, { ...opts, isEnabled: false })).toEqual([1]); + }); + it('returns all 1s when isMobile', () => { const items = Array.from({ length: 3 }, () => makePostItem(makePost({ significance: 'breaking' })), diff --git a/packages/shared/src/lib/feedHighlightColSpan.ts b/packages/shared/src/lib/feedHighlightColSpan.ts index c10c6d9dbe9..974a2d094cc 100644 --- a/packages/shared/src/lib/feedHighlightColSpan.ts +++ b/packages/shared/src/lib/feedHighlightColSpan.ts @@ -44,6 +44,7 @@ export interface ComputeColSpansOptions { numCards: number; isMobile: boolean; isList: boolean; + isEnabled: boolean; /** * Indices that have a full-row insertion (brief banner, hero, promo) * rendered BEFORE the item at that index. The column tracker resets to @@ -66,10 +67,11 @@ export const computeColSpans = ( numCards, isMobile, isList, + isEnabled, fullRowInsertionBeforeIndex, }: ComputeColSpansOptions, ): number[] => { - if (isMobile || isList || numCards <= 1) { + if (!isEnabled || isMobile || isList || numCards <= 1) { return items.map(() => 1); } From 2b8867cc703fa8ab5ca3ce5f229a1e4002648c01 Mon Sep 17 00:00:00 2001 From: capJavert Date: Tue, 26 May 2026 12:34:06 +0200 Subject: [PATCH 3/4] feat: adjust sizing --- packages/shared/src/lib/feedHighlightColSpan.spec.ts | 10 +++++----- packages/shared/src/lib/feedHighlightColSpan.ts | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/shared/src/lib/feedHighlightColSpan.spec.ts b/packages/shared/src/lib/feedHighlightColSpan.spec.ts index e411399599f..54d4afaa0d4 100644 --- a/packages/shared/src/lib/feedHighlightColSpan.spec.ts +++ b/packages/shared/src/lib/feedHighlightColSpan.spec.ts @@ -52,8 +52,8 @@ describe('requestedColSpan', () => { it.each<[PostHighlightSignificance, number]>([ ['breaking', 4], - ['major', 3], - ['notable', 2], + ['major', 4], + ['notable', 3], ['routine', 1], ])('maps %s significance to span %i', (significance, expected) => { expect(requestedColSpan(makePostItem(makePost({ significance })))).toBe( @@ -79,7 +79,7 @@ describe('requestedColSpan', () => { expect( requestedColSpan( makePostItem( - makePost({ type: PostType.VideoYouTube, significance: 'major' }), + makePost({ type: PostType.VideoYouTube, significance: 'notable' }), ), ), ).toBe(3); @@ -124,7 +124,7 @@ describe('computeColSpans', () => { it('renders a wide card at requested size at the start of a row', () => { const items = [ - makePostItem(makePost({ significance: 'major' })), + makePostItem(makePost({ significance: 'notable' })), makePostItem(makePost()), makePostItem(makePost()), ]; @@ -163,7 +163,7 @@ describe('computeColSpans', () => { const items = [ makePostItem(makePost()), makePostItem(makePost()), - makePostItem(makePost({ significance: 'major' })), + makePostItem(makePost({ significance: 'notable' })), ]; const fullRowInsertionBeforeIndex = new Set([2]); expect( diff --git a/packages/shared/src/lib/feedHighlightColSpan.ts b/packages/shared/src/lib/feedHighlightColSpan.ts index 974a2d094cc..c786e147e1a 100644 --- a/packages/shared/src/lib/feedHighlightColSpan.ts +++ b/packages/shared/src/lib/feedHighlightColSpan.ts @@ -5,8 +5,8 @@ import type { PostHighlightSignificance } from '../graphql/posts'; const SIGNIFICANCE_COL_SPAN: Record = { breaking: 4, - major: 3, - notable: 2, + major: 4, + notable: 3, routine: 1, }; From 87dffdb7a2c51fc9e44e08b6a7d35106b8364b91 Mon Sep 17 00:00:00 2001 From: capJavert Date: Tue, 26 May 2026 14:42:26 +0200 Subject: [PATCH 4/4] Revert "feat: adjust sizing" This reverts commit 2b8867cc703fa8ab5ca3ce5f229a1e4002648c01. --- packages/shared/src/lib/feedHighlightColSpan.spec.ts | 10 +++++----- packages/shared/src/lib/feedHighlightColSpan.ts | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/shared/src/lib/feedHighlightColSpan.spec.ts b/packages/shared/src/lib/feedHighlightColSpan.spec.ts index 54d4afaa0d4..e411399599f 100644 --- a/packages/shared/src/lib/feedHighlightColSpan.spec.ts +++ b/packages/shared/src/lib/feedHighlightColSpan.spec.ts @@ -52,8 +52,8 @@ describe('requestedColSpan', () => { it.each<[PostHighlightSignificance, number]>([ ['breaking', 4], - ['major', 4], - ['notable', 3], + ['major', 3], + ['notable', 2], ['routine', 1], ])('maps %s significance to span %i', (significance, expected) => { expect(requestedColSpan(makePostItem(makePost({ significance })))).toBe( @@ -79,7 +79,7 @@ describe('requestedColSpan', () => { expect( requestedColSpan( makePostItem( - makePost({ type: PostType.VideoYouTube, significance: 'notable' }), + makePost({ type: PostType.VideoYouTube, significance: 'major' }), ), ), ).toBe(3); @@ -124,7 +124,7 @@ describe('computeColSpans', () => { it('renders a wide card at requested size at the start of a row', () => { const items = [ - makePostItem(makePost({ significance: 'notable' })), + makePostItem(makePost({ significance: 'major' })), makePostItem(makePost()), makePostItem(makePost()), ]; @@ -163,7 +163,7 @@ describe('computeColSpans', () => { const items = [ makePostItem(makePost()), makePostItem(makePost()), - makePostItem(makePost({ significance: 'notable' })), + makePostItem(makePost({ significance: 'major' })), ]; const fullRowInsertionBeforeIndex = new Set([2]); expect( diff --git a/packages/shared/src/lib/feedHighlightColSpan.ts b/packages/shared/src/lib/feedHighlightColSpan.ts index c786e147e1a..974a2d094cc 100644 --- a/packages/shared/src/lib/feedHighlightColSpan.ts +++ b/packages/shared/src/lib/feedHighlightColSpan.ts @@ -5,8 +5,8 @@ import type { PostHighlightSignificance } from '../graphql/posts'; const SIGNIFICANCE_COL_SPAN: Record = { breaking: 4, - major: 4, - notable: 3, + major: 3, + notable: 2, routine: 1, };