diff --git a/packages/storybook/.storybook/preview.tsx b/packages/storybook/.storybook/preview.tsx index 38d5deaeb84..267f120534f 100644 --- a/packages/storybook/.storybook/preview.tsx +++ b/packages/storybook/.storybook/preview.tsx @@ -18,6 +18,7 @@ const preview: Preview = { 'Atoms', 'Components', 'Pages', + 'Open Graph', 'Experiments', 'Extension', ], diff --git a/packages/storybook/stories/open-graph/Benchmark.stories.tsx b/packages/storybook/stories/open-graph/Benchmark.stories.tsx new file mode 100644 index 00000000000..6c8945c2802 --- /dev/null +++ b/packages/storybook/stories/open-graph/Benchmark.stories.tsx @@ -0,0 +1,526 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + Bullets, + Divider, + Heading, + Muted, + Page, + PageHeader, + SpecTable, +} from './ogStoryLayout'; + +const SANS = + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; + +const CardLabel = ({ + children, +}: { + children: React.ReactNode; +}): React.ReactElement => ( +
+ {children} +
+); + +/** How a YouTube link unfurls — the most-shared link type on the web. */ +const YouTubeCard = (): React.ReactElement => ( +
+
+ + + ▶ + + + + 12:04 + +
+
+ +
+
+ How we cut edge cold-starts by 90% with Rust and Wasm +
+
+ The Pragmatic Engineer · 1.2M views +
+
+
+
+); + +/** How a Reddit link post unfurls — the model that sits between GitHub and X. */ +const RedditCard = (): React.ReactElement => ( +
+
+ + + 2.4k + + + ▼ + +
+
+
+ r/programming · Posted by u/idoshamun · 5h +
+
+ How we cut edge cold-starts by 90% with Rust and Wasm +
+ + app.daily.dev + +
+ 💬 340 comments + ↗ Share +
+
+
+
+); + +/** GitHub's auto-generated repo social card. */ +const GitHubRepoCard = (): React.ReactElement => ( +
+
+ + + vercel /{' '} + satori + +
+
+ Enlightened library to convert HTML and CSS to SVG +
+
+ + + TypeScript + + ★ 11.8k + ⑂ 312 +
+
+); + +const Benchmark = (): React.ReactElement => ( + + + The question that matters isn’t how much traffic a platform sends us — + it’s whose links get shared everywhere else: pasted into + WhatsApp, posted on X, dropped in a Slack channel. So we looked at the + platforms whose links travel the most across the web and studied how they + unfurl. The pattern is stark: the most-shared platforms have{' '} + rich, open, contextual previews — and the ones that gate + their metadata get shared less because their links look broken. + + + Whose links get shared the most + + + + + + The three to model + + YouTube, Reddit and GitHub are where the most-shared, best-unfurling links + live. Here’s how each looks and exactly what we should take. + + +
+
+ YouTube — the thumbnail IS the product + +
+ +
+
+
+ GitHub — contextual + live stats + +
+ +
+
+
+ + + + Reddit — between GitHub and X + + You called it: for developers Reddit sits right between GitHub’s + credibility and X’s reach. It’s a social network and a dev + watering hole, and its link cards are a masterclass in context — exactly + the balance daily.dev wants. + +
+
+ How a daily.dev link looks on Reddit + +
+
+ +
+
+ + + + The anti-pattern: don’t be Instagram or TikTok + + + The takeaway for us:{' '} + openness + complete, valid tags is itself a growth lever.{' '} + Every daily.dev link should unfurl richly for every scraper (Facebook, X, + Slack, Discord, and Reddit’s Embedly) — never gated, never half-formed. + + + + + Enhanced principles (from the most-shared platforms) + + + + + Sources + +
+); + +const meta: Meta = { + title: 'Open Graph/6. How the Best Platforms Share', + component: Benchmark, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +type Story = StoryObj; + +export const Benchmarks: Story = { name: 'Platform benchmark' }; diff --git a/packages/storybook/stories/open-graph/CoverDna.stories.tsx b/packages/storybook/stories/open-graph/CoverDna.stories.tsx new file mode 100644 index 00000000000..a4c9000e49a --- /dev/null +++ b/packages/storybook/stories/open-graph/CoverDna.stories.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { COVER_DNA } from './coverDna'; +import { Cover } from './cover'; +import { + Bullets, + Divider, + Heading, + Muted, + Page, + PageHeader, +} from './ogStoryLayout'; + +const SANS = + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; + +const label = (text: string): React.ReactElement => ( +
+ {text} +
+); + +const CoverDna = (): React.ReactElement => ( + + + Clean slate. Base = the clean Layout A you liked (source + left, real logo right, cover art bottom-right, glass upvote + comment + bar). Each variation adds one recognizability device so + the cover instantly reads as daily.dev. Grouped into four{' '} + systems — tell me which system feels right, then we + refine. + + + Baseline (no branding device) + The clean cover, unchanged — everything below builds on this. +
+
+ {label('Layout A — clean')} + +
+
+ + {COVER_DNA.map((cat, ci) => ( +
+ + + {String.fromCharCode(65 + ci)}. {cat.category} + + {cat.blurb} +
+ {cat.items.map((it) => ( +
+ {label(`${it.id.toUpperCase()} · ${it.name}`)} + {it.Component()} +
+ ))} +
+
+ ))} + + + Pick a direction + +
+); + +const meta: Meta = { + title: 'Open Graph/7 Recognizable Covers ★', + component: CoverDna, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +type Story = StoryObj; + +export const Recognizable: Story = { name: '20 branding devices' }; diff --git a/packages/storybook/stories/open-graph/CurrentOpenGraph.stories.tsx b/packages/storybook/stories/open-graph/CurrentOpenGraph.stories.tsx new file mode 100644 index 00000000000..44ea412f0b9 --- /dev/null +++ b/packages/storybook/stories/open-graph/CurrentOpenGraph.stories.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { MetaTagsTable, PlatformGrid } from './platformCards'; +import { USE_CASES } from './useCases'; +import { + Bullets, + Divider, + Heading, + Muted, + Page, + PageHeader, +} from './ogStoryLayout'; + +const CurrentPreviews = (): React.ReactElement => ( + + + Every place daily.dev content gets shared, rendered the way it actually + unfurls on X, LinkedIn, Facebook, Slack, Discord, WhatsApp and iMessage. + These are the real, live images daily.dev serves today — + pulled straight from the actual URLs (e.g.{' '} + og.daily.dev/api/posts/{id} for posts,{' '} + daily.dev/og-image.png for the homepage), not re-creations. + The tell is obvious: only posts get a real card — profile, source, tag, + squad, invite and Plus all fall back to the same generic image. + + + {USE_CASES.map((uc, i) => ( +
+ + {i + 1}. {uc.name} + + {uc.what} + +
+ +
+ + + +
+ +
+ + {i < USE_CASES.length - 1 && } +
+ ))} +
+); + +const meta: Meta = { + title: 'Open Graph/1. Current Share Previews', + component: CurrentPreviews, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +type Story = StoryObj; + +export const AllUseCases: Story = { name: 'All use cases' }; diff --git a/packages/storybook/stories/open-graph/LinkCopyBehavior.stories.tsx b/packages/storybook/stories/open-graph/LinkCopyBehavior.stories.tsx new file mode 100644 index 00000000000..01cd7b9d5ea --- /dev/null +++ b/packages/storybook/stories/open-graph/LinkCopyBehavior.stories.tsx @@ -0,0 +1,314 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + Bullets, + CodeBlock, + Divider, + Heading, + Muted, + Page, + PageHeader, + SpecTable, + TwoCol, +} from './ogStoryLayout'; + +const SANS = + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; + +/** Anatomy of a link preview, labeling the parts that are NOT the image. */ +const PreviewAnatomy = (): React.ReactElement => ( +
+
+
+
+ + d. + + + app.daily.dev · favicon + domain + +
+
+ og:title — the headline +
+
+ og:description — the supporting line shown on FB, Slack, Discord, + WhatsApp. +
+
+
+); + +const SHARE_TEXT_CODE = `// Today — shared/src/lib/share.ts +getTwitterShareLink(link, text) // → "{text} via @dailydotdev" +// "text" is usually just the post title, so a tweet reads: +// "How we cut edge cold-starts… via @dailydotdev app.daily.dev/posts/…" + +// Recommended — context-aware, pre-filled share copy (still user-editable) +share.article = \`\${title}\\n\\nvia @dailydotdev\`; +share.invite = \`I use daily.dev to keep up with dev news — join me 👇\`; +share.profile = \`My developer profile on @dailydotdev 👇\`; +share.squad = \`We're sharing the best \${topic} content in this Squad 👇\`; +// Keep "via @dailydotdev" for attribution; add 1 relevant emoji max; no hashtag spam.`; + +const LinkCopyBehavior = (): React.ReactElement => ( + + + The image gets the attention, but half of a great link preview is + everything around it: the title wording, the description, the little + favicon and brand avatar, the URL that shows, and the message we pre-fill + when someone taps “share”. This page covers all of it — current state vs. + what we should do — for the parts that live outside the Open + Graph image. + + + Anatomy — the non-image parts +
+ +
+ + On messengers and Slack/Discord/Facebook the text{' '} + does most of the work — the image may be small or absent. The favicon + and domain are the trust signal. The pre-filled share message is what + actually frames the link for the recipient. + +
+
+ + + + Title & description copy + + + + + Favicon, site name & brand avatar + + Slack, Discord, iMessage and Reddit show a small favicon next to the + domain; X shows the @dailydotdev profile avatar. These are + tiny but they’re the brand’s handshake. + + + + + + The pre-filled share message + + When a user shares from inside daily.dev, we pre-fill the text. Today it’s + basically the title plus “via @dailydotdev”. Tailoring it per context (and + keeping it editable) makes the share feel personal and lifts CTR. + + {SHARE_TEXT_CODE} + + } + right={ + + } + /> + + + + URLs & link behavior + + + + + + Priorities (beyond the image) + +
+); + +const meta: Meta = { + title: 'Open Graph/8. Link Copy, Metadata & Behavior', + component: LinkCopyBehavior, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +type Story = StoryObj; + +export const CopyAndBehavior: Story = { name: 'Copy, metadata & behavior' }; diff --git a/packages/storybook/stories/open-graph/OpenGraphComparison.stories.tsx b/packages/storybook/stories/open-graph/OpenGraphComparison.stories.tsx new file mode 100644 index 00000000000..74210b94538 --- /dev/null +++ b/packages/storybook/stories/open-graph/OpenGraphComparison.stories.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + LinkedInCard, + MetaTagsTable, + WhatsAppCard, + XCard, +} from './platformCards'; +import type { OgData } from './platformCards'; +import { USE_CASES } from './useCases'; +import { + Bullets, + Divider, + Heading, + Muted, + Page, + PageHeader, + TwoCol, +} from './ogStoryLayout'; + +const Stack = ({ data }: { data: OgData }): React.ReactElement => ( +
+ + +
+ +
+ +
+); + +const Comparison = (): React.ReactElement => ( + + + A side-by-side of what we ship today (the{' '} + real, live images, pulled from the actual URLs) against a + single, unified template system that adapts per share type. Each surface + keeps the same visual language so a daily.dev link is instantly + recognizable, while the headline, attribution and context change to fit + the object being shared. Red column = today; green column = proposed. + + + {USE_CASES.map((uc, i) => ( +
+ + {i + 1}. {uc.name} + + {uc.what} + +
+ } + right={} + /> +
+ + } + right={} + /> + + {i < USE_CASES.length - 1 && } +
+ ))} +
+); + +const meta: Meta = { + title: 'Open Graph/2. Current vs Recommended', + component: Comparison, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +type Story = StoryObj; + +export const SideBySide: Story = { name: 'Side by side' }; diff --git a/packages/storybook/stories/open-graph/OpenGraphGuidelines.stories.tsx b/packages/storybook/stories/open-graph/OpenGraphGuidelines.stories.tsx new file mode 100644 index 00000000000..367bcbb2615 --- /dev/null +++ b/packages/storybook/stories/open-graph/OpenGraphGuidelines.stories.tsx @@ -0,0 +1,314 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + Bullets, + CodeBlock, + Divider, + Heading, + Muted, + Page, + PageHeader, + SpecTable, +} from './ogStoryLayout'; + +const Guidelines = (): React.ReactElement => ( + + + A condensed field guide to how link previews actually work across + platforms in 2026, the technical details that matter, and the concrete + changes we should make to daily.dev’s sharing. Pair this with the “Current + vs Recommended” page for the per-surface mock-ups. + + + The one image spec that covers everything + + Ship a single 1200 × 630 px (1.91:1) image. It satisfies + Facebook, LinkedIn, X large cards, Slack, Discord, WhatsApp and iMessage + without cropping the important bits. Keep the file under{' '} + ~500 KB (hard ceiling ~1 MB or some crawlers skip it), + prefer PNG for text-heavy cards / JPEG for photographic ones, and keep all + critical content inside a centered ~80% safe area since + some surfaces center-crop toward square. + + + + + + Practical takeaway: write for the strictest platform.{' '} + Keep og:title ≤ ~60 chars and og:description ≤ ~110 chars so nothing + important is clipped on LinkedIn/X while still reading well on + Slack/Facebook where more is shown. + + + + + Tags we should always emit + ', + 'Lets Reddit (Embedly), Discord & others build richer cards', + '✗ Likely missing — add for the Reddit/dev audience', + ], + ]} + /> + + {` + + + + + + + + + + + + + +`} + + + + Design principles for the generated image + + + + + Lessons from the most-shared platforms + + We studied the platforms whose links travel the most across the web (see + the Benchmark page). The winners — YouTube, Reddit, GitHub — all share + rich, open, contextual cards; the gated ones (Instagram, TikTok) get + shared less because their links look broken. What that means for us: + + + + + + Caching & invalidation + + Crawlers cache aggressively: Facebook and LinkedIn hold previews for{' '} + up to ~7 days, and they fetch your tags exactly once per + URL. Two consequences: + + + + + + Recommended rollout order + + + + + Sources + + +); + +const meta: Meta = { + title: 'Open Graph/3. Guidelines & Research', + component: Guidelines, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +type Story = StoryObj; + +export const Research: Story = { name: 'Guidelines & research' }; diff --git a/packages/storybook/stories/open-graph/Overview.stories.tsx b/packages/storybook/stories/open-graph/Overview.stories.tsx new file mode 100644 index 00000000000..b35b48e58fa --- /dev/null +++ b/packages/storybook/stories/open-graph/Overview.stories.tsx @@ -0,0 +1,166 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { RecommendedOg } from './dailyOgImages'; +import { + Bullets, + Divider, + Heading, + Muted, + Page, + PageHeader, +} from './ogStoryLayout'; + +const SANS = + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; + +const TOC: Array<{ n: string; name: string; what: string }> = [ + { + n: '1', + name: 'Current Share Previews', + what: 'Every share type as it unfurls today on 7 platforms, with a meta-tag inspector.', + }, + { + n: '2', + name: 'Current vs Recommended', + what: 'Side-by-side mock-ups and the fix for each surface.', + }, + { + n: '3', + name: 'Guidelines & Research', + what: 'Platform spec cheat-sheet, tags to emit, caching, priorities.', + }, + { + n: '4', + name: 'X (Twitter) Deep Dive', + what: 'How X actually renders links and how to win the surface where the image is everything.', + }, + { + n: '5', + name: 'Recommended Template Spec', + what: 'The real @vercel/og template, tokens, text rules and rollout.', + }, + { + n: '6', + name: 'How the Best Platforms Share', + what: 'What GitHub, X, Reddit & co. do — and which platforms drive the most shares.', + }, + { + n: '7', + name: 'Design Directions', + what: 'Five creative directions for the image — pick one with me.', + }, + { + n: '8', + name: 'Link Copy, Metadata & Behavior', + what: 'Everything outside the image: titles, descriptions, favicon, URLs, share text.', + }, +]; + +const Overview = (): React.ReactElement => ( + + + When a developer shares a daily.dev link — an article, their profile, a + squad, an invite — that preview is often the first time someone sees us. + It’s free, high-intent distribution, and right now we’re leaving most of + it on the table: titles get truncated by a “| daily.dev” suffix, several + share types fall back to one generic image, and nothing feels distinctly + ours. This initiative fixes the whole surface — the image, the copy, and + the link behavior — across every place we’re shared. + + +
+ +
+ + Why this matters now + + + + + What’s in this section +
+ {TOC.map((t) => ( +
+ + {t.n} + +
+
+ {t.name} +
+
+ {t.what} +
+
+
+ ))} +
+ + Open the numbered pages under “Open Graph” in the sidebar in order. Start + with the design directions (7) if you just want to react to visuals. + +
+); + +const meta: Meta = { + title: 'Open Graph/0. Overview', + component: Overview, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +type Story = StoryObj; + +export const Start: Story = { name: 'Start here' }; diff --git a/packages/storybook/stories/open-graph/RecommendedSpec.stories.tsx b/packages/storybook/stories/open-graph/RecommendedSpec.stories.tsx new file mode 100644 index 00000000000..d64444e3e69 --- /dev/null +++ b/packages/storybook/stories/open-graph/RecommendedSpec.stories.tsx @@ -0,0 +1,470 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { RecommendedOg } from './dailyOgImages'; +import { + Bullets, + CodeBlock, + Divider, + Heading, + Muted, + Page, + PageHeader, + SpecTable, +} from './ogStoryLayout'; + +const SANS = + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; + +const Callout = ({ + n, + top, + left, +}: { + n: number; + top: string; + left: string; +}): React.ReactElement => ( + + {n} + +); + +const Anatomy = (): React.ReactElement => ( +
+
+ +
+ + + + + +
+); + +const TEMPLATE_CODE = `// share-card.tsx — Satori (@vercel/og) template. Flexbox + inline styles only. +// One component, driven by \`kind\`. No grid, no external CSS. + +type Kind = + | 'article' | 'shared' | 'profile' | 'squad' + | 'invite' | 'tag' | 'comment' | 'source' | 'generic'; + +interface ShareCardProps { + kind: Kind; + title: string; // already truncated to <= 3 lines server-side + subtitle?: string; // 0–2 lines + identity?: { name: string; handle?: string; avatar?: string }; // top-left + upvotes?: string; // article / shared / comment -> engagement bar + comments?: string; + meta?: string; // other kinds -> glass meta pill ("4,210 members") + cover?: string; // absolute https URL; drives backdrop + art thumbnail +} + +export function ShareCard(p: ShareCardProps) { + const engage = ['article', 'shared', 'comment'].includes(p.kind); + return ( +
+ {/* Ambient backdrop — a pre-blurred cover (Satori can't blur at runtime), + else a brand-gradient glow. A dark gradient on top keeps text legible. */} + {p.cover + ? + :
} +
+ + {/* Top bar: identity (left) + the real daily.dev logo (right) */} +
+ + +
+ + {/* Left content: headline + subtitle on top, the glass bar pinned to the bottom */} +
+
+
{p.title}
+ {p.subtitle &&
{p.subtitle}
} +
+ {/* Glass bar — pre-composited translucent fill (no runtime backdrop-blur in Satori) */} +
+ {engage + ? <> + : {p.meta}} +
+
+ + {/* Art bottom-right: cover thumbnail, avatar, or brand tile (radius 54) */} +
+ +
+
+ ); +} + +// Satori clamps text with the webkit box trio: +const clamp = (lines: number) => + ({ display: '-webkit-box', WebkitBoxOrient: 'vertical', WebkitLineClamp: lines, overflow: 'hidden' });`; + +const ROUTE_CODE = `// /api/share route — edge runtime. Generates 1200×630 once, then CDN-cached. +import { ImageResponse } from '@vercel/og'; // or 'next/og' + +export const runtime = 'edge'; + +const FONT = (w: string) => + fetch(\`https://og.daily.dev/fonts/Inter-\${w}.ttf\`).then((r) => r.arrayBuffer()); + +export async function GET(req: Request) { + const u = new URL(req.url); + const kind = (u.searchParams.get('kind') ?? 'generic') as Kind; + const props = await resolveProps(kind, u.searchParams); // fetch post/squad/user, truncate title + + const [regular, bold, extrabold] = await Promise.all([FONT('Regular'), FONT('Bold'), FONT('ExtraBold')]); + + return new ImageResponse(, { + width: 1200, + height: 630, + fonts: [ + { name: 'Inter', data: regular, weight: 400, style: 'normal' }, + { name: 'Inter', data: bold, weight: 700, style: 'normal' }, + { name: 'Inter', data: extrabold, weight: 800, style: 'normal' }, + ], + headers: { + // version the URL with ?v={contentHash}; then this immutable cache is safe. + 'cache-control': 'public, immutable, no-transform, max-age=31536000', + }, + }); +}`; + +const META_CODE = `// Webapp side — point og:image at the versioned generator and keep tags lean. +const v = post.contentHash; // bust X/LinkedIn/FB caches on edit or redesign +const img = \`https://og.daily.dev/api/share?kind=article&id=\${post.id}&v=\${v}\`; + +const seo: NextSeoProps = { + title: post.title, // NO "| daily.dev" suffix + description: clamp(post.summary, 110), + openGraph: { + type: 'article', + title: post.title, + description: clamp(post.summary, 110), + siteName: 'daily.dev', + images: [{ url: img, width: 1200, height: 630, alt: post.title }], + }, + twitter: { cardType: 'summary_large_image', site: '@dailydotdev' }, + additionalMetaTags: [ + { name: 'twitter:image:alt', content: post.title }, + ...(post.author?.twitter ? [{ name: 'twitter:creator', content: post.author.twitter }] : []), + { name: 'twitter:label1', content: 'Author' }, { name: 'twitter:data1', content: post.source.name }, + { name: 'twitter:label2', content: 'Reading time' }, { name: 'twitter:data2', content: \`\${post.readTime} min\` }, + ], +};`; + +const RecommendedSpec = (): React.ReactElement => ( + + + A single, contextual template that renders every share type — driven by a{' '} + kind parameter — plus the text/naming rules and the actual{' '} + @vercel/og (Satori) implementation it should ship as on{' '} + og.daily.dev. The goal: a daily.dev link is recognizable at a + glance on any platform, and reads perfectly even on X where the image is + the entire message. + + + Anatomy of the card +
+ +
    +
  1. + Identity — source / author / sharer top-left, where + the context lives (a small avatar + name). +
  2. +
  3. + daily.dev logo — top-right, the real wordmark; + present but secondary, never competes with the message. +
  4. +
  5. + Headline — the hero. ≤ 3 lines, the only thing X + shows; sits over an ambient backdrop derived from the cover. +
  6. +
  7. + Engagement bar — avocado upvote + comment in a + glass/blur pill; the ownable daily.dev signal. +
  8. +
  9. + Cover art — rounded thumbnail bottom-right (becomes + an avatar/tile for profiles, squads, tags…). +
  10. +
+
+ + + + Design tokens + + + + + The text system — titles, descriptions, names + + The copy matters as much as the picture. Three global rules, then a + pattern per surface. + + + + + + + 1 · The Satori template + + One component, switched on kind. Satori supports flexbox, + inline styles, gradients, borders and the webkit line-clamp trio — no CSS + grid, no stylesheets. Fonts must be supplied explicitly (TTF/OTF/WOFF; not + WOFF2). + + {TEMPLATE_CODE} + + 2 · The edge route + + Generate once at the edge, then serve immutable from the CDN. The{' '} + ?v= content hash is what lets us safely use a one-year + immutable cache while still busting stale previews on edit. + + {ROUTE_CODE} + + 3 · Meta tags on the webapp + + Point og:image at the versioned generator and keep the tags + lean. Note: no title suffix, explicit width/height, alt text, and the + Slack-only label/data pairs. + + {META_CODE} + + + + Rollout against the existing code + + + + + Sources + +
+); + +const meta: Meta = { + title: 'Open Graph/5. Recommended Template Spec', + component: RecommendedSpec, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +type Story = StoryObj; + +export const Spec: Story = { name: 'Template spec & code' }; diff --git a/packages/storybook/stories/open-graph/XDeepDive.stories.tsx b/packages/storybook/stories/open-graph/XDeepDive.stories.tsx new file mode 100644 index 00000000000..0b883bbdf2c --- /dev/null +++ b/packages/storybook/stories/open-graph/XDeepDive.stories.tsx @@ -0,0 +1,376 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { ReactNode } from 'react'; +import { XCard } from './platformCards'; +import type { OgData } from './platformCards'; +import { RecommendedOg } from './dailyOgImages'; +import { USE_CASES } from './useCases'; +import { + Bullets, + Divider, + Heading, + Muted, + Page, + PageHeader, + SpecTable, + TwoCol, +} from './ogStoryLayout'; + +const SANS = + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; + +const article = USE_CASES.find((u) => u.id === 'article'); + +const labelStyle: React.CSSProperties = { + fontFamily: SANS, + fontSize: 12, + fontWeight: 700, + textTransform: 'uppercase', + letterSpacing: 0.6, + color: '#71717a', + marginBottom: 8, +}; + +/** Realistic X timeline chrome wrapped around a link card. */ +const Tweet = ({ + data, + tweetText, +}: { + data: OgData; + tweetText: string; +}): React.ReactElement => ( +
+
+ +
+
+ + Ido Shamun + + + @idoshamun · 2h + +
+
+ {tweetText} +
+ +
+ 💬 24 + 🔁 108 + ♥ 642 + 📊 23K +
+
+
+
+); + +/** Overlay the 80% safe area + rounded-corner danger zones on an image. */ +const SafeAreaFrame = ({ + children, +}: { + children: ReactNode; +}): React.ReactElement => ( +
+
{children}
+ {/* 80% safe area */} +
+ + 80% safe area + + {/* corner danger markers */} + {['0 0 auto auto', '0 auto auto 0', 'auto 0 0 auto', 'auto auto 0 0'].map( + (inset) => ( + + ), + )} +
+); + +const XDeepDive = (): React.ReactElement => ( + + + X is the highest-leverage surface for developer sharing — and the most + unforgiving. On a modern summary_large_image card, X shows{' '} + only the image and a small domain pill: no title, no + description, no body text outside the picture. Whatever you want a reader + to know has to live inside the 1200×630 frame. This page covers exactly + how X behaves and how our generated image should be built for it. + + + Why X is where the image has to do all the work + + + + + The same link, in an X timeline + + Today the card leads with the publisher’s cover and a small daily.dev mark + — the headline is barely legible and the brand is easy to miss. The + recommended card bakes the headline, author and brand into the image, so + it reads at a glance even at timeline scale. + + {article && ( +
+ + } + right={ + + } + /> +
+ )} + + + + Design for the X thumbnail, not the full-size image + + Keep everything that matters inside the centered{' '} + 80% safe area, and out of the four corners (rounded + + domain pill). Headline ≥ 48px at 1200px width, bold, with a hard contrast + floor so it survives next to vibrant posts in the feed. + +
+ + + +
+ +
+
+ + + + X-specific tag & image spec + + + + + Large vs summary card + + We should always request summary_large_image. The small{' '} + summary card (square thumbnail + text) is the weak fallback — + shown here only so the difference is obvious. With the small card the + image shrinks to a tile and the text X shows is our og:title/description, + so those still need to read well. + + {article && ( +
+
+
summary_large_image (use this)
+ +
+
+
summary (avoid)
+ +
+
+ )} + + + + X recommendations + + + + + Sources + +
+); + +const meta: Meta = { + title: 'Open Graph/4. X (Twitter) Deep Dive', + component: XDeepDive, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +type Story = StoryObj; + +export const Optimizing: Story = { name: 'Optimizing for X' }; diff --git a/packages/storybook/stories/open-graph/cover.tsx b/packages/storybook/stories/open-graph/cover.tsx new file mode 100644 index 00000000000..f6922709c42 --- /dev/null +++ b/packages/storybook/stories/open-graph/cover.tsx @@ -0,0 +1,613 @@ +import React from 'react'; +import type { CSSProperties, ReactNode } from 'react'; +import LogoIcon from '@dailydotdev/shared/src/svg/LogoIcon'; +import LogoText from '@dailydotdev/shared/src/svg/LogoText'; +import { UpvoteIcon } from '@dailydotdev/shared/src/components/icons/Upvote'; +import { DiscussIcon } from '@dailydotdev/shared/src/components/icons/Discuss'; + +/** + * The locked "Layout A — clean" baseline cover, self-contained. + * Source top-left, real daily.dev logo top-right, headline left, cover art + * bottom-right (bottom-aligned with the actions bar), and a glass/blur + * upvote + comment actions bar pinned bottom-left. This is the agreed base — + * recognizability explorations build on it via the slot props. + */ + +export const SANS = + '-apple-system, "Helvetica Neue", Helvetica, Inter, Arial, "Segoe UI", system-ui, sans-serif'; +export const MONO = + '"SF Mono", "JetBrains Mono", "Fira Code", ui-monospace, Menlo, monospace'; + +export const INK = '#0B0E13'; +export const CABBAGE = '#CE3DF3'; +export const ONION = '#8A63F4'; +export const AVOCADO = '#1DDC6F'; +export const CHEESE = '#FFE923'; +export const BLUECHEESE = '#2CDCE6'; +export const BACON = '#FF5C5C'; +export const TEXT = '#FFFFFF'; +export const TERTIARY = '#A6AEBF'; +export const SECONDARY = '#D7DCE6'; +export const GRAD = `linear-gradient(135deg, ${CABBAGE}, ${ONION})`; +export const white = { + ['--theme-text-primary' as string]: '#FFFFFF', +} as CSSProperties; + +const COVER = + 'https://media.daily.dev/image/upload/s--P4t4XyoV--/f_auto/v1722860399/public/Placeholder%2001'; + +export interface CoverData { + title: string; + source: string; + sourceColor: string; + readTime: string; + upvotes: string; + comments: string; + cover: string; +} +export const XD: CoverData = { + title: 'How we cut edge cold-starts by 90% with Rust and Wasm', + source: 'The Pragmatic Engineer', + sourceColor: '#FF8E3B', + readTime: '6m read', + upvotes: '312', + comments: '448', + cover: COVER, +}; + +export const clamp = (lines: number): CSSProperties => ({ + display: '-webkit-box', + WebkitLineClamp: lines, + WebkitBoxOrient: 'vertical', + overflow: 'hidden', +}); + +export const Root = ({ + children, +}: { + children: ReactNode; +}): React.ReactElement => ( +
+ {children} +
+); + +export const Ambient = ({ src }: { src: string }): React.ReactElement => ( + <> + +
+ +); + +export const Logo = (): React.ReactElement => ( + + + + + + + + +); + +export const Source = ({ d }: { d: CoverData }): React.ReactElement => ( +
+ + + {d.source} + + + · {d.readTime} + +
+); + +const Stat = ({ + Cmp, + count, +}: { + Cmp: typeof UpvoteIcon; + count: string; +}): React.ReactElement => ( + + + + + + {count} + + +); + +// The glass/blur bar chrome — shared by the engagement bar and meta pills. +export const GlassBar = ({ + children, + style, +}: { + children: ReactNode; + style?: CSSProperties; +}): React.ReactElement => ( +
+ {children} +
+); + +export const Actions = ({ d }: { d: CoverData }): React.ReactElement => ( + + + + + + + +); + +export const MetaPill = ({ text }: { text: string }): React.ReactElement => ( + + + {text} + + +); + +export interface StatItem { + label: string; + Cmp: typeof UpvoteIcon; + value: string; + color?: string; +} + +// Glass bar with several icon+number stats (profile reputation/streak/posts, +// squad members/posts/upvotes) — same chrome as the engagement bar. +export const StatBar = ({ + stats, +}: { + stats: StatItem[]; +}): React.ReactElement => ( + + + {stats.map((s, i) => ( + + {i > 0 && ( + + )} + + + + + + {s.value} + + + + ))} + + +); + +// White primary button (daily.dev primary on a dark surface). +export const PrimaryButton = ({ + children, +}: { + children: ReactNode; +}): React.ReactElement => ( + + {children} + +); + +export const Headline = ({ d }: { d: CoverData }): React.ReactElement => ( + + {d.title} + +); + +// Headline/title for the generalized cover (any text, sized per use case). +export const Title = ({ + children, + size = 5.2, + lines = 3, +}: { + children: ReactNode; + size?: number; + lines?: number; +}): React.ReactElement => ( + + {children} + +); + +export const Subtitle = ({ + children, +}: { + children: ReactNode; +}): React.ReactElement => ( + + {children} + +); + +export const ArtBox = ({ + children, +}: { + children?: ReactNode; +}): React.ReactElement => ( +
+ + {children} +
+); + +export interface CoverProps { + d?: CoverData; + bg?: ReactNode; + overlay?: ReactNode; + logo?: ReactNode; + art?: ReactNode; + headlineNode?: ReactNode; +} + +// The clean baseline cover. Pass slots to layer a recognizability signature +// without touching the locked structure. +export const Cover = ({ + d = XD, + bg, + overlay, + logo, + art, + headlineNode, +}: CoverProps): React.ReactElement => ( + + + {bg} + {art ?? } +
+ + {logo ?? } +
+
+ + {headlineNode ?? } + +
+ +
+
+ {overlay} +
+); + +// Brand backdrop for covers with no cover image (profile/squad/tag/etc.). +const BrandBackdrop = (): React.ReactElement => ( + <> +
+
+ +); + +export interface OgCoverProps { + // When true, fills its positioned parent (e.g. a platform preview frame). + // Otherwise it is a self-sizing 1200×630 box. + fill?: boolean; + backdrop?: string; + eyebrow?: ReactNode; + title?: ReactNode; + subtitle?: ReactNode; + meta?: ReactNode; + art?: ReactNode; + logo?: ReactNode; + // Where the left content column starts (raise it for content-dense covers). + contentTop?: string; +} + +/** + * The generalized Layout A — same clean structure for every share type: + * identity top-left, logo top-right, title + meta on the left, art bottom-right. + */ +export const OgCover = ({ + fill = false, + backdrop, + eyebrow, + title, + subtitle, + meta, + art, + logo, + contentTop = '15.5cqw', +}: OgCoverProps): React.ReactElement => { + const body = ( + <> + {backdrop ? : } + {art && ( +
+ {art} +
+ )} +
+ {eyebrow} + {logo ?? } +
+
+
+ {title} + {subtitle} +
+ {meta &&
{meta}
} +
+ + ); + if (fill) { + return ( +
+ {body} +
+ ); + } + return {body}; +}; + +export interface CoverItem { + id: string; + name: string; + Component: () => React.ReactElement; +} +export interface CoverCategory { + category: string; + blurb: string; + items: CoverItem[]; +} diff --git a/packages/storybook/stories/open-graph/coverDna.tsx b/packages/storybook/stories/open-graph/coverDna.tsx new file mode 100644 index 00000000000..452cbfecb76 --- /dev/null +++ b/packages/storybook/stories/open-graph/coverDna.tsx @@ -0,0 +1,416 @@ +import React from 'react'; +import type { CSSProperties } from 'react'; +import LogoIcon from '@dailydotdev/shared/src/svg/LogoIcon'; +import LogoText from '@dailydotdev/shared/src/svg/LogoText'; +import { Cover, Logo, GRAD, CABBAGE, ONION, white } from './cover'; +import type { CoverCategory } from './cover'; + +/** + * 20 fresh recognizability treatments on the locked clean baseline — four + * "systems" for making a cover instantly read as daily.dev: + * A. Frame & edge B. Brand color skin C. Background brand mark + * D. Brand lockup (the logo treatment top-right) + * Each changes exactly one thing; the rest of the clean cover is untouched. + */ + +// ============================ A · FRAME & EDGE ============================= +const Keyline = (): React.ReactElement => ( +
+); +const BottomBar = (): React.ReactElement => ( +
+); +const LeftSpine = (): React.ReactElement => ( +
+); +const bar = (s: CSSProperties): React.ReactElement => ( + +); +const Brackets = (): React.ReactElement => ( +
+ {bar({ top: '3cqw', left: '3cqw', width: '11cqw', height: '0.7cqw' })} + {bar({ top: '3cqw', left: '3cqw', width: '0.7cqw', height: '11cqw' })} + {bar({ bottom: '3cqw', right: '3cqw', width: '11cqw', height: '0.7cqw' })} + {bar({ bottom: '3cqw', right: '3cqw', width: '0.7cqw', height: '11cqw' })} +
+); +const InsetFrame = (): React.ReactElement => ( +
+); +const A1 = (): React.ReactElement => } />; +const A2 = (): React.ReactElement => } />; +const A3 = (): React.ReactElement => } />; +const A4 = (): React.ReactElement => } />; +const A5 = (): React.ReactElement => } />; + +// ============================ B · BRAND COLOR SKIN ======================== +const Duotone = (): React.ReactElement => ( + <> +
+
+ +); +const SoftWash = (): React.ReactElement => ( +
+); +const CornerGlow = (): React.ReactElement => ( +
+); +const Vignette = (): React.ReactElement => ( +
+); +const TopFlood = (): React.ReactElement => ( +
+); +const B1 = (): React.ReactElement => } />; +const B2 = (): React.ReactElement => } />; +const B3 = (): React.ReactElement => } />; +const B4 = (): React.ReactElement => } />; +const B5 = (): React.ReactElement => } />; + +// ============================ C · BACKGROUND BRAND MARK =================== +const MonogramWatermark = (): React.ReactElement => ( +
+ +
+); +const CenterEmboss = (): React.ReactElement => ( +
+ +
+); +const Sheen = (): React.ReactElement => ( +
+); +const DotGrid = (): React.ReactElement => ( +
+); +const GhostWordmark = (): React.ReactElement => ( + + daily.dev + +); +const C1 = (): React.ReactElement => } />; +const C2 = (): React.ReactElement => } />; +const C3 = (): React.ReactElement => } />; +const C4 = (): React.ReactElement => } />; +const C5 = (): React.ReactElement => } />; + +// ============================ D · BRAND LOCKUP (top-right) ================ +const Mark = ({ + h = 3, + color = '#fff', +}: { + h?: number; + color?: string; +}): React.ReactElement => ( + + + +); +const Word = ({ h = 3 }: { h?: number }): React.ReactElement => ( + + + +); +const LogoPill = (): React.ReactElement => ( + + + +); +const LogoUnderline = (): React.ReactElement => ( + + + + +); +const LogoTile = (): React.ReactElement => ( + + + + + + +); +const LogoDot = (): React.ReactElement => ( + + + + +); +const LogoRail = (): React.ReactElement => ( + + + + +); +const D1 = (): React.ReactElement => } />; +const D2 = (): React.ReactElement => } />; +const D3 = (): React.ReactElement => } />; +const D4 = (): React.ReactElement => } />; +const D5 = (): React.ReactElement => } />; + +export const COVER_DNA: CoverCategory[] = [ + { + category: 'Frame & edge', + blurb: + 'A consistent border/edge brands every cover (the way a card chrome does). Cleanest, most system-like.', + items: [ + { id: 'a1', name: 'Gradient keyline', Component: A1 }, + { id: 'a2', name: 'Bottom brand bar', Component: A2 }, + { id: 'a3', name: 'Left brand spine', Component: A3 }, + { id: 'a4', name: 'Corner brackets', Component: A4 }, + { id: 'a5', name: 'Inset frame', Component: A5 }, + ], + }, + { + category: 'Brand color skin', + blurb: + 'A consistent color treatment of the imagery — ownable at thumbnail size.', + items: [ + { id: 'b1', name: 'Brand duotone', Component: B1 }, + { id: 'b2', name: 'Soft brand wash', Component: B2 }, + { id: 'b3', name: 'Corner glow', Component: B3 }, + { id: 'b4', name: 'Brand vignette', Component: B4 }, + { id: 'b5', name: 'Top flood', Component: B5 }, + ], + }, + { + category: 'Background brand mark', + blurb: 'The mark/wordmark lives quietly behind the content as a watermark.', + items: [ + { id: 'c1', name: 'Monogram watermark', Component: C1 }, + { id: 'c2', name: 'Center emboss', Component: C2 }, + { id: 'c3', name: 'Diagonal sheen', Component: C3 }, + { id: 'c4', name: 'Dot grid', Component: C4 }, + { id: 'c5', name: 'Ghost wordmark', Component: C5 }, + ], + }, + { + category: 'Brand lockup (the logo, top-right)', + blurb: + 'A consistent treatment of the logo where it already sits — restrained.', + items: [ + { id: 'd1', name: 'Logo pill', Component: D1 }, + { id: 'd2', name: 'Logo underline', Component: D2 }, + { id: 'd3', name: 'Logo tile', Component: D3 }, + { id: 'd4', name: 'Brand dot', Component: D4 }, + { id: 'd5', name: 'Brand rail', Component: D5 }, + ], + }, +]; diff --git a/packages/storybook/stories/open-graph/dailyOgImages.tsx b/packages/storybook/stories/open-graph/dailyOgImages.tsx new file mode 100644 index 00000000000..72790df2648 --- /dev/null +++ b/packages/storybook/stories/open-graph/dailyOgImages.tsx @@ -0,0 +1,829 @@ +import React from 'react'; +import type { CSSProperties } from 'react'; +import LogoIcon from '@dailydotdev/shared/src/svg/LogoIcon'; +import { UpvoteIcon } from '@dailydotdev/shared/src/components/icons/Upvote'; +import { UserIcon } from '@dailydotdev/shared/src/components/icons/User'; +import { SquadIcon } from '@dailydotdev/shared/src/components/icons/Squad'; +import { DocsIcon } from '@dailydotdev/shared/src/components/icons/Docs'; +import { DevPlusIcon } from '@dailydotdev/shared/src/components/icons/DevPlus'; +import { + OgCover, + Ambient, + Logo, + Title, + Subtitle, + MetaPill, + Actions, + StatBar, + PrimaryButton, + GlassBar, + GRAD, + CABBAGE, + INK, + SANS, + TEXT, + SECONDARY, + TERTIARY, + clamp, + white, + XD, +} from './cover'; +import type { StatItem } from './cover'; + +// =========================================================================== +// RECOMMENDED — one unified, contextual system +// =========================================================================== + +export type RecommendedKind = + | 'article' + | 'shared' + | 'profile' + | 'squad' + | 'invite' + | 'tag' + | 'comment' + | 'plus' + | 'generic'; + +interface RecommendedProps { + kind?: RecommendedKind; + title?: string; + subtitle?: string; + cover?: string; + name?: string; + handle?: string; + avatarSrc?: string; + meta?: string; + sharer?: string; + upvotes?: string; + comments?: string; + // profile stats + reputation?: string; + streak?: string; + posts?: string; + reads?: string; + tags?: string[]; + sources?: string[]; // favorite source logo URLs ("reads the most") + // squad stats + members?: string; + // invite / referral / plus + cta?: string; // white primary button label + count?: string; // prominent count (number shows in cabbage) + countWord?: string; // e.g. "developers" + mascot?: string; // Charm illustration URL for the art slot + square?: boolean; // render the square (1:1) summary-card variant +} + +// Square (1:1) variant — responsive fallback for summary cards. The headline +// is dropped (unreadable at thumbnail size, and it's already in the link); +// keep only what reads: the cover art, the daily.dev logo, and the source. +const SquareCover = ({ + cover, + source, +}: { + cover?: string; + source?: string; +}): React.ReactElement => ( +
+ {cover ? ( + + ) : ( +
+ )} + {/* top bar: source (left) + logo (right) */} +
+ {source ? ( + + + + {source} + + + ) : ( + + )} + +
+ {/* centered cover art — the hero of the square */} + {cover && ( +
+ +
+ )} +
+); + +// ---- Layout A building blocks for the recommended template ---------------- +const RArt = ({ + src, + circle = false, +}: { + src: string; + circle?: boolean; +}): React.ReactElement => ( + +); +const RTile = ({ + children, + circle = false, +}: { + children: React.ReactNode; + circle?: boolean; +}): React.ReactElement => ( +
+ {children} +
+); +const RMark = (): React.ReactElement => ( + + + +); +const REyebrow = ({ + text, + sub, + src, + dot = false, +}: { + text: string; + sub?: string; + src?: string; + dot?: boolean; +}): React.ReactElement => ( +
+ {src && ( + + )} + {!src && dot && ( + + )} + + {text} + + {sub && ( + + · {sub} + + )} +
+); + +// Charm mascot in the art slot. +// Charm mascot — bigger, anchored bottom-right and dropped a bit lower so it +// fills the corner instead of floating small. (The art slot is position: +// absolute, so this positions against it.) +const RMascot = ({ src }: { src: string }): React.ReactElement => ( + +); + +// Prominent count line — the number pops in cabbage, with a leading icon. +const ProminentCount = ({ + count, + word, +}: { + count: string; + word?: string; +}): React.ReactElement => ( + + + + + + {count} + {word ? ` ${word}` : ''} + + +); + +// Community proof — a stack of real member avatars + the count, the way the +// squad/source pages surface the community. Used for source + tag. Faces are +// real daily.dev developer avatars. +const COMMUNITY_FACES = [ + 'https://media.daily.dev/image/upload/s--FcI3RdS1--/f_auto/v1745335145/avatars/avatar_R9RafYjp15h3mJ9XdIkhy', + 'https://media.daily.dev/image/upload/s--AVEMGQgE--/f_auto/v1744349812/avatars/avatar_RVvUzGofSIHGyTxdjqDN1', + 'https://media.daily.dev/image/upload/s--CwdXky60--/f_auto/v1733031652/avatars/avatar_rmFJzNXUNPh163VaQkmF0', +]; +const Community = ({ count }: { count: string }): React.ReactElement => ( + + + + {COMMUNITY_FACES.map((src, i) => ( + + ))} + + + {count} + + + +); + +// ---- Developer profile — mirrors the real DevCard ------------------------ +// Rounded avatar with a clean 1px hairline border (subtle, low-opacity white — +// a soft edge rather than a heavy band). +const TiltAvatar = ({ + label, + src, +}: { + label?: string; + src?: string; +}): React.ReactElement => { + const base: CSSProperties = { + width: '100%', + height: '100%', + borderRadius: '8cqw', + border: '1px solid rgba(255,255,255,0.2)', + boxSizing: 'border-box', + boxShadow: '0 4cqw 11cqw rgba(0,0,0,0.5)', + }; + return src ? ( + + ) : ( +
+ + {label} + +
+ ); +}; + +// The DevCard stats bar: 3 sections, each icon + number + label. +const ProfileStatSection = ({ + value, + label, +}: { + value: string; + label: string; +}): React.ReactElement => ( + + + {value} + + + {label} + + +); +const ProfileStats = ({ + reputation, + streak, + reads, +}: { + reputation?: string; + streak?: string; + reads?: string; +}): React.ReactElement => { + const divider = ( + + ); + return ( +
+ {reputation && ( + + )} + {reputation && streak && divider} + {streak && } + {streak && reads && divider} + {reads && } +
+ ); +}; + +const TagChips = ({ tags }: { tags: string[] }): React.ReactElement => ( + + {tags.map((t) => ( + + #{t} + + ))} + +); + +// Favorite sources the developer reads most — a "Reads" label + source logos +// (rounded squares on white), like the DevCard footer. +const SourceLogos = ({ logos }: { logos: string[] }): React.ReactElement => ( + + + Reads from + + + {logos.map((l) => ( + + + + ))} + + +); + +/** + * The recommended template = the locked "Layout A — clean" cover, one system + * for every share type. Article/shared/comment keep the real engagement bar + * (avocado upvote + comment); other types show stats, a meta pill, or a CTA. + */ +export const RecommendedOg = ({ + kind = 'article', + title = 'How to build a dynamic Open Graph image pipeline at the edge', + subtitle, + cover, + name, + handle, + avatarSrc, + meta, + sharer, + upvotes = '312', + comments = '448', + reputation, + streak, + posts, + reads, + tags, + sources, + members, + cta, + count, + countWord, + mascot, + square = false, +}: RecommendedProps): React.ReactElement => { + if (square) { + return ; + } + const engagement = ; + const pill = meta ? : undefined; + const sub = subtitle ? {subtitle} : undefined; + const ctaNode = cta ? {cta} : undefined; + + const squadStats: StatItem[] = []; + if (members) { + squadStats.push({ label: 'members', Cmp: SquadIcon, value: members }); + } + if (posts) { + squadStats.push({ label: 'posts', Cmp: DocsIcon, value: posts }); + } + if (upvotes) { + squadStats.push({ label: 'upvotes', Cmp: UpvoteIcon, value: upvotes }); + } + + switch (kind) { + case 'shared': + return ( + + } + title={{title}} + subtitle={name ? {name} : undefined} + meta={engagement} + art={cover ? : undefined} + /> + ); + case 'comment': + // No art tile — the quote gets the full width. + return ( + + } + title={{title}} + subtitle={meta ? {meta} : undefined} + meta={engagement} + /> + ); + case 'profile': + // One ordered column with equal gaps: name/role → stats → tags → sources. + return ( + } + title={ + + + + {title} + + {subtitle && {subtitle}} + + {(reputation || streak || reads) && ( + + + + )} + {sources?.length ? : null} + {tags?.length ? : null} + + } + art={} + /> + ); + case 'squad': + return ( + } + title={{title}} + subtitle={sub} + meta={squadStats.length ? : pill} + art={ + cover ? ( + + ) : ( + + + {(title ?? 'S')[0]} + + + ) + } + /> + ); + case 'tag': + return ( + } + title={{title}} + subtitle={sub} + meta={ctaNode ?? (meta ? : undefined)} + art={ + mascot ? ( + + ) : ( + + + # + + + ) + } + /> + ); + case 'invite': + return ( + + } + title={{title}} + subtitle={ + count ? : sub + } + meta={ctaNode ?? pill} + art={ + mascot ? ( + + ) : cover ? ( + + ) : ( + + + + ) + } + /> + ); + case 'plus': + return ( + + + + + + daily.dev Plus + +
+ } + title={{title}} + subtitle={sub} + meta={ctaNode ?? (meta ? : undefined)} + art={ + mascot ? ( + + ) : ( + + + + + + ) + } + /> + ); + case 'generic': + return ( + } + title={{title}} + subtitle={sub} + meta={ctaNode ?? (meta ? : undefined)} + art={ + mascot ? ( + + ) : cover ? ( + + ) : ( + + + + ) + } + /> + ); + default: + return ( + } + title={{title}} + meta={engagement} + art={cover ? : undefined} + /> + ); + } +}; diff --git a/packages/storybook/stories/open-graph/ogStoryLayout.tsx b/packages/storybook/stories/open-graph/ogStoryLayout.tsx new file mode 100644 index 00000000000..2f4747ba5e8 --- /dev/null +++ b/packages/storybook/stories/open-graph/ogStoryLayout.tsx @@ -0,0 +1,348 @@ +import React from 'react'; +import type { CSSProperties, ReactNode } from 'react'; + +/** + * Presentational scaffolding shared by the Open Graph review stories. Uses the + * daily.dev theme CSS variables so it follows Storybook's light/dark toggle, + * but keeps everything provider-free so the stories render standalone. + */ + +const SANS = + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; + +export const Page = ({ + children, +}: { + children: ReactNode; +}): React.ReactElement => ( +
+ {children} +
+); + +export const PageHeader = ({ + eyebrow, + title, + children, +}: { + eyebrow: string; + title: string; + children?: ReactNode; +}): React.ReactElement => ( +
+
+ {eyebrow} +
+

+ {title} +

+ {children && ( +
+ {children} +
+ )} +
+); + +export const Heading = ({ + children, + badge, +}: { + children: ReactNode; + badge?: string; +}): React.ReactElement => ( +
+

+ {children} +

+ {badge && ( + + {badge} + + )} +
+); + +export const Muted = ({ + children, + style, +}: { + children: ReactNode; + style?: CSSProperties; +}): React.ReactElement => ( +

+ {children} +

+); + +export const Bullets = ({ + items, + tone = 'neutral', + title, +}: { + items: string[]; + tone?: 'neutral' | 'bad' | 'good'; + title?: string; +}): React.ReactElement => { + const color = { bad: '#d4342c', good: '#1f9d55', neutral: 'inherit' }[tone]; + const mark = { bad: '✗', good: '✓', neutral: '•' }[tone]; + return ( +
+ {title && ( +
+ {title} +
+ )} +
    + {items.map((item) => ( +
  • + + {mark} + + {item} +
  • + ))} +
+
+ ); +}; + +export const Divider = (): React.ReactElement => ( +
+); + +export const TwoCol = ({ + left, + right, + leftLabel, + rightLabel, +}: { + left: ReactNode; + right: ReactNode; + leftLabel: string; + rightLabel: string; +}): React.ReactElement => ( +
+ {[ + { label: leftLabel, node: left, tone: '#d4342c' }, + { label: rightLabel, node: right, tone: '#1f9d55' }, + ].map((col) => ( +
+
+ {col.label} +
+ {col.node} +
+ ))} +
+); + +const specCell: CSSProperties = { + padding: '8px 12px', + fontSize: 13, + textAlign: 'left', + borderBottom: '1px solid var(--theme-divider-tertiary)', + verticalAlign: 'top', +}; +const specHead: CSSProperties = { + ...specCell, + fontWeight: 700, + color: 'var(--theme-text-primary)', + borderBottom: '2px solid var(--theme-divider-secondary)', +}; + +export const SpecTable = ({ + columns, + rows, +}: { + columns: string[]; + rows: string[][]; +}): React.ReactElement => ( +
+ + + + {columns.map((c) => ( + + ))} + + + + {rows.map((r) => ( + + {r.map((c, ci) => ( + + ))} + + ))} + +
+ {c} +
+ {c} +
+
+); + +/** A 1200×630 (by default) frame for rendering share-image mock-ups. */ +export const OgFrame = ({ + width, + ratio = '1200 / 630', + children, +}: { + width: number | string; + ratio?: string; + children: ReactNode; +}): React.ReactElement => ( +
+ {children} +
+); + +export const CodeBlock = ({ + children, +}: { + children: ReactNode; +}): React.ReactElement => ( +
+    {children}
+  
+); diff --git a/packages/storybook/stories/open-graph/platformCards.tsx b/packages/storybook/stories/open-graph/platformCards.tsx new file mode 100644 index 00000000000..52d30578c56 --- /dev/null +++ b/packages/storybook/stories/open-graph/platformCards.tsx @@ -0,0 +1,614 @@ +import React from 'react'; +import type { CSSProperties, ReactNode } from 'react'; + +/** + * Faithful mock-ups of how a shared link unfurls on each major platform. + * These intentionally use raw inline styles (not the daily.dev design tokens) + * because the goal is to reproduce the *third-party* chrome of X, LinkedIn, + * Facebook, Slack, Discord and the messengers as closely as possible so the + * team can review our Open Graph output in a realistic context. + */ + +export type CardType = 'summary' | 'summary_large_image'; + +export interface OgData { + /** Host shown in the preview, e.g. "app.daily.dev". */ + domain: string; + /** og:url path, shown in the meta table only. */ + path?: string; + /** og:title — the headline platforms display. */ + title: string; + /** og:description. */ + description: string; + /** og:image URL. Used when imageNode is not provided. */ + image?: string; + /** Render a faithful mock of a dynamically generated image instead of . */ + imageNode?: ReactNode; + /** Square-ratio variant of the generated image, for summary cards. */ + squareNode?: ReactNode; + /** twitter:card. */ + cardType: CardType; + /** og:site_name. */ + siteName?: string; + /** og:image:alt / twitter:image:alt. */ + imageAlt?: string; + /** twitter:site handle, e.g. "@dailydotdev". */ + twitterSite?: string; +} + +const SANS = + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; + +const clamp = (lines: number): CSSProperties => ({ + display: '-webkit-box', + WebkitLineClamp: lines, + WebkitBoxOrient: 'vertical', + overflow: 'hidden', +}); + +const Favicon = ({ size = 16 }: { size?: number }): React.ReactElement => ( + + d. + +); + +const ImageFrame = ({ + data, + radius = 0, + ratio = 1200 / 630, +}: { + data: OgData; + radius?: number; + ratio?: number; +}): React.ReactElement => ( +
+ {data.imageNode ? ( +
{data.imageNode}
+ ) : ( + {data.imageAlt + )} +
+); + +const SquareThumb = ({ + data, + size, +}: { + data: OgData; + size: number; +}): React.ReactElement => ( +
+ {data.squareNode ? ( + // A dedicated square-ratio design (logo + cover art + source, no title). +
{data.squareNode}
+ ) : ( + // Fallback: keep the 1.91:1 design intact and letterbox it into the + // square so the whole card still reads — rather than squishing it. +
+ {data.imageNode ? ( +
{data.imageNode}
+ ) : ( + {data.imageAlt + )} +
+ )} +
+); + +// --------------------------------------------------------------------------- +// X / Twitter +// --------------------------------------------------------------------------- +export const XCard = ({ data }: { data: OgData }): React.ReactElement => { + if (data.cardType === 'summary') { + return ( +
+ +
+
{data.domain}
+
+ {data.title} +
+
+ {data.description} +
+
+
+ ); + } + + return ( +
+ + + {data.domain} + +
+ ); +}; + +// --------------------------------------------------------------------------- +// LinkedIn +// --------------------------------------------------------------------------- +export const LinkedInCard = ({ + data, +}: { + data: OgData; +}): React.ReactElement => ( +
+ +
+
+ {data.title} +
+
+ {data.domain} +
+
+
+); + +// --------------------------------------------------------------------------- +// Facebook +// --------------------------------------------------------------------------- +export const FacebookCard = ({ + data, +}: { + data: OgData; +}): React.ReactElement => ( +
+ +
+
+ {data.domain} +
+
+ {data.title} +
+
+ {data.description} +
+
+
+); + +// --------------------------------------------------------------------------- +// Slack +// --------------------------------------------------------------------------- +export const SlackCard = ({ data }: { data: OgData }): React.ReactElement => ( +
+
+ + + {data.siteName || 'daily.dev'} + +
+
+ {data.title} +
+
+ {data.description} +
+
+ +
+
+); + +// --------------------------------------------------------------------------- +// Discord +// --------------------------------------------------------------------------- +export const DiscordCard = ({ data }: { data: OgData }): React.ReactElement => ( +
+
+ {data.siteName || 'daily.dev'} +
+
+ {data.title} +
+
+ {data.description} +
+
+ +
+
+); + +// --------------------------------------------------------------------------- +// WhatsApp (received bubble) +// --------------------------------------------------------------------------- +export const WhatsAppCard = ({ + data, +}: { + data: OgData; +}): React.ReactElement => ( +
+ +
+
+ {data.title} +
+
+ {data.description} +
+
+ {data.domain} +
+
+
+); + +// --------------------------------------------------------------------------- +// iMessage (received bubble) +// --------------------------------------------------------------------------- +export const IMessageCard = ({ + data, +}: { + data: OgData; +}): React.ReactElement => ( +
+ +
+
+ {data.title} +
+
+ {data.domain} +
+
+
+); + +// --------------------------------------------------------------------------- +// Platform grid — renders one preview per platform for a given OgData. +// --------------------------------------------------------------------------- +const PLATFORMS: Array<{ label: string; Card: typeof XCard }> = [ + { label: 'X / Twitter', Card: XCard }, + { label: 'LinkedIn', Card: LinkedInCard }, + { label: 'Facebook', Card: FacebookCard }, + { label: 'Slack', Card: SlackCard }, + { label: 'Discord', Card: DiscordCard }, + { label: 'WhatsApp', Card: WhatsAppCard }, + { label: 'iMessage', Card: IMessageCard }, +]; + +const platformLabelStyle: CSSProperties = { + fontFamily: SANS, + fontSize: 12, + fontWeight: 700, + textTransform: 'uppercase', + letterSpacing: 0.6, + color: '#71717a', + marginBottom: 8, +}; + +export const PlatformGrid = ({ + data, +}: { + data: OgData; +}): React.ReactElement => ( +
+ {PLATFORMS.map(({ label, Card }) => ( +
+
{label}
+ +
+ ))} +
+); + +// --------------------------------------------------------------------------- +// Meta-tag inspector table with per-platform length warnings. +// --------------------------------------------------------------------------- +const LIMITS = { title: 60, description: 110 }; + +const Row = ({ + tag, + value, + limit, +}: { + tag: string; + value?: string; + limit?: number; +}): React.ReactElement => { + const len = value?.length ?? 0; + const over = limit ? len > limit : false; + return ( + + + {tag} + + + {value || } + + + {limit ? `${len} / ${limit}` : len || ''} + + + ); +}; + +export const MetaTagsTable = ({ + data, +}: { + data: OgData; +}): React.ReactElement => { + const url = `https://${data.domain}${data.path || ''}`; + return ( + + + + + + + + + + + +
+ ); +}; diff --git a/packages/storybook/stories/open-graph/useCases.tsx b/packages/storybook/stories/open-graph/useCases.tsx new file mode 100644 index 00000000000..3cb768da0b0 --- /dev/null +++ b/packages/storybook/stories/open-graph/useCases.tsx @@ -0,0 +1,591 @@ +import React from 'react'; +import { + cloudinaryCharmEmptySquads, + cloudinaryCharmReadLater, + cloudinaryCharmNotEnoughTags, +} from '@dailydotdev/shared/src/lib/image'; +import type { OgData } from './platformCards'; +import { RecommendedOg } from './dailyOgImages'; + +export const DEFAULT_OG_IMAGE = + 'https://media.daily.dev/image/upload/s--VAY5ToZt--/f_auto/v1724209435/public/daily.dev%20-%20open%20graph'; + +// The CURRENT column uses the REAL images daily.dev serves today (raw, not +// re-created). Reality check (verified live 2026-06-17): only POSTS get a +// generated card; profile, source, tag, squad, invite, Plus and the app +// default all fall back to the SAME generic image. The marketing homepage +// serves its own og-image.png. +const LANDING_OG = 'https://daily.dev/og-image.png'; + +// A real, live daily.dev post (so the current post card is the genuine asset). +const POST_OG = 'https://og.daily.dev/api/posts/qojM1enSN'; +// The shared variant adds the sharer (real endpoint, ?userid=). +const SHARED_OG = `${POST_OG}?userid=u_123`; +const POST_TITLE = 'How to use traces to avoid breaking changes'; +const POST_SOURCE = 'Community Picks'; +const POST_COVER = + 'https://media.daily.dev/image/upload/f_auto,q_auto/v1/posts/2a3c2ca8481678b5c9178cd656700187'; +// The post's real source logo (Community Picks) and the real commenter avatar. +const POST_SOURCE_LOGO = + 'https://media.daily.dev/image/upload/t_logo,f_auto/v1655817725/logos/community'; +const COMMENT_AVATAR = + 'https://media.daily.dev/image/upload/s--IbwvoTYq--/f_auto/v1772778404/avatars/avatar_giQfoBCN9hYxcvRVo3s95'; + +// Profile share = the real DevCard "wide" image (ProfileLayout sets og:image to +// /devcards/v2/{userId}.png?type=wide — a generated DevCard, NOT the generic). +const PROFILE_OG = + 'https://api.daily.dev/devcards/v2/28849d86070e4c099c877ab6837c61f0.png?type=wide'; +// Real assets for the recommended profile: the user's avatar, their rank-based +// DevCard cover (the bg changes with reputation rank), and real source logos. +const PROFILE_AVATAR = + 'https://media.daily.dev/image/upload/s---xy_OAwk--/f_auto,q_auto/v1703781380/avatars/avatar_28849d86070e4c099c877ab6837c61f0'; +const PROFILE_RANK_COVER = + 'https://media.daily.dev/image/upload/s--xDkKz00z--/f_auto/v1707920136/covers/cover_28849d86070e4c099c877ab6837c61f0'; +// The user's real "reads the most" sources, straight from their DevCard. +const PROFILE_SOURCES = [ + 'https://media.daily.dev/image/upload/s--fk_6ycEi--/f_auto,q_auto/v1780996001/logos/collections', + 'https://media.daily.dev/image/upload/s--stRJbTCn--/f_auto/v1752953684/squads/ad08ba59-6646-487f-a8bd-2147d9e572a6', + 'https://media.daily.dev/image/upload/s--V91DY4ls--/f_auto,q_auto/v1772617267/logos/agents_digest', + 'https://media.daily.dev/image/upload/t_logo,f_auto/v1/logos/hn', +]; +// Real WebDev squad share image (its own image is what production serves). +const WEBDEV_OG = + 'https://media.daily.dev/image/upload/s--3B1fh4kU--/f_auto,q_auto/v1/squads/94fc7a56-e6d2-403f-acd6-b988b426574f'; +// Real freeCodeCamp source logo. +const FREECODECAMP_LOGO = + 'https://media.daily.dev/image/upload/t_logo,f_auto/v1628412854/logos/freecodecamp'; + +export interface UseCase { + id: string; + /** Human label for the share type. */ + name: string; + /** What object is being shared and from where. */ + what: string; + /** ReferralCampaignKey / route that produces it. */ + source: string; + /** Problems with the current treatment. */ + issues: string[]; + /** What we should change. */ + recommendations: string[]; + current: OgData; + recommended: OgData; +} + +export const USE_CASES: UseCase[] = [ + { + id: 'article', + name: 'Article / Post', + what: 'A developer shares an aggregated article from the feed or post page.', + source: '/posts/[slug] · ReferralCampaignKey.SharePost', + issues: [ + 'og:title always gets a " | daily.dev" suffix appended, eating ~12 chars of the headline and pushing it over the 60-char sweet spot.', + 'og:description falls back to a truncated, often HTML-stripped excerpt with no consistent value framing.', + 'The generated image leads with the publisher’s cover; daily.dev branding is small and easy to miss.', + 'No og:image:alt / twitter:image:alt is set.', + ], + recommendations: [ + 'Drop the title suffix for shareable content — site_name already conveys the brand. Reserve the suffix for navigational pages.', + 'Lead the image with the headline + author + reading time over a consistent branded frame; treat the cover as a supporting thumbnail.', + 'Always emit og:image:alt mirroring the post title.', + 'Add twitter:label/data (Author, Reading time) for richer Slack/X-adjacent unfurls.', + ], + current: { + domain: 'app.daily.dev', + path: '/posts/how-to-use-traces-to-avoid-breaking-changes', + title: 'How to use traces to avoid breaking changes | daily.dev', + description: + 'How distributed traces help you catch breaking changes before they ship…', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + image: POST_OG, + }, + recommended: { + domain: 'app.daily.dev', + path: '/posts/how-to-use-traces-to-avoid-breaking-changes', + title: 'How to use traces to avoid breaking changes', + description: + 'How distributed traces help you catch breaking changes before they ship — a practical walkthrough.', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + imageAlt: 'How to use traces to avoid breaking changes', + imageNode: ( + + ), + squareNode: ( + + ), + }, + }, + { + id: 'shared-post', + name: 'Shared post (with attribution)', + what: 'A user shares a post via their personal share link, attributing the share to them.', + source: + '/posts/[id]/share?userid=… · og.daily.dev/api/posts/{id}?userid={uid}', + issues: [ + 'Attribution ("X shared") is small and inconsistent vs. the standard post image.', + 'Same title-suffix and description issues as a normal post.', + 'The sharer’s avatar/identity is not reliably surfaced, weakening the social proof that drives the click.', + ], + recommendations: [ + 'Make the sharer a first-class element: avatar + "shared by {name}" pill at the top of the image.', + 'Keep the rest of the article template identical so the share feels like a native daily.dev object.', + ], + current: { + domain: 'app.daily.dev', + path: '/posts/.../share?userid=u_123', + title: 'How to use traces to avoid breaking changes | daily.dev', + description: + 'How distributed traces help you catch breaking changes before they ship…', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + image: SHARED_OG, + }, + recommended: { + domain: 'app.daily.dev', + path: '/posts/.../share?userid=u_123', + title: 'How to use traces to avoid breaking changes', + description: + 'Ido shared this on daily.dev — how traces catch breaking changes before they ship.', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + imageAlt: 'Ido shared: How to use traces to avoid breaking changes', + imageNode: ( + + ), + }, + }, + { + id: 'profile', + name: 'Developer profile', + what: 'Sharing a user profile / DevCard.', + source: + '/[username] · DevCard v2 wide image · ReferralCampaignKey.ShareProfile', + issues: [ + 'The profile shares the DevCard image (/devcards/v2/{id}.png?type=wide) — a different visual language from post shares, and not 1.91:1, so it gets letterboxed/cropped in unfurls.', + 'Bio (og:description) is often empty; falls back to the generic site description.', + 'Title carries the " | daily.dev" suffix.', + ], + recommendations: [ + 'Use the same template family as posts, in "developer" mode: avatar, name, @handle, headline stat (streak, reputation).', + 'Default description to a generated one-liner ("{name} reads about React, Rust & AI on daily.dev") when bio is empty.', + ], + current: { + domain: 'app.daily.dev', + path: '/idoshamun', + title: 'Ido Shamun (@idoshamun) | daily.dev', + description: + 'daily.dev is the easiest way to stay updated on the latest programming news…', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + image: PROFILE_OG, + }, + recommended: { + domain: 'app.daily.dev', + path: '/idoshamun', + title: 'Ido Shamun (@idoshamun)', + description: + 'Ido reads about AI, LLMs & web dev on daily.dev — 20.5k posts read, 1,087-day longest streak.', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + imageAlt: 'Ido Shamun on daily.dev', + imageNode: ( + + ), + }, + }, + { + id: 'squad', + name: 'Squad', + what: 'Sharing a Squad’s public page.', + source: + '/squads/[handle] · getSquadOpenGraph() · ReferralCampaignKey.ShareSource', + issues: [ + 'Uses the squad’s own uploaded banner, whose dimensions/quality vary wildly and may not be 1.91:1.', + 'Falls back to the generic brand image when no banner is set — zero context.', + 'No member count / activity signal in the preview to drive joins.', + ], + recommendations: [ + 'Render a generated, on-brand squad card: squad name + avatar + member count + "active today" signal, with the banner as a backdrop.', + 'Never fall back to the bare generic image — always generate a contextual squad card.', + ], + current: { + domain: 'app.daily.dev', + path: '/squads/webdev', + title: 'WebDev | daily.dev', + description: + 'The official daily.dev web development community. Led by thought leaders and webdev experts. Join the Squad!', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + image: WEBDEV_OG, + }, + recommended: { + domain: 'app.daily.dev', + path: '/squads/webdev', + title: 'WebDev — a Squad on daily.dev', + description: + 'The official daily.dev web development community, led by thought leaders and webdev experts.', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + imageAlt: 'WebDev squad on daily.dev', + imageNode: ( + + ), + }, + }, + { + id: 'squad-invite', + name: 'Squad invite link', + what: 'A member invites someone to a private/public Squad.', + source: '/squads/[handle]/[token]', + issues: [ + 'Title is good ("{inviter} invited you to {squad}") but the image is just the squad banner — the invite framing lives only in text the platform may truncate.', + 'No inviter identity in the image; the personal, social hook is lost.', + ], + recommendations: [ + 'Bake the invite framing into the image: inviter avatar + "invited you to join {squad}" + member count + CTA chip.', + 'Keep the strong title but ensure the image stands on its own when the title is clipped.', + ], + current: { + domain: 'app.daily.dev', + path: '/squads/webdev/abc123token', + title: 'Ido invited you to WebDev', + description: + 'The official daily.dev web development community. Led by thought leaders and webdev experts. Join the Squad!', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + image: WEBDEV_OG, + }, + recommended: { + domain: 'app.daily.dev', + path: '/squads/webdev/abc123token', + title: 'Ido invited you to join WebDev on daily.dev', + description: + 'Join the official daily.dev web development community. It’s free.', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + imageAlt: 'Ido invited you to join WebDev', + imageNode: ( + + ), + }, + }, + { + id: 'source', + name: 'Source / Publication', + what: 'Sharing a publication/source page.', + source: '/sources/[source]', + issues: [ + 'Uses the bare generic brand image (defaultOpenGraph) — no indication of which source it is.', + 'Title + suffix only; description is the source description if present.', + ], + recommendations: [ + 'Generate a source card with the source logo, name and "Followed by N developers on daily.dev".', + ], + current: { + domain: 'app.daily.dev', + path: '/sources/freecodecamp', + title: 'freeCodeCamp | daily.dev', + description: 'The latest from freeCodeCamp on daily.dev.', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + image: DEFAULT_OG_IMAGE, + }, + recommended: { + domain: 'app.daily.dev', + path: '/sources/freecodecamp', + title: 'freeCodeCamp on daily.dev', + description: + 'Followed by developers across daily.dev. Get every new post in your feed.', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + imageAlt: 'freeCodeCamp on daily.dev', + imageNode: ( + + ), + }, + }, + { + id: 'tag', + name: 'Tag / Topic feed', + what: 'Sharing a tag feed (e.g. /tags/react).', + source: 'ReferralCampaignKey.ShareTag', + issues: ['Generic brand image; the topic name appears only in text.'], + recommendations: [ + 'Generate a topic card: "#react" + a one-line topic description + a few sample source logos.', + ], + current: { + domain: 'app.daily.dev', + path: '/tags/react', + title: 'react | daily.dev', + description: + 'Explore the React JavaScript library for building user interfaces.', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + image: DEFAULT_OG_IMAGE, + }, + recommended: { + domain: 'app.daily.dev', + path: '/tags/react', + title: '#react on daily.dev', + description: + 'Explore the React JavaScript library for building user interfaces.', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + imageAlt: 'The #react topic on daily.dev', + imageNode: ( + + ), + }, + }, + { + id: 'comment', + name: 'Comment / Discussion', + what: 'Sharing a specific comment on a post.', + source: 'ReferralCampaignKey.ShareComment', + issues: [ + 'Reuses the post image; the comment that the user actually wanted to highlight is invisible in the preview.', + ], + recommendations: [ + 'Generate a discussion card: comment excerpt as the hero, commenter avatar/name, post title as context.', + ], + current: { + domain: 'app.daily.dev', + path: '/posts/qojM1enSN#c_456', + title: 'How to use traces to avoid breaking changes | daily.dev', + description: + 'Reuses the post image — the highlighted comment is invisible in the preview.', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + image: POST_OG, + }, + recommended: { + domain: 'app.daily.dev', + path: '/posts/qojM1enSN#c_456', + title: '“Great article! Thanks for sharing 🙌” — sirajju on daily.dev', + description: + 'sirajju commented on “How to use traces to avoid breaking changes”. Join the discussion.', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + imageAlt: 'Comment by sirajju on a daily.dev discussion', + imageNode: ( + + ), + }, + }, + { + id: 'invite', + name: 'Invite friends / Referral', + what: 'Personal referral link (the generic /?cid root link from Invite friends).', + source: '/?cid=… · ReferralCampaignKey.Generic', + issues: [ + 'The root referral link (app.daily.dev/?cid=…&userid=…) falls back to the generic brand image — no referrer, no incentive, no personalization.', + 'Highest-intent growth surface, weakest preview. (A separate og.daily.dev/api/refs card exists for /join links, but it’s off the unified template and currently returns empty for many users.)', + ], + recommendations: [ + 'Generate a referral card with the referrer’s avatar + "{name} invited you to daily.dev" + a clear CTA, on the unified template.', + ], + current: { + domain: 'app.daily.dev', + path: '/?cid=generic&userid=u_123', + title: 'daily.dev | Where developers grow together', + description: + 'daily.dev is the easiest way to stay updated on the latest programming news…', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + image: DEFAULT_OG_IMAGE, + }, + recommended: { + domain: 'app.daily.dev', + path: '/?cid=generic&userid=u_123', + title: 'Ido invited you to daily.dev', + description: + 'The professional network where developers grow together. Free forever.', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + imageAlt: 'Ido invited you to daily.dev', + imageNode: ( + + ), + }, + }, + { + id: 'plus', + name: 'Plus subscription', + what: 'Sharing / gifting daily.dev Plus.', + source: '/plus', + issues: [ + 'Generic brand image; nothing communicates the Plus value or the gift.', + ], + recommendations: [ + 'Generate a Plus card with the Plus mark, headline benefit and gift framing when applicable.', + ], + current: { + domain: 'app.daily.dev', + path: '/plus', + title: 'Unlock Premium Developer Features with Plus | daily.dev', + description: + 'daily.dev is the easiest way to stay updated on the latest programming news…', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + image: DEFAULT_OG_IMAGE, + }, + recommended: { + domain: 'app.daily.dev', + path: '/plus', + title: 'daily.dev Plus — your feed, supercharged', + description: + 'AI summaries, advanced filters, and an ad-free feed. Try Plus free for 14 days.', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + imageAlt: 'daily.dev Plus', + imageNode: ( + + ), + }, + }, + { + id: 'home', + name: 'Homepage / default', + what: 'The root URL and any page without a specific override.', + source: 'next-seo.ts defaultOpenGraph', + issues: [ + 'Single static image; fine as a true fallback but currently does double duty for many specific share types it should not.', + ], + recommendations: [ + 'Keep as the genuine last-resort fallback only. Every specific surface above should override it.', + ], + current: { + domain: 'app.daily.dev', + path: '/', + title: 'daily.dev | Where developers grow together', + description: + 'daily.dev is the easiest way to stay updated on the latest programming news. Get the best content from the top tech publications on any topic you want.', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + image: DEFAULT_OG_IMAGE, + }, + recommended: { + domain: 'app.daily.dev', + path: '/', + title: 'daily.dev | Where developers grow together', + description: + 'The professional network for developers. One feed for the best engineering content, every day.', + cardType: 'summary_large_image', + siteName: 'daily.dev', + twitterSite: '@dailydotdev', + imageAlt: 'daily.dev — where developers grow together', + image: LANDING_OG, + }, + }, +];