Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
c393e85
feat(monetization): add the sponsor strip experiment
rebelchris Sep 4, 2026
f1d8560
fix(monetization): default the sponsor strip flag to isDevelopment
rebelchris Sep 4, 2026
6818513
refactor(monetization): make the sponsor strip flag a plain boolean
rebelchris Sep 4, 2026
dddc017
fix(monetization): give the sponsor strip its own inset in layout v2
rebelchris Sep 4, 2026
85d2769
Merge branch 'main' into feat/sponsor-strip
rebelchris Sep 7, 2026
c1731c9
feat(monetization): put Google Cloud in the sponsor strip's gold slot
rebelchris Sep 7, 2026
364e995
fix(monetization): let the breaking news ticker follow the backend
rebelchris Sep 7, 2026
7e6e6aa
fix(monetization): stop the ticker pulling the feed's card out mid-sc…
rebelchris Sep 7, 2026
d946676
fix(monetization): reserve both dock rows so the logos stop jumping
rebelchris Sep 7, 2026
98da73b
fix(monetization): give the dock one resting place in the v2 frame
rebelchris Sep 7, 2026
315a48e
fix(monetization): let the dock reach the bottom of the window
rebelchris Sep 7, 2026
810fc95
fix(monetization): end the frame level with the window when it holds …
rebelchris Sep 7, 2026
84ef608
feat(monetization): align the dock to the feed and let the ticker scroll
rebelchris Sep 7, 2026
09022a3
Merge branch 'main' into feat/sponsor-strip
rebelchris Sep 7, 2026
6449980
fix(monetization): use Google Cloud's own lockup in the gold slot
tsahimatsliah Sep 7, 2026
a2e1b0b
fix(monetization): line the dock up with the cards inside the v2 frame
tsahimatsliah Sep 8, 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
62 changes: 62 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,68 @@ describe('Feed logged in', () => {
).toEqual(['postItem', 'postItem', 'highlightItem', 'postItem']);
});

// `disableHighlightItems` is set by MainFeedLayout when it mounts the sponsor
// strip, which is what carries breaking news in the card's place.
it('should drop the Happening Now card when the layout says the strip carries it', async () => {
renderComponent(
[
{
request: {
query: FEED_V2_QUERY,
variables,
},
result: {
data: {
page: {
pageInfo: defaultFeedPage.pageInfo,
edges: [
{
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,
},
},
],
},
},
{
node: {
__typename: 'FeedPostItem',
post: defaultFeedPage.edges[0].node,
feedMeta: defaultFeedPage.edges[0].node.feedMeta ?? null,
},
},
],
},
},
},
},
],
defaultUser,
SharedFeedPage.MyFeed,
FEED_V2_QUERY,
{ disableHighlightItems: true },
);

await waitForNock();

