From 5e11ff78f3871a07608342f1f1836b76e5945b8c Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 16 Jun 2026 14:25:19 +0300 Subject: [PATCH 01/20] feat: sidebar "what's new" announcement cards Add a Linear-style announcement surface for the bottom of the V2 sidebar to prominently announce new features/releases and drive a single action. - AnnouncementCard with Compact / Default / Cover variants, built from existing primitives (Card tokens, Typography, Button, CloseButton, Pill) - AnnouncementCarousel stack: dot indicators, prev/next, advance-on-dismiss - useSidebarAnnouncements + content config; dismissal persists client-side via usePersistentState (no backend ActionType needed) - SidebarAnnouncements container wired into SidebarDesktopV2, gated by the new sidebar_announcements feature flag and shown only in the expanded home panel - Storybook stories for every variant/state + a Guidelines & anatomy page Co-Authored-By: Claude Opus 4.8 --- .../announcements/AnnouncementCard.spec.tsx | 79 +++++ .../announcements/AnnouncementCard.tsx | 222 ++++++++++++ .../announcements/AnnouncementCarousel.tsx | 112 ++++++ .../src/components/announcements/content.tsx | 54 +++ .../src/components/announcements/types.ts | 38 +++ .../sidebar/SidebarAnnouncements.tsx | 82 +++++ .../components/sidebar/SidebarDesktopV2.tsx | 4 + .../src/hooks/useSidebarAnnouncements.ts | 40 +++ packages/shared/src/lib/featureManagement.ts | 5 + packages/shared/src/lib/log.ts | 4 + .../AnnouncementCard.stories.tsx | 131 +++++++ .../AnnouncementCarousel.stories.tsx | 99 ++++++ .../AnnouncementGuidelines.stories.tsx | 320 ++++++++++++++++++ 13 files changed, 1190 insertions(+) create mode 100644 packages/shared/src/components/announcements/AnnouncementCard.spec.tsx create mode 100644 packages/shared/src/components/announcements/AnnouncementCard.tsx create mode 100644 packages/shared/src/components/announcements/AnnouncementCarousel.tsx create mode 100644 packages/shared/src/components/announcements/content.tsx create mode 100644 packages/shared/src/components/announcements/types.ts create mode 100644 packages/shared/src/components/sidebar/SidebarAnnouncements.tsx create mode 100644 packages/shared/src/hooks/useSidebarAnnouncements.ts create mode 100644 packages/storybook/stories/components/announcements/AnnouncementCard.stories.tsx create mode 100644 packages/storybook/stories/components/announcements/AnnouncementCarousel.stories.tsx create mode 100644 packages/storybook/stories/components/announcements/AnnouncementGuidelines.stories.tsx diff --git a/packages/shared/src/components/announcements/AnnouncementCard.spec.tsx b/packages/shared/src/components/announcements/AnnouncementCard.spec.tsx new file mode 100644 index 00000000000..6fca3c9bc4e --- /dev/null +++ b/packages/shared/src/components/announcements/AnnouncementCard.spec.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { AnnouncementCard } from './AnnouncementCard'; +import { AnnouncementCardVariant } from './types'; + +describe('AnnouncementCard', () => { + it('renders the compact variant as a link and fires onClick', () => { + const onClick = jest.fn(); + render( + , + ); + + const link = screen.getByRole('link', { + name: /keyboard shortcuts are here/i, + }); + expect(link).toHaveAttribute('href', '/settings'); + + fireEvent.click(link); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('renders title, description, badge and CTA for the default variant', () => { + const onCtaClick = jest.fn(); + render( + , + ); + + expect(screen.getByText('Smarter custom feeds')).toBeInTheDocument(); + expect( + screen.getByText('Custom feeds now learn from what you read.'), + ).toBeInTheDocument(); + expect(screen.getByText('Updated')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'See what changed' })); + expect(onCtaClick).toHaveBeenCalledTimes(1); + }); + + it('renders a dismiss control when onClose is provided and fires it', () => { + const onClose = jest.fn(); + render( + , + ); + + fireEvent.click(screen.getByTitle('Close')); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('renders a cover image for the cover variant', () => { + const { container } = render( + undefined} + />, + ); + + // Decorative cover (alt=""), so it has no accessible role — query the + // element directly. + // eslint-disable-next-line testing-library/no-container + const image = container.querySelector('img'); + expect(image).toHaveAttribute('src', 'https://media.daily.dev/cover.png'); + }); +}); diff --git a/packages/shared/src/components/announcements/AnnouncementCard.tsx b/packages/shared/src/components/announcements/AnnouncementCard.tsx new file mode 100644 index 00000000000..a682153ad0b --- /dev/null +++ b/packages/shared/src/components/announcements/AnnouncementCard.tsx @@ -0,0 +1,222 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import CloseButton from '../CloseButton'; +import { ArrowIcon } from '../icons'; +import { IconSize } from '../Icon'; +import { Image } from '../image/Image'; +import { Pill, PillSize } from '../Pill'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../typography/Typography'; +import type { AnnouncementBadge, AnnouncementCta } from './types'; +import { AnnouncementCardVariant } from './types'; + +export interface AnnouncementCardProps { + variant?: AnnouncementCardVariant; + title: string; + description?: string; + badge?: AnnouncementBadge; + icon?: ReactNode; + // Cover image, only rendered by the Cover variant. + image?: string; + cta?: AnnouncementCta; + // Whole-card link target, used by the Compact variant. + href?: string; + // Whole-card click handler (analytics / navigation) for the Compact variant. + onClick?: () => void; + // When provided, a dismiss (×) control is rendered. + onClose?: () => void; + className?: string; +} + +const cardBaseClasses = + 'border border-border-subtlest-tertiary bg-surface-float transition-colors'; + +const renderBadge = (badge?: AnnouncementBadge): ReactElement | null => { + if (!badge) { + return null; + } + + return ( + + ); +}; + +const renderCta = (cta?: AnnouncementCta): ReactElement | null => { + if (!cta) { + return null; + } + + return ( + + ); +}; + +export function AnnouncementCard({ + variant = AnnouncementCardVariant.Default, + title, + description, + badge, + icon, + image, + cta, + href, + onClick, + onClose, + className, +}: AnnouncementCardProps): ReactElement { + if (variant === AnnouncementCardVariant.Compact) { + const Tag = href ? 'a' : 'button'; + + return ( + + {icon && ( + + {icon} + + )} + + + {title} + + {description && ( + + {description} + + )} + + + + ); + } + + // reserveCloseSpace keeps the title clear of an inline (top-right) close. + // The Cover variant overlays its close on the image, so it passes false. + const renderBody = (reserveCloseSpace: boolean): ReactElement => ( +
+ {(icon || badge) && ( +
+ {icon && ( + + {icon} + + )} + {renderBadge(badge)} +
+ )} + + {title} + + {description && ( + + {description} + + )} + {renderCta(cta)} +
+ ); + + if (variant === AnnouncementCardVariant.Cover) { + return ( +
+
+ {image && ( + + )} + {onClose && ( + // Float is invisible over imagery — use a solid Primary close. + + )} +
+
{renderBody(false)}
+
+ ); + } + + return ( +
+ {onClose && ( + + )} + {renderBody(Boolean(onClose))} +
+ ); +} + +export default AnnouncementCard; diff --git a/packages/shared/src/components/announcements/AnnouncementCarousel.tsx b/packages/shared/src/components/announcements/AnnouncementCarousel.tsx new file mode 100644 index 00000000000..751aea6f152 --- /dev/null +++ b/packages/shared/src/components/announcements/AnnouncementCarousel.tsx @@ -0,0 +1,112 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useState } from 'react'; +import classNames from 'classnames'; +import { AnnouncementCard } from './AnnouncementCard'; +import { AnnouncementCardVariant } from './types'; +import type { AnnouncementItem } from './types'; +import CarouselIndicator from '../containers/CarouselIndicator'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { ArrowIcon } from '../icons'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../typography/Typography'; + +export interface AnnouncementCarouselProps { + items: AnnouncementItem[]; + onDismiss: (id: string) => void; + // Fired when a card becomes the active (visible) one — for impressions. + onView?: (item: AnnouncementItem) => void; + // Fired when a Compact card's row is clicked. + onItemClick?: (item: AnnouncementItem) => void; + className?: string; +} + +export function AnnouncementCarousel({ + items, + onDismiss, + onView, + onItemClick, + className, +}: AnnouncementCarouselProps): ReactElement | null { + const [active, setActive] = useState(0); + const count = items.length; + // Items shrink as cards are dismissed; keep the index in range. + const safeActive = Math.min(active, Math.max(count - 1, 0)); + const current = items[safeActive]; + + useEffect(() => { + if (current) { + onView?.(current); + } + // Re-run when the visible card changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [current?.id]); + + if (!current) { + return null; + } + + const hasMultiple = count > 1; + + return ( +
+ onItemClick(current) + : undefined + } + onClose={() => onDismiss(current.id)} + /> + {hasMultiple && ( +
+ +
+ + {safeActive + 1} of {count} + +
+
+ )} +
+ ); +} + +export default AnnouncementCarousel; diff --git a/packages/shared/src/components/announcements/content.tsx b/packages/shared/src/components/announcements/content.tsx new file mode 100644 index 00000000000..8dd407394dd --- /dev/null +++ b/packages/shared/src/components/announcements/content.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { AnnouncementCardVariant } from './types'; +import type { AnnouncementItem } from './types'; +import { HotIcon, MegaphoneIcon, SparkleIcon } from '../icons'; +import { IconSize } from '../Icon'; +import { + cloudinaryFeedFiltersYourFeedDark, + cloudinarySquadsDirectoryCardBannerDefault, +} from '../../lib/image'; + +// Curated list of "what's new" announcements shown in the sidebar. +// Keep this short and high-signal — show fewer, better entries. Newest first. +// Bump/replace entries on each release; once dismissed, a card stays gone +// (persisted client-side by id via useSidebarAnnouncements). +export const SIDEBAR_ANNOUNCEMENTS: AnnouncementItem[] = [ + { + id: 'cover-2026-06-clips', + variant: AnnouncementCardVariant.Cover, + image: cloudinarySquadsDirectoryCardBannerDefault, + badge: { label: 'New' }, + title: 'Introducing Clips', + description: + 'Save the best moments from any post and share them with your network in one tap.', + cta: { text: 'Try Clips', href: '/clips' }, + }, + { + id: 'default-2026-06-custom-feeds', + variant: AnnouncementCardVariant.Default, + icon: , + badge: { label: 'Updated' }, + title: 'Smarter custom feeds', + description: + 'Custom feeds now learn from what you read to surface more of what matters.', + cta: { text: 'See what changed', href: '/feeds' }, + }, + { + id: 'compact-2026-06-shortcuts', + variant: AnnouncementCardVariant.Compact, + icon: , + title: 'Keyboard shortcuts are here', + href: '/settings', + }, + { + id: 'compact-2026-05-changelog', + variant: AnnouncementCardVariant.Compact, + icon: , + title: 'Catch up on everything new', + description: 'Browse the full changelog', + href: 'https://daily.dev/changelog', + }, +]; + +// Exported for stories/tests that want a richer cover example. +export const exampleCoverImage = cloudinaryFeedFiltersYourFeedDark; diff --git a/packages/shared/src/components/announcements/types.ts b/packages/shared/src/components/announcements/types.ts new file mode 100644 index 00000000000..f15bfda7531 --- /dev/null +++ b/packages/shared/src/components/announcements/types.ts @@ -0,0 +1,38 @@ +import type { ReactNode } from 'react'; + +export enum AnnouncementCardVariant { + // icon + title (+ one line) + arrow — for minor updates + Compact = 'compact', + // badge + title + body + CTA — the standard release card + Default = 'default', + // cover image on top of the standard card — for headline launches + Cover = 'cover', +} + +export interface AnnouncementBadge { + label: string; + // Tailwind classes to tint the pill. Defaults to a brand (cabbage) tint. + className?: string; +} + +export interface AnnouncementCta { + text: string; + href?: string; + onClick?: () => void; +} + +export interface AnnouncementItem { + // Stable id — used for dismissal persistence and analytics. + id: string; + variant: AnnouncementCardVariant; + title: string; + description?: string; + badge?: AnnouncementBadge; + // Cover image url, used by the Cover variant. + image?: string; + // Leading icon for the Compact/Default variants. + icon?: ReactNode; + cta?: AnnouncementCta; + // Makes the whole card a link when there is no explicit CTA (Compact). + href?: string; +} diff --git a/packages/shared/src/components/sidebar/SidebarAnnouncements.tsx b/packages/shared/src/components/sidebar/SidebarAnnouncements.tsx new file mode 100644 index 00000000000..1eecebbf520 --- /dev/null +++ b/packages/shared/src/components/sidebar/SidebarAnnouncements.tsx @@ -0,0 +1,82 @@ +import type { ReactElement } from 'react'; +import React, { useMemo } from 'react'; +import { AnnouncementCarousel } from '../announcements/AnnouncementCarousel'; +import type { AnnouncementItem } from '../announcements/types'; +import { useSidebarAnnouncements } from '../../hooks/useSidebarAnnouncements'; +import { useConditionalFeature } from '../../hooks/useConditionalFeature'; +import { featureSidebarAnnouncements } from '../../lib/featureManagement'; +import { useLogContext } from '../../contexts/LogContext'; +import { LogEvent } from '../../lib/log'; + +interface SidebarAnnouncementsProps { + className?: string; +} + +export function SidebarAnnouncements({ + className, +}: SidebarAnnouncementsProps): ReactElement | null { + const { logEvent } = useLogContext(); + const { items, dismiss, isReady } = useSidebarAnnouncements(); + const { value: isEnabled } = useConditionalFeature({ + feature: featureSidebarAnnouncements, + shouldEvaluate: isReady && items.length > 0, + }); + + // Wrap each CTA so its click is logged before navigation runs. + const loggedItems = useMemo( + () => + items.map((item) => { + if (!item.cta) { + return item; + } + + return { + ...item, + cta: { + ...item.cta, + onClick: () => { + logEvent({ + event_name: LogEvent.ClickAnnouncement, + target_id: item.id, + }); + item.cta?.onClick?.(); + }, + }, + }; + }), + [items, logEvent], + ); + + if (!isReady || !isEnabled || loggedItems.length === 0) { + return null; + } + + return ( +
+ + logEvent({ + event_name: LogEvent.ImpressionAnnouncement, + target_id: item.id, + }) + } + onItemClick={(item) => + logEvent({ + event_name: LogEvent.ClickAnnouncement, + target_id: item.id, + }) + } + onDismiss={(id) => { + logEvent({ + event_name: LogEvent.DismissAnnouncement, + target_id: id, + }); + dismiss(id); + }} + /> +
+ ); +} + +export default SidebarAnnouncements; diff --git a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx index e25c98ceab1..c5ece66d3fe 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx @@ -30,6 +30,7 @@ import { BookmarkSection } from './sections/BookmarkSection'; import { NetworkSection } from './sections/NetworkSection'; import { GameCenterSection } from './sections/GameCenterSection'; import { HelpWidget } from '../help/HelpWidget'; +import { SidebarAnnouncements } from './SidebarAnnouncements'; import { BookmarkIcon, FeedbackIcon, @@ -924,6 +925,9 @@ export const SidebarDesktopV2 = ({ + {isExpanded && !isUtilityPanelSelected && ( + + )} {!isUtilityPanelSelected && } {showFeedbackWidget && !isUtilityPanelSelected && (
diff --git a/packages/shared/src/hooks/useSidebarAnnouncements.ts b/packages/shared/src/hooks/useSidebarAnnouncements.ts new file mode 100644 index 00000000000..1424a048e4c --- /dev/null +++ b/packages/shared/src/hooks/useSidebarAnnouncements.ts @@ -0,0 +1,40 @@ +import { useMemo } from 'react'; +import { usePersistentState } from './usePersistentState'; +import { SIDEBAR_ANNOUNCEMENTS } from '../components/announcements/content'; +import type { AnnouncementItem } from '../components/announcements/types'; + +const DISMISSED_STORAGE_KEY = 'sidebar_announcements_dismissed'; + +interface UseSidebarAnnouncements { + items: AnnouncementItem[]; + dismiss: (id: string) => void; + // True once the persisted dismissal list has hydrated — gate rendering on + // this so dismissed cards never flash on load. + isReady: boolean; +} + +export const useSidebarAnnouncements = (): UseSidebarAnnouncements => { + const [dismissed, setDismissed, loaded] = usePersistentState( + DISMISSED_STORAGE_KEY, + [], + ); + + const items = useMemo(() => { + if (!loaded) { + return []; + } + + const dismissedSet = new Set(dismissed); + return SIDEBAR_ANNOUNCEMENTS.filter((item) => !dismissedSet.has(item.id)); + }, [dismissed, loaded]); + + const dismiss = (id: string): void => { + if (dismissed.includes(id)) { + return; + } + + setDismissed([...dismissed, id]); + }; + + return { items, dismiss, isReady: loaded }; +}; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 855c8b20012..fb239fe41f6 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -216,6 +216,11 @@ export const featureHijackingVariants = new Feature( export const featureLayoutV2 = new Feature('layout_v2', false); +export const featureSidebarAnnouncements = new Feature( + 'sidebar_announcements', + false, +); + export const featureEngagementBarV2 = new Feature('engagement_bar_v2', false); export const featureHeroCards = new Feature('hero_cards', { diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index da9420fe1b7..4dd93b555c9 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -257,6 +257,10 @@ export enum LogEvent { // Tags - end // marketing CTA MarketingCtaDismiss = 'dismiss promotion', + // sidebar announcements ("what's new") + ImpressionAnnouncement = 'impression announcement', + ClickAnnouncement = 'click announcement', + DismissAnnouncement = 'dismiss announcement', // Reading reminder ScheduleReadingReminder = 'schedule reading reminder', SkipReadingReminder = 'skip reading reminder', diff --git a/packages/storybook/stories/components/announcements/AnnouncementCard.stories.tsx b/packages/storybook/stories/components/announcements/AnnouncementCard.stories.tsx new file mode 100644 index 00000000000..48ac9158884 --- /dev/null +++ b/packages/storybook/stories/components/announcements/AnnouncementCard.stories.tsx @@ -0,0 +1,131 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { AnnouncementCard } from '@dailydotdev/shared/src/components/announcements/AnnouncementCard'; +import { AnnouncementCardVariant } from '@dailydotdev/shared/src/components/announcements/types'; +import { + HotIcon, + MegaphoneIcon, + SparkleIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { cloudinarySquadsDirectoryCardBannerDefault } from '@dailydotdev/shared/src/lib/image'; + +// The card is built for the ~240px expanded sidebar — frame the single-variant +// stories at that width so spacing/truncation read true. +const framed: NonNullable = (Story) => ( +
+ +
+); + +const meta: Meta = { + title: 'Components/Announcements/Card', + component: AnnouncementCard, + parameters: { + controls: { expanded: true }, + backgrounds: { default: 'dark' }, + }, + argTypes: { + variant: { + control: 'select', + options: Object.values(AnnouncementCardVariant), + }, + title: { control: 'text' }, + description: { control: 'text' }, + }, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +export const Compact: Story = { + decorators: [framed], + args: { + variant: AnnouncementCardVariant.Compact, + icon: , + title: 'Keyboard shortcuts are here', + href: '#', + }, +}; + +export const CompactWithSubtitle: Story = { + decorators: [framed], + args: { + variant: AnnouncementCardVariant.Compact, + icon: , + title: 'Catch up on everything new', + description: 'Browse the full changelog', + href: '#', + }, +}; + +export const Default: Story = { + decorators: [framed], + args: { + variant: AnnouncementCardVariant.Default, + icon: , + badge: { label: 'Updated' }, + title: 'Smarter custom feeds', + description: + 'Custom feeds now learn from what you read to surface more of what matters.', + cta: { text: 'See what changed', href: '#' }, + onClose: () => undefined, + }, +}; + +export const Cover: Story = { + decorators: [framed], + args: { + variant: AnnouncementCardVariant.Cover, + image: cloudinarySquadsDirectoryCardBannerDefault, + badge: { label: 'New' }, + title: 'Introducing Clips', + description: + 'Save the best moments from any post and share them with your network in one tap.', + cta: { text: 'Try Clips', href: '#' }, + onClose: () => undefined, + }, +}; + +export const WithoutDismiss: Story = { + name: 'Default · no dismiss', + decorators: [framed], + args: { + ...Default.args, + onClose: undefined, + }, +}; + +const allVariants = [ + { + label: 'Compact', + props: Compact.args, + }, + { + label: 'Default', + props: Default.args, + }, + { + label: 'Cover', + props: Cover.args, + }, +]; + +export const AllVariants: StoryObj = { + parameters: { controls: { disable: true } }, + render: () => ( +
+ {allVariants.map(({ label, props }) => ( +
+ + {label} + + {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + +
+ ))} +
+ ), +}; diff --git a/packages/storybook/stories/components/announcements/AnnouncementCarousel.stories.tsx b/packages/storybook/stories/components/announcements/AnnouncementCarousel.stories.tsx new file mode 100644 index 00000000000..4bae5197fb9 --- /dev/null +++ b/packages/storybook/stories/components/announcements/AnnouncementCarousel.stories.tsx @@ -0,0 +1,99 @@ +import React, { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { AnnouncementCarousel } from '@dailydotdev/shared/src/components/announcements/AnnouncementCarousel'; +import { AnnouncementCardVariant } from '@dailydotdev/shared/src/components/announcements/types'; +import type { AnnouncementItem } from '@dailydotdev/shared/src/components/announcements/types'; +import { + HotIcon, + MegaphoneIcon, + SparkleIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { cloudinarySquadsDirectoryCardBannerDefault } from '@dailydotdev/shared/src/lib/image'; + +const sampleItems: AnnouncementItem[] = [ + { + id: 'cover', + variant: AnnouncementCardVariant.Cover, + image: cloudinarySquadsDirectoryCardBannerDefault, + badge: { label: 'New' }, + title: 'Introducing Clips', + description: + 'Save the best moments from any post and share them in one tap.', + cta: { text: 'Try Clips', href: '#' }, + }, + { + id: 'default', + variant: AnnouncementCardVariant.Default, + icon: , + badge: { label: 'Updated' }, + title: 'Smarter custom feeds', + description: + 'Custom feeds now learn from what you read to surface more of what matters.', + cta: { text: 'See what changed', href: '#' }, + }, + { + id: 'compact-1', + variant: AnnouncementCardVariant.Compact, + icon: , + title: 'Keyboard shortcuts are here', + href: '#', + }, + { + id: 'compact-2', + variant: AnnouncementCardVariant.Compact, + icon: , + title: 'Catch up on everything new', + description: 'Browse the full changelog', + href: '#', + }, +]; + +// Local state so dismiss/advance behaves like the real sidebar hook. +const StatefulCarousel = ({ + initialItems, +}: { + initialItems: AnnouncementItem[]; +}) => { + const [items, setItems] = useState(initialItems); + + return ( +
+ setItems((prev) => prev.filter((i) => i.id !== id))} + /> + {items.length === 0 && ( +

+ All caught up — nothing left to show. +

+ )} +
+ ); +}; + +const meta: Meta = { + title: 'Components/Announcements/Carousel', + component: AnnouncementCarousel, + parameters: { + layout: 'centered', + backgrounds: { default: 'dark' }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Stack: Story = { + name: 'Stack (browse + dismiss)', + render: () => , +}; + +export const SingleItem: Story = { + render: () => , +}; + +export const Empty: Story = { + render: () => , +}; diff --git a/packages/storybook/stories/components/announcements/AnnouncementGuidelines.stories.tsx b/packages/storybook/stories/components/announcements/AnnouncementGuidelines.stories.tsx new file mode 100644 index 00000000000..ca17629e69a --- /dev/null +++ b/packages/storybook/stories/components/announcements/AnnouncementGuidelines.stories.tsx @@ -0,0 +1,320 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { AnnouncementCard } from '@dailydotdev/shared/src/components/announcements/AnnouncementCard'; +import { AnnouncementCardVariant } from '@dailydotdev/shared/src/components/announcements/types'; +import { + HotIcon, + SparkleIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { cloudinarySquadsDirectoryCardBannerDefault } from '@dailydotdev/shared/src/lib/image'; + +const meta: Meta = { + title: 'Components/Announcements/Guidelines', + parameters: { + layout: 'fullscreen', + backgrounds: { default: 'dark' }, + controls: { disable: true }, + }, +}; + +export default meta; + +const H = ({ children }: { children: ReactNode }): ReactElement => ( +

{children}

+); + +const Lead = ({ children }: { children: ReactNode }): ReactElement => ( +

{children}

+); + +const Table = ({ + head, + rows, +}: { + head: string[]; + rows: ReactNode[][]; +}): ReactElement => ( +
+ + + + {head.map((h) => ( + + ))} + + + + {rows.map((row, i) => ( + // eslint-disable-next-line react/no-array-index-key + + {row.map((cell, j) => ( + + ))} + + ))} + +
+ {h} +
+ {cell} +
+
+); + +const Code = ({ children }: { children: ReactNode }): ReactElement => ( + + {children} + +); + +const Do = ({ + ok, + children, +}: { + ok: boolean; + children: ReactNode; +}): ReactElement => ( +
  • + + {ok ? '✓' : '✕'} + + {children} +
  • +); + +export const Guidelines: StoryObj = { + render: () => ( +
    +
    +

    Announcement cards

    + + A Linear-style “what’s new” surface for the bottom of the sidebar. + Use it to prominently announce a new feature or release and drive one + clear action. Cards stack into a browsable carousel, carry an + optional “New/Beta” badge, and are dismissible — once dismissed, a + card stays gone (persisted client-side by id). Keep the list short + and high-signal: show fewer, better entries. + + + The three tiers + + Match the tier to the weight of the update. Minor → Compact, standard + release → Default, headline launch → Cover. + +
    + {[ + { + label: 'Compact', + note: 'Minor update / pointer. Whole row links out.', + card: ( + } + title="Keyboard shortcuts are here" + href="#" + /> + ), + }, + { + label: 'Default', + note: 'Standard release. Badge + body + one CTA.', + card: ( + } + badge={{ label: 'Updated' }} + title="Smarter custom feeds" + description="Custom feeds now learn from what you read to surface more of what matters." + cta={{ text: 'See what changed', href: '#' }} + onClose={() => undefined} + /> + ), + }, + { + label: 'Cover', + note: 'Headline launch. Adds a cover image on top.', + card: ( + undefined} + /> + ), + }, + ].map(({ label, note, card }) => ( +
    + + {label} + + {card} + {note} +
    + ))} +
    + + When to use which + Compact, + 'Small improvements, settings pointers, “catch up” links.', + 'Leading icon · title (1 line, truncates) · optional subtitle · trailing arrow.', + ], + [ + Default, + 'A normal feature/release worth a sentence and a CTA.', + 'Badge (+icon) · title · 2-line body · primary CTA · dismiss ×.', + ], + [ + Cover, + 'Flagship launches that deserve a visual.', + 'Cover image (with overlaid dismiss ×) · badge · title · body · CTA.', + ], + ]} + /> + + Specs +
    + Built for the expanded sidebar (w-60); fills its + container. + , + ], + [ + 'Corner radius', + 'Compact 12px · Default/Cover 16px', + <> + rounded-12 / rounded-16 + , + ], + [ + 'Padding', + '12px', + <> + p-3 (Cover body p-3, image flush to + edges) + , + ], + [ + 'Internal gap', + '8px between blocks · 12px row gap (Compact)', + <> + gap-2 / gap-3 + , + ], + [ + 'Cover image', + 'height 112px, object-cover', + <> + h-28 w-full object-cover + , + ], + [ + 'Surface', + 'Floating surface + subtle border', + <> + bg-surface-float +{' '} + border-border-subtlest-tertiary, hover{' '} + border-border-subtlest-secondary + , + ], + [ + 'Icon size', + '24px', + <> + IconSize.Small (arrow{' '} + IconSize.Size16) + , + ], + ]} + /> + + Typography & color +
    typo-callout, + text-text-primary, + ], + [ + 'Title (Compact)', + typo-footnote · bold, + text-text-primary, + ], + [ + 'Body / description', + typo-footnote · line-clamp-2, + text-text-tertiary, + ], + [ + 'Badge', + Pill · XSmall (typo-caption2), + bg-accent-cabbage-subtlest · text-brand-default, + ], + [ + 'Carousel counter', + typo-caption2, + text-text-tertiary, + ], + ]} + /> + + Buttons & dismissal +
      + + One primary CTA per card — ButtonVariant.Primary,{' '} + ButtonSize.Small, left-aligned under the body. + + + Dismiss is a CloseButton (XSmall) top-right. + Over a cover image use ButtonVariant.Primary (Float is + invisible on imagery); on the solid Default card the Tertiary + default is fine. + + + Persist dismissal by id so a closed card never returns + (handled by useSidebarAnnouncements). + + + Don’t stack multiple CTAs or add a separate text “Dismiss” button. + + + Don’t make a Compact row individually dismissible — it’s a single + link target (nested buttons are invalid); reserve × for + Default/Cover. + +
    + + Do & don’t +
      + Keep titles to one line; let the body carry detail (2 lines max). + Show 1–4 curated entries, newest first. + Hide the whole surface when the sidebar is collapsed or empty. + Don’t use a Cover image just to fill space — only for true headline launches. + Don’t leave an empty container when there’s nothing to show (render nothing). +
    + + + ), +}; From ea8cf14be155c6cb9348a79450fdf6acd6d4b7b8 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 16 Jun 2026 16:04:00 +0300 Subject: [PATCH 02/20] fix(announcements): cleaner badge and less dominant card surface The badge used cabbage (brand purple) subtlest bg with brand-default text, which rendered as low-contrast purple-on-purple. Card used a filled surface-float that read too heavy in the sidebar. - Badge: neutral, readable label (bg-surface-float + text-text-secondary) - Card: transparent with a hairline border; interactive (compact) variant gets a subtle hover fill instead of a permanent one - Update Guidelines spec tables to match Co-Authored-By: Claude Opus 4.8 --- .../src/components/announcements/AnnouncementCard.tsx | 8 +++++--- .../announcements/AnnouncementGuidelines.stories.tsx | 6 +++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/components/announcements/AnnouncementCard.tsx b/packages/shared/src/components/announcements/AnnouncementCard.tsx index a682153ad0b..f3930eea1bd 100644 --- a/packages/shared/src/components/announcements/AnnouncementCard.tsx +++ b/packages/shared/src/components/announcements/AnnouncementCard.tsx @@ -33,8 +33,10 @@ export interface AnnouncementCardProps { className?: string; } +// Clean, low-dominance surface: a hairline border over the sidebar background +// rather than a filled card. Interactive variants add a subtle hover fill. const cardBaseClasses = - 'border border-border-subtlest-tertiary bg-surface-float transition-colors'; + 'border border-border-subtlest-tertiary transition-colors'; const renderBadge = (badge?: AnnouncementBadge): ReactElement | null => { if (!badge) { @@ -47,7 +49,7 @@ const renderBadge = (badge?: AnnouncementBadge): ReactElement | null => { size={PillSize.XSmall} className={classNames( 'uppercase tracking-wide', - badge.className ?? 'bg-accent-cabbage-subtlest text-brand-default', + badge.className ?? 'bg-surface-float text-text-secondary', )} /> ); @@ -96,7 +98,7 @@ export function AnnouncementCard({ className={classNames( 'focus-outline group flex w-full items-center gap-3 rounded-12 p-3 text-left', cardBaseClasses, - 'hover:border-border-subtlest-secondary', + 'hover:border-border-subtlest-secondary hover:bg-surface-float', className, )} > diff --git a/packages/storybook/stories/components/announcements/AnnouncementGuidelines.stories.tsx b/packages/storybook/stories/components/announcements/AnnouncementGuidelines.stories.tsx index ca17629e69a..0eb25a3c0b0 100644 --- a/packages/storybook/stories/components/announcements/AnnouncementGuidelines.stories.tsx +++ b/packages/storybook/stories/components/announcements/AnnouncementGuidelines.stories.tsx @@ -230,10 +230,10 @@ export const Guidelines: StoryObj = { ], [ 'Surface', - 'Floating surface + subtle border', + 'Transparent + hairline border (no fill)', <> + border-border-subtlest-tertiary; interactive hover{' '} bg-surface-float +{' '} - border-border-subtlest-tertiary, hover{' '} border-border-subtlest-secondary , ], @@ -270,7 +270,7 @@ export const Guidelines: StoryObj = { [ 'Badge', Pill · XSmall (typo-caption2), - bg-accent-cabbage-subtlest · text-brand-default, + bg-surface-float · text-text-secondary, ], [ 'Carousel counter', From d78c62fd0012da2877e983eabf57202d0cd4222a Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 16 Jun 2026 16:16:08 +0300 Subject: [PATCH 03/20] fix(announcements): vertically center the badge in the header row Pill defaults to align-self:flex-start, top-aligning the 16px badge in the 24px icon row. Set alignment="self-center" so it sits centered. Co-Authored-By: Claude Opus 4.8 --- .../shared/src/components/announcements/AnnouncementCard.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/shared/src/components/announcements/AnnouncementCard.tsx b/packages/shared/src/components/announcements/AnnouncementCard.tsx index f3930eea1bd..e9d6e5d54ef 100644 --- a/packages/shared/src/components/announcements/AnnouncementCard.tsx +++ b/packages/shared/src/components/announcements/AnnouncementCard.tsx @@ -47,6 +47,7 @@ const renderBadge = (badge?: AnnouncementBadge): ReactElement | null => { Date: Tue, 16 Jun 2026 16:20:03 +0300 Subject: [PATCH 04/20] fix(announcements): frosted glass close button on cover image Replace the solid Primary close over the cover image with a translucent, backdrop-blurred circular button (bg-overlay-secondary-pepper + backdrop-blur-md + subtle white border), matching the floating-control idiom in ArticleFeaturedWideGridCard. Reads cleaner over imagery than a solid fill. Co-Authored-By: Claude Opus 4.8 --- .../announcements/AnnouncementCard.tsx | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/shared/src/components/announcements/AnnouncementCard.tsx b/packages/shared/src/components/announcements/AnnouncementCard.tsx index e9d6e5d54ef..29c012d92cf 100644 --- a/packages/shared/src/components/announcements/AnnouncementCard.tsx +++ b/packages/shared/src/components/announcements/AnnouncementCard.tsx @@ -3,7 +3,7 @@ import React from 'react'; import classNames from 'classnames'; import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; import CloseButton from '../CloseButton'; -import { ArrowIcon } from '../icons'; +import { ArrowIcon, MiniCloseIcon } from '../icons'; import { IconSize } from '../Icon'; import { Image } from '../image/Image'; import { Pill, PillSize } from '../Pill'; @@ -186,14 +186,18 @@ export function AnnouncementCard({ )} {onClose && ( - // Float is invisible over imagery — use a solid Primary close. - + className="focus-outline absolute right-2 top-2 z-1 flex size-7 items-center justify-center rounded-full border border-white/24 bg-overlay-secondary-pepper text-white backdrop-blur-md transition-colors hover:bg-overlay-primary-pepper" + > + + )}
    {renderBody(false)}
    From 51fd0fcf9be896815ba0517357d73791981434a8 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 16 Jun 2026 23:10:45 +0300 Subject: [PATCH 05/20] fix(announcements): square the cover close button to rounded-10 Match the design-system corner radius (Small icon button = rounded-10) instead of a fully circular close, keeping the frosted-glass treatment. Co-Authored-By: Claude Opus 4.8 --- .../shared/src/components/announcements/AnnouncementCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/components/announcements/AnnouncementCard.tsx b/packages/shared/src/components/announcements/AnnouncementCard.tsx index 29c012d92cf..ab0db29d845 100644 --- a/packages/shared/src/components/announcements/AnnouncementCard.tsx +++ b/packages/shared/src/components/announcements/AnnouncementCard.tsx @@ -194,7 +194,7 @@ export function AnnouncementCard({ aria-label="Dismiss" title="Close" onClick={onClose} - className="focus-outline absolute right-2 top-2 z-1 flex size-7 items-center justify-center rounded-full border border-white/24 bg-overlay-secondary-pepper text-white backdrop-blur-md transition-colors hover:bg-overlay-primary-pepper" + className="focus-outline absolute right-2 top-2 z-1 flex size-7 items-center justify-center rounded-10 border border-white/24 bg-overlay-secondary-pepper text-white backdrop-blur-md transition-colors hover:bg-overlay-primary-pepper" > From d2ac12252dfa50f9579b80f68bb5e82f90b5b08a Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 16 Jun 2026 23:20:10 +0300 Subject: [PATCH 06/20] fix(announcements): visible badge chip, left-aligned with the title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ~8% surface-float badge fill was nearly invisible, and in the Default variant the leading icon pushed the badge right of the title, so the label read as misaligned floating text. - Badge: idiomatic flat-accent chip (bg-accent-cabbage-flat + text-brand-default) — a visible, theme-safe brand tint with readable text - Default/Cover now lead with the badge (flush-left, aligned with the title); the inline leading icon is dropped for these variants (icon stays Compact-only) - Drop the now-unused icon props/imports from content + stories; update Guidelines Co-Authored-By: Claude Opus 4.8 --- .../announcements/AnnouncementCard.tsx | 18 ++++++------------ .../src/components/announcements/content.tsx | 3 +-- .../announcements/AnnouncementCard.stories.tsx | 2 -- .../AnnouncementCarousel.stories.tsx | 2 -- .../AnnouncementGuidelines.stories.tsx | 10 +++------- 5 files changed, 10 insertions(+), 25 deletions(-) diff --git a/packages/shared/src/components/announcements/AnnouncementCard.tsx b/packages/shared/src/components/announcements/AnnouncementCard.tsx index ab0db29d845..f6a5c865b7a 100644 --- a/packages/shared/src/components/announcements/AnnouncementCard.tsx +++ b/packages/shared/src/components/announcements/AnnouncementCard.tsx @@ -20,6 +20,7 @@ export interface AnnouncementCardProps { title: string; description?: string; badge?: AnnouncementBadge; + // Leading icon for the Compact variant (Default/Cover lead with the badge). icon?: ReactNode; // Cover image, only rendered by the Cover variant. image?: string; @@ -47,10 +48,12 @@ const renderBadge = (badge?: AnnouncementBadge): ReactElement | null => { ); @@ -142,16 +145,7 @@ export function AnnouncementCard({
    - {(icon || badge) && ( -
    - {icon && ( - - {icon} - - )} - {renderBadge(badge)} -
    - )} + {renderBadge(badge)} , badge: { label: 'Updated' }, title: 'Smarter custom feeds', description: diff --git a/packages/storybook/stories/components/announcements/AnnouncementCard.stories.tsx b/packages/storybook/stories/components/announcements/AnnouncementCard.stories.tsx index 48ac9158884..ce40315f8e3 100644 --- a/packages/storybook/stories/components/announcements/AnnouncementCard.stories.tsx +++ b/packages/storybook/stories/components/announcements/AnnouncementCard.stories.tsx @@ -5,7 +5,6 @@ import { AnnouncementCardVariant } from '@dailydotdev/shared/src/components/anno import { HotIcon, MegaphoneIcon, - SparkleIcon, } from '@dailydotdev/shared/src/components/icons'; import { IconSize } from '@dailydotdev/shared/src/components/Icon'; import { cloudinarySquadsDirectoryCardBannerDefault } from '@dailydotdev/shared/src/lib/image'; @@ -65,7 +64,6 @@ export const Default: Story = { decorators: [framed], args: { variant: AnnouncementCardVariant.Default, - icon: , badge: { label: 'Updated' }, title: 'Smarter custom feeds', description: diff --git a/packages/storybook/stories/components/announcements/AnnouncementCarousel.stories.tsx b/packages/storybook/stories/components/announcements/AnnouncementCarousel.stories.tsx index 4bae5197fb9..269b00695ef 100644 --- a/packages/storybook/stories/components/announcements/AnnouncementCarousel.stories.tsx +++ b/packages/storybook/stories/components/announcements/AnnouncementCarousel.stories.tsx @@ -6,7 +6,6 @@ import type { AnnouncementItem } from '@dailydotdev/shared/src/components/announ import { HotIcon, MegaphoneIcon, - SparkleIcon, } from '@dailydotdev/shared/src/components/icons'; import { IconSize } from '@dailydotdev/shared/src/components/Icon'; import { cloudinarySquadsDirectoryCardBannerDefault } from '@dailydotdev/shared/src/lib/image'; @@ -25,7 +24,6 @@ const sampleItems: AnnouncementItem[] = [ { id: 'default', variant: AnnouncementCardVariant.Default, - icon: , badge: { label: 'Updated' }, title: 'Smarter custom feeds', description: diff --git a/packages/storybook/stories/components/announcements/AnnouncementGuidelines.stories.tsx b/packages/storybook/stories/components/announcements/AnnouncementGuidelines.stories.tsx index 0eb25a3c0b0..7d41ef21da4 100644 --- a/packages/storybook/stories/components/announcements/AnnouncementGuidelines.stories.tsx +++ b/packages/storybook/stories/components/announcements/AnnouncementGuidelines.stories.tsx @@ -3,10 +3,7 @@ import React from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; import { AnnouncementCard } from '@dailydotdev/shared/src/components/announcements/AnnouncementCard'; import { AnnouncementCardVariant } from '@dailydotdev/shared/src/components/announcements/types'; -import { - HotIcon, - SparkleIcon, -} from '@dailydotdev/shared/src/components/icons'; +import { HotIcon } from '@dailydotdev/shared/src/components/icons'; import { IconSize } from '@dailydotdev/shared/src/components/Icon'; import { cloudinarySquadsDirectoryCardBannerDefault } from '@dailydotdev/shared/src/lib/image'; @@ -130,7 +127,6 @@ export const Guidelines: StoryObj = { card: ( } badge={{ label: 'Updated' }} title="Smarter custom feeds" description="Custom feeds now learn from what you read to surface more of what matters." @@ -177,7 +173,7 @@ export const Guidelines: StoryObj = { [ Default, 'A normal feature/release worth a sentence and a CTA.', - 'Badge (+icon) · title · 2-line body · primary CTA · dismiss ×.', + 'Badge · title · 2-line body · primary CTA · dismiss ×.', ], [ Cover, @@ -270,7 +266,7 @@ export const Guidelines: StoryObj = { [ 'Badge', Pill · XSmall (typo-caption2), - bg-surface-float · text-text-secondary, + bg-accent-cabbage-flat · text-brand-default, ], [ 'Carousel counter', From 392c98d7a9b06e9c3096da75dc9e212c537b927b Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 16 Jun 2026 23:28:36 +0300 Subject: [PATCH 07/20] feat(announcements): stacked carousel motion + interactive playground MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Polish the carousel into a proper "what's new" stack and add a playground to explore every interaction. - Stacked-card cue: edges of the cards behind peek under the active card (1–2 layers, shrinking as the stack empties) - Switching animates with a keyed enter (fade-slide-up via animate-composer-in) - Dismiss plays an exit (fade + slide/scale) then the next card slides into place and fires its impression - Respects prefers-reduced-motion (motion-safe enter; exit delay skipped) - New Components/Announcements/Playground: stateful harness with switch / dismiss / add / reset / dismiss-all, empty state, and a live event log Co-Authored-By: Claude Opus 4.8 --- .../announcements/AnnouncementCarousel.tsx | 118 +++++--- .../AnnouncementPlayground.stories.tsx | 272 ++++++++++++++++++ 2 files changed, 354 insertions(+), 36 deletions(-) create mode 100644 packages/storybook/stories/components/announcements/AnnouncementPlayground.stories.tsx diff --git a/packages/shared/src/components/announcements/AnnouncementCarousel.tsx b/packages/shared/src/components/announcements/AnnouncementCarousel.tsx index 751aea6f152..638e20ecb48 100644 --- a/packages/shared/src/components/announcements/AnnouncementCarousel.tsx +++ b/packages/shared/src/components/announcements/AnnouncementCarousel.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import classNames from 'classnames'; import { AnnouncementCard } from './AnnouncementCard'; import { AnnouncementCardVariant } from './types'; @@ -7,11 +7,13 @@ import type { AnnouncementItem } from './types'; import CarouselIndicator from '../containers/CarouselIndicator'; import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; import { ArrowIcon } from '../icons'; -import { - Typography, - TypographyColor, - TypographyType, -} from '../typography/Typography'; + +const EXIT_MS = 200; + +const prefersReducedMotion = (): boolean => + typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches; export interface AnnouncementCarouselProps { items: AnnouncementItem[]; @@ -31,6 +33,9 @@ export function AnnouncementCarousel({ className, }: AnnouncementCarouselProps): ReactElement | null { const [active, setActive] = useState(0); + const [exitingId, setExitingId] = useState(null); + const exitTimer = useRef>(); + const count = items.length; // Items shrink as cards are dismissed; keep the index in range. const safeActive = Math.min(active, Math.max(count - 1, 0)); @@ -40,48 +45,91 @@ export function AnnouncementCarousel({ if (current) { onView?.(current); } - // Re-run when the visible card changes. + // Re-run only when the visible card changes. // eslint-disable-next-line react-hooks/exhaustive-deps }, [current?.id]); + useEffect(() => () => clearTimeout(exitTimer.current), []); + if (!current) { return null; } + const goTo = (index: number): void => { + setActive(Math.max(0, Math.min(count - 1, index))); + }; + + const handleDismiss = (id: string): void => { + // Animate the card out, then let the parent remove it so the next card + // slides into its place. Skip the delay when motion is reduced. + if (prefersReducedMotion()) { + onDismiss(id); + return; + } + setExitingId(id); + exitTimer.current = setTimeout(() => { + setExitingId(null); + onDismiss(id); + }, EXIT_MS); + }; + const hasMultiple = count > 1; + const isExiting = exitingId === current.id; + // Decorative "cards behind" edges peeking below the active card. + const peekLayers = Math.min(count - 1, 2); return ( -
    - onItemClick(current) - : undefined - } - onClose={() => onDismiss(current.id)} - /> +
    +
    +
    + onItemClick(current) + : undefined + } + onClose={() => handleDismiss(current.id)} + /> +
    + {peekLayers > 0 && ( +
    +
    + {peekLayers > 1 && ( +
    + )} +
    + )} +
    + {hasMultiple && (
    - - {safeActive + 1} of {count} -
    )} -
    +
    ); } diff --git a/packages/storybook/stories/components/announcements/AnnouncementPlayground.stories.tsx b/packages/storybook/stories/components/announcements/AnnouncementPlayground.stories.tsx new file mode 100644 index 00000000000..f59e74b420b --- /dev/null +++ b/packages/storybook/stories/components/announcements/AnnouncementPlayground.stories.tsx @@ -0,0 +1,272 @@ +import type { ReactElement } from 'react'; +import React, { useMemo, useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { AnnouncementCarousel } from '@dailydotdev/shared/src/components/announcements/AnnouncementCarousel'; +import { AnnouncementCardVariant } from '@dailydotdev/shared/src/components/announcements/types'; +import type { AnnouncementItem } from '@dailydotdev/shared/src/components/announcements/types'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + BellIcon, + HotIcon, + MagicIcon, + MegaphoneIcon, + SparkleIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { + Typography, + TypographyColor, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { cloudinarySquadsDirectoryCardBannerDefault } from '@dailydotdev/shared/src/lib/image'; + +const seed = (): AnnouncementItem[] => [ + { + id: 'clips', + variant: AnnouncementCardVariant.Cover, + image: cloudinarySquadsDirectoryCardBannerDefault, + badge: { label: 'New' }, + title: 'Introducing Clips', + description: + 'Save the best moments from any post and share them in one tap.', + cta: { text: 'Try Clips', href: '#' }, + }, + { + id: 'custom-feeds', + variant: AnnouncementCardVariant.Default, + badge: { label: 'Updated' }, + title: 'Smarter custom feeds', + description: + 'Custom feeds now learn from what you read to surface more of what matters.', + cta: { text: 'See what changed', href: '#' }, + }, + { + id: 'shortcuts', + variant: AnnouncementCardVariant.Compact, + icon: , + title: 'Keyboard shortcuts are here', + href: '#', + }, + { + id: 'digest', + variant: AnnouncementCardVariant.Default, + badge: { label: 'Beta' }, + title: 'Your weekly digest', + description: 'A personalized briefing of the best posts, every Monday.', + cta: { text: 'Enable digest', href: '#' }, + }, + { + id: 'changelog', + variant: AnnouncementCardVariant.Compact, + icon: , + title: 'Catch up on everything new', + description: 'Browse the full changelog', + href: '#', + }, +]; + +const extras: AnnouncementItem[] = [ + { + id: 'extra-magic', + variant: AnnouncementCardVariant.Default, + badge: { label: 'New' }, + icon: , + title: 'AI-powered summaries', + description: 'Get the gist of any long read in two lines.', + cta: { text: 'Try it', href: '#' }, + }, + { + id: 'extra-bell', + variant: AnnouncementCardVariant.Compact, + icon: , + title: 'Smarter notifications', + href: '#', + }, + { + id: 'extra-sparkle', + variant: AnnouncementCardVariant.Cover, + image: cloudinarySquadsDirectoryCardBannerDefault, + badge: { label: 'New' }, + icon: , + title: 'A fresh reading theme', + description: 'Easier on the eyes during those late-night sessions.', + cta: { text: 'Preview', href: '#' }, + }, +]; + +const Chip = ({ children }: { children: string }): ReactElement => ( + + {children} + +); + +const Playground = (): ReactElement => { + const [items, setItems] = useState(seed); + const [log, setLog] = useState([]); + const [extraIndex, setExtraIndex] = useState(0); + + const pushLog = (line: string): void => + setLog((prev) => [line, ...prev].slice(0, 8)); + + const remaining = items.length; + const addOne = (): void => { + const next = extras[extraIndex % extras.length]; + const id = `${next.id}-${extraIndex}`; + setItems((prev) => [...prev, { ...next, id }]); + setExtraIndex((i) => i + 1); + pushLog(`added · ${id}`); + }; + + const sidebarBg = useMemo( + () => + 'flex w-[17rem] flex-col rounded-16 border border-border-subtlest-tertiary bg-background-default p-3', + [], + ); + + return ( +
    + {/* Sidebar-like panel hosting the real carousel */} +
    + + Sidebar panel ({remaining} announcement{remaining === 1 ? '' : 's'}) + +
    +
    + + What's new + +
    + {remaining > 0 ? ( + pushLog(`viewed · ${item.id}`)} + onItemClick={(item) => pushLog(`clicked · ${item.id}`)} + onDismiss={(id) => { + pushLog(`dismissed · ${id}`); + setItems((prev) => prev.filter((i) => i.id !== id)); + }} + /> + ) : ( +
    + + You're all caught up 🎉 + + +
    + )} +
    +
    + + {/* Controls + how-to + event log */} +
    +
    +

    Try the interactions

    +
      +
    • + • Switch: click the dots, the{' '} + arrows, or press{' '} + (focus a control first). +
    • +
    • + • Dismiss: hit the — the card animates out + and the next one slides up into its place. +
    • +
    • + • Stack: the edges peeking under the card show there are + more behind; they shrink as the stack empties. +
    • +
    • + • Empty: dismiss them all to see the caught-up state, then + restore. +
    • +
    • + • Motion: enable “Reduce motion” in your OS — enter/exit + animations are skipped automatically. +
    • +
    +
    + +
    + + + +
    + +
    + + Event log + +
    + {log.length === 0 ? ( + + interactions will appear here… + + ) : ( + log.map((line, i) => ( + // eslint-disable-next-line react/no-array-index-key + {line} + )) + )} +
    +
    +
    +
    + ); +}; + +const meta: Meta = { + title: 'Components/Announcements/Playground', + parameters: { + layout: 'fullscreen', + backgrounds: { default: 'dark' }, + controls: { disable: true }, + }, +}; + +export default meta; + +export const Interactive: StoryObj = { + render: () => , +}; From 2f7db61984b009ccfc0f9d7dc53e221848f3af5b Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 16 Jun 2026 23:41:03 +0300 Subject: [PATCH 08/20] fix(announcements): cleaner notification stack + smaller label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback on the stack and badge. - Stack: one single, centered card peeking behind (was two misaligned bars), dimmed so it reads as recessed; the active card sits on a defined bg-background-subtle surface with a shadow-2 for the 3D "lift" off the card behind. Dismissing reveals the card behind before the next slides in. - Label: small flush-left brand-colored text label (typo-caption2) instead of the filled chip — visible but no longer competing with the title. - Update Guidelines spec (surface + badge tokens). Co-Authored-By: Claude Opus 4.8 --- .../announcements/AnnouncementCard.tsx | 26 +++++++++---------- .../announcements/AnnouncementCarousel.tsx | 24 +++++++---------- .../AnnouncementGuidelines.stories.tsx | 12 ++++----- 3 files changed, 28 insertions(+), 34 deletions(-) diff --git a/packages/shared/src/components/announcements/AnnouncementCard.tsx b/packages/shared/src/components/announcements/AnnouncementCard.tsx index f6a5c865b7a..3707fd10fc4 100644 --- a/packages/shared/src/components/announcements/AnnouncementCard.tsx +++ b/packages/shared/src/components/announcements/AnnouncementCard.tsx @@ -6,7 +6,6 @@ import CloseButton from '../CloseButton'; import { ArrowIcon, MiniCloseIcon } from '../icons'; import { IconSize } from '../Icon'; import { Image } from '../image/Image'; -import { Pill, PillSize } from '../Pill'; import { Typography, TypographyColor, @@ -34,28 +33,27 @@ export interface AnnouncementCardProps { className?: string; } -// Clean, low-dominance surface: a hairline border over the sidebar background -// rather than a filled card. Interactive variants add a subtle hover fill. +// Subtle, defined surface (the canonical card background) so cards read as a +// clean stack and a card behind is properly occluded. const cardBaseClasses = - 'border border-border-subtlest-tertiary transition-colors'; + 'border border-border-subtlest-tertiary bg-background-subtle transition-colors'; +// Small, flush-left brand label — visible via the brand color but kept light so +// it never competes with the title (no filled chip). const renderBadge = (badge?: AnnouncementBadge): ReactElement | null => { if (!badge) { return null; } return ( - + > + {badge.label} + ); }; @@ -102,7 +100,7 @@ export function AnnouncementCard({ className={classNames( 'focus-outline group flex w-full items-center gap-3 rounded-12 p-3 text-left', cardBaseClasses, - 'hover:border-border-subtlest-secondary hover:bg-surface-float', + 'hover:border-border-subtlest-secondary', className, )} > diff --git a/packages/shared/src/components/announcements/AnnouncementCarousel.tsx b/packages/shared/src/components/announcements/AnnouncementCarousel.tsx index 638e20ecb48..71b89ff0837 100644 --- a/packages/shared/src/components/announcements/AnnouncementCarousel.tsx +++ b/packages/shared/src/components/announcements/AnnouncementCarousel.tsx @@ -75,8 +75,6 @@ export function AnnouncementCarousel({ const hasMultiple = count > 1; const isExiting = exitingId === current.id; - // Decorative "cards behind" edges peeking below the active card. - const peekLayers = Math.min(count - 1, 2); return (
    -
    +
    + {hasMultiple && ( + // A single card peeking behind, occluded by the active card so only + // its bottom edge shows — like a notification stack. +
    + )}
    handleDismiss(current.id)} + className={hasMultiple ? 'shadow-2' : undefined} />
    - {peekLayers > 0 && ( -
    -
    - {peekLayers > 1 && ( -
    - )} -
    - )}
    {hasMultiple && ( diff --git a/packages/storybook/stories/components/announcements/AnnouncementGuidelines.stories.tsx b/packages/storybook/stories/components/announcements/AnnouncementGuidelines.stories.tsx index 7d41ef21da4..3b7aacd615f 100644 --- a/packages/storybook/stories/components/announcements/AnnouncementGuidelines.stories.tsx +++ b/packages/storybook/stories/components/announcements/AnnouncementGuidelines.stories.tsx @@ -226,11 +226,11 @@ export const Guidelines: StoryObj = { ], [ 'Surface', - 'Transparent + hairline border (no fill)', + 'Subtle card surface + hairline border', <> - border-border-subtlest-tertiary; interactive hover{' '} - bg-surface-float +{' '} - border-border-subtlest-secondary + bg-background-subtle +{' '} + border-border-subtlest-tertiary; when stacked, a{' '} + shadow-2 lifts it over the card behind , ], [ @@ -265,8 +265,8 @@ export const Guidelines: StoryObj = { ], [ 'Badge', - Pill · XSmall (typo-caption2), - bg-accent-cabbage-flat · text-brand-default, + typo-caption2 · bold · uppercase, + text-brand-default (text label, no chip), ], [ 'Carousel counter', From fbf4a1cff85b9f3c6acd1fe774342cd29cbf888a Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 16 Jun 2026 23:53:01 +0300 Subject: [PATCH 09/20] fix(announcements): soft inner-bottom gradient + lighter outer shadow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add a subtle white inner gradient hugging the card's bottom edge (from-white/8 → transparent) for a soft 3D "lip" that's visible on the dark sidebar where a dark inner shadow wouldn't read - Soften the stacked outer shadow to a small, transparent drop (0 4px 12px -6px rgba(0,0,0,.25)) instead of shadow-2 Co-Authored-By: Claude Opus 4.8 --- .../src/components/announcements/AnnouncementCard.tsx | 11 +++++++++++ .../components/announcements/AnnouncementCarousel.tsx | 6 +++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/components/announcements/AnnouncementCard.tsx b/packages/shared/src/components/announcements/AnnouncementCard.tsx index 3707fd10fc4..ff080b847d2 100644 --- a/packages/shared/src/components/announcements/AnnouncementCard.tsx +++ b/packages/shared/src/components/announcements/AnnouncementCard.tsx @@ -76,6 +76,15 @@ const renderCta = (cta?: AnnouncementCta): ReactElement | null => { ); }; +// Soft white inner gradient hugging the bottom edge — a subtle "lip" that reads +// as depth/3D on the dark sidebar (gracefully invisible on light surfaces). +const bottomDepth = ( + +); + export function AnnouncementCard({ variant = AnnouncementCardVariant.Default, title, @@ -193,6 +202,7 @@ export function AnnouncementCard({ )}
    {renderBody(false)}
    + {bottomDepth}
    ); } @@ -214,6 +224,7 @@ export function AnnouncementCard({ /> )} {renderBody(Boolean(onClose))} + {bottomDepth}
    ); } diff --git a/packages/shared/src/components/announcements/AnnouncementCarousel.tsx b/packages/shared/src/components/announcements/AnnouncementCarousel.tsx index 71b89ff0837..59b0b5b9821 100644 --- a/packages/shared/src/components/announcements/AnnouncementCarousel.tsx +++ b/packages/shared/src/components/announcements/AnnouncementCarousel.tsx @@ -113,7 +113,11 @@ export function AnnouncementCarousel({ : undefined } onClose={() => handleDismiss(current.id)} - className={hasMultiple ? 'shadow-2' : undefined} + className={ + hasMultiple + ? 'shadow-[0_4px_12px_-6px_rgba(0,0,0,0.25)]' + : undefined + } />
    From 6a3545e53104b837cd224675dd09be00d43a61c7 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 16 Jun 2026 23:58:07 +0300 Subject: [PATCH 10/20] feat(announcements): polished hover & micro-interactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the surface feel alive and standout, all motion-safe (reduced-motion falls back to instant): - Card: gentle hover lift + border brighten (transition-all) - Cover: image scales up softly on card hover (group/card) - Stack: hovering "opens" it — the card behind slides out and brightens (group/stack), and the active card's shadow deepens on hover - Frosted close: scale up on hover, press-down on active - Carousel dots scale on hover; prev/next arrows scale on hover and press Co-Authored-By: Claude Opus 4.8 --- .../announcements/AnnouncementCard.tsx | 18 +++++++++++------- .../announcements/AnnouncementCarousel.tsx | 14 ++++++++++---- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/packages/shared/src/components/announcements/AnnouncementCard.tsx b/packages/shared/src/components/announcements/AnnouncementCard.tsx index ff080b847d2..8cb5b0090d0 100644 --- a/packages/shared/src/components/announcements/AnnouncementCard.tsx +++ b/packages/shared/src/components/announcements/AnnouncementCard.tsx @@ -34,9 +34,10 @@ export interface AnnouncementCardProps { } // Subtle, defined surface (the canonical card background) so cards read as a -// clean stack and a card behind is properly occluded. +// clean stack and a card behind is properly occluded. Hover gently brightens +// the border and lifts the card for a tactile, alive feel. const cardBaseClasses = - 'border border-border-subtlest-tertiary bg-background-subtle transition-colors'; + 'border border-border-subtlest-tertiary bg-background-subtle transition-all duration-200 ease-out hover:border-border-subtlest-secondary motion-safe:hover:-translate-y-0.5'; // Small, flush-left brand label — visible via the brand color but kept light so // it never competes with the title (no filled chip). @@ -109,7 +110,6 @@ export function AnnouncementCard({ className={classNames( 'focus-outline group flex w-full items-center gap-3 rounded-12 p-3 text-left', cardBaseClasses, - 'hover:border-border-subtlest-secondary', className, )} > @@ -177,14 +177,18 @@ export function AnnouncementCard({ return (
    -
    +
    {image && ( - + )} {onClose && ( // Frosted glass close over imagery — translucent + backdrop blur so @@ -195,7 +199,7 @@ export function AnnouncementCard({ aria-label="Dismiss" title="Close" onClick={onClose} - className="focus-outline absolute right-2 top-2 z-1 flex size-7 items-center justify-center rounded-10 border border-white/24 bg-overlay-secondary-pepper text-white backdrop-blur-md transition-colors hover:bg-overlay-primary-pepper" + className="focus-outline absolute right-2 top-2 z-1 flex size-7 items-center justify-center rounded-10 border border-white/24 bg-overlay-secondary-pepper text-white backdrop-blur-md transition duration-200 hover:bg-overlay-primary-pepper motion-safe:hover:scale-105 motion-safe:active:scale-95" > diff --git a/packages/shared/src/components/announcements/AnnouncementCarousel.tsx b/packages/shared/src/components/announcements/AnnouncementCarousel.tsx index 59b0b5b9821..cb75acd6822 100644 --- a/packages/shared/src/components/announcements/AnnouncementCarousel.tsx +++ b/packages/shared/src/components/announcements/AnnouncementCarousel.tsx @@ -82,13 +82,14 @@ export function AnnouncementCarousel({ aria-label="What's new" className={classNames('flex flex-col gap-2', className)} > -
    +
    {hasMultiple && ( // A single card peeking behind, occluded by the active card so only - // its bottom edge shows — like a notification stack. + // its bottom edge shows — like a notification stack. On hover the + // stack "opens": the card behind slides out and brightens.
    )}
    handleDismiss(current.id)} className={ hasMultiple - ? 'shadow-[0_4px_12px_-6px_rgba(0,0,0,0.25)]' + ? 'shadow-[0_4px_12px_-6px_rgba(0,0,0,0.25)] hover:shadow-[0_10px_24px_-8px_rgba(0,0,0,0.35)]' : undefined } /> @@ -128,6 +129,9 @@ export function AnnouncementCarousel({ active={safeActive} max={count} onItemClick={goTo} + className={{ + item: 'transition-transform duration-150 ease-out hover:scale-125', + }} />
    {hasMultiple && ( -
    - -
    -
    + // Centered segmented indicator. Hovering (or focusing) a dot switches + // to that announcement; the active dot stretches into a pill and the + // fill glides between positions for a sense of motion. +
    + {items.map((item, index) => { + const isActive = index === safeActive; + return ( +
    )} diff --git a/packages/storybook/stories/components/announcements/AnnouncementPlayground.stories.tsx b/packages/storybook/stories/components/announcements/AnnouncementPlayground.stories.tsx index f59e74b420b..a433e459df8 100644 --- a/packages/storybook/stories/components/announcements/AnnouncementPlayground.stories.tsx +++ b/packages/storybook/stories/components/announcements/AnnouncementPlayground.stories.tsx @@ -180,17 +180,16 @@ const Playground = (): ReactElement => {

    Try the interactions

    • - • Switch: click the dots, the{' '} - arrows, or press{' '} - (focus a control first). + • Switch: hover (or click) the dots — the card changes as + you move across them and the active dot stretches into a pill.
    • Dismiss: hit the — the card animates out and the next one slides up into its place.
    • - • Stack: the edges peeking under the card show there are - more behind; they shrink as the stack empties. + • Stack: a card peeks behind the active one; hover the + stack and it “opens” — the card behind slides out and brightens.
    • Empty: dismiss them all to see the caught-up state, then From 9414e0c69cf40976e186ed27baa7921e71fff2de Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 17 Jun 2026 00:06:09 +0300 Subject: [PATCH 12/20] style(announcements): softer border, rectangular indicator, more dot gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Card border uses border-subtlest-quaternary (the soft V2 sidebar border) by default; hover brightens one step to tertiary - Active indicator is now a small rounded rectangle (rounded-2, 16x6) instead of an oval pill — slightly smaller - More vertical gap between the dots and the card Co-Authored-By: Claude Opus 4.8 --- .../src/components/announcements/AnnouncementCard.tsx | 2 +- .../src/components/announcements/AnnouncementCarousel.tsx | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/components/announcements/AnnouncementCard.tsx b/packages/shared/src/components/announcements/AnnouncementCard.tsx index 8cb5b0090d0..6dabb2e8cdb 100644 --- a/packages/shared/src/components/announcements/AnnouncementCard.tsx +++ b/packages/shared/src/components/announcements/AnnouncementCard.tsx @@ -37,7 +37,7 @@ export interface AnnouncementCardProps { // clean stack and a card behind is properly occluded. Hover gently brightens // the border and lifts the card for a tactile, alive feel. const cardBaseClasses = - 'border border-border-subtlest-tertiary bg-background-subtle transition-all duration-200 ease-out hover:border-border-subtlest-secondary motion-safe:hover:-translate-y-0.5'; + 'border border-border-subtlest-quaternary bg-background-subtle transition-all duration-200 ease-out hover:border-border-subtlest-tertiary motion-safe:hover:-translate-y-0.5'; // Small, flush-left brand label — visible via the brand color but kept light so // it never competes with the title (no filled chip). diff --git a/packages/shared/src/components/announcements/AnnouncementCarousel.tsx b/packages/shared/src/components/announcements/AnnouncementCarousel.tsx index f85c6e8962b..5195d657c8c 100644 --- a/packages/shared/src/components/announcements/AnnouncementCarousel.tsx +++ b/packages/shared/src/components/announcements/AnnouncementCarousel.tsx @@ -86,7 +86,7 @@ export function AnnouncementCarousel({ // stack "opens": the card behind slides out and brightens.
      )}
      +
      {items.map((item, index) => { const isActive = index === safeActive; return ( @@ -137,9 +137,9 @@ export function AnnouncementCarousel({ onFocus={() => goTo(index)} onClick={() => goTo(index)} className={classNames( - 'h-1.5 rounded-full transition-all duration-300 ease-out', + 'h-1.5 rounded-2 transition-all duration-300 ease-out', isActive - ? 'w-5 bg-text-primary' + ? 'w-4 bg-text-primary' : 'w-1.5 bg-text-quaternary hover:bg-text-tertiary', )} /> From 11e7f9c027392c70cb2f0198e00081ce0da18d0e Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 17 Jun 2026 00:20:27 +0300 Subject: [PATCH 13/20] fix(announcements): debounce impressions, avoid widget overlap, add tests Address PR review findings: - Impressions now fire only after a card dwells (400ms) and once per id, so hovering across the dots to browse no longer logs an impression per card - Don't render the sidebar announcements when the feedback widget occupies the bottom slot, avoiding overlap - Memoize the hook's dismiss callback - Add AnnouncementCarousel + useSidebarAnnouncements specs (switch, dismiss, empty, impression dedupe; filtering, hydration gating, persistence) Co-Authored-By: Claude Opus 4.8 --- .../AnnouncementCarousel.spec.tsx | 111 ++++++++++++++++++ .../announcements/AnnouncementCarousel.tsx | 34 +++++- .../components/sidebar/SidebarDesktopV2.tsx | 2 +- .../src/hooks/useSidebarAnnouncements.spec.ts | 68 +++++++++++ .../src/hooks/useSidebarAnnouncements.ts | 17 +-- 5 files changed, 218 insertions(+), 14 deletions(-) create mode 100644 packages/shared/src/components/announcements/AnnouncementCarousel.spec.tsx create mode 100644 packages/shared/src/hooks/useSidebarAnnouncements.spec.ts diff --git a/packages/shared/src/components/announcements/AnnouncementCarousel.spec.tsx b/packages/shared/src/components/announcements/AnnouncementCarousel.spec.tsx new file mode 100644 index 00000000000..57f9dcc5d01 --- /dev/null +++ b/packages/shared/src/components/announcements/AnnouncementCarousel.spec.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { AnnouncementCarousel } from './AnnouncementCarousel'; +import { AnnouncementCardVariant } from './types'; +import type { AnnouncementItem } from './types'; + +const items: AnnouncementItem[] = [ + { + id: 'a', + variant: AnnouncementCardVariant.Default, + badge: { label: 'New' }, + title: 'First card', + description: 'first description', + cta: { text: 'Go', onClick: jest.fn() }, + }, + { + id: 'b', + variant: AnnouncementCardVariant.Default, + title: 'Second card', + }, + { + id: 'c', + variant: AnnouncementCardVariant.Compact, + title: 'Third card', + href: '#', + }, +]; + +beforeEach(() => { + jest.useFakeTimers(); +}); + +afterEach(() => { + act(() => { + jest.runOnlyPendingTimers(); + }); + jest.useRealTimers(); +}); + +const getDots = () => + screen.getAllByRole('button', { name: /Show announcement/ }); + +describe('AnnouncementCarousel', () => { + it('renders the first card and one dot per item', () => { + render(); + + expect(screen.getByText('First card')).toBeInTheDocument(); + expect(getDots()).toHaveLength(3); + }); + + it('renders nothing when there are no items', () => { + const { container } = render( + , + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it('switches the active card when a dot is hovered', () => { + render(); + + fireEvent.mouseOver(getDots()[1]); + + expect(screen.getByText('Second card')).toBeInTheDocument(); + expect(screen.queryByText('First card')).not.toBeInTheDocument(); + }); + + it('dismisses the active card and reports it to the parent', () => { + const onDismiss = jest.fn(); + render(); + + fireEvent.click(screen.getByTitle('Close')); + act(() => { + jest.advanceTimersByTime(250); + }); + + expect(onDismiss).toHaveBeenCalledWith('a'); + }); + + it('logs an impression once per card after a dwell, not on rapid scrub', () => { + const onView = jest.fn(); + render( + , + ); + + // The first card settles and logs a single impression. + act(() => { + jest.advanceTimersByTime(400); + }); + expect(onView).toHaveBeenCalledTimes(1); + expect(onView).toHaveBeenLastCalledWith( + expect.objectContaining({ id: 'a' }), + ); + + // Scrub past 'b' to 'c' without dwelling — only the settled card logs. + fireEvent.mouseOver(getDots()[1]); + fireEvent.mouseOver(getDots()[2]); + act(() => { + jest.advanceTimersByTime(400); + }); + + expect(onView).toHaveBeenCalledTimes(2); + expect(onView).toHaveBeenLastCalledWith( + expect.objectContaining({ id: 'c' }), + ); + }); +}); diff --git a/packages/shared/src/components/announcements/AnnouncementCarousel.tsx b/packages/shared/src/components/announcements/AnnouncementCarousel.tsx index 5195d657c8c..2b1a875b3d5 100644 --- a/packages/shared/src/components/announcements/AnnouncementCarousel.tsx +++ b/packages/shared/src/components/announcements/AnnouncementCarousel.tsx @@ -6,6 +6,9 @@ import { AnnouncementCardVariant } from './types'; import type { AnnouncementItem } from './types'; const EXIT_MS = 200; +// How long a card must stay active before it counts as a genuine impression — +// long enough that hovering across the dots to browse doesn't log every card. +const VIEW_DWELL_MS = 400; const prefersReducedMotion = (): boolean => typeof window !== 'undefined' && @@ -32,21 +35,39 @@ export function AnnouncementCarousel({ const [active, setActive] = useState(0); const [exitingId, setExitingId] = useState(null); const exitTimer = useRef>(); + const viewTimer = useRef>(); + // Ids already counted as viewed, so scrubbing back and forth never + // double-logs an impression. + const viewedRef = useRef>(new Set()); const count = items.length; // Items shrink as cards are dismissed; keep the index in range. const safeActive = Math.min(active, Math.max(count - 1, 0)); const current = items[safeActive]; + // Log an impression only after a card has settled (dwell) and only once — + // hovering across the dots to browse the stack shouldn't fire impressions. useEffect(() => { - if (current) { - onView?.(current); + const id = current?.id; + if (!id || viewedRef.current.has(id)) { + return undefined; } + viewTimer.current = setTimeout(() => { + viewedRef.current.add(id); + onView?.(current); + }, VIEW_DWELL_MS); + return () => clearTimeout(viewTimer.current); // Re-run only when the visible card changes. // eslint-disable-next-line react-hooks/exhaustive-deps }, [current?.id]); - useEffect(() => () => clearTimeout(exitTimer.current), []); + useEffect( + () => () => { + clearTimeout(exitTimer.current); + clearTimeout(viewTimer.current); + }, + [], + ); if (!current) { return null; @@ -121,9 +142,10 @@ export function AnnouncementCarousel({
      {hasMultiple && ( - // Centered segmented indicator. Hovering (or focusing) a dot switches - // to that announcement; the active dot stretches into a pill and the - // fill glides between positions for a sense of motion. + // Centered segmented indicator. Hovering or focusing a dot switches to + // that announcement (this lives in the desktop sidebar; click/focus + // cover keyboard and any non-hover input). The active dot stretches + // into a pill and the fill glides between positions.
      {items.map((item, index) => { const isActive = index === safeActive; diff --git a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx index c5ece66d3fe..e944a34f479 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx @@ -925,7 +925,7 @@ export const SidebarDesktopV2 = ({ - {isExpanded && !isUtilityPanelSelected && ( + {isExpanded && !isUtilityPanelSelected && !showFeedbackWidget && ( )} {!isUtilityPanelSelected && } diff --git a/packages/shared/src/hooks/useSidebarAnnouncements.spec.ts b/packages/shared/src/hooks/useSidebarAnnouncements.spec.ts new file mode 100644 index 00000000000..9190fbad23d --- /dev/null +++ b/packages/shared/src/hooks/useSidebarAnnouncements.spec.ts @@ -0,0 +1,68 @@ +import { act, renderHook } from '@testing-library/react'; + +const mockSetDismissed = jest.fn(); +let mockPersistentState: [string[], jest.Mock, boolean] = [ + [], + mockSetDismissed, + true, +]; + +jest.mock('./usePersistentState', () => ({ + usePersistentState: () => mockPersistentState, +})); + +jest.mock('../components/announcements/content', () => ({ + SIDEBAR_ANNOUNCEMENTS: [ + { id: 'a', variant: 'default', title: 'A' }, + { id: 'b', variant: 'default', title: 'B' }, + ], +})); + +// eslint-disable-next-line import/first +import { useSidebarAnnouncements } from './useSidebarAnnouncements'; + +describe('useSidebarAnnouncements', () => { + beforeEach(() => { + mockSetDismissed.mockReset(); + mockPersistentState = [[], mockSetDismissed, true]; + }); + + it('returns no items until the dismissed list has hydrated', () => { + mockPersistentState = [[], mockSetDismissed, false]; + const { result } = renderHook(() => useSidebarAnnouncements()); + + expect(result.current.isReady).toBe(false); + expect(result.current.items).toHaveLength(0); + }); + + it('returns all announcements once loaded', () => { + const { result } = renderHook(() => useSidebarAnnouncements()); + + expect(result.current.isReady).toBe(true); + expect(result.current.items.map((item) => item.id)).toEqual(['a', 'b']); + }); + + it('filters out dismissed announcements', () => { + mockPersistentState = [['a'], mockSetDismissed, true]; + const { result } = renderHook(() => useSidebarAnnouncements()); + + expect(result.current.items.map((item) => item.id)).toEqual(['b']); + }); + + it('persists a newly dismissed id', () => { + const { result } = renderHook(() => useSidebarAnnouncements()); + + act(() => result.current.dismiss('a')); + + expect(mockSetDismissed).toHaveBeenCalledWith(['a']); + }); + + it('ignores dismissing an already dismissed id', () => { + mockPersistentState = [['a'], mockSetDismissed, true]; + const { result } = renderHook(() => useSidebarAnnouncements()); + + act(() => result.current.dismiss('a')); + + expect(mockSetDismissed).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/shared/src/hooks/useSidebarAnnouncements.ts b/packages/shared/src/hooks/useSidebarAnnouncements.ts index 1424a048e4c..d92ca253bf6 100644 --- a/packages/shared/src/hooks/useSidebarAnnouncements.ts +++ b/packages/shared/src/hooks/useSidebarAnnouncements.ts @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { usePersistentState } from './usePersistentState'; import { SIDEBAR_ANNOUNCEMENTS } from '../components/announcements/content'; import type { AnnouncementItem } from '../components/announcements/types'; @@ -28,13 +28,16 @@ export const useSidebarAnnouncements = (): UseSidebarAnnouncements => { return SIDEBAR_ANNOUNCEMENTS.filter((item) => !dismissedSet.has(item.id)); }, [dismissed, loaded]); - const dismiss = (id: string): void => { - if (dismissed.includes(id)) { - return; - } + const dismiss = useCallback( + (id: string): void => { + if (dismissed.includes(id)) { + return; + } - setDismissed([...dismissed, id]); - }; + setDismissed([...dismissed, id]); + }, + [dismissed, setDismissed], + ); return { items, dismiss, isReady: loaded }; }; From 372fb9ef410540f9e5c494cadd3c08ef3bca3a70 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 17 Jun 2026 00:23:46 +0300 Subject: [PATCH 14/20] style(announcements): use smaller right-pointing MoveTo icon on compact Replace the rotated ArrowIcon on the Compact card with MoveToIcon (points right by default) at XXSmall (12px) for a clearer, lighter 'go to' affordance. Co-Authored-By: Claude Opus 4.8 --- .../src/components/announcements/AnnouncementCard.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/components/announcements/AnnouncementCard.tsx b/packages/shared/src/components/announcements/AnnouncementCard.tsx index 6dabb2e8cdb..cc3fc544db9 100644 --- a/packages/shared/src/components/announcements/AnnouncementCard.tsx +++ b/packages/shared/src/components/announcements/AnnouncementCard.tsx @@ -3,7 +3,7 @@ import React from 'react'; import classNames from 'classnames'; import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; import CloseButton from '../CloseButton'; -import { ArrowIcon, MiniCloseIcon } from '../icons'; +import { MiniCloseIcon, MoveToIcon } from '../icons'; import { IconSize } from '../Icon'; import { Image } from '../image/Image'; import { @@ -137,9 +137,9 @@ export function AnnouncementCard({ )} - From 79944c92191c25f39c29cafceabbc874081180dc Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 17 Jun 2026 08:28:30 +0300 Subject: [PATCH 15/20] docs(announcements): add cover-directions gallery (16 options) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single Storybook page exploring 16 cover treatments at the real ~240px sidebar width — classic, full-bleed overlays, brand wash, duotone, framed, thumbnail, hero, floating panel, dual scrim, corner badge, glass panel, split color, icon-only, video, and top-accent. Every image is full-bleed and every scrim/overlay spans edge to edge so directions can be compared cleanly. Co-Authored-By: Claude Opus 4.8 --- .../AnnouncementCoverDirections.stories.tsx | 497 ++++++++++++++++++ 1 file changed, 497 insertions(+) create mode 100644 packages/storybook/stories/components/announcements/AnnouncementCoverDirections.stories.tsx diff --git a/packages/storybook/stories/components/announcements/AnnouncementCoverDirections.stories.tsx b/packages/storybook/stories/components/announcements/AnnouncementCoverDirections.stories.tsx new file mode 100644 index 00000000000..45d7045b59b --- /dev/null +++ b/packages/storybook/stories/components/announcements/AnnouncementCoverDirections.stories.tsx @@ -0,0 +1,497 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Image } from '@dailydotdev/shared/src/components/image/Image'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + MegaphoneIcon, + MiniCloseIcon, + MoveToIcon, + PlayIcon, + SparkleIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { + cloudinaryFeedFiltersScrollDark, + cloudinaryFeedFiltersYourFeedDark, + cloudinaryNotificationsBig, + cloudinarySquadsDirectoryCardBannerDefault, + cloudinarySquadsPromotionBanner, + cloudinarySquadsTourBanner2, +} from '@dailydotdev/shared/src/lib/image'; + +// One sample announcement reused across every direction so they're comparable. +const S = { + badge: 'New', + title: 'Introducing Clips', + desc: 'Save the best moments from any post and share them in one tap.', + cta: 'Try Clips', +}; + +const IMG = { + banner: cloudinarySquadsDirectoryCardBannerDefault, + feed: cloudinaryFeedFiltersYourFeedDark, + scroll: cloudinaryFeedFiltersScrollDark, + notif: cloudinaryNotificationsBig, + tour: cloudinarySquadsTourBanner2, + promo: cloudinarySquadsPromotionBanner, +}; + +// Card frame: relative + clip so every image and scrim respects the radius. +const CARD = + 'relative w-full overflow-hidden rounded-16 border border-border-subtlest-quaternary'; +const SURFACE = 'bg-background-subtle'; + +const cover = (src: string, className: string): ReactElement => ( + +); + +const Close = ({ light = true }: { light?: boolean }): ReactElement => ( + +); + +const Label = ({ onImage = false }: { onImage?: boolean }): ReactElement => ( + + {S.badge} + +); + +const SolidBadge = (): ReactElement => ( + + {S.badge} + +); + +const Title = ({ onImage = false }: { onImage?: boolean }): ReactElement => ( +

      + {S.title} +

      +); + +const Desc = ({ onImage = false }: { onImage?: boolean }): ReactElement => ( +

      + {S.desc} +

      +); + +const Cta = (): ReactElement => ( + +); + +// Full-width bottom scrim so overlaid text stays readable, edge to edge. +const bottomScrim = ( +
      +); + +// ---- Directions ----------------------------------------------------------- + +const Classic = (): ReactElement => ( +
      + {cover(IMG.banner, 'h-28 w-full object-cover')} + +
      +