Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
5e11ff7
feat: sidebar "what's new" announcement cards
tsahimatsliah Jun 16, 2026
ea8cf14
fix(announcements): cleaner badge and less dominant card surface
tsahimatsliah Jun 16, 2026
d78c62f
fix(announcements): vertically center the badge in the header row
tsahimatsliah Jun 16, 2026
d0d22b6
fix(announcements): frosted glass close button on cover image
tsahimatsliah Jun 16, 2026
51fd0fc
fix(announcements): square the cover close button to rounded-10
tsahimatsliah Jun 16, 2026
d2ac122
fix(announcements): visible badge chip, left-aligned with the title
tsahimatsliah Jun 16, 2026
392c98d
feat(announcements): stacked carousel motion + interactive playground
tsahimatsliah Jun 16, 2026
2f7db61
fix(announcements): cleaner notification stack + smaller label
tsahimatsliah Jun 16, 2026
fbf4a1c
fix(announcements): soft inner-bottom gradient + lighter outer shadow
tsahimatsliah Jun 16, 2026
6a3545e
feat(announcements): polished hover & micro-interactions
tsahimatsliah Jun 16, 2026
528e106
feat(announcements): hover-to-switch centered dot indicator
tsahimatsliah Jun 16, 2026
9414e0c
style(announcements): softer border, rectangular indicator, more dot gap
tsahimatsliah Jun 16, 2026
11e7f9c
fix(announcements): debounce impressions, avoid widget overlap, add t…
tsahimatsliah Jun 16, 2026
372fb9e
style(announcements): use smaller right-pointing MoveTo icon on compact
tsahimatsliah Jun 16, 2026
79944c9
docs(announcements): add cover-directions gallery (16 options)
tsahimatsliah Jun 17, 2026
c456e2c
chore(announcements): default flag on for review + live container story
tsahimatsliah Jun 17, 2026
f49c6ba
chore(announcements): default layout_v2 on for review
tsahimatsliah Jun 17, 2026
d55fbbf
fix(announcements): show card even when feedback widget is enabled
tsahimatsliah Jun 17, 2026
4cbb25a
chore(announcements): finalize — tighten dot spacing, drop review hacks
tsahimatsliah Jun 17, 2026
6ac130b
chore(announcements): remove dead export, fix stale comments
tsahimatsliah Jun 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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(
<AnnouncementCard
variant={AnnouncementCardVariant.Compact}
title="Keyboard shortcuts are here"
href="/settings"
onClick={onClick}
/>,
);

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(
<AnnouncementCard
variant={AnnouncementCardVariant.Default}
badge={{ label: 'Updated' }}
title="Smarter custom feeds"
description="Custom feeds now learn from what you read."
cta={{ text: 'See what changed', onClick: onCtaClick }}
/>,
);

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(
<AnnouncementCard
variant={AnnouncementCardVariant.Default}
title="Smarter custom feeds"
onClose={onClose}
/>,
);

fireEvent.click(screen.getByTitle('Close'));
expect(onClose).toHaveBeenCalledTimes(1);
});

it('renders a cover image for the cover variant', () => {
const { container } = render(
<AnnouncementCard
variant={AnnouncementCardVariant.Cover}
image="https://media.daily.dev/cover.png"
title="Introducing Clips"
onClose={() => 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');
});
});
236 changes: 236 additions & 0 deletions packages/shared/src/components/announcements/AnnouncementCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
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 { MiniCloseIcon, MoveToIcon } from '../icons';
import { IconSize } from '../Icon';
import { Image } from '../image/Image';
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;
// Leading icon for the Compact variant (Default/Cover lead with the badge).
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;
}

// Subtle, defined surface (the canonical card background) so cards read as a
// 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-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).
const renderBadge = (badge?: AnnouncementBadge): ReactElement | null => {
if (!badge) {
return null;
}

return (
<span
className={classNames(
'font-bold uppercase typo-caption2',
badge.className ?? 'text-brand-default',
)}
>
{badge.label}
</span>
);
};

const renderCta = (cta?: AnnouncementCta): ReactElement | null => {
if (!cta) {
return null;
}

return (
<Button
className="mt-1 self-start"
size={ButtonSize.Small}
variant={ButtonVariant.Primary}
tag={cta.href ? 'a' : undefined}
href={cta.href}
onClick={cta.onClick}
>
{cta.text}
</Button>
);
};

// 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 = (
<span
aria-hidden
className="pointer-events-none absolute inset-x-0 bottom-0 h-6 rounded-b-16 bg-gradient-to-t from-white/[0.08] to-transparent"
/>
);

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 (
<Tag
href={href}
type={href ? undefined : 'button'}
onClick={onClick}
className={classNames(
'focus-outline group flex w-full items-center gap-3 rounded-12 p-3 text-left',
cardBaseClasses,
className,
)}
>
{icon && (
<span className="flex text-text-primary" aria-hidden>
{icon}
</span>
)}
<span className="flex min-w-0 flex-1 flex-col">
<Typography
type={TypographyType.Footnote}
color={TypographyColor.Primary}
bold
truncate
>
{title}
</Typography>
{description && (
<Typography
type={TypographyType.Caption1}
color={TypographyColor.Tertiary}
truncate
>
{description}
</Typography>
)}
</span>
<MoveToIcon
size={IconSize.XXSmall}
className="text-text-tertiary transition-transform group-hover:translate-x-0.5"
aria-hidden
/>
</Tag>
);
}

// 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 => (
<div
className={classNames('flex flex-col gap-2', reserveCloseSpace && 'pr-5')}
>
{renderBadge(badge)}
<Typography
type={TypographyType.Callout}
color={TypographyColor.Primary}
bold
>
{title}
</Typography>
{description && (
<Typography
type={TypographyType.Footnote}
color={TypographyColor.Tertiary}
className="line-clamp-2"
>
{description}
</Typography>
)}
{renderCta(cta)}
</div>
);

if (variant === AnnouncementCardVariant.Cover) {
return (
<div
className={classNames(
'group/card relative flex flex-col overflow-hidden rounded-16',
cardBaseClasses,
className,
)}
>
<div className="relative overflow-hidden">
{image && (
<Image
src={image}
alt=""
className="h-28 w-full object-cover transition-transform duration-500 ease-out motion-safe:group-hover/card:scale-105"
/>
)}
{onClose && (
// Frosted glass close over imagery — translucent + backdrop blur so
// the image reads through, matching the floating-control idiom in
// ArticleFeaturedWideGridCard.
<button
type="button"
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 duration-200 hover:bg-overlay-primary-pepper motion-safe:hover:scale-105 motion-safe:active:scale-95"
>
<MiniCloseIcon size={IconSize.Size16} aria-hidden />
</button>
)}
</div>
<div className="p-3">{renderBody(false)}</div>
{bottomDepth}
</div>
);
}

return (
<div
className={classNames(
'relative flex flex-col rounded-16 p-3',
cardBaseClasses,
className,
)}
>
{onClose && (
<CloseButton
type="button"
size={ButtonSize.XSmall}
className="absolute right-2 top-2"
onClick={onClose}
/>
)}
{renderBody(Boolean(onClose))}
{bottomDepth}
</div>
);
}

export default AnnouncementCard;
Loading
Loading