From f6d9fbe39b32ebf43402a0849738365036d41418 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 2 Jun 2026 11:09:53 +0300 Subject: [PATCH 001/111] 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 00000000000..c565519eb39 --- /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 00000000000..43d4f8f9cc3 --- /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 00000000000..76604bf2d9d --- /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 1ac4bda6a88..905c4f520e4 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 002/111] 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 682d087d756..7afd3779d05 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 003/111] 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 950159f70a2..f151ed8135a 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 7afd3779d05..5caeba24ade 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 004/111] 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 c565519eb39..205ce661c1c 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 43d4f8f9cc3..ea0fda212be 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 005/111] 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 a2b535e9c2d..10b314a863b 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 006/111] 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 8caab583e4e..09d9bb38daf 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 007/111] 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 205ce661c1c..8200f2ecbbc 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 008/111] 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 10b314a863b..37c87d094ff 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 009/111] 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 8200f2ecbbc..68142d8a7b2 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 ea0fda212be..2c458d839e8 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 010/111] 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 37c87d094ff..6b51210be5d 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} + /> +
+
From 52f4dec005394d718dbaaf2c075de21cc03a42aa Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 9 Jun 2026 10:01:02 +0300 Subject: [PATCH 082/111] fix(discovery): always show an ad title on the icon line Fall back to the ad's tagline when there's no company name, so the inline ad always shows a title beside the favicon (above the promoted strip). Avoid repeating the tagline in the body when it becomes the title. Co-Authored-By: Claude Opus 4.8 --- .../components/post/PostSidebarAdWidget.tsx | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/components/post/PostSidebarAdWidget.tsx b/packages/shared/src/components/post/PostSidebarAdWidget.tsx index 9aa3dc70472..81ea903d90c 100644 --- a/packages/shared/src/components/post/PostSidebarAdWidget.tsx +++ b/packages/shared/src/components/post/PostSidebarAdWidget.tsx @@ -103,6 +103,14 @@ export function PostSidebarAdWidget({ }); if (variant === 'inline') { + const tagLine = ad.tagLine?.trim(); + // Always surface a title on the icon line: the company if present, + // otherwise the ad's tagline. The tagline only repeats in the body when + // the company is the title. + const inlineTitle = company || tagLine; + const inlineBodyTagLine = company ? tagLine : undefined; + const inlineHasBody = !!ad.description || !!inlineBodyTagLine; + return (
- {company && ( + {inlineTitle && ( - {company} + {inlineTitle} )}
- {hasDescription && ( + {inlineHasBody && ( - {ad.tagLine && {ad.tagLine}} - {ad.tagLine && ad.description ? ' ' : ''} + {inlineBodyTagLine && {inlineBodyTagLine}} + {inlineBodyTagLine && ad.description ? ' ' : ''} {ad.description} )} From f99fbd34f5d20ebd5d06d6f76cda2e5e4df8a6de Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 9 Jun 2026 10:06:56 +0300 Subject: [PATCH 083/111] fix(discovery): consistent comment-style author header for authored posts Show the user author (shared, freeform, welcome) with the same UserShortInfo layout and avatar size (Large) as comment/reply authors, instead of falling back to the source strip for some types. Co-Authored-By: Claude Opus 4.8 --- .../post/discovery/PostFocusCard.tsx | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/shared/src/components/post/discovery/PostFocusCard.tsx b/packages/shared/src/components/post/discovery/PostFocusCard.tsx index 7acd69742e7..80e71024539 100644 --- a/packages/shared/src/components/post/discovery/PostFocusCard.tsx +++ b/packages/shared/src/components/post/discovery/PostFocusCard.tsx @@ -102,9 +102,15 @@ export const PostFocusCard = ({ ? post.source : undefined; const isCollection = article.type === PostType.Collection; - // Shared posts lead with the person who shared it; the squad/source is - // surfaced via the "Shared via" eyebrow below the author. - const sharedByAuthor = isShared ? post.author : undefined; + // Posts authored by a user (shared, freeform, welcome) lead with that + // user, shown exactly like a comment author. Publication-sourced posts + // (article/video/collection) keep their source strip. + const author = + post.type === PostType.Share || + post.type === PostType.Freeform || + post.type === PostType.Welcome + ? post.author + : undefined; const isVideoType = isVideoPost(article); const { title } = useSmartTitle(article); const { onCopyPostLink, onReadArticle } = usePostContent({ origin, post }); @@ -139,11 +145,11 @@ export const PostFocusCard = ({
- {sharedByAuthor ? ( + {author ? (
Date: Tue, 9 Jun 2026 10:10:50 +0300 Subject: [PATCH 084/111] feat(discovery): rebuild action bar on CardAction guideline (PR #6064) Migrate the discovery engagement bar from the legacy QuaternaryButton to the CardAction primitives: the count lives inside the click target, the award icon uses icon/iconPressed (filled until awarded), and copy/ analytics are CardActions too. Sticky + stuck X behavior preserved. Co-Authored-By: Claude Opus 4.8 --- .../post/discovery/PostDiscoveryActionBar.tsx | 130 +++++++----------- 1 file changed, 46 insertions(+), 84 deletions(-) diff --git a/packages/shared/src/components/post/discovery/PostDiscoveryActionBar.tsx b/packages/shared/src/components/post/discovery/PostDiscoveryActionBar.tsx index 185ff9fcaa1..faf986b5438 100644 --- a/packages/shared/src/components/post/discovery/PostDiscoveryActionBar.tsx +++ b/packages/shared/src/components/post/discovery/PostDiscoveryActionBar.tsx @@ -13,13 +13,9 @@ import { useAuthContext } from '../../../contexts/AuthContext'; import type { PostOrigin } from '../../../hooks/log/useLogContextData'; import { Origin } from '../../../lib/log'; import { AuthTriggers } from '../../../lib/auth'; -import { - Button, - ButtonColor, - ButtonSize, - ButtonVariant, -} from '../../buttons/Button'; -import { QuaternaryButton } from '../../buttons/QuaternaryButton'; +import { ButtonSize } from '../../buttons/Button'; +import { ButtonColor } from '../../buttons/ButtonV2'; +import { CardAction } from '../../buttons/CardAction'; import { BookmarkButton } from '../../buttons/BookmarkButton'; import CloseButton from '../../CloseButton'; import { UpvoteButtonIcon } from '../../cards/common/UpvoteButtonIcon'; @@ -32,8 +28,6 @@ import { MedalBadgeIcon, } from '../../icons'; import { Tooltip } from '../../tooltip/Tooltip'; -import Link from '../../utilities/Link'; -import { largeNumberFormat } from '../../../lib'; import type { LoggedUser } from '../../../lib/user'; import { canViewPostAnalytics } from '../../../lib/user'; import { webappUrl } from '../../../lib/constants'; @@ -51,10 +45,10 @@ interface PostDiscoveryActionBarProps { } /** - * Compact, border-framed engagement bar for the discovery focus card. Each - * action keeps its count inline in the same button, so clicking the icon or - * the number performs the action (vote/comment/award) — the dedicated popups - * live in the stats strip below the tags. Sticks to the top while scrolling. + * Engagement bar for the discovery focus card, built on the CardAction + * primitives (PR #6064 guideline): each action's count lives inside the click + * target so the icon or number performs the action. Sticks to the top while + * scrolling; the modal X appears only once the bar is pinned. */ export const PostDiscoveryActionBar = ({ post, @@ -146,90 +140,69 @@ export const PostDiscoveryActionBar = ({
-
+
- - } - onClick={onToggleUpvote} + icon={} + iconPressed={} + count={upvotes} pressed={isUpvoteActive} - size={ButtonSize.Small} - variant={ButtonVariant.Tertiary} - > - {upvotes > 0 ? largeNumberFormat(upvotes) : undefined} - + onClick={onToggleUpvote} + /> - - } - onClick={onToggleDownvote} + icon={} + iconPressed={} pressed={isDownvoteActive} - size={ButtonSize.Small} - variant={ButtonVariant.Tertiary} + onClick={onToggleDownvote} /> - - } - onClick={onComment} + icon={} + iconPressed={} + count={comments} pressed={post.commented} - size={ButtonSize.Small} - variant={ButtonVariant.Tertiary} - > - {comments > 0 ? largeNumberFormat(comments) : undefined} - + onClick={onComment} + /> {canAward && ( - - } - onClick={onGiveAward} + icon={} + iconPressed={} + count={awards} pressed={isAwarded} - size={ButtonSize.Small} - variant={ButtonVariant.Tertiary} - > - {awards > 0 ? largeNumberFormat(awards) : undefined} - + onClick={onGiveAward} + /> )}
-
+
-
From 4e59a4577e712f3654d47746d430fcd358622792 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 9 Jun 2026 10:24:29 +0300 Subject: [PATCH 085/111] fix(discovery): sticky action bar on the post page below the header Offset the sticky top by the fixed laptop header height on the post page (top-0 laptop:top-16) so the bar pins below the header instead of behind it. The modal keeps top-0 and remains the only surface with the X close button. Co-Authored-By: Claude Opus 4.8 --- .../components/post/discovery/PostDiscoveryActionBar.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/components/post/discovery/PostDiscoveryActionBar.tsx b/packages/shared/src/components/post/discovery/PostDiscoveryActionBar.tsx index faf986b5438..160887fc82b 100644 --- a/packages/shared/src/components/post/discovery/PostDiscoveryActionBar.tsx +++ b/packages/shared/src/components/post/discovery/PostDiscoveryActionBar.tsx @@ -92,6 +92,10 @@ export const PostDiscoveryActionBar = ({ const comments = post.numComments || 0; const awards = post.numAwards || 0; const canSeeAnalytics = canViewPostAnalytics({ user, post }); + // In the modal there is no app header, so pin to the very top; on the post + // page the bar must sit below the fixed laptop header (4rem). `onClose` is + // only provided by the post modal, so it doubles as the surface flag. + const stickyTopClassName = onClose ? 'top-0' : 'top-0 laptop:top-16'; const onToggleUpvote = async () => { if (post?.userState?.vote === UserVote.None) { @@ -140,7 +144,8 @@ export const PostDiscoveryActionBar = ({
From eccf62cbd7ac106194cbb2d47bec5d6806eb19f5 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 9 Jun 2026 10:27:45 +0300 Subject: [PATCH 086/111] feat(discovery): show Read post button for shared posts The header hides its built-in read button for Share-type posts, and the shared card now reads like a regular post, so add a top-right "Read post" button (linking to the actual article via getReadArticleHref) for shared posts to navigate to the original. Co-Authored-By: Claude Opus 4.8 --- .../post/discovery/PostFocusCard.tsx | 40 ++++++++++++++----- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/packages/shared/src/components/post/discovery/PostFocusCard.tsx b/packages/shared/src/components/post/discovery/PostFocusCard.tsx index 80e71024539..6d77a2d40bc 100644 --- a/packages/shared/src/components/post/discovery/PostFocusCard.tsx +++ b/packages/shared/src/components/post/discovery/PostFocusCard.tsx @@ -4,6 +4,7 @@ import React, { useRef } from 'react'; import type { Post } from '../../../graphql/posts'; import { getReadArticleHref, + getReadPostButtonText, isVideoPost, PostType, } from '../../../graphql/posts'; @@ -20,7 +21,7 @@ import Markdown from '../../Markdown'; import { ContentEmbeds } from '../../contentEmbeds/ContentEmbeds'; import { LazyImage } from '../../LazyImage'; import { cloudinaryPostImageCoverPlaceholder } from '../../../lib/image'; -import { ButtonSize, ButtonVariant } from '../../buttons/Button'; +import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; import { PostHeaderActions } from '../PostHeaderActions'; import { PostUpvotesCommentsCount } from '../PostUpvotesCommentsCount'; import { PostTagList } from '../tags/PostTagList'; @@ -177,16 +178,33 @@ export const PostFocusCard = ({ /> ) )} - + {isShared ? ( + readHref && ( + + ) + ) : ( + + )}
From a846b5d866f4d0f5889c5b59e9bcdffa5495bd7b Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 9 Jun 2026 10:31:11 +0300 Subject: [PATCH 087/111] style(discovery): match comment-section header, drop the handle Remove the @handle from the author header (transformUsername) so it mirrors the comment author: avatar + name + reputation. Also drop the handle from the compact source strip for the same guideline. Co-Authored-By: Claude Opus 4.8 --- .../shared/src/components/post/discovery/PostFocusCard.tsx | 1 + packages/shared/src/components/post/reader/SourceStrip.tsx | 6 ++---- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/components/post/discovery/PostFocusCard.tsx b/packages/shared/src/components/post/discovery/PostFocusCard.tsx index 6d77a2d40bc..7f6c7f6bdd5 100644 --- a/packages/shared/src/components/post/discovery/PostFocusCard.tsx +++ b/packages/shared/src/components/post/discovery/PostFocusCard.tsx @@ -152,6 +152,7 @@ export const PostFocusCard = ({ user={author as unknown as UserShortProfile} imageSize={ProfileImageSize.Large} showDescription={false} + transformUsername={() => null} className={{ container: 'min-w-0 !p-0 hover:bg-transparent', textWrapper: 'min-w-0', diff --git a/packages/shared/src/components/post/reader/SourceStrip.tsx b/packages/shared/src/components/post/reader/SourceStrip.tsx index 9acf802c094..36f4fc45440 100644 --- a/packages/shared/src/components/post/reader/SourceStrip.tsx +++ b/packages/shared/src/components/post/reader/SourceStrip.tsx @@ -99,12 +99,10 @@ export function SourceStrip({ {source.name} - {sourceHandle && ( + {!compact && sourceHandle && ( From 4a6b3ebff37bed075ebf07a844722b7b56e556ed Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 9 Jun 2026 10:36:15 +0300 Subject: [PATCH 088/111] fix(discovery): smoother composer trigger + tighter top gap Make the comment composer trigger a compact single row (avatar + placeholder + Comment) instead of a tall two-row box, so swapping to the real input is a much smaller jump. Drop the extra pt-2 above the discussion panel so the gap matches the rest of the card. Co-Authored-By: Claude Opus 4.8 --- .../post/discovery/PostDiscussionPanel.tsx | 64 +++++++++---------- .../post/discovery/PostFocusCard.tsx | 1 - 2 files changed, 30 insertions(+), 35 deletions(-) diff --git a/packages/shared/src/components/post/discovery/PostDiscussionPanel.tsx b/packages/shared/src/components/post/discovery/PostDiscussionPanel.tsx index 0b36e3c5c31..b84e507e68f 100644 --- a/packages/shared/src/components/post/discovery/PostDiscussionPanel.tsx +++ b/packages/shared/src/components/post/discovery/PostDiscussionPanel.tsx @@ -125,41 +125,37 @@ export const PostDiscussionPanel = ({ type="button" aria-label="Add a comment" onClick={() => onCommentClick(Origin.StartDiscussion)} - className="group flex min-h-24 w-full flex-col justify-between gap-3 rounded-16 border border-border-subtlest-tertiary bg-surface-float p-3 text-left transition-colors hover:border-border-subtlest-primary hover:bg-surface-hover" + className="group flex w-full items-center gap-3 rounded-16 border border-border-subtlest-tertiary bg-surface-float p-3 text-left transition-colors hover:border-border-subtlest-primary hover:bg-surface-hover" > -
- {triggerUser ? ( - - ) : ( - Placeholder image for anonymous user - )} - - Share your thoughts — what stood out, or what would you add? - -
-
- - Comment - -
+ {triggerUser ? ( + + ) : ( + Placeholder image for anonymous user + )} + + Share your thoughts… + + + Comment + ); diff --git a/packages/shared/src/components/post/discovery/PostFocusCard.tsx b/packages/shared/src/components/post/discovery/PostFocusCard.tsx index 7f6c7f6bdd5..f64078f99c7 100644 --- a/packages/shared/src/components/post/discovery/PostFocusCard.tsx +++ b/packages/shared/src/components/post/discovery/PostFocusCard.tsx @@ -359,7 +359,6 @@ export const PostFocusCard = ({
{ From c41bc5db22caec71e91049d5c383802abc0328bc Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 9 Jun 2026 10:37:38 +0300 Subject: [PATCH 089/111] fix(discovery): drop action bar top border when stuck Avoid the double-border look against the header by keeping only the bottom border once the sticky action bar is pinned; the top border shows only while it's in flow. Co-Authored-By: Claude Opus 4.8 --- .../src/components/post/discovery/PostDiscoveryActionBar.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/components/post/discovery/PostDiscoveryActionBar.tsx b/packages/shared/src/components/post/discovery/PostDiscoveryActionBar.tsx index 160887fc82b..0166e39d47e 100644 --- a/packages/shared/src/components/post/discovery/PostDiscoveryActionBar.tsx +++ b/packages/shared/src/components/post/discovery/PostDiscoveryActionBar.tsx @@ -144,7 +144,10 @@ export const PostDiscoveryActionBar = ({
Date: Tue, 9 Jun 2026 11:34:22 +0300 Subject: [PATCH 090/111] style(discovery): match source strip avatar/name size to comments The compact source strip used a 28px avatar + footnote name; bump it to a 40px avatar + callout name (gap-3) so the source header matches the comment author and the post author header. Co-Authored-By: Claude Opus 4.8 --- packages/shared/src/components/post/reader/SourceStrip.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/components/post/reader/SourceStrip.tsx b/packages/shared/src/components/post/reader/SourceStrip.tsx index 36f4fc45440..cbc4ceb57f3 100644 --- a/packages/shared/src/components/post/reader/SourceStrip.tsx +++ b/packages/shared/src/components/post/reader/SourceStrip.tsx @@ -63,7 +63,7 @@ export function SourceStrip({
@@ -77,7 +77,7 @@ export function SourceStrip({ alt="" className={classNames( 'rounded-full', - compact ? 'size-7' : 'size-8', + compact ? 'size-10' : 'size-8', )} loading="lazy" aria-hidden @@ -89,7 +89,7 @@ export function SourceStrip({ Date: Tue, 9 Jun 2026 12:21:12 +0300 Subject: [PATCH 091/111] feat(discovery): fade/slide the composer in when it opens Add a composer-in animation (reusing the fade-slide-up keyframe, no delay) and apply it when the comment composer swaps from the trigger to the typeable input, so the change reads as a smooth fade + slide instead of an instant pop. Co-Authored-By: Claude Opus 4.8 --- .../post/discovery/PostDiscussionPanel.tsx | 20 ++++++++++--------- packages/shared/tailwind.config.ts | 1 + 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/shared/src/components/post/discovery/PostDiscussionPanel.tsx b/packages/shared/src/components/post/discovery/PostDiscussionPanel.tsx index b84e507e68f..0d190261456 100644 --- a/packages/shared/src/components/post/discovery/PostDiscussionPanel.tsx +++ b/packages/shared/src/components/post/discovery/PostDiscussionPanel.tsx @@ -165,15 +165,17 @@ export const PostDiscussionPanel = ({ aria-label="Discussion" className={classNames('flex min-h-0 min-w-0 flex-col gap-2', className)} > - } - shouldHandleCommentQuery - onComposerOpenChange={setIsComposerOpen} - size={ProfileImageSize.Medium} - CommentInputOrModal={CommentInputOrModal} - renderTrigger={renderComposerTrigger} - /> +
+ } + shouldHandleCommentQuery + onComposerOpenChange={setIsComposerOpen} + size={ProfileImageSize.Medium} + CommentInputOrModal={CommentInputOrModal} + renderTrigger={renderComposerTrigger} + /> +
{showSortHeader && ( diff --git a/packages/shared/tailwind.config.ts b/packages/shared/tailwind.config.ts index 73d83ce3ae6..bc346985b3e 100644 --- a/packages/shared/tailwind.config.ts +++ b/packages/shared/tailwind.config.ts @@ -302,6 +302,7 @@ export default { 'scale-down-pulse': 'scale-down-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite', 'fade-slide-up': 'fade-slide-up 0.5s ease-out 1s both', + 'composer-in': 'fade-slide-up 0.2s ease-out both', 'highlight-fade': 'highlight-fade 2.5s ease-out forwards', 'reaction-burst': 'reaction-burst 720ms cubic-bezier(0.2, 0.7, 0.4, 1) forwards', From 7458e23565c46abd646645904afedc3ef88b4216 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 9 Jun 2026 12:23:34 +0300 Subject: [PATCH 092/111] fix(discovery): left-align sort strip, outline award icon by default - Drop the px-1 indent on the Sort strip so it aligns flush-left with the rest of the content - Swap the award icon variants so the default (not-awarded) state is the outline medal and the awarded state is filled Co-Authored-By: Claude Opus 4.8 --- .../src/components/post/discovery/PostDiscoveryActionBar.tsx | 4 ++-- .../src/components/post/discovery/PostDiscussionPanel.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/components/post/discovery/PostDiscoveryActionBar.tsx b/packages/shared/src/components/post/discovery/PostDiscoveryActionBar.tsx index 0166e39d47e..f333e412d5e 100644 --- a/packages/shared/src/components/post/discovery/PostDiscoveryActionBar.tsx +++ b/packages/shared/src/components/post/discovery/PostDiscoveryActionBar.tsx @@ -200,8 +200,8 @@ export const PostDiscoveryActionBar = ({ id="award-post-btn" label="Award" color={ButtonColor.Cabbage} - icon={} - iconPressed={} + icon={} + iconPressed={} count={awards} pressed={isAwarded} onClick={onGiveAward} diff --git a/packages/shared/src/components/post/discovery/PostDiscussionPanel.tsx b/packages/shared/src/components/post/discovery/PostDiscussionPanel.tsx index 0d190261456..88f568f6602 100644 --- a/packages/shared/src/components/post/discovery/PostDiscussionPanel.tsx +++ b/packages/shared/src/components/post/discovery/PostDiscussionPanel.tsx @@ -178,7 +178,7 @@ export const PostDiscussionPanel = ({
{showSortHeader && ( - + Date: Tue, 9 Jun 2026 12:27:17 +0300 Subject: [PATCH 093/111] fix(discovery): remove double gap around the action bar Drop the action bar's my-2; the content column already spaces siblings with gap-4, so the extra margin stacked into a double gap between the title and the bar. Co-Authored-By: Claude Opus 4.8 --- .../src/components/post/discovery/PostDiscoveryActionBar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/components/post/discovery/PostDiscoveryActionBar.tsx b/packages/shared/src/components/post/discovery/PostDiscoveryActionBar.tsx index f333e412d5e..930d8017f8f 100644 --- a/packages/shared/src/components/post/discovery/PostDiscoveryActionBar.tsx +++ b/packages/shared/src/components/post/discovery/PostDiscoveryActionBar.tsx @@ -144,7 +144,7 @@ export const PostDiscoveryActionBar = ({
Date: Tue, 9 Jun 2026 12:47:39 +0300 Subject: [PATCH 094/111] fix(discovery): put ad title on the logo row, shrink height Fall back the inline ad title to the description so a description-only ad (no company/tagline) shows its text on the logo row instead of a third body row. Drop the now-empty body, clamp the title to two lines, and tighten padding (p-4 -> p-3) to reduce the component height. Co-Authored-By: Claude Opus 4.8 --- .../components/post/PostSidebarAdWidget.tsx | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/shared/src/components/post/PostSidebarAdWidget.tsx b/packages/shared/src/components/post/PostSidebarAdWidget.tsx index 81ea903d90c..20501eda7ce 100644 --- a/packages/shared/src/components/post/PostSidebarAdWidget.tsx +++ b/packages/shared/src/components/post/PostSidebarAdWidget.tsx @@ -104,17 +104,19 @@ export function PostSidebarAdWidget({ if (variant === 'inline') { const tagLine = ad.tagLine?.trim(); - // Always surface a title on the icon line: the company if present, - // otherwise the ad's tagline. The tagline only repeats in the body when - // the company is the title. - const inlineTitle = company || tagLine; + const description = ad.description?.trim(); + // Always surface a title on the icon line: the company, else the + // tagline, else the description. Whatever is left over renders in the + // body — so a description-only ad has no extra body row. + const inlineTitle = company || tagLine || description; const inlineBodyTagLine = company ? tagLine : undefined; - const inlineHasBody = !!ad.description || !!inlineBodyTagLine; + const inlineBodyDescription = company || tagLine ? description : undefined; + const inlineHasBody = !!inlineBodyTagLine || !!inlineBodyDescription; return (
@@ -137,7 +139,7 @@ export function PostSidebarAdWidget({ tag={TypographyTag.P} type={TypographyType.Body} color={TypographyColor.Primary} - className="min-w-0 flex-1 truncate" + className="line-clamp-2 min-w-0 flex-1" bold > {inlineTitle} @@ -171,8 +173,8 @@ export function PostSidebarAdWidget({ className="relative z-1 whitespace-pre-line" > {inlineBodyTagLine && {inlineBodyTagLine}} - {inlineBodyTagLine && ad.description ? ' ' : ''} - {ad.description} + {inlineBodyTagLine && inlineBodyDescription ? ' ' : ''} + {inlineBodyDescription} )} From b0b202af92677e779c646d138dbae0549f36c439 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 9 Jun 2026 12:57:00 +0300 Subject: [PATCH 095/111] style(discovery): smaller, medium-weight inline ad title Drop the ad title from body+bold to callout + font-medium for a lighter, slightly smaller headline. Co-Authored-By: Claude Opus 4.8 --- packages/shared/src/components/post/PostSidebarAdWidget.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/components/post/PostSidebarAdWidget.tsx b/packages/shared/src/components/post/PostSidebarAdWidget.tsx index 20501eda7ce..dc28be7d7a7 100644 --- a/packages/shared/src/components/post/PostSidebarAdWidget.tsx +++ b/packages/shared/src/components/post/PostSidebarAdWidget.tsx @@ -137,10 +137,9 @@ export function PostSidebarAdWidget({ {inlineTitle && ( {inlineTitle} From 273f1a6bfc32ad8c55cb999a482fdbec4fa9801d Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 9 Jun 2026 13:02:27 +0300 Subject: [PATCH 096/111] feat(discovery): 70% video preview that expands to full width on play Show a click-to-play thumbnail at 70% width by default; on click it animates (max-width transition) to the full content width and loads the autoplaying YouTube embed. Add an optional autoplay prop to YoutubeVideo. Co-Authored-By: Claude Opus 4.8 --- .../post/discovery/PostFocusCard.tsx | 50 +++++++++++++++---- .../src/components/video/YoutubeVideo.tsx | 7 ++- 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/packages/shared/src/components/post/discovery/PostFocusCard.tsx b/packages/shared/src/components/post/discovery/PostFocusCard.tsx index f64078f99c7..ee1142c6a1a 100644 --- a/packages/shared/src/components/post/discovery/PostFocusCard.tsx +++ b/packages/shared/src/components/post/discovery/PostFocusCard.tsx @@ -1,6 +1,7 @@ import dynamic from 'next/dynamic'; import type { ComponentProps, ReactElement } from 'react'; -import React, { useRef } from 'react'; +import React, { useRef, useState } from 'react'; +import classNames from 'classnames'; import type { Post } from '../../../graphql/posts'; import { getReadArticleHref, @@ -17,6 +18,8 @@ import { useUpvoteQuery } from '../../../hooks/useUpvoteQuery'; import { useReaderInstallPromptGate } from '../../../hooks/useReaderInstallPromptGate'; import PostMetadata from '../../cards/common/PostMetadata'; import YoutubeVideo from '../../video/YoutubeVideo'; +import { PlayIcon } from '../../icons'; +import { IconSize } from '../../Icon'; import Markdown from '../../Markdown'; import { ContentEmbeds } from '../../contentEmbeds/ContentEmbeds'; import { LazyImage } from '../../LazyImage'; @@ -121,6 +124,7 @@ export const PostFocusCard = ({ const showCodeSnippets = useFeature(feature.showCodeSnippets); const focusCommentRef = useRef<() => void>(() => {}); const discussionRef = useRef(null); + const [isVideoPlaying, setIsVideoPlaying] = useState(false); const readHref = getReadArticleHref(post); const handleImageClick = (event: React.MouseEvent) => { if (onReaderInstallGateClick(event)) { @@ -312,14 +316,42 @@ export const PostFocusCard = ({ /> {isVideoType && ( -
- +
+ {isVideoPlaying ? ( + + ) : ( + + )}
)} diff --git a/packages/shared/src/components/video/YoutubeVideo.tsx b/packages/shared/src/components/video/YoutubeVideo.tsx index b8ddb8e312c..98a992b7645 100644 --- a/packages/shared/src/components/video/YoutubeVideo.tsx +++ b/packages/shared/src/components/video/YoutubeVideo.tsx @@ -12,6 +12,7 @@ import { webappUrl } from '../../lib/constants'; interface YoutubeVideoProps extends HTMLAttributes { videoId: string; className?: string; + autoplay?: boolean; placeholderProps: Pick< YoutubeVideoWithoutConsentProps, 'post' | 'onWatchVideo' @@ -21,6 +22,7 @@ interface YoutubeVideoProps extends HTMLAttributes { const YoutubeVideo = ({ videoId, className, + autoplay = false, placeholderProps, ...props }: YoutubeVideoProps): ReactElement => { @@ -47,11 +49,12 @@ const YoutubeVideo = ({ ); } + const autoplayParam = autoplay ? '?autoplay=1' : ''; // Extension pages don't send Referer header, causing YouTube Error 153 // Use webapp as intermediate page which sends proper Referer const embedSrc = isExtension - ? `${webappUrl}embed/youtube/${videoId}` - : `https://www.youtube-nocookie.com/embed/${videoId}`; + ? `${webappUrl}embed/youtube/${videoId}${autoplayParam}` + : `https://www.youtube-nocookie.com/embed/${videoId}${autoplayParam}`; return ( From 2720713c3eb4cf2a2a1f8fa6cd83ff0b82da9904 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 9 Jun 2026 14:26:11 +0300 Subject: [PATCH 097/111] style(discovery): reduce top padding on the post page Trim the page wrapper's top padding (the focus card already adds its own internal top spacing), keeping the bottom padding intact. Co-Authored-By: Claude Opus 4.8 --- .../src/components/post/discovery/PostDiscoveryLayout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/components/post/discovery/PostDiscoveryLayout.tsx b/packages/shared/src/components/post/discovery/PostDiscoveryLayout.tsx index 7d803700dd9..1118b98eec2 100644 --- a/packages/shared/src/components/post/discovery/PostDiscoveryLayout.tsx +++ b/packages/shared/src/components/post/discovery/PostDiscoveryLayout.tsx @@ -73,7 +73,7 @@ export const PostDiscoveryLayout = ({ return (
-
+
Date: Tue, 9 Jun 2026 14:35:45 +0300 Subject: [PATCH 098/111] fix(discovery): muted autoplay on click + align inline ad attribution - Play the YouTube video on the first click: cross-origin iframes block unmuted autoplay even with a parent gesture, so add mute=1 (YouTube shows its native unmute) to avoid a second press on a still frame. - Inline ad: align the "Promoted by" / "Advertise here" row with the title, left-align both with a dot separator, and trim bottom padding. Co-Authored-By: Claude Opus 4.8 --- .../shared/src/components/post/PostSidebarAdWidget.tsx | 7 +++++-- packages/shared/src/components/video/YoutubeVideo.tsx | 5 ++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/components/post/PostSidebarAdWidget.tsx b/packages/shared/src/components/post/PostSidebarAdWidget.tsx index dc28be7d7a7..c1b6cbcf589 100644 --- a/packages/shared/src/components/post/PostSidebarAdWidget.tsx +++ b/packages/shared/src/components/post/PostSidebarAdWidget.tsx @@ -116,7 +116,7 @@ export function PostSidebarAdWidget({ return (
@@ -157,8 +157,11 @@ export function PostSidebarAdWidget({ Visit
-
+
+ + · + Date: Tue, 9 Jun 2026 14:42:31 +0300 Subject: [PATCH 099/111] feat(discovery): cap video TL;DR to four lines with inline Show more For video posts, clamp the TL;DR summary to four lines. When it overflows, a blue "Show more" link sits at the end of the last visible line (with a fade into the clamped text) and expands the summary to full length on click. Co-Authored-By: Claude Opus 4.8 --- .../post/discovery/PostFocusCard.tsx | 65 ++++++++++++++++++- 1 file changed, 62 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/components/post/discovery/PostFocusCard.tsx b/packages/shared/src/components/post/discovery/PostFocusCard.tsx index ee1142c6a1a..7be26d6f07e 100644 --- a/packages/shared/src/components/post/discovery/PostFocusCard.tsx +++ b/packages/shared/src/components/post/discovery/PostFocusCard.tsx @@ -1,6 +1,6 @@ import dynamic from 'next/dynamic'; import type { ComponentProps, ReactElement } from 'react'; -import React, { useRef, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import classNames from 'classnames'; import type { Post } from '../../../graphql/posts'; import { @@ -25,6 +25,7 @@ import { ContentEmbeds } from '../../contentEmbeds/ContentEmbeds'; import { LazyImage } from '../../LazyImage'; import { cloudinaryPostImageCoverPlaceholder } from '../../../lib/image'; import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; +import { ClickableText } from '../../buttons/ClickableText'; import { PostHeaderActions } from '../PostHeaderActions'; import { PostUpvotesCommentsCount } from '../PostUpvotesCommentsCount'; import { PostTagList } from '../tags/PostTagList'; @@ -88,6 +89,61 @@ const ArticleLink = ({ ); }; +/** + * Video TL;DR capped to four lines. When the text overflows, a blue "Show + * more" link sits at the end of the last visible line (with a fade so it + * blends into the clamped text) and expands the summary to full length. + */ +const VideoSummary = ({ summary }: { summary: string }): ReactElement => { + const ref = useRef(null); + const [isExpanded, setIsExpanded] = useState(false); + const [isClamped, setIsClamped] = useState(false); + + useEffect(() => { + const el = ref.current; + if (!el) { + return undefined; + } + const measure = () => setIsClamped(el.scrollHeight - el.clientHeight > 1); + measure(); + if (typeof ResizeObserver === 'undefined') { + return undefined; + } + const observer = new ResizeObserver(measure); + observer.observe(el); + return () => observer.disconnect(); + }, [summary]); + + return ( +
+

+ {summary} +

+ {isClamped && !isExpanded && ( + + + setIsExpanded(true)} + > + Show more + + + )} +
+ ); +}; + export const PostFocusCard = ({ post, origin, @@ -361,14 +417,17 @@ export const PostFocusCard = ({ ) : ( - article.summary && ( + article.summary && + (isVideoType ? ( + + ) : (

{article.summary}

- ) + )) )} From 0275ec6f8effb9a18ea18e07db90bc2b77c3e6b5 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 9 Jun 2026 21:00:35 +0300 Subject: [PATCH 100/111] style(ad): vertically center inline ad strip, tighten title spacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group the title and the "Promoted by · Advertise here" line into a column beside the favicon so the icon and Visit button center against the text block, and reduce the gap between the title and the line below it. Co-Authored-By: Claude Opus 4.8 --- .../components/post/PostSidebarAdWidget.tsx | 42 ++++++++++--------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/packages/shared/src/components/post/PostSidebarAdWidget.tsx b/packages/shared/src/components/post/PostSidebarAdWidget.tsx index c1b6cbcf589..0f563e85b49 100644 --- a/packages/shared/src/components/post/PostSidebarAdWidget.tsx +++ b/packages/shared/src/components/post/PostSidebarAdWidget.tsx @@ -134,16 +134,28 @@ export function PostSidebarAdWidget({ alt={ad.source} className="size-10 shrink-0 rounded-full object-cover" /> - {inlineTitle && ( - - {inlineTitle} - - )} +
+ {inlineTitle && ( + + {inlineTitle} + + )} +
+ + + · + + +
+
-
- - - · - - -
{inlineHasBody && ( Date: Wed, 10 Jun 2026 09:44:26 +0300 Subject: [PATCH 101/111] fix(ad): balance inline ad padding by removing AdPixel flex gap The trailing zero-size AdPixel was a flex child, so gap-2 reserved extra space below the content row, making the bottom padding look larger. Pull it out of flex flow (absolute) and use equal padding on all sides. Co-Authored-By: Claude Opus 4.8 --- packages/shared/src/components/post/PostSidebarAdWidget.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/components/post/PostSidebarAdWidget.tsx b/packages/shared/src/components/post/PostSidebarAdWidget.tsx index 0f563e85b49..106434e382d 100644 --- a/packages/shared/src/components/post/PostSidebarAdWidget.tsx +++ b/packages/shared/src/components/post/PostSidebarAdWidget.tsx @@ -116,7 +116,7 @@ export function PostSidebarAdWidget({ return (
@@ -181,7 +181,9 @@ export function PostSidebarAdWidget({ {inlineBodyDescription} )} - + + +
); } From 173d2fa4813ab7d682577fb8f70a0bcca12d3cfd Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 10 Jun 2026 09:48:17 +0300 Subject: [PATCH 102/111] style(ad): add hover state to inline ad card Highlight the inline ad on hover (border + surface) to signal it is clickable, matching the discussion composer trigger pattern. Co-Authored-By: Claude Opus 4.8 --- packages/shared/src/components/post/PostSidebarAdWidget.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/components/post/PostSidebarAdWidget.tsx b/packages/shared/src/components/post/PostSidebarAdWidget.tsx index 106434e382d..6cfc41f7ff1 100644 --- a/packages/shared/src/components/post/PostSidebarAdWidget.tsx +++ b/packages/shared/src/components/post/PostSidebarAdWidget.tsx @@ -116,7 +116,7 @@ export function PostSidebarAdWidget({ return (
From 1643fc3bc4b019624d794321ddb18a1035da75fa Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 10 Jun 2026 09:53:42 +0300 Subject: [PATCH 103/111] fix(discovery): remove doubled page padding, tighten action bar gap On the post page the layout wrapper added the same horizontal padding the focus card already applies, doubling it (huge side padding on mobile and squeezing the content into overflow). Drop the wrapper padding (the focus card owns it, matching the modal) and give the signup hero its own. Also reduce the action bar's inter-group gap so its buttons fit on narrow widths. Co-Authored-By: Claude Opus 4.8 --- .../components/post/discovery/PostDiscoveryActionBar.tsx | 2 +- .../src/components/post/discovery/PostDiscoveryLayout.tsx | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/components/post/discovery/PostDiscoveryActionBar.tsx b/packages/shared/src/components/post/discovery/PostDiscoveryActionBar.tsx index 930d8017f8f..34543dd2f11 100644 --- a/packages/shared/src/components/post/discovery/PostDiscoveryActionBar.tsx +++ b/packages/shared/src/components/post/discovery/PostDiscoveryActionBar.tsx @@ -144,7 +144,7 @@ export const PostDiscoveryActionBar = ({
-
+
+ {/* The focus card owns its own horizontal padding (shared with the + post modal), so this wrapper must not add more or it doubles up. */}
{!user && ( -
+
)} From 8688f5cfda27ba6e11ec7bc688f1a38ac6162d64 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 10 Jun 2026 10:01:33 +0300 Subject: [PATCH 104/111] fix(discovery): match production mobile side padding on focus card The focus card used px-4 (16px) on mobile vs production post content's px-2 (8px). Halve the small-resolution horizontal padding to px-2 so the page/modal align with the rest of the app; keep the larger breakpoints. Co-Authored-By: Claude Opus 4.8 --- packages/shared/src/components/post/discovery/PostFocusCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/components/post/discovery/PostFocusCard.tsx b/packages/shared/src/components/post/discovery/PostFocusCard.tsx index 7be26d6f07e..2d1726bdb12 100644 --- a/packages/shared/src/components/post/discovery/PostFocusCard.tsx +++ b/packages/shared/src/components/post/discovery/PostFocusCard.tsx @@ -203,7 +203,7 @@ export const PostFocusCard = ({ className="flex w-full flex-col rounded-24 bg-background-default" data-testid="post-focus-card" > -
+
{author ? ( From 83c4bcfc4cd4f677ca6fda434670084b38f62f08 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 10 Jun 2026 10:50:35 +0300 Subject: [PATCH 105/111] fix(discovery): match production reader side padding (px-4) on mobile Restore px-4 mobile padding to align with the production post-page reader (PostExperienceLayout/EngagementRail/ReaderFallback all use px-4 tablet:px-6 laptop:px-8). The earlier "huge padding" was the layout doubling the card padding, already removed; px-2 overcorrected. Co-Authored-By: Claude Opus 4.8 --- packages/shared/src/components/post/discovery/PostFocusCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/components/post/discovery/PostFocusCard.tsx b/packages/shared/src/components/post/discovery/PostFocusCard.tsx index 2d1726bdb12..7be26d6f07e 100644 --- a/packages/shared/src/components/post/discovery/PostFocusCard.tsx +++ b/packages/shared/src/components/post/discovery/PostFocusCard.tsx @@ -203,7 +203,7 @@ export const PostFocusCard = ({ className="flex w-full flex-col rounded-24 bg-background-default" data-testid="post-focus-card" > -
+
{author ? ( From 68b1d0e5ebab26bd276b16a72a0b01ff6ba2f983 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 10 Jun 2026 10:53:33 +0300 Subject: [PATCH 106/111] style(ad): soften inline ad hover state Drop the border-brightening on hover so the border stays subtle; keep a gentle background fill as the hover affordance instead. Co-Authored-By: Claude Opus 4.8 --- packages/shared/src/components/post/PostSidebarAdWidget.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/components/post/PostSidebarAdWidget.tsx b/packages/shared/src/components/post/PostSidebarAdWidget.tsx index 6cfc41f7ff1..a0aa46bb294 100644 --- a/packages/shared/src/components/post/PostSidebarAdWidget.tsx +++ b/packages/shared/src/components/post/PostSidebarAdWidget.tsx @@ -116,7 +116,7 @@ export function PostSidebarAdWidget({ return (
From 1a8bc219e8cc590422ce31f64f16f0bea3f44cb8 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 10 Jun 2026 11:08:14 +0300 Subject: [PATCH 107/111] fix(discovery): unify Read post button + fix mobile title/image layout - Use one Read post button for every post type: icon + label via the shared helpers, Primary variant on all resolutions (keeps the background on mobile). Previously shared posts used an icon-less button while other types went through PostHeaderActions, which dropped the background to Tertiary on mobile. - Stop the title being squeezed on mobile: smaller title font (typo-title2 -> typo-large-title from tablet) and a smaller cover thumbnail (w-20 -> w-40 from tablet). Co-Authored-By: Claude Opus 4.8 --- .../post/discovery/PostFocusCard.tsx | 47 +++++++------------ 1 file changed, 18 insertions(+), 29 deletions(-) diff --git a/packages/shared/src/components/post/discovery/PostFocusCard.tsx b/packages/shared/src/components/post/discovery/PostFocusCard.tsx index 7be26d6f07e..e2c566c1f66 100644 --- a/packages/shared/src/components/post/discovery/PostFocusCard.tsx +++ b/packages/shared/src/components/post/discovery/PostFocusCard.tsx @@ -6,6 +6,7 @@ import type { Post } from '../../../graphql/posts'; import { getReadArticleHref, getReadPostButtonText, + isInternalReadType, isVideoPost, PostType, } from '../../../graphql/posts'; @@ -26,7 +27,7 @@ import { LazyImage } from '../../LazyImage'; import { cloudinaryPostImageCoverPlaceholder } from '../../../lib/image'; import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; import { ClickableText } from '../../buttons/ClickableText'; -import { PostHeaderActions } from '../PostHeaderActions'; +import { getReadPostButtonIcon } from '../../cards/common/ReadArticleButton'; import { PostUpvotesCommentsCount } from '../PostUpvotesCommentsCount'; import { PostTagList } from '../tags/PostTagList'; import { TruncateText } from '../../utilities'; @@ -239,32 +240,20 @@ export const PostFocusCard = ({ /> ) )} - {isShared ? ( - readHref && ( - - ) - ) : ( - + {readHref && !isInternalReadType(post) && ( + )}
@@ -312,14 +301,14 @@ export const PostFocusCard = ({ )}

{title}

{!isVideoType && article.image && ( Date: Wed, 10 Jun 2026 11:20:24 +0300 Subject: [PATCH 108/111] style(discovery): square cover image, stacked below title on mobile Replace the small wide thumbnail with a square cover (ratio 1:1). On mobile the title spans full width with the square image stacked below (size-32); on tablet+ the square sits beside the title (size-40). Fixes the cramped title and gives the image real presence on both layouts. Co-Authored-By: Claude Opus 4.8 --- .../src/components/post/discovery/PostFocusCard.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/components/post/discovery/PostFocusCard.tsx b/packages/shared/src/components/post/discovery/PostFocusCard.tsx index e2c566c1f66..fb1974da754 100644 --- a/packages/shared/src/components/post/discovery/PostFocusCard.tsx +++ b/packages/shared/src/components/post/discovery/PostFocusCard.tsx @@ -299,16 +299,16 @@ export const PostFocusCard = ({ {!isShared && isCollection && (

Collection

)} -
+

{title}

{!isVideoType && article.image && (
)} From d711237da5e19fb74dc9fe6ebb49a41bd85ce981 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 10 Jun 2026 12:24:24 +0300 Subject: [PATCH 109/111] style(discovery): square cover only below laptop, wide ratio on desktop Scope the square crop to mobile/tablet via responsive aspect classes (aspect-square -> laptop:aspect-[25/13], the original 52% ratio), since LazyImage's ratio prop is inline padding and can't respond to breakpoints. Desktop keeps the previous wide thumbnail beside the title. Co-Authored-By: Claude Opus 4.8 --- .../shared/src/components/post/discovery/PostFocusCard.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/components/post/discovery/PostFocusCard.tsx b/packages/shared/src/components/post/discovery/PostFocusCard.tsx index fb1974da754..8c27a2858d4 100644 --- a/packages/shared/src/components/post/discovery/PostFocusCard.tsx +++ b/packages/shared/src/components/post/discovery/PostFocusCard.tsx @@ -308,7 +308,7 @@ export const PostFocusCard = ({ {!isVideoType && article.image && ( 25/13). + className="aspect-square w-full laptop:aspect-[25/13]" fallbackSrc={cloudinaryPostImageCoverPlaceholder} fetchPriority="high" imgAlt="Post cover image" imgSrc={article.image} - ratio="100%" /> )} From 25a1a81ff175d02452b6865d5b35bd5885580997 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 10 Jun 2026 13:18:25 +0300 Subject: [PATCH 110/111] feat(discovery): morphing engagement bar (Liquid Glass-style) Redesign the focus-card action bar to concentrate-then-expand, inspired by the iOS 26 Liquid Glass toolbar: - Default (collapsed): show only the upvote (always, so the bar reads as actionable) plus any action that has a metric (comment/award when their count > 0). Everything else is hidden. - On hover/focus of the bar it morphs open to the full action set; the upvote stays anchored while siblings grow in around it. Touch devices, which have no hover, show everything by default. - The reveal is CSS-driven (grid 0fr->1fr width + opacity morph, the same trick as Section.tsx) so there is no hover re-render and no flicker. - Frosted glass surface (translucent bg + backdrop blur). - Fix the pin-time layout shift: keep the top border always present and toggle its color instead of its presence, so height never changes. Co-Authored-By: Claude Opus 4.8 --- .../post/discovery/PostDiscoveryActionBar.tsx | 234 +++++++++++------- 1 file changed, 147 insertions(+), 87 deletions(-) diff --git a/packages/shared/src/components/post/discovery/PostDiscoveryActionBar.tsx b/packages/shared/src/components/post/discovery/PostDiscoveryActionBar.tsx index 34543dd2f11..51310d95fd9 100644 --- a/packages/shared/src/components/post/discovery/PostDiscoveryActionBar.tsx +++ b/packages/shared/src/components/post/discovery/PostDiscoveryActionBar.tsx @@ -1,4 +1,4 @@ -import type { ReactElement } from 'react'; +import type { ReactElement, ReactNode } from 'react'; import React, { useEffect, useRef, useState } from 'react'; import classNames from 'classnames'; import type { Post } from '../../../graphql/posts'; @@ -44,11 +44,48 @@ interface PostDiscoveryActionBarProps { className?: string; } +/** + * One control in the morphing engagement bar. `pinned` slots are always + * visible; the rest collapse to zero width on pointer devices and morph open + * (width + opacity) when the bar is hovered or focused. Touch devices, which + * have no hover, reveal everything by default. + * + * The width morph uses the grid `0fr -> 1fr` track trick (the inner element is + * `min-w-0 overflow-hidden`) so each control animates to its natural content + * width — the Liquid Glass "concentrate, then expand on intent" behavior from + * the iOS 26 toolbar, where the source control stays anchored as siblings grow + * in around it. + */ +const MorphSlot = ({ + pinned = false, + children, +}: { + pinned?: boolean; + children: ReactNode; +}): ReactElement => ( +
+
+ {children} +
+
+); + /** * Engagement bar for the discovery focus card, built on the CardAction * primitives (PR #6064 guideline): each action's count lives inside the click * target so the icon or number performs the action. Sticks to the top while * scrolling; the modal X appears only once the bar is pinned. + * + * Concentrated by default to the upvote (always) plus any action that has a + * metric, it morphs open to the full action set on hover/focus — see + * {@link MorphSlot}. */ export const PostDiscoveryActionBar = ({ post, @@ -144,110 +181,133 @@ export const PostDiscoveryActionBar = ({
-
- - } - iconPressed={} - count={upvotes} - pressed={isUpvoteActive} - onClick={onToggleUpvote} - /> - - - } - iconPressed={} - pressed={isDownvoteActive} - onClick={onToggleDownvote} - /> - - - } - iconPressed={} - count={comments} - pressed={post.commented} - onClick={onComment} - /> - - {canAward && ( +
+ } - iconPressed={} - count={awards} - pressed={isAwarded} - onClick={onGiveAward} + id="upvote-post-btn" + label="Upvote" + color={ButtonColor.Avocado} + icon={} + iconPressed={} + count={upvotes} + pressed={isUpvoteActive} + onClick={onToggleUpvote} + /> + + + + + } + iconPressed={} + pressed={isDownvoteActive} + onClick={onToggleDownvote} /> + + 0}> + + } + iconPressed={} + count={comments} + pressed={post.commented} + onClick={onComment} + /> + + + {canAward && ( + 0}> + + } + iconPressed={} + count={awards} + pressed={isAwarded} + onClick={onGiveAward} + /> + + )}
-
- - - } - onClick={() => onCopyLinkClick?.(post)} +
+ + - - {post.clickbaitTitleDetected && ( - - )} - {canSeeAnalytics && ( - + + + } - href={`${webappUrl}posts/${post.id}/analytics`} + label="Copy link" + color={ButtonColor.Cabbage} + icon={} + onClick={() => onCopyLinkClick?.(post)} /> + + {post.clickbaitTitleDetected && ( + + + + )} + {canSeeAnalytics && ( + + + } + href={`${webappUrl}posts/${post.id}/analytics`} + /> + + )} - + + + {isStuck && onClose && ( - onClose()} /> + onClose()} + /> )}
From e9a54d2d0b04ccc330c0ae927f4a60a06829542e Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 10 Jun 2026 13:28:51 +0300 Subject: [PATCH 111/111] =?UTF-8?q?fix(discovery):=20mobile=20header=20?= =?UTF-8?q?=E2=80=94=20read=20button=20below=20title,=20fewer=20squads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move the Read post / Watch video button below the title on mobile (the header row was too tight beside the author + Follow); keep it inline next to Follow from tablet up. Follow button stays in the header on all sizes. - Share row shows only two squads on mobile (four from tablet up) so it doesn't overflow the row on small screens. Co-Authored-By: Claude Opus 4.8 --- .../post/discovery/DiscussionShareRow.tsx | 8 ++++- .../post/discovery/PostFocusCard.tsx | 36 +++++++++++-------- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/packages/shared/src/components/post/discovery/DiscussionShareRow.tsx b/packages/shared/src/components/post/discovery/DiscussionShareRow.tsx index 5bc8d09707e..51058cae65e 100644 --- a/packages/shared/src/components/post/discovery/DiscussionShareRow.tsx +++ b/packages/shared/src/components/post/discovery/DiscussionShareRow.tsx @@ -33,6 +33,7 @@ interface DiscussionShareRowProps { } const maxInlineSquads = 4; +const mobileInlineSquads = 2; /** * Compact share row for the discussion panel. Surfaces the most-used quick @@ -142,10 +143,15 @@ export const DiscussionShareRow = ({ variant={ButtonVariant.Tertiary} /> - {inlineSquads.map((squad) => ( + {inlineSquads.map((squad, index) => ( + ) : null; + return (
) )} - {readHref && !isInternalReadType(post) && ( - - )} + {renderReadButton('ml-auto hidden shrink-0 tablet:flex')}
@@ -328,6 +333,7 @@ export const PostFocusCard = ({ )}
+ {renderReadButton('w-fit tablet:hidden')}