From dc78eb946654455af0cf3f853467b877a95c3b62 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 9 Jun 2026 14:54:19 +0300 Subject: [PATCH 01/23] feat(cards): glass floating action bar on feed cards behind a flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Float the engagement bar over the bottom of the cover image with an iOS/macOS-style dark glass (translucent + blur) treatment and shrink the grid card height since the bar no longer takes its own row. Gated by the new `feed_card_glass_actions` GrowthBook flag. The flag defaults to `true` for now so the design can be reviewed by default — flip to `false` before running the real experiment. Co-Authored-By: Claude Opus 4.8 --- .../components/cards/article/ArticleGrid.tsx | 37 +++-- .../cards/common/FeedCardGlassActions.tsx | 24 +++ .../src/hooks/useFeedCardGlassActions.ts | 12 ++ packages/shared/src/lib/featureManagement.ts | 9 ++ packages/shared/tailwind.config.ts | 3 + .../cards/GlassActionsGrid.stories.tsx | 153 ++++++++++++++++++ 6 files changed, 226 insertions(+), 12 deletions(-) create mode 100644 packages/shared/src/components/cards/common/FeedCardGlassActions.tsx create mode 100644 packages/shared/src/hooks/useFeedCardGlassActions.ts create mode 100644 packages/storybook/stories/components/cards/GlassActionsGrid.stories.tsx diff --git a/packages/shared/src/components/cards/article/ArticleGrid.tsx b/packages/shared/src/components/cards/article/ArticleGrid.tsx index 4abb8fcd9c2..e9bc6660fbf 100644 --- a/packages/shared/src/components/cards/article/ArticleGrid.tsx +++ b/packages/shared/src/components/cards/article/ArticleGrid.tsx @@ -23,9 +23,11 @@ import PostTags from '../common/PostTags'; import PostMetadata from '../common/PostMetadata'; import { PostCardFooter } from '../common/PostCardFooter'; import ActionButtons from '../common/ActionButtons'; +import { FeedCardGlassActions } from '../common/FeedCardGlassActions'; import { FeedbackGrid } from './feedback/FeedbackGrid'; import { ClickbaitShield } from '../common/ClickbaitShield'; import { useSmartTitle } from '../../../hooks/post/useSmartTitle'; +import { useFeedCardGlassActions } from '../../../hooks/useFeedCardGlassActions'; export const ArticleGrid = forwardRef(function ArticleGrid( { @@ -55,6 +57,7 @@ export const ArticleGrid = forwardRef(function ArticleGrid( const { showFeedback } = usePostFeedback({ post }); const { title } = useSmartTitle(post); const isVideoType = isVideoPost(post); + const glassActions = useFeedCardGlassActions(); if (isHidden) { return ( @@ -91,7 +94,7 @@ export const ArticleGrid = forwardRef(function ArticleGrid( className: getPostClassNames( post, classNames(className, showFeedback && '!p-0'), - 'min-h-card', + glassActions && !showFeedback ? 'min-h-cardGlass' : 'min-h-card', ), }} ref={ref} @@ -150,7 +153,7 @@ export const ArticleGrid = forwardRef(function ArticleGrid( /> )} - + - {!showFeedback && ( - - )} + {!showFeedback && + (glassActions ? ( + + ) : ( + + ))} {children} diff --git a/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx b/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx new file mode 100644 index 00000000000..82cb34b2b1a --- /dev/null +++ b/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx @@ -0,0 +1,24 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import type { ActionButtonsProps } from './ActionButtons'; +import ActionButtons from './ActionButtons'; + +// iOS/macOS-style "liquid glass" bar: a consistently dark translucent tint +// (so it reads in both themes over any cover image) plus a heavy backdrop blur. +// `--button-default-color` recolors the resting action icons to white; their +// pressed/hover brand tints stay bright enough to pop against the dark glass. +const glassContainerClasses = classNames( + 'pointer-events-auto absolute inset-x-2 bottom-2 z-1 flex items-center', + 'rounded-12 border border-border-subtlest-tertiary px-0.5', + 'bg-overlay-primary-pepper shadow-3 backdrop-blur-2xl', + 'text-white [--button-default-color:theme(colors.white)]', +); + +export function FeedCardGlassActions(props: ActionButtonsProps): ReactElement { + return ( +
+ +
+ ); +} diff --git a/packages/shared/src/hooks/useFeedCardGlassActions.ts b/packages/shared/src/hooks/useFeedCardGlassActions.ts new file mode 100644 index 00000000000..57b1c7b77a0 --- /dev/null +++ b/packages/shared/src/hooks/useFeedCardGlassActions.ts @@ -0,0 +1,12 @@ +import { useAuthContext } from '../contexts/AuthContext'; +import { useConditionalFeature } from './useConditionalFeature'; +import { featureFeedCardGlassActions } from '../lib/featureManagement'; + +export const useFeedCardGlassActions = (): boolean => { + const { isAuthReady } = useAuthContext(); + const { value } = useConditionalFeature({ + feature: featureFeedCardGlassActions, + shouldEvaluate: isAuthReady, + }); + return value; +}; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 5e5361928e4..76f3831d605 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -231,6 +231,15 @@ export const featureHeroCards = new Feature('hero_cards', { }, }); +// Floats the feed card action bar over the cover image with an iOS-style glass +// (dark translucent + blur) effect and shrinks the card height. +// NOTE: defaulted to `true` to showcase the design mock-up — flip to `false` +// before running the real GrowthBook experiment. +export const featureFeedCardGlassActions = new Feature( + 'feed_card_glass_actions', + true, +); + export const featureOnboardingPermissionPrimer = new Feature( 'onboarding_permission_primer', false, diff --git a/packages/shared/tailwind.config.ts b/packages/shared/tailwind.config.ts index f644e689a4f..6afc5cbd4c1 100644 --- a/packages/shared/tailwind.config.ts +++ b/packages/shared/tailwind.config.ts @@ -197,6 +197,9 @@ export default { minHeight: { page: 'calc(100vh - 4rem)', card: '24rem', + // Shorter floor for the glass-action-bar feed card variant: the action + // bar floats over the cover image instead of taking its own row. + cardGlass: '21.5rem', }, gap: { unset: 'unset', diff --git a/packages/storybook/stories/components/cards/GlassActionsGrid.stories.tsx b/packages/storybook/stories/components/cards/GlassActionsGrid.stories.tsx new file mode 100644 index 00000000000..34b31123d59 --- /dev/null +++ b/packages/storybook/stories/components/cards/GlassActionsGrid.stories.tsx @@ -0,0 +1,153 @@ +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 { ArticleGrid } from '@dailydotdev/shared/src/components/cards/article/ArticleGrid'; + +import ExtensionProviders from '../../extension/_providers'; + +const mockSource = { + 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, +}; + +const mockAuthor = { + id: 'author-1', + name: 'John Developer', + image: 'https://media.daily.dev/image/upload/f_auto/v1/avatars/default', + permalink: 'https://app.daily.dev/johndeveloper', + username: 'johndeveloper', + bio: 'Full-stack developer', +}; + +const basePost = { + numUpvotes: 42, + numComments: 12, + bookmarked: false, + read: false, + upvoted: false, + commented: false, + tags: ['javascript', 'react', 'typescript'], + source: mockSource, + author: mockAuthor, + readTime: 8, + createdAt: '2024-01-15T10:30:00.000Z', + permalink: 'https://api.daily.dev/r/article-1', + commentsPermalink: 'https://daily.dev/posts/article-1', + image: + 'https://media.daily.dev/image/upload/f_auto,q_auto/v1/posts/article-placeholder', + type: PostType.Article, + userState: { + vote: UserVote.None, + flags: { feedbackDismiss: false }, + }, +}; + +const make = (overrides: Partial): Post => + ({ ...basePost, ...overrides } as Post); + +const posts: Post[] = [ + make({ + id: 'glass-1', + title: + 'Understanding React Server Components: A Deep Dive into the Future of Web Development', + }), + make({ + id: 'glass-2', + title: 'A short and punchy headline', + numUpvotes: 1280, + numComments: 342, + }), + make({ + id: 'glass-3', + title: 'The TypeScript features you are probably not using yet', + bookmarked: true, + }), + make({ + id: 'glass-4', + title: 'Why everyone is talking about edge rendering in 2024', + read: true, + numUpvotes: 7, + }), + make({ + id: 'glass-5', + title: 'Building resilient systems with queues and idempotency keys', + trending: 25, + }), + make({ + id: 'glass-6', + title: 'How we cut our bundle size in half (and what broke along the way)', + userState: { vote: UserVote.Up, flags: { feedbackDismiss: false } }, + upvoted: true, + numUpvotes: 512, + }), +]; + +const actionHandlers = { + onPostClick: fn(), + onPostAuxClick: fn(), + onUpvoteClick: fn(), + onDownvoteClick: fn(), + onCommentClick: fn(), + onBookmarkClick: fn(), + onCopyLinkClick: fn(), + onShare: fn(), + onReadArticleClick: fn(), +}; + +const gridContainerStyle = { + '--num-cards': 3, + '--feed-gap': '2rem', +} as React.CSSProperties; + +const GlassActionsGrid = () => ( + +
+

+ Feed cards — glass floating actions +

+

+ The engagement bar floats over the bottom of the cover image with an + iOS-style dark glass (translucent + blur) treatment, and each card is + shorter because the bar no longer takes its own row. Gated by the{' '} + feed_card_glass_actions GrowthBook flag (on by default in + this mock-up). Toggle Storybook's light/dark theme to see both. +

+
+ {posts.map((post) => ( + + ))} +
+
+
+); + +const meta: Meta = { + title: 'Components/Cards/Glass Actions Grid', + component: GlassActionsGrid, + parameters: { + layout: 'fullscreen', + }, +}; + +export default meta; + +type Story = StoryObj; + +export const GlassActions: Story = { + render: () => , + name: 'Glass Floating Actions', +}; From d298b6ee867949c6df95a5e2460e3bee1d8306cc Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 9 Jun 2026 21:03:45 +0300 Subject: [PATCH 02/23] feat(cards): extend glass floating actions to share/collection/freeform Apply the floating glass action bar to the other image-based grid cards (shared-to-squad, collection, image freeform), gated by the same feed_card_glass_actions flag. The bar floats only when the card has a cover image to float over; poll, text-only freeform, and the shared-post preview/tweet/empty footers keep the inline bar so it never covers text or vote options. Co-Authored-By: Claude Opus 4.8 --- .../cards/Freeform/FreeformGrid.tsx | 46 +++++-- .../cards/collection/CollectionGrid.tsx | 39 ++++-- .../src/components/cards/share/ShareGrid.tsx | 42 +++++-- .../cards/GlassActionsGrid.stories.tsx | 114 +++++++++++++++++- 4 files changed, 207 insertions(+), 34 deletions(-) diff --git a/packages/shared/src/components/cards/Freeform/FreeformGrid.tsx b/packages/shared/src/components/cards/Freeform/FreeformGrid.tsx index 9ed4e2fb7b3..f52f9a53f12 100644 --- a/packages/shared/src/components/cards/Freeform/FreeformGrid.tsx +++ b/packages/shared/src/components/cards/Freeform/FreeformGrid.tsx @@ -16,8 +16,10 @@ import { SquadPostCardHeader } from '../common/SquadPostCardHeader'; import PostMetadata from '../common/PostMetadata'; import { WelcomePostCardFooter } from '../common/WelcomePostCardFooter'; import ActionButtons from '../common/ActionButtons'; +import { FeedCardGlassActions } from '../common/FeedCardGlassActions'; import { ClickbaitShield } from '../common/ClickbaitShield'; import { useSmartTitle } from '../../../hooks/post/useSmartTitle'; +import { useFeedCardGlassActions } from '../../../hooks/useFeedCardGlassActions'; import { useHiddenFeedbackPanel } from '../../../hooks/post/useHiddenFeedbackPanel'; export const FreeformGrid = forwardRef(function SharePostCard( @@ -43,6 +45,10 @@ export const FreeformGrid = forwardRef(function SharePostCard( const image = usePostImage(post); const { title } = useSmartTitle(post); const { isHidden, content: hiddenPanel } = useHiddenFeedbackPanel(post); + const glassActions = useFeedCardGlassActions(); + // Glass bar floats over the cover image; text-only freeform posts keep the + // inline bar so it doesn't overlap the contentHtml. + const useGlass = glassActions && !!image; if (isHidden) { return ( @@ -64,7 +70,11 @@ export const FreeformGrid = forwardRef(function SharePostCard( - + - + {useGlass ? ( + + ) : ( + + )} {children} diff --git a/packages/shared/src/components/cards/collection/CollectionGrid.tsx b/packages/shared/src/components/cards/collection/CollectionGrid.tsx index fe03639b74b..ee21135bea3 100644 --- a/packages/shared/src/components/cards/collection/CollectionGrid.tsx +++ b/packages/shared/src/components/cards/collection/CollectionGrid.tsx @@ -12,8 +12,10 @@ import { } from '../common/Card'; import { WelcomePostCardFooter } from '../common/WelcomePostCardFooter'; import ActionButtons from '../common/ActionButtons'; +import { FeedCardGlassActions } from '../common/FeedCardGlassActions'; import PostMetadata from '../common/PostMetadata'; import { usePostImage } from '../../../hooks/post/usePostImage'; +import { useFeedCardGlassActions } from '../../../hooks/useFeedCardGlassActions'; import CardOverlay from '../common/CardOverlay'; import PostTags from '../common/PostTags'; import { isPostUpdated } from '../../../graphql/posts'; @@ -42,6 +44,10 @@ export const CollectionGrid = forwardRef(function CollectionCard( const onPostCardClick = () => onPostClick?.(post); const onPostCardAuxClick = () => onPostAuxClick?.(post); const { isHidden, content: hiddenPanel } = useHiddenFeedbackPanel(post); + const glassActions = useFeedCardGlassActions(); + // Glass bar floats over the cover image; image-less collections keep the + // inline bar so it doesn't overlap the title/contentHtml. + const useGlass = glassActions && !!image; if (isHidden) { return ( @@ -70,7 +76,7 @@ export const CollectionGrid = forwardRef(function CollectionCard( className: getPostClassNames( post, domProps.className ?? '', - 'min-h-card', + useGlass ? 'min-h-cardGlass' : 'min-h-card', ), }} ref={ref} @@ -106,22 +112,33 @@ export const CollectionGrid = forwardRef(function CollectionCard( numSources={post.numCollectionSources} className={classNames('mx-4', post.image ? 'my-0' : 'mb-4 mt-2')} /> - + - + {useGlass ? ( + + ) : ( + + )} {children} diff --git a/packages/shared/src/components/cards/share/ShareGrid.tsx b/packages/shared/src/components/cards/share/ShareGrid.tsx index 5318356cb73..ac240f0378f 100644 --- a/packages/shared/src/components/cards/share/ShareGrid.tsx +++ b/packages/shared/src/components/cards/share/ShareGrid.tsx @@ -17,8 +17,10 @@ import PostTags from '../common/PostTags'; import PostMetadata from '../common/PostMetadata'; import { PostCardFooter } from '../common/PostCardFooter'; import ActionButtons from '../common/ActionButtons'; +import { FeedCardGlassActions } from '../common/FeedCardGlassActions'; import { ClickbaitShield } from '../common/ClickbaitShield'; import { useSmartTitle } from '../../../hooks/post/useSmartTitle'; +import { useFeedCardGlassActions } from '../../../hooks/useFeedCardGlassActions'; import { DeletedPostId } from '../../../lib/constants'; import { SourceType } from '../../../graphql/sources'; import classed from '../../../lib/classed'; @@ -67,6 +69,12 @@ export const ShareGrid = forwardRef(function ShareGrid( const isSharedPostPreviewEnabled = useFeature(sharedPostPreviewFeature); const isSharedTweet = isSocialTwitterPost(sharedPost); const { isHidden, content: hiddenPanel } = useHiddenFeedbackPanel(post); + const glassActions = useFeedCardGlassActions(); + // The glass bar floats over a cover image; the preview/tweet/empty footers + // render text we must not cover, so keep the inline bar for those. + const footerIsCover = + !isDeleted && !isPrivate && !isSharedTweet && !isSharedPostPreviewEnabled; + const useGlass = glassActions && footerIsCover; const footer = useMemo(() => { if (isDeleted) { @@ -169,7 +177,7 @@ export const ShareGrid = forwardRef(function ShareGrid( className: getPostClassNames( post, domProps.className, - 'min-h-card max-h-card', + useGlass ? 'min-h-cardGlass max-h-card' : 'min-h-card max-h-card', ), }} ref={ref} @@ -211,16 +219,30 @@ export const ShareGrid = forwardRef(function ShareGrid( /> - + {footer} - + {useGlass ? ( + + ) : ( + + )} {children} diff --git a/packages/storybook/stories/components/cards/GlassActionsGrid.stories.tsx b/packages/storybook/stories/components/cards/GlassActionsGrid.stories.tsx index 34b31123d59..31f9af5dce9 100644 --- a/packages/storybook/stories/components/cards/GlassActionsGrid.stories.tsx +++ b/packages/storybook/stories/components/cards/GlassActionsGrid.stories.tsx @@ -4,6 +4,10 @@ import { fn } from 'storybook/test'; import { PostType, UserVote } from '@dailydotdev/shared/src/graphql/posts'; import type { Post } from '@dailydotdev/shared/src/graphql/posts'; import { ArticleGrid } from '@dailydotdev/shared/src/components/cards/article/ArticleGrid'; +import { ShareGrid } from '@dailydotdev/shared/src/components/cards/share/ShareGrid'; +import { CollectionGrid } from '@dailydotdev/shared/src/components/cards/collection/CollectionGrid'; +import { FreeformGrid } from '@dailydotdev/shared/src/components/cards/Freeform/FreeformGrid'; +import PollGrid from '@dailydotdev/shared/src/components/cards/poll/PollGrid'; import ExtensionProviders from '../../extension/_providers'; @@ -17,6 +21,18 @@ const mockSource = { active: true, }; +const mockSquadSource = { + id: 'squad-1', + handle: 'devs', + name: 'Developer Squad', + permalink: 'https://app.daily.dev/squads/devs', + image: 'https://media.daily.dev/image/upload/t_logo,f_auto/v1/logos/squad', + type: 'squad' as const, + active: true, + public: true, + membersCount: 150, +}; + const mockAuthor = { id: 'author-1', name: 'John Developer', @@ -49,8 +65,8 @@ const basePost = { }, }; -const make = (overrides: Partial): Post => - ({ ...basePost, ...overrides } as Post); +const make = (overrides: Record): Post => + ({ ...basePost, ...overrides } as unknown as Post); const posts: Post[] = [ make({ @@ -89,6 +105,73 @@ const posts: Post[] = [ }), ]; +// Mixed post types — shared-to-squad, collection and freeform float over their +// cover image; the text-only freeform and the poll deliberately keep the inline +// bar because there is no image to float over. +const sharePost = make({ + id: 'glass-share', + title: 'Great breakdown of edge rendering — worth a read', + source: mockSquadSource, + type: PostType.Share, + sharedPost: { + id: 'shared-article-1', + title: 'TypeScript Best Practices for 2024', + image: + 'https://media.daily.dev/image/upload/f_auto,q_auto/v1/posts/article-placeholder', + readTime: 11, + permalink: 'https://api.daily.dev/r/shared-article-1', + commentsPermalink: 'https://app.daily.dev/posts/shared-article-1', + summary: 'Learn the best TypeScript practices for modern development.', + createdAt: '2024-01-07T19:26:43.146Z', + private: false, + type: PostType.Article, + tags: ['typescript'], + source: mockSource, + }, +}); + +const collectionPost = make({ + id: 'glass-collection', + title: 'Essential React Hooks Every Developer Should Know', + source: mockSource, + type: PostType.Collection, + readTime: 15, + collectionSources: [mockSource, mockSquadSource], + numCollectionSources: 5, +}); + +const freeformPost = make({ + id: 'glass-freeform', + title: 'Just shipped a new feature! 🚀', + source: mockSquadSource, + type: PostType.Freeform, + contentHtml: '

Just shipped a new feature! 🚀

', +}); + +const freeformTextPost = make({ + id: 'glass-freeform-text', + title: 'A text-only post keeps the inline bar', + source: mockSquadSource, + type: PostType.Freeform, + image: undefined, + contentHtml: + '

This freeform post has no cover image, so the action bar stays inline below the text instead of floating.

', +}); + +const pollPost = make({ + id: 'glass-poll', + title: 'What is your favorite programming language for 2024?', + source: mockSquadSource, + type: PostType.Poll, + pollOptions: [ + { id: 'opt-1', text: 'JavaScript', order: 1, numVotes: 45 }, + { id: 'opt-2', text: 'Python', order: 2, numVotes: 32 }, + { id: 'opt-3', text: 'TypeScript', order: 3, numVotes: 28 }, + ], + endsAt: '2026-01-22T10:30:00.000Z', + numPollVotes: 105, +}); + const actionHandlers = { onPostClick: fn(), onPostAuxClick: fn(), @@ -131,6 +214,33 @@ const GlassActionsGrid = () => ( ))} + +

+ Other post types +

+

+ Shared-to-squad, collection and image freeform cards float the bar over + their cover image too. The text-only freeform and the poll keep the + inline bar — there's no image to float over, and the bar must not + cover the text or vote options. (Storybook force-enables the separate + shared-post-preview experiment, so the share card here shows its preview + layout with the inline bar; with that experiment off — the default — a + shared-to-squad post uses the cover image and gets the glass bar.) +

+
+ + + + + +
); From ea15f1207454d67ab923d152174564971f1e9553 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 10 Jun 2026 09:47:15 +0300 Subject: [PATCH 03/23] feat(cards): collapse glass action bar to a compact peek until hover By default the floating glass bar shows only a small left-aligned summary (upvote + comment counts) so it barely covers the cover image. On card hover (mouse/laptop) it expands into the full action bar; touch devices keep the full bar always visible. Co-Authored-By: Claude Opus 4.8 --- .../cards/common/FeedCardGlassActions.tsx | 59 +++++++++++++++---- .../cards/GlassActionsGrid.stories.tsx | 9 ++- 2 files changed, 55 insertions(+), 13 deletions(-) diff --git a/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx b/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx index 82cb34b2b1a..9407afa9180 100644 --- a/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx +++ b/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx @@ -3,22 +3,61 @@ import React from 'react'; import classNames from 'classnames'; import type { ActionButtonsProps } from './ActionButtons'; import ActionButtons from './ActionButtons'; +import InteractionCounter from '../../InteractionCounter'; +import { UpvoteIcon, DiscussIcon } from '../../icons'; +import { IconSize } from '../../Icon'; // iOS/macOS-style "liquid glass" bar: a consistently dark translucent tint // (so it reads in both themes over any cover image) plus a heavy backdrop blur. -// `--button-default-color` recolors the resting action icons to white; their -// pressed/hover brand tints stay bright enough to pop against the dark glass. -const glassContainerClasses = classNames( - 'pointer-events-auto absolute inset-x-2 bottom-2 z-1 flex items-center', - 'rounded-12 border border-border-subtlest-tertiary px-0.5', - 'bg-overlay-primary-pepper shadow-3 backdrop-blur-2xl', - 'text-white [--button-default-color:theme(colors.white)]', +const glassSurface = classNames( + 'rounded-12 border border-border-subtlest-tertiary', + 'bg-overlay-primary-pepper text-white shadow-3 backdrop-blur-2xl', +); + +// Collapsed "peek" state: a small left-aligned pill with just the upvote and +// comment counts, so it barely covers the cover image. Non-interactive — clicks +// fall through to the card link. Shown by default on mouse/laptop and hidden +// while hovering; on touch devices it's hidden in favor of the full bar. +const collapsedClasses = classNames( + glassSurface, + 'pointer-events-none absolute bottom-2 left-2 z-1 flex items-center gap-2', + 'px-2 py-1 tabular-nums typo-footnote', + 'opacity-0 transition-opacity duration-200', + 'laptop:mouse:opacity-100 laptop:mouse:group-hover:opacity-0', +); + +// Full bar: all actions. `--button-default-color` recolors the resting icons to +// white; their pressed/hover brand tints stay bright against the dark glass. +// On mouse/laptop it stays collapsed (hidden + non-interactive) and expands from +// the bottom-left on card hover; on touch devices it's always visible. +const fullBarClasses = classNames( + glassSurface, + 'absolute inset-x-2 bottom-2 z-1 flex items-center px-0.5', + '[--button-default-color:theme(colors.white)]', + 'origin-bottom-left transition-[opacity,transform] duration-200', + 'pointer-events-auto opacity-100', + 'laptop:mouse:pointer-events-none laptop:mouse:scale-95 laptop:mouse:opacity-0', + 'laptop:mouse:group-hover:pointer-events-auto laptop:mouse:group-hover:scale-100 laptop:mouse:group-hover:opacity-100', ); export function FeedCardGlassActions(props: ActionButtonsProps): ReactElement { + const { post } = props; + return ( -
- -
+ <> +
+ + + + + + + + +
+
+ +
+ ); } diff --git a/packages/storybook/stories/components/cards/GlassActionsGrid.stories.tsx b/packages/storybook/stories/components/cards/GlassActionsGrid.stories.tsx index 31f9af5dce9..faee997463b 100644 --- a/packages/storybook/stories/components/cards/GlassActionsGrid.stories.tsx +++ b/packages/storybook/stories/components/cards/GlassActionsGrid.stories.tsx @@ -198,9 +198,12 @@ const GlassActionsGrid = () => (

The engagement bar floats over the bottom of the cover image with an iOS-style dark glass (translucent + blur) treatment, and each card is - shorter because the bar no longer takes its own row. Gated by the{' '} - feed_card_glass_actions GrowthBook flag (on by default in - this mock-up). Toggle Storybook's light/dark theme to see both. + shorter because the bar no longer takes its own row. By default it shows + a compact left-aligned peek (upvotes + comments only) so it barely + covers the artwork; hover a card to expand it into the + full action bar. On touch devices the full bar is always shown. Gated by + the feed_card_glass_actions GrowthBook flag (on by default + in this mock-up). Toggle Storybook's light/dark theme to see both.

Date: Wed, 10 Jun 2026 09:55:21 +0300 Subject: [PATCH 04/23] feat(cards): make glass card cover image full-bleed In the glass variant the cover image now goes edge-to-edge (drop the side padding), sits flush to the card bottom (drop the bottom margin), is taller so it dominates the card, and its bottom corners are rounded to match the card. Threaded via a shared `glassCoverImageClassName` and a new `imageClassName` override on WelcomePostCardFooter. Co-Authored-By: Claude Opus 4.8 --- .../src/components/cards/Freeform/FreeformGrid.tsx | 6 +++++- .../src/components/cards/article/ArticleGrid.tsx | 11 +++++++++-- .../components/cards/collection/CollectionGrid.tsx | 6 +++++- .../components/cards/common/FeedCardGlassActions.tsx | 6 ++++++ .../components/cards/common/WelcomePostCardFooter.tsx | 4 +++- .../shared/src/components/cards/share/ShareGrid.tsx | 8 ++++++-- 6 files changed, 34 insertions(+), 7 deletions(-) diff --git a/packages/shared/src/components/cards/Freeform/FreeformGrid.tsx b/packages/shared/src/components/cards/Freeform/FreeformGrid.tsx index f52f9a53f12..2b943b33d0e 100644 --- a/packages/shared/src/components/cards/Freeform/FreeformGrid.tsx +++ b/packages/shared/src/components/cards/Freeform/FreeformGrid.tsx @@ -16,7 +16,10 @@ import { SquadPostCardHeader } from '../common/SquadPostCardHeader'; import PostMetadata from '../common/PostMetadata'; import { WelcomePostCardFooter } from '../common/WelcomePostCardFooter'; import ActionButtons from '../common/ActionButtons'; -import { FeedCardGlassActions } from '../common/FeedCardGlassActions'; +import { + FeedCardGlassActions, + glassCoverImageClassName, +} from '../common/FeedCardGlassActions'; import { ClickbaitShield } from '../common/ClickbaitShield'; import { useSmartTitle } from '../../../hooks/post/useSmartTitle'; import { useFeedCardGlassActions } from '../../../hooks/useFeedCardGlassActions'; @@ -128,6 +131,7 @@ export const FreeformGrid = forwardRef(function SharePostCard( image={image} contentHtml={post.contentHtml} post={post} + imageClassName={useGlass ? glassCoverImageClassName : undefined} /> {useGlass ? ( diff --git a/packages/shared/src/components/cards/collection/CollectionGrid.tsx b/packages/shared/src/components/cards/collection/CollectionGrid.tsx index ee21135bea3..02b9763e5c1 100644 --- a/packages/shared/src/components/cards/collection/CollectionGrid.tsx +++ b/packages/shared/src/components/cards/collection/CollectionGrid.tsx @@ -12,7 +12,10 @@ import { } from '../common/Card'; import { WelcomePostCardFooter } from '../common/WelcomePostCardFooter'; import ActionButtons from '../common/ActionButtons'; -import { FeedCardGlassActions } from '../common/FeedCardGlassActions'; +import { + FeedCardGlassActions, + glassCoverImageClassName, +} from '../common/FeedCardGlassActions'; import PostMetadata from '../common/PostMetadata'; import { usePostImage } from '../../../hooks/post/usePostImage'; import { useFeedCardGlassActions } from '../../../hooks/useFeedCardGlassActions'; @@ -118,6 +121,7 @@ export const CollectionGrid = forwardRef(function CollectionCard( contentHtml={post.contentHtml} post={post} onShare={onShare} + imageClassName={useGlass ? glassCoverImageClassName : undefined} /> {useGlass ? ( void; + imageClassName?: string; } export const WelcomePostCardFooter = ({ @@ -19,6 +20,7 @@ export const WelcomePostCardFooter = ({ image, onShare, contentHtml, + imageClassName, }: WelcomePostCardFooterProps): ReactElement | null => { const { overlay } = useCardCover({ post, @@ -49,7 +51,7 @@ export const WelcomePostCardFooter = ({ post={post} imageProps={{ src: image, - className: 'mt-2 mb-1 w-full px-1', + className: classNames('mb-1 mt-2 w-full px-1', imageClassName), alt: 'Post Cover image', }} /> diff --git a/packages/shared/src/components/cards/share/ShareGrid.tsx b/packages/shared/src/components/cards/share/ShareGrid.tsx index ac240f0378f..0753d74d277 100644 --- a/packages/shared/src/components/cards/share/ShareGrid.tsx +++ b/packages/shared/src/components/cards/share/ShareGrid.tsx @@ -17,7 +17,10 @@ import PostTags from '../common/PostTags'; import PostMetadata from '../common/PostMetadata'; import { PostCardFooter } from '../common/PostCardFooter'; import ActionButtons from '../common/ActionButtons'; -import { FeedCardGlassActions } from '../common/FeedCardGlassActions'; +import { + FeedCardGlassActions, + glassCoverImageClassName, +} from '../common/FeedCardGlassActions'; import { ClickbaitShield } from '../common/ClickbaitShield'; import { useSmartTitle } from '../../../hooks/post/useSmartTitle'; import { useFeedCardGlassActions } from '../../../hooks/useFeedCardGlassActions'; @@ -136,7 +139,7 @@ export const ShareGrid = forwardRef(function ShareGrid( openNewTab={openNewTab ?? false} post={footerPost} className={{ - image: 'px-1', + image: useGlass ? `px-1 ${glassCoverImageClassName}` : 'px-1', }} /> ); @@ -148,6 +151,7 @@ export const ShareGrid = forwardRef(function ShareGrid( openNewTab, post, sharedPost, + useGlass, ]); if (isHidden) { From ed94746aa5c12988d7ae0056a68d8d453ea63177 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 10 Jun 2026 10:12:15 +0300 Subject: [PATCH 05/23] feat(cards): morph glass bar on hover + apply to all post types Rebuild the floating bar as one component that expands: upvote + comment stay anchored on the left and the rest of the actions (downvote, award, bookmark, copy) reveal to the right on hover via an animated grid column, so the same pill grows rightward instead of cross-fading two elements. Reuses useCardActions so no mutation logic is duplicated; keeps the action tooltips for accessibility. Also extend the floating bar to every grid post type: text/markdown freeform and image-less collections float it over the content (which blurs through the glass), and polls reserve a little bottom space so the bar never covers the vote options. Co-Authored-By: Claude Opus 4.8 --- .../cards/Freeform/FreeformGrid.tsx | 13 +- .../cards/collection/CollectionGrid.tsx | 13 +- .../cards/common/FeedCardGlassActions.tsx | 208 ++++++++++++++---- .../src/components/cards/poll/PollGrid.tsx | 41 +++- .../cards/GlassActionsGrid.stories.tsx | 13 +- scripts/typecheck-strict-changed.js | 5 + 6 files changed, 222 insertions(+), 71 deletions(-) diff --git a/packages/shared/src/components/cards/Freeform/FreeformGrid.tsx b/packages/shared/src/components/cards/Freeform/FreeformGrid.tsx index 2b943b33d0e..352da6bcbc6 100644 --- a/packages/shared/src/components/cards/Freeform/FreeformGrid.tsx +++ b/packages/shared/src/components/cards/Freeform/FreeformGrid.tsx @@ -49,9 +49,10 @@ export const FreeformGrid = forwardRef(function SharePostCard( const { title } = useSmartTitle(post); const { isHidden, content: hiddenPanel } = useHiddenFeedbackPanel(post); const glassActions = useFeedCardGlassActions(); - // Glass bar floats over the cover image; text-only freeform posts keep the - // inline bar so it doesn't overlap the contentHtml. - const useGlass = glassActions && !!image; + // The floating glass bar applies to every freeform post. When there's a cover + // image it floats over it full-bleed; for text/markdown posts it floats over + // the bottom of the content (which blurs through the glass). + const useGlass = glassActions; if (isHidden) { return ( @@ -125,13 +126,15 @@ export const FreeformGrid = forwardRef(function SharePostCard( {useGlass ? ( onPostAuxClick?.(post); const { isHidden, content: hiddenPanel } = useHiddenFeedbackPanel(post); const glassActions = useFeedCardGlassActions(); - // Glass bar floats over the cover image; image-less collections keep the - // inline bar so it doesn't overlap the title/contentHtml. - const useGlass = glassActions && !!image; + // The floating glass bar applies to every collection. When there's a cover + // image it floats over it full-bleed; otherwise it floats over the bottom of + // the content (which blurs through the glass). + const useGlass = glassActions; if (isHidden) { return ( @@ -115,13 +116,15 @@ export const CollectionGrid = forwardRef(function CollectionCard( numSources={post.numCollectionSources} className={classNames('mx-4', post.image ? 'my-0' : 'mb-4 mt-2')} /> - + {useGlass ? (