From f6d9fbe39b32ebf43402a0849738365036d41418 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 2 Jun 2026 11:09:53 +0300 Subject: [PATCH 01/48] feat: add Pinterest-style post discovery mockup Introduce a discovery experience for the post page that turns a read into a continuous exploration loop: - PostFocusCard: borderless two-column card (summary/key points left, live discussion right) with a Lean/Rich content-depth toggle. - PostDiscussionPanel: extracted the comment stack (counts, sort, composer, comments, share) out of EngagementRail so the reader and the focus card share one source of truth instead of duplicating it. - PostDiscoveryFeed: a finite, topic-relevant rail followed by the infinite personalized feed, filling the page with relevant content. - PostDiscoveryLayout: shell with a value-timed signup nudge, impression logging, and a back-to-top control. - Mockup route /posts/[id]/discovery (noindex) gated behind the new post_discovery_experience flag. Co-authored-by: Cursor --- .../post/discovery/PostDiscoveryFeed.tsx | 124 ++++++++++++ .../post/discovery/PostDiscoveryLayout.tsx | 102 ++++++++++ .../post/discovery/PostDiscussionPanel.tsx | 181 ++++++++++++++++++ .../post/discovery/PostFocusCard.tsx | 162 ++++++++++++++++ .../components/post/reader/EngagementRail.tsx | 162 +++------------- packages/shared/src/lib/featureManagement.ts | 4 + .../webapp/pages/posts/[id]/discovery.tsx | 175 +++++++++++++++++ 7 files changed, 775 insertions(+), 135 deletions(-) create mode 100644 packages/shared/src/components/post/discovery/PostDiscoveryFeed.tsx create mode 100644 packages/shared/src/components/post/discovery/PostDiscoveryLayout.tsx create mode 100644 packages/shared/src/components/post/discovery/PostDiscussionPanel.tsx create mode 100644 packages/shared/src/components/post/discovery/PostFocusCard.tsx create mode 100644 packages/webapp/pages/posts/[id]/discovery.tsx diff --git a/packages/shared/src/components/post/discovery/PostDiscoveryFeed.tsx b/packages/shared/src/components/post/discovery/PostDiscoveryFeed.tsx new file mode 100644 index 0000000000..c565519eb3 --- /dev/null +++ b/packages/shared/src/components/post/discovery/PostDiscoveryFeed.tsx @@ -0,0 +1,124 @@ +import type { ReactElement } from 'react'; +import React, { useContext, useMemo } from 'react'; +import type { Post } from '../../../graphql/posts'; +import Feed from '../../Feed'; +import FeedContext from '../../../contexts/FeedContext'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { + ANONYMOUS_FEED_QUERY, + FEED_BY_TAGS_QUERY, + FEED_V2_QUERY, +} from '../../../graphql/feed'; +import { OtherFeedPage } from '../../../lib/query'; +import { SharedFeedPage } from '../../utilities'; +import { + getPostTopicLabel, + getPostTopicTags, +} from '../anonymousPostExperience'; + +export const POST_DISCOVERY_FEED_ANCHOR = 'post-discovery-feed'; + +interface PostDiscoveryFeedProps { + post: Post; +} + +interface SectionHeaderProps { + eyebrow: string; + title: string; + description: string; +} + +const SectionHeader = ({ + eyebrow, + title, + description, +}: SectionHeaderProps): ReactElement => ( +
+

{eyebrow}

+

{title}

+

{description}

+
+); + +/** + * Wraps a Feed in a FeedContext override so the discovery rail/grid uses a + * deliberate column count instead of inheriting the user's feed density. + */ +const FeedWithColumns = ({ + columns, + children, +}: { + columns: number; + children: ReactElement; +}): ReactElement => { + const currentSettings = useContext(FeedContext); + const value = useMemo( + () => ({ + ...currentSettings, + numCards: { eco: columns, roomy: columns, cozy: columns }, + }), + [currentSettings, columns], + ); + + return {children}; +}; + +/** + * The Pinterest-style discovery surface below the focus card: a finite, + * topic-relevant rail ("more like this") followed by the infinite personalized + * feed, turning the post page into a continuous exploration loop. + */ +export const PostDiscoveryFeed = ({ + post, +}: PostDiscoveryFeedProps): ReactElement => { + const { user } = useAuthContext(); + const topics = getPostTopicTags(post); + const topicLabel = getPostTopicLabel(topics); + const tags = (post.tags ?? []).filter((tag): tag is string => !!tag); + const hasTags = tags.length > 0; + + const mainQuery = user ? FEED_V2_QUERY : ANONYMOUS_FEED_QUERY; + + return ( +
+ {hasTags && ( +
+ + + + +
+ )} + +
+ + +
+
+ ); +}; diff --git a/packages/shared/src/components/post/discovery/PostDiscoveryLayout.tsx b/packages/shared/src/components/post/discovery/PostDiscoveryLayout.tsx new file mode 100644 index 0000000000..43d4f8f9cc --- /dev/null +++ b/packages/shared/src/components/post/discovery/PostDiscoveryLayout.tsx @@ -0,0 +1,102 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; +import type { Post } from '../../../graphql/posts'; +import type { PostOrigin } from '../../../hooks/log/useLogContextData'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { useLogContext } from '../../../contexts/LogContext'; +import { LogEvent, TargetType } from '../../../lib/log'; +import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; +import { ArrowIcon } from '../../icons'; +import { BuildYourFeedWidget } from '../BuildYourFeedWidget'; +import type { FocusCardLeftVariant } from './PostFocusCard'; +import { PostFocusCard } from './PostFocusCard'; +import { + POST_DISCOVERY_FEED_ANCHOR, + PostDiscoveryFeed, +} from './PostDiscoveryFeed'; + +interface PostDiscoveryLayoutProps { + post: Post; + origin: PostOrigin; + leftVariant?: FocusCardLeftVariant; +} + +const BackToTop = (): ReactElement | null => { + const [isVisible, setIsVisible] = useState(false); + + useEffect(() => { + const onScroll = (): void => { + setIsVisible(globalThis.window.scrollY > 800); + }; + onScroll(); + globalThis.window.addEventListener('scroll', onScroll, { passive: true }); + + return () => globalThis.window.removeEventListener('scroll', onScroll); + }, []); + + const scrollToTop = useCallback(() => { + globalThis.window.scrollTo({ top: 0, behavior: 'smooth' }); + }, []); + + if (!isVisible) { + return null; + } + + return ( + + + } + shouldHandleCommentQuery + onComposerOpenChange={setIsComposerOpen} + size={ProfileImageSize.Medium} + CommentInputOrModal={CommentInputOrModal} + /> + openShareComment(comment, post)} + onClickUpvote={(id, count) => onShowUpvoted(id, count, 'comment')} + modalParentSelector={resolveModalParent} + /> + + + ); +}; diff --git a/packages/shared/src/components/post/discovery/PostFocusCard.tsx b/packages/shared/src/components/post/discovery/PostFocusCard.tsx new file mode 100644 index 0000000000..76604bf2d9 --- /dev/null +++ b/packages/shared/src/components/post/discovery/PostFocusCard.tsx @@ -0,0 +1,162 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import type { Post } from '../../../graphql/posts'; +import { isVideoPost } from '../../../graphql/posts'; +import type { PostOrigin } from '../../../hooks/log/useLogContextData'; +import usePostContent from '../../../hooks/usePostContent'; +import { useSmartTitle } from '../../../hooks/post/useSmartTitle'; +import { useReaderInstallPromptGate } from '../../../hooks/useReaderInstallPromptGate'; +import PostMetadata from '../../cards/common/PostMetadata'; +import YoutubeVideo from '../../video/YoutubeVideo'; +import { PostHero } from '../experience/PostHero'; +import { PostInsightPanel } from '../experience/PostInsightPanel'; +import { PostTopicChips } from '../PostTopicChips'; +import { + getPostTopicLabel, + getPostTopicTags, +} from '../anonymousPostExperience'; +import { PostDiscussionPanel } from './PostDiscussionPanel'; + +export type FocusCardLeftVariant = 'lean' | 'rich'; + +interface PostFocusCardProps { + post: Post; + origin: PostOrigin; + leftVariant?: FocusCardLeftVariant; + /** + * Anchor id the "jump to related" teaser scrolls to (the discovery feed). + */ + discoveryAnchorId?: string; +} + +/** + * Pulls the first sentences out of the summary to fake "key takeaways" for the + * Rich mockup variant. Real key points would come from the backend. + */ +const getKeyPoints = (summary?: string): string[] => { + if (!summary) { + return []; + } + + return summary + .split(/(?<=[.!?])\s+/) + .map((sentence) => sentence.trim()) + .filter((sentence) => sentence.length > 24) + .slice(0, 3); +}; + +export const PostFocusCard = ({ + post, + origin, + leftVariant = 'rich', + discoveryAnchorId, +}: PostFocusCardProps): ReactElement => { + const isVideoType = isVideoPost(post); + const { title } = useSmartTitle(post); + const { onReadArticle } = usePostContent({ origin, post }); + const { onReadClick: onReaderInstallGateClick } = + useReaderInstallPromptGate(post); + const handleImageClick = (event: React.MouseEvent) => { + if (onReaderInstallGateClick(event)) { + return; + } + onReadArticle(); + }; + + const topics = getPostTopicTags(post); + const topicLabel = getPostTopicLabel(topics); + const keyPoints = leftVariant === 'rich' ? getKeyPoints(post.summary) : []; + + return ( +
+
+
+ +
+ + } + onImageClick={handleImageClick} + onReadArticle={onReadArticle} + post={post} + title={title} + /> +
+ +
+
+ {isVideoType && post.videoId && ( + + )} + + + + {leftVariant === 'rich' && keyPoints.length > 0 && ( +
+

+ Key takeaways +

+
    + {keyPoints.map((point) => ( +
  • + + {point} +
  • + ))} +
+
+ )} + + {leftVariant === 'rich' && ( + +

+ Keep exploring +

+

+ More developer stories on {topicLabel} +

+ +
+ )} +
+ +
+
+

Community

+

+ What developers are saying +

+
+ +
+
+
+ ); +}; diff --git a/packages/shared/src/components/post/reader/EngagementRail.tsx b/packages/shared/src/components/post/reader/EngagementRail.tsx index 1ac4bda6a8..905c4f520e 100644 --- a/packages/shared/src/components/post/reader/EngagementRail.tsx +++ b/packages/shared/src/components/post/reader/EngagementRail.tsx @@ -1,47 +1,28 @@ import dynamic from 'next/dynamic'; -import type { LegacyRef, ReactElement } from 'react'; -import React, { useContext, useEffect, useRef, useState } from 'react'; +import type { ReactElement } from 'react'; +import React, { useCallback, useContext, useRef } from 'react'; import classNames from 'classnames'; -import AuthContext, { useAuthContext } from '../../../contexts/AuthContext'; +import AuthContext from '../../../contexts/AuthContext'; import type { Post } from '../../../graphql/posts'; import { isVideoPost } from '../../../graphql/posts'; import { SourceType } from '../../../graphql/sources'; import type { SourceTooltip } from '../../../graphql/sources'; -import { useShareComment } from '../../../hooks/useShareComment'; -import { useUpvoteQuery } from '../../../hooks/useUpvoteQuery'; import { Origin } from '../../../lib/log'; import EntityCardSkeleton from '../../cards/entity/EntityCardSkeleton'; import FurtherReading from '../../widgets/FurtherReading'; import { PostSidebarAdWidget } from '../PostSidebarAdWidget'; -import { PostComments } from '../PostComments'; -import type { NewCommentRef } from '../NewComment'; -import { NewComment } from '../NewComment'; import { PostTagList } from '../tags/PostTagList'; import PostMetadata from '../../cards/common/PostMetadata'; -import { useSettingsContext } from '../../../contexts/SettingsContext'; import ShowMoreContent from '../../cards/common/ShowMoreContent'; -import { - Button, - ButtonIconPosition, - ButtonSize, - ButtonVariant, -} from '../../buttons/Button'; -import { TimeSortIcon } from '../../icons/Sort/Time'; -import { AnalyticsIcon, ArrowIcon } from '../../icons'; +import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; +import { ArrowIcon } from '../../icons'; import { PostMenuOptions } from '../PostMenuOptions'; -import { SortCommentsBy } from '../../../graphql/comments'; import { Tooltip } from '../../tooltip/Tooltip'; -import { ClickableText } from '../../buttons/ClickableText'; -import Link from '../../utilities/Link'; -import { largeNumberFormat } from '../../../lib'; -import { canViewPostAnalytics } from '../../../lib/user'; -import { webappUrl } from '../../../lib/constants'; -import { ProfileImageSize } from '../../ProfilePicture'; import { PostPosition } from '../../../hooks/usePostModalNavigation'; import { SourceStrip } from './SourceStrip'; import { ReaderRailActionBar } from './ReaderRailActionBar'; -import ShareBar from '../../ShareBar'; import { ReaderCloseButton } from './ReaderHeaderActionButtons'; +import { PostDiscussionPanel } from '../discovery/PostDiscussionPanel'; const SquadEntityCard = dynamic( () => @@ -53,13 +34,6 @@ const SquadEntityCard = dynamic( }, ); -const CommentInputOrModal = dynamic( - () => - import( - /* webpackChunkName: "commentInputOrModal" */ '../../comments/CommentInputOrModal' - ), -); - type EngagementRailProps = { post: Post; postPosition?: PostPosition; @@ -80,8 +54,6 @@ type EngagementRailProps = { inlineHeaderMenu?: boolean; }; -const noopFocus = (): void => {}; - export function EngagementRail({ post, postPosition, @@ -93,33 +65,23 @@ export function EngagementRail({ inlineHeaderMenu = false, }: EngagementRailProps): ReactElement { const { tokenRefreshed } = useContext(AuthContext); - const { user } = useAuthContext(); - const { sortCommentsBy: sortBy, updateSortCommentsBy: setSortBy } = - useSettingsContext(); - const commentRef = useRef(null); - const [isComposerOpen, setIsComposerOpen] = useState(false); - const { onShowUpvoted } = useUpvoteQuery(); - const { openShareComment } = useShareComment(Origin.ReaderModal); const isVideoType = isVideoPost(post); - const upvotes = post.numUpvotes || 0; - const comments = post.numComments || 0; - const canSeeAnalytics = canViewPostAnalytics({ user, post }); - useEffect(() => { - const run = (): void => { - commentRef.current?.onShowInput(Origin.PostCommentButton); - }; - onRegisterFocusComment(run); - return () => { - onRegisterFocusComment(noopFocus); - }; - }, [onRegisterFocusComment, post.id]); + // The discussion composer lives inside PostDiscussionPanel; keep a local + // handle so the summary action bar's "comment" button can focus it too, while + // still forwarding registration up to the reader's floating action bar. + const focusCommentRef = useRef<() => void>(() => {}); + const registerFocusComment = useCallback( + (fn: () => void) => { + focusCommentRef.current = fn; + onRegisterFocusComment(fn); + }, + [onRegisterFocusComment], + ); const { source } = post; const showNavigation = !!onPreviousPost || !!onNextPost; - const isNewestFirst = sortBy === SortCommentsBy.NewestFirst; - const sortLabel = isNewestFirst ? 'Sort: Newest first' : 'Sort: Oldest first'; const railHeaderGroupClasses = 'flex h-9 items-center gap-px rounded-12 border border-border-subtlest-tertiary bg-background-default/70 p-px shadow-3 backdrop-blur-md backdrop-saturate-150'; const iconButtonClassName = '!h-8 !w-8 !min-w-8 !rounded-10 !p-0'; @@ -244,90 +206,20 @@ export function EngagementRail({ /> - commentRef.current?.onShowInput(Origin.PostCommentButton) - } + onCommentClick={() => focusCommentRef.current()} className="mt-1" /> -
-
-
- {upvotes > 0 && ( - onShowUpvoted(post.id, upvotes)}> - {largeNumberFormat(upvotes)} Upvote{upvotes > 1 ? 's' : ''} - - )} - - {largeNumberFormat(comments)} Comment - {comments === 1 ? '' : 's'} - - {canSeeAnalytics && ( - - - - Analytics - - - )} -
- -
- } - shouldHandleCommentQuery - onComposerOpenChange={setIsComposerOpen} - size={ProfileImageSize.Medium} - CommentInputOrModal={CommentInputOrModal} - /> - openShareComment(comment, post)} - onClickUpvote={(id, count) => onShowUpvoted(id, count, 'comment')} - modalParentSelector={() => - document.getElementById('reader-post-modal-root') ?? document.body - } - /> - -
+ + document.getElementById('reader-post-modal-root') ?? document.body + } + /> void; +} + +const VariantControl = ({ + leftVariant, + onChange, +}: VariantControlProps): ReactElement => { + const options: { id: FocusCardLeftVariant; label: string }[] = [ + { id: 'lean', label: 'Lean' }, + { id: 'rich', label: 'Rich' }, + ]; + + return ( +
+ Mockup · content +
+ {options.map((option) => ( + + ))} +
+
+ ); +}; + +export const PostDiscoveryPage = ({ id, initialData }: Props): ReactElement => { + const [leftVariant, setLeftVariant] = useState('rich'); + const { post, isError, isLoading } = usePostById({ + id, + options: { initialData, retry: false }, + }); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!post || isError) { + return ( +
+ Post not found +
+ ); + } + + return ( + + + + + + + + + + ); +}; + +PostDiscoveryPage.getLayout = getLayout; +PostDiscoveryPage.layoutProps = { + screenCentered: false, +}; + +export default PostDiscoveryPage; + +export interface PostParams extends ParsedUrlQuery { + id: string; +} + +export async function getStaticPaths(): Promise { + return { paths: [], fallback: 'blocking' }; +} + +export async function getStaticProps({ + params, +}: GetStaticPropsContext): Promise> { + if (!params?.id) { + return { notFound: true, revalidate: 60 }; + } + + const { id } = params; + try { + const initialData = await gqlClient.request( + POST_BY_ID_STATIC_FIELDS_QUERY, + { id }, + ); + const post = initialData.post as Post; + const pageSeoTitles = getPageSeoTitles(seoTitle(post) ?? ''); + const seo: NextSeoProps = { + canonical: post?.slug ? `${webappUrl}posts/${post.slug}` : undefined, + title: pageSeoTitles.title, + description: getSeoDescription(post), + // This is an internal mockup surface; keep it out of the index. + noindex: true, + nofollow: true, + }; + + return { + props: { id: initialData.post.id, initialData, seo }, + revalidate: 60, + }; + } catch (err) { + const clientError = err as ClientError; + const errorCode = clientError?.response?.errors?.[0]?.extensions?.code; + if (errorCode === ApiError.NotFound) { + return { notFound: true, revalidate: 60 }; + } + + return { props: { id, error: errorCode as ApiError }, revalidate: 60 }; + } +} From 7508344f523c95eedc9baba007c858b0e20d0b5e Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 2 Jun 2026 11:25:40 +0300 Subject: [PATCH 02/48] feat: render discovery experience on the real post page behind flag Gate /posts/[id] on post_discovery_experience for article/video posts so the discovery layout takes over the actual post page (not only the standalone mockup route). Add a ?discovery=1 query override for easy preview without flipping the flag, and suppress the duplicate auth banner in that mode. Co-authored-by: Cursor --- packages/webapp/pages/posts/[id]/index.tsx | 58 +++++++++++++++------- 1 file changed, 39 insertions(+), 19 deletions(-) diff --git a/packages/webapp/pages/posts/[id]/index.tsx b/packages/webapp/pages/posts/[id]/index.tsx index 682d087d75..7afd3779d0 100644 --- a/packages/webapp/pages/posts/[id]/index.tsx +++ b/packages/webapp/pages/posts/[id]/index.tsx @@ -26,12 +26,15 @@ import type { PostContentProps } from '@dailydotdev/shared/src/components/post/c import { useScrollTopOffset } from '@dailydotdev/shared/src/hooks/useScrollTopOffset'; import { LogEvent, Origin, TargetType } from '@dailydotdev/shared/src/lib/log'; import { + useConditionalFeature, useEventListener, useJoinReferral, usePostById, useViewSize, ViewSize, } from '@dailydotdev/shared/src/hooks'; +import { featurePostDiscoveryExperience } from '@dailydotdev/shared/src/lib/featureManagement'; +import { PostDiscoveryLayout } from '@dailydotdev/shared/src/components/post/discovery/PostDiscoveryLayout'; import { usePrivateSourceJoin } from '@dailydotdev/shared/src/hooks/source/usePrivateSourceJoin'; import { ApiError, gqlClient } from '@dailydotdev/shared/src/graphql/common'; import PostLoadingSkeleton from '@dailydotdev/shared/src/components/post/PostLoadingSkeleton'; @@ -203,6 +206,18 @@ export const PostPage = ({ }, }); const featureTheme = useFeatureTheme(); + const isDiscoveryEligible = [ + PostType.Article, + PostType.VideoYouTube, + ].includes(post?.type); + const { value: isDiscoveryFlagOn } = useConditionalFeature({ + feature: featurePostDiscoveryExperience, + shouldEvaluate: isDiscoveryEligible, + }); + const forceDiscovery = + router.query.discovery === '1' || router.query.discovery === 'true'; + const showDiscovery = + isDiscoveryEligible && (isDiscoveryFlagOn || forceDiscovery); const containerClass = classNames( 'mb-16 min-h-page max-w-[69.25rem] tablet:mb-8 laptop:mb-0 laptop:pb-6 laptopL:pb-0', [ @@ -278,25 +293,30 @@ export const PostPage = ({ - - {shouldShowAuthBanner && + {showDiscovery ? ( + + ) : ( + + )} + {!showDiscovery && + shouldShowAuthBanner && isLaptop && (isAnonPostExperience ? ( From 5d62e66a243c79cc61b57acb31f73137db96014f Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 2 Jun 2026 11:55:12 +0300 Subject: [PATCH 03/48] fix: show post discovery preview by default Default the post discovery feature on for the mockup branch so PR preview URLs show the planned discovery design without requiring a query param. Keep ?discovery=0 as a reviewer escape hatch for comparing against the classic post page. Co-authored-by: Cursor --- packages/shared/src/lib/featureManagement.ts | 2 +- packages/webapp/pages/posts/[id]/index.tsx | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 950159f70a..f151ed8135 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -42,7 +42,7 @@ export const featureAnonymousPostExperience = new Feature( ); export const featurePostDiscoveryExperience = new Feature( 'post_discovery_experience', - false, + true, ); // @ts-expect-error stale feature without default diff --git a/packages/webapp/pages/posts/[id]/index.tsx b/packages/webapp/pages/posts/[id]/index.tsx index 7afd3779d0..5caeba24ad 100644 --- a/packages/webapp/pages/posts/[id]/index.tsx +++ b/packages/webapp/pages/posts/[id]/index.tsx @@ -216,8 +216,12 @@ export const PostPage = ({ }); const forceDiscovery = router.query.discovery === '1' || router.query.discovery === 'true'; + const forceClassic = + router.query.discovery === '0' || router.query.discovery === 'false'; const showDiscovery = - isDiscoveryEligible && (isDiscoveryFlagOn || forceDiscovery); + isDiscoveryEligible && + !forceClassic && + (isDiscoveryFlagOn || forceDiscovery); const containerClass = classNames( 'mb-16 min-h-page max-w-[69.25rem] tablet:mb-8 laptop:mb-0 laptop:pb-6 laptopL:pb-0', [ From a26f1f7bc0c6da7aa5d0cd102ec6b717cc605453 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 2 Jun 2026 12:03:25 +0300 Subject: [PATCH 04/48] fix: align discovery post layout with platform reading surface Simplify the discovery post surface so it feels like daily.dev instead of a new boxed composition. Render the post details directly in the left column, keep comments in a compact reader-style right rail, widen the focus area, and show related content as feed-grid cards instead of a horizontal/list-like rail. Co-authored-by: Cursor --- .../post/discovery/PostDiscoveryFeed.tsx | 19 +- .../post/discovery/PostDiscoveryLayout.tsx | 8 +- .../post/discovery/PostFocusCard.tsx | 210 ++++++++++-------- 3 files changed, 135 insertions(+), 102 deletions(-) diff --git a/packages/shared/src/components/post/discovery/PostDiscoveryFeed.tsx b/packages/shared/src/components/post/discovery/PostDiscoveryFeed.tsx index c565519eb3..205ce661c1 100644 --- a/packages/shared/src/components/post/discovery/PostDiscoveryFeed.tsx +++ b/packages/shared/src/components/post/discovery/PostDiscoveryFeed.tsx @@ -99,8 +99,8 @@ export const PostDiscoveryFeed = ({ variables={{ tags }} disableAds allowFetchMore={false} - pageSize={12} - isHorizontal + pageSize={9} + disableListFrame /> @@ -112,12 +112,15 @@ export const PostDiscoveryFeed = ({ title="Discover more" description="A fresh stream of developer stories, discussions, and tools." /> - + + + ); diff --git a/packages/shared/src/components/post/discovery/PostDiscoveryLayout.tsx b/packages/shared/src/components/post/discovery/PostDiscoveryLayout.tsx index 43d4f8f9cc..ea0fda212b 100644 --- a/packages/shared/src/components/post/discovery/PostDiscoveryLayout.tsx +++ b/packages/shared/src/components/post/discovery/PostDiscoveryLayout.tsx @@ -10,10 +10,7 @@ import { ArrowIcon } from '../../icons'; import { BuildYourFeedWidget } from '../BuildYourFeedWidget'; import type { FocusCardLeftVariant } from './PostFocusCard'; import { PostFocusCard } from './PostFocusCard'; -import { - POST_DISCOVERY_FEED_ANCHOR, - PostDiscoveryFeed, -} from './PostDiscoveryFeed'; +import { PostDiscoveryFeed } from './PostDiscoveryFeed'; interface PostDiscoveryLayoutProps { post: Post; @@ -77,9 +74,8 @@ export const PostDiscoveryLayout = ({ return (
-
+
{ const isVideoType = isVideoPost(post); const { title } = useSmartTitle(post); @@ -64,99 +62,135 @@ export const PostFocusCard = ({ onReadArticle(); }; - const topics = getPostTopicTags(post); - const topicLabel = getPostTopicLabel(topics); const keyPoints = leftVariant === 'rich' ? getKeyPoints(post.summary) : []; + const readHref = getReadArticleHref(post); + const readText = getReadPostButtonText(post); return (
-
-
- -
- +
+ +
+

+ {title} +

- } - onImageClick={handleImageClick} - onReadArticle={onReadArticle} - post={post} - title={title} - /> -
+ {post.clickbaitTitleDetected && } +
-
-
- {isVideoType && post.videoId && ( - + {!!readHref && ( + + )} + - )} - - +
+
- {leftVariant === 'rich' && keyPoints.length > 0 && ( -
-

- Key takeaways -

-
    - {keyPoints.map((point) => ( -
  • - - {point} -
  • - ))} -
-
- )} + {isVideoType && post.videoId ? ( + + ) : ( + + + + )} - {leftVariant === 'rich' && ( - +

TL;DR

+ {post.summary ? ( +

-

- Keep exploring -

-

- More developer stories on {topicLabel} -

- -
+ {post.summary} +

+ ) : ( +

+ Read the original post, then use the developer discussion and feed + below to keep exploring related stories. +

)} -
+ -
-
-

Community

-

- What developers are saying -

-
- -
+ {leftVariant === 'rich' && keyPoints.length > 0 && ( +
+

Key takeaways

+
    + {keyPoints.map((point) => ( +
  • + + {point} +
  • + ))} +
+
+ )} + +
+ +
); }; From bbc79880f6eb2add14f13090454359bf610a1660 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 2 Jun 2026 12:05:47 +0300 Subject: [PATCH 05/48] fix: remove duplicate discovery read action Keep the discovery post action row focused on the primary read/watch CTA plus the context menu, avoiding a second icon-only read action from PostHeaderActions. Co-authored-by: Cursor --- .../src/components/post/discovery/PostFocusCard.tsx | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/shared/src/components/post/discovery/PostFocusCard.tsx b/packages/shared/src/components/post/discovery/PostFocusCard.tsx index a2b535e9c2..10b314a863 100644 --- a/packages/shared/src/components/post/discovery/PostFocusCard.tsx +++ b/packages/shared/src/components/post/discovery/PostFocusCard.tsx @@ -14,7 +14,7 @@ import PostMetadata from '../../cards/common/PostMetadata'; import YoutubeVideo from '../../video/YoutubeVideo'; import { PostTagList } from '../tags/PostTagList'; import PostSourceInfo from '../PostSourceInfo'; -import { PostHeaderActions } from '../PostHeaderActions'; +import { PostMenuOptions } from '../PostMenuOptions'; import { PostClickbaitShield } from '../common/PostClickbaitShield'; import { LazyImage } from '../../LazyImage'; import { cloudinaryPostImageCoverPlaceholder } from '../../../lib/image'; @@ -110,13 +110,9 @@ export const PostFocusCard = ({ {readText} )} -
From bdce1caaf40a1d5f1448a1dcdfbfbc6928aa44cf Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 2 Jun 2026 12:14:58 +0300 Subject: [PATCH 06/48] test: keep classic post page tests on classic layout Pin the post discovery feature mock off in the existing PostPage suite so those assertions continue to cover the classic post-page behavior while the mockup branch defaults the new discovery experience on for preview. Co-authored-by: Cursor --- packages/webapp/__tests__/PostPage.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/webapp/__tests__/PostPage.tsx b/packages/webapp/__tests__/PostPage.tsx index 8caab583e4..09d9bb38da 100644 --- a/packages/webapp/__tests__/PostPage.tsx +++ b/packages/webapp/__tests__/PostPage.tsx @@ -90,6 +90,9 @@ jest.mock('@dailydotdev/shared/src/hooks/useConditionalFeature', () => ({ if (args?.feature?.id === 'reader_modal') { return { value: false, isLoading: false }; } + if (args?.feature?.id === 'post_discovery_experience') { + return { value: false, isLoading: false }; + } return { value: args?.feature?.defaultValue, isLoading: false }; }, })); From 5611539783127d2b4b17ff97e760f35dafbc4b10 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 2 Jun 2026 12:25:06 +0300 Subject: [PATCH 07/48] fix: force discovery feed to render as card grid The discovery feed was inheriting the post page/list-mode feed layout, so users with list mode enabled still saw list cards. Scope the nested discovery feeds to a grid-oriented feed context and disable insane mode only for this surface so it uses the familiar card grid regardless of global feed layout preference. Co-authored-by: Cursor --- .../post/discovery/PostDiscoveryFeed.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/components/post/discovery/PostDiscoveryFeed.tsx b/packages/shared/src/components/post/discovery/PostDiscoveryFeed.tsx index 205ce661c1..8200f2ecbb 100644 --- a/packages/shared/src/components/post/discovery/PostDiscoveryFeed.tsx +++ b/packages/shared/src/components/post/discovery/PostDiscoveryFeed.tsx @@ -3,7 +3,9 @@ import React, { useContext, useMemo } from 'react'; import type { Post } from '../../../graphql/posts'; import Feed from '../../Feed'; import FeedContext from '../../../contexts/FeedContext'; +import SettingsContext from '../../../contexts/SettingsContext'; import { useAuthContext } from '../../../contexts/AuthContext'; +import { ActiveFeedNameContext } from '../../../contexts'; import { ANONYMOUS_FEED_QUERY, FEED_BY_TAGS_QUERY, @@ -52,15 +54,28 @@ const FeedWithColumns = ({ children: ReactElement; }): ReactElement => { const currentSettings = useContext(FeedContext); - const value = useMemo( + const settings = useContext(SettingsContext); + const feedContextValue = useMemo( () => ({ ...currentSettings, numCards: { eco: columns, roomy: columns, cozy: columns }, }), [currentSettings, columns], ); + const settingsContextValue = useMemo( + () => ({ ...settings, insaneMode: false }), + [settings], + ); - return {children}; + return ( + + + + {children} + + + + ); }; /** From cd763748946fc7b5c5aca20300af8b493e9eac40 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 2 Jun 2026 12:27:57 +0300 Subject: [PATCH 08/48] fix: reuse production post actions in discovery layout Keep the discovery left column closer to the production post page by using the existing stats/action bar components instead of a custom detail section. Wire the comment action to the right-side discussion rail composer and remove the custom key-takeaways block from this surface. Co-authored-by: Cursor --- .../post/discovery/PostFocusCard.tsx | 69 ++++++++----------- 1 file changed, 28 insertions(+), 41 deletions(-) diff --git a/packages/shared/src/components/post/discovery/PostFocusCard.tsx b/packages/shared/src/components/post/discovery/PostFocusCard.tsx index 10b314a863..37c87d094f 100644 --- a/packages/shared/src/components/post/discovery/PostFocusCard.tsx +++ b/packages/shared/src/components/post/discovery/PostFocusCard.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react'; -import React from 'react'; +import React, { useRef } from 'react'; import type { Post } from '../../../graphql/posts'; import { getReadArticleHref, @@ -10,6 +10,7 @@ import type { PostOrigin } from '../../../hooks/log/useLogContextData'; import usePostContent from '../../../hooks/usePostContent'; import { useSmartTitle } from '../../../hooks/post/useSmartTitle'; import { useReaderInstallPromptGate } from '../../../hooks/useReaderInstallPromptGate'; +import { useUpvoteQuery } from '../../../hooks/useUpvoteQuery'; import PostMetadata from '../../cards/common/PostMetadata'; import YoutubeVideo from '../../video/YoutubeVideo'; import { PostTagList } from '../tags/PostTagList'; @@ -19,6 +20,8 @@ import { PostClickbaitShield } from '../common/PostClickbaitShield'; import { LazyImage } from '../../LazyImage'; import { cloudinaryPostImageCoverPlaceholder } from '../../../lib/image'; import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; +import { PostActions } from '../PostActions'; +import { PostUpvotesCommentsCount } from '../PostUpvotesCommentsCount'; import { PostDiscussionPanel } from './PostDiscussionPanel'; export type FocusCardLeftVariant = 'lean' | 'rich'; @@ -29,32 +32,17 @@ interface PostFocusCardProps { leftVariant?: FocusCardLeftVariant; } -/** - * Pulls the first sentences out of the summary to fake "key takeaways" for the - * Rich mockup variant. Real key points would come from the backend. - */ -const getKeyPoints = (summary?: string): string[] => { - if (!summary) { - return []; - } - - return summary - .split(/(?<=[.!?])\s+/) - .map((sentence) => sentence.trim()) - .filter((sentence) => sentence.length > 24) - .slice(0, 3); -}; - export const PostFocusCard = ({ post, origin, - leftVariant = 'rich', }: PostFocusCardProps): ReactElement => { const isVideoType = isVideoPost(post); const { title } = useSmartTitle(post); - const { onReadArticle } = usePostContent({ origin, post }); + const { onCopyPostLink, onReadArticle } = usePostContent({ origin, post }); const { onReadClick: onReaderInstallGateClick } = useReaderInstallPromptGate(post); + const { onShowUpvoted } = useUpvoteQuery(); + const focusCommentRef = useRef<() => void>(() => {}); const handleImageClick = (event: React.MouseEvent) => { if (onReaderInstallGateClick(event)) { return; @@ -62,7 +50,6 @@ export const PostFocusCard = ({ onReadArticle(); }; - const keyPoints = leftVariant === 'rich' ? getKeyPoints(post.summary) : []; const readHref = getReadArticleHref(post); const readText = getReadPostButtonText(post); @@ -161,31 +148,31 @@ export const PostFocusCard = ({ )} - {leftVariant === 'rich' && keyPoints.length > 0 && ( -
-

Key takeaways

-
    - {keyPoints.map((point) => ( -
  • - - {point} -
  • - ))} -
-
- )} - + +
+ onShowUpvoted(post.id, upvotes)} + /> + focusCommentRef.current()} + onCopyLinkClick={onCopyPostLink} + origin={origin} + /> +
); From 6b6ac3165107ea454d48c604f72a4e60bce25b04 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 2 Jun 2026 12:34:35 +0300 Subject: [PATCH 09/48] fix: let discovery feed use full page width Keep the post details constrained, but render the discovery feed outside that post-detail wrapper so it can expand like the main feed. Let it use the normal responsive feed column counts while still forcing this nested surface onto the grid-card layout path. Co-authored-by: Cursor --- .../post/discovery/PostDiscoveryFeed.tsx | 29 +++++-------------- .../post/discovery/PostDiscoveryLayout.tsx | 2 ++ 2 files changed, 10 insertions(+), 21 deletions(-) diff --git a/packages/shared/src/components/post/discovery/PostDiscoveryFeed.tsx b/packages/shared/src/components/post/discovery/PostDiscoveryFeed.tsx index 8200f2ecbb..68142d8a7b 100644 --- a/packages/shared/src/components/post/discovery/PostDiscoveryFeed.tsx +++ b/packages/shared/src/components/post/discovery/PostDiscoveryFeed.tsx @@ -2,7 +2,6 @@ import type { ReactElement } from 'react'; import React, { useContext, useMemo } from 'react'; import type { Post } from '../../../graphql/posts'; import Feed from '../../Feed'; -import FeedContext from '../../../contexts/FeedContext'; import SettingsContext from '../../../contexts/SettingsContext'; import { useAuthContext } from '../../../contexts/AuthContext'; import { ActiveFeedNameContext } from '../../../contexts'; @@ -43,25 +42,15 @@ const SectionHeader = ({ ); /** - * Wraps a Feed in a FeedContext override so the discovery rail/grid uses a - * deliberate column count instead of inheriting the user's feed density. + * Keeps the nested discovery feeds on the grid-card path even though the page + * route itself is a post page, which normally forces list layout. */ -const FeedWithColumns = ({ - columns, +const DiscoveryFeedGridScope = ({ children, }: { - columns: number; children: ReactElement; }): ReactElement => { - const currentSettings = useContext(FeedContext); const settings = useContext(SettingsContext); - const feedContextValue = useMemo( - () => ({ - ...currentSettings, - numCards: { eco: columns, roomy: columns, cozy: columns }, - }), - [currentSettings, columns], - ); const settingsContextValue = useMemo( () => ({ ...settings, insaneMode: false }), [settings], @@ -70,9 +59,7 @@ const FeedWithColumns = ({ return ( - - {children} - + {children} ); @@ -106,7 +93,7 @@ export const PostDiscoveryFeed = ({ title={`More on ${topicLabel}`} description="Hand-picked stories close to what you just read." /> - + - + )} @@ -127,7 +114,7 @@ export const PostDiscoveryFeed = ({ title="Discover more" description="A fresh stream of developer stories, discussions, and tools." /> - + - +
); diff --git a/packages/shared/src/components/post/discovery/PostDiscoveryLayout.tsx b/packages/shared/src/components/post/discovery/PostDiscoveryLayout.tsx index ea0fda212b..2c458d839e 100644 --- a/packages/shared/src/components/post/discovery/PostDiscoveryLayout.tsx +++ b/packages/shared/src/components/post/discovery/PostDiscoveryLayout.tsx @@ -89,7 +89,9 @@ export const PostDiscoveryLayout = ({
)} + +
From 5e02970be7e704352ccb29e737cd54d2f9fd2448 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 2 Jun 2026 12:37:00 +0300 Subject: [PATCH 10/48] fix: reuse production post details in discovery layout Render the discovery left column with the same production post detail stack used above comments today: PostHero, video embed, PostInsightPanel, code snippets, and the standard stats/action bar. Keep only the comment thread separated into the right rail. Co-authored-by: Cursor --- .../post/discovery/PostFocusCard.tsx | 212 +++++++++--------- 1 file changed, 101 insertions(+), 111 deletions(-) diff --git a/packages/shared/src/components/post/discovery/PostFocusCard.tsx b/packages/shared/src/components/post/discovery/PostFocusCard.tsx index 37c87d094f..6b51210be5 100644 --- a/packages/shared/src/components/post/discovery/PostFocusCard.tsx +++ b/packages/shared/src/components/post/discovery/PostFocusCard.tsx @@ -1,11 +1,8 @@ -import type { ReactElement } from 'react'; +import dynamic from 'next/dynamic'; +import type { ComponentProps, ReactElement } from 'react'; import React, { useRef } from 'react'; import type { Post } from '../../../graphql/posts'; -import { - getReadArticleHref, - getReadPostButtonText, - isVideoPost, -} from '../../../graphql/posts'; +import { isVideoPost } from '../../../graphql/posts'; import type { PostOrigin } from '../../../hooks/log/useLogContextData'; import usePostContent from '../../../hooks/usePostContent'; import { useSmartTitle } from '../../../hooks/post/useSmartTitle'; @@ -13,17 +10,22 @@ import { useReaderInstallPromptGate } from '../../../hooks/useReaderInstallPromp import { useUpvoteQuery } from '../../../hooks/useUpvoteQuery'; import PostMetadata from '../../cards/common/PostMetadata'; import YoutubeVideo from '../../video/YoutubeVideo'; -import { PostTagList } from '../tags/PostTagList'; -import PostSourceInfo from '../PostSourceInfo'; -import { PostMenuOptions } from '../PostMenuOptions'; -import { PostClickbaitShield } from '../common/PostClickbaitShield'; -import { LazyImage } from '../../LazyImage'; -import { cloudinaryPostImageCoverPlaceholder } from '../../../lib/image'; -import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; import { PostActions } from '../PostActions'; import { PostUpvotesCommentsCount } from '../PostUpvotesCommentsCount'; +import { PostHero } from '../experience/PostHero'; +import { PostInsightPanel } from '../experience/PostInsightPanel'; +import { TruncateText } from '../../utilities'; +import { combinedClicks } from '../../../lib/click'; +import { useFeature } from '../../GrowthBookProvider'; +import { feature } from '../../../lib/featureManagement'; import { PostDiscussionPanel } from './PostDiscussionPanel'; +const PostCodeSnippets = dynamic(() => + import(/* webpackChunkName: "postCodeSnippets" */ '../PostCodeSnippets').then( + (mod) => ({ default: mod.PostCodeSnippets }), + ), +); + export type FocusCardLeftVariant = 'lean' | 'rich'; interface PostFocusCardProps { @@ -32,9 +34,36 @@ interface PostFocusCardProps { leftVariant?: FocusCardLeftVariant; } +const ArticleLink = ({ + href, + onClick, + children, + ...props +}: ComponentProps<'a'> & { + href?: string; + onClick?: (event: React.MouseEvent) => void; +}) => { + const clickHandlers = onClick + ? combinedClicks(onClick) + : undefined; + return ( + + {children} + + ); +}; + export const PostFocusCard = ({ post, origin, + leftVariant, }: PostFocusCardProps): ReactElement => { const isVideoType = isVideoPost(post); const { title } = useSmartTitle(post); @@ -42,6 +71,7 @@ export const PostFocusCard = ({ const { onReadClick: onReaderInstallGateClick } = useReaderInstallPromptGate(post); const { onShowUpvoted } = useUpvoteQuery(); + const showCodeSnippets = useFeature(feature.showCodeSnippets); const focusCommentRef = useRef<() => void>(() => {}); const handleImageClick = (event: React.MouseEvent) => { if (onReaderInstallGateClick(event)) { @@ -50,119 +80,79 @@ export const PostFocusCard = ({ onReadArticle(); }; - const readHref = getReadArticleHref(post); - const readText = getReadPostButtonText(post); - return (
-
-
- -
-

- {title} -

+
+ 0 && ( + + From{' '} + + {post.domain} + + + ) + } isVideoType={isVideoType} readTime={post.readTime} /> - {post.clickbaitTitleDetected && } -
- -
- {!!readHref && ( - - )} - -
-
+ } + onImageClick={handleImageClick} + onReadArticle={onReadArticle} + post={post} + title={title} + /> - {isVideoType && post.videoId ? ( - - ) : ( - - + {isVideoType && ( + - - )} - -
-

TL;DR

- {post.summary ? ( -

- {post.summary} -

- ) : ( -

- Read the original post, then use the developer discussion and feed - below to keep exploring related stories. -

)} -
- + + {showCodeSnippets && ( + + )} + -
- onShowUpvoted(post.id, upvotes)} - /> - focusCommentRef.current()} - onCopyLinkClick={onCopyPostLink} - origin={origin} - /> -
+
+ onShowUpvoted(post.id, upvotes)} + /> + focusCommentRef.current()} + onCopyLinkClick={onCopyPostLink} + origin={origin} + /> +
+