From a0a48bdb0e9306dc76851977f986c1911cb21393 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 23 Jun 2026 13:00:25 +0300 Subject: [PATCH 01/26] fix(cards): even glass action bar spacing, no count clipping, no hero overlap The floating glass action bar had three spacing problems: - The award action only renders when a viewer can award a post, leaving an empty equal-width slot on most cards and making the bar look inconsistent from card to card. Removed it from the glass bar entirely. - Equal fixed-width slots with overflow-hidden clipped large counters at real card widths (e.g. "1.3K" rendered as "1.3", "512" as "51"). Switched the pill to justify-between with content-sized buttons (matching the standard action bar) so counts render in full, icons keep even gaps, and a long counter grows its own button instead of clipping or shoving a neighbour. - On the hero card the bar floated over the content column and the old pb-12 reserve didn't actually keep long titles/summaries out from behind it (overflow-hidden clips at the padding edge). Made the text column flex-1 min-h-0 overflow-hidden with an in-flow spacer that genuinely reserves the bar's footprint, so text always clips above the bar regardless of length. Co-Authored-By: Claude Opus 4.8 --- .../article/ArticleFeaturedWideGridCard.tsx | 39 ++-- .../cards/common/FeedCardGlassActions.tsx | 199 ++++++++---------- 2 files changed, 114 insertions(+), 124 deletions(-) diff --git a/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx b/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx index e333c7042c..35c7dfabb1 100644 --- a/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx +++ b/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx @@ -261,7 +261,11 @@ export const ArticleFeaturedWideGridCard = forwardRef( ) : ( <> - + {description ? ( -

+

{description}

) : null}
{useGlass ? ( - + <> + {/* Reserve the floating bar's footprint (h-10 + bottom-2) + plus a small gap in the flow so the clipped text column + always ends above it — long titles or summaries can never + render behind the bar. */} +
+ + ) : ( diff --git a/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx b/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx index 9a63092fe5..e639527c0f 100644 --- a/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx +++ b/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx @@ -6,7 +6,6 @@ import { UpvoteButtonIcon } from './UpvoteButtonIcon'; import InteractionCounter from '../../InteractionCounter'; import { QuaternaryButton } from '../../buttons/QuaternaryButton'; import { BookmarkButton } from '../../buttons/BookmarkButton'; -import PostAwardAction from '../../post/PostAwardAction'; import { DiscussIcon as CommentIcon, DownvoteIcon, @@ -28,17 +27,23 @@ const outerClasses = 'pointer-events-none absolute inset-x-2 bottom-2 z-1'; // Only the rest color is pinned to text-primary for contrast on the glass; // hover/pressed colors are left to each `btn-tertiary-*` class so icons keep // their brand tint on hover, matching the standard ActionButtons. +// +// `justify-between` (matching the standard ActionButtons row) spreads the +// actions evenly across the pill: the icons keep equal gaps and a long counter +// (e.g. 900 upvotes / 900 comments) just grows its own button instead of +// clipping or shoving a neighbour off its mark. const pillClasses = classNames( - 'pointer-events-auto flex h-10 w-full items-center overflow-hidden px-1', + 'pointer-events-auto flex h-10 w-full items-center justify-between gap-1 overflow-hidden px-2', 'rounded-12 border border-border-subtlest-tertiary', 'bg-blur-bg text-text-primary backdrop-blur-xl backdrop-saturate-150', '[&_.btn-quaternary]:[--button-default-color:var(--theme-text-primary)]', '[&_.btn]:[--button-default-color:var(--theme-text-primary)]', ); -// Every action gets an equal-width centered slot so the icons stay evenly spaced -// across the pill regardless of upvote/comment counts widening a button. -const slotClasses = 'flex min-w-0 flex-1 items-center justify-center'; +// Keep the counter compact: monospaced digits so it never jitters, and the +// `pr-1` breathing room mirrors the standard grid action bar. +const countLabelClasses = '!pl-0.5 pr-1'; +const countClasses = 'tabular-nums typo-footnote'; // Dark glow behind the pill so it stays readable over busy cover images. Fixed // pepper tint in both themes; inline gradient since it's a one-off scrim. @@ -55,7 +60,6 @@ export function FeedCardGlassActions({ onCopyLinkClick, onDownvoteClick, showDownvoteAction = true, - showAwardAction = true, coverScrim = false, }: ActionButtonsProps & { coverScrim?: boolean }): ReactElement | null { const isFeedPreview = useFeedPreviewMode(); @@ -92,118 +96,101 @@ export function FeedCardGlassActions({ )}
-
+ + + } + > + {upvoteCount > 0 && ( + + )} + + + + + } + pressed={post.commented} + onClick={() => onCommentClick?.(post)} + size={ButtonSize.Small} + className="btn-tertiary-blueCheese pointer-events-auto" + > + {commentCount > 0 && ( + + )} + + + {showDownvoteAction && ( - } - > - {upvoteCount > 0 && ( - - )} - - -
-
- - } - pressed={post.commented} - onClick={() => onCommentClick?.(post)} + pressed={isDownvoteActive} + onClick={onToggleDownvote} + variant={ButtonVariant.Tertiary} size={ButtonSize.Small} - className="btn-tertiary-blueCheese pointer-events-auto" - > - {commentCount > 0 && ( - - )} - + /> -
- {showDownvoteAction && ( -
- - - } - pressed={isDownvoteActive} - onClick={onToggleDownvote} - variant={ButtonVariant.Tertiary} - size={ButtonSize.Small} - /> - -
)} - {showAwardAction && ( -
- -
- )} -
- + + } + onClick={onCopyLink} + variant={ButtonVariant.Tertiary} + color={ButtonColor.Cabbage} + className="pointer-events-auto" /> -
-
- - } - onClick={onCopyLink} - variant={ButtonVariant.Tertiary} - color={ButtonColor.Cabbage} - className="pointer-events-auto" - /> - -
+
From 972d9dce5e469bda1fdf0bf06b15d0e1d09a7ede Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 23 Jun 2026 14:13:18 +0300 Subject: [PATCH 02/26] fix(cards): shrink hero title to fit three lines so the TLDR stays visible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A long hero title (e.g. "Valve officially supports DIY Steam Machines with SteamOS 3.8") wrapped to four lines at typo-title1, consuming the column and pushing the summary off the bottom of the card behind the glass action bar. Add useFitFontSize: a small SSR-safe hook that measures the rendered element and steps the font size down (typo-title1 → title2 → title3) until the text fits within a line budget — measuring rather than guessing from character count, since the hero content column width varies with wideColSpan. The hero title now shrinks to fit three lines (full title, no ellipsis) and only clamps as a last resort at the smallest size, leaving room for the TLDR. Co-Authored-By: Claude Opus 4.8 --- .../article/ArticleFeaturedWideGridCard.tsx | 25 +++++- packages/shared/src/hooks/useFitFontSize.ts | 85 +++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 packages/shared/src/hooks/useFitFontSize.ts diff --git a/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx b/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx index 35c7dfabb1..74a10ce9d5 100644 --- a/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx +++ b/packages/shared/src/components/cards/article/ArticleFeaturedWideGridCard.tsx @@ -25,6 +25,7 @@ import { FeedCardGlassActions } from '../common/FeedCardGlassActions'; import { FeedbackGrid } from './feedback/FeedbackGrid'; import { ClickbaitShield } from '../common/ClickbaitShield'; import { useSmartTitle } from '../../../hooks/post/useSmartTitle'; +import { useFitFontSize } from '../../../hooks/useFitFontSize'; import { useFeedCardGlassActions } from '../../../hooks/useFeedCardGlassActions'; import { usePostImage } from '../../../hooks/post/usePostImage'; import { useCardCover } from '../../../hooks/feed/useCardCover'; @@ -51,6 +52,12 @@ const IMAGE_COL_SPAN: Record = { 4: 'col-span-3', }; +// The hero title shrinks (title1 → title3) to stay within three lines so the +// summary below always has room, rather than spilling onto a fourth line and +// pushing the TLDR off the card. +const HERO_TITLE_SIZE_CLASSES = ['typo-title1', 'typo-title2', 'typo-title3']; +const HERO_TITLE_MAX_LINES = 3; + const HighlightChip = ({ significance, className, @@ -148,6 +155,15 @@ export const ArticleFeaturedWideGridCard = forwardRef( const { pinnedAt } = post; const { showFeedback } = usePostFeedback({ post }); const { title } = useSmartTitle(post); + const { + ref: titleRef, + sizeClass: titleSizeClass, + isClamped: titleClamped, + } = useFitFontSize({ + text: title, + sizeClasses: HERO_TITLE_SIZE_CLASSES, + maxLines: HERO_TITLE_MAX_LINES, + }); const isVideoType = isVideoPost(post); const image = usePostImage(post); const { overlay } = useCardCover({ post, onShare }); @@ -275,7 +291,14 @@ export const ArticleFeaturedWideGridCard = forwardRef( onReadArticleClick={onReadArticleClick} showFeedback={false} /> -

+

{title}

