Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
f1240f9
feat(shared): add a Polymarket-style feed hero section
claude Aug 23, 2026
e7c6ba5
feat(shared): rebuild the hero ad slot from the feed ad card's elements
claude Aug 23, 2026
2777645
feat(shared): flatten the hero rail and use the feed's list card on m…
claude Aug 23, 2026
7949d5b
refactor(shared): drop the carousel position counter
claude Aug 23, 2026
4074b6f
feat(shared): render the feed hero on the feed behind a flag
claude Aug 24, 2026
8dad3d3
chore(shared): add a temporary URL switch for the feed hero
claude Aug 24, 2026
f4890bb
feat(shared): cap the hero width and resize the ad slot and highlights
claude Aug 24, 2026
1aec4c6
feat(shared): reorder the hero ad and let its card summary breathe
claude Aug 24, 2026
eb46163
feat(shared): autoplay the feed hero carousel and align its rail
claude Aug 24, 2026
107164f
feat(shared): crossfade the hero carousel and fold its ad into Happen…
claude Aug 24, 2026
7ed7a41
Merge remote-tracking branch 'origin/main' into claude/hero-carousel-…
claude Aug 24, 2026
462f110
feat(shared): give the hero a 40/60 split and make its ad read as a h…
claude Aug 24, 2026
58bdb0a
fix(shared): stop a long hero headline pushing the action row off the…
claude Aug 24, 2026
d4781d9
feat(shared): add a second, flat ad slot under the hero rail
claude Aug 25, 2026
a8424fa
feat(shared): move the hero ad beside the headlines instead of under …
claude Aug 25, 2026
cb12036
Merge remote-tracking branch 'origin/main' into claude/hero-carousel-…
claude Aug 25, 2026
60db0ad
feat(shared): let the hero span the card grid and pad its hover surfaces
claude Aug 25, 2026
70ce843
Merge branch 'main' into claude/hero-carousel-layout-e5e06u
rebelchris Aug 26, 2026
6e270a6
Merge remote-tracking branch 'origin/main' into claude/hero-carousel-…
claude Aug 26, 2026
837fc2c
feat(shared): give the hero ad a column of its own and drop the row
claude Sep 2, 2026
d03e4b6
feat(shared): scale the hero ad up to the featured card and fix the r…
claude Sep 2, 2026
d0fe5bf
feat(shared): align the hero to the v2 grid and follow the featured c…
claude Sep 2, 2026
498d709
Merge branch 'main' into claude/hero-carousel-layout-e5e06u
claude Sep 2, 2026
010dee9
fix(shared): give the hero ad the card corner radius
claude Sep 2, 2026
cdd0552
fix(shared): sit the hero's three footers on one line
claude Sep 2, 2026
43f4c3f
feat(shared): keep the feed's wide cards out of the row under the hero
claude Sep 2, 2026
5cb83ef
Merge remote-tracking branch 'origin/main' into claude/hero-carousel-…
claude Sep 2, 2026
75ed5d1
Merge remote-tracking branch 'origin/main' into claude/hero-carousel-…
claude Sep 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions packages/shared/src/components/Feed.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down Expand Up @@ -1889,6 +1947,7 @@ interface HighlightLayoutRenderParams {
briefBannerPage?: number;
staticAd?: { ad: Ad; index: number };
disableAds?: boolean;
skipFirstAd?: boolean;
user?: LoggedUser;
isHorizontal?: boolean;
feedName?: AllFeedPages;
Expand All @@ -1906,6 +1965,7 @@ const renderWithHighlightLayout = ({
briefBannerPage,
staticAd,
disableAds,
skipFirstAd,
user = defaultUser,
isHorizontal,
feedName = SharedFeedPage.MyFeed,
Expand Down Expand Up @@ -2012,6 +2072,7 @@ const renderWithHighlightLayout = ({
variables={variables}
staticAd={staticAd}
disableAds={disableAds}
skipFirstAd={skipFirstAd}
isHorizontal={isHorizontal}
/>
</FeedContext.Provider>
Expand Down Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions packages/shared/src/components/Feed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ export interface FeedProps<T>
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;
Expand Down Expand Up @@ -211,6 +217,9 @@ export default function Feed<T>({
shortcuts,
actionButtons,
disableAds,
disableHighlightCards,
skipFirstAd,
deferWideCards,
staticAd,
disableAdRefresh = false,
allowFetchMore,
Expand Down Expand Up @@ -376,6 +385,9 @@ export default function Feed<T>({
excludePinnedPosts,
settings: {
disableAds,
disableHighlightCards,
skipFirstAd,
deferWideCards,
staticAd,
adPostLength: isSquadFeed ? 2 : undefined,
feedName,
Expand Down
22 changes: 2 additions & 20 deletions packages/shared/src/components/FeedItemComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -135,20 +131,6 @@ const PostTypeToTagCard: Record<PostType, React.ComponentType<any>> = {
[PostType.Digest]: ArticleGrid,
};

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const PostTypeToTagList: Record<PostType, React.ComponentType<any>> = {
[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;
Expand Down Expand Up @@ -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;
Expand Down
67 changes: 54 additions & 13 deletions packages/shared/src/components/MainFeedLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,20 +63,24 @@ 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,
popularFeedVersion,
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';
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -763,6 +780,38 @@ export default function MainFeedLayout({
}
return '';
}, [customFeedsData, feedName, router.query.slugOrId]);
const chipsTopContent =
(isExploreTag || shouldUseListFeedLayout) && chipsNode ? (
<div
className={classNames('mb-8 w-full', shouldUseListFeedLayout && 'mt-8')}
>
{chipsNode}
</div>
) : 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 ? (
<>
<FeedHero className={heroClassName} />
{chipsTopContent}
</>
) : (
chipsTopContent
);

const v2ActionButtons = feedProps?.actionButtons;
const showFeedV2PageHeader =
isV2 &&
Expand Down Expand Up @@ -837,18 +886,10 @@ export default function MainFeedLayout({
<Feed
{...feedProps}
shortcuts={shortcuts}
topContent={
(isExploreTag || shouldUseListFeedLayout) && chipsNode ? (
<div
className={classNames(
'mb-8 w-full',
shouldUseListFeedLayout && 'mt-8',
)}
>
{chipsNode}
</div>
) : undefined
}
topContent={topContent}
disableHighlightCards={isFeedHeroEnabled}
skipFirstAd={isHeroAdVisible}
deferWideCards={isFeedHeroEnabled}
className={classNames(
shouldUseListFeedLayout && !isFinder && 'laptop:px-6',
)}
Expand Down
Loading
Loading