diff --git a/packages/shared/src/components/buttons/CardAction.tsx b/packages/shared/src/components/buttons/CardAction.tsx index f5e64df8b7d..732b8410ddc 100644 --- a/packages/shared/src/components/buttons/CardAction.tsx +++ b/packages/shared/src/components/buttons/CardAction.tsx @@ -14,18 +14,22 @@ import { ButtonSize, ButtonVariant, ButtonIconPosition } from './common'; import type { ColorName } from '../../styles/colors'; import InteractionCounter from '../InteractionCounter'; -export type CardActionDensity = 'comfortable' | 'compact'; +export type CardActionDensity = 'comfortable' | 'compact' | 'tight'; const densityToSize: Record = { comfortable: ButtonSize.Medium, compact: ButtonSize.Small, + tight: ButtonSize.XSmall, }; // Larger than buttonSizeToIconSizeV2: engagement-bar icons sit closer // to a 60% ratio (Material 3, Instagram, Reddit) so they read at a glance. -const densityToIconSize: Record = { +// `tight` is the feed-card tier, sized so six actions with counters fit the +// 272px min card width without shrinking. +export const densityToIconSize: Record = { comfortable: IconSize.Small, compact: IconSize.XSmall, + tight: IconSize.Size16, }; type IconElement = React.ReactElement; diff --git a/packages/shared/src/components/buttons/CardActionBar.tsx b/packages/shared/src/components/buttons/CardActionBar.tsx index 6b0a9233a43..c753b65a4bb 100644 --- a/packages/shared/src/components/buttons/CardActionBar.tsx +++ b/packages/shared/src/components/buttons/CardActionBar.tsx @@ -10,7 +10,9 @@ export type CardActionBarLayout = const layoutToClass: Record = { default: 'gap-1', - feedCard: 'flex-1 min-w-0 gap-1 justify-between', + // No `gap`: `justify-between` already spreads the actions, and since buttons + // never shrink a gap only adds width the 272px min card cannot give back. + feedCard: 'flex-1 min-w-0 justify-between', between: 'gap-1 justify-between w-full', compact: 'gap-0.5', }; diff --git a/packages/shared/src/components/cards/common/ActionButtons.spec.tsx b/packages/shared/src/components/cards/common/ActionButtons.spec.tsx new file mode 100644 index 00000000000..96af6772532 --- /dev/null +++ b/packages/shared/src/components/cards/common/ActionButtons.spec.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import ActionButtons from './ActionButtons'; +import type { ActionButtonsVariant } from './ActionButtons'; +import post from '../../../../__tests__/fixture/post'; +import { TestBootProvider } from '../../../../__tests__/helpers/boot'; +import { usePostImpressions } from '../../../hooks/post/usePostImpressions'; +import { useEngagementBarV2 } from '../../../hooks/useEngagementBarV2'; +import { useViewSize } from '../../../hooks/useViewSize'; + +jest.mock('../../../hooks/post/usePostImpressions', () => ({ + usePostImpressions: jest.fn(), +})); + +// jsdom reports every media query as unmatched, so the viewport is forced: +// the award gate must behave the same on both sides of the laptop breakpoint. +jest.mock('../../../hooks/useViewSize', () => ({ + ...jest.requireActual('../../../hooks/useViewSize'), + useViewSize: jest.fn(), +})); + +jest.mock('../../../hooks/post/usePostImpressionsModal', () => ({ + usePostImpressionsModal: () => jest.fn(), +})); + +jest.mock('../../../hooks/useEngagementBarV2', () => ({ + useEngagementBarV2: jest.fn(), +})); + +jest.mock('../../post/PostAwardAction', () => ({ + __esModule: true, + default: () =>
, +})); + +const mockImpressions = (enabled: boolean) => + jest.mocked(usePostImpressions).mockReturnValue({ + enabled, + showImpressions: enabled, + impressions: enabled ? 1000 : 0, + }); + +const renderComponent = (variant: ActionButtonsVariant) => + render( + + + , + ); + +const variants: ActionButtonsVariant[] = ['grid', 'list', 'signal']; + +describe.each([ + [false, false], + [false, true], + [true, false], + [true, true], +])('ActionButtons (v2: %s, laptop: %s)', (isV2, isLaptop) => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(useEngagementBarV2).mockReturnValue(isV2); + jest.mocked(useViewSize).mockReturnValue(isLaptop); + }); + + it.each(variants)( + 'hides the award action on a %s card when impressions are enabled', + (variant) => { + mockImpressions(true); + + renderComponent(variant); + + expect(screen.queryByTestId('award-action')).not.toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Impressions' }), + ).toBeInTheDocument(); + }, + ); + + it.each(variants)( + 'keeps the award action on a %s card when impressions are disabled', + (variant) => { + mockImpressions(false); + + renderComponent(variant); + + expect(screen.getByTestId('award-action')).toBeInTheDocument(); + }, + ); +}); diff --git a/packages/shared/src/components/cards/common/ActionButtons.tsx b/packages/shared/src/components/cards/common/ActionButtons.tsx index 84dfd0ac500..20815b49861 100644 --- a/packages/shared/src/components/cards/common/ActionButtons.tsx +++ b/packages/shared/src/components/cards/common/ActionButtons.tsx @@ -10,17 +10,22 @@ import { LinkIcon, DownvoteIcon, } from '../../icons'; -import { ButtonColor, ButtonSize, ButtonVariant } from '../../buttons/Button'; -import { useFeedPreviewMode, useViewSize, ViewSize } from '../../../hooks'; +import { ButtonColor, ButtonVariant } from '../../buttons/Button'; +import { useFeedPreviewMode } from '../../../hooks'; import { UpvoteButtonIcon } from './UpvoteButtonIcon'; import { BookmarkButton } from '../../buttons'; -import { IconSize } from '../../Icon'; import { Tooltip } from '../../tooltip/Tooltip'; import PostAwardAction from '../../post/PostAwardAction'; import ConditionalWrapper from '../../ConditionalWrapper'; import { PostTagsPanel } from '../../post/block/PostTagsPanel'; import { LinkWithTooltip } from '../../tooltips/LinkWithTooltip'; import { useCardActions } from '../../../hooks/cards/useCardActions'; +import { + actionCounterClassName as counterClassName, + actionCounterLabelClassName as counterLabelClassName, + FEED_ACTION_BUTTON_SIZE, + FEED_ACTION_ICON_SIZE, +} from './actionCounter'; import { useBrandSponsorship } from '../../../hooks/useBrandSponsorship'; import { usePostImpressionsModal } from '../../../hooks/post/usePostImpressionsModal'; import { usePostImpressions } from '../../../hooks/post/usePostImpressions'; @@ -45,22 +50,24 @@ export interface ActionButtonsProps { const variantConfig = { grid: { - buttonSize: ButtonSize.Small, - iconSize: IconSize.XSmall, - containerClassName: 'px-1 pb-1', + buttonSize: FEED_ACTION_BUTTON_SIZE, + iconSize: FEED_ACTION_ICON_SIZE, + // Asymmetric: an icon sits on the left edge and the impressions number on + // the right, which needs more room to look optically centred. + containerClassName: 'py-1.5 pl-1 pr-2.5', showTagsPanel: false, useCommentLink: false, }, list: { - buttonSize: ButtonSize.Small, - iconSize: IconSize.XSmall, + buttonSize: FEED_ACTION_BUTTON_SIZE, + iconSize: FEED_ACTION_ICON_SIZE, containerClassName: '', showTagsPanel: true, useCommentLink: true, }, signal: { - buttonSize: ButtonSize.Small, - iconSize: IconSize.XSmall, + buttonSize: FEED_ACTION_BUTTON_SIZE, + iconSize: FEED_ACTION_ICON_SIZE, containerClassName: '', showTagsPanel: false, useCommentLink: true, @@ -81,14 +88,7 @@ const ActionButtonsV1 = ({ }: ActionButtonsProps): ReactElement | null => { const config = variantConfig[variant]; const isFeedPreview = useFeedPreviewMode(); - const isLaptop = useViewSize(ViewSize.Laptop); const { buttonSize, iconSize } = config; - // On mobile/tablet keep full-size icons but shrink the count so the icon - // reads as the primary affordance and the number as a subtle stat. - const counterClassName = classNames( - 'tabular-nums', - isLaptop ? variant === 'grid' && 'typo-footnote' : 'typo-caption1', - ); const { getUpvoteAnimation } = useBrandSponsorship(); const { @@ -145,7 +145,7 @@ const ActionButtonsV1 = ({ href={post.commentsPermalink} > } pressed={post.commented} @@ -206,7 +206,7 @@ const ActionButtonsV1 = ({ side={variant === 'grid' ? 'bottom' : undefined} > )} - {/* When impressions are enabled, drop awards below laptop to make room - for the extra action; with the flag off, awards stay on every - viewport (unchanged from control). */} - {showAwardAction && (!impressionsEnabled || isLaptop) && ( - + {showAwardAction && !impressionsEnabled && ( + )} } diff --git a/packages/shared/src/components/cards/common/ActionButtons.v2.tsx b/packages/shared/src/components/cards/common/ActionButtons.v2.tsx index 38a382a8388..fd9197cbe21 100644 --- a/packages/shared/src/components/cards/common/ActionButtons.v2.tsx +++ b/packages/shared/src/components/cards/common/ActionButtons.v2.tsx @@ -11,7 +11,7 @@ import { DownvoteIcon, } from '../../icons'; import { ButtonColor } from '../../buttons/ButtonV2'; -import { useFeedPreviewMode, useViewSize, ViewSize } from '../../../hooks'; +import { useFeedPreviewMode } from '../../../hooks'; import { UpvoteButtonIcon } from './UpvoteButtonIcon'; import { BookmarkButton } from '../../buttons/BookmarkButton.v2'; import { Tooltip } from '../../tooltip/Tooltip'; @@ -39,11 +39,13 @@ export interface ActionButtonsProps { showAwardAction?: boolean; } -const FEED_CARD_DENSITY = 'compact'; +const FEED_CARD_DENSITY = 'tight'; const variantConfig = { grid: { - containerClassName: 'px-1 pb-1', + // Matches the v1 bar: `py-1.5` holds the row at 36px around the h-6 + // buttons, and the wider right edge gives the trailing number room. + containerClassName: 'py-1.5 pl-1 pr-2.5', showTagsPanel: false, useCommentLink: false, }, @@ -73,9 +75,6 @@ const ActionButtons = ({ }: ActionButtonsProps): ReactElement | null => { const config = variantConfig[variant]; const isFeedPreview = useFeedPreviewMode(); - // When impressions are enabled, awards are hidden below laptop (tablet + - // mobile) to make room for the extra action. - const isLaptop = useViewSize(ViewSize.Laptop); const { getUpvoteAnimation } = useBrandSponsorship(); const { @@ -207,7 +206,7 @@ const ActionButtons = ({ /> )} - {showAwardAction && (!impressionsEnabled || isLaptop) && ( + {showAwardAction && !impressionsEnabled && ( )} ({ + useAuthContext: jest.fn(), +})); + +jest.mock('../../hooks/useCoresFeature', () => ({ + useCanAwardUser: jest.fn(), +})); + +jest.mock('../../hooks/useEngagementBarV2', () => ({ + useEngagementBarV2: jest.fn(), +})); + +jest.mock('../../hooks/useLazyModal', () => ({ + useLazyModal: () => ({ openModal: jest.fn() }), +})); + +const post = { + id: 'p1', + numAwards: 0, +} as Post; + +const mockAuth = (user: Partial | null) => + jest + .mocked(useAuthContext) + .mockReturnValue({ user, showLogin: jest.fn() } as never); + +const renderComponent = (postProps: Partial = {}) => + render( + + + , + ); + +describe.each([false, true])('PostAwardAction (v2: %s)', (isV2) => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(useEngagementBarV2).mockReturnValue(isV2); + jest.mocked(useCanAwardUser).mockReturnValue(false); + }); + + it('stays hidden for a logged out user on a post without an author', () => { + mockAuth(null); + + renderComponent(); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + // Owned by useCanAwardUser, which returns false without a sending user. + it('stays hidden for a logged out user on an authored post', () => { + mockAuth(null); + + renderComponent({ author: { id: 'u2' } as Post['author'] }); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('stays hidden for a logged in user on a post without an author', () => { + mockAuth({ id: 'u1' }); + + renderComponent(); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('renders on the logged in user own post', () => { + mockAuth({ id: 'u1' }); + + renderComponent({ author: { id: 'u1' } as Post['author'] }); + + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + + it('renders when the author can be awarded', () => { + mockAuth({ id: 'u1' }); + jest.mocked(useCanAwardUser).mockReturnValue(true); + + renderComponent({ author: { id: 'u2' } as Post['author'] }); + + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + + it('sizes the button like the other feed actions', () => { + mockAuth({ id: 'u1' }); + + renderComponent({ author: { id: 'u1' } as Post['author'] }); + + expect(screen.getByRole('button')).toHaveClass('h-6'); + }); + + it('keeps the awarded image at the action icon size', () => { + mockAuth({ id: 'u1' }); + + renderComponent({ + author: { id: 'u1' } as Post['author'], + userState: { awarded: true } as Post['userState'], + featuredAward: { + award: { image: 'https://media.daily.dev/award.png', name: 'Award' }, + } as Post['featuredAward'], + }); + + expect(screen.getByAltText('Award')).toHaveClass('size-4'); + }); +}); diff --git a/packages/shared/src/components/post/PostAwardAction.tsx b/packages/shared/src/components/post/PostAwardAction.tsx index 34d876fb367..365d6f6ffb8 100644 --- a/packages/shared/src/components/post/PostAwardAction.tsx +++ b/packages/shared/src/components/post/PostAwardAction.tsx @@ -1,11 +1,12 @@ import React from 'react'; -import classNames from 'classnames'; import { useAuthContext } from '../../contexts/AuthContext'; import { useCanAwardUser } from '../../hooks/useCoresFeature'; import { useLazyModal } from '../../hooks/useLazyModal'; -import { ButtonColor, ButtonSize, ButtonVariant } from '../buttons/Button'; +import type { ButtonSize } from '../buttons/Button'; +import { ButtonColor, ButtonVariant } from '../buttons/Button'; import { QuaternaryButton } from '../buttons/QuaternaryButton'; -import { IconSize, iconSizeToClassName } from '../Icon'; +import type { IconSize } from '../Icon'; +import { iconSizeToClassName } from '../Icon'; import { MedalBadgeIcon } from '../icons'; import InteractionCounter from '../InteractionCounter'; import { Tooltip } from '../tooltip/Tooltip'; @@ -15,18 +16,30 @@ import { AuthTriggers } from '../../lib/auth'; import { LazyModal } from '../modals/common/types'; import type { LoggedUser } from '../../lib/user'; import { useEngagementBarV2 } from '../../hooks/useEngagementBarV2'; +import type { CardActionDensity } from '../buttons/CardAction'; +import { + actionCounterClassName, + actionCounterLabelClassName, + FEED_ACTION_BUTTON_SIZE, + FEED_ACTION_ICON_SIZE, +} from '../cards/common/actionCounter'; import PostAwardActionV2 from './PostAwardAction.v2'; export interface PostAwardActionProps { post: Post; iconSize?: IconSize; - density?: 'comfortable' | 'compact'; + buttonSize?: ButtonSize; + density?: CardActionDensity; } -const PostAwardActionV1 = ({ post, iconSize }: PostAwardActionProps) => { +const PostAwardActionV1 = ({ + post, + iconSize = FEED_ACTION_ICON_SIZE, + buttonSize = FEED_ACTION_BUTTON_SIZE, +}: PostAwardActionProps) => { const { openModal } = useLazyModal(); const { user, showLogin } = useAuthContext(); - const isSameUser = user?.id === post?.author?.id; + const isSameUser = !!user?.id && user.id === post?.author?.id; const canAward = useCanAwardUser({ sendingUser: user, receivingUser: post?.author as LoggedUser, @@ -77,17 +90,17 @@ const PostAwardActionV1 = ({ post, iconSize }: PostAwardActionProps) => { id={`post-${post.id}-award-btn`} pressed={!!post.userState?.awarded} onClick={openAwardModal} - size={ButtonSize.Small} + size={buttonSize} className="btn-tertiary-cabbage pointer-events-auto" variant={ButtonVariant.Tertiary} - labelClassName="!pl-[1px]" + labelClassName={actionCounterLabelClassName} color={ButtonColor.Cabbage} icon={ post.userState?.awarded && post.featuredAward?.award?.image ? ( {post?.featuredAward?.award?.name} ) : ( @@ -96,10 +109,7 @@ const PostAwardActionV1 = ({ post, iconSize }: PostAwardActionProps) => { > {post?.numAwards > 0 && ( )} diff --git a/packages/shared/src/components/post/PostAwardAction.v2.tsx b/packages/shared/src/components/post/PostAwardAction.v2.tsx index 8c6ec1c4e66..997db1f4cab 100644 --- a/packages/shared/src/components/post/PostAwardAction.v2.tsx +++ b/packages/shared/src/components/post/PostAwardAction.v2.tsx @@ -4,8 +4,9 @@ import { useCanAwardUser } from '../../hooks/useCoresFeature'; import { useLazyModal } from '../../hooks/useLazyModal'; import { ButtonColor } from '../buttons/ButtonV2'; import type { CardActionDensity } from '../buttons/CardAction'; -import { CardAction } from '../buttons/CardAction'; -import { IconSize, iconSizeToClassName } from '../Icon'; +import { CardAction, densityToIconSize } from '../buttons/CardAction'; +import type { IconSize } from '../Icon'; +import { iconSizeToClassName } from '../Icon'; import { MedalBadgeIcon } from '../icons'; import { Tooltip } from '../tooltip/Tooltip'; import type { Post } from '../../graphql/posts'; @@ -22,12 +23,12 @@ export interface PostAwardActionProps { const PostAwardAction = ({ post, - density = 'compact', + density = 'tight', iconSize, }: PostAwardActionProps) => { const { openModal } = useLazyModal(); const { user, showLogin } = useAuthContext(); - const isSameUser = user?.id === post?.author?.id; + const isSameUser = !!user?.id && user.id === post?.author?.id; const canAward = useCanAwardUser({ sendingUser: user, receivingUser: post?.author as LoggedUser, @@ -72,7 +73,7 @@ const PostAwardAction = ({ {post?.featuredAward?.award?.name} ) : ( diff --git a/packages/storybook/stories/components/cards/ActionBarAlignment.stories.tsx b/packages/storybook/stories/components/cards/ActionBarAlignment.stories.tsx new file mode 100644 index 00000000000..07395c5564a --- /dev/null +++ b/packages/storybook/stories/components/cards/ActionBarAlignment.stories.tsx @@ -0,0 +1,144 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import React from 'react'; +import { fn } from 'storybook/test'; +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import { PostType, UserVote } from '@dailydotdev/shared/src/graphql/posts'; +import { ArticleGrid } from '@dailydotdev/shared/src/components/cards/article/ArticleGrid'; +import ExtensionProviders from '../../extension/_providers'; +import { FeatureOverrides } from '../../../mock/GrowthBookProvider'; + +const basePost = { + id: 'article-1', + title: + 'Cloud Run now scales to zero, even when your service uses less than a single CPU or less than 1792 MB of memory', + permalink: 'https://api.daily.dev/r/article-1', + commentsPermalink: 'https://daily.dev/posts/article-1', + createdAt: '2024-01-15T10:30:00.000Z', + readTime: 8, + tags: ['javascript'], + type: PostType.Article, + image: + 'https://media.daily.dev/image/upload/f_auto,q_auto/v1/posts/article-placeholder', + userState: { vote: UserVote.None, flags: { feedbackDismiss: false } }, + // Matches the mocked boot user, so the award action renders on the + // card_impressions-off rows the way it does for a post's own author. + author: { id: 'u1', name: 'Dev Dana', username: 'devdana' }, + 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: 'machine' as const, + active: true, + }, +} as unknown as Post; + +const makePost = ( + id: string, + numUpvotes: number, + numComments: number, + impressions: number, + numAwards: number, +): Post => + ({ + ...basePost, + id, + numUpvotes, + numComments, + numAwards, + analytics: { impressions }, + } as Post); + +const cases = [ + { label: '36 · 3 · 52.4K · 4', post: makePost('a', 36, 3, 52400, 4) }, + { label: '100 · 80 · 100K · 12', post: makePost('b', 100, 80, 100000, 12) }, + { label: '200 · 80 · 200K · 99', post: makePost('c', 200, 80, 234500, 99) }, + { + label: '9999 · 999 · 1.2M · 999', + post: makePost('d', 9999, 999, 1200000, 999), + }, +]; + +const handlers = { + onPostClick: fn(), + onPostAuxClick: fn(), + onUpvoteClick: fn(), + onDownvoteClick: fn(), + onCommentClick: fn(), + onBookmarkClick: fn(), + onCopyLinkClick: fn(), + onShare: fn(), + onReadArticleClick: fn(), +}; + +const v1 = { + card_impressions: true, + engagement_bar_v2: false, +}; +const v2 = { ...v1, engagement_bar_v2: true }; +const v1Control = { ...v1, card_impressions: false }; +const v2Control = { ...v2, card_impressions: false }; + +const Row = ({ + title, + values, + width, +}: { + title: string; + values: Record; + width: string; +}) => ( +
+

{title}

+
+ {cases.map(({ label, post }) => ( +
+

{label}

+ + + +
+ ))} +
+
+); + +const ActionBarAlignment = () => ( + +
+ + + + + + +
+
+); + +const meta: Meta = { + title: 'Components/Cards/ActionBarAlignment', + component: ActionBarAlignment, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +export const Default: StoryObj = {}; diff --git a/packages/storybook/stories/components/cards/ActionBarBeforeAfter.stories.tsx b/packages/storybook/stories/components/cards/ActionBarBeforeAfter.stories.tsx new file mode 100644 index 00000000000..904d4913734 --- /dev/null +++ b/packages/storybook/stories/components/cards/ActionBarBeforeAfter.stories.tsx @@ -0,0 +1,326 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import React from 'react'; +import { fn } from 'storybook/test'; +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import { PostType, UserVote } from '@dailydotdev/shared/src/graphql/posts'; +import ActionButtons from '@dailydotdev/shared/src/components/cards/common/ActionButtons'; +import InteractionCounter from '@dailydotdev/shared/src/components/InteractionCounter'; +import { QuaternaryButton } from '@dailydotdev/shared/src/components/buttons/QuaternaryButton'; +import { CardAction } from '@dailydotdev/shared/src/components/buttons/CardAction'; +import { CardActionBar } from '@dailydotdev/shared/src/components/buttons/CardActionBar'; +import { + ButtonColor, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { ButtonColor as ButtonColorV2 } from '@dailydotdev/shared/src/components/buttons/ButtonV2'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { + AnalyticsIcon, + BookmarkIcon, + DiscussIcon, + DownvoteIcon, + LinkIcon, + MedalBadgeIcon, + UpvoteIcon, +} from '@dailydotdev/shared/src/components/icons'; +import ExtensionProviders from '../../extension/_providers'; +import { FeatureOverrides } from '../../../mock/GrowthBookProvider'; + +const post = { + id: 'article-1', + title: 'Cloud Run now scales to zero', + permalink: 'https://api.daily.dev/r/article-1', + commentsPermalink: 'https://daily.dev/posts/article-1', + createdAt: '2024-01-15T10:30:00.000Z', + readTime: 8, + type: PostType.Article, + numUpvotes: 200, + numComments: 80, + numAwards: 99, + analytics: { impressions: 234500 }, + userState: { vote: UserVote.None, flags: { feedbackDismiss: false } }, + author: { id: 'u1', name: 'Dev Dana', username: 'devdana' }, + 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: 'machine' as const, + active: true, + }, +} as unknown as Post; + +const impressionsOn = { + card_impressions: true, + engagement_bar_v2: false, +}; +const impressionsOnV2 = { ...impressionsOn, engagement_bar_v2: true }; +const control = { ...impressionsOn, card_impressions: false }; +const controlV2 = { ...impressionsOnV2, card_impressions: false }; + +/** + * The v1 bar exactly as it stands on `main`: Small buttons, XSmall icons, + * `px-1 pb-1`, `!pl-[1px]` counters, and the award action hardcoded to Small. + * Kept as plain markup so the comparison survives future edits to the real one. + */ +const MainV1Bar = ({ withAward }: { withAward: boolean }) => ( +
+
+ } + > + + + } + > + + + } + /> + {withAward && ( + } + > + + + )} + } + /> + } + /> + {!withAward && ( + } + > + + + )} +
+
+); + +/** The v2 bar on `main`: compact density plus the `gap-1` on the feed row. */ +const MainV2Bar = ({ withAward }: { withAward: boolean }) => ( +
+ + } + label="Upvote" + count={200} + /> + } + label="Comments" + count={80} + /> + } + label="Downvote" + /> + {withAward && ( + } + label="Award" + count={99} + /> + )} + } + label="Bookmark" + /> + } + label="Copy link" + /> + {!withAward && ( + } + label="Impressions" + count={234500} + /> + )} + +
+); + +const CardFrame = ({ + width, + children, +}: { + width: number; + children: React.ReactNode; +}) => ( +
+
+ {children} +
+); + +const Pair = ({ + title, + note, + width, + before, + values, +}: { + title: string; + note: string; + width: number; + before: React.ReactNode; + values: Record; +}) => ( +
+

{title}

+

{note}

+
+
+

+ Before — main +

+ {before} +
+
+

+ After — this PR +

+ + + + + +
+
+
+); + +const ActionBarBeforeAfter = () => ( + +
+

+ Default card action bar — before / after +

+

+ Every card here holds the same post — 200 upvotes, 80 comments, 99 + awards, 234.5K impressions — so the only difference between the two + columns is the bar itself. +

+ + } + /> + + } + /> + + } + /> + + } + /> + + } + /> +
+
+); + +const meta: Meta = { + title: 'Components/Cards/ActionBarBeforeAfter', + component: ActionBarBeforeAfter, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +export const Default: StoryObj = {}; diff --git a/packages/webapp/pages/dev/buttons.tsx b/packages/webapp/pages/dev/buttons.tsx index 0b9b222e59a..6cfcd93842d 100644 --- a/packages/webapp/pages/dev/buttons.tsx +++ b/packages/webapp/pages/dev/buttons.tsx @@ -621,14 +621,14 @@ const V1EngagementBar = ({ pressed = false }: { pressed?: boolean }) => ( ); /** - * v2 engagement bar — `density="compact"` + `feedCard` layout, the - * production rule for grid cards. Width footprint matches v1 - * exactly (5 × 32 px), so swapping in is layout-neutral. + * v2 engagement bar — `density="tight"` + `feedCard` layout, the + * production rule for grid cards. 24 px buttons with 16 px icons, + * matching the v1 bar. */ const V2EngagementBar = ({ pressed = false }: { pressed?: boolean }) => ( } iconPressed={} @@ -637,28 +637,28 @@ const V2EngagementBar = ({ pressed = false }: { pressed?: boolean }) => ( count={1234} /> } iconPressed={} label="Downvote" color={ButtonColor.Ketchup} /> } label="Comment" color={ButtonColor.BlueCheese} count={42} /> } iconPressed={} label="Bookmark" color={ButtonColor.Bun} /> } label="Copy link" color={ButtonColor.Cabbage} @@ -1141,7 +1141,7 @@ const ButtonsDevPage = (): ReactElement => { Subtle = outlined chip, Material 3 outlined pattern).{' '} Card-action width contract: on a multi-column feed grid (where a card can render at 140 – 280 px), use{' '} - density="compact" on every{' '} + density="tight" on every{' '} CardAction and wrap the row in{' '} <CardActionBar layout="feedCard"> — the bar then sits at flex-1 min-w-0 justify-between{' '} @@ -1389,7 +1389,7 @@ const ButtonsDevPage = (): ReactElement => {
{
} iconPressed={} label="Upvote" color={ButtonColor.Avocado} /> } label="Comment" color={ButtonColor.BlueCheese} /> } iconPressed={} label="Bookmark" color={ButtonColor.Bun} /> } label="Share" /> @@ -1506,7 +1506,7 @@ const ButtonsDevPage = (): ReactElement => {
} iconPressed={} label="Upvote" @@ -1514,21 +1514,21 @@ const ButtonsDevPage = (): ReactElement => { count={1234} /> } label="Comment" color={ButtonColor.BlueCheese} count={42} /> } iconPressed={} label="Bookmark" color={ButtonColor.Bun} /> } label="Share" /> @@ -1584,7 +1584,7 @@ const ButtonsDevPage = (): ReactElement => {
} iconPressed={} @@ -1593,14 +1593,14 @@ const ButtonsDevPage = (): ReactElement => { count={1235} /> } label="Comment" color={ButtonColor.BlueCheese} count={42} /> } iconPressed={} @@ -1608,7 +1608,7 @@ const ButtonsDevPage = (): ReactElement => { color={ButtonColor.Bun} /> } label="Share" /> @@ -1685,34 +1685,34 @@ const ButtonsDevPage = (): ReactElement => {
} iconPressed={} label="Upvote" color={ButtonColor.Avocado} /> } iconPressed={} label="Downvote" color={ButtonColor.Ketchup} /> } label="Comment" color={ButtonColor.BlueCheese} /> } iconPressed={} label="Bookmark" color={ButtonColor.Bun} /> } label="Share" /> @@ -1773,13 +1773,13 @@ const ButtonsDevPage = (): ReactElement => {
- RIGHT @ 272 px · feedCard layout + compact density — bar + RIGHT @ 272 px · feedCard layout + tight density — bar fills 272 px, distributes children, never pushes the card
} iconPressed={} label="Upvote" @@ -1787,28 +1787,28 @@ const ButtonsDevPage = (): ReactElement => { count={1234} /> } iconPressed={} label="Downvote" color={ButtonColor.Ketchup} /> } label="Comment" color={ButtonColor.BlueCheese} count={42} /> } iconPressed={} label="Bookmark" color={ButtonColor.Bun} /> } label="Share" />