diff --git a/packages/shared/src/hooks/useFitFontSize.ts b/packages/shared/src/hooks/useFitFontSize.ts new file mode 100644 index 0000000000..131f522ea6 --- /dev/null +++ b/packages/shared/src/hooks/useFitFontSize.ts @@ -0,0 +1,85 @@ +import type { RefObject } from 'react'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; + +// Layout effects must run before paint to avoid a flash of the wrong size, but +// `useLayoutEffect` warns during SSR — fall back to `useEffect` on the server. +const useIsomorphicLayoutEffect = + typeof window !== 'undefined' ? useLayoutEffect : useEffect; + +interface UseFitFontSizeProps { + /** Re-measures whenever this changes (pass the rendered text). */ + text: string; + /** Font-size utility classes, largest first (e.g. typo-title1 → title3). */ + sizeClasses: string[]; + /** Maximum number of lines the text may occupy at the chosen size. */ + maxLines: number; +} + +interface UseFitFontSizeResult { + ref: RefObject; + /** The largest size class at which the text fits within `maxLines`. */ + sizeClass: string; + /** + * True once the smallest size is reached and the text still overflows — the + * caller should clamp (ellipsis) as a last resort. While stepping down we + * leave the text unclamped so `scrollHeight` reflects its true height. + */ + isClamped: boolean; +} + +/** + * Shrinks text to the largest font size at which it still fits within + * `maxLines`, measuring the rendered element rather than guessing from + * character count (the container width varies, so a count heuristic can't be + * trusted). Steps down one size per layout pass until it fits or bottoms out. + */ +export function useFitFontSize({ + text, + sizeClasses, + maxLines, +}: UseFitFontSizeProps): UseFitFontSizeResult { + const ref = useRef(null); + const [index, setIndex] = useState(0); + const lastIndex = sizeClasses.length - 1; + + // Start fresh from the largest size whenever the text changes. + useIsomorphicLayoutEffect(() => { + setIndex(0); + }, [text]); + + // After each applied size, measure and step down once if it still overflows. + useIsomorphicLayoutEffect(() => { + const el = ref.current; + if (!el || index >= lastIndex) { + return; + } + const lineHeight = parseFloat(getComputedStyle(el).lineHeight); + if (!lineHeight) { + return; + } + if (Math.round(el.scrollHeight / lineHeight) > maxLines) { + setIndex((current) => Math.min(current + 1, lastIndex)); + } + }, [text, index, maxLines, lastIndex]); + + // Re-fit from scratch when the available width changes (height changes from + // our own shrinking are ignored so we don't loop). + useIsomorphicLayoutEffect(() => { + const parent = ref.current?.parentElement; + if (!parent || typeof ResizeObserver === 'undefined') { + return undefined; + } + let lastWidth = parent.clientWidth; + const observer = new ResizeObserver((entries) => { + const width = entries[0]?.contentRect.width ?? lastWidth; + if (Math.abs(width - lastWidth) > 1) { + lastWidth = width; + setIndex(0); + } + }); + observer.observe(parent); + return () => observer.disconnect(); + }, []); + + return { ref, sizeClass: sizeClasses[index], isClamped: index >= lastIndex }; +} From 6a09438000ef442186690fe9c5176d9a781ae306 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 23 Jun 2026 14:20:45 +0300 Subject: [PATCH 03/26] style(cards): equal glass bar padding on all sides, slightly darker fill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Match the pill's left/right padding to the 4px it already leaves above and below its buttons (px-2 → px-1), so the inner padding is equal on all sides. - Darken the glass a few percent by compositing a low-opacity black layer over the bg-blur-bg token (kept inline so the shared token is untouched), for better contrast over bright cover images. Co-Authored-By: Claude Opus 4.8 --- .../cards/common/FeedCardGlassActions.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx b/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx index e639527c0f..2bbf6a1b8a 100644 --- a/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx +++ b/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx @@ -32,14 +32,23 @@ const outerClasses = 'pointer-events-none absolute inset-x-2 bottom-2 z-1'; // actions evenly across the pill: the icons keep equal gaps and a long counter // (e.g. 900 upvotes / 900 comments) just grows its own button instead of // clipping or shoving a neighbour off its mark. +// `px-1` matches the 4px the h-10 pill leaves above/below its h-8 buttons, so +// the padding is equal on all four sides. const pillClasses = classNames( - 'pointer-events-auto flex h-10 w-full items-center justify-between gap-1 overflow-hidden px-2', + 'pointer-events-auto flex h-10 w-full items-center justify-between gap-1 overflow-hidden px-1', 'rounded-12 border border-border-subtlest-tertiary', - 'bg-blur-bg text-text-primary backdrop-blur-xl backdrop-saturate-150', + 'text-text-primary backdrop-blur-xl backdrop-saturate-150', '[&_.btn-quaternary]:[--button-default-color:var(--theme-text-primary)]', '[&_.btn]:[--button-default-color:var(--theme-text-primary)]', ); +// The glass fill, a few % darker than the bare `bg-blur-bg` token: a low-opacity +// black layer composited over the theme blur colour (kept inline so it stays a +// one-off and doesn't shift the shared token). backdrop-blur/saturate above +// still do the frosting. +const glassBackground = + 'linear-gradient(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.08)), var(--theme-blur-blur-bg)'; + // Keep the counter compact: monospaced digits so it never jitters, and the // `pr-1` breathing room mirrors the standard grid action bar. const countLabelClasses = '!pl-0.5 pr-1'; @@ -95,7 +104,7 @@ export function FeedCardGlassActions({ /> )}
-
+
Date: Tue, 23 Jun 2026 14:30:16 +0300 Subject: [PATCH 04/26] fix(cards): restore 3-line title clamp on glass article cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The glass-action-bar variant overrode the article card title to line-clamp-2, breaking the standard feed guideline of a fixed three-line title area (ellipsis beyond three lines, reserved height so short titles don't change card height). min-h-cardGlass is only shorter than min-h-card because the action bar floats over the cover image instead of taking its own row — the title's vertical budget is unchanged — so there was no reason to drop a line. Let the glass card fall through to the default CardTitle clamp (line-clamp-3), matching the non-glass production card. Verified card heights stay equal across titles of 1–3 lines. Co-Authored-By: Claude Opus 4.8 --- .../shared/src/components/cards/article/ArticleGrid.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/shared/src/components/cards/article/ArticleGrid.tsx b/packages/shared/src/components/cards/article/ArticleGrid.tsx index 9eaa610944..272bd5ed3b 100644 --- a/packages/shared/src/components/cards/article/ArticleGrid.tsx +++ b/packages/shared/src/components/cards/article/ArticleGrid.tsx @@ -137,11 +137,7 @@ export const ArticleGrid = forwardRef(function ArticleGrid( onReadArticleClick={onReadArticleClick} showFeedback={showFeedback} /> - + {title} From 6e582fa21ee64bdf30015bd43ecf5075b84e8456 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 23 Jun 2026 14:58:14 +0300 Subject: [PATCH 05/26] feat(cards): add impressions action to the feed card glass bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an impressions stat to the floating glass action bar, placed right after the comments button (upvote · comment · impressions · downvote · bookmark · copy link). It reuses the post-page AnalyticsIcon and shows the post's view count, and is gated with canViewPostAnalytics so — like the post page — only the author or a team member sees it. - Source the count from the feed `views` field (already selected by FEED_POST_INFO_FRAGMENT; no extra query cost) and type it on Post. - Drop the fixed gap and trim the counter padding so the sixth action still fits a narrow card; justify-between keeps the five-action (non-author) bar evenly spaced and unchanged. Also hardens useFitFontSize from the prior review: it now tracks the element with a callback ref so the measure/resize effects run even when the title mounts late (e.g. after a feedback card is dismissed), instead of a one-shot empty-dependency observer that never attached. Co-Authored-By: Claude Opus 4.8 --- .../cards/common/FeedCardGlassActions.tsx | 37 +++++++++++++++++-- packages/shared/src/graphql/posts.ts | 2 + packages/shared/src/hooks/useFitFontSize.ts | 29 +++++++++------ 3 files changed, 52 insertions(+), 16 deletions(-) diff --git a/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx b/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx index 2bbf6a1b8a..36deb5ddee 100644 --- a/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx +++ b/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx @@ -7,6 +7,7 @@ import InteractionCounter from '../../InteractionCounter'; import { QuaternaryButton } from '../../buttons/QuaternaryButton'; import { BookmarkButton } from '../../buttons/BookmarkButton'; import { + AnalyticsIcon, DiscussIcon as CommentIcon, DownvoteIcon, LinkIcon, @@ -16,6 +17,8 @@ import { IconSize } from '../../Icon'; import { Tooltip } from '../../tooltip/Tooltip'; import { useFeedPreviewMode } from '../../../hooks/useFeedPreviewMode'; import { useCardActions } from '../../../hooks/cards/useCardActions'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { canViewPostAnalytics } from '../../../lib/user'; // Full-bleed cover: drop side padding/bottom margin and round only the bottom // corners so the image meets the card edges. Height/crop are untouched. @@ -35,7 +38,7 @@ const outerClasses = 'pointer-events-none absolute inset-x-2 bottom-2 z-1'; // `px-1` matches the 4px the h-10 pill leaves above/below its h-8 buttons, so // the padding is equal on all four sides. const pillClasses = classNames( - 'pointer-events-auto flex h-10 w-full items-center justify-between gap-1 overflow-hidden px-1', + 'pointer-events-auto flex h-10 w-full items-center justify-between overflow-hidden px-1', 'rounded-12 border border-border-subtlest-tertiary', 'text-text-primary backdrop-blur-xl backdrop-saturate-150', '[&_.btn-quaternary]:[--button-default-color:var(--theme-text-primary)]', @@ -49,9 +52,10 @@ const pillClasses = classNames( const glassBackground = 'linear-gradient(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.08)), var(--theme-blur-blur-bg)'; -// Keep the counter compact: monospaced digits so it never jitters, and the -// `pr-1` breathing room mirrors the standard grid action bar. -const countLabelClasses = '!pl-0.5 pr-1'; +// Keep the counter compact: monospaced digits so it never jitters, and a hair +// of padding on each side so six actions (incl. impressions) still fit a narrow +// card without the trailing icon clipping. +const countLabelClasses = '!pl-0.5 pr-0.5'; const countClasses = 'tabular-nums typo-footnote'; // Dark glow behind the pill so it stays readable over busy cover images. Fixed @@ -72,6 +76,7 @@ export function FeedCardGlassActions({ coverScrim = false, }: ActionButtonsProps & { coverScrim?: boolean }): ReactElement | null { const isFeedPreview = useFeedPreviewMode(); + const { user } = useAuthContext(); const { isUpvoteActive, isDownvoteActive, @@ -93,6 +98,10 @@ export function FeedCardGlassActions({ const upvoteCount = post.numUpvotes ?? 0; const commentCount = post.numComments ?? 0; + // Impressions come from the feed `views` field and are analytics — only the + // author (or a team member) may see them, matching the post-page gating. + const impressions = post.views ?? 0; + const canSeeImpressions = canViewPostAnalytics({ user, post }); return ( <> @@ -156,6 +165,26 @@ export function FeedCardGlassActions({ )} + {canSeeImpressions && ( + + } + size={ButtonSize.Small} + variant={ButtonVariant.Tertiary} + color={ButtonColor.Cheese} + className="pointer-events-auto" + > + {impressions > 0 && ( + + )} + + + )} {showDownvoteAction && ( { - ref: RefObject; + ref: RefCallback; /** The largest size class at which the text fits within `maxLines`. */ sizeClass: string; /** @@ -32,40 +32,45 @@ interface UseFitFontSizeResult { * `maxLines`, measuring the rendered element rather than guessing from * character count (the container width varies, so a count heuristic can't be * trusted). Steps down one size per layout pass until it fits or bottoms out. + * + * The element is tracked via a callback ref (state, not `useRef`) so the + * measure/observe effects re-run whenever the node actually attaches — e.g. + * when the title only mounts after a sibling panel (feedback card) is dismissed. */ export function useFitFontSize({ text, sizeClasses, maxLines, }: UseFitFontSizeProps): UseFitFontSizeResult { - const ref = useRef(null); + const [node, setNode] = useState(null); const [index, setIndex] = useState(0); const lastIndex = sizeClasses.length - 1; + const ref = useCallback>((el) => setNode(el), []); // Start fresh from the largest size whenever the text changes. useIsomorphicLayoutEffect(() => { setIndex(0); }, [text]); - // After each applied size, measure and step down once if it still overflows. + // After each applied size (and once the node attaches), measure and step + // down once if it still overflows. useIsomorphicLayoutEffect(() => { - const el = ref.current; - if (!el || index >= lastIndex) { + if (!node || index >= lastIndex) { return; } - const lineHeight = parseFloat(getComputedStyle(el).lineHeight); + const lineHeight = parseFloat(getComputedStyle(node).lineHeight); if (!lineHeight) { return; } - if (Math.round(el.scrollHeight / lineHeight) > maxLines) { + if (Math.round(node.scrollHeight / lineHeight) > maxLines) { setIndex((current) => Math.min(current + 1, lastIndex)); } - }, [text, index, maxLines, lastIndex]); + }, [node, text, index, maxLines, lastIndex]); // Re-fit from scratch when the available width changes (height changes from // our own shrinking are ignored so we don't loop). useIsomorphicLayoutEffect(() => { - const parent = ref.current?.parentElement; + const parent = node?.parentElement; if (!parent || typeof ResizeObserver === 'undefined') { return undefined; } @@ -79,7 +84,7 @@ export function useFitFontSize({ }); observer.observe(parent); return () => observer.disconnect(); - }, []); + }, [node]); return { ref, sizeClass: sizeClasses[index], isClamped: index >= lastIndex }; } From e7ef2f2e8a34d413bfae2e37e012ff272d78922d Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 23 Jun 2026 20:08:45 +0300 Subject: [PATCH 06/26] feat(cards): show impressions everywhere, shrink glass bar, add to post bars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Glass feed bar: shrink icons (20→16px) and buttons (Small→XSmall, pill h-10→h-8) so the bar is more compact and all six actions — including impressions — fit a narrow feed-card width without clipping. - Impressions are now a public stat (ungated) sourced from post.views, shown on every card right after the comment action. - Add the same impressions stat (analytics icon + count) after comment to the post action bars the post view actually renders: classic PostActions, PostActions.v2, and the redesigned FocusCardActionBar. - Select `views` in SHARED_POST_INFO_FRAGMENT so the post detail has the count. Co-Authored-By: Claude Opus 4.8 --- .../cards/common/FeedCardGlassActions.tsx | 69 +++++++++---------- .../src/components/post/PostActions.tsx | 18 +++++ .../src/components/post/PostActions.v2.tsx | 10 +++ .../post/focus/FocusCardActionBar.tsx | 11 +++ packages/shared/src/graphql/fragments.ts | 1 + 5 files changed, 71 insertions(+), 38 deletions(-) diff --git a/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx b/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx index 36deb5ddee..6bb23424f9 100644 --- a/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx +++ b/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx @@ -17,8 +17,6 @@ import { IconSize } from '../../Icon'; import { Tooltip } from '../../tooltip/Tooltip'; import { useFeedPreviewMode } from '../../../hooks/useFeedPreviewMode'; import { useCardActions } from '../../../hooks/cards/useCardActions'; -import { useAuthContext } from '../../../contexts/AuthContext'; -import { canViewPostAnalytics } from '../../../lib/user'; // Full-bleed cover: drop side padding/bottom margin and round only the bottom // corners so the image meets the card edges. Height/crop are untouched. @@ -35,10 +33,10 @@ const outerClasses = 'pointer-events-none absolute inset-x-2 bottom-2 z-1'; // actions evenly across the pill: the icons keep equal gaps and a long counter // (e.g. 900 upvotes / 900 comments) just grows its own button instead of // clipping or shoving a neighbour off its mark. -// `px-1` matches the 4px the h-10 pill leaves above/below its h-8 buttons, so +// `px-1` matches the 4px the h-8 pill leaves above/below its h-6 buttons, so // the padding is equal on all four sides. const pillClasses = classNames( - 'pointer-events-auto flex h-10 w-full items-center justify-between overflow-hidden px-1', + 'pointer-events-auto flex h-8 w-full items-center justify-between overflow-hidden px-1', 'rounded-12 border border-border-subtlest-tertiary', 'text-text-primary backdrop-blur-xl backdrop-saturate-150', '[&_.btn-quaternary]:[--button-default-color:var(--theme-text-primary)]', @@ -76,7 +74,6 @@ export function FeedCardGlassActions({ coverScrim = false, }: ActionButtonsProps & { coverScrim?: boolean }): ReactElement | null { const isFeedPreview = useFeedPreviewMode(); - const { user } = useAuthContext(); const { isUpvoteActive, isDownvoteActive, @@ -98,10 +95,8 @@ export function FeedCardGlassActions({ const upvoteCount = post.numUpvotes ?? 0; const commentCount = post.numComments ?? 0; - // Impressions come from the feed `views` field and are analytics — only the - // author (or a team member) may see them, matching the post-page gating. + // Impressions = per-post views, shown on every card as a public stat. const impressions = post.views ?? 0; - const canSeeImpressions = canViewPostAnalytics({ user, post }); return ( <> @@ -126,11 +121,11 @@ export function FeedCardGlassActions({ pressed={isUpvoteActive} onClick={onToggleUpvote} variant={ButtonVariant.Tertiary} - size={ButtonSize.Small} + size={ButtonSize.XSmall} icon={ } > @@ -149,12 +144,12 @@ export function FeedCardGlassActions({ icon={ } pressed={post.commented} onClick={() => onCommentClick?.(post)} - size={ButtonSize.Small} + size={ButtonSize.XSmall} className="btn-tertiary-blueCheese pointer-events-auto" > {commentCount > 0 && ( @@ -165,26 +160,24 @@ export function FeedCardGlassActions({ )} - {canSeeImpressions && ( - - } - size={ButtonSize.Small} - variant={ButtonVariant.Tertiary} - color={ButtonColor.Cheese} - className="pointer-events-auto" - > - {impressions > 0 && ( - - )} - - - )} + + } + size={ButtonSize.XSmall} + variant={ButtonVariant.Tertiary} + color={ButtonColor.Cheese} + className="pointer-events-auto" + > + {impressions > 0 && ( + + )} + + {showDownvoteAction && ( } pressed={isDownvoteActive} onClick={onToggleDownvote} variant={ButtonVariant.Tertiary} - size={ButtonSize.Small} + size={ButtonSize.XSmall} /> )} @@ -213,16 +206,16 @@ export function FeedCardGlassActions({ buttonProps={{ id: `post-${post.id}-bookmark-btn`, onClick: onToggleBookmark, - size: ButtonSize.Small, + size: ButtonSize.XSmall, className: 'btn-tertiary-bun pointer-events-auto', }} - iconSize={IconSize.XSmall} + iconSize={IconSize.Size16} /> } + size={ButtonSize.XSmall} + icon={} onClick={onCopyLink} variant={ButtonVariant.Tertiary} color={ButtonColor.Cabbage} diff --git a/packages/shared/src/components/post/PostActions.tsx b/packages/shared/src/components/post/PostActions.tsx index 5b0acf6a5d..ac9b4be479 100644 --- a/packages/shared/src/components/post/PostActions.tsx +++ b/packages/shared/src/components/post/PostActions.tsx @@ -3,11 +3,13 @@ import React, { useEffect, useMemo, useRef } from 'react'; import type { QueryKey } from '@tanstack/react-query'; import classNames from 'classnames'; import { + AnalyticsIcon, DiscussIcon as CommentIcon, DownvoteIcon, LinkIcon, MedalBadgeIcon, } from '../icons'; +import InteractionCounter from '../InteractionCounter'; import type { Post } from '../../graphql/posts'; import { UserVote } from '../../graphql/posts'; import { QuaternaryButton } from '../buttons/QuaternaryButton'; @@ -260,6 +262,22 @@ function PostActionsV1({ > Comment + + } + aria-label="Impressions" + variant={ButtonVariant.Tertiary} + color={ButtonColor.Cheese} + > + {(post.views ?? 0) > 0 && ( + + )} + + {canAward && ( + + } + label="Impressions" + count={post.views ?? 0} + color={ButtonColor.Cheese} + /> + {canAward && ( + + } + count={isPinned ? impressions : undefined} + /> + {canAward && ( Date: Tue, 23 Jun 2026 20:17:25 +0300 Subject: [PATCH 07/26] style(post): shrink post action bar buttons to match the compact feed bar Reduce the post engagement bar buttons themselves (not just icons): CardAction bars (PostActions.v2, FocusCardActionBar) use density="compact" (Small / 20px), and the classic PostActions QuaternaryButtons drop to ButtonSize.Small. Bookmark, post menu and close in the focus bar match the smaller size so the row stays uniform. The glass feed bar stays at its XSmall size. Co-Authored-By: Claude Opus 4.8 --- .../shared/src/components/post/PostActions.tsx | 9 ++++++++- .../shared/src/components/post/PostActions.v2.tsx | 7 +++++++ .../components/post/focus/FocusCardActionBar.tsx | 14 ++++++++++---- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/components/post/PostActions.tsx b/packages/shared/src/components/post/PostActions.tsx index ac9b4be479..47d01c1516 100644 --- a/packages/shared/src/components/post/PostActions.tsx +++ b/packages/shared/src/components/post/PostActions.tsx @@ -19,7 +19,7 @@ import { Origin } from '../../lib/log'; import { PostTagsPanel } from './block/PostTagsPanel'; import { useBlockPostPanel } from '../../hooks/post/useBlockPostPanel'; import { useBookmarkPost } from '../../hooks/useBookmarkPost'; -import { ButtonColor, ButtonVariant } from '../buttons/Button'; +import { ButtonColor, ButtonSize, ButtonVariant } from '../buttons/Button'; import { BookmarkButton } from '../buttons'; import { AuthTriggers } from '../../lib/auth'; import { LazyModal } from '../modals/common/types'; @@ -225,6 +225,7 @@ function PostActionsV1({ content={isUpvoteActive ? 'Remove upvote' : 'More like this'} > } aria-label="Impressions" @@ -290,6 +294,7 @@ function PostActionsV1({ }} > { @@ -331,6 +336,7 @@ function PostActionsV1({ id: 'bookmark-post-btn', pressed: post.bookmarked, onClick: onToggleBookmark, + size: ButtonSize.Small, className: 'btn-tertiary-bun', }} > @@ -338,6 +344,7 @@ function PostActionsV1({
onCopyLinkClick?.(post)} icon={} diff --git a/packages/shared/src/components/post/PostActions.v2.tsx b/packages/shared/src/components/post/PostActions.v2.tsx index ba20844e3f..9f07e86fee 100644 --- a/packages/shared/src/components/post/PostActions.v2.tsx +++ b/packages/shared/src/components/post/PostActions.v2.tsx @@ -207,6 +207,7 @@ export function PostActions({ content={isUpvoteActive ? 'Remove upvote' : 'More like this'} > } label="Impressions" @@ -262,6 +266,7 @@ export function PostActions({ }} > { @@ -297,6 +302,7 @@ export function PostActions({ )} onCopyLinkClick?.(post)} icon={} diff --git a/packages/shared/src/components/post/focus/FocusCardActionBar.tsx b/packages/shared/src/components/post/focus/FocusCardActionBar.tsx index ae4b1257df..d30be55625 100644 --- a/packages/shared/src/components/post/focus/FocusCardActionBar.tsx +++ b/packages/shared/src/components/post/focus/FocusCardActionBar.tsx @@ -232,6 +232,7 @@ export const FocusCardActionBar = ({ content={isUpvoteActive ? 'Remove upvote' : 'More like this'} > {/* Bookmark stays — it is the primary save action. Copy link folds @@ -313,6 +318,7 @@ export const FocusCardActionBar = ({
} @@ -329,11 +335,11 @@ export const FocusCardActionBar = ({ )} {isPinnedTop && onClose && ( - onClose()} /> + onClose()} /> )}
From 099dd5d88d3b35598553fe8ebe1fd68c5fa7047f Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 24 Jun 2026 09:20:39 +0300 Subject: [PATCH 08/26] fix(cards): taller glass bar + always-visible impressions count - Restore the floating bar to its original h-10 height (it had been shrunk to h-8) while keeping the smaller XSmall buttons. - Always render the impressions counter (even at 0) instead of hiding it when the count is falsy. The number was disappearing whenever post.views was 0/ null; impressions is a public stat shown to everyone, so it now always shows. Co-Authored-By: Claude Opus 4.8 --- .../cards/common/FeedCardGlassActions.tsx | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx b/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx index 6bb23424f9..463452a2b5 100644 --- a/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx +++ b/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx @@ -33,10 +33,10 @@ const outerClasses = 'pointer-events-none absolute inset-x-2 bottom-2 z-1'; // actions evenly across the pill: the icons keep equal gaps and a long counter // (e.g. 900 upvotes / 900 comments) just grows its own button instead of // clipping or shoving a neighbour off its mark. -// `px-1` matches the 4px the h-8 pill leaves above/below its h-6 buttons, so -// the padding is equal on all four sides. +// The pill stays h-10 (its original height) so it reads as a comfortable bar, +// while the buttons themselves are the smaller XSmall size. const pillClasses = classNames( - 'pointer-events-auto flex h-8 w-full items-center justify-between overflow-hidden px-1', + 'pointer-events-auto flex h-10 w-full items-center justify-between overflow-hidden px-1', 'rounded-12 border border-border-subtlest-tertiary', 'text-text-primary backdrop-blur-xl backdrop-saturate-150', '[&_.btn-quaternary]:[--button-default-color:var(--theme-text-primary)]', @@ -170,12 +170,13 @@ export function FeedCardGlassActions({ color={ButtonColor.Cheese} className="pointer-events-auto" > - {impressions > 0 && ( - - )} + {/* Always render the impression count (even 0) so the number is + visible to everyone — unlike upvotes/comments it is not hidden + at zero. */} +
{showDownvoteAction && ( From 23798e4b27255a02c8d7e7486feee8f52f50e728 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 24 Jun 2026 09:33:20 +0300 Subject: [PATCH 09/26] feat(cards): show post impressions with a mock fallback for review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The feed doesn't return real per-post impressions yet (post.views is empty), so the impressions number rendered as 0/blank. Add getPostImpressions: use the real post.views when present, otherwise a stable, realistic-looking number (~100K–3M) derived from the post id so the impressions UI can be reviewed on a preview build. Used by the glass feed card bar and all three post action bars; the count renders next to the analytics icon exactly like the upvote/comment counters. MOCK: the fallback is clearly marked for the engineer to drop once the API exposes real feed impressions. Co-Authored-By: Claude Opus 4.8 --- .../cards/common/FeedCardGlassActions.tsx | 6 ++-- .../src/components/post/PostActions.tsx | 11 ++++--- .../src/components/post/PostActions.v2.tsx | 3 +- .../post/focus/FocusCardActionBar.tsx | 3 +- packages/shared/src/lib/impressions.ts | 30 +++++++++++++++++++ 5 files changed, 43 insertions(+), 10 deletions(-) create mode 100644 packages/shared/src/lib/impressions.ts diff --git a/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx b/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx index 463452a2b5..53a8725961 100644 --- a/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx +++ b/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx @@ -17,6 +17,7 @@ import { IconSize } from '../../Icon'; import { Tooltip } from '../../tooltip/Tooltip'; import { useFeedPreviewMode } from '../../../hooks/useFeedPreviewMode'; import { useCardActions } from '../../../hooks/cards/useCardActions'; +import { getPostImpressions } from '../../../lib/impressions'; // Full-bleed cover: drop side padding/bottom margin and round only the bottom // corners so the image meets the card edges. Height/crop are untouched. @@ -95,8 +96,9 @@ export function FeedCardGlassActions({ const upvoteCount = post.numUpvotes ?? 0; const commentCount = post.numComments ?? 0; - // Impressions = per-post views, shown on every card as a public stat. - const impressions = post.views ?? 0; + // Impressions = per-post views, shown on every card as a public stat (mock + // fallback until the feed returns real views — see getPostImpressions). + const impressions = getPostImpressions(post); return ( <> diff --git a/packages/shared/src/components/post/PostActions.tsx b/packages/shared/src/components/post/PostActions.tsx index 47d01c1516..8cc428be81 100644 --- a/packages/shared/src/components/post/PostActions.tsx +++ b/packages/shared/src/components/post/PostActions.tsx @@ -31,6 +31,7 @@ import { generateQueryKey, RequestKey, updatePostCache } from '../../lib/query'; import type { LoggedUser } from '../../lib/user'; import { useCanAwardUser } from '../../hooks/useCoresFeature'; import { useUpdateQuery } from '../../hooks/useUpdateQuery'; +import { getPostImpressions } from '../../lib/impressions'; import { Tooltip } from '../tooltip/Tooltip'; import ConditionalWrapper from '../ConditionalWrapper'; import { useBrandSponsorship } from '../../hooks/useBrandSponsorship'; @@ -274,12 +275,10 @@ function PostActionsV1({ variant={ButtonVariant.Tertiary} color={ButtonColor.Cheese} > - {(post.views ?? 0) > 0 && ( - - )} +
{canAward && ( diff --git a/packages/shared/src/components/post/PostActions.v2.tsx b/packages/shared/src/components/post/PostActions.v2.tsx index 9f07e86fee..aa26e72acc 100644 --- a/packages/shared/src/components/post/PostActions.v2.tsx +++ b/packages/shared/src/components/post/PostActions.v2.tsx @@ -31,6 +31,7 @@ import { generateQueryKey, RequestKey, updatePostCache } from '../../lib/query'; import type { LoggedUser } from '../../lib/user'; import { useCanAwardUser } from '../../hooks/useCoresFeature'; import { useUpdateQuery } from '../../hooks/useUpdateQuery'; +import { getPostImpressions } from '../../lib/impressions'; import { Tooltip } from '../tooltip/Tooltip'; import ConditionalWrapper from '../ConditionalWrapper'; import { useBrandSponsorship } from '../../hooks/useBrandSponsorship'; @@ -250,7 +251,7 @@ export function PostActions({ id="impressions-post-btn" icon={} label="Impressions" - count={post.views ?? 0} + count={getPostImpressions(post)} color={ButtonColor.Cheese} /> diff --git a/packages/shared/src/components/post/focus/FocusCardActionBar.tsx b/packages/shared/src/components/post/focus/FocusCardActionBar.tsx index d30be55625..547c1b069f 100644 --- a/packages/shared/src/components/post/focus/FocusCardActionBar.tsx +++ b/packages/shared/src/components/post/focus/FocusCardActionBar.tsx @@ -30,6 +30,7 @@ import { } from '../../icons'; import { Tooltip } from '../../tooltip/Tooltip'; import type { LoggedUser } from '../../../lib/user'; +import { getPostImpressions } from '../../../lib/impressions'; import { PostClickbaitShield } from '../common/PostClickbaitShield'; import { PostMenuOptions } from '../PostMenuOptions'; @@ -105,7 +106,7 @@ export const FocusCardActionBar = ({ const upvotes = post.numUpvotes || 0; const comments = post.numComments || 0; const awards = post.numAwards || 0; - const impressions = post.views || 0; + const impressions = getPostImpressions(post); // The bar floats (sticky) from tablet up, so surface the metrics + menu // whenever it's actually pinned there — including when a long post floats it // at the bottom on load, where the stats row above has scrolled off. Below diff --git a/packages/shared/src/lib/impressions.ts b/packages/shared/src/lib/impressions.ts new file mode 100644 index 0000000000..e2bcd49823 --- /dev/null +++ b/packages/shared/src/lib/impressions.ts @@ -0,0 +1,30 @@ +import type { Post } from '../graphql/posts'; + +/** + * MOCK FALLBACK — the feed does not yet return real per-post impressions + * (`post.views` is currently empty in the feed payload), so when it is missing + * we derive a stable, realistic-looking number from the post id purely so the + * impressions UI can be reviewed on a preview build. When `views` is populated + * we always use the real value. + * + * @engineer Replace this fallback with the real impressions field once the API + * exposes it on the feed — keep only the `post.views` branch. + */ +export const getPostImpressions = ( + post: Pick, +): number => { + if (typeof post.views === 'number' && post.views > 0) { + return post.views; + } + + // Deterministic hash of the id → a stable number in ~100K–3M so each card + // shows a different realistic value (and it never changes between renders). + let hash = 0; + for (let i = 0; i < post.id.length; i += 1) { + hash = (hash * 131 + post.id.charCodeAt(i)) % 2_900_000; + } + // Final multiplicative mix so ids sharing a prefix (e.g. feed slugs) spread + // out instead of producing near-identical values. + hash = (hash * 1_000_003) % 2_900_000; + return 100_000 + hash; +}; From ed3b2ee340c52f53a7dca93a49599e02252cc2c7 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 24 Jun 2026 09:35:55 +0300 Subject: [PATCH 10/26] revert(post): keep post page/modal action bars at their original size The compact (smaller button + icon) treatment should apply to the feed card glass bar only. Restore the post engagement bars to their original sizes: remove density="compact" from the CardAction bars (PostActions.v2, FocusCardActionBar), drop the ButtonSize.Small overrides from the classic PostActions, and put the focus bar's bookmark/menu/close back to Medium. The impressions action stays in all three bars, now at the default size. Co-Authored-By: Claude Opus 4.8 --- .../shared/src/components/post/PostActions.tsx | 9 +-------- .../shared/src/components/post/PostActions.v2.tsx | 7 ------- .../components/post/focus/FocusCardActionBar.tsx | 14 ++++---------- 3 files changed, 5 insertions(+), 25 deletions(-) diff --git a/packages/shared/src/components/post/PostActions.tsx b/packages/shared/src/components/post/PostActions.tsx index 8cc428be81..5f77ce1afb 100644 --- a/packages/shared/src/components/post/PostActions.tsx +++ b/packages/shared/src/components/post/PostActions.tsx @@ -19,7 +19,7 @@ import { Origin } from '../../lib/log'; import { PostTagsPanel } from './block/PostTagsPanel'; import { useBlockPostPanel } from '../../hooks/post/useBlockPostPanel'; import { useBookmarkPost } from '../../hooks/useBookmarkPost'; -import { ButtonColor, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { ButtonColor, ButtonVariant } from '../buttons/Button'; import { BookmarkButton } from '../buttons'; import { AuthTriggers } from '../../lib/auth'; import { LazyModal } from '../modals/common/types'; @@ -226,7 +226,6 @@ function PostActionsV1({ content={isUpvoteActive ? 'Remove upvote' : 'More like this'} > } aria-label="Impressions" @@ -293,7 +289,6 @@ function PostActionsV1({ }} > { @@ -335,7 +330,6 @@ function PostActionsV1({ id: 'bookmark-post-btn', pressed: post.bookmarked, onClick: onToggleBookmark, - size: ButtonSize.Small, className: 'btn-tertiary-bun', }} > @@ -343,7 +337,6 @@ function PostActionsV1({
onCopyLinkClick?.(post)} icon={} diff --git a/packages/shared/src/components/post/PostActions.v2.tsx b/packages/shared/src/components/post/PostActions.v2.tsx index aa26e72acc..7efaae352a 100644 --- a/packages/shared/src/components/post/PostActions.v2.tsx +++ b/packages/shared/src/components/post/PostActions.v2.tsx @@ -208,7 +208,6 @@ export function PostActions({ content={isUpvoteActive ? 'Remove upvote' : 'More like this'} > } label="Impressions" @@ -267,7 +263,6 @@ export function PostActions({ }} > { @@ -303,7 +298,6 @@ export function PostActions({ )} onCopyLinkClick?.(post)} icon={} diff --git a/packages/shared/src/components/post/focus/FocusCardActionBar.tsx b/packages/shared/src/components/post/focus/FocusCardActionBar.tsx index 547c1b069f..6dbd6d69e6 100644 --- a/packages/shared/src/components/post/focus/FocusCardActionBar.tsx +++ b/packages/shared/src/components/post/focus/FocusCardActionBar.tsx @@ -233,7 +233,6 @@ export const FocusCardActionBar = ({ content={isUpvoteActive ? 'Remove upvote' : 'More like this'} > {/* Bookmark stays — it is the primary save action. Copy link folds @@ -319,7 +314,6 @@ export const FocusCardActionBar = ({
} @@ -336,11 +330,11 @@ export const FocusCardActionBar = ({ )} {isPinnedTop && onClose && ( - onClose()} /> + onClose()} /> )}
From 02d6157cab6b69e4be52bfc0a6caf8c49eda59eb Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 24 Jun 2026 09:44:10 +0300 Subject: [PATCH 11/26] style(buttons): default QuaternaryButton label to font-medium per guideline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The button typography guideline is medium weight — the Button base and CardAction already use font-medium, while the (deprecated) QuaternaryButton hard-coded font-bold on its label. Default it to font-medium so action-bar counts/labels match the guideline. Call sites that pass an explicit weight via labelClassName still override it. Co-Authored-By: Claude Opus 4.8 --- packages/shared/src/components/buttons/QuaternaryButton.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/components/buttons/QuaternaryButton.tsx b/packages/shared/src/components/buttons/QuaternaryButton.tsx index 84aa5c2c17..32a846d19f 100644 --- a/packages/shared/src/components/buttons/QuaternaryButton.tsx +++ b/packages/shared/src/components/buttons/QuaternaryButton.tsx @@ -80,7 +80,9 @@ function QuaternaryButtonComponent( htmlFor={id} {...labelProps} className={classNames( - 'flex cursor-pointer items-center pl-1 font-bold typo-callout', + // Medium weight to match the button typography guideline (the + // Button base and CardAction both use font-medium). + 'flex cursor-pointer items-center pl-1 font-medium typo-callout', { readOnly: props.disabled }, labelClassName, )} From b3b7a2e8b38040f99d692ccf29ee63d030489254 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 24 Jun 2026 09:59:54 +0300 Subject: [PATCH 12/26] style(cards): move impressions/analytics action to the end of the bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Place the impressions (analytics) action last — after the copy-link/share button — instead of right after comments, across the glass feed bar and all three post action bars (classic, v2, focus card). Co-Authored-By: Claude Opus 4.8 --- .../cards/common/FeedCardGlassActions.tsx | 38 +++++++++---------- .../src/components/post/PostActions.tsx | 28 +++++++------- .../src/components/post/PostActions.v2.tsx | 18 ++++----- .../post/focus/FocusCardActionBar.tsx | 18 ++++----- 4 files changed, 51 insertions(+), 51 deletions(-) diff --git a/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx b/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx index 53a8725961..60aeb24d1b 100644 --- a/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx +++ b/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx @@ -162,25 +162,6 @@ export function FeedCardGlassActions({ )}
- - } - size={ButtonSize.XSmall} - variant={ButtonVariant.Tertiary} - color={ButtonColor.Cheese} - className="pointer-events-auto" - > - {/* Always render the impression count (even 0) so the number is - visible to everyone — unlike upvotes/comments it is not hidden - at zero. */} - - - {showDownvoteAction && ( + + } + size={ButtonSize.XSmall} + variant={ButtonVariant.Tertiary} + color={ButtonColor.Cheese} + className="pointer-events-auto" + > + {/* Always render the impression count (even 0) so the number is + visible to everyone — unlike upvotes/comments it is not hidden + at zero. */} + + +
diff --git a/packages/shared/src/components/post/PostActions.tsx b/packages/shared/src/components/post/PostActions.tsx index 5f77ce1afb..d0bccae49a 100644 --- a/packages/shared/src/components/post/PostActions.tsx +++ b/packages/shared/src/components/post/PostActions.tsx @@ -263,20 +263,6 @@ function PostActionsV1({ > Comment - - } - aria-label="Impressions" - variant={ButtonVariant.Tertiary} - color={ButtonColor.Cheese} - > - - - {canAward && (
+ + } + aria-label="Impressions" + variant={ButtonVariant.Tertiary} + color={ButtonColor.Cheese} + > + + +
{showTagsPanel !== undefined && ( diff --git a/packages/shared/src/components/post/PostActions.v2.tsx b/packages/shared/src/components/post/PostActions.v2.tsx index 7efaae352a..2dfe097ae9 100644 --- a/packages/shared/src/components/post/PostActions.v2.tsx +++ b/packages/shared/src/components/post/PostActions.v2.tsx @@ -242,15 +242,6 @@ export function PostActions({ labelVisible color={ButtonColor.BlueCheese} /> - - } - label="Impressions" - count={getPostImpressions(post)} - color={ButtonColor.Cheese} - /> - {canAward && ( + + } + label="Impressions" + count={getPostImpressions(post)} + color={ButtonColor.Cheese} + /> + {showTagsPanel !== undefined && ( diff --git a/packages/shared/src/components/post/focus/FocusCardActionBar.tsx b/packages/shared/src/components/post/focus/FocusCardActionBar.tsx index 6dbd6d69e6..8ef64d8f92 100644 --- a/packages/shared/src/components/post/focus/FocusCardActionBar.tsx +++ b/packages/shared/src/components/post/focus/FocusCardActionBar.tsx @@ -268,15 +268,6 @@ export const FocusCardActionBar = ({ onClick={onComment} /> - - } - count={isPinned ? impressions : undefined} - /> - {canAward && ( + + } + count={isPinned ? impressions : undefined} + /> + {post.clickbaitTitleDetected && ( )} From 505bc1194fca0423b688be45a291e90ef1bbac9b Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 24 Jun 2026 10:37:59 +0300 Subject: [PATCH 13/26] feat(cards): X/Twitter-style impressions number (decimal only under 10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Format the impressions count like X/Twitter: a decimal is shown only while the abbreviated value is below 10 (1.2K, 1.8M) and dropped once it reaches double digits (12K, 137K, 45M), so the decimal point isn't displayed at every magnitude. Applied at all resolutions. Add an optional `format` override to InteractionCounter (and `countFormat` to CardAction), defaulting to the existing largeNumberFormat, and pass formatImpressions only for the impressions counter — upvotes/comments keep their current formatting. Widen the formatter param types to number | null so the override composes cleanly. Co-Authored-By: Claude Opus 4.8 --- .../src/components/InteractionCounter.tsx | 15 ++++++----- .../src/components/buttons/CardAction.tsx | 7 ++++- .../cards/common/FeedCardGlassActions.tsx | 6 ++++- .../src/components/post/PostActions.tsx | 3 ++- .../src/components/post/PostActions.v2.tsx | 3 ++- .../post/focus/FocusCardActionBar.tsx | 6 ++++- packages/shared/src/lib/impressions.ts | 27 +++++++++++++++++++ packages/shared/src/lib/numberFormat.ts | 2 +- 8 files changed, 57 insertions(+), 12 deletions(-) diff --git a/packages/shared/src/components/InteractionCounter.tsx b/packages/shared/src/components/InteractionCounter.tsx index 84831e54f0..402f9e7308 100644 --- a/packages/shared/src/components/InteractionCounter.tsx +++ b/packages/shared/src/components/InteractionCounter.tsx @@ -7,20 +7,23 @@ import { largeNumberFormat } from '../lib'; export type InteractionCounterProps = { className?: string; value: number | null; + /** Override the number formatter (defaults to `largeNumberFormat`). */ + format?: (value: number | null) => string | null; }; export default function InteractionCounter({ className, value, + format = largeNumberFormat, ...props }: InteractionCounterProps): ReactElement { const [shownValue, setShownValue] = useState(value); const [animate, setAnimate] = useState(false); useEffect(() => { - const formattedValue = largeNumberFormat(value); - const formattedShownValue = largeNumberFormat(shownValue); + const formattedValue = format(value); + const formattedShownValue = format(shownValue); if (formattedValue !== formattedShownValue) { - if (value < shownValue) { + if ((value ?? 0) < (shownValue ?? 0)) { setShownValue(value); } else { setAnimate(false); @@ -39,7 +42,7 @@ export default function InteractionCounter({ if (shownValue === value) { return ( - {largeNumberFormat(shownValue)} + {format(shownValue)} ); } @@ -60,7 +63,7 @@ export default function InteractionCounter({ animate ? '-translate-y-full opacity-0' : 'translate-y-0 opacity-100', )} > - {largeNumberFormat(shownValue)} + {format(shownValue)} - {largeNumberFormat(value)} + {format(value)} ); diff --git a/packages/shared/src/components/buttons/CardAction.tsx b/packages/shared/src/components/buttons/CardAction.tsx index f5e64df8b7..a3e97475eb 100644 --- a/packages/shared/src/components/buttons/CardAction.tsx +++ b/packages/shared/src/components/buttons/CardAction.tsx @@ -46,6 +46,8 @@ type CardActionBaseProps = CardActionPassthroughProps & { iconPressed?: IconElement; label: string; count?: number | null; + /** Override the counter formatter (defaults to `largeNumberFormat`). */ + countFormat?: (value: number | null) => string | null; color?: ColorName; pressed?: boolean; loading?: boolean; @@ -64,6 +66,7 @@ function CardActionComponent( iconPressed, label, count, + countFormat, color, pressed, loading, @@ -121,7 +124,9 @@ function CardActionComponent( {showLabel && ( {label} )} - {showCount && } + {showCount && ( + + )} )} diff --git a/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx b/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx index 60aeb24d1b..6cf58e3e98 100644 --- a/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx +++ b/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx @@ -17,7 +17,10 @@ import { IconSize } from '../../Icon'; import { Tooltip } from '../../tooltip/Tooltip'; import { useFeedPreviewMode } from '../../../hooks/useFeedPreviewMode'; import { useCardActions } from '../../../hooks/cards/useCardActions'; -import { getPostImpressions } from '../../../lib/impressions'; +import { + formatImpressions, + getPostImpressions, +} from '../../../lib/impressions'; // Full-bleed cover: drop side padding/bottom margin and round only the bottom // corners so the image meets the card edges. Height/crop are untouched. @@ -222,6 +225,7 @@ export function FeedCardGlassActions({ diff --git a/packages/shared/src/components/post/PostActions.tsx b/packages/shared/src/components/post/PostActions.tsx index d0bccae49a..ea067d8f34 100644 --- a/packages/shared/src/components/post/PostActions.tsx +++ b/packages/shared/src/components/post/PostActions.tsx @@ -31,7 +31,7 @@ import { generateQueryKey, RequestKey, updatePostCache } from '../../lib/query'; import type { LoggedUser } from '../../lib/user'; import { useCanAwardUser } from '../../hooks/useCoresFeature'; import { useUpdateQuery } from '../../hooks/useUpdateQuery'; -import { getPostImpressions } from '../../lib/impressions'; +import { formatImpressions, getPostImpressions } from '../../lib/impressions'; import { Tooltip } from '../tooltip/Tooltip'; import ConditionalWrapper from '../ConditionalWrapper'; import { useBrandSponsorship } from '../../hooks/useBrandSponsorship'; @@ -347,6 +347,7 @@ function PostActionsV1({ diff --git a/packages/shared/src/components/post/PostActions.v2.tsx b/packages/shared/src/components/post/PostActions.v2.tsx index 2dfe097ae9..8ef1fda4c2 100644 --- a/packages/shared/src/components/post/PostActions.v2.tsx +++ b/packages/shared/src/components/post/PostActions.v2.tsx @@ -31,7 +31,7 @@ import { generateQueryKey, RequestKey, updatePostCache } from '../../lib/query'; import type { LoggedUser } from '../../lib/user'; import { useCanAwardUser } from '../../hooks/useCoresFeature'; import { useUpdateQuery } from '../../hooks/useUpdateQuery'; -import { getPostImpressions } from '../../lib/impressions'; +import { formatImpressions, getPostImpressions } from '../../lib/impressions'; import { Tooltip } from '../tooltip/Tooltip'; import ConditionalWrapper from '../ConditionalWrapper'; import { useBrandSponsorship } from '../../hooks/useBrandSponsorship'; @@ -310,6 +310,7 @@ export function PostActions({ icon={} label="Impressions" count={getPostImpressions(post)} + countFormat={formatImpressions} color={ButtonColor.Cheese} /> diff --git a/packages/shared/src/components/post/focus/FocusCardActionBar.tsx b/packages/shared/src/components/post/focus/FocusCardActionBar.tsx index 8ef64d8f92..17bddcf6e3 100644 --- a/packages/shared/src/components/post/focus/FocusCardActionBar.tsx +++ b/packages/shared/src/components/post/focus/FocusCardActionBar.tsx @@ -30,7 +30,10 @@ import { } from '../../icons'; import { Tooltip } from '../../tooltip/Tooltip'; import type { LoggedUser } from '../../../lib/user'; -import { getPostImpressions } from '../../../lib/impressions'; +import { + formatImpressions, + getPostImpressions, +} from '../../../lib/impressions'; import { PostClickbaitShield } from '../common/PostClickbaitShield'; import { PostMenuOptions } from '../PostMenuOptions'; @@ -319,6 +322,7 @@ export const FocusCardActionBar = ({ color={ButtonColor.Cheese} icon={} count={isPinned ? impressions : undefined} + countFormat={formatImpressions} /> {post.clickbaitTitleDetected && ( diff --git a/packages/shared/src/lib/impressions.ts b/packages/shared/src/lib/impressions.ts index e2bcd49823..7e53479d73 100644 --- a/packages/shared/src/lib/impressions.ts +++ b/packages/shared/src/lib/impressions.ts @@ -1,5 +1,32 @@ import type { Post } from '../graphql/posts'; +/** + * X/Twitter-style compact number: a decimal only shows while the abbreviated + * value is < 10 (1.2K, 9.9K, 1.8M) and is dropped once it reaches double digits + * (12K, 137K, 45M) — so the decimal point isn't displayed at every magnitude. + * Applied at all resolutions. + */ +export const formatImpressions = (value: number | null): string => { + if (value == null || !Number.isFinite(value)) { + return '0'; + } + if (Math.abs(value) < 1000) { + return String(Math.round(value)); + } + const units = ['K', 'M', 'B', 'T']; + let scaled = value; + let unit = -1; + while (Math.abs(scaled) >= 1000 && unit < units.length - 1) { + scaled /= 1000; + unit += 1; + } + const compact = + Math.abs(scaled) < 10 + ? (Math.round(scaled * 10) / 10).toFixed(1).replace(/\.0$/, '') + : String(Math.round(scaled)); + return `${compact}${units[unit]}`; +}; + /** * MOCK FALLBACK — the feed does not yet return real per-post impressions * (`post.views` is currently empty in the feed payload), so when it is missing diff --git a/packages/shared/src/lib/numberFormat.ts b/packages/shared/src/lib/numberFormat.ts index 1ed5599d89..ce34cbce08 100644 --- a/packages/shared/src/lib/numberFormat.ts +++ b/packages/shared/src/lib/numberFormat.ts @@ -1,6 +1,6 @@ import type { PaddleProductLineItem } from '../graphql/paddle'; -export function largeNumberFormat(value: number): string | null { +export function largeNumberFormat(value: number | null): string | null { if (typeof value !== 'number') { return null; } From da22ebb6842a7df267d600b9fcb06782a4ebe558 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 24 Jun 2026 10:58:14 +0300 Subject: [PATCH 14/26] feat(cards): impressions explainer popup + optical right padding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Optical alignment: a number reads tighter against an edge than an icon, so the glass bar now uses asymmetric padding (pl-1 pr-2.5) — the left edge holds the upvote icon, the right edge holds the impressions number, which needs more padding to look balanced (per jakub.kr "details that make interfaces feel better"). - Non-author popup: clicking impressions opens an X/Twitter-style "Views" explainer (daily.dev modal styling, X-like copy) via a new LazyModal (PostImpressions). usePostImpressionsModal wires it across the glass bar and all three post action bars; the post author/team get no popup (they own the stat), so the click is a no-op for them. Co-Authored-By: Claude Opus 4.8 --- .../cards/common/FeedCardGlassActions.tsx | 9 ++- .../shared/src/components/modals/common.tsx | 8 +++ .../src/components/modals/common/types.ts | 1 + .../modals/post/PostImpressionsModal.tsx | 69 +++++++++++++++++++ .../src/components/post/PostActions.tsx | 3 + .../src/components/post/PostActions.v2.tsx | 3 + .../post/focus/FocusCardActionBar.tsx | 3 + .../src/hooks/post/usePostImpressionsModal.ts | 31 +++++++++ 8 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 packages/shared/src/components/modals/post/PostImpressionsModal.tsx create mode 100644 packages/shared/src/hooks/post/usePostImpressionsModal.ts diff --git a/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx b/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx index 6cf58e3e98..70803f47c1 100644 --- a/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx +++ b/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx @@ -17,6 +17,7 @@ import { IconSize } from '../../Icon'; import { Tooltip } from '../../tooltip/Tooltip'; import { useFeedPreviewMode } from '../../../hooks/useFeedPreviewMode'; import { useCardActions } from '../../../hooks/cards/useCardActions'; +import { usePostImpressionsModal } from '../../../hooks/post/usePostImpressionsModal'; import { formatImpressions, getPostImpressions, @@ -39,8 +40,12 @@ const outerClasses = 'pointer-events-none absolute inset-x-2 bottom-2 z-1'; // clipping or shoving a neighbour off its mark. // The pill stays h-10 (its original height) so it reads as a comfortable bar, // while the buttons themselves are the smaller XSmall size. +// Asymmetric `pl-1 pr-2.5`: the left edge holds the upvote icon while the right +// edge holds the impressions number — a number reads tighter against the edge +// than an icon, so it needs more padding to look optically balanced (per +// jakub.kr "details that make interfaces feel better"). const pillClasses = classNames( - 'pointer-events-auto flex h-10 w-full items-center justify-between overflow-hidden px-1', + 'pointer-events-auto flex h-10 w-full items-center justify-between overflow-hidden pl-1 pr-2.5', 'rounded-12 border border-border-subtlest-tertiary', 'text-text-primary backdrop-blur-xl backdrop-saturate-150', '[&_.btn-quaternary]:[--button-default-color:var(--theme-text-primary)]', @@ -92,6 +97,7 @@ export function FeedCardGlassActions({ onBookmarkClick, onCopyLinkClick, }); + const onImpressionsClick = usePostImpressionsModal(post); if (isFeedPreview) { return null; @@ -217,6 +223,7 @@ export function FeedCardGlassActions({ size={ButtonSize.XSmall} variant={ButtonVariant.Tertiary} color={ButtonColor.Cheese} + onClick={onImpressionsClick} className="pointer-events-auto" > {/* Always render the impression count (even 0) so the number is diff --git a/packages/shared/src/components/modals/common.tsx b/packages/shared/src/components/modals/common.tsx index 199f5dfcce..2934626328 100644 --- a/packages/shared/src/components/modals/common.tsx +++ b/packages/shared/src/components/modals/common.tsx @@ -512,6 +512,13 @@ const ReaderPreviewLazyModal = dynamic( ), ); +const PostImpressionsModal = dynamic( + () => + import( + /* webpackChunkName: "postImpressionsModal" */ './post/PostImpressionsModal' + ), +); + export const modals = { [LazyModal.SquadMember]: SquadMemberModal, [LazyModal.UpvotedPopup]: UpvotedPopupModal, @@ -595,6 +602,7 @@ export const modals = { [LazyModal.ReaderInstallPrompt]: ReaderInstallPromptModal, [LazyModal.ReaderExtensionInstall]: ReaderExtensionInstallModal, [LazyModal.ReaderPreview]: ReaderPreviewLazyModal, + [LazyModal.PostImpressions]: PostImpressionsModal, }; type GetComponentProps = T extends diff --git a/packages/shared/src/components/modals/common/types.ts b/packages/shared/src/components/modals/common/types.ts index dfda6a0d09..f28b066f11 100644 --- a/packages/shared/src/components/modals/common/types.ts +++ b/packages/shared/src/components/modals/common/types.ts @@ -107,6 +107,7 @@ export enum LazyModal { ReaderInstallPrompt = 'readerInstallPrompt', ReaderExtensionInstall = 'readerExtensionInstall', ReaderPreview = 'readerPreview', + PostImpressions = 'postImpressions', } export type ModalTabItem = { diff --git a/packages/shared/src/components/modals/post/PostImpressionsModal.tsx b/packages/shared/src/components/modals/post/PostImpressionsModal.tsx new file mode 100644 index 0000000000..2233fbc9f4 --- /dev/null +++ b/packages/shared/src/components/modals/post/PostImpressionsModal.tsx @@ -0,0 +1,69 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { Modal } from '../common/Modal'; +import type { LazyModalCommonProps } from '../common/Modal'; +import { ModalClose } from '../common/ModalClose'; +import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; +import { + Typography, + TypographyTag, + TypographyType, + TypographyColor, +} from '../../typography/Typography'; +import { AnalyticsIcon } from '../../icons'; +import { IconSize } from '../../Icon'; +import { docs } from '../../../lib/constants'; +import { anchorDefaultRel } from '../../../lib/strings'; + +/** + * X/Twitter-style "Views" explainer shown to non-authors when they tap the + * impressions stat — daily.dev's own modal styling, X-like messaging. + */ +function PostImpressionsModal(props: LazyModalCommonProps): ReactElement { + const { onRequestClose } = props; + + return ( + + + +
+ +
+ + Impressions + + + The number of times this post was seen across daily.dev. To learn + more, visit our{' '} + + docs + + . + + +
+
+ ); +} + +export default PostImpressionsModal; diff --git a/packages/shared/src/components/post/PostActions.tsx b/packages/shared/src/components/post/PostActions.tsx index ea067d8f34..f95bc46086 100644 --- a/packages/shared/src/components/post/PostActions.tsx +++ b/packages/shared/src/components/post/PostActions.tsx @@ -27,6 +27,7 @@ import { useLazyModal } from '../../hooks/useLazyModal'; import { useAuthContext } from '../../contexts/AuthContext'; import type { AwardProps } from '../../graphql/njord'; import { getProductsQueryOptions } from '../../graphql/njord'; +import { usePostImpressionsModal } from '../../hooks/post/usePostImpressionsModal'; import { generateQueryKey, RequestKey, updatePostCache } from '../../lib/query'; import type { LoggedUser } from '../../lib/user'; import { useCanAwardUser } from '../../hooks/useCoresFeature'; @@ -58,6 +59,7 @@ function PostActionsV1({ const { data, onShowPanel, onClose } = useBlockPostPanel(post); const { showTagsPanel } = data; const actionsRef = useRef(null); + const onImpressionsClick = usePostImpressionsModal(post); const canAward = useCanAwardUser({ sendingUser: user, receivingUser: post.author as LoggedUser | undefined, @@ -343,6 +345,7 @@ function PostActionsV1({ aria-label="Impressions" variant={ButtonVariant.Tertiary} color={ButtonColor.Cheese} + onClick={onImpressionsClick} > diff --git a/packages/shared/src/components/post/focus/FocusCardActionBar.tsx b/packages/shared/src/components/post/focus/FocusCardActionBar.tsx index 17bddcf6e3..761397d688 100644 --- a/packages/shared/src/components/post/focus/FocusCardActionBar.tsx +++ b/packages/shared/src/components/post/focus/FocusCardActionBar.tsx @@ -34,6 +34,7 @@ import { formatImpressions, getPostImpressions, } from '../../../lib/impressions'; +import { usePostImpressionsModal } from '../../../hooks/post/usePostImpressionsModal'; import { PostClickbaitShield } from '../common/PostClickbaitShield'; import { PostMenuOptions } from '../PostMenuOptions'; @@ -110,6 +111,7 @@ export const FocusCardActionBar = ({ const comments = post.numComments || 0; const awards = post.numAwards || 0; const impressions = getPostImpressions(post); + const onImpressionsClick = usePostImpressionsModal(post); // The bar floats (sticky) from tablet up, so surface the metrics + menu // whenever it's actually pinned there — including when a long post floats it // at the bottom on load, where the stats row above has scrolled off. Below @@ -323,6 +325,7 @@ export const FocusCardActionBar = ({ icon={} count={isPinned ? impressions : undefined} countFormat={formatImpressions} + onClick={onImpressionsClick} /> {post.clickbaitTitleDetected && ( diff --git a/packages/shared/src/hooks/post/usePostImpressionsModal.ts b/packages/shared/src/hooks/post/usePostImpressionsModal.ts new file mode 100644 index 0000000000..cb1e2ba107 --- /dev/null +++ b/packages/shared/src/hooks/post/usePostImpressionsModal.ts @@ -0,0 +1,31 @@ +import type { MouseEvent } from 'react'; +import { useCallback } from 'react'; +import type { Post } from '../../graphql/posts'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { useLazyModal } from '../useLazyModal'; +import { LazyModal } from '../../components/modals/common/types'; +import { canViewPostAnalytics } from '../../lib/user'; + +/** + * Click handler for the impressions stat. Non-authors get the X-style explainer + * popup; the author (or a team member) owns the post and has real analytics, so + * they get no popup (returns `undefined` → the stat stays display-only). + */ +export const usePostImpressionsModal = ( + post: Pick, +): ((event?: MouseEvent) => void) | undefined => { + const { user } = useAuthContext(); + const { openModal } = useLazyModal(); + const isAuthor = canViewPostAnalytics({ user, post }); + + const onClick = useCallback( + (event?: MouseEvent) => { + event?.stopPropagation(); + event?.preventDefault(); + openModal({ type: LazyModal.PostImpressions }); + }, + [openModal], + ); + + return isAuthor ? undefined : onClick; +}; From bbff68d2575a1b305f42e79ca8ef81e87af7fd22 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 24 Jun 2026 12:14:13 +0300 Subject: [PATCH 15/26] =?UTF-8?q?fix(cards):=20wire=20impressions=20click?= =?UTF-8?q?=20for=20owners/team=20=E2=86=92=20analytics,=20others=20?= =?UTF-8?q?=E2=86=92=20popup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The impressions click did nothing for the post owner or daily.dev team members: canViewPostAnalytics returns true for them, and the hook returned undefined (no handler). It now always returns a handler — owners/team go to the post analytics page, everyone else opens the X-style explainer popup. Co-Authored-By: Claude Opus 4.8 --- .../src/hooks/post/usePostImpressionsModal.ts | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/shared/src/hooks/post/usePostImpressionsModal.ts b/packages/shared/src/hooks/post/usePostImpressionsModal.ts index cb1e2ba107..e6ff89499b 100644 --- a/packages/shared/src/hooks/post/usePostImpressionsModal.ts +++ b/packages/shared/src/hooks/post/usePostImpressionsModal.ts @@ -1,5 +1,6 @@ import type { MouseEvent } from 'react'; import { useCallback } from 'react'; +import { useRouter } from 'next/router'; import type { Post } from '../../graphql/posts'; import { useAuthContext } from '../../contexts/AuthContext'; import { useLazyModal } from '../useLazyModal'; @@ -7,25 +8,31 @@ import { LazyModal } from '../../components/modals/common/types'; import { canViewPostAnalytics } from '../../lib/user'; /** - * Click handler for the impressions stat. Non-authors get the X-style explainer - * popup; the author (or a team member) owns the post and has real analytics, so - * they get no popup (returns `undefined` → the stat stays display-only). + * Click handler for the impressions stat: + * - the post owner (or a team member) can see real analytics, so they go to the + * post analytics page; + * - everyone else gets the X/Twitter-style explainer popup. */ export const usePostImpressionsModal = ( - post: Pick, -): ((event?: MouseEvent) => void) | undefined => { + post: Pick, +): ((event?: MouseEvent) => void) => { + const router = useRouter(); const { user } = useAuthContext(); const { openModal } = useLazyModal(); - const isAuthor = canViewPostAnalytics({ user, post }); + const canViewAnalytics = canViewPostAnalytics({ user, post }); - const onClick = useCallback( + return useCallback( (event?: MouseEvent) => { event?.stopPropagation(); event?.preventDefault(); + + if (canViewAnalytics) { + router.push(`/posts/${post.id}/analytics`); + return; + } + openModal({ type: LazyModal.PostImpressions }); }, - [openModal], + [canViewAnalytics, openModal, router, post.id], ); - - return isAuthor ? undefined : onClick; }; From ec94dc9c644fba0e4d6560f435bf2b5fad623804 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 24 Jun 2026 12:19:44 +0300 Subject: [PATCH 16/26] feat(cards): add impressions to the standard action bar (list + all resolutions) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the impressions action to the non-glass ActionButtons (V1 and V2) so list cards and the standard grid/signal bars match the glass feed bar across all resolutions: analytics icon + X/Twitter-style count (formatImpressions) placed last after copy-link, owner/team → analytics page, everyone else → the explainer popup (usePostImpressionsModal). Co-Authored-By: Claude Opus 4.8 --- .../components/cards/common/ActionButtons.tsx | 32 +++++++++++++++++++ .../cards/common/ActionButtons.v2.tsx | 26 +++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/packages/shared/src/components/cards/common/ActionButtons.tsx b/packages/shared/src/components/cards/common/ActionButtons.tsx index 83e2a65b5b..c8ff366f10 100644 --- a/packages/shared/src/components/cards/common/ActionButtons.tsx +++ b/packages/shared/src/components/cards/common/ActionButtons.tsx @@ -5,6 +5,7 @@ import type { Post } from '../../../graphql/posts'; import InteractionCounter from '../../InteractionCounter'; import { QuaternaryButton } from '../../buttons/QuaternaryButton'; import { + AnalyticsIcon, DiscussIcon as CommentIcon, LinkIcon, DownvoteIcon, @@ -21,6 +22,11 @@ import { PostTagsPanel } from '../../post/block/PostTagsPanel'; import { LinkWithTooltip } from '../../tooltips/LinkWithTooltip'; import { useCardActions } from '../../../hooks/cards/useCardActions'; import { useBrandSponsorship } from '../../../hooks/useBrandSponsorship'; +import { usePostImpressionsModal } from '../../../hooks/post/usePostImpressionsModal'; +import { + formatImpressions, + getPostImpressions, +} from '../../../lib/impressions'; import { useEngagementBarV2 } from '../../../hooks/useEngagementBarV2'; import ActionButtonsV2 from './ActionButtons.v2'; @@ -114,6 +120,8 @@ const ActionButtonsV1 = ({ }; }, [getUpvoteAnimation, post.tags]); + const onImpressionsClick = usePostImpressionsModal(post); + if (isFeedPreview) { return null; } @@ -269,6 +277,30 @@ const ActionButtonsV1 = ({ className={variant === 'list' ? 'pointer-events-auto' : undefined} /> + + } + onClick={onImpressionsClick} + variant={ButtonVariant.Tertiary} + color={ButtonColor.Cheese} + className={variant === 'list' ? 'pointer-events-auto' : undefined} + > + + + ); diff --git a/packages/shared/src/components/cards/common/ActionButtons.v2.tsx b/packages/shared/src/components/cards/common/ActionButtons.v2.tsx index 9c5063f5fa..5204836c69 100644 --- a/packages/shared/src/components/cards/common/ActionButtons.v2.tsx +++ b/packages/shared/src/components/cards/common/ActionButtons.v2.tsx @@ -5,6 +5,7 @@ import type { Post } from '../../../graphql/posts'; import { CardAction } from '../../buttons/CardAction'; import { CardActionBar } from '../../buttons/CardActionBar'; import { + AnalyticsIcon, DiscussIcon as CommentIcon, LinkIcon, DownvoteIcon, @@ -20,6 +21,11 @@ import { PostTagsPanel } from '../../post/block/PostTagsPanel'; import { LinkWithTooltip } from '../../tooltips/LinkWithTooltip'; import { useCardActions } from '../../../hooks/cards/useCardActions'; import { useBrandSponsorship } from '../../../hooks/useBrandSponsorship'; +import { usePostImpressionsModal } from '../../../hooks/post/usePostImpressionsModal'; +import { + formatImpressions, + getPostImpressions, +} from '../../../lib/impressions'; export type ActionButtonsVariant = 'grid' | 'list' | 'signal'; @@ -105,6 +111,8 @@ const ActionButtons = ({ }; }, [getUpvoteAnimation, post.tags]); + const onImpressionsClick = usePostImpressionsModal(post); + if (isFeedPreview) { return null; } @@ -223,6 +231,24 @@ const ActionButtons = ({ )} /> + + } + label="Impressions" + count={getPostImpressions(post)} + countFormat={formatImpressions} + onClick={onImpressionsClick} + color={ButtonColor.Cheese} + buttonClassName={classNames( + variant === 'list' && 'pointer-events-auto', + )} + /> + ); From ad8032dd6d4f5161c2c6e74bc6bfbe4f8198afc3 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 24 Jun 2026 12:37:23 +0300 Subject: [PATCH 17/26] style(cards): compact action bar on small screens + downvote 3rd to match glass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - On small resolutions (below laptop) the standard ActionButtons now shrink to the glass feed bar's size (ButtonSize.XSmall + 16px icons) for consistency; laptop and up keep the regular Small size. - Reorder to match the glass bar: downvote moves to the 3rd position (upvote, comment, downvote, …) in both V1 and V2. Co-Authored-By: Claude Opus 4.8 --- .../components/cards/common/ActionButtons.tsx | 44 +++++++++---------- .../cards/common/ActionButtons.v2.tsx | 2 +- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/packages/shared/src/components/cards/common/ActionButtons.tsx b/packages/shared/src/components/cards/common/ActionButtons.tsx index c8ff366f10..6081a1322e 100644 --- a/packages/shared/src/components/cards/common/ActionButtons.tsx +++ b/packages/shared/src/components/cards/common/ActionButtons.tsx @@ -11,7 +11,7 @@ import { DownvoteIcon, } from '../../icons'; import { ButtonColor, ButtonSize, ButtonVariant } from '../../buttons/Button'; -import { useFeedPreviewMode } from '../../../hooks'; +import { useFeedPreviewMode, useViewSize, ViewSize } from '../../../hooks'; import { UpvoteButtonIcon } from './UpvoteButtonIcon'; import { BookmarkButton } from '../../buttons'; import { IconSize } from '../../Icon'; @@ -84,6 +84,11 @@ const ActionButtonsV1 = ({ }: ActionButtonsProps): ReactElement | null => { const config = variantConfig[variant]; const isFeedPreview = useFeedPreviewMode(); + // Below laptop, shrink to the glass feed bar's size (XSmall / 16px icons) so + // the action bar stays compact and consistent on small resolutions. + const isLaptop = useViewSize(ViewSize.Laptop); + const buttonSize = isLaptop ? config.buttonSize : ButtonSize.XSmall; + const iconSize = isLaptop ? config.iconSize : IconSize.Size16; const { getUpvoteAnimation } = useBrandSponsorship(); const { @@ -143,8 +148,8 @@ const ActionButtonsV1 = ({ href={post.commentsPermalink} pressed={post.commented} variant={ButtonVariant.Tertiary} - size={config.buttonSize} - icon={} + size={buttonSize} + icon={} onClick={() => onCommentClick?.(post)} > {commentCount > 0 && ( @@ -160,10 +165,10 @@ const ActionButtonsV1 = ({ } + icon={} pressed={post.commented} onClick={() => onCommentClick?.(post)} - size={config.buttonSize} + size={buttonSize} className="btn-tertiary-blueCheese" > {commentCount > 0 && ( @@ -200,11 +205,11 @@ const ActionButtonsV1 = ({ pressed={isUpvoteActive} onClick={onToggleUpvote} variant={ButtonVariant.Tertiary} - size={config.buttonSize} + size={buttonSize} icon={ } @@ -220,6 +225,7 @@ const ActionButtonsV1 = ({ )} + {commentButton} {showDownvoteAction && ( + } pressed={isDownvoteActive} onClick={onToggleDownvote} variant={ButtonVariant.Tertiary} - size={config.buttonSize} + size={buttonSize} /> )} - {commentButton} - {showAwardAction && ( - - )} + {showAwardAction && } } + size={buttonSize} + icon={} onClick={onCopyLink} variant={ButtonVariant.Tertiary} color={ButtonColor.Cabbage} @@ -284,8 +284,8 @@ const ActionButtonsV1 = ({ } + size={buttonSize} + icon={} onClick={onImpressionsClick} variant={ButtonVariant.Tertiary} color={ButtonColor.Cheese} diff --git a/packages/shared/src/components/cards/common/ActionButtons.v2.tsx b/packages/shared/src/components/cards/common/ActionButtons.v2.tsx index 5204836c69..a2d07632eb 100644 --- a/packages/shared/src/components/cards/common/ActionButtons.v2.tsx +++ b/packages/shared/src/components/cards/common/ActionButtons.v2.tsx @@ -183,6 +183,7 @@ const ActionButtons = ({ buttonClassName="pointer-events-auto" /> + {commentButton} {showDownvoteAction && ( )} - {commentButton} {showAwardAction && ( )} From 69e1b29bb2b609245d4993783033d90c3e61de20 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 24 Jun 2026 12:58:15 +0300 Subject: [PATCH 18/26] fix(cards): hide feed awards on tablet/mobile + consistent impressions counter - Hide the award action in the feed below laptop (tablet + mobile), matching the glass bar which drops it entirely. - Fix the impressions counter styling: its wrapper was missing the btn-tertiary-cheese class, so the number rendered in text-primary (darker) instead of the muted rest colour used by the upvote/comment counters. Now all feed stat numbers share the same colour, size and weight. Co-Authored-By: Claude Opus 4.8 --- .../shared/src/components/cards/common/ActionButtons.tsx | 9 +++++++-- .../src/components/cards/common/ActionButtons.v2.tsx | 6 ++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/components/cards/common/ActionButtons.tsx b/packages/shared/src/components/cards/common/ActionButtons.tsx index 6081a1322e..aef8bc8636 100644 --- a/packages/shared/src/components/cards/common/ActionButtons.tsx +++ b/packages/shared/src/components/cards/common/ActionButtons.tsx @@ -245,7 +245,9 @@ const ActionButtonsV1 = ({ /> )} - {showAwardAction && } + {showAwardAction && isLaptop && ( + + )} { const config = variantConfig[variant]; const isFeedPreview = useFeedPreviewMode(); + // Awards are hidden in the feed below laptop (tablet + mobile). + const isLaptop = useViewSize(ViewSize.Laptop); const { getUpvoteAnimation } = useBrandSponsorship(); const { @@ -202,7 +204,7 @@ const ActionButtons = ({ /> )} - {showAwardAction && ( + {showAwardAction && isLaptop && ( )} Date: Wed, 24 Jun 2026 13:03:42 +0300 Subject: [PATCH 19/26] fix(cards): impressions number turns yellow on hover like the other stats The glass bar and classic post bar impressions buttons were missing the btn-tertiary-cheese class on their wrapper, so the number kept its rest colour on hover. Adding it wires --button-hover-color to the cheese accent, so the impressions count turns yellow on hover just like upvote (avocado) / comment (blueCheese). The CardAction-based bars already inherit this via their color prop. Co-Authored-By: Claude Opus 4.8 --- .../shared/src/components/cards/common/FeedCardGlassActions.tsx | 2 +- packages/shared/src/components/post/PostActions.tsx | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx b/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx index c12ce7c06d..ae247cd24d 100644 --- a/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx +++ b/packages/shared/src/components/cards/common/FeedCardGlassActions.tsx @@ -224,7 +224,7 @@ export function FeedCardGlassActions({ variant={ButtonVariant.Tertiary} color={ButtonColor.Cheese} onClick={onImpressionsClick} - className="pointer-events-auto" + className="btn-tertiary-cheese pointer-events-auto" > {/* Always render the impression count (even 0) so the number is visible to everyone — unlike upvotes/comments it is not hidden diff --git a/packages/shared/src/components/post/PostActions.tsx b/packages/shared/src/components/post/PostActions.tsx index 6f7ef94aed..78002ebe36 100644 --- a/packages/shared/src/components/post/PostActions.tsx +++ b/packages/shared/src/components/post/PostActions.tsx @@ -337,6 +337,7 @@ function PostActionsV1({ } aria-label="Impressions" variant={ButtonVariant.Tertiary} From 0ba1a5fa535df1391dcf4cec6ef1d9e9083c8829 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 24 Jun 2026 13:09:10 +0300 Subject: [PATCH 20/26] docs(posts): correct the views field comment (also in SharedPostInfo) Co-Authored-By: Claude Opus 4.8 --- packages/shared/src/graphql/posts.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/graphql/posts.ts b/packages/shared/src/graphql/posts.ts index 05fec5a388..1d37a10f53 100644 --- a/packages/shared/src/graphql/posts.ts +++ b/packages/shared/src/graphql/posts.ts @@ -261,7 +261,8 @@ export interface Post { numComments?: number; numAwards?: number; numReposts?: number; - // Per-post impressions, selected by FEED_POST_INFO_FRAGMENT for feed cards. + // Per-post impressions, selected by the feed (FeedPostInfo) and post + // (SharedPostInfo) fragments. views?: number; author?: Author; scout?: Scout; From 7b7f34d8f9f71aa235f3ba4fa7a3b6786b9da568 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 24 Jun 2026 13:53:03 +0300 Subject: [PATCH 21/26] style(cards): bigger icons + smaller stat numbers on mobile/tablet feed bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop shrinking the feed action buttons below laptop — keep the full-size icons (Small button / 20px icons) so they read as the primary affordance — and instead shrink the stat counters to typo-caption1 on mobile/tablet. Desktop counters are unchanged. Applied to all counters (upvote, comment, impressions) for a consistent look. Co-Authored-By: Claude Opus 4.8 --- .../components/cards/common/ActionButtons.tsx | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/shared/src/components/cards/common/ActionButtons.tsx b/packages/shared/src/components/cards/common/ActionButtons.tsx index aef8bc8636..88b6bbe21d 100644 --- a/packages/shared/src/components/cards/common/ActionButtons.tsx +++ b/packages/shared/src/components/cards/common/ActionButtons.tsx @@ -84,11 +84,14 @@ const ActionButtonsV1 = ({ }: ActionButtonsProps): ReactElement | null => { const config = variantConfig[variant]; const isFeedPreview = useFeedPreviewMode(); - // Below laptop, shrink to the glass feed bar's size (XSmall / 16px icons) so - // the action bar stays compact and consistent on small resolutions. const isLaptop = useViewSize(ViewSize.Laptop); - const buttonSize = isLaptop ? config.buttonSize : ButtonSize.XSmall; - const iconSize = isLaptop ? config.iconSize : IconSize.Size16; + 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 { @@ -154,7 +157,10 @@ const ActionButtonsV1 = ({ > {commentCount > 0 && ( )} @@ -174,7 +180,7 @@ const ActionButtonsV1 = ({ {commentCount > 0 && ( {upvoteCount > 0 && ( )} @@ -297,10 +300,7 @@ const ActionButtonsV1 = ({ )} > From 2be0671ec073d9dfa773ec2fc19fa479417d4095 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 24 Jun 2026 14:02:47 +0300 Subject: [PATCH 22/26] feat(post): impressions stat next to comments; move it left in the sticky bar Article post page/modal: - Stats row: replace the blue "Post analytics" button with an impressions stat styled exactly like the comment count (tertiary ClickableText), placed right after comments, that opens the analytics page on click. Shown when the API returns impressions (author/team), same gating the button had. - Sticky focus action bar: move the impressions action from the right group to the left, right after the comment, so it stays next to comments when pinned. Co-Authored-By: Claude Opus 4.8 --- .../post/PostUpvotesCommentsCount.tsx | 51 ++++++++++--------- .../post/focus/FocusCardActionBar.tsx | 22 ++++---- 2 files changed, 37 insertions(+), 36 deletions(-) diff --git a/packages/shared/src/components/post/PostUpvotesCommentsCount.tsx b/packages/shared/src/components/post/PostUpvotesCommentsCount.tsx index 7e0ca893eb..53415af277 100644 --- a/packages/shared/src/components/post/PostUpvotesCommentsCount.tsx +++ b/packages/shared/src/components/post/PostUpvotesCommentsCount.tsx @@ -8,11 +8,7 @@ import { Image } from '../image/Image'; import { useLazyModal } from '../../hooks/useLazyModal'; import { LazyModal } from '../modals/common/types'; import { useHasAccessToCores } from '../../hooks/useCoresFeature'; -import { canViewPostAnalytics } from '../../lib/user'; -import { useAuthContext } from '../../contexts/AuthContext'; import Link from '../utilities/Link'; -import { Button, ButtonSize } from '../buttons/Button'; -import { AnalyticsIcon } from '../icons'; import { webappUrl } from '../../lib/constants'; const DEFAULT_REPOSTS_PER_PAGE = 20; @@ -43,7 +39,6 @@ interface PostUpvotesCommentsCountProps { type PostUpvotesCommentsCountContentProps = PostUpvotesCommentsCountProps & { onRepostsClick?: () => unknown; onAwardsClick?: () => unknown; - showPostAnalytics?: boolean; }; const PostUpvotesCommentsCountContent = ({ @@ -52,7 +47,6 @@ const PostUpvotesCommentsCountContent = ({ onCommentsClick, onRepostsClick, onAwardsClick, - showPostAnalytics = false, className, compact = false, passive = false, @@ -95,11 +89,6 @@ const PostUpvotesCommentsCountContent = ({ )} data-testid="statsBar" > - {!!post.analytics?.impressions && ( - - {getText({ count: post.analytics.impressions, label: 'Impression' })} - - )} {upvotes > 0 && renderText({ key: 'upvotes', @@ -112,6 +101,32 @@ const PostUpvotesCommentsCountContent = ({ onClick: onCommentsClick, children: getText({ count: comments, label: 'Comment' }), })} + {/* Impressions sit right after comments and look like the other stats; the + analytics page is opened by tapping them (replacing the old blue + "Post analytics" button). Only present when the API returns analytics + (author / team). */} + {!!post.analytics?.impressions && + (post.id && !passive ? ( + + + {getText({ + count: post.analytics.impressions, + label: 'Impression', + })} + + + ) : ( + + {getText({ + count: post.analytics.impressions, + label: 'Impression', + })} + + ))} {reposts > 0 && renderText({ key: 'reposts', @@ -136,18 +151,6 @@ const PostUpvotesCommentsCountContent = ({ ), })} - {showPostAnalytics && ( - - - - )} ); }; @@ -160,7 +163,6 @@ const InteractivePostUpvotesCommentsCount = ({ compact, }: PostUpvotesCommentsCountProps): ReactElement => { const { openModal } = useLazyModal(); - const { user } = useAuthContext(); const awards = post.numAwards || 0; const hasAccessToCores = useHasAccessToCores(); if (!post.id) { @@ -213,7 +215,6 @@ const InteractivePostUpvotesCommentsCount = ({ } : undefined } - showPostAnalytics={canViewPostAnalytics({ user, post })} className={className} compact={compact} /> diff --git a/packages/shared/src/components/post/focus/FocusCardActionBar.tsx b/packages/shared/src/components/post/focus/FocusCardActionBar.tsx index 3a365c9ede..a227b76e21 100644 --- a/packages/shared/src/components/post/focus/FocusCardActionBar.tsx +++ b/packages/shared/src/components/post/focus/FocusCardActionBar.tsx @@ -269,6 +269,17 @@ export const FocusCardActionBar = ({ onClick={onComment} /> + + } + count={isPinned ? impressions : undefined} + countFormat={formatImpressions} + onClick={onImpressionsClick} + /> + {canAward && ( - - } - count={isPinned ? impressions : undefined} - countFormat={formatImpressions} - onClick={onImpressionsClick} - /> - {post.clickbaitTitleDetected && ( )} From c220f82782de240f95bb082ca4b6394a286a7648 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 24 Jun 2026 14:22:01 +0300 Subject: [PATCH 23/26] feat(post): impressions live in the stats strip, not the post action bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the post page/modal the impressions belong with the other counts (upvotes, comments, awards) in the stats strip — where the old blue "Post analytics" link used to be — not as an action in the bar. Remove the impressions action from the post action bars (FocusCardActionBar + classic PostActions v1/v2). The feed action bars keep their impressions button unchanged. Co-Authored-By: Claude Opus 4.8 --- .../src/components/post/PostActions.tsx | 22 ------------------- .../src/components/post/PostActions.v2.tsx | 15 ------------- .../post/focus/FocusCardActionBar.tsx | 19 ---------------- 3 files changed, 56 deletions(-) diff --git a/packages/shared/src/components/post/PostActions.tsx b/packages/shared/src/components/post/PostActions.tsx index 78002ebe36..d7b78d2171 100644 --- a/packages/shared/src/components/post/PostActions.tsx +++ b/packages/shared/src/components/post/PostActions.tsx @@ -3,13 +3,11 @@ import React, { useEffect, useMemo, useRef } from 'react'; import type { QueryKey } from '@tanstack/react-query'; import classNames from 'classnames'; import { - AnalyticsIcon, DiscussIcon as CommentIcon, DownvoteIcon, LinkIcon, MedalBadgeIcon, } from '../icons'; -import InteractionCounter from '../InteractionCounter'; import type { Post } from '../../graphql/posts'; import { UserVote } from '../../graphql/posts'; import { QuaternaryButton } from '../buttons/QuaternaryButton'; @@ -27,12 +25,10 @@ import { useLazyModal } from '../../hooks/useLazyModal'; import { useAuthContext } from '../../contexts/AuthContext'; import type { AwardProps } from '../../graphql/njord'; import { getProductsQueryOptions } from '../../graphql/njord'; -import { usePostImpressionsModal } from '../../hooks/post/usePostImpressionsModal'; import { generateQueryKey, RequestKey, updatePostCache } from '../../lib/query'; import type { LoggedUser } from '../../lib/user'; import { useCanAwardUser } from '../../hooks/useCoresFeature'; import { useUpdateQuery } from '../../hooks/useUpdateQuery'; -import { formatImpressions, getPostImpressions } from '../../lib/impressions'; import { Tooltip } from '../tooltip/Tooltip'; import ConditionalWrapper from '../ConditionalWrapper'; import { useBrandSponsorship } from '../../hooks/useBrandSponsorship'; @@ -59,7 +55,6 @@ function PostActionsV1({ const { data, onShowPanel, onClose } = useBlockPostPanel(post); const { showTagsPanel } = data; const actionsRef = useRef(null); - const onImpressionsClick = usePostImpressionsModal(post); const canAward = useCanAwardUser({ sendingUser: user, receivingUser: post.author as LoggedUser | undefined, @@ -334,23 +329,6 @@ function PostActionsV1({ Copy - - } - aria-label="Impressions" - variant={ButtonVariant.Tertiary} - color={ButtonColor.Cheese} - onClick={onImpressionsClick} - > - - - {showTagsPanel !== undefined && ( diff --git a/packages/shared/src/components/post/PostActions.v2.tsx b/packages/shared/src/components/post/PostActions.v2.tsx index 9ace654b61..b7aae1aecb 100644 --- a/packages/shared/src/components/post/PostActions.v2.tsx +++ b/packages/shared/src/components/post/PostActions.v2.tsx @@ -3,7 +3,6 @@ import React, { useEffect, useMemo } from 'react'; import type { QueryKey } from '@tanstack/react-query'; import classNames from 'classnames'; import { - AnalyticsIcon, DiscussIcon as CommentIcon, DownvoteIcon, LinkIcon, @@ -31,8 +30,6 @@ import { generateQueryKey, RequestKey, updatePostCache } from '../../lib/query'; import type { LoggedUser } from '../../lib/user'; import { useCanAwardUser } from '../../hooks/useCoresFeature'; import { useUpdateQuery } from '../../hooks/useUpdateQuery'; -import { formatImpressions, getPostImpressions } from '../../lib/impressions'; -import { usePostImpressionsModal } from '../../hooks/post/usePostImpressionsModal'; import { Tooltip } from '../tooltip/Tooltip'; import ConditionalWrapper from '../ConditionalWrapper'; import { useBrandSponsorship } from '../../hooks/useBrandSponsorship'; @@ -58,7 +55,6 @@ export function PostActions({ const { data, onShowPanel, onClose } = useBlockPostPanel(post); const { showTagsPanel } = data; const { ref: actionsRef } = usePostActionsLabelVisibility(); - const onImpressionsClick = usePostImpressionsModal(post); const canAward = useCanAwardUser({ sendingUser: user, receivingUser: post.author as LoggedUser | undefined, @@ -302,17 +298,6 @@ export function PostActions({ labelVisible color={ButtonColor.Cabbage} /> - - } - label="Impressions" - count={getPostImpressions(post)} - countFormat={formatImpressions} - color={ButtonColor.Cheese} - onClick={onImpressionsClick} - /> - {showTagsPanel !== undefined && ( diff --git a/packages/shared/src/components/post/focus/FocusCardActionBar.tsx b/packages/shared/src/components/post/focus/FocusCardActionBar.tsx index a227b76e21..de70d8284a 100644 --- a/packages/shared/src/components/post/focus/FocusCardActionBar.tsx +++ b/packages/shared/src/components/post/focus/FocusCardActionBar.tsx @@ -22,7 +22,6 @@ import CloseButton from '../../CloseButton'; import { UpvoteButtonIcon } from '../../cards/common/UpvoteButtonIcon'; import { IconSize } from '../../Icon'; import { - AnalyticsIcon, DiscussIcon as CommentIcon, DownvoteIcon, LinkIcon, @@ -30,11 +29,6 @@ import { } from '../../icons'; import { Tooltip } from '../../tooltip/Tooltip'; import type { LoggedUser } from '../../../lib/user'; -import { - formatImpressions, - getPostImpressions, -} from '../../../lib/impressions'; -import { usePostImpressionsModal } from '../../../hooks/post/usePostImpressionsModal'; import { PostClickbaitShield } from '../common/PostClickbaitShield'; import { PostMenuOptions } from '../PostMenuOptions'; @@ -110,8 +104,6 @@ export const FocusCardActionBar = ({ const upvotes = post.numUpvotes || 0; const comments = post.numComments || 0; const awards = post.numAwards || 0; - const impressions = getPostImpressions(post); - const onImpressionsClick = usePostImpressionsModal(post); // The bar floats (sticky) from tablet up, so surface the metrics + menu // whenever it's actually pinned there — including when a long post floats it // at the bottom on load, where the stats row above has scrolled off. Below @@ -269,17 +261,6 @@ export const FocusCardActionBar = ({ onClick={onComment} /> - - } - count={isPinned ? impressions : undefined} - countFormat={formatImpressions} - onClick={onImpressionsClick} - /> - {canAward && ( Date: Wed, 24 Jun 2026 14:38:14 +0300 Subject: [PATCH 24/26] fix(post): always show impressions in the stats strip via mock fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stats-strip impressions only rendered when the API returned `analytics.impressions` (author/team-gated), so it was invisible in most environments. Use the shared getPostImpressions helper (real `views`, else the deterministic mock) so the impressions stat is always visible on the post page/modal strip for review — scoped to the non-compact strip so the compact post embed stays clean. Co-Authored-By: Claude Opus 4.8 --- .../post/PostUpvotesCommentsCount.tsx | 61 +++++++++++-------- 1 file changed, 34 insertions(+), 27 deletions(-) diff --git a/packages/shared/src/components/post/PostUpvotesCommentsCount.tsx b/packages/shared/src/components/post/PostUpvotesCommentsCount.tsx index 53415af277..f410891915 100644 --- a/packages/shared/src/components/post/PostUpvotesCommentsCount.tsx +++ b/packages/shared/src/components/post/PostUpvotesCommentsCount.tsx @@ -10,12 +10,18 @@ import { LazyModal } from '../modals/common/types'; import { useHasAccessToCores } from '../../hooks/useCoresFeature'; import Link from '../utilities/Link'; import { webappUrl } from '../../lib/constants'; +import { getPostImpressions } from '../../lib/impressions'; const DEFAULT_REPOSTS_PER_PAGE = 20; type PostUpvotesCommentsCountPost = Pick< Post, - 'analytics' | 'numAwards' | 'numComments' | 'numReposts' | 'numUpvotes' + | 'analytics' + | 'numAwards' + | 'numComments' + | 'numReposts' + | 'numUpvotes' + | 'views' > & Partial> & { author?: Pick, 'id'>; @@ -101,32 +107,33 @@ const PostUpvotesCommentsCountContent = ({ onClick: onCommentsClick, children: getText({ count: comments, label: 'Comment' }), })} - {/* Impressions sit right after comments and look like the other stats; the - analytics page is opened by tapping them (replacing the old blue - "Post analytics" button). Only present when the API returns analytics - (author / team). */} - {!!post.analytics?.impressions && - (post.id && !passive ? ( - - - {getText({ - count: post.analytics.impressions, - label: 'Impression', - })} - - - ) : ( - - {getText({ - count: post.analytics.impressions, - label: 'Impression', - })} - - ))} + {/* Impressions sit right after comments and look like the other stats; + tapping them opens the analytics page (replacing the old blue "Post + analytics" button). Shown on the post page/modal strip (not the + compact embed). Uses the shared impressions helper so it stays visible + via the mock fallback until the API populates real `views`. */} + {!compact && + post.id && + (() => { + const impressions = getPostImpressions({ + id: post.id, + views: post.views, + }); + const label = getText({ count: impressions, label: 'Impression' }); + return !passive ? ( + + + {label} + + + ) : ( + {label} + ); + })()} {reposts > 0 && renderText({ key: 'reposts', From 582b6c29632887d1f1c3f200391784c82ebb07d7 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 24 Jun 2026 15:01:23 +0300 Subject: [PATCH 25/26] fix(cards): vertically center the stat count in its fixed-height box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The InteractionCounter is a fixed h-5 (20px) box with top-aligned content, so smaller type — typo-caption1 (16px line-height) on the mobile/tablet feed action bars — sat visibly higher than the icon. Center the number within the box (justify-center at rest, leading-5 on the rolling slices) so upvote, comment and impressions counts line up with their icons at every resolution. Co-Authored-By: Claude Opus 4.8 --- .../shared/src/components/InteractionCounter.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/components/InteractionCounter.tsx b/packages/shared/src/components/InteractionCounter.tsx index 402f9e7308..ed0c3e3e1e 100644 --- a/packages/shared/src/components/InteractionCounter.tsx +++ b/packages/shared/src/components/InteractionCounter.tsx @@ -40,8 +40,14 @@ export default function InteractionCounter({ ); if (shownValue === value) { + // Center the number within the fixed-height (h-5) box. Without this the text + // is top-aligned, so smaller type (e.g. typo-caption1 on mobile/tablet, whose + // 1rem line-height is shorter than h-5) sits visibly higher than the icon. return ( - + {format(shownValue)} ); @@ -52,8 +58,10 @@ export default function InteractionCounter({ setShownValue(value); }; + // leading-5 makes each rolling slice's line box fill its h-5 height so the + // digits stay centered during the roll (matches the resting state above). const childClassName = - 'h-5 inline-block transition-[opacity,transform] ease-in-out duration-300 will-change-[opacity,transform]'; + 'h-5 leading-5 inline-block transition-[opacity,transform] ease-in-out duration-300 will-change-[opacity,transform]'; return ( From 663b3aec213127e133e862c211e5d3372e6a9d6c Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 24 Jun 2026 15:47:27 +0300 Subject: [PATCH 26/26] fix(post): prefer real impressions over the mock in the stats strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getPostImpressions now returns the real value (analytics.impressions, then views) when present and only falls back to the deterministic mock when both are missing — so the post page shows the real impressions count (fixes the PostPage "shows impressions" test) while still staying visible for review when there is no data. Drop the impressions assertion from the compact ContentEmbeds spec, which no longer renders impressions. Co-Authored-By: Claude Opus 4.8 --- .../contentEmbeds/ContentEmbeds.spec.tsx | 4 +++- .../post/PostUpvotesCommentsCount.tsx | 1 + packages/shared/src/lib/impressions.ts | 19 ++++++++++--------- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/shared/src/components/contentEmbeds/ContentEmbeds.spec.tsx b/packages/shared/src/components/contentEmbeds/ContentEmbeds.spec.tsx index 2f9ebceaf5..db6381eff0 100644 --- a/packages/shared/src/components/contentEmbeds/ContentEmbeds.spec.tsx +++ b/packages/shared/src/components/contentEmbeds/ContentEmbeds.spec.tsx @@ -43,7 +43,9 @@ it('should render post metadata and engagement stats as passive metadata', () => expect(screen.getByText('Now')).toBeInTheDocument(); expect(screen.getByText('4m read time')).toBeInTheDocument(); - expect(screen.getByText('10 Impressions')).toBeInTheDocument(); + // Impressions are shown only on the full post page/modal stats strip, not in + // the compact embed. + expect(screen.queryByText(/Impressions?$/)).not.toBeInTheDocument(); expect(screen.getByText('1.2K Upvotes')).toBeInTheDocument(); expect(screen.getByText('3 Comments')).toBeInTheDocument(); expect(screen.getByText('5 Reposts')).toBeInTheDocument(); diff --git a/packages/shared/src/components/post/PostUpvotesCommentsCount.tsx b/packages/shared/src/components/post/PostUpvotesCommentsCount.tsx index f410891915..41f76a6350 100644 --- a/packages/shared/src/components/post/PostUpvotesCommentsCount.tsx +++ b/packages/shared/src/components/post/PostUpvotesCommentsCount.tsx @@ -118,6 +118,7 @@ const PostUpvotesCommentsCountContent = ({ const impressions = getPostImpressions({ id: post.id, views: post.views, + analytics: post.analytics, }); const label = getText({ count: impressions, label: 'Impression' }); return !passive ? ( diff --git a/packages/shared/src/lib/impressions.ts b/packages/shared/src/lib/impressions.ts index 7e53479d73..036dc8392e 100644 --- a/packages/shared/src/lib/impressions.ts +++ b/packages/shared/src/lib/impressions.ts @@ -28,20 +28,21 @@ export const formatImpressions = (value: number | null): string => { }; /** - * MOCK FALLBACK — the feed does not yet return real per-post impressions - * (`post.views` is currently empty in the feed payload), so when it is missing - * we derive a stable, realistic-looking number from the post id purely so the - * impressions UI can be reviewed on a preview build. When `views` is populated - * we always use the real value. + * MOCK FALLBACK — per-post impressions are only populated in some payloads + * (`analytics.impressions` on the post detail for authors/team, `views` once + * the feed exposes it). When a real value is present we always use it; when it + * is missing we derive a stable, realistic-looking number from the post id + * purely so the impressions UI can be reviewed on a preview build. * * @engineer Replace this fallback with the real impressions field once the API - * exposes it on the feed — keep only the `post.views` branch. + * exposes it everywhere — keep only the real-value branch. */ export const getPostImpressions = ( - post: Pick, + post: Pick, ): number => { - if (typeof post.views === 'number' && post.views > 0) { - return post.views; + const real = post.analytics?.impressions ?? post.views; + if (typeof real === 'number' && real > 0) { + return real; } // Deterministic hash of the id → a stable number in ~100K–3M so each card