From 94947708c1951bcf34c8b8796826adf11d500e36 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 28 Jul 2026 09:48:59 +0300 Subject: [PATCH 1/3] fix: link collection source avatars and scroll the achievement showcase Collection/brief source avatars rendered as bare images, so clicking one fell through to the post link. They now navigate to the source page, like the single source avatar on article cards. The profile achievement showcase strip had no overflow handling, so a full showcase made the whole profile page scroll horizontally on mobile. Co-Authored-By: Claude Opus 5 --- .../cards/collection/CollectionGrid.spec.tsx | 21 +++++++ .../post/collection/CollectionPillSources.tsx | 10 +-- .../post/collection/CollectionSourceStack.tsx | 15 ++--- .../profile/source/SourceAvatarLink.tsx | 61 +++++++++++++++++++ .../ProfileAchievementShowcase.tsx | 6 +- packages/shared/src/graphql/posts.ts | 4 ++ 6 files changed, 101 insertions(+), 16 deletions(-) create mode 100644 packages/shared/src/components/profile/source/SourceAvatarLink.tsx diff --git a/packages/shared/src/components/cards/collection/CollectionGrid.spec.tsx b/packages/shared/src/components/cards/collection/CollectionGrid.spec.tsx index e2b1c53a60c..d6feb938769 100644 --- a/packages/shared/src/components/cards/collection/CollectionGrid.spec.tsx +++ b/packages/shared/src/components/cards/collection/CollectionGrid.spec.tsx @@ -134,6 +134,27 @@ it('should display the collection source stack in the header', async () => { await screen.findByAltText('Avatar of src2'); }); +it('should link each source in the stack to its source page', async () => { + renderComponent({ + post: { + ...post, + collectionSources: [ + { + id: 'src1', + handle: 'src1', + name: 'Source One', + image: 'https://daily.dev/src1.png', + permalink: 'https://daily.dev/sources/src1', + }, + ], + numCollectionSources: 1, + } as Post, + }); + const link = await screen.findByLabelText('Go to Source One'); + expect(link).toHaveAttribute('href', 'https://daily.dev/sources/src1'); + expect(link).toContainElement(screen.getByAltText('Avatar of src1')); +}); + it('should show options button on hover when in laptop size', async () => { renderComponent(); const header = await screen.findByLabelText('Options'); diff --git a/packages/shared/src/components/post/collection/CollectionPillSources.tsx b/packages/shared/src/components/post/collection/CollectionPillSources.tsx index 9e8d8ad5132..563dde3ce6a 100644 --- a/packages/shared/src/components/post/collection/CollectionPillSources.tsx +++ b/packages/shared/src/components/post/collection/CollectionPillSources.tsx @@ -2,8 +2,8 @@ import type { ReactElement, ReactNode } from 'react'; import React from 'react'; import classNames from 'classnames'; import { Pill } from '../../Pill'; -import type { SourceAvatarProps } from '../../profile/source'; -import { SourceAvatar } from '../../profile/source'; +import type { LinkableSource } from '../../profile/source/SourceAvatarLink'; +import { SourceAvatarLink } from '../../profile/source/SourceAvatarLink'; import { ProfilePictureGroup } from '../../ProfilePictureGroup'; import { ProfileImageSize } from '../../ProfilePicture'; @@ -12,7 +12,7 @@ interface CollectionPillSourcesProps { main?: string; avatar?: string; }; - sources: SourceAvatarProps['source'][]; + sources: LinkableSource[]; alwaysShowSources?: boolean; totalSources: number; size?: ProfileImageSize; @@ -49,8 +49,8 @@ export const CollectionPillSources = ({ limit={limit} > {sources.map((source) => ( - withHoverCard( source, -
- -
, + avatarClassName="!mr-0 box-content ring-2 ring-background-default" + source={source} + size={size} + />, ), )} {remaining > 0 && ( diff --git a/packages/shared/src/components/profile/source/SourceAvatarLink.tsx b/packages/shared/src/components/profile/source/SourceAvatarLink.tsx new file mode 100644 index 00000000000..e61085ce4c6 --- /dev/null +++ b/packages/shared/src/components/profile/source/SourceAvatarLink.tsx @@ -0,0 +1,61 @@ +import type { AnchorHTMLAttributes, ReactElement, Ref } from 'react'; +import React, { forwardRef } from 'react'; +import classNames from 'classnames'; +import Link from '../../utilities/Link'; +import { SourceAvatar } from './SourceAvatar'; +import type { Source } from '../../../graphql/sources'; +import { ProfileImageSize } from '../../ProfilePicture'; + +export type LinkableSource = Pick & + Partial>; + +export interface SourceAvatarLinkProps + extends Omit, 'href'> { + source: LinkableSource; + size?: ProfileImageSize; + avatarClassName?: string; + ref?: Ref; +} + +/** + * A source avatar that navigates to the source (or squad) page, matching the + * behavior of the single source avatar on article cards. Falls back to a bare + * avatar when the query that produced the source did not select `permalink`. + */ +function SourceAvatarLinkComponent( + { + source, + size = ProfileImageSize.Medium, + className, + avatarClassName, + ...props + }: SourceAvatarLinkProps, + ref?: Ref, +): ReactElement { + const avatar = ( + + ); + + if (!source?.permalink) { + return ( + + {avatar} + + ); + } + + return ( + + + {avatar} + + + ); +} + +export const SourceAvatarLink = forwardRef(SourceAvatarLinkComponent); diff --git a/packages/shared/src/features/profile/components/achievements/ProfileAchievementShowcase.tsx b/packages/shared/src/features/profile/components/achievements/ProfileAchievementShowcase.tsx index 60c425ef477..cd1802f5628 100644 --- a/packages/shared/src/features/profile/components/achievements/ProfileAchievementShowcase.tsx +++ b/packages/shared/src/features/profile/components/achievements/ProfileAchievementShowcase.tsx @@ -66,7 +66,7 @@ export function ProfileAchievementShowcase({ } return ( -
+
{hasShowcase ? ( -
+ // negative margins cancel the padding that keeps the rarity glow and + // sparkles from being clipped by the scroll container +
{showcaseAchievements.map((userAchievement) => { const { achievement } = userAchievement; const rarityTier = getAchievementRarityTier(achievement.rarity); diff --git a/packages/shared/src/graphql/posts.ts b/packages/shared/src/graphql/posts.ts index e5e9b0858c5..e3f8ddb3e60 100644 --- a/packages/shared/src/graphql/posts.ts +++ b/packages/shared/src/graphql/posts.ts @@ -415,6 +415,8 @@ export const POST_BY_ID_QUERY = gql` collectionSources { handle image + name + permalink } } relatedCollectionPosts: relatedPosts( @@ -502,6 +504,8 @@ export const POST_BY_ID_STATIC_FIELDS_QUERY = gql` collectionSources { handle image + name + permalink } sharedPost { ...SharedPostInfo From a5ce6e163e9701db581ef9b71f395e3ce68f4b9a Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 28 Jul 2026 10:23:28 +0300 Subject: [PATCH 2/3] fix: bleed profile horizontal strips to the card edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page wraps every section in p-6, so the achievement showcase, the activity feed and the mobile Highlights row all clipped 24px short of the page edge — the content read as abruptly cut off rather than continuing off-screen. Each scroll container now cancels that padding and re-applies it inside, so it clips at the card edge while its items stay aligned with the section heading. Activity's overflow-hidden guard is gone: the feed grid scrolls itself, and the guard would have clipped the bleed straight back. Co-Authored-By: Claude Opus 5 --- packages/shared/src/features/profile/common.ts | 9 +++++++++ .../features/profile/components/Activity.helpers.tsx | 5 ++++- .../src/features/profile/components/Activity.tsx | 7 ++++--- .../components/ProfileWidgets/ProfileWidgets.tsx | 6 ++++++ .../achievements/ProfileAchievementShowcase.tsx | 12 +++++++++--- 5 files changed, 32 insertions(+), 7 deletions(-) diff --git a/packages/shared/src/features/profile/common.ts b/packages/shared/src/features/profile/common.ts index 12bed6510d5..c44521080d0 100644 --- a/packages/shared/src/features/profile/common.ts +++ b/packages/shared/src/features/profile/common.ts @@ -1,3 +1,12 @@ +/** + * The profile page wraps every section in `p-6`, so a horizontally scrolling + * strip clips 24px short of the page edge — the content looks abruptly cut off + * instead of continuing off-screen. Cancel that padding on the scroll container + * and re-apply it inside, so the strip clips at the card edge while its items + * stay aligned with the section heading. + */ +export const profileStripBleed = '-mx-6 px-6'; + export const profileSecondaryFieldStyles = { outerLabel: '!px-0 !typo-callout', baseField: '!h-12', diff --git a/packages/shared/src/features/profile/components/Activity.helpers.tsx b/packages/shared/src/features/profile/components/Activity.helpers.tsx index a5678a93a3c..f78826b13b2 100644 --- a/packages/shared/src/features/profile/components/Activity.helpers.tsx +++ b/packages/shared/src/features/profile/components/Activity.helpers.tsx @@ -67,8 +67,11 @@ export const COMMENT_CLASS_NAME = { } as const; export const MIN_ITEMS_FOR_SHOW_MORE = 3; +// The bleed classes are the `profileStripBleed` pair scoped to the feed's own +// scroll container, so the strip clips at the card edge rather than 24px short +// of it. `scroll-px-6` keeps snapped cards aligned with the section heading. export const HORIZONTAL_FEED_CLASSES = - '[&_.grid]:!auto-cols-[17rem] tablet:[&_.grid]:!auto-cols-[20rem] [&_.grid]:gap-4'; + '[&_.grid]:!auto-cols-[17rem] tablet:[&_.grid]:!auto-cols-[20rem] [&_.grid]:gap-4 [&_.grid]:-mx-6 [&_.grid]:px-6 [&_.grid]:scroll-px-6'; export const TAB_ITEMS = activityTabs.map((tab) => ({ label: tab.title })); export const ACTIVITY_QUERY_KEYS = { diff --git a/packages/shared/src/features/profile/components/Activity.tsx b/packages/shared/src/features/profile/components/Activity.tsx index a3f6b7c1e53..12f8d362c21 100644 --- a/packages/shared/src/features/profile/components/Activity.tsx +++ b/packages/shared/src/features/profile/components/Activity.tsx @@ -71,8 +71,9 @@ export const Activity = ({ user }: ActivityProps): ReactElement | null => { } return ( -
- {renderContent()} -
+ // No overflow guard here: the feed grid scrolls itself and bleeds past this + // box to the card edge (see HORIZONTAL_FEED_CLASSES), so clipping at the + // section's padding would cut the strip short of the page edge again. +
{renderContent()}
); }; diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/ProfileWidgets.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/ProfileWidgets.tsx index 2db7735133e..6f21e091ce7 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/ProfileWidgets.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/ProfileWidgets.tsx @@ -23,6 +23,7 @@ import { useConditionalFeature } from '../../../../hooks/useConditionalFeature'; import { achievementTrackingWidgetFeature } from '../../../../lib/featureManagement'; import { useProfileAchievements } from '../../../../hooks/profile/useProfileAchievements'; import { shouldShowAchievementTracker } from '../../../../lib/achievements'; +import { profileStripBleed } from '../../common'; const BadgesAndAwards = dynamic(() => import('./BadgesAndAwards').then((mod) => mod.BadgesAndAwards), @@ -110,7 +111,12 @@ export function ProfileWidgets({ return (
diff --git a/packages/shared/src/features/profile/components/achievements/ProfileAchievementShowcase.tsx b/packages/shared/src/features/profile/components/achievements/ProfileAchievementShowcase.tsx index cd1802f5628..a1de1d61c7e 100644 --- a/packages/shared/src/features/profile/components/achievements/ProfileAchievementShowcase.tsx +++ b/packages/shared/src/features/profile/components/achievements/ProfileAchievementShowcase.tsx @@ -27,6 +27,7 @@ import { AchievementCard } from './AchievementCard'; import { RaritySparkles } from './RaritySparkles'; import { useLazyModal } from '../../../../hooks/useLazyModal'; import { LazyModal } from '../../../../components/modals/common/types'; +import { profileStripBleed } from '../../common'; interface ProfileAchievementShowcaseProps { user: PublicProfile; @@ -88,9 +89,14 @@ export function ProfileAchievementShowcase({
{hasShowcase ? ( - // negative margins cancel the padding that keeps the rarity glow and - // sparkles from being clipped by the scroll container -
+ // -my-2/py-2 keeps the rarity glow and sparkles, which deliberately + // escape each tile, from being clipped by the scroll container +
{showcaseAchievements.map((userAchievement) => { const { achievement } = userAchievement; const rarityTier = getAchievementRarityTier(achievement.rarity); From 04c4da4995f8d6985d6487b1d8d4e65d9fdf787f Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 28 Jul 2026 11:15:57 +0300 Subject: [PATCH 3/3] fix: address review findings on the profile strip changes - SourceAvatarLink forwards its ref in the no-permalink branch. Radix's hover card trigger composes a ref onto it, and the discarded ref left the trigger without an anchor node. Props are typed against HTMLElement so the spread is valid on both the anchor and the span. - Scope the feed bleed to .grid.snap-x. As plain .grid it matched every descendant grid, including the bare `grid` class on list cards' metadata row, where negative margins would be visible. - Restore an overflow guard on the replies tab only. The posts/upvoted feeds scroll themselves, but replies are plain content, so wide markdown could widen the page again. Co-Authored-By: Claude Opus 5 --- .../profile/source/SourceAvatarLink.tsx | 16 +++++++++++----- .../profile/components/Activity.helpers.tsx | 5 ++++- .../features/profile/components/Activity.tsx | 18 ++++++++++++++---- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/packages/shared/src/components/profile/source/SourceAvatarLink.tsx b/packages/shared/src/components/profile/source/SourceAvatarLink.tsx index e61085ce4c6..efe80980ae2 100644 --- a/packages/shared/src/components/profile/source/SourceAvatarLink.tsx +++ b/packages/shared/src/components/profile/source/SourceAvatarLink.tsx @@ -1,4 +1,4 @@ -import type { AnchorHTMLAttributes, ReactElement, Ref } from 'react'; +import type { HTMLAttributes, ReactElement, Ref } from 'react'; import React, { forwardRef } from 'react'; import classNames from 'classnames'; import Link from '../../utilities/Link'; @@ -9,12 +9,10 @@ import { ProfileImageSize } from '../../ProfilePicture'; export type LinkableSource = Pick & Partial>; -export interface SourceAvatarLinkProps - extends Omit, 'href'> { +export interface SourceAvatarLinkProps extends HTMLAttributes { source: LinkableSource; size?: ProfileImageSize; avatarClassName?: string; - ref?: Ref; } /** @@ -36,9 +34,17 @@ function SourceAvatarLinkComponent( ); + // No permalink means nothing to link to, but the ref still has to reach a DOM + // node: Radix's hover card trigger composes one onto this component and never + // opens without it. A span (not an href-less anchor) also keeps the click + // falling through to the card link, since `.card a` gets pointer-events: all. if (!source?.permalink) { return ( - + } + className={classNames('flex', className)} + > {avatar} ); diff --git a/packages/shared/src/features/profile/components/Activity.helpers.tsx b/packages/shared/src/features/profile/components/Activity.helpers.tsx index f78826b13b2..8eecd9ba726 100644 --- a/packages/shared/src/features/profile/components/Activity.helpers.tsx +++ b/packages/shared/src/features/profile/components/Activity.helpers.tsx @@ -70,8 +70,11 @@ export const MIN_ITEMS_FOR_SHOW_MORE = 3; // The bleed classes are the `profileStripBleed` pair scoped to the feed's own // scroll container, so the strip clips at the card edge rather than 24px short // of it. `scroll-px-6` keeps snapped cards aligned with the section heading. +// They match on `.grid.snap-x` (only FeedContainer's horizontal scroller has +// both) rather than plain `.grid`: a bare `grid` class also appears inside list +// cards, and margins there would visibly break them. export const HORIZONTAL_FEED_CLASSES = - '[&_.grid]:!auto-cols-[17rem] tablet:[&_.grid]:!auto-cols-[20rem] [&_.grid]:gap-4 [&_.grid]:-mx-6 [&_.grid]:px-6 [&_.grid]:scroll-px-6'; + '[&_.grid]:!auto-cols-[17rem] tablet:[&_.grid]:!auto-cols-[20rem] [&_.grid]:gap-4 [&_.grid.snap-x]:-mx-6 [&_.grid.snap-x]:px-6 [&_.grid.snap-x]:scroll-px-6'; export const TAB_ITEMS = activityTabs.map((tab) => ({ label: tab.title })); export const ACTIVITY_QUERY_KEYS = { diff --git a/packages/shared/src/features/profile/components/Activity.tsx b/packages/shared/src/features/profile/components/Activity.tsx index 12f8d362c21..d77ce21a82d 100644 --- a/packages/shared/src/features/profile/components/Activity.tsx +++ b/packages/shared/src/features/profile/components/Activity.tsx @@ -1,5 +1,6 @@ import type { ReactElement } from 'react'; import React, { useContext, useState, useMemo, useCallback } from 'react'; +import classNames from 'classnames'; import type { PublicProfile } from '../../../lib/user'; import AuthContext from '../../../contexts/AuthContext'; import { ActivityTabIndex, activityTabs } from './Activity.helpers'; @@ -71,9 +72,18 @@ export const Activity = ({ user }: ActivityProps): ReactElement | null => { } return ( - // No overflow guard here: the feed grid scrolls itself and bleeds past this - // box to the card edge (see HORIZONTAL_FEED_CLASSES), so clipping at the - // section's padding would cut the strip short of the page edge again. -
{renderContent()}
+
+ {renderContent()} +
); };