From f1f349dd1faf93c50ad9926ab79ce9fc5a1f919f Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sun, 28 Jun 2026 16:55:58 +0300 Subject: [PATCH 01/13] feat(notifications): readability redesign + Storybook coverage Improve notification readability and collect every notification surface into a single Storybook folder for review. Components (shared): - NotificationItem: scannable hierarchy (regular title with bold actor name at ~15px to match X, tertiary low-glare description), relative time moved inline after the title, top-aligned avatar/cover/menu, three-dots menu pinned to the top-right corner, no reserved gap when a row has no menu, no title truncation, roomier vertical padding. - Extract shared NotificationItemLead (avatar + category badge / type icon) so the feed row and in-app popup render leads identically. - InAppNotificationItem: redesigned to match the row (icon + category badge lead, bold-name headline). - NotificationsBell: compact corner badge on the rail bell instead of a full-size bubble that blanketed the icon. - Category badges: Squads now shares the Followers purple (dropped the low-contrast cheese yellow); per-badge glyph color for contrast on both bright and dark fills. Storybook (Components/Notifications/*): - Overview, Bell & badge, Icons & badges, List item, Full page, In-app popup, Toast (live), Incoming playground. Co-Authored-By: Claude Opus 4.8 --- .../notifications/InAppNotificationItem.tsx | 49 ++-- .../notifications/NotificationItem.tsx | 110 +++----- .../notifications/NotificationItemLead.tsx | 63 +++++ .../notifications/NotificationsBell.tsx | 9 +- .../src/components/notifications/utils.ts | 23 +- .../components/NotificationItem.stories.tsx | 2 +- .../components/notifications/Bell.stories.tsx | 150 +++++++++++ .../notifications/FullPage.stories.tsx | 133 ++++++++++ .../notifications/Icons.stories.tsx | 127 ++++++++++ .../notifications/InAppPopup.stories.tsx | 132 ++++++++++ .../IncomingPlayground.stories.tsx | 236 ++++++++++++++++++ .../notifications/Overview.stories.tsx | 178 +++++++++++++ .../notifications/Toast.stories.tsx | 124 +++++++++ .../components/notifications/_mock.tsx | 227 +++++++++++++++++ 14 files changed, 1456 insertions(+), 107 deletions(-) create mode 100644 packages/shared/src/components/notifications/NotificationItemLead.tsx create mode 100644 packages/storybook/stories/components/notifications/Bell.stories.tsx create mode 100644 packages/storybook/stories/components/notifications/FullPage.stories.tsx create mode 100644 packages/storybook/stories/components/notifications/Icons.stories.tsx create mode 100644 packages/storybook/stories/components/notifications/InAppPopup.stories.tsx create mode 100644 packages/storybook/stories/components/notifications/IncomingPlayground.stories.tsx create mode 100644 packages/storybook/stories/components/notifications/Overview.stories.tsx create mode 100644 packages/storybook/stories/components/notifications/Toast.stories.tsx create mode 100644 packages/storybook/stories/components/notifications/_mock.tsx diff --git a/packages/shared/src/components/notifications/InAppNotificationItem.tsx b/packages/shared/src/components/notifications/InAppNotificationItem.tsx index 93b3083b31e..aa1d76dccc9 100644 --- a/packages/shared/src/components/notifications/InAppNotificationItem.tsx +++ b/packages/shared/src/components/notifications/InAppNotificationItem.tsx @@ -1,36 +1,19 @@ import type { ReactElement } from 'react'; import React from 'react'; -import classNames from 'classnames'; import { useObjectPurify } from '../../hooks/useDomPurify'; -import classed from '../../lib/classed'; -import NotificationItemIcon from './NotificationIcon'; -import NotificationItemAvatar from './NotificationItemAvatar'; -import styles from './InAppNotification.module.css'; +import { NotificationItemLead } from './NotificationItemLead'; import type { NewNotification } from '../../graphql/notifications'; -const NotificationWrapper = classed( - 'div', - 'relative flex h-full w-full flex-row overflow-hidden rounded-16 p-3 pr-10 hover:bg-surface-hover focus:bg-theme-active', -); -const NotificationLink = classed('a', 'absolute inset-0 h-full w-full'); -const NotificationAvatar = classed( - 'span', - classNames( - styles.inAppNotificationAvatar, - 'flex flex-row items-start gap-[0.375rem]', - ), -); -const NotificationText = classed( - 'p', - 'flex flex-col flex-1 ml-4 w-full text-left typo-callout multi-truncate line-clamp-3 h-16', -); - interface InAppNotificationItemProps extends NewNotification { onClick?: (e: React.MouseEvent) => void; } +// Real-time popup, laid out like a feed row (NotificationItem): the avatar with +// its colored category badge leads, then the headline with the actor's name +// bold and the rest regular for a scannable hierarchy. export function InAppNotificationItem({ icon, + type, title, avatars, targetUrl, @@ -43,18 +26,24 @@ export function InAppNotificationItem({ } const [avatar] = avatars ?? []; + return ( - - - - - {!!avatar && } - - + +
+ +
+

- + ); } diff --git a/packages/shared/src/components/notifications/NotificationItem.tsx b/packages/shared/src/components/notifications/NotificationItem.tsx index 8e7e3c6cf12..c001be70a18 100644 --- a/packages/shared/src/components/notifications/NotificationItem.tsx +++ b/packages/shared/src/components/notifications/NotificationItem.tsx @@ -5,8 +5,8 @@ import { useRouter } from 'next/router'; import Link from '../utilities/Link'; import type { Notification } from '../../graphql/notifications'; import { useObjectPurify } from '../../hooks/useDomPurify'; -import NotificationItemIcon from './NotificationIcon'; import NotificationItemAvatar from './NotificationItemAvatar'; +import { NotificationItemLead } from './NotificationItemLead'; import { getNotificationCategory, NotificationFilterCategory, @@ -14,7 +14,6 @@ import { notificationMutingCopy, NotificationType, notificationTypeNotClickable, - notificationTypeTheme, } from './utils'; import { KeyboardCommand } from '../../lib/element'; import { ProfileTooltip } from '../profile/ProfileTooltip'; @@ -154,9 +153,6 @@ const NotificationOptionsButton = ({ // Tertiary is the flat variant — transparent, no background or // border (Float carries a faint surface-float background). variant={ButtonVariant.Tertiary} - // Visible by default on mobile (no hover); reveal on hover from - // tablet up. - className="tablet:invisible tablet:group-hover:visible" icon={} size={ButtonSize.XSmall} /> @@ -212,11 +208,6 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { const hasOptions = Object.keys(notificationMutingCopy).includes(type); const [attachment] = attachments ?? []; - // When there is a person/source involved we show their avatar with a colored - // type badge; otherwise (system/digest/streak) the type icon is the lead. - const leadIcon = ( - - ); const category = getNotificationCategory(type); const badge = notificationCategoryBadge[category]; const BadgeIcon = badge.Icon; @@ -229,11 +220,7 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { // three render as a 2x2 grid the size of one avatar — up to three faces plus // the action icon — so the lead never grows wider and every row stays aligned. let avatarContent: ReactElement | null = null; - if (!showGrid) { - avatarContent = hasAvatar ? ( - - ) : null; - } else { + if (showGrid) { // 2x2 of separate, individually-rounded face boxes (no connecting frame, // no "+N" count) plus a circular action cell, sized like one avatar so the // lead width never grows. Each face keeps its hover profile tooltip. @@ -287,7 +274,7 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { badge.bg, )} > - + , ); } @@ -319,7 +306,7 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { return (

@@ -348,50 +335,33 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { {/* Leading avatar + colored type badge — the eye-catching, type-at-a- glance cue (Instagram/Facebook/TikTok). System rows with no person - fall back to the plain type icon. */} -
-
- {hasAvatar ? avatarContent : leadIcon} - {showBadge && !showGrid && ( - - - - )} -
+ fall back to the plain type icon. The >3-actor grid is a list-only + layout; everything else uses the shared single-actor lead. */} +
+ {showGrid ? ( +
{avatarContent}
+ ) : ( + + )}
- {/* Bold headline, then the comment (if any), then the referenced post's - title so it's clear which post/article the notification is about. */} -
- - {/* Meta line leads with the time, then a dot, then the comment (or the - post title when there's no comment). */} - {(timeText || showDescription || showAttachmentTitle) && ( -
- {timeText && ( - - - - )} - {timeText && (showDescription || showAttachmentTitle) && ( - · - )} + {/* Headline (actor name bold, the rest regular for a scannable + hierarchy) with the relative time flowing inline right after it — so a + row reads "what happened" first and the time is a quiet suffix, not a + right-aligned column. Then the comment, then the post's title. */} +
+
+ + {timeText && ( + + + + )} +
+ {(showDescription || showAttachmentTitle) && ( +
{showDescription ? ( ) : ( @@ -402,7 +372,7 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { {/* When there's both a comment and a post, name the post on its own line so it's clear which article it's about. */} {showDescription && showAttachmentTitle && ( -
+
{attachmentTitle}
)} @@ -413,13 +383,13 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { )}
- {/* Trailing actions: the post cover plus a fixed-width menu slot. The - slot is always reserved whenever a row has a cover and/or a menu, so - every cover image lands at the same x regardless of whether that row - has a menu — keeping covers aligned down the whole feed. The cover is - top-aligned with the title (centered-looking on a two-line row). */} + {/* Trailing: the post cover and/or the options menu, both top-aligned with + the title (the menu is the rightmost element, so it pins to the + top-right corner). The menu column is only reserved when the row + actually has a menu — otherwise the cover sits flush to the right edge + instead of leaving an empty gap. */} {(attachment?.image || hasOptions) && ( -
+
{attachment?.image && ( )} -
- {hasOptions && ( + {hasOptions && ( +
- )} -
+
+ )}
)}
diff --git a/packages/shared/src/components/notifications/NotificationItemLead.tsx b/packages/shared/src/components/notifications/NotificationItemLead.tsx new file mode 100644 index 00000000000..c925dc3b6d9 --- /dev/null +++ b/packages/shared/src/components/notifications/NotificationItemLead.tsx @@ -0,0 +1,63 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import type { NotificationAvatar } from '../../graphql/notifications'; +import NotificationItemIcon from './NotificationIcon'; +import NotificationItemAvatar from './NotificationItemAvatar'; +import type { NotificationIconType, NotificationType } from './utils'; +import { + getNotificationCategory, + NotificationFilterCategory, + notificationCategoryBadge, + notificationTypeTheme, +} from './utils'; +import { IconSize } from '../Icon'; + +interface NotificationItemLeadProps { + type: NotificationType; + icon: NotificationIconType; + avatar?: NotificationAvatar; +} + +// Single-actor lead: the avatar with the colored category badge notched into +// its corner (Instagram/Facebook/TikTok pattern), or the plain type icon when +// there's no actor (system/digest/streak). Shared so the feed row and the +// real-time in-app popup present a notification identically. NotificationItem's +// >3-actor 2x2 grid is a list-only layout and stays in that component. +export function NotificationItemLead({ + type, + icon, + avatar, +}: NotificationItemLeadProps): ReactElement { + const category = getNotificationCategory(type); + const badge = notificationCategoryBadge[category]; + const BadgeIcon = badge.Icon; + const hasAvatar = !!avatar; + // Badge only for notifications about you (upvotes/comments/mentions/follows/ + // squad activity). Source posts & system land in `Updates` and stay clean. + const showBadge = + hasAvatar && category !== NotificationFilterCategory.Updates; + + return ( +
+ {avatar ? ( + + ) : ( + + )} + {showBadge && ( + + + + )} +
+ ); +} diff --git a/packages/shared/src/components/notifications/NotificationsBell.tsx b/packages/shared/src/components/notifications/NotificationsBell.tsx index 0b17ebff20a..aa27616006b 100644 --- a/packages/shared/src/components/notifications/NotificationsBell.tsx +++ b/packages/shared/src/components/notifications/NotificationsBell.tsx @@ -78,9 +78,14 @@ function NotificationsBell({ className="pointer-events-none" /> {hasNotification && ( - + // Compact corner badge: the rail bell is only `size-6` (24px), + // so the full-size shared `Bubble` (min 20px) blankets the + // glyph. A smaller pill notched into the top-right corner reads + // as a badge instead of covering the icon. `border-background- + // default` matches the sidebar surface for the cutout effect. + {getUnreadText(unreadCount)} - +
)} {!railHideLabel && ( diff --git a/packages/shared/src/components/notifications/utils.ts b/packages/shared/src/components/notifications/utils.ts index 6ecd587f797..00f4b146aed 100644 --- a/packages/shared/src/components/notifications/utils.ts +++ b/packages/shared/src/components/notifications/utils.ts @@ -461,34 +461,49 @@ export const getNotificationCategory = ( notificationTypeToCategory[type] ?? NotificationFilterCategory.Updates; // Eye-catching colored type badge overlaid on the avatar (Instagram/Facebook/ -// TikTok pattern): a solid accent circle + white glyph that signals the -// notification type at a glance. +// TikTok pattern): a solid accent circle + glyph that signals the notification +// type at a glance. +// +// `fg` is chosen for contrast against the badge fill, not for decoration: the +// food-palette accents split into bright (avocado green, blueCheese cyan, bun +// orange) where a white glyph washes out, and dark (cabbage/onion purples) +// where white reads. Bright fills get a dark glyph, dark fills get white — so +// the icon stays legible in both light and dark themes (the fill hue barely +// shifts between themes, so a fixed per-badge `fg` is enough). export const notificationCategoryBadge: Record< NotificationFilterCategory, - { bg: string; Icon: ComponentType } + { bg: string; fg: string; Icon: ComponentType } > = { [NotificationFilterCategory.Upvotes]: { bg: 'bg-accent-avocado-default', + fg: 'text-black', Icon: UpvoteIcon, }, [NotificationFilterCategory.Mentions]: { bg: 'bg-accent-cabbage-default', + fg: 'text-white', Icon: AtIcon, }, [NotificationFilterCategory.Comments]: { bg: 'bg-accent-blueCheese-default', + fg: 'text-black', Icon: DiscussIcon, }, [NotificationFilterCategory.Followers]: { bg: 'bg-accent-onion-default', + fg: 'text-white', Icon: AddUserIcon, }, + // Squads shares the Followers purple — the cheese yellow read poorly and a + // white glyph on yellow had almost no contrast. [NotificationFilterCategory.Squads]: { - bg: 'bg-accent-cheese-default', + bg: 'bg-accent-onion-default', + fg: 'text-white', Icon: SquadIcon, }, [NotificationFilterCategory.Updates]: { bg: 'bg-accent-bun-default', + fg: 'text-black', Icon: MegaphoneIcon, }, }; diff --git a/packages/storybook/stories/components/NotificationItem.stories.tsx b/packages/storybook/stories/components/NotificationItem.stories.tsx index 2197136973f..534142e2440 100644 --- a/packages/storybook/stories/components/NotificationItem.stories.tsx +++ b/packages/storybook/stories/components/NotificationItem.stories.tsx @@ -40,7 +40,7 @@ const postAttachment = (seed: string, title: string) => ({ }); const meta: Meta = { - title: 'Components/NotificationItem', + title: 'Components/Notifications/List item — all types', component: NotificationItem, tags: ['autodocs'], decorators: [ diff --git a/packages/storybook/stories/components/notifications/Bell.stories.tsx b/packages/storybook/stories/components/notifications/Bell.stories.tsx new file mode 100644 index 00000000000..bdce0cbc394 --- /dev/null +++ b/packages/storybook/stories/components/notifications/Bell.stories.tsx @@ -0,0 +1,150 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import NotificationsBell from '@dailydotdev/shared/src/components/notifications/NotificationsBell'; +import { NotificationsContextProvider } from '@dailydotdev/shared/src/contexts/NotificationsContext'; +import { Bubble } from '@dailydotdev/shared/src/components/tooltips/utils'; +import { getUnreadText } from '@dailydotdev/shared/src/components/notifications/utils'; +import ExtensionProviders from '../../extension/_providers'; + +// The notifications bell + its unread "Bubble" badge, across the surfaces it +// renders on: the header button (laptop Float / mobile Option) and the v2 +// sidebar rail (vertical icon + "Alerts" label). The badge caps at "20+". +// +// `NotificationsContextProvider` is nested here to drive `unreadCount` — it sits +// closer to the bell than the boot-data provider, so it wins. + +const meta: Meta = { + title: 'Components/Notifications/Bell & badge', + component: NotificationsBell, + parameters: { + docs: { + description: { + component: + 'Header/rail bell button and the unread count Bubble. Readability levers: badge contrast against the bell, the 20+ cap, badge offset/size, and the rail label.', + }, + }, + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +const Cell = ({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) => ( +
+
{children}
+ {label} +
+); + +const WithCount = ({ + count, + children, +}: { + count: number; + children: React.ReactNode; +}) => ( + + {children} + +); + +export const HeaderButton: Story = { + name: 'Header button — unread counts', + render: () => ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ ), +}; + +export const RailVariant: Story = { + name: 'Sidebar rail — icon + label', + render: () => ( +
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+
+ ), +}; + +// The raw badge primitive, isolated so its contrast/size can be tuned directly. +export const CountBubble: Story = { + name: 'Count bubble (raw)', + render: () => ( +
+ {[1, 5, 9, 20, 21, 142].map((n) => ( + + + + {getUnreadText(n)} + + + + ))} +
+ ), +}; diff --git a/packages/storybook/stories/components/notifications/FullPage.stories.tsx b/packages/storybook/stories/components/notifications/FullPage.stories.tsx new file mode 100644 index 00000000000..f321519aaeb --- /dev/null +++ b/packages/storybook/stories/components/notifications/FullPage.stories.tsx @@ -0,0 +1,133 @@ +import React, { useMemo, useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import NotificationItem from '@dailydotdev/shared/src/components/notifications/NotificationItem'; +import { + getNotificationCategory, + notificationFilterCategoryLabel, + notificationFilterCategoryList, + NotificationFilterCategory, +} from '@dailydotdev/shared/src/components/notifications/utils'; +import { + SquadDirectoryNavbar, + SquadDirectoryNavbarItem, +} from '@dailydotdev/shared/src/components/squads/layout/SquadDirectoryNavbar'; +import { ButtonSize } from '@dailydotdev/shared/src/components/buttons/Button'; +import { SettingsIcon } from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import ExtensionProviders from '../../extension/_providers'; +import { groupByTime, sampleNotifications } from './_mock'; + +// Faithful, provider-light reconstruction of the /notifications page +// (packages/webapp/components/notifications/NotificationsFeed.tsx) using the +// real shared NotificationItem rows + the same filter bar primitive and time +// grouping. The live page is auth-gated and needs backend data, so this is the +// canvas to iterate on the page's readability. + +const meta: Meta = { + title: 'Components/Notifications/Full page', + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: + 'Reconstruction of the /notifications page — header, type filters, time-grouped feed of NotificationItem rows. Use the filter tabs to scope by category. The real page lives in NotificationsFeed.tsx and is auth-gated, so this is where to review page-level readability.', + }, + }, + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +const NotificationsPage = (): React.ReactElement => { + const [active, setActive] = useState(null); + + const filtered = useMemo( + () => + active + ? sampleNotifications.filter( + (item) => getNotificationCategory(item.type) === active, + ) + : sampleNotifications, + [active], + ); + + const groups = useMemo(() => groupByTime(filtered), [filtered]); + + return ( +
+
+

Notifications

+ +
+ +
+ + setActive(null)} + /> + {notificationFilterCategoryList.map((category) => ( + setActive(category)} + /> + ))} + +
+ + {groups.map((group) => ( +
+

+ {group.label} +

+ {group.items.map((item) => ( + + ))} +
+ ))} + + {filtered.length === 0 && active && ( +

+ No {notificationFilterCategoryLabel[active].toLowerCase()}{' '} + notifications yet. +

+ )} +
+ ); +}; + +export const Page: Story = { + name: 'Notifications page', + render: () => , +}; + +export const MobileViewport: Story = { + name: 'Notifications page (mobile)', + parameters: { viewport: { defaultViewport: 'mobile2' } }, + render: () => , +}; diff --git a/packages/storybook/stories/components/notifications/Icons.stories.tsx b/packages/storybook/stories/components/notifications/Icons.stories.tsx new file mode 100644 index 00000000000..1833b9b23c6 --- /dev/null +++ b/packages/storybook/stories/components/notifications/Icons.stories.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import NotificationIcon from '@dailydotdev/shared/src/components/notifications/NotificationIcon'; +import { + NotificationFilterCategory, + notificationCategoryBadge, + notificationFilterCategoryLabel, + NotificationIconType, +} from '@dailydotdev/shared/src/components/notifications/utils'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import ExtensionProviders from '../../extension/_providers'; + +// The two visual vocabularies a notification row leans on: +// 1. The lead type-icon (NotificationIcon) — what shows when there is no actor +// avatar (system / digest / streak rows). +// 2. The category badge — the colored corner glyph overlaid on an avatar that +// signals upvote / comment / mention / follow / squad at a glance. + +const meta: Meta = { + title: 'Components/Notifications/Icons & badges', + parameters: { + docs: { + description: { + component: + 'Every notification lead icon and every category badge in one place — the building blocks for a row\'s type-at-a-glance cue. Tune glyph contrast, background, and color tokens here.', + }, + }, + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +const Tile = ({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) => ( +
+
{children}
+ + {label} + +
+); + +export const LeadIcons: Story = { + name: 'Lead type icons', + render: () => ( +
+ {Object.values(NotificationIconType).map((icon) => ( + + + + ))} +
+ ), +}; + +export const CategoryBadges: Story = { + name: 'Category badges', + render: () => ( +
+ {Object.values(NotificationFilterCategory).map((category) => { + const badge = notificationCategoryBadge[category]; + const BadgeIcon = badge.Icon; + return ( + + + + + + ); + })} +
+ ), +}; + +// How the badge actually sits on an avatar in the feed (corner overlay). +export const BadgeOnAvatar: Story = { + name: 'Badge over avatar', + render: () => ( +
+ {Object.values(NotificationFilterCategory) + .filter((c) => c !== NotificationFilterCategory.Updates) + .map((category) => { + const badge = notificationCategoryBadge[category]; + const BadgeIcon = badge.Icon; + return ( +
+
+ + + + +
+ + {notificationFilterCategoryLabel[category]} + +
+ ); + })} +
+ ), +}; diff --git a/packages/storybook/stories/components/notifications/InAppPopup.stories.tsx b/packages/storybook/stories/components/notifications/InAppPopup.stories.tsx new file mode 100644 index 00000000000..32288f1a257 --- /dev/null +++ b/packages/storybook/stories/components/notifications/InAppPopup.stories.tsx @@ -0,0 +1,132 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { fn } from 'storybook/test'; +import { InAppNotificationItem } from '@dailydotdev/shared/src/components/notifications/InAppNotificationItem'; +import { + NotificationIconType, + NotificationType, +} from '@dailydotdev/shared/src/components/notifications/utils'; +import { ModalClose } from '@dailydotdev/shared/src/components/modals/common/ModalClose'; +import { ButtonSize } from '@dailydotdev/shared/src/components/buttons/Button'; +import ExtensionProviders from '../../extension/_providers'; +import { userAvatar, sourceAvatar } from './_mock'; + +// The real-time, in-app toast that bounces in (top-center on mobile, bottom- +// right on desktop) when a new notification arrives. Live shell: +// InAppNotification.tsx wraps InAppNotificationItem in a pepper-subtler card +// with a close button. Reproduced statically here so its 3-line clamp and +// icon+avatar lockup can be reviewed. + +const meta: Meta = { + title: 'Components/Notifications/In-app popup', + component: InAppNotificationItem, + parameters: { + docs: { + description: { + component: + 'Real-time push-style popup. Title is sanitized HTML and clamps to 3 lines. Review: title contrast on the pepper-subtler card, icon/avatar lockup, and how long titles truncate.', + }, + }, + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +// Mirror of the live Container in InAppNotification.tsx (size + surface), minus +// the fixed positioning so it sits inline in the canvas. +const PopupShell = ({ + children, +}: { + children: React.ReactNode; +}): React.ReactElement => ( +
+ + {children} +
+); + +export const CommentReply: Story = { + render: () => ( +
+ + + +
+ ), +}; + +export const NewPost: Story = { + render: () => ( +
+ + + +
+ ), +}; + +export const LongTitleClamp: Story = { + name: 'Long title (3-line clamp)', + render: () => ( +
+ + + +
+ ), +}; + +export const SystemNoAvatar: Story = { + name: 'System (icon only)', + render: () => ( +
+ + + +
+ ), +}; diff --git a/packages/storybook/stories/components/notifications/IncomingPlayground.stories.tsx b/packages/storybook/stories/components/notifications/IncomingPlayground.stories.tsx new file mode 100644 index 00000000000..b5edc8dac77 --- /dev/null +++ b/packages/storybook/stories/components/notifications/IncomingPlayground.stories.tsx @@ -0,0 +1,236 @@ +import React, { useCallback, useRef, useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { fn } from 'storybook/test'; +import classNames from 'classnames'; +import NotificationsBell from '@dailydotdev/shared/src/components/notifications/NotificationsBell'; +import { InAppNotificationItem } from '@dailydotdev/shared/src/components/notifications/InAppNotificationItem'; +import { NotificationsContextProvider } from '@dailydotdev/shared/src/contexts/NotificationsContext'; +import { + NotificationIconType, + NotificationType, +} from '@dailydotdev/shared/src/components/notifications/utils'; +import { ModalClose } from '@dailydotdev/shared/src/components/modals/common/ModalClose'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +// The real production entrance animation lives in this CSS module — importing +// it here renders the "Current" popup exactly as it animates in the app. +import inAppStyles from '@dailydotdev/shared/src/components/notifications/InAppNotification.module.css'; +import ExtensionProviders from '../../extension/_providers'; +import { userAvatar, sourceAvatar } from './_mock'; + +// Playground: watch a notification arrive — the NotificationsBell badge ticks +// up and the in-app popup flies in — so the entrance motion can be evaluated +// and improved. "Current" mirrors production exactly; "Proposed" is a calmer +// spring (no infinite bounce) plus a badge pop on the bell. + +const meta: Meta = { + title: 'Components/Notifications/Incoming playground', + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: + 'Simulates a notification arriving in-product: the bell badge increments and the real-time popup animates in. Toggle Current vs Proposed entrance motion. The popup uses the redesigned item (icon + category badge lead, bold-name headline).', + }, + }, + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +type Kind = 'comment' | 'upvote' | 'follow' | 'system'; + +const SAMPLES: Record< + Kind, + Pick< + React.ComponentProps, + 'icon' | 'type' | 'title' | 'avatars' + > +> = { + comment: { + icon: NotificationIconType.Comment, + type: NotificationType.CommentReply, + title: 'Ido Shamun replied to your comment', + avatars: [userAvatar('ido', 'Ido')], + }, + upvote: { + icon: NotificationIconType.Upvote, + type: NotificationType.ArticleUpvoteMilestone, + title: 'Nimrod Kramer and 24 others upvoted your post', + avatars: [userAvatar('nimrod', 'Nimrod')], + }, + follow: { + icon: NotificationIconType.User, + type: NotificationType.UserFollow, + title: 'Tobias Wolf started following you', + avatars: [userAvatar('tobias', 'Tobias')], + }, + system: { + icon: NotificationIconType.DailyDev, + type: NotificationType.DigestReady, + title: 'Your daily digest is ready', + avatars: undefined, + }, +}; + +// Calmer alternative entrance + a badge pop, kept story-local so nothing ships +// until it's chosen. Compare against the production motion with the toggle. +const proposedCss = ` +@keyframes nudgePopupIn { + 0% { transform: translateY(12px) scale(0.96); opacity: 0; } + 60% { transform: translateY(-2px) scale(1.01); opacity: 1; } + 100% { transform: translateY(0) scale(1); opacity: 1; } +} +@keyframes nudgeBadgePop { + 0% { transform: scale(0.4); } + 60% { transform: scale(1.18); } + 100% { transform: scale(1); } +} +.nudge-popup-in { animation: nudgePopupIn 0.34s cubic-bezier(0.22, 1, 0.36, 1) both; } +.nudge-badge-pop { animation: nudgeBadgePop 0.32s cubic-bezier(0.22, 1, 0.36, 1); } +`; + +const Playground = (): React.ReactElement => { + const [unread, setUnread] = useState(2); + const [kind, setKind] = useState('comment'); + const [proposed, setProposed] = useState(false); + // `seq` changes per send so the popup remounts and replays its entrance. + const [popup, setPopup] = useState<{ seq: number; kind: Kind } | null>(null); + const seqRef = useRef(0); + const timerRef = useRef>(); + + const send = useCallback( + (next: Kind) => { + seqRef.current += 1; + setUnread((u) => Math.min(u + 1, 999)); + setPopup({ seq: seqRef.current, kind: next }); + clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => setPopup(null), 5200); + }, + [], + ); + + const sample = popup ? SAMPLES[popup.kind] : null; + + return ( +
+ + + {/* Mock product top bar with the real bell on the right. */} +
+ + daily.dev + + + {/* Keyed so the bell remounts on each new notification, replaying the + proposed badge pop. */} + + + + +
+ + {/* Controls */} +
+

+ Send a notification and watch it arrive (popup appears bottom-right on + desktop, top-center on mobile). Unread: {unread}. +

+ +
+ Type: + {(Object.keys(SAMPLES) as Kind[]).map((k) => ( + + ))} +
+ +
+ Motion: + + +
+ +
+ +
+
+ + {/* The popup. Fixed like production; remounted per send via `key`. */} + {popup && sample && ( +
+ setPopup(null)} + /> + +
+ )} +
+ ); +}; + +export const Playground_: Story = { + name: 'Watch it arrive', + render: () => , +}; diff --git a/packages/storybook/stories/components/notifications/Overview.stories.tsx b/packages/storybook/stories/components/notifications/Overview.stories.tsx new file mode 100644 index 00000000000..a4811b47e38 --- /dev/null +++ b/packages/storybook/stories/components/notifications/Overview.stories.tsx @@ -0,0 +1,178 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import ExtensionProviders from '../../extension/_providers'; + +// Landing page for the notifications readability audit. Collects every +// notification surface in one folder and calls out the concrete levers to pull +// on each one, so improving readability is a guided pass rather than a hunt. + +const meta: Meta = { + title: 'Components/Notifications/Overview', + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: + 'Start here. A map of every notification surface, where it lives in the codebase, and the specific readability levers to review on each.', + }, + }, + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +interface SurfaceRow { + story: string; + surface: string; + source: string; + levers: string[]; +} + +const surfaces: SurfaceRow[] = [ + { + story: 'Bell & badge', + surface: 'Header / rail bell + unread count Bubble', + source: 'shared/components/notifications/NotificationsBell.tsx', + levers: [ + 'Badge contrast (white on cabbage) against the bell', + 'Badge offset / size — does it clip the bell glyph?', + 'The "20+" cap (getUnreadText) — is the threshold right?', + 'Rail "Alerts" label legibility at caption2', + ], + }, + { + story: 'Icons & badges', + surface: 'Lead type icons + category badges', + source: 'shared/components/notifications/NotificationIcon.tsx + utils.ts', + levers: [ + 'Glyph contrast inside the rounded surface-float chip', + 'Category badge color per food token (avocado / cabbage / blueCheese / onion / cheese / bun)', + 'White glyph legibility at XXSmall on the colored badge', + 'Which types deserve a badge vs. fall into "Updates"', + ], + }, + { + story: 'List item — all types', + surface: 'A single feed row (NotificationItem)', + source: 'shared/components/notifications/NotificationItem.tsx', + levers: [ + 'Title weight/size (bold typo-callout) vs. 2-line clamp', + 'Meta line: time + description at typo-footnote text-tertiary — is it readable?', + 'When description AND post title both show — is the hierarchy clear?', + 'Unread state (bg-surface-float) — is it distinct enough?', + 'Avatar + corner badge + attachment alignment down the column', + ], + }, + { + story: 'Full page', + surface: 'The /notifications page', + source: 'webapp/components/notifications/NotificationsFeed.tsx', + levers: [ + 'Time-group headers (Today / This week / …) at typo-footnote text-tertiary', + 'Filter tabs density and active underline', + 'Row-to-row rhythm and divider treatment', + 'Empty-state copy per filter', + ], + }, + { + story: 'In-app popup', + surface: 'Real-time push-style popup', + source: 'shared/components/notifications/InAppNotificationItem.tsx', + levers: [ + 'Title contrast on the accent-pepper-subtler card', + '3-line clamp — where do long titles get cut?', + 'Icon + avatar lockup spacing', + ], + }, + { + story: 'Toast (live)', + surface: 'Transient app toast', + source: 'shared/components/notifications/Toast.tsx', + levers: [ + 'Inverting chip — text/icon resolve against the chip, not the page', + 'Status icon color vibrance per variant', + 'Action button + dismiss ring legibility', + ], + }, +]; + +const Pill = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + +const NotificationsOverview = (): React.ReactElement => ( +
+

