diff --git a/packages/shared/src/features/snapshot/AchievementSnapshotCard.tsx b/packages/shared/src/features/snapshot/AchievementSnapshotCard.tsx new file mode 100644 index 0000000000..1dd4bd0ae6 --- /dev/null +++ b/packages/shared/src/features/snapshot/AchievementSnapshotCard.tsx @@ -0,0 +1,130 @@ +import type { ReactElement } from 'react'; +import React, { forwardRef } from 'react'; +import { AchievementRarityTier } from '../profile/components/achievements/achievementRarity'; +import { SnapshotFrame } from './SnapshotFrame'; + +const CARD_WIDTH = 620; +/** Trading-card proportions (2.5:3.5) rather than a square slab. */ +const CARD_HEIGHT = Math.round(CARD_WIDTH * 1.4); +const CARD_RADIUS = 36; + +/** + * The Game Center slab treatment: artwork full bleed, a scrim carrying the + * copy, and gold reserved for the sub-1% band so it means something. + */ +const SCRIM = + 'linear-gradient(to top, rgba(6,8,11,0.94) 0%, rgba(6,8,11,0.72) 34%, rgba(6,8,11,0.12) 66%, rgba(6,8,11,0) 100%)'; + +const PILL = { + gold: { background: '#efab27', color: '#08110c' }, + plain: { background: 'rgba(8,10,13,0.72)', color: '#FFFFFF' }, +}; + +export interface AchievementSnapshotCardProps { + name: string; + description: string; + image?: string; + rarity: number | null; + tier: AchievementRarityTier | null; + completedAt: string; + seed?: string; +} + +function AchievementSnapshotCardComponent( + { + name, + description, + image, + rarity, + tier, + completedAt, + seed, + }: AchievementSnapshotCardProps, + ref: React.Ref, +): ReactElement { + const isEmerald = tier === AchievementRarityTier.Emerald; + const pill = isEmerald ? PILL.gold : PILL.plain; + const rarityLabel = isEmerald ? '<1%' : `${Math.round(rarity ?? 0)}%`; + + return ( + +
+ {image && ( + + )} + + + + {tier && ( + + {rarityLabel} rare + + )} + +
+ + {name} + + + {description} + + + Completed {completedAt} + +
+
+
+ ); +} + +export const AchievementSnapshotCard = forwardRef( + AchievementSnapshotCardComponent, +); diff --git a/packages/shared/src/features/snapshot/AchievementsSnapshotCard.tsx b/packages/shared/src/features/snapshot/AchievementsSnapshotCard.tsx new file mode 100644 index 0000000000..e54ab31e47 --- /dev/null +++ b/packages/shared/src/features/snapshot/AchievementsSnapshotCard.tsx @@ -0,0 +1,99 @@ +import type { ReactElement } from 'react'; +import React, { forwardRef } from 'react'; +import colors from '../../styles/colors'; +import { largeNumberFormat } from '../../lib/numberFormat'; +import { SnapshotEyebrow } from './SnapshotEyebrow'; +import { SnapshotFrame } from './SnapshotFrame'; +import type { SnapshotIdentityProps } from './SnapshotIdentity'; +import { SnapshotIdentity } from './SnapshotIdentity'; +import { SnapshotTile } from './SnapshotStats'; + +const MUTED = colors.salt['90']; +const DIVIDER = colors.pepper['10']; + +const TILE_SIZE = 104; + +export interface UnlockedAchievement { + name: string; + image?: string; + emoji?: string; +} + +export interface AchievementsSnapshotCardProps { + user: SnapshotIdentityProps; + unlocked: number; + total: number; + points: number; + achievements: UnlockedAchievement[]; + seed?: string; +} + +function AchievementsSnapshotCardComponent( + { + user, + unlocked, + total, + points, + achievements, + seed, + }: AchievementsSnapshotCardProps, + ref: React.Ref, +): ReactElement { + return ( + } + ref={ref} + seed={seed ?? 'achievements'} + > +
+ + +
+ + +
+ +
+ Rarest unlocked +
+ {achievements.slice(0, 10).map((achievement) => ( + + {achievement.image ? ( + + ) : ( + + {achievement.emoji} + + )} + + ))} +
+
+
+
+ ); +} + +export const AchievementsSnapshotCard = forwardRef( + AchievementsSnapshotCardComponent, +); diff --git a/packages/shared/src/features/snapshot/AwardSnapshotCard.tsx b/packages/shared/src/features/snapshot/AwardSnapshotCard.tsx new file mode 100644 index 0000000000..50aa4ff1b6 --- /dev/null +++ b/packages/shared/src/features/snapshot/AwardSnapshotCard.tsx @@ -0,0 +1,104 @@ +import type { ReactElement } from 'react'; +import React, { forwardRef } from 'react'; +import colors from '../../styles/colors'; +import { SnapshotEyebrow } from './SnapshotEyebrow'; +import { SnapshotFrame } from './SnapshotFrame'; +import type { SnapshotIdentityProps } from './SnapshotIdentity'; +import { SnapshotIdentity } from './SnapshotIdentity'; + +const MUTED = colors.salt['90']; +const DIVIDER = colors.pepper['10']; + +export interface AwardSnapshotCardProps { + /** The recipient β€” the card is theirs to share. */ + user: SnapshotIdentityProps; + /** Who sent it. Named, because that is the whole point of this moment. */ + from: string; + award: string; + emoji?: string; + image?: string; + /** What the award was given for, e.g. a post or comment title. */ + reason?: string; + /** How many of this award the recipient now holds. */ + total?: number; + seed?: string; +} + +/** + * Being awarded: the one status moment that comes from someone + * else rather than from your own activity. The sender is on the card because a + * gift with no giver reads as a self-congratulation. + */ +function AwardSnapshotCardComponent( + { + user, + from, + award, + emoji, + image, + reason, + total, + seed, + }: AwardSnapshotCardProps, + ref: React.Ref, +): ReactElement { + return ( + } + ref={ref} + seed={seed ?? `award-${award}`} + watermark={emoji} + > +
+ + +
+ {image ? ( + + ) : ( + emoji && ( + {emoji} + ) + )} + + + {award} + + + from {from} + + {reason && ( + + {reason} + + )} +
+ + {total !== undefined && ( +
+ + {total} awards received + +
+ )} +
+
+ ); +} + +export const AwardSnapshotCard = forwardRef(AwardSnapshotCardComponent); diff --git a/packages/shared/src/features/snapshot/BadgesSnapshotCard.tsx b/packages/shared/src/features/snapshot/BadgesSnapshotCard.tsx new file mode 100644 index 0000000000..ea79db1db0 --- /dev/null +++ b/packages/shared/src/features/snapshot/BadgesSnapshotCard.tsx @@ -0,0 +1,129 @@ +import type { ReactElement } from 'react'; +import React, { forwardRef } from 'react'; +import colors from '../../styles/colors'; +import { largeNumberFormat } from '../../lib/numberFormat'; +import { SnapshotEyebrow } from './SnapshotEyebrow'; +import { SnapshotFrame } from './SnapshotFrame'; +import type { SnapshotIdentityProps } from './SnapshotIdentity'; +import { SnapshotIdentity } from './SnapshotIdentity'; +import { SnapshotTile } from './SnapshotStats'; + +const MUTED = colors.salt['90']; +const DIVIDER = colors.pepper['10']; + +export interface TopReaderBadge { + keyword: string; + earnedAt: string; +} + +export interface AwardTally { + count: number; + emoji?: string; + image?: string; + name: string; +} + +export interface BadgesSnapshotCardProps { + user: SnapshotIdentityProps; + topReaderBadges: number; + totalAwards: number; + badges: TopReaderBadge[]; + awards: AwardTally[]; + seed?: string; +} + +function BadgesSnapshotCardComponent( + { + user, + topReaderBadges, + totalAwards, + badges, + awards, + seed, + }: BadgesSnapshotCardProps, + ref: React.Ref, +): ReactElement { + return ( + } + ref={ref} + seed={seed ?? 'badges'} + > +
+ + +
+ + +
+ +
+ {badges.slice(0, 4).map((badge) => ( +
+ + {badge.keyword} + + + {badge.earnedAt} + +
+ ))} +
+ +
+ {awards.slice(0, 6).map((award) => ( +
+ {award.image ? ( + + ) : ( + + {award.emoji} + + )} + + x{award.count} + +
+ ))} +
+
+
+ ); +} + +export const BadgesSnapshotCard = forwardRef(BadgesSnapshotCardComponent); diff --git a/packages/shared/src/features/snapshot/CelebrationSnapshotCard.tsx b/packages/shared/src/features/snapshot/CelebrationSnapshotCard.tsx new file mode 100644 index 0000000000..8f9872e8a5 --- /dev/null +++ b/packages/shared/src/features/snapshot/CelebrationSnapshotCard.tsx @@ -0,0 +1,94 @@ +import type { ReactElement } from 'react'; +import React, { forwardRef } from 'react'; +import colors from '../../styles/colors'; +import { largeNumberFormat } from '../../lib/numberFormat'; +import { SnapshotEyebrow } from './SnapshotEyebrow'; +import { SnapshotFrame } from './SnapshotFrame'; +import type { SnapshotIdentityProps } from './SnapshotIdentity'; +import { SnapshotIdentity } from './SnapshotIdentity'; +import { SnapshotLevelRing } from './SnapshotLevelRing'; +import { + SnapshotStat, + SnapshotStatRow, + SnapshotStatValue, +} from './SnapshotStats'; + +const MUTED = colors.salt['90']; + +export interface CelebrationSnapshotCardProps { + user: SnapshotIdentityProps; + level: number; + levelProgress: number; + totalXp: number; + questsCompleted: number; + headline?: string; + seed?: string; +} + +function CelebrationSnapshotCardComponent( + { + user, + level, + levelProgress, + totalXp, + questsCompleted, + headline, + seed, + }: CelebrationSnapshotCardProps, + ref: React.Ref, +): ReactElement { + return ( + } + ref={ref} + seed={seed ?? `level-${level}`} + watermark="πŸŽ‰" + > +
+ + +
+ + + {headline ?? `Level ${level} reached`} + + + {Math.round(levelProgress)}% of the way to level {level + 1} + +
+ + + + {largeNumberFormat(totalXp) ?? totalXp} + + } + /> + + {largeNumberFormat(questsCompleted) ?? questsCompleted} + + } + /> + +
+
+ ); +} + +export const CelebrationSnapshotCard = forwardRef( + CelebrationSnapshotCardComponent, +); diff --git a/packages/shared/src/features/snapshot/DiscussionSnapshotCard.tsx b/packages/shared/src/features/snapshot/DiscussionSnapshotCard.tsx new file mode 100644 index 0000000000..fa1f210d7d --- /dev/null +++ b/packages/shared/src/features/snapshot/DiscussionSnapshotCard.tsx @@ -0,0 +1,32 @@ +import type { ReactElement } from 'react'; +import React, { forwardRef } from 'react'; +import { HighlightTextSnapshotCard } from './HighlightTextSnapshotCard'; + +export interface DiscussionSnapshotCardProps { + comment: string; + author: { name: string; handle: string; image?: string }; + seed?: string; +} + +/** + * A comment, set like the post card's TLDR and credited to whoever wrote it. + * No label above the copy: a comment in someone's name already reads as a + * comment, and the post it hung off was context nobody shares for. + */ +function DiscussionSnapshotCardComponent( + { comment, author, seed }: DiscussionSnapshotCardProps, + ref: React.Ref, +): ReactElement { + return ( + + ); +} + +export const DiscussionSnapshotCard = forwardRef( + DiscussionSnapshotCardComponent, +); diff --git a/packages/shared/src/features/snapshot/EntitySnapshotCard.tsx b/packages/shared/src/features/snapshot/EntitySnapshotCard.tsx new file mode 100644 index 0000000000..1d807aba38 --- /dev/null +++ b/packages/shared/src/features/snapshot/EntitySnapshotCard.tsx @@ -0,0 +1,124 @@ +import type { ReactElement } from 'react'; +import React, { forwardRef } from 'react'; +import colors from '../../styles/colors'; +import { largeNumberFormat } from '../../lib/numberFormat'; +import { SnapshotEyebrow } from './SnapshotEyebrow'; +import { SnapshotFrame } from './SnapshotFrame'; +import { + SnapshotStat, + SnapshotStatRow, + SnapshotStatValue, +} from './SnapshotStats'; + +const MUTED = colors.salt['90']; + +export type SnapshotEntityKind = 'tag' | 'source' | 'squad'; + +const KIND_LABEL: Record = { + tag: 'Topic', + source: 'Source', + squad: 'Squad', +}; + +export interface SnapshotEntityStat { + value: number; + label: string; +} + +export interface EntitySnapshotCardProps { + kind: SnapshotEntityKind; + name: string; + handle?: string; + description?: string; + image?: string; + stats: SnapshotEntityStat[]; + seed?: string; +} + +function EntitySnapshotCardComponent( + { + kind, + name, + handle, + description, + image, + stats, + seed, + }: EntitySnapshotCardProps, + ref: React.Ref, +): ReactElement { + const isTag = kind === 'tag'; + + return ( + } + ref={ref} + seed={seed ?? name} + > +
+
+ {isTag || !image ? ( + + # + + ) : ( + + )} +
+ +

+ {isTag ? `#${name}` : name} +

+ {handle && ( + + {handle} + + )} + + {description && ( +

+ {description} +

+ )} + + + {stats.slice(0, 3).map((stat) => ( + + {largeNumberFormat(stat.value) ?? stat.value} + + } + /> + ))} + +
+
+ ); +} + +export const EntitySnapshotCard = forwardRef(EntitySnapshotCardComponent); diff --git a/packages/shared/src/features/snapshot/HighlightTextSnapshotCard.tsx b/packages/shared/src/features/snapshot/HighlightTextSnapshotCard.tsx index 771bd165f9..963c7dea6c 100644 --- a/packages/shared/src/features/snapshot/HighlightTextSnapshotCard.tsx +++ b/packages/shared/src/features/snapshot/HighlightTextSnapshotCard.tsx @@ -1,4 +1,4 @@ -import type { ReactElement } from 'react'; +import type { ReactElement, ReactNode } from 'react'; import React, { forwardRef } from 'react'; import { SnapshotCredit } from './SnapshotCredit'; import { SnapshotFrame } from './SnapshotFrame'; @@ -20,6 +20,8 @@ export interface HighlightTextSnapshotCardProps { */ highlight?: HighlightRange; source?: { name: string; image?: string }; + /** The surface's own label, on the logo row. */ + label?: ReactNode; seed?: string; } @@ -43,7 +45,7 @@ const MARK_BACKGROUND = 'rgba(217, 126, 254, 0.22)'; * unclickable in an image. */ function HighlightTextSnapshotCardComponent( - { passage, highlight, source, seed }: HighlightTextSnapshotCardProps, + { passage, highlight, source, label, seed }: HighlightTextSnapshotCardProps, ref: React.Ref, ): ReactElement { const trimmed = passage.trim(); @@ -57,7 +59,7 @@ function HighlightTextSnapshotCardComponent( : undefined; return ( - +

, +): ReactElement { + return ( + +

+ {image && ( + + )} + + + {name} + + {handle} + +

+ {headline} +

+ + {perk && ( + + {perk} + + )} + +
+ + {link} + +
+
+ + ); +} + +export const InviteSnapshotCard = forwardRef(InviteSnapshotCardComponent); diff --git a/packages/shared/src/features/snapshot/LeaderboardSnapshotCard.tsx b/packages/shared/src/features/snapshot/LeaderboardSnapshotCard.tsx new file mode 100644 index 0000000000..c77cfdb6bd --- /dev/null +++ b/packages/shared/src/features/snapshot/LeaderboardSnapshotCard.tsx @@ -0,0 +1,131 @@ +import type { ReactElement } from 'react'; +import React, { forwardRef } from 'react'; +import colors from '../../styles/colors'; +import { largeNumberFormat } from '../../lib/numberFormat'; +import { SnapshotFrame } from './SnapshotFrame'; +import { + SnapshotStat, + SnapshotStatRow, + SnapshotStatValue, +} from './SnapshotStats'; +import { SnapshotLevelRing } from './SnapshotLevelRing'; + +const MUTED = colors.salt['90']; +const DIVIDER = colors.pepper['10']; + +/** Gold, silver and bronze, matching the leaderboard's own top-rank palette. */ +const RANK_COLORS = [ + colors.cheese['40'], + colors.salt['90'], + colors.bacon['40'], +]; + +export interface LeaderboardSnapshotCardProps { + board: string; + rank: number; + name: string; + handle: string; + image?: string; + score: number; + level: number; + levelProgress: number; + reputation: number; + seed?: string; +} + +function LeaderboardSnapshotCardComponent( + { + board, + rank, + name, + handle, + image, + score, + level, + levelProgress, + reputation, + seed, + }: LeaderboardSnapshotCardProps, + ref: React.Ref, +): ReactElement { + const rankColor = RANK_COLORS[rank - 1] ?? colors.cabbage['10']; + + return ( + +
+
+ + #{rank} + + + {board} + +
+ + {image && ( + + )} + +
+ + {name} + + {handle} +
+ + + + {largeNumberFormat(score) ?? score} + + } + /> + } + /> + + {largeNumberFormat(reputation) ?? reputation} + + } + /> + +
+
+ ); +} + +export const LeaderboardSnapshotCard = forwardRef( + LeaderboardSnapshotCardComponent, +); diff --git a/packages/shared/src/features/snapshot/ListSnapshotCard.tsx b/packages/shared/src/features/snapshot/ListSnapshotCard.tsx new file mode 100644 index 0000000000..c4657373ae --- /dev/null +++ b/packages/shared/src/features/snapshot/ListSnapshotCard.tsx @@ -0,0 +1,120 @@ +import type { ReactElement } from 'react'; +import React, { forwardRef } from 'react'; +import colors from '../../styles/colors'; +import { SnapshotEyebrow } from './SnapshotEyebrow'; +import { SnapshotFrame } from './SnapshotFrame'; + +const MUTED = colors.salt['90']; +const DIVIDER = colors.pepper['10']; + +export interface SnapshotListItem { + title: string; + meta?: string; +} + +export interface ListSnapshotCardProps { + eyebrow: string; + title: string; + subtitle?: string; + items: SnapshotListItem[]; + footer?: string; + seed?: string; +} + +/** + * One card for every "a list of posts" share β€” the briefing, the best-of + * archive, a feed digest β€” which are the same object with a different label. + */ +function ListSnapshotCardComponent( + { eyebrow, title, subtitle, items, footer, seed }: ListSnapshotCardProps, + ref: React.Ref, +): ReactElement { + const visible = items.slice(0, 5); + + return ( + } + ref={ref} + seed={seed ?? title} + > +
+

+ {title} +

+ {subtitle && ( + + {subtitle} + + )} + +
    + {visible.map((item, index) => ( +
  1. + + {index + 1} + + + + {item.title} + + {item.meta && ( + + {item.meta} + + )} + +
  2. + ))} +
+ + {footer && ( + + {footer} + + )} +
+
+ ); +} + +export const ListSnapshotCard = forwardRef(ListSnapshotCardComponent); diff --git a/packages/shared/src/features/snapshot/PostSnapshotCard.tsx b/packages/shared/src/features/snapshot/PostSnapshotCard.tsx new file mode 100644 index 0000000000..1348f16b42 --- /dev/null +++ b/packages/shared/src/features/snapshot/PostSnapshotCard.tsx @@ -0,0 +1,48 @@ +import type { ReactElement } from 'react'; +import React, { forwardRef } from 'react'; +import type { Post } from '../../graphql/posts'; +import { HighlightTextSnapshotCard } from './HighlightTextSnapshotCard'; +import { SnapshotEyebrow } from './SnapshotEyebrow'; +import { snapshotSource } from './snapshotSource'; + +interface PostSnapshotCardProps { + post: Post; + /** A surface label on the logo row, e.g. "Happening now". */ + eyebrow?: string; + /** Paints the eyebrow with the surface's own wordmark gradient. */ + eyebrowGradient?: string; + /** + * Credits someone other than the post's source: the author, on surfaces + * where a person wrote the copy rather than a publication. + */ + credit?: { name: string; image?: string }; + seed?: string; +} + +/** + * The TLDR in white, credited to its source, with the surface's own label on + * the logo row where it has one. The post headline is left off because + * the TLDR already says what it says, at more length; so is the rest of the + * page's furniture (date, thumbnail, follow, tags, counts, read time), which + * competed with the copy for the room and cannot be pressed anyway. + */ +function PostSnapshotCardComponent( + { post, eyebrow, eyebrowGradient, credit, seed }: PostSnapshotCardProps, + ref: React.Ref, +): ReactElement { + return ( + + ) + } + passage={post.summary ?? ''} + ref={ref} + seed={seed ?? post.id} + source={credit ?? snapshotSource(post)} + /> + ); +} + +export const PostSnapshotCard = forwardRef(PostSnapshotCardComponent); diff --git a/packages/shared/src/features/snapshot/ProfileSnapshotCard.tsx b/packages/shared/src/features/snapshot/ProfileSnapshotCard.tsx new file mode 100644 index 0000000000..d2cb1c33bc --- /dev/null +++ b/packages/shared/src/features/snapshot/ProfileSnapshotCard.tsx @@ -0,0 +1,144 @@ +import type { ReactElement } from 'react'; +import React, { forwardRef } from 'react'; +import colors from '../../styles/colors'; +import { largeNumberFormat } from '../../lib/numberFormat'; +import { SNAPSHOT_CARD_PADDING, SnapshotFrame } from './SnapshotFrame'; +import { + SnapshotStat, + SnapshotStatRow, + SnapshotStatValue, +} from './SnapshotStats'; + +const MUTED = colors.salt['90']; + +const COVER_HEIGHT = 268; +const AVATAR_SIZE = 208; +const AVATAR_RING = 8; +const AVATAR_RADIUS = 46; + +export interface ProfileSnapshotCardProps { + name: string; + handle: string; + bio?: string; + image?: string; + cover?: string; + postsRead: number; + joined: string; + reputation: number; + seed?: string; +} + +function ProfileSnapshotCardComponent( + { + name, + handle, + bio, + image, + cover, + postsRead, + joined, + reputation, + seed, + }: ProfileSnapshotCardProps, + ref: React.Ref, +): ReactElement { + return ( + +
+
+ + {image && ( + // The ring is a padded wrapper rather than a border on the image: + // its radius is the image's plus the ring width, so the two curves + // stay concentric and no cover shows through at the corners. +
+ +
+ )} + +
+ + {name} + + {handle} +
+ + {bio && ( +

+ {bio} +

+ )} + + + + {largeNumberFormat(postsRead) ?? postsRead} + + } + /> + {joined}} + /> + + {largeNumberFormat(reputation) ?? reputation} + + } + /> + +
+ + ); +} + +export const ProfileSnapshotCard = forwardRef(ProfileSnapshotCardComponent); diff --git a/packages/shared/src/features/snapshot/ReadingOverviewSnapshotCard.tsx b/packages/shared/src/features/snapshot/ReadingOverviewSnapshotCard.tsx new file mode 100644 index 0000000000..3f33c46829 --- /dev/null +++ b/packages/shared/src/features/snapshot/ReadingOverviewSnapshotCard.tsx @@ -0,0 +1,166 @@ +import type { ReactElement } from 'react'; +import React, { forwardRef } from 'react'; +import colors from '../../styles/colors'; +import { largeNumberFormat } from '../../lib/numberFormat'; +import { SnapshotEyebrow } from './SnapshotEyebrow'; +import { SnapshotFrame } from './SnapshotFrame'; +import type { SnapshotIdentityProps } from './SnapshotIdentity'; +import { SnapshotIdentity } from './SnapshotIdentity'; +import { SnapshotTile } from './SnapshotStats'; + +const MUTED = colors.salt['90']; +const DIVIDER = colors.pepper['10']; + +const HEATMAP_ROWS = 4; +const HEATMAP_COLS = 22; +const HEATMAP_CELL = 20; +const HEATMAP_GAP = 6; + +/** Four steps, matching the Less -> More legend on the profile heatmap. */ +const HEATMAP_LEVELS = [ + colors.pepper['70'], + colors.pepper['40'], + colors.pepper['10'], + '#FFFFFF', +]; + +export interface ReadingOverviewTag { + name: string; + percentage: number; +} + +export interface ReadingOverviewSnapshotCardProps { + user: SnapshotIdentityProps; + longestStreak: number; + totalReadingDays: number; + postsRead: number; + monthsLabel: string; + topTags: ReadingOverviewTag[]; + /** One entry per cell, 0-3, read left to right like the profile heatmap. */ + heatmap: number[]; + seed?: string; +} + +const TagChip = ({ + name, + percentage, + share, +}: ReadingOverviewTag & { share: number }): ReactElement => { + // Relative to the strongest tag, so the leader reads as a full-ish bar and + // the rest fall away from it β€” an absolute percentage would fill them all. + const fill = Math.max(12, Math.min(share * 68, 68)); + + return ( +
+ + {name} + + + +{percentage}% + +
+ ); +}; + +function ReadingOverviewSnapshotCardComponent( + { + user, + longestStreak, + totalReadingDays, + postsRead, + monthsLabel, + topTags, + heatmap, + seed, + }: ReadingOverviewSnapshotCardProps, + ref: React.Ref, +): ReactElement { + const cells = heatmap.slice(0, HEATMAP_ROWS * HEATMAP_COLS); + const visibleTags = topTags.slice(0, 6); + const topPercentage = Math.max( + ...visibleTags.map((tag) => tag.percentage), + 1, + ); + + return ( + } + ref={ref} + seed={seed ?? 'reading-overview'} + > +
+ + +
+ + +
+ +
+ + Top tags by reading days + +
+ {visibleTags.map((tag) => ( + + ))} +
+
+ +
+ + Posts read {monthsLabel} ( + {largeNumberFormat(postsRead) ?? postsRead}) + +
+ {cells.map((level, index) => ( + + ))} +
+
+
+
+ ); +} + +export const ReadingOverviewSnapshotCard = forwardRef( + ReadingOverviewSnapshotCardComponent, +); diff --git a/packages/shared/src/features/snapshot/SnapshotEyebrow.tsx b/packages/shared/src/features/snapshot/SnapshotEyebrow.tsx new file mode 100644 index 0000000000..64797e2650 --- /dev/null +++ b/packages/shared/src/features/snapshot/SnapshotEyebrow.tsx @@ -0,0 +1,39 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import colors from '../../styles/colors'; + +export interface SnapshotEyebrowProps { + label: string; + /** Paints the label with the surface's own wordmark gradient. */ + gradient?: string; +} + +/** + * Which part of the product the card came from. It rides the logo row rather + * than the copy: it is a sibling of the mark, not a headline for the text + * under it. + */ +export function SnapshotEyebrow({ + label, + gradient, +}: SnapshotEyebrowProps): ReactElement { + return ( + + {label} + + ); +} diff --git a/packages/shared/src/features/snapshot/SnapshotFrame.tsx b/packages/shared/src/features/snapshot/SnapshotFrame.tsx index 298e40f3f9..a35021ebca 100644 --- a/packages/shared/src/features/snapshot/SnapshotFrame.tsx +++ b/packages/shared/src/features/snapshot/SnapshotFrame.tsx @@ -21,7 +21,7 @@ export const SNAPSHOT_CARD_MAX = SNAPSHOT_SIZE - 150; const CARD_RADIUS = 48; const CARD_EDGE = 2; -const CARD_PADDING = 58; +export const SNAPSHOT_CARD_PADDING = 58; const CARD_PADDING_WIDE = 32; /** @@ -159,7 +159,7 @@ function SnapshotFrameComponent( ...(!grow && { minHeight: SNAPSHOT_SIZE - gutter * 2 - CARD_EDGE * 2, }), - padding: wide ? CARD_PADDING_WIDE : CARD_PADDING, + padding: wide ? CARD_PADDING_WIDE : SNAPSHOT_CARD_PADDING, borderRadius: CARD_RADIUS - CARD_EDGE, background: CARD_BODY, }} diff --git a/packages/shared/src/features/snapshot/SnapshotIdentity.tsx b/packages/shared/src/features/snapshot/SnapshotIdentity.tsx new file mode 100644 index 0000000000..1858e397c8 --- /dev/null +++ b/packages/shared/src/features/snapshot/SnapshotIdentity.tsx @@ -0,0 +1,42 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import colors from '../../styles/colors'; + +const MUTED = colors.salt['90']; + +export interface SnapshotIdentityProps { + name: string; + handle: string; + image?: string; +} + +export function SnapshotIdentity({ + name, + handle, + image, +}: SnapshotIdentityProps): ReactElement { + return ( +
+ {image && ( + + )} +
+ + {name} + + + {handle} + +
+
+ ); +} diff --git a/packages/shared/src/features/snapshot/SnapshotLevelRing.tsx b/packages/shared/src/features/snapshot/SnapshotLevelRing.tsx new file mode 100644 index 0000000000..948a6a83bd --- /dev/null +++ b/packages/shared/src/features/snapshot/SnapshotLevelRing.tsx @@ -0,0 +1,64 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import colors from '../../styles/colors'; +import { SNAPSHOT_STAT_HEIGHT } from './SnapshotStats'; + +interface SnapshotLevelRingProps { + level: number; + progress: number; + size?: number; + stroke?: number; + fontSize?: number; +} + +export function SnapshotLevelRing({ + level, + progress, + size = SNAPSHOT_STAT_HEIGHT, + stroke = 10, + fontSize = 40, +}: SnapshotLevelRingProps): ReactElement { + const radius = (size - stroke) / 2; + const circumference = 2 * Math.PI * radius; + const safeProgress = Math.max(0, Math.min(progress, 100)); + + return ( + + + + + + + {level} + + + ); +} diff --git a/packages/shared/src/features/snapshot/SnapshotStats.tsx b/packages/shared/src/features/snapshot/SnapshotStats.tsx new file mode 100644 index 0000000000..fb4769920f --- /dev/null +++ b/packages/shared/src/features/snapshot/SnapshotStats.tsx @@ -0,0 +1,100 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import colors from '../../styles/colors'; + +const MUTED = colors.salt['90']; +const DIVIDER = colors.pepper['10']; + +/** Tall enough to hold the level ring, so numbers and rings share one axis. */ +export const SNAPSHOT_STAT_HEIGHT = 116; + +export const SnapshotStatValue = ({ + children, + compact, +}: { + children: ReactNode; + /** For word-shaped values like a date, which run wider than a number. */ + compact?: boolean; +}): ReactElement => ( + + {children} + +); + +export const SnapshotStat = ({ + value, + label, +}: { + value: ReactNode; + label: string; +}): ReactElement => ( +
+ + {value} + + + {label} + +
+); + +/** A boxed headline number, for the cards that lead with two of them. */ +export const SnapshotTile = ({ + value, + label, + glyph, +}: { + value: string; + label: string; + glyph?: string; +}): ReactElement => ( +
+ + {value} + + + {label} {glyph} + +
+); + +export const SnapshotStatRow = ({ + children, +}: { + children: ReactNode; +}): ReactElement => ( +
+ {React.Children.toArray(children).map((child, index) => ( + // eslint-disable-next-line react/no-array-index-key + + {index > 0 && } + {child} + + ))} +
+); diff --git a/packages/shared/src/features/snapshot/StreakSnapshotCard.tsx b/packages/shared/src/features/snapshot/StreakSnapshotCard.tsx new file mode 100644 index 0000000000..82b9f109a5 --- /dev/null +++ b/packages/shared/src/features/snapshot/StreakSnapshotCard.tsx @@ -0,0 +1,85 @@ +import type { ReactElement } from 'react'; +import React, { forwardRef } from 'react'; +import colors from '../../styles/colors'; +import { SnapshotEyebrow } from './SnapshotEyebrow'; +import { SnapshotFrame } from './SnapshotFrame'; +import type { SnapshotIdentityProps } from './SnapshotIdentity'; +import { SnapshotIdentity } from './SnapshotIdentity'; + +const MUTED = colors.salt['90']; +const DIVIDER = colors.pepper['10']; + +export interface StreakSnapshotCardProps { + user: SnapshotIdentityProps; + days: number; + milestone?: string; + longestStreak: number; + totalReadingDays: number; + seed?: string; +} + +function StreakSnapshotCardComponent( + { + user, + days, + milestone, + longestStreak, + totalReadingDays, + seed, + }: StreakSnapshotCardProps, + ref: React.Ref, +): ReactElement { + return ( + } + ref={ref} + seed={seed ?? `streak-${days}`} + watermark="πŸ”₯" + > +
+ + +
+ + {days} + + + day reading streak + + {milestone && ( + + {milestone} + + )} +
+ +
+ + Longest streak {longestStreak} + + + Total reading days {totalReadingDays} + +
+
+
+ ); +} + +export const StreakSnapshotCard = forwardRef(StreakSnapshotCardComponent); diff --git a/packages/storybook/stories/features/snapshot/ShareImages.stories.tsx b/packages/storybook/stories/features/snapshot/ShareImages.stories.tsx new file mode 100644 index 0000000000..77c5dd47e4 --- /dev/null +++ b/packages/storybook/stories/features/snapshot/ShareImages.stories.tsx @@ -0,0 +1,652 @@ +import React, { useCallback, useRef, useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { SnapshotFrame } from '@dailydotdev/shared/src/features/snapshot/SnapshotFrame'; +import { ProfileSnapshotCard } from '@dailydotdev/shared/src/features/snapshot/ProfileSnapshotCard'; +import { ReadingOverviewSnapshotCard } from '@dailydotdev/shared/src/features/snapshot/ReadingOverviewSnapshotCard'; +import { BadgesSnapshotCard } from '@dailydotdev/shared/src/features/snapshot/BadgesSnapshotCard'; +import { AchievementsSnapshotCard } from '@dailydotdev/shared/src/features/snapshot/AchievementsSnapshotCard'; +import { AchievementSnapshotCard } from '@dailydotdev/shared/src/features/snapshot/AchievementSnapshotCard'; +import { AchievementRarityTier } from '@dailydotdev/shared/src/features/profile/components/achievements/achievementRarity'; +import { HighlightTextSnapshotCard } from '@dailydotdev/shared/src/features/snapshot/HighlightTextSnapshotCard'; +import { findHighlightRange } from '@dailydotdev/shared/src/features/snapshot/snapshotText'; +import { InviteSnapshotCard } from '@dailydotdev/shared/src/features/snapshot/InviteSnapshotCard'; +import { StreakSnapshotCard } from '@dailydotdev/shared/src/features/snapshot/StreakSnapshotCard'; +import { EntitySnapshotCard } from '@dailydotdev/shared/src/features/snapshot/EntitySnapshotCard'; +import { DiscussionSnapshotCard } from '@dailydotdev/shared/src/features/snapshot/DiscussionSnapshotCard'; +import { ListSnapshotCard } from '@dailydotdev/shared/src/features/snapshot/ListSnapshotCard'; +import { CelebrationSnapshotCard } from '@dailydotdev/shared/src/features/snapshot/CelebrationSnapshotCard'; +import { getSnapshotCaptureOptions } from '@dailydotdev/shared/src/features/snapshot/snapshotCapture'; +import { PostSnapshotCard } from '@dailydotdev/shared/src/features/snapshot/PostSnapshotCard'; +import { SnapshotEyebrow } from '@dailydotdev/shared/src/features/snapshot/SnapshotEyebrow'; +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import { LeaderboardSnapshotCard } from '@dailydotdev/shared/src/features/snapshot/LeaderboardSnapshotCard'; +import { AwardSnapshotCard } from '@dailydotdev/shared/src/features/snapshot/AwardSnapshotCard'; +import { captureShareImage } from '@dailydotdev/shared/src/lib/imageShare/captureShareImage'; +import { + Button, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; + +import { + ACHIEVEMENT_ART, + avatarUri, + BOBBY_AVATAR, + COVER_PLACEHOLDER, + HEATMAP, + PROFILE_USER, + thumbUri, + UNLOCKED_ART, +} from './snapshotFixtures'; +import type { SnapshotContentProps } from './SnapshotContent'; +import { + HIGHLIGHTS_EYEBROW_GRADIENT, + HOT_TAKE_EYEBROW_GRADIENT, + SnapshotContent, +} from './SnapshotContent'; + +/** The post page as it actually reads, for surface 1. */ +const POST = { + id: 'cdpr-physical', + title: "CD Projekt Red won't be abandoning physical releases", + summary: + "CD Projekt Red joint-CEO Michal Nowakowski says the studio has no plans to abandon physical game releases, despite Sony announcing it will end physical media production for PlayStation in 2028. Nowakowski notes CDPR doesn't control disc manufacturing (platform holders like Sony, Microsoft, and Nintendo do), but pledges to keep bundling extras into physical editions, potentially swapping the game disc for a download code, similar to the approach planned for GTA VI.", + createdAt: '2026-09-03T09:00:00.000Z', + readTime: 3, + domain: 'gamedeveloper.com', + image: thumbUri('#2A1436', '#0E0A18', 'The Witcher III'), + tags: ['tech-news', 'gaming', 'cd-projekt-red'], + numUpvotes: 44, + numComments: 11, + analytics: { impressions: 429900 }, + source: { + id: 'game-developer', + name: 'Game Developer', + image: avatarUri('#EC527A', 'G'), + }, +} as Post; + +/** A Happening now highlight, for surface 2 β€” same card, its own copy. */ +const HIGHLIGHT_POST = { + id: 'qwen-3-8-max', + summary: + 'Alibaba released downloadable weights for Qwen3.8-Max, a 2.4 trillion-parameter mixture-of-experts vision-language model, alongside the smaller Qwen3.8-27B, within a week of unveiling the Max model.', + source: { + id: 'alibaba-cloud', + name: 'Alibaba Cloud', + image: avatarUri('#FF6A00', 'A'), + }, +} as Post; + +/** + * A watercooler post, for surface 4 β€” a person's own words, so they take the + * credit rather than a publication. + */ +const WATERCOOLER_POST = { + id: 'watercooler-ripgrep', + summary: + 'Mine is ripgrep. I use it more than my editor at this point β€” every investigation starts with a search, and nothing else comes close on a big monorepo.', +} as Post; + +const WATERCOOLER_AUTHOR = { + name: 'Ante BariΔ‡', + image: avatarUri('#EC527A', 'A'), +}; + +interface Placement { + id: string; + surface: string; + watermark?: string; + /** Rides the logo row, far right β€” the surface's own label. */ + eyebrow?: { label: string; gradient?: string }; + /** Height follows the content, for the text-heavy surfaces. */ + grow?: boolean; + content?: SnapshotContentProps; + render?: (ref: (node: HTMLDivElement | null) => void) => React.ReactNode; +} + +const HIGHLIGHT_PARAGRAPH = + "He walks through the last two years without flinching: the week ChatGPT shipped, the quarter the layoffs started, the month Tailwind's business model came apart. The speaker shares a personal timeline from ChatGPT's release through AI-driven layoffs, Tailwind's business model disruption, and his own layoff. None of it is framed as a warning \u2014 it reads as the ordinary shape of a career in this industry now."; +const HIGHLIGHT_MARKED = + "The speaker shares a personal timeline from ChatGPT's release through AI-driven layoffs, Tailwind's business model disruption, and his own layoff"; +const HIGHLIGHT_PASSAGE = { + passage: HIGHLIGHT_PARAGRAPH, + highlight: findHighlightRange(HIGHLIGHT_PARAGRAPH, HIGHLIGHT_MARKED), +}; + +const PLACEMENTS: Placement[] = [ + { + id: 'post', + surface: '1 Β· Post page (under the TLDR)', + render: (ref) => , + }, + { + id: 'highlight-text', + surface: '1b Β· Highlighted text (reader selection)', + render: (ref) => ( + + ), + }, + { + id: 'highlight', + surface: '2 Β· Happening now (expanded highlight)', + render: (ref) => ( + + ), + }, + { + id: 'leaderboard', + surface: '3 Β· Leaderboard row', + render: (ref) => ( + + ), + }, + { + id: 'watercooler', + surface: '4 Β· Watercooler post', + render: (ref) => ( + + ), + }, + { + id: 'hot-take', + surface: '5 Β· Hot take', + watermark: 'πŸ”₯', + eyebrow: { label: 'Hot take', gradient: HOT_TAKE_EYEBROW_GRADIENT }, + grow: true, + content: { + // One take, one type style: split across a title and a body it read as + // two voices arguing the same point. + title: + 'Tabs won. Prettier just hid the bodies. Every formatter argument is a proxy war over indentation.', + titleLines: 0, + centered: true, + stat: { value: '128', label: 'found this hot' }, + statVariant: 'inline' as const, + }, + }, + { + id: 'profile', + surface: '6a Β· Profile header', + render: (ref) => ( + + ), + }, + { + id: 'reading-overview', + surface: '6b Β· Reading overview', + render: (ref) => ( + + ), + }, + { + id: 'badges', + surface: '6c Β· Badges & awards', + render: (ref) => ( + + ), + }, + { + id: 'achievements-widget', + surface: '6d Β· Achievements widget', + render: (ref) => ( + ({ + name: `achievement-${index}`, + image, + }))} + points={1240} + seed="achievements" + total={60} + unlocked={18} + user={{ ...PROFILE_USER, image: avatarUri('#B14BD7', 'T') }} + /> + ), + }, + { + id: 'achievement', + surface: '7 Β· Single achievement', + render: (ref) => ( + + ), + }, + { + id: 'invite', + surface: '8 Β· Invite a friend (#6366)', + render: (ref) => ( + + ), + }, + { + id: 'award', + surface: '9b Β· Being awarded (#6581)', + render: (ref) => ( + + ), + }, + { + id: 'streak', + surface: '9 Β· Reading streak (#6358)', + render: (ref) => ( + + ), + }, + { + id: 'tag', + surface: '10a Β· Tag page (#6357)', + render: (ref) => ( + + ), + }, + { + id: 'source', + surface: '10b Β· Source page (#6357)', + render: (ref) => ( + + ), + }, + { + id: 'squad', + surface: '11 Β· Squad (#6363)', + render: (ref) => ( + + ), + }, + { + id: 'discussion', + surface: '12 Β· Discussion (#6349)', + render: (ref) => ( + + ), + }, + { + id: 'briefing', + surface: '13a Β· Briefing / digest (#6353)', + render: (ref) => ( + + ), + }, + { + id: 'best-of', + surface: '13b Β· Best of / collection (#6364)', + render: (ref) => ( + + ), + }, + { + id: 'celebration', + surface: '14 Β· Level up (#6360)', + render: (ref) => ( + + ), + }, +]; + +const Gallery = () => { + const stage = useRef>({}); + const [images, setImages] = useState>({}); + const [isRunning, setIsRunning] = useState(false); + const [error, setError] = useState(null); + + const generateAll = useCallback(async () => { + setIsRunning(true); + setError(null); + + try { + // Sequential: snapdom rasterizes one 1080Β² tree at a time, and ten in + // parallel starves the main thread for long enough to look hung. + const next: Record = {}; + + for (const placement of PLACEMENTS) { + const node = stage.current[placement.id]; + + if (node) { + // eslint-disable-next-line no-await-in-loop + const blob = await captureShareImage( + node, + getSnapshotCaptureOptions(node), + ); + next[placement.id] = URL.createObjectURL(blob); + setImages({ ...next }); + } + } + } catch (e) { + setError(String(e)); + } finally { + setIsRunning(false); + } + }, []); + + return ( +
+
+

+ Snapshot share images β€” every placement +

+

+ The actual PNG each Snapshot button exports, one per surface. Every + image is generated by the real capture pipeline, so what you see here + is what gets shared. Most are 1080Γ—1080; the text surfaces grow taller + so the image carries more than a screenshot would. Press Generate to + re-render them all. +

+
+ + {error && ( + {error} + )} +
+
+ + {/* Off-screen stage: the real cards the capture reads from. */} +
+ {PLACEMENTS.map((placement) => { + const setRef = (node: HTMLDivElement | null) => { + stage.current[placement.id] = node; + }; + + if (placement.render) { + return ( + + {placement.render(setRef)} + + ); + } + + return ( + + ) + } + seed={placement.id} + watermark={placement.watermark} + ref={setRef} + > + + + ); + })} +
+ +
+ {PLACEMENTS.map((placement) => ( +
+
+ {placement.surface} +
+ {images[placement.id] ? ( + {placement.surface} + ) : ( +
+ {isRunning ? 'Rendering…' : 'Not generated yet'} +
+ )} +
+ ))} +
+
+ ); +}; + +const meta: Meta = { + title: 'Features/Snapshot/Share images', + component: Gallery, + parameters: { + layout: 'fullscreen', + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; + +export const AllPlacements: StoryObj = {}; diff --git a/packages/storybook/stories/features/snapshot/SharingMap.stories.tsx b/packages/storybook/stories/features/snapshot/SharingMap.stories.tsx new file mode 100644 index 0000000000..46f2e85888 --- /dev/null +++ b/packages/storybook/stories/features/snapshot/SharingMap.stories.tsx @@ -0,0 +1,148 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { Support } from './sharingMap'; +import { SHARING_MAP } from './sharingMap'; + +const H1 = ({ children }: { children: React.ReactNode }) => ( +

{children}

+); + +const H2 = ({ children }: { children: React.ReactNode }) => ( +

{children}

+); + +const P = ({ children }: { children: React.ReactNode }) => ( +

{children}

+); + +const Table = ({ + head, + rows, +}: { + head: string[]; + rows: React.ReactNode[][]; +}) => ( +
+ + + + {head.map((cell) => ( + + ))} + + + + {rows.map((row) => ( + + {row.map((cell, i) => ( + + ))} + + ))} + +
+ {cell} +
+ {cell} +
+
+); + +const Primary = ({ children }: { children: string }) => ( + {children} +); + +const SUPPORT: Record = { + core: core, + secondary: secondary, + none: β€”, +}; + +const MAP: React.ReactNode[][] = SHARING_MAP.map((row) => [ + row.surface, + row.pr, + SUPPORT[row.link], + SUPPORT[row.snapshot], + {row.leads}, + row.why, +]); + +const TARGETS: React.ReactNode[][] = [ + [ + 'Copy link', + 'Link only', + 'Going into a Slack thread or a DM, where a URL is the useful thing', + ], + [ + 'X', + 'Image + link in text', + 'Native images outperform link cards, and outbound links get demoted', + ], + [ + 'LinkedIn', + 'Image + link in text', + 'Same trade β€” native media beats an outbound link post', + ], + ['WhatsApp', 'Image + link', 'Renders inline in the conversation'], + ['Facebook', 'Image + link', 'Same inline rendering'], +]; + +const SharingMap = () => ( +
+

Where sharing lives, and what it should send

+

+ daily.dev has three share actions: copy link, share to a named target, and + snapshot. This maps every surface that can be shared to the one that + should lead there. +

+ +

The rule

+

+ One question decides it: does the destination add something the payload + does not? An article, a profile you can follow, a squad you can join β€” the + destination is the value, so send a link. A quote, a rank, a streak, an + unlocked achievement β€” the payload is the value, and often there is no + page for the recipient to visit at all. +

+ +

Surface decides the offer. Target decides the payload.

+

+ Copy link and share-to are not really two decisions, because the targets + are named rather than a system sheet. Once someone picks a target, the + payload follows from it β€” nobody has to choose between three buttons. +

+ + +

The map

+
+ +

Every snapshot needs a way back

+

+ Only the invite card carries a URL today, and only because a referral is + useless without one. Every other snapshot is a dead end: no path back to + daily.dev beyond the logo. The fix is to bake a short URL into every card. + The clipboard stays image-only: a link written beside the image arrives in + the composer as a stray line of text (#6556). +

+ +); + +const meta: Meta = { + title: 'Features/Snapshot/Sharing map', + component: SharingMap, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +export const Map: StoryObj = {}; diff --git a/packages/storybook/stories/features/snapshot/SnapshotContent.tsx b/packages/storybook/stories/features/snapshot/SnapshotContent.tsx new file mode 100644 index 0000000000..63df29d032 --- /dev/null +++ b/packages/storybook/stories/features/snapshot/SnapshotContent.tsx @@ -0,0 +1,202 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import colors from '@dailydotdev/shared/src/styles/colors'; + +const MUTED = colors.salt['90']; +const DIVIDER = colors.pepper['10']; + +/** Fire, for a take that ran hot: yellow core through orange into red. */ +export const HOT_TAKE_EYEBROW_GRADIENT = `linear-gradient(100deg, ${colors.cheese['40']} 0%, ${colors.ketchup['10']} 48%, ${colors.ketchup['50']} 100%)`; + +/** + * The production "Happening Now" wordmark animates across + * blueCheese -> cheese -> avocado. A still frame has to pick a position, and + * the yellow-to-green end is the one the brand shots use. + */ +export const HIGHLIGHTS_EYEBROW_GRADIENT = `linear-gradient(120deg, ${colors.cheese['40']} 0%, ${colors.avocado['10']} 52%, ${colors.avocado['40']} 100%)`; + +export interface SnapshotAvatar { + src?: string; + name: string; + handle?: string; +} + +export interface SnapshotStat { + value: string; + label: string; +} + +/** + * Generic card copy for the surfaces that have no designed card yet, so the + * gallery can still show them on the real frame. + */ +export interface SnapshotContentProps { + eyebrow?: string; + eyebrowGradient?: string; + avatar?: SnapshotAvatar; + emoji?: string; + title: string; + /** 0 lets the title run in full, for frames that grow to fit. */ + titleLines?: number; + meta?: string[]; + body?: string; + /** 0 lets the body run in full, for frames that grow to fit. */ + bodyLines?: number; + stat?: SnapshotStat; + /** + * 'display' sets the number apart at headline scale; 'inline' keeps it level + * with its label, so the pair reads as one sentence. + */ + statVariant?: 'display' | 'inline'; + /** Centres the copy and its stat, for a card that is one short statement. */ + centered?: boolean; +} + +// 0 means no clamp: a growing frame carries the copy instead of cutting it. +const clamp = (lines: number) => + lines + ? { + display: '-webkit-box' as const, + WebkitBoxOrient: 'vertical' as const, + WebkitLineClamp: lines, + overflow: 'hidden' as const, + } + : {}; + +export function SnapshotContent({ + eyebrow, + eyebrowGradient, + avatar, + emoji, + title, + titleLines = 4, + meta, + body, + bodyLines = 7, + stat, + statVariant = 'display', + centered, +}: SnapshotContentProps): ReactElement { + const isInlineStat = statVariant === 'inline'; + const statColor = eyebrowGradient + ? { + color: 'transparent', + backgroundImage: eyebrowGradient, + backgroundClip: 'text' as const, + WebkitBackgroundClip: 'text' as const, + } + : { color: colors.cabbage['10'] }; + return ( + <> + {eyebrow && ( + + {eyebrow} + + )} + + {emoji && ( + + {emoji} + + )} + + {avatar && ( +
+ {avatar.src && ( + + )} +
+ + {avatar.name} + + {avatar.handle && ( + + {avatar.handle} + + )} +
+
+ )} + +

+ {title} +

+ + {meta && meta.length > 0 && ( + + {meta.join(' Β· ')} + + )} + + {body && ( + <> + +

+ {body} +

+ + )} + + {stat && ( +
+ + {stat.value} + + {stat.label} +
+ )} + + ); +} diff --git a/packages/storybook/stories/features/snapshot/SnapshotEdgeCases.stories.tsx b/packages/storybook/stories/features/snapshot/SnapshotEdgeCases.stories.tsx new file mode 100644 index 0000000000..b243c3d5b2 --- /dev/null +++ b/packages/storybook/stories/features/snapshot/SnapshotEdgeCases.stories.tsx @@ -0,0 +1,673 @@ +import React, { useRef, useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { SNAPSHOT_SIZE } from '@dailydotdev/shared/src/features/snapshot/snapshotGradient'; +import { getSnapshotCaptureOptions } from '@dailydotdev/shared/src/features/snapshot/snapshotCapture'; +import { + findHighlightRange, + SNAPSHOT_COPY_SIZE, + SNAPSHOT_PASSAGE_LIMIT, + SNAPSHOT_TEXT_LIMIT, +} from '@dailydotdev/shared/src/features/snapshot/snapshotText'; +import { PostSnapshotCard } from '@dailydotdev/shared/src/features/snapshot/PostSnapshotCard'; +import { HighlightTextSnapshotCard } from '@dailydotdev/shared/src/features/snapshot/HighlightTextSnapshotCard'; +import { LeaderboardSnapshotCard } from '@dailydotdev/shared/src/features/snapshot/LeaderboardSnapshotCard'; +import { ProfileSnapshotCard } from '@dailydotdev/shared/src/features/snapshot/ProfileSnapshotCard'; +import { EntitySnapshotCard } from '@dailydotdev/shared/src/features/snapshot/EntitySnapshotCard'; +import { DiscussionSnapshotCard } from '@dailydotdev/shared/src/features/snapshot/DiscussionSnapshotCard'; +import { ListSnapshotCard } from '@dailydotdev/shared/src/features/snapshot/ListSnapshotCard'; +import { StreakSnapshotCard } from '@dailydotdev/shared/src/features/snapshot/StreakSnapshotCard'; +import { InviteSnapshotCard } from '@dailydotdev/shared/src/features/snapshot/InviteSnapshotCard'; +import { AchievementSnapshotCard } from '@dailydotdev/shared/src/features/snapshot/AchievementSnapshotCard'; +import { AchievementRarityTier } from '@dailydotdev/shared/src/features/profile/components/achievements/achievementRarity'; +import { captureShareImage } from '@dailydotdev/shared/src/lib/imageShare/captureShareImage'; +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; + +const AVATAR = `data:image/svg+xml;utf8,${encodeURIComponent( + 'T', +)}`; + +const USER = { name: 'Tomer Redlich', handle: '@tomer', image: AVATAR }; + +const POST = { + id: 'post-a', + summary: + 'Nokia, BlackBerry and Kodak all led their categories and all missed the same turn.', + domain: 'xda-developers.com', + source: { id: 'xda', name: 'XDA Developers', image: AVATAR }, +} as Post; + +const LOREM = + 'The bundler war is over and nobody noticed, because we spent five entire years optimising cold starts while the actual bottleneck was always the four hundred kilobytes of analytics we shipped on every single page load, and no amount of tree shaking was ever going to fix a problem that lived in the product requirements rather than the build graph.'; + +/** Long enough that the highlight, sitting near the end, has to be windowed to. */ +const LONG_PASSAGE = `Nobody set out to build it this way, and nobody in the room could have told you which meeting it started in. ${LOREM} ${LOREM} The honest version is that every one of those decisions was locally correct and the sum of them was not, which is the only interesting thing about it.`; + +const UNBREAKABLE = + 'ReallyLongGenericTypeParameterNameThatNeverBreaks https://app.daily.dev/posts/some-extremely-long-slug-that-keeps-going-and-going'; + +const HIGHLIGHT_CONTEXT = + 'Every framework team arrived at the same answer within about eighteen months of each other. TypeScript has become the default across frontend frameworks, and the holdouts are now the ones explaining themselves rather than the other way around.'; + +const SCALE = 0.34; + +interface CaseSpec { + label: string; + node: (ref: (n: HTMLDivElement | null) => void) => React.ReactNode; +} + +interface CardSpec { + id: string; + title: string; + note: string; + cases: CaseSpec[]; +} + +const CARDS: CardSpec[] = [ + { + id: 'highlight', + title: 'Highlighted text', + note: `The whole paragraph at one size (${SNAPSHOT_COPY_SIZE}px, the post card's), with the marked run picked out inside it. Over ${SNAPSHOT_PASSAGE_LIMIT} characters the passage is windowed around the highlight, cut at word boundaries. The frame grows or shrinks around it.`, + cases: [ + { + label: 'Typical (59 chars)', + node: (ref) => ( + + ), + }, + { + label: 'Very short (10 chars)', + node: (ref) => ( + + ), + }, + { + label: `Over the cap (${LONG_PASSAGE.length} chars β†’ windowed)`, + node: (ref) => ( + + ), + }, + { + label: 'Unbreakable strings', + node: (ref) => ( + + ), + }, + ], + }, + { + id: 'post', + title: 'Post', + note: 'The TLDR runs in full up to the passage limit. No credit without a source; an unattributed link credits its domain.', + cases: [ + { + label: 'Typical', + node: (ref) => ( + + ), + }, + { + label: 'Unattributed source', + node: (ref) => ( + + ), + }, + { + label: 'Overflowing TLDR', + node: (ref) => ( + + ), + }, + ], + }, + { + id: 'leaderboard', + title: 'Leaderboard rank', + note: 'Stat row never wraps; large values shorten to K/M.', + cases: [ + { + label: 'Typical (#1)', + node: (ref) => ( + + ), + }, + { + label: 'Seven-digit values, level 1000, rank 48', + node: (ref) => ( + + ), + }, + { + label: 'Zeroes, no avatar', + node: (ref) => ( + + ), + }, + ], + }, + { + id: 'profile', + title: 'Profile', + note: 'Cover falls back to a gradient band; bio collapses.', + cases: [ + { + label: 'Typical', + node: (ref) => ( + + ), + }, + { + label: 'No cover, no bio, zeroes', + node: (ref) => ( + + ), + }, + { + label: 'Long name and bio, 7-digit reputation', + node: (ref) => ( + + ), + }, + ], + }, + { + id: 'entity', + title: 'Tag / source / squad', + note: 'Tags use a hash tile; description collapses when absent.', + cases: [ + { + label: 'Tag, typical', + node: (ref) => ( + + ), + }, + { + label: 'Long tag, no description, one stat', + node: (ref) => ( + + ), + }, + { + label: 'Source, no image', + node: (ref) => ( + + ), + }, + ], + }, + { + id: 'discussion', + title: 'Discussion', + note: `Comment truncates at ${SNAPSHOT_TEXT_LIMIT} characters, post title clamps at 2 lines.`, + cases: [ + { + label: 'Typical', + node: (ref) => ( + + ), + }, + { + label: 'Long comment and title, no avatar', + node: (ref) => ( + + ), + }, + ], + }, + { + id: 'list', + title: 'Briefing / best-of', + note: 'Shows at most 5 rows; each title clamps at 2 lines.', + cases: [ + { + label: 'Five rows', + node: (ref) => ( + + ), + }, + { + label: 'One row, no subtitle', + node: (ref) => ( + + ), + }, + { + label: 'Overflowing titles', + node: (ref) => ( + ({ + title: LOREM.slice(0, 90 + i * 10), + meta: UNBREAKABLE.slice(0, 40), + }))} + seed="li-c" + subtitle={LOREM.slice(0, 70)} + title={LOREM.slice(0, 80)} + /> + ), + }, + ], + }, + { + id: 'streak', + title: 'Reading streak', + note: 'Zero is meaningful here, so it renders rather than hides.', + cases: [ + { + label: 'Typical (100)', + node: (ref) => ( + + ), + }, + { + label: 'Day one, no milestone', + node: (ref) => ( + + ), + }, + { + label: 'Four digits', + node: (ref) => ( + + ), + }, + ], + }, + { + id: 'invite', + title: 'Invite', + note: 'Perk collapses when there is no reward to offer.', + cases: [ + { + label: 'Typical', + node: (ref) => ( + + ), + }, + { + label: 'No perk, long name and link', + node: (ref) => ( + + ), + }, + ], + }, + { + id: 'achievement', + title: 'Single achievement', + note: 'Gold pill is reserved for sub-1%; art falls back to the card body.', + cases: [ + { + label: 'Sub-1% with art', + node: (ref) => ( + + ), + }, + { + label: 'Common tier, no art, long copy', + node: (ref) => ( + + ), + }, + ], + }, +]; + +const Case = ({ spec }: { spec: CaseSpec }) => { + const ref = useRef(null); + const [png, setPng] = useState(null); + const [isBusy, setIsBusy] = useState(false); + + const capture = async () => { + if (!ref.current) { + return; + } + + setIsBusy(true); + try { + const blob = await captureShareImage( + ref.current, + getSnapshotCaptureOptions(ref.current), + ); + setPng(URL.createObjectURL(blob)); + } finally { + setIsBusy(false); + } + }; + + return ( +
+
+ {spec.label} +
+ {/* Live DOM at scale: 40-odd states as real captures would take minutes, + and the layout is identical either way. zoom, not transform: a growing + frame has to push the preview box taller instead of being clipped. */} +
+
+ {spec.node((node) => { + ref.current = node; + })} +
+
+
+ + {png && ( + + open 1080Β² + + )} +
+
+ ); +}; + +const EdgeCases = () => ( +
+
+

+ Snapshot edge cases +

+

+ Every card under the states that break layouts: nothing to show, far too + much to show, unbreakable strings, and numbers with more digits than the + design expected. Rendered live at {Math.round(SCALE * 100)}% β€” press + Capture on any one to get the real 1080Β² PNG. +

+
+ + {CARDS.map((card) => ( +
+
+

+ {card.title} +

+

{card.note}

+
+
+ {card.cases.map((spec) => ( + + ))} +
+
+ ))} +
+); + +const meta: Meta = { + title: 'Features/Snapshot/Edge cases', + component: EdgeCases, + parameters: { layout: 'fullscreen' }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; + +export const AllStates: StoryObj = {}; diff --git a/packages/storybook/stories/features/snapshot/SnapshotPlacements.stories.tsx b/packages/storybook/stories/features/snapshot/SnapshotPlacements.stories.tsx new file mode 100644 index 0000000000..2144687b36 --- /dev/null +++ b/packages/storybook/stories/features/snapshot/SnapshotPlacements.stories.tsx @@ -0,0 +1,1253 @@ +import classNames from 'classnames'; +import React, { useRef, useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { SnapshotButton } from '@dailydotdev/shared/src/components/imageShare/SnapshotButton'; +import Toast from '@dailydotdev/shared/src/components/notifications/Toast'; +// Via the barrel on purpose: the direct hook path is aliased to a mock that +// swallows toasts into console.log, so the real one never runs. +import { ToastType, useToastNotification } from '@dailydotdev/shared/src/hooks'; +import { + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import type { UserAchievement } from '@dailydotdev/shared/src/graphql/user/achievements'; +import { AchievementType } from '@dailydotdev/shared/src/graphql/user/achievements'; +import { + EditIcon, + ArrowIcon, + HotIcon, + MedalBadgeIcon, + UpvoteIcon, + DiscussIcon, + BookmarkIcon, + LinkIcon, + ShareIcon, + CopyIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { Button } from '@dailydotdev/shared/src/components/buttons/Button'; + +const AVATAR = + 'https://res.cloudinary.com/daily-now/image/upload/s--O0TOmw4y--/f_auto/v1715772965/public/noProfile'; + +const CaptureContext = React.createContext<(blob: Blob) => void>(() => {}); + +const useCaptureSink = () => React.useContext(CaptureContext); + +const Snapshot = ( + props: Omit, 'onCapture'>, +) => ; + +type LeadAction = 'Link' | 'Share to' | 'Snapshot'; + +/** Only the action that should lead the surface β€” see the Sharing map. */ +const PreferredActions = ({ + leads, + target, + filename, + className, + compact, + size = ButtonSize.Small, + variant = ButtonVariant.Secondary, +}: { + leads: LeadAction; + target: React.RefObject; + filename: string; + className?: string; + /** Icon only, for headers that already carry a title and a link. */ + compact?: boolean; + /** Match whatever sits beside it β€” a control that differs reads as a bug. */ + size?: ButtonSize; + variant?: ButtonVariant; +}) => { + if (leads === 'Snapshot') { + return ( + + ); + } + + return ( + + ); +}; + +/** Snapshot leads only where the payload is the value β€” see the Sharing map. */ +const LEAD_STYLE: Record = { + Link: 'text-text-tertiary border-border-subtlest-tertiary', + 'Share to': 'text-text-tertiary border-border-subtlest-tertiary', + Snapshot: 'text-accent-cabbage-default border-accent-cabbage-default', +}; + +/** + * Most remaining surfaces are one of two shapes: a section header with a + * control on the right, or a card with a control in its footer. Two mocks + * cover them rather than thirteen bespoke ones. + */ +const HeaderSurface = ({ + eyebrow, + title, + meta, + leads, + filename, + trailing, +}: { + eyebrow?: string; + title: string; + meta?: string; + leads: LeadAction; + filename: string; + trailing?: React.ReactNode; +}) => { + const ref = useRef(null); + + return ( +
+
+ {eyebrow && ( + + {eyebrow} + + )} + + {title} + + {meta && ( + + {meta} + + )} +
+ {trailing} + +
+ ); +}; + +const CardSurface = ({ + title, + body, + footer, + leads, + filename, + variant, +}: { + title: string; + body?: string; + footer?: React.ReactNode; + leads: LeadAction; + filename: string; + variant?: ButtonVariant; +}) => { + const ref = useRef(null); + + return ( +
+
+ {title} + {body &&

{body}

} +
+
+ {footer} + +
+
+ ); +}; + +/** + * The floating bar from #6352: copy link, copy text, quote, share. Rebuilt + * here because that PR is closed and SelectionShareBar never reached main β€” + * same control set and chrome, with Snapshot added as the action that leads. + */ +const SelectionShareBarDemo = () => { + const ref = useRef(null); + + return ( +
+
+
+ + + Why iconic tech brands like HTC and LG lost their dominance + +

+ A brief retrospective on how once-dominant brands declined.{' '} + + TypeScript has become the default across frontend frameworks + {' '} + and the rest of the stack followed within two release cycles. +

+
+ ); +}; + +/** One control with the surfaces it appears on, so the set stays auditable. */ +const Specimen = ({ + control, + name, + used, +}: { + control: React.ReactNode; + name: string; + used: string; +}) => ( +
+
{control}
+ {name} + {used} +
+); + +/** + * Toasts render into a portal the app mounts once, so a story has to mount it + * too or the feedback is invisible while testing. + */ +const ToastPreview = () => { + const { displayToast } = useToastNotification(); + + return ( +
+

Feedback

+

+ Snapshot puts the image on the clipboard, so it can be pasted straight + into a chat or a composer. That is invisible without a confirmation. + Press a Snapshot button anywhere on this page to see the real one, or + trigger them here. +

+
+ + + + +
+
+ ); +}; + +const Panel = ({ + step, + title, + note, + leads, + children, +}: { + step: string; + title: string; + note: string; + leads: LeadAction; + children: React.ReactNode; +}) => ( +
+
+ + {step} + +
+

{title}

+ + leads with {leads} + +
+

{note}

+
+
+ {children} +
+
+); + +/** 1. Post page β€” the button sits under the TLDR paragraph. */ +const PostTldrPlacement = () => { + const ref = useRef(null); + + return ( +
+
+
+ + + XDA Developers + +
+

+ Why iconic tech brands like HTC and LG lost their dominance +

+

+ Yesterday Β· 1m read time Β· From xda-developers.com +

+

+ A brief retrospective on how once-dominant tech and smartphone brands + declined, citing OnePlus's recent troubles, LG's exit from + the mobile business, and HTC's fall from once outselling Apple in + America to a niche VR-focused company. +

+
+ +
+ ); +}; + +/** 2. Happening now β€” next to "Read more" on an expanded highlight. */ +const HighlightPlacement = () => { + const ref = useRef(null); + + return ( +
+
+
+ + Alibaba open-sources Qwen3.8-Max weights and releases 27B model for + local use + + + 14h ago + +
+ +
+
+

+ Alibaba released downloadable weights for Qwen3.8-Max, a 2.4 + trillion-parameter mixture-of-experts vision-language model, alongside + the smaller Qwen3.8-27B, within a week of unveiling the Max model. +

+
+ Read more + +
+
+
+ ); +}; + +/** 3. Leaderboard β€” icon-only, revealed on row hover. */ +const LEADERBOARD_ROWS = [ + { score: 15500, name: 'Bobby Iliev', handle: 'bobbyiliev', level: 103 }, + { score: 14200, name: 'Keshav Ashiya', handle: 'keshavashiya', level: 98 }, + { score: 13700, name: 'Hadil Ben Abdallah', handle: 'hadilben', level: 96 }, +]; + +const LeaderboardPlacement = () => ( +
    + {LEADERBOARD_ROWS.map((row) => ( +
  • + + {row.score.toLocaleString()} + + + {row.level} + + + + + {row.name} + + + @{row.handle} + + + +
  • + ))} +
+); + +/** 4. Watercooler feed β€” one per post card, in the card action row. */ +const WatercoolerPlacement = () => { + const ref = useRef(null); + + return ( +
+
+
+ +
+ + Ante Barić + + + Watercooler · 2h + +
+
+

+ What is the one dev tool you would not give up? +

+

+ Mine is ripgrep. I use it more than my editor at this point. +

+
+
+ + + + +
+
+ ); +}; + +/** 5. Hot takes modal β€” icon-only, top-right of the swipe card. */ +const HotTakePlacement = () => { + const ref = useRef(null); + + return ( +
+
+
+ πŸ”₯ +
+

+ Tabs won. Prettier just hid the bodies. +

+

+ Every formatter argument is a proxy war over indentation. +

+
+
+ + + 128 + +
+ +
+
+
+ ); +}; + +/** 6a. Profile header β€” right of the edit button. */ +const ProfileHeaderPlacement = () => { + const ref = useRef(null); + + return ( +
+
+ +
+
+
+ + Tomer Redlich + + @tomer +
+
+ ); +}; + +/** 6b–6d. Profile widgets β€” icon-only, in the widget header row. */ +const WidgetPlacement = ({ + title, + trailing, + children, +}: { + title: React.ReactNode; + trailing?: React.ReactNode; + children: React.ReactNode; +}) => { + const ref = useRef(null); + + return ( +
+
+

+ {title} +

+
+ {trailing} + +
+
+ {children} +
+ ); +}; + +const ACHIEVEMENT: UserAchievement = { + achievement: { + id: 'achievement-1', + name: 'Streak keeper', + description: 'Read something on daily.dev 100 days in a row.', + image: + 'https://media.daily.dev/image/upload/s--SNnLKKWe--/q_auto/v1773608419/achievements/coraholic', + points: 120, + rarity: 4, + type: AchievementType.Milestone, + criteria: { targetCount: 100 }, + unit: 'days', + }, + progress: 100, + unlockedAt: '2026-06-01T00:00:00Z', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-06-01T00:00:00Z', +}; + +const AchievementBox = ({ entry }: { entry: UserAchievement }) => { + const isUnlocked = entry.unlockedAt !== null; + + return ( +
+
+ +
+ + {entry.achievement.name} + + + {entry.achievement.description} + +
+
+ + + {entry.achievement.points} + +
+
+
+ ); +}; + +const LOCKED_ACHIEVEMENT: UserAchievement = { + ...ACHIEVEMENT, + achievement: { + ...ACHIEVEMENT.achievement, + id: 'achievement-2', + name: 'First take', + description: 'Post your first hot take.', + rarity: 38, + image: + 'https://media.daily.dev/image/upload/v1770222937/achievements/Town_crier.png', + criteria: { targetCount: 1 }, + unit: null, + }, + progress: 0, + unlockedAt: null, +}; + +const Placements = () => { + const [capture, setCapture] = useState(null); + const onCapture = React.useCallback((blob: Blob) => { + setCapture(URL.createObjectURL(blob)); + }, []); + + return ( + +
+
+

+ Snapshot button placements +

+

+ Every surface that gets a Snapshot control, and which of the three + share actions leads there. Pressing any button captures its + surrounding block into the square share image, shown in the panel + below. +

+

+ Snapshot leads where the + payload is the value and there is often no page to visit: a quote, a + rank, a take, an unlocked achievement.{' '} + Link leads where the + destination adds something the image cannot β€” the article, the + profile you can follow, the squad you can join. Snapshot still + appears on those surfaces, just not first. Full reasoning in{' '} + Sharing map. +

+

+ Placements 1–7 are built and live; 8–20 are mock-ups of surfaces the + Sharing map covers but the code does not touch yet, so the control + and its verdict can be reviewed before anything is wired. +

+
+ +
+

+ Last capture +

+ {capture ? ( + Captured share image + ) : ( +

+ Press a Snapshot button to render the share image here. +

+ )} +
+ + + +
+

+ The control +

+
+ + } + name="Snapshot β€” labelled" + used="Leads on Happening now, hot takes, briefing, streak, copy my feed and the profile widgets" + /> + + } + name="Snapshot β€” icon, hover revealed" + used="Leaderboard rows and achievement cards, where a labelled button would crowd the list" + /> + + } + name="Snapshot β€” icon, quiet" + used="The floating selection bar and the profile widget headers, where it sits among other icons" + /> + } + size={ButtonSize.Small} + variant={ButtonVariant.Secondary} + > + Copy link + + } + name="Copy link β€” labelled" + used="Leads on the post, watercooler, profile header, tags and sources, leaderboard page, history, squads, best-of and invite" + /> + } + size={ButtonSize.Small} + variant={ButtonVariant.Secondary} + > + Share + + } + name="Share β€” labelled" + used="Leads on the DevCard, which is already an image" + /> + +
+ } + name="The selection bar set" + used="#6352 in full: copy link, copy text, quote, share, and Snapshot alongside them" + /> +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + Learn more + + } + > +

+ Posts read in the last months (412) +

+
+ {Array.from({ length: 36 }).map((_, i) => ( + + ))} +
+
+ + +
+
+ + x4 + + + Top reader badge + +
+
+ + x12 + + + Total Awards + +
+
+
+ + + + Achievements + + } + trailing={ + 18/60 + } + > +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+
+
+
+
+ + +
+ {[ACHIEVEMENT, LOCKED_ACHIEVEMENT].map((entry) => ( + + ))} +
+
+ + + + + + + + + + + + + + + + + + +
+ + Follow + + } + /> + + Follow + + } + /> +
+
+ + + + + + + + + + + + Download + + } + /> + + + + + + + + + + + + + Join + + } + /> + + + + + + + + + +
+ + ); +}; + +const meta: Meta = { + title: 'Features/Snapshot/Button placements', + component: Placements, + parameters: { + layout: 'fullscreen', + }, + decorators: [ + (Story) => ( + + + {/* The app mounts this once; a story has to as well or the feedback + never appears. */} + + + ), + ], +}; + +export default meta; + +export const AllPlacements: StoryObj = {}; diff --git a/packages/storybook/stories/features/snapshot/SnapshotSpec.stories.tsx b/packages/storybook/stories/features/snapshot/SnapshotSpec.stories.tsx new file mode 100644 index 0000000000..218ff5e95b --- /dev/null +++ b/packages/storybook/stories/features/snapshot/SnapshotSpec.stories.tsx @@ -0,0 +1,293 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + SNAPSHOT_MAX_HEIGHT, + SNAPSHOT_SIZE, +} from '@dailydotdev/shared/src/features/snapshot/snapshotGradient'; +import { SNAPSHOT_CARD_SIZE } from '@dailydotdev/shared/src/features/snapshot/SnapshotFrame'; +import { SNAPSHOT_TEXT_LIMIT } from '@dailydotdev/shared/src/features/snapshot/snapshotText'; + +const H1 = ({ children }: { children: React.ReactNode }) => ( +

{children}

+); + +const H2 = ({ children }: { children: React.ReactNode }) => ( +

{children}

+); + +const P = ({ children }: { children: React.ReactNode }) => ( +

{children}

+); + +const Table = ({ + head, + rows, +}: { + head: string[]; + rows: React.ReactNode[][]; +}) => ( +
+
+ + + {head.map((cell) => ( + + ))} + + + + {rows.map((row) => ( + + {row.map((cell, i) => ( + + ))} + + ))} + +
+ {cell} +
+ {cell} +
+
+); + +const Code = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + +const CANVAS = [ + [ + 'Export', + `${SNAPSHOT_SIZE} wide PNG`, + `Square by default; text surfaces grow to ${SNAPSHOT_MAX_HEIGHT} tall`, + ], + [ + 'Card', + `${SNAPSHOT_CARD_SIZE} Γ— ${SNAPSHOT_CARD_SIZE} min`, + 'Grows with content; 48px radius, 2px lit edge', + ], + ['Card padding', '58px', 'Cover art escapes it with negative margins'], + ['Body', '#0B0812', 'Darker than the ground so the edge reads'], + ['Logo', '36px tall, centred below the card', 'Always white, never themed'], +]; + +const TYPE = [ + ['Hero headline', '56–72px bold', 'Post title, quote, entity name'], + ['Card title', '46–54px bold', 'Collectible name, streak label, list title'], + [ + 'Stat value', + '52px bold (40px compact)', + 'Compact for word-shaped values like a date', + ], + ['Body', '28px, 1.55 line height', 'TLDR, description, comment context'], + ['Meta', '26px', 'Date, read time, domain, counts'], + [ + 'Eyebrow / stat label', + '22px bold uppercase, 2px tracking', + 'Section labels', + ], +]; + +const CARDS = [ + [ + 'Post', + '#6350', + 'Source, title, date, read time, domain, TLDR', + 'Title, source', + ], + ['Highlighted text', '#6352', 'Quote, source, post title, domain', 'Quote'], + [ + 'Happening now', + '#6355', + 'Gradient eyebrow, headline, age, TLDR', + 'Headline', + ], + [ + 'Leaderboard rank', + '#6359', + 'Rank pill, avatar, name, XP, level ring, reputation', + 'Rank, name, all three stats', + ], + [ + 'Watercooler post', + 'β€”', + 'Author, title, body, age, comments', + 'Author, title', + ], + [ + 'Hot take', + '#6365', + 'Fire eyebrow, take, subtitle, upvotes, flame watermark', + 'Take', + ], + [ + 'Profile', + '#6354', + 'Cover, avatar, name, handle, bio, posts read, joined, reputation', + 'Name, handle', + ], + [ + 'Reading overview', + '#6358', + 'Identity, streak + total days tiles, top tags, heatmap', + 'Identity, both tiles', + ], + [ + 'Badges & awards', + '#6360', + 'Identity, badge + award tiles, keyword list, award tally', + 'Identity, both tiles', + ], + [ + 'Achievements', + '#6360', + 'Identity, unlocked/total, points, rarest grid', + 'Identity, counts', + ], + [ + 'Single achievement', + '#6360', + 'Full-bleed art, rarity pill, name, description, date', + 'Art, name', + ], + [ + 'Invite', + '#6366', + 'Avatar, name, handle, headline, perk, link', + 'Headline, link', + ], + [ + 'Reading streak', + '#6358', + 'Identity, day count, milestone, longest, total', + 'Day count', + ], + [ + 'Tag / source / squad', + '#6357, #6363', + 'Kind label, image or hash, name, handle, description, 3 stats', + 'Name, at least one stat', + ], + [ + 'Discussion', + '#6349', + 'Comment, post title, author, upvotes, replies', + 'Comment, author', + ], + [ + 'Briefing / best-of', + '#6353, #6364', + 'Eyebrow, title, subtitle, up to 5 ranked rows', + 'Title, β‰₯1 row', + ], + ['Level up', '#6360', 'Identity, level ring, headline, XP, quests', 'Level'], +]; + +const RULES = [ + [ + 'Text over the cap', + <> + Truncated at the last full word plus an ellipsis at{' '} + {SNAPSHOT_TEXT_LIMIT} characters. A selection is never + refused. + , + ], + [ + 'Text well under the cap', + 'The quote scales up instead: 72 / 60 / 48 / 40px by length. Short highlights are allowed at any length.', + ], + [ + 'Unbreakable strings', + <> + overflow-wrap: anywhere keeps long URLs and type names inside + the card. + , + ], + [ + 'Ragged wrapping', + <> + text-wrap: balance on every headline evens the line lengths + and removes the orphan last word. Hyphenation is off. + , + ], + [ + 'Missing optional copy', + 'The region collapses. No empty band, no stray divider.', + ], + [ + 'Missing image', + 'Falls back to an initial or token tile. A hung image is caught by the 15s capture timeout.', + ], + [ + 'Zero counts', + 'The stat is hidden rather than printed as 0 β€” except streak and level, where zero is meaningful.', + ], + [ + 'Large numbers', + <> + Run through largeNumberFormat (12.4K, 1.2M) and never wrap. + , + ], + [ + 'RTL and non-Latin', + 'Not handled yet. Needs dir="auto" on the quote and body blocks β€” tracked as a follow-up.', + ], +]; + +const Spec = () => ( +
+

Snapshot share images β€” spec

+

+ A Snapshot turns a surface into a square image built for sharing. Every + card is a real React component rasterized by snapdom and composed onto one + canvas, so what renders in Storybook is what ships. The gradient is seeded + from the subject's id, so a given post or profile always produces the + same background. +

+ +

Canvas

+ + +

Type scale

+
+ +

Cards and their content

+

+ “Required” is the content without which the card should not be + offered. Everything else collapses when absent. +

+
+ +

Content rules

+
+ +

Background

+

+ Sampled from the App Store screenshots: a near-black violet ground, one + large halo behind the subject, and a quieter wash along the bottom. The + seed only moves the halo's position, hue and intensity, so every + image stays in one family. The card wears the device-frame treatment β€” a + lit hairline brightest along the top edge, fading by the middle. +

+ +); + +const meta: Meta = { + title: 'Features/Snapshot/Spec', + component: Spec, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +export const Documentation: StoryObj = {}; diff --git a/packages/storybook/stories/features/snapshot/sharingMap.ts b/packages/storybook/stories/features/snapshot/sharingMap.ts new file mode 100644 index 0000000000..63c6c3bada --- /dev/null +++ b/packages/storybook/stories/features/snapshot/sharingMap.ts @@ -0,0 +1,179 @@ +/** + * The decision behind every share control, shared by the Sharing map and the + * Visibility variations pages so the two can never drift apart. + */ +export type LeadAction = 'Link' | 'Share to' | 'Snapshot'; + +export type Support = 'core' | 'secondary' | 'none'; + +export interface SharingMapRow { + surface: string; + pr: string; + link: Support; + snapshot: Support; + leads: LeadAction; + why: string; +} + +export const SHARING_MAP: SharingMapRow[] = [ + { + surface: 'Post page & modal', + pr: '6350', + link: 'core', + snapshot: 'none', + leads: 'Link', + why: 'It already has a real OG image, so the link carries the payload', + }, + { + surface: 'Highlighted text', + pr: '6352', + link: 'secondary', + snapshot: 'core', + leads: 'Snapshot', + why: 'The quote is the share; the link is attribution', + }, + { + surface: 'End of conversation', + pr: '6349', + link: 'core', + snapshot: 'secondary', + leads: 'Link', + why: 'The thread keeps moving; a still frame goes stale', + }, + { + surface: 'Post-upvote prompt', + pr: '6351', + link: 'core', + snapshot: 'none', + leads: 'Link', + why: 'Same payload as the post β€” no second image', + }, + { + surface: 'Briefing / digest', + pr: '6353', + link: 'none', + snapshot: 'core', + leads: 'Snapshot', + why: 'Personalized: a link gives them their briefing, or nothing', + }, + { + surface: 'Profile', + pr: '6354', + link: 'core', + snapshot: 'secondary', + leads: 'Link', + why: 'The point is that they follow you', + }, + { + surface: 'Tags & sources', + pr: '6357', + link: 'core', + snapshot: 'none', + leads: 'Link', + why: 'A live feed; an image of a tag says little', + }, + { + surface: 'Leaderboard β€” the board', + pr: '6359', + link: 'core', + snapshot: 'secondary', + leads: 'Link', + why: 'It changes weekly', + }, + { + surface: 'Leaderboard β€” my rank', + pr: '6359', + link: 'secondary', + snapshot: 'core', + leads: 'Snapshot', + why: 'Status content is image-first', + }, + { + surface: 'Happening Now', + pr: '6355', + link: 'secondary', + snapshot: 'core', + leads: 'Snapshot', + why: 'Payload β‰ˆ the whole page, and news travels in chat apps', + }, + { + surface: 'Reading streak', + pr: '6358', + link: 'none', + snapshot: 'core', + leads: 'Snapshot', + why: 'A link to your streak means nothing to anyone else', + }, + { + surface: 'Celebrations & achievements', + pr: '6360', + link: 'none', + snapshot: 'core', + leads: 'Snapshot', + why: 'Pure status', + }, + { + surface: 'DevCard', + pr: '6356', + link: 'secondary', + snapshot: 'none', + leads: 'Share to', + why: 'Already an image β€” do not wrap an image in an image', + }, + { + surface: 'Reading history', + pr: '6361', + link: 'core', + snapshot: 'none', + leads: 'Link', + why: 'Each row is just a post', + }, + { + surface: 'Copy my feed', + pr: '6362', + link: 'none', + snapshot: 'core', + leads: 'Snapshot', + why: 'No URL anyone else can open', + }, + { + surface: 'Squad directory', + pr: '6363', + link: 'core', + snapshot: 'secondary', + leads: 'Link', + why: 'The point is joining', + }, + { + surface: 'Best-of / discovery', + pr: '6364', + link: 'core', + snapshot: 'secondary', + leads: 'Link', + why: 'Evergreen page worth landing on', + }, + { + surface: 'Hot takes', + pr: '6365', + link: 'secondary', + snapshot: 'core', + leads: 'Snapshot', + why: 'Opinion is quotable and self-contained', + }, + { + surface: 'Invite a friend', + pr: '6366', + link: 'core', + snapshot: 'secondary', + leads: 'Link', + why: 'An image of a referral cannot be clicked', + }, + { + surface: 'Watercooler post', + pr: 'β€”', + link: 'core', + snapshot: 'secondary', + leads: 'Link', + why: 'It is a post', + }, +]; diff --git a/packages/storybook/stories/features/snapshot/snapshotFixtures.ts b/packages/storybook/stories/features/snapshot/snapshotFixtures.ts new file mode 100644 index 0000000000..5b398d20df --- /dev/null +++ b/packages/storybook/stories/features/snapshot/snapshotFixtures.ts @@ -0,0 +1,53 @@ +/* Fixtures shared by the Snapshot stories. */ + +// Inlined so the capture never waits on a fetch: MSW intercepts every request +// inside Storybook, and a pending image stalls snapdom's inliner. +export const BOBBY_AVATAR = + 'data:image/jpeg;base64,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'; + +export const COVER_PLACEHOLDER = `data:image/svg+xml;utf8,${encodeURIComponent( + '', +)}`; + +// A plausible six-month read history: dense midweek, quieter at the edges. +export const HEATMAP = Array.from({ length: 88 }, (_, i) => { + const wave = Math.sin(i / 5) + Math.cos(i / 3.2); + return Math.max(0, Math.min(3, Math.round(0.55 + wave * 0.85))); +}); + +// Real production artwork: media.daily.dev serves these with CORS, so the +// capture can inline them. +export const ACHIEVEMENT_ART = + 'https://media.daily.dev/image/upload/s--_MjhSTze--/q_auto/v1773608417/achievements/cant_spend_it_all'; + +export const UNLOCKED_ART = [ + 'https://media.daily.dev/image/upload/s--UV44P2mG--/v1779263302/achievements/big_byte_energy', + 'https://media.daily.dev/image/upload/s--SNnLKKWe--/q_auto/v1773608419/achievements/coraholic', + 'https://media.daily.dev/image/upload/v1770222928/achievements/In_the_big_league.png', + 'https://media.daily.dev/image/upload/s--h7KVoOJI--/q_auto/v1773608418/achievements/referral_spree', + 'https://media.daily.dev/image/upload/v1770222884/achievements/Boosted.png', + 'https://media.daily.dev/image/upload/s--5WqXv9y7--/q_auto/v1773743176/achievements/heros_quest', + 'https://media.daily.dev/image/upload/s--N7NXEDEH--/q_auto/v1770803408/achievements/the_head_of_the_committee.png', + 'https://media.daily.dev/image/upload/s--W0-BqBQd--/v1783416167/achievements/Devil_is_impressed', + 'https://media.daily.dev/image/upload/v1770222923/achievements/Organized.png', + 'https://media.daily.dev/image/upload/v1770222937/achievements/Town_crier.png', +]; + +export const PROFILE_USER = { + name: 'Tomer Redlich', + handle: '@tomer', +}; + +export const avatarUri = (fill: string, glyph: string) => + `data:image/svg+xml;utf8,${encodeURIComponent( + `${glyph}`, + )}`; + +/** + * A stand-in post thumbnail. Inline, like the avatars: a real cross-origin + * image is intercepted by MSW inside Storybook and never reaches the capture. + */ +export const thumbUri = (from: string, to: string, label: string) => + `data:image/svg+xml;utf8,${encodeURIComponent( + `${label}`, + )}`; diff --git a/packages/storybook/stories/features/snapshot/surfaceChrome.tsx b/packages/storybook/stories/features/snapshot/surfaceChrome.tsx new file mode 100644 index 0000000000..5118cb20af --- /dev/null +++ b/packages/storybook/stories/features/snapshot/surfaceChrome.tsx @@ -0,0 +1,230 @@ +import React from 'react'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + LinkIcon, + ShareIcon, + SnapshotIcon, +} from '@dailydotdev/shared/src/components/icons'; +import type { LeadAction } from './sharingMap'; + +export const AVATAR = + 'https://res.cloudinary.com/daily-now/image/upload/s--O0TOmw4y--/f_auto/v1715772965/public/noProfile'; + +export const ART = + 'https://media.daily.dev/image/upload/s--_MjhSTze--/q_auto/v1773608417/achievements/cant_spend_it_all'; + +/* ------------------------------------------------------------------ prose */ + +export const H1 = ({ children }: { children: React.ReactNode }) => ( +

{children}

+); + +export const H2 = ({ children }: { children: React.ReactNode }) => ( +

{children}

+); + +export const P = ({ children }: { children: React.ReactNode }) => ( +

{children}

+); + +export const Note = ({ children }: { children: React.ReactNode }) => ( +

+ {children} +

+); + +/* ---------------------------------------------------------------- controls */ + +export const ICONS: Record = { + Link: , + 'Share to': , + Snapshot: , +}; + +export const LABELS: Record = { + Link: 'Copy link', + 'Share to': 'Share', + Snapshot: 'Snapshot', +}; + +/** + * Inert on purpose: this page compares where a control sits inside a real + * screen. The working buttons and live capture are on Button placements. + */ +export const Control = ({ + action, + className, + label, + size = ButtonSize.Small, + variant = ButtonVariant.Tertiary, +}: { + action: LeadAction; + className?: string; + label?: boolean; + size?: ButtonSize; + variant?: ButtonVariant; +}) => ( + +); + +/* ---------------------------------------------------------- page furniture */ + +/** The frame every surface is drawn inside, so variants compare like for like. */ +export const Screen = ({ + children, + width = 'w-[26rem]', + className, +}: { + children: React.ReactNode; + width?: string; + className?: string; +}) => ( +
+ {children} +
+); + +/** + * The real context menu, not an illustration of one. Every surface below + * passes its production item list β€” today the share entry is "Share via", + * which opens the share modal; no surface offers Copy link from a menu. + */ +/** + * A real context menu. Every production menu in the product leads with a + * share item β€” "Share via" on posts and squads, "Share" on profiles and + * tags, "Share post via..." in reading history β€” and none of them offers + * "Copy link" directly, so the items are passed in rather than invented. + */ +export type DeviceName = 'Desktop' | 'Tablet' | 'Mobile'; + +/** + * Breakpoints matter more than usual here. PostSourceInfo renders the whole + * header cluster as `hidden laptop:flex`, so the β‹― menu that carries sharing + * on desktop is simply not in the article header below 1020px β€” it moves to a + * sticky back-bar, and a floating action bar appears at the bottom. A + * recommendation that only works on one of the three is not a recommendation. + */ +export const DEVICES: Record = + { + Desktop: { width: 680, viewport: '1020px and up' }, + Tablet: { width: 560, viewport: '768px' }, + Mobile: { width: 375, viewport: '375px' }, + }; + +/** A surface drawn at one real viewport width, so density is comparable. */ +export const Device = ({ + name, + children, + height, +}: { + name: DeviceName; + children: React.ReactNode; + /** Mobile surfaces pin a floating bar, so the frame needs a known height. */ + height?: number; +}) => ( +
+ + {name} Β· {DEVICES[name].viewport} + +
+ {children} +
+
+); + +/** Devices sit in a scroller rather than wrapping, so widths stay honest. */ +export const Rail = ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+); + +export const Variant = ({ + step, + headline, + note, + children, +}: { + step: string; + headline: string; + note: string; + children: React.ReactNode; +}) => ( + // Full width so a device rail can scroll across the whole canvas. +
+
+ + {step} + + + {headline} + + {note} +
+ {children} +
+); + +export const Category = ({ + title, + covers, + verdict, + children, +}: { + title: string; + covers: string; + verdict: string; + children: React.ReactNode; +}) => ( +
+
+

{title}

+ {covers} +

+ {verdict} +

+
+
{children}
+
+); + +/** Every category page opens with the same header, so they read as a set. */ +export const SurfacePage = ({ + title, + intro, + map, + children, +}: { + title: string; + intro: string; + map: string; + children: React.ReactNode; +}) => ( +
+
+

{title}

+

{intro}

+ {map} +
+ {children} +
+); diff --git a/packages/storybook/stories/features/snapshot/surfaces/Briefing.stories.tsx b/packages/storybook/stories/features/snapshot/surfaces/Briefing.stories.tsx new file mode 100644 index 0000000000..5006bdca0e --- /dev/null +++ b/packages/storybook/stories/features/snapshot/surfaces/Briefing.stories.tsx @@ -0,0 +1,338 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + AnalyticsIcon, + LinkIcon, + SettingsIcon, + ShareIcon, + TimerIcon, +} from '@dailydotdev/shared/src/components/icons'; +import type { DeviceName } from '../surfaceChrome'; +import { + AVATAR, + Category, + Control, + Device, + Rail, + SurfacePage, + Variant, +} from '../surfaceChrome'; + +type Spot = 'allSizes' | 'closing'; + +/** + * BriefPostContent renders the body through `` + * β€” one blob, no per-item nodes β€” so the sections here are illustrative of the + * copy, not of a component the UI could hang a control on. + */ +const BODY = [ + [ + 'The TypeScript migration is effectively over', + 'Four of the five major frameworks now ship types first, and the fifth has an RFC open.', + ], + [ + 'Postgres keeps eating the specialist databases', + 'Vector, queue and time-series workloads are consolidating back into one engine.', + ], + [ + 'Nobody agrees on what an AI agent is', + 'Three definitions in circulation, and the benchmarks measure none of them.', + ], +]; + +const HeaderActions = ({ spot }: { spot: Spot }) => { + return ( +
+ {spot !== 'closing' && ( +
+ ); +}; + +const BriefingScreen = ({ + device, + spot, +}: { + device: DeviceName; + spot: Spot; +}) => ( + +
+ {/* BriefUpgradeAlert β€” non-Plus only. */} +
+ Upgrade to Plus for a briefing every morning +
+ +
+
+ + Your Monday briefing + + +
+

+ Tomer presidential briefing +

+ + + Save 12m of reading + + + 34 posts analyzed + + +
+ +
+ + + 5m read + +
+
+ {[0, 1, 2, 3, 4, 5].map((i) => ( + + ))} +
+ + 12 Sources + +
+
+ +
+ {BODY.map(([heading, body]) => ( +
+ + {heading} + + {body} +
+ ))} +
+ + {spot === 'closing' && ( +
+
+ + Share your briefing + + + Short briefing by @tomer + +
+ +
+ )} +
+
+); + +const AllDevices = ({ spot }: { spot: Spot }) => ( + + + + + +); + +/* ------------------------------------------------------- the /briefing list */ + +const BRIEFS = [ + { title: 'Your Monday briefing', pill: 'Just in', read: false, mins: 5 }, + { title: 'Your Sunday briefing', read: true, mins: 4 }, + { title: 'Your Saturday briefing', read: true, mins: 6 }, +]; + +/** + * BriefShareControls from #6353: copy link on the left, then the arrow that + * opens the social surface. One glyph per meaning β€” the arrow is never a + * one-tap copy. Rendered after the full-bleed CardLink with an explicit + * z-index, or the overlay swallows the clicks. + */ +const RowControls = () => ( +
+
+); + +const BriefRow = ({ + brief, + device, +}: { + brief: (typeof BRIEFS)[number]; + device: DeviceName; +}) => ( +
+ {/* BriefGradientIcon β€” `hidden mobileXL:flex`. */} + {device !== 'Mobile' && ( + + )} +
+
+ + {brief.title} + + {brief.pill && ( + + {brief.pill} + + )} +
+ + {brief.mins}m read time + {' β€’ '} + Based on 34 posts from 12 sources + +
+ +
+); + +const BriefListScreen = ({ device }: { device: DeviceName }) => ( + +
+
+

+ Presidential briefings +

+ +
+ +
+ Upgrade to Plus for a briefing every morning +
+ +
+ {BRIEFS.map((brief) => ( + + ))} +
+ + + 2025 + +
+
+); + +const AllLists = () => ( + + + + + +); + +const Briefing = () => ( + + + + + + + + + + + + + + + + + + + +); + +const meta: Meta = { + title: 'Features/Snapshot/Surfaces/Briefing', + component: Briefing, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +export const Variations: StoryObj = {}; diff --git a/packages/storybook/stories/features/snapshot/surfaces/CopyMyFeed.stories.tsx b/packages/storybook/stories/features/snapshot/surfaces/CopyMyFeed.stories.tsx new file mode 100644 index 0000000000..2e91af2bf2 --- /dev/null +++ b/packages/storybook/stories/features/snapshot/surfaces/CopyMyFeed.stories.tsx @@ -0,0 +1,497 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + AiIcon, + AlertIcon, + ArrowIcon, + BlockIcon, + CopyIcon, + EditIcon, + FilterIcon, + HashtagIcon, + LinkIcon, + MiniCloseIcon, + PlusIcon, + PlusUserIcon, + StarIcon, + TrashIcon, +} from '@dailydotdev/shared/src/components/icons'; +import type { DeviceName } from '../surfaceChrome'; +import { + AVATAR, + Category, + Device, + Rail, + SurfacePage, + Variant, +} from '../surfaceChrome'; + +type Spot = 'section' | 'list'; + +const MENU: [string, React.ReactElement][] = [ + ['General', ], + ['Tags', ], + ['Content sources', ], + ['Content preferences', ], + ['AI superpowers', ], + ['Filters', ], + ['Blocked content', ], +]; + +const Field = ({ + label, + value, + counter, +}: { + label: string; + value: string; + counter?: string; +}) => ( +
+ + {label} + {value} + + {counter && ( + {counter} + )} +
+); + +const Block = ({ + title, + description, + children, +}: { + title: string; + description?: string; + children?: React.ReactNode; +}) => ( +
+
+ {title} + {description && ( + {description} + )} +
+ {children} +
+); + +const Divider = () => ( +
+); + +/** + * FeedSettingsGeneralSection inside the feed settings modal. Feed name, + * emoji picker, default-feed toggle, Happening Now placement and delete are + * all `isCustomFeed`-gated already β€” the export belongs in the same set. + */ +const FeedSettingsScreen = ({ + device, + spot, +}: { + device: DeviceName; + spot: Spot; +}) => ( + +
+
+ + + My new feed + + + +
+ +
+ {device !== 'Mobile' && ( + + )} + +
+ + + + + + + + + + + + + + +
+
+ + dly.to/f/tomer-frontend + + +
+ {spot === 'list' && ( + + )} +
+
+ + + + +
+ + Default + + +
+
+ + + + + + +
+
+
+
+); + +/* ------------------------------------------------------- the recipient side */ + +type LandingSpot = 'preview' | 'added' | 'signin' | 'limit'; + +const TAGS = [ + '#typescript', + '#react', + '#webdev', + '#css', + '#nextjs', + '#tooling', +]; + +/** + * The recipient's landing. `/feeds/new?entityId=&entityType=` already creates + * a feed and follows one entity into it β€” this is the same flow with a set + * rather than a single tag, so the precedent exists in FeedSettingsCreate. + */ +const LandingScreen = ({ + device, + spot, +}: { + device: DeviceName; + spot: LandingSpot; +}) => ( + +
+ + + {spot === 'added' && ( + <> +

+ Tomer's feed is yours now +

+

+ It is in your sidebar as a new feed. Rename it, add tags, or delete + it β€” from here it is your feed, not a copy of theirs. +

+ + )} + {spot === 'signin' && ( + <> +

+ Sign in to add Tomer's feed +

+

+ A feed lives in an account, so there is nowhere to put this one yet. + Sign in or sign up, then open the link again. +

+ + )} + {(spot === 'preview' || spot === 'limit') && ( + <> +

+ Tomer shared a feed with you +

+

+ 6 tags and 4 sources. Adding it creates a new feed in your account + called Tomer's feed. +

+ + )} + +
+ {TAGS.slice(0, device === 'Mobile' ? 4 : 6).map((tag) => ( + + {tag} + + ))} +
+ +
+ {spot === 'added' && ( + + )} + {spot === 'signin' && ( + + )} + {(spot === 'preview' || spot === 'limit') && ( + + )} +
+ + {spot === 'limit' && ( +
+ + + You've reached the maximum number of feeds. Delete one or + upgrade to Plus to add this feed. + +
+ )} + + {/* A sample of what the feed holds, not a reading surface: nothing here + is a link, so the only way in is to add the feed. */} +
+ {[ + 'Why iconic tech brands lost their dominance', + 'The case against microservices', + 'Postgres is all you need, again', + ].map((title) => ( +
+
+ + {title} + +
+ ))} +
+
+ +); + +const LandingRails = ({ spot }: { spot: LandingSpot }) => ( + + + + + +); + +const Rails = ({ spot }: { spot: Spot }) => ( + + + + + +); + +const CopyMyFeed = () => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ + +
+ + +
+ + + +); + +const meta: Meta = { + title: 'Features/Snapshot/Surfaces/Share my feed', + component: CopyMyFeed, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +export const Variations: StoryObj = {}; diff --git a/packages/storybook/stories/features/snapshot/surfaces/Directories.stories.tsx b/packages/storybook/stories/features/snapshot/surfaces/Directories.stories.tsx new file mode 100644 index 0000000000..042cb0bf4f --- /dev/null +++ b/packages/storybook/stories/features/snapshot/surfaces/Directories.stories.tsx @@ -0,0 +1,333 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + BellIcon, + BlockIcon, + MenuIcon, +} from '@dailydotdev/shared/src/components/icons'; +import type { DeviceName } from '../surfaceChrome'; +import { + AVATAR, + Category, + Control, + Device, + Rail, + SurfacePage, + Variant, +} from '../surfaceChrome'; + +/** CustomFeedOptionsMenu β€” the same two items on tags, sources and profiles. */ + +const Options = () => ( + + + + +
+ + +); + +/* ---------------------------------------------------------------- sources */ + +/** The source page is left-aligned, and its actions sit below the title. */ +const SourceScreen = ({ device }: { device: DeviceName }) => ( + +
+ + Sources / XDA Developers + + +
+ +

+ XDA Developers +

+
+ +
+ +
+ +

+ News and reviews for developers, by developers. +

+ +
+ {['#android', '#hardware', '#reviews'].map((tag) => ( + + {tag} + + ))} +
+
+
+); + +/* ----------------------------------------------------------------- squads */ + +/** SquadEntityCard: w-80, image and actions on one row, body under it. */ +const SquadCard = () => ( +
+
+ +
+ + + {/* SquadHeaderMenu β€” `invisible group-hover/menu:visible`. */} +
+
+
+ + Frontend Fans + +

+ Everything CSS, React and the browser. Ship it and show it. +

+ + 2.4K Members + Β· + 12K Upvotes + +
+
+); + +const SquadScreen = ({ device }: { device: DeviceName }) => ( + +
+

Squads

+
+ +
+
+
+); + +/* ---------------------------------------------------------------- archive */ + +/** ArchiveIndexPage: a month grid, no posts and no controls. */ +const ArchiveScreen = ({ device }: { device: DeviceName }) => ( + +
+ + Sources / XDA Developers / Best of + +
+

+ Best of XDA Developers — Archive +

+ +
+ +
+ {['2026', '2025'].map((year) => ( +
+

+ {year} +

+
+ {['January', 'February', 'March', 'April'].map((month) => ( + + + {month} + + + 24 posts + + + ))} +
+
+ ))} +
+
+
+); + +/* -------------------------------------------------------------------- page */ + +const Rails = ({ + Screen, +}: { + Screen: React.ComponentType<{ device: DeviceName }>; +}) => ( + + + + + +); + +const Directories = () => ( + + + + + + + + + + + + + + + + + + + + + + + + + +); + +const meta: Meta = { + title: 'Features/Snapshot/Surfaces/Topic & directory pages', + component: Directories, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +export const Variations: StoryObj = {}; diff --git a/packages/storybook/stories/features/snapshot/surfaces/FeedAndLists.stories.tsx b/packages/storybook/stories/features/snapshot/surfaces/FeedAndLists.stories.tsx new file mode 100644 index 0000000000..047981ce99 --- /dev/null +++ b/packages/storybook/stories/features/snapshot/surfaces/FeedAndLists.stories.tsx @@ -0,0 +1,378 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + DownvoteIcon, + HotIcon, + MenuIcon, + MiniCloseIcon, + ReputationIcon, + UpvoteIcon, +} from '@dailydotdev/shared/src/components/icons'; +import type { DeviceName } from '../surfaceChrome'; +import { + AVATAR, + Category, + Control, + Device, + Rail, + SurfacePage, + Variant, +} from '../surfaceChrome'; + +type Spot = 'row' | 'lead'; + +/* -------------------------------------------------------------- hot takes */ + +const TAKES = [ + { + emoji: 'πŸ”₯', + title: 'Microservices were a mistake for most teams', + subtitle: 'Distributed systems are a tax, not a feature', + upvotes: 128, + }, + { + emoji: '🧊', + title: 'Code review is mostly theatre', + subtitle: 'Two approvals, forty seconds of reading', + upvotes: 64, + }, +]; + +/** + * HotTakeItem: a surface-float row with a 48px emoji tile, title and + * subtitle, then the upvote counter. Edit and delete are owner-only and + * hover-revealed. There is no menu on this row and no author avatar. + */ +const HotTakeRow = ({ + take, + spot, +}: { + take: (typeof TAKES)[number]; + spot: Spot; +}) => ( +
+
+ {take.emoji} +
+
+ + {take.title} + + {take.subtitle} +
+
+ {spot === 'row' && } + +
+
+); + +/* --------------------------------------------------------- the swipe modal */ + +/** + * HotAndColdModal: a swipe card on a stack. Emoji tile, Title3 centred, the + * Body-tertiary subtitle, an upvote pill, then a bordered author footer. The + * snapshot already ships on the top card, beside the pill. Below the card sit + * the three reaction buttons (❄️ 😐 πŸ”₯, the middle one size-12 and the outer + * two size-14) and a full-width "Add your own hot take". + */ +const HotTakeModalScreen = ({ + device, + spot, +}: { + device: DeviceName; + spot: Spot; +}) => ( + +
+
+ + Hot Takes + +
+ +
+
+
+
+ 😐 +
+ + Most developers have a talent for turning simple problems into + overengineered nightmares. + + + “Simplicity is prerequisite for reliability” - Edsger + W. Dijkstra + +
+ + + + 587 + + + +
+
+ +
+ +
+ + + James Davis + + + @jamesdavis7 + + + + + 11.4K + +
+
+
+
+ +
+ {[ + ['❄️', 'size-14', 'Cold take - downvote'], + ['😐', 'size-12', 'Skip hot take'], + ['πŸ”₯', 'size-14', 'Hot take - upvote'], + ].map(([glyph, size, label]) => ( +
+ +
+ +
+ {device === 'Mobile' && mobile} +
+
+); + +const HotTakesScreen = ({ + device, + spot, +}: { + device: DeviceName; + spot: Spot; +}) => ( + +
+ Hot takes + {TAKES.map((take) => ( + + ))} +
+
+); + +/* -------------------------------------------------------- reading history */ + +const HISTORY = [ + 'Why iconic tech brands lost their dominance', + 'The case against microservices', + 'Postgres is all you need, again', +]; + +/** + * PostItemCard: a 64px thumbnail with the source avatar overlapping its + * bottom-left, a two-line title, metadata, then vote buttons and the β‹― menu. + * The vote buttons and the hide X are `hidden laptop:flex`. + */ +const HistoryRow = ({ + title, + device, + spot, +}: { + title: string; + device: DeviceName; + spot: Spot; +}) => ( +
+
+
+ +
+
+

+ {title} +

+ + 4 min read Β· 128 upvotes + +
+
+ {device === 'Desktop' && ( + <> +
+
+); + +const HistoryScreen = ({ + device, + spot, +}: { + device: DeviceName; + spot: Spot; +}) => ( + +
+ + Reading history + + {HISTORY.map((title) => ( + + ))} +
+
+); + +/* -------------------------------------------------------------------- page */ + +const Rails = ({ + Screen, + spot, +}: { + Screen: React.ComponentType<{ device: DeviceName; spot: Spot }>; + spot: Spot; +}) => ( + + + + + +); + +const FeedAndLists = () => ( + + + + + + + + + + + + + + + + + + + +); + +const meta: Meta = { + title: 'Features/Snapshot/Surfaces/Hot takes & history', + component: FeedAndLists, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +export const Variations: StoryObj = {}; diff --git a/packages/storybook/stories/features/snapshot/surfaces/HappeningNow.stories.tsx b/packages/storybook/stories/features/snapshot/surfaces/HappeningNow.stories.tsx new file mode 100644 index 0000000000..1bb42e7ef4 --- /dev/null +++ b/packages/storybook/stories/features/snapshot/surfaces/HappeningNow.stories.tsx @@ -0,0 +1,141 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { ButtonVariant } from '@dailydotdev/shared/src/components/buttons/Button'; +import { ArrowIcon } from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import type { DeviceName } from '../surfaceChrome'; +import { + Category, + Control, + Device, + Rail, + SurfacePage, + Variant, +} from '../surfaceChrome'; + +const TABS = ['Major headlines', 'All highlights', 'AI', 'Web']; + +const HIGHLIGHTS = [ + { + headline: 'OpenAI ships a cheaper model tier', + time: '2h ago', + tldr: 'Priced at a third of the previous tier with the same context window. Existing keys work unchanged, and the older tier stays available until March.', + }, + { headline: 'React 20 drops the legacy render path', time: '4h ago' }, + { headline: 'Postgres 19 lands async I/O by default', time: '6h ago' }, + { headline: 'Cloudflare open-sources its edge router', time: '9h ago' }, +]; + +const HappeningScreen = ({ device }: { device: DeviceName }) => ( + +
+
+ {/* feed-highlights-title-gradient in production. */} +

+ Happening Now +

+ +
+ +
+ {TABS.map((tab, index) => ( + + {tab} + + ))} +
+ + {HIGHLIGHTS.map((item, index) => { + const open = index === 0; + + return ( +
+
+
+ + {item.headline} + + + {item.time} + +
+ +
+ + {open && item.tldr && ( +
+

{item.tldr}

+
+ + Read more + + +
+
+ )} +
+ ); + })} +
+
+); + +const AllDevices = () => ( + + + + + +); + +const HappeningNow = () => ( + + + + + + + +); + +const meta: Meta = { + title: 'Features/Snapshot/Surfaces/Happening now', + component: HappeningNow, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +export const Variations: StoryObj = {}; diff --git a/packages/storybook/stories/features/snapshot/surfaces/InviteOnboarding.stories.tsx b/packages/storybook/stories/features/snapshot/surfaces/InviteOnboarding.stories.tsx new file mode 100644 index 0000000000..d19c5c411e --- /dev/null +++ b/packages/storybook/stories/features/snapshot/surfaces/InviteOnboarding.stories.tsx @@ -0,0 +1,196 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + Button, + ButtonIconPosition, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + MoveToIcon, + PlusIcon, + VIcon, +} from '@dailydotdev/shared/src/components/icons'; +import type { DeviceName } from '../surfaceChrome'; +import { + AVATAR, + Category, + Device, + Rail, + SurfacePage, + Variant, +} from '../surfaceChrome'; + +type Spot = 'step' | 'progress'; + +const TARGETS = [ + ['X', 'bg-text-primary'], + ['WhatsApp', 'bg-accent-avocado-default'], + ['Facebook', 'bg-accent-bun-default'], + ['Reddit', 'bg-accent-ketchup-default'], + ['LinkedIn', 'bg-accent-blueCheese-default'], + ['Telegram', 'bg-accent-water-default'], + ['Email', 'bg-accent-burger-default'], +]; + +/* ---------------------------------------------------------- today: settings */ + +/* ------------------------------------------------------- proposed: the step */ + +const Slot = ({ filled }: { filled?: boolean }) => + filled ? ( +
+ + + + +
+ ) : ( + + + + ); + +/** + * FunnelStepCtaWrapper's glass branch: a sticky top bar with the logo and + * Skip, the step content on the 32rem rail, then a scrim, the glass bar with + * a Medium Primary CTA, and the step dots. + */ +const StepScreen = ({ device, spot }: { device: DeviceName; spot: Spot }) => ( + +
+
+ + daily.dev + + +
+ +
+

+ Invite 3 friends, get a month of Plus +

+

+ They get daily.dev, you both get Plus. It counts as soon as they sign + up with your link. +

+ +
+ + + +
+ + {spot === 'progress' && ( + + 1 of 3 joined + + )} + +
+ + dly.to/tomer + + +
+ +
+ {TARGETS.slice(0, device === 'Mobile' ? 5 : 7).map( + ([label, tone]) => ( +
+ + + {label} + +
+ ), + )} +
+
+ +
+
+ +
+
+ {[0, 1, 2, 3, 4].map((dot) => ( + + ))} +
+
+
+
+); + +/* -------------------------------------------------------------------- page */ + +const InviteOnboarding = () => ( + + + + + + + + + + + + + + + + + +); + +const meta: Meta = { + title: 'Features/Snapshot/Surfaces/Invite onboarding', + component: InviteOnboarding, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +export const Variations: StoryObj = {}; diff --git a/packages/storybook/stories/features/snapshot/surfaces/Overview.stories.tsx b/packages/storybook/stories/features/snapshot/surfaces/Overview.stories.tsx new file mode 100644 index 0000000000..f6ed7e190c --- /dev/null +++ b/packages/storybook/stories/features/snapshot/surfaces/Overview.stories.tsx @@ -0,0 +1,179 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { Support } from '../sharingMap'; +import { SHARING_MAP } from '../sharingMap'; +import { H1, Note, P } from '../surfaceChrome'; + +const H2 = ({ children }: { children: React.ReactNode }) => ( +

{children}

+); + +const Table = ({ + head, + rows, +}: { + head: string[]; + rows: React.ReactNode[][]; +}) => ( +
+
+ + + {head.map((cell) => ( + + ))} + + + + {rows.map((row) => ( + + {row.map((cell, i) => ( + + ))} + + ))} + +
+ {cell} +
+ {cell} +
+
+); + +const SUPPORT: Record = { + core: core, + secondary: secondary, + none: β€”, +}; + +const MAP_ROWS: React.ReactNode[][] = SHARING_MAP.map((row) => [ + row.surface, + row.pr, + SUPPORT[row.link], + SUPPORT[row.snapshot], + {row.leads}, + row.why, +]); + +const PAGES: React.ReactNode[][] = [ + [ + 'Post page', + '#6556', + 'Shipped: the live post page is the reference, so it has no mockup here', + ], + [ + 'Happening now', + '#6355', + 'Page, topic and highlight level β€” and what a page-level snapshot actually looks like at thumbnail size', + ], + [ + 'Briefing', + '#6353', + 'Whole briefing versus per item, plus a closing band at the end of the read', + ], + [ + 'Profile', + '#6354 #6360 #6356', + 'Header, the three widgets, and the DevCard β€” three surfaces on one page that want three different controls', + ], + [ + 'Status moments', + '#6358 #6360 #6359', + 'Streak, achievements, leaderboard rank. Frequency and touch support matter more than placement here', + ], + [ + 'Feed cards & lists', + '#6365 #6361', + 'Feed card, hot take, reading history β€” where hover-reveal quietly excludes mobile', + ], + [ + 'Topic & directory pages', + '#6357 #6363 #6364 #6359', + 'Tags, sources, squads, collections β€” sharing beside a primary CTA without competing with it', + ], + [ + 'Invite & feed export', + '#6366 #6362', + 'The two surfaces that exist to send something outward, at opposite ends of the payload question', + ], +]; + +const STEPS: React.ReactNode[][] = [ + ['Today', 'What ships now β€” usually the β‹― menu, or nothing at all'], + [ + 'Recommended', + 'Visible in place, leading with the action the map chose for that surface', + ], + [ + 'Push', + 'The loudest treatment worth trying: labeled, filled, or self-opening β€” and snapshot promoted to primary wherever the payload can carry it', + ], +]; + +const Overview = () => ( +
+

Share visibility β€” one page per surface

+

+ Two goals drive every variation in this section: make the share control + impossible to miss, and lead with snapshot wherever the payload is the + thing worth sending. Each page draws one real surface and shows every + variation of it side by side, so the options can be compared as designs + rather than argued as a list. +

+ + +

The three steps on every page

+
+ + The controls in this section are inert β€” it compares where a control sits + and how loud it is. The working buttons and live capture are on{' '} + Button placements, + and the images they produce are on{' '} + Share images. + + +

The mapping this is built on

+

+ Unchanged from the Sharing map. Where a Push variation contradicts it, the + note on that variation says so and why it is still worth testing β€” the map + is a default, not a veto. +

+
+ +

Where snapshot can lead

+

+ Seven surfaces have no useful destination to send anyone to β€” a streak, a + rank, an unlocked achievement, your own briefing, your own feed. Snapshot + is not the louder option there, it is the only one, so it leads by + default. On the rest the destination is the value and a snapshot competes + with it; those get a Push variation so the trade can be measured instead + of assumed. +

+ + One caveat applies to every Push variation: only the invite card carries a + URL today. Promoting snapshot on a surface before a short link is baked + into the card trades a share that leads somewhere for one that does not. + + +); + +const meta: Meta = { + title: 'Features/Snapshot/Surfaces/Overview', + component: Overview, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +export const Index: StoryObj = {}; diff --git a/packages/storybook/stories/features/snapshot/surfaces/Profile.stories.tsx b/packages/storybook/stories/features/snapshot/surfaces/Profile.stories.tsx new file mode 100644 index 0000000000..f870525389 --- /dev/null +++ b/packages/storybook/stories/features/snapshot/surfaces/Profile.stories.tsx @@ -0,0 +1,328 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + DownloadIcon, + EditIcon, + MedalBadgeIcon, + ReputationIcon, +} from '@dailydotdev/shared/src/components/icons'; +import type { DeviceName } from '../surfaceChrome'; +import { + AVATAR, + Category, + Control, + Device, + Rail, + SurfacePage, + Variant, +} from '../surfaceChrome'; + +/* ------------------------------------------------------------------ header */ + +/** + * ProfileHeader: an h-36 cover, a 7.5rem rounded-16 avatar pinned at + * `left-6 top-16`, then a right-aligned action row above the name. The + * snapshot button already ships here, matched to the edit button at Medium + * Float. + */ +const ProfileScreen = ({ device }: { device: DeviceName }) => ( + +
+
+ + +
+
+
+ + + Tomer Redlich + + +
+

+ Building the feed developers actually read. +

+ Tel Aviv + + @tomer Β· Joined Jan 4. 2021 + + +
+ + + 1.2K Reputation + + + 3.4K Upvotes + + + 842 Followers + + + 61 Following + +
+
+
+
+ +); + +/* ----------------------------------------------------------------- widgets */ + +const SummaryCard = ({ count, label }: { count: string; label: string }) => ( +
+ {count} + {label} +
+); + +const WidgetHeader = ({ + title, + icon, + trailing, +}: { + title: string; + icon?: React.ReactNode; + trailing?: React.ReactNode; +}) => ( +
+

+ {icon} + {title} +

+
+ {trailing} + +
+
+); + +const WidgetsScreen = ({ device }: { device: DeviceName }) => ( + +
+
+ + Learn more +
+ + +
+

+ Top tags by reading days +

+
+ {[ + ['#typescript', 82], + ['#react', 64], + ['#webdev', 41], + ['#css', 28], + ].map(([tag, pct]) => ( +
+ + + {tag} + +
+ ))} +
+

+ Posts read in the last months (3.4K) +

+
+ {Array.from({ length: 60 }, (_, i) => { + const level = Math.max( + 0, + Math.min(3, Math.round(2 + Math.sin(i / 4) * 1.4)), + ); + const tone = [ + 'bg-surface-float', + 'bg-overlay-float-cabbage', + 'bg-accent-cabbage-subtler', + 'bg-accent-cabbage-default', + ][level]; + + return ( + // eslint-disable-next-line react/no-array-index-key + + ); + })} +
+
+ +
+ + Learn more +
+ + +
+
+ {['#typescript', '#react'].map((tag) => ( + + πŸ₯‡ Top reader in {tag} + + ))} +
+
+ +
+ } + title="Achievements" + trailing={12/40} + /> +
+ {['Can't spend it all', 'Big byte energy'].map((name) => ( +
+ +
+ + {name} + + + Unlocked 12 Aug 2026 + +
+ + 120 + +
+ ))} +
+
+ {device === 'Mobile' && mobile} +
+
+); + +/* ---------------------------------------------------------------- devcard */ + +const DevCardScreen = () => ( + +
+ + Your DevCard is ready + +
+
+ + +
+
+ +); + +/* -------------------------------------------------------------------- page */ + +const Profile = () => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + +const meta: Meta = { + title: 'Features/Snapshot/Surfaces/Profile', + component: Profile, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +export const Variations: StoryObj = {}; diff --git a/packages/storybook/stories/features/snapshot/surfaces/StatusMoments.stories.tsx b/packages/storybook/stories/features/snapshot/surfaces/StatusMoments.stories.tsx new file mode 100644 index 0000000000..36d6f2bb65 --- /dev/null +++ b/packages/storybook/stories/features/snapshot/surfaces/StatusMoments.stories.tsx @@ -0,0 +1,458 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + DownloadIcon, + MiniCloseIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { + ART, + AVATAR, + Category, + Control, + Screen, + SurfacePage, + Variant, +} from '../surfaceChrome'; + +/** + * NewStreakModal: a reminder switch and close at the top, the fire art with + * the count overlaid in typo-tera, a title, one line of copy, the freeze + * upsell, and an opt-out checkbox. No share control, and no primary button. + */ +const StreakScreen = () => ( + +
+
+ + + Remind me + +
+ + + πŸ”₯ + 100 + + + + 100 days streak + +

+ New milestone reached! You are unstoppable. +

+ + + Protect your streak with streak freezes + + + + + + + Never show this again + +
+
+); + +/** + * AchievementCard: a 48px thumbnail, name and description, then the snapshot + * button and the points. The snapshot is `opacity-0 group-hover:opacity-100`, + * so it is invisible until hover β€” and permanently invisible on touch. + */ +const AchievementScreen = () => ( + +
+
+
+ +
+ + Can't spend it all + + + Hold more than 10,000 cores without spending any of them. + +
+
+ + + 120 + +
+
+ +
+ + Progress + 8/10 + + + + +
+
+
+
+); + +/** + * UserTopList rows: a w-14 tabular score, TopRankBadge, an optional level + * ring, then UserHighlight (32px avatar, caption1 name, caption2 handle). + * The snapshot is `opacity-0 group-hover:opacity-100` at XSmall Float β€” + * shipped, and invisible on touch. + */ +const RankScreen = () => ( + +
+

+ Highest level +

+ +
    + {[ + ['Bobby Iliev', 'bobbyiliev', 103], + ['Ante Barić', 'antebaric', 99], + ['Ido Shamun', 'idoshamun', 95], + ].map(([name, handle, score], index) => ( +
  1. + + {score} + + + {index === 0 ? 'πŸ₯‡' : `#${index + 1}`} + +
    + +
    + + {name} + + + @{handle} + +
    +
    + {index === 0 && ( + + )} +
  2. + ))} +
+
+
+); + +/* -------------------------------------------------- other win moments */ + +const TopReaderScreen = () => ( + +
+
+ πŸ₯‡ +
+

+ You've earned the top reader badge! +

+

+ Top 1% of readers in #typescript this week +

+
+ + +
+
+
+); + +const TIER_DAYS = [ + ['Spark', 3], + ['Flame', 7], + ['Inferno', 30], + ['Supernova', 180], +]; + +const StreakTierScreen = () => ( + +
+
+ πŸ”₯ +
+ + Inferno + + 30 +

A full month, unbroken

+
+ {['M', 'T', 'W', 'T', 'F', 'S', 'S'].map((day, index) => ( + + {day} + + ))} +
+
+ {TIER_DAYS.map(([label, day]) => ( + + {label} Β· {day}d + + ))} +
+ +
+
+); + +/** + * The award moment, from the recipient's side. ListAwardsModal shows who gave + * what and the cores total; the person who received it gets no way to mark it. + */ +const AwardedScreen = () => ( + +
+
+ + Awards given + +
+ +
+ + πŸͺ™ + +
+ Cores given + 1,250 +
+ +
+ + {['Bobby Iliev', 'Ante Barić', 'Ido Shamun'].map((name) => ( +
+ + + {name} + + πŸ… +
+ ))} +
+
+); + +const POST_STATS: [string, string][] = [ + ['Impressions', '41.2K'], + ['Upvotes', '862'], + ['Comments', '134'], + ['Clicks', '3.8K'], + ['Followers gained', '96'], + ['Cores earned', '1,250'], +]; + +/** posts/[id]/analytics, behind canViewPostAnalytics. */ +const AnalyticsScreen = () => ( + +
+
+
+ + Post analytics + + + Why iconic tech brands lost their dominance + +
+ +
+ +
+ +
+ {POST_STATS.map(([label, value]) => ( +
+ + {value} + + {label} +
+ ))} +
+ + +
+ +); + +const StatusMoments = () => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + +const meta: Meta = { + title: 'Features/Snapshot/Surfaces/Status moments', + component: StatusMoments, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +export const Variations: StoryObj = {};