// The posts around it still render, so the card is dropped rather than the
// whole response being discarded.
expect(await screen.findAllByTestId('postItem')).not.toHaveLength(0);
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
8 changes: 8 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;
/**
* Drop the Happening Now card. Owned by the layout that renders the sponsor
* strip, which is where breaking news goes while the strip is up — a feed
* with no strip above it must never lose the card.
*/
disableHighlightItems?: boolean;
staticAd?: { ad: Ad; index: number };
disableAdRefresh?: boolean;
allowFetchMore?: boolean;
Expand Down Expand Up @@ -211,6 +217,7 @@ export default function Feed<T>({
shortcuts,
actionButtons,
disableAds,
disableHighlightItems,
staticAd,
disableAdRefresh = false,
allowFetchMore,
Expand Down Expand Up @@ -374,6 +381,7 @@ export default function Feed<T>({
disableTopHero: isV2,
isHorizontal,
excludePinnedPosts,
disableHighlightItems,
settings: {
disableAds,
staticAd,
Expand Down
33 changes: 27 additions & 6 deletions packages/shared/src/components/MainFeedLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,13 @@ import classNames from 'classnames';
import { useRouter } from 'next/router';
import type { FeedProps } from './Feed';
import Feed from './Feed';
import { FeedPageLayoutMobile, feedGutter } from './utilities/common';
import {
FeedPageLayoutMobile,
feedGutter,
feedWidth,
} from './utilities/common';
import { SponsorStrip } from '../features/monetization/sponsorStrip/SponsorStrip';
import { useSponsorStripFeed } from '../features/monetization/sponsorStrip/useSponsorStripFeed';
import { ExploreChipsBar } from './feeds/ExploreChipsBar';
import { buildPersonalizedCategories } from './feeds/exploreCategories';
import { useFeeds } from '../hooks/feed/useFeeds';
Expand All @@ -26,7 +32,6 @@ import { AskSearchBanner } from './marketing/banners/AskSearchBanner';
import { FeedEngagementBanner } from './brand/FeedEngagementBanner';
import { ExploreSignupStrip } from './auth/ExploreSignupStrip';
import FeedContext from '../contexts/FeedContext';
import feedStyles from './Feed.module.css';
import AuthContext from '../contexts/AuthContext';
import type { LoggedUser } from '../lib/user';
import { SharedFeedPage } from './utilities';
Expand Down Expand Up @@ -215,10 +220,7 @@ const getQueryBasedOnLogin = (
// The feed's own width: full width normally, and clamped + centered to the
// same card-based max-width as the grid on wide screens (desktopL). The CSS
// vars feed the `styles.container` max-width calc (grid gap is 2rem).
const feedWidthClassName = classNames(
'relative flex w-full flex-col laptopL:mx-auto',
feedStyles.container,
);
const feedWidthClassName = classNames('relative flex flex-col', feedWidth);
const commentClassName = {
container: 'rounded-none border-0 border-b tablet:border-x',
commentBox: {
Expand Down Expand Up @@ -791,6 +793,13 @@ export default function MainFeedLayout({
}
return '';
}, [customFeedsData, feedName, router.query.slugOrId]);
// Read here rather than inside the feed or the strip: this is the one place
// that owns both, so the card can only ever go missing on a surface that is
// mounting the strip — with headlines in it — in the card's place.
const sponsorStrip = useSponsorStripFeed({
feedName,
disableAds: feedProps?.disableAds,
});
const v2ActionButtons = feedProps?.actionButtons;
const showFeedV2PageHeader =
isV2 &&
Expand Down Expand Up @@ -858,6 +867,7 @@ export default function MainFeedLayout({
feedProps && (
<Feed
{...feedProps}
disableHighlightItems={sponsorStrip.disableHighlightItems}
shortcuts={shortcuts}
topContent={
(isExploreTag || shouldUseListFeedLayout) && chipsNode ? (
Expand All @@ -877,6 +887,17 @@ export default function MainFeedLayout({
)}
{children}
</FeedPageLayoutComponent>
{/* Docked outside the page container so it spans the feed column and
pins to the window, and mounted here rather than in each app's
MainFeedPage because this is the one component both the webapp and
the extension new tab render — and the only place the feed name is
already resolved from `default` to the reader's own feed. */}
{sponsorStrip.isEnabled && (
<SponsorStrip
headlines={sponsorStrip.headlines}
headlinesSettled={sponsorStrip.headlinesSettled}
/>
)}
</>
);
}
29 changes: 26 additions & 3 deletions packages/shared/src/components/MainLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,20 @@ function MainLayoutComponent({
/>
)}
{sidebarOwnsHeader ? (
<div className="flex min-h-0 flex-1 flex-col laptop:my-3 laptop:ml-1 laptop:mr-3">
<div
className={classNames(
'flex min-h-0 flex-1 flex-col laptop:my-3 laptop:ml-1 laptop:mr-3',
// A dock pins to the window, so the frame gives up its bottom
// gutter for the one case that holds one. Otherwise the frame
// stops 14px short of the viewport and a `sticky bottom-0` dock
// inside it cannot reach the bottom, resting there on first
// paint and at the end of the feed while pinning flush in
// between — a dock that jumps as the feed loads.
// Literal, not built from `DOCK_CLASS`: Tailwind scans source
// text and generates nothing for an interpolated class name.
'laptop:has-[.feed-dock]:mb-0',
)}
>
{showHomepageTopBanners && (
<HomepageTopBanners className="mx-4 mb-3 laptop:mx-0" />
)}
Expand All @@ -432,12 +445,22 @@ function MainLayoutComponent({
// No drop shadow — the subtle border defines the floating card
// in both themes; shadow-2 cast a heavy bottom shadow.
'laptop:overflow-clip laptop:rounded-24 laptop:border laptop:border-border-subtlest-quaternary laptop:bg-background-default laptop:p-0.5',
// The dock becomes the frame's bottom edge, so the padding
// that would hold it up goes, and the corners it would be
// clipped into square off.
'laptop:has-[.feed-dock]:rounded-b-none laptop:has-[.feed-dock]:border-b-0 laptop:has-[.feed-dock]:pb-0',
LAYOUT_FRAME_CLASS,
// These subtract exactly the chrome above the frame plus its
// own margins, so the frame ends level with the window. With
// a dock the bottom margin is gone, so 0.75rem less comes off
// — a frame that stops short leaves a `sticky bottom-0` dock
// resting at its end, which is what happens for as long as
// the feed is too short to make the page scrollable.
!hasTopBanners &&
!topBanner &&
(isBannerAvailable
? 'laptop:min-h-[calc(100vh-3.5rem)]'
: 'laptop:min-h-[calc(100vh-1.5rem)]'),
? 'laptop:min-h-[calc(100vh-3.5rem)] laptop:has-[.feed-dock]:min-h-[calc(100vh-2.75rem)]'
: 'laptop:min-h-[calc(100vh-1.5rem)] laptop:has-[.feed-dock]:min-h-[calc(100vh-0.75rem)]'),
)}
>
<RouteProgressBar />
Expand Down
4 changes: 3 additions & 1 deletion packages/shared/src/components/ScrollToTopButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,9 @@ export default function ScrollToTopButton(): ReactElement | null {
tabIndex={show ? 0 : -1}
{...props}
className={classNames(
'absolute right-4 z-2',
// Lifts over the sponsor strip when one is docked; the property is
// unset otherwise, so the margin collapses to zero.
'absolute right-4 z-2 mt-[calc(-1_*_var(--sponsor-strip-height,0px))]',
showFeedbackButton
? '-top-26 tablet:-top-32'
: '-top-12 tablet:-top-18 laptop:-top-24',
Expand Down
4 changes: 3 additions & 1 deletion packages/shared/src/components/feedback/FeedbackWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,9 @@ export function FeedbackWidget({
<Button
variant={ButtonVariant.Primary}
size={ButtonSize.Medium}
className="group fixed bottom-4 right-4 z-max !h-auto !gap-0 !px-3 py-1.5 shadow-2"
// `--sponsor-strip-height` is set only while the sponsor strip is docked
// (see `sponsorStripOffset`), so this is the usual 1rem everywhere else.
className="group fixed bottom-[calc(1rem_+_var(--sponsor-strip-height,0px))] right-4 z-max !h-auto !gap-0 !px-3 py-1.5 shadow-2"
onClick={() => openModal({ type: LazyModal.Feedback })}
aria-label="Send feedback. Real people reply."
aria-keyshortcuts="Control+Shift+F Meta+Shift+F"
Expand Down
33 changes: 33 additions & 0 deletions packages/shared/src/components/utilities/common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React from 'react';
import classNames from 'classnames';
import classed from '../../lib/classed';
import styles from './utilities.module.css';
import feedStyles from '../Feed.module.css';
import { ArrowIcon } from '../icons';
import { SourceMemberRole } from '../../graphql/sources';
import type { OrganizationMemberRole } from '../../features/organizations/types';
Expand Down Expand Up @@ -136,6 +137,38 @@ export const BaseFeedPage = classed(
export const feedGutter =
'tablet:px-6 laptop:px-10 laptop:[.layout-frame_&]:px-0';

/**
* The feed grid's own width: full width normally, and clamped and centred to
* the same card-based max-width as the grid on wide screens (desktopL). Needs
* `--num-cards` and `--feed-gap` set on the element for that calc.
*
* Chrome that has to line up with the cards pairs this with `feedGutter`:
* together they are where the feed's left and right edges actually are, which
* neither one is on its own.
*/
export const feedWidth = classNames(
'w-full laptopL:mx-auto',
feedStyles.container,
);

/**
* The last step in to the cards, and the one `feedGutter` cannot describe.
*
* Inside the v2 floating card the gutter is zero — the card is already the
* frame — but the grid then takes an inset of its own so the cards sit off
* the frame's rounded corners (`laptop:p-6` in FeedContainer). So in v2 the
* cards' left and right edges are 24px inside the gutter's, and chrome that
* lines up with them by gutter alone lands a full inset short.
*
* Horizontal only: the grid's vertical half is the frame's own breathing
* room, and chrome outside the grid sets its own height.
*
* Keyed to the frame's class rather than to `isV2` for the same reason
* `feedGutter` is: the flag resolves after mount, and this inset has to
* appear at exactly the moment the frame it insets from does.
*/
export const feedFrameInsetX = 'laptop:[.layout-frame_&]:px-6';

// Vertical padding only. The horizontal inset moved to FeedContainer
// (see `feedGutter`) because this component is not in the tree on
// every feed route — FeedPageLayoutList is used instead on some — and
Expand Down
Loading
Loading