Notifications — readability audit

+

+ Every notification surface, collected in one folder. The feedback is that + notifications aren't readable enough — so each story below isolates a + surface and lists the concrete levers to tune. Toggle the Storybook theme + (light / dark) on each to check contrast both ways. Work top to bottom. +

+ +
+ {surfaces.map((row, index) => ( +
+
+ + {index + 1}. + +

{row.surface}

+
+

+ Story:{' '} + + Notifications / {row.story} + {' '} + · {row.source} +

+
    + {row.levers.map((lever) => ( +
  • {lever}
  • + ))} +
+
+ ))} +
+ +
+

Shared readability levers

+
    +
  • + Type scale: titles use typo-callout bold; meta uses{' '} + typo-footnote. Most "hard to read" reports trace + back to the footnote meta line at text-tertiary /{' '} + text-quaternary. +
  • +
  • + Color tokens only (text-primary,{' '} + text-secondary, …) — no raw colors (ESLint{' '} + no-custom-color). +
  • +
  • + Check both themes and the mobile viewport — the kebab menu and badge + offsets differ on mobile. +
  • +
+
+
+); + +export const Start: Story = { + name: 'Start here', + render: () => , +}; diff --git a/packages/storybook/stories/components/notifications/Toast.stories.tsx b/packages/storybook/stories/components/notifications/Toast.stories.tsx new file mode 100644 index 00000000000..2db1bb2989c --- /dev/null +++ b/packages/storybook/stories/components/notifications/Toast.stories.tsx @@ -0,0 +1,124 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useQueryClient } from '@tanstack/react-query'; +import Toast from '@dailydotdev/shared/src/components/notifications/Toast'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import ExtensionProviders from '../../extension/_providers'; + +// The transient, app-level toast (bottom-left). This is the SAME component the +// app ships (Toast.tsx). Storybook mocks `useToastNotification`, so instead of +// calling the hook we seed the toast query cache directly — the exact key/shape +// the real Toast reads — and let the real component render, animate and dismiss. +// +// `variant` values mirror ToastType ('success' | 'error' | …); Toast maps them +// to the leading status icon + color. + +const TOAST_NOTIF_KEY = ['toast_notif']; + +const meta: Meta = { + title: 'Components/Notifications/Toast (live)', + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: + 'Live transient toast — the real Toast.tsx. The chip background inverts per theme, so status colors and text resolve against the chip, not the page. See also the deeper design playground at Components/Toast/Redesign Comparison.', + }, + }, + }, + decorators: [ + (Story) => ( + + + {/* Auto-dismiss on so the countdown ring is visible. */} + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +type Variant = + | 'default' + | 'success' + | 'error' + | 'warning' + | 'info' + | 'loading'; + +const Triggers = (): React.ReactElement => { + const client = useQueryClient(); + + const fire = + (message: string, variant?: Variant, extra?: Record) => + () => + client.setQueryData(TOAST_NOTIF_KEY, { + message, + timer: 6000, + variant, + ...extra, + }); + + const variants: Array<{ label: string; message: string; variant?: Variant }> = + [ + { label: 'Default', message: 'Saved to your bookmarks' }, + { + label: 'Success', + message: 'Post shared successfully', + variant: 'success', + }, + { + label: 'Error', + message: 'Something went wrong, try again', + variant: 'error', + }, + { + label: 'Warning', + message: 'Your session is about to expire', + variant: 'warning', + }, + { label: 'Info', message: 'Syncing your preferences', variant: 'info' }, + { label: 'Loading', message: 'Uploading…', variant: 'loading' }, + ]; + + return ( +
+

+ Fire a toast — it appears bottom-left. Hover to pause the dismiss ring. +

+
+ {variants.map(({ label, message, variant }) => ( + + ))} +
+ +
+ ); +}; + +export const Playground: Story = { + render: () => , +}; diff --git a/packages/storybook/stories/components/notifications/_mock.tsx b/packages/storybook/stories/components/notifications/_mock.tsx new file mode 100644 index 00000000000..6815592a041 --- /dev/null +++ b/packages/storybook/stories/components/notifications/_mock.tsx @@ -0,0 +1,227 @@ +import { fn } from 'storybook/test'; +import type { NotificationItemProps } from '@dailydotdev/shared/src/components/notifications/NotificationItem'; +import { + NotificationIconType, + NotificationType, +} from '@dailydotdev/shared/src/components/notifications/utils'; +import { + NotificationAttachmentType, + NotificationAvatarType, +} from '@dailydotdev/shared/src/graphql/notifications'; + +// Shared sample data for the Notifications stories so the "Full page" and +// "Overview" surfaces stay in sync with one realistic data set instead of each +// re-inventing notifications. Mirrors the real feed: a mix of types spread +// across the same time buckets the live page groups by (Today / This week / +// This month / Earlier). + +export const img = (seed: string, size = 96): string => + `https://picsum.photos/seed/${seed}/${size}`; + +const minutesAgo = (m: number) => new Date(Date.now() - m * 60_000); +const hoursAgo = (h: number) => new Date(Date.now() - h * 3_600_000); +const daysAgo = (d: number) => new Date(Date.now() - d * 86_400_000); + +export const userAvatar = (seed: string, name: string) => ({ + type: NotificationAvatarType.User, + referenceId: seed, + name, + image: img(`user-${seed}`, 64), + targetUrl: `/${seed}`, +}); + +export const sourceAvatar = (seed: string, name: string) => ({ + type: NotificationAvatarType.Source, + referenceId: seed, + name, + image: img(`source-${seed}`, 64), + targetUrl: `/sources/${seed}`, +}); + +export const postAttachment = (seed: string, title: string) => ({ + type: NotificationAttachmentType.Post, + title, + image: img(`post-${seed}`, 160), +}); + +// `createdAt` is deliberately chosen so the set lands across all four time +// groups, with a couple of unread (recent) rows at the top. +const defs: Array & { isUnread?: boolean }> = [ + // ---- Today ---- + { + type: NotificationType.ArticleNewComment, + icon: NotificationIconType.Comment, + title: 'Nimrod Kramer commented on your post', + description: 'Great write-up — the part about caching really helped.', + avatars: [userAvatar('nimrod', 'Nimrod')], + attachments: [postAttachment('c1', 'Scaling our cache layer')], + createdAt: minutesAgo(8), + isUnread: true, + }, + { + type: NotificationType.CommentMention, + icon: NotificationIconType.Comment, + title: 'Tsahi Matsliah mentioned you in a comment', + description: 'Hey @you, what do you think about this approach?', + avatars: [userAvatar('tsahi', 'Tsahi')], + attachments: [ + postAttachment('mention2', 'The state of frontend frameworks in 2026'), + ], + createdAt: minutesAgo(42), + isUnread: true, + }, + { + type: NotificationType.ArticleUpvoteMilestone, + icon: NotificationIconType.Upvote, + title: '25 upvotes! No bugs, just vibes ✨', + description: 'Your post is on a roll today', + numTotalAvatars: 25, + avatars: [ + userAvatar('a1', 'One'), + userAvatar('a2', 'Two'), + userAvatar('a3', 'Three'), + ], + createdAt: hoursAgo(3), + isUnread: true, + }, + { + type: NotificationType.UserFollow, + icon: NotificationIconType.User, + title: 'Tobias Wolf started following you', + avatars: [userAvatar('tobias', 'Tobias')], + createdAt: hoursAgo(6), + }, + // ---- This week ---- + { + type: NotificationType.CommentReply, + icon: NotificationIconType.Comment, + title: 'Ido Shamun replied to your comment', + description: + 'This is a longer reply so we can see the snippet wrap to a maximum of three lines and then truncate with an ellipsis instead of being cut off after one line.', + avatars: [userAvatar('ido', 'Ido')], + createdAt: daysAgo(2), + }, + { + type: NotificationType.SquadPostAdded, + icon: NotificationIconType.Bell, + title: 'GeekLuffy posted in AI', + avatars: [sourceAvatar('ai', 'AI'), userAvatar('luffy', 'Luffy')], + attachments: [postAttachment('sq1', 'Fine-tuning on a budget')], + createdAt: daysAgo(3), + }, + { + type: NotificationType.SourcePostAdded, + icon: NotificationIconType.Bell, + title: 'New post in Agentic Digest', + avatars: [sourceAvatar('agentic', 'Agentic Digest')], + attachments: [ + postAttachment( + 'p1', + 'MAI-Code-1-Flash beats Claude Haiku 4.5 on SWE-Bench', + ), + ], + createdAt: daysAgo(5), + }, + // ---- This month ---- + { + type: NotificationType.UserReceivedAward, + icon: NotificationIconType.Star, + title: 'keshavashiya awarded you +7 Cores for being awesome!', + avatars: [userAvatar('keshav', 'keshavashiya')], + createdAt: daysAgo(12), + }, + { + type: NotificationType.UserTopReaderBadge, + icon: NotificationIconType.TopReaderBadge, + title: 'You earned the Top Reader badge in JavaScript', + createdAt: daysAgo(16), + }, + { + type: NotificationType.StreakReminder, + icon: NotificationIconType.Streak, + title: 'Your 7-day streak is about to expire', + description: 'Read a post today to keep it alive', + createdAt: daysAgo(20), + }, + { + type: NotificationType.BriefingReady, + icon: NotificationIconType.DailyDev, + title: 'Your presidential briefing is ready', + createdAt: daysAgo(24), + }, + // ---- Earlier ---- + { + type: NotificationType.NewOpportunityMatch, + icon: NotificationIconType.Opportunity, + title: 'New match: Senior Frontend Engineer at Acme', + description: 'Based on your profile and interests', + createdAt: daysAgo(45), + }, + { + type: NotificationType.Announcements, + icon: NotificationIconType.DailyDev, + title: 'A big new feature just landed on daily.dev', + createdAt: daysAgo(70), + }, + { + type: NotificationType.System, + icon: NotificationIconType.DailyDev, + title: 'We updated our terms of service', + createdAt: daysAgo(120), + }, +]; + +export const sampleNotifications: Array< + NotificationItemProps & { isUnread?: boolean } +> = defs.map((def, index) => ({ + onClick: fn(), + targetUrl: '/post/123', + referenceId: `mock-${index}`, + icon: NotificationIconType.Bell, + type: NotificationType.System, + title: 'Notification', + ...def, +})); + +// Same coarse buckets the live page uses (see NotificationsFeed.tsx). +export const TIME_GROUPS = [ + { key: 'today', label: 'Today', maxDays: 0 }, + { key: 'week', label: 'This week', maxDays: 7 }, + { key: 'month', label: 'This month', maxDays: 30 }, + { key: 'earlier', label: 'Earlier', maxDays: Number.POSITIVE_INFINITY }, +] as const; + +const calendarDaysAgo = (date: Date): number => { + const startOf = (d: Date) => + new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); + return Math.round((startOf(new Date()) - startOf(date)) / 86_400_000); +}; + +export const groupByTime = ( + items: Array, +): Array<{ + key: string; + label: string; + items: Array; +}> => { + const byKey = new Map< + string, + Array + >(); + items.forEach((item) => { + const days = item.createdAt ? calendarDaysAgo(item.createdAt) : Infinity; + const group = + TIME_GROUPS.find((bucket) => days <= bucket.maxDays) ?? + TIME_GROUPS[TIME_GROUPS.length - 1]; + const list = byKey.get(group.key) ?? []; + list.push(item); + byKey.set(group.key, list); + }); + return TIME_GROUPS.filter((bucket) => byKey.has(bucket.key)).map((bucket) => ({ + key: bucket.key, + label: bucket.label, + items: byKey.get(bucket.key) as Array< + NotificationItemProps & { isUnread?: boolean } + >, + })); +}; From ec77a4cc31c8d70eee491bc88e1f50b3434c49aa Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sun, 28 Jun 2026 17:18:24 +0300 Subject: [PATCH 02/13] style: prettier formatting for NotificationItemLead usage Co-Authored-By: Claude Opus 4.8 --- .../src/components/notifications/NotificationItem.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/components/notifications/NotificationItem.tsx b/packages/shared/src/components/notifications/NotificationItem.tsx index c001be70a18..b8e6279c2c9 100644 --- a/packages/shared/src/components/notifications/NotificationItem.tsx +++ b/packages/shared/src/components/notifications/NotificationItem.tsx @@ -341,7 +341,11 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { {showGrid ? (
{avatarContent}
) : ( - + )}
From 5e83f78a1ec76cb75fa94f5834c73ba4aa2981d3 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 29 Jun 2026 00:38:49 +0300 Subject: [PATCH 03/13] fix(notifications): lead row with the human actor, not the source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row led with avatars[0], but the API lists avatars in different orders per type — squad comments/replies/posts arrive as [source, user], so the row showed a source/squad logo instead of the person who acted (e.g. a comment on your post showed the post's source, not the commenter). Add getNotificationLeadAvatar: always lead with the User avatar when one is present, falling back to the first avatar for source-only rows (new post in a source, role changes) and the type icon for system/digest rows. Applied to both NotificationItem and the in-app popup. Kept in its own module so the value import of NotificationAvatarType doesn't create a utils <-> graphql import cycle. Also add a "Use cases" Storybook page documenting every scenario with the avatars the API sends, the resolved lead, the badge, and the rationale. Co-Authored-By: Claude Opus 4.8 --- .../notifications/InAppNotificationItem.tsx | 3 +- .../notifications/NotificationItem.tsx | 12 +- .../components/notifications/leadAvatar.ts | 19 + .../notifications/UseCases.stories.tsx | 455 ++++++++++++++++++ 4 files changed, 482 insertions(+), 7 deletions(-) create mode 100644 packages/shared/src/components/notifications/leadAvatar.ts create mode 100644 packages/storybook/stories/components/notifications/UseCases.stories.tsx diff --git a/packages/shared/src/components/notifications/InAppNotificationItem.tsx b/packages/shared/src/components/notifications/InAppNotificationItem.tsx index aa1d76dccc9..0eb091766f4 100644 --- a/packages/shared/src/components/notifications/InAppNotificationItem.tsx +++ b/packages/shared/src/components/notifications/InAppNotificationItem.tsx @@ -2,6 +2,7 @@ import type { ReactElement } from 'react'; import React from 'react'; import { useObjectPurify } from '../../hooks/useDomPurify'; import { NotificationItemLead } from './NotificationItemLead'; +import { getNotificationLeadAvatar } from './leadAvatar'; import type { NewNotification } from '../../graphql/notifications'; interface InAppNotificationItemProps extends NewNotification { @@ -25,7 +26,7 @@ export function InAppNotificationItem({ return null; } - const [avatar] = avatars ?? []; + const avatar = getNotificationLeadAvatar(avatars); return (
diff --git a/packages/shared/src/components/notifications/NotificationItem.tsx b/packages/shared/src/components/notifications/NotificationItem.tsx index b8e6279c2c9..7661e69b365 100644 --- a/packages/shared/src/components/notifications/NotificationItem.tsx +++ b/packages/shared/src/components/notifications/NotificationItem.tsx @@ -7,6 +7,7 @@ import type { Notification } from '../../graphql/notifications'; import { useObjectPurify } from '../../hooks/useDomPurify'; import NotificationItemAvatar from './NotificationItemAvatar'; import { NotificationItemLead } from './NotificationItemLead'; +import { getNotificationLeadAvatar } from './leadAvatar'; import { getNotificationCategory, NotificationFilterCategory, @@ -196,7 +197,10 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { return null; } - const [primaryAvatar] = filteredAvatars; + // Lead with the human actor, not whatever avatar the backend listed first + // (squad comments/posts arrive source-first). Falls back to the source for + // source-only notifications. + const leadAvatar = getNotificationLeadAvatar(filteredAvatars); const hasAvatar = filteredAvatars.length > 0; // `numTotalAvatars` can arrive as 0 from the backend even when avatars are // present, so take the larger of the two rather than `??` (which keeps 0). @@ -341,11 +345,7 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { {showGrid ? (
{avatarContent}
) : ( - + )}
diff --git a/packages/shared/src/components/notifications/leadAvatar.ts b/packages/shared/src/components/notifications/leadAvatar.ts new file mode 100644 index 00000000000..696d859f26f --- /dev/null +++ b/packages/shared/src/components/notifications/leadAvatar.ts @@ -0,0 +1,19 @@ +import { NotificationAvatarType } from '../../graphql/notifications'; +import type { NotificationAvatar } from '../../graphql/notifications'; + +// Which avatar leads a notification row. Always lead with the human who acted — +// the commenter, the upvoter, the follower, the person who posted — because +// that's the identity the notification is about. The backend often lists the +// source/squad first (e.g. squad comments arrive as [source, user]), which is +// why leading with `avatars[0]` showed a source logo instead of the actor. The +// source is still conveyed by the title text and the category badge. Falls back +// to the first avatar when there's no user at all (source-only notifications +// like "new post in ", role changes, or system/digest rows). +// +// Lives in its own module (not utils.ts) so the value import of +// NotificationAvatarType doesn't create a utils <-> graphql import cycle. +export const getNotificationLeadAvatar = ( + avatars: NotificationAvatar[] = [], +): NotificationAvatar | undefined => + avatars.find((avatar) => avatar.type === NotificationAvatarType.User) ?? + avatars[0]; diff --git a/packages/storybook/stories/components/notifications/UseCases.stories.tsx b/packages/storybook/stories/components/notifications/UseCases.stories.tsx new file mode 100644 index 00000000000..21a77774a08 --- /dev/null +++ b/packages/storybook/stories/components/notifications/UseCases.stories.tsx @@ -0,0 +1,455 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { fn } from 'storybook/test'; +import NotificationItem from '@dailydotdev/shared/src/components/notifications/NotificationItem'; +import type { NotificationItemProps } from '@dailydotdev/shared/src/components/notifications/NotificationItem'; +import { + getNotificationCategory, + NotificationFilterCategory, + notificationCategoryBadge, + notificationFilterCategoryLabel, + NotificationIconType, + NotificationType, +} from '@dailydotdev/shared/src/components/notifications/utils'; +import { getNotificationLeadAvatar } from '@dailydotdev/shared/src/components/notifications/leadAvatar'; +import { NotificationAvatarType } from '@dailydotdev/shared/src/graphql/notifications'; +import ExtensionProviders from '../../extension/_providers'; +import { postAttachment, sourceAvatar, userAvatar } from './_mock'; + +// One page that documents every notification scenario and EXPLAINS the avatar + +// badge logic, so the choices can be reviewed deliberately. Each card shows the +// rendered row, the avatars the API sends (in their real order), which one the +// row leads with, and why. The lead is computed with the SAME helper the +// component uses (getNotificationLeadAvatar), so this page never drifts from +// the real behavior. + +const meta: Meta = { + title: 'Components/Notifications/Use cases', + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: + 'Every notification scenario with its avatar/badge rationale. Lead avatar rule: always show the human who acted (commenter, upvoter, follower, poster); fall back to the source only when there is no person; system/digest rows show the type icon.', + }, + }, + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +interface UseCase { + scenario: string; + why: string; + def: Partial; +} + +interface Section { + title: string; + blurb: string; + cases: UseCase[]; +} + +// Avatars are listed in the order the backend actually sends them — notably +// source-FIRST for squad activity, which is exactly what used to make the row +// show a source logo instead of the person who acted. +const sections: Section[] = [ + { + title: 'Comments & replies', + blurb: + 'A person commented or replied. Lead with that person — even when the API lists the squad/source first.', + cases: [ + { + scenario: 'Someone comments on your post', + why: 'API sends [user]. Leads with the commenter. Blue Comments badge.', + def: { + type: NotificationType.ArticleNewComment, + icon: NotificationIconType.Comment, + title: 'Nimrod Kramer commented on your post', + description: 'Great write-up — the caching part really helped.', + avatars: [userAvatar('nimrod', 'Nimrod Kramer')], + attachments: [postAttachment('c1', 'Scaling our cache layer')], + }, + }, + { + scenario: 'Someone comments on your post inside a Squad', + why: 'API sends [source, user]. FIXED: was showing the squad logo; now leads with the commenter (Lee). The squad is still clear from the title + badge.', + def: { + type: NotificationType.SquadNewComment, + icon: NotificationIconType.Comment, + title: 'Lee Solway commented on your post in WebDev', + description: 'Have you tried the new view transitions API?', + avatars: [ + sourceAvatar('webdev', 'WebDev'), + userAvatar('lee', 'Lee Solway'), + ], + }, + }, + { + scenario: 'Reply to your comment', + why: 'API sends [user]. Leads with the replier.', + def: { + type: NotificationType.CommentReply, + icon: NotificationIconType.Comment, + title: 'Ido Shamun replied to your comment', + description: 'Looks great — shipping it!', + avatars: [userAvatar('ido', 'Ido Shamun')], + }, + }, + { + scenario: 'Reply to your comment inside a Squad', + why: 'API sends [source, user]. FIXED: leads with the replier (Ante), not the AI squad logo.', + def: { + type: NotificationType.SquadReply, + icon: NotificationIconType.Comment, + title: 'Ante Barić replied to your comment in AI', + avatars: [sourceAvatar('ai', 'AI'), userAvatar('ante', 'Ante Barić')], + }, + }, + ], + }, + { + title: 'Mentions', + blurb: 'Someone @mentioned you. Lead with the person who mentioned you.', + cases: [ + { + scenario: 'Mentioned you in a post', + why: 'Leads with the author. Pink Mentions badge.', + def: { + type: NotificationType.PostMention, + icon: NotificationIconType.Comment, + title: 'Tsahi Matsliah mentioned you in a post', + avatars: [userAvatar('tsahi', 'Tsahi Matsliah')], + attachments: [postAttachment('m1', 'How we cut build times in half')], + }, + }, + { + scenario: 'Mentioned you in a comment', + why: 'Leads with the author of the comment.', + def: { + type: NotificationType.CommentMention, + icon: NotificationIconType.Comment, + title: 'Tsahi Matsliah mentioned you in a comment', + description: 'Hey @you, what do you think about this approach?', + avatars: [userAvatar('tsahi', 'Tsahi Matsliah')], + }, + }, + ], + }, + { + title: 'Upvotes', + blurb: + 'Reactions on your content. Lead with an upvoter; 4+ upvoters render as a face grid.', + cases: [ + { + scenario: 'A few upvotes on your post', + why: 'API sends the upvoters. 3 or fewer → single lead (the first upvoter). Green Upvotes badge.', + def: { + type: NotificationType.ArticleUpvoteMilestone, + icon: NotificationIconType.Upvote, + title: '3 upvotes on your post! 🎉', + numTotalAvatars: 3, + avatars: [ + userAvatar('u1', 'Ada'), + userAvatar('u2', 'Bram'), + userAvatar('u3', 'Cleo'), + ], + }, + }, + { + scenario: 'Many upvotes on your comment', + why: 'FIXED case you flagged: leads with real upvoters (not you / not a source). 4+ → 2x2 face grid + the upvote badge.', + def: { + type: NotificationType.CommentUpvoteMilestone, + icon: NotificationIconType.Upvote, + title: '24 upvotes on your comment!', + numTotalAvatars: 24, + avatars: [ + userAvatar('u4', 'Dana'), + userAvatar('u5', 'Eli'), + userAvatar('u6', 'Fae'), + userAvatar('u7', 'Gus'), + ], + }, + }, + ], + }, + { + title: 'Followers', + blurb: 'Someone followed you. Lead with the follower (purple badge).', + cases: [ + { + scenario: 'New follower', + why: 'Leads with the follower; includes the follow-back button.', + def: { + type: NotificationType.UserFollow, + icon: NotificationIconType.User, + title: 'Tobias Wolf started following you', + avatars: [userAvatar('tobias', 'Tobias Wolf')], + }, + }, + ], + }, + { + title: 'Squads', + blurb: + 'Squad activity. When a person acted, lead with the person; when it is about your role/squad status, lead with the squad.', + cases: [ + { + scenario: 'Someone posted in your Squad', + why: 'API sends [source, user]. Leads with the poster (GeekLuffy). The squad is shown by the title + purple Squads badge.', + def: { + type: NotificationType.SquadPostAdded, + icon: NotificationIconType.Bell, + title: 'GeekLuffy posted in AI', + avatars: [sourceAvatar('ai', 'AI'), userAvatar('luffy', 'GeekLuffy')], + attachments: [postAttachment('sq1', 'Fine-tuning on a budget')], + }, + }, + { + scenario: 'Someone joined your Squad', + why: 'API sends [source, user]. Leads with the new member (Donald).', + def: { + type: NotificationType.SquadMemberJoined, + icon: NotificationIconType.User, + title: 'Donald Major joined DevOps', + avatars: [ + sourceAvatar('devops', 'DevOps'), + userAvatar('donald', 'Donald Major'), + ], + }, + }, + { + scenario: 'A post was submitted for review', + why: 'API sends [source, user]. Leads with the submitter (Ankur).', + def: { + type: NotificationType.SourcePostSubmitted, + icon: NotificationIconType.Bell, + title: 'Ankur Gupta submitted a post in WebDev', + avatars: [ + sourceAvatar('webdev', 'WebDev'), + userAvatar('ankur', 'Ankur Gupta'), + ], + }, + }, + { + scenario: 'Your role in a Squad changed', + why: 'API sends [source] only — no person acts here, so it correctly leads with the squad.', + def: { + type: NotificationType.PromotedToModerator, + icon: NotificationIconType.Star, + title: 'You are now a moderator in AI', + avatars: [sourceAvatar('ai', 'AI')], + }, + }, + ], + }, + { + title: 'Updates & following', + blurb: + 'Content from sources/people you follow. Lead with the source when no person is involved, otherwise the person. No badge (these land in Updates).', + cases: [ + { + scenario: 'New post from a source you follow', + why: 'API sends [source]. No person acts → leads with the source. No badge.', + def: { + type: NotificationType.SourcePostAdded, + icon: NotificationIconType.Bell, + title: 'New post in Agentic Digest', + avatars: [sourceAvatar('agentic', 'Agentic Digest')], + attachments: [ + postAttachment('p1', 'MAI-Code-1-Flash beats Haiku on SWE-Bench'), + ], + }, + }, + { + scenario: 'Someone you follow published a post', + why: 'API sends [user]. Leads with the author.', + def: { + type: NotificationType.UserPostAdded, + icon: NotificationIconType.Bell, + title: 'Ido Shamun published a new post', + avatars: [userAvatar('ido', 'Ido Shamun')], + attachments: [postAttachment('p3', 'Lessons from scaling daily.dev')], + }, + }, + { + scenario: 'You received an award', + why: 'API sends [user] — the giver. Leads with the giver; lands in Updates so no badge.', + def: { + type: NotificationType.UserReceivedAward, + icon: NotificationIconType.Star, + title: 'keshavashiya awarded you +7 Cores!', + avatars: [userAvatar('keshav', 'keshavashiya')], + }, + }, + ], + }, + { + title: 'System, streaks & digests (no avatar)', + blurb: + 'No human actor — these lead with the type icon, never an avatar or badge.', + cases: [ + { + scenario: 'Top reader badge earned', + why: 'No avatars → the achievement icon leads.', + def: { + type: NotificationType.UserTopReaderBadge, + icon: NotificationIconType.TopReaderBadge, + title: 'You earned the Top Reader badge in JavaScript', + }, + }, + { + scenario: 'Streak about to expire', + why: 'No avatars → the streak icon leads.', + def: { + type: NotificationType.StreakReminder, + icon: NotificationIconType.Streak, + title: 'Your 7-day streak is about to expire', + description: 'Read a post today to keep it alive', + }, + }, + { + scenario: 'Daily digest / system update', + why: 'No avatars → the daily.dev icon leads.', + def: { + type: NotificationType.System, + icon: NotificationIconType.DailyDev, + title: 'We updated our terms of service', + }, + }, + ], + }, +]; + +const avatarTypeLabel: Partial> = { + [NotificationAvatarType.User]: 'user', + [NotificationAvatarType.Source]: 'source', + [NotificationAvatarType.Organization]: 'org', +}; + +const Chip = ({ + children, + tone = 'default', +}: { + children: React.ReactNode; + tone?: 'default' | 'lead'; +}) => ( + + {children} + +); + +const Case = ({ scenario, why, def }: UseCase): React.ReactElement => { + const props: NotificationItemProps = { + onClick: fn(), + targetUrl: '/post/123', + referenceId: `case-${scenario}`, + icon: NotificationIconType.Bell, + type: NotificationType.System, + title: 'Notification', + isUnread: true, + ...def, + }; + const avatars = props.avatars ?? []; + const lead = getNotificationLeadAvatar(avatars); + const category = getNotificationCategory(props.type); + const showsBadge = + avatars.length > 0 && category !== NotificationFilterCategory.Updates; + const badge = notificationCategoryBadge[category]; + + return ( +
+

{scenario}

+
+ +
+
+
+ API avatars: + {avatars.length === 0 && none → type icon} + {avatars.map((a, i) => ( + + {avatarTypeLabel[a.type] ?? a.type} · {a.name} + + ))} +
+
+ Leads with: + + {lead + ? `${avatarTypeLabel[lead.type] ?? lead.type} · ${lead.name}` + : 'type icon'} + + Badge: + {showsBadge ? ( + + + {notificationFilterCategoryLabel[category]} + + ) : ( + none + )} +
+

{why}

+
+
+ ); +}; + +const AllUseCases = (): React.ReactElement => ( +
+

+ Notification use cases +

+

+ Every scenario and the reasoning behind the avatar + badge it shows. +

+
+ Lead avatar rule. Always show the + human who acted — the commenter, upvoter, follower, or poster. The backend + often lists the squad/source first (squad activity arrives as{' '} + [source, user]), which is why rows used to show a source logo + instead of the person. We now pick the user; the source stays clear from + the title text and the colored category badge. When there is no person (a + source posting, a role change, a system/digest message) the row leads with + the source, or the type icon when there is no avatar at all. +
+ +
+ {sections.map((section) => ( +
+
+

+ {section.title} +

+

{section.blurb}

+
+ {section.cases.map((useCase) => ( + + ))} +
+ ))} +
+
+); + +export const AllCases: Story = { + name: 'All use cases', + render: () => , +}; From 6c733b8d391338ccded2d14e4a3a4d9566e3bb4d Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 29 Jun 2026 00:51:53 +0300 Subject: [PATCH 04/13] =?UTF-8?q?fix(notifications):=20tighten=20page=20he?= =?UTF-8?q?ader=E2=86=92tabs=20spacing,=20match=20Squad=20tab=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NotificationsFeed: shrink the mobile title-header padding (p-6 → px-4 pt-4 pb-2) to close the gap above the filter tabs, and render the tab strip like the Squad/Explore header (min-h-14, items-center, quaternary border) so the tabs read consistently across the app. - Mirror the same layout in the Storybook Full page story. Add a "Avatar alternatives" Storybook page presenting options for the multi-actor lead (current 2x2 grid vs overlapping stack, single avatar + badge, single + count pill, icon-only, two-avatar overlap) so we can pick a direction before changing the shared component. Co-Authored-By: Claude Opus 4.8 --- .../AvatarAlternatives.stories.tsx | 265 ++++++++++++++++++ .../notifications/FullPage.stories.tsx | 4 +- .../notifications/NotificationsFeed.tsx | 4 +- 3 files changed, 269 insertions(+), 4 deletions(-) create mode 100644 packages/storybook/stories/components/notifications/AvatarAlternatives.stories.tsx diff --git a/packages/storybook/stories/components/notifications/AvatarAlternatives.stories.tsx b/packages/storybook/stories/components/notifications/AvatarAlternatives.stories.tsx new file mode 100644 index 00000000000..c042ff3040c --- /dev/null +++ b/packages/storybook/stories/components/notifications/AvatarAlternatives.stories.tsx @@ -0,0 +1,265 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { UpvoteIcon } from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { + NotificationFilterCategory, + notificationCategoryBadge, +} from '@dailydotdev/shared/src/components/notifications/utils'; +import ExtensionProviders from '../../extension/_providers'; +import { img } from './_mock'; + +// Design comparison for the multi-actor lead (upvote milestones / many actors). +// The current production lead is a 2x2 face grid; these are alternatives to +// choose from. STORY-LOCAL ONLY — nothing here changes the shipped component +// until a direction is picked. + +const meta: Meta = { + title: 'Components/Notifications/Avatar alternatives', + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: + 'Alternatives to the 3-image grid + upvote stack. Each option is shown as a full row so it can be judged in context. Pick a direction and I will wire it into the real NotificationItem lead.', + }, + }, + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +const upvoteBadge = + notificationCategoryBadge[NotificationFilterCategory.Upvotes]; + +const FACES = ['ada', 'bram', 'cleo', 'dana', 'eli']; +const face = (seed: string, size: string) => ( + +); + +const UpvotePill = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + +// The colored upvote badge as it sits on the corner of a single avatar. +const CornerBadge = () => ( + + + +); + +// ---- The lead variants ---------------------------------------------------- + +// A — current: 2x2 grid of 3 faces + the action badge cell. +const LeadGrid = () => ( +
+ {FACES.slice(0, 3).map((s) => ( + + ))} + + + +
+); + +// B — overlapping avatar stack (3) with the upvote badge on the corner. +const LeadStack = () => ( +
+
+ {FACES.slice(0, 3).map((s) => face(s, 'size-7'))} +
+ +
+); + +// C — single avatar + upvote badge (the count lives in the title). Matches how +// comments/mentions/follows already render — one consistent lead everywhere. +const LeadSingle = () => ( +
+ + +
+); + +// D — single avatar + a "+N" count pill instead of the glyph badge. +const LeadCount = () => ( +
+ + + +22 + +
+); + +// E — icon only: the upvote glyph in a filled circle, no faces at all. +const LeadIconOnly = () => ( + + + + + +); + +// F — two overlapping avatars + badge (lighter than three). +const LeadDuo = () => ( +
+
+ {FACES.slice(0, 2).map((s) => face(s, 'size-8'))} +
+ +
+); + +interface Option { + key: string; + label: string; + note: string; + recommended?: boolean; + Lead: () => React.ReactElement; +} + +const options: Option[] = [ + { + key: 'A', + label: 'A · Current — 2×2 grid', + note: 'Three faces + the upvote cell. Busy at a small size; the faces are tiny and the grid reads as a cluster of squares.', + Lead: LeadGrid, + }, + { + key: 'B', + label: 'B · Overlapping stack', + note: 'Three circular faces overlapping with a ring, badge on the corner. Familiar social-proof pattern (Slack/Linear), still shows multiple people.', + recommended: true, + Lead: LeadStack, + }, + { + key: 'C', + label: 'C · Single avatar + badge', + note: 'One avatar + the upvote badge; the count stays in the title. Identical lead to comments/mentions/follows — most consistent and calmest.', + recommended: true, + Lead: LeadSingle, + }, + { + key: 'D', + label: 'D · Single avatar + "+N" pill', + note: 'One avatar with a count pill instead of the glyph. Communicates volume directly, but loses the upvote color/glyph cue.', + Lead: LeadCount, + }, + { + key: 'E', + label: 'E · Icon only', + note: 'No faces — just the upvote glyph in a filled circle. Cleanest and most scalable, but drops the human social proof.', + Lead: LeadIconOnly, + }, + { + key: 'F', + label: 'F · Two-avatar overlap', + note: 'A lighter version of the stack with two faces. Good middle ground between “one person” and “a crowd”.', + Lead: LeadDuo, + }, +]; + +const Row = ({ Lead }: { Lead: () => React.ReactElement }) => ( +
+
+ +
+
+
+ 24 upvotes on your comment!{' '} + + · 2h + +
+
+ “Have you tried the new view transitions API?” +
+
+
+); + +const Alternatives = (): React.ReactElement => ( +
+

+ Multi-actor lead — alternatives +

+

+ How to show the lead when several people act (upvote milestones, multiple + actors). The count is already in the title, so the avatars are social + proof — they don’t need to carry the number. Pick a direction. +

+ +
+ {options.map((option) => ( +
+
+

+ {option.label} +

+ {option.recommended && ( + + recommended + + )} +
+

{option.note}

+
+ +
+
+ ))} +
+ +
+ My take: C (single avatar + + badge) is the most consistent — every notification then leads the same way + and the row stays calm. If you want to keep visible social proof, B{' '} + (overlapping stack) is the strongest “multiple people” option without the + boxy grid. Tell me which to ship and I’ll wire it into the real lead. +
+
+); + +export const Compare: Story = { + name: 'Compare options', + render: () => , +}; diff --git a/packages/storybook/stories/components/notifications/FullPage.stories.tsx b/packages/storybook/stories/components/notifications/FullPage.stories.tsx index f321519aaeb..15450e5ad02 100644 --- a/packages/storybook/stories/components/notifications/FullPage.stories.tsx +++ b/packages/storybook/stories/components/notifications/FullPage.stories.tsx @@ -64,7 +64,7 @@ const NotificationsPage = (): React.ReactElement => { return (
-
+

Notifications

-
+
{ {!showPushBanner && } {!isV2Laptop && ( -
+

{ {/* On v2 the type filters live in the sidebar rail panel; on the legacy/mobile layout (no rail) keep them as in-page tabs. */} {!isV2Laptop && (hasNotifications || !!activeCategory) && ( -
+
Date: Mon, 29 Jun 2026 08:21:33 +0300 Subject: [PATCH 05/13] docs(storybook): round-2 contained multi-avatar alternatives Per feedback, the lead must show several faces inside the fixed lead box without overlapping other content. Replace the avatar-alternatives page with contained grid refinements (circular faces, 4-face + corner badge, +N count cell, +N corner chip), each shown at 'a few' (3) and 'many' (24), and keep the round-1 options below for reference. Co-Authored-By: Claude Opus 4.8 --- .../AvatarAlternatives.stories.tsx | 296 ++++++++++-------- 1 file changed, 170 insertions(+), 126 deletions(-) diff --git a/packages/storybook/stories/components/notifications/AvatarAlternatives.stories.tsx b/packages/storybook/stories/components/notifications/AvatarAlternatives.stories.tsx index c042ff3040c..52d6edc4766 100644 --- a/packages/storybook/stories/components/notifications/AvatarAlternatives.stories.tsx +++ b/packages/storybook/stories/components/notifications/AvatarAlternatives.stories.tsx @@ -10,9 +10,15 @@ import ExtensionProviders from '../../extension/_providers'; import { img } from './_mock'; // Design comparison for the multi-actor lead (upvote milestones / many actors). -// The current production lead is a 2x2 face grid; these are alternatives to -// choose from. STORY-LOCAL ONLY — nothing here changes the shipped component -// until a direction is picked. +// +// Feedback from round 1: the grid won because it's the only option that shows +// several faces WHILE staying inside the fixed lead box — it never overlaps +// neighboring content or breaks row alignment. The overlapping stack bled +// outside the box. So round 2 keeps that hard constraint (everything fits the +// 40px lead column, faces stay circular) and explores nicer contained grids +// plus how each handles "a few" vs "many" actors. +// +// STORY-LOCAL ONLY — nothing here changes the shipped component yet. const meta: Meta = { title: 'Components/Notifications/Avatar alternatives', @@ -21,7 +27,7 @@ const meta: Meta = { docs: { description: { component: - 'Alternatives to the 3-image grid + upvote stack. Each option is shown as a full row so it can be judged in context. Pick a direction and I will wire it into the real NotificationItem lead.', + 'Contained multi-face leads for upvote milestones / multi-actor rows. Every option fits the fixed lead box (no overlap onto other content) and is shown at "a few" (3) and "many" (24).', }, }, }, @@ -38,170 +44,184 @@ export default meta; type Story = StoryObj; -const upvoteBadge = - notificationCategoryBadge[NotificationFilterCategory.Upvotes]; +const upvote = notificationCategoryBadge[NotificationFilterCategory.Upvotes]; +const SEEDS = ['ada', 'bram', 'cleo', 'dana']; -const FACES = ['ada', 'bram', 'cleo', 'dana', 'eli']; -const face = (seed: string, size: string) => ( +const RoundFace = ({ + seed, + className, +}: { + seed: string; + className: string; +}) => ( ); -const UpvotePill = ({ children }: { children: React.ReactNode }) => ( +const BadgeCell = () => ( - {children} + ); -// The colored upvote badge as it sits on the corner of a single avatar. +// The badge as a corner overlay (used when the grid cells are all faces). const CornerBadge = () => ( - + ); -// ---- The lead variants ---------------------------------------------------- - -// A — current: 2x2 grid of 3 faces + the action badge cell. -const LeadGrid = () => ( -
- {FACES.slice(0, 3).map((s) => ( - - ))} - - - -
+const CountChip = ({ n }: { n: number }) => ( + + +{n} + ); -// B — overlapping avatar stack (3) with the upvote badge on the corner. -const LeadStack = () => ( -
-
- {FACES.slice(0, 3).map((s) => face(s, 'size-7'))} -
- +// ---- Round 2: contained grids (everything fits the 40px lead box) ---------- +type Lead = (total: number) => React.ReactElement; + +const cells = (seeds: string[], count: number) => + seeds + .slice(0, count) + .map((s) => ); + +// G — rounded 2x2: 3 faces + the upvote badge cell (the softened current grid). +const leadRoundedGrid: Lead = () => ( +
+ {cells(SEEDS, 3)} +
); -// C — single avatar + upvote badge (the count lives in the title). Matches how -// comments/mentions/follows already render — one consistent lead everywhere. -const LeadSingle = () => ( -
- +// H — rounded 2x2: up to 4 faces, badge floats on the corner (frees the 4th +// cell for another face). +const leadGridFour: Lead = (total) => ( +
+
+ {cells(SEEDS, Math.min(4, total))} +
); -// D — single avatar + a "+N" count pill instead of the glyph badge. -const LeadCount = () => ( -
- - - +22 - +// K — rounded 2x2: 3 faces + a 4th cell that is the badge when few, or a green +// "+N" count when many (keeps the upvote color, shows the count in place). +const leadGridCountCell: Lead = (total) => ( +
+ {cells(SEEDS, 3)} + {total > 3 ? ( + + +{total - 3} + + ) : ( + + )}
); -// E — icon only: the upvote glyph in a filled circle, no faces at all. -const LeadIconOnly = () => ( - - - - - -); - -// F — two overlapping avatars + badge (lighter than three). -const LeadDuo = () => ( -
-
- {FACES.slice(0, 2).map((s) => face(s, 'size-8'))} +// L — rounded 2x2: up to 4 faces with a "+N" count chip on the corner when +// there are more than fit. +const leadGridCountChip: Lead = (total) => ( +
+
+ {cells(SEEDS, Math.min(4, total))}
- + {total > 4 ? : }
); -interface Option { +interface RefinedOption { key: string; label: string; note: string; recommended?: boolean; - Lead: () => React.ReactElement; + lead: Lead; } -const options: Option[] = [ - { - key: 'A', - label: 'A · Current — 2×2 grid', - note: 'Three faces + the upvote cell. Busy at a small size; the faces are tiny and the grid reads as a cluster of squares.', - Lead: LeadGrid, - }, +const refined: RefinedOption[] = [ { - key: 'B', - label: 'B · Overlapping stack', - note: 'Three circular faces overlapping with a ring, badge on the corner. Familiar social-proof pattern (Slack/Linear), still shows multiple people.', + key: 'G', + label: 'G · Rounded grid (3 + badge)', + note: 'The current grid, but circular faces instead of squares — same contained footprint, much softer. Count stays in the title.', recommended: true, - Lead: LeadStack, + lead: leadRoundedGrid, }, { - key: 'C', - label: 'C · Single avatar + badge', - note: 'One avatar + the upvote badge; the count stays in the title. Identical lead to comments/mentions/follows — most consistent and calmest.', + key: 'H', + label: 'H · Rounded grid, 4 faces + corner badge', + note: 'Frees the 4th cell for another face by floating the badge on the corner — shows more people, still fully inside the box.', recommended: true, - Lead: LeadSingle, + lead: leadGridFour, }, { - key: 'D', - label: 'D · Single avatar + "+N" pill', - note: 'One avatar with a count pill instead of the glyph. Communicates volume directly, but loses the upvote color/glyph cue.', - Lead: LeadCount, + key: 'K', + label: 'K · Rounded grid, 3 faces + "+N" cell', + note: 'The 4th cell carries the overflow count in the upvote color (badge when there is nothing extra). Faces + explicit count, no overlap.', + lead: leadGridCountCell, }, { - key: 'E', - label: 'E · Icon only', - note: 'No faces — just the upvote glyph in a filled circle. Cleanest and most scalable, but drops the human social proof.', - Lead: LeadIconOnly, - }, - { - key: 'F', - label: 'F · Two-avatar overlap', - note: 'A lighter version of the stack with two faces. Good middle ground between “one person” and “a crowd”.', - Lead: LeadDuo, + key: 'L', + label: 'L · Rounded grid, 4 faces + "+N" chip', + note: 'Four faces fill the grid; a small "+N" chip on the corner adds the count for big numbers. Most information, still contained.', + lead: leadGridCountChip, }, ]; -const Row = ({ Lead }: { Lead: () => React.ReactElement }) => ( +// ---- Round 1 (reference): the options shown previously ----------------------- +const FACES3 = SEEDS.slice(0, 3); + +const leadOriginalGrid = () => ( +
+ {FACES3.map((s) => ( + + ))} + +
+); +const leadSingle = () => ( +
+ + +
+); +const leadIconOnly = () => ( + + + +); + +// ---- Layout helpers --------------------------------------------------------- + +const LeadOnChip = ({ + caption, + children, +}: { + caption: string; + children: React.ReactNode; +}) => ( +
+
{children}
+ {caption} +
+); + +const Row = ({ children }: { children: React.ReactNode }) => (
- + {children}
@@ -220,17 +240,18 @@ const Row = ({ Lead }: { Lead: () => React.ReactElement }) => ( const Alternatives = (): React.ReactElement => (

- Multi-actor lead — alternatives + Multi-actor lead — round 2 (contained)

- How to show the lead when several people act (upvote milestones, multiple - actors). The count is already in the title, so the avatars are social - proof — they don’t need to carry the number. Pick a direction. + Constraint locked in from your feedback: the lead must show several faces{' '} + inside the fixed 40px box — never overlapping other content or + breaking row alignment. These all satisfy that; each is shown with{' '} + a few (3) and many (24) actors, plus a full row for context.

- {options.map((option) => ( -
+ {refined.map((option) => ( +

{option.label} @@ -242,19 +263,42 @@ const Alternatives = (): React.ReactElement => ( )}

{option.note}

+
+ {option.lead(3)} + {option.lead(24)} +
- + {option.lead(24)}
))}
- My take: C (single avatar + - badge) is the most consistent — every notification then leads the same way - and the row stays calm. If you want to keep visible social proof, B{' '} - (overlapping stack) is the strongest “multiple people” option without the - boxy grid. Tell me which to ship and I’ll wire it into the real lead. + My take: H (4 faces + corner + badge) shows the most people while staying contained, and L adds a{' '} + +N for very large counts. If you want the calmest version, G{' '} + is just the current grid with circular faces. All three keep faces inside + the box and never touch the title or the cover image. Tell me the letter + and I’ll wire it into the real lead. +
+ +

+ Round 1 (for reference) +

+

+ The earlier options — the boxy grid, single avatar, and icon-only. +

+
+
+ {leadOriginalGrid()} +
+
+ {leadSingle()} +
+
+ {leadIconOnly()} +
); From a651906a536f26bc3122feb34b6f506868a0980a Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 29 Jun 2026 08:32:13 +0300 Subject: [PATCH 06/13] fix(notifications): smaller mobile tabs + denser rows, matching Squad page - NotificationFilterBar: use XSmall tabs on mobile and Small on laptop, exactly like the Squads directory (isMobileLayout ? XSmall : Small). - NotificationItem: tighter rows below laptop (min-h-14 + py-3), restoring min-h-16 + py-4 from laptop up. - Mirror the responsive tab size in the Storybook Full page story. Co-Authored-By: Claude Opus 4.8 --- .../src/components/notifications/NotificationItem.tsx | 2 +- .../components/notifications/FullPage.stories.tsx | 7 +++++-- .../components/notifications/NotificationFilterBar.tsx | 9 +++++++-- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/components/notifications/NotificationItem.tsx b/packages/shared/src/components/notifications/NotificationItem.tsx index 7661e69b365..859c95655d6 100644 --- a/packages/shared/src/components/notifications/NotificationItem.tsx +++ b/packages/shared/src/components/notifications/NotificationItem.tsx @@ -310,7 +310,7 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { return (
diff --git a/packages/storybook/stories/components/notifications/FullPage.stories.tsx b/packages/storybook/stories/components/notifications/FullPage.stories.tsx index 15450e5ad02..d111b00b559 100644 --- a/packages/storybook/stories/components/notifications/FullPage.stories.tsx +++ b/packages/storybook/stories/components/notifications/FullPage.stories.tsx @@ -12,6 +12,7 @@ import { SquadDirectoryNavbarItem, } from '@dailydotdev/shared/src/components/squads/layout/SquadDirectoryNavbar'; import { ButtonSize } from '@dailydotdev/shared/src/components/buttons/Button'; +import { useViewSize, ViewSize } from '@dailydotdev/shared/src/hooks'; import { SettingsIcon } from '@dailydotdev/shared/src/components/icons'; import { IconSize } from '@dailydotdev/shared/src/components/Icon'; import ExtensionProviders from '../../extension/_providers'; @@ -49,6 +50,8 @@ type Story = StoryObj; const NotificationsPage = (): React.ReactElement => { const [active, setActive] = useState(null); + const isLaptop = useViewSize(ViewSize.Laptop); + const buttonSize = isLaptop ? ButtonSize.Small : ButtonSize.XSmall; const filtered = useMemo( () => @@ -81,7 +84,7 @@ const NotificationsPage = (): React.ReactElement => { className="!mx-0 min-w-0 flex-1 !border-0 !px-0" > { {notificationFilterCategoryList.map((category) => ( ( Date: Mon, 29 Jun 2026 08:41:08 +0300 Subject: [PATCH 07/13] docs(storybook): round-3 creative multi-actor lead options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the avatar-alternatives page with 5 fresh directions that obey two rules — never overlap the text/layout, always show several faces: 1) liked-by face row under the title, 2) faces pill in the subtitle, 3) clipped peeking cluster (overflow-hidden so it can't spill), 4) diamond cluster, 5) count coin + peeking faces. Each shown at few (3) and many (24). Co-Authored-By: Claude Opus 4.8 --- .../AvatarAlternatives.stories.tsx | 388 +++++++++--------- 1 file changed, 202 insertions(+), 186 deletions(-) diff --git a/packages/storybook/stories/components/notifications/AvatarAlternatives.stories.tsx b/packages/storybook/stories/components/notifications/AvatarAlternatives.stories.tsx index 52d6edc4766..d092fde452b 100644 --- a/packages/storybook/stories/components/notifications/AvatarAlternatives.stories.tsx +++ b/packages/storybook/stories/components/notifications/AvatarAlternatives.stories.tsx @@ -9,15 +9,14 @@ import { import ExtensionProviders from '../../extension/_providers'; import { img } from './_mock'; -// Design comparison for the multi-actor lead (upvote milestones / many actors). -// -// Feedback from round 1: the grid won because it's the only option that shows -// several faces WHILE staying inside the fixed lead box — it never overlaps -// neighboring content or breaks row alignment. The overlapping stack bled -// outside the box. So round 2 keeps that hard constraint (everything fits the -// 40px lead column, faces stay circular) and explores nicer contained grids -// plus how each handles "a few" vs "many" actors. -// +// Round 3 — creative directions for the multi-actor lead (upvote milestones). +// Earlier rounds (square grid, overlapping stack, single avatar, icon-only) +// were all rejected. Two hard rules drive these: +// 1. Never overlap or break the text/layout — everything is either inside the +// 40px lead box (clipped) or in the content column where there's room. +// 2. Always show several faces, so it reads as "lots of people", never one. +// A few of these deliberately move the social proof OUT of the tiny lead box +// and into the row body, where multiple faces fit comfortably. // STORY-LOCAL ONLY — nothing here changes the shipped component yet. const meta: Meta = { @@ -27,7 +26,7 @@ const meta: Meta = { docs: { description: { component: - 'Contained multi-face leads for upvote milestones / multi-actor rows. Every option fits the fixed lead box (no overlap onto other content) and is shown at "a few" (3) and "many" (24).', + 'Creative, contained ways to show that many people acted. Each obeys two rules: never overlap the text/design, and always show several faces. Shown at "a few" (3) and "many" (24).', }, }, }, @@ -45,31 +44,24 @@ export default meta; type Story = StoryObj; const upvote = notificationCategoryBadge[NotificationFilterCategory.Upvotes]; -const SEEDS = ['ada', 'bram', 'cleo', 'dana']; +const SEEDS = ['ada', 'bram', 'cleo', 'dana', 'eli']; -const RoundFace = ({ - seed, - className, -}: { - seed: string; - className: string; -}) => ( +const Mini = ({ seed, size }: { seed: string; size: string }) => ( ); -const BadgeCell = () => ( +const UpvoteCircle = ({ size = 'size-9' }: { size?: string }) => ( - + ); -// The badge as a corner overlay (used when the grid cells are all faces). const CornerBadge = () => ( ( ); -const CountChip = ({ n }: { n: number }) => ( - - +{n} - -); - -// ---- Round 2: contained grids (everything fits the 40px lead box) ---------- -type Lead = (total: number) => React.ReactElement; - -const cells = (seeds: string[], count: number) => - seeds - .slice(0, count) - .map((s) => ); +// --------------------------------------------------------------------------- +// Idea 1 — "Liked-by" face row. The lead is just the upvote mark; the faces +// move to their own line under the title, where there's plenty of room. +// --------------------------------------------------------------------------- +const FaceRow = ({ total }: { total: number }) => { + const shown = Math.min(4, total); + const rest = total - shown; + return ( + + + {SEEDS.slice(0, shown).map((s) => ( + + ))} + + + {rest > 0 ? `and ${rest} more upvoted` : 'upvoted your comment'} + + + ); +}; -// G — rounded 2x2: 3 faces + the upvote badge cell (the softened current grid). -const leadRoundedGrid: Lead = () => ( -
- {cells(SEEDS, 3)} - -
-); +// --------------------------------------------------------------------------- +// Idea 2 — faces pill. A compact rounded chip of overlapping mini-faces + count +// that sits inline in the subtitle. Self-contained, never bleeds. +// --------------------------------------------------------------------------- +const FacesPill = ({ total }: { total: number }) => { + const shown = Math.min(3, total); + const rest = total - shown; + return ( + + + {SEEDS.slice(0, shown).map((s) => ( + + ))} + + + {rest > 0 ? `+${rest}` : 'new'} + + + ); +}; -// H — rounded 2x2: up to 4 faces, badge floats on the corner (frees the 4th -// cell for another face). -const leadGridFour: Lead = (total) => ( +// --------------------------------------------------------------------------- +// Idea 3 — clipped cluster. Overlapping faces, but the lead box hard-clips them +// (overflow-hidden), so it can NEVER spill onto other content — and the cut-off +// edge itself implies "there are more". Badge sits outside the clip. +// --------------------------------------------------------------------------- +const ClippedCluster = ({ total }: { total: number }) => (
-
- {cells(SEEDS, Math.min(4, total))} +
+ + {SEEDS.slice(0, Math.min(5, Math.max(3, total))).map((s) => ( + + ))} +
); -// K — rounded 2x2: 3 faces + a 4th cell that is the badge when few, or a green -// "+N" count when many (keeps the upvote color, shows the count in place). -const leadGridCountCell: Lead = (total) => ( -
- {cells(SEEDS, 3)} - {total > 3 ? ( - - +{total - 3} - - ) : ( - - )} +// --------------------------------------------------------------------------- +// Idea 4 — diamond cluster. Three faces + the upvote mark arranged in a diamond +// inside the box. Organic, distinct from the square grid. +// --------------------------------------------------------------------------- +const Diamond = () => ( +
+ + + + + + + + + + + +
); -// L — rounded 2x2: up to 4 faces with a "+N" count chip on the corner when -// there are more than fit. -const leadGridCountChip: Lead = (total) => ( +// --------------------------------------------------------------------------- +// Idea 5 — count coin + mini faces. A bold count "coin" leads (instantly says +// "a lot"), with two real faces peeking from the corner so it stays human. +// --------------------------------------------------------------------------- +const CountCoin = ({ total }: { total: number }) => (
-
- {cells(SEEDS, Math.min(4, total))} -
- {total > 4 ? : } + + {total} + + + {SEEDS.slice(0, 2).map((s) => ( + + ))} +
); -interface RefinedOption { +// --------------------------------------------------------------------------- + +interface Idea { key: string; label: string; note: string; recommended?: boolean; - lead: Lead; + // A row needs lead + subtitle; ideas 1/2 put the faces in the subtitle. + render: (total: number) => { + lead: React.ReactNode; + subtitle: React.ReactNode; + }; } -const refined: RefinedOption[] = [ +const COMMENT = '“Have you tried the new view transitions API?”'; + +const ideas: Idea[] = [ { - key: 'G', - label: 'G · Rounded grid (3 + badge)', - note: 'The current grid, but circular faces instead of squares — same contained footprint, much softer. Count stays in the title.', + key: '1', + label: '1 · “Liked-by” face row', + note: 'Lead is just the upvote mark; the faces get their own line under the title where there is real room. Reads instantly as “a group of people”, and can never crowd the lead box.', recommended: true, - lead: leadRoundedGrid, + render: (total) => ({ + lead: , + subtitle: , + }), }, { - key: 'H', - label: 'H · Rounded grid, 4 faces + corner badge', - note: 'Frees the 4th cell for another face by floating the badge on the corner — shows more people, still fully inside the box.', + key: '2', + label: '2 · Faces pill in the subtitle', + note: 'A compact rounded chip of overlapping mini-faces + count, inline in the subtitle. Self-contained, scannable, leaves the lead for the type mark.', + render: (total) => ({ + lead: , + subtitle: ( + + upvoted your comment + + ), + }), + }, + { + key: '3', + label: '3 · Clipped cluster (peeking crowd)', + note: 'Overlapping faces hard-clipped to the lead box, so it physically cannot spill onto the text — and the cut-off edge implies more people. Badge stays outside the clip.', recommended: true, - lead: leadGridFour, + render: () => ({ lead: , subtitle: COMMENT }), }, { - key: 'K', - label: 'K · Rounded grid, 3 faces + "+N" cell', - note: 'The 4th cell carries the overflow count in the upvote color (badge when there is nothing extra). Faces + explicit count, no overlap.', - lead: leadGridCountCell, + key: '4', + label: '4 · Diamond cluster', + note: 'Three faces + the upvote mark in a diamond inside the box. Organic and clearly different from the square grid; fully contained.', + render: () => ({ lead: , subtitle: COMMENT }), }, { - key: 'L', - label: 'L · Rounded grid, 4 faces + "+N" chip', - note: 'Four faces fill the grid; a small "+N" chip on the corner adds the count for big numbers. Most information, still contained.', - lead: leadGridCountChip, + key: '5', + label: '5 · Count coin + peeking faces', + note: 'A bold count “coin” leads — instantly says “a lot” — with two real faces peeking from the corner so it stays human, not just a number.', + render: (total) => ({ + lead: , + subtitle: COMMENT, + }), }, ]; -// ---- Round 1 (reference): the options shown previously ----------------------- -const FACES3 = SEEDS.slice(0, 3); - -const leadOriginalGrid = () => ( -
- {FACES3.map((s) => ( - - ))} - -
-); -const leadSingle = () => ( -
- - -
-); -const leadIconOnly = () => ( - - - -); - -// ---- Layout helpers --------------------------------------------------------- - -const LeadOnChip = ({ - caption, - children, +const Row = ({ + lead, + subtitle, }: { - caption: string; - children: React.ReactNode; + lead: React.ReactNode; + subtitle: React.ReactNode; }) => ( -
-
{children}
- {caption} -
-); - -const Row = ({ children }: { children: React.ReactNode }) => ( -
-
- {children} -
+
+
{lead}
24 upvotes on your comment!{' '} @@ -231,7 +253,7 @@ const Row = ({ children }: { children: React.ReactNode }) => (
- “Have you tried the new view transitions API?” + {subtitle}
@@ -240,65 +262,59 @@ const Row = ({ children }: { children: React.ReactNode }) => ( const Alternatives = (): React.ReactElement => (

- Multi-actor lead — round 2 (contained) + Multi-actor lead — round 3 (creative)

- Constraint locked in from your feedback: the lead must show several faces{' '} - inside the fixed 40px box — never overlapping other content or - breaking row alignment. These all satisfy that; each is shown with{' '} - a few (3) and many (24) actors, plus a full row for context. + Fresh directions, not grid tweaks. Every one obeys two rules: it never + overlaps the text or breaks the layout, and it always shows several faces + so it reads as “lots of people”. Ideas 1–2 move the faces into the row + body (more room); 3–5 are contained inside the lead box. Each is shown at{' '} + a few (3) and many (24).

-
- {refined.map((option) => ( -
-
-

- {option.label} -

- {option.recommended && ( - - recommended +
+ {ideas.map((idea) => { + const few = idea.render(3); + const many = idea.render(24); + return ( +
+
+

+ {idea.label} +

+ {idea.recommended && ( + + recommended + + )} +
+

{idea.note}

+
+ + A few (3) - )} -
-

{option.note}

-
- {option.lead(3)} - {option.lead(24)} -
-
- {option.lead(24)} -
-
- ))} +
+ +
+ + Many (24) + +
+ +
+
+
+ ); + })}
- My take: H (4 faces + corner - badge) shows the most people while staying contained, and L adds a{' '} - +N for very large counts. If you want the calmest version, G{' '} - is just the current grid with circular faces. All three keep faces inside - the box and never touch the title or the cover image. Tell me the letter - and I’ll wire it into the real lead. -
- -

- Round 1 (for reference) -

-

- The earlier options — the boxy grid, single avatar, and icon-only. -

-
-
- {leadOriginalGrid()} -
-
- {leadSingle()} -
-
- {leadIconOnly()} -
+ My take: 1 (“liked-by” face + row) is the boldest and most legible — the faces finally get room and the + row reads like a social moment. 3 (clipped cluster) is the most + striking if you want to keep the faces in the lead while guaranteeing they + can’t spill. Tell me which number and I’ll wire it into the real + NotificationItem (and the in-app popup).
); From e91b6ba63d3a3baa6b27f1c8088e564ab6f9a8c6 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 29 Jun 2026 08:53:31 +0300 Subject: [PATCH 08/13] docs(storybook): round-4 diamond variations + circle vs square faces Build on the diamond direction (#4): 4A triad + mark, 4B quad + center hub, 4C triad + '+N' chip, 4D loose quad. Each rendered with circular and rounded-square faces side by side to compare the face shape. Co-Authored-By: Claude Opus 4.8 --- .../AvatarAlternatives.stories.tsx | 363 ++++++++---------- 1 file changed, 165 insertions(+), 198 deletions(-) diff --git a/packages/storybook/stories/components/notifications/AvatarAlternatives.stories.tsx b/packages/storybook/stories/components/notifications/AvatarAlternatives.stories.tsx index d092fde452b..a953c3ff5f0 100644 --- a/packages/storybook/stories/components/notifications/AvatarAlternatives.stories.tsx +++ b/packages/storybook/stories/components/notifications/AvatarAlternatives.stories.tsx @@ -9,15 +9,10 @@ import { import ExtensionProviders from '../../extension/_providers'; import { img } from './_mock'; -// Round 3 — creative directions for the multi-actor lead (upvote milestones). -// Earlier rounds (square grid, overlapping stack, single avatar, icon-only) -// were all rejected. Two hard rules drive these: -// 1. Never overlap or break the text/layout — everything is either inside the -// 40px lead box (clipped) or in the content column where there's room. -// 2. Always show several faces, so it reads as "lots of people", never one. -// A few of these deliberately move the social proof OUT of the tiny lead box -// and into the row body, where multiple faces fit comfortably. -// STORY-LOCAL ONLY — nothing here changes the shipped component yet. +// Round 4 — variations on the "diamond cluster" (the chosen direction), each +// rendered with circular faces AND rounded-square faces so the shape can be +// compared directly. Same rules: never overlap the text/layout, always show +// several faces. STORY-LOCAL ONLY. const meta: Meta = { title: 'Components/Notifications/Avatar alternatives', @@ -26,7 +21,7 @@ const meta: Meta = { docs: { description: { component: - 'Creative, contained ways to show that many people acted. Each obeys two rules: never overlap the text/design, and always show several faces. Shown at "a few" (3) and "many" (24).', + 'Variations of the diamond cluster, shown with circular vs rounded-square faces. Pick a variation + a face shape and I will wire it into the real NotificationItem lead.', }, }, }, @@ -44,205 +39,175 @@ export default meta; type Story = StoryObj; const upvote = notificationCategoryBadge[NotificationFilterCategory.Upvotes]; -const SEEDS = ['ada', 'bram', 'cleo', 'dana', 'eli']; +const SEEDS = ['ada', 'bram', 'cleo', 'dana']; -const Mini = ({ seed, size }: { seed: string; size: string }) => ( +type Shape = 'circle' | 'square'; +const radius = (shape: Shape) => + shape === 'square' ? 'rounded-8' : 'rounded-full'; + +const Face = ({ + seed, + size, + shape, +}: { + seed: string; + size: string; + shape: Shape; +}) => ( ); -const UpvoteCircle = ({ size = 'size-9' }: { size?: string }) => ( - - - -); - -const CornerBadge = () => ( +// The upvote mark, shape-matched so the whole cluster stays cohesive. +const Mark = ({ + size, + shape, + className = '', +}: { + size: string; + shape: Shape; + className?: string; +}) => ( ); -// --------------------------------------------------------------------------- -// Idea 1 — "Liked-by" face row. The lead is just the upvote mark; the faces -// move to their own line under the title, where there's plenty of room. -// --------------------------------------------------------------------------- -const FaceRow = ({ total }: { total: number }) => { - const shown = Math.min(4, total); - const rest = total - shown; - return ( - - - {SEEDS.slice(0, shown).map((s) => ( - - ))} - - - {rest > 0 ? `and ${rest} more upvoted` : 'upvoted your comment'} - +// --- 4A · Triad + mark: 3 faces (top, left, right) + the upvote mark at the +// bottom point. The original diamond. +const DiamondTriad = (shape: Shape) => ( +
+ + - ); -}; - -// --------------------------------------------------------------------------- -// Idea 2 — faces pill. A compact rounded chip of overlapping mini-faces + count -// that sits inline in the subtitle. Self-contained, never bleeds. -// --------------------------------------------------------------------------- -const FacesPill = ({ total }: { total: number }) => { - const shown = Math.min(3, total); - const rest = total - shown; - return ( - - - {SEEDS.slice(0, shown).map((s) => ( - - ))} - - - {rest > 0 ? `+${rest}` : 'new'} - + + - ); -}; + + + + + + +
+); -// --------------------------------------------------------------------------- -// Idea 3 — clipped cluster. Overlapping faces, but the lead box hard-clips them -// (overflow-hidden), so it can NEVER spill onto other content — and the cut-off -// edge itself implies "there are more". Badge sits outside the clip. -// --------------------------------------------------------------------------- -const ClippedCluster = ({ total }: { total: number }) => ( +// --- 4B · Quad + center hub: 4 faces in the diamond, the upvote mark in the +// middle. Shows the most faces. +const DiamondQuad = (shape: Shape) => (
-
- - {SEEDS.slice(0, Math.min(5, Math.max(3, total))).map((s) => ( - - ))} - -
- + + + + + + + + + + + + + + +
); -// --------------------------------------------------------------------------- -// Idea 4 — diamond cluster. Three faces + the upvote mark arranged in a diamond -// inside the box. Organic, distinct from the square grid. -// --------------------------------------------------------------------------- -const Diamond = () => ( +// --- 4C · Triad + "+N": 3 faces + a count chip at the bottom point (carries +// the number for big milestones). +const DiamondCount = (shape: Shape, total: number) => (
- + - + - + - + +{total - 3}
); -// --------------------------------------------------------------------------- -// Idea 5 — count coin + mini faces. A bold count "coin" leads (instantly says -// "a lot"), with two real faces peeking from the corner so it stays human. -// --------------------------------------------------------------------------- -const CountCoin = ({ total }: { total: number }) => ( +// --- 4D · Loose quad: 4 smaller faces with more separation (less overlap) + +// corner mark. Reads as four distinct people. +const DiamondLoose = (shape: Shape) => (
- - {total} + + - - {SEEDS.slice(0, 2).map((s) => ( - - ))} + + + + + + + + +
); -// --------------------------------------------------------------------------- - -interface Idea { +interface Variant { key: string; label: string; note: string; recommended?: boolean; - // A row needs lead + subtitle; ideas 1/2 put the faces in the subtitle. - render: (total: number) => { - lead: React.ReactNode; - subtitle: React.ReactNode; - }; + lead: (shape: Shape) => React.ReactElement; } -const COMMENT = '“Have you tried the new view transitions API?”'; - -const ideas: Idea[] = [ +const variants: Variant[] = [ { - key: '1', - label: '1 · “Liked-by” face row', - note: 'Lead is just the upvote mark; the faces get their own line under the title where there is real room. Reads instantly as “a group of people”, and can never crowd the lead box.', + key: '4A', + label: '4A · Triad + mark', + note: 'Three faces with the upvote mark as the fourth point — the original diamond. Count stays in the title.', recommended: true, - render: (total) => ({ - lead: , - subtitle: , - }), + lead: (shape) => DiamondTriad(shape), }, { - key: '2', - label: '2 · Faces pill in the subtitle', - note: 'A compact rounded chip of overlapping mini-faces + count, inline in the subtitle. Self-contained, scannable, leaves the lead for the type mark.', - render: (total) => ({ - lead: , - subtitle: ( - - upvoted your comment - - ), - }), - }, - { - key: '3', - label: '3 · Clipped cluster (peeking crowd)', - note: 'Overlapping faces hard-clipped to the lead box, so it physically cannot spill onto the text — and the cut-off edge implies more people. Badge stays outside the clip.', + key: '4B', + label: '4B · Quad + center hub', + note: 'Four faces around a central upvote hub. The most faces, with the mark anchoring the middle.', recommended: true, - render: () => ({ lead: , subtitle: COMMENT }), + lead: (shape) => DiamondQuad(shape), }, { - key: '4', - label: '4 · Diamond cluster', - note: 'Three faces + the upvote mark in a diamond inside the box. Organic and clearly different from the square grid; fully contained.', - render: () => ({ lead: , subtitle: COMMENT }), + key: '4C', + label: '4C · Triad + “+N”', + note: 'Three faces + a count chip at the bottom point — carries the exact number for big milestones.', + lead: (shape) => DiamondCount(shape, 24), }, { - key: '5', - label: '5 · Count coin + peeking faces', - note: 'A bold count “coin” leads — instantly says “a lot” — with two real faces peeking from the corner so it stays human, not just a number.', - render: (total) => ({ - lead: , - subtitle: COMMENT, - }), + key: '4D', + label: '4D · Loose quad', + note: 'Four smaller faces with more breathing room (less overlap) and the mark tucked in the corner — reads as four distinct people.', + lead: (shape) => DiamondLoose(shape), }, ]; -const Row = ({ - lead, - subtitle, -}: { - lead: React.ReactNode; - subtitle: React.ReactNode; -}) => ( +const Row = ({ lead }: { lead: React.ReactNode }) => (
{lead}
@@ -253,68 +218,70 @@ const Row = ({
- {subtitle} + “Have you tried the new view transitions API?”
); +const ShapeColumn = ({ + caption, + lead, +}: { + caption: string; + lead: React.ReactElement; +}) => ( +
+ {caption} +
+ +
+
+); + const Alternatives = (): React.ReactElement => ( -
+

- Multi-actor lead — round 3 (creative) + Diamond cluster — variations & face shape

- Fresh directions, not grid tweaks. Every one obeys two rules: it never - overlaps the text or breaks the layout, and it always shows several faces - so it reads as “lots of people”. Ideas 1–2 move the faces into the row - body (more room); 3–5 are contained inside the lead box. Each is shown at{' '} - a few (3) and many (24). + Building on #4. Each variation is shown with circular faces (left) + and rounded-square faces (right) so you can judge the shape. All + stay inside the lead box and show several people.

- {ideas.map((idea) => { - const few = idea.render(3); - const many = idea.render(24); - return ( -
-
-

- {idea.label} -

- {idea.recommended && ( - - recommended - - )} -
-

{idea.note}

-
- - A few (3) - -
- -
- - Many (24) + {variants.map((variant) => ( +
+
+

+ {variant.label} +

+ {variant.recommended && ( + + recommended -
- -
-
-
- ); - })} + )} +
+

{variant.note}

+
+ + +
+
+ ))}
- My take: 1 (“liked-by” face - row) is the boldest and most legible — the faces finally get room and the - row reads like a social moment. 3 (clipped cluster) is the most - striking if you want to keep the faces in the lead while guaranteeing they - can’t spill. Tell me which number and I’ll wire it into the real - NotificationItem (and the in-app popup). + My take: 4B (quad + center + hub) is the strongest evolution — four faces and the upvote mark anchored + in the middle. On shape, rounded-squares give the cluster a + tighter, more deliberate “app icon” feel and the diamond points read + cleaner; circles feel softer and more social. Tell me the variation + + shape and I’ll wire it into the real lead and the in-app popup.
); From 77e8c1723617e86e4c4417d537fa462a7cd13f60 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 29 Jun 2026 09:04:16 +0300 Subject: [PATCH 09/13] feat(notifications): diamond multi-actor lead + rounded-square badges - Replace the 2x2 grid lead (>3 actors) with the chosen "4A" diamond: rounded-square faces at the top/left/right points and the colored category mark at the bottom point (a 4th face when the row has no badge). Stays inside the 40px lead box, so rows keep their alignment. - Square off the category badge everywhere (rounded-full -> rounded-8) via the shared NotificationItemLead, so single-avatar rows and the in-app popup get the rounded-square badge too. - Update the Icons story badges to match. Co-Authored-By: Claude Opus 4.8 --- .../notifications/NotificationItem.tsx | 109 +++++++++--------- .../notifications/NotificationItemLead.tsx | 2 +- .../notifications/Icons.stories.tsx | 4 +- 3 files changed, 60 insertions(+), 55 deletions(-) diff --git a/packages/shared/src/components/notifications/NotificationItem.tsx b/packages/shared/src/components/notifications/NotificationItem.tsx index 859c95655d6..8ed226799dc 100644 --- a/packages/shared/src/components/notifications/NotificationItem.tsx +++ b/packages/shared/src/components/notifications/NotificationItem.tsx @@ -221,41 +221,50 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { hasAvatar && category !== NotificationFilterCategory.Updates; // Up to three actors render a single avatar (with a corner badge). More than - // three render as a 2x2 grid the size of one avatar — up to three faces plus - // the action icon — so the lead never grows wider and every row stays aligned. + // three render as a "diamond" cluster the size of one avatar — rounded-square + // faces at the diamond points so the lead never grows wider and every row + // stays aligned. When the row carries a category badge, the bottom point + // becomes the colored mark instead of a fourth face. let avatarContent: ReactElement | null = null; if (showGrid) { - // 2x2 of separate, individually-rounded face boxes (no connecting frame, - // no "+N" count) plus a circular action cell, sized like one avatar so the - // lead width never grows. Each face keeps its hover profile tooltip. - const slots = showBadge ? 3 : 4; - const cells: ReactElement[] = filteredAvatars - .slice(0, slots) - .map((avatar) => { - // Only image-backed actors (the ones that actually appear in a >3 - // stack) render as a compact face. Icon-backed types (badges/briefs/ - // digests) never reach this path, but fall back to the full avatar - // renderer rather than a broken if one ever does. - const isImageBacked = - avatar.type === NotificationAvatarType.User || - avatar.type === NotificationAvatarType.Source || - avatar.type === NotificationAvatarType.Organization; - if (!isImageBacked) { - return ( - - ); - } - const image = ( - {`${avatar.name} - ); - return avatar.type === NotificationAvatarType.User ? ( + // top, left, right, bottom points of the diamond. + const diamondPoints = [ + 'left-1/2 top-0 -translate-x-1/2', + 'left-0 top-1/2 -translate-y-1/2', + 'right-0 top-1/2 -translate-y-1/2', + 'bottom-0 left-1/2 -translate-x-1/2', + ]; + const faceCount = showBadge ? 3 : 4; + const faces = filteredAvatars.slice(0, faceCount).map((avatar, index) => { + const positioned = (child: ReactElement) => ( + + {child} + + ); + // Only image-backed actors (the ones that actually appear in a >3 stack) + // render as a compact face. Icon-backed types (badges/briefs/digests) + // never reach this path, but fall back to the full avatar renderer rather + // than a broken if one ever does. + const isImageBacked = + avatar.type === NotificationAvatarType.User || + avatar.type === NotificationAvatarType.Source || + avatar.type === NotificationAvatarType.Organization; + if (!isImageBacked) { + return positioned(); + } + const image = ( + {`${avatar.name} + ); + return positioned( + avatar.type === NotificationAvatarType.User ? ( @@ -263,28 +272,24 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { ) : ( image - ); - }); - // Pad empty face cells so the circular action always lands bottom-right. - while (cells.length < slots) { - cells.push(); - } - if (showBadge) { - cells.push( - - - , + ), ); - } + }); avatarContent = ( -
{cells}
+
+ {faces} + {showBadge && ( + + + + )} +
); } const timeText = createdAt ? publishTimeRelativeShort(createdAt) : ''; @@ -343,7 +348,7 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { layout; everything else uses the shared single-actor lead. */}
{showGrid ? ( -
{avatarContent}
+ avatarContent ) : ( )} diff --git a/packages/shared/src/components/notifications/NotificationItemLead.tsx b/packages/shared/src/components/notifications/NotificationItemLead.tsx index c925dc3b6d9..6d854e0edaf 100644 --- a/packages/shared/src/components/notifications/NotificationItemLead.tsx +++ b/packages/shared/src/components/notifications/NotificationItemLead.tsx @@ -51,7 +51,7 @@ export function NotificationItemLead({ {showBadge && ( diff --git a/packages/storybook/stories/components/notifications/Icons.stories.tsx b/packages/storybook/stories/components/notifications/Icons.stories.tsx index 1833b9b23c6..4e175153eb6 100644 --- a/packages/storybook/stories/components/notifications/Icons.stories.tsx +++ b/packages/storybook/stories/components/notifications/Icons.stories.tsx @@ -77,7 +77,7 @@ export const CategoryBadges: Story = { return ( Date: Mon, 29 Jun 2026 09:18:03 +0300 Subject: [PATCH 10/13] fix(notifications): robust diamond gating + valid popup title markup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-review follow-ups: - Only render the diamond cluster when enough real avatars were sent to fill it (3 + badge, or 4) — otherwise fall back to the single lead. Avoids a lopsided cluster with empty points when numTotalAvatars > sent avatars. - Key diamond faces with `referenceId ?? index` to avoid key collisions. - In-app popup: render the title in a
(not

) with [&_p]:inline — the sanitized title can be wrapped in

, and

inside

is invalid and gets auto-closed by the browser, breaking the layout. Co-Authored-By: Claude Opus 4.8 --- .../notifications/InAppNotificationItem.tsx | 7 +- .../notifications/NotificationItem.tsx | 92 ++++++++++--------- 2 files changed, 54 insertions(+), 45 deletions(-) diff --git a/packages/shared/src/components/notifications/InAppNotificationItem.tsx b/packages/shared/src/components/notifications/InAppNotificationItem.tsx index 0eb091766f4..ca16cf6b1c6 100644 --- a/packages/shared/src/components/notifications/InAppNotificationItem.tsx +++ b/packages/shared/src/components/notifications/InAppNotificationItem.tsx @@ -39,8 +39,11 @@ export function InAppNotificationItem({

-

): the sanitized title can itself be wrapped in a +

, and a

inside a

is invalid and gets auto-closed by the + browser, breaking the layout. */} +

1 && totalAvatars > 3; const renderLink = onClick && isClickable; const hasOptions = Object.keys(notificationMutingCopy).includes(type); const [attachment] = attachments ?? []; @@ -219,6 +216,14 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { // squad activity). Source posts & system land in `Updates` and stay clean. const showBadge = hasAvatar && category !== NotificationFilterCategory.Updates; + // The diamond needs a full set of faces to stay balanced: 3 faces + the + // badge, or 4 faces when there's no badge. Only switch to it for more than + // three actors AND when enough real avatars were sent to fill it — otherwise + // fall back to the single lead (the total still shows in the title) rather + // than rendering a lopsided cluster with empty points. + const diamondFaceCount = showBadge ? 3 : 4; + const showGrid = + totalAvatars > 3 && filteredAvatars.length >= diamondFaceCount; // Up to three actors render a single avatar (with a corner badge). More than // three render as a "diamond" cluster the size of one avatar — rounded-square @@ -234,47 +239,48 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { 'right-0 top-1/2 -translate-y-1/2', 'bottom-0 left-1/2 -translate-x-1/2', ]; - const faceCount = showBadge ? 3 : 4; - const faces = filteredAvatars.slice(0, faceCount).map((avatar, index) => { - const positioned = (child: ReactElement) => ( - - {child} - - ); - // Only image-backed actors (the ones that actually appear in a >3 stack) - // render as a compact face. Icon-backed types (badges/briefs/digests) - // never reach this path, but fall back to the full avatar renderer rather - // than a broken if one ever does. - const isImageBacked = - avatar.type === NotificationAvatarType.User || - avatar.type === NotificationAvatarType.Source || - avatar.type === NotificationAvatarType.Organization; - if (!isImageBacked) { - return positioned(); - } - const image = ( - {`${avatar.name} - ); - return positioned( - avatar.type === NotificationAvatarType.User ? ( - { + const positioned = (child: ReactElement) => ( + - {image} - - ) : ( - image - ), - ); - }); + {child} + + ); + // Only image-backed actors (the ones that actually appear in a >3 stack) + // render as a compact face. Icon-backed types (badges/briefs/digests) + // never reach this path, but fall back to the full avatar renderer rather + // than a broken if one ever does. + const isImageBacked = + avatar.type === NotificationAvatarType.User || + avatar.type === NotificationAvatarType.Source || + avatar.type === NotificationAvatarType.Organization; + if (!isImageBacked) { + return positioned(); + } + const image = ( + {`${avatar.name} + ); + return positioned( + avatar.type === NotificationAvatarType.User ? ( + + {image} + + ) : ( + image + ), + ); + }); avatarContent = (
From c98e805102059d7f6ac5cdcd2029a4d191200c6c Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 1 Jul 2026 10:12:18 +0300 Subject: [PATCH 11/13] feat(notifications): multi-actor lead as overlapping rounded-square stack Replace the diamond cluster with the chosen direction (#61): more than three actors now render as a tight overlap of up to three rounded-square faces, with the category badge centered on the bottom-right corner of the front face (rounded-square badge, matching the single-actor lead). Sized like one avatar so the lead never grows wider and rows stay aligned; the exact count stays in the title. Also includes the exploration Storybook pages (Stack explorations I/II/III, Avatar alternatives round 4, Use cases) used to arrive at this direction. Co-Authored-By: Claude Opus 4.8 --- .../notifications/NotificationItem.tsx | 141 ++--- .../StackExplorations.stories.tsx | 526 +++++++++++++++++ .../StackExplorationsII.stories.tsx | 524 +++++++++++++++++ .../StackExplorationsIII.stories.tsx | 554 ++++++++++++++++++ 4 files changed, 1666 insertions(+), 79 deletions(-) create mode 100644 packages/storybook/stories/components/notifications/StackExplorations.stories.tsx create mode 100644 packages/storybook/stories/components/notifications/StackExplorationsII.stories.tsx create mode 100644 packages/storybook/stories/components/notifications/StackExplorationsIII.stories.tsx diff --git a/packages/shared/src/components/notifications/NotificationItem.tsx b/packages/shared/src/components/notifications/NotificationItem.tsx index f773a8604f5..671f3b4fb6d 100644 --- a/packages/shared/src/components/notifications/NotificationItem.tsx +++ b/packages/shared/src/components/notifications/NotificationItem.tsx @@ -216,85 +216,68 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { // squad activity). Source posts & system land in `Updates` and stay clean. const showBadge = hasAvatar && category !== NotificationFilterCategory.Updates; - // The diamond needs a full set of faces to stay balanced: 3 faces + the - // badge, or 4 faces when there's no badge. Only switch to it for more than - // three actors AND when enough real avatars were sent to fill it — otherwise - // fall back to the single lead (the total still shows in the title) rather - // than rendering a lopsided cluster with empty points. - const diamondFaceCount = showBadge ? 3 : 4; - const showGrid = - totalAvatars > 3 && filteredAvatars.length >= diamondFaceCount; + // More than three actors render as a tight overlapping stack of up to three + // rounded-square faces — sized like one avatar so the lead never grows wider + // and every row stays aligned. The exact count still lives in the title. + const showStack = filteredAvatars.length > 1 && totalAvatars > 3; - // Up to three actors render a single avatar (with a corner badge). More than - // three render as a "diamond" cluster the size of one avatar — rounded-square - // faces at the diamond points so the lead never grows wider and every row - // stays aligned. When the row carries a category badge, the bottom point - // becomes the colored mark instead of a fourth face. let avatarContent: ReactElement | null = null; - if (showGrid) { - // top, left, right, bottom points of the diamond. - const diamondPoints = [ - 'left-1/2 top-0 -translate-x-1/2', - 'left-0 top-1/2 -translate-y-1/2', - 'right-0 top-1/2 -translate-y-1/2', - 'bottom-0 left-1/2 -translate-x-1/2', - ]; - const faces = filteredAvatars - .slice(0, diamondFaceCount) - .map((avatar, index) => { - const positioned = (child: ReactElement) => ( - - {child} - - ); - // Only image-backed actors (the ones that actually appear in a >3 stack) - // render as a compact face. Icon-backed types (badges/briefs/digests) - // never reach this path, but fall back to the full avatar renderer rather - // than a broken if one ever does. - const isImageBacked = - avatar.type === NotificationAvatarType.User || - avatar.type === NotificationAvatarType.Source || - avatar.type === NotificationAvatarType.Organization; - if (!isImageBacked) { - return positioned(); - } - const image = ( - {`${avatar.name} - ); - return positioned( - avatar.type === NotificationAvatarType.User ? ( - - {image} - - ) : ( - image - ), - ); - }); - + if (showStack) { + const faces = filteredAvatars.slice(0, 3); avatarContent = ( -
- {faces} - {showBadge && ( - - - - )} +
+ {faces.map((avatar, index) => { + // Icon-backed types (briefs/digests/badges) never reach a >3 stack, + // but fall back to the full avatar renderer rather than a broken + // if one ever does. + const isImageBacked = + avatar.type === NotificationAvatarType.User || + avatar.type === NotificationAvatarType.Source || + avatar.type === NotificationAvatarType.Organization; + const isFront = index === faces.length - 1; + const face = isImageBacked ? ( + {`${avatar.name} + ) : ( + + ); + return ( + + {avatar.type === NotificationAvatarType.User ? ( + + {face} + + ) : ( + face + )} + {/* Category badge centered on the bottom-right corner of the + front face (straddling it), matching the single-avatar lead. */} + {isFront && showBadge && ( + + + + )} + + ); + })}
); } @@ -350,10 +333,10 @@ function NotificationItem(props: NotificationItemProps): ReactElement | null { {/* Leading avatar + colored type badge — the eye-catching, type-at-a- glance cue (Instagram/Facebook/TikTok). System rows with no person - fall back to the plain type icon. The >3-actor grid is a list-only - layout; everything else uses the shared single-actor lead. */} + fall back to the plain type icon. More than three actors render as an + overlapping stack; everything else uses the shared single-actor lead. */}
- {showGrid ? ( + {showStack ? ( avatarContent ) : ( diff --git a/packages/storybook/stories/components/notifications/StackExplorations.stories.tsx b/packages/storybook/stories/components/notifications/StackExplorations.stories.tsx new file mode 100644 index 00000000000..1bbecd6513a --- /dev/null +++ b/packages/storybook/stories/components/notifications/StackExplorations.stories.tsx @@ -0,0 +1,526 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { UpvoteIcon } from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { + NotificationFilterCategory, + notificationCategoryBadge, +} from '@dailydotdev/shared/src/components/notifications/utils'; +import ExtensionProviders from '../../extension/_providers'; +import { img } from './_mock'; + +// Stack explorations — 20 distinct ways to show a STACK of avatars with the +// green upvote badge sitting ABOVE the stack. Every variant obeys three rules: +// 1. It's a stack of several images (never one). +// 2. It lives inside a FIXED 48px lead box — it can't overlap the text or +// push it to the right, no matter the arrangement. +// 3. The green upvote badge sits above / on top of the stack. +// All faces are rounded squares (the chosen direction). STORY-LOCAL ONLY. + +const meta: Meta = { + title: 'Components/Notifications/Stack explorations', + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: + '20 different avatar-stack arrangements with the upvote badge above, all rounded squares, all inside a fixed 48px lead so the text never shifts. Pick the number you like and I will wire it into the real NotificationItem lead.', + }, + }, + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +const upvote = notificationCategoryBadge[NotificationFilterCategory.Upvotes]; +const SEEDS = ['ada', 'bram', 'cleo', 'dana', 'eli']; +const RING = 'rounded-8 border-2 border-background-default object-cover'; + +const Face = ({ + seed, + size, + className = '', +}: { + seed: string; + size: string; + className?: string; +}) => ( + +); + +// Rounded-square upvote badge (matches the new badge shape). +const Badge = ({ + size = 'size-4', + className = '', +}: { + size?: string; + className?: string; +}) => ( + + + +); + +// A "+N" chip in the badge shape/color, for count-carrying variants. +const CountChip = ({ + n, + className = '', +}: { + n: number; + className?: string; +}) => ( + + +{n} + +); + +// Fixed 48px canvas — every variant renders inside this, so the lead footprint +// is identical and the text can never be pushed. +const Box = ({ children }: { children: React.ReactNode }) => ( +
{children}
+); +const Center = ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+); + +// ---- 20 variants ---------------------------------------------------------- + +const leads: Array<{ + n: number; + label: string; + note: string; + render: () => React.ReactElement; +}> = [ + { + n: 1, + label: 'Duo overlap · badge top-left', + note: 'Two faces overlapping horizontally, badge on the top-left corner (the reference image).', + render: () => ( + +
+
+ + +
+
+ +
+ ), + }, + { + n: 2, + label: 'Trio overlap · badge top-left', + note: 'Three faces overlapping in a row, badge top-left.', + render: () => ( + +
+
+ {SEEDS.slice(0, 3).map((s) => ( + + ))} +
+
+ +
+ ), + }, + { + n: 3, + label: 'Vertical stack · badge top-center', + note: 'Two faces stacked vertically, badge centered on top.', + render: () => ( + +
+
+ + +
+
+ +
+ ), + }, + { + n: 4, + label: 'Diagonal cascade · badge top-left', + note: 'Three faces cascading top-left → bottom-right, badge over the first.', + render: () => ( + + + + + + + + + + + + + ), + }, + { + n: 5, + label: 'Fanned cards · badge top-center', + note: 'Three faces fanned like a hand of cards, badge above the center.', + render: () => ( + +
+
+ + + + + + + + + +
+
+ +
+ ), + }, + { + n: 6, + label: 'Triangle · badge top-center', + note: 'Two faces on top, one below, badge nested above the pair.', + render: () => ( + + + + + + + + + + + + + ), + }, + { + n: 7, + label: 'Diamond · badge is the top point', + note: 'Three faces (left, right, bottom) with the badge as the top point.', + render: () => ( + + + + + + + + + + + + + ), + }, + { + n: 8, + label: 'Coin stack · badge top', + note: 'Faces offset like stacked coins (down-right), badge top-left.', + render: () => ( + + + + + + + + + + + + + ), + }, + { + n: 9, + label: 'Depth stack · badge top-left', + note: 'A smaller face behind a bigger one (depth), badge top-left.', + render: () => ( + + + + + + + + + + ), + }, + { + n: 10, + label: '2×2 grid · badge top-left', + note: 'Four faces in a tight grid, badge overlapping the top-left.', + render: () => ( + +
+
+ {SEEDS.slice(0, 4).map((s) => ( + + ))} +
+
+ +
+ ), + }, + { + n: 11, + label: 'Arc · badge top-center', + note: 'Three faces along a shallow arc (smile), badge above the peak.', + render: () => ( + + + + + + + + + + + + + ), + }, + { + n: 12, + label: 'Heavy overlap · badge top-right', + note: 'Three faces heavily overlapped into a tight cluster, badge top-right.', + render: () => ( + +
+
+ {SEEDS.slice(0, 3).map((s) => ( + + ))} +
+
+ +
+ ), + }, + { + n: 13, + label: 'Row + badge floating above', + note: 'A tidy row of small faces with the badge floating separately above (no overlap).', + render: () => ( + + +
+ {SEEDS.slice(0, 3).map((s) => ( + + ))} +
+
+ ), + }, + { + n: 14, + label: 'Pill container · badge top-left', + note: 'Two faces inside a rounded surface pill, badge on the corner.', + render: () => ( + +
+ + + + +
+ +
+ ), + }, + { + n: 15, + label: 'Duo + “+N” · badge top', + note: 'Two faces + a “+N” chip as the third tile, badge top-left.', + render: () => ( + +
+
+ + + +
+
+ +
+ ), + }, + { + n: 16, + label: 'Isometric cascade · badge top-left', + note: 'Faces stepping up-and-to-the-right, badge over the lowest one.', + render: () => ( + + + + + + + + + + + + + ), + }, + { + n: 17, + label: 'Side-by-side · badge bridging top', + note: 'Two faces side by side with the badge straddling the top seam.', + render: () => ( + +
+
+ + +
+
+ +
+ ), + }, + { + n: 18, + label: 'Nested sizes · badge top', + note: 'Three faces of different sizes packed together, badge top-left.', + render: () => ( + + + + + + + + + + + + + ), + }, + { + n: 19, + label: 'Staggered heights · badge top', + note: 'Overlapping row where faces sit at different heights, badge top-left.', + render: () => ( + +
+
+ + + +
+
+ +
+ ), + }, + { + n: 20, + label: 'Badge-hat · centered on top edge', + note: 'Two overlapping faces with the badge sitting like a hat on the top edge.', + render: () => ( + +
+
+ + +
+
+ +
+ ), + }, +]; + +const Row = ({ lead }: { lead: React.ReactNode }) => ( +
+
{lead}
+
+
+ 24 upvotes on your comment!{' '} + + · 2h + +
+
+ “Have you tried the new view transitions API?” +
+
+
+); + +const Explorations = (): React.ReactElement => ( +
+

+ Stack explorations — 20 variants +

+

+ Twenty ways to stack the avatars with the green upvote badge above. Rules: + always a stack of several rounded-square faces, always inside a fixed 48px + lead (the text never moves), and the badge always sits above the stack. + Tell me the number and I’ll wire it into the real notification lead. +

+ +
+ {leads.map((lead) => ( +
+
+ + {lead.n}. + +

+ {lead.label} +

+
+

{lead.note}

+
+ +
+
+ ))} +
+
+); + +export const Compare: Story = { + name: '20 variants', + render: () => , +}; diff --git a/packages/storybook/stories/components/notifications/StackExplorationsII.stories.tsx b/packages/storybook/stories/components/notifications/StackExplorationsII.stories.tsx new file mode 100644 index 00000000000..22913564ee9 --- /dev/null +++ b/packages/storybook/stories/components/notifications/StackExplorationsII.stories.tsx @@ -0,0 +1,524 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { UpvoteIcon } from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { + NotificationFilterCategory, + notificationCategoryBadge, +} from '@dailydotdev/shared/src/components/notifications/utils'; +import ExtensionProviders from '../../extension/_providers'; +import { img } from './_mock'; + +// Stack explorations II — 20 MORE arrangements, more experimental than round 1. +// New/updated rules: +// 1. Always a stack of several rounded-square faces (never one). +// 2. Fixed 48px lead box — the text never shifts. +// 3. The upvote badge sits at the BOTTOM-RIGHT corner, same rounded-square +// style as everywhere else. +// STORY-LOCAL ONLY. + +const meta: Meta = { + title: 'Components/Notifications/Stack explorations II', + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: + '20 more avatar-stack arrangements, badge pinned bottom-right (rounded square). Fixed 48px lead so text never moves. Pick a number and I will wire it into the real lead.', + }, + }, + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +const upvote = notificationCategoryBadge[NotificationFilterCategory.Upvotes]; +const SEEDS = ['ada', 'bram', 'cleo', 'dana']; +const RING = 'rounded-8 border-2 border-background-default object-cover'; + +const Face = ({ + seed, + size, + className = '', +}: { + seed: string; + size: string; + className?: string; +}) => ( + +); + +// Rounded-square upvote badge, always bottom-right (matches the shared corner +// badge: -bottom-1 -right-1 with a background-default notch). +const Badge = () => ( + + + +); + +// Fixed 48px canvas so every lead has the same footprint. +const Box = ({ children }: { children: React.ReactNode }) => ( +
{children}
+); +const Center = ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+); + +const leads: Array<{ + n: number; + label: string; + note: string; + render: () => React.ReactElement; +}> = [ + { + n: 21, + label: 'Trio overlap · badge BR', + note: 'The baseline for this round: three faces overlapping, badge bottom-right.', + render: () => ( + +
+
+ {SEEDS.slice(0, 3).map((s) => ( + + ))} +
+
+ +
+ ), + }, + { + n: 22, + label: 'Pinwheel', + note: 'Four faces around the center, each tilted for a pinwheel spin.', + render: () => ( + + + + + + + + + + + + + + + + ), + }, + { + n: 23, + label: 'Honeycomb', + note: 'Two faces up top, one tucked below between them — a tight hex cluster.', + render: () => ( + +
+
+
+ + +
+ + + +
+
+ +
+ ), + }, + { + n: 24, + label: 'Brick rows', + note: 'Two rows of two, the bottom row offset like bricks.', + render: () => ( + +
+
+
+ + +
+
+ + +
+
+
+ +
+ ), + }, + { + n: 25, + label: 'Plus / cross', + note: 'Four faces arranged in a plus, open center.', + render: () => ( + + + + + + + + + + + + + + + + ), + }, + { + n: 26, + label: 'Picture-in-picture', + note: 'A big lead face with a small one perched in the top-right.', + render: () => ( + +
+ +
+ + + + +
+ ), + }, + { + n: 27, + label: 'Deck peeking', + note: 'Faces heavily overlapped so the deck peeks out on the left.', + render: () => ( + +
+
+ {SEEDS.slice(0, 3).map((s) => ( + + ))} +
+
+ +
+ ), + }, + { + n: 28, + label: 'Leaning cards', + note: 'Two faces skewed in opposite directions for a dynamic tilt.', + render: () => ( + +
+
+ + + + + + +
+
+ +
+ ), + }, + { + n: 29, + label: 'Framed pair', + note: 'Faces sit inside a rounded surface frame; badge on the corner.', + render: () => ( + +
+ + + + +
+ +
+ ), + }, + { + n: 30, + label: 'Totem column', + note: 'Three faces stacked in a slim vertical column.', + render: () => ( + +
+
+ {SEEDS.slice(0, 3).map((s) => ( + + ))} +
+
+ +
+ ), + }, + { + n: 31, + label: 'L-shape', + note: 'Faces trace an L along the left and bottom edges.', + render: () => ( + + + + + + + + + + + + + ), + }, + { + n: 32, + label: 'Photo scatter', + note: 'Three faces tossed at small random angles like a photo pile.', + render: () => ( + + + + + + + + + + + + + ), + }, + { + n: 33, + label: 'Hero + peekers', + note: 'One big face in front with two small ones peeking over the top.', + render: () => ( + + + + + + + +
+ +
+ +
+ ), + }, + { + n: 34, + label: 'Perspective row', + note: 'Faces grow left → right for a sense of depth.', + render: () => ( + +
+
+ + + +
+
+ +
+ ), + }, + { + n: 35, + label: 'Quad grid', + note: 'A clean 2×2 of four faces, badge bottom-right.', + render: () => ( + +
+
+ {SEEDS.slice(0, 4).map((s) => ( + + ))} +
+
+ +
+ ), + }, + { + n: 36, + label: 'Fan downward', + note: 'Three faces fanning down like a dealt hand.', + render: () => ( + +
+
+ + + + + + + + + +
+
+ +
+ ), + }, + { + n: 37, + label: 'Rising diagonal', + note: 'Faces climb from bottom-left to top-right.', + render: () => ( + + + + + + + + + + + + + ), + }, + { + n: 38, + label: 'Zigzag', + note: 'An overlapping row where faces alternate up and down.', + render: () => ( + +
+
+ + + +
+
+ +
+ ), + }, + { + n: 39, + label: 'Crossed pair', + note: 'Two faces rotated into an X for a bold, graphic mark.', + render: () => ( + +
+
+ + + + + + +
+
+ +
+ ), + }, + { + n: 40, + label: 'Orbit', + note: 'A center face with two small satellites at opposite corners.', + render: () => ( + +
+ +
+ + + + + + + +
+ ), + }, +]; + +const Row = ({ lead }: { lead: React.ReactNode }) => ( +
+
{lead}
+
+
+ 24 upvotes on your comment!{' '} + + · 2h + +
+
+ “Have you tried the new view transitions API?” +
+
+
+); + +const Explorations = (): React.ReactElement => ( +
+

+ Stack explorations II — 20 more +

+

+ More experimental arrangements, all with the upvote badge pinned + bottom-right (rounded square, like the reference). Same guarantees: always + a stack of several rounded-square faces, always inside a fixed 48px lead + so the text never moves. Numbers continue from the first page (21–40). +

+ +
+ {leads.map((lead) => ( +
+
+ + {lead.n}. + +

+ {lead.label} +

+
+

{lead.note}

+
+ +
+
+ ))} +
+
+); + +export const Compare: Story = { + name: '20 more (badge bottom-right)', + render: () => , +}; diff --git a/packages/storybook/stories/components/notifications/StackExplorationsIII.stories.tsx b/packages/storybook/stories/components/notifications/StackExplorationsIII.stories.tsx new file mode 100644 index 00000000000..abd61c37bf9 --- /dev/null +++ b/packages/storybook/stories/components/notifications/StackExplorationsIII.stories.tsx @@ -0,0 +1,554 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { UpvoteIcon } from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { + NotificationFilterCategory, + notificationCategoryBadge, +} from '@dailydotdev/shared/src/components/notifications/utils'; +import ExtensionProviders from '../../extension/_providers'; +import { img } from './_mock'; + +// Stack explorations III — variations on the three favorites: #4 diagonal +// cascade, #12 heavy overlap, #16 isometric. Fixes from feedback: +// • Badge is a clear ROUNDED RECTANGLE (size-5 + rounded-8, same proportion +// as the faces) — not the near-circle the size-4 badge collapsed into. +// • Faces are BIGGER (size-6 / size-7). +// Rules still hold: a stack of several rounded-square faces, fixed 48px lead so +// the text never moves. STORY-LOCAL ONLY. + +const meta: Meta = { + title: 'Components/Notifications/Stack explorations III', + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: + 'Variations on #4 (diagonal cascade), #12 (heavy overlap) and #16 (isometric), with bigger faces and a rounded-rectangle badge. Pick a number and I will wire it into the real lead.', + }, + }, + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +const upvote = notificationCategoryBadge[NotificationFilterCategory.Upvotes]; +const SEEDS = ['ada', 'bram', 'cleo', 'dana']; +const RING = 'rounded-8 border-2 border-background-default object-cover'; + +const Face = ({ + seed, + size, + className = '', +}: { + seed: string; + size: string; + className?: string; +}) => ( + +); + +type Pos = 'br' | 'tr' | 'tl' | 'bl'; +const posClass: Record = { + br: '-bottom-1 -right-1', + tr: '-top-1 -right-1', + tl: '-top-1 -left-1', + bl: '-bottom-1 -left-1', +}; + +// Badge whose CENTER sits exactly on the bottom-right corner of its parent +// (wrap the front face in `relative` and drop this inside it). +const CornerCenteredBadge = () => ( + + + +); + +// Rounded-RECTANGLE upvote badge (size-5 + rounded-8 = same corner proportion +// as the faces, so it reads as a rounded square, not a circle). +const Badge = ({ pos = 'br' as Pos }: { pos?: Pos }) => ( + + + +); + +const CountTile = ({ n, size }: { n: number; size: string }) => ( + + +{n} + +); + +const Box = ({ children }: { children: React.ReactNode }) => ( +
{children}
+); +const Center = ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+); + +const leads: Array<{ + n: number; + label: string; + note: string; + render: () => React.ReactElement; +}> = [ + // ---- Heavy overlap family (from #12) ---- + { + n: 41, + label: 'Overlap duo (big) · BR', + note: 'Two large faces overlapping, rounded-rect badge bottom-right.', + render: () => ( + +
+
+ + +
+
+ +
+ ), + }, + { + n: 42, + label: 'Overlap trio · BR', + note: 'Three big faces overlapping in a row.', + render: () => ( + +
+
+ {SEEDS.slice(0, 3).map((s) => ( + + ))} +
+
+ +
+ ), + }, + { + n: 43, + label: 'Tight overlap trio · TR', + note: 'Heavier overlap into a tight cluster, badge top-right.', + render: () => ( + +
+
+ {SEEDS.slice(0, 3).map((s) => ( + + ))} +
+
+ +
+ ), + }, + { + n: 44, + label: 'Overlap duo · TL', + note: 'Two big faces, badge tucked top-left.', + render: () => ( + +
+
+ + +
+
+ +
+ ), + }, + // ---- Diagonal cascade family (from #4) ---- + { + n: 45, + label: 'Cascade trio ↘ · BR', + note: 'Three big faces cascading top-left → bottom-right.', + render: () => ( + + + + + + + + + + + + + ), + }, + { + n: 46, + label: 'Cascade duo ↘ · BR', + note: 'Two large faces on a diagonal, lots of presence.', + render: () => ( + + + + + + + + + + ), + }, + { + n: 47, + label: 'Rising cascade ↗ · TR', + note: 'Three faces climbing bottom-left → top-right, badge top-right.', + render: () => ( + + + + + + + + + + + + + ), + }, + { + n: 48, + label: 'Cascade + “+N” tail · BR', + note: 'A two-face diagonal capped by a “+N” tile continuing the line.', + render: () => ( + + + + + + + + + + + + + ), + }, + // ---- Isometric family (from #16) ---- + { + n: 49, + label: 'Isometric ↗ · BL', + note: 'Faces stepping up-and-right, badge bottom-left.', + render: () => ( + + + + + + + + + + + + + ), + }, + { + n: 50, + label: 'Isometric ↗ · BR', + note: 'Same up-right steps with the badge bottom-right.', + render: () => ( + + + + + + + + + + + + + ), + }, + { + n: 51, + label: 'Descending stairs ↘ · TR', + note: 'Faces stepping down-and-right, badge top-right.', + render: () => ( + + + + + + + + + + + + + ), + }, + // ---- Variations / combos ---- + { + n: 52, + label: 'Photo pile (big, tilted) · BR', + note: 'Overlap trio with slight tilts, like tossed photos.', + render: () => ( + + + + + + + + + + + + + ), + }, + { + n: 53, + label: 'Perspective row · BR', + note: 'Faces grow left → right for depth (biggest in front).', + render: () => ( + +
+
+ + + +
+
+ +
+ ), + }, + { + n: 54, + label: 'Vertical overlap (big) · BR', + note: 'Two large faces stacked vertically.', + render: () => ( + +
+
+ + +
+
+ +
+ ), + }, + { + n: 55, + label: 'Hero + peeker · BR', + note: 'One big lead face with a second peeking behind the top-right.', + render: () => ( + + + + + + + + + + ), + }, + { + n: 56, + label: 'Overlap trio + “+N” · BR', + note: 'Three big faces plus a “+N” tile as the fourth.', + render: () => ( + +
+
+ + + +
+
+ +
+ ), + }, + { + n: 57, + label: 'Fanned big cards · BR', + note: 'Three big faces fanned with a gentle rotation.', + render: () => ( + +
+
+ + + + + + + + + +
+
+ +
+ ), + }, + { + n: 58, + label: 'Brick rows (big) · BR', + note: 'Two offset rows of big faces.', + render: () => ( + +
+
+
+ + +
+
+ + +
+
+
+ +
+ ), + }, + { + n: 59, + label: 'Front hero overlap · BR', + note: 'Overlap where the front face is scaled up to lead.', + render: () => ( + +
+
+ + +
+
+ +
+ ), + }, + { + n: 60, + label: 'Steep cascade ↘ (big) · BR', + note: 'A tighter, steeper diagonal of two big faces + a third small step.', + render: () => ( + + + + + + + + + + + + + ), + }, + { + n: 61, + label: 'Tight overlap trio · badge centered on corner', + note: 'Variation of #43 — the badge’s center sits exactly on the bottom-right corner of the front profile image (straddling it).', + render: () => ( + +
+
+ + + + + + +
+
+
+ ), + }, +]; + +const Row = ({ lead }: { lead: React.ReactNode }) => ( +
+
{lead}
+
+
+ 24 upvotes on your comment!{' '} + + · 2h + +
+
+ “Have you tried the new view transitions API?” +
+
+
+); + +const Explorations = (): React.ReactElement => ( +
+

+ Stack explorations III — bigger faces, rounded-rect badge +

+

+ Twenty variations on your three favorites — #4 (diagonal cascade), #12 + (heavy overlap) and #16 (isometric) — with larger rounded-square faces and + a rounded-rectangle upvote badge (no longer a near-circle). Fixed 48px + lead, so the text never moves. Numbers 41–60. +

+ +
+ {leads.map((lead) => ( +
+
+ + {lead.n}. + +

+ {lead.label} +

+
+

{lead.note}

+
+ +
+
+ ))} +
+
+); + +export const Compare: Story = { + name: '20 more (bigger faces + rounded-rect badge)', + render: () => , +}; From ba8165a10615a5b6cf689b7a76e26b896627df98 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 1 Jul 2026 10:30:41 +0300 Subject: [PATCH 12/13] feat(notifications): rounded-square avatars across all rows Make every notification avatar a rounded square to match the multi-actor stack: users render at rounded-8, and org/achievement/source avatars switch from circles to rounded-8. SourceButton gains a `rounded` prop (defaults to 'full', so all other callers are unchanged) since it hard-coded a circle. Co-Authored-By: Claude Opus 4.8 --- .../src/components/cards/common/SourceButton.tsx | 10 +++++++--- .../notifications/NotificationItemAvatar.tsx | 12 +++++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/components/cards/common/SourceButton.tsx b/packages/shared/src/components/cards/common/SourceButton.tsx index 81f0f909f5e..8830569b6b1 100644 --- a/packages/shared/src/components/cards/common/SourceButton.tsx +++ b/packages/shared/src/components/cards/common/SourceButton.tsx @@ -25,6 +25,9 @@ interface SourceButtonProps { className?: string; style?: CSSProperties; size?: ProfileImageSize; + // Defaults to a circle; callers (e.g. notifications) can pass a size token to + // render a rounded square instead. + rounded?: ProfileImageSize | 'full'; pureTextTooltip?: boolean; tooltipPosition?: HoverCardContentProps['side']; } @@ -34,6 +37,7 @@ export default function SourceButton({ tooltipPosition = 'bottom', pureTextTooltip = false, size = ProfileImageSize.Medium, + rounded = 'full', className, ...props }: SourceButtonProps): ReactElement { @@ -45,7 +49,7 @@ export default function SourceButton({ {...props} className={className} size={size} - rounded="full" + rounded={rounded} user={{ id: source.id, image: source.image, @@ -62,7 +66,7 @@ export default function SourceButton({ From aebbf1fe15a5a37dcf44bda2b95a2eb10444b2a4 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 1 Jul 2026 10:39:56 +0300 Subject: [PATCH 13/13] fix(SourceButton): guard optional source id for strict typecheck Co-Authored-By: Claude Opus 4.8 --- .../shared/src/components/cards/common/SourceButton.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/components/cards/common/SourceButton.tsx b/packages/shared/src/components/cards/common/SourceButton.tsx index 8830569b6b1..14b819c7d2b 100644 --- a/packages/shared/src/components/cards/common/SourceButton.tsx +++ b/packages/shared/src/components/cards/common/SourceButton.tsx @@ -51,7 +51,7 @@ export default function SourceButton({ size={size} rounded={rounded} user={{ - id: source.id, + id: source.id ?? source.handle, image: source.image, username: source.handle, }} @@ -68,7 +68,7 @@ export default function SourceButton({ className={className} picture={{ size, rounded }} user={{ - id: source.id, + id: source.id ?? source.handle, image: source.image, permalink: source.permalink, username: source.handle, @@ -90,7 +90,7 @@ export default function SourceButton({ className={className} picture={{ size, rounded }} user={{ - id: source.id, + id: source.id ?? source.handle, image: source.image, permalink: source.permalink, username: source.handle,