diff --git a/packages/shared/src/components/cards/Freeform/FreeformGrid.tsx b/packages/shared/src/components/cards/Freeform/FreeformGrid.tsx
index c3a4e67dd2b..a6e3a09820f 100644
--- a/packages/shared/src/components/cards/Freeform/FreeformGrid.tsx
+++ b/packages/shared/src/components/cards/Freeform/FreeformGrid.tsx
@@ -1,8 +1,7 @@
import type { ReactElement, Ref } from 'react';
import React, { forwardRef, useRef } from 'react';
-import classNames from 'classnames';
import type { PostCardProps } from '../common/common';
-import { Container, generateTitleClamp } from '../common/common';
+import { Container } from '../common/common';
import { usePostImage } from '../../../hooks/post/usePostImage';
import FeedItemContainer from '../common/FeedItemContainer';
import {
@@ -21,6 +20,7 @@ import {
glassCoverImageClassName,
} from '../common/FeedCardGlassActions';
import { ClickbaitShield } from '../common/ClickbaitShield';
+import PostTags from '../common/PostTags';
import { useSmartTitle } from '../../../hooks/post/useSmartTitle';
import { useFeedCardGlassActions } from '../../../hooks/useFeedCardGlassActions';
import { useHiddenFeedbackPanel } from '../../../hooks/post/useHiddenFeedbackPanel';
@@ -90,36 +90,22 @@ export const FreeformGrid = forwardRef(function SharePostCard(
post={post}
enableSourceHeader={enableSourceHeader}
/>
-
- {title}
-
+ {title}
- <>
- {image && }
-
+ {/* Match the collection card: push the tags + date to the bottom of the
+ text area, just above the footer (cover image or text preview). */}
+
+
+
{post.clickbaitTitleDetected &&
}
+
- >
+
{useGlass ? (
{post.clickbaitTitleDetected && }
+
+
{!isMobile && actionButtons}
diff --git a/packages/shared/src/components/cards/collection/CollectionCardHeader.tsx b/packages/shared/src/components/cards/collection/CollectionCardHeader.tsx
index 35726515867..2338b7ff68b 100644
--- a/packages/shared/src/components/cards/collection/CollectionCardHeader.tsx
+++ b/packages/shared/src/components/cards/collection/CollectionCardHeader.tsx
@@ -1,8 +1,7 @@
import type { ReactElement } from 'react';
import React from 'react';
import classNames from 'classnames';
-import { CollectionPillSources } from '../../post/collection/CollectionPillSources';
-import { getCollectionPillLabel } from '../../post/collection/common';
+import { CollectionSourceStack } from '../../post/collection/CollectionSourceStack';
import {
BookmakProviderHeader,
headerHiddenClassName,
@@ -31,23 +30,24 @@ export const CollectionCardHeader = ({
return (
<>
{highlightBookmarkedPost && }
-
+
-
+
>
);
};
diff --git a/packages/shared/src/components/cards/collection/CollectionFeaturedWideGridCard.tsx b/packages/shared/src/components/cards/collection/CollectionFeaturedWideGridCard.tsx
index 7a7b76f8da6..bca38d92762 100644
--- a/packages/shared/src/components/cards/collection/CollectionFeaturedWideGridCard.tsx
+++ b/packages/shared/src/components/cards/collection/CollectionFeaturedWideGridCard.tsx
@@ -94,7 +94,6 @@ export const CollectionFeaturedWideGridCard = forwardRef(
wasUpdated ? TimeFormatType.PostUpdated : TimeFormatType.Post
}
readTime={post.readTime}
- numSources={post.numCollectionSources}
className="mt-1"
/>
{!!post.summary && (
diff --git a/packages/shared/src/components/cards/collection/CollectionGrid.spec.tsx b/packages/shared/src/components/cards/collection/CollectionGrid.spec.tsx
index e2bafbcb4f8..e2b1c53a60c 100644
--- a/packages/shared/src/components/cards/collection/CollectionGrid.spec.tsx
+++ b/packages/shared/src/components/cards/collection/CollectionGrid.spec.tsx
@@ -107,20 +107,31 @@ it('should hide read time when not available', async () => {
expect(screen.queryByTestId('readTime')).not.toBeInTheDocument();
});
-it('should display the `collection` pill in the header', async () => {
- renderComponent();
- await screen.findByText('Collection');
-});
-
-it('should display the `Trend` pill when the post source is trends', async () => {
+it('should display the collection source stack in the header', async () => {
renderComponent({
post: {
...post,
- source: { ...post.source, id: 'trends' } as Post['source'],
- },
+ collectionSources: [
+ {
+ id: 'src1',
+ handle: 'src1',
+ name: 'Source One',
+ image: 'https://daily.dev/src1.png',
+ permalink: 'https://daily.dev/sources/src1',
+ },
+ {
+ id: 'src2',
+ handle: 'src2',
+ name: 'Source Two',
+ image: 'https://daily.dev/src2.png',
+ permalink: 'https://daily.dev/sources/src2',
+ },
+ ],
+ numCollectionSources: 2,
+ } as Post,
});
- await screen.findByText('Trend');
- expect(screen.queryByText('Collection')).not.toBeInTheDocument();
+ await screen.findByAltText('Avatar of src1');
+ await screen.findByAltText('Avatar of src2');
});
it('should show options button on hover when in laptop size', async () => {
diff --git a/packages/shared/src/components/cards/collection/CollectionGrid.tsx b/packages/shared/src/components/cards/collection/CollectionGrid.tsx
index b9ae3ee8c59..baa42f46501 100644
--- a/packages/shared/src/components/cards/collection/CollectionGrid.tsx
+++ b/packages/shared/src/components/cards/collection/CollectionGrid.tsx
@@ -2,13 +2,14 @@ import type { Ref } from 'react';
import React, { forwardRef } from 'react';
import classNames from 'classnames';
import type { PostCardProps } from '../common/common';
-import { Container, generateTitleClamp } from '../common/common';
+import { Container } from '../common/common';
import FeedItemContainer from '../common/FeedItemContainer';
import { CollectionCardHeader } from './CollectionCardHeader';
import {
getPostClassNames,
FreeformCardTitle,
CardTextContainer,
+ CardSpace,
} from '../common/Card';
import { WelcomePostCardFooter } from '../common/WelcomePostCardFooter';
import ActionButtons from '../common/ActionButtons';
@@ -69,6 +70,16 @@ export const CollectionGrid = forwardRef(function CollectionCard(
);
}
+ const postMetadata = (
+
+ );
+
return (
-
+
{post.title}
-
-
-
+ {/* Push the tags + date to the bottom of the text area, just above the
+ footer (cover image or freeform text preview), matching the default
+ article card so they align regardless of title length. */}
+
+
+
+ {postMetadata}
+
{useGlass ? (
-
diff --git a/packages/shared/src/components/cards/common/AuthorSourceStack.tsx b/packages/shared/src/components/cards/common/AuthorSourceStack.tsx
new file mode 100644
index 00000000000..042e2be93c2
--- /dev/null
+++ b/packages/shared/src/components/cards/common/AuthorSourceStack.tsx
@@ -0,0 +1,140 @@
+import type { CSSProperties, ReactElement } from 'react';
+import React from 'react';
+import classNames from 'classnames';
+import dynamic from 'next/dynamic';
+import type { Author } from '../../../graphql/comments';
+import type { Source, SourceTooltip } from '../../../graphql/sources';
+import { isSourceUserSource } from '../../../graphql/sources';
+import { ProfileImageSize, roundClasses } from '../../ProfilePicture';
+import { ProfileImageLink } from '../../profile/ProfileImageLink';
+import SourceButton from './SourceButton';
+import { useViewSize, ViewSize } from '../../../hooks';
+
+const HoverCard = dynamic(
+ /* webpackChunkName: "hoverCard" */ () => import('./HoverCard'),
+);
+
+// Lazy: the author's profile is fetched on hover, so the feed stays lean.
+const LazyUserEntityCard = dynamic(
+ /* webpackChunkName: "lazyUserEntityCard" */ () =>
+ import('../entity/LazyUserEntityCard').then((m) => m.LazyUserEntityCard),
+);
+
+// Overlap for the author + source stack — mirrors CollectionSourceStack: it
+// rests tight and opens up on hover.
+const OPEN_OVERLAP_PX = 6;
+const REST_OVERLAP_RATIO = 0.62;
+
+const avatarPx: Partial> = {
+ [ProfileImageSize.Small]: 24,
+ [ProfileImageSize.Medium]: 32,
+ [ProfileImageSize.Large]: 40,
+};
+
+// Ring separating the overlapping avatars from the card behind them.
+const avatarRing = 'ring-2 ring-background-default';
+
+// margin-left is applied through a class reading a CSS var (never inline) so the
+// group-hover variant can win and the stack opens up on hover.
+const marginClass =
+ 'ml-[var(--ml-rest)] group-hover/authstack:ml-[var(--ml-open)]';
+
+interface AuthorSourceStackProps {
+ author?: Author | null;
+ source?: Source | null;
+ size?: ProfileImageSize;
+ className?: string;
+}
+
+/**
+ * Renders a post's author + source as an overlapping stack matching the
+ * collection source stack: the author (primary, a rounded-square user avatar)
+ * sits in front on the left, the source/squad (secondary, a circle) tucks in
+ * behind on the right, and the stack opens on hover. Each avatar keeps its own
+ * hover card (author → profile, source → source/squad).
+ *
+ * Falls back to a single bare avatar when only one is present (so article
+ * cards, which have a source and no author, render exactly as before).
+ */
+export const AuthorSourceStack = ({
+ author,
+ source,
+ size = ProfileImageSize.Medium,
+ className,
+}: AuthorSourceStackProps): ReactElement | null => {
+ const alwaysExpanded = !useViewSize(ViewSize.Laptop);
+ const showSource = !!source && !isSourceUserSource(source);
+
+ if (!author && !showSource) {
+ return null;
+ }
+
+ const authorAvatar = author ? (
+ }
+ >
+
+
+ ) : null;
+
+ const sourceAvatar = showSource ? (
+
+ ) : null;
+
+ // Single avatar: render it bare (no ring / overlap) — keeps non-author cards
+ // (e.g. articles) pixel-identical to before.
+ if (!authorAvatar || !sourceAvatar) {
+ return authorAvatar ?? sourceAvatar;
+ }
+
+ const restStep = alwaysExpanded
+ ? OPEN_OVERLAP_PX
+ : Math.round((avatarPx[size] ?? 32) * REST_OVERLAP_RATIO);
+
+ // index 0 = author (front, fixed); index 1 = source (behind, opens on hover).
+ const overlapStyle = (index: number): CSSProperties =>
+ ({
+ '--ml-rest': `${index === 0 ? 0 : -restStep}px`,
+ '--ml-open': `${index === 0 ? 0 : -OPEN_OVERLAP_PX}px`,
+ transitionProperty: 'margin-left',
+ transitionDuration: '260ms',
+ transitionTimingFunction: 'cubic-bezier(0.22, 1, 0.36, 1)',
+ // zIndex is inline (this build doesn't emit Tailwind z-* utilities) so the
+ // author (primary) always wins the stacking order over the source.
+ zIndex: index === 0 ? 2 : 1,
+ } as CSSProperties);
+
+ return (
+
+ {/* Author avatar: a rounded square, like every user avatar (rounding
+ matches the ProfilePicture size so the ring hugs the avatar). */}
+
+ {authorAvatar}
+
+ {/* Source/squad avatar stays circular. */}
+
+ {sourceAvatar}
+
+
+ );
+};
diff --git a/packages/shared/src/components/cards/common/PostCardHeader.tsx b/packages/shared/src/components/cards/common/PostCardHeader.tsx
index 3b0d0a0d922..8014d1c2bc4 100644
--- a/packages/shared/src/components/cards/common/PostCardHeader.tsx
+++ b/packages/shared/src/components/cards/common/PostCardHeader.tsx
@@ -1,11 +1,9 @@
import type { ReactElement, ReactNode } from 'react';
import React, { useMemo } from 'react';
import classNames from 'classnames';
-import dynamic from 'next/dynamic';
import { CardHeader } from './Card';
-import SourceButton from './SourceButton';
import type { Source } from '../../../graphql/sources';
-import { isSourceUserSource } from '../../../graphql/sources';
+import { AuthorSourceStack } from './AuthorSourceStack';
import { ReadArticleButton, getReadPostButtonIcon } from './ReadArticleButton';
import { getGroupedHoverContainer } from './common';
import { useBookmarkProvider, useFeedPreviewMode } from '../../../hooks';
@@ -25,20 +23,8 @@ import {
BookmakProviderHeader,
headerHiddenClassName,
} from './BookmarkProviderHeader';
-import { ProfileImageLink } from '../../profile/ProfileImageLink';
-import { ProfileImageSize } from '../../ProfilePicture';
import { DeletedPostId } from '../../../lib/constants';
import { PostOptionButton } from '../../../features/posts/PostOptionButton';
-import type { UserShortProfile } from '../../../lib/user';
-
-const HoverCard = dynamic(
- /* webpackChunkName: "hoverCard" */ () => import('./HoverCard'),
-);
-
-const UserEntityCard = dynamic(
- /* webpackChunkName: "userEntityCard" */ () =>
- import('../entity/UserEntityCard'),
-);
interface CardHeaderProps {
post: Post;
@@ -68,7 +54,6 @@ export const PostCardHeader = ({
}: CardHeaderProps): ReactElement => {
const isFeedPreview = useFeedPreviewMode();
const isSharedPostDeleted = post.sharedPost?.id === DeletedPostId;
- const isUserSource = isSourceUserSource(post.source);
const { highlightBookmarkedPost } = useBookmarkProvider({
bookmarked: (post.bookmarked && !showFeedback) ?? false,
});
@@ -116,22 +101,7 @@ export const PostCardHeader = ({
highlightBookmarkedPost && headerHiddenClassName,
)}
>
- {!isUserSource && }
- {!!post?.author && (
-
- }
- >
-
-
- )}
+
{children}
- import('../entity/UserEntityCard'),
-);
-
-const HoverCard = dynamic(
- /* webpackChunkName: "hoverCard" */ () => import('./HoverCard'),
-);
+import { AuthorSourceStack } from './AuthorSourceStack';
+// enableSourceHeader is kept for API compatibility with the shared card props;
+// the source now always renders in the stack (when it isn't a user source).
type SquadPostCardHeaderProps = { post: Post; enableSourceHeader?: boolean };
export const SquadPostCardHeader = ({
post,
- enableSourceHeader = false,
}: SquadPostCardHeaderProps): ReactElement => {
const { author, source, bookmarked } = post;
const { highlightBookmarkedPost } = useBookmarkProvider({
bookmarked: bookmarked ?? false,
});
- const isUserSource = isSourceUserSource(post.source);
return (
<>
@@ -46,36 +31,8 @@ export const SquadPostCardHeader = ({
highlightBookmarkedPost && headerHiddenClassName,
)}
>
-
- {!isUserSource && (
-
- )}
- {author && (
-
- }
- >
-
-
- )}
+
+
void;
imageClassName?: string;
+ contentClassName?: string;
glassActions?: boolean;
}
@@ -22,6 +23,7 @@ export const WelcomePostCardFooter = ({
onShare,
contentHtml,
imageClassName,
+ contentClassName,
glassActions = false,
}: WelcomePostCardFooterProps): ReactElement | null => {
const { overlay } = useCardCover({
@@ -74,6 +76,7 @@ export const WelcomePostCardFooter = ({
// The glass action bar floats over the card bottom. Clamp one line
// tighter and reserve its height (pb-12) so text never sits under it.
glassActions ? 'line-clamp-5 pb-12' : 'line-clamp-6',
+ contentClassName,
)}
>
{decodedText}
diff --git a/packages/shared/src/components/cards/common/list/PostCardHeader.tsx b/packages/shared/src/components/cards/common/list/PostCardHeader.tsx
index f5da75be75e..3c630287da3 100644
--- a/packages/shared/src/components/cards/common/list/PostCardHeader.tsx
+++ b/packages/shared/src/components/cards/common/list/PostCardHeader.tsx
@@ -1,7 +1,6 @@
import type { ReactElement, ReactNode } from 'react';
import React from 'react';
import classNames from 'classnames';
-import dynamic from 'next/dynamic';
import { CardHeader } from './ListCard';
import { ReadArticleButton } from '../ReadArticleButton';
import { getGroupedHoverContainer } from '../common';
@@ -14,20 +13,8 @@ import PostMetadata from './PostMetadata';
import { OpenLinkIcon } from '../../../icons';
import { useReadPostButtonText } from './hooks';
import { BookmakProviderHeader } from './BookmarkProviderHeader';
-import { ProfileImageSize } from '../../../ProfilePicture';
-import { ProfileImageLink } from '../../../profile/ProfileImageLink';
-import type { UserShortProfile } from '../../../../lib/user';
import { PostOptionButton } from '../../../../features/posts/PostOptionButton';
-import { isSourceUserSource } from '../../../../graphql/sources';
-
-const HoverCard = dynamic(
- /* webpackChunkName: "hoverCard" */ () => import('../HoverCard'),
-);
-
-const UserEntityCard = dynamic(
- /* webpackChunkName: "userEntityCard" */ () =>
- import('../../entity/UserEntityCard'),
-);
+import { AuthorSourceStack } from '../AuthorSourceStack';
interface CardHeaderProps {
post: Post;
@@ -71,7 +58,6 @@ export const PostCardHeader = ({
});
const isCollectionType = post.type === 'collection';
- const isUserSource = isSourceUserSource(post.source);
const showCTA =
!isFeedPreview &&
([PostType.Article, PostType.VideoYouTube].includes(post.type) ||
@@ -82,26 +68,16 @@ export const PostCardHeader = ({
<>
{highlightBookmarkedPost && }
- {children}
- {!!post?.author && (
-
- }
- >
-
-
+ {post?.author && !isCollectionType ? (
+ // Author present (freeform/share/squad): show the author + source as
+ // an overlapping stack — author in front on the left, source behind
+ // on the right — expanded on mobile, matching the collection source
+ // stack. Collection cards keep their own source-stack children (even
+ // if a collection ever has an author), and article cards keep their
+ // single source.
+
+ ) : (
+ children
)}
{
+ const { data: source } = useQuery({
+ queryKey: generateQueryKey(RequestKey.Source, undefined, id, 'tooltip'),
+ queryFn: () => getSourceTooltip(id),
+ enabled: !!id,
+ staleTime: StaleTime.Default,
+ });
+
+ if (!source) {
+ return null;
+ }
+
+ return ;
+};
diff --git a/packages/shared/src/components/cards/entity/LazyUserEntityCard.tsx b/packages/shared/src/components/cards/entity/LazyUserEntityCard.tsx
new file mode 100644
index 00000000000..9b7d055fd83
--- /dev/null
+++ b/packages/shared/src/components/cards/entity/LazyUserEntityCard.tsx
@@ -0,0 +1,34 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { getUserShortInfo } from '../../../graphql/users';
+import { generateQueryKey, RequestKey, StaleTime } from '../../../lib/query';
+import UserEntityCard from './UserEntityCard';
+
+interface LazyUserEntityCardProps {
+ id: string;
+}
+
+/**
+ * Fetches the author's profile on demand (when the hover card mounts) so the
+ * feed request stays lean — instead of shipping bio/reputation/… on every post.
+ */
+export const LazyUserEntityCard = ({
+ id,
+}: LazyUserEntityCardProps): ReactElement | null => {
+ const { data: user } = useQuery({
+ queryKey: generateQueryKey(
+ RequestKey.UserShortById,
+ id ? { id } : undefined,
+ ),
+ queryFn: () => getUserShortInfo(id),
+ enabled: !!id,
+ staleTime: StaleTime.Default,
+ });
+
+ if (!user) {
+ return null;
+ }
+
+ return ;
+};
diff --git a/packages/shared/src/components/post/collection/CollectionSourceStack.tsx b/packages/shared/src/components/post/collection/CollectionSourceStack.tsx
new file mode 100644
index 00000000000..8760cb03bbe
--- /dev/null
+++ b/packages/shared/src/components/post/collection/CollectionSourceStack.tsx
@@ -0,0 +1,168 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import classNames from 'classnames';
+import dynamic from 'next/dynamic';
+import { SourceAvatar } from '../../profile/source/SourceAvatar';
+import { ProfileImageSize, sizeClasses } from '../../ProfilePicture';
+import HoverCard from '../../cards/common/HoverCard';
+import type { SourceTooltip } from '../../../graphql/sources';
+import { SourceType } from '../../../graphql/sources';
+import { Origin } from '../../../lib/log';
+import { useViewSize, ViewSize } from '../../../hooks';
+
+const SquadEntityCard = dynamic(
+ /* webpackChunkName: "squadEntityCard" */ () =>
+ import('../../cards/entity/SquadEntityCard'),
+);
+
+// Lazy: the source's details are fetched on hover, so the feed stays lean.
+const LazySourceEntityCard = dynamic(
+ /* webpackChunkName: "lazySourceEntityCard" */ () =>
+ import('../../cards/entity/LazySourceEntityCard').then(
+ (m) => m.LazySourceEntityCard,
+ ),
+);
+
+// Resting stack shows at most 3 avatars + one "+N" counter = 4 circles.
+const MAX_AVATARS = 3;
+// Open spacing matches how sources sit today (~6px overlap, i.e. -ml-1.5).
+const OPEN_OVERLAP_PX = 6;
+// While resting the circles overlap ~62% of their width for a compact mark.
+const REST_OVERLAP_RATIO = 0.62;
+
+const avatarPx: Partial> = {
+ [ProfileImageSize.Small]: 24,
+ [ProfileImageSize.Medium]: 32,
+ [ProfileImageSize.Large]: 40,
+};
+
+interface CollectionSourceStackProps {
+ sources: SourceTooltip[];
+ totalSources: number;
+ size?: ProfileImageSize;
+ className?: string;
+ /**
+ * Force the stack open (no hover needed). Defaults to true on touch/mobile
+ * where there is no cursor to hover.
+ */
+ alwaysExpanded?: boolean;
+ /**
+ * Show the rich source hover card per avatar. Defaults to true on desktop
+ * only — touch surfaces have no hover.
+ */
+ enableHoverCard?: boolean;
+}
+
+export const CollectionSourceStack = ({
+ sources,
+ totalSources,
+ size = ProfileImageSize.Medium,
+ className,
+ alwaysExpanded: alwaysExpandedProp,
+ enableHoverCard: enableHoverCardProp,
+}: CollectionSourceStackProps): ReactElement | null => {
+ const isLaptop = useViewSize(ViewSize.Laptop);
+ const alwaysExpanded = alwaysExpandedProp ?? !isLaptop;
+ const enableHoverCard = enableHoverCardProp ?? isLaptop;
+
+ if (!sources?.length) {
+ return null;
+ }
+
+ const shown = sources.slice(0, MAX_AVATARS);
+ const remaining = Math.max(totalSources - shown.length, 0);
+ const px = avatarPx[size] ?? 32;
+ const restStep = alwaysExpanded
+ ? OPEN_OVERLAP_PX
+ : Math.round(px * REST_OVERLAP_RATIO);
+
+ // margin-left is applied through a class reading a CSS var (never inline), so
+ // the group-hover variant can win. Inline margin would always beat the class.
+ const marginClass =
+ 'ml-[var(--ml-rest)] group-hover/srcstack:ml-[var(--ml-open)]';
+
+ const circleStyle = (index: number): React.CSSProperties =>
+ ({
+ '--ml-rest': `${index === 0 ? 0 : -restStep}px`,
+ '--ml-open': `${index === 0 ? 0 : -OPEN_OVERLAP_PX}px`,
+ transitionProperty: 'margin-left',
+ transitionDuration: '260ms',
+ transitionTimingFunction: 'cubic-bezier(0.22, 1, 0.36, 1)',
+ } as React.CSSProperties);
+
+ // Squads read from their handle; every other source lazily fetches its
+ // details by id when the hover card mounts.
+ const renderEntityCard = (source: SourceTooltip): ReactElement | null => {
+ if (source.type === SourceType.Squad) {
+ return ;
+ }
+
+ if (!source.id) {
+ return null;
+ }
+
+ return ;
+ };
+
+ const withHoverCard = (
+ source: SourceTooltip,
+ avatar: ReactElement,
+ ): ReactElement => {
+ if (!enableHoverCard) {
+ return {avatar};
+ }
+
+ return (
+
+ {renderEntityCard(source)}
+
+ );
+ };
+
+ return (
+ // `pointer-events-auto` re-enables hover: the card's text container is
+ // `pointer-events-none` so post clicks fall through to the card link, and
+ // that inherits down to the stack.
+
+ {shown.map((source, index) =>
+ withHoverCard(
+ source,
+
+
+
,
+ ),
+ )}
+ {remaining > 0 && (
+
+ +{remaining}
+
+ )}
+
+ );
+};
diff --git a/packages/shared/src/graphql/fragments.ts b/packages/shared/src/graphql/fragments.ts
index 9fbff7cb170..1f3f7b33552 100644
--- a/packages/shared/src/graphql/fragments.ts
+++ b/packages/shared/src/graphql/fragments.ts
@@ -658,7 +658,10 @@ export const FEED_POST_FRAGMENT = gql`
}
author {
id
+ name
+ image
username
+ permalink
}
slug
clickbaitTitleDetected
@@ -670,8 +673,15 @@ export const FEED_POST_FRAGMENT = gql`
trending
feedMeta
collectionSources {
+ # Only the fields the stacked avatars render. The hover card's rich data
+ # (description, followers, upvotes) is fetched lazily on hover via
+ # getSourceTooltip, so the feed request stays lean.
+ id
handle
+ name
image
+ permalink
+ type
}
numCollectionSources
updatedAt
diff --git a/packages/shared/src/graphql/sources.ts b/packages/shared/src/graphql/sources.ts
index ed04ed9cefa..1214ab23f5d 100644
--- a/packages/shared/src/graphql/sources.ts
+++ b/packages/shared/src/graphql/sources.ts
@@ -139,6 +139,31 @@ export const SOURCE_QUERY = gql`
${SOURCE_DIRECTORY_INFO_FRAGMENT}
`;
+// Fetched lazily when a source hover card opens, so the feed stays lean.
+// membersCount/flags resolve here (unlike in the feed's collectionSources,
+// where the membersCount resolver throws for Machine sources).
+export const SOURCE_TOOLTIP_QUERY = gql`
+ query SourceTooltip($id: ID!) {
+ source(id: $id) {
+ ...SourceDirectoryInfo
+ type
+ membersCount
+ flags {
+ totalUpvotes
+ }
+ }
+ }
+ ${SOURCE_DIRECTORY_INFO_FRAGMENT}
+`;
+
+export const getSourceTooltip = async (
+ id: string,
+): Promise => {
+ const res = await gqlClient.request(SOURCE_TOOLTIP_QUERY, { id });
+
+ return (res.source as SourceTooltip) ?? null;
+};
+
export const SOURCE_DIRECTORY_QUERY = gql`
query SourceDirectory {
trendingSources {
diff --git a/packages/storybook/stories/components/cards/CollectionTrendCardTags.stories.tsx b/packages/storybook/stories/components/cards/CollectionTrendCardTags.stories.tsx
new file mode 100644
index 00000000000..3cdebd20f53
--- /dev/null
+++ b/packages/storybook/stories/components/cards/CollectionTrendCardTags.stories.tsx
@@ -0,0 +1,244 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import React from 'react';
+import { fn } from 'storybook/test';
+import { PostType, UserVote } from '@dailydotdev/shared/src/graphql/posts';
+import type { Post } from '@dailydotdev/shared/src/graphql/posts';
+import { SourceType } from '@dailydotdev/shared/src/graphql/sources';
+import { ArticleGrid } from '@dailydotdev/shared/src/components/cards/article/ArticleGrid';
+import { CollectionGrid } from '@dailydotdev/shared/src/components/cards/collection/CollectionGrid';
+import { FreeformGrid } from '@dailydotdev/shared/src/components/cards/Freeform/FreeformGrid';
+
+import ExtensionProviders from '../../extension/_providers';
+
+const source = {
+ id: 'tds',
+ handle: 'tds',
+ name: 'Towards Data Science',
+ permalink: 'https://app.daily.dev/sources/tds',
+ image: 'https://media.daily.dev/image/upload/t_logo,f_auto/v1/logos/tds',
+ type: SourceType.Machine,
+ active: true,
+ description: 'Your home for data science and AI.',
+ membersCount: 132000,
+ flags: { totalUpvotes: 48000 },
+};
+
+const collectionSources = [
+ {
+ id: 'react',
+ handle: 'reactjs',
+ name: 'React',
+ permalink: 'https://app.daily.dev/sources/reactjs',
+ image: 'https://media.daily.dev/image/upload/t_logo,f_auto/v1/logos/react',
+ type: SourceType.Machine,
+ },
+ {
+ id: 'nextjs',
+ handle: 'nextjs',
+ name: 'Next.js',
+ permalink: 'https://app.daily.dev/sources/nextjs',
+ image: 'https://media.daily.dev/image/upload/t_logo,f_auto/v1/logos/nextjs',
+ type: SourceType.Machine,
+ },
+ {
+ id: 'ts',
+ handle: 'typescript',
+ name: 'TypeScript',
+ permalink: 'https://app.daily.dev/sources/typescript',
+ image: 'https://media.daily.dev/image/upload/t_logo,f_auto/v1/logos/ts',
+ type: SourceType.Machine,
+ },
+];
+
+const image =
+ 'https://media.daily.dev/image/upload/f_auto,q_auto/v1/posts/scvgbr5cbutrjhi1e8ao';
+
+const tags = ['react', 'javascript', 'frontend'];
+
+const base = {
+ numUpvotes: 42,
+ numComments: 12,
+ bookmarked: false,
+ read: false,
+ upvoted: false,
+ commented: false,
+ source,
+ tags,
+ readTime: 7,
+ createdAt: '2024-01-15T10:30:00.000Z',
+ updatedAt: '2026-07-16T09:00:00.000Z',
+ permalink: 'https://api.daily.dev/r/post-1',
+ commentsPermalink: 'https://daily.dev/posts/post-1',
+ userState: { vote: UserVote.None, flags: { feedbackDismiss: false } },
+};
+
+const make = (overrides: Record): Post =>
+ ({ ...base, ...overrides } as unknown as Post);
+
+// Titles chosen to wrap to 1, 2 and 3 lines at the ~320px grid card width.
+const TITLES: { label: string; title: string }[] = [
+ { label: '1-line title', title: 'The WASM revolution' },
+ {
+ label: '2-line title',
+ title: 'The best React libraries we shipped this year',
+ },
+ {
+ label: '3-line title',
+ title:
+ 'Grok AI uploads entire git repository, including history and user directories',
+ },
+];
+
+const collectionImage = (title: string, i: number): Post =>
+ make({
+ id: `coll-image-${i}`,
+ type: PostType.Collection,
+ title,
+ image,
+ collectionSources,
+ numCollectionSources: 7,
+ });
+
+const collectionFreeform = (title: string, i: number): Post =>
+ make({
+ id: `coll-freeform-${i}`,
+ type: PostType.Collection,
+ title,
+ contentHtml:
+ 'A hand-picked round-up of what the community shipped this year, ' +
+ 'distilled into one place. Two competing philosophies about how teams ' +
+ 'build and ship are colliding, and the gap between them is getting ' +
+ 'harder to ignore for anyone paying attention.
',
+ collectionSources,
+ numCollectionSources: 10,
+ });
+
+const article = (title: string, i: number): Post =>
+ make({ id: `article-${i}`, type: PostType.Article, title, image });
+
+// A full author so the profile hover card (UserEntityCard) renders in
+// Storybook — it needs id, username, name, image, permalink and createdAt.
+const author = {
+ id: 'author-1',
+ name: 'John Developer',
+ username: 'johndeveloper',
+ image:
+ 'https://media.daily.dev/image/upload/f_auto,q_auto/v1/posts/scvgbr5cbutrjhi1e8ao',
+ permalink: 'https://app.daily.dev/johndeveloper',
+ bio: 'Full-stack developer who loves shipping fast and writing about it.',
+ createdAt: '2022-03-10T10:00:00.000Z',
+ reputation: 4200,
+ isPlus: false,
+};
+
+// A regular freeform (share) post — NOT a collection. Rendered by the default
+// FreeformGrid. The author (primary) sits in front on the left and the source
+// (secondary) tucks in behind on the right. Uses the inline machine source so
+// the source hover card renders in Storybook; real squad posts fetch theirs.
+const freeform = (title: string, i: number): Post =>
+ make({
+ id: `freeform-${i}`,
+ type: PostType.Freeform,
+ author,
+ title,
+ contentHtml:
+ 'A hand-picked round-up of what the community shipped this year, ' +
+ 'distilled into one place. Two competing philosophies about how teams ' +
+ 'build and ship are colliding, and the gap between them is getting ' +
+ 'harder to ignore for anyone paying attention.
',
+ });
+
+const handlers = {
+ onPostClick: fn(),
+ onPostAuxClick: fn(),
+ onUpvoteClick: fn(),
+ onDownvoteClick: fn(),
+ onCommentClick: fn(),
+ onBookmarkClick: fn(),
+ onCopyLinkClick: fn(),
+ onShare: fn(),
+ onReadArticleClick: fn(),
+};
+
+const gridStyle = {
+ maxWidth: 'calc(20rem * 4 + 2rem * 3)',
+} as React.CSSProperties;
+
+const Row = ({
+ label,
+ children,
+}: {
+ label: string;
+ children: React.ReactNode;
+}): React.ReactElement => (
+ <>
+ {label}
+
+ {children}
+
+ >
+);
+
+const ColLabel = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+);
+
+const CollectionTrendCardTags = (): React.ReactElement => (
+
+
+
+ Collection / Trend card — alignment across all three cards
+
+
+ Each row shows the cards side by side with the{' '}
+ same title, at 1, 2 and 3 lines: freeform collection,
+ collection with image, the default article card, and a freeform post.
+ Check that the tag chips and date strip line up across all of them, that
+ the text preview sits a comfortable gap below the date, and hover the
+ header avatars — author shows the profile card, source shows the source
+ card.
+
+
+ Collection (freeform text)
+ Collection (with image)
+ Default article (image)
+ Freeform post (not a collection)
+
+
+ {TITLES.map((t, i) => (
+
+
+
+
+
+
+ ))}
+
+
+);
+
+const meta: Meta = {
+ title: 'Components/Cards/Collection Trend Card Tags',
+ component: CollectionTrendCardTags,
+ parameters: {
+ layout: 'fullscreen',
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const TagPlacement: Story = {
+ render: () => ,
+ name: 'Alignment matrix',